21 KiB
🎙️ 配音前置重构 — 第一阶段 (Day 23)
概述
将配音从视频生成流程中独立出来,实现"先生成配音 → 选中配音 → 再选素材 → 生成视频"的新工作流。用户可以独立管理配音(生成/试听/改名/删除/选择),并在选中配音后看到时长信息,为第二阶段的素材时间轴编排奠定数据基础。
旧流程: 文案 + 选素材 → 一键生成(内联 TTS → Whisper → 均分 → LipSync → 合成) 新流程: 文案 → 配音方式 → 生成配音 → 选中配音 → 选素材 → 背景音乐 → 生成视频
一、后端:新增 generated_audios 模块
模块结构
backend/app/modules/generated_audios/
├── __init__.py
├── router.py # 5 个 API 端点
├── schemas.py # 请求/响应模型
└── service.py # 生成/列表/删除/改名
API 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/generated-audios/generate |
异步生成配音(返回 task_id) |
| GET | /api/generated-audios/tasks/{task_id} |
轮询生成进度 |
| GET | /api/generated-audios |
列出用户所有配音 |
| DELETE | /api/generated-audios/{audio_id} |
删除配音 |
| PUT | /api/generated-audios/{audio_id} |
改名 |
存储方案
- Supabase 存储桶:
generated-audios(启动时自动创建) - 音频文件:
{user_id}/{timestamp}_audio.wav - 元数据文件:
{user_id}/{timestamp}_audio.json(含 display_name、text、tts_mode、duration_sec 等)
生成流程
复用现有 TTSService / voice_clone_service / task_store:
POST /generate → 创建 task → BackgroundTask:
1. edgetts → TTSService.generate_audio()
voiceclone → 下载 ref_audio → voice_clone_service.generate_audio()
2. ffprobe 获取时长
3. 上传 .wav + .json 到 generated-audios 桶
4. 更新 task(status=completed, output={audio_id, duration_sec, ...})
二、后端:修改视频生成 workflow
GenerateRequest 新增字段
generated_audio_id: Optional[str] = None # 预生成配音 ID(存在时跳过内联 TTS)
workflow.py TTS 阶段新增分支
if req.generated_audio_id:
# 下载预生成配音 + 从元数据读取 language
elif req.tts_mode == "voiceclone":
# 原有声音克隆逻辑
else:
# 原有 EdgeTTS 逻辑
向后兼容:不传 generated_audio_id 时,原有内联 TTS 流程不受影响。
三、前端:新增配音列表 hook + 面板
useGeneratedAudios.ts
- 状态:
generatedAudios[]、selectedAudio、isGeneratingAudio、audioTask - 方法:
fetchGeneratedAudios()、generateAudio()、deleteAudio()、renameAudio()、selectAudio() - 轮询:生成后 1s 轮询 task 状态,完成后自动刷新列表并选中最新配音
- 独立于视频生成的 TaskContext(不互相干扰)
GeneratedAudiosPanel.tsx
- 每条配音:播放/暂停、名称、时长、重命名、删除
- 选中态:
border-purple-500 bg-purple-500/20 - 内嵌进度条(生成中显示)
- 底部显示选中配音的原始文案(截断)
- 播放逻辑自包含于面板内(
new Audio()+ play/pause toggle)
四、前端:UI 面板重排序
旧顺序: MaterialSelector → ScriptEditor → TitleSubtitle → VoiceSelector → BgmPanel → GenerateActionBar
新顺序:
- ScriptEditor(文案编辑)
- TitleSubtitlePanel(标题与字幕样式)
- VoiceSelector(配音方式)
- GeneratedAudiosPanel(配音列表)← 新增
- MaterialSelector(视频素材)← 后移,需选中配音才解锁
- BgmPanel(背景音乐)
- GenerateActionBar(生成视频)
素材区门控
未选中配音时,素材区显示半透明遮罩 + "请先生成并选中配音"提示。素材上传/预览/改名/删除始终可用,仅选择勾选被遮罩。
时长信息
选中配音后,MaterialSelector 顶部显示:
当前配音: 45.2 秒 | 已选 3 个素材(自动均分每段 ~15.1 秒)
生成按钮条件更新
// 旧条件
disabled={isGenerating || selectedMaterials.length === 0 || (ttsMode === "voiceclone" && !selectedRefAudio)}
// 新条件
disabled={isGenerating || selectedMaterials.length === 0 || !selectedAudio}
五、持久化
useHomePersistence 新增 selectedAudioId 的 localStorage 读写,刷新页面后恢复选中的配音。
涉及文件汇总
后端新增
| 文件 | 说明 |
|---|---|
backend/app/modules/generated_audios/__init__.py |
模块标记 |
backend/app/modules/generated_audios/router.py |
5 个 API 端点 |
backend/app/modules/generated_audios/service.py |
生成/列表/删除/改名 |
backend/app/modules/generated_audios/schemas.py |
请求/响应模型 |
后端修改
| 文件 | 变更 |
|---|---|
backend/app/main.py |
注册 generated_audios 路由 |
backend/app/services/storage.py |
新增 BUCKET_GENERATED_AUDIOS,启动时自动创建桶 |
backend/app/modules/videos/schemas.py |
GenerateRequest 新增 generated_audio_id 字段 |
backend/app/modules/videos/workflow.py |
TTS 阶段新增预生成音频分支 |
前端新增
| 文件 | 说明 |
|---|---|
frontend/src/features/home/model/useGeneratedAudios.ts |
配音列表 hook |
frontend/src/features/home/ui/GeneratedAudiosPanel.tsx |
配音列表面板 |
前端修改
| 文件 | 变更 |
|---|---|
frontend/src/features/home/ui/HomePage.tsx |
面板重排序 + 素材区门控 + 插入 GeneratedAudiosPanel |
frontend/src/features/home/ui/MaterialSelector.tsx |
新增 selectedAudioDuration prop + 时长信息显示 |
frontend/src/features/home/ui/GenerateActionBar.tsx |
禁用条件改为 !selectedAudio |
frontend/src/features/home/model/useHomeController.ts |
集成 useGeneratedAudios、新增 handleGenerateAudio、修改 handleGenerate 使用 generated_audio_id |
frontend/src/features/home/model/useHomePersistence.ts |
新增 selectedAudioId 持久化 |
🎞️ 素材时间轴编排 — 第二阶段 (Day 23)
概述
在第一阶段"配音前置"基础上,新增时间轴编辑器,用户可以:
- 在音频波形上查看各素材块的时长分配
- 拖拽分割线调整每段素材的时长(无缝铺满,调整一段自动压缩/扩展相邻段)
- 为每段素材设置源视频截取起点(从视频任意位置开始,而非始终从头)
旧行为: 多素材时自动均分(_split_equal),无法控制每段时长和源视频起始点
新行为: 时间轴编辑器可视化分配 + 拖拽调整 + ClipTrimmer 截取设置
一、后端改动
1.1 新增 CustomAssignment 模型
# backend/app/modules/videos/schemas.py
class CustomAssignment(BaseModel):
material_path: str
start: float # 音频时间轴起点
end: float # 音频时间轴终点
source_start: float = 0.0 # 源视频截取起点
GenerateRequest 新增 custom_assignments: Optional[List[CustomAssignment]] = None。存在时跳过 Whisper 均分,直接使用用户定义的分配。
1.2 prepare_segment 支持 source_start
def prepare_segment(self, video_path, target_duration, output_path,
target_resolution=None, source_start: float = 0.0):
关键逻辑:
source_start > 0时使用-ss快速 seek,并强制重编码(避免 stream copy 关键帧不精确)- 当需要循环且有
source_start时,先裁剪出source_start到视频结尾的片段,再循环裁剪后的文件(避免stream_loop从视频 0s 开始循环) - 裁剪临时文件在
finally中自动清理
1.3 workflow.py 支持 custom_assignments
- 多素材模式:
custom_assignments存在时,直接使用用户分配(仍运行 Whisper 生成字幕),每个prepare_segment调用传入source_start - 单素材模式:
custom_assignments有 1 条且source_start > 0时,先截取片段再传入 LatentSync - 向后兼容:
custom_assignments为None时完全走旧路径
二、前端新增组件
2.1 useTimelineEditor.ts — 时间轴段管理 hook
interface TimelineSegment {
id: string; // React key
materialId: string; // 素材 ID
materialName: string; // 显示名
start: number; // 音频时间轴开始秒数
end: number; // 音频时间轴结束秒数
sourceStart: number; // 源视频截取起点(默认 0)
sourceEnd: number; // 源视频截取终点(0 = 到结尾)
color: string; // 色块颜色
}
核心方法:
initSegments(): selectedMaterials 变化时按数量均分 audioDurationresizeSegment(id, newEnd): 拖拽右边界,约束每段最小 1ssetSourceRange(id, sourceStart, sourceEnd): 设置截取范围toCustomAssignments(): 转为后端CustomAssignment[]格式
2.2 TimelineEditor.tsx — 波形 + 色块时间轴
- wavesurfer.js 渲染音频波形(仅展示,不播放)
- 色块层按比例排列,显示素材名 + 时长 + 截取标记
- 色块间分割线可拖拽(
onPointerDown/Move/Up实现连续像素拖拽) - 点击色块打开 ClipTrimmer
2.3 ClipTrimmer.tsx — 素材截取模态框
- HTML5
<video>实时预览,拖拽滑块时video.currentTime跟随 - 双端 Range Slider(起点/终点),互锁约束 ≥ 0.5s
- 显示截取时长 vs 分配时长对比(循环补足/截断提示)
loadedmetadata获取源视频时长
三、前端整合改动
3.1 useHomeController.ts
- 集成
useTimelineEditorhook - 新增
clipTrimmerOpen/clipTrimmerSegmentId状态 handleGenerate多素材时始终发送custom_assignments;单素材 +sourceStart > 0时也发送- 移除不再使用的
reorderMaterials导出
3.2 HomePage.tsx
- 在 MaterialSelector 和 BgmPanel 之间插入 TimelineEditor(仅当有配音且已选素材时显示)
- 底部新增 ClipTrimmer 模态框
- 移除
reorderMaterials和selectedAudioDurationprop 传递
3.3 MaterialSelector.tsx
- 移除配音时长信息栏(功能迁至 TimelineEditor)
- 移除拖拽排序区(SortableChip + @dnd-kit 相关代码)
- 移除
onReorderMaterials/selectedAudioDurationprop
四、审查修复的 Bug
| # | 严重程度 | 问题 | 修复 |
|---|---|---|---|
| 1 | 中 | prepare_segment 使用 source_start > 0 + stream copy 时 seek 不精确 |
添加 source_start > 0 到重编码条件 |
| 2 | 高 | stream_loop + source_start 循环时从视频 0s 开始而非从 source_start 循环 |
改为两步:先裁剪片段再循环裁剪后的文件 |
| 3 | 低 | useHomeController 导出已废弃的 reorderMaterials |
移除 |
涉及文件汇总
后端修改
| 文件 | 变更 |
|---|---|
backend/app/modules/videos/schemas.py |
新增 CustomAssignment model,GenerateRequest 新增 custom_assignments 字段 |
backend/app/services/video_service.py |
prepare_segment 新增 source_start 参数,循环+截取两步处理 |
backend/app/modules/videos/workflow.py |
多素材/单素材流水线支持 custom_assignments,传递 source_start |
前端新增
| 文件 | 说明 |
|---|---|
frontend/src/features/home/model/useTimelineEditor.ts |
时间轴段管理 hook |
frontend/src/features/home/ui/TimelineEditor.tsx |
波形 + 色块时间轴组件 |
frontend/src/features/home/ui/ClipTrimmer.tsx |
素材截取模态框 |
前端修改
| 文件 | 变更 |
|---|---|
frontend/src/features/home/ui/HomePage.tsx |
插入 TimelineEditor + ClipTrimmer |
frontend/src/features/home/ui/MaterialSelector.tsx |
移除时长信息 + 拖拽排序区 + 相关 prop |
frontend/src/features/home/model/useHomeController.ts |
集成 useTimelineEditor,handleGenerate 发送 custom_assignments |
frontend/package.json |
新增 wavesurfer.js 依赖 |
🎨 UI 体验优化 + TTS 稳定性修复 — 第三阶段 (Day 23)
概述
根据用户反馈,修复 6 项 UI 体验问题,同时修复 Qwen3-TTS 声音克隆服务的 SoX 路径问题和显存缓存管理。
一、Qwen3-TTS 稳定性修复
1.1 SoX PATH 修复
问题: PM2 启动 qwen-tts 时,sox 工具安装在 conda env 的 bin 目录中,系统 PATH 找不到,导致音频编解码走 fallback 路径(CPU 密集型),日志中出现 SoX could not be found! 警告。
修复: run_qwen_tts.sh 中 export conda env bin 到 PATH:
export PATH="/home/rongye/ProgramFiles/miniconda3/envs/qwen-tts/bin:$PATH"
1.2 CUDA 缓存清理
修复: qwen_tts_server.py 每次生成完成后(无论成功或失败)调用 torch.cuda.empty_cache(),防止显存碎片累积。使用 asyncio.to_thread() 在线程池中运行推理,避免阻塞事件循环导致健康检查超时。
二、配音列表按钮布局统一 (反馈 #1 + #6)
问题: GeneratedAudiosPanel 的试听按钮位于左侧(独立于 Edit/Delete),与 RefAudioPanel 的布局不一致。底部文案摘要区域不需要展示。
修复:
- Play/Edit/Delete 按钮统一放在右侧同组,hover 显示,顺序为 试听→重命名→删除
- 移除选中配音的文案摘要区域
- 布局与 RefAudioPanel 一致:左侧名称+时长,右侧操作按钮组
三、视频素材区域移除配音依赖遮罩 (反馈 #2)
问题: MaterialSelector 被 !selectedAudio 遮罩覆盖,必须先选配音才能操作素材。
修复: 移除 HomePage.tsx 中 MaterialSelector 外层的 disabled overlay <div>。素材随时可上传/预览/管理,仅 TimelineEditor 需要选中配音才显示(已有独立条件 selectedAudio && selectedMaterials.length > 0)。
四、时间轴拖拽排序 (反馈 #3)
问题: TimelineEditor 不支持调换素材顺序。
修复:
useTimelineEditor已有reorderSegments()方法(交换两个段的素材信息但保留时间范围)- 通过
useHomeController暴露reorderSegments,传入TimelineEditor - 色块支持 HTML5 Drag & Drop:
draggable+onDragStart/Over/Drop/End - 拖拽时:源色块半透明(
opacity-50),目标色块高亮 ring(ring-2 ring-purple-400 scale-[1.02]) - 光标样式:
cursor-grab/active:cursor-grabbing
五、截取设置双手柄 Range Slider (反馈 #4)
问题: ClipTrimmer 使用两个独立的 <input type="range"> 滑块,起点和终点分开操作,体验不直观。
修复: 改为自定义双手柄 range slider:
- 单条轨道,紫色圆形手柄(起点)+ 粉色圆形手柄(终点)
- 轨道底色
bg-white/10,选中范围用素材对应颜色高亮 - Pointer Events 实现拖拽:
onPointerDown捕获手柄 →onPointerMove更新位置 →onPointerUp释放 - 手柄互锁约束:起点不超过终点 - 0.5s,终点不低于起点 + 0.5s
- 底部显示起点(紫色)和终点(粉色)时间标签
六、截取设置视频预览 (反馈 #5)
问题: ClipTrimmer 的视频只能静态查看,无法播放预览截取范围。
修复:
- 视频区域点击可播放/暂停(Play/Pause 图标覆盖层)
- 播放范围:从 sourceStart 播放到 sourceEnd 自动停止
- 播放结束后回到起点
- 拖拽手柄时
video.currentTime实时跟随(seek 到当前位置查看画面) - 播放进度条(白色竖线)叠加在 range slider 轨道上
preload="auto"预加载视频,确保拖拽时快速 seek
涉及文件汇总
后端修改
| 文件 | 变更 |
|---|---|
run_qwen_tts.sh |
export conda env bin 到 PATH,修复 SoX 找不到问题 |
models/Qwen3-TTS/qwen_tts_server.py |
每次生成后 torch.cuda.empty_cache(),asyncio.to_thread 避免阻塞 |
前端修改
| 文件 | 变更 |
|---|---|
frontend/src/features/home/ui/GeneratedAudiosPanel.tsx |
按钮布局统一(Play/Edit/Delete 右侧同组),移除文案摘要 |
frontend/src/features/home/ui/HomePage.tsx |
移除 MaterialSelector 配音遮罩,传入 onReorderSegment |
frontend/src/features/home/ui/TimelineEditor.tsx |
新增 HTML5 Drag & Drop 排序,新增 onReorderSegment prop |
frontend/src/features/home/ui/ClipTrimmer.tsx |
双手柄 range slider + 视频播放预览 + 播放进度指示 |
frontend/src/features/home/model/useHomeController.ts |
暴露 reorderSegments 方法 |
📝 历史文案保存 + 时间轴拖拽修复 — 第四阶段 (Day 23)
概述
新增文案手动保存与加载功能,修复时间轴拖拽排序后素材时长不跟随的 Bug,统一按钮视觉规范。
一、历史文案保存与加载
功能
用户可手动保存当前文案到历史列表,随时从历史中加载恢复。只有手动保存的文案才出现在历史列表中,与自动保存(useHomePersistence)完全独立。
UI 布局
按钮栏: [历史文案▼] [文案提取助手] [AI多语言▼] [AI生成标题标签]
底部栏: 128 字 [保存文案]
- 历史文案下拉: 展示已保存列表(名称 + 日期 + 删除按钮),点击条目加载文案,空列表显示"暂无保存的文案"
- 保存文案按钮: 文案为空时 disabled,点击后
toast.success("文案已保存") - 预计时长已移除: 底部栏只保留字数 + 保存按钮
实现
useSavedScripts.ts(新建)
interface SavedScript { id: string; name: string; content: string; savedAt: number }
- localStorage key:
vigent_{storageKey}_savedScripts saveScript(content): 取前 15 字符自动命名,新条目插入列表头部,直接写入 localStoragedeleteScript(id): 删除指定条目,直接写入 localStorageuseEffect([lsKey]): lsKey 变化时(guest → userId)重新从 localStorage 读取- 不使用自动持久化 effect,避免 storageKey 切换时空数组覆盖已有数据
数据流
ScriptEditor (UI)
↑ savedScripts / onSaveScript / onLoadScript / onDeleteScript (纯 props + callbacks)
│
useHomeController
├── useSavedScripts(storageKey) → { savedScripts, saveScript, deleteScript }
└── handleSaveScript() → saveScript(text) + toast
│
HomePage
└── 传递 props 到 ScriptEditor
二、时间轴拖拽排序 Bug 修复
问题
拖拽调换素材顺序后,各素材的时长没有跟随素材移动,而是留在原槽位。例如:素材1(3s) + 素材2(8s+4s循环),拖拽后变成素材2(3s) + 素材1(8s+4s循环),时长分配没变。
根因
reorderSegments 使用属性交换方式:逐个拷贝 materialId、sourceStart、sourceEnd 等属性在两个槽位间交换,然后调用 recalcPositions 重算位置。
修复
改为数组移动(splice):将整个 segment 对象从旧位置取出,插入到新位置。segment 对象携带全部属性(materialId、sourceStart、sourceEnd、color 等)作为一个整体移动,再由 recalcPositions 重算位置。
// 修复前:属性交换
const fromMat = { materialId: next[fromIdx].materialId, ... };
const toMat = { materialId: next[toIdx].materialId, ... };
next[fromIdx] = { ...next[fromIdx], ...toMat };
next[toIdx] = { ...next[toIdx], ...fromMat };
// 修复后:数组移动
const [moved] = next.splice(fromIdx, 1);
next.splice(toIdx, 0, moved);
附带优势:3+ 素材拖拽行为从"交换"变为"插入",更符合用户直觉。
三、按钮视觉统一
问题
历史文案、文案提取助手、AI多语言、AI生成标题标签 4 个按钮高度不一致,AI 按钮的文本被 <span> 嵌套包裹导致内部布局差异。
修复
- 4 个按钮统一为
h-7 px-2.5 text-xs rounded inline-flex items-center gap-1(固定高度 28px) - 移除 AI多语言 / AI生成标题标签 按钮内多余的
<span>嵌套,改为<>...</>fragment
涉及文件汇总
前端新增
| 文件 | 说明 |
|---|---|
frontend/src/features/home/model/useSavedScripts.ts |
历史文案 hook(localStorage 持久化) |
前端修改
| 文件 | 变更 |
|---|---|
frontend/src/features/home/ui/ScriptEditor.tsx |
历史文案下拉 + 保存按钮 + 移除预计时长 + 按钮高度统一 |
frontend/src/features/home/model/useHomeController.ts |
集成 useSavedScripts,新增 handleSaveScript |
frontend/src/features/home/ui/HomePage.tsx |
传递 savedScripts / handleSaveScript / deleteSavedScript 到 ScriptEditor |
frontend/src/features/home/model/useTimelineEditor.ts |
reorderSegments 从属性交换改为数组移动(splice) |