diff --git a/scripts/build_it_infra_video.py b/scripts/build_it_infra_video.py index 16c06cc..f064159 100755 --- a/scripts/build_it_infra_video.py +++ b/scripts/build_it_infra_video.py @@ -13,6 +13,7 @@ import html import json import os import re +import shlex import shutil import subprocess import sys @@ -208,7 +209,7 @@ def build_sections(rows: list[ManifestRow], section_duration: float) -> list[Sec start=start, duration=section_duration, time_label=format_time(start), - timeline_label=title[:8] or f"Chapter {index + 1}", + timeline_label=title or f"Chapter {index + 1}", title=title, subtitle=subtitle, tags=tags, @@ -498,6 +499,150 @@ def js_array(values: list[str | float]) -> str: return json.dumps(values, ensure_ascii=False) +def ffmpeg_drawtext_text(value: str) -> str: + return value.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'") + + +def display_units(value: str) -> float: + units = 0.0 + for char in value: + if char.isspace(): + units += 0.35 + elif ord(char) < 128: + units += 0.55 + else: + units += 1.0 + return units + + +def truncate_display(value: str, max_units: float) -> str: + output: list[str] = [] + used = 0.0 + for char in value: + char_units = display_units(char) + if output and used + char_units > max_units: + break + output.append(char) + used += char_units + return "".join(output).strip() + + +def ffmpeg_timeline_label(section: dict, max_units: float) -> str: + title = str(section.get("timelineLabel") or section.get("title") or "").strip() + return truncate_display(title, max_units) or "章节" + + +def ffmpeg_visual_filter(config: dict, active_index: int) -> str: + sections = config["sections"] + duration = float(config["duration"]) + count = max(1, len(sections)) + slot = 1736 / count + active = sections[active_index] + active_start = float(active["start"]) + active_x = round(92 + active_index * slot, 2) + active_w = max(140, round(slot - 12, 2)) + font_size = 23 if count <= 6 else max(15, int(23 - (count - 6) * 1.2)) + label_units = max(4.0, (slot - 82) / font_size) + label_y = 1012 + progress_y = 1060 + font_file = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + + filters = [ + "[0:v]split=2[vb][vf]", + "[vb]scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080,boxblur=18:1,eq=brightness=-0.02:saturation=0.9[bg]", + "[vf]scale=1760:810:force_original_aspect_ratio=decrease,pad=1760:810:(ow-iw)/2:(oh-ih)/2:white,setsar=1[fg]", + "[bg][fg]overlay=(W-w)/2:88", + "drawbox=x=0:y=0:w=1920:h=84:color=0xf8fbffff:t=fill", + "drawbox=x=0:y=970:w=1920:h=110:color=0x061a3acc:t=fill", + f"drawbox=x={active_x}:y=1002:w={active_w}:h=42:color=0x11b5d6cc:t=fill", + ] + + for index, section in enumerate(sections): + label_x = round(112 + index * slot, 2) + text = ffmpeg_drawtext_text(f"{section['timeLabel']} {ffmpeg_timeline_label(section, label_units)}") + filters.append( + "drawtext=" + f"fontfile='{font_file}':" + f"text='{text}':" + f"x={label_x}:y={label_y}:fontsize={font_size}:fontcolor=white:" + "borderw=1:bordercolor=0x06205fcc" + ) + + progress_expr = ( + f"min(1736\\,max(0\\,(t+{active_start})/{duration}*1736))" + if duration > 0 + else "0" + ) + filters.extend( + [ + f"drawbox=x=92:y={progress_y}:w=1736:h=8:color=0xffffff33:t=fill", + f"drawbox=x=92:y={progress_y}:w='{progress_expr}':h=8:color=0x11b5d6ee:t=fill", + ] + ) + return ",".join(filters) + + +def write_ffmpeg_fallback_script(project_dir: Path, config: dict, output_name: str) -> None: + sections = config["sections"] + duration = float(config["duration"]) + segment_dir = "build_segments" + output_path = f"renders/{output_name}" + script_lines = [ + "#!/usr/bin/env bash", + "set -euo pipefail", + f"mkdir -p {segment_dir} renders", + ] + concat_lines = [] + for index, section in enumerate(sections): + image = shlex.quote(str(section["image"])) + segment_path = f"{segment_dir}/seg_{index:02d}.mp4" + section_duration = float(section["duration"]) + visual_filter = ffmpeg_visual_filter(config, index) + script_lines.extend( + [ + f"ffmpeg -y -hide_banner -loop 1 -i {image} -t {section_duration:g} \\", + f" -filter_complex {shlex.quote(visual_filter)} \\", + f" -r 30 -c:v libx264 -preset veryfast -crf 21 -pix_fmt yuv420p {shlex.quote(segment_path)}", + ] + ) + concat_lines.append(f"file '{project_dir / segment_path}'") + + script_lines.extend( + [ + f"cat > {segment_dir}/concat.txt <<'EOF'", + *concat_lines, + "EOF", + f"ffmpeg -y -hide_banner -f concat -safe 0 -i {segment_dir}/concat.txt -c copy {segment_dir}/video_silent.mp4", + ] + ) + + audio_inputs = ["-stream_loop -1 -i assets/audio/bgm.wav"] + audio_filters = [f"[1:a]volume=0.08,atrim=0:{duration:g}[bgm]"] + mix_inputs = ["[bgm]"] + for index, section in enumerate(sections): + input_index = index + 2 + voiceover = shlex.quote(str(section["voiceover"])) + audio_inputs.append(f"-i {voiceover}") + delay_ms = max(0, int(round((float(section["start"]) + 0.2) * 1000))) + audio_filters.append(f"[{input_index}:a]adelay={delay_ms}|{delay_ms},volume=1.8[a{index}]") + mix_inputs.append(f"[a{index}]") + + audio_filter = ";".join(audio_filters + [f"{''.join(mix_inputs)}amix=inputs={len(mix_inputs)}:duration=first:normalize=0[a]"]) + script_lines.extend( + [ + "ffmpeg -y -hide_banner -i build_segments/video_silent.mp4 \\", + *[f" {line} \\" for line in audio_inputs], + f" -filter_complex {shlex.quote(audio_filter)} \\", + f" -map 0:v -map '[a]' -t {duration:g} -c:v copy -c:a aac -b:a 160k -movflags +faststart {shlex.quote(output_path)}", + f"ffprobe -v quiet -show_entries format=duration,size:stream=codec_type,width,height,r_frame_rate -of json {shlex.quote(output_path)} > ffprobe.json", + ] + ) + + script_path = project_dir / "build_ffmpeg_video.sh" + script_path.write_text("\n".join(script_lines) + "\n", encoding="utf-8") + script_path.chmod(0o755) + + def write_html(project_dir: Path, title: str, config: dict) -> None: sections = config["sections"] duration = config["duration"] @@ -745,6 +890,7 @@ def main(argv: list[str] | None = None) -> int: elif not (project_dir / "assets/audio/bgm.wav").exists(): fail("--audio-mode none requires existing assets/audio/bgm.wav") write_html(project_dir, args.title, config) + write_ffmpeg_fallback_script(project_dir, config, args.output_name) if args.run_acceptance: run_acceptance(project_dir, config, args.output_name) write_delivery_report(project_dir, args.title, args.output_name, expected_scope, args.run_acceptance) diff --git a/skills/image-production/it-infra-continuous-png/SKILL.md b/skills/image-production/it-infra-continuous-png/SKILL.md index 7e8555f..be5fbfe 100644 --- a/skills/image-production/it-infra-continuous-png/SKILL.md +++ b/skills/image-production/it-infra-continuous-png/SKILL.md @@ -34,6 +34,37 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一 - `prompts/image-prompts.md` - 每张图的主题、标题、副标题、结构块、关键词、生成提示词。 +## XWorkmate/OpenClaw 交付目录 + +通过 OpenClaw 调用 Codex CLI/codecli 执行时,最终文件必须写入当前 run +的 prepared artifact scope。Bridge 会在本轮系统上下文中提供 +`artifactDirectory` / `artifactScope`,并在可用时等价映射为环境变量。 +目录解析优先级: + +1. `$XWORKMATE_TASK_ARTIFACT_DIR` +2. `$XWORKMATE_ARTIFACT_DIRECTORY` +3. 本轮系统上下文里的 `artifactDirectory: ...` +4. 当前工作目录本身已经是形如 `.../tasks//` 的目录 + +如果以上四项都不存在,必须先说明“缺少 XWorkmate prepared artifact +scope”,不要声称已经完成文件交付。 + +执行要求: + +- 先确认目标目录是当前 run 的 `tasks//` scope;不要使用 + `/owners/.../threads/` 作为最终交付目录。 +- `cd` 到选定目录,或所有写入都使用选定目录的绝对路径。 +- 在选定目录下直接创建 `assets/images/` 和 `prompts/`。 +- 禁止在选定目录内再创建 `task_artifacts//...`、`tasks/.../tasks/...` + 或其他二次嵌套导出目录。 +- Codex 产图如果先落在 `~/.codex/generated_images/...`,必须复制到 + `assets/images/*.png`,不能只引用缓存路径。 +- `assets/images/manifest.md`、`prompts/image-prompts.md` 和 + `series.config.json` 必须与 PNG 留在同一个 artifact scope。 +- 完成前必须确认每个 PNG 都是非空文件,manifest 的文件数量与 PNG 数量一致。 +- 最终回复列出 artifact scope 内的相对路径。不要把正文中的文件清单当作 + XWorkmate artifact 面板交付。 + ## 工作流 1. 读取用户输入:参考图、文件路径、主题清单、文字描述、目标数量和用途。 @@ -41,8 +72,9 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一 3. 生成系列配置,字段见 `templates/series.config.example.json`。 4. 为每张图生成独立 prompt,保证同一系列的布局、字体、色彩、底部总结条和视觉元素连续。 5. 逐张生成或编辑 PNG 图片。每次生成请求只描述一张目标图,避免模型把多张图拼进同一个画布。 -6. 保存输出到用户指定目录;未指定时放在当前项目的 `assets/images/`。 -7. 写 `assets/images/manifest.md`,供 `it-infra-evolution-video` 作为真实素材清单使用。 +6. 保存输出到选定 artifact scope 的 `assets/images/`;用户指定额外目录时,也复制一份到 artifact scope。 +7. 写 artifact scope 下的 `assets/images/manifest.md`,供 `it-infra-evolution-video` 作为真实素材清单使用。 +8. 写 artifact scope 下的 `prompts/image-prompts.md` 和 `series.config.json`。 ## 多图输出规则 @@ -52,6 +84,8 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一 - 每张 PNG 内部可以有多个信息卡片,但只能围绕一个主题/章节。 - 批量生成时,先生成 `series.config.json`,再按 `images[]` 逐项生成。 - manifest 必须逐文件记录,行数应等于输出 PNG 数量。 +- manifest 中的 `file` 必须是 artifact scope 内的相对路径,例如 + `assets/images/001-local-permission.png`。 ## 风格硬约束 diff --git a/skills/video-production/it-infra-evolution-video-v2/SKILL.md b/skills/video-production/it-infra-evolution-video-v2/SKILL.md index abe9a44..477bbb1 100644 --- a/skills/video-production/it-infra-evolution-video-v2/SKILL.md +++ b/skills/video-production/it-infra-evolution-video-v2/SKILL.md @@ -20,9 +20,13 @@ description: "从 it-infra-continuous-png 的真实 PNG manifest 生成 IT 基 ## 标准调用 -在当前任务工作目录或视频项目目录执行: +在 Bridge 预先准备的当前任务 artifact scope 中执行。目录解析优先使用 +`$XWORKMATE_TASK_ARTIFACT_DIR` / `$XWORKMATE_ARTIFACT_DIRECTORY`,其次使用 +本轮系统上下文里的 `artifactDirectory: ...`;只有当前 `pwd` 已经是 +`.../tasks//` 时才可直接使用 `.`。 ```bash +cd "${XWORKMATE_TASK_ARTIFACT_DIR:-${XWORKMATE_ARTIFACT_DIRECTORY:-.}}" python3 "${AI_VIDEO_SKILLS_HOME:-/home/ubuntu/ai-video-skills}/scripts/build_it_infra_video.py" \ --project-dir . \ --title "云原生 Service Mesh 网络科普视频" \ @@ -36,7 +40,8 @@ python3 "${AI_VIDEO_SKILLS_HOME:-/home/ubuntu/ai-video-skills}/scripts/build_it_ 在 XWorkmate/OpenClaw 中,`.` 必须是 Bridge 预先准备的 `tasks//` artifact scope。不能在 -`owners/.../threads/` 工作区直接渲染;如果只知道 +`owners/.../threads/` 工作区直接渲染,也不能在 scope 内再创建 +`task_artifacts//...` 二次嵌套目录;如果只知道 `artifactScope`,可用 `--artifact-scope "tasks//"` 代替 `--session-key/--run-id`。 @@ -56,6 +61,7 @@ runner 负责: - 保证 scene、caption、voiceover 在各自 track 上不重叠。 - 只保留一个全局 BGM 音轨。 - 生成 `video.config.json` 和 `inspectTimes`。 +- 生成 `build_ffmpeg_video.sh` 作为 HyperFrames 之外的应急合成路径;该脚本必须从 `video.config.json` 的 `sections` 动态生成章节标签、时间、active marker、总进度条和音频 delay,禁止手写固定 6 段、固定 25 秒、固定标题。 - 执行 `lint -> inspect -> snapshot -> render -> ffprobe`。 生产模式默认 `--audio-mode edge-tts`。本地测试或无网络 dry-run 可以使用 `--audio-mode tone`,但不能把 tone 输出当作正式口播成片。 @@ -81,3 +87,13 @@ runner 负责: - 时长接近 `video.config.json` 的 `duration` 如果 HyperFrames 或 ffprobe 任一阶段失败,只输出失败阶段和原因,不输出“完成”。 + +## FFmpeg fallback timeline text + +如果因为长版、快速修复或 HyperFrames 之外的 fallback 路径使用 FFmpeg 直接合成 timeline,不能只用 `drawbox` 绘制底部色块。必须同时绘制可见章节文字: + +- 优先使用 `/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc` 或 `fc-match "Noto Sans CJK SC"` 返回的中文字体。 +- 优先使用 runner 生成的 `build_ffmpeg_video.sh`,不要在任务现场临时手写 `build_long_video.sh` 或固定数组脚本。 +- 每个章节 marker 必须包含时间和短标题,例如 `0:25 服务器是一座孤岛`。 +- 必须保留 v1/HyperFrames 风格的底部总进度条:一条低透明底线加一条随全片时间推进的高亮进度线。 +- 修复后必须从最终 MP4 抽帧确认底部 timeline 文字和总进度条都可见,不能只依赖脚本执行成功。 diff --git a/tests/test_build_it_infra_video.py b/tests/test_build_it_infra_video.py index cbecfc4..6e67b29 100644 --- a/tests/test_build_it_infra_video.py +++ b/tests/test_build_it_infra_video.py @@ -2,6 +2,7 @@ import importlib.util import json import shutil import struct +import subprocess import sys import tempfile import unittest @@ -95,6 +96,14 @@ class BuildItInfraVideoTest(unittest.TestCase): self.assertEqual(html.count('id="scene-service-mesh-control-plane"'), 1) self.assertEqual(html.count('data-track-index="1"'), 3) self.assertEqual(html.count('data-track-index="5"'), 3) + fallback_script = (project / "build_ffmpeg_video.sh").read_text(encoding="utf-8") + subprocess.run(["bash", "-n", str(project / "build_ffmpeg_video.sh")], check=True) + self.assertIn("0\\:00 控制平面", fallback_script) + self.assertIn("0\\:01 数据平面", fallback_script) + self.assertIn("(t+1.2)/3.6*1736", fallback_script) + self.assertNotIn("25*${i}", fallback_script) + self.assertEqual(runner.truncate_display("虚拟化把服务器切成逻辑资源", 8), "虚拟化把服务器切") + self.assertEqual(runner.truncate_display("Hypervisor 是新的资源仲裁者", 8), "Hypervisor 是新") self.assertIn( "Render not executed", (project / "DELIVERY.md").read_text(encoding="utf-8"),