Concat · 中文配音扩展(DUB)

施工图纸:在哪儿加什么,怎么实现

基线 v0.2.8 · 分支 amd @ fd415e0 A 区 · 新路径 3 个 · ~1560 行 B 区 · 上游接线 4 个文件 / 8 行 图纸 7 张
一句话:业务逻辑和界面全部装进上游不存在的三个路径(crates/concat-dub/、ui/dialogs/dub.slint、src/dub_glue.rs), 上游源码只动 4 个文件、8 行,每行带 // DUB: 尾巴。升级时新路径永远不冲突,8 行重放一次即可。 操作模型是「以字幕条为单元」:右键一条字幕就能 译 / 配 / 对;框选多条后右键 = 批量版;整条字幕轨由面板「范围」下拉指定(上游轨道头没有右键菜单)。
A 区 · 新增文件(不冲突) B 区 · 上游 8 行接线(重放) ① ② ③ 红色圈号 = 图上标注,下文表格给实现

图 1两区分离:改哪儿、加哪儿

左边是必须动的上游文件(琥珀色),右边是全新路径(青色)。中间的箭头就是 8 行的全部意义:上游只留两个"插座"——注入菜单行、接收菜单点击。

改动分区:上游 4 文件 / 8 行 + 新增 3 路径 新 crate 自动成为 workspace 成员(members = ["crates/*"] 是通配符),根 Cargo.toml 零改动 B 区 · 上游(只有 8 行) crates/concat/Cargo.toml A1 concat-dub = { path = "../concat-dub" } // DUB: 依赖链接。注意写 path,不写 workspace 依赖 crates/concat/src/studio.rs A2 A3 A4 A5 pub dub: crate::dub_glue::DubState, // 状态挂载 dub: Default::default(), // 构造初始化 rows.extend(menu_rows(self, clip)); // 注入菜单 a if a.starts_with("dub.") => act(..) // 收点击 ← 全部在原函数末尾追加,不改任何已有分支 crates/concat/src/lib.rs A6 mod dub_glue; // 模块声明 crates/concat/ui/app.slint A7 A8 import { DubDialog, DubData } from "dialogs/dub.slint"; DubDialog { data: app.dub; } 箭头一进一出:A4 注入菜单行,A5 把点击收回来 升级自检:grep -rn "DUB:" crates/ | wc -l 应恒等于 8 A4 A5 A 区 · 新路径(上游不存在 → 永不冲突) crates/concat/src/dub_glue.rs ★ 新文件 ~200 行 · menu_rows() / act() / DubState 上游只看见 A4 / A5 两行调用;90% 接线复杂度封在这里 crates/concat/ui/dialogs/dub.slint ★ 新文件 ~240 行 · 设置面板 + 进度 + 结果报告(见图 4) 只被 A7/A8 引用,不继承任何现有 dialog 基类 crates/concat-dub/ ★ 新目录 ~1120 行 · 8 个模块 只依赖上游叶子 crate,不依赖 concat 二进制 → 无循环依赖 text.rs改字不毁样式with_content() translate.rs在线翻译ureq · 行数对齐 speak.rsTTS 编排语速 = 字数/窗口 fit.rs对齐策略→ Vec<Command> prompt.rs提示词temperature 0.3 lang.rs语言表默认 zh-CN target.rsOne / Lane只取 Text clip lib.rs门面clip_menu() 只向下依赖 上游叶子 crate(一行不改,dub 只是调用方) concat-project Command · ClipPatch · TextStyle · Clip concat-speech Transcriber · Speech · SpeakRequest concat-host AppDirs · media::probe(不启 GPU)
图 1 · 左边 4 个文件 8 行是唯一需要"重放"的改动;右边三个路径上游永远不会有,git 三方合并只会看到"我方新增"。

图 2界面草图:六个落点全在主窗口里

不改停靠布局、不加新面板(PaneKind 只有 5 个值,牵一发动全身)。所有操作从时间线上右键一条字幕发起(选一条 = 单条,框选多条 = 批量),设置面板是浮层。

Concat — ep01_素材 文件编辑 时间线视图 帮助 v0.2.8 · amd 媒体库 A.mp4 B.mp4 C.mp4 D.mp4 生成 配音_001.wav origin = MediaOrigin::Speech 配音产物自动归到「生成」 AddMedia 的 item.origin 决定 画面预览 ▶ ■ 00:00:12:08 / 00:03:41:00 检查器 文字 苏晴:快走! ← 第 5 步在这手改 (现有能力,dub 不碰) 字号 42 颜色 #FFFFFF 描边 2.0 这些字段 改字时必须 原样保留 → text.rs 配音设置面板(默认隐藏) A8: DubDialog { data: app.dub; } 语言 / API Key / 音色 / 语速 / 对齐 右键菜单底部「配音设置…」打开 时间线 0:000:10 0:200:300:40 V1 原声 素材.mp4(外语原声) T1 字幕 字1字2字3 字4字5字6 ClipKind::Text ← dub 的唯一作用对象 Audio / Video / Image 不出现 dub 菜单 配音轨 配音① 配音② 未生成(虚线) AddClip { track_id, start: clip.start } 走 Task 队列,不阻塞 UI 1 2 3 4 5 ⑤ 静音原声 = SetTrackFlag { Mute }(V1 轨道) 6
图 2 · 关键点:① ② 都在"右键"上,不需要工具栏按钮;③ 配音落在新建的「配音轨」;⑥ 面板是浮层,不占停靠位。
标注界面位置怎么实现
①时间线上单条字幕右键 上游已有 on_clip_context → clip_action(id, action)。A5 在 clip_action 的 match 里拦 "dub." 前缀,交给 dub_glue::act(studio, id, action)。菜单行由 A4 注入(menu_rows 只对 ClipKind::Text 返回)。
②框选 / Shift 点选多条字幕后右键 上游右键只挂在 clip 上(lanes.slint:555),轨道头没有右键菜单——不做轨头菜单,整轨改由面板「范围」下拉指定。act() 读 studio.selection: Vec<String>(studio.rs:845,pub 字段),右键的那条不在选中集里就只取它自己;多条结果包一个 Command::Batch(一次 undo)。
③新建的「配音轨」(名字不叫 A1,避免和锚点编号混淆) AddMedia(origin = MediaOrigin::Speech)+ AddClip{ track_id, start: clip.start, ripple: false };单条重配走 ReplaceClipMedia,不新增 clip。
④检查器·文字字段 第 5 步手工修正,现有能力,一行不改。注意:dub 侧改字必须走 text.rs::with_content,否则样式被重置(图 5 ①)。
⑤V1 轨道头的静音 SetTrackFlag { track_id, flag: TrackFlag::Mute, value: true }。对齐完成后由面板上的「静音原声」勾选框触发。
⑥配音设置浮层 A7/A8 挂 DubDialog,可见性由 app.dub.open 控制;菜单底部「配音设置…」把它置 true。不加新菜单栏项、不加新 PaneKind。

回到你给的 6 步:只有第 2 步是从零写的

把上表翻成你的操作顺序,就能看清工作量落点 —— 第 1、4、5 步一行新代码都不用写,第 3 步和第 6 步是同一套编排的两档范围。

步你要的操作谁提供落地命令 / 说明
1外语素材 → 语音转字幕 已有 Transcriber::transcribe(transcribe.rs:435)+ srt_commands(captions.rs:587) Batch{ AddTextClip{duration} }
2字幕译成中文 / 指定语言 新增 dub.translate → translate.rs(在线 ureq) UpdateClip{patch.text},多条包 Batch。唯一从零开始的模块。
3整轨字幕 → 中文配音 已有 Speech::speak(tts.rs:726);新增只是编排 + 语速估算 AddMedia + AddClip(范围 = Target::Lane)
4配音对接画面(时长贴合) 已有 AddClip / ReplaceClipMedia / TrimClip / MoveClips 同上 + TrimClip;策略在 fit.rs 里三选一
5手工修正字幕(改字 / 拖时间) 已有 检查器 / MoveClips / SplitClips 用户操作,零新代码。dub 侧改字要走 text.rs 保住样式
6对单条已修正的字幕单独配音 新增 dub.speak 的 Target::One ReplaceClipMedia(不新增 clip)+ TrimClip。与第 3 步同一套代码,只差范围。

图 3右键菜单:三项动词,一个前缀

菜单是 Rust 侧数据驱动的(editor.slint:435 的 menu-items 由 studio.rs:10964 fn menu() 生成),所以加菜单项不改任何 slint、不新增 Glyph。所有新菜单项的 id 统一 dub. 前缀,A5 就靠这个前缀拦下来。

单条菜单(选中的是 1 条) 同一张菜单,选中的是 3 条 字3 · 苏晴:快走! 译成中文 dub.translate 配音这条 dub.speak 对齐… ▸ dub.fit 复制 copy(上游) 删除 delete(上游) 配音设置… dub.settings ↑ 新加 4 项,其余是上游原有 非 Text 的 clip(视频/音频/图片) menu_rows 返回空 → 这 4 项不出现 菜单项 id 只有 app 内部认识, 不进 locales,不需要翻译表 保画面(推荐) keep_picture 保语音 keep_voice 手动 manual 三条策略 = 三条不同命令,见 图 5 下半 已选 3 条字幕 译这 3 条 配这 3 条 对齐这 3 条…▸ 配音设置… 文案里带数量,来自 Target 里 clip 的个数 落一个 Batch → 一次 Ctrl+Z 全撤 实现:menu_rows 读 studio.selection.len() 一行菜单项长什么样 MenuItemData { id: "dub.translate", kind: MenuRow::Action, glyph: Glyph::TextMark, // 复用,不新增枚举值 label: "译成中文", // dub crate 自带表 } clip_menu() 返回 (id, label, icon 名) 三元组,Glyph 由 dub_glue 补 TypeIcon 为什么 dub crate 不直接给 MenuItemData:Glyph 是 slint 生成的类型,crates/concat-dub 拿不到, 硬依赖会逼它依赖 concat 二进制 → 循环。所以拆成两层:crate 出规格,glue 贴图标。
图 3 · 前缀 dub. 是全部接线点:A5 用 a.starts_with("dub.") 一次性拦下所有新菜单项,上游 match 分支一个不改。

图 4设置面板:dub.slint 线框

这是唯一需要手写的界面文件,约 240 行 slint。四项能力:范围、翻译、配音、对齐,加一条进度条。

DubDialog · 300×470 浮层(居中,可拖) 中文配音 / 翻译 ✕ 范围 当前选中:3 条字幕 ▾ 翻译(在线) 目标语言 中文(普通话) · zh-CN▾ Base URL https://api.deepseek.com/v1 模型 deepseek-chat API Key sk-•••••••••••••••• 从 .env 导入 配音(本地 TTS) 音色 zf_xiaoxiao(女声 · 普通话)▾ 语速区间 0.85 1.20 中文基准 5 字/秒;超出区间才动时间 对齐策略 保画面(推荐) 时间轴不动,靠语速挤进去 保语音 字幕跟语音走,后面 ripple 后移 手动 只选中,用户自己拖 对齐后静音原声轨 SetTrackFlag { Mute } 正在翻译 12 / 48 … 44% A B C D 配置落在独立文件,不碰 Concat 的 prefs.json ~/.config/app.concat.editor/dub.json { "range": "selection", "base_url": "https://api.deepseek.com/v1", "api_key": "sk-...", "model": "deepseek-chat", "target_lang": "zh-CN", "voice": "zf_xiaoxiao", "speed": [0.85, 1.20], "cps": 5.0, "fit": "keep_picture" } 为什么不放进 prefs.json prefs.json 是上游的 serde 结构,多一个字段要么被丢弃、要么要改上游结构体。 独立文件 + 自家的 serde 结构 = 升级时上游改 prefs 也碰不到它。 api_key 处理 显示掩码,只在进程内解密使用;不写日志、不进项目文件、不进导出。 进度条的数据从哪来 翻译按「行」报(第几行 / 共几行),配音按「句」报。 后台线程 ← spawn_in_project,回调直接拿 &mut Studio 更新 app.dub.progress。 失败不中断 单条失败记进报告列表(面板底部可展开),成功的照常落地。 整批只在最后统一 Batch,失败的那条不进命令。 面板关闭不影响任务继续:作业在后台线程,落地时再回 Studio。
图 4 · Ⓐ 范围(One / Selection / Lane 三态)、Ⓑ API Key、Ⓒ 语速区间(clamp 0.85–1.20)、Ⓓ 对齐三选一。

图 5数据流:一次右键之后发生什么

上半是「译」,下半是「配 + 对」。两条链路都在后台线程(spawn_in_project),UI 不卡;落地时再把命令一次性交给 studio.apply。

① 译 translate · 一次请求多行,行数必须对齐 右键「译成中文」 上游 on_menu_selected → clip_action(id, action) dub_glue::act A5 拦 "dub." 前缀 读 studio.selection 定范围 target.rs Target → Vec<Clip> 只留 ClipKind::Text,按 start 排 translate.rs ureq → POST /chat/completions temperature 0.3 · 失败重试 2 次 按行喂、按行收;行数不等就整批放弃 text.rs::with_content TextStyle { content, ..old.clone() } ← 字体/颜色/描边一个不丢 Command::Batch UpdateClip{ patch.text: Some(Some(..)) } 多条 = 一条 undo studio.apply(batch) 上游命令总线,校验 + 落 undo 栈 dub 不直接改 project 时间线刷新 字幕变中文,样式不变 Ctrl+Z 一次回到原文 ② 配 → 对 speak + fit · 语速先挤,挤不下才动时间 右键「配这 3 条」 同一套 act() 范围不同,代码相同 speak.rs 算 speed est = 字数 / 5.0 clamp(est / clip.duration, .85, 1.20) tts.rs speak() SpeakRequest{ speed, text, voice } 原生语速,不用 atempo <project>/audio/*.wav 每句一个文件 项目未保存则报错(tts.rs:794) media::probe 拿真实时长(合成出来常与估算差 5-15%) AddMedia + AddClip origin = Speech;is_free 判空 空了退 AddClipAtFirstFree fit.rs 三策略 保画面 → TrimClip(Tail, 溢出 20%+) 保语音 → TrimClip(ripple: true) SetTrackFlag Mute 原声轨(面板勾选) 整个 ② 包一个 Batch 唯一的坑,单独说:properties.rs:81 的 assign 是覆盖 只发 {"content":"你好"},这条字幕的字号/颜色/描边/背景会被重置成 TextStyle::default()——因为 TextStyle 上的 #[serde(default)] 是「缺字段取默认」,不是「缺字段保持原样」。 所以任何改字路径都必须 TextStyle { content, ..old.clone() } (照 captions.rs:751 写法)M1 先用单测钉住这条。
图 5 · 上半 4 个上游命令全是现成的;真正新写的只有 target / translate / speak / fit 四个模块。

图 68 个锚点落在文件树的哪里

琥珀小牌 = 上游改动(8 行);青色 ★ = 新文件,上游永不出现。左边的 [ ] 牌和右边的代码行是一一对应的。

src/crates/ 锚点 ├── concat/ │ ├── Cargo.toml │ ├── src/ │ │ ├── lib.rs │ │ ├── studio.rs │ │ └── dub_glue.rs ★ │ └── ui/ │ ├── app.slint │ └── dialogs/dub.slint ★ └── concat-dub/ ★ ├── Cargo.toml ├── README.md └── src/ ├── lib.rs ├── text.rs ├── target.rs ├── translate.rs └── speak.rs · fit.rs · prompt.rs · lang.rs ★ = 上游从来不存在的路径(新 crate ~1120 行 / 新 slint ~240 行 / 新胶水 ~200 行) A1 A6 A2 A3 A4 A5 A7 A8 这 8 行就是全部上游改动 // crates/concat/Cargo.toml concat-dub = { path = "../concat-dub" } // DUB: A1 // crates/concat/src/lib.rs mod dub_glue; // DUB: A6 // crates/concat/src/studio.rs pub dub: crate::dub_glue::DubState, // DUB: A2 dub: Default::default(), // DUB: A3 rows.extend(crate::dub_glue::menu_rows(self, clip)); // A4 a if a.starts_with("dub.") => dub_glue::act(self, id, a), // A5 // crates/concat/ui/app.slint import { DubDialog, DubData } from "dialogs/dub.slint"; // A7 DubDialog { data: app.dub; } // DUB: A8 grep -rn "DUB:" crates/ | wc -l → 8 为什么不放 9 处、不放到别的 crate · 放 concat-api / concat-core?它们都要被 dub 依赖,会与 TTS 的 heavy 依赖绑在一起,且 concat-api 明确不含 concat-speech。 · 放 panes/mod.rs(加 Msg 变体)?已核实 spawn_in_project 的回调 直接拿 &mut Studio,不需要过 Msg → 省掉 panes/mod.rs、i18n.rs、 locales/*.json 三处改动。 · 每处都是「在原函数末尾追加」或「match 加一个 guard 臂」, 不重排、不改签名、不动已有分支 → 冲突时人眼可判。 · A1 是链接、A2/A3 状态、A4 注入、A5 分派、A6 声明、A7/A8 挂载: 这是上游"承认扩展"的最小接口面,再少只能改上游函数体内部。
图 6 · 8 个锚点里有 6 个是单行、2 个是两行成对;全部收在 4 个文件内。

图 7升级时为什么不会打架

不是靠"小心",是靠三条可验证的路径事实:新路径上游不存在、通配符让 crate 自动入列、8 行全部带标记。

上游 jub0t/Concat · v0.3.0 · studio.rs 改了若干函数 · app.slint 加了新 dialog · 新增 crates/concat-xxx · 也许删掉了某个旧函数 (上游不知道 dub 存在) → 但你的 A 区它从没见过 你的 amd 分支 = 上游 v0.2.8 + 5 个本地 commit(speech 等) + A 区三个新路径 + 8 行 DUB 接线 HEAD: fd415e0 合并后 上游新版 + 你的 5 个 commit + A 区(原样,零冲突) + 8 行(重放,几分钟) cargo check -p concat-dub cargo check -p concat → 两条都过 = 接线完整 merge 结果的三种情形 零冲突 crates/concat-dub/ 整个目录(~1120 行) 零冲突 ui/dialogs/dub.slint · src/dub_glue.rs · ~440 行 重放 4 文件里的 8 行(有标记,逐个照抄) git 三方合并时这里只有"我方新增",没有"双方修改" 同上:上游永远不会有同名文件 冲突框里保留两边、删掉标记即可,一行一分钟 升级一次,四步 git fetch origin && git merge v0.3.0 # A 区自动落地,只有 8 行可能要你选 grep -rn "DUB:" crates/ | wc -l # 应恒等于 8 cargo check -p concat-dub && cargo check -p concat && cargo build --release --profile app
图 7 · 冲突面被压到 8 行,而且每行都能用 grep "DUB:" 找回来——不靠记忆。

附三个里程碑与当前状态

阶段做完什么验收(能被眼睛看见)
M1 concat-dub 骨架 + text.rs(带单测)+ 单句翻译 + 单句 TTS;A1~A6 接上 一条外语视频 → 转字幕 → 右键某条「译成中文」→ 变中文且字号/颜色/描边没变 → 右键「配音这条」→ 多出一条音频
M2 多选走 Batch + 三策略 + 面板(A7/A8)+ 原声静音 整片框选一次译完,Ctrl+Z 一次全撤;输出一条完整中文配音成片
M3 原文/译文双行、失败报告、.env 导入、重试提示 断网跑一次,报告说清哪几条没译上,其余照常落地

两个需要你定的点(其余都已按上游实际代码定死)

本文与 Concat中文配音扩展_施工方案.md 是同一套方案:文档给文字细节与命令,图纸给位置。当前代码一行未动(git status 只多出未跟踪的 transub/ 目录)。