diff --git a/docs/operations.md b/docs/operations.md index 85ca442..241235e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -76,8 +76,8 @@ 无版本/一个视频都没有)返回 422。 - **产物放在视频旁**:每个视频处理完成后,把工作流 `final_outputs` 对应的最终 产物文件(字幕流水线即中文 `.srt` 与双目 `.ass`)**复制一份到视频所在目录**, - 与 .mp4 放在一起(`_place_products`)。文件名**对齐媒体库既有约定**:中文字幕 - 存为 `<视频名>.CN.srt`、双目字幕存为 `<视频名>.CN_dual_eye.ass`(稳定无时间戳, + 与 .mp4 放在一起(`_place_products`)。文件名**对齐媒体库既有约定**:日语转写 + 存为 `<视频名>.JA.srt`、中文字幕存为 `<视频名>.CN.srt`、双目字幕存为 `<视频名>.CN_dual_eye.ass`(稳定无时间戳, `_sidecar_product_name` 映射,其余扩展名产物保留原文件名;同名目标直接覆盖)。 文件名含视频主名,下次批量扫描会命中"已有字幕"规则直接跳过该视频。 - **成品放置校验(审查 R03)**:先预检全部 `final_outputs` 对应的记录与文件, diff --git a/docs/workflows.md b/docs/workflows.md index 7c311a2..81533a0 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -65,7 +65,9 @@ Expecting value: line 1 column 1 (char 0)`),也会直接渲染成超长字 保留节点原始文件及 URI,把成品副本存入 `runs//finals/output-<编码别名>/`; 时间戳固定取 run 创建时间,重复收尾覆盖相同路径,多个别名分目录避免冲突。 复制先写同目录临时文件,再原子替换目标,失败不登记残缺文件;`final_outputs` -声明的引用或文件缺失时任务失败,不能标完成。旧版本原文件已改名但最终别名记录 +声明的引用或文件缺失时任务失败,不能标完成。learn-translate 声明三份最终产物: +`ja_srt`(asr 节点清洗后的日语转写,翻译前的归档)、`cn_srt`(中文译文)、`ass`(双目)。 +旧版本原文件已改名但最终别名记录 仍指向有效文件时允许复用;原文件与成品都丢失时明确报错。批量场景下成品还会 复制到视频旁,命名约定见 [operations.md](./operations.md#文件夹批量处理)。 diff --git a/src/wov_app/batch.py b/src/wov_app/batch.py index 5d731bc..2b5ad15 100644 --- a/src/wov_app/batch.py +++ b/src/wov_app/batch.py @@ -149,16 +149,28 @@ def list_sidecar_subtitles(video: Path) -> list[Path]: return sorted(found) -def _sidecar_product_name(video: Path, source: Path) -> str: +# final_outputs 别名 → 视频旁文件名后缀:日语转写与中文译文同为 `.srt`, +# 只按扩展名映射会让两份产物撞名互相覆盖,必须按别名区分语言。 +_PRODUCT_SUFFIXES = { + "ja_srt": ".JA.srt", + "cn_srt": ".CN.srt", + "ass": ".CN_dual_eye.ass", +} + + +def _sidecar_product_name(video: Path, source: Path, alias: str | None = None) -> str: """把最终产物映射为放在视频旁时的标准字幕文件名。 对齐媒体库既有约定(文件名稳定、无时间戳,媒体库可按视频主名自动匹配): + - 日语转写(`ja_srt`)→ `<视频名>.JA.srt`; - 中文 `.srt` 产物 → `<视频名>.CN.srt`; - 双目 `.ass` 产物 → `<视频名>.CN_dual_eye.ass`; - 其余扩展名的最终产物保留原文件名(含时间戳),避免误改语义。 - 注:当前内置字幕工作流(learn-translate / zh-direct)在视频旁放置的 `.srt/.ass` 即 - 中文字幕与双目字幕;若未来出现"非中文 .srt"类产物需在此按产物来源区分。 + + 别名来自 `final_outputs` 的键,未登记别名时按扩展名兜底(历史工作流兼容)。 """ + if alias in _PRODUCT_SUFFIXES: + return f"{video.stem}{_PRODUCT_SUFFIXES[alias]}" suffix = source.suffix.lower() if suffix == ".srt": return f"{video.stem}.CN.srt" @@ -600,8 +612,8 @@ class BatchWorker: def _place_products(self, run_id: str, video: Path, definition: WorkflowDefinition) -> list[str]: """把最终产物文件复制到视频所在目录(视频旁),返回放置的文件名。 - 只为 `final_outputs` 声明的最终产物放置副本:字幕流水线的产物即中文 - `.srt` 与双目 `.ass`,按库内约定命名(见 _sidecar_product_name), + 只为 `final_outputs` 声明的最终产物放置副本:字幕流水线的产物即日语 + 转写 `.srt`、中文 `.srt` 与双目 `.ass`,按库内约定命名(见 _sidecar_product_name), 文件名稳定且含视频主名——媒体库按主名匹配字幕,下次批量扫描也会命中 "已有字幕"规则跳过该视频。同名目标直接覆盖:可能是上一次运行/旧工作流 留下的旧内容,应以本次产物为准。 @@ -615,7 +627,7 @@ class BatchWorker: source = Path(artifact["uri"]) if not source.is_file(): raise ValueError(f"missing final artifact file: {alias} ({source})") - target = video.parent / _sidecar_product_name(video, source) + target = video.parent / _sidecar_product_name(video, source, alias) products.append((source, target)) placed: list[str] = [] for source, target in products: diff --git a/tests/app/test_batch/test_batch.py b/tests/app/test_batch/test_batch.py index 98f7ba8..f1f6b05 100644 --- a/tests/app/test_batch/test_batch.py +++ b/tests/app/test_batch/test_batch.py @@ -247,6 +247,17 @@ def test_sidecar_product_name_maps_srt_and_ass() -> None: assert _sidecar_product_name(video, Path("/tmp/x.ass")) == "movie.CN_dual_eye.ass" +def test_sidecar_product_name_maps_language_variants_by_alias() -> None: + """日语转写与中文译文都是 .srt:按 final_outputs 别名区分,互不覆盖。""" + # 数据:同一个视频的两份 .srt 产物(日语转写、中文译文)。 + video = Path("/videos/movie.mp4") + + # 测试过程与验证结果:按别名映射成不同语言后缀。 + assert _sidecar_product_name(video, Path("/tmp/transcript.srt"), "ja_srt") == "movie.JA.srt" + assert _sidecar_product_name(video, Path("/tmp/cn.srt"), "cn_srt") == "movie.CN.srt" + assert _sidecar_product_name(video, Path("/tmp/x.ass"), "ass") == "movie.CN_dual_eye.ass" + + def test_sidecar_product_name_keeps_other_extensions() -> None: """其他扩展名产物保留原文件名(不误改语义)。""" # 数据:vtt 产物。 @@ -432,6 +443,34 @@ def test_worker_processes_pending_video_end_to_end(tmp_path: Path, monkeypatch) assert list(folder.glob("movie.*")), "应在视频旁放置最终产物" +def test_final_outputs_place_japanese_transcript_by_language(tmp_path: Path, monkeypatch) -> None: + """日语转写(过滤后待翻译)与中文译文都放到视频旁,文件名按语言区分。""" + # 数据:prep 阶段产出日语转写、translate 阶段产出中文译文,两者都是 .srt。 + folder = tmp_path / "videos" + _make_video(folder / "movie.mp4") + db = Database(tmp_path / "wov.db") + db.upsert_workflow({ + "id": "wf", "name": "两产物流程", "description": "", "published": 1, + "latest_version": 1, + }) + definition = _staged_definition().to_dict() + definition["final_outputs"] = {"ja_srt": "prep.file_uri", "cn_srt": "translate.file_uri"} + db.create_workflow_version("wf", 1, WorkflowDefinition.from_dict(definition).to_dict()) + monkeypatch.setattr("wov_app.batch.BATCH_WORK_ROOT", tmp_path / "storage" / "batch") + + # 测试过程:驱动一轮批量处理。 + with _recording_nodes([]): + job_id = create_job(db, str(folder), "wf") + BatchWorker(db, interval_seconds=999)._process_job(db.get_batch_job(job_id)) + + # 验证结果:两份产物都在视频旁且不互相覆盖。 + placed = sorted( + p.name for p in folder.iterdir() + if p.name.startswith("movie.") and p.suffix.lower() in (".srt", ".ass") + ) + assert placed == ["movie.CN.srt", "movie.JA.srt"] + + def test_worker_skips_video_with_sidecar_subtitle(tmp_path: Path, monkeypatch) -> None: """已有旁挂字幕的视频不触发流水线(SKIPPED 不产生 run)。""" # 数据:一个已带字幕的视频。 diff --git a/web/assets/app.js b/web/assets/app.js index 54c9757..d4794dd 100644 --- a/web/assets/app.js +++ b/web/assets/app.js @@ -382,11 +382,13 @@ function progressBar(percent, failed) { return `
`; } -// 为已完成任务生成最终产物下载链接(cn_srt / ass)。 +// 为已完成任务生成最终产物下载链接(ja_srt / cn_srt / ass)。 +// 日语转写是翻译前的中间产物,归档下来便于回看与对照译文。 function artifactLinks(runId, status) { if (status !== "COMPLETED") return "-"; return `
+ 日语 SRT 中文 SRT VR ASS
`; diff --git a/workflows/learn-translate.json b/workflows/learn-translate.json index bf6a018..21dce7c 100644 --- a/workflows/learn-translate.json +++ b/workflows/learn-translate.json @@ -89,6 +89,7 @@ "video_uri": "file" }, "final_outputs": { + "ja_srt": "asr.srt_uri", "cn_srt": "translate.cn_srt_uri", "ass": "ass.ass_uri" }