Keep video runner outputs in task artifact scope

This commit is contained in:
Haitao Pan 2026-05-25 13:23:16 +08:00
parent dc3719fee2
commit 881d372db0
6 changed files with 187 additions and 11 deletions

View File

@ -57,17 +57,21 @@
1. `it-infra-continuous-png` 先输出 `assets/images/*.png``assets/images/manifest.md` 1. `it-infra-continuous-png` 先输出 `assets/images/*.png``assets/images/manifest.md`
2. `it-infra-evolution-video-v2` 读取 manifest并调用 `scripts/build_it_infra_video.py` 2. `it-infra-evolution-video-v2` 读取 manifest并调用 `scripts/build_it_infra_video.py`
3. 任务目录中必须留下 `video.config.json`、`index.html`、`renders/*.mp4`、`ffprobe.json` 3. 任务目录必须是 XWorkmate/OpenClaw 准备好的 `tasks/<session>/<run>` artifact scope
4. 任务目录中必须留下 `video.config.json`、`index.html`、`renders/*.mp4`、`ffprobe.json`、`DELIVERY.md`
示例: 示例:
```bash ```bash
python3 scripts/build_it_infra_video.py \ python3 "${AI_VIDEO_SKILLS_HOME:-/home/ubuntu/ai-video-skills}/scripts/build_it_infra_video.py" \
--project-dir /path/to/task/service-mesh-video \ --project-dir /home/ubuntu/.openclaw/workspace/tasks/draft_1779524982823421-3/turn-1779685283403237342 \
--title "云原生 Service Mesh 网络科普视频" \ --title "云原生 Service Mesh 网络科普视频" \
--audio-mode edge-tts \ --audio-mode edge-tts \
--run-acceptance \ --run-acceptance \
--output-name service-mesh-video.mp4 --output-name service-mesh-video.mp4 \
--require-task-scope \
--session-key "draft:1779524982823421-3" \
--run-id "turn-1779685283403237342"
``` ```
## 账号信息 ## 账号信息

View File

@ -33,6 +33,7 @@ REQUIRED_MANIFEST_COLUMNS = [
PNG_MAGIC = b"\x89PNG\r\n\x1a\n" PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
HYPERFRAMES_VERSION = "0.6.15" HYPERFRAMES_VERSION = "0.6.15"
TASK_SCOPE_ROOT = "tasks"
class BuildError(RuntimeError): class BuildError(RuntimeError):
@ -91,6 +92,55 @@ def slugify(value: str, fallback: str) -> str:
return value or fallback return value or fallback
def safe_scope_segment(value: str) -> str:
value = value.strip()
value = re.sub(r"[\\/]+", "_", value)
value = re.sub(r"[^A-Za-z0-9._-]+", "_", value)
value = re.sub(r"^[._-]+|[._-]+$", "", value)
return value[:96] or "scope"
def expected_task_scope(session_key: str, run_id: str) -> str:
return f"{TASK_SCOPE_ROOT}/{safe_scope_segment(session_key)}/{safe_scope_segment(run_id)}"
def project_dir_from_env() -> Path:
for name in ["XWORKMATE_TASK_ARTIFACT_DIR", "XWORKMATE_ARTIFACT_DIRECTORY"]:
value = os.environ.get(name, "").strip()
if value:
return Path(value)
return Path.cwd()
def validate_task_scope_project_dir(
project_dir: Path,
*,
require_task_scope: bool,
artifact_scope: str,
session_key: str,
run_id: str,
) -> str:
expected_scope = artifact_scope.strip().strip("/")
if not expected_scope and session_key.strip() and run_id.strip():
expected_scope = expected_task_scope(session_key, run_id)
if not expected_scope:
expected_scope = str(Path(TASK_SCOPE_ROOT) / "*" / "*")
normalized_parts = project_dir.resolve().parts
is_task_scope_path = len(normalized_parts) >= 3 and normalized_parts[-3] == TASK_SCOPE_ROOT
if expected_scope and "*" not in expected_scope:
expected_parts = tuple(expected_scope.split("/"))
is_task_scope_path = tuple(normalized_parts[-len(expected_parts) :]) == expected_parts
if require_task_scope and not is_task_scope_path:
fail(
"Project directory must be the prepared XWorkmate task artifact scope. "
f"Expected {expected_scope}, got {project_dir.resolve()}. "
"Run this script from tasks/<session>/<run> or pass --project-dir to that directory."
)
return expected_scope
def parse_markdown_table(path: Path) -> list[dict[str, str]]: def parse_markdown_table(path: Path) -> list[dict[str, str]]:
if not path.exists(): if not path.exists():
fail(f"Manifest not found: {path}") fail(f"Manifest not found: {path}")
@ -625,19 +675,63 @@ def run_acceptance(project_dir: Path, config: dict, output_name: str) -> None:
(project_dir / "ffprobe.json").write_text(json.dumps(probe_json, indent=2) + "\n", encoding="utf-8") (project_dir / "ffprobe.json").write_text(json.dumps(probe_json, indent=2) + "\n", encoding="utf-8")
def write_delivery_report(project_dir: Path, title: str, output_name: str, expected_scope: str, rendered: bool) -> None:
lines = [
f"# {title}",
"",
"## XWorkmate Artifacts",
"",
f"- Artifact scope: `{expected_scope}`",
"- `index.html`",
"- `video.config.json`",
"- `assets/images/manifest.md`",
"- `assets/audio/`",
]
if rendered:
lines.extend(
[
f"- `renders/{output_name}`",
"- `ffprobe.json`",
]
)
else:
lines.append("- Render not executed; run again with `--run-acceptance` before reporting completion.")
(project_dir / "DELIVERY.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project-dir", type=Path, default=Path.cwd(), help="HyperFrames project directory") parser.add_argument(
"--project-dir",
type=Path,
default=None,
help="HyperFrames project directory. Defaults to XWORKMATE_TASK_ARTIFACT_DIR, XWORKMATE_ARTIFACT_DIRECTORY, or cwd.",
)
parser.add_argument("--manifest", type=Path, default=None, help="PNG manifest path") parser.add_argument("--manifest", type=Path, default=None, help="PNG manifest path")
parser.add_argument("--title", default="IT 基础设施长图讲解视频", help="Video title") parser.add_argument("--title", default="IT 基础设施长图讲解视频", help="Video title")
parser.add_argument("--section-duration", type=float, default=8.0, help="Seconds per manifest row") parser.add_argument("--section-duration", type=float, default=8.0, help="Seconds per manifest row")
parser.add_argument("--audio-mode", choices=["edge-tts", "tone", "none"], default="edge-tts") parser.add_argument("--audio-mode", choices=["edge-tts", "tone", "none"], default="edge-tts")
parser.add_argument("--run-acceptance", action="store_true", help="Run lint/inspect/snapshot/render/ffprobe") parser.add_argument("--run-acceptance", action="store_true", help="Run lint/inspect/snapshot/render/ffprobe")
parser.add_argument("--output-name", default="it-infra-evolution.mp4", help="Rendered MP4 file name") parser.add_argument("--output-name", default="it-infra-evolution.mp4", help="Rendered MP4 file name")
parser.add_argument(
"--require-task-scope",
action="store_true",
help="Fail unless project-dir is the prepared tasks/<session>/<run> artifact scope.",
)
parser.add_argument("--artifact-scope", default=os.environ.get("XWORKMATE_ARTIFACT_SCOPE", ""), help="Expected artifact scope")
parser.add_argument("--session-key", default=os.environ.get("XWORKMATE_SESSION_KEY", ""), help="Expected XWorkmate session key")
parser.add_argument("--run-id", default=os.environ.get("XWORKMATE_RUN_ID", ""), help="Expected XWorkmate/OpenClaw run id")
args = parser.parse_args(argv) args = parser.parse_args(argv)
try: try:
project_dir = args.project_dir.resolve() project_dir = (args.project_dir or project_dir_from_env()).resolve()
expected_scope = validate_task_scope_project_dir(
project_dir,
require_task_scope=args.require_task_scope,
artifact_scope=args.artifact_scope,
session_key=args.session_key,
run_id=args.run_id,
)
manifest = (args.manifest or project_dir / "assets/images/manifest.md").resolve() manifest = (args.manifest or project_dir / "assets/images/manifest.md").resolve()
ensure_project_scaffold(project_dir) ensure_project_scaffold(project_dir)
doctor(args.audio_mode, args.run_acceptance) doctor(args.audio_mode, args.run_acceptance)
@ -653,6 +747,7 @@ def main(argv: list[str] | None = None) -> int:
write_html(project_dir, args.title, config) write_html(project_dir, args.title, config)
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)
print("Build complete. Required task artifacts: index.html, video.config.json, assets/images/manifest.md, assets/audio/, renders/ or run with --run-acceptance.") print("Build complete. Required task artifacts: index.html, video.config.json, assets/images/manifest.md, assets/audio/, renders/ or run with --run-acceptance.")
return 0 return 0
except (BuildError, subprocess.CalledProcessError, json.JSONDecodeError) as exc: except (BuildError, subprocess.CalledProcessError, json.JSONDecodeError) as exc:

View File

@ -88,14 +88,22 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一
3. 将下一步命令写给视频 skill 3. 将下一步命令写给视频 skill
```bash ```bash
python3 /path/to/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 "<用户主题>" \ --title "<用户主题>" \
--audio-mode edge-tts \ --audio-mode edge-tts \
--run-acceptance \ --run-acceptance \
--output-name "<topic-slug>.mp4" --output-name "<topic-slug>.mp4" \
--require-task-scope \
--session-key "$XWORKMATE_SESSION_KEY" \
--run-id "$XWORKMATE_RUN_ID"
``` ```
交接目录必须是 XWorkmate/OpenClaw 当前任务的
`tasks/<safe-session-key>/<safe-run-id>` artifact scope。不要把 manifest
和 PNG 留在 `owners/.../threads/<session>` 后再渲染;否则 Bridge 的当前 run
artifact 面板可能无法稳定收敛到同一 scope。
不要在本 skill 中生成 `index.html`、`video.config.json` 或 MP4这些是视频 skill 的职责。 不要在本 skill 中生成 `index.html`、`video.config.json` 或 MP4这些是视频 skill 的职责。
## 参考文件 ## 参考文件

View File

@ -23,19 +23,28 @@ description: "从 it-infra-continuous-png 的真实 PNG manifest 生成 IT 基
在当前任务工作目录或视频项目目录执行: 在当前任务工作目录或视频项目目录执行:
```bash ```bash
python3 /path/to/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 网络科普视频" \
--audio-mode edge-tts \ --audio-mode edge-tts \
--run-acceptance \ --run-acceptance \
--output-name service-mesh-video.mp4 --output-name service-mesh-video.mp4 \
--require-task-scope \
--session-key "$XWORKMATE_SESSION_KEY" \
--run-id "$XWORKMATE_RUN_ID"
``` ```
在 XWorkmate/OpenClaw 中,`.` 必须是 Bridge 预先准备的
`tasks/<safe-session-key>/<safe-run-id>` artifact scope。不能在
`owners/.../threads/<session>` 工作区直接渲染;如果只知道
`artifactScope`,可用 `--artifact-scope "tasks/<safe-session-key>/<safe-run-id>"`
代替 `--session-key/--run-id`
OpenClaw 任务中如果同时选择了 `it-infra-continuous-png``it-infra-evolution-video-v2`,必须按以下顺序执行: OpenClaw 任务中如果同时选择了 `it-infra-continuous-png``it-infra-evolution-video-v2`,必须按以下顺序执行:
1. 先用 `it-infra-continuous-png` 生成多张 PNG 和 manifest。 1. 先用 `it-infra-continuous-png` 生成多张 PNG 和 manifest。
2. 再用本 skill 的 runner 读取 manifest。 2. 再用本 skill 的 runner 读取 manifest。
3. 最后把 `renders/service-mesh-video.mp4`、`video.config.json`、`assets/images/manifest.md`、`ffprobe.json` 留在当前 task workspace。 3. 最后把 `renders/service-mesh-video.mp4`、`video.config.json`、`assets/images/manifest.md`、`ffprobe.json`、`DELIVERY.md` 留在当前 `tasks/<session>/<run>` workspace。
## Runner 合同 ## Runner 合同
@ -62,6 +71,7 @@ runner 负责:
- `assets/audio/bgm.wav` - `assets/audio/bgm.wav`
- `renders/<output-name>.mp4` - `renders/<output-name>.mp4`
- `ffprobe.json` - `ffprobe.json`
- `DELIVERY.md`
`ffprobe.json` 必须显示: `ffprobe.json` 必须显示:

View File

@ -11,6 +11,10 @@ description: "制作 IT 基础设施系列长图讲解视频。适用于 IT基
- Version: `v1` - Version: `v1`
- Status: read-only frozen template - Status: read-only frozen template
- Do not mutate this skill in-place for v2 work. Create a new copy instead. - Do not mutate this skill in-place for v2 work. Create a new copy instead.
- XWorkmate/OpenClaw task runs that must sync artifacts to `tasks/<session>/<run>`
should use `it-infra-evolution-video-v2`, because v2 has the deterministic
runner and task-scope guard. Do not report completion from v1 unless the final
MP4 is actually present in the current prepared task artifact scope.
使用 `HyperFrames + edge-tts + 用户长图/信息图素材` 制作中文技术讲解视频。默认效果必须对齐样片 `/Users/shenlan/workspaces/cloud-neutral-toolkit/docs/it-infra-evolution-video` 的合成风格:蓝白技术纪录片视觉、双栏长图扫描、底部章节 timeline tag、高亮当前章节、深蓝字幕条和本地合成 BGM/SFX。样片里的 `8 段完整结构`、`82 秒`、`8 列 timeline` 是可提取的结构特征和默认配置不是固定限制实际章节数、总时长、timeline 列数必须由用户输入或生成配置决定。这个 skill 是 `ai-tech-news-video` 的独立派生副本,**不要修改原始 `ai-tech-news-video` skill**。 使用 `HyperFrames + edge-tts + 用户长图/信息图素材` 制作中文技术讲解视频。默认效果必须对齐样片 `/Users/shenlan/workspaces/cloud-neutral-toolkit/docs/it-infra-evolution-video` 的合成风格:蓝白技术纪录片视觉、双栏长图扫描、底部章节 timeline tag、高亮当前章节、深蓝字幕条和本地合成 BGM/SFX。样片里的 `8 段完整结构`、`82 秒`、`8 列 timeline` 是可提取的结构特征和默认配置不是固定限制实际章节数、总时长、timeline 列数必须由用户输入或生成配置决定。这个 skill 是 `ai-tech-news-video` 的独立派生副本,**不要修改原始 `ai-tech-news-video` skill**。

View File

@ -95,6 +95,61 @@ 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)
self.assertIn(
"Render not executed",
(project / "DELIVERY.md").read_text(encoding="utf-8"),
)
def test_requires_prepared_task_scope_when_requested(self):
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
project = copy_fixture(tmp_path)
code = runner.main(
[
"--project-dir",
str(project),
"--title",
"Service Mesh fixture",
"--audio-mode",
"tone",
"--require-task-scope",
"--session-key",
"draft:1779524982823421-3",
"--run-id",
"turn-1779685283403237342",
]
)
self.assertEqual(code, 1)
scoped_project = (
tmp_path
/ "workspace"
/ "tasks"
/ "draft_1779524982823421-3"
/ "turn-1779685283403237342"
)
shutil.copytree(project, scoped_project)
code = runner.main(
[
"--project-dir",
str(scoped_project),
"--title",
"Service Mesh fixture",
"--audio-mode",
"tone",
"--require-task-scope",
"--session-key",
"draft:1779524982823421-3",
"--run-id",
"turn-1779685283403237342",
]
)
self.assertEqual(code, 0)
delivery = (scoped_project / "DELIVERY.md").read_text(encoding="utf-8")
self.assertIn("tasks/draft_1779524982823421-3/turn-1779685283403237342", delivery)
def test_rejects_manifest_image_that_is_not_real_png(self): def test_rejects_manifest_image_that_is_not_real_png(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp: