From 881d372db0712e60971425c27367a58fc12d3021 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 25 May 2026 13:23:16 +0800 Subject: [PATCH] Keep video runner outputs in task artifact scope --- README.md | 12 ++- scripts/build_it_infra_video.py | 99 ++++++++++++++++++++- skills/it-infra-continuous-png/SKILL.md | 12 ++- skills/it-infra-evolution-video-v2/SKILL.md | 16 +++- skills/it-infra-evolution-video/SKILL.md | 4 + tests/test_build_it_infra_video.py | 55 ++++++++++++ 6 files changed, 187 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2e401cf..4447ad9 100644 --- a/README.md +++ b/README.md @@ -57,17 +57,21 @@ 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` -3. 任务目录中必须留下 `video.config.json`、`index.html`、`renders/*.mp4`、`ffprobe.json` +3. 任务目录必须是 XWorkmate/OpenClaw 准备好的 `tasks//` artifact scope +4. 任务目录中必须留下 `video.config.json`、`index.html`、`renders/*.mp4`、`ffprobe.json`、`DELIVERY.md` 示例: ```bash -python3 scripts/build_it_infra_video.py \ - --project-dir /path/to/task/service-mesh-video \ +python3 "${AI_VIDEO_SKILLS_HOME:-/home/ubuntu/ai-video-skills}/scripts/build_it_infra_video.py" \ + --project-dir /home/ubuntu/.openclaw/workspace/tasks/draft_1779524982823421-3/turn-1779685283403237342 \ --title "云原生 Service Mesh 网络科普视频" \ --audio-mode edge-tts \ --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" ``` ## 账号信息 diff --git a/scripts/build_it_infra_video.py b/scripts/build_it_infra_video.py index df833ea..16c06cc 100755 --- a/scripts/build_it_infra_video.py +++ b/scripts/build_it_infra_video.py @@ -33,6 +33,7 @@ REQUIRED_MANIFEST_COLUMNS = [ PNG_MAGIC = b"\x89PNG\r\n\x1a\n" HYPERFRAMES_VERSION = "0.6.15" +TASK_SCOPE_ROOT = "tasks" class BuildError(RuntimeError): @@ -91,6 +92,55 @@ def slugify(value: str, fallback: str) -> str: 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// or pass --project-dir to that directory." + ) + return expected_scope + + def parse_markdown_table(path: Path) -> list[dict[str, str]]: if not path.exists(): 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") +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: 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("--title", default="IT 基础设施长图讲解视频", help="Video title") 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("--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( + "--require-task-scope", + action="store_true", + help="Fail unless project-dir is the prepared tasks// 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) 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() ensure_project_scaffold(project_dir) 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) 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) 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 except (BuildError, subprocess.CalledProcessError, json.JSONDecodeError) as exc: diff --git a/skills/it-infra-continuous-png/SKILL.md b/skills/it-infra-continuous-png/SKILL.md index 89ddda7..7e8555f 100644 --- a/skills/it-infra-continuous-png/SKILL.md +++ b/skills/it-infra-continuous-png/SKILL.md @@ -88,14 +88,22 @@ description: "生成 IT 基础设施系列连续风格 PNG 图片。适用于一 3. 将下一步命令写给视频 skill: ```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 . \ --title "<用户主题>" \ --audio-mode edge-tts \ --run-acceptance \ - --output-name ".mp4" + --output-name ".mp4" \ + --require-task-scope \ + --session-key "$XWORKMATE_SESSION_KEY" \ + --run-id "$XWORKMATE_RUN_ID" ``` +交接目录必须是 XWorkmate/OpenClaw 当前任务的 +`tasks//` artifact scope。不要把 manifest +和 PNG 留在 `owners/.../threads/` 后再渲染;否则 Bridge 的当前 run +artifact 面板可能无法稳定收敛到同一 scope。 + 不要在本 skill 中生成 `index.html`、`video.config.json` 或 MP4;这些是视频 skill 的职责。 ## 参考文件 diff --git a/skills/it-infra-evolution-video-v2/SKILL.md b/skills/it-infra-evolution-video-v2/SKILL.md index 0fba663..abe9a44 100644 --- a/skills/it-infra-evolution-video-v2/SKILL.md +++ b/skills/it-infra-evolution-video-v2/SKILL.md @@ -23,19 +23,28 @@ description: "从 it-infra-continuous-png 的真实 PNG manifest 生成 IT 基 在当前任务工作目录或视频项目目录执行: ```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 . \ --title "云原生 Service Mesh 网络科普视频" \ --audio-mode edge-tts \ --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//` artifact scope。不能在 +`owners/.../threads/` 工作区直接渲染;如果只知道 +`artifactScope`,可用 `--artifact-scope "tasks//"` +代替 `--session-key/--run-id`。 + OpenClaw 任务中如果同时选择了 `it-infra-continuous-png` 和 `it-infra-evolution-video-v2`,必须按以下顺序执行: 1. 先用 `it-infra-continuous-png` 生成多张 PNG 和 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//` workspace。 ## Runner 合同 @@ -62,6 +71,7 @@ runner 负责: - `assets/audio/bgm.wav` - `renders/.mp4` - `ffprobe.json` +- `DELIVERY.md` `ffprobe.json` 必须显示: diff --git a/skills/it-infra-evolution-video/SKILL.md b/skills/it-infra-evolution-video/SKILL.md index 8dbc108..08a1324 100644 --- a/skills/it-infra-evolution-video/SKILL.md +++ b/skills/it-infra-evolution-video/SKILL.md @@ -11,6 +11,10 @@ description: "制作 IT 基础设施系列长图讲解视频。适用于 IT基 - Version: `v1` - Status: read-only frozen template - 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//` + 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**。 diff --git a/tests/test_build_it_infra_video.py b/tests/test_build_it_infra_video.py index f79d8f1..cbecfc4 100644 --- a/tests/test_build_it_infra_video.py +++ b/tests/test_build_it_infra_video.py @@ -95,6 +95,61 @@ 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) + 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): with tempfile.TemporaryDirectory() as tmp: