Keep video runner outputs in task artifact scope
This commit is contained in:
parent
dc3719fee2
commit
881d372db0
12
README.md
12
README.md
@ -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"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 账号信息
|
## 账号信息
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 的职责。
|
||||||
|
|
||||||
## 参考文件
|
## 参考文件
|
||||||
|
|||||||
@ -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` 必须显示:
|
||||||
|
|
||||||
|
|||||||
@ -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**。
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user