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`
|
||||
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
|
||||
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"
|
||||
```
|
||||
|
||||
## 账号信息
|
||||
|
||||
@ -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/<session>/<run> 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/<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)
|
||||
|
||||
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:
|
||||
|
||||
@ -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 "<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 的职责。
|
||||
|
||||
## 参考文件
|
||||
|
||||
@ -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/<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`,必须按以下顺序执行:
|
||||
|
||||
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/<session>/<run>` workspace。
|
||||
|
||||
## Runner 合同
|
||||
|
||||
@ -62,6 +71,7 @@ runner 负责:
|
||||
- `assets/audio/bgm.wav`
|
||||
- `renders/<output-name>.mp4`
|
||||
- `ffprobe.json`
|
||||
- `DELIVERY.md`
|
||||
|
||||
`ffprobe.json` 必须显示:
|
||||
|
||||
|
||||
@ -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/<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**。
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user