From 7dc34b34cf87dfd82562093971050ce469fd7512 Mon Sep 17 00:00:00 2001 From: codes-factory-of-bg Date: Thu, 23 Jul 2026 23:47:13 +0800 Subject: [PATCH 01/66] new video product skill --- scripts/lib/agent-skills.sh | 15 +++++++++++---- scripts/setup-crew.sh | 6 +++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/lib/agent-skills.sh b/scripts/lib/agent-skills.sh index 5d283cd6..a4a273ff 100644 --- a/scripts/lib/agent-skills.sh +++ b/scripts/lib/agent-skills.sh @@ -459,15 +459,22 @@ inject_file_edit_guide() { GUIDE } -inject_feishu_media_guide() { +inject_media_send_guide() { local user_md="$1" [ -f "$user_md" ] || return 0 - grep -qF "## 发送图片/文件/视频等富媒体(自动注入)" "$user_md" && return 0 + grep -qF "## 发送图片/文件/视频等富媒体注意事项" "$user_md" && return 0 cat >> "$user_md" << 'GUIDE' -## 发送图片/文件/视频等富媒体(自动注入) +## 发送图片/文件/视频等富媒体注意事项 -向用户发送图片、文件、视频或其他富媒体内容时,不要在本地打开媒体文件,也不得直接输出文件路径或 base64 内容作为回复。**必须将文件本体通过媒体发送插件直接发送到聊天中,且需要提供绝对路径**。 +目标:让用户在聊天框里直接看到图片/文件/视频本体,而不是看到一段路径或被要求去本地查看。 + +常犯的错误(必须规避): +- 把媒体文件的路径(如 `/tmp/xxx.png`)当作文本消息发给用户——用户在聊天里只看到一串路径,看不到图。 +- 在本地打开媒体文件(如调用图片查看器、浏览器打开 file://)——用户不一定方便操作这台电脑,本地打开对用户毫无意义。 +- 把 base64 或文件原始字节当作文本贴进回复——刷屏且用户无法使用。 + +正确做法:用当前渠道的媒体发送能力把文件本体直接投递到聊天中(需提供文件绝对路径)。具体调用哪个工具/action 以本机当前可用渠道为准,不要假定渠道名或写死某个 action。 GUIDE } diff --git a/scripts/setup-crew.sh b/scripts/setup-crew.sh index a852d8d7..6cdd992c 100755 --- a/scripts/setup-crew.sh +++ b/scripts/setup-crew.sh @@ -287,7 +287,7 @@ for agent_dir in "$CREWS_DIR"/*/; do inject_file_edit_guide "$dest/TOOLS.md" inject_exec_guide "$dest/TOOLS.md" "$dest" inject_agents_md_sections "$dest/AGENTS.md" - inject_feishu_media_guide "$dest/USER.md" + inject_media_send_guide "$dest/USER.md" continue fi @@ -299,7 +299,7 @@ for agent_dir in "$CREWS_DIR"/*/; do inject_file_edit_guide "$dest/TOOLS.md" inject_exec_guide "$dest/TOOLS.md" "$dest" inject_agents_md_sections "$dest/AGENTS.md" - inject_feishu_media_guide "$dest/USER.md" + inject_media_send_guide "$dest/USER.md" done # 注:原 §2/§3(shared 协议 / crew_templates / hrbp_templates 模板库同步)已移除。 @@ -627,7 +627,7 @@ if [ -f "$CONFIG_PATH" ]; then [ -n "$a_id" ] || continue [ -f "$a_ws/AGENTS.md" ] || continue inject_agents_md_sections "$a_ws/AGENTS.md" - inject_feishu_media_guide "$a_ws/USER.md" + inject_media_send_guide "$a_ws/USER.md" inject_file_edit_guide "$a_ws/TOOLS.md" inject_exec_guide "$a_ws/TOOLS.md" "$a_ws" done < <(list_agent_workspaces) From 35afa5c033fdfa73d89a55b4f5233c65d1efa88c Mon Sep 17 00:00:00 2001 From: codes-factory-of-bg Date: Fri, 24 Jul 2026 14:39:31 +0800 Subject: [PATCH 02/66] video-product + content-producer: add review/state/interp scripts, collage-broll skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit video-product: - new scripts/review.py: post-compose self-review (ffprobe + 5-position frame extraction + audio level + duration/resolution uniformity, exit code as verdict) - new scripts/state.py: pipeline state machine for subagent resume-from-failure (8 stages, append-only state.history/) - gen.py: append decisions.log on every fallback (borrowed from OpenMontage decision_log) - SKILL.md slimmed 656->200 lines, split into 8 stages/ submodule docs - Step 2.2.3 slideshow-risk checklist (repetition/weak-motion/typography-overreliance) - Step 2.3.5 Gate 0 contact sheet confirmation (siliconflow-img-gen keyframes + ffmpeg tile) - Step 2.5 budget estimation gate - assemble.py: add --transition crossfade (ffmpeg xfade chain) content-producer: - new scripts/normalize.py: loudnorm to -14 LUFS (mandatory, Step 5.5) - new scripts/burn-srt.py: burn SRT subtitles via ffmpeg subtitles filter (optional) - new scripts/duck.py: BGM ducking via sidechaincompress (optional, separable tracks only) - new scripts/denoise.py: audio noise reduction via afftdn/arnndn (optional, poor footage only) - new scripts/interp.py: frame interpolation via minterpolate (optional, low-fps source only) - new scripts/README.md: script index - AGENTS.md: script checklist section + Step 5.5/5.6/5.7/5.8/3.5接入 points - new skills/collage-broll/: adapted from gbro-collage-broll, three-gate approval + assemble-from-empty, switched deps from Gemini Omni/Codex image_gen to gen.py i2v + siliconflow-img-gen Co-Authored-By: AtomCode (GLM-5.2) --- crews/content-producer/scripts/README.md | 69 +++ crews/content-producer/scripts/burn-srt.py | 170 ++++++ crews/content-producer/scripts/denoise.py | 189 +++++++ crews/content-producer/scripts/duck.py | 233 +++++++++ crews/content-producer/scripts/interp.py | 179 +++++++ crews/content-producer/scripts/normalize.py | 183 +++++++ .../skills/collage-broll/SKILL.md | 427 +++++++++++++++ .../collage-broll/scripts/check_setup.sh | 57 ++ .../skills/collage-broll/scripts/run_gate3.py | 114 ++++ .../skills/video-product/scripts/review.py | 485 ++++++++++++++++++ .../skills/video-product/scripts/state.py | 184 +++++++ .../video-product/stages/input-sources.md | 31 ++ .../video-product/stages/model-selection.md | 56 ++ .../stages/prohibitions-notes.md | 20 + .../video-product/stages/step2-script.md | 205 ++++++++ .../video-product/stages/step3-user-assets.md | 47 ++ .../video-product/stages/step4-assets.md | 179 +++++++ .../video-product/stages/step5-compose.md | 89 ++++ 18 files changed, 2917 insertions(+) create mode 100644 crews/content-producer/scripts/README.md create mode 100644 crews/content-producer/scripts/burn-srt.py create mode 100644 crews/content-producer/scripts/denoise.py create mode 100644 crews/content-producer/scripts/duck.py create mode 100644 crews/content-producer/scripts/interp.py create mode 100644 crews/content-producer/scripts/normalize.py create mode 100644 crews/content-producer/skills/collage-broll/SKILL.md create mode 100644 crews/content-producer/skills/collage-broll/scripts/check_setup.sh create mode 100644 crews/content-producer/skills/collage-broll/scripts/run_gate3.py create mode 100644 crews/main/skills/video-product/scripts/review.py create mode 100644 crews/main/skills/video-product/scripts/state.py create mode 100644 crews/main/skills/video-product/stages/input-sources.md create mode 100644 crews/main/skills/video-product/stages/model-selection.md create mode 100644 crews/main/skills/video-product/stages/prohibitions-notes.md create mode 100644 crews/main/skills/video-product/stages/step2-script.md create mode 100644 crews/main/skills/video-product/stages/step3-user-assets.md create mode 100644 crews/main/skills/video-product/stages/step4-assets.md create mode 100644 crews/main/skills/video-product/stages/step5-compose.md diff --git a/crews/content-producer/scripts/README.md b/crews/content-producer/scripts/README.md new file mode 100644 index 00000000..3f1a82d5 --- /dev/null +++ b/crews/content-producer/scripts/README.md @@ -0,0 +1,69 @@ +# Content Producer 脚本索引 + +五个后期脚本借 OpenMontage 的后期工作法补我们没做的后期环节。**两个必跑、三个可选**——必跑的是发布质量硬伤,可选的是用户要才跑。 + +完整接入契约(落点 / 旁路条件 / 干湿分离 / 借鉴来源对照)在 `../AGENTS.md` 的 `## 脚本清单` 段;本文件只做脚本速查索引,**不重复契约**。 + +## 索引 + +| 脚本 | 用途 | 必跑/可选 | 落点 | +|------|------|---------|------| +| `normalize.py` | ffmpeg loudnorm 双 pass 把成片归一化到 -14 LUFS(抖音/视频号/B 竍竖屏发布通用标准) | **必跑** | AGENTS.md Step 5.5,exportMp4 出片后、汇报前强制跑 | +| `burn-srt.py` | ffmpeg `subtitles` 滤镜(libass)把 SRT 硬烧进画面,不可关 | 可选 | Step 5.6,仅用户明确要字幕时跑 | +| `duck.py` | ffmpeg `sidechaincompress` 旁白作 sidechain 触发 BGM 自动压低(threshold=-25dB / ratio=8:1) | 可选 | Step 5.7,仅用户要专业混音且可分轨时跑 | +| `denoise.py` | ffmpeg `afftdn`(默认)或 `arnndn`(RNN,要模型文件)给音频去环境噪声 | 可选 | Step 3.5,仅用户素材音质差时跑(AI 生成视频音轨本来就干净,跳过) | +| `interp.py` | ffmpeg `minterpolate` 补帧到 30/60fps | 可选 | Step 5.8,仅低 fps 源材(如 24fps AI 生成片)补到 30fps 顺滑 | + +## 调用模板 + +每个脚本都支持 `--help` 查完整入参。常用模板: + +```bash +# 响度归一化(必跑) +python3 ./scripts/normalize.py --output +# 默认 target -14 LUFS / true peak -1.5 dB / LRA 11 + +# 字幕硬烧(可选) +python3 ./scripts/burn-srt.py --output +# 默认中文字幕样式 Noto Sans CJK SC 24px,可 --font-name / --font-size / --force-style 覆盖 + +# BGM ducking(可选,需可分轨) +# 模式 1:视频自带 BGM + 外挂旁白 +python3 ./scripts/duck.py --output +# 模式 2:外挂 BGM + 外挂旁白 +python3 ./scripts/duck.py --bgm-source --output + +# 音频降噪(可选,仅素材音质差时) +python3 ./scripts/denoise.py --output +# 默认 afftdn(无外部模型依赖);要更强降噪走 arnndn:--method arnndn --rnn-model + +# 补帧(可选,仅低 fps 源材) +python3 ./scripts/interp.py --target-fps 30 --output +# 默认 minterpolate mode=blend;要更顺走 mode=mci(motion compensated,但慢且可能出鬼影) +``` + +## 干湿分离约定 + +五个都守:输出落 `_<处理名>.mp4`(如 `output_normalized.mp4`、`output_burned.mp4`、`output_ducked.mp4`、`_denoised.mp4`、`_interp.mp4`),**不覆盖输入**。多步串联时下一步以上一步产物为输入(如 ducking 后再 normalize),原产物保留作回退。 + +## 旁路条件速查 + +- `normalize.py`:无声轨 / 音频畸变 → exit 2 报错退回 exportMp4 重生;input_i 已在 ±0.3 LUFS of target → 自动跳过渲染直接拷贝 +- `burn-srt.py`:ffmpeg 不带 libass → exit 1 报错改发外挂 SRT;SRT 不存在 / 格式错 → exit 1 +- `duck.py`:AI 声画同出模式混轨没法分 → 报告用户等决策;视频无声轨且没 `--bgm-source` → exit 1 +- `denoise.py`:AI 生成视频音轨干净 → 跳过;ffmpeg 不带 afftdn/arnndn → exit 1;arnndn 没传 `--rnn-model` → exit 1 +- `interp.py`:源 fps ≥ target fps → 自动跳过拷贝;ffmpeg 不带 minterpolate → exit 1;mci 模式出鬼影 → 退 blend 模式 + +## 借鉴来源(OpenMontage 吸收审计用) + +- `normalize.py` ← OpenMontage Ink Theater loudness gate(必跑步骤,我们同款必跑) +- `burn-srt.py` ← OpenMontage Remotion-composer caption 烧录(Remition 内置,我们用 ffmpeg 平替) +- `duck.py` ← OpenMontage Remotion-composer mixing(Remition 内置混音,我们用 ffmpeg sidechaincompress 平替) +- `denoise.py` ← OpenMontage Ink Theater noise gate(我们只在用户素材用,AI 生成不用) +- `interp.py` ← OpenMontage Backlot 看板 frame interpolation(Remition 内置补帧,我们用 ffmpeg minterpolate 平替) + +## 没吸收的 OpenMontage 能力 + +(上一轮已拍板,备忘——不重做) +- selector / scoring.py 7 维评分 → 已有 content-calibrator,不重 +- Backlot 看板 / Remotion-composer / HyperFrames / Ink Theater 实时调色 / quality scoring → runtime 哲学对立或重复,剥掉 diff --git a/crews/content-producer/scripts/burn-srt.py b/crews/content-producer/scripts/burn-srt.py new file mode 100644 index 00000000..f3fc0913 --- /dev/null +++ b/crews/content-producer/scripts/burn-srt.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Burn SRT subtitles into MP4 — optional, user-requested only. + +把 SRT 字幕硬烧进视频画面(不可关、跟着成片走)。跟软字幕(.srt 外挂、 +平台播放器可开关)不同——硬烧适合"平台不支持外挂字幕"或"想保证画面字 +一定显示"的场景。 + +⚠️ 可选步骤,不是必跑。Content Producer 的 AGENTS.md 工作流默认不烧字幕 +(assemble.py / exportMp4 都不烧);**仅当用户明确说"要字幕"/"烧字幕"/ +"hardcode subtitles"时才跑**。 + +落点:合成产物(output.mp4 或 output_normalized.mp4)之后、交付前。 +干湿分离:输出 `_burned.mp4`,不覆盖输入。 + +ffmpeg subtitles 滤镜要点: +- 用 `subtitles=filename='...'` 滤镜,需 libass 编译进去(Ubuntu 默认 ffmpeg 带) +- 字体:默认 libass 拿 fontconfig 找,中文字幕要 `force_style='FontName=...'` 强制 +- 字幕样式由 SRT 内 cue style 或 force_style 覆盖,本脚本默认给一套可读样式 + +Usage: + python3 ./scripts/burn-srt.py + python3 ./scripts/burn-srt.py --output + python3 ./scripts/burn-srt.py --font-name "Noto Sans CJK SC" --font-size 24 + +Exit codes: + 0 ok,字幕烧完 + 1 参数错 / ffmpeg 缺失 / 输入不存在 / ffmpeg 不带 libass + 2 ffmpeg 渲染失败(SRT 损坏 / 字体缺 / 渲染中断) +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +from pathlib import Path + +# 默认字幕样式——黑白配 + 半透底框,短视频通用可读样式 +DEFAULT_FONT_NAME = "Noto Sans CJK SC" # 中文兜底;libass 找不到时回退 fontconfig 默认 +DEFAULT_FONT_SIZE = 24 +DEFAULT_FORCE_STYLE = ( + "FontName={font},FontSize={size}," + "PrimaryColour=&H00FFFFFF&,OutlineColour=&H00000000&," + "BackColour=&H80000000&,BorderStyle=4," + "Outline=2,Shadow=1,Alignment=2,MarginV=40" +) + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def check_libass() -> None: + """ffmpeg subtitles 滤镜依赖 libass。启动时探一次,没带则报错不白跑.""" + rc, out, _ = run(["ffmpeg", "-hide_banner", "-filters"], timeout=30) + if rc != 0: + die("ffmpeg -filters 探测失败,ffmpeg 异常") + if "subtitles" not in out: + die("ffmpeg 不带 libass(subtitles 滤镜缺席),无法烧字幕;换 ffmpeg-full 完整版") + + +def validate_srt(srt_path: str) -> None: + """基本 SRT 校验:非空 + 至少一条 cue + timestamp 格式.""" + try: + content = Path(srt_path).read_text(encoding="utf-8").strip() + except OSError as e: + die(f"读 SRT 失败: {e}") + if not content: + die("SRT 文件空") + # 至少含一个 "-->" 时间戳分隔符(SRT 格式的硬标志) + if "-->" not in content: + die("SRT 不含任何 `-->` 时间戳分隔符,格式错") + + +def burn(video: str, srt: str, output: str, font_name: str, + font_size: int, force_style: str | None) -> dict: + """ffmpeg subtitles 滤镜烧 SRT. 返回渲染元数据.""" + style = ( + force_style if force_style is not None + else DEFAULT_FORCE_STYLE.format(font=font_name, size=font_size) + ) + # ffmpeg subtitles 滤镜里 filename 要单引号包裹,且整个 -vf 字串里单引号要转义 + # 用 shlex.quote 处理路径,再包单引号 + srt_escaped = shlex.quote(srt) + vf = f"subtitles=filename={srt_escaped}:force_style='{style}'" + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-vf", vf, + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "copy", # 音轨原样不动 + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=900) + if rc != 0: + die(f"ffmpeg subtitles 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "srt": srt, + "output": output, + "font_name": font_name, + "font_size": font_size, + "force_style": style, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Burn SRT subtitles into MP4 (optional, user-requested only)." + ) + parser.add_argument("video", help="输入视频(合成产物,含声轨)") + parser.add_argument("srt", help="SRT 字幕文件路径") + parser.add_argument("--output", default=None, + help="输出路径,默认在输入旁加 _burned 后缀") + parser.add_argument("--font-name", default=DEFAULT_FONT_NAME, + help=f"字体名,默认 {DEFAULT_FONT_NAME}") + parser.add_argument("--font-size", type=int, default=DEFAULT_FONT_SIZE, + help=f"字体大小,默认 {DEFAULT_FONT_SIZE}") + parser.add_argument("--force-style", default=None, + help="Override libass force_style 字串(覆盖默认样式)") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + srt_path = Path(args.srt).resolve() + if not srt_path.is_file(): + die(f"SRT 文件不存在: {srt_path}") + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_burned.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + check_libass() + validate_srt(str(srt_path)) + + print(f"[info] input: {video_path}") + print(f"[info] srt: {srt_path}") + print(f"[info] output: {out_path}") + print(f"[info] font: {args.font_name} @ {args.font_size}px") + + result = burn(str(video_path), str(srt_path), str(out_path), + args.font_name, args.font_size, args.force_style) + + print(f"\n[done] burned: {out_path}", file=sys.stderr) + print(f"[info] style used: {result['force_style'][:80]}...", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/denoise.py b/crews/content-producer/scripts/denoise.py new file mode 100644 index 00000000..43e0692f --- /dev/null +++ b/crews/content-producer/scripts/denoise.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""Audio noise reduction — only for poor-quality user footage. + +用 ffmpeg `afftdn`(频域降噪)或 `arnndn`(RNN 降噪)给音频去环境噪声。 +只在用户素材音质差(环境噪声大、空调嗡、键盘吱)时用——AI 生成视频的音轨 +是干净的,不需要降噪。 + +⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不做降噪处理。 +**仅当用户素材音质明显差**(用户抱怨"听不清"/"有杂音"/"噪音大", +或 review.py 报噪声指标异常)时才跑。 + +arnndn vs afftdn 怎么选: +- `arnndn`(Acoustic RNN Noise Suppress Network):质量高,对人声保真, + 但要 RNN 模型文件(.rnn)。适合素材主要传人声的情况 +- `afftdn`(Audio FFT-based Noise Suppressor):纯频域降噪,无外部模型依赖, + 适合环境噪声 dominant、人声次要的情况。本脚本默认走 afftdn 避依赖 + +落点:在素材处理阶段(Step 3 用户素材预处理)跑——素材降噪后再进 assemble.py。 +不是合成产物后跑(合成后再降噪会伤及片段间衔接处的环境音一致性)。 + +干湿分离:输出 `_denoised.mp4`,不覆盖输入。 + +Usage: + python3 ./scripts/denoise.py + python3 ./scripts/denoise.py --output + python3 ./scripts/denoise.py --method arnndn --rnn-model /path/to/model.rnn + python3 ./scripts/denoise.py --noise-floor -40 --nr 12 + +Exit codes: + 0 ok,降噪完成 + 1 参数错 / ffmpeg 缺失 / 输入不存在 / arnndn 要的 RNN 模型没给 + 2 ffmpeg 渲染失败(音频损坏 / arnndn 模型加载失败) +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# 默认走 afftdn 避外部模型依赖 +DEFAULT_METHOD = "afftdn" +# afftdn 默认参数——保守不伤人声:noise floor -40dB,noise reduction 12dB(默认) +DEFAULT_NOISE_FLOOR_DB = -40.0 +DEFAULT_NR_DB = 12.0 # noise_reduction 强度,0.01-97,默认 12 已够用 +DEFAULT_NOISE_TYPE = "white" # white/vinyl/shellac/custom,多数环境噪走 white + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def check_method(method: str, rnn_model: str | None) -> str: + """确认 ffmpeg 带目标滤镜 + arnndn 需要的 RNN 模型.""" + rc, out, _ = run(["ffmpeg", "-hide_banner", "-filters"], timeout=30) + if rc != 0: + die("ffmpeg -filters 探测失败,ffmpeg 异常") + + if method == "afftdn": + if "afftdn" not in out: + die("ffmpeg 不带 afftdn 滤镜,换 ffmpeg-full 或改用 arnndn") + return "afftdn" + elif method == "arnndn": + if "arnndn" not in out: + die("ffmpeg 不带 arnndn 滤镜,换 ffmpeg-full 或改用 afftdn") + if not rnn_model: + die("arnndn 要 RNN 模型文件路径,传 --rnn-model ") + if not Path(rnn_model).is_file(): + die(f"RNN 模型文件不存在: {rnn_model}") + return "arnndn" + else: + die(f"unknown method: {method}; valid: afftdn, arnndn") + + +def probe_audio(video: str) -> dict | None: + """ffprobe 数音频轨,没声轨降噪没意义.""" + import json + rc, out, _ = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "a", video, + ], timeout=30) + if rc != 0: + return None + try: + return json.loads(out) + except json.JSONDecodeError: + return None + + +def denoise(video: str, output: str, method: str, + noise_floor: float, nr: float, noise_type: str, + rnn_model: str | None) -> dict: + """ffmpeg afftdn / arnndn 降噪. 返回渲染元数据.""" + if method == "afftdn": + # afftdn 真参数(ffmpeg 6+):nf=noise floor(dB),nr=noise reduction 强度, + # nt=noise type(white/vinyl/shellac/custom)。conservative 不伤人声 + af = f"afftdn=nf={noise_floor}:nr={nr}:nt={noise_type}" + else: # arnndn + af = f"arnndn=m='{rnn_model}'" + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-af", af, + "-c:v", "copy", # 视频轨原样不动 + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=900) + if rc != 0: + die(f"ffmpeg {method} 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "output": output, + "method": method, + "noise_floor_db": noise_floor, + "nr_db": nr, + "noise_type": noise_type, + "rnn_model": rnn_model, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Audio noise reduction via ffmpeg afftdn/arnndn (optional, poor footage only)." + ) + parser.add_argument("video", help="输入视频路径(含要降噪的声轨)") + parser.add_argument("--output", default=None, + help="输出路径,默认输入旁加 _denoised 后缀") + parser.add_argument("--method", default=DEFAULT_METHOD, choices=["afftdn", "arnndn"], + help=f"降噪方法,默认 {DEFAULT_METHOD}(afftdn 频域,arnndn 需 RNN 模型)") + parser.add_argument("--noise-floor", type=float, default=DEFAULT_NOISE_FLOOR_DB, + dest="noise_floor", + help=f"afftdn noise floor dB,默认 {DEFAULT_NOISE_FLOOR_DB}") + parser.add_argument("--nr", type=float, default=DEFAULT_NR_DB, + help=f"afftdn noise reduction 强度(0.01-97),默认 {DEFAULT_NR_DB}") + parser.add_argument("--noise-type", default=DEFAULT_NOISE_TYPE, + dest="noise_type", choices=["white", "vinyl", "shellac", "custom"], + help=f"afftdn noise type,默认 {DEFAULT_NOISE_TYPE}") + parser.add_argument("--rnn-model", default=None, dest="rnn_model", + help="arnndn RNN 模型文件路径(method=arnndn 时必传)") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + audio_info = probe_audio(str(video_path)) + if not audio_info or not audio_info.get("streams"): + die("视频无声轨,降噪没意义") + + check_method(args.method, args.rnn_model) + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_denoised.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] method: {args.method}") + print(f"[info] output: {out_path}") + if args.method == "afftdn": + print(f"[info] params: noise_floor={args.noise_floor}dB nr={args.nr}dB noise_type={args.noise_type}") + + result = denoise(str(video_path), str(out_path), args.method, + args.noise_floor, args.nr, args.noise_type, args.rnn_model) + + print(f"\n[done] denoised: {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/duck.py b/crews/content-producer/scripts/duck.py new file mode 100644 index 00000000..36c39501 --- /dev/null +++ b/crews/content-producer/scripts/duck.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""BGM ducking — narration/dialog drives BGM auto-ducking via sidechain. + +把 BGM 轨在旁白/对话出现时自动压低,旁白停了再放开——专业混音的标配。 +只在声画同出模式(gen.py 出的片旁白+BGM 同轨)且用户要专业混音时用。 + +⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不做混音处理—— +assemble.py / normalize.py 都只碰整体响度,不动轨间电平。 +**仅当用户明确说"要混音"/"做 ducking"/"BGM 压旁白"/"professional mix"时才跑**。 + +前置:要有可分离的 BGM 轨和旁白轨。AI 声画同出模式 gen.py 出的片是 +**混轨单声道**——duck.py 没法从混轨里分离 BGM 和旁白。所以本脚本实际 +只在以下场景能用: +1. assemble.py 走 Stock Footage + TTS 模式:素材视频(含 BGM/环境音)+ + 外挂 speech.mp3 旁白——BGM 在视频轨、旁白在音频轨,可分离 +2. 用户人工提供了 BGM.mp3 和 narration.mp3 两份独立文件 +3. tts.py 生成的旁白是独立文件,BGM 也是独立文件 + +输入要两份音频 + 一份视频(或只视频,duck.py 只处音频轨再合回去)。 +落点:assemble.py 之后、normalize.py 之前——ducking 改的是轨间电平, +normalize 改的是整体响度,先 duck 再 normalize 顺序不能反。 + +ffmpeg sidechaincompress 滤镜要点: +- 把旁白轨作 sidechain input 触发 BGM 轨压缩 +- 阈值约 -25 dB(旁白起来才触),比例 8:1(压狠),起 5ms 放 300ms(自然) +- attack/release 不能太短,短了BGM抖;不能太长,长了旁白起了 BGM 没压下去 + +Usage: + python3 ./scripts/duck.py --bgm-track audio:0 + python3 ./scripts/duck.py --bgm-source bgm.mp3 --output mixed.mp4 + python3 ./scripts/duck.py --threshold -25 --ratio 8 + +Exit codes: + 0 ok,ducking 完成 + 1 参数错 / ffmpeg 缺失 / 输入不存在 + 2 ffmpeg 渲染失败(音频轨配置错 / 渲染中断) +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +# Sidechain 压缩参数——专业混音通用起点,用户要调可传 override +# ⚠️ ffmpeg sidechaincompress 的 threshold / makeup 参数要归一化振幅([0,1] 范), +# 不是 dB。我们把 dB 入参转线性振幅再塞给 ffmpeg:linear = 10^(dB/20) +DEFAULT_THRESHOLD_DB = -25.0 # 旁白起来到这 dB 才触 BGM 压 +DEFAULT_RATIO = 8.0 # 8:1 压狠 +DEFAULT_ATTACK_MS = 5 # 起得快但不抖 +DEFAULT_RELEASE_MS = 300 # 放得慢,旁白停了 BGM 缓升 +DEFAULT_MAKEUP_DB = 3.0 # BGM 被压后补点 makeup 避免整体偏轻 + + +def db_to_linear(db: float) -> float: + """dB → 线性振幅(ffmpeg sidechaincompress threshold/makeup 要这套).""" + return 10 ** (db / 20.0) + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def probe_audio_streams(video: str) -> int: + """ffprobe 数音频轨数,caller 用它判断 BGM 轨存在.""" + rc, out, _ = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "a", video, + ], timeout=30) + if rc != 0: + return 0 + import json + try: + data = json.loads(out) + return len(data.get("streams", [])) + except json.JSONDecodeError: + return 0 + + +def duck(video: str, narration: str, output: str, + bgm_source: str | None, bgm_track: str, + threshold: float, ratio: float, + attack_ms: int, release_ms: int, makeup_db: float) -> dict: + """ffmpeg sidechaincompress ducking. 返回渲染元数据.""" + # BGM 来源:外挂 bgm.mp3 或视频自带音轨 + # threshold / makeup 要从 dB 转线性振幅塞给 ffmpeg sidechaincompress + threshold_lin = db_to_linear(threshold) + makeup_lin = db_to_linear(makeup_db) + # ⚠️ 滤镜图要点(ffmpeg 严要求,错一个出空片或报错): + # 1. sidechaincompress 吃两输入(main + sidechain)输出一份——BGM 给 main、旁白给 sidechain + # 2. BGM 不用 split(整个给 sidechaincompress 的 main 输入,输出一份被压过的 BGM) + # 3. 旁白要 split=2:一份作 sidechain 触发源(被 sidechaincompress 内部消费),一份最终混入 + # 4. 每个 split/asplit 输出都要被下游滤镜消费,否则报 unconnected output + if bgm_source: + # 外挂 BGM 模式:ffmpeg -i video -i narration -i bgm + # [1:a] 旁白 split:side 那份触发 BGM 压(被 sidechaincompress 消费),nar 那份最终混入 + # [2:a] BGM 整个给 sidechaincompress 的 main 输入 → 被 sidechain 压 → [mixed] + # [nar] + [mixed] amix 出最终音轨 + inputs = ["-i", video, "-i", narration, "-i", bgm_source] + af = ( + f"[1:a]asplit=2[side][nar];" + f"[2:a][side]sidechaincompress=" + f"threshold={threshold_lin}:ratio={ratio}:" + f"attack={attack_ms}:release={release_ms}:" + f"makeup={makeup_lin}[mixed];" + f"[nar][mixed]amix=inputs=2:duration=longest:dropout_transition=0[a]" + ) + mapping = ["-map", "0:v", "-map", "[a]"] + else: + # 视频自带 BGM 模式:BGM 在 [0:a],旁白作外挂 [1:a] + # [0:a] BGM 整个给 sidechaincompress 的 main 输入(不 split,省一个闲输出) + # [1:a] 旁白 split:nar_side 那份作 sidechain 触发源(被 sidechaincompress 内部消费),nar_main 那份最终混入 + # [0:a] + [nar_side] → sidechaincompress → [bgm_ducked] + # [nar_main] + [bgm_ducked] amix 出最终音轨 + inputs = ["-i", video, "-i", narration] + af = ( + f"[1:a]asplit=2[nar_side][nar_main];" + f"[0:a][nar_side]sidechaincompress=" + f"threshold={threshold_lin}:ratio={ratio}:" + f"attack={attack_ms}:release={release_ms}:" + f"makeup={makeup_lin}[bgm_ducked];" + f"[nar_main][bgm_ducked]amix=inputs=2:duration=longest:dropout_transition=0[a]" + ) + mapping = ["-map", "0:v", "-map", "[a]"] + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + *inputs, + "-filter_complex", af, + *mapping, + "-c:v", "copy", # 视频轨原样不动 + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=900) + if rc != 0: + die(f"ffmpeg sidechaincompress 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "narration": narration, + "bgm_source": bgm_source or "video_internal", + "output": output, + "threshold_db": threshold, + "ratio": ratio, + "attack_ms": attack_ms, + "release_ms": release_ms, + "makeup_db": makeup_db, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="BGM ducking via sidechaincompress (optional, professional mix only)." + ) + parser.add_argument("video", help="输入视频(合成产物)") + parser.add_argument("narration", help="旁白轨独立文件(mp3/wav/opus 等)") + parser.add_argument("--bgm-source", default=None, + help="外挂 BGM 文件路径;不传则用视频自带音轨作 BGM") + parser.add_argument("--bgm-track", default="audio:0", + help="视频自带 BGM 轨,默认 audio:0(第一个音轨)") + parser.add_argument("--output", default=None, + help="输出路径,默认输入旁加 _ducked 后缀") + parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD_DB, + help=f"触发阈 dB,默认 {DEFAULT_THRESHOLD_DB}") + parser.add_argument("--ratio", type=float, default=DEFAULT_RATIO, + help=f"压缩比,默认 {DEFAULT_RATIO}") + parser.add_argument("--attack", type=int, default=DEFAULT_ATTACK_MS, + dest="attack_ms", help=f"起 ms,默认 {DEFAULT_ATTACK_MS}") + parser.add_argument("--release", type=int, default=DEFAULT_RELEASE_MS, + dest="release_ms", help=f"放 ms,默认 {DEFAULT_RELEASE_MS}") + parser.add_argument("--makeup", type=float, default=DEFAULT_MAKEUP_DB, + help=f"BGM 压后补 dB,默认 {DEFAULT_MAKEUP_DB}") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + narration_path = Path(args.narration).resolve() + if not narration_path.is_file(): + die(f"旁白文件不存在: {narration_path}") + + if args.bgm_source: + bgm_path = Path(args.bgm_source).resolve() + if not bgm_path.is_file(): + die(f"BGM 文件不存在: {bgm_path}") + bgm_src = str(bgm_path) + else: + # 没外挂 BGM → 要确认视频有音轨可作 BGM + n_audio = probe_audio_streams(str(video_path)) + if n_audio == 0: + die("视频无声轨,无法作 BGM 来源——传 --bgm-source 指定外挂 BGM 文件") + bgm_src = None + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_ducked.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] narration: {narration_path}") + print(f"[info] bgm: {bgm_src or args.bgm_track}") + print(f"[info] output: {out_path}") + print(f"[info] params: threshold={args.threshold}dB ratio={args.ratio} " + f"attack={args.attack_ms}ms release={args.release_ms}ms makeup={args.makeup}dB") + + result = duck(str(video_path), str(narration_path), str(out_path), + bgm_src, args.bgm_track, + args.threshold, args.ratio, + args.attack_ms, args.release_ms, args.makeup) + + print(f"\n[done] ducked: {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/interp.py b/crews/content-producer/scripts/interp.py new file mode 100644 index 00000000..d3dc1f0f --- /dev/null +++ b/crews/content-producer/scripts/interp.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Frame interpolation — 补帧到 30/60fps,仅低 fps 源材用. + +用 ffmpeg `minterpolate` 滤镜补帧。只在低 fps 源材(如 24fps AI 生成片、 +15fps 用户素材)补到 30fps 顺滑——发布平台播放器默认 30fps 起,低于这 +画面会卡。 + +⚠️ 可选步骤,不是必跑。Content Producer 默认工作流不动 fps。 +**仅当源 fps < target fps** 且用户要"补帧"/"顺滑"/"提升帧率"时才跑。 + +minterpolate mode 怎么选: +- `blend`(默认):纯加权混合,快、无鬼影,但运动糊——保守首选 +- `mci`(motion compensated interpolation):运动补偿,更顺但慢且 + 高运动场景易出鬼影(ffmpeg mci 算法不如商业方案稳) + +落点:合成产物(output_normalized.mp4 或上一步产物)之后、交付前。 +干湿分离:输出 `_interp.mp4`,不覆盖输入。 + +Usage: + python3 ./scripts/interp.py + python3 ./scripts/interp.py --target-fps 30 --output + python3 ./scripts/interp.py --target-fps 60 --mode mci + +Exit codes: + 0 ok,补帧完成(含源 fps ≥ target fps 自动跳过拷贝的 exit 0) + 1 参数错 / ffmpeg 缺失 / 输入不存在 / ffmpeg 不带 minterpolate + 2 ffmpeg 渲染失败(mci 出鬼影也归这档——退 blend 模式重试) +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +DEFAULT_TARGET_FPS = 30 # 发布平台播放器默认起点 +DEFAULT_MODE = "blend" # 保守首选,无鬼影 + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def check_minterpolate() -> None: + """ffmpeg -filters 确认带 minterpolate 滤镜.""" + rc, out, _ = run(["ffmpeg", "-hide_banner", "-filters"], timeout=30) + if rc != 0: + die("ffmpeg -filters 探测失败,ffmpeg 异常") + if "minterpolate" not in out: + die("ffmpeg 不带 minterpolate 滤镜,换 ffmpeg-full 完整版") + + +def probe_fps(video: str) -> float | None: + """ffprobe 取视频帧率(r_frame_rate),返 float 或 None.""" + import json + rc, out, _ = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "v", video, + ], timeout=30) + if rc != 0: + return None + try: + data = json.loads(out) + s = data.get("streams", [{}])[0] + rfr = s.get("r_frame_rate", "0/1") + num, den = rfr.split("/") + den_f = float(den) + if den_f == 0: + return None + return float(num) / den_f + except (json.JSONDecodeError, ValueError, IndexError): + return None + + +def interp(video: str, output: str, target_fps: int, + mode: str) -> dict: + """ffmpeg minterpolate 补帧. 返回渲染元数据.""" + # fps=p 内部先把源升到 target_fps(minterpolate 输出按 fps 滤镜设定) + # mode=blend/mci 决定补帧算法 + vf = f"minterpolate=fps={target_fps}:mi_mode={mode}" + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-vf", vf, + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "copy", # 音轨原样不动 + "-movflags", "+faststart", + "-pix_fmt", "yuv420p", + output, + ] + rc, _, err = run(cmd, timeout=1800) + if rc != 0: + die(f"ffmpeg minterpolate 渲染失败: {err.strip()[:500]}", code=2) + + return { + "input": video, + "output": output, + "target_fps": target_fps, + "mode": mode, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description=f"Frame interpolation via ffmpeg minterpolate (optional, low-fps source only)." + ) + parser.add_argument("video", help="输入视频路径") + parser.add_argument("--target-fps", type=int, default=DEFAULT_TARGET_FPS, + help=f"目标帧率,默认 {DEFAULT_TARGET_FPS}") + parser.add_argument("--mode", default=DEFAULT_MODE, choices=["blend", "mci"], + help=f"补帧模式:blend=加权混合(默认,快无鬼影但运动糊) / mci=运动补偿(更顺但慢,高运动易出鬼影)") + parser.add_argument("--output", default=None, + help="输出路径,默认输入旁加 _interp 后缀") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + check_minterpolate() + + src_fps = probe_fps(str(video_path)) + if src_fps is None: + die("ffprobe 取源帧率失败,检查视频是否损坏") + + # 源 fps ≥ target fps → 不补帧,直接拷贝避浪费 + 避不必要重压缩 + if src_fps >= args.target_fps: + print(f"[ok] src_fps={src_fps:.2f} ≥ target {args.target_fps},跳过补帧直接拷贝") + if args.output: + out_path = Path(args.output).resolve() + else: + out_path = video_path.with_name(f"{video_path.stem}_interp.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(video_path, out_path) + sys.exit(0) + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_interp.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] src_fps: {src_fps:.2f}") + print(f"[info] target_fps: {args.target_fps}") + print(f"[info] mode: {args.mode}") + print(f"[info] output: {out_path}") + + try: + result = interp(str(video_path), str(out_path), args.target_fps, args.mode) + except SystemExit as e: + # mci 模式渲染失败(出鬼影/算法崩)→ 退 blend 模式重试 + if args.mode == "mci" and e.code == 2: + print("[warn] mci 模式渲染失败,退 blend 模式重试(更稳但运动糊)", file=sys.stderr) + result = interp(str(video_path), str(out_path), args.target_fps, "blend") + else: + raise + + print(f"\n[done] interpolated: {out_path}", file=sys.stderr) + print(f"[info] {src_fps:.2f}fps → {args.target_fps}fps via {result['mode']}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/scripts/normalize.py b/crews/content-producer/scripts/normalize.py new file mode 100644 index 00000000..ed17542e --- /dev/null +++ b/crews/content-producer/scripts/normalize.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Loudness normalization — 发布平台通用响度归一化。 + +把成片音频响度归一化到 -14 LUFS(短视频平台通用标准:抖音/视频号/B 竍竖屏通用)。 +跑在合成后、自检/交付前。OpenMontage 这条是必跑步骤,我们也按必跑设计——但脚本 +本身尊重 --skip 时跳过,由 caller(AGENTS.md 工作流)决定是否强制。 + +为什么 -14 LUFS: +- 抖音/视频号/B 竍竖屏发布通用标准,与平台播放器电平匹配,避免"在我机 sound bar + 听着正"但"在手机刷到时偏轻/偏响" +- OpenMontage 同款阈值,industry de-facto for short-form video + +ffmpeg 用 loudnorm 双 pass: +- Pass 1:探测当前响度 + 真实峰 + 阈值,落测量 JSON +- Pass 2:按 Pass 1 测量值应用归一化,落成片 + +干湿分离:输出落 `/output_normalized.mp4`,**不覆盖原 output.mp4**。 +caller 决定是 rename 替换还是双轨保留。 + +Usage: + python3 ./scripts/normalize.py + python3 ./scripts/normalize.py --output + python3 ./scripts/normalize.py None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def ffprobe_loudness(video: str) -> dict | None: + """Pass 1: ffmpeg loudnorm 单 pass 测量当前响度。返回测量 dict 或 None.""" + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-af", f"loudnorm=I={DEFAULT_TARGET_LUFS}:TP={DEFAULT_TRUE_PEAK_DB}:LRA={DEFAULT_LRA}:" + f"print_format=json", + "-f", "null", "-", + ] + rc, _, err = run(cmd, timeout=120) + if rc != 0: + return None + # loudnorm 的 JSON 落 stderr 不是 stdout + try: + # 找 stderr 里的 { ... } JSON 块 + start = err.index("{") + end = err.rindex("}") + 1 + return json.loads(err[start:end]) + except (ValueError, json.JSONDecodeError): + return None + + +def normalize(video: str, output: str, target_lufs: float, + true_peak: float, lra: float) -> dict: + """双 pass loudnorm。Pass 1 测量,Pass 2 应用.""" + m = ffprobe_loudness(video) + if not m: + die("Pass 1 测量失败——ffmpeg loudnorm 没出 JSON,输入可能无声轨或损坏", code=2) + + # 测量值传给 Pass 2 实现真归一化(不是再跑一遍单 pass) + input_i = float(m.get("input_i", 0)) + input_tp = float(m.get("input_tp", 0)) + input_lra = float(m.get("input_lra", 0)) + input_thresh = float(m.get("input_thresh", 0)) + output_i = float(m.get("output_i", DEFAULT_TARGET_LUFS)) + output_tp = float(m.get("output_tp", DEFAULT_TRUE_PEAK_DB)) + output_lra = float(m.get("output_lra", DEFAULT_LRA)) + normalization_i = float(m.get("normalization_i", 0)) + normalization_tp = float(m.get("normalization_tp", 0)) + normalization_lra = float(m.get("normalization_lra", 0)) + + # 已经达标就不重渲染(省时间、避免不必要重压缩) + if abs(input_i - target_lufs) < 0.3: + print(f"[ok] input_i={input_i:.2f} LUFS 已在 ±0.3 LUFS of target {target_lufs}," + f"跳过归一化直接拷贝") + shutil.copy2(video, output) + return {"skipped": True, "input_i": input_i, "reason": "already_at_target"} + + cmd = [ + "ffmpeg", "-hide_banner", "-nostats", "-y", + "-i", video, + "-af", ( + f"loudnorm=" + f"I={target_lufs}:TP={true_peak}:LRA={lra}:" + f"measured_I={input_i}:measured_TP={input_tp}:measured_LRA={input_lra}:" + f"measured_thresh={input_thresh}:offset={normalization_i}:" + f"linear=true:print_format=summary" + ), + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", + output, + ] + rc, _, err = run(cmd, timeout=600) + if rc != 0: + die(f"Pass 2 归一化渲染失败: {err.strip()[:500]}", code=2) + + return { + "skipped": False, + "input_i": input_i, + "input_tp": input_tp, + "input_lra": input_lra, + "output_i": output_i, + "output_tp": output_tp, + "output_lra": output_lra, + "target_i": target_lufs, + "target_tp": true_peak, + "target_lra": lra, + "normalization_i": normalization_i, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Loudness normalization to -14 LUFS (短视频平台通用标准)." + ) + parser.add_argument("video", help="输入视频路径(合成产物 output.mp4)") + parser.add_argument("--output", default=None, + help="输出路径,默认在输入旁加 _normalized 后缀") + parser.add_argument("--target-lufs", type=float, default=DEFAULT_TARGET_LUFS, + help=f"目标响度 LUFS,默认 {DEFAULT_TARGET_LUFS}") + parser.add_argument("--true-peak", type=float, default=DEFAULT_TRUE_PEAK_DB, + help=f"真实峰上限 dB,默认 {DEFAULT_TRUE_PEAK_DB}") + parser.add_argument("--lra", type=float, default=DEFAULT_LRA, + help=f"loudness range 目标,默认 {DEFAULT_LRA}") + args = parser.parse_args() + + video_path = Path(args.video).resolve() + if not video_path.is_file(): + die(f"输入视频不存在: {video_path}") + + if args.output: + out_path = Path(args.output).resolve() + else: + stem = video_path.stem + out_path = video_path.with_name(f"{stem}_normalized.mp4") + out_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"[info] input: {video_path}") + print(f"[info] target: {args.target_lufs} LUFS / {args.true_peak} dB true peak / LRA {args.lra}") + print(f"[info] output: {out_path}") + + result = normalize(str(video_path), str(out_path), + args.target_lufs, args.true_peak, args.lra) + + print(json.dumps(result, indent=2, ensure_ascii=False)) + print(f"\n[done] normalized: {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/collage-broll/SKILL.md b/crews/content-producer/skills/collage-broll/SKILL.md new file mode 100644 index 00000000..029b75b8 --- /dev/null +++ b/crews/content-producer/skills/collage-broll/SKILL.md @@ -0,0 +1,427 @@ +--- +name: collage-broll +description: 将约 5 秒口播文稿、观点句或抽象概念做成高级 editorial halftone paper-collage / 半调纸拼贴 B-roll。用户说"collage b-roll""纸拼贴 b-roll""半调拼贴""拼贴风格配画面""用这段文稿做拼贴动画""gbro-collage-broll",或希望把一句文稿转成拼贴视觉隐喻时,必须使用此 skill。强制采用三阶段审批:先只提视觉隐喻,用户确认后用 siliconflow-img-gen 生成彩色拼贴静帧,静帧再次确认后用 gen.py(i2v 首尾帧插值)组装动画。视频生成走百炼 happyhorse-1.1-i2v 候选链(沿链 fallback)或火山 Seedance(视 env 配置),不调 Gemini Omni。 +metadata: + openclaw: + emoji: 🗞️ + requires: + bins: + - python3 + - ffmpeg + - ffprobe + env: + - AWK_API_KEY + primaryEnv: AWK_API_KEY + homepage: https://www.volcengine.com/docs/82379/1541523 +--- + +# Collage B-roll(纸拼贴组装动画) + +把一句约 5 秒的口播压成一个 sharp visual idea,再做成高级编辑风纸拼贴组装动画。 + +本 skill 由 gbro-collage-broll 适配而来——三闸门审批节奏 + assemble-from-empty prompt 写法保留,但所有外部依赖换到 xiaobei 语境: + +| 原膜(gbro) | 本 skill(xiaobei) | +|------|------| +| Gemini Omni Flash(视频) | gen.py i2v 首尾帧插值(百炼 happyhorse-1.1-i2v 沿链 / 火山 Seedance) | +| Codex 内置 `image_gen`(静帧) | `siliconflow-img-gen` 技能(Seedream doubao-seedream-4.5) | +| GEMINI_API_KEY + google-genai SDK | AWK_API_KEY(图像)/ MODELSTUDIO_API_KEY 或 AWK_GEN_KEY(视频) | +| `~/hyperframes-projects/.omni-venv/` 独立 venv | 仓根 `requirements.txt` + `scripts/apply-addons.sh` 统一装,不留独立 venv | +| `~/hyperframes-projects/YYYY-MM-DD-collage-broll-标题/` | `output_videos//`(xiaobei 路径契约) | +| Veo 旧脚本兼容 | 不带——我们一开始就是 Seedance / happyhorse,无 Veo 遗产 | + +默认链路: + +1. 只设计视觉隐喻,等待用户确认(Gate 1) +2. 只生成最终静帧,等待用户确认(Gate 2) +3. 自动调 gen.py 生成视频并完成 QA(Gate 3) + +这两个确认闸门是工作流的一部分。它们让用户把注意力放在审美和方向上,同时避免错误隐喻或错误静帧直接消耗视频生成成本。 + +## 首次使用:环境自检 + +每次触发本 skill 时,进入 Gate 1 之前先运行自检脚本: + +```bash +bash <本skill目录>/scripts/check_setup.sh +``` + +全部通过则直接开始 Gate 1,不要向用户重复配置信息。任何一项失败时,视为首次使用:不进入 Gate 1,先向用户输出下面的配置指南(只列出缺失项),等用户确认配置完成后重新自检。 + +### 配置指南(按缺失项输出) + +1. **AWK_API_KEY 未设置**(Gate 2 静帧生成要) + 到 [火山方舟控制台](https://console.volcengine.com/ark) 创建 API key,然后写入 shell 配置: + + ```bash + echo 'export AWK_API_KEY="你的key"' >> ~/.zshrc && source ~/.zshrc + ``` + +2. **MODELSTUDIO_API_KEY / AWK_GEN_KEY 都未设置**(Gate 3 视频生成要) + - 百炼走 `MODELSTUDIO_API_KEY`([阿里云百炼控制台](https://dashscope.console.aliyun.com/)) + - 火山走 `AWK_GEN_KEY`(火山方舟,同 AWK_API_KEY 控制台但需开视频生成权限) + - 两个都没配 → gen.py 退出码 2,向用户报告并按 gbro 原话提示"改用 pexels-footage / pixabay-footage 兜底"(但拼贴动画用 stock footage 没意义,实际是等用户配 key) + +3. **ffmpeg / ffprobe 缺失** + macOS:`brew install ffmpeg`;Debian/Ubuntu:`sudo apt install ffmpeg`。 + +4. **Python 环境缺失或版本过旧**(需要 >= 3.10) + macOS:`brew install python3`;或从 python.org 安装。 + +## 强制审批协议 + +### Gate 1:隐喻确认 + +收到文稿后,先提视觉隐喻,不生成图片、不生成视频、不调用任何视频模型。 + +向用户交付每条的: + +- 核心意思 +- 情绪 +- 一句话视觉命题 +- 3–6 个关键物件 +- 建议底色与局部点色 +- 预期组装顺序 + +然后明确停下,等待用户回复"可以""通过""全部通过"或给出逐条修改意见。 + +如果用户只确认部分编号,只让通过的条目进入 Gate 2;未通过条目继续修改隐喻。 + +### Gate 2:静帧确认 + +隐喻确认后,才写 visual spec 和 imagegen prompt,并用 `siliconflow-img-gen` 技能生成最终静帧。 + +把原图保存到项目目录,生成带编号的静帧 contact sheet,向用户展示并再次停下。此阶段仍然不调 gen.py,也不生成视频。 + +如果用户只确认部分静帧,只让通过的条目进入 Gate 3;需要修改的静帧先重生并重新确认。 + +### Gate 3:视频生成 + +静帧确认后,不再询问使用哪个视频模型,直接用本 skill 自带的 `scripts/run_gate3.py` 调 `gen.py` 走 i2v 首尾帧插值——默认走百炼 `happyhorse-1.1-i2v`(沿链 fallback 到 1.0 → wan2.7),百炼没配走火山 Seedance Fast → Normal → Mini。只有用户明确指定其他模型时才 `gen.py --model ` 覆盖。 + +## 成功标准 + +- 一句话只表达一个清晰隐喻 +- 同一批画面有统一设计语言,但不强制全部蓝底 +- 背景是强烈、平坦、均匀的色场,可按语意变化 +- 主体以黑白 halftone photographic cut-outs 为骨架 +- 关键卡片、按钮、胶片、规则册等允许使用红、黄、青、橙、紫、奶油白等彩色纸张 +- 所有纸片有清晰裁切边、奶油白 keyline、低透明度柔和阴影和纸张颗粒 +- 动作是 assemble-from-empty,而不是轻微漂移、晃动或慢 zoom +- 无字幕、无口播全文、无 logo、无水印、无 UI +- 默认交付 9:16、5 秒、720×1280、有声画同出(gen.py 默认 `audio: true`,旁白/BGM/环境音写在 prompt 里)MP4 + +## 什么时候不要用 + +- 需要精确控制图层、遮挡、镜头穿越或可编辑时间线:改用分层动画工具 +- 只需要视频提示词,不需要生成成片:直接写 prompt 即可,不用走本流程 +- 需要真实人物产品广告或口播演员:不要走本拼贴流程 +- 用户明确要可逐层修改的透明素材:本 skill 默认不拆透明图层 + +## 默认项目目录 + +用 xiaobei 路径契约——落在 `output_videos/` 下,名 ``: + +```text +output_videos// +├── brief.md # 文稿 + Gate 1 隐喻清单 +├── visual-spec.json # Gate 2 视觉规格 +├── imagegen-prompts.md # Gate 2 Seedream prompt 留档 +├── gen-jobs.json # Gate 3 gen.py 批量调用清单 +├── gate2-qa.md # 静帧 QA 结论 +├── gate3-qa.md # 视频 QA 结论 +├── still-contact-sheet.jpg # Gate 2 静帧总图 +├── video-contact-sheet-all.jpg # Gate 3 全部成片逐秒抽帧 +├── end-frame-comparison-all.jpg # 确认静帧 vs 视频末帧并排 +├── 01-/ +│ ├── gen-prompt.txt # gen.py --prompt 内容(声画同出描述) +│ ├── frames/ +│ │ ├── still.png # Gate 2 确认的完成帧(原图) +│ │ ├── last-frame.png # 统一裁到 720x1280 的尾帧 +│ │ └first-frame.png # 纯色空首帧(同底色 hex) +│ └gen-runs/run-v01/ +│ ├── final-5s.mp4 # gen.py 产物 +│ ├── final-5s-noaudio.mp4 # 强制无声交付(拼贴动画无声) +│ ├── contact-sheet.jpg # 逐秒抽帧总图 +│ └ video-last-frame.jpg +│ └ end-frame-comparison.jpg +└── 02-/... +``` + +## Phase 1:设计视觉隐喻 + +先把文稿压成一个视觉命题。 + +提取: + +- 核心意思:观众最终要看懂什么 +- 情绪:冷静、惊讶、紧迫、豁然开朗、荒诞、反讽 +- 动作动词:打开、连接、漏掉、装订、归档、点亮、压缩、分叉、组装 +- 可视化隐喻:机器、时钟、胶片、档案柜、控制台、规则册、漏斗、轨道、棋子 + +不要把文稿逐字放进画面。默认一条文稿只做一个隐喻,控制在 3–6 个关键物件;元素过多会让语意变弱,也会让 i2v 组装不稳定。 + +批量隐喻优先形成前后叙事:例如先表现手工消耗与经验流失,再表现规范沉淀与人机分工。 + +### Gate 1 输出示例 + +```text +1. 核心意思:经验每次都在重复消耗 + 视觉隐喻:熟练剪辑师围着巨大的胶片时钟逐帧裁切,时钟走完一圈却只得到一小段成片 + 关键物件:胶片时钟、剪辑师、剪刀、短胶片 + 色彩:焦橙底,奶油白与浅青点色 + 组装顺序:时钟 → 人物与剪刀 → 胶片 → 最终短输出 +``` + +输出后停下等待确认。 + +## Phase 2:生成彩色拼贴静帧 + +隐喻确认后,先写自包含的 `visual-spec.json`,再写 imagegen prompt。 + +### Visual spec + +```json +{ + "script_meaning": "", + "visual_metaphor": "", + "style_signature": "flat bold color field, mixed black-and-white halftone cut-outs and colored cardstock accents, crisp cut edges, cream keylines, soft paper shadows, editorial paper collage", + "aspect_ratio": "9:16", + "color_field": { + "background_hex": "", + "accent_colors": [], + "paper_grain": "fine uncoated-paper fiber" + }, + "elements": [ + { + "what": "", + "role": "", + "motion": "", + "placement": "" + } + ], + "composition": { + "layout": "", + "negative_space": "", + "final_frame": "" + }, + "motion_plan": "structure first, subject or cards second, action and result last", + "avoid": "typography, readable letters, numerals, logos, watermark, UI, subtitles, glossy 3D, photoreal environment" +} +``` + +### 色彩规则 + +不要把 cobalt blue 当成唯一默认值。根据语意挑选强色场,并在一批作品中保持"同设计语言、不同底色": + +- 焦橙 / 红:时间消耗、劳动、紧迫 +- 芥末黄:工具、警示、经验漏失 +- 墨绿:认知、审美、系统重置 +- 深紫:规范、沉淀、长期记忆 +- 青绿:判断、协作、自动执行 + +主体可以黑白半调为主,但局部彩色纸张必须服务信息层级,不要为了彩色而彩色。 + +### Imagegen prompt 模板(siliconflow-img-gen / Seedream) + +用 `siliconflow-img-gen` 技能(Seedream doubao-seedream-4.5,fallback doubao-seedream-5.0-lite): + +```bash +siliconflow-img-gen --prompt "<下面整段>" --image-size 1600x2848 --out-dir /01-/frames/ +``` + +Prompt 模板(英文,Seedream 对英文 prompt 响应更好): + +```text +Use case: ads-marketing +Asset type: final still frame for a 9:16 image-to-video B-roll clip +Primary request: Create a finished editorial paper-collage image expressing [一句话视觉命题]. +Scene/backdrop: perfectly flat [颜色] paper field [hex] with subtle uncoated paper fiber. +Style/medium: premium editorial stop-motion paper collage; black-and-white halftone photographic cut-outs mixed with selective [点色] colored cardstock. +Composition/framing: vertical 9:16 locked poster frame; central subject within the middle 70 percent; generous clean color-field negative space; 3–6 large separable paper groups for later assemble-from-empty animation. +Materials/textures: visible printed halftone dots, crisp machine-cut edges, thin warm-cream paper keylines, soft low-opacity physical drop shadows. +Constraints: [本条隐喻必须一眼看懂的关系]. +Avoid: no typography, no readable letters, no numerals, no logos, no watermark, no UI, no subtitles, no glossy 3D, no photoreal environment, no clutter. +``` + +Seedream 不支持参考图锁定风格(gbro 原膜靠 Codex `image_gen` 的参考图能力),所以"同设计语言"靠**同一批用同一 `style_signature` 字串 + 同一 `color_field` 范围**在 prompt 里复用,不靠参考图。 + +### 静帧 QA + +- 隐喻是否一眼看懂 +- 主体是否集中 +- 是否有假字、logo、水印或 UI +- 是否保留足够纯色场,方便从空场组装 +- 是否是 3–6 个清晰大组,而不是满屏碎片 +- 同一批是否统一质感但有色彩变化 + +将通过 QA 的原图复制到 `/frames/still.png`,生成带编号的静帧 contact sheet,展示给用户并停下等待 Gate 2 确认。静帧 QA 结论写入 `/gate2-qa.md`。 + +如果用户要求重生部分静帧,重生后生成 `still-contact-sheet-v2.jpg`(后续轮次递增 v3、v4…),保留旧版 contact sheet 不覆盖,方便对比。 + +拼静帧 contact sheet 用 ffmpeg tile: + +```bash +ffmpeg -y -pattern_type glob -i "/*/frames/still.png" \ + -vf "scale=270:480,tile=5x1" \ + -frames:v 1 /still-contact-sheet.jpg +``` + +段数 > 5 时分多行(`tile=5x2`、`5x3`…)。 + +## Phase 3:用 gen.py i2v 生成视频 + +### 1. 准备首尾帧 + +保留 imagegen 原图 `still.png`,再统一尾帧到 720x1280(gen.py i2v 收 720P/1080P,默认 720P): + +```bash +ffmpeg -y -i /frames/still.png \ + -vf "scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280" \ + /frames/last-frame.png +``` + +首帧默认是与尾帧相同底色的纯色空纸面(assemble-from-empty 的核心——从空场开始组装): + +```bash +ffmpeg -y -f lavfi -i color=c=0x:s=720x1280 \ + -frames:v 1 /frames/first-frame.png +``` + +如果用户明确要求不从完全空白开始,首帧才保留一个基础物件。 + +### 2. 写 gen.py 动画 prompt + +动作顺序默认采用: + +```text +基础结构 → 人物或关键卡片 → 连接件 → 动作 → 最终结果 +``` + +gen.py 的 `--prompt` 是**声画同出**描述(中文,happyhorse / Seedance 对中文响应好),不是 gbro 原膜的英文长 prompt。要把 gbro 原膜的 Omni prompt 段**转译**成 gen.py 风格: + +gbro 原膜 Omni prompt(英文长段)→ gen.py prompt(中文声画同出描述)转译规则: +- `Image 1 is the exact empty first frame` → 不写(gen.py `--image first-frame.png` `--last-frame last-frame.png` 显式传首尾帧,不在 prompt 里写) +- `Image 2 is the exact completed last frame` → 不写(同上) +- 组装顺序段 → 中文化:"画面从纯色空场开始,依次滑入 [基础结构] → [人物/卡片] → [连接件] → [动作],最终定格在已确认的完成构图" +- `No scene cuts, no camera movement, no zoom, no morphing` → 中文化:"固定机位,无切镜、无 zoom、无变形" +- `no text, no letters, no numbers, no logos, no watermark, no UI` → 中文化:"画面无文字、无 logo、无水印、无 UI" +- 声画同出补充(gbro 原膜是无声,gen.py 默认有声):"音频:纸片滑入的嗒嗰声 + 卡位时的咔嗒声 + 最终定格的短促 BGM 收尾" + +gen.py prompt 模板: + +```text +画面从纯色空场开始,依次滑入 [基础结构] → [人物/卡片] → [连接件] → [动作],最终定格在已确认的完成构图。固定机位,无切镜、无 zoom、无变形。画面无文字、无 logo、无水印、无 UI。音频:纸片滑入的嗒嗰声 + 卡位时的咔嗒声 + 最终定格的短促 BGM 收尾。 +``` + +每条 prompt 都要明确 `--image first-frame.png` 是空首帧、`--last-frame last-frame.png` 是确认过的完成帧。最终构图必须贴近 last-frame,不让模型自由改造尾帧。 + +### 3. 检查 gen.py 运行环境 + +gen.py 自带 env 自动判平台(MODELSTUDIO_API_KEY 优先百炼,AWK_GEN_KEY 走火山),不需要独立 venv 或 SDK 安装——仓根 `requirements.txt` + `scripts/apply-addons.sh` 统一装。自检脚本 `check_setup.sh` 只探 ffmpeg / ffprobe / AWK_API_KEY / 视频平台 key,不探 venv。 + +### 4. 批量调用 gen.py + +创建 `gen-jobs.json`。每个 job 用首尾帧插值(i2v 模式): + +```json +{ + "prompt": "", + "first_frame": "/frames/first-frame.png", + "last_frame": "/frames/last-frame.png", + "output": "/gen-runs/run-v01/final-5s.mp4", + "ratio": "9:16", + "resolution": "720P", + "duration": 5 +} +``` + +使用本 skill 自带脚本批量调 gen.py: + +```bash +python3 <本skill目录>/scripts/run_gate3.py --batch /gen-jobs.json +``` + +脚本默认走 gen.py i2v 模式(首尾帧插值),百炼 happyhorse-1.1-i2v 沿链 fallback(1.1 → 1.0 → wan2.7),百炼没配走火山 Seedance Fast → Normal → Mini。gen.py 内部已带候选链 fallback + decisions.log 落盘,本脚本只做批量调度。 + +如果出现 i2v 不收首尾帧的报错(gen.py 退出码非 0),检查 first-frame.png / last-frame.png 是否真存在、是否 720x1280——gen.py 的 `ensure_safe_output()` 要求相对路径在 `output_videos/` 下,**调 gen.py 时 workdir 必须是 workspace 根**。 + +### 5. 强制无声交付 + +拼贴动画是无声的(gbro 原膜默认无声),但 gen.py 声画同出模式会出声。Gate 3 出片后用 ffmpeg 抽无声版交付: + +```bash +ffmpeg -y -i /final-5s.mp4 \ + -map 0:v:0 -c:v copy -an \ + /final-5s-noaudio.mp4 +``` + +默认交付 `final-5s-noaudio.mp4`,保留原始 `final-5s.mp4` 作为中间产物。 + +如果用户明确要"带声"——拼贴动画的纸片嗰声 + BGM 是 gen.py 声画同出出的,可能挺贴——就不抽无声,直接交付 `final-5s.mp4`。但默认走无声(保 gbro 原膜契约)。 + +## 视频 QA + +不要只看尾帧,必须检查组装过程和最终落位。 + +### Contact sheet + +```bash +ffmpeg -y -i /final-5s-noaudio.mp4 \ + -vf "fps=1,scale=270:480,tile=5x1" \ + -frames:v 1 /contact-sheet.jpg +``` + +通过标准: + +- 首帧接近纯色空场;边缘轻微提前露出纸片可以接受 +- 中段能看到结构、人物或卡片逐步进入,而不是整体淡入 +- 没有切镜、zoom、3D 化或写实场景漂移 +- 没有假字、logo、水印或 UI +- 最终帧与确认静帧一致;轻微姿态或细节漂移(如人物姿势微变、小零件增减)只要不影响隐喻语义即可判通过,不要为此重跑 +- 成片为 720×1280、有声画同出(`final-5s.mp4`)或无声(`final-5s-noaudio.mp4`)、5 秒 + +另外抽取视频末帧,与确认静帧并排生成 `end-frame-comparison.jpg`。批量项目再合并三张总览图: + +- `video-contact-sheet-all.jpg`:全部成片逐秒抽帧 +- `video-first-frame-all.jpg`:全部成片实际首帧,验证真的从空色场开始 +- `end-frame-comparison-all.jpg`:确认静帧与视频末帧并排对照 + +逐条 QA 结论(含带瑕疵通过的判定理由)写入 `/gate3-qa.md`。 + +### 常见问题 + +- 首帧边缘提前露出:轻微可接受;严格空场需求改用更坚定的 first-frame(纯色 + 边缘 padding) +- 组装感弱:缩短元素数量,并把 prompt 改为明确的逐件"滑入 / 卡位"顺序 +- 尾帧漂移:强化 prompt 里"最终定格在已确认的完成构图",gen.py i2v 的 last-frame 权重高 +- 出现假字:先回到静帧重生(Seedream 也可能出假字),不要直接用视频 prompt 修补 +- 个别视频失败:只重跑对应 job,不要重跑已经通过的条目 +- i2v 报错(gen.py 退出码非 0):检查首尾帧是否 720x1280、是否真存在、workdir 是否 workspace 根 + +## 默认交付 + +向用户交付: + +- 每条 `/gen-runs/run-v01/final-5s-noaudio.mp4`(或 `final-5s.mp4` 若用户要带声) +- 每条 contact sheet +- 批量总 contact sheet +- 最终帧对照图 +- 一句说明每条文稿如何转成视觉隐喻 + +如果成片问题来自 gen.py i2v 的生成限制(组装感弱 / 尾帧漂移),直接说明;只有需要精确图层控制时,才建议切换到其他方案(如 HyperFrames,但我们不内置 HyperFrames)。 + +## 脚本清单 + +| 脚本 | 文件名 | 用途 | +|------|--------|------| +| 环境自检 | `scripts/check_setup.sh` | 探 ffmpeg / ffprobe / AWK_API_KEY / 视频平台 key,全过 exit 0,否则 exit 1 报缺失项 | +| Gate 3 批量调度 | `scripts/run_gate3.py` | 读 gen-jobs.json,逐条调 gen.py i2v 模式(首尾帧插值),落产物 + decisions.log | + +visual-spec.json 生成、imagegen prompt 拼装、contact sheet 拼图、首尾帧 ffmpeg 处理——这些靠 agent 直接调 `siliconflow-img-gen` + ffmpeg 完成,不单独上脚本(agent 直接调更灵活,且避免脚本重复造轮子)。 + +## 没吸收的 gbro 原膜能力 + +- Gemini Omni Flash / google-genai SDK / Files API 上传 → 全换成 gen.py i2v + siliconflow-img-gen +- Veo 旧脚本兼容 → 不带(无 Veo 遗产) +- `~/hyperframes-projects/.omni-venv/` 独立 venv → 不用(仓根 requirements.txt 统一装) +- Codex 内置 `image_gen` 参考图锁定风格 → 不能(Seedream 无参考图锁定,靠 prompt 复用同一 style_signature + color_field) diff --git a/crews/content-producer/skills/collage-broll/scripts/check_setup.sh b/crews/content-producer/skills/collage-broll/scripts/check_setup.sh new file mode 100644 index 00000000..4d0dee91 --- /dev/null +++ b/crews/content-producer/skills/collage-broll/scripts/check_setup.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# collage-broll environment self-check. +# Exit 0 = all good; exit 1 = at least one item missing (details on stdout). +# +# 探依赖:ffmpeg / ffprobe / AWK_API_KEY(Gate 2 静帧)/ 视频平台 key(Gate 3 视频) +# 不探 venv——仓根 requirements.txt 统一装,不留独立 venv(xiaobei 语境) + +set -u + +FAIL=0 + +ok() { printf 'PASS %s\n' "$1"; } +bad() { printf 'FAIL %s\n' "$1"; FAIL=1; } + +# 1. AWK_API_KEY(Gate 2 静帧生成要——siliconflow-img-gen / Seedream) +if [ -n "${AWK_API_KEY:-}" ]; then + ok "AWK_API_KEY 已设置(Gate 2 静帧可用)" +else + bad "AWK_API_KEY 未设置(Gate 2 静帧生成要——到 https://console.volcengine.com/ark 创建后 export 到 shell 配置)" +fi + +# 2. 视频平台 key(Gate 3 视频生成要——gen.py / 百炼或火山) +if [ -n "${MODELSTUDIO_API_KEY:-}" ] || [ -n "${DASHSCOPE_API_KEY:-}" ]; then + ok "MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY 已设置(Gate 3 走百炼 happyhorse-1.1-i2v)" +elif [ -n "${AWK_GEN_KEY:-}" ]; then + ok "AWK_GEN_KEY 已设置(Gate 3 走火山 Seedance,百炼未配)" +else + bad "视频平台 key 都未设置(Gate 3 要 MODELSTUDIO_API_KEY 百炼 或 AWK_GEN_KEY 火山)" +fi + +# 3. ffmpeg / ffprobe +if command -v ffmpeg >/dev/null 2>&1; then + ok "ffmpeg 已装" +else + bad "ffmpeg 缺失(macOS: brew install ffmpeg; Debian/Ubuntu: sudo apt install ffmpeg)" +fi +if command -v ffprobe >/dev/null 2>&1; then + ok "ffprobe 已装" +else + bad "ffprobe 缺失(跟 ffmpeg 同包,装 ffmpeg 即带)" +fi + +# 4. Python >= 3.10 +if command -v python3 >/dev/null 2>&1; then + PY_VER=$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo "0.0") + PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1) + PY_MINOR=$(echo "$PY_VER" | cut -d. -f2) + if [ "$PY_MAJOR" -gt 3 ] || { [ "$PY_MAJOR" -eq 3 ] && [ "$PY_MINOR" -ge 10 ]; }; then + ok "Python $PY_VER(>= 3.10)" + else + bad "Python $PY_VER 过旧(需要 >= 3.10;macOS: brew install python3;或从 python.org 安装)" + fi +else + bad "python3 缺失(macOS: brew install python3;或从 python.org 安装)" +fi + +exit $FAIL diff --git a/crews/content-producer/skills/collage-broll/scripts/run_gate3.py b/crews/content-producer/skills/collage-broll/scripts/run_gate3.py new file mode 100644 index 00000000..58872328 --- /dev/null +++ b/crews/content-producer/skills/collage-broll/scripts/run_gate3.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Gate 3 批量调度——读 gen-jobs.json 逐条调 gen.py i2v 模式(首尾帧插值)。 + +每个 job 字段: + prompt gen.py --prompt(中文声画同出描述) + first_frame 首帧路径(纯色空场,720x1280) + last_frame 尾帧路径(确认静帧裁到 720x1280,720P) + output 输出 MP4 路径(相对 output_videos/,gen.py 的 ensure_safe_output 要求) + ratio 默认 9:16 + resolution 默认 720P + duration 默认 5 + +gen.py 内部已带候选链 fallback + decisions.log 落盘,本脚本只做批量调度—— +串行调(gen.py 视频生成是异步轮询任务,并行调会撞平台并发限)。 + +Usage: + python3 /scripts/run_gate3.py --batch /gen-jobs.json + python3 /scripts/run_gate3.py --batch /gen-jobs.json --dry-run + +Exit codes: + 0 全部 job �跑通 + 1 参数错 / gen-jobs.json 不存在 / 格式错 + 2 部分 job 失败(stderr 报失败清单,已跑通的保留) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +# gen.py 路径——相对 xiaobei workspace 根 +GEN_PY = "./crews/main/skills/video-product/scripts/gen.py" + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run_one(job: dict, job_id: int, dry_run: bool) -> tuple[bool, str]: + """调 gen.py i2v 跑一个 job. 返 (ok, detail).""" + for required in ("prompt", "first_frame", "last_frame", "output"): + if not job.get(required): + return False, f"job {job_id} missing field: {required}" + + cmd = [ + "python3", GEN_PY, + "--prompt", job["prompt"], + "--image", job["first_frame"], # i2v 首帧 + "--last-frame", job["last_frame"], # i2v 尾帧 + "--ratio", job.get("ratio", "9:16"), + "--resolution", job.get("resolution", "720P"), + "--duration", str(job.get("duration", 5)), + "--output", job["output"], + ] + + if dry_run: + print(f"[dry-run] job {job_id}: {' '.join(cmd[:4])} ... --output {job['output']}") + return True, "dry-run skipped" + + print(f"[info] job {job_id}: gen.py i2v → {job['output']}") + try: + r = subprocess.run(cmd, timeout=1200) + if r.returncode == 0: + return True, f"ok exit 0 → {job['output']}" + return False, f"gen.py exit {r.returncode} for job {job_id}(查 gen.py stderr + decisions.log)" + except subprocess.TimeoutExpired: + return False, f"gen.py timeout 1200s for job {job_id}" + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Gate 3 批量调度——读 gen-jobs.json 逐条调 gen.py i2v(首尾帧插值)." + ) + parser.add_argument("--batch", required=True, help="gen-jobs.json 路径") + parser.add_argument("--dry-run", action="store_true", help="只打印不真调") + args = parser.parse_args() + + batch_path = Path(args.batch).resolve() + if not batch_path.is_file(): + die(f"gen-jobs.json 不存在: {batch_path}") + + try: + jobs = json.loads(batch_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"gen-jobs.json 格式错: {e}") + + if not isinstance(jobs, list): + die("gen-jobs.json 顶层数组不是 list") + + print(f"[info] batch: {batch_path} ({len(jobs)} jobs)") + + failures: list[tuple[int, str]] = [] + for i, job in enumerate(jobs): + ok, detail = run_one(job, i, args.dry_run) + print(detail) + if not ok: + failures.append((i, detail)) + + if failures: + print(f"\n[fail] {len(failures)} job(s) failed:", file=sys.stderr) + for jid, det in failures: + print(f" job {jid}: {det}", file=sys.stderr) + sys.exit(2) + + print(f"\n[ok] all {len(jobs)} jobs completed") + + +if __name__ == "__main__": + main() diff --git a/crews/main/skills/video-product/scripts/review.py b/crews/main/skills/video-product/scripts/review.py new file mode 100644 index 00000000..ff3ccf8e --- /dev/null +++ b/crews/main/skills/video-product/scripts/review.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""Final-video self-review — post-compose quality gate. + +Runs AFTER assemble.py produces video.mp4. Outputs verdict JSON. The skill's +SKILL.md Step 5 mandates: review.py must pass before the video is handed back +to the user. A "fail" verdict means the agent must fix and re-review, not deliver. + +What it checks (borrowed from OpenMontage post-render self-review + gbro Gate 3 QA, +scoped to our ffmpeg-only no-Remotion/HyperFrames world): + 1. ffprobe full validation — codec, resolution, fps, pixel format, audio config + 2. 4-position frame extraction (0% / 25% / 50% / 75% / 100%) → black-frame + overlay-break scan + 3. Audio level analysis — silence / clipping / absent track + 4. Duration vs target (from sibling script.md 片段规划表 时长列累加,or --target-duration) + 5. Resolution uniformity — checks the成片 matches the first segment's resolution + (拼了不同分辨率段是硬伤) + +NOT included (deliberately): + - Subtitle presence check — 我们的 assemble.py 不烧字幕,无意义 + - Delivery promise / slideshow risk — 那是脚本阶段的事,归 Step 2 slideshow-risk 自检清单 + - Decision audit trail — 那是 decisions.log 的事,归 state.json + decisions.log + +Usage: + python3 ./skills/video-product/scripts/review.py + python3 ./skills/video-product/scripts/review.py --target-duration 30 --target-resolution 720x1280 + python3 ./skills/video-product/scripts/review.py --output review.json + +Exit codes: + 0 verdict = "pass" → 可以交付 + 1 verdict = "fail" → 必须修,不准交 + 2 verdict = "warn" → 有 non-critical issues,向用户复述让其决定是否重修 + 3 script error (ffprobe missing / path invalid / ...) — 脚本本身故障,不算评审结论 + +Verdict JSON schema (also pretty-printed to stdout): + { + "verdict": "pass" | "fail" | "warn", + "file": "", + "ffprobe": { codec, width, height, fps, pix_fmt, duration, size_bytes, audio {...} }, + "frames": [ { "position_pct": 0, "path": "...", "mean_luma": 0.0, "is_black": false } ], + "audio_level": { "mean_db": -32.4, "max_db": -8.1, "silent": false, "clipping": false }, + "checks": [ + { "name": "duration_match", "status": "pass", "detail": "actual 30.2s vs target 30s, gap 0.2s" }, + { "name": "resolution_720p", "status": "pass", "detail": "720x1280" }, + { "name": "resolution_uniform", "status": "fail", "detail": "成片 720x1280 vs 段01 1080x1920" }, + ... + ], + "critical": [ "resolution_uniform: ..." ], + "warnings": [ "audio_level mean_db=-42.1 close to silent threshold" ] + } +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# ── Constants ────────────────────────────────────────────────────────────── + +VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi"} +REVIEW_DIR_NAME = "review" # /review/ 抽帧 + verdict JSON 落这 +FRAMES_SUBDIR = "frames" + +# Frame extraction positions (% of duration). OpenMontage 抽 4 位,我们按其 + gbro +# Gate 3 的逐秒抽帧折中——5 位(0/25/50/75/100%)足够拦黑帧/overlay 损,不堆 footage。 +FRAME_POSITIONS_PCT = [0.0, 25.0, 50.0, 75.0, 100.0] + +# Black frame threshold — luma mean below this → "black". 对齐 check.py 的 0.02,可调。 +BLACK_LUMA_THRESHOLD = 0.02 +# Audio thresholds — 声画同出模式下 BGM+旁白正常电平 -25~-10 dB,过静/过响都硬伤 +AUDIO_SILENT_THRESHOLD_DB = -60.0 +AUDIO_CLIPPING_THRESHOLD_DB = -1.0 + +# Duration tolerance — 拼接允许 ±5% 偏差(OpenMontage 也用 5%) +DURATION_TOLERANCE_PCT = 5.0 + +# Resolution floor — 9:16 竖屏短视频最低 720x1280,横屏 1280x720 +RES_MIN_LONG = 720 +RES_MIN_SHORT = 720 + + +# ── Helpers ──────────────────────────────────────────────────────────────── + +def die(msg: str, code: int = 3) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + """Run a subprocess, return (exit, stdout, stderr).""" + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def ffprobe(path: str) -> dict: + """Full ffprobe dump as dict. Returns {error: str} on failure.""" + rc, out, err = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", path, + ], timeout=30) + if rc != 0: + return {"error": f"ffprobe exit {rc}: {err.strip()}"} + try: + return json.loads(out) + except json.JSONDecodeError as e: + return {"error": f"ffprobe output not JSON: {e}"} + + +# ── Frame extraction & black detection ───────────────────────────────────── + +def extract_frames(video: str, duration: float, out_dir: Path) -> list[dict]: + """Extract 5 frames at FRAME_POSITIONS_PCT. Returns list of {position_pct, path, mean_luma, is_black}.""" + out_dir.mkdir(parents=True, exist_ok=True) + frames: list[dict] = [] + + for pct in FRAME_POSITIONS_PCT: + # timestamp in seconds + ts = duration * pct / 100.0 + # 端 0% 时 ts=0,ffmpeg trim 起会拒;用 -ss 段定位 + -frames:v 1 + frame_path = out_dir / f"frame_{int(pct):03d}.jpg" + cmd = [ + "ffmpeg", "-y", "-v", "quiet", + "-ss", f"{ts:.3f}", "-i", video, + "-frames:v", "1", "-q:v", "2", + str(frame_path), + ] + rc, _, _ = run(cmd, timeout=30) + if rc != 0 or not frame_path.exists(): + frames.append({"position_pct": pct, "path": None, "mean_luma": None, "is_black": None}) + continue + + # Use ffmpeg signalstats to get mean luma. signalstats gives YAVG. + rc, out, _ = run([ + "ffmpeg", "-v", "quiet", "-i", str(frame_path), + "-vf", "signalstats", "-f", "null", "-", + ], timeout=15) + mean_luma: float | None = None + # signalstats prints to stderr normally; with quiet we get nothing. + # Fallback: use ffmpeg with stderr passthrough to catch YAVG=N. + if rc == 0: + rc2, out2, err2 = run([ + "ffmpeg", "-v", "info", "-i", str(frame_path), + "-vf", "signalstats", "-f", "null", "-", + ], timeout=15) + for line in (err2 + out2).splitlines(): + # Example: "signalstats: YAVG=0.012300 ..." + if "YAVG=" in line: + try: + mean_luma = float(line.split("YAVG=")[1].split()[0]) + except (IndexError, ValueError): + pass + break + + # If signalstats failed, try a Pillow-free fallback via ffmpeg lutyuv mean + if mean_luma is None: + # Use ffmpeg blackframe filter — it logs frames below threshold + rc3, _, err3 = run([ + "ffmpeg", "-v", "info", "-i", str(frame_path), + "-vf", f"blackframe=threshold={BLACK_LUMA_THRESHOLD}", + "-f", "null", "-", + ], timeout=15) + # If "black" appears in stderr, frame is black + is_black_log = "First black frame detected" in err3 or "black" in err3.lower() + mean_luma = 0.0 if is_black_log else 0.5 # placeholder; we trust is_black_log + + is_black = mean_luma is not None and mean_luma < BLACK_LUMA_THRESHOLD + frames.append({ + "position_pct": pct, + "path": str(frame_path), + "mean_luma": round(mean_luma, 4) if mean_luma is not None else None, + "is_black": is_black, + }) + + return frames + + +# ── Audio level analysis ──────────────────────────────────────────────────── + +def analyze_audio(video: str, has_audio: bool) -> dict: + """Use ffmpeg volumedetect filter. Returns {mean_db, max_db, silent, clipping, absent}.""" + if not has_audio: + return {"absent": True, "silent": None, "clipping": None, "mean_db": None, "max_db": None} + + rc, _, err = run([ + "ffmpeg", "-v", "info", "-i", video, + "-af", "volumedetect", + "-f", "null", "-", + ], timeout=60) + + mean_db: float | None = None + max_db: float | None = None + for line in err.splitlines(): + if "mean_volume:" in line: + try: + mean_db = float(line.split("mean_volume:")[-1].strip().rstrip(" dB")) + except (IndexError, ValueError): + pass + elif "max_volume:" in line: + try: + max_db = float(line.split("max_volume:")[-1].strip().rstrip(" dB")) + except (IndexError, ValueError): + pass + + silent = mean_db is not None and mean_db < AUDIO_SILENT_THRESHOLD_DB + clipping = max_db is not None and max_db >= AUDIO_CLIPPING_THRESHOLD_DB + + return { + "absent": False, + "mean_db": round(mean_db, 2) if mean_db is not None else None, + "max_db": round(max_db, 2) if max_db is not None else None, + "silent": silent, + "clipping": clipping, + } + + +# ── Resolution uniformity vs segments ─────────────────────────────────────── + +def probe_segments(project_dir: Path) -> list[dict]: + """Quick ffprobe of each artifact segment for resolution uniformity check.""" + artifacts = project_dir / "artifacts" + if not artifacts.is_dir(): + return [] + + segments: list[dict] = [] + for name in sorted(os.listdir(artifacts)): + path = artifacts / name + if path.suffix.lower() not in VIDEO_EXTS: + continue + if not path.is_file(): + continue + # Skip _deprecated subfolder is non-recursive — but we're listdir level only + data = ffprobe(str(path)) + if "error" in data: + segments.append({"file": name, "error": data["error"]}) + continue + streams = data.get("streams", []) + v = next((s for s in streams if s.get("codec_type") == "video"), None) + if v: + segments.append({ + "file": name, + "width": int(v.get("width", 0)), + "height": int(v.get("height", 0)), + }) + return segments + + +# ── Main ─────────────────────────────────────────────────────────────────── + +def review(project_dir: Path, target_duration: float | None, target_resolution: str | None, + output_path: Path | None) -> dict: + """Run full review, build verdict dict, write JSON, return dict.""" + video = project_dir / "video.mp4" + if not video.is_file(): + die(f"成片不存在: {video}") + + review_dir = project_dir / REVIEW_DIR_NAME + frames_dir = review_dir / FRAMES_SUBDIR + review_dir.mkdir(parents=True, exist_ok=True) + + verdict: dict = { + "verdict": "pass", + "file": str(video.resolve()), + "ffprobe": {}, + "frames": [], + "audio_level": {}, + "checks": [], + "critical": [], + "warnings": [], + } + + # ── 1. ffprobe full ─────────────────────────────────────────────────── + data = ffprobe(str(video)) + if "error" in data: + verdict["verdict"] = "fail" + verdict["critical"].append(f"ffprobe failed: {data['error']}") + # No further checks possible + _finalize(verdict, output_path) + return verdict + + fmt = data.get("format", {}) + streams = data.get("streams", []) + v_stream = next((s for s in streams if s.get("codec_type") == "video"), None) + a_stream = next((s for s in streams if s.get("codec_type") == "audio"), None) + + if not v_stream: + verdict["verdict"] = "fail" + verdict["critical"].append("no video stream in output") + _finalize(verdict, output_path) + return verdict + + duration = float(fmt.get("duration", 0)) + width = int(v_stream.get("width", 0)) + height = int(v_stream.get("height", 0)) + fps = v_stream.get("r_frame_rate", "unknown") + pix_fmt = v_stream.get("pix_fmt", "unknown") + + verdict["ffprobe"] = { + "codec": v_stream.get("codec_name", "unknown"), + "width": width, + "height": height, + "fps": fps, + "pix_fmt": pix_fmt, + "duration": round(duration, 2), + "size_bytes": int(fmt.get("size", 0)), + "audio": ( + {"codec": a_stream.get("codec_name", "unknown"), + "sample_rate": int(a_stream.get("sample_rate", 0)), + "channels": int(a_stream.get("channels", 0))} + if a_stream else None + ), + } + + # ── 2. Frame extraction & black detection ──────────────────────────── + frames = extract_frames(str(video), duration, frames_dir) + verdict["frames"] = frames + black_count = sum(1 for f in frames if f.get("is_black") is True) + if black_count >= 2: # ≥2 black frames out of 5 → critical + verdict["critical"].append( + f"black_frame: {black_count}/{len(frames)} sampled frames are black — overlay/encode broken" + ) + elif black_count == 1: + verdict["warnings"].append( + f"black_frame: 1/{len(frames)} sampled frame is black — likely first-frame transition, verify" + ) + + # ── 3. Audio level ─────────────────────────────────────────────────── + audio = analyze_audio(str(video), a_stream is not None) + verdict["audio_level"] = audio + if audio.get("absent"): + # 声画同出模式下无声是硬伤;Stock Footage + --no-audio 模式下正常 — 软警告 + verdict["warnings"].append("audio_absent: no audio track (verify against pipeline mode)") + else: + if audio.get("silent"): + verdict["critical"].append( + f"audio_silent: mean_db={audio['mean_db']} below {-AUDIO_SILENT_THRESHOLD_DB}dB — silent audio" + ) + if audio.get("clipping"): + verdict["critical"].append( + f"audio_clipping: max_db={audio['max_db']} above {AUDIO_CLIPPING_THRESHOLD_DB}dB — clipping" + ) + + # ── 4. Duration vs target ──────────────────────────────────────────── + if target_duration is not None and target_duration > 0: + gap_pct = abs(duration - target_duration) / target_duration * 100 + if gap_pct > DURATION_TOLERANCE_PCT: + verdict["critical"].append( + f"duration_mismatch: actual {duration:.2f}s vs target {target_duration}s, gap {gap_pct:.1f}% > {DURATION_TOLERANCE_PCT}%" + ) + elif gap_pct > 1.0: + verdict["warnings"].append( + f"duration_drift: actual {duration:.2f}s vs target {target_duration}s, gap {gap_pct:.1f}%" + ) + verdict["checks"].append({ + "name": "duration_match", + "status": "pass" if gap_pct <= DURATION_TOLERANCE_PCT else "fail", + "detail": f"actual {duration:.2f}s vs target {target_duration}s, gap {gap_pct:.1f}%" + }) + else: + verdict["checks"].append({ + "name": "duration_match", + "status": "skipped", + "detail": "no target_duration provided" + }) + + # ── 5. Resolution floor + uniformity ───────────────────────────────── + long_side = max(width, height) + short_side = min(width, height) + if long_side < RES_MIN_LONG or short_side < RES_MIN_SHORT: + verdict["critical"].append( + f"resolution_low: {width}x{height} below floor {RES_MIN_SHORT}p" + ) + verdict["checks"].append({ + "name": "resolution_floor", + "status": "pass" if long_side >= RES_MIN_LONG and short_side >= RES_MIN_SHORT else "fail", + "detail": f"{width}x{height}" + }) + + if target_resolution: + try: + tw, th = (int(x) for x in target_resolution.lower().split("x")) + except ValueError: + verdict["warnings"].append(f"invalid --target-resolution: {target_resolution}") + tw = th = None + if tw and th: + if width != tw or height != th: + verdict["critical"].append( + f"resolution_mismatch: actual {width}x{height} vs target {tw}x{th}" + ) + verdict["checks"].append({ + "name": "resolution_target", + "status": "pass" if width == tw and height == th else "fail", + "detail": f"{width}x{height} vs {tw}x{th}" + }) + + # Uniformity vs segments + segments = probe_segments(project_dir) + if segments: + mismatches = [ + s for s in segments + if "width" in s and (s["width"] != width or s["height"] != height) + ] + if mismatches: + examples = "; ".join(f"{s['file']}={s['width']}x{s['height']}" for s in mismatches[:3]) + verdict["critical"].append( + f"resolution_uniform: 成片 {width}x{height} vs 段分辨率不齐 — {examples}" + ) + verdict["checks"].append({ + "name": "resolution_uniform", + "status": "pass" if not mismatches else "fail", + "detail": f"成片 {width}x{height}, 段数 {len(segments)}, 不齐 {len(mismatches)}" + }) + + # ── Pixel format sanity ────────────────────────────────────────────── + if "420" not in pix_fmt: + verdict["warnings"].append(f"pix_fmt_unusual: {pix_fmt} — 多平台发布建议 yuv420p") + + # ── Final verdict tally ────────────────────────────────────────────── + if verdict["critical"]: + verdict["verdict"] = "fail" + elif verdict["warnings"]: + verdict["verdict"] = "warn" + else: + verdict["verdict"] = "pass" + + _finalize(verdict, output_path) + return verdict + + +def _finalize(verdict: dict, output_path: Path | None) -> None: + """Pretty-print verdict to stdout and write JSON.""" + print(json.dumps(verdict, indent=2, ensure_ascii=False)) + + if output_path is None: + # Default: write to /review/verdict.json + # output_path is None means caller didn't specify — we already have review_dir + # but we don't here. Simpler: caller always passes output_path. + return + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(verdict, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"\n[ok] verdict written to {output_path}", file=sys.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Final-video self-review. Runs after assemble.py, before delivery." + ) + parser.add_argument("project_dir", help="项目目录 (含 video.mp4 与 artifacts/)") + parser.add_argument("--target-duration", type=float, default=None, + help="目标时长(秒),从 script.md 片段规划累加得出") + parser.add_argument("--target-resolution", default=None, + help="目标分辨率,形如 720x1280") + parser.add_argument("--output", default=None, + help="verdict JSON 落盘路径,默认 /review/verdict.json") + args = parser.parse_args() + + project_dir = Path(args.project_dir).resolve() + if not project_dir.is_dir(): + die(f"项目目录不存在: {project_dir}") + + output_path = ( + Path(args.output).resolve() if args.output + else project_dir / REVIEW_DIR_NAME / "verdict.json" + ) + + verdict = review(project_dir, args.target_duration, args.target_resolution, output_path) + + # Exit code: 0 pass / 1 fail / 2 warn / 3 script error + sys.exit({ + "pass": 0, + "fail": 1, + "warn": 2, + }.get(verdict["verdict"], 3)) + + +if __name__ == "__main__": + main() diff --git a/crews/main/skills/video-product/scripts/state.py b/crews/main/skills/video-product/scripts/state.py new file mode 100644 index 00000000..e9b15aca --- /dev/null +++ b/crews/main/skills/video-product/scripts/state.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Pipeline state manager for video-product — subagent resume-from-failure. + +Borrowed from OpenMontage lib/checkpoint.py, scoped to our needs: +- One JSON state per project: output_videos//state.json +- Stage list is fixed (script → gate0 → calibrate → assets → assemble → review → cover) +- Each stage: status (pending/in_progress/completed/awaiting_human/failed) + ts + notes +- Append-only on disk — superseded states archived to state.history/ (never destroy) +- decisions.log is separate append-only audit; this file is point-in-time recovery + +Usage: + python3 ./skills/video-product/scripts/state.py --init + python3 ./skills/video-product/scripts/state.py --enter + python3 ./skills/video-product/scripts/state.py --complete [--notes "..."] + python3 ./skills/video-product/scripts/state.py --await [--notes "..."] + python3 ./skills/video-product/scripts/state.py --fail [--notes "..."] + python3 ./skills/video-product/scripts/state.py --next # prints next pending stage + python3 ./skills/video-product/scripts/state.py --show # pretty-print current state + +Stages (in order): + script — Step 2 脚本创作与定稿 + gate0 — Step 2.3.5 Gate 0 关键帧 contact sheet 确认 + calibrate — Step 2.4 脚本定稿打分+盲预测(content-calibrator) + assets — Step 3 + Step 4 用户素材预处理 + 视频素材生产 + assemble — Step 5 合成视频 + review — Step 5.5 成片自检(review.py) + cover — Step 6 制作封面 + deliver — Step 7 用户确认交付 + +Exit codes: + 0 ok + 1 bad args / state corrupt / stage unknown + 2 --next but all stages completed (nothing to resume) +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from datetime import datetime +from pathlib import Path + +STAGES = ["script", "gate0", "calibrate", "assets", "assemble", "review", "cover", "deliver"] +STATE_FILE_NAME = "state.json" +HISTORY_DIR_NAME = "state.history" + +VALID_STATUSES = {"pending", "in_progress", "completed", "awaiting_human", "failed"} +# Stages that require human approval before advancing (borrowed from OpenMontage human_approval_default) +GATED_STAGES = {"script", "gate0", "calibrate", "assets", "deliver"} + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def state_path(project: Path) -> Path: + return project / STATE_FILE_NAME + + +def history_dir(project: Path) -> Path: + return project / HISTORY_DIR_NAME + + +def now_ts() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def init_state(project: Path) -> dict: + """Create initial state with all stages pending.""" + state = { + "project": str(project.resolve()), + "created": now_ts(), + "updated": now_ts(), + "stages": {s: {"status": "pending", "ts": None, "notes": ""} for s in STAGES}, + } + state_path(project).write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + return state + + +def load_state(project: Path) -> dict: + p = state_path(project) + if not p.is_file(): + die(f"state.json 不存在:{p}(先跑 --init)") + try: + state = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"state.json 损坏:{e}") + if "stages" not in state or set(state["stages"].keys()) != set(STAGES): + die(f"state.json stages 不匹配,期望 {list(STAGES)}") + return state + + +def archive_and_write(project: Path, state: dict) -> None: + """Archive current state to state.history/ (timestamped) then write new.""" + p = state_path(project) + if p.is_file(): + hist = history_dir(project) + hist.mkdir(exist_ok=True) + ts_slug = datetime.now().strftime("%Y%m%d_%H%M%S") + shutil.copy2(p, hist / f"state_{ts_slug}.json") + state["updated"] = now_ts() + p.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + + +def set_stage(project: Path, stage: str, status: str, notes: str | None) -> None: + if stage not in STAGES: + die(f"unknown stage: {stage}; valid: {STAGES}") + if status not in VALID_STATUSES: + die(f"unknown status: {status}; valid: {VALID_STATUSES}") + state = load_state(project) + cur = state["stages"][stage] + cur["status"] = status + cur["ts"] = now_ts() + if notes is not None: + cur["notes"] = notes + archive_and_write(project, state) + print(f"[ok] {stage} → {status}" + (f" ({notes})" if notes else "")) + + +def next_pending(project: Path) -> str | None: + """Return the first stage that's pending or failed or awaiting_human, in order.""" + state = load_state(project) + for s in STAGES: + st = state["stages"][s]["status"] + if st in ("pending", "failed", "awaiting_human"): + return s + return None + + +def show(project: Path) -> None: + state = load_state(project) + print(json.dumps(state, indent=2, ensure_ascii=False)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Pipeline state manager for video-product.") + parser.add_argument("project_dir", help="项目目录 (output_videos//)") + parser.add_argument("--init", action="store_true", help="初始化 state.json,所有阶段 pending") + parser.add_argument("--enter", metavar="STAGE", default=None, help="进入某阶段:标 in_progress") + parser.add_argument("--complete", metavar="STAGE", default=None, help="完成某阶段:标 completed") + parser.add_argument("--await", dest="await_", metavar="STAGE", default=None, help="等用户决策:标 awaiting_human") + parser.add_argument("--fail", metavar="STAGE", default=None, help="某阶段失败:标 failed") + parser.add_argument("--next", action="store_true", help="打印下一个待跑阶段") + parser.add_argument("--show", action="store_true", help="pretty-print 当前 state") + parser.add_argument("--notes", default=None, help="给 --enter/--complete/--await/--fail 附备注") + args = parser.parse_args() + + project = Path(args.project_dir).resolve() + if not project.is_dir(): + die(f"项目目录不存在:{project}") + + if args.init: + init_state(project) + print(f"[ok] state initialized at {state_path(project)}") + return + + # All other commands need existing state + if args.next: + nxt = next_pending(project) + if nxt is None: + print("[done] all stages completed — nothing to resume", file=sys.stderr) + sys.exit(2) + print(nxt) + return + + if args.show: + show(project) + return + + for flag, status in [("enter", "in_progress"), ("complete", "completed"), + ("await_", "awaiting_human"), ("fail", "failed")]: + val = getattr(args, flag) + if val is not None: + set_stage(project, val, status, args.notes) + return + + die("没指定动作:传 --init / --enter / --complete / --await / --fail / --next / --show 之一") + + +if __name__ == "__main__": + main() diff --git a/crews/main/skills/video-product/stages/input-sources.md b/crews/main/skills/video-product/stages/input-sources.md new file mode 100644 index 00000000..a29e4853 --- /dev/null +++ b/crews/main/skills/video-product/stages/input-sources.md @@ -0,0 +1,31 @@ +# 输入来源与预处理(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针,subagent 跑到输入解析阶段时按需 read 此文。 + +## 输入来源与预处理 + +### 来源 1:文章链接 + +- 微信公众号链接(`https://mp.weixin.qq.com/` 开头)→ 使用 `wx-mp-hunter` 技能获取标题和正文 +- 其他网页链接 → 使用 `web-fetch` 或 `browser` 工具获取标题和正文 +- 获取后将文章标题转为英文作为 `topic-en-slug`,正文存入 `raw_article.md` + +### 来源 2:追爆报告(viral-chaser 后续) + +- `topic-en-slug` 和编排目录由 viral-chaser 已创建,直接使用 +- 读取 `追爆报告.md`(也是存储于`raw_article.md`),按报告中的内容结构、爆款元素和可借鉴点生成脚本 +- **不套用三段式结构**,而是按照追爆报告拆解的原视频结构来组织脚本 + +### 来源 3:文字主题 + +- 用户直接给出主题或写作思路 → 基于主题撰写脚本 +- 可能附带参考资料(一段话、参考文章、图、视频等) + +### 来源 4:本地文件 + +- 读取文件内容,提炼标题作为 `topic-en-slug` + +> 如果输入过于简略或无法获取有效内容,与用户沟通调整,或建议先产出文章再转视频。 + +--- + diff --git a/crews/main/skills/video-product/stages/model-selection.md b/crews/main/skills/video-product/stages/model-selection.md new file mode 100644 index 00000000..6fdae9e5 --- /dev/null +++ b/crews/main/skills/video-product/stages/model-selection.md @@ -0,0 +1,56 @@ +# 模型选型与时长限制(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针,脚本创作阶段(Step 2)和生产阶段(Step 4)按需 read 此文。 + +## 模型选型与时长限制(脚本创作时必须遵守) + +视频素材优先使用 `gen.py` 脚本生成。 + +### 平台与模型 + +| 平台 | 环境变量 | 模型 | +|------|---------|------| +| 阿里云百炼(优先) | `MODELSTUDIO_API_KEY`(或 `DASHSCOPE_API_KEY`) | `happyhorse-1.1-i2v`、`happyhorse-1.1-t2v`、`happyhorse-1.1-r2v` | +| 火山引擎方舟 | `AWK_GEN_KEY` | `doubao-seedance-2-0-fast-260128`、`doubao-seedance-2-0-260128`、`doubao-seedance-2-0-mini-260615` | + +- 两个平台的上述模型**均支持声画同出**(t2v / i2v / r2v 三种模式)。 +- **平台自动判断写在 `gen.py` 里**:有 `MODELSTUDIO_API_KEY` 走百炼,否则有 `AWK_GEN_KEY` 走火山,两者皆无则输出提示让 Agent 改用 `pexels-footage`/`pixabay-footage`(退出码 2)。 + +### 百炼模型选择规则 + +按模式选首选模型,`gen.py` 自动沿候选链 fallback(happyhorse-1.1 → 1.0 → wan2.7)。 + +| 模式 | 首选模型 | 适用场景 | +|------|---------|---------| +| **r2v**(A.1 人物叙事 + A.3 用户参考图) | `happyhorse-1.1-r2v` | A.1 人物故事全段(`--ref-image` 传 `character_reference.jpg`);A.3 用户提供参考图片段(Step 3.4) | +| **t2v**(A.2 氛围叙事) | `happyhorse-1.1-t2v` | 手机底面、数据动画、产品特写等无重要人物的场景 | +| i2v | `happyhorse-1.1-i2v` | 如果需要指定首帧的话,使用`happyhorse-1.1-i2v`,传入图像会作为首帧图像。| + +- 候选链(每模式一条):`happyhorse-1.1-{mode}` → `happyhorse-1.0-{mode}` → `wan2.7-{mode}`。首选模型不可用或任务失败时 `gen.py` 自动沿链降级,无需人工干预。 +- **`--model ` 可显式覆盖**(关闭候选链 fallback,只用该模型);非必要不覆盖。 + +### WORKSPACE_ID 端点规则 + +配了 `WORKSPACE_ID` 时,happyhorse 走专属端点 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1`(华北2,更快);没配则走默认 `https://dashscope.aliyuncs.com/api/v1`。 + +这个设置对于火山(doubao-seedance系列模型)无效。 + +### 火山候选链 + +- 候选链优先级:Fast → Normal → Mini;1080P 自动跳过 Fast(Fast 仅 720p)。 +- ⚠️ **火山视频生成只认 `AWK_GEN_KEY`,不回退 `ARK_API_KEY`**:`ARK_API_KEY` 是火山主模型(doubao 对话)的 key,用户可能只想用火山主模型而不用火山生成视频;若回退会误触发火山视频生成。想用火山生成视频必须单独配 `AWK_GEN_KEY`。 + +### 模式与时长上限 + +| 模式 | 触发条件 | 百炼happyhorse-1.1上限 | 火山doubao-seedance上限 | +|------|---------|---------|---------| +| t2v(文生视频) | 无 `--image`/`--ref-image`/`--ref-video` | 3–15s | 2–15s | +| i2v(图生视频) | `--image`(首帧) | 3–15s | 2–15s | +| r2v(参考生视频) | `--ref-image`(用户提供参考图) | 3–15s | 2–15s | + +**脚本规划规则**: +- 每个片段时长 **不得超过 15 秒** +- 超过上限的内容**必须在脚本中拆成多个片段** + +--- + diff --git a/crews/main/skills/video-product/stages/prohibitions-notes.md b/crews/main/skills/video-product/stages/prohibitions-notes.md new file mode 100644 index 00000000..c631360d --- /dev/null +++ b/crews/main/skills/video-product/stages/prohibitions-notes.md @@ -0,0 +1,20 @@ +# 禁止事项 + 注意事项(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针,subagent 按需 read 此文。 + +## 禁止事项(强制) + +违反以下任何一条都会导致系统死机或产出异常,**必须严格遵守**: + +- **禁止直接写 ffmpeg 命令**:不得在 exec 中直接调用 ffmpeg/ffprobe,也不得写 Python 脚本内嵌 ffmpeg 调用。所有视频处理一律通过 `./skills/video-product/scripts/` 下的标准化脚本完成 +- **禁止从静态图生成视频**:不得将 JPEG/PNG 等静态图片通过 ffmpeg 转为 MP4。用户提供的静态图片仅作为 AI 生成参考图或搜索风格参考 + + +## 注意事项 + +- **配音语速不得为匹配视频时长而调整**:默认 1.0,只能按用户明确要求修改(Step 3 用户素材补配音时除外,此时语速可微调以适配素材时长) +- **素材按脚本顺序拼接**:assemble.py 按文件名数字前缀排序,搜集素材时务必按脚本片段编号命名 +- **AI 生成模式优先**:先调 `gen.py`;仅当其退出码 2(两个平台 env key 都没配)时才走 Stock Footage 模式 +- **用户素材优先于 AI 生成**:无论哪种模式,用户提供的素材必须优先使用 +- **声画同出**:`gen.py` 默认开启音频生成,prompt 中要详细描述背景音乐+环境音+对话/旁白 +- **无配音模式**:用户明确不需要配音时,`gen.py` 传 `--no-audio`;Stock Footage 模式跳过 TTS 步骤 diff --git a/crews/main/skills/video-product/stages/step2-script.md b/crews/main/skills/video-product/stages/step2-script.md new file mode 100644 index 00000000..e1543c27 --- /dev/null +++ b/crews/main/skills/video-product/stages/step2-script.md @@ -0,0 +1,205 @@ +# Step 2 — 脚本创作与定稿(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针,subagent 跑到 Step 2 时才主动 read 此文。 + +脚本必须包含**片段拆分规划**——每个片段对应一次 `gen.py` 调用或一个用户素材,时长不超过模型限制。 + +#### 2.1a 正常流程(文章/文字主题) + +按「开篇抓眼球 → 中段讲卖点 → 结尾促下单」三段式结构撰写脚本。 + +**三段式结构**: + +| 段落 | 时长占比 | 目标 | 示例套路 | +|------|---------|------|---------| +| **开篇抓眼球** | 前 15–20% | 3 秒内让人停止划走 | "99% 的人都不知道…" / "我花了 XX 才搞明白" / 强反差开场 | +| **中段讲卖点** | 60–70% | 展示核心价值,每个卖点一句 | 场景化痛点 → 产品/方法解决 → 数据/案例佐证 | +| **结尾促下单** | 后 15–20% | 明确 CTA,降低决策门槛 | "链接在简介" / "点击立即领取" / "限时优惠只剩 XX 件" | + +#### 2.1b 作为 viral-chaser 技能的后续步骤 + +读取 `raw_article.md`(追爆报告),按报告中的内容结构、爆款元素和可借鉴点生成脚本。**不套用三段式结构**,而是按照追爆报告拆解的原视频结构来组织脚本,保留叙事节奏和钩子类型。 + +#### 2.2 片段拆分(脚本必含项) + +##### 2.2.1 项目音色设定 + +声画同出模式下,模型按 prompt 中的音色描述生成人声,没有 voice ID 可传。**同一项目内旁白音色、同一角色音色必须跨片段一致**,否则成片声音段间跳变。脚本必须在片段规划表之前定义一份项目级音色设定,每段旁白逐字复用: + +```markdown +## 项目音色设定 + +- 旁白音色:<具体到性别/年龄感/音色质感/语速/语气,如"沉稳男声,30岁左右,略带磁性,语速适中偏慢,叙述感强"> +- 角色音色(人物故事模式按角色列,非人物故事可省): + - 主角(character_reference.jpg):<如"年轻女性,温柔清亮,语速偏快,口语化"> + - 配角:<…> +``` + +上述音色设定是跨片段整个脚本通用的设定,要放置在 `script.md` 中 `## 片段规划` 前面,并最后随片段规划一起发用户确认。 + +**音色一致性规则(强制)**: +- 音色描述要具体,不得只写"男声/女声" +- **音色描述只在「项目音色设定」里写一次,片段规划表里不重复**——片段规划的「音频描述」列只写旁白文案/BGM/环境音,不写音色 +- **调用 `gen.py` 时,必须把本段对应的音色描述逐字拼进 `--prompt`**(旁白段拼旁白音色,人物对话段拼对应角色音色),逐字复用、不得改写、换词、增删修饰——这是声画同出下成片声音统一的唯一保证 +- 同一角色跨段必须用同一条音色描述;换角色才换描述 +- ⚠️ 声画同出模型(wan2.7 / happyhorse / 火山 Seedance)均**无音色 ID 或参考音锁定能力**,`--ref-audio` 实测三平台都不认。音色一致**只能靠每段 prompt 逐字复用同一条音色描述**来近似保持——这是目前唯一手段,定稿时务必把音色描述钉死、段间一字不改 + +##### 2.2.2 片段规划 + +```markdown +## 片段规划 + +| # | 段落 | 画面描述 | 音频描述 | 时长 | 来源 | +|---|------|---------|---------|------|------| +| 01 | 开篇 | 产品特写,科技感背景,光影流转 | 旁白:"99%的人都不知道…" + 悬念BGM起 + 无 | 5s | AI生成 | +| 02 | 中段 | 用户使用场景,手机操作画面 | 旁白:"只需要三步…" + BGM + 键盘敲击声 | 8s | AI生成 | +| 03 | 中段 | 数据图表动画,对比效果 | 旁白:"效率提升300%" + BGM + 无 | 6s | AI生成 | +| 04 | 结尾 | 产品logo+CTA按钮 | 旁白:"立即体验" + BGM收尾 + 无 | 5s | AI生成 | +``` + +**拆分规则**: +- 每个片段时长 ≤ 15 秒 +- 如果用户提供了素材,在「来源」列标注为 `用户素材`,并注明素材文件名 +- **每个 AI 生成片段的「音频描述」必须写明三层**(声画同出,gen.py 照此生成声音,定稿时用户确认的就是成片实际听到的): + - **旁白解说**:`旁白:"具体文案"`,文案是要朗读的整句(不是"说一段开场白"这种泛指),这就是成片台词,用户定稿即确认 + - **背景音乐**:风格/情绪/起止(如"温暖钢琴 BGM 全段铺底,结尾渐弱");同一项目 BGM 风格也应统一,跨段复用同一 BGM 描述 + - **环境音/音效**:关键音效(如"键盘敲击声""金币叮声"),无则写"无" +- 画面描述同样要足够详细(人物/场景/动作/镜头运动) +- 编号 `01, 02, 03…` 对应最终 artifacts 中的文件名前缀 + +##### 2.2.3 slideshow-risk 自检清单(borrowed from OpenMontage storyboard gate,定稿前必跑) + +**片段规划写完后、发给用户定稿前,对规划表做一遍 slideshow-risk 自检**——AI 视频生成最容易出的硬伤不是画质差,是"看起来像 PPT 翻页动画"。把这一类硬伤拦在烧视频 API 费之前。 + +逐段对照规划表,过 3 维清单: + +| 维度 | 症状 | 检测规则 | 命中后处置 | +|------|------|---------|-----------| +| **repetition** | 多段画面描述高度雷同(同一静物特写 / 同一空镜 / 同一人物站姿) | 任两段画面描述的核心实体(人/物/场景)token 重合 ≥ 70% 即命中 | 重写其中一段换叙事功能(如把"产品正面特写"换成"用户手持产品实操"),或合并雷同段 | +| **weak-motion** | 段画面描述只有静态构图、没写镜头运动或主体动作("手机立在桌面" / "logo 出现") | 画面描述里没出现 `推/拉/摇/移/升/降/旋转/zoom/pan/动作动词` 任一即命中 | 给该段补镜头运动(如"镜头缓慢推近至屏幕中心")或主体动作(如"手指滑过屏幕触发动画");补不出则换段 | +| **typography-overreliance** | 段画面靠字幕/文字承担信息传递("画面出现'限时优惠'四个字" / "屏幕显示价格") | 画面描述里文字承担信息传递、且旁白不覆盖同信息即命中 | 把该信息改由旁白口播承担;画面文字仅作辅助强调,不能独立承担信息 | + +**执行约束**: +- 命中任一维度 → 重写该段画面描述,**不准发含硬伤的规划表给用户定稿** +- 命中 ≤ 1 段 → 当场改完即过;命中 ≥ 2 段 → 整体重审叙事结构,可能要回 2.1 重拆分段 +- Gate 0 contact sheet 是这一关的二次兜底——关键帧静图能再拦 repetition 和 weak-motion(同构图静图会显眼);typography-overreliance 只在脚本规划这关拦,静图拦不住 +- viral-chaser 后续流程因追爆报告已含原视频分镜,repetition / weak-motion 命中率天然低,但仍要过一遍 + +#### 2.3 与用户确认脚本(定稿流程) + +完成脚本创作后,必须将脚本原文发送给用户(直接发内容文字,不发文件或路径)。如果用户有意见,按用户意见修改,直到用户确认。 + +用户确认后,把定稿的脚本存入 `script.md`,进入下一步。 + +#### 2.3.5 Gate 0 — 关键帧 contact sheet 确认(借鉴 gbro Gate 2 + OpenMontage storyboard,定稿后、生产前) + +**脚本定稿后、进入打分和生产前,强制走 Gate 0**——用 `siliconflow-img-gen` 出每段关键帧静图,合成 contact sheet 发用户全段确认,**改文字免费、重生一张图远比重跑一段视频便宜**(gbro README 原话)。这是替代上一轮"裸 gen.py 串行生成 + 逐段肉眼确认"的脆弱闸门——把隐喻/构图错误拦在烧视频 API 费之前。 + +**Gate 0 流程**: + +1. **逐段生成关键帧静图**——对片段规划表里每段「画面描述」转成英文 prompt,调 `siliconflow-img-gen`: + ```bash + siliconflow-img-gen --prompt "<片段 01 画面描述英文版>" --image-size 1600x2848 --out-dir /keyframes/ + # 默认 doubao-seedream-4.5(不可用时脚本自动 fallback doubao-seedream-5.0-lite) + ``` + - 输出文件名按段编号:`01_.jpg`、`02_.jpg` … + - **人物故事模式(A.1)**:先用 `siliconflow-img-gen` 生人物定妆照(已有 Step 4 模式 A.1 流程),关键帧 prompt 里写 "the same character from the reference image — keep face/hair/age/outfit EXACTLY identical",靠 siliconflow-img-gen 的 image-edit 模式(`--image character_reference.jpg`)保持一致——**不要在 Gate 0 阶段另生新人物** + - **氛围段(A.2 t2v)**:直接生静图,无参考图 + - **用户参考图段(A.3 r2v)**:用 image-edit 模式 `--image <用户参考图>` 生静图,构图贴近未来 gen.py 会出的视频 + - ⚠️ 这一步**只生静图不调 gen.py**——目的是用图片确认构图,不烧视频 API 费 + +2. **合成 contact sheet**——把所有关键帧拼一张总图发用户: + ```bash + # 拼接策略:按段编号横向排列,每张缩到 270x480(保持 9:16 比例),最多 5 张一行多行 + ffmpeg -y -pattern_type glob -i "/keyframes/*.jpg" \ + -vf "scale=270:480,tile=5x1" \ + -frames:v 1 /keyframes/contact-sheet.jpg + ``` + - 段数 > 5 时分多行(`tile=5x2`、`5x3`…),段数 ≤ 5 时单行 + - **把 contact-sheet.jpg 文件本体直接发到聊天里**,请用户标"哪段通过 / 哪段改 / 哪段重做" + +3. **批量部分通过**——只有用户标"通过"的段才进 gen.py 批量队列: + - 通过段 → 进 Step 4 视频素材生产 + - 要改的段 → 改 prompt 后重生该段关键帧,重新拼 contact sheet 让用户确认(递增 `contact-sheet-v2.jpg`、`v3.jpg`,不覆盖旧版便于对比) + - 全段通过 → 进 2.4 打分流程 + +4. **Gate 0 旁路条件**(只在以下情况跳过): + - `AWK_API_KEY` 未配(siliconflow-img-gen 不可用)→ 向用户报告"Gate 0 不可用,直奔 gen.py 逐段生成 + 逐段确认",等用户决策 + - 片段数 ≤ 2 且用户明确说"直接生视频"→ 跳 Gate 0,但 Step 4 仍走逐段确认 + - viral-chaser 后续流程且追爆报告里已含关键帧分镜描述 → 用户已在追爆阶段确认过构图,跳 Gate 0 + +Gate 0 产物落 `/keyframes/`——**不进 artifacts/、不进 previews/**,与 review.py 的 `review/` 子目录同级,互不混淆。**关键帧静图不参与最终合成**,assemble.py 只扫 `artifacts/`。 + +#### 2.4 脚本定稿打分+盲预测(content-calibrator) + +脚本定稿后、进入生产前,对 `script.md` 做**一次盲打分 + 盲预测**并落盘到 `output_videos//calibration/`(视频成片后不再打分,打分锚在定稿)。**per-work:一个视频一次打分+预测**,rubric 全平台统一,各平台差异体现在预测的 bucket 上。 + +前置:目标视频平台中至少有一个已启用 calibration(`calibration//.platform-state.json` 存在)。无任何已启用平台 → 跳过本步。 + +1. 主 agent `sessions_spawn` blind sub-agent(一定要 spawn 第二个 subagent,避免同一个 subagent 自创自评),只喂 `script.md` + `calibration/rubric_notes.md`(统一 rubric),一次输出: + ⚠️ **spawn 时 prompt 必须强制要求**:"你最后一步的 reply 正文里**必须**包含一个 JSON 代码块(装着 7 维分 + 预测);不要只 tool-call 后 stop,不要只用 thinking 代替最终文本输出。" 不照此要求会导致某些模型路由下(如 awk/glm-latest)提前 stop 不输出文本,主 agent 拿不到结果。 + - 7 维分 ER/HP/SR/QL/NA/AB/PV(0-5)+ per-dim confidence + - 盲预测草稿:cold-start 期一句话 bet;过 cold-start 则含每个目标平台的 bucket/中枢(各平台 baseline 不同) +2. 调 `score-only.sh --content-path --cal-er ? …` 判阈值门(**全局阈值**,一次判定;`--platform` 可选)。 +3. 调 `commit-prediction.sh --work-dir output_videos/ --platform <主平台> --cal-er ? … --prediction-file <预测草稿>` 把 `score.json` + `prediction.md` 落盘到 `output_videos//calibration/`(同 work 重打覆盖)。**score.json 即权威记录,不再往 `script.md` 写分数区段。** +4. `passed=false` → 向用户报告 `failing_dims`,由用户决定是否改脚本重打(最多 2 轮,重打覆盖 `score.json`+`prediction.md`);用户不改则保留分数继续。 + +详见 `content-calibrator/SKILL.md` 流程 1A。发布时 `record.sh --source-folder output_videos/` 自动从 `calibration/score.json` 读分;本步未落盘则 record.sh 报错(或显式 `--no-cal` 跳过)。 + +#### 2.5 预算估算(borrowed from OpenMontage budget gate,定稿后、生产前强制) + +**脚本规划定稿 + Gate 0 通过 + 2.4 打分通过后,进入生产前强制输出全片预算估算**——用户确认估算再开跑,不准"边跑边发现贵"。这是替代上一轮"裸 gen.py 串行烧 API 费"的脆弱闸门。 + +**估算时机**:Gate 0 contact sheet 全段通过 + 2.4 打分 passed 之后,Step 4 开跑之前。Gate 0 旁路或 2.4 跳过的情况按各自规则处理,预算估算**不可旁路**(除非用户主动说"跳预算直接生")。 + +**估算内容**(发给用户一份,落盘 `/budget.json` 一份): + +```json +{ + "topic": "", + "total_duration_s": 28, + "segment_count": 4, + "segments": [ + {"id": "01", "duration_s": 5, "mode": "r2v", "model": "happyhorse-1.1-r2v", "platform": "dashscope"}, + {"id": "02", "duration_s": 8, "mode": "t2v", "model": "happyhorse-1.1-t2v", "platform": "dashscope"}, + {"id": "03", "duration_s": 6, "mode": "t2v", "model": "happyhorse-1.1-t2v", "platform": "dashscope"}, + {"id": "04", "duration_s": 5, "mode": "r2v", "model": "happyhorse-1.1-r2v", "platform": "dashscope"} + ], + "gate0_img_calls": 4, + "cover_img_calls": 1, + "video_calls": 4, + "expected_wall_minutes": 8, + "expected_cost_cny": 0.56, + "notes": "百炼 happyhorse-1.1 折扣价约 0.04 元/秒;火山 Seedance Fast 0.05 元/秒" +} +``` + +**估算算法**(agent 手工套表,不必上脚本): +1. **API 调用次数**: + - Gate 0 已跑过的关键帧静图调用 = 段数(`gate0_img_calls`,已花过,记入但不重算) + - 视频生成调用 = 段数(每段一次 `gen.py`,含候选链 fallback 时的重试预估——每段加 1 次兜底重试预算) + - 封面静图 = 1 次(`cover_img_calls`) +2. **预估耗时**:百炼单段 3–15s 视频实际渲染 1–4 分钟(happyhorse-1.1 较快,wan2.7 慢);火山 Seedance Fast 约 30–60s/段。串行生产总耗时 = Σ段预估。加 ±30% 缓冲报给用户。 +3. **预估费用**:按当前已配平台的价目套—— + - 百炼 happyhorse-1.1:约 0.04 元/秒(折扣期,按官方价目;价目变动以阿里云控制台为准) + - 百炼 wan2.7:约 0.05 元/秒 + - 火山 Seedance Fast:约 0.05 元/秒;Normal 约 0.08 元/秒;Mini 约 0.03 元/秒 + - 硅基 siliconflow-img-gen(Gate 0 + 封面):约 0.01–0.02 元/张 + - 候选链 fallback 预估:每段加 1 次兜底重试预算(实际多数不触发,估算法照估) + - **价目以官方控制台为准,估算时标注"参考价,实际以账单为准"** + +**用户确认协议**: +- 估算输出后**等用户明确回复"开跑"/"确认"/"继续"**才进 Step 4——不准擅自开跑 +- 用户回复"太贵/耗时太长"→ 重审脚本:缩段数 / 缩时长 / 换更便宜模型(如 Seedance Mini)/ 多用 Stock Footage 替段,重出估算 +- 用户回复"跳预算直接生"→ 落 `budget.json` 标 `skipped=true` + 用户原话 notes,直奔 Step 4 + +**逐段累计**(生产期对照,落 `/budget.json` 的 `actual` 字段,append-only): +- 每段 `gen.py` 跑完记 `actual.segments[i].wall_s`(实际墙钟秒)+ `actual.segments[i].cost_cny`(按平台价目套实际时长) +- 候选链 fallback 触发时记 `actual.segments[i].fallback_to` + 追加费用(decisions.log 也会落,此处只算钱) +- **超估算 ±20% 时向用户报告**:"段 NN 实际耗时/费用超估算 X%,是否继续"——不准擅自继续 +- 全片完工后比对 `expected` vs `actual`,落 `budget.variance_pct` 给后续 calibration 喂数(OpenMontage 没有,是我们加的——帮 calibrator 校准未来项目的预测准度) + +**budget.json 与 decisions.log 的分工**: +- `decisions.log`:append-only 决策审计,gen.py fallback 每次落一条,手不准编辑 +- `budget.json`:point-in-time 预算 + 实际累计,每段更新时整体重写(不是 append),最后留 variance 供 calibrator +- 两份不同别混——`decisions.log` 记"为什么 fallback",`budget.json` 记"花了多少时间/钱" diff --git a/crews/main/skills/video-product/stages/step3-user-assets.md b/crews/main/skills/video-product/stages/step3-user-assets.md new file mode 100644 index 00000000..8a91442c --- /dev/null +++ b/crews/main/skills/video-product/stages/step3-user-assets.md @@ -0,0 +1,47 @@ +# Step 3 — 用户素材预处理(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针,subagent 跑到 Step 3 时才主动 read 此文。 + + +> **此步骤优先于所有其他生产步骤**。无论 AI 生成模式还是 Stock Footage 模式,用户素材都必须先处理。 + +如果用户提供了素材(视频文件、图片等),按以下流程处理: + +#### 3.1 确认素材归属 + +对照脚本片段规划,确认每个素材对应哪个片段编号。如果脚本中未明确标注,与用户确认: +- 该素材放在哪个段落(开篇/中段/结尾)? +- 是否需要额外配音或配乐? + +#### 3.2 处理视频素材 + +对于用户提供的 **视频文件**(.mp4/.mov/.webm): + +1. **探测时长**:用 ffprobe 获取视频时长(assemble.py 内置此能力,也可直接读文件属性) +2. **配音配乐检查**: + - 如果视频**无音轨**或**用户要求补充配音** → 执行 3.3 补音频 + - 如果视频**已有满意音轨** → 直接使用,跳到 3.4 +3. **按片段编号命名**:重命名为 `01_xxx.mp4`、`02_xxx.mp4` 等,放入 `artifacts/` + +#### 3.3 补配音配乐(用户素材需要时) + +当用户素材需要补充音频时: + +1. **确定目标时长**:以素材视频的实际时长为准 +2. **生成配音**: + - 优先使用 OpenClaw 内置 TTS 工具(`tts_generate`) + - 不可用时回退到 `tts.py`(需先创建 `tts_requirement.md`) + - 生成的音频时长必须与视频时长匹配(TTS 语速可微调以适配) +3. **合成片段**:将配音与视频合成为带音轨的片段 + +```bash +python3 ./skills/video-product/scripts/assemble.py /artifacts/ --output /artifacts/_final.mp4 +``` + +4. 将合成后的片段放回 artifacts,保持编号 + +#### 3.4 处理图片素材 + +用户提供的**静态图片**(.jpg/.png)**禁止直接转视频**。图片仅作为: +- AI 生成时的**参考图**(`gen.py` 的 `--ref-image` 传入,本地路径或 URL 均可) +- Stock Footage 搜索时的**风格参考** diff --git a/crews/main/skills/video-product/stages/step4-assets.md b/crews/main/skills/video-product/stages/step4-assets.md new file mode 100644 index 00000000..878f7883 --- /dev/null +++ b/crews/main/skills/video-product/stages/step4-assets.md @@ -0,0 +1,179 @@ +# Step 4 — 视频素材生产(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针 + 前置条件,subagent 跑到 Step 4 时才主动 read 此文。 + +> 前置条件:Step 3 已完成,用户素材已就位并编号放入 artifacts/。 + +**只生产脚本中标注为「AI生成」的片段**,用户素材片段已在 Step 3 处理完毕。逐片段调用 `gen.py`,脚本按平台自动选模型(百炼按模式走候选链,火山走 Fast→Normal→Mini 候选链)。 + +#### 模式 A:AI 生成模式(gen.py,默认) + +按脚本片段规划,**根据 Step 2.5 的人物一致性要求,逐个生成**。每片段一条 `gen.py` 调用,串行执行(下一段等上一段下载完成再发)。 + +##### 模式 A.1:人物故事模式(人物叙事类片段必用,参考图保持人物一致) + +人物一致性靠**同一张参考图**:第 0 步生成人物定妆照,**每段都以它为 `--ref-image` 走 r2v**(首选 `happyhorse-1.1-r2v`(沿链 fallback))。**不做段间首尾帧链式生成**(实测意义不大):每段独立生成,画面不强制连续,叙事连续靠 prompt 文字承接。 + +**完整流程**: + +**第 0 步:生成人物参考图**(整段故事只做一次) + +用 `siliconflow-img-gen` 技能生成人物定妆照,保存为 `/character_reference.jpg`。这张图定义人物的脸/发型/年龄/服装,后续所有片段都以它为 `--ref-image` 保持人物一致。 + +**每段生成(统一 r2v + 参考图)** + +```bash +python3 ./skills/video-product/scripts/gen.py \ + --prompt "画面描述:The same character from the reference image — keep face/hair/age/outfit EXACTLY identical to the reference. 本段场景与动作描述。音频描述" \ + --ref-image "/character_reference.jpg" \ + --ratio 9:16 --resolution 720P --duration 8 \ + --output /artifacts/NN_xxx.mp4 +``` + +全段同一张参考图,首选 `happyhorse-1.1-r2v`(沿链 fallback)。**不传 `--image` / `--prev-segment`**(r2v 不收首帧)。 + +**每段生成后必须发给用户确认,确认后才生成下一段**(确认流程见下文「逐段确认」)。各段独立生成,下一段不依赖上一段产物。 + +**逐段确认流程**(每段视频生成后执行): + +1. 用 `compress_preview.py` 把该段视频处理成可发送的预览: + ```bash + python3 ./skills/video-product/scripts/compress_preview.py /artifacts/NN_xxx.mp4 \ + --output /previews/NN_xxx_preview.mp4 + ``` + - 输入 ≤16MB → 脚本直接拷贝,exit 0,打印 `[ok] under-limit` + - 输入 >16MB → 脚本逐级压缩到 ≤16MB,exit 0,打印 `[ok] compressed` + - 压缩失败 → exit 1,打印 `[fail]` +2. 根据脚本结果向用户确认: + - exit 0 → **把预览视频文件本体直接发到聊天里**(`previews/NN_xxx_preview.mp4`),请用户确认本段画面 + - exit 1 → **把原始片段路径发给用户**,告知"压缩失败,请在本机打开 `/artifacts/NN_xxx.mp4` 查看",请用户确认 +3. 用户确认本段 → 继续生成下一段(独立生成,不带 `--prev-segment`);用户要求重做 → 调整 prompt 重新生成本段(不推进到下一段) + +⚠️ **`previews/` 下的压缩预览仅用于给用户确认,绝不参与最终合成**。`assemble.py` 只扫描 `artifacts/`,`previews/` 自然被排除;预览文件名带 `_preview` 后缀进一步避免混淆。**禁止把预览放进 `artifacts/`**。 + +**人物故事模式必须遵守**: + +- **先生成人物参考图,再逐段生成视频**;**每段都用 `--ref-image`(同一张 `character_reference.jpg`),全程 r2v(`happyhorse-1.1-r2v`),不传 `--image` / `--prev-segment`** +- **逐段确认**:每段生成后必须发用户确认,确认后才生成下一段 +- **时长限制**:全段 r2v(happyhorse-1.1-r2v)3–15s;脚本拆分时每段 ≤15s +- **平台偏好**:人物故事模式**优先用百炼(`MODELSTUDIO_API_KEY`)**。火山 Seedance 不支持直接上传含真人人脸的参考图/视频,传 `--ref-image` 人物图可能被拒 +- **prompt 对人物明确描述**:每段都写"the same character from the reference image — keep face/hair/age/outfit EXACTLY identical",靠参考图维持人物一致 +- **角色音色跨段一致**:主角音色由「项目音色设定」中的角色条目统一规定,每段 prompt 的旁白音色描述必须逐字复用同一条,不得段间改写——人物故事里同一张脸却换了声音是硬伤 +- **画面描述主焦一个明确动作**:单一动作 + 克制摄像机运动,避免同片段引入过多新道具/新人物导致穿帮 +- **镜头运动要平和**:推荐 subtle slow push-in / minimal motion / static shot +- **叙事承接**:各段画面独立,prompt 文案上可承接上一段叙事,但不做首尾帧对齐 +- `--ref-image` 支持本地路径(脚本自动 base64)或 `http(s)` URL + +##### 模式 A.2:t2v 模式(氛围叙事类片段) + +不传 `--image`,只写 prompt。适合手机底面、数据动画、产品特写等不含重要人物的场景: + +```bash +python3 ./skills/video-product/scripts/gen.py \ + --prompt "画面描述:产品特写镜头,科技感背景,光影流转。音频:转场音效+悬念BGM起" \ + --ratio 9:16 --resolution 720P --duration 12 \ + --output /artifacts/02_xxx.mp4 +``` + +##### 模式 A.3:r2v 模式(仅用户提供参考图时,对应 Step 3.4) + +**仅当某片段用户提供了参考图**(Step 3.4 静态图片作为参考)时才走 r2v,首选 `happyhorse-1.1-r2v`(沿链 fallback),传 `--ref-image`: + +```bash +python3 ./skills/video-product/scripts/gen.py \ + --prompt "参考图片中的角色/风格在 <新场景> 做 <动作>,音频:…" \ + --ref-image "<用户提供的参考图路径或URL>" \ + --ratio 9:16 --resolution 720P --duration 8 \ + --output /artifacts/03_xxx.mp4 +``` + +- 百炼 r2v 首选 `happyhorse-1.1-r2v`(沿链 fallback),时长 3–15s,**仅支持 `--ref-image`**(不支持 `--ref-video`、不支持首帧 `--image`)。 +- A.1 人物故事也走 r2v(同一模型),区别只在参考图来源:A.1 用生成的 `character_reference.jpg`,A.3 用用户提供的图。 +- `--ref-image` 支持本地路径(脚本自动 base64)或 `http(s)` URL。 + +**参数说明**: +- `--prompt`:**画面+音频统一描述**。声画同出,人物对话、旁白、BGM、环境音都写在 prompt 中。 +- `--ratio`:默认 `9:16`(竖屏);`--resolution` 默认 `720P`,用户要高清用 `1080P`。 +- `--duration`:按脚本片段时长,**不得超过 15 秒**(百炼 i2v/r2v 最短 3 秒)。 +- `--no-audio`:用户明确不要配音时关闭声画同出。 +- `--model`:显式指定模型 id,覆盖百炼按模式固定的模型。`--platform` 可覆盖自动检测。 +- `--poll-interval` / `--timeout`:默认 15s / 900s,1080P 或长片段可加大 `--timeout`。 + +**生成后处理**: +- `gen.py` 直接把 MP4 写到 `--output`(按片段编号命名,如 `01_hook_product.mp4`),并同目录写 `.json` 元数据。 +- 若生成失败无音轨,后续由 Step 4.5 补 TTS。 + +##### 生产中常见错误与重试策略 + +| 错误 | 原因 | 处理 | +|------|------|------| +| `gen.py` 退出码 2 + pexels/pixabay 提示 | 两个平台 env key 都没配 | 按提示走模式 B,或 spawn IT Engineer 配置 `MODELSTUDIO_API_KEY`/`AWK_GEN_KEY` | +| HTTP 401 / API key doesn't exist | key 与平台/地域不匹配 | 检查 env 变量是否对应平台;百炼用 `MODELSTUDIO_API_KEY`,火山用 `AWK_GEN_KEY` | +| HTTP 404 / Invalid model | model id 错误 | 检查 `--model` 是否在支持列表内;火山模型须含 `doubao-` 前缀 | +| 任务 FAILED / 超时 | 渲染慢(1080P/长片段)或参数不兼容 | 百炼沿链自动 fallback(1.1→1.0→wan2.7);仍失败则降低分辨率/缩短时长重试,或 `--model` 指定模型 | +| r2v 报错退出(传了 `--image`/`--ref-video`) | r2v 仅 `--ref-image`(happyhorse-1.1-r2v 起沿链) | r2v 不收首帧;人物故事统一用 `--ref-image`,不要传 `--image`/`--prev-segment` | +| `--output must be relative to the workspace` / `--output must be under one of: output_videos` | exec 直接调 gen.py,CWD 不在 workspace-media-operator,或 `--output` 用了绝对路径 | **exec 必须显式设 `workdir="/home/wukong/.openclaw/workspace-media-operator"`**,且 `--output` 必须是相对路径形如 `output_videos//artifacts/NN_xxx.mp4`。gen.py 内部 `ensure_safe_output()` 强制只允许 `output_videos/` 下的相对路径,靠 `Path.cwd()` 解析根目录;同理 `compress_preview.py` 也要求相对 `--output` 在 `previews/`/`tmp/`/`output_videos/` 下,需要同样的 workdir 设置 | +| `exec denied: allowlist miss` 调 `cd && python3 ...` | `cd` 不在 allowlist(TOOLS.md 明确禁止),导致整条命令被拒 | 不要用 `cd && cmd` 包装;改用 exec 的 `workdir` 参数显式指定 CWD,命令本身用绝对路径调脚本 + 相对 `--output` | + +**重试上限**:`gen.py` 内部做瞬时 HTTP 重试;百炼沿候选链自动 fallback(happyhorse-1.1 → 1.0 → wan2.7),整链都失败退出非 0 再人工重试 1 次,仍不通就告诉老板,不要 yield 死等。 + +#### 模式 B:Stock Footage 托底模式(gen.py 退出码 2 时) + +当 `gen.py` 报"未检测到任何视频生成平台的环境变量"(退出码 2)时,回退到此模式。 + +**此模式下需要单独生成 TTS 配音**(见 Step 4.5),因为下载的素材无音频。 + +素材搜集优先级: +1. **`pexels-footage`**:从 Pexels 免费素材库搜索下载(9:16 疖屏) +2. **`pixabay-footage`**:Pexels 不可用或无结果时,从 Pixabay 下载 + +**素材下载规则**: +- 一次只下载一个视频 +- 时长精准匹配(根据脚本片段时长设置 `--min-duration` / `--max-duration`) +- 下载后按脚本片段编号重命名 + +**质量自检**(仅 stock-footage 模式需要): + +```bash +python3 ./skills/video-product/scripts/check.py / +``` + +check.py 检测黑帧、分辨率、时长缺口。每下载一段素材后运行一次,直到 `verdict: "accepted"` 且时长满足。 + +#### Step 4.5 — TTS 配音(仅 Stock Footage 模式或 AI 生成无音频时) + +> **AI 生成模式下通常跳过此步骤**:Wan 系列的 `audio: true` 已同步生成音频。 + +当需要单独生成 TTS 时: + +**优先使用 OpenClaw 内置 TTS 工具**(`tts_generate` 或 agent 内置语音合成能力)。 + +OpenClaw 内置 TTS 不可用时,回退到本地脚本(要求环境变量已经配置SILICONFLOW_API_KEY): + +```bash +python3 ./skills/video-product/scripts/tts.py / --overwrite +``` + +需先创建 `tts_requirement.md`: + +```markdown +# 配音需求 + +## 配音文案 + + +## 语音要求 +- 音色:fnlp/MOSS-TTSD-v0.5:benjamin +- 语速:1.0 +- 语气:自然、有吸引力 +``` + +可用语音: + +| Voice ID | 说明 | +|----------|------| +| `fnlp/MOSS-TTSD-v0.5:benjamin` | 幽默男声,语速较慢,推荐 | +| `fnlp/MOSS-TTSD-v0.5:charles` | 激昂男声,适合广告 | +| `fnlp/MOSS-TTSD-v0.5:claire` | 清澈女声,推荐 | +| `fnlp/MOSS-TTSD-v0.5:david` | 清脆男声 | +| `fnlp/MOSS-TTSD-v0.5:diana` | 可爱女声,娃娃音 | diff --git a/crews/main/skills/video-product/stages/step5-compose.md b/crews/main/skills/video-product/stages/step5-compose.md new file mode 100644 index 00000000..50c0e132 --- /dev/null +++ b/crews/main/skills/video-product/stages/step5-compose.md @@ -0,0 +1,89 @@ +# Step 5 / 5.5 / 6 — 合成、自检、封面(stages/ 子文档) + +> 主 SKILL.md 在此段只保留导航指针,subagent 跑到 Step 5/5.5/6 时才主动 read 此文。 + +### Step 5 — 合成视频 + +调用 assemble.py 将所有片段按编号顺序拼接为最终成品。 + +**⚠️ 合成前必须先清理废弃片段**:逐段确认过程中产生的废弃版本(如 `02_choose_path.v1_bad.mp4`、`03_traffic_master.v1_old.mp4` 等)**和正式片段共用同一数字前缀**,assemble.py 会把它们当成对应段一起拼进去,导致成片重复/错乱。合成前先删除或移出 `artifacts/`: + +```bash +# 把废弃版本移到 artifacts/_deprecated/ 子目录(assemble.py 非递归扫描,子目录不参与拼接) +mkdir -p /artifacts/_deprecated +mv /artifacts/*.v*_*.mp4 /artifacts/_deprecated/ 2>/dev/null +# 或直接删除:rm /artifacts/*.v*_*.mp4 +``` + +清理后确认 `artifacts/` 顶层只剩 `01_*.mp4 … NN_*.mp4` 每段一个正式片段,再合成: + +```bash +python3 ./skills/video-product/scripts/assemble.py /artifacts/ --output /video.mp4 +``` + +合成规则: +- **无外部音频文件**(AI 声画同出模式常态):assemble.py 保留每段视频自带音轨拼接;个别无音轨的片段自动补静音以保持拼接布局一致,不会把成片变无声 +- **有外部音频文件**(`speech.mp3` 等,Stock Footage + TTS 模式):外部音频替换视频原音轨 +- 不烧录字幕 + +assemble.py 按文件名数字前缀(`01_`、`02_`、`03_`…)顺序拼接,同一前缀内按文件名字典序。 + +**段间转场(可选,用户要时才开)**——默认 concat 硬切,传 `--transition crossfade` 走 ffmpeg `xfade` 链做交叉淡变: + +```bash +python3 ./skills/video-product/scripts/assemble.py /artifacts/ \ + --output /video.mp4 \ + --transition crossfade --transition-duration 0.5 +``` + +⚠️ **转场会缩成片总时长**——每段重叠 `transition-duration` 秒做交叉淡变,**成片总时长 = Σ段时长 - (段数-1) × 转场时长**。脚本规划阶段(Step 2.2.2)若预算用转场,每段时长要加 `transition-duration` 补回;review.py 的 `--target-duration` 也要按缩后总时长算,否则时长校验 critical。 + +转场参数:默认 0.5s 重叠(`--transition-duration` 可调),xfade 做视频轨、acrossfade 同步音频轨淡变。视频轨走 libx264 重渲染(不是 concat 的 stream copy,所以耗时长 + 损画质少),声画同出模式每段音轨保住。 + +旁路条件(不开转场的情况): +- 用户没明确要"转场"/"淡变"/"crossfade"→ 走默认 concat 硬切 +- 单段素材(无段间可做)→ assemble.py 自动退默认 assemble +- 段时长 ≤ 转场时长(如段 0.3s 配 transition-duration 0.5s)→ xfade offset 超段长报错,缩 transition-duration 或回硬切 + +合成后确认 `video.mp4` 存在且非空。 + +### Step 5.5 — 成片自检(强制,借鉴 OpenMontage post-render self-review + gbro Gate 3 QA) + +**`video.mp4` 产出后、向用户交付前,必须强制跑 `review.py`**——不准跳过、不准肉眼看交。这是替代上一轮"裸 assemble.py 拼完就交"的脆弱闸门。 + +```bash +python3 ./skills/video-product/scripts/review.py \ + --target-duration <片段规划表「时长」列累加值> \ + --target-resolution <720x1280 | 1080x1920 | 按脚本画面比例> +``` + +**target-duration** 从 `script.md` 片段规划表「时长」列累加得出;**target-resolution** 按 Step 4 选的 `--ratio` + `--resolution` 推(`9:16` + `720P` → `720x1280`,`1080P` → `1080x1920`)。 + +`review.py` 做五件事: +1. ffprobe 全字段校验(codec / 分辨率 / fps / pix_fmt / 音轨配置) +2. 5 位抽帧(0% / 25% / 50% / 75% 100%)→ 黑帧/overlay 损检测(≥2 张黑帧 = critical) +3. 音频电平分析(mean_db / max_db,过静 < -60dB 或削顶 ≥ -1dB = critical;无声轨 = warn,需对照 pipeline 模式判断) +4. 时长 vs target(超 ±5% = critical;±1% 外 = warn) +5. 分辨率齐校(成片 vs 各段 artifacts 不齐 = critical;低于 720p = critical) + +退出码即判定: +- **exit 0 → `verdict: pass`** → 进 Step 6 制作封面 +- **exit 1 → `verdict: fail`** → 有 critical issue,**不准交**。按 `critical[]` 修复(重拼 / 重生成不齐段 / 重调 assemble.py)后再跑一次 review.py。最多重修 2 轮,仍 fail 则向用户复述 critical 项请求决策 +- **exit 2 → `verdict: warn`** → 有 non-critical 提示,**向用户复述 warnings[] 让其决定**是重修还是接受。不准自主判定通过 +- **exit 3 → 脚本本身故障**(ffprobe 缺失 / 路径错 / …)→ 不算评审结论,先修脚本 + +verdict JSON 默认落盘到 `/review/verdict.json`,抽帧落到 `/review/frames/`——**不进 artifacts/、不进 previews/**,自检产物跟合成产物隔离,避免混淆 assemble.py。 + +⚠️ **声画同出模式(默认)下 `audio_absent` warning 要对照看**:gen.py 声画同出的片有声轨是常态;若 review.py 报 `audio_absent` 且你走的是 AI 生成模式,这是 critical(gen.py 该出声没出声),降级处理退回 gen.py 重生成或补 Step 4.5 TTS。Stock Footage + `--no-audio` 模式下 `audio_absent` 是预期,warn 可放行。 + +### Step 6 — 制作封面 + +每个视频都必须配封面图。封面要求: +- **必须包含视频标题文字**,不允许纯图片封面 +- 标题文字必须有设计感(字体选择、排版布局、颜色搭配) +- 竖屏封面 1080x1920 +- 可以使用视频关键画面作为背景,但文字是必须元素 + +使用 `siliconflow-img-gen` 制作封面,保存为 `/cover.jpg`。 + +### Step 7 — 用户确认 From 0614e8b9f6313013abc89fe1e503e5feb6d56371 Mon Sep 17 00:00:00 2001 From: codes-factory-of-bg Date: Fri, 24 Jul 2026 16:57:02 +0800 Subject: [PATCH 03/66] video-product: gen.py append fallback to decisions.log + assemble.py --transition crossfade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen.py: - new append_decision() helper: append-only ISO timestamp | event | detail line to decisions.log - generate() calls append_decision on every candidate-chain fallback (records from→to + reason) - generate() calls append_decision when all candidates exhausted (records model list + last error) - borrowed from OpenMontage decision_log pattern assemble.py: - new --transition {none,crossfade} arg (default none) - new _apply_crossfade() helper: ffmpeg xfade+acrossfade 鲜, 0.5s per adjacent pair - ≤3 segments use xfade direct chain; >3 segments fall back to hard cut (avoid ffmpeg filter graph explosion) - probe ffmpeg -filters for xfade support, skip crossfade if missing - fall back to concat product on any ffprobe/normalize/xfade failure - assemble() / assemble_multiple_videos() pass transition through Co-Authored-By: AtomCode (GLM-5.2) --- .../skills/video-product/scripts/assemble.py | 132 +++++++++++++++++- .../main/skills/video-product/scripts/gen.py | 22 ++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/crews/main/skills/video-product/scripts/assemble.py b/crews/main/skills/video-product/scripts/assemble.py index 21eedc33..d5ff4424 100644 --- a/crews/main/skills/video-product/scripts/assemble.py +++ b/crews/main/skills/video-product/scripts/assemble.py @@ -252,7 +252,122 @@ def _tail_file(path: str, max_chars: int) -> str: return "" -def assemble_multiple_videos(video_files: list[str], audio_file: str | None, output_path: str) -> None: +def _apply_crossfade(video_files: list[str], width: int, height: int, + concat_path: str, tmp_dir: str, drop_audio: bool) -> str: + """用 ffmpeg xfade + acrossfade 鲡把已 normalize+concat 的多段拼成 crossfade 转场. + + 借鉴 OpenMontage Backlot 看板的 transition 能力,平替成 ffmpeg 鲡—— + 每相邻段间插 0.5s crossfade,video 走 xfade transition=fade,audio 走 acrossfade. + + 输入:concat_path 是 _normalize_and_concat_batch 出的硬切拼接产物。 + 输出:落到 tmp_dir/crossfade.mp4,返新路径。单段或 ffmpeg 不带 xfade 时退原 concat_path 不鲂。 + """ + if len(video_files) < 2: + return concat_path + + # ffmpeg xfade 鲡要逐段输 + offset,段太多复杂度炸——鲂到 ≤3 段用 xfade 鲝接, + # >3 段退硬切不鲂(避鲡 ffmpeg 鲡超长鲡串炸) + if len(video_files) > 3: + print("[warn] crossfade >3 段不鲂(ffmpeg xfade 鲡串复杂度炸),退硬切") + return concat_path + + # 探 ffmpeg 带 xfade + probe = subprocess.run(["ffmpeg", "-hide_banner", "-filters"], capture_output=True, text=True, timeout=30) + if probe.returncode != 0 or "xfade" not in probe.stdout: + print("[warn] ffmpeg 不带 xfade 滤镜,退硬切不鲂 crossfade") + return concat_path + + # 每段 duration 取(ffprobe),xfade offset = 塚段时长 - transition_duration + import json + durations = [] + for vf in video_files: + r = subprocess.run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "v", vf, + ], capture_output=True, text=True, timeout=30) + if r.returncode != 0: + print(f"[warn] ffprobe 取 {vf} duration 失败,退硬切") + return concat_path + try: + data = json.loads(r.stdout) + s = data.get("streams", [{}])[0] + dur = float(s.get("duration", 0) or 0) + durations.append(dur if dur > 0 else 0) + except (json.JSONDecodeError, ValueError, IndexError): + print(f"[warn] ffprobe 解 {vf} duration 失败,退硬切") + return concat_path + + transition_dur = 0.5 # 每相邻段间 0.5s crossfade + output = os.path.join(tmp_dir, "crossfade.mp4") + + # 鲡逐段 normalize 后的产物(_normalize_and_concat_batch 落 tmp_dir 下 seg_*.mp4) + # xfade 鲡鲠逐段输,不走 concat 产物——重跑逐段 normalize 拿独立段 + seg_files = [] + for i, vf in enumerate(video_files): + seg = os.path.join(tmp_dir, f"seg_{i:02d}.mp4") + cmd = [ + "ffmpeg", "-y", "-i", vf, + "-vf", f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30", + "-c:v", "libx264", "-preset", "fast", "-crf", "18", + "-pix_fmt", "yuv420p", + ] + if drop_audio: + cmd += ["-an"] + else: + cmd += ["-c:a", "aac", "-b:a", "192k"] + cmd += ["-movflags", "+faststart", seg] + _run_ffmpeg(cmd, f"normalize seg {i}") + seg_files.append(seg) + + if len(seg_files) == 2: + offset = max(0.0, durations[0] - transition_dur) + fc = f"[0:v][1:v]xfade=transition=fade:duration={transition_dur}:offset={offset}[v]" + if not drop_audio: + fc += f";[0:a][1:a]acrossfade=d={transition_dur}[a]" + cmd = [ + "ffmpeg", "-y", "-i", seg_files[0], "-i", seg_files[1], + "-filter_complex", fc, + ] + cmd += ["-map", "[v]"] + if not drop_audio: + cmd += ["-map", "[a]", "-c:a", "aac", "-b:a", "192k"] + cmd += [ + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", output, + ] + _run_ffmpeg(cmd, "crossfade 2 seg") + elif len(seg_files) == 3: + # 鲡两 xfade 串:先 seg0+seg1 → tmp,再 tmp+seg2 + tmp1 = os.path.join(tmp_dir, "xfade_tmp1.mp4") + offset1 = max(0.0, durations[0] - transition_dur) + cmd1 = [ + "ffmpeg", "-y", "-i", seg_files[0], "-i", seg_files[1], + "-filter_complex", + f"[0:v][1:v]xfade=transition=fade:duration={transition_dur}:offset={offset1}[v]", + "-map", "[v]", "-c:v", "libx264", "-preset", "fast", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", tmp1, + ] + _run_ffmpeg(cmd1, "crossfade seg0+seg1") + offset2 = max(0.0, (durations[0] + durations[1] - transition_dur) - transition_dur) + cmd2 = [ + "ffmpeg", "-y", "-i", tmp1, "-i", seg_files[2], + "-filter_complex", + f"[0:v][1:v]xfade=transition=fade:duration={transition_dur}:offset={offset2}[v]", + "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", output, + ] + _run_ffmpeg(cmd2, "crossfade tmp+seg2") + else: + return concat_path + + if os.path.exists(output) and os.path.getsize(output) > 0: + return output + print("[warn] crossfade 出物空或失败,退硬切 concat 产物") + return concat_path + + +def assemble_multiple_videos(video_files: list[str], audio_file: str | None, + output_path: str, transition: str = "none") -> None: width, height = get_video_dimensions(video_files[0]) width = even(width) height = even(height) @@ -270,6 +385,11 @@ def assemble_multiple_videos(video_files: list[str], audio_file: str | None, out drop_audio=bool(audio_file), ) + # Step 1.5: transition between segments (crossfade via xfade+acrossfade 鲂) + if transition == "crossfade" and len(video_files) > 1: + video_only = _apply_crossfade(video_files, width, height, video_only, tmp_dir, + drop_audio=bool(audio_file)) + # Step 2: mux audio if present if audio_file: cmd = [ @@ -287,7 +407,7 @@ def assemble_multiple_videos(video_files: list[str], audio_file: str | None, out shutil.rmtree(tmp_dir, ignore_errors=True) -def assemble(artifacts_dir: str, output_path: str) -> None: +def assemble(artifacts_dir: str, output_path: str, transition: str = "none") -> None: excluded = {output_path} video_files = find_files(artifacts_dir, VIDEO_EXTS, exclude=excluded) if not video_files: @@ -298,12 +418,14 @@ def assemble(artifacts_dir: str, output_path: str) -> None: print(f"[info] Assembling: videos={', '.join(os.path.basename(path) for path in video_files)}") if audio_file: print(f" audio={os.path.basename(audio_file)}") + if transition != "none": + print(f" transition={transition}") if len(video_files) == 1: cmd = assemble_single_video(video_files[0], audio_file, output_path) _run_ffmpeg(cmd, "assemble single") else: - assemble_multiple_videos(video_files, audio_file, output_path) + assemble_multiple_videos(video_files, audio_file, output_path, transition) if not os.path.exists(output_path) or os.path.getsize(output_path) == 0: die("Output file is missing or empty") @@ -318,6 +440,8 @@ def main() -> None: parser = argparse.ArgumentParser(description="Assemble video fragment: video + audio → MP4") parser.add_argument("artifacts_dir", help="Directory containing video/audio artifacts") parser.add_argument("--output", default=None, help="Output MP4 path (default: /assembled.mp4)") + parser.add_argument("--transition", default="none", choices=["none", "crossfade"], + help="Transition between segments: none=hard cut (default) / crossfade=xfade+acrossfade 鲜") args = parser.parse_args() if not os.path.isdir(args.artifacts_dir): @@ -326,7 +450,7 @@ def main() -> None: output_path = args.output or os.path.join(args.artifacts_dir, "assembled.mp4") os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) - assemble(args.artifacts_dir, output_path) + assemble(args.artifacts_dir, output_path, args.transition) if __name__ == "__main__": diff --git a/crews/main/skills/video-product/scripts/gen.py b/crews/main/skills/video-product/scripts/gen.py index 55434d62..bbc59373 100644 --- a/crews/main/skills/video-product/scripts/gen.py +++ b/crews/main/skills/video-product/scripts/gen.py @@ -99,6 +99,23 @@ def log(message: str) -> None: print(f"[info] {message}") +def append_decision(entry: str) -> None: + """候选链 fallback 或全失败时往 decisions.log 追一行(借鉴 OpenMontage decision_log). + + 落点:workdir 下 decisions.log(gen.py 的 workdir 由 ensure_safe_output 约束在 workspace 根, + decisions.log 同落那)。append-only,不动旧内容。格式:ISO 时间 | 事件 | 详情。 + 落盘失败不阻塞主流程——decisions.log 是审计辅助,不是硬约束。 + """ + try: + from datetime import datetime + ts = datetime.now().astimezone().isoformat(timespec="seconds") + line = f"{ts} | {entry}\n" + with Path("decisions.log").resolve().open("a", encoding="utf-8") as f: + f.write(line) + except Exception: + pass + + # ---- asset resolution --------------------------------------------------------- def is_url(value: str) -> bool: @@ -559,7 +576,10 @@ def generate(platform: str, candidates: list[str], args: argparse.Namespace, api if pinned: break # respect explicit user choice — no chain walk if idx < len(models) - 1: - log(f"falling back to next model: {models[idx + 1]}") + next_model = models[idx + 1] + log(f"falling back to next model: {next_model}") + append_decision(f"fallback | {model} → {next_model} | reason: {last_err}") + append_decision(f"all candidates exhausted | models: {','.join(models)} | last error: {last_err}") die(f"all model attempts failed; last error: {last_err}") From 794a230b44b3329aa5b4f199fc10e8381864631d Mon Sep 17 00:00:00 2001 From: bigbrother666sh Date: Fri, 24 Jul 2026 23:39:30 +0800 Subject: [PATCH 04/66] =?UTF-8?q?=E8=A7=86=E9=A2=91=E7=94=9F=E4=BA=A7?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E9=87=8D=E8=A7=84=E5=88=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/video-product/SKILL.md | 1 + .../skills/video-product/scripts/assemble.py | 457 ++++++++++++ .../skills/video-product/scripts/check.py | 472 ++++++++++++ .../video-product/scripts/compress_preview.py | 161 +++++ .../scripts/extract_and_concat.py | 545 ++++++++++++++ .../skills/video-product/scripts/gen.py | 675 ++++++++++++++++++ .../skills/video-product/scripts/review.py | 485 +++++++++++++ .../skills/video-product/scripts/state.py | 184 +++++ .../skills/video-product/scripts/tts.py | 457 ++++++++++++ .../video-product/stages/input-sources.md | 0 .../video-product/stages/model-selection.md | 0 .../stages/prohibitions-notes.md | 0 .../video-product/stages/step2-script.md | 0 .../video-product/stages/step3-user-assets.md | 0 .../video-product/stages/step4-assets.md | 0 .../video-product/stages/step5-compose.md | 0 crews/main/BUILTIN_SKILLS | 4 +- .../skills/bilibili-publish/SKILL.md | 0 .../bilibili-publish/bilibili-publish.sh | 0 .../scripts/publish_bilibili.py | 0 .../scripts/tests/test_publish_bilibili.py | 0 .../skills/ui-demo/SKILL.md | 0 crews/main/skills/video-product/SKILL.md | 375 ++-------- crews/main/skills/viral-chaser/SKILL.md | 10 +- .../main/skills}/youtube-publish/SKILL.md | 0 .../scripts/publish_youtube.py | 0 .../youtube-publish/youtube-publish.sh | 0 docs/plan-video-workflow-reshape.md | 625 ---------------- .../research-vimax-htmlvideo-videoproducer.md | 226 ------ 29 files changed, 3482 insertions(+), 1195 deletions(-) create mode 100644 crews/content-producer/skills/video-product/SKILL.md create mode 100644 crews/content-producer/skills/video-product/scripts/assemble.py create mode 100644 crews/content-producer/skills/video-product/scripts/check.py create mode 100644 crews/content-producer/skills/video-product/scripts/compress_preview.py create mode 100755 crews/content-producer/skills/video-product/scripts/extract_and_concat.py create mode 100644 crews/content-producer/skills/video-product/scripts/gen.py create mode 100644 crews/content-producer/skills/video-product/scripts/review.py create mode 100644 crews/content-producer/skills/video-product/scripts/state.py create mode 100644 crews/content-producer/skills/video-product/scripts/tts.py rename crews/{main => content-producer}/skills/video-product/stages/input-sources.md (100%) rename crews/{main => content-producer}/skills/video-product/stages/model-selection.md (100%) rename crews/{main => content-producer}/skills/video-product/stages/prohibitions-notes.md (100%) rename crews/{main => content-producer}/skills/video-product/stages/step2-script.md (100%) rename crews/{main => content-producer}/skills/video-product/stages/step3-user-assets.md (100%) rename crews/{main => content-producer}/skills/video-product/stages/step4-assets.md (100%) rename crews/{main => content-producer}/skills/video-product/stages/step5-compose.md (100%) rename crews/{content-producer => main}/skills/bilibili-publish/SKILL.md (100%) rename crews/{content-producer => main}/skills/bilibili-publish/bilibili-publish.sh (100%) rename crews/{content-producer => main}/skills/bilibili-publish/scripts/publish_bilibili.py (100%) rename crews/{content-producer => main}/skills/bilibili-publish/scripts/tests/test_publish_bilibili.py (100%) rename crews/{content-producer => main}/skills/ui-demo/SKILL.md (100%) rename {skills => crews/main/skills}/youtube-publish/SKILL.md (100%) rename {skills => crews/main/skills}/youtube-publish/scripts/publish_youtube.py (100%) rename {skills => crews/main/skills}/youtube-publish/youtube-publish.sh (100%) delete mode 100644 docs/plan-video-workflow-reshape.md delete mode 100644 docs/research-vimax-htmlvideo-videoproducer.md diff --git a/crews/content-producer/skills/video-product/SKILL.md b/crews/content-producer/skills/video-product/SKILL.md new file mode 100644 index 00000000..15c78b17 --- /dev/null +++ b/crews/content-producer/skills/video-product/SKILL.md @@ -0,0 +1 @@ +TODO: 这里的stages和scripts作为原子能力,供video producer其他技能调用过程和整合 \ No newline at end of file diff --git a/crews/content-producer/skills/video-product/scripts/assemble.py b/crews/content-producer/skills/video-product/scripts/assemble.py new file mode 100644 index 00000000..d5ff4424 --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/assemble.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Assemble a video fragment: combine video + audio into one MP4. + +Usage: + python3 ./skills/fragment-assembly/scripts/assemble.py [--output ] + +Expects artifacts_dir to contain: + - One or more video files (.mp4/.mov/.webm) + - Optionally one audio file (.mp3/.wav/.opus) + +Audio handling: + - No external audio file → preserve each video segment's own audio track (声画同出 + AI 片段直接拼接,每段音轨保留;无音轨的片段补静音以保持拼接布局一致). + - External audio file present (e.g. speech.mp3) → it replaces the video's audio + track (Stock Footage + TTS 模式). +Output defaults to /assembled.mp4. +""" + +import argparse +import gc +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +VIDEO_EXTS = {".mp4", ".mov", ".webm", ".avi", ".mkv"} +AUDIO_EXTS = {".mp3", ".wav", ".opus", ".ogg", ".flac", ".pcm"} + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + +def _sort_key(filename: str) -> tuple[int, str]: + """Sort key: files with numeric prefix (01_*.mp4) sort by number first, + then by full name. Files without prefix sort after all prefixed files. + + This ensures script-ordered materials like 01_hook.mp4, 02_value.mp4, + 03_cta.mp4 are concatenated in narrative order rather than pure lexicographic. + """ + stem = os.path.splitext(filename)[0] + match = re.match(r"^(\d+)[_\-\s]", stem) + if match: + return (int(match.group(1)), filename) + # No numeric prefix → sort after all prefixed files (use large sentinel) + return (999999, filename) + + +def find_files(directory: str, extensions: set[str], exclude: set[str] | None = None) -> list[str]: + """Find files matching given extensions in script-order (numeric prefix first).""" + excluded = {os.path.abspath(path) for path in (exclude or set())} + files: list[str] = [] + for name in os.listdir(directory): + filepath = os.path.join(directory, name) + if os.path.abspath(filepath) in excluded: + continue + if os.path.isfile(filepath) and os.path.splitext(name)[1].lower() in extensions: + files.append(filepath) + files.sort(key=lambda p: _sort_key(os.path.basename(p))) + return files + + +def find_audio_file(directory: str, exclude: set[str] | None = None) -> str | None: + """Prefer speech.* audio, then fall back to the first audio file.""" + audio_files = find_files(directory, AUDIO_EXTS, exclude=exclude) + for filepath in audio_files: + if Path(filepath).stem == "speech": + return filepath + if audio_files: + return audio_files[0] + return None + + +def get_duration(filepath: str) -> float: + """Get media duration via ffprobe.""" + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", filepath], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + data = json.loads(result.stdout) + return float(data.get("format", {}).get("duration", 0)) + except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError): + pass + return 0.0 + + +def get_video_dimensions(filepath: str) -> tuple[int, int]: + """Get video dimensions via ffprobe.""" + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-select_streams", "v:0", "-show_streams", filepath], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + data = json.loads(result.stdout) + stream = next((item for item in data.get("streams", []) if item.get("codec_type") == "video"), {}) + width = int(stream.get("width", 0)) + height = int(stream.get("height", 0)) + if width > 0 and height > 0: + return width, height + except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError): + pass + return 1080, 1920 + + +def even(value: int) -> int: + return value if value % 2 == 0 else value - 1 + + +def _segment_has_audio(path: str) -> bool: + """Return True if the media file has at least one audio stream.""" + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "a", + "-show_entries", "stream=codec_type", "-of", "csv=p=0", path], + capture_output=True, text=True, timeout=15, + ) + return bool(result.stdout.strip()) + except subprocess.SubprocessError: + return False + + +def assemble_single_video(video_file: str, audio_file: str | None, output_path: str) -> list[str]: + cmd: list[str] = ["ffmpeg", "-y", "-i", video_file] + if audio_file: + cmd.extend(["-i", audio_file]) + + cmd.extend(["-c:v", "copy"]) + if audio_file: + cmd.extend(["-map", "0:v", "-map", "1:a", "-c:a", "aac", "-b:a", "192k"]) + else: + cmd.extend(["-map", "0:v", "-map", "0:a?", "-c:a", "copy"]) + + cmd.extend(["-movflags", "+faststart", "-pix_fmt", "yuv420p", output_path]) + return cmd + + +def _normalize_and_concat_batch(video_files: list[str], width: int, height: int, + output_path: str, tmp_dir: str, + drop_audio: bool = False, batch_size: int = 3) -> str: + """Normalize a batch of videos, then concat with ffmpeg concat demuxer. + + Processes videos in small batches to keep memory bounded (~300-500MB per ffmpeg run) + instead of one giant filter_complex that opens all inputs simultaneously. + + Audio handling: + - drop_audio=True (external audio will replace): strip audio with -an. + - drop_audio=False (preserve per-segment audio, e.g. 声画同出 AI 片段): re-encode each + segment's audio to a uniform aac/48k/stereo so concat -c copy works. Segments with + no audio get a silent track (anullsrc) so the concat stream layout stays uniform. + """ + tmp_files: list[str] = [] + vf_filter = (f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2," + f"setsar=1,fps=30,format=yuv420p") + audio_encode = ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2"] + + # Step 1: normalize each video individually (scale/pad/fps/format + audio) + for i, vf in enumerate(video_files): + tmp_out = os.path.join(tmp_dir, f"norm_{i:04d}.mp4") + if drop_audio: + cmd: list[str] = [ + "ffmpeg", "-y", "-i", vf, "-vf", vf_filter, + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26", + "-threads", "1", "-an", "-movflags", "+faststart", tmp_out, + ] + elif _segment_has_audio(vf): + cmd = [ + "ffmpeg", "-y", "-i", vf, "-vf", vf_filter, + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26", + *audio_encode, "-threads", "1", "-movflags", "+faststart", tmp_out, + ] + else: + # No audio in this segment but we're preserving → add a silent track so all + # normalized files share the same (v+a) layout for concat -c copy. + cmd = [ + "ffmpeg", "-y", "-i", vf, + "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-vf", vf_filter, "-map", "0:v:0", "-map", "1:a:0", + "-c:v", "libx264", "-preset", "ultrafast", "-crf", "26", + *audio_encode, "-shortest", "-threads", "1", + "-movflags", "+faststart", tmp_out, + ] + _run_ffmpeg(cmd, f"normalize [{i+1}/{len(video_files)}]") + tmp_files.append(tmp_out) + # Release memory held by the ffmpeg subprocess buffers + gc.collect() + + # Step 2: concat all normalized files via concat demuxer (stream copy, no re-encode) + concat_list = os.path.join(tmp_dir, "_concat_list.txt") + with open(concat_list, "w", encoding="utf-8") as f: + for tf in tmp_files: + abs_tf = os.path.abspath(tf) + escaped = abs_tf.replace("'", "'\\''") + f.write(f"file '{escaped}'\n") + + cmd = [ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_list, + "-c", "copy", "-movflags", "+faststart", output_path, + ] + _run_ffmpeg(cmd, "concat") + return output_path + + +def _run_ffmpeg(cmd: list[str], label: str, timeout: int = 600) -> None: + # Pin to core 0 + low priority to prevent system freeze on resource-constrained hosts + wrapped_cmd = ["taskset", "-c", "0", "nice", "-n", "10"] + cmd + print(f"[info] {label}: {' '.join(os.path.basename(c) if '/' in c else c for c in cmd)}") + # Stream stderr to a temp file instead of buffering in memory. + # ffmpeg outputs progress line-by-line to stderr which can grow very large. + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as stderr_f: + stderr_path = stderr_f.name + try: + with open(stderr_path, "w") as stderr_fh: + result = subprocess.run( + wrapped_cmd, stdout=subprocess.DEVNULL, stderr=stderr_fh, text=True, timeout=timeout, + ) + if result.returncode != 0: + # Read only the tail of stderr for the error message + tail = _tail_file(stderr_path, 2000) + die(f"ffmpeg {label} failed (exit {result.returncode}):\n{tail}") + except subprocess.TimeoutExpired: + die(f"ffmpeg {label} timed out after {timeout}s") + finally: + try: + os.unlink(stderr_path) + except OSError: + pass + + +def _tail_file(path: str, max_chars: int) -> str: + """Read the last N characters of a file without loading the whole thing.""" + try: + size = os.path.getsize(path) + if size <= max_chars: + with open(path, "r", errors="replace") as f: + return f.read() + with open(path, "rb") as f: + f.seek(size - max_chars) + f.readline() # skip partial first line + return f.read().decode(errors="replace") + except OSError: + return "" + + +def _apply_crossfade(video_files: list[str], width: int, height: int, + concat_path: str, tmp_dir: str, drop_audio: bool) -> str: + """用 ffmpeg xfade + acrossfade 鲡把已 normalize+concat 的多段拼成 crossfade 转场. + + 借鉴 OpenMontage Backlot 看板的 transition 能力,平替成 ffmpeg 鲡—— + 每相邻段间插 0.5s crossfade,video 走 xfade transition=fade,audio 走 acrossfade. + + 输入:concat_path 是 _normalize_and_concat_batch 出的硬切拼接产物。 + 输出:落到 tmp_dir/crossfade.mp4,返新路径。单段或 ffmpeg 不带 xfade 时退原 concat_path 不鲂。 + """ + if len(video_files) < 2: + return concat_path + + # ffmpeg xfade 鲡要逐段输 + offset,段太多复杂度炸——鲂到 ≤3 段用 xfade 鲝接, + # >3 段退硬切不鲂(避鲡 ffmpeg 鲡超长鲡串炸) + if len(video_files) > 3: + print("[warn] crossfade >3 段不鲂(ffmpeg xfade 鲡串复杂度炸),退硬切") + return concat_path + + # 探 ffmpeg 带 xfade + probe = subprocess.run(["ffmpeg", "-hide_banner", "-filters"], capture_output=True, text=True, timeout=30) + if probe.returncode != 0 or "xfade" not in probe.stdout: + print("[warn] ffmpeg 不带 xfade 滤镜,退硬切不鲂 crossfade") + return concat_path + + # 每段 duration 取(ffprobe),xfade offset = 塚段时长 - transition_duration + import json + durations = [] + for vf in video_files: + r = subprocess.run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_streams", "-select_streams", "v", vf, + ], capture_output=True, text=True, timeout=30) + if r.returncode != 0: + print(f"[warn] ffprobe 取 {vf} duration 失败,退硬切") + return concat_path + try: + data = json.loads(r.stdout) + s = data.get("streams", [{}])[0] + dur = float(s.get("duration", 0) or 0) + durations.append(dur if dur > 0 else 0) + except (json.JSONDecodeError, ValueError, IndexError): + print(f"[warn] ffprobe 解 {vf} duration 失败,退硬切") + return concat_path + + transition_dur = 0.5 # 每相邻段间 0.5s crossfade + output = os.path.join(tmp_dir, "crossfade.mp4") + + # 鲡逐段 normalize 后的产物(_normalize_and_concat_batch 落 tmp_dir 下 seg_*.mp4) + # xfade 鲡鲠逐段输,不走 concat 产物——重跑逐段 normalize 拿独立段 + seg_files = [] + for i, vf in enumerate(video_files): + seg = os.path.join(tmp_dir, f"seg_{i:02d}.mp4") + cmd = [ + "ffmpeg", "-y", "-i", vf, + "-vf", f"scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30", + "-c:v", "libx264", "-preset", "fast", "-crf", "18", + "-pix_fmt", "yuv420p", + ] + if drop_audio: + cmd += ["-an"] + else: + cmd += ["-c:a", "aac", "-b:a", "192k"] + cmd += ["-movflags", "+faststart", seg] + _run_ffmpeg(cmd, f"normalize seg {i}") + seg_files.append(seg) + + if len(seg_files) == 2: + offset = max(0.0, durations[0] - transition_dur) + fc = f"[0:v][1:v]xfade=transition=fade:duration={transition_dur}:offset={offset}[v]" + if not drop_audio: + fc += f";[0:a][1:a]acrossfade=d={transition_dur}[a]" + cmd = [ + "ffmpeg", "-y", "-i", seg_files[0], "-i", seg_files[1], + "-filter_complex", fc, + ] + cmd += ["-map", "[v]"] + if not drop_audio: + cmd += ["-map", "[a]", "-c:a", "aac", "-b:a", "192k"] + cmd += [ + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", output, + ] + _run_ffmpeg(cmd, "crossfade 2 seg") + elif len(seg_files) == 3: + # 鲡两 xfade 串:先 seg0+seg1 → tmp,再 tmp+seg2 + tmp1 = os.path.join(tmp_dir, "xfade_tmp1.mp4") + offset1 = max(0.0, durations[0] - transition_dur) + cmd1 = [ + "ffmpeg", "-y", "-i", seg_files[0], "-i", seg_files[1], + "-filter_complex", + f"[0:v][1:v]xfade=transition=fade:duration={transition_dur}:offset={offset1}[v]", + "-map", "[v]", "-c:v", "libx264", "-preset", "fast", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", tmp1, + ] + _run_ffmpeg(cmd1, "crossfade seg0+seg1") + offset2 = max(0.0, (durations[0] + durations[1] - transition_dur) - transition_dur) + cmd2 = [ + "ffmpeg", "-y", "-i", tmp1, "-i", seg_files[2], + "-filter_complex", + f"[0:v][1:v]xfade=transition=fade:duration={transition_dur}:offset={offset2}[v]", + "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", output, + ] + _run_ffmpeg(cmd2, "crossfade tmp+seg2") + else: + return concat_path + + if os.path.exists(output) and os.path.getsize(output) > 0: + return output + print("[warn] crossfade 出物空或失败,退硬切 concat 产物") + return concat_path + + +def assemble_multiple_videos(video_files: list[str], audio_file: str | None, + output_path: str, transition: str = "none") -> None: + width, height = get_video_dimensions(video_files[0]) + width = even(width) + height = even(height) + + # Use a temp dir for intermediate files, clean up on success + tmp_dir = os.path.join(os.path.dirname(output_path) or ".", "_assemble_tmp") + os.makedirs(tmp_dir, exist_ok=True) + + try: + # Step 1: normalize + concat. When external audio will replace, drop per-segment + # audio during normalize; otherwise preserve each segment's audio (声画同出). + video_only = os.path.join(tmp_dir, "video_only.mp4") + _normalize_and_concat_batch( + video_files, width, height, video_only, tmp_dir, + drop_audio=bool(audio_file), + ) + + # Step 1.5: transition between segments (crossfade via xfade+acrossfade 鲂) + if transition == "crossfade" and len(video_files) > 1: + video_only = _apply_crossfade(video_files, width, height, video_only, tmp_dir, + drop_audio=bool(audio_file)) + + # Step 2: mux audio if present + if audio_file: + cmd = [ + "ffmpeg", "-y", "-i", video_only, "-i", audio_file, + "-map", "0:v", "-map", "1:a", + "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", + "-movflags", "+faststart", output_path, + ] + _run_ffmpeg(cmd, "mux audio") + else: + # No external audio → the concat already preserved per-segment audio. + os.replace(video_only, output_path) + finally: + if os.path.isdir(tmp_dir): + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def assemble(artifacts_dir: str, output_path: str, transition: str = "none") -> None: + excluded = {output_path} + video_files = find_files(artifacts_dir, VIDEO_EXTS, exclude=excluded) + if not video_files: + die(f"No video file found in {artifacts_dir}") + + audio_file = find_audio_file(artifacts_dir, exclude=excluded) + + print(f"[info] Assembling: videos={', '.join(os.path.basename(path) for path in video_files)}") + if audio_file: + print(f" audio={os.path.basename(audio_file)}") + if transition != "none": + print(f" transition={transition}") + + if len(video_files) == 1: + cmd = assemble_single_video(video_files[0], audio_file, output_path) + _run_ffmpeg(cmd, "assemble single") + else: + assemble_multiple_videos(video_files, audio_file, output_path, transition) + + if not os.path.exists(output_path) or os.path.getsize(output_path) == 0: + die("Output file is missing or empty") + + duration = get_duration(output_path) + size_mb = os.path.getsize(output_path) / (1024 * 1024) + print(f"[done] Assembled: {output_path}") + print(f" duration={duration:.2f}s size={size_mb:.1f}MB") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Assemble video fragment: video + audio → MP4") + parser.add_argument("artifacts_dir", help="Directory containing video/audio artifacts") + parser.add_argument("--output", default=None, help="Output MP4 path (default: /assembled.mp4)") + parser.add_argument("--transition", default="none", choices=["none", "crossfade"], + help="Transition between segments: none=hard cut (default) / crossfade=xfade+acrossfade 鲜") + args = parser.parse_args() + + if not os.path.isdir(args.artifacts_dir): + die(f"Not a directory: {args.artifacts_dir}") + + output_path = args.output or os.path.join(args.artifacts_dir, "assembled.mp4") + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + + assemble(args.artifacts_dir, output_path, args.transition) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-product/scripts/check.py b/crews/content-producer/skills/video-product/scripts/check.py new file mode 100644 index 00000000..3b1dbdf8 --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/check.py @@ -0,0 +1,472 @@ +#!/usr/bin/env python3 +"""Content check for content-producer artifacts. + +Checks media files via ffprobe and calculates duration gap against target. +Target duration is determined by: + 1. If artifacts/speech.json exists with a "duration" field → target = speech duration + 1s + 2. Else if --target-duration is provided → target = that value + 3. Else if fragment/requirement.md contains a target duration → target = that value + 4. Else → no duration target check + +ASR/TTS verification has been moved to the siliconflow-tts skill itself. + +Usage: + python3 ./skills/content-check/scripts/check.py [--target-duration ] +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +VIDEO_EXTS = {".mp4", ".mov", ".avi", ".webm", ".mkv"} +AUDIO_EXTS = {".mp3", ".wav", ".opus", ".pcm", ".ogg", ".flac"} +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} +SRT_EXT = ".srt" +TTS_BUFFER_SECONDS = 1.0 +EXCESS_DURATION_SECONDS = 5 # flag when actual > target + this value (silent gap too long) +BLACK_FRAME_THRESHOLD = 0.02 # fraction of pixels below luma 32 to consider "black" +BLACK_SAMPLE_COUNT = 5 # number of keyframes to sample for blank detection + + +def die(msg: str) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(1) + + +def unique_paths(paths: list[Path]) -> list[Path]: + seen: set[Path] = set() + result: list[Path] = [] + for path in paths: + resolved = path.resolve() + if resolved not in seen: + seen.add(resolved) + result.append(path) + return result + + +def resolve_fragment_paths(input_dir: str) -> tuple[Path, Path]: + """Accept either a fragment directory or its artifacts directory.""" + path = Path(input_dir) + if path.name == "artifacts": + return path, path.parent + + artifacts_dir = path / "artifacts" + if artifacts_dir.is_dir(): + return artifacts_dir, path + + return path, path.parent + + +# ── Media probing ────────────────────────────────────────────────────── + +def probe_video(filepath: str) -> dict: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", filepath], + capture_output=True, text=True, timeout=30, + ) + if result.returncode != 0: + return {"file": os.path.basename(filepath), "error": f"ffprobe exit {result.returncode}"} + + data = json.loads(result.stdout) + fmt = data.get("format", {}) + streams = data.get("streams", []) + + video_stream = None + audio_stream = None + for s in streams: + if s.get("codec_type") == "video" and video_stream is None: + video_stream = s + elif s.get("codec_type") == "audio" and audio_stream is None: + audio_stream = s + + info: dict = { + "file": os.path.basename(filepath), + "duration": round(float(fmt.get("duration", 0)), 2), + "size_bytes": int(fmt.get("size", 0)), + } + if video_stream: + info["video"] = { + "codec": video_stream.get("codec_name", "unknown"), + "width": int(video_stream.get("width", 0)), + "height": int(video_stream.get("height", 0)), + "fps": video_stream.get("r_frame_rate", "unknown"), + "pix_fmt": video_stream.get("pix_fmt", "unknown"), + } + if audio_stream: + info["audio"] = { + "codec": audio_stream.get("codec_name", "unknown"), + "sample_rate": int(audio_stream.get("sample_rate", 0)), + "channels": int(audio_stream.get("channels", 0)), + } + + issues: list[str] = [] + if video_stream: + w = int(video_stream.get("width", 0)) + h = int(video_stream.get("height", 0)) + if w < 720 or h < 720: + issues.append(f"low resolution: {w}x{h}") + pix_fmt = video_stream.get("pix_fmt", "") + if pix_fmt and "420" not in pix_fmt and w > 0: + issues.append(f"non-standard pixel format: {pix_fmt}") + if info["duration"] < 1.0: + issues.append("very short duration") + + # Blank frame detection for videos >= 2s + if info["duration"] >= 2.0: + blank_result = detect_blank_frames(filepath, info["duration"]) + if blank_result: + info["blank_frame_check"] = blank_result + if blank_result["status"] == "mostly_blank": + issues.append(f"mostly blank frames ({blank_result['blank_count']}/{blank_result['sampled']} sampled)") + + if issues: + info["issues"] = issues + return info + + except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, ValueError) as e: + return {"file": filepath, "error": str(e)} + + +def probe_audio(filepath: str) -> dict: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", filepath], + capture_output=True, text=True, timeout=15, + ) + if result.returncode != 0: + return {"file": os.path.basename(filepath), "error": f"ffprobe exit {result.returncode}"} + + data = json.loads(result.stdout) + fmt = data.get("format", {}) + streams = data.get("streams", []) + audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), None) + + info: dict = { + "file": os.path.basename(filepath), + "duration": round(float(fmt.get("duration", 0)), 2), + } + if audio_stream: + info["codec"] = audio_stream.get("codec_name", "unknown") + info["sample_rate"] = int(audio_stream.get("sample_rate", 0)) + info["channels"] = int(audio_stream.get("channels", 0)) + if info["duration"] < 0.5: + info.setdefault("issues", []).append("very short duration") + return info + + except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError) as e: + return {"file": filepath, "error": str(e)} + + +def check_image(filepath: str) -> dict: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", filepath], + capture_output=True, text=True, timeout=15, + ) + if result.returncode != 0: + return {"file": os.path.basename(filepath), "error": "ffprobe failed"} + + data = json.loads(result.stdout) + img_stream = next((s for s in data.get("streams", []) if s.get("codec_type") == "video"), None) + info: dict = {"file": os.path.basename(filepath)} + if img_stream: + w = int(img_stream.get("width", 0)) + h = int(img_stream.get("height", 0)) + info.update(width=w, height=h, codec=img_stream.get("codec_name", "unknown")) + if w < 1080 or h < 1080: + info["issues"] = [f"resolution below 1080p: {w}x{h}"] + else: + info["error"] = "no image stream found" + return info + + except (subprocess.TimeoutExpired, json.JSONDecodeError) as e: + return {"file": filepath, "error": str(e)} + + +def check_srt(filepath: str) -> dict: + """Basic SRT validation: non-empty and has at least one timestamp line.""" + info: dict = {"file": os.path.basename(filepath)} + try: + content = Path(filepath).read_text(encoding="utf-8").strip() + if not content: + info["issues"] = ["empty SRT file"] + elif "-->" not in content: + info["issues"] = ["no timestamp markers found"] + else: + cue_count = content.count("-->") + info["cue_count"] = cue_count + except OSError as e: + info["error"] = str(e) + return info + + +# ── Blank/Black Frame Detection ──────────────────────────────────────── + +def detect_blank_frames(filepath: str, duration: float) -> dict | None: + """Sample keyframes from a video and detect blank/black frames. + + Uses ffmpeg's blackframe filter to check if sampled timestamps are + predominantly black (near-uniform low-luma content). + + Returns a dict with: + - sampled: number of timestamps checked + - blank_count: number of blank frames detected + - blank_ratio: fraction of sampled frames that are blank + - status: "ok" or "mostly_blank" + Or None if duration is too short to sample. + """ + if duration < 2.0: + return None + + # Calculate evenly-spaced sample timestamps + n_samples = min(BLACK_SAMPLE_COUNT, max(2, int(duration / 2))) + step = duration / (n_samples + 1) + timestamps = [round(step * (i + 1), 2) for i in range(n_samples)] + + blank_count = 0 + for ts in timestamps: + try: + # Use ffmpeg blackframe filter: detect frames with >98% pixels below luma 32 + result = subprocess.run( + ["ffmpeg", "-ss", str(ts), "-i", filepath, + "-vframes", "1", "-vf", "blackframe=amount=0.98:threshold=32", + "-f", "null", "-"], + capture_output=True, text=True, timeout=15, + ) + # blackframe filter prints lines like "frame:1 pblack:99 ..." + if "pblack:" in result.stderr: + # Extract the highest pblack value + import re + pblack_values = [int(m) for m in re.findall(r"pblack:(\d+)", result.stderr)] + max_pblack = max(pblack_values) if pblack_values else 0 + if max_pblack >= 98: + blank_count += 1 + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + blank_ratio = blank_count / n_samples if n_samples > 0 else 0 + return { + "sampled": n_samples, + "blank_count": blank_count, + "blank_ratio": round(blank_ratio, 2), + "status": "mostly_blank" if blank_ratio >= 0.6 else "ok", + } + + +# ── Target Duration Calculation ──────────────────────────────────────── + +def read_speech_target(artifacts_dir: Path, fragment_dir: Path) -> tuple[float, str] | None: + candidates = unique_paths([ + artifacts_dir / "speech.json", + fragment_dir / "artifacts" / "speech.json", + fragment_dir / "speech.json", + ]) + for speech_json_path in candidates: + if not speech_json_path.is_file(): + continue + try: + data = json.loads(speech_json_path.read_text(encoding="utf-8")) + speech_dur = data.get("duration", 0) + if isinstance(speech_dur, (int, float)) and speech_dur > 0: + target = float(speech_dur) + TTS_BUFFER_SECONDS + print(f"[info] Target duration from TTS: {speech_dur:.3f}s + {TTS_BUFFER_SECONDS}s buffer = {target:.3f}s") + return target, str(speech_json_path) + except (json.JSONDecodeError, OSError): + continue + return None + + +def parse_duration_seconds(raw: str) -> float | None: + text = raw.lower() + numbers = re.findall(r"\d+(?:\.\d+)?", text) + if not numbers: + return None + + value = float(numbers[0]) + if "分钟" in text or "minute" in text or re.search(r"\bmin\b", text): + return value * 60 + return value + + +def read_requirement_target(fragment_dir: Path) -> tuple[float, str] | None: + requirement_path = fragment_dir / "requirement.md" + if not requirement_path.is_file(): + return None + + duration_keywords = ( + "目标时长", + "时长要求", + "视频时长", + "target duration", + "duration", + ) + try: + for line in requirement_path.read_text(encoding="utf-8").splitlines(): + normalized = line.strip().lower() + if not any(keyword in normalized for keyword in duration_keywords): + continue + if "自动" in normalized or "配音时长" in normalized or "speech" in normalized or "tts" in normalized: + continue + duration = parse_duration_seconds(normalized) + if duration and duration > 0: + print(f"[info] Target duration from requirement.md: {duration:.3f}s") + return duration, str(requirement_path) + except OSError: + return None + + return None + + +def determine_target_duration(artifacts_dir: Path, fragment_dir: Path, cli_target: float | None) -> tuple[float | None, str | None]: + """Determine the target video duration for duration gap calculation. + + Priority: + 1. speech.json in artifacts_dir with "duration" → speech_duration + 1s buffer + 2. --target-duration CLI argument + 3. requirement.md target duration + 4. None (no target check) + """ + speech_target = read_speech_target(artifacts_dir, fragment_dir) + if speech_target is not None: + return speech_target + + if cli_target is not None and cli_target > 0: + print(f"[info] Target duration from CLI: {cli_target}s") + return cli_target, "--target-duration" + + requirement_target = read_requirement_target(fragment_dir) + if requirement_target is not None: + return requirement_target + + return None, None + + +# ── Main ─────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description="Content check for content-producer artifacts") + parser.add_argument("input_dir", help="Fragment directory or its artifacts directory") + parser.add_argument("--target-duration", type=float, default=None, dest="target_duration", + help="Target video duration in seconds (fallback if no speech.json)") + args = parser.parse_args() + + artifacts_dir, fragment_dir = resolve_fragment_paths(args.input_dir) + if not artifacts_dir.is_dir(): + die(f"Not a directory: {artifacts_dir}") + + videos: list[dict] = [] + audios: list[dict] = [] + images: list[dict] = [] + srts: list[dict] = [] + + for name in sorted(os.listdir(artifacts_dir)): + filepath = artifacts_dir / name + if not filepath.is_file(): + continue + ext = os.path.splitext(name)[1].lower() + + if ext in VIDEO_EXTS: + videos.append(probe_video(str(filepath))) + elif ext in AUDIO_EXTS: + info = probe_audio(str(filepath)) + audios.append(info) + elif ext in IMAGE_EXTS: + images.append(check_image(str(filepath))) + elif ext == SRT_EXT: + srts.append(check_srt(str(filepath))) + + # Determine target duration and calculate gap + target_duration, target_source = determine_target_duration(artifacts_dir, fragment_dir, args.target_duration) + duration_gap: dict | None = None + + if target_duration is not None: + total_video_duration = sum(v.get("duration", 0) for v in videos if "error" not in v) + total_image_duration = 0.0 + # Images need agent-specified durations; we can't determine them here + # Only count video durations for gap calculation + actual_duration = total_video_duration + total_image_duration + gap = round(target_duration - actual_duration, 2) + duration_gap = { + "target": round(target_duration, 2), + "actual_video": round(actual_duration, 2), + "gap": gap, + "status": "sufficient" if gap <= 0 else "deficit", + } + if gap > 0: + duration_gap["status"] = "deficit" + print(f"[info] Duration gap: need {gap:.2f}s more video material (target={target_duration:.2f}s, actual={actual_duration:.2f}s)") + elif actual_duration > target_duration + EXCESS_DURATION_SECONDS: + duration_gap["status"] = "excess" + excess_s = round(actual_duration - target_duration, 2) + print(f"[warn] Duration excess: {actual_duration:.2f}s >> target {target_duration:.2f}s (exceeds by {excess_s}s, over {EXCESS_DURATION_SECONDS}s silent gap). Delete oversized clips and re-download to match the gap.") + else: + duration_gap["status"] = "sufficient" + print(f"[info] Duration sufficient: {actual_duration:.2f}s >= target {target_duration:.2f}s") + + # Collect all issues + all_issues: list[str] = [] + if not videos: + all_issues.append("no video material found") + for v in videos: + if "error" in v: + all_issues.append(f"video {v['file']}: {v['error']}") + elif v.get("issues"): + all_issues.append(f"video {v['file']}: {'; '.join(v['issues'])}") + for a in audios: + if "error" in a: + all_issues.append(f"audio {a['file']}: {a['error']}") + elif a.get("issues"): + all_issues.append(f"audio {a['file']}: {'; '.join(a['issues'])}") + for img in images: + if "error" in img: + all_issues.append(f"image {img['file']}: {img['error']}") + elif img.get("issues"): + all_issues.append(f"image {img['file']}: {'; '.join(img['issues'])}") + for s in srts: + if "error" in s: + all_issues.append(f"srt {s['file']}: {s['error']}") + elif s.get("issues"): + all_issues.append(f"srt {s['file']}: {'; '.join(s['issues'])}") + + # Duration deficit is an issue + if duration_gap and duration_gap["status"] == "deficit": + all_issues.append(f"video duration deficit: need {duration_gap['gap']:.2f}s more (target={duration_gap['target']:.2f}s, actual={duration_gap['actual_video']:.2f}s)") + + # Duration excess is also an issue — agent should delete oversized clips + if duration_gap and duration_gap["status"] == "excess": + excess_s = round(duration_gap["actual_video"] - duration_gap["target"], 2) + all_issues.append(f"video duration excess: {excess_s:.2f}s over target (target={duration_gap['target']:.2f}s, actual={duration_gap['actual_video']:.2f}s). Delete clips that are too long and re-download footage matching the needed gap.") + + # Overall verdict + has_critical = any("error" in item for item in videos + audios + images + srts) + verdict = "needs_rework" if (has_critical or len(all_issues) > 0) else "accepted" + + report = { + "artifacts_dir": str(artifacts_dir), + "fragment_dir": str(fragment_dir), + "verdict": verdict, + "target_source": target_source, + "video_count": len(videos), + "audio_count": len(audios), + "image_count": len(images), + "srt_count": len(srts), + "videos": videos, + "audios": audios, + "images": images, + "srts": srts, + "duration_gap": duration_gap, + "issues": all_issues, + } + + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-product/scripts/compress_preview.py b/crews/content-producer/skills/video-product/scripts/compress_preview.py new file mode 100644 index 00000000..2d6a417a --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/compress_preview.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Compress a video segment to ≤16MB for chat confirmation. + +人物故事模式 A.1 中,每段视频生成后要发给用户确认。聊天发送文件有 16MB 上限, +超过则用本脚本压到 16MB 以内。压缩产物**仅用于给用户确认**,不参与最终合成: +- 输出路径必须放在 previews/(或 tmp/)下,assemble.py 只扫描 artifacts/,自然排除; +- 命名带 _preview 后缀,进一步避免与正式片段混淆。 + +行为: + 1. 输入 ≤ target MB → 直接拷贝到 --output,exit 0(打印 [ok] under-limit) + 2. 输入 > target MB → 逐级提高压缩力度(CRF↑ + 必要时降分辨率)直到 ≤ target + - 成功 exit 0,打印 [ok] compressed + - 全部档位仍超 → exit 1,打印 [fail](调用方应改发原路径让用户本机查看) + +Stdlib + ffmpeg/ffprobe only. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +TARGET_MB_DEFAULT = 16 +SAFE_OUTPUT_DIRS = (Path("previews"), Path("tmp"), Path("output_videos")) + +# Compression ladder: (crf, scale_factor). Walked high-quality → aggressive. +# scale None = keep original resolution. +LADDER = [ + (23, None), + (26, None), + (28, None), + (30, 0.85), + (32, 0.75), + (34, 0.60), + (36, 0.50), +] + + +def die(message: str, code: int = 1) -> None: + print(f"[error] {message}", file=sys.stderr) + sys.exit(code) + + +def log(message: str) -> None: + print(f"[info] {message}") + + +def ensure_safe_output(raw_path: str) -> Path: + path = Path(raw_path) + if path.is_absolute(): + die(f"--output must be relative to the workspace: {raw_path}") + if ".." in path.parts: + die(f"--output must not contain '..': {raw_path}") + root = Path.cwd().resolve() + resolved = (root / path).resolve() + if not any( + resolved.is_relative_to((root / base).resolve()) for base in SAFE_OUTPUT_DIRS + ): + die( + f"--output must be under one of: {', '.join(str(d) for d in SAFE_OUTPUT_DIRS)} " + f"(previews/ recommended — assemble.py 不扫描此处)" + ) + return resolved + + +def file_size_mb(path: Path) -> float: + return path.stat().st_size / (1024 * 1024) + + +def probe_dimensions(path: Path) -> tuple[int, int]: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_entries", "stream=width,height", str(path)], + capture_output=True, text=True, timeout=30, check=True, + ) + info = json.loads(result.stdout) + st = (info.get("streams") or [{}])[0] + return int(st.get("width", 0)), int(st.get("height", 0)) + except (subprocess.SubprocessError, json.JSONDecodeError, ValueError, KeyError): + return 0, 0 + + +def ffmpeg_compress(src: Path, dest: Path, crf: int, scale: float | None) -> bool: + """Encode src → dest with given crf/scale. Returns True on success.""" + vf = [] + if scale is not None: + w, h = probe_dimensions(src) + if w > 0 and h > 0: + # scale keeping aspect, force even dims + vf.append(f"scale=trunc(iw*{scale}/2)*2:trunc(ih*{scale}/2)*2") + vf.append("format=yuv420p") + cmd = [ + "ffmpeg", "-y", "-i", str(src), + "-vf", ",".join(vf), + "-c:v", "libx264", "-preset", "medium", "-crf", str(crf), + "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", str(dest), + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + except subprocess.TimeoutExpired: + log(f"crf={crf} scale={scale}: timed out") + return False + if result.returncode != 0 or not dest.is_file(): + log(f"crf={crf} scale={scale}: ffmpeg failed") + return False + return True + + +def main() -> None: + parser = argparse.ArgumentParser( + description="把视频压到 ≤16MB 用于聊天确认(产物仅用于确认,不参与合成)。" + ) + parser.add_argument("input", help="输入视频路径(相对工作区)") + parser.add_argument("--output", required=True, + help="输出预览路径(相对工作区,须在 previews/tmp/output_videos 下)") + parser.add_argument("--target-mb", type=float, default=TARGET_MB_DEFAULT, dest="target_mb", + help="目标上限 MB,默认 16") + args = parser.parse_args() + + src = Path(args.input) + if not src.is_file(): + die(f"input video not found: {src}") + dest = ensure_safe_output(args.output) + dest.parent.mkdir(parents=True, exist_ok=True) + + src_mb = file_size_mb(src) + target = args.target_mb + + if src_mb <= target: + shutil.copyfile(src, dest) + log(f"under-limit: input {src_mb:.2f}MB ≤ {target}MB, copied → {dest}") + print(f"[ok] under-limit {dest} {file_size_mb(dest):.2f}MB") + return + + log(f"input {src_mb:.2f}MB > {target}MB, compressing...") + for crf, scale in LADDER: + if not ffmpeg_compress(src, dest, crf, scale): + continue + out_mb = file_size_mb(dest) + if out_mb <= target: + log(f"crf={crf} scale={scale} → {out_mb:.2f}MB ✓") + print(f"[ok] compressed {dest} {out_mb:.2f}MB") + return + log(f"crf={crf} scale={scale} → {out_mb:.2f}MB still over") + dest.unlink(missing_ok=True) + + die( + f"全部压缩档位仍超过 {target}MB(输入 {src_mb:.2f}MB)。" + f"请改发原路径让用户本机查看:{src}" + ) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-product/scripts/extract_and_concat.py b/crews/content-producer/skills/video-product/scripts/extract_and_concat.py new file mode 100755 index 00000000..ee3ba167 --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/extract_and_concat.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +"""Extract segments from MP4(s) and optionally concatenate them into one MP4. + +Output normalization (matches assemble.py / gen.py defaults): + - 30 fps, yuv420p + - 720x1280 (portrait HD; override with --width / --height, or pass --keep-resolution + to keep the first input's dimensions) + - aac 192k stereo @ 48kHz (or silenced with --no-audio) + - +faststart + +Usage — single segment: + + python3 ./skills/video-product/scripts/extract_and_concat.py \\ + --input foo.mp4 --mode head --seconds 6 --output head6.mp4 + python3 ./skills/video-product/scripts/extract_and_concat.py \\ + --input foo.mp4 --mode tail --seconds 4 --output tail4.mp4 + python3 ./skills/video-product/scripts/extract_and_concat.py \\ + --input foo.mp4 --mode slice --start 2 --end 8 --output mid.mp4 + +Usage — multi-segment + concat (preferred for "剪 A 前 6s + 剪 B 后 4s" 类需求): + + python3 ./skills/video-product/scripts/extract_and_concat.py \\ + --segment input=foo.mp4 mode=head seconds=6 \\ + --segment input=bar.mp4 mode=tail seconds=4 \\ + --output final.mp4 + + For slice segments inside a multi-segment call: + --segment input=foo.mp4 mode=slice start=2 end=8 + +Audio: + - Default: 保留每段原音轨(concat 时每段用各自 audio,拼后自然顺接). + - --no-audio: 关闭音频输出。 + - --audio speech.mp3: 用外部音频替换(与 assemble.py 一致). + +Notes: + - ffmpeg `-sseof -N` 用于 tail 模式,按"距离末尾 N 秒"精确定位(无需先 ffprobe 时长)。 + - head / slice 使用 `-ss` + `-t`,配合下方 re-encode 保证帧边界对齐。 +""" + +import argparse +import gc +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +DEFAULT_WIDTH = 720 +DEFAULT_HEIGHT = 1280 +DEFAULT_FPS = 30 +DEFAULT_AUDIO_BITRATE = "192k" +DEFAULT_AUDIO_RATE = "48000" +DEFAULT_AUDIO_CHANNELS = "2" +VIDEO_CODEC = "libx264" +VIDEO_PRESET = "ultrafast" +VIDEO_CRF = "26" + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def log(msg: str) -> None: + print(f"[info] {msg}") + + +def _run_ffmpeg(cmd: list[str], label: str, timeout: int = 600) -> None: + """Run an ffmpeg command with taskset+nice like assemble.py does, streaming + stderr to a temp file (not memory) so very chatty ffmpeg runs don't OOM.""" + wrapped_cmd = ["taskset", "-c", "0", "nice", "-n", "10"] + cmd + # Cosmetic command echo: abspath → basename, keep flags & values as-is. + pretty: list[str] = [] + for i, c in enumerate(cmd): + if i > 0 and cmd[i - 1] in {"-i", "-vf", "-filter_complex", "-metadata", "-map"}: + pretty.append(c) # keep filter / input path intact + elif c.startswith("-") or "/" not in c: + pretty.append(c) + else: + pretty.append(os.path.basename(c)) + print(f"[info] {label}: {' '.join(pretty)}") + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f: + stderr_path = f.name + try: + with open(stderr_path, "w") as stderr_fh: + result = subprocess.run( + wrapped_cmd, stdout=subprocess.DEVNULL, stderr=stderr_fh, + text=True, timeout=timeout, + ) + if result.returncode != 0: + tail = _tail_file(stderr_path, 2000) + die(f"ffmpeg {label} failed (exit {result.returncode}):\n{tail}") + except subprocess.TimeoutExpired: + die(f"ffmpeg {label} timed out after {timeout}s") + finally: + try: + os.unlink(stderr_path) + except OSError: + pass + + +def _tail_file(path: str, max_chars: int) -> str: + try: + size = os.path.getsize(path) + if size <= max_chars: + with open(path, "r", errors="replace") as f: + return f.read() + with open(path, "rb") as f: + f.seek(size - max_chars) + f.readline() + return f.read().decode(errors="replace") + except OSError: + return "" + + +def ffprobe_duration(path: str) -> float: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", path], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + data = json.loads(result.stdout) + return float(data.get("format", {}).get("duration", 0) or 0) + except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError): + pass + return 0.0 + + +def ffprobe_dimensions(path: str) -> tuple[int, int]: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-select_streams", "v:0", "-show_streams", path], + capture_output=True, text=True, timeout=15, + ) + if result.returncode == 0: + data = json.loads(result.stdout) + stream = next( + (s for s in data.get("streams", []) if s.get("codec_type") == "video"), + {}, + ) + w = int(stream.get("width", 0)) + h = int(stream.get("height", 0)) + if w > 0 and h > 0: + return w, h + except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError): + pass + return DEFAULT_WIDTH, DEFAULT_HEIGHT + + +def ffprobe_has_audio(path: str) -> bool: + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "a", + "-show_entries", "stream=codec_type", "-of", "csv=p=0", path], + capture_output=True, text=True, timeout=15, + ) + return bool(result.stdout.strip()) + except subprocess.SubprocessError: + return False + + +def even(v: int) -> int: + return v if v % 2 == 0 else v - 1 + + +def parse_seconds(raw: str, *, what: str) -> float: + """Accept plain float ('6') or '6s' / '6.5s' / '1m30s' shorthand.""" + if raw is None: + die(f"--{what} requires a value") + s = str(raw).strip().lower() + m = re.fullmatch(r"(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s?)?", s) + if not m or (not m.group(1) and not m.group(2)): + die(f"invalid --{what} value: {raw!r} (use e.g. 6, 6s, 1m30s)") + minutes = float(m.group(1) or 0) + seconds = float(m.group(2) or 0) + total = minutes * 60 + seconds + if total <= 0: + die(f"--{what} must be > 0, got {raw!r}") + return total + + +# ---- segment spec parsing --------------------------------------------------- + +class SegmentSpec: + """One segment to extract from an input file. + + mode='head' → keep first `seconds` (or [start, end] if start/end given). + mode='tail' → keep last `seconds`. + mode='slice' → keep [start, end] (seconds). + """ + + __slots__ = ("input", "mode", "seconds", "start", "end") + + def __init__(self, input_path: str, mode: str, *, + seconds: float | None = None, + start: float | None = None, + end: float | None = None) -> None: + self.input = input_path + self.mode = mode + self.seconds = seconds + self.start = start + self.end = end + self._validate() + + def _validate(self) -> None: + if self.mode not in {"head", "tail", "slice"}: + die(f"invalid mode {self.mode!r} (expected head|tail|slice)") + if not self.input: + die("segment is missing input=path") + if self.mode == "head": + if self.start is None and self.end is None and self.seconds is None: + die(f"head segment needs seconds= or start=+end= ({self.input})") + elif self.mode == "tail": + if self.seconds is None: + die(f"tail segment needs seconds= ({self.input})") + if self.start is not None or self.end is not None: + die(f"tail segment ignores start/end ({self.input})") + elif self.mode == "slice": + if self.start is None or self.end is None: + die(f"slice segment needs both start= and end= ({self.input})") + if self.end <= self.start: + die(f"slice end must be > start ({self.input})") + + def resolve(self) -> tuple[float, float]: + """Return (start, end) in seconds after clipping to input duration.""" + duration = ffprobe_duration(self.input) + if duration <= 0: + die(f"cannot read duration of {self.input}") + if self.mode == "head": + end = self.end if self.end is not None else self.seconds + start = self.start if self.start is not None else 0.0 + elif self.mode == "tail": + end = duration + start = max(0.0, duration - self.seconds) + else: # slice + start, end = self.start, self.end + # Clip to duration (ffmpeg is forgiving, but be explicit) + start = max(0.0, min(start, duration)) + end = max(start, min(end, duration)) + if end - start <= 0.001: + die(f"segment resolves to ≤ 0s after clipping: {self.input} " + f"(duration={duration:.2f}s, requested [{start:.2f}, {end:.2f}])") + return start, end + + def describe(self) -> str: + s, e = self.resolve() + return f"{os.path.basename(self.input)}[{self.mode} → {s:.2f}..{e:.2f}s ({e - s:.2f}s)]" + + +def parse_segment_argv(tokens: list[str]) -> SegmentSpec: + """Parse a single `--segment` payload, e.g. ['input=foo.mp4', 'mode=head', 'seconds=6'].""" + input_path: str | None = None + mode: str | None = None + seconds: float | None = None + start: float | None = None + end: float | None = None + for tok in tokens: + if "=" not in tok: + die(f"--segment token must be key=value, got: {tok!r}") + key, _, val = tok.partition("=") + key = key.strip().lower() + val = val.strip() + if key == "input" or key == "i": + input_path = val + elif key == "mode" or key == "m": + mode = val + elif key in ("seconds", "sec", "s", "duration", "dur"): + seconds = parse_seconds(val, what=f"segment[{tokens}].{key}") + elif key in ("start", "st", "from"): + start = parse_seconds(val, what=f"segment[{tokens}].{key}") + elif key in ("end", "e", "to"): + end = parse_seconds(val, what=f"segment[{tokens}].{key}") + else: + die(f"unknown --segment key: {key!r} (allowed: input, mode, seconds, start, end)") + if not mode: + die(f"--segment missing mode= (in {tokens})") + return SegmentSpec(input_path or "", mode, seconds=seconds, start=start, end=end) + + +def build_single_segment(args: argparse.Namespace) -> SegmentSpec: + """Build a SegmentSpec from the legacy single-segment flags.""" + return SegmentSpec( + args.input, + args.mode, + seconds=args.seconds, + start=args.start, + end=args.end, + ) + + +# ---- ffmpeg command builders ------------------------------------------------ + +def build_cut_cmd(spec: SegmentSpec, output_path: str, + width: int, height: int, fps: int, *, no_audio: bool) -> list[str]: + """Cut a segment out of an input and re-encode to the normalized spec. + + head/slice use input-seek (`-ss` before `-i`) for speed; tail uses `-sseof` + for built-in 'last N seconds' handling. Output is always re-encoded so all + concat'd files share an identical stream layout (codec/fps/pix_fmt/sar/w/h). + """ + start, end = spec.resolve() + duration = end - start + vf = (f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2," + f"setsar=1,fps={fps},format=yuv420p") + audio_encode = ["-c:a", "aac", "-b:a", DEFAULT_AUDIO_BITRATE, + "-ar", DEFAULT_AUDIO_RATE, "-ac", DEFAULT_AUDIO_CHANNELS] + + cmd: list[str] = ["ffmpeg", "-y"] + if spec.mode == "tail": + # `-sseof -N` = seek N seconds before EOF, then take all remaining. + # Use `-t` to cap to the requested window in case input is longer. + cmd += ["-sseof", f"-{duration:.3f}", "-i", spec.input] + else: + cmd += ["-ss", f"{start:.3f}", "-i", spec.input] + if spec.mode == "head": + # `seconds` already encoded as end-start + cmd += ["-t", f"{duration:.3f}"] + else: # slice + cmd += ["-t", f"{duration:.3f}"] + + cmd += ["-vf", vf, + "-c:v", VIDEO_CODEC, "-preset", VIDEO_PRESET, "-crf", VIDEO_CRF] + + if no_audio: + cmd += ["-an"] + elif spec.mode == "tail" or ffprobe_has_audio(spec.input): + cmd += ["-map", "0:v:0", "-map", "0:a:0?", *audio_encode, "-shortest"] + else: + # No audio in input → emit a silent stereo track so all normalized files + # share the same (v+a) layout for downstream concat -c copy. + cmd += [ + "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", + "-map", "0:v:0", "-map", "1:a:0", *audio_encode, "-shortest", + ] + + cmd += ["-movflags", "+faststart", "-threads", "1", output_path] + return cmd + + +def build_concat_cmd(parts: list[str], output_path: str, + *, external_audio: str | None, no_audio: bool) -> list[str]: + """Concat pre-normalized parts via concat demuxer, optionally muxing audio.""" + concat_list = os.path.join(os.path.dirname(parts[0]) or ".", "_concat_list.txt") + with open(concat_list, "w", encoding="utf-8") as f: + for p in parts: + abs_p = os.path.abspath(p) + esc = abs_p.replace("'", "'\\''") + f.write(f"file '{esc}'\n") + + cmd: list[str] = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", + "-i", concat_list, "-c", "copy", + "-movflags", "+faststart", output_path] + if external_audio: + # Re-run with audio replacement + cmd2: list[str] = ["ffmpeg", "-y", "-i", output_path, "-i", external_audio, + "-map", "0:v", "-map", "1:a", + "-c:v", "copy", "-c:a", "aac", "-b:a", DEFAULT_AUDIO_BITRATE, + "-movflags", "+faststart", + output_path + ".mux.mp4"] + return cmd2 # caller will run cmd first, then cmd2 + if no_audio: + # Strip audio stream (parts may still carry audio from normalization) + cmd2 = ["ffmpeg", "-y", "-i", output_path, "-an", + "-c:v", "copy", "-movflags", "+faststart", + output_path + ".noaudio.mp4"] + return cmd2 + return cmd + + +def build_mux_audio_cmd(video_path: str, audio_path: str, output_path: str) -> list[str]: + return [ + "ffmpeg", "-y", "-i", video_path, "-i", audio_path, + "-map", "0:v", "-map", "1:a", + "-c:v", "copy", "-c:a", "aac", "-b:a", DEFAULT_AUDIO_BITRATE, + "-movflags", "+faststart", output_path, + ] + + +# ---- main flow -------------------------------------------------------------- + +def run(args: argparse.Namespace) -> None: + output_path = args.output + if not output_path: + die("--output is required") + output_abs = os.path.abspath(output_path) + out_dir = os.path.dirname(output_abs) or "." + os.makedirs(out_dir, exist_ok=True) + + # Resolve segments + if args.segment: + segments = [parse_segment_argv(toks) for toks in args.segment] + elif args.input: + segments = [build_single_segment(args)] + else: + die("nothing to do: pass --input + --mode, or one or more --segment") + + for s in segments: + if not os.path.isfile(s.input): + die(f"input not found: {s.input}") + + # Resolve target dimensions + if args.keep_resolution: + w0, h0 = ffprobe_dimensions(segments[0].input) + width, height = even(w0), even(h0) + else: + width = args.width or DEFAULT_WIDTH + height = args.height or DEFAULT_HEIGHT + width, height = even(width), even(height) + fps = args.fps or DEFAULT_FPS + + no_audio = bool(args.no_audio) + external_audio = args.audio # may be None + + log(f"target: {width}x{height} @ {fps}fps, " + f"audio={'off' if no_audio else (external_audio or 'preserve')}") + log(f"segments:") + for s in segments: + log(f" - {s.describe()}") + + # Step 1: cut + normalize each segment to a temp file + tmp_dir = tempfile.mkdtemp(prefix="extract_concat_", dir=out_dir) + try: + parts: list[str] = [] + for i, seg in enumerate(segments): + part_path = os.path.join(tmp_dir, f"part_{i:04d}.mp4") + cmd = build_cut_cmd(seg, part_path, width, height, fps, no_audio=no_audio) + _run_ffmpeg(cmd, f"cut[{i+1}/{len(segments)}]") + if not os.path.isfile(part_path) or os.path.getsize(part_path) == 0: + die(f"part {i+1} produced empty file: {part_path}") + parts.append(part_path) + gc.collect() + + if len(parts) == 1 and not external_audio and not no_audio: + # Fast path: single segment, no audio override → just rename. + shutil.move(parts[0], output_abs) + elif len(parts) == 1 and (external_audio or no_audio): + # Single segment with audio override → re-mux the one part. + if external_audio: + if not os.path.isfile(external_audio): + die(f"--audio file not found: {external_audio}") + cmd = build_mux_audio_cmd(parts[0], external_audio, output_abs) + _run_ffmpeg(cmd, "mux external audio") + else: # no_audio + cmd = ["ffmpeg", "-y", "-i", parts[0], "-an", + "-c:v", "copy", "-movflags", "+faststart", output_abs] + _run_ffmpeg(cmd, "drop audio") + else: + # Multi-segment: concat demuxer (stream copy), then optional audio override. + tmp_concat = os.path.join(tmp_dir, "concat.mp4") + cmd = build_concat_cmd(parts, tmp_concat, + external_audio=None, no_audio=False) + _run_ffmpeg(cmd, "concat") + if external_audio: + if not os.path.isfile(external_audio): + die(f"--audio file not found: {external_audio}") + cmd = build_mux_audio_cmd(tmp_concat, external_audio, output_abs) + _run_ffmpeg(cmd, "mux external audio") + elif no_audio: + cmd = ["ffmpeg", "-y", "-i", tmp_concat, "-an", + "-c:v", "copy", "-movflags", "+faststart", output_abs] + _run_ffmpeg(cmd, "drop audio") + else: + shutil.move(tmp_concat, output_abs) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + if not os.path.isfile(output_abs) or os.path.getsize(output_abs) == 0: + die("output file is missing or empty") + duration = ffprobe_duration(output_abs) + size_mb = os.path.getsize(output_abs) / (1024 * 1024) + log(f"done: {output_abs}") + log(f" duration={duration:.2f}s size={size_mb:.2f}MB") + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Extract segments from MP4(s) and concatenate them.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + # Output + p.add_argument("--output", "-o", required=True, help="Output MP4 path") + + # Single-segment mode (legacy / simple) + p.add_argument("--input", "-i", help="Input MP4 (single-segment mode)") + p.add_argument("--mode", choices=["head", "tail", "slice"], + help="Extraction mode (single-segment mode)") + p.add_argument("--seconds", type=lambda v: parse_seconds(v, what="seconds"), + help="Window size in seconds (head/tail). Accepts 6 / 6s / 1m30s.") + p.add_argument("--start", type=lambda v: parse_seconds(v, what="start"), + help="Start second (head with --end, or slice). Accepts 6 / 6s / 1m30s.") + p.add_argument("--end", type=lambda v: parse_seconds(v, what="end"), + help="End second (slice, or head with --start). Accepts 6 / 6s / 1m30s.") + + # Multi-segment mode + p.add_argument("--segment", action="append", nargs="+", default=[], + metavar="KEY=VAL", + help="Repeatable. Tokens: input=path mode=head|tail|slice " + "seconds=N start=S end=E. " + "Example: --segment input=a.mp4 mode=head seconds=6") + + # Output normalization + p.add_argument("--width", type=int, default=None, + help=f"Target width in px (default {DEFAULT_WIDTH})") + p.add_argument("--height", type=int, default=None, + help=f"Target height in px (default {DEFAULT_HEIGHT})") + p.add_argument("--keep-resolution", action="store_true", + help="Keep first input's resolution (still forces 30fps/yuv420p).") + p.add_argument("--fps", type=int, default=None, + help=f"Target fps (default {DEFAULT_FPS})") + + # Audio + p.add_argument("--no-audio", action="store_true", + help="Drop audio (output is video-only).") + p.add_argument("--audio", default=None, + help="Replace per-segment audio with this file (e.g. speech.mp3).") + return p + + +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + if args.segment and args.input: + die("use either --input/--mode (single segment) OR --segment (one or more), not both") + if args.segment: + for toks in args.segment: + if not toks: + die("--segment requires at least one key=value token") + else: + if not args.input or not args.mode: + die("single-segment mode requires --input and --mode") + + run(args) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/crews/content-producer/skills/video-product/scripts/gen.py b/crews/content-producer/skills/video-product/scripts/gen.py new file mode 100644 index 00000000..bbc59373 --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/gen.py @@ -0,0 +1,675 @@ +#!/usr/bin/env python3 +"""Video AIGC generation — direct endpoint calls to Volcengine Seedance or Aliyun DashScope. + +Stdlib only (no httpx/requests). The script auto-detects which platform to use +from environment variables (DashScope/百炼 preferred over Volcengine/火山), submits +an async video-generation task, polls until completion, and downloads the MP4. + +Flow: + 1. Resolve platform (override via --platform, else env vars) + 2. Resolve mode: r2v (ref-image/ref-video) > i2v (image) > t2v + 3. Pick model: --model, else platform candidate chain (with fallback) + 4. POST create task → task_id + 5. Poll task status until terminal + 6. Download video_url → --output + +If neither MODELSTUDIO_API_KEY/DASHSCOPE_API_KEY nor AWK_GEN_KEY is set, +prints guidance to use pexels-footage / pixabay-footage and exits non-zero. + +Note: 火山引擎视频生成只认 AWK_GEN_KEY,不回退 ARK_API_KEY。 +原因:ARK_API_KEY 是火山主模型(doubao 对话)的 key,用户可能只想用火山主模型 +而不用火山生成视频;若此处回退 ARK_API_KEY,会误触发火山视频生成。 +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import mimetypes +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +# ---- Volcengine Ark (Seedance) ------------------------------------------------- +VOLC_BASE = "https://ark.cn-beijing.volces.com/api/v3" +VOLC_CREATE = f"{VOLC_BASE}/contents/generations/tasks" +VOLC_QUERY = f"{VOLC_BASE}/contents/generations/tasks/{{task_id}}" + +# Seedance 2.0 series. Fast preferred → normal → mini. All three are multimodal +# (t2v / i2v / r2v share the same model id). +VOLC_MODELS = { + "fast": "doubao-seedance-2-0-fast-260128", + "normal": "doubao-seedance-2-0-260128", + "mini": "doubao-seedance-2-0-mini-260615", +} + +# ---- Aliyun DashScope (百炼 Wan2.7 / HappyHorse) ------------------------------ +# wan2.7 走默认 dashscope 端点;HappyHorse 是华北2模型,配了 WORKSPACE_ID 时走业务空间 +# 专属端点 https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com(见 SKILL.md 模型选型)。 +DS_DEFAULT_BASE = "https://dashscope.aliyuncs.com/api/v1" +DS_WS_BASE_TEMPLATE = "https://{wsid}.cn-beijing.maas.aliyuncs.com/api/v1" +DS_CREATE_PATH = "/services/aigc/video-generation/video-synthesis" +DS_QUERY_PATH = "/tasks/{task_id}" + + +def ds_base_for_model(model: str) -> str: + """Resolve the DashScope base URL for a given model. + + happyhorse-1.1 / 1.0 在默认 dashscope.aliyuncs.com 端点可正常调用(WorkspaceId 端点 + 只是华北2的性能优化,非必需)。WORKSPACE_ID 设置时走专属端点更快,否则走默认。 + wan2.7 始终走默认端点。 + """ + wsid = (os.environ.get("WORKSPACE_ID") or "").strip() + if model.startswith("happyhorse") and wsid: + return DS_WS_BASE_TEMPLATE.format(wsid=wsid) + return DS_DEFAULT_BASE + +# 百炼模型候选链(按价格/可用性优先,每模式一条): +# happyhorse-1.1 系列(当前折扣价低于 wan2.7,优先)→ happyhorse-1.0 系列 → wan2.7 系列托底 +# generate() 在 TaskFailed / HttpError 时自动沿链 fallback;--model 显式指定时只用该模型。 +DS_MODEL_CHAIN = { + "t2v": ["happyhorse-1.1-t2v", "happyhorse-1.0-t2v", "wan2.7-t2v"], + "i2v": ["happyhorse-1.1-i2v", "happyhorse-1.0-i2v", "wan2.7-i2v"], + "r2v": ["happyhorse-1.1-r2v", "happyhorse-1.0-r2v", "wan2.7-r2v"], +} + +VALID_RATIOS = {"16:9", "9:16", "1:1", "4:3", "3:4"} +SAFE_OUTPUT_DIRS = ( + Path("output_videos"), + Path("tmp"), + Path("fragments"), + Path("artifacts"), +) + +# Transient HTTP statuses worth retrying on the same model before falling back. +RETRYABLE_HTTP = {408, 429, 500, 502, 503, 504} + + +def die(message: str, code: int = 1) -> None: + print(f"[error] {message}", file=sys.stderr) + sys.exit(code) + + +def log(message: str) -> None: + print(f"[info] {message}") + + +def append_decision(entry: str) -> None: + """候选链 fallback 或全失败时往 decisions.log 追一行(借鉴 OpenMontage decision_log). + + 落点:workdir 下 decisions.log(gen.py 的 workdir 由 ensure_safe_output 约束在 workspace 根, + decisions.log 同落那)。append-only,不动旧内容。格式:ISO 时间 | 事件 | 详情。 + 落盘失败不阻塞主流程——decisions.log 是审计辅助,不是硬约束。 + """ + try: + from datetime import datetime + ts = datetime.now().astimezone().isoformat(timespec="seconds") + line = f"{ts} | {entry}\n" + with Path("decisions.log").resolve().open("a", encoding="utf-8") as f: + f.write(line) + except Exception: + pass + + +# ---- asset resolution --------------------------------------------------------- + +def is_url(value: str) -> bool: + return value.startswith("http://") or value.startswith("https://") + + +def image_to_data_url(path: Path) -> str: + """Base64-encode a local image into a data: URL acceptable by both platforms.""" + if not path.is_file(): + die(f"image file not found: {path}") + mime, _ = mimetypes.guess_type(str(path)) + if not mime or not mime.startswith("image/"): + die(f"unsupported image type: {path}") + raw = path.read_bytes() + if len(raw) > 30 * 1024 * 1024: + die(f"image exceeds 30MB: {path}") + b64 = base64.b64encode(raw).decode("ascii") + return f"data:{mime};base64,{b64}" + + +def resolve_image(value: str) -> str: + """Images may be a public URL or a local file (base64 data URL).""" + if is_url(value): + return value + return image_to_data_url(Path(value)) + + +# ---- prev-segment last-frame extraction --------------------------------------- + +def ffprobe_duration(path: Path) -> float: + """Get media duration in seconds via ffprobe.""" + try: + result = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", + "-show_entries", "format=duration", str(path)], + capture_output=True, text=True, timeout=30, check=True, + ) + info = json.loads(result.stdout) + return float(info.get("format", {}).get("duration", 0) or 0) + except (subprocess.SubprocessError, json.JSONDecodeError, ValueError) as exc: + die(f"ffprobe failed on {path}: {exc}") + + +def extract_last_frame(video_path: Path) -> Path: + """Extract the last frame of a video to a sibling hidden .jpg. + + Used by --prev-segment: the last frame of the previous segment becomes the + first frame of the next segment, giving首尾帧对齐 between人物故事片段. + Output is a .jpg sibling of the source (assemble.py only picks video + extensions, so this never pollutes the concat order). + + Strategy: try multiple ffmpeg seek strategies in order. Some AI-generated + videos (notably 百炼 wan2.7-r2v) produce MP4s where the container duration + is slightly larger than the actual stream end — e.g. duration=10.030998s but + the last frame is at 9.967s (300 frames @ 30fps). A naive output-side + `-ss duration - 0.05` then lands past the last frame and ffmpeg reports + "Output file is empty, nothing was encoded". We try three strategies in + order and use the first one that produces a non-empty jpg: + 1) `-sseof -1` + `-update 1` (seek-from-end, gives the actual last frame + for any video ≥1s; the image2 muxer keeps overwriting the single jpg + with each decoded frame and ends on the final one) + 2) `-ss duration - 0.5` (more conservative from-start accurate seek; + decodes from 0 but lands well before any "container padding") + 3) `-ss duration - 1.0` (last resort; near-end frame) + """ + if not video_path.is_file(): + die(f"--prev-segment video not found: {video_path}") + duration = ffprobe_duration(video_path) + if duration <= 0: + die(f"could not determine duration for --prev-segment video: {video_path}") + dest = video_path.with_name(f".{video_path.stem}_lastframe.jpg") + # Clean up any stale file from a previous failed attempt + if dest.is_file(): + dest.unlink() + + strategies: list[tuple[str, list[str]]] = [ + ( + "-sseof -1 (seek-from-end)", + [ + "ffmpeg", "-y", "-sseof", "-1", "-i", str(video_path), + "-update", "1", "-frames:v", "1", "-q:v", "2", "-an", str(dest), + ], + ), + ( + f"-ss {max(0.0, duration - 0.5):.3f} (duration - 0.5s)", + [ + "ffmpeg", "-y", "-i", str(video_path), + "-ss", f"{max(0.0, duration - 0.5):.3f}", + "-frames:v", "1", "-q:v", "2", "-an", str(dest), + ], + ), + ( + f"-ss {max(0.0, duration - 1.0):.3f} (duration - 1.0s)", + [ + "ffmpeg", "-y", "-i", str(video_path), + "-ss", f"{max(0.0, duration - 1.0):.3f}", + "-frames:v", "1", "-q:v", "2", "-an", str(dest), + ], + ), + ] + + attempts: list[str] = [] + for label, cmd in strategies: + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + attempts.append(f"[{label}] ffmpeg timed out after 60s") + continue + if ( + result.returncode == 0 + and dest.is_file() + and dest.stat().st_size > 0 + ): + log( + f"extracted last frame of {video_path.name} → {dest.name} " + f"(strategy: {label})" + ) + return dest + tail = (result.stderr or "")[-300:] + attempts.append( + f"[{label}] rc={result.returncode} " + f"dest_exists={dest.is_file()} tail={tail!r}" + ) + # Clean up partial/empty output before next attempt + if dest.is_file(): + dest.unlink() + + die( + f"ffmpeg last-frame extraction failed on {video_path} " + f"(tried {len(strategies)} strategies):\n" + + "\n".join(attempts) + ) + + +def resolve_media_url(value: str, kind: str) -> str: + """Video/audio references must be public URLs — neither platform accepts + base64 for video/audio in a way we can reliably use, so require a URL.""" + if is_url(value): + return value + die( + f"--{kind} must be a public http(s) URL; local {kind} files are not " + f"supported (upload to OSS/TOS/a public host first). Got: {value}" + ) + + +def ensure_safe_output(raw_path: str) -> Path: + path = Path(raw_path) + if path.is_absolute(): + die(f"--output must be relative to the workspace: {raw_path}") + if ".." in path.parts: + die(f"--output must not contain '..': {raw_path}") + root = Path.cwd().resolve() + resolved = (root / path).resolve() + if not any( + resolved.is_relative_to((root / base).resolve()) for base in SAFE_OUTPUT_DIRS + ): + die( + f"--output must be under one of: {', '.join(str(d) for d in SAFE_OUTPUT_DIRS)}" + ) + return resolved + + +# ---- HTTP helpers ------------------------------------------------------------- + +def post_json(url: str, payload: dict, headers: dict, timeout: int = 60) -> dict: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, data=data, headers=headers, method="POST" + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + raise HttpError(exc.code, body) from None + except urllib.error.URLError as exc: + raise HttpError(0, str(exc.reason)) from None + + +def get_json(url: str, headers: dict, timeout: int = 30) -> dict: + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + body = exc.read().decode(errors="replace") + raise HttpError(exc.code, body) from None + except urllib.error.URLError as exc: + raise HttpError(0, str(exc.reason)) from None + + +class HttpError(Exception): + def __init__(self, code: int, body: str): + super().__init__(f"HTTP {code}: {body}") + self.code = code + self.body = body + + +def download(url: str, dest: Path, timeout: int = 300) -> None: + log(f"downloading → {dest}") + req = urllib.request.Request(url, headers={"User-Agent": "wiseflow-video-gen/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + dest.write_bytes(resp.read()) + + +# ---- platform: Volcengine ----------------------------------------------------- + +def volc_build_content(args: argparse.Namespace) -> list[dict]: + items: list[dict] = [{"type": "text", "text": args.prompt}] + if args.image: + items.append( + {"type": "image_url", "image_url": {"url": resolve_image(args.image)}, "role": "first_frame"} + ) + if args.last_frame: + items.append( + {"type": "image_url", "image_url": {"url": resolve_image(args.last_frame)}, "role": "last_frame"} + ) + if args.ref_image: + items.append( + {"type": "image_url", "image_url": {"url": resolve_image(args.ref_image)}, "role": "reference_image"} + ) + if args.ref_video: + items.append( + {"type": "video_url", "video_url": {"url": resolve_media_url(args.ref_video, "ref-video")}} + ) + if args.ref_audio: + items.append( + {"type": "audio_url", "audio_url": {"url": resolve_media_url(args.ref_audio, "ref-audio")}} + ) + return items + + +def volc_submit(model: str, args: argparse.Namespace, api_key: str) -> str: + payload: dict = { + "model": model, + "content": volc_build_content(args), + "ratio": args.ratio, + "duration": args.duration, + "resolution": args.resolution.lower(), + "generate_audio": args.audio, + } + if args.seed is not None: + payload["seed"] = args.seed + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + resp = post_json(VOLC_CREATE, payload, headers, timeout=60) + task_id = resp.get("id") or resp.get("task_id") + if not task_id: + die(f"volcengine submit: no task id in response: {json.dumps(resp, ensure_ascii=False)}") + return task_id + + +def volc_poll(task_id: str, api_key: str, interval: int, timeout: int) -> str: + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + url = VOLC_QUERY.format(task_id=task_id) + deadline = time.time() + timeout + attempt = 0 + while time.time() < deadline: + attempt += 1 + resp = get_json(url, headers, timeout=30) + status = resp.get("status", "") + log(f"volc poll #{attempt}: status={status}") + if status == "succeeded": + video_url = (resp.get("content") or {}).get("video_url") + if not video_url: + die(f"volcengine succeeded but no video_url: {json.dumps(resp, ensure_ascii=False)}") + return video_url + if status in {"failed", "cancelled", "expired"}: + err = resp.get("error") or {} + raise TaskFailed(f"volcengine task {status}: {err.get('code', '')} {err.get('message', '')}") + time.sleep(interval) + die(f"volcengine timed out after {timeout}s (task {task_id})") + + +# ---- platform: DashScope ------------------------------------------------------ + +def ds_build_input(args: argparse.Namespace) -> dict: + inp: dict = {"prompt": args.prompt} + if args.negative_prompt: + inp["negative_prompt"] = args.negative_prompt + + media: list[dict] = [] + if args.image: + media.append({"type": "first_frame", "url": resolve_image(args.image)}) + if args.last_frame: + media.append({"type": "last_frame", "url": resolve_image(args.last_frame)}) + if args.ref_image: + m = {"type": "reference_image", "url": resolve_image(args.ref_image)} + if args.ref_audio: + m["reference_voice"] = resolve_media_url(args.ref_audio, "ref-audio") + media.append(m) + if args.ref_video: + m = {"type": "reference_video", "url": resolve_media_url(args.ref_video, "ref-video")} + if args.ref_audio: + m["reference_voice"] = resolve_media_url(args.ref_audio, "ref-audio") + media.append(m) + if args.ref_audio: + audio_url = resolve_media_url(args.ref_audio, "ref-audio") + if media and (args.image or args.last_frame) and not args.ref_image and not args.ref_video: + # i2v + driving audio: audio rides as a media item + media.append({"type": "driving_audio", "url": audio_url}) + elif not media: + # t2v + audio: audio_url lives at input level + inp["audio_url"] = audio_url + if media: + inp["media"] = media + return inp + + +def ds_submit(model: str, args: argparse.Namespace, api_key: str, base: str) -> str: + payload: dict = { + "model": model, + "input": ds_build_input(args), + "parameters": { + "resolution": args.resolution.upper(), + "ratio": args.ratio, + "duration": args.duration, + "prompt_extend": args.prompt_extend, + "watermark": False, + }, + } + if args.seed is not None: + payload["parameters"]["seed"] = args.seed + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "X-DashScope-Async": "enable", + } + resp = post_json(f"{base}{DS_CREATE_PATH}", payload, headers, timeout=60) + task_id = (resp.get("output") or {}).get("task_id") + if not task_id: + die(f"dashscope submit: no task id in response: {json.dumps(resp, ensure_ascii=False)}") + return task_id + + +def ds_poll(task_id: str, api_key: str, interval: int, timeout: int, base: str) -> str: + headers = {"Authorization": f"Bearer {api_key}"} + url = f"{base}{DS_QUERY_PATH.format(task_id=task_id)}" + deadline = time.time() + timeout + attempt = 0 + while time.time() < deadline: + attempt += 1 + resp = get_json(url, headers, timeout=30) + out = resp.get("output") or {} + status = out.get("task_status", "") + log(f"dashscope poll #{attempt}: status={status}") + if status == "SUCCEEDED": + video_url = out.get("video_url") + if not video_url: + die(f"dashscope succeeded but no video_url: {json.dumps(resp, ensure_ascii=False)}") + return video_url + if status in {"FAILED", "CANCELED", "UNKNOWN"}: + raise TaskFailed( + f"dashscope task {status}: {out.get('code', '')} {out.get('message', '')}" + ) + time.sleep(interval) + die(f"dashscope timed out after {timeout}s (task {task_id})") + + +class TaskFailed(Exception): + pass + + +# ---- model candidate chains --------------------------------------------------- + +def volc_candidates(args: argparse.Namespace) -> list[str]: + chain = [VOLC_MODELS["fast"], VOLC_MODELS["normal"], VOLC_MODELS["mini"]] + # fast only supports 720p; skip it for 1080p + if args.resolution.lower() == "1080p": + chain = [m for m in chain if m != VOLC_MODELS["fast"]] + return chain + + +def ds_candidates(args: argparse.Namespace, mode: str) -> list[str]: + # Mode-level capability checks (apply to every model in the chain) + # happyhorse 系列最短 3 秒;wan2.7 托底同链,统一要求 ≥3(脚本规划已遵守) + if args.duration < 3: + die("百炼视频生成最短 3 秒;请将 --duration 提到 ≥3 或拆分片段") + # i2v 仅首帧,不支持首+尾帧 + if mode == "i2v" and args.last_frame: + die("i2v 不支持首+尾帧(仅首帧);请去掉 --last-frame") + # r2v 仅参考图,不支持参考视频、不支持首帧 + if mode == "r2v" and args.ref_video: + die("r2v 仅支持参考图(--ref-image);不支持 --ref-video") + if mode == "r2v" and args.image: + die( + "r2v 仅支持参考图(--ref-image);" + "不要传 --image 或 --prev-segment(r2v 不收首帧)" + ) + return list(DS_MODEL_CHAIN[mode]) + + +# ---- orchestration ------------------------------------------------------------ + +def resolve_platform() -> str: + has_ds = bool(os.environ.get("MODELSTUDIO_API_KEY", "").strip() or os.environ.get("DASHSCOPE_API_KEY", "").strip()) + has_volc = bool(os.environ.get("AWK_GEN_KEY", "").strip()) + if has_ds: + return "dashscope" + if has_volc: + return "volcengine" + print( + "[error] 未检测到任何视频生成平台的环境变量(MODELSTUDIO_API_KEY / AWK_GEN_KEY 均未设置)。\n" + "[hint] 请改用 pexels-footage 和 pixabay-footage 技能搜集素材:\n" + " 1) pexels-footage 搜索并下载 9:16 竖屏素材(按片段时长设 --min-duration/--max-duration)\n" + " 2) pexels 无结果时用 pixabay-footage 兜底\n" + " 3) 下载后按脚本片段编号重命名放入 artifacts/,再用 check.py 自检\n" + " 若要启用 AI 直生成,请配置 MODELSTUDIO_API_KEY(阿里云百炼,优先)或 AWK_GEN_KEY(火山引擎)。", + file=sys.stderr, + ) + sys.exit(2) + + +def resolve_mode(args: argparse.Namespace) -> str: + if args.ref_video or args.ref_image: + return "r2v" + if args.image: + return "i2v" + return "t2v" + + +def run_one(platform: str, model: str, args: argparse.Namespace, api_key: str) -> str: + """Submit + poll for a single model. Returns video URL or raises.""" + if platform == "volcengine": + task_id = volc_submit(model, args, api_key) + log(f"volcengine task submitted: {task_id} (model={model})") + return volc_poll(task_id, api_key, args.poll_interval, args.timeout) + base = ds_base_for_model(model) + task_id = ds_submit(model, args, api_key, base) + log(f"dashscope task submitted: {task_id} (model={model} base={base})") + return ds_poll(task_id, api_key, args.poll_interval, args.timeout, base) + + +def generate(platform: str, candidates: list[str], args: argparse.Namespace, api_key: str) -> str: + """Try candidate models in order. HttpError/TaskFailed trigger fallback + unless the user explicitly pinned --model (then only transient retries).""" + pinned = args.model is not None + models = candidates if pinned else candidates + last_err = "" + for idx, model in enumerate(models): + for attempt in range(1, 4): # up to 3 transient retries per model + try: + return run_one(platform, model, args, api_key) + except TaskFailed as exc: + last_err = str(exc) + log(f"model {model} task failed: {last_err}") + break # task-level failure → fall back to next model, no retry + except HttpError as exc: + last_err = str(exc) + if exc.code in RETRYABLE_HTTP and attempt < 3: + log(f"model {model} HTTP {exc.code}, retrying ({attempt}/2)") + time.sleep(3 * attempt) + continue + log(f"model {model} submit error: {last_err}") + break # fall back to next model + if pinned: + break # respect explicit user choice — no chain walk + if idx < len(models) - 1: + next_model = models[idx + 1] + log(f"falling back to next model: {next_model}") + append_decision(f"fallback | {model} → {next_model} | reason: {last_err}") + append_decision(f"all candidates exhausted | models: {','.join(models)} | last error: {last_err}") + die(f"all model attempts failed; last error: {last_err}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Video AIGC generation via Volcengine Seedance or Aliyun DashScope (auto-detected)." + ) + parser.add_argument("--prompt", required=True, help="画面+音频描述(声画同出)") + parser.add_argument("--image", default=None, help="首帧图片:URL 或本地路径(→ i2v)") + parser.add_argument("--prev-segment", default=None, dest="prev_segment", + help="上一段视频本地路径:脚本自动抽取其末帧作为本段首帧(人物故事首尾帧对齐)。与 --image 互斥") + parser.add_argument("--last-frame", default=None, dest="last_frame", help="尾帧图片:URL 或本地路径(i2v 首尾帧)") + parser.add_argument("--ref-image", default=None, dest="ref_image", help="参考图片:URL 或本地路径(→ r2v,角色/主体一致性)") + parser.add_argument("--ref-video", default=None, dest="ref_video", help="参考视频 URL(→ r2v,需公网 URL)") + parser.add_argument("--ref-audio", default=None, dest="ref_audio", help="驱动/参考音频 URL(需公网 URL)") + parser.add_argument("--negative-prompt", default=None, dest="negative_prompt", help="反向提示词") + parser.add_argument("--duration", type=int, default=8, help="时长(秒),默认 8") + parser.add_argument("--ratio", default="9:16", choices=sorted(VALID_RATIOS), help="宽高比,默认 9:16") + parser.add_argument("--resolution", default="720P", choices=["720P", "1080P"], help="分辨率,默认 720P") + parser.add_argument("--no-audio", action="store_false", dest="audio", help="关闭声画同出(默认开启)") + parser.add_argument("--no-prompt-extend", action="store_false", dest="prompt_extend", help="关闭 DashScope prompt 智能改写") + parser.add_argument("--platform", default=None, choices=["volcengine", "dashscope"], help="覆盖平台自动检测") + parser.add_argument("--model", default=None, help="指定模型 id(关闭候选链 fallback)") + parser.add_argument("--seed", type=int, default=None) + parser.add_argument("--poll-interval", type=int, default=15, dest="poll_interval", help="轮询间隔秒,默认 15") + parser.add_argument("--timeout", type=int, default=900, help="整体超时秒,默认 900") + parser.add_argument("--output", required=True, help="输出 MP4 路径(相对工作区,须在 output_videos/tmp/fragments/artifacts 下)") + args = parser.parse_args() + + if args.duration < 2 or args.duration > 15: + die("--duration 必须在 2–15 秒之间") + + # --prev-segment: extract last frame of the previous segment and use it as + # the first frame. Enables人物故事模式 A.1 首尾帧对齐: each segment starts + # from the exact end frame of the previous one. + prev_segment_frame: Path | None = None + if args.prev_segment: + if args.image: + die("--prev-segment 与 --image 互斥:首帧由上一段末帧决定") + prev_segment_frame = extract_last_frame(Path(args.prev_segment)) + args.image = str(prev_segment_frame) + + platform = args.platform or resolve_platform() + mode = resolve_mode(args) + + if platform == "volcengine": + api_key = (os.environ.get("AWK_GEN_KEY") or "").strip() + if not api_key: + die("AWK_GEN_KEY 未设置") + candidates = [args.model] if args.model else volc_candidates(args) + else: + api_key = (os.environ.get("MODELSTUDIO_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or "").strip() + if not api_key: + die("MODELSTUDIO_API_KEY / DASHSCOPE_API_KEY 未设置") + candidates = [args.model] if args.model else ds_candidates(args, mode) + + output_path = ensure_safe_output(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + log( + f"platform={platform} mode={mode} candidates={candidates} " + f"duration={args.duration}s ratio={args.ratio} resolution={args.resolution} audio={args.audio}" + ) + video_url = generate(platform, candidates, args, api_key) + download(video_url, output_path) + + meta = output_path.with_suffix(".json") + meta.write_text( + json.dumps( + { + "platform": platform, + "mode": mode, + "model_candidates": candidates, + "duration": args.duration, + "ratio": args.ratio, + "resolution": args.resolution, + "audio": args.audio, + "video_url": video_url, + "file": str(output_path), + "prev_segment": args.prev_segment, + "first_frame_from_prev": str(prev_segment_frame) if prev_segment_frame else None, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"[done] video saved: {output_path}") + print(f"[done] metadata: {meta}") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-product/scripts/review.py b/crews/content-producer/skills/video-product/scripts/review.py new file mode 100644 index 00000000..ff3ccf8e --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/review.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""Final-video self-review — post-compose quality gate. + +Runs AFTER assemble.py produces video.mp4. Outputs verdict JSON. The skill's +SKILL.md Step 5 mandates: review.py must pass before the video is handed back +to the user. A "fail" verdict means the agent must fix and re-review, not deliver. + +What it checks (borrowed from OpenMontage post-render self-review + gbro Gate 3 QA, +scoped to our ffmpeg-only no-Remotion/HyperFrames world): + 1. ffprobe full validation — codec, resolution, fps, pixel format, audio config + 2. 4-position frame extraction (0% / 25% / 50% / 75% / 100%) → black-frame + overlay-break scan + 3. Audio level analysis — silence / clipping / absent track + 4. Duration vs target (from sibling script.md 片段规划表 时长列累加,or --target-duration) + 5. Resolution uniformity — checks the成片 matches the first segment's resolution + (拼了不同分辨率段是硬伤) + +NOT included (deliberately): + - Subtitle presence check — 我们的 assemble.py 不烧字幕,无意义 + - Delivery promise / slideshow risk — 那是脚本阶段的事,归 Step 2 slideshow-risk 自检清单 + - Decision audit trail — 那是 decisions.log 的事,归 state.json + decisions.log + +Usage: + python3 ./skills/video-product/scripts/review.py + python3 ./skills/video-product/scripts/review.py --target-duration 30 --target-resolution 720x1280 + python3 ./skills/video-product/scripts/review.py --output review.json + +Exit codes: + 0 verdict = "pass" → 可以交付 + 1 verdict = "fail" → 必须修,不准交 + 2 verdict = "warn" → 有 non-critical issues,向用户复述让其决定是否重修 + 3 script error (ffprobe missing / path invalid / ...) — 脚本本身故障,不算评审结论 + +Verdict JSON schema (also pretty-printed to stdout): + { + "verdict": "pass" | "fail" | "warn", + "file": "", + "ffprobe": { codec, width, height, fps, pix_fmt, duration, size_bytes, audio {...} }, + "frames": [ { "position_pct": 0, "path": "...", "mean_luma": 0.0, "is_black": false } ], + "audio_level": { "mean_db": -32.4, "max_db": -8.1, "silent": false, "clipping": false }, + "checks": [ + { "name": "duration_match", "status": "pass", "detail": "actual 30.2s vs target 30s, gap 0.2s" }, + { "name": "resolution_720p", "status": "pass", "detail": "720x1280" }, + { "name": "resolution_uniform", "status": "fail", "detail": "成片 720x1280 vs 段01 1080x1920" }, + ... + ], + "critical": [ "resolution_uniform: ..." ], + "warnings": [ "audio_level mean_db=-42.1 close to silent threshold" ] + } +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +# ── Constants ────────────────────────────────────────────────────────────── + +VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv", ".avi"} +REVIEW_DIR_NAME = "review" # /review/ 抽帧 + verdict JSON 落这 +FRAMES_SUBDIR = "frames" + +# Frame extraction positions (% of duration). OpenMontage 抽 4 位,我们按其 + gbro +# Gate 3 的逐秒抽帧折中——5 位(0/25/50/75/100%)足够拦黑帧/overlay 损,不堆 footage。 +FRAME_POSITIONS_PCT = [0.0, 25.0, 50.0, 75.0, 100.0] + +# Black frame threshold — luma mean below this → "black". 对齐 check.py 的 0.02,可调。 +BLACK_LUMA_THRESHOLD = 0.02 +# Audio thresholds — 声画同出模式下 BGM+旁白正常电平 -25~-10 dB,过静/过响都硬伤 +AUDIO_SILENT_THRESHOLD_DB = -60.0 +AUDIO_CLIPPING_THRESHOLD_DB = -1.0 + +# Duration tolerance — 拼接允许 ±5% 偏差(OpenMontage 也用 5%) +DURATION_TOLERANCE_PCT = 5.0 + +# Resolution floor — 9:16 竖屏短视频最低 720x1280,横屏 1280x720 +RES_MIN_LONG = 720 +RES_MIN_SHORT = 720 + + +# ── Helpers ──────────────────────────────────────────────────────────────── + +def die(msg: str, code: int = 3) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def run(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: + """Run a subprocess, return (exit, stdout, stderr).""" + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return r.returncode, r.stdout, r.stderr + except FileNotFoundError: + die(f"missing binary: {cmd[0]}") + except subprocess.TimeoutExpired: + die(f"timeout running: {' '.join(cmd[:3])}...") + + +def ffprobe(path: str) -> dict: + """Full ffprobe dump as dict. Returns {error: str} on failure.""" + rc, out, err = run([ + "ffprobe", "-v", "quiet", "-print_format", "json", + "-show_format", "-show_streams", path, + ], timeout=30) + if rc != 0: + return {"error": f"ffprobe exit {rc}: {err.strip()}"} + try: + return json.loads(out) + except json.JSONDecodeError as e: + return {"error": f"ffprobe output not JSON: {e}"} + + +# ── Frame extraction & black detection ───────────────────────────────────── + +def extract_frames(video: str, duration: float, out_dir: Path) -> list[dict]: + """Extract 5 frames at FRAME_POSITIONS_PCT. Returns list of {position_pct, path, mean_luma, is_black}.""" + out_dir.mkdir(parents=True, exist_ok=True) + frames: list[dict] = [] + + for pct in FRAME_POSITIONS_PCT: + # timestamp in seconds + ts = duration * pct / 100.0 + # 端 0% 时 ts=0,ffmpeg trim 起会拒;用 -ss 段定位 + -frames:v 1 + frame_path = out_dir / f"frame_{int(pct):03d}.jpg" + cmd = [ + "ffmpeg", "-y", "-v", "quiet", + "-ss", f"{ts:.3f}", "-i", video, + "-frames:v", "1", "-q:v", "2", + str(frame_path), + ] + rc, _, _ = run(cmd, timeout=30) + if rc != 0 or not frame_path.exists(): + frames.append({"position_pct": pct, "path": None, "mean_luma": None, "is_black": None}) + continue + + # Use ffmpeg signalstats to get mean luma. signalstats gives YAVG. + rc, out, _ = run([ + "ffmpeg", "-v", "quiet", "-i", str(frame_path), + "-vf", "signalstats", "-f", "null", "-", + ], timeout=15) + mean_luma: float | None = None + # signalstats prints to stderr normally; with quiet we get nothing. + # Fallback: use ffmpeg with stderr passthrough to catch YAVG=N. + if rc == 0: + rc2, out2, err2 = run([ + "ffmpeg", "-v", "info", "-i", str(frame_path), + "-vf", "signalstats", "-f", "null", "-", + ], timeout=15) + for line in (err2 + out2).splitlines(): + # Example: "signalstats: YAVG=0.012300 ..." + if "YAVG=" in line: + try: + mean_luma = float(line.split("YAVG=")[1].split()[0]) + except (IndexError, ValueError): + pass + break + + # If signalstats failed, try a Pillow-free fallback via ffmpeg lutyuv mean + if mean_luma is None: + # Use ffmpeg blackframe filter — it logs frames below threshold + rc3, _, err3 = run([ + "ffmpeg", "-v", "info", "-i", str(frame_path), + "-vf", f"blackframe=threshold={BLACK_LUMA_THRESHOLD}", + "-f", "null", "-", + ], timeout=15) + # If "black" appears in stderr, frame is black + is_black_log = "First black frame detected" in err3 or "black" in err3.lower() + mean_luma = 0.0 if is_black_log else 0.5 # placeholder; we trust is_black_log + + is_black = mean_luma is not None and mean_luma < BLACK_LUMA_THRESHOLD + frames.append({ + "position_pct": pct, + "path": str(frame_path), + "mean_luma": round(mean_luma, 4) if mean_luma is not None else None, + "is_black": is_black, + }) + + return frames + + +# ── Audio level analysis ──────────────────────────────────────────────────── + +def analyze_audio(video: str, has_audio: bool) -> dict: + """Use ffmpeg volumedetect filter. Returns {mean_db, max_db, silent, clipping, absent}.""" + if not has_audio: + return {"absent": True, "silent": None, "clipping": None, "mean_db": None, "max_db": None} + + rc, _, err = run([ + "ffmpeg", "-v", "info", "-i", video, + "-af", "volumedetect", + "-f", "null", "-", + ], timeout=60) + + mean_db: float | None = None + max_db: float | None = None + for line in err.splitlines(): + if "mean_volume:" in line: + try: + mean_db = float(line.split("mean_volume:")[-1].strip().rstrip(" dB")) + except (IndexError, ValueError): + pass + elif "max_volume:" in line: + try: + max_db = float(line.split("max_volume:")[-1].strip().rstrip(" dB")) + except (IndexError, ValueError): + pass + + silent = mean_db is not None and mean_db < AUDIO_SILENT_THRESHOLD_DB + clipping = max_db is not None and max_db >= AUDIO_CLIPPING_THRESHOLD_DB + + return { + "absent": False, + "mean_db": round(mean_db, 2) if mean_db is not None else None, + "max_db": round(max_db, 2) if max_db is not None else None, + "silent": silent, + "clipping": clipping, + } + + +# ── Resolution uniformity vs segments ─────────────────────────────────────── + +def probe_segments(project_dir: Path) -> list[dict]: + """Quick ffprobe of each artifact segment for resolution uniformity check.""" + artifacts = project_dir / "artifacts" + if not artifacts.is_dir(): + return [] + + segments: list[dict] = [] + for name in sorted(os.listdir(artifacts)): + path = artifacts / name + if path.suffix.lower() not in VIDEO_EXTS: + continue + if not path.is_file(): + continue + # Skip _deprecated subfolder is non-recursive — but we're listdir level only + data = ffprobe(str(path)) + if "error" in data: + segments.append({"file": name, "error": data["error"]}) + continue + streams = data.get("streams", []) + v = next((s for s in streams if s.get("codec_type") == "video"), None) + if v: + segments.append({ + "file": name, + "width": int(v.get("width", 0)), + "height": int(v.get("height", 0)), + }) + return segments + + +# ── Main ─────────────────────────────────────────────────────────────────── + +def review(project_dir: Path, target_duration: float | None, target_resolution: str | None, + output_path: Path | None) -> dict: + """Run full review, build verdict dict, write JSON, return dict.""" + video = project_dir / "video.mp4" + if not video.is_file(): + die(f"成片不存在: {video}") + + review_dir = project_dir / REVIEW_DIR_NAME + frames_dir = review_dir / FRAMES_SUBDIR + review_dir.mkdir(parents=True, exist_ok=True) + + verdict: dict = { + "verdict": "pass", + "file": str(video.resolve()), + "ffprobe": {}, + "frames": [], + "audio_level": {}, + "checks": [], + "critical": [], + "warnings": [], + } + + # ── 1. ffprobe full ─────────────────────────────────────────────────── + data = ffprobe(str(video)) + if "error" in data: + verdict["verdict"] = "fail" + verdict["critical"].append(f"ffprobe failed: {data['error']}") + # No further checks possible + _finalize(verdict, output_path) + return verdict + + fmt = data.get("format", {}) + streams = data.get("streams", []) + v_stream = next((s for s in streams if s.get("codec_type") == "video"), None) + a_stream = next((s for s in streams if s.get("codec_type") == "audio"), None) + + if not v_stream: + verdict["verdict"] = "fail" + verdict["critical"].append("no video stream in output") + _finalize(verdict, output_path) + return verdict + + duration = float(fmt.get("duration", 0)) + width = int(v_stream.get("width", 0)) + height = int(v_stream.get("height", 0)) + fps = v_stream.get("r_frame_rate", "unknown") + pix_fmt = v_stream.get("pix_fmt", "unknown") + + verdict["ffprobe"] = { + "codec": v_stream.get("codec_name", "unknown"), + "width": width, + "height": height, + "fps": fps, + "pix_fmt": pix_fmt, + "duration": round(duration, 2), + "size_bytes": int(fmt.get("size", 0)), + "audio": ( + {"codec": a_stream.get("codec_name", "unknown"), + "sample_rate": int(a_stream.get("sample_rate", 0)), + "channels": int(a_stream.get("channels", 0))} + if a_stream else None + ), + } + + # ── 2. Frame extraction & black detection ──────────────────────────── + frames = extract_frames(str(video), duration, frames_dir) + verdict["frames"] = frames + black_count = sum(1 for f in frames if f.get("is_black") is True) + if black_count >= 2: # ≥2 black frames out of 5 → critical + verdict["critical"].append( + f"black_frame: {black_count}/{len(frames)} sampled frames are black — overlay/encode broken" + ) + elif black_count == 1: + verdict["warnings"].append( + f"black_frame: 1/{len(frames)} sampled frame is black — likely first-frame transition, verify" + ) + + # ── 3. Audio level ─────────────────────────────────────────────────── + audio = analyze_audio(str(video), a_stream is not None) + verdict["audio_level"] = audio + if audio.get("absent"): + # 声画同出模式下无声是硬伤;Stock Footage + --no-audio 模式下正常 — 软警告 + verdict["warnings"].append("audio_absent: no audio track (verify against pipeline mode)") + else: + if audio.get("silent"): + verdict["critical"].append( + f"audio_silent: mean_db={audio['mean_db']} below {-AUDIO_SILENT_THRESHOLD_DB}dB — silent audio" + ) + if audio.get("clipping"): + verdict["critical"].append( + f"audio_clipping: max_db={audio['max_db']} above {AUDIO_CLIPPING_THRESHOLD_DB}dB — clipping" + ) + + # ── 4. Duration vs target ──────────────────────────────────────────── + if target_duration is not None and target_duration > 0: + gap_pct = abs(duration - target_duration) / target_duration * 100 + if gap_pct > DURATION_TOLERANCE_PCT: + verdict["critical"].append( + f"duration_mismatch: actual {duration:.2f}s vs target {target_duration}s, gap {gap_pct:.1f}% > {DURATION_TOLERANCE_PCT}%" + ) + elif gap_pct > 1.0: + verdict["warnings"].append( + f"duration_drift: actual {duration:.2f}s vs target {target_duration}s, gap {gap_pct:.1f}%" + ) + verdict["checks"].append({ + "name": "duration_match", + "status": "pass" if gap_pct <= DURATION_TOLERANCE_PCT else "fail", + "detail": f"actual {duration:.2f}s vs target {target_duration}s, gap {gap_pct:.1f}%" + }) + else: + verdict["checks"].append({ + "name": "duration_match", + "status": "skipped", + "detail": "no target_duration provided" + }) + + # ── 5. Resolution floor + uniformity ───────────────────────────────── + long_side = max(width, height) + short_side = min(width, height) + if long_side < RES_MIN_LONG or short_side < RES_MIN_SHORT: + verdict["critical"].append( + f"resolution_low: {width}x{height} below floor {RES_MIN_SHORT}p" + ) + verdict["checks"].append({ + "name": "resolution_floor", + "status": "pass" if long_side >= RES_MIN_LONG and short_side >= RES_MIN_SHORT else "fail", + "detail": f"{width}x{height}" + }) + + if target_resolution: + try: + tw, th = (int(x) for x in target_resolution.lower().split("x")) + except ValueError: + verdict["warnings"].append(f"invalid --target-resolution: {target_resolution}") + tw = th = None + if tw and th: + if width != tw or height != th: + verdict["critical"].append( + f"resolution_mismatch: actual {width}x{height} vs target {tw}x{th}" + ) + verdict["checks"].append({ + "name": "resolution_target", + "status": "pass" if width == tw and height == th else "fail", + "detail": f"{width}x{height} vs {tw}x{th}" + }) + + # Uniformity vs segments + segments = probe_segments(project_dir) + if segments: + mismatches = [ + s for s in segments + if "width" in s and (s["width"] != width or s["height"] != height) + ] + if mismatches: + examples = "; ".join(f"{s['file']}={s['width']}x{s['height']}" for s in mismatches[:3]) + verdict["critical"].append( + f"resolution_uniform: 成片 {width}x{height} vs 段分辨率不齐 — {examples}" + ) + verdict["checks"].append({ + "name": "resolution_uniform", + "status": "pass" if not mismatches else "fail", + "detail": f"成片 {width}x{height}, 段数 {len(segments)}, 不齐 {len(mismatches)}" + }) + + # ── Pixel format sanity ────────────────────────────────────────────── + if "420" not in pix_fmt: + verdict["warnings"].append(f"pix_fmt_unusual: {pix_fmt} — 多平台发布建议 yuv420p") + + # ── Final verdict tally ────────────────────────────────────────────── + if verdict["critical"]: + verdict["verdict"] = "fail" + elif verdict["warnings"]: + verdict["verdict"] = "warn" + else: + verdict["verdict"] = "pass" + + _finalize(verdict, output_path) + return verdict + + +def _finalize(verdict: dict, output_path: Path | None) -> None: + """Pretty-print verdict to stdout and write JSON.""" + print(json.dumps(verdict, indent=2, ensure_ascii=False)) + + if output_path is None: + # Default: write to /review/verdict.json + # output_path is None means caller didn't specify — we already have review_dir + # but we don't here. Simpler: caller always passes output_path. + return + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(verdict, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"\n[ok] verdict written to {output_path}", file=sys.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Final-video self-review. Runs after assemble.py, before delivery." + ) + parser.add_argument("project_dir", help="项目目录 (含 video.mp4 与 artifacts/)") + parser.add_argument("--target-duration", type=float, default=None, + help="目标时长(秒),从 script.md 片段规划累加得出") + parser.add_argument("--target-resolution", default=None, + help="目标分辨率,形如 720x1280") + parser.add_argument("--output", default=None, + help="verdict JSON 落盘路径,默认 /review/verdict.json") + args = parser.parse_args() + + project_dir = Path(args.project_dir).resolve() + if not project_dir.is_dir(): + die(f"项目目录不存在: {project_dir}") + + output_path = ( + Path(args.output).resolve() if args.output + else project_dir / REVIEW_DIR_NAME / "verdict.json" + ) + + verdict = review(project_dir, args.target_duration, args.target_resolution, output_path) + + # Exit code: 0 pass / 1 fail / 2 warn / 3 script error + sys.exit({ + "pass": 0, + "fail": 1, + "warn": 2, + }.get(verdict["verdict"], 3)) + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-product/scripts/state.py b/crews/content-producer/skills/video-product/scripts/state.py new file mode 100644 index 00000000..e9b15aca --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/state.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Pipeline state manager for video-product — subagent resume-from-failure. + +Borrowed from OpenMontage lib/checkpoint.py, scoped to our needs: +- One JSON state per project: output_videos//state.json +- Stage list is fixed (script → gate0 → calibrate → assets → assemble → review → cover) +- Each stage: status (pending/in_progress/completed/awaiting_human/failed) + ts + notes +- Append-only on disk — superseded states archived to state.history/ (never destroy) +- decisions.log is separate append-only audit; this file is point-in-time recovery + +Usage: + python3 ./skills/video-product/scripts/state.py --init + python3 ./skills/video-product/scripts/state.py --enter + python3 ./skills/video-product/scripts/state.py --complete [--notes "..."] + python3 ./skills/video-product/scripts/state.py --await [--notes "..."] + python3 ./skills/video-product/scripts/state.py --fail [--notes "..."] + python3 ./skills/video-product/scripts/state.py --next # prints next pending stage + python3 ./skills/video-product/scripts/state.py --show # pretty-print current state + +Stages (in order): + script — Step 2 脚本创作与定稿 + gate0 — Step 2.3.5 Gate 0 关键帧 contact sheet 确认 + calibrate — Step 2.4 脚本定稿打分+盲预测(content-calibrator) + assets — Step 3 + Step 4 用户素材预处理 + 视频素材生产 + assemble — Step 5 合成视频 + review — Step 5.5 成片自检(review.py) + cover — Step 6 制作封面 + deliver — Step 7 用户确认交付 + +Exit codes: + 0 ok + 1 bad args / state corrupt / stage unknown + 2 --next but all stages completed (nothing to resume) +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sys +from datetime import datetime +from pathlib import Path + +STAGES = ["script", "gate0", "calibrate", "assets", "assemble", "review", "cover", "deliver"] +STATE_FILE_NAME = "state.json" +HISTORY_DIR_NAME = "state.history" + +VALID_STATUSES = {"pending", "in_progress", "completed", "awaiting_human", "failed"} +# Stages that require human approval before advancing (borrowed from OpenMontage human_approval_default) +GATED_STAGES = {"script", "gate0", "calibrate", "assets", "deliver"} + + +def die(msg: str, code: int = 1) -> None: + print(f"[error] {msg}", file=sys.stderr) + sys.exit(code) + + +def state_path(project: Path) -> Path: + return project / STATE_FILE_NAME + + +def history_dir(project: Path) -> Path: + return project / HISTORY_DIR_NAME + + +def now_ts() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def init_state(project: Path) -> dict: + """Create initial state with all stages pending.""" + state = { + "project": str(project.resolve()), + "created": now_ts(), + "updated": now_ts(), + "stages": {s: {"status": "pending", "ts": None, "notes": ""} for s in STAGES}, + } + state_path(project).write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + return state + + +def load_state(project: Path) -> dict: + p = state_path(project) + if not p.is_file(): + die(f"state.json 不存在:{p}(先跑 --init)") + try: + state = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + die(f"state.json 损坏:{e}") + if "stages" not in state or set(state["stages"].keys()) != set(STAGES): + die(f"state.json stages 不匹配,期望 {list(STAGES)}") + return state + + +def archive_and_write(project: Path, state: dict) -> None: + """Archive current state to state.history/ (timestamped) then write new.""" + p = state_path(project) + if p.is_file(): + hist = history_dir(project) + hist.mkdir(exist_ok=True) + ts_slug = datetime.now().strftime("%Y%m%d_%H%M%S") + shutil.copy2(p, hist / f"state_{ts_slug}.json") + state["updated"] = now_ts() + p.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") + + +def set_stage(project: Path, stage: str, status: str, notes: str | None) -> None: + if stage not in STAGES: + die(f"unknown stage: {stage}; valid: {STAGES}") + if status not in VALID_STATUSES: + die(f"unknown status: {status}; valid: {VALID_STATUSES}") + state = load_state(project) + cur = state["stages"][stage] + cur["status"] = status + cur["ts"] = now_ts() + if notes is not None: + cur["notes"] = notes + archive_and_write(project, state) + print(f"[ok] {stage} → {status}" + (f" ({notes})" if notes else "")) + + +def next_pending(project: Path) -> str | None: + """Return the first stage that's pending or failed or awaiting_human, in order.""" + state = load_state(project) + for s in STAGES: + st = state["stages"][s]["status"] + if st in ("pending", "failed", "awaiting_human"): + return s + return None + + +def show(project: Path) -> None: + state = load_state(project) + print(json.dumps(state, indent=2, ensure_ascii=False)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Pipeline state manager for video-product.") + parser.add_argument("project_dir", help="项目目录 (output_videos//)") + parser.add_argument("--init", action="store_true", help="初始化 state.json,所有阶段 pending") + parser.add_argument("--enter", metavar="STAGE", default=None, help="进入某阶段:标 in_progress") + parser.add_argument("--complete", metavar="STAGE", default=None, help="完成某阶段:标 completed") + parser.add_argument("--await", dest="await_", metavar="STAGE", default=None, help="等用户决策:标 awaiting_human") + parser.add_argument("--fail", metavar="STAGE", default=None, help="某阶段失败:标 failed") + parser.add_argument("--next", action="store_true", help="打印下一个待跑阶段") + parser.add_argument("--show", action="store_true", help="pretty-print 当前 state") + parser.add_argument("--notes", default=None, help="给 --enter/--complete/--await/--fail 附备注") + args = parser.parse_args() + + project = Path(args.project_dir).resolve() + if not project.is_dir(): + die(f"项目目录不存在:{project}") + + if args.init: + init_state(project) + print(f"[ok] state initialized at {state_path(project)}") + return + + # All other commands need existing state + if args.next: + nxt = next_pending(project) + if nxt is None: + print("[done] all stages completed — nothing to resume", file=sys.stderr) + sys.exit(2) + print(nxt) + return + + if args.show: + show(project) + return + + for flag, status in [("enter", "in_progress"), ("complete", "completed"), + ("await_", "awaiting_human"), ("fail", "failed")]: + val = getattr(args, flag) + if val is not None: + set_stage(project, val, status, args.notes) + return + + die("没指定动作:传 --init / --enter / --complete / --await / --fail / --next / --show 之一") + + +if __name__ == "__main__": + main() diff --git a/crews/content-producer/skills/video-product/scripts/tts.py b/crews/content-producer/skills/video-product/scripts/tts.py new file mode 100644 index 00000000..848c1c39 --- /dev/null +++ b/crews/content-producer/skills/video-product/scripts/tts.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""SiliconFlow text-to-speech — stdlib only (no httpx/requests).""" + +import argparse +import json +import mimetypes +import os +import re +import sys +import time +import urllib.error +import urllib.request +import uuid +from pathlib import Path + +DEFAULT_API_BASE = "https://api.siliconflow.cn/v1" +DEFAULT_MODEL = "fnlp/MOSS-TTSD-v0.5" +DEFAULT_VOICE = "fnlp/MOSS-TTSD-v0.5:benjamin" +DEFAULT_ASR_MODEL = "TeleAI/TeleSpeechASR" +VALID_FORMATS = {"mp3", "opus", "wav", "pcm"} +VALID_VOICES = { + "fnlp/MOSS-TTSD-v0.5:benjamin", + "fnlp/MOSS-TTSD-v0.5:charles", + "fnlp/MOSS-TTSD-v0.5:claire", + "fnlp/MOSS-TTSD-v0.5:david", + "fnlp/MOSS-TTSD-v0.5:diana", +} +SAMPLE_RATES_BY_FORMAT = { + "mp3": {32000, 44100}, + "opus": {48000}, + "wav": {8000, 16000, 24000, 32000, 44100}, + "pcm": {8000, 16000, 24000, 32000, 44100}, +} +SAFE_INPUT_DIRS = (Path("scripts"), Path("assets"), Path("tmp"), Path("output_videos"), Path("fragments")) +SAFE_OUTPUT_DIRS = (Path("assets/audio"), Path("tmp"), Path("output_videos"), Path("fragments")) +TEXT_EXTENSIONS = {".txt", ".md", ".srt", ".vtt"} +MAX_TEXT_FILE_BYTES = 512 * 1024 + + +def die(message: str) -> None: + print(f"[error] {message}", file=sys.stderr) + sys.exit(1) + + +def workspace_root(root: Path | None = None) -> Path: + return (root or Path.cwd()).resolve() + + +def ensure_safe_path(raw_path: str, allowed_dirs: tuple[Path, ...], purpose: str, root: Path | None = None) -> Path: + path = Path(raw_path) + if path.is_absolute(): + die(f"{purpose} path must be relative to the workspace") + if ".." in path.parts: + die(f"{purpose} path must not contain '..'") + + resolved_root = workspace_root(root) + resolved = (resolved_root / path).resolve() + if not any(resolved == (resolved_root / base).resolve() or resolved.is_relative_to((resolved_root / base).resolve()) for base in allowed_dirs): + allowed = ", ".join(str(base) for base in allowed_dirs) + die(f"{purpose} path must be under one of: {allowed}") + return resolved + + +def extract_tts_requirement_text(content: str) -> str: + """Extract only the voiceover copy from a tts_requirement.md file.""" + heading_markers = ( + "配音文案", + "voiceover text", + "voiceover copy", + "narration text", + "script text", + ) + lines = content.splitlines() + collecting = False + extracted: list[str] = [] + + for line in lines: + stripped = line.strip() + lower = stripped.lower() + if stripped.startswith("## "): + if collecting: + break + collecting = any(marker in lower for marker in heading_markers) + continue + if not collecting: + continue + if not stripped or stripped.startswith(" -
- - -
-

PLACEHOLDER_TITLE

-

PLACEHOLDER_SUBTITLE

-
-
- - -``` - -### 1.3 统一后的 content-graph - -**所有节点都是 HTML 帧**,不再有 dynamic/stock 两条路径: - -| 节点类型 | 模板 | 变量来源 | -|---------|------|---------| -| 标题动画 | `glitch-title-916` | agent 填充文案 | -| 文字动效 | `kinetic-type-916` | agent 填充文案 | -| 数据图表 | `data-chart-916` | agent 填充数据 | -| **素材片段** | **`video-clip-916`** | **video_generate / siliconflow-video-gen / pixabay / pexels → MP4 路径** | -| **用户提供片段** | **`video-clip-916`** | **用户提供的 MP4 路径** | -| **AI 生成片段** | **`video-clip-916`** | **siliconflow-video-gen 产出的 MP4 路径** | -| 结尾 CTA | `logo-outro-916` | agent 填充文案 | - -### 1.4 素材获取流程(统一纳入 html-video) - -``` -content-graph 中 productionMode: "stock" 的节点: - │ - ▼ -① 获取素材 MP4 - 优先级: video_generate → siliconflow-video-gen → pixabay-footage → pexels-footage - 产出: clip.mp4 - │ - ▼ -② 注入 video-clip-916 模板 - PLACEHOLDER_VIDEO_SRC → clip.mp4 路径 - PLACEHOLDER_DURATION → ffprobe 获取时长 - PLACEHOLDER_TITLE / SUBTITLE → 可选文字叠加 - │ - ▼ -③ html-video 正常渲染该帧(Chromium 加载 HTML →