diff --git a/marketplace.json b/marketplace.json index a82aec6e..92ca692f 100644 --- a/marketplace.json +++ b/marketplace.json @@ -94,7 +94,7 @@ "en": "Automated video editing toolkit: standard cloud speech transcription and synthesis, full video frame extraction and understanding, local re-inspection, timeline, preview rendering, QC, and TTS.", "zh-CN": "自动化视频剪辑工具包: 标准云端语音转录与合成、完整视频抽帧理解、局部复看、时间线、预览渲染、QC 和 TTS。" }, - "version": "0.4.3", + "version": "0.4.4", "author": { "name": "Z.ai", "url": "https://z.ai" diff --git a/plugins/video-agent-kit/.claude-plugin/plugin.json b/plugins/video-agent-kit/.claude-plugin/plugin.json index c0c93dfe..b90561ca 100644 --- a/plugins/video-agent-kit/.claude-plugin/plugin.json +++ b/plugins/video-agent-kit/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "video-agent-kit", - "version": "0.4.3", + "version": "0.4.4", "description": "自动化视频剪辑工具包: 标准云端语音转录与合成、完整视频抽帧理解、局部复看、时间线、预览渲染、QC 和 TTS。", "author": { "name": "Z.ai", diff --git a/plugins/video-agent-kit/.zcode-plugin/plugin.json b/plugins/video-agent-kit/.zcode-plugin/plugin.json index a6179d52..7521928a 100644 --- a/plugins/video-agent-kit/.zcode-plugin/plugin.json +++ b/plugins/video-agent-kit/.zcode-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "video-agent-kit", - "version": "0.4.3", + "version": "0.4.4", "description": "自动化视频剪辑工具包: 标准云端语音转录与合成、完整视频抽帧理解、局部复看、时间线、预览渲染、QC 和 TTS。", "author": { "name": "Z.ai", diff --git a/plugins/video-agent-kit/README.md b/plugins/video-agent-kit/README.md index 4da1bd85..5e6b39b0 100644 --- a/plugins/video-agent-kit/README.md +++ b/plugins/video-agent-kit/README.md @@ -26,6 +26,8 @@ `video-recap-workflows` 覆盖完整电影、足球、LoL 和篮球解说剪辑。电影解说使用 `detect_shots` / `arrange_footage` / `synthesize_narration` / `bind_narration` / `render_narrated`;足球使用 `soccer_ingest` / `soccer_arrange` / `soccer_tts` / `soccer_render`;LoL 与篮球复用 `lol_ingest` / `lol_arrange` / `lol_tts` / `lol_render`。这些流程通常产出 `out/final.mp4` 和 workflow-specific QC,而不是通用 `out/preview.mp4`。 +所有基于 FFmpeg concat demuxer 的拼接路径都会统一转义,因此源素材或项目目录名称中包含英文撇号(例如 `Editor's project`)时,预览渲染、基础拼接、语音压缩和 recap 工作流仍可正常读取文件。 + `out/media.json` 保留给源素材探测。`qc_preview` 和 `tts_generate` 内部做媒体探测时会写 `out/preview_media.json`、`out/tts_*_media.json` 这类 sidecar,不覆盖源素材 metadata。`validate_timeline` 和 `qc_preview` 会在报告中写入文件 hash;Stop hook 会拒绝已经过期的 validation / QC report。 `schemas/video_observation.schema.json` 对应工具真实落盘的 observation package,即 `video_ingest` / `video_watch_segment` 生成的帧清单、sheet 清单、媒体元数据、抽帧参数和匹配转录。模型读图后的主观摘要、候选片段和风险判断应写入 `out/report.md` 或后续独立的 review 文件,不写回这个工具产物 schema。 diff --git a/plugins/video-agent-kit/mcp/ve_tools/basic_ops.py b/plugins/video-agent-kit/mcp/ve_tools/basic_ops.py index e6d58705..529d2a83 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/basic_ops.py +++ b/plugins/video-agent-kit/mcp/ve_tools/basic_ops.py @@ -4,12 +4,12 @@ import math import shutil import subprocess -from .ffproc import run_proc +from .ffproc import run_proc, write_ffconcat_file import time from pathlib import Path from typing import Any -from .render import cut_clip, ffconcat_escape, probe_video_size, source_has_audio, source_has_video +from .render import cut_clip, probe_video_size, source_has_audio, source_has_video from .result import ToolResult from .run_context import RunContext, reject_input_output_collision from .timeline import file_sha256, media_duration_seconds @@ -136,7 +136,7 @@ def op_splice(args: dict, ctx: RunContext) -> tuple[Path, list[str]]: cut_clip(clip, segment_path, target_width=target_width, target_height=target_height) segment_paths.append(segment_path) concat_file = work_dir / "concat.txt" - concat_file.write_text("".join(f"file '{ffconcat_escape(path)}'\n" for path in segment_paths), encoding="utf-8") + write_ffconcat_file(concat_file, segment_paths) cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file), diff --git a/plugins/video-agent-kit/mcp/ve_tools/condense.py b/plugins/video-agent-kit/mcp/ve_tools/condense.py index 3e1b1357..f431247f 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/condense.py +++ b/plugins/video-agent-kit/mcp/ve_tools/condense.py @@ -30,7 +30,7 @@ import re import shutil import subprocess -from .ffproc import run_proc, require_ffmpeg +from .ffproc import require_ffmpeg, run_proc, write_ffconcat_file import time from pathlib import Path from typing import Any @@ -2215,10 +2215,7 @@ def _cut_segment( def _concat_hard(segments: list[dict[str, Any]], output_path: Path, *, crf: int) -> str | None: concat_file = Path(segments[0]["path"]).parent / "concat.txt" - concat_file.write_text( - "".join(f"file '{Path(s['path']).as_posix()}'\n" for s in segments), - encoding="utf-8", - ) + write_ffconcat_file(concat_file, (s["path"] for s in segments)) cmd = [ "ffmpeg", "-y", "-v", "error", "-f", "concat", "-safe", "0", "-i", str(concat_file), @@ -3046,4 +3043,3 @@ def _qc_text(report: dict[str, Any], sampled: list[dict[str, Any]], report_path: lines.append("\nNo cuts to sample (single-clip output), so there are no join artefacts to inspect.") return "\n".join(lines) - diff --git a/plugins/video-agent-kit/mcp/ve_tools/ffproc.py b/plugins/video-agent-kit/mcp/ve_tools/ffproc.py index 34ab8ab8..aa53df15 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/ffproc.py +++ b/plugins/video-agent-kit/mcp/ve_tools/ffproc.py @@ -20,6 +20,7 @@ """ from __future__ import annotations +from collections.abc import Iterable import re as _re import shutil import subprocess @@ -48,6 +49,32 @@ def run_proc(cmd: list[str], *args, timeout: float | None = None, **kwargs): return subprocess.run(cmd, *args, timeout=timeout, **kwargs) +# --------------------------------------------------------------------------- +# ffconcat path escaping +# +# Concat demuxer scripts are not shell scripts, but they have their own quoting +# rules. A literal apostrophe closes a single-quoted path, so a project such as +# ``Director's cut`` is otherwise parsed as ``Directors cut`` and every concat +# render fails with "No such file or directory". +# --------------------------------------------------------------------------- + +def ffconcat_file_line(path: str | Path) -> str: + """Return one absolute, safely quoted ``file`` directive for ffconcat.""" + value = Path(path).resolve().as_posix() + if "\n" in value or "\r" in value: + raise ValueError("ffconcat input paths must not contain line breaks") + escaped = value.replace("'", "'\\''") + return f"file '{escaped}'\n" + + +def write_ffconcat_file(destination: str | Path, paths: Iterable[str | Path]) -> None: + """Write an UTF-8 ffconcat input list with every path escaped once.""" + Path(destination).write_text( + "".join(ffconcat_file_line(path) for path in paths), + encoding="utf-8", + ) + + # --------------------------------------------------------------------------- # ffmpeg filtergraph escaping # diff --git a/plugins/video-agent-kit/mcp/ve_tools/lol.py b/plugins/video-agent-kit/mcp/ve_tools/lol.py index a511c77f..2aaab090 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/lol.py +++ b/plugins/video-agent-kit/mcp/ve_tools/lol.py @@ -20,7 +20,7 @@ import os import re import subprocess -from .ffproc import run_proc, escape_text +from .ffproc import escape_text, run_proc, write_ffconcat_file from pathlib import Path from .fonts import ass_font_name, find_cjk_font @@ -873,7 +873,8 @@ def lol_render(args: dict, ctx: RunContext) -> ToolResult: files, err = _render_segments(reel, match, outline, work) if err: return ToolResult(text=f"[ERROR] render failed: {err}") - lst = work / "concat.txt"; lst.write_text("".join(f"file '{os.path.abspath(f)}'\n" for f in files), encoding="utf-8") + lst = work / "concat.txt" + write_ffconcat_file(lst, files) base = work / "base.mp4" r = _ffmpeg(["-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", str(base)]) if r.returncode: diff --git a/plugins/video-agent-kit/mcp/ve_tools/narrate_tts.py b/plugins/video-agent-kit/mcp/ve_tools/narrate_tts.py index cad55754..985e2392 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/narrate_tts.py +++ b/plugins/video-agent-kit/mcp/ve_tools/narrate_tts.py @@ -14,7 +14,7 @@ import re import shlex import subprocess -from .ffproc import run_proc +from .ffproc import run_proc, write_ffconcat_file import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path @@ -301,9 +301,7 @@ def _concat_audio(parts: list[str], out: str) -> None: _run(["ffmpeg", "-y", "-i", parts[0], *codec1, out]) return lst = out + ".txt" - with open(lst, "w", encoding="utf-8") as f: - for p in parts: - f.write(f"file '{p}'\n") + write_ffconcat_file(lst, parts) codec = ["-c:a", "pcm_s16le"] if Path(out).suffix.lower() == ".wav" else \ ["-c:a", "libmp3lame", "-b:a", "192k"] _run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst, *codec, out]) diff --git a/plugins/video-agent-kit/mcp/ve_tools/render.py b/plugins/video-agent-kit/mcp/ve_tools/render.py index 89cefbb1..4d11320f 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/render.py +++ b/plugins/video-agent-kit/mcp/ve_tools/render.py @@ -5,7 +5,7 @@ import os import shutil import subprocess -from .ffproc import run_proc, safe_expr, require_ffmpeg +from .ffproc import require_ffmpeg, run_proc, safe_expr, write_ffconcat_file import time from pathlib import Path @@ -84,10 +84,7 @@ def render_preview(args: dict, ctx: RunContext) -> ToolResult: ) segment_paths.append(seg_path) concat_file = work_dir / "concat.txt" - concat_file.write_text( - "".join(f"file '{ffconcat_escape(p)}'\n" for p in segment_paths), - encoding="utf-8", - ) + write_ffconcat_file(concat_file, segment_paths) cmd = [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file), @@ -733,10 +730,6 @@ def edit_decisions_from_plan(plan: dict) -> dict: } -def ffconcat_escape(path: Path) -> str: - return path.as_posix().replace("'", "'\\''") - - def cut_clip(clip: dict, output_path: Path, *, target_width: int, target_height: int) -> None: duration = float(clip["end"]) - float(clip["start"]) if duration <= 0: diff --git a/plugins/video-agent-kit/mcp/ve_tools/render_narrated.py b/plugins/video-agent-kit/mcp/ve_tools/render_narrated.py index 1592f208..fe504acb 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/render_narrated.py +++ b/plugins/video-agent-kit/mcp/ve_tools/render_narrated.py @@ -28,6 +28,7 @@ from pathlib import Path from typing import Any +from .ffproc import write_ffconcat_file from .fonts import ass_font_name from .result import ToolResult from .run_context import RunContext, clean_env @@ -807,9 +808,7 @@ def _concat(parts: list[str], out: str) -> None: run(["ffmpeg", "-y", "-i", parts[0], "-c", "copy", out]) return lst = out + ".txt" - with open(lst, "w") as f: - for p in parts: - f.write(f"file '{Path(p).resolve()}'\n") + write_ffconcat_file(lst, parts) # 切片已统一规格, 这里可安全 -c copy; 若仍报错回退重编 r = run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", out], check=False) @@ -833,9 +832,7 @@ def _concat_audio_plain(parts: list[str], out: str, run(cmd) norm.append(np_) lst = out + ".txt" - with open(lst, "w") as f: - for p in norm: - f.write(f"file '{Path(p).resolve()}'\n") + write_ffconcat_file(lst, norm) run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", out]) diff --git a/plugins/video-agent-kit/mcp/ve_tools/soccer.py b/plugins/video-agent-kit/mcp/ve_tools/soccer.py index b9a236f8..2dc574bb 100644 --- a/plugins/video-agent-kit/mcp/ve_tools/soccer.py +++ b/plugins/video-agent-kit/mcp/ve_tools/soccer.py @@ -24,7 +24,7 @@ import json import re import subprocess -from .ffproc import run_proc, escape_text +from .ffproc import escape_text, run_proc, write_ffconcat_file from pathlib import Path from urllib.parse import quote @@ -546,7 +546,7 @@ def _render_segments(reel: dict, match: dict, outline: dict, work: Path) -> tupl if flash_files: # 快闪蒙太奇内部保持硬切: 先硬拼成 open.mp4 再进溶解链 lst = work / "open_concat.txt" - lst.write_text("".join(f"file '{f.resolve()}'\n" for f in flash_files), encoding="utf-8") + write_ffconcat_file(lst, flash_files) open_mp4 = work / "open.mp4" r = _ffmpeg(["-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", str(open_mp4)]) if r.returncode: diff --git a/plugins/video-agent-kit/tests/test_ffconcat_paths.py b/plugins/video-agent-kit/tests/test_ffconcat_paths.py new file mode 100644 index 00000000..eb7ca78c --- /dev/null +++ b/plugins/video-agent-kit/tests/test_ffconcat_paths.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN_ROOT / "mcp")) + +from ve_tools.ffproc import ffconcat_file_line, write_ffconcat_file # noqa: E402 + + +class FFConcatPathTests(unittest.TestCase): + def test_file_line_escapes_apostrophe_once(self) -> None: + path = Path("Director's cut") / "clip.mp4" + + line = ffconcat_file_line(path) + + expected_path = path.resolve().as_posix().replace("'", "'\\''") + self.assertEqual(line, f"file '{expected_path}'\n") + + def test_file_line_rejects_line_breaks(self) -> None: + with self.assertRaisesRegex(ValueError, "line breaks"): + ffconcat_file_line("clip\nnext.mp4") + + @unittest.skipUnless(shutil.which("ffmpeg"), "ffmpeg is required for the concat smoke test") + def test_ffmpeg_reads_concat_file_below_apostrophe_directory(self) -> None: + ffmpeg = shutil.which("ffmpeg") + assert ffmpeg is not None + with tempfile.TemporaryDirectory(prefix="video-agent-concat-") as directory: + root = Path(directory) / "Editor's project" + root.mkdir() + inputs: list[Path] = [] + for index, frequency in enumerate((440, 660)): + source = root / f"tone-{index}.wav" + generated = subprocess.run( + [ + ffmpeg, + "-nostdin", + "-v", + "error", + "-f", + "lavfi", + "-i", + f"sine=frequency={frequency}:duration=0.05", + "-c:a", + "pcm_s16le", + str(source), + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(generated.returncode, 0, generated.stderr) + inputs.append(source) + + concat_file = root / "concat.txt" + output = root / "joined.wav" + write_ffconcat_file(concat_file, inputs) + rendered = subprocess.run( + [ + ffmpeg, + "-nostdin", + "-v", + "error", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-c", + "copy", + str(output), + ], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(rendered.returncode, 0, rendered.stderr) + self.assertTrue(output.is_file()) + self.assertGreater(output.stat().st_size, 0) + + +if __name__ == "__main__": + unittest.main()