Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/video-agent-kit/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "video-agent-kit",
"version": "0.4.3",
"version": "0.4.4",
"description": "自动化视频剪辑工具包: 标准云端语音转录与合成、完整视频抽帧理解、局部复看、时间线、预览渲染、QC 和 TTS。",
"author": {
"name": "Z.ai",
Expand Down
2 changes: 1 addition & 1 deletion plugins/video-agent-kit/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "video-agent-kit",
"version": "0.4.3",
"version": "0.4.4",
"description": "自动化视频剪辑工具包: 标准云端语音转录与合成、完整视频抽帧理解、局部复看、时间线、预览渲染、QC 和 TTS。",
"author": {
"name": "Z.ai",
Expand Down
2 changes: 2 additions & 0 deletions plugins/video-agent-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
6 changes: 3 additions & 3 deletions plugins/video-agent-kit/mcp/ve_tools/basic_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 2 additions & 6 deletions plugins/video-agent-kit/mcp/ve_tools/condense.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)


27 changes: 27 additions & 0 deletions plugins/video-agent-kit/mcp/ve_tools/ffproc.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""
from __future__ import annotations

from collections.abc import Iterable
import re as _re
import shutil
import subprocess
Expand Down Expand Up @@ -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
#
Expand Down
5 changes: 3 additions & 2 deletions plugins/video-agent-kit/mcp/ve_tools/lol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions plugins/video-agent-kit/mcp/ve_tools/narrate_tts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down
11 changes: 2 additions & 9 deletions plugins/video-agent-kit/mcp/ve_tools/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 3 additions & 6 deletions plugins/video-agent-kit/mcp/ve_tools/render_narrated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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])


Expand Down
4 changes: 2 additions & 2 deletions plugins/video-agent-kit/mcp/ve_tools/soccer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
95 changes: 95 additions & 0 deletions plugins/video-agent-kit/tests/test_ffconcat_paths.py
Original file line number Diff line number Diff line change
@@ -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()