From 035ee29d72a1669b2cc1f0b3ec2881029e31e4ed Mon Sep 17 00:00:00 2001 From: Kevin Wong Date: Wed, 11 Feb 2026 14:33:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Docs/BACKEND_DEV.md | 9 ++++++ Docs/BACKEND_README.md | 2 ++ Docs/DevLogs/Day24.md | 25 +++++++++++++--- Docs/FRONTEND_DEV.md | 9 ++++++ Docs/FRONTEND_README.md | 2 +- Docs/SUBTITLE_DEPLOY.md | 3 +- Docs/task_complete.md | 1 + README.md | 3 +- backend/app/modules/videos/schemas.py | 2 ++ backend/app/modules/videos/workflow.py | 10 ++++++- backend/app/services/remotion_service.py | 14 +++++++-- .../features/home/model/useHomeController.ts | 11 +++++++ .../features/home/model/useHomePersistence.ts | 15 ++++++++++ frontend/src/features/home/ui/HomePage.tsx | 4 +++ .../features/home/ui/TitleSubtitlePanel.tsx | 22 ++++++++++++-- remotion/render.ts | 9 +++++- remotion/src/Root.tsx | 3 +- remotion/src/Video.tsx | 6 ++-- remotion/src/components/Title.tsx | 30 ++++++++++++------- 19 files changed, 153 insertions(+), 27 deletions(-) diff --git a/Docs/BACKEND_DEV.md b/Docs/BACKEND_DEV.md index dbfb446..45f8176 100644 --- a/Docs/BACKEND_DEV.md +++ b/Docs/BACKEND_DEV.md @@ -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. 认证与权限 diff --git a/Docs/BACKEND_README.md b/Docs/BACKEND_README.md index 4eb5ac9..2779c87 100644 --- a/Docs/BACKEND_README.md +++ b/Docs/BACKEND_README.md @@ -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`: 字幕字号(覆盖样式默认值) diff --git a/Docs/DevLogs/Day24.md b/Docs/DevLogs/Day24.md index f1a217f..7e24a8c 100644 --- a/Docs/DevLogs/Day24.md +++ b/Docs/DevLogs/Day24.md @@ -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` diff --git a/Docs/FRONTEND_DEV.md b/Docs/FRONTEND_DEV.md index 3d77710..bf269cd 100644 --- a/Docs/FRONTEND_DEV.md +++ b/Docs/FRONTEND_DEV.md @@ -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` 统一处理输入逻辑。 diff --git a/Docs/FRONTEND_README.md b/Docs/FRONTEND_README.md index b00dc24..ef4bc6f 100644 --- a/Docs/FRONTEND_README.md +++ b/Docs/FRONTEND_README.md @@ -52,7 +52,7 @@ ViGent2 的前端界面,采用 Next.js 16 + TailwindCSS 构建。 - **画面比例控制**: 时间轴顶部支持 `9:16 / 16:9` 输出比例选择,设置持久化并透传后端。 ### 5. 字幕与标题 [Day 13 新增] -- **片头标题**: 可选输入,限制 15 字,视频开头显示 3 秒淡入淡出标题。 +- **片头标题**: 可选输入,限制 15 字;支持“短暂显示 / 常驻显示”,默认短暂显示(4 秒)。 - **标题同步**: 首页片头标题修改会同步到发布信息标题。 - **逐字高亮字幕**: 卡拉OK效果,默认开启,可关闭。 - **自动对齐**: 基于 faster-whisper 生成字级别时间戳。 diff --git a/Docs/SUBTITLE_DEPLOY.md b/Docs/SUBTITLE_DEPLOY.md index 7179800..fe8aa54 100644 --- a/Docs/SUBTITLE_DEPLOY.md +++ b/Docs/SUBTITLE_DEPLOY.md @@ -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` 模式生效) | --- diff --git a/Docs/task_complete.md b/Docs/task_complete.md index 6956352..9c52eea 100644 --- a/Docs/task_complete.md +++ b/Docs/task_complete.md @@ -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 为准,超出段不参与。 diff --git a/README.md b/README.md index 03e91f1..e8db277 100644 --- a/README.md +++ b/README.md @@ -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` 输出比例,生成链路全程按目标比例处理。 diff --git a/backend/app/modules/videos/schemas.py b/backend/app/modules/videos/schemas.py index 7ffa421..4b2e88f 100644 --- a/backend/app/modules/videos/schemas.py +++ b/backend/app/modules/videos/schemas.py @@ -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 diff --git a/backend/app/modules/videos/workflow.py b/backend/app/modules/videos/workflow.py index f19b60b..5fcd8a2 100644 --- a/backend/app/modules/videos/workflow.py +++ b/backend/app/modules/videos/workflow.py @@ -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, diff --git a/backend/app/services/remotion_service.py b/backend/app/services/remotion_service.py index b05e5cc..87f3a44 100644 --- a/backend/app/services/remotion_service.py +++ b/backend/app/services/remotion_service.py @@ -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) diff --git a/frontend/src/features/home/model/useHomeController.ts b/frontend/src/features/home/model/useHomeController.ts index 9b10132..702c832 100644 --- a/frontend/src/features/home/model/useHomeController.ts +++ b/frontend/src/features/home/model/useHomeController.ts @@ -87,6 +87,8 @@ const LANG_TO_LOCALE: Record = { "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(false); const [titleSizeLocked, setTitleSizeLocked] = useState(false); const [titleTopMargin, setTitleTopMargin] = useState(62); + const [titleDisplayMode, setTitleDisplayMode] = useState<"short" | "persistent">("short"); const [subtitleBottomMargin, setSubtitleBottomMargin] = useState(80); const [outputAspectRatio, setOutputAspectRatio] = useState<"9:16" | "16:9">("9:16"); const [showStylePreview, setShowStylePreview] = useState(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, diff --git a/frontend/src/features/home/model/useHomePersistence.ts b/frontend/src/features/home/model/useHomePersistence.ts index 6dd650a..25ec2f2 100644 --- a/frontend/src/features/home/model/useHomePersistence.ts +++ b/frontend/src/features/home/model/useHomePersistence.ts @@ -37,6 +37,8 @@ interface UseHomePersistenceOptions { setTitleSizeLocked: React.Dispatch>; titleTopMargin: number; setTitleTopMargin: React.Dispatch>; + titleDisplayMode: 'short' | 'persistent'; + setTitleDisplayMode: React.Dispatch>; subtitleBottomMargin: number; setSubtitleBottomMargin: React.Dispatch>; 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)); diff --git a/frontend/src/features/home/ui/HomePage.tsx b/frontend/src/features/home/ui/HomePage.tsx index 7842d21..709220e 100644 --- a/frontend/src/features/home/ui/HomePage.tsx +++ b/frontend/src/features/home/ui/HomePage.tsx @@ -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} diff --git a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx index 324eb28..45b9a41 100644 --- a/frontend/src/features/home/ui/TitleSubtitlePanel.tsx +++ b/frontend/src/features/home/ui/TitleSubtitlePanel.tsx @@ -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({ )}
- +
+ +
+ + +
+
; titleStyle?: Record; outputPath: string; @@ -46,6 +47,11 @@ async function parseArgs(): Promise { 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, diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx index 11789fe..ff7b24f 100644 --- a/remotion/src/Root.tsx +++ b/remotion/src/Root.tsx @@ -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, diff --git a/remotion/src/Video.tsx b/remotion/src/Video.tsx index cacbf86..ebe55a6 100644 --- a/remotion/src/Video.tsx +++ b/remotion/src/Video.tsx @@ -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 = ({ audioSrc, captions, title, - titleDuration = 3, + titleDuration = 4, + titleDisplayMode = 'short', enableSubtitles = true, subtitleStyle, titleStyle, @@ -44,7 +46,7 @@ export const Video: React.FC = ({ {/* 顶层:标题 */} {title && ( - + <Title title={title} duration={titleDuration} displayMode={titleDisplayMode} style={titleStyle} /> )} </AbsoluteFill> ); diff --git a/remotion/src/components/Title.tsx b/remotion/src/components/Title.tsx index a65d741..d27ba09 100644 --- a/remotion/src/components/Title.tsx +++ b/remotion/src/components/Title.tsx @@ -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); // 轻微的下滑动画