更新
This commit is contained in:
@@ -74,6 +74,15 @@ backend/
|
||||
- 错误通过 `HTTPException` 抛出,统一由全局异常处理返回 `{success:false, message, code}`。
|
||||
- 不再使用 `detail` 作为前端错误文案(前端已改为读 `message`)。
|
||||
|
||||
### `/api/videos/generate` 参数契约(关键约定)
|
||||
|
||||
- `custom_assignments` 每项使用 `material_path/start/end/source_start/source_end?`,并以时间轴可见段为准。
|
||||
- `output_aspect_ratio` 仅允许 `9:16` / `16:9`,默认 `9:16`。
|
||||
- 标题显示模式参数:
|
||||
- `title_display_mode`: `short` / `persistent`(默认 `short`)
|
||||
- `title_duration`: 默认 `4.0`(秒),仅 `short` 模式生效
|
||||
- workflow/remotion 侧需保持字段透传一致,避免前后端语义漂移。
|
||||
|
||||
---
|
||||
|
||||
## 4. 认证与权限
|
||||
|
||||
@@ -131,6 +131,8 @@ backend/
|
||||
- `output_aspect_ratio`: 输出画面比例(`9:16` 或 `16:9`,默认 `9:16`)
|
||||
- `language`: TTS 语言(默认自动检测,声音克隆时透传给 CosyVoice 3.0)
|
||||
- `title`: 片头标题文字
|
||||
- `title_display_mode`: 标题显示模式(`short` / `persistent`,默认 `short`)
|
||||
- `title_duration`: 标题显示时长(秒,默认 `4.0`;`short` 模式生效)
|
||||
- `subtitle_style_id`: 字幕样式 ID
|
||||
- `title_style_id`: 标题样式 ID
|
||||
- `subtitle_font_size`: 字幕字号(覆盖样式默认值)
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
- 开启可换行:`white-space: normal` + `word-break` + `overflow-wrap`。
|
||||
- 描边、字距、上下边距同步按比例缩放。
|
||||
|
||||
### 2.3 片头标题显示模式(短暂/常驻)
|
||||
|
||||
- 在“标题与字幕”面板的“片头标题”行尾新增下拉,支持:`短暂显示` / `常驻显示`。
|
||||
- 默认模式为 `短暂显示`,短暂模式默认时长为 4 秒。
|
||||
- 用户选择会持久化到 localStorage,刷新后保持上次配置。
|
||||
- 生成请求新增 `title_display_mode`,短暂模式透传 `title_duration=4.0`。
|
||||
- Remotion 端到端支持该参数:
|
||||
- `short`:标题在设定时长后淡出并结束渲染;
|
||||
- `persistent`:标题全程常驻(保留淡入动画,不执行淡出)。
|
||||
|
||||
---
|
||||
|
||||
## 🎥 方向归一化 + 多素材拼接稳定性 — 第三阶段 (Day 24)
|
||||
@@ -139,8 +149,9 @@
|
||||
| `backend/app/core/deps.py` | `get_current_user` / `get_current_user_optional` 接入到期失效检查 |
|
||||
| `backend/app/modules/auth/router.py` | 登录时到期停用 + `/api/auth/me` 统一鉴权依赖 |
|
||||
| `backend/app/modules/videos/schemas.py` | `CustomAssignment` 新增 `source_end`;保留 `output_aspect_ratio` |
|
||||
| `backend/app/modules/videos/workflow.py` | 多素材/单素材透传 `source_end`;多素材 prepare/concat 统一 25fps |
|
||||
| `backend/app/modules/videos/workflow.py` | 多素材/单素材透传 `source_end`;多素材 prepare/concat 统一 25fps;标题显示模式参数透传 Remotion |
|
||||
| `backend/app/services/video_service.py` | 旋转元数据解析与方向归一化;`prepare_segment` 支持 `source_end/target_fps`;concat 强制 CFR + `+genpts` |
|
||||
| `backend/app/services/remotion_service.py` | render 支持 `title_display_mode/title_duration` 并传递到 render.ts |
|
||||
|
||||
### 前端修改
|
||||
|
||||
@@ -149,20 +160,26 @@
|
||||
| `frontend/src/features/home/model/useTimelineEditor.ts` | `CustomAssignment` 新增 `source_end`;修复 sourceStart 开放终点时长计算 |
|
||||
| `frontend/src/features/home/model/useHomeController.ts` | 多素材以可见 assignments 为准发送;单素材截取触发条件补齐 |
|
||||
| `frontend/src/features/home/ui/TimelineEditor.tsx` | 画面比例下拉;循环比例按截取后有效时长计算 |
|
||||
| `frontend/src/features/home/model/useHomePersistence.ts` | `outputAspectRatio` 持久化 |
|
||||
| `frontend/src/features/home/model/useHomePersistence.ts` | `outputAspectRatio` 与 `titleDisplayMode` 持久化 |
|
||||
| `frontend/src/features/home/ui/HomePage.tsx` | 页面进入滚动到顶部;ClipTrimmer/Timeline 交互保持一致 |
|
||||
| `frontend/src/features/home/ui/FloatingStylePreview.tsx` | 标题/字幕样式预览与成片渲染策略对齐 |
|
||||
| `frontend/src/features/home/ui/TitleSubtitlePanel.tsx` | 标题行新增“短暂显示/常驻显示”下拉 |
|
||||
|
||||
### Remotion 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `remotion/src/components/Title.tsx` | 标题响应式缩放与自动换行,优化竖屏窄画布适配 |
|
||||
| `remotion/src/components/Title.tsx` | 标题响应式缩放与自动换行;新增短暂/常驻显示模式控制 |
|
||||
| `remotion/src/components/Subtitles.tsx` | 字幕响应式缩放与自动换行,减少预览/成片差异 |
|
||||
| `remotion/src/Video.tsx` | 新增 `titleDisplayMode` 透传到标题组件 |
|
||||
| `remotion/src/Root.tsx` | 默认 props 增加 `titleDisplayMode='short'` 与 `titleDuration=4` |
|
||||
| `remotion/render.ts` | CLI 参数新增 `--titleDisplayMode`,inputProps 增加 `titleDisplayMode` |
|
||||
|
||||
---
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 后端语法检查:`python -m py_compile backend/app/modules/videos/schemas.py backend/app/modules/videos/workflow.py backend/app/services/video_service.py`
|
||||
- 后端语法检查:`python -m py_compile backend/app/modules/videos/schemas.py backend/app/modules/videos/workflow.py backend/app/services/video_service.py backend/app/services/remotion_service.py`
|
||||
- 前端类型检查:`npx tsc --noEmit`
|
||||
- 前端 ESLint:`npx eslint src/features/home/model/useHomeController.ts src/features/home/model/useHomePersistence.ts src/features/home/ui/HomePage.tsx src/features/home/ui/TitleSubtitlePanel.tsx`
|
||||
- Remotion 渲染脚本构建:`npm run build:render`
|
||||
|
||||
@@ -256,6 +256,12 @@ import { formatDate } from '@/shared/lib/media';
|
||||
|
||||
## ⚡️ 体验优化规范
|
||||
|
||||
### 刷新回顶部(统一体验)
|
||||
|
||||
- 长页面(如首页/发布页)在首次挂载时统一回到顶部,避免浏览器恢复旧滚动位置导致进入即跳到中部。
|
||||
- 推荐实现:`useEffect(() => { window.scrollTo({ top: 0, left: 0, behavior: 'auto' }); }, [])`
|
||||
- 列表内自动定位(素材/历史记录)应跳过恢复后的首次触发,防止刷新后页面二次跳动。
|
||||
|
||||
### 路由预取
|
||||
|
||||
- 首页进入发布管理时使用 `router.prefetch("/publish")`
|
||||
@@ -305,7 +311,9 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- **必须持久化**:
|
||||
- 标题样式 ID / 字幕样式 ID
|
||||
- 标题字号 / 字幕字号
|
||||
- 标题显示模式(`short` / `persistent`)
|
||||
- 背景音乐选择 / 音量 / 开关状态
|
||||
- 输出画面比例(`9:16` / `16:9`)
|
||||
- 素材选择 / 历史作品选择
|
||||
- 选中配音 ID (`selectedAudioId`)
|
||||
- 语速 (`speed`,声音克隆模式)
|
||||
@@ -333,6 +341,7 @@ import { formatDate } from '@/shared/lib/media';
|
||||
- 片头标题与发布信息标题统一限制 15 字。
|
||||
- 中文输入法合成阶段不截断,合成结束后才校验长度。
|
||||
- 首页片头标题修改会同步写入 `vigent_${storageKey}_publish_title`。
|
||||
- 标题显示模式使用 `short` / `persistent` 两个固定值;默认 `short`(短暂显示 4 秒)。
|
||||
- 避免使用 `maxLength` 强制截断输入法合成态。
|
||||
- 推荐使用 `@/shared/hooks/useTitleInput` 统一处理输入逻辑。
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。
|
||||
- **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。
|
||||
|
||||
### 5. 字幕与标题 [Day 13 新增]
|
||||
- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。
|
||||
- **片头标题**: 可选输入,限制 15 字;支持“短暂显示 / 常驻显示”,默认短暂显示(4 秒)。
|
||||
- **标题同步**: 首页片头标题修改会同步到发布信息标题。
|
||||
- **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。
|
||||
- **自动对齐**: 基于 faster-whisper 生成字级别时间戳。
|
||||
|
||||
@@ -185,7 +185,8 @@ Remotion 渲染参数在 `backend/app/services/remotion_service.py` 中配置:
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `fps` | 25 | 输出帧率 |
|
||||
| `title_duration` | 3.0 | 标题显示时长(秒) |
|
||||
| `title_display_mode` | `short` | 标题显示模式(`short`=短暂显示;`persistent`=常驻显示) |
|
||||
| `title_duration` | 4.0 | 标题显示时长(秒,仅 `short` 模式生效) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [x] **会员到期请求时失效**: 登录与鉴权接口统一执行 `expires_at` 检查;到期后自动停用账号、清理 session,并返回“会员已到期,请续费”。
|
||||
- [x] **画面比例控制**: 时间轴新增 `9:16 / 16:9` 输出比例选择,前端持久化并透传后端,单素材/多素材统一按目标分辨率处理。
|
||||
- [x] **标题/字幕防溢出**: Remotion 与前端预览统一响应式缩放、自动换行、描边/字距/边距比例缩放,降低预览与成片差异。
|
||||
- [x] **标题显示模式**: 标题行新增“短暂显示/常驻显示”下拉;默认短暂显示(4 秒),用户选择持久化并透传至 Remotion 渲染链路。
|
||||
- [x] **MOV 方向归一化**: 新增旋转元数据解析与 orientation normalize,修复“编码横屏+旋转元数据”导致的竖屏判断偏差。
|
||||
- [x] **多素材拼接稳定性**: 片段 prepare 与 concat 统一 25fps/CFR,concat 增加 `+genpts`,缓解段切换处“画面冻结口型还动”。
|
||||
- [x] **时间轴语义对齐**: 打通 `source_end` 全链路;修复 `sourceStart>0 且 sourceEnd=0` 时长计算;生成时以时间轴可见段 assignments 为准,超出段不参与。
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
- 🎬 **高清唇形同步** - LatentSync 1.6 驱动,512×512 高分辨率 Latent Diffusion 模型。
|
||||
- 🎙️ **多模态配音** - 支持 **EdgeTTS** (微软超自然语音, 10 语言) 和 **CosyVoice 3.0** (3秒极速声音克隆, 9语言+18方言, 语速可调)。上传参考音频自动 Whisper 转写 + 智能截取。配音前置工作流:先生成配音 → 选素材 → 生成视频。
|
||||
- 📝 **智能字幕** - 集成 faster-whisper + Remotion,自动生成逐字高亮 (卡拉OK效果) 字幕。
|
||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||
- 🎨 **样式预设** - 标题/字幕样式选择 + 预览 + 字号调节,支持自定义字体库。
|
||||
- 🏷️ **标题显示模式** - 片头标题支持 `短暂显示` / `常驻显示`,默认短暂显示(4秒),用户偏好自动持久化。
|
||||
- 🖼️ **作品预览一致性** - 标题/字幕预览与 Remotion 成片统一响应式缩放和自动换行,窄屏画布也稳定显示。
|
||||
- 🎞️ **多素材多机位** - 支持多选素材 + 时间轴编辑器 (wavesurfer.js 波形可视化),拖拽分割线调整时长、拖拽排序切换机位、按 `source_start/source_end` 截取片段。
|
||||
- 📐 **画面比例控制** - 时间轴一键切换 `9:16 / 16:9` 输出比例,生成链路全程按目标比例处理。
|
||||
|
||||
@@ -21,6 +21,8 @@ class GenerateRequest(BaseModel):
|
||||
language: str = "zh-CN"
|
||||
generated_audio_id: Optional[str] = None # 预生成配音 ID(存在时跳过内联 TTS)
|
||||
title: Optional[str] = None
|
||||
title_display_mode: Literal["short", "persistent"] = "short"
|
||||
title_duration: float = 4.0
|
||||
enable_subtitles: bool = True
|
||||
subtitle_style_id: Optional[str] = None
|
||||
title_style_id: Optional[str] = None
|
||||
|
||||
@@ -657,12 +657,20 @@ async def process_video_generation(task_id: str, req: GenerateRequest, user_id:
|
||||
mapped = 87 + int(percent * 0.08)
|
||||
_update_task(task_id, progress=mapped)
|
||||
|
||||
title_display_mode = (
|
||||
req.title_display_mode
|
||||
if req.title_display_mode in ("short", "persistent")
|
||||
else "short"
|
||||
)
|
||||
title_duration = max(0.5, min(float(req.title_duration or 4.0), 30.0))
|
||||
|
||||
await remotion_service.render(
|
||||
video_path=str(composed_video_path),
|
||||
output_path=str(final_output_local_path),
|
||||
captions_path=str(captions_path) if captions_path else None,
|
||||
title=req.title,
|
||||
title_duration=3.0,
|
||||
title_duration=title_duration,
|
||||
title_display_mode=title_display_mode,
|
||||
fps=25,
|
||||
enable_subtitles=req.enable_subtitles,
|
||||
subtitle_style=subtitle_style,
|
||||
|
||||
@@ -7,6 +7,7 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from loguru import logger
|
||||
@@ -29,12 +30,13 @@ class RemotionService:
|
||||
output_path: str,
|
||||
captions_path: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
title_duration: float = 3.0,
|
||||
title_duration: float = 4.0,
|
||||
title_display_mode: str = "short",
|
||||
fps: int = 25,
|
||||
enable_subtitles: bool = True,
|
||||
subtitle_style: Optional[dict] = None,
|
||||
title_style: Optional[dict] = None,
|
||||
on_progress: Optional[callable] = None
|
||||
on_progress: Optional[Callable[[int], None]] = None
|
||||
) -> str:
|
||||
"""
|
||||
使用 Remotion 渲染视频(添加字幕和标题)
|
||||
@@ -45,6 +47,7 @@ class RemotionService:
|
||||
captions_path: 字幕 JSON 文件路径(Whisper 生成)
|
||||
title: 视频标题(可选)
|
||||
title_duration: 标题显示时长(秒)
|
||||
title_display_mode: 标题显示模式(short/persistent)
|
||||
fps: 帧率
|
||||
enable_subtitles: 是否启用字幕
|
||||
on_progress: 进度回调函数
|
||||
@@ -75,6 +78,7 @@ class RemotionService:
|
||||
if title:
|
||||
cmd.extend(["--title", title])
|
||||
cmd.extend(["--titleDuration", str(title_duration)])
|
||||
cmd.extend(["--titleDisplayMode", title_display_mode])
|
||||
|
||||
if subtitle_style:
|
||||
cmd.extend(["--subtitleStyle", json.dumps(subtitle_style, ensure_ascii=False)])
|
||||
@@ -95,8 +99,12 @@ class RemotionService:
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Remotion process stdout is unavailable")
|
||||
stdout = process.stdout
|
||||
|
||||
output_lines = []
|
||||
for line in iter(process.stdout.readline, ''):
|
||||
for line in iter(stdout.readline, ''):
|
||||
line = line.strip()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
|
||||
@@ -87,6 +87,8 @@ const LANG_TO_LOCALE: Record<string, string> = {
|
||||
"Português": "pt-BR",
|
||||
};
|
||||
|
||||
const DEFAULT_SHORT_TITLE_DURATION = 4;
|
||||
|
||||
|
||||
|
||||
const scrollContainerToItem = (container: HTMLDivElement, item: HTMLDivElement) => {
|
||||
@@ -149,6 +151,7 @@ export const useHomeController = () => {
|
||||
const [subtitleSizeLocked, setSubtitleSizeLocked] = useState<boolean>(false);
|
||||
const [titleSizeLocked, setTitleSizeLocked] = useState<boolean>(false);
|
||||
const [titleTopMargin, setTitleTopMargin] = useState<number>(62);
|
||||
const [titleDisplayMode, setTitleDisplayMode] = useState<"short" | "persistent">("short");
|
||||
const [subtitleBottomMargin, setSubtitleBottomMargin] = useState<number>(80);
|
||||
const [outputAspectRatio, setOutputAspectRatio] = useState<"9:16" | "16:9">("9:16");
|
||||
const [showStylePreview, setShowStylePreview] = useState<boolean>(false);
|
||||
@@ -447,6 +450,8 @@ export const useHomeController = () => {
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
titleDisplayMode,
|
||||
setTitleDisplayMode,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
@@ -938,6 +943,10 @@ export const useHomeController = () => {
|
||||
}
|
||||
|
||||
if (videoTitle.trim()) {
|
||||
payload.title_display_mode = titleDisplayMode;
|
||||
if (titleDisplayMode === "short") {
|
||||
payload.title_duration = DEFAULT_SHORT_TITLE_DURATION;
|
||||
}
|
||||
payload.title_top_margin = Math.round(titleTopMargin);
|
||||
}
|
||||
|
||||
@@ -1048,6 +1057,8 @@ export const useHomeController = () => {
|
||||
setSubtitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
titleDisplayMode,
|
||||
setTitleDisplayMode,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
|
||||
@@ -37,6 +37,8 @@ interface UseHomePersistenceOptions {
|
||||
setTitleSizeLocked: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
titleTopMargin: number;
|
||||
setTitleTopMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
titleDisplayMode: 'short' | 'persistent';
|
||||
setTitleDisplayMode: React.Dispatch<React.SetStateAction<'short' | 'persistent'>>;
|
||||
subtitleBottomMargin: number;
|
||||
setSubtitleBottomMargin: React.Dispatch<React.SetStateAction<number>>;
|
||||
outputAspectRatio: '9:16' | '16:9';
|
||||
@@ -83,6 +85,8 @@ export const useHomePersistence = ({
|
||||
setTitleSizeLocked,
|
||||
titleTopMargin,
|
||||
setTitleTopMargin,
|
||||
titleDisplayMode,
|
||||
setTitleDisplayMode,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
outputAspectRatio,
|
||||
@@ -122,6 +126,7 @@ export const useHomePersistence = ({
|
||||
const savedBgmVolume = localStorage.getItem(`vigent_${storageKey}_bgmVolume`);
|
||||
const savedEnableBgm = localStorage.getItem(`vigent_${storageKey}_enableBgm`);
|
||||
const savedTitleTopMargin = localStorage.getItem(`vigent_${storageKey}_titleTopMargin`);
|
||||
const savedTitleDisplayMode = localStorage.getItem(`vigent_${storageKey}_titleDisplayMode`);
|
||||
const savedSubtitleBottomMargin = localStorage.getItem(`vigent_${storageKey}_subtitleBottomMargin`);
|
||||
const savedOutputAspectRatio = localStorage.getItem(`vigent_${storageKey}_outputAspectRatio`);
|
||||
const savedSpeed = localStorage.getItem(`vigent_${storageKey}_speed`);
|
||||
@@ -174,6 +179,9 @@ export const useHomePersistence = ({
|
||||
const parsed = parseInt(savedTitleTopMargin, 10);
|
||||
if (!Number.isNaN(parsed)) setTitleTopMargin(parsed);
|
||||
}
|
||||
if (savedTitleDisplayMode === 'short' || savedTitleDisplayMode === 'persistent') {
|
||||
setTitleDisplayMode(savedTitleDisplayMode);
|
||||
}
|
||||
if (savedSubtitleBottomMargin) {
|
||||
const parsed = parseInt(savedSubtitleBottomMargin, 10);
|
||||
if (!Number.isNaN(parsed)) setSubtitleBottomMargin(parsed);
|
||||
@@ -208,6 +216,7 @@ export const useHomePersistence = ({
|
||||
setTitleFontSize,
|
||||
setTitleSizeLocked,
|
||||
setTitleTopMargin,
|
||||
setTitleDisplayMode,
|
||||
setSubtitleBottomMargin,
|
||||
setOutputAspectRatio,
|
||||
setTtsMode,
|
||||
@@ -280,6 +289,12 @@ export const useHomePersistence = ({
|
||||
}
|
||||
}, [titleTopMargin, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_titleDisplayMode`, titleDisplayMode);
|
||||
}
|
||||
}, [titleDisplayMode, storageKey, isRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRestored) {
|
||||
localStorage.setItem(`vigent_${storageKey}_subtitleBottomMargin`, String(subtitleBottomMargin));
|
||||
|
||||
@@ -80,6 +80,8 @@ export function HomePage() {
|
||||
setTitleTopMargin,
|
||||
subtitleBottomMargin,
|
||||
setSubtitleBottomMargin,
|
||||
titleDisplayMode,
|
||||
setTitleDisplayMode,
|
||||
outputAspectRatio,
|
||||
setOutputAspectRatio,
|
||||
resolveAssetUrl,
|
||||
@@ -235,6 +237,8 @@ export function HomePage() {
|
||||
onTitleTopMarginChange={setTitleTopMargin}
|
||||
subtitleBottomMargin={subtitleBottomMargin}
|
||||
onSubtitleBottomMarginChange={setSubtitleBottomMargin}
|
||||
titleDisplayMode={titleDisplayMode}
|
||||
onTitleDisplayModeChange={setTitleDisplayMode}
|
||||
resolveAssetUrl={resolveAssetUrl}
|
||||
getFontFormat={getFontFormat}
|
||||
buildTextShadow={buildTextShadow}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye } from "lucide-react";
|
||||
import { ChevronDown, Eye } from "lucide-react";
|
||||
import { FloatingStylePreview } from "@/features/home/ui/FloatingStylePreview";
|
||||
|
||||
interface SubtitleStyleOption {
|
||||
@@ -52,6 +52,8 @@ interface TitleSubtitlePanelProps {
|
||||
onTitleTopMarginChange: (value: number) => void;
|
||||
subtitleBottomMargin: number;
|
||||
onSubtitleBottomMarginChange: (value: number) => void;
|
||||
titleDisplayMode: "short" | "persistent";
|
||||
onTitleDisplayModeChange: (mode: "short" | "persistent") => void;
|
||||
resolveAssetUrl: (path?: string | null) => string | null;
|
||||
getFontFormat: (fontFile?: string) => string;
|
||||
buildTextShadow: (color: string, size: number) => string;
|
||||
@@ -80,6 +82,8 @@ export function TitleSubtitlePanel({
|
||||
onTitleTopMarginChange,
|
||||
subtitleBottomMargin,
|
||||
onSubtitleBottomMarginChange,
|
||||
titleDisplayMode,
|
||||
onTitleDisplayModeChange,
|
||||
resolveAssetUrl,
|
||||
getFontFormat,
|
||||
buildTextShadow,
|
||||
@@ -123,7 +127,21 @@ export function TitleSubtitlePanel({
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-300 mb-2 block">片头标题(限制15个字)</label>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<label className="text-sm text-gray-300">片头标题(限制15个字)</label>
|
||||
<div className="relative shrink-0">
|
||||
<select
|
||||
value={titleDisplayMode}
|
||||
onChange={(e) => onTitleDisplayModeChange(e.target.value as "short" | "persistent")}
|
||||
className="appearance-none rounded-lg border border-white/15 bg-black/35 px-2.5 py-1.5 pr-7 text-xs text-gray-200 outline-none transition-colors hover:border-white/25 focus:border-purple-500"
|
||||
aria-label="标题显示方式"
|
||||
>
|
||||
<option value="short">短暂显示</option>
|
||||
<option value="persistent">常驻显示</option>
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={videoTitle}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RenderOptions {
|
||||
captionsPath?: string;
|
||||
title?: string;
|
||||
titleDuration?: number;
|
||||
titleDisplayMode?: 'short' | 'persistent';
|
||||
subtitleStyle?: Record<string, unknown>;
|
||||
titleStyle?: Record<string, unknown>;
|
||||
outputPath: string;
|
||||
@@ -46,6 +47,11 @@ async function parseArgs(): Promise<RenderOptions> {
|
||||
case 'titleDuration':
|
||||
options.titleDuration = parseFloat(value);
|
||||
break;
|
||||
case 'titleDisplayMode':
|
||||
if (value === 'short' || value === 'persistent') {
|
||||
options.titleDisplayMode = value;
|
||||
}
|
||||
break;
|
||||
case 'output':
|
||||
options.outputPath = value;
|
||||
break;
|
||||
@@ -151,7 +157,8 @@ async function main() {
|
||||
videoSrc: videoFileName,
|
||||
captions,
|
||||
title: options.title,
|
||||
titleDuration: options.titleDuration || 3,
|
||||
titleDuration: options.titleDuration || 4,
|
||||
titleDisplayMode: options.titleDisplayMode || 'short',
|
||||
subtitleStyle: options.subtitleStyle,
|
||||
titleStyle: options.titleStyle,
|
||||
enableSubtitles: options.enableSubtitles !== false,
|
||||
|
||||
@@ -25,7 +25,8 @@ export const RemotionRoot: React.FC = () => {
|
||||
audioSrc: undefined,
|
||||
captions: undefined,
|
||||
title: undefined,
|
||||
titleDuration: 3,
|
||||
titleDuration: 4,
|
||||
titleDisplayMode: 'short',
|
||||
enableSubtitles: true,
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface VideoProps {
|
||||
captions?: CaptionsData;
|
||||
title?: string;
|
||||
titleDuration?: number;
|
||||
titleDisplayMode?: 'short' | 'persistent';
|
||||
enableSubtitles?: boolean;
|
||||
subtitleStyle?: SubtitleStyle;
|
||||
titleStyle?: TitleStyle;
|
||||
@@ -27,7 +28,8 @@ export const Video: React.FC<VideoProps> = ({
|
||||
audioSrc,
|
||||
captions,
|
||||
title,
|
||||
titleDuration = 3,
|
||||
titleDuration = 4,
|
||||
titleDisplayMode = 'short',
|
||||
enableSubtitles = true,
|
||||
subtitleStyle,
|
||||
titleStyle,
|
||||
@@ -44,7 +46,7 @@ export const Video: React.FC<VideoProps> = ({
|
||||
|
||||
{/* 顶层:标题 */}
|
||||
{title && (
|
||||
<Title title={title} duration={titleDuration} style={titleStyle} />
|
||||
<Title title={title} duration={titleDuration} displayMode={titleDisplayMode} style={titleStyle} />
|
||||
)}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface TitleStyle {
|
||||
interface TitleProps {
|
||||
title: string;
|
||||
duration?: number; // 标题显示时长(秒)
|
||||
displayMode?: 'short' | 'persistent'; // 短暂显示或常驻显示
|
||||
fadeOutStart?: number; // 开始淡出的时间(秒)
|
||||
style?: TitleStyle;
|
||||
}
|
||||
@@ -54,8 +55,9 @@ const buildTextShadow = (color: string, size: number) => {
|
||||
|
||||
export const Title: React.FC<TitleProps> = ({
|
||||
title,
|
||||
duration = 3,
|
||||
fadeOutStart = 2,
|
||||
duration = 4,
|
||||
displayMode = 'short',
|
||||
fadeOutStart,
|
||||
style,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
@@ -63,8 +65,8 @@ export const Title: React.FC<TitleProps> = ({
|
||||
|
||||
const currentTimeInSeconds = frame / fps;
|
||||
|
||||
// 如果超过显示时长,不渲染
|
||||
if (currentTimeInSeconds > duration) {
|
||||
// 短暂显示:超过设定时长后不再渲染;常驻显示:全程保留
|
||||
if (displayMode === 'short' && currentTimeInSeconds > duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,14 +78,22 @@ export const Title: React.FC<TitleProps> = ({
|
||||
{ extrapolateRight: 'clamp' }
|
||||
);
|
||||
|
||||
// 淡出效果
|
||||
const fadeOutOpacity = interpolate(
|
||||
currentTimeInSeconds,
|
||||
[fadeOutStart, duration],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
const defaultFadeOutStart = Math.max(duration - 1, 0.5);
|
||||
const effectiveFadeOutStart = Math.max(
|
||||
0.1,
|
||||
Math.min(fadeOutStart ?? defaultFadeOutStart, duration - 0.05)
|
||||
);
|
||||
|
||||
// 淡出效果(仅短暂显示模式生效)
|
||||
const fadeOutOpacity = displayMode === 'persistent'
|
||||
? 1
|
||||
: interpolate(
|
||||
currentTimeInSeconds,
|
||||
[effectiveFadeOutStart, duration],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
|
||||
);
|
||||
|
||||
const opacity = Math.min(fadeInOpacity, fadeOutOpacity);
|
||||
|
||||
// 轻微的下滑动画
|
||||
|
||||
Reference in New Issue
Block a user