Skip to content
Draft
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
4 changes: 2 additions & 2 deletions marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,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 Expand Up @@ -1411,4 +1411,4 @@
]
}
]
}
}
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
1 change: 1 addition & 0 deletions plugins/video-agent-kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- `video_ingest`:完整视频抽帧并返回 contact sheet,必须先跑;Claude Code 主 Agent 自己看图并形成理解。显式传入的 `transcript_path` 必须存在;不传 transcript 才表示允许无转录观察;在同一个 MCP 会话里,如果重新观察此前已绑定过 transcript 的同一视频指纹,会复用该视频对应的 transcript。同一路径的视频文件如果被覆盖或替换,必须重新调用 `video_ingest`。只有成功产出 observation package 后,工具才会把该视频记为已 full-ingest;抽帧失败不会推进会话状态。
- `video_watch_segment`:局部高 fps 复看;单窗口用 `start_time/end_time/fps`,多窗口用 `segments=[{start,end}, ...]` + `fps`,返回片段 contact sheet 和匹配转录。显式传 `video_path` 可直接复看任意存在的视频,不要求先跑完整 `video_ingest`。不带 `video_path` 时默认使用会话里的 active video;如果 active 文件被覆盖或替换,隐式复看会报错并要求显式传当前 `video_path` 或重新 `video_ingest`。显式传入的 `transcript_path` 必须存在;在同一个 MCP 会话里,如果显式传入此前已绑定过 transcript 的同一视频指纹且未传 transcript 参数,会复用该视频对应的 transcript。非数字时间参数会返回结构化错误;明显超过视频时长的窗口会报错,不会静默截断成较短观察。窗口台账按 `(start, end, fps)` 判重:只有时间和 fps 都相同的窗口才会被跳过,换一个 fps 重看同一窗口是新的观察;确有需要时可传 `force=true` 跳过判重。只有成功产出新 contact sheet 后,局部复看才会更新 active-video 会话状态;抽帧失败或 `skipped_duplicate` 不算新的视觉证据,多窗口部分失败时整体结果会带 `[ERROR]` 前缀汇总。
- `video_basic_operation`:OpenChatCut 风格的确定性基础操作封装,支持 `trim`、`splice`、`speed`、`crop`、`scale`、`rotate`、`flip`、`freeze_frame`。它适合源素材预处理或单步变换;timeline 成片仍应走 `validate_timeline` / `render_preview`。
- `subtitle_render`:`mode="soft"` 复制原始视频和音频流,只需要 FFmpeg 的 `mov_text` 字幕编码器,不需要 libass 或媒体重编码器;`burn` 需要 `libx264` 和 `ass` 滤镜,`both` 还需要 `mov_text`。各模式在写出字幕和视频文件前检查所需能力。
- `speech_synthesize`:标准语音合成工具。配置 `VE_SPEECH_MCP_URL` 时调用远端 HTTP MCP;未配置时,在 ZCode 中优先使用宿主注入身份的官方通道,其他宿主回退到本地兼容后端。对外统一为 `cloud_tts`;`allowed_providers` 提供时是硬白名单。并发限制、限流、网络和服务端瞬时错误默认额外重试 3 次,可用 `retries` / `retry_count` 和 `retry_backoff_seconds` 覆盖;参数错误、缺凭据、格式不支持不会重试。`speed` 会映射为 `speech_rate`,也可显式传 `speech_rate` / `pitch_rate` / `loudness_rate` / `sample_rate` / `output_format`,本工具只暴露可被 ffprobe 稳定校验的 `wav` / `mp3` / `ogg_opus` 输出,并拒绝 `output_path` 后缀与 `output_format` 不一致。`tts_generate` 保留为兼容 alias。
- `validate_timeline`:校验 timeline 项目 JSON;依赖 ffprobe 检查源素材时长边界,时长优先读 format duration,缺失时回退到 stream duration,且同一源文件的时长探测会缓存。除了旧版线性 `clips[]`,也支持更接近剪辑软件项目文件的 `project` / `sequence` / `output_canvas` / `assets` / `tracks` / `markers` / `transitions` / clip effects 元数据校验。`tracks[]` timeline 中非视频轨的 clip(字幕 / overlay / callout 计划)可以用非空 `text` 代替媒体 `source`;视频 / main 轨 clip 和顶层 `clips[]` 仍必须有 `source`。
- `render_preview`:用 FFmpeg / ffprobe 渲染预览;render report 会记录 timeline hash 和 output hash。顶层 `clips[]` 仍按 legacy/debug 线性拼接渲染;项目式 `tracks[]` 会按 sequence 画布和 `timeline_start` 编译成基础多轨预览:主视频铺到画布并在片尾补少量末帧降低边界闪帧,源视频音频与 `audio/music/voiceover` 轨先烘焙成全片长度、已按时间线定位的 wav bed,再非归一化混音,`subtitle/text` 轨用 `drawtext` 烧录,`overlay/image` 轨按启用窗口叠加。父轨道没有 `type/name/track_type` 时不会被默认当作主视频轨;未知 track 类型会记录到 render plan 的 unsupported tracks 中,而不是声称完整渲染。
Expand Down
18 changes: 11 additions & 7 deletions plugins/video-agent-kit/mcp/ve_tools/subtitle.py
Original file line number Diff line number Diff line change
Expand Up @@ -976,9 +976,17 @@ def subtitle_render(args: dict, ctx: RunContext) -> ToolResult:
return ToolResult(text="[ERROR] subtitles_path is required (out/subtitles.json from subtitle_build)")
if not shutil.which("ffmpeg"):
return ToolResult(text="[ERROR] ffmpeg not found on PATH")
# Fail fast: subtitle burn-in needs libass (subtitles/ass filter); minimal
# builds drop it and only fail after reading the whole video.
cap_err = require_ffmpeg(encoders=("libx264", "aac"), filters=("subtitles",))
mode = str(args.get("mode") or "burn").strip().lower()
if mode not in {"burn", "soft", "both"}:
return ToolResult(text="[ERROR] mode must be one of: burn, soft, both")

# Soft subtitles copy the source streams; only burn-in needs libass and
# a video encoder. Check the actual filters/codecs used by each command.
encoders = ("libx264",) if mode in {"burn", "both"} else ()
if mode in {"soft", "both"}:
encoders += ("mov_text",)
filters = ("ass",) if mode in {"burn", "both"} else ()
cap_err = require_ffmpeg(encoders=encoders, filters=filters)
if cap_err:
return ToolResult(text=cap_err)
video_path = ctx.resolve(args["video_path"])
Expand All @@ -988,10 +996,6 @@ def subtitle_render(args: dict, ctx: RunContext) -> ToolResult:
if not subtitles_path.is_file():
return ToolResult(text=f"[ERROR] subtitles not found: {subtitles_path}")

mode = str(args.get("mode") or "burn").strip().lower()
if mode not in {"burn", "soft", "both"}:
return ToolResult(text="[ERROR] mode must be one of: burn, soft, both")

try:
package = json.loads(subtitles_path.read_text(encoding="utf-8"))
except Exception as exc:
Expand Down
113 changes: 113 additions & 0 deletions tests/test_video_agent_subtitle_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Subtitle render capabilities and real stream-copy mux regression coverage."""

import json
from contextlib import nullcontext
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "plugins/video-agent-kit/mcp"))
from ve_tools import subtitle # noqa: E402
from ve_tools.run_context import RunContext # noqa: E402


class SubtitleCapabilitiesTest(unittest.TestCase):
def test_capabilities_match_the_selected_render_mode(self):
for mode, encoders, filters in [
(None, ("libx264",), ("ass",)),
("soft", ("mov_text",), ()),
("burn", ("libx264",), ("ass",)),
("both", ("libx264", "mov_text"), ("ass",)),
]:
with self.subTest(mode=mode), patch.object(
subtitle.shutil, "which", return_value="ffmpeg"
), patch.object(subtitle, "require_ffmpeg", return_value="[ERROR] missing capability") as check:
result = subtitle.subtitle_render(
{"video_path": "source.mp4", "subtitles_path": "captions.json", "mode": mode},
None,
)
self.assertEqual(result.text, "[ERROR] missing capability")
check.assert_called_once_with(encoders=encoders, filters=filters)

def test_invalid_mode_is_reported_before_capability_checks(self):
with patch.object(subtitle.shutil, "which", return_value="ffmpeg"), patch.object(
subtitle, "require_ffmpeg", return_value="[ERROR] missing capability"
) as check:
result = subtitle.subtitle_render(
{"video_path": "source.mp4", "subtitles_path": "captions.json", "mode": "invalid"},
None,
)
self.assertIn("mode must be one of", result.text)
check.assert_not_called()


@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "requires ffmpeg and ffprobe")
class SoftSubtitleMuxTest(unittest.TestCase):
def test_soft_subtitles_keep_media_packets_without_burn_in_capabilities(self):
self.assert_soft_mux(minimal=True)

def test_soft_subtitles_with_installed_ffmpeg_capabilities(self):
self.assert_soft_mux(minimal=False)

def assert_soft_mux(self, minimal):
with tempfile.TemporaryDirectory() as tmp, patch.dict(
"os.environ", {"CLAUDE_PROJECT_DIR": tmp}
):
directory = Path(tmp)
source = directory / "source.mp4"
subprocess.run(
["ffmpeg", "-nostdin", "-v", "error", "-f", "lavfi", "-i", "color=size=64x64:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=44100", "-t", "1",
"-c:v", "mpeg4", "-c:a", "aac", str(source)],
check=True, capture_output=True, timeout=30,
)
package = directory / "captions.json"
package.write_text(json.dumps({
"language": "eng",
"cues": [{"start": 0, "end": 0.8, "text": "Hello subtitles"}],
"style": {
"font_file": str(directory / "unused-font.ttf"), "font_family_resolved": "Arial",
"font_size": 12, "video_width": 64, "video_height": 64,
"primary_colour": "&H00FFFFFF", "outline_colour": "&H00000000",
"back_colour": "&H80000000", "border_style": 1, "outline": 1, "shadow": 0,
"alignment": 2, "margin_l": 2, "margin_r": 2, "margin_v": 2,
},
}), encoding="utf-8")
# Simulate a minimal FFmpeg capability listing, not the mux itself:
# the real command must only need mov_text, with no video/audio
# encoder or libass filter. Homebrew's minimal build has no libass.
capabilities = patch("ve_tools.ffproc._ffmpeg_feature_text", side_effect=lambda kind:
" S..... mov_text " if kind == "encoders" else "") if minimal else nullcontext()
with capabilities:
result = subtitle.subtitle_render({
"video_path": str(source), "subtitles_path": str(package), "mode": "soft",
}, RunContext())
self.assertFalse(result.text.startswith("[ERROR]"), result.text)
output = Path(result.data["outputs"]["soft"])
for selector in ("v:0", "a:0"):
self.assertEqual(self.packet_hashes(source, selector), self.packet_hashes(output, selector))
subtitles = subprocess.run(
["ffmpeg", "-nostdin", "-v", "error", "-i", str(output), "-map", "0:s:0", "-f", "srt", "-"],
check=True, capture_output=True, text=True, timeout=30,
).stdout
self.assertIn("00:00:00,000 --> 00:00:00,800", subtitles)
self.assertIn("Hello subtitles", subtitles)

def packet_hashes(self, path, selector):
probe = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", selector, "-show_packets",
"-show_data_hash", "sha256", "-show_entries", "packet=data_hash", "-of", "json", str(path)],
check=True, capture_output=True, text=True, timeout=30,
)
packets = json.loads(probe.stdout)["packets"]
self.assertTrue(packets)
return [packet["data_hash"] for packet in packets]


if __name__ == "__main__":
unittest.main()