fix(skills): require prepared artifact scope
This commit is contained in:
parent
70d1903077
commit
f607f28960
@ -13,6 +13,7 @@ import html
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@ -208,7 +209,7 @@ def build_sections(rows: list[ManifestRow], section_duration: float) -> list[Sec
|
|||||||
start=start,
|
start=start,
|
||||||
duration=section_duration,
|
duration=section_duration,
|
||||||
time_label=format_time(start),
|
time_label=format_time(start),
|
||||||
timeline_label=title[:8] or f"Chapter {index + 1}",
|
timeline_label=title or f"Chapter {index + 1}",
|
||||||
title=title,
|
title=title,
|
||||||
subtitle=subtitle,
|
subtitle=subtitle,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
@ -498,6 +499,150 @@ def js_array(values: list[str | float]) -> str:
|
|||||||
return json.dumps(values, ensure_ascii=False)
|
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:
|
def write_html(project_dir: Path, title: str, config: dict) -> None:
|
||||||
sections = config["sections"]
|
sections = config["sections"]
|
||||||
duration = config["duration"]
|
duration = config["duration"]
|
||||||
@ -745,6 +890,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
elif not (project_dir / "assets/audio/bgm.wav").exists():
|
elif not (project_dir / "assets/audio/bgm.wav").exists():
|
||||||
fail("--audio-mode none requires existing assets/audio/bgm.wav")
|
fail("--audio-mode none requires existing assets/audio/bgm.wav")
|
||||||
write_html(project_dir, args.title, config)
|
write_html(project_dir, args.title, config)
|
||||||
|
write_ffmpeg_fallback_script(project_dir, config, args.output_name)
|
||||||
if args.run_acceptance:
|
if args.run_acceptance:
|
||||||
run_acceptance(project_dir, config, args.output_name)
|
run_acceptance(project_dir, config, args.output_name)
|
||||||
write_delivery_report(project_dir, args.title, args.output_name, expected_scope, args.run_acceptance)
|
write_delivery_report(project_dir, args.title, args.output_name, expected_scope, args.run_acceptance)
|
||||||
|
|||||||
@ -34,6 +34,37 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一
|
|||||||
- `prompts/image-prompts.md`
|
- `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/<session>/<run>` 的目录
|
||||||
|
|
||||||
|
如果以上四项都不存在,必须先说明“缺少 XWorkmate prepared artifact
|
||||||
|
scope”,不要声称已经完成文件交付。
|
||||||
|
|
||||||
|
执行要求:
|
||||||
|
|
||||||
|
- 先确认目标目录是当前 run 的 `tasks/<session>/<run>` scope;不要使用
|
||||||
|
`/owners/.../threads/<session>` 作为最终交付目录。
|
||||||
|
- `cd` 到选定目录,或所有写入都使用选定目录的绝对路径。
|
||||||
|
- 在选定目录下直接创建 `assets/images/` 和 `prompts/`。
|
||||||
|
- 禁止在选定目录内再创建 `task_artifacts/<session>/...`、`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. 读取用户输入:参考图、文件路径、主题清单、文字描述、目标数量和用途。
|
1. 读取用户输入:参考图、文件路径、主题清单、文字描述、目标数量和用途。
|
||||||
@ -41,8 +72,9 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一
|
|||||||
3. 生成系列配置,字段见 `templates/series.config.example.json`。
|
3. 生成系列配置,字段见 `templates/series.config.example.json`。
|
||||||
4. 为每张图生成独立 prompt,保证同一系列的布局、字体、色彩、底部总结条和视觉元素连续。
|
4. 为每张图生成独立 prompt,保证同一系列的布局、字体、色彩、底部总结条和视觉元素连续。
|
||||||
5. 逐张生成或编辑 PNG 图片。每次生成请求只描述一张目标图,避免模型把多张图拼进同一个画布。
|
5. 逐张生成或编辑 PNG 图片。每次生成请求只描述一张目标图,避免模型把多张图拼进同一个画布。
|
||||||
6. 保存输出到用户指定目录;未指定时放在当前项目的 `assets/images/`。
|
6. 保存输出到选定 artifact scope 的 `assets/images/`;用户指定额外目录时,也复制一份到 artifact scope。
|
||||||
7. 写 `assets/images/manifest.md`,供 `it-infra-evolution-video` 作为真实素材清单使用。
|
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 内部可以有多个信息卡片,但只能围绕一个主题/章节。
|
- 每张 PNG 内部可以有多个信息卡片,但只能围绕一个主题/章节。
|
||||||
- 批量生成时,先生成 `series.config.json`,再按 `images[]` 逐项生成。
|
- 批量生成时,先生成 `series.config.json`,再按 `images[]` 逐项生成。
|
||||||
- manifest 必须逐文件记录,行数应等于输出 PNG 数量。
|
- manifest 必须逐文件记录,行数应等于输出 PNG 数量。
|
||||||
|
- manifest 中的 `file` 必须是 artifact scope 内的相对路径,例如
|
||||||
|
`assets/images/001-local-permission.png`。
|
||||||
|
|
||||||
## 风格硬约束
|
## 风格硬约束
|
||||||
|
|
||||||
|
|||||||
@ -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/<session>/<run>` 时才可直接使用 `.`。
|
||||||
|
|
||||||
```bash
|
```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" \
|
python3 "${AI_VIDEO_SKILLS_HOME:-/home/ubuntu/ai-video-skills}/scripts/build_it_infra_video.py" \
|
||||||
--project-dir . \
|
--project-dir . \
|
||||||
--title "云原生 Service Mesh 网络科普视频" \
|
--title "云原生 Service Mesh 网络科普视频" \
|
||||||
@ -36,7 +40,8 @@ python3 "${AI_VIDEO_SKILLS_HOME:-/home/ubuntu/ai-video-skills}/scripts/build_it_
|
|||||||
|
|
||||||
在 XWorkmate/OpenClaw 中,`.` 必须是 Bridge 预先准备的
|
在 XWorkmate/OpenClaw 中,`.` 必须是 Bridge 预先准备的
|
||||||
`tasks/<safe-session-key>/<safe-run-id>` artifact scope。不能在
|
`tasks/<safe-session-key>/<safe-run-id>` artifact scope。不能在
|
||||||
`owners/.../threads/<session>` 工作区直接渲染;如果只知道
|
`owners/.../threads/<session>` 工作区直接渲染,也不能在 scope 内再创建
|
||||||
|
`task_artifacts/<session>/...` 二次嵌套目录;如果只知道
|
||||||
`artifactScope`,可用 `--artifact-scope "tasks/<safe-session-key>/<safe-run-id>"`
|
`artifactScope`,可用 `--artifact-scope "tasks/<safe-session-key>/<safe-run-id>"`
|
||||||
代替 `--session-key/--run-id`。
|
代替 `--session-key/--run-id`。
|
||||||
|
|
||||||
@ -56,6 +61,7 @@ runner 负责:
|
|||||||
- 保证 scene、caption、voiceover 在各自 track 上不重叠。
|
- 保证 scene、caption、voiceover 在各自 track 上不重叠。
|
||||||
- 只保留一个全局 BGM 音轨。
|
- 只保留一个全局 BGM 音轨。
|
||||||
- 生成 `video.config.json` 和 `inspectTimes`。
|
- 生成 `video.config.json` 和 `inspectTimes`。
|
||||||
|
- 生成 `build_ffmpeg_video.sh` 作为 HyperFrames 之外的应急合成路径;该脚本必须从 `video.config.json` 的 `sections` 动态生成章节标签、时间、active marker、总进度条和音频 delay,禁止手写固定 6 段、固定 25 秒、固定标题。
|
||||||
- 执行 `lint -> inspect -> snapshot -> render -> ffprobe`。
|
- 执行 `lint -> inspect -> snapshot -> render -> ffprobe`。
|
||||||
|
|
||||||
生产模式默认 `--audio-mode edge-tts`。本地测试或无网络 dry-run 可以使用 `--audio-mode tone`,但不能把 tone 输出当作正式口播成片。
|
生产模式默认 `--audio-mode edge-tts`。本地测试或无网络 dry-run 可以使用 `--audio-mode tone`,但不能把 tone 输出当作正式口播成片。
|
||||||
@ -81,3 +87,13 @@ runner 负责:
|
|||||||
- 时长接近 `video.config.json` 的 `duration`
|
- 时长接近 `video.config.json` 的 `duration`
|
||||||
|
|
||||||
如果 HyperFrames 或 ffprobe 任一阶段失败,只输出失败阶段和原因,不输出“完成”。
|
如果 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 文字和总进度条都可见,不能只依赖脚本执行成功。
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
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('id="scene-service-mesh-control-plane"'), 1)
|
||||||
self.assertEqual(html.count('data-track-index="1"'), 3)
|
self.assertEqual(html.count('data-track-index="1"'), 3)
|
||||||
self.assertEqual(html.count('data-track-index="5"'), 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(
|
self.assertIn(
|
||||||
"Render not executed",
|
"Render not executed",
|
||||||
(project / "DELIVERY.md").read_text(encoding="utf-8"),
|
(project / "DELIVERY.md").read_text(encoding="utf-8"),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user