@remixmate/cli 0.9.38 → 0.9.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/manifest.json +83 -3
- package/package.json +5 -2
- package/skills/doc-parse/SKILL.md +124 -0
- package/skills/doc-parse/scripts/extract_via_render.py +218 -0
- package/skills/doc-parse/skill.json +54 -0
- package/skills/gen-script/scripts/gen_script.py +141 -25
- package/skills/gen-script/skill.json +8 -1
- package/skills/gen-script/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +24 -0
- package/skills/render-video/scripts/required_props.py +166 -0
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/countdown_spec.py +188 -0
- package/skills/template-registry/version.json +1 -1
package/dist/manifest.json
CHANGED
|
@@ -1,8 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.40",
|
|
4
|
+
"generatedAt": "2026-09-22T08:04:46.896Z",
|
|
5
5
|
"skills": [
|
|
6
|
+
{
|
|
7
|
+
"id": "doc-parse",
|
|
8
|
+
"toolName": "doc_parse",
|
|
9
|
+
"tier": "tool",
|
|
10
|
+
"category": "consuming",
|
|
11
|
+
"title": "Document Extraction",
|
|
12
|
+
"summary": "Extract a document (.pptx) into structured data: per-page title and body text, the speaker notes, and — when asked for the layout layer — the exact shapes, coordinates, colors and animation beats of every slide. Runs server-side (ab-render /extract); nothing is installed locally and no model is involved, so the result is deterministic. Two layers via `fields`: `content` is lightweight text (title / text / rawNotes) and is what you want for narration or for summarizing a deck; `layout` additionally returns the slide payload that the ppt-to-video template renders one-to-one from the original file — it embeds every icon as a data URI and is large, so it is written to a file rather than printed.",
|
|
13
|
+
"triggers": [
|
|
14
|
+
"Turn this PPT / slide deck into a video",
|
|
15
|
+
"Extract text or speaker notes from a .pptx",
|
|
16
|
+
"Use a PowerPoint file as source material",
|
|
17
|
+
"Get customPayload.slide for ppt-to-video"
|
|
18
|
+
],
|
|
19
|
+
"entry": {
|
|
20
|
+
"type": "python",
|
|
21
|
+
"scriptPath": "scripts/extract_via_render.py"
|
|
22
|
+
},
|
|
23
|
+
"runtime": "python",
|
|
24
|
+
"envVars": [
|
|
25
|
+
"RENDER_API_URL",
|
|
26
|
+
"PRIV_TOKEN",
|
|
27
|
+
"CONVERSATION_ID"
|
|
28
|
+
],
|
|
29
|
+
"parameters": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"url": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Direct document URL (http/https). A page link or a share link will not work — pass the file URL itself (e.g. what the asset library returns)."
|
|
35
|
+
},
|
|
36
|
+
"kind": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": [
|
|
39
|
+
"pptx"
|
|
40
|
+
],
|
|
41
|
+
"description": "Document kind. Only pptx today; a binary .ppt must be re-saved as .pptx first."
|
|
42
|
+
},
|
|
43
|
+
"fields": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Comma-separated layers: `content` (title/text/rawNotes — small) and/or `layout` (slide shapes + beats — large, embeds icons). Default `content,layout`. Ask for content only when you just need the words."
|
|
46
|
+
},
|
|
47
|
+
"slide": {
|
|
48
|
+
"type": "number",
|
|
49
|
+
"description": "Only extract this 1-based page. Omit for the whole deck."
|
|
50
|
+
},
|
|
51
|
+
"out": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Where to write the full JSON result (default ./<name>.extract.json). stdout gets a per-page summary instead."
|
|
54
|
+
},
|
|
55
|
+
"json_output": {
|
|
56
|
+
"type": "boolean",
|
|
57
|
+
"description": "Pipeline mode — emit the full JSON on stdout. Avoid with the layout layer: it is hundreds of KB of base64 per page."
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"required": [
|
|
61
|
+
"url"
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"ui": {
|
|
65
|
+
"primary": [
|
|
66
|
+
"url",
|
|
67
|
+
"fields"
|
|
68
|
+
],
|
|
69
|
+
"advanced": [
|
|
70
|
+
"slide",
|
|
71
|
+
"kind"
|
|
72
|
+
],
|
|
73
|
+
"hidden": [
|
|
74
|
+
"out",
|
|
75
|
+
"json_output"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
},
|
|
6
79
|
{
|
|
7
80
|
"id": "export-jianying",
|
|
8
81
|
"toolName": "export_jianying",
|
|
@@ -418,6 +491,11 @@
|
|
|
418
491
|
"skip_asset_generation": {
|
|
419
492
|
"type": "boolean",
|
|
420
493
|
"description": "All-in-one switch for downstream agents (e.g. template-creator) that only want the DSL shape: every produced AssetRef is marked as already generated with placeholder URLs (image: https://placeholder.local/stub.png, video: stub.mp4, audio: stub.mp3). Implies the equivalent of --stub-image-url + --stub-video-url with sentinel defaults plus the same rewrite for gen-voice / gen-digital-human assets. Useful when the agent only needs to inspect DSL structure or feed it into try_render_local with all assets pre-stubbed."
|
|
494
|
+
},
|
|
495
|
+
"item_count": {
|
|
496
|
+
"type": "integer",
|
|
497
|
+
"minimum": 1,
|
|
498
|
+
"description": "Number of content beats, for templates declaring capabilities.sceneStrategy=countdown — the '一个开场 + N 个同类节拍,倒数编号' shape (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. The accepted range and the default are declared by the chosen template (template_registry prints capabilities.countdown and the count field's bounds); a value outside it fails with the template's own numbers in the message. **Pass the count the user asked for, not the total scene count** — `scenes` is the total and the two are cross-checked, so passing the user's 5 here yields 5 beats while passing it as `scenes` would yield 4. The skeleton only carries each beat's index: fill in the real per-scene content before preparing assets. Keep any count stated in headline/subheadline consistent with this value — gen_script warns on a mismatch but never rewrites your copy."
|
|
421
499
|
}
|
|
422
500
|
},
|
|
423
501
|
"required": [
|
|
@@ -428,7 +506,9 @@
|
|
|
428
506
|
"primary": [
|
|
429
507
|
"topic"
|
|
430
508
|
],
|
|
431
|
-
"advanced": [
|
|
509
|
+
"advanced": [
|
|
510
|
+
"item_count"
|
|
511
|
+
],
|
|
432
512
|
"hidden": [
|
|
433
513
|
"duration",
|
|
434
514
|
"style",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remixmate/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.40",
|
|
4
4
|
"description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -28,13 +28,16 @@
|
|
|
28
28
|
"test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
|
|
29
29
|
"test:narration-tts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-tts.py",
|
|
30
30
|
"test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
|
|
31
|
+
"test:required-props": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-required-props.py",
|
|
31
32
|
"test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
|
|
32
33
|
"test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
|
|
33
34
|
"test:contracts": "PYTHONDONTWRITEBYTECODE=1 python3 skills/template-registry/scripts/check_contracts.py",
|
|
34
35
|
"regress": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/regress.py",
|
|
35
36
|
"regress:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/regress.py --update",
|
|
36
37
|
"check:render": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/render-template-check.py",
|
|
37
|
-
"clean": "node scripts/clean.mjs"
|
|
38
|
+
"clean": "node scripts/clean.mjs",
|
|
39
|
+
"test:countdown": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-countdown-strategy.py",
|
|
40
|
+
"test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
|
|
38
41
|
},
|
|
39
42
|
"keywords": [
|
|
40
43
|
"claude-code",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: doc-parse
|
|
3
|
+
description: |
|
|
4
|
+
Document extraction skill — turn a `.pptx` into structured data through the ab-render `/extract` endpoint.
|
|
5
|
+
|
|
6
|
+
Two layers, picked with `--fields`:
|
|
7
|
+
- **content** — per-page `title`, `text[]`, and `rawNotes` (the speaker notes). Small, no base64. This is the narration source material.
|
|
8
|
+
- **layout** — the full `slide` payload the `ppt-to-video` template renders from: every shape, coordinate, color, clip path and the animation `beats`, plus the `mode` verdict. Large (icons are embedded as data URIs).
|
|
9
|
+
|
|
10
|
+
Deterministic: the endpoint parses OOXML, it does **not** call a model. Notes come back verbatim — rewriting them into narration is your job, not the endpoint's.
|
|
11
|
+
|
|
12
|
+
Use this skill immediately whenever the user asks for any of:
|
|
13
|
+
- Turn this PPT / slide deck into a video
|
|
14
|
+
- Read / extract / summarize a .pptx, pull the text or the speaker notes out of a deck
|
|
15
|
+
- Use a PowerPoint file as source material for a script
|
|
16
|
+
- Anything that needs `customPayload.slide` for the `ppt-to-video` template
|
|
17
|
+
|
|
18
|
+
Never hand-write `slide` nodes for `ppt-to-video`. A page has dozens of shapes with exact EMU coordinates; transcribing them by eye is guaranteed to drift from the original, and the template's own hint says so.
|
|
19
|
+
triggers:
|
|
20
|
+
- Turn this PPT / slide deck into a video
|
|
21
|
+
- Extract text or speaker notes from a .pptx
|
|
22
|
+
- Use a PowerPoint file as source material
|
|
23
|
+
- Get customPayload.slide for ppt-to-video
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
# Document Extraction Skill (`doc_parse`)
|
|
27
|
+
|
|
28
|
+
Entry script **`scripts/extract_via_render.py`**, pure Python (stdlib only — no Office libraries, no local install).
|
|
29
|
+
|
|
30
|
+
> Path convention: read the system-injected `Base directory for this skill: <path>` as `<SkillDir>`. Never hardcode an absolute path.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
POST $RENDER_API_URL/extract → { taskId }
|
|
36
|
+
POST $RENDER_API_URL/extractStatus → { status, result? } (polled for you)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The heavy lifting (download, unzip, OOXML parsing, geometry) happens server-side. The script submits, polls, then **writes the full JSON to a file and prints a per-page summary**.
|
|
40
|
+
|
|
41
|
+
That default matters: one page of `layout` with embedded icons can be hundreds of KB. Printing it would fill your context with base64 and bury the three facts you actually need — the page's mode, its beat count, and whether it has speaker notes.
|
|
42
|
+
|
|
43
|
+
## Environment
|
|
44
|
+
|
|
45
|
+
| Var | Meaning | Default |
|
|
46
|
+
| --- | --- | --- |
|
|
47
|
+
| `RENDER_API_URL` | ab-render base URL (falls back to `REMOTION_RENDER_API_URL`) | `https://api-render.remixmate.ai` |
|
|
48
|
+
| `PRIV_TOKEN` | ab-api private token — **required**, the endpoint refuses anonymous calls | — |
|
|
49
|
+
| `CONVERSATION_ID` | Conversation id, for provenance | — |
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
Whole deck, both layers (what `ppt-to-video` needs):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 <SkillDir>/scripts/extract_via_render.py --url "https://oss.example.com/deck.pptx"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Text and notes only — summarizing a deck, or writing narration without touching the visuals:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python3 <SkillDir>/scripts/extract_via_render.py --url "https://…/deck.pptx" --fields content
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
One page:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python3 <SkillDir>/scripts/extract_via_render.py --url "https://…/deck.pptx" --slide 3
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Output
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
{
|
|
75
|
+
"source": { "url": "…", "name": "deck.pptx", "slideCount": 12, "canvas": [1920, 1080], "fields": ["content","layout"] },
|
|
76
|
+
"slides": [
|
|
77
|
+
{
|
|
78
|
+
"slideIndex": 1,
|
|
79
|
+
// content 层
|
|
80
|
+
"title": "产品介绍",
|
|
81
|
+
"text": ["三大优势", "…"],
|
|
82
|
+
"rawNotes": "备注页原文,逐字返回,未经改写",
|
|
83
|
+
// layout 层
|
|
84
|
+
"mode": "sequenced",
|
|
85
|
+
"modeReason": "4×1 格点,4 块 + 2 组页面家具",
|
|
86
|
+
"nodes": [ /* … */ ],
|
|
87
|
+
"beats": [ { "reveal": ["n12","n13"] } ],
|
|
88
|
+
"ambientIds": ["n2"],
|
|
89
|
+
"recommendedSec": 5.3,
|
|
90
|
+
"background": { "color": "#FFFFFF" },
|
|
91
|
+
"flags": ["unsupported_geom:rightArrow×3"]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Using the result
|
|
98
|
+
|
|
99
|
+
**For the picture** — feed `slides[i]` straight into the template's `customPayload.slide`. Do not edit the coordinates, and do not raise `mode`: the verdict comes from the extractor, which can see reconstruction problems you cannot (`modeReason` says why). You may lower it. Raising it requires `trustMeMode: true` and means you have personally checked that page.
|
|
100
|
+
|
|
101
|
+
**For the words** — `rawNotes` and `text` are two different kinds of raw material, and they need different treatment:
|
|
102
|
+
|
|
103
|
+
| Source | What it is | What to do | Can you check yourself? |
|
|
104
|
+
| --- | --- | --- | --- |
|
|
105
|
+
| `rawNotes` | already a script, written to be spoken | re-cut into one sentence per beat, make it spoken-register, drop stage directions (“as you can see here”, “(next slide)”) and non-script items (citations, TODOs) | **yes** — the original is right there |
|
|
106
|
+
| `text` | bullet points, sometimes just keywords | expand into sentences | **no** — there is nothing to check against |
|
|
107
|
+
|
|
108
|
+
Two rules:
|
|
109
|
+
|
|
110
|
+
1. **Do not introduce facts that are not on the slide.** This is the user's own deck; inventing content puts words in their mouth.
|
|
111
|
+
2. **Talk about it, don't read it out.** The body text is already on screen. Narrating it verbatim gives the viewer the same sentence three times — on the slide, in the subtitle, and in the voice-over.
|
|
112
|
+
|
|
113
|
+
When a page had no notes and you wrote the narration from `text`, say so and ask the user to look it over. Do not ship it silently.
|
|
114
|
+
|
|
115
|
+
## Failure modes
|
|
116
|
+
|
|
117
|
+
Errors come back as plain sentences; pass them to the user as-is rather than retrying:
|
|
118
|
+
|
|
119
|
+
- a binary `.ppt` (PowerPoint 97-2003) — it must be re-saved as `.pptx`, the formats are unrelated
|
|
120
|
+
- not a zip at all, or a zip without `ppt/slides/`
|
|
121
|
+
- the deck exceeds the per-call page limit — extract page by page with `--slide`, or split the file
|
|
122
|
+
- the file is too large, or unpacks to too much
|
|
123
|
+
|
|
124
|
+
`flags` on a page is **not** an error: it records what the extractor refused to reconstruct. Surface it — a silently missing element on screen is worse than a mentioned one.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
文档抽取脚本 — 通过 ab-render HTTP 接口实现,本地不需要任何 Office/解析依赖。
|
|
4
|
+
|
|
5
|
+
调用链:
|
|
6
|
+
POST $RENDER_API_URL/extract → { taskId }
|
|
7
|
+
POST $RENDER_API_URL/extractStatus → { status, progress, result?, error? }
|
|
8
|
+
|
|
9
|
+
两层产物(--fields):
|
|
10
|
+
content title / text[] / rawNotes —— 正文与备注页,轻量,不含任何 base64
|
|
11
|
+
layout slide{nodes,beats,mode,…} —— 坐标形状,带内嵌图标,**体积很大**
|
|
12
|
+
|
|
13
|
+
默认**不把完整结果打到 stdout**:一页 PPT 的 layout 带上内嵌图标可能几百 KB,
|
|
14
|
+
直接进对话正文会撑爆上下文,还会让用户在聊天记录里滚过一屏 base64。默认写文件 +
|
|
15
|
+
打一份人能读的摘要;确实要管道传全量时用 --json-output(或只要 content 层)。
|
|
16
|
+
|
|
17
|
+
必要环境变量(由 ab-agent 自动注入):
|
|
18
|
+
RENDER_API_URL ab-render 服务地址(默认 https://api-render.remixmate.ai)
|
|
19
|
+
PRIV_TOKEN ab-api 私有 token
|
|
20
|
+
|
|
21
|
+
可选环境变量:
|
|
22
|
+
CONVERSATION_ID 对话 ID,用于留痕与产物归属
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import urllib.error
|
|
31
|
+
import urllib.request
|
|
32
|
+
|
|
33
|
+
# ─── 环境变量 ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
RENDER_API_URL = (
|
|
36
|
+
os.environ.get("RENDER_API_URL")
|
|
37
|
+
or os.environ.get("REMOTION_RENDER_API_URL")
|
|
38
|
+
or "https://api-render.remixmate.ai"
|
|
39
|
+
).rstrip("/")
|
|
40
|
+
PRIV_TOKEN = os.environ.get("PRIV_TOKEN", "")
|
|
41
|
+
CONVERSATION_ID = os.environ.get("CONVERSATION_ID", "")
|
|
42
|
+
|
|
43
|
+
# ─── HTTP 工具 ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def api_post(path: str, body: dict, timeout: int = 30) -> dict:
|
|
47
|
+
"""向 ab-render 发送 POST 请求,返回解析后的 JSON。"""
|
|
48
|
+
url = f"{RENDER_API_URL}{path}"
|
|
49
|
+
data = json.dumps(body).encode()
|
|
50
|
+
|
|
51
|
+
headers: dict[str, str] = {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"X-Priv-Token": PRIV_TOKEN,
|
|
54
|
+
}
|
|
55
|
+
if CONVERSATION_ID:
|
|
56
|
+
headers["x-conversation-id"] = CONVERSATION_ID
|
|
57
|
+
|
|
58
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
59
|
+
try:
|
|
60
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
61
|
+
return json.loads(resp.read())
|
|
62
|
+
except urllib.error.HTTPError as e:
|
|
63
|
+
body_text = e.read().decode(errors="replace")
|
|
64
|
+
raise RuntimeError(f"HTTP {e.code}: {body_text}") from e
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def log(msg: str, silent: bool = False) -> None:
|
|
68
|
+
if not silent:
|
|
69
|
+
print(msg, flush=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ─── 摘要 ─────────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def summarize(result: dict, out_path: str | None) -> str:
|
|
76
|
+
"""
|
|
77
|
+
人能读的一页纸。**这是这个 skill 存在的一半价值** —— 端点给的是全量结构,
|
|
78
|
+
决定"喂多少进上下文"是入口层的责任(设计文档 §4.4)。
|
|
79
|
+
|
|
80
|
+
每页一行:页码、档位、拍数、标题、讲稿来源。档位与拍数决定后面怎么写旁白,
|
|
81
|
+
讲稿来源决定走严格改写还是扩写,这两件是下一步真正要用的信息。
|
|
82
|
+
"""
|
|
83
|
+
src = result.get("source", {})
|
|
84
|
+
lines = [
|
|
85
|
+
f"共 {src.get('slideCount', '?')} 页,本次抽到 {len(result.get('slides', []))} 页"
|
|
86
|
+
f"(fields={'+'.join(src.get('fields', []))})",
|
|
87
|
+
"",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
for s in result.get("slides", []):
|
|
91
|
+
idx = s.get("slideIndex", "?")
|
|
92
|
+
bits = [f"第 {idx} 页"]
|
|
93
|
+
|
|
94
|
+
if s.get("mode"):
|
|
95
|
+
beats = len(s.get("beats") or [])
|
|
96
|
+
bits.append(f"{s['mode']}/{beats} 拍")
|
|
97
|
+
if s.get("title"):
|
|
98
|
+
bits.append(f"「{s['title']}」")
|
|
99
|
+
|
|
100
|
+
notes = (s.get("rawNotes") or "").strip()
|
|
101
|
+
if notes:
|
|
102
|
+
head = notes.replace("\n", " ")[:40]
|
|
103
|
+
bits.append(f"备注 {len(notes)} 字:{head}…")
|
|
104
|
+
elif "rawNotes" in s:
|
|
105
|
+
bits.append("无备注(旁白需按正文扩写)")
|
|
106
|
+
|
|
107
|
+
lines.append(" " + " · ".join(bits))
|
|
108
|
+
|
|
109
|
+
if s.get("modeReason"):
|
|
110
|
+
lines.append(f" 判定:{s['modeReason']}")
|
|
111
|
+
flags = s.get("flags") or []
|
|
112
|
+
if flags:
|
|
113
|
+
# flag 是显式的降级记录,不是日志 —— 画面上少一块,这里不说就没人知道为什么。
|
|
114
|
+
lines.append(f" ⚠ 降级:{'; '.join(flags[:3])}")
|
|
115
|
+
|
|
116
|
+
if out_path:
|
|
117
|
+
lines += ["", f"完整结果已写入:{out_path}"]
|
|
118
|
+
return "\n".join(lines)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ─── 主逻辑 ───────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> None:
|
|
125
|
+
parser = argparse.ArgumentParser(
|
|
126
|
+
description="Document extraction — calls the ab-render /extract endpoint"
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument("--url", required=True, help="Direct document URL (.pptx)")
|
|
129
|
+
parser.add_argument("--kind", default="pptx", choices=["pptx"], help="Document kind (default pptx)")
|
|
130
|
+
parser.add_argument("--slide", type=int, help="Only extract this 1-based page")
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--fields", default="content,layout",
|
|
133
|
+
help="Comma-separated: content / layout (default both)",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument(
|
|
136
|
+
"--out", help="Write the full JSON result here (default: ./<name>.extract.json)",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--json-output", action="store_true",
|
|
140
|
+
help="Pipeline mode: emit the full JSON on stdout (careful: layout is large)",
|
|
141
|
+
)
|
|
142
|
+
args = parser.parse_args()
|
|
143
|
+
|
|
144
|
+
if not PRIV_TOKEN:
|
|
145
|
+
print(
|
|
146
|
+
"❌ PRIV_TOKEN env var is not configured — /extract requires auth.",
|
|
147
|
+
file=sys.stderr,
|
|
148
|
+
)
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
|
|
151
|
+
fields = [f.strip() for f in args.fields.split(",") if f.strip()]
|
|
152
|
+
|
|
153
|
+
# ── 1. Submit ────────────────────────────────────────────────────────────
|
|
154
|
+
log(f"[doc-parse] Submitting extraction: {args.url[:80]}", args.json_output)
|
|
155
|
+
|
|
156
|
+
body: dict = {"documentUrl": args.url, "kind": args.kind, "fields": fields}
|
|
157
|
+
if args.slide is not None:
|
|
158
|
+
body["slide"] = args.slide
|
|
159
|
+
|
|
160
|
+
resp = api_post("/extract", body)
|
|
161
|
+
if resp.get("code") != 0:
|
|
162
|
+
print(f"❌ submission failed: {resp.get('msg', 'unknown error')}", file=sys.stderr)
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
task_id: str = resp["data"]["taskId"]
|
|
166
|
+
log(f"[doc-parse] task accepted, taskId={task_id}", args.json_output)
|
|
167
|
+
|
|
168
|
+
# ── 2. Poll ──────────────────────────────────────────────────────────────
|
|
169
|
+
max_wait_sec = 600
|
|
170
|
+
poll_interval = 2
|
|
171
|
+
elapsed = 0
|
|
172
|
+
|
|
173
|
+
while elapsed < max_wait_sec:
|
|
174
|
+
time.sleep(poll_interval)
|
|
175
|
+
elapsed += poll_interval
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
status_resp = api_post("/extractStatus", {"taskId": task_id})
|
|
179
|
+
except Exception as e: # noqa: BLE001
|
|
180
|
+
log(f"[doc-parse] polling error (will retry): {e}", args.json_output)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
if status_resp.get("code") != 0:
|
|
184
|
+
log(f"[doc-parse] polling error code (will retry): {status_resp.get('msg')}", args.json_output)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
task = status_resp["data"]
|
|
188
|
+
status = task.get("status", "unknown")
|
|
189
|
+
|
|
190
|
+
if status == "succeeded":
|
|
191
|
+
result = task.get("result") or {}
|
|
192
|
+
|
|
193
|
+
if args.json_output:
|
|
194
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# 默认落盘 + 摘要:layout 层直接打进 stdout 会撑爆上下文(见文件头)。
|
|
198
|
+
out_path = args.out or f"./{(result.get('source') or {}).get('name', 'document')}.extract.json"
|
|
199
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
200
|
+
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
201
|
+
|
|
202
|
+
print("\n✅ 抽取完成\n")
|
|
203
|
+
print(summarize(result, out_path))
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if status == "failed":
|
|
207
|
+
# 端点的错误已经是人话(格式不对/超页数/超体积),原样透出,不要再包一层。
|
|
208
|
+
print(f"❌ extraction failed: {task.get('error', 'unknown error')}", file=sys.stderr)
|
|
209
|
+
sys.exit(1)
|
|
210
|
+
|
|
211
|
+
log(f"[doc-parse] status={status} elapsed={elapsed}s", args.json_output)
|
|
212
|
+
|
|
213
|
+
print(f"❌ extraction timed out (over {max_wait_sec}s)", file=sys.stderr)
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "doc-parse",
|
|
3
|
+
"toolName": "doc_parse",
|
|
4
|
+
"tier": "tool",
|
|
5
|
+
"category": "consuming",
|
|
6
|
+
"title": "Document Extraction",
|
|
7
|
+
"description": "Extract a document (.pptx) into structured data: per-page title and body text, the speaker notes, and — when asked for the layout layer — the exact shapes, coordinates, colors and animation beats of every slide. Runs server-side (ab-render /extract); nothing is installed locally and no model is involved, so the result is deterministic. Two layers via `fields`: `content` is lightweight text (title / text / rawNotes) and is what you want for narration or for summarizing a deck; `layout` additionally returns the slide payload that the ppt-to-video template renders one-to-one from the original file — it embeds every icon as a data URI and is large, so it is written to a file rather than printed.",
|
|
8
|
+
"auth": "required",
|
|
9
|
+
"envVars": [
|
|
10
|
+
"RENDER_API_URL",
|
|
11
|
+
"PRIV_TOKEN",
|
|
12
|
+
"CONVERSATION_ID"
|
|
13
|
+
],
|
|
14
|
+
"entry": {
|
|
15
|
+
"type": "python",
|
|
16
|
+
"scriptPath": "scripts/extract_via_render.py"
|
|
17
|
+
},
|
|
18
|
+
"parameters": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"url": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Direct document URL (http/https). A page link or a share link will not work — pass the file URL itself (e.g. what the asset library returns)."
|
|
24
|
+
},
|
|
25
|
+
"kind": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["pptx"],
|
|
28
|
+
"description": "Document kind. Only pptx today; a binary .ppt must be re-saved as .pptx first."
|
|
29
|
+
},
|
|
30
|
+
"fields": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Comma-separated layers: `content` (title/text/rawNotes — small) and/or `layout` (slide shapes + beats — large, embeds icons). Default `content,layout`. Ask for content only when you just need the words."
|
|
33
|
+
},
|
|
34
|
+
"slide": {
|
|
35
|
+
"type": "number",
|
|
36
|
+
"description": "Only extract this 1-based page. Omit for the whole deck."
|
|
37
|
+
},
|
|
38
|
+
"out": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "Where to write the full JSON result (default ./<name>.extract.json). stdout gets a per-page summary instead."
|
|
41
|
+
},
|
|
42
|
+
"json_output": {
|
|
43
|
+
"type": "boolean",
|
|
44
|
+
"description": "Pipeline mode — emit the full JSON on stdout. Avoid with the layout layer: it is hundreds of KB of base64 per page."
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"required": ["url"]
|
|
48
|
+
},
|
|
49
|
+
"ui": {
|
|
50
|
+
"primary": ["url", "fields"],
|
|
51
|
+
"advanced": ["slide", "kind"],
|
|
52
|
+
"hidden": ["out", "json_output"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -17,6 +17,7 @@ Environment variables:
|
|
|
17
17
|
|
|
18
18
|
import argparse
|
|
19
19
|
import json
|
|
20
|
+
import re
|
|
20
21
|
import math
|
|
21
22
|
import os
|
|
22
23
|
import sys
|
|
@@ -41,6 +42,21 @@ _SHARED_SCRIPTS_DIR = os.path.join(
|
|
|
41
42
|
if os.path.isdir(_SHARED_SCRIPTS_DIR):
|
|
42
43
|
sys.path.insert(0, os.path.abspath(_SHARED_SCRIPTS_DIR))
|
|
43
44
|
|
|
45
|
+
# countdown 策略的声明解析。与下面几个共享依赖同样守护式导入:本文件全篇假定
|
|
46
|
+
# 共享目录**可能**不在(见 _SHARED_SCRIPTS_DIR 的 isdir 判断),硬导入会让共享目录
|
|
47
|
+
# 缺失时连非 countdown 模板一起起不来。缺失只影响 countdown 这一条路,并在真的
|
|
48
|
+
# 用到时明确报错。
|
|
49
|
+
try:
|
|
50
|
+
from countdown_spec import ( # noqa: E402 (needs the sys.path setup above)
|
|
51
|
+
opening_sec as countdown_opening_sec,
|
|
52
|
+
resolve_count as resolve_countdown_count,
|
|
53
|
+
resolve_countdown_spec,
|
|
54
|
+
scene_payload as countdown_scene_payload,
|
|
55
|
+
)
|
|
56
|
+
_HAS_COUNTDOWN = True
|
|
57
|
+
except ImportError:
|
|
58
|
+
_HAS_COUNTDOWN = False
|
|
59
|
+
|
|
44
60
|
try:
|
|
45
61
|
from video_dsl.runtime.prompt_enhancer import build_enhanced_prompt
|
|
46
62
|
_HAS_ENHANCER = True
|
|
@@ -459,8 +475,10 @@ def _template_needs_image(template_config: dict | None) -> bool:
|
|
|
459
475
|
#
|
|
460
476
|
# 归一化 contract 字段(全部可缺省,缺省值保证"未声明能力"的模板走通用图文旁白):
|
|
461
477
|
# - needs_narration bool capabilities.needsNarration,默认 True
|
|
462
|
-
# - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed},默认 arc
|
|
478
|
+
# - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed, countdown},默认 arc
|
|
463
479
|
# - fixed_scenes list[dict] capabilities.fixedScenes(single/fixed 用;缺省单 opening)
|
|
480
|
+
# - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
|
|
481
|
+
# 非 countdown 模板为 None)
|
|
464
482
|
# - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
|
|
465
483
|
# - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
|
|
466
484
|
# - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
|
|
@@ -469,6 +487,20 @@ def _template_needs_image(template_config: dict | None) -> bool:
|
|
|
469
487
|
# - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导
|
|
470
488
|
|
|
471
489
|
|
|
490
|
+
def _resolve_countdown(template_config: dict | None) -> dict:
|
|
491
|
+
"""countdown 声明解析;共享模块缺失时明确报错而不是静默换一种形状。
|
|
492
|
+
|
|
493
|
+
退回 ``arc`` 会是最坏的处理:一个倒数模板会拿到「至少 3 段 + 末尾 cta + 升序编号」
|
|
494
|
+
的场景表,既没有数量也没有倒数索引,然后一路通过到渲染。
|
|
495
|
+
"""
|
|
496
|
+
if not _HAS_COUNTDOWN:
|
|
497
|
+
raise ValueError(
|
|
498
|
+
"capabilities.sceneStrategy=countdown needs the shared countdown_spec module "
|
|
499
|
+
"from skills/template-registry/scripts/, which is not importable here."
|
|
500
|
+
)
|
|
501
|
+
return resolve_countdown_spec(template_config)
|
|
502
|
+
|
|
503
|
+
|
|
472
504
|
def _resolve_contract(template_config: dict | None) -> dict:
|
|
473
505
|
"""把 template.json 的 capabilities 归一化成一份装配 contract。
|
|
474
506
|
|
|
@@ -482,7 +514,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
|
|
|
482
514
|
needs_narration = caps.get("needsNarration", True) is not False
|
|
483
515
|
|
|
484
516
|
scene_strategy = caps.get("sceneStrategy")
|
|
485
|
-
if scene_strategy not in ("arc", "single", "fixed"):
|
|
517
|
+
if scene_strategy not in ("arc", "single", "fixed", "countdown"):
|
|
486
518
|
scene_strategy = "arc"
|
|
487
519
|
|
|
488
520
|
primary_visual_type = _template_primary_visual_type(template_config)
|
|
@@ -507,6 +539,9 @@ def _resolve_contract(template_config: dict | None) -> dict:
|
|
|
507
539
|
"needs_narration": needs_narration,
|
|
508
540
|
"scene_strategy": scene_strategy,
|
|
509
541
|
"fixed_scenes": fixed_scenes,
|
|
542
|
+
# 声明解析放共享模块:render-video 的计划校验要读同一份声明,
|
|
543
|
+
# 两边各抄一遍正是这个功能上一版栽过的跟头。
|
|
544
|
+
"countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
|
|
510
545
|
"payload_style": payload_style,
|
|
511
546
|
"duration_strategy": duration_strategy,
|
|
512
547
|
"duration_model": caps.get("durationModel"),
|
|
@@ -982,6 +1017,61 @@ def _build_custom_payload(template_config: dict | None) -> dict:
|
|
|
982
1017
|
return {}
|
|
983
1018
|
|
|
984
1019
|
|
|
1020
|
+
def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
|
|
1021
|
+
"""``sceneStrategy=countdown``:一个开场 + N 个同类节拍,倒数编号,没有 cta。
|
|
1022
|
+
|
|
1023
|
+
为什么不能复用 ``arc``:``plan_scenes`` 写死了「至少 3 个场景」(Top 1 只需要 2 个)、
|
|
1024
|
+
无条件在末尾 append 一个 ``cta``(榜单类模板往往不要片尾),而且 ``content_scenes
|
|
1025
|
+
= scene_count - 2``、编号从 #1 升序 —— 倒数要的正好相反。``fixed`` 则要一份静态
|
|
1026
|
+
场景表,表达不了动态的 N。所以 countdown 是 sceneStrategy 轴上的第四种形状,
|
|
1027
|
+
而不是某个模板的特例。
|
|
1028
|
+
"""
|
|
1029
|
+
head_sec = countdown_opening_sec(spec, count, duration)
|
|
1030
|
+
beat_sec = (duration - head_sec) / count
|
|
1031
|
+
return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
|
|
1032
|
+
{"purpose": "point", "duration": beat_sec, "label": f"#{index}", "countdownIndex": index}
|
|
1033
|
+
for index in range(count, 0, -1)
|
|
1034
|
+
]
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _warn_on_count_mismatch(headline: str, subheadline: str, count: int) -> None:
|
|
1038
|
+
"""画面文字里写的数量与实际节拍数不一致时出声提醒(不阻断、不改写)。
|
|
1039
|
+
|
|
1040
|
+
**刻意只 warn 不改写。** 这里曾经有一对 ``_rank_copy`` / ``_rank_title``,是
|
|
1041
|
+
github-repo-rank 的 ``payload.ts`` 里 ``formatRankCopy`` / ``formatRankTitle``
|
|
1042
|
+
的逐行 Python 移植 —— 同样的三个正则,在两个仓库、两种语言里各存一份。那是本文件
|
|
1043
|
+
在 ``_echo_template_llm_hint`` 里写明要避免的事:**CLI 出机制,模板出内容**。标题
|
|
1044
|
+
该怎么写、数量该以什么措辞出现(``Top 5`` / ``前 5 名`` / ``5 个项目``)是模板的
|
|
1045
|
+
版式事实,真源是模板自己。
|
|
1046
|
+
|
|
1047
|
+
去掉那份移植不会让成片出错:模板在渲染时本来就会同步(github-repo-rank 在
|
|
1048
|
+
``Opening.tsx`` / ``Cover.tsx`` 里做),而且它**必须**保留这个能力 —— 手写 DSL、
|
|
1049
|
+
历史计划、ab-web 改过的标题都不经过 gen_script。CLI 这一份自始至终只影响用户在
|
|
1050
|
+
确认脚本那一步看到的文字,为它复制一套会漂移的正则不值得。
|
|
1051
|
+
|
|
1052
|
+
换成告警的另一个好处:静默改写调用方给的文案本身就不妥(同 ``_warn_on_url_text_layer``
|
|
1053
|
+
的分寸)。把数量告诉它,让它自己把文案写对,比替它改更不容易出意外。
|
|
1054
|
+
|
|
1055
|
+
这里认的措辞(``Top N`` / ``N 个项目`` / ``前 N 名``)只用来**发现不一致**,
|
|
1056
|
+
不用来生成文案,所以它不构成一份需要跟模板同步的版式规则。
|
|
1057
|
+
"""
|
|
1058
|
+
pattern = r"(?<![a-zA-Z0-9_])top\s*([0-9]+)(?![a-zA-Z0-9_])|(?:^|[^0-9])([0-9]+)\s*(?:个(?=\s*(?:开源项目|项目|仓库|工具))|(?=\s*名))"
|
|
1059
|
+
for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
|
|
1060
|
+
stated = {int(g) for match in re.finditer(pattern, value or "", flags=re.I)
|
|
1061
|
+
for g in match.groups() if g is not None}
|
|
1062
|
+
if stated and count not in stated:
|
|
1063
|
+
print(
|
|
1064
|
+
f"⚠️ {flag} says {sorted(stated)} item(s) but --item-count is {count}. "
|
|
1065
|
+
"The rendered beat count follows --item-count, so the on-screen text is the "
|
|
1066
|
+
"part that is wrong.\n"
|
|
1067
|
+
" Rewrite it to state the count you asked for (or leave the count out "
|
|
1068
|
+
"and let the template append it). gen_script deliberately does not "
|
|
1069
|
+
"rewrite your copy: how this template words a count is declared by the "
|
|
1070
|
+
"template, not by the CLI.",
|
|
1071
|
+
file=sys.stderr,
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
985
1075
|
def build_dsl(
|
|
986
1076
|
topic: str,
|
|
987
1077
|
duration: int,
|
|
@@ -1003,6 +1093,7 @@ def build_dsl(
|
|
|
1003
1093
|
carousel_items: list[str] | None = None,
|
|
1004
1094
|
caption_lines: list[str] | None = None,
|
|
1005
1095
|
scene_images: list[str] | None = None,
|
|
1096
|
+
item_count: int | None = None,
|
|
1006
1097
|
) -> dict:
|
|
1007
1098
|
"""Build a complete Video DSL JSON.
|
|
1008
1099
|
|
|
@@ -1034,6 +1125,12 @@ def build_dsl(
|
|
|
1034
1125
|
# Load template config(registry 单一数据源)→ 归一化 contract
|
|
1035
1126
|
template_config = _load_template_config(template_id) if template_id else None
|
|
1036
1127
|
contract = _resolve_contract(template_config)
|
|
1128
|
+
countdown = contract["countdown"]
|
|
1129
|
+
if countdown is None and item_count is not None:
|
|
1130
|
+
raise ValueError(
|
|
1131
|
+
"--item-count requires a template declaring capabilities.sceneStrategy=countdown"
|
|
1132
|
+
)
|
|
1133
|
+
beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
|
|
1037
1134
|
|
|
1038
1135
|
primary_visual_type = contract["primary_visual_type"]
|
|
1039
1136
|
payload_style = contract["payload_style"]
|
|
@@ -1061,6 +1158,8 @@ def build_dsl(
|
|
|
1061
1158
|
effective_subheadline = _normalize_linebreaks(subheadline)
|
|
1062
1159
|
_echo_template_llm_hint(template_config, template_id)
|
|
1063
1160
|
_warn_on_url_text_layer(effective_headline, effective_subheadline)
|
|
1161
|
+
if beat_count is not None:
|
|
1162
|
+
_warn_on_count_mismatch(effective_headline, effective_subheadline, beat_count)
|
|
1064
1163
|
|
|
1065
1164
|
# ── carousel-caption 模板(单场景、图/视频轮播 + 打字机)单独装配并直接返回 ──
|
|
1066
1165
|
if payload_style == "carousel-caption":
|
|
@@ -1143,7 +1242,10 @@ def build_dsl(
|
|
|
1143
1242
|
# 就是它,AI 段能插在任意位置,而不是只能挂在末尾。
|
|
1144
1243
|
provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
|
|
1145
1244
|
|
|
1146
|
-
|
|
1245
|
+
if beat_count is not None:
|
|
1246
|
+
scene_plans = _plan_countdown_scenes(countdown, beat_count, duration, effective_headline)
|
|
1247
|
+
else:
|
|
1248
|
+
scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
|
|
1147
1249
|
|
|
1148
1250
|
# 条目比场景多时**抬高**场景数,让每一条都有地方放。
|
|
1149
1251
|
#
|
|
@@ -1384,6 +1486,12 @@ def build_dsl(
|
|
|
1384
1486
|
text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
|
|
1385
1487
|
|
|
1386
1488
|
custom_payload = _build_custom_payload(template_config)
|
|
1489
|
+
if beat_count is not None:
|
|
1490
|
+
# Field names come from the template's countdown declaration — this
|
|
1491
|
+
# function does not know what a "rank" or a "topN" is.
|
|
1492
|
+
custom_payload.update(
|
|
1493
|
+
countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
|
|
1494
|
+
)
|
|
1387
1495
|
|
|
1388
1496
|
scene = {
|
|
1389
1497
|
"id": scene_id,
|
|
@@ -1459,6 +1567,7 @@ Examples:
|
|
|
1459
1567
|
parser.add_argument("--style", default="", help="Style tag")
|
|
1460
1568
|
parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
|
|
1461
1569
|
parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
|
|
1570
|
+
parser.add_argument("--item-count", type=int, default=None, help="Number of content beats, for templates declaring capabilities.sceneStrategy=countdown (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. Bounds and the default are declared by the template — this flag has no range of its own. Pass the count the user asked for, NOT the total scene count; --scenes is the total and the two are cross-checked.")
|
|
1462
1571
|
parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
|
|
1463
1572
|
parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices prints the live catalog.")
|
|
1464
1573
|
parser.add_argument(
|
|
@@ -1649,28 +1758,35 @@ Examples:
|
|
|
1649
1758
|
file=sys.stderr,
|
|
1650
1759
|
)
|
|
1651
1760
|
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1761
|
+
try:
|
|
1762
|
+
dsl = build_dsl(
|
|
1763
|
+
topic=args.topic,
|
|
1764
|
+
duration=args.duration,
|
|
1765
|
+
style=args.style,
|
|
1766
|
+
ratio=args.ratio,
|
|
1767
|
+
resolution=args.resolution,
|
|
1768
|
+
voice_id=args.voice_id,
|
|
1769
|
+
speed=args.speed,
|
|
1770
|
+
font_id=args.font_id,
|
|
1771
|
+
font_name=args.font_name,
|
|
1772
|
+
scene_count=args.scenes,
|
|
1773
|
+
allow_digital_human=args.allow_digital_human,
|
|
1774
|
+
allow_ai_video=args.allow_ai_video,
|
|
1775
|
+
template_id=args.template_id,
|
|
1776
|
+
stub_image_url=stub_image_url,
|
|
1777
|
+
stub_video_url=stub_video_url,
|
|
1778
|
+
headline=args.headline,
|
|
1779
|
+
subheadline=args.subheadline,
|
|
1780
|
+
carousel_items=args.carousel_items,
|
|
1781
|
+
caption_lines=args.caption_lines,
|
|
1782
|
+
scene_images=args.scene_images,
|
|
1783
|
+
item_count=args.item_count,
|
|
1784
|
+
)
|
|
1785
|
+
except ValueError as exc:
|
|
1786
|
+
# Covers both a bad --item-count and an unusable countdown declaration
|
|
1787
|
+
# (CountdownError subclasses ValueError), so an authoring mistake surfaces
|
|
1788
|
+
# as a message instead of a traceback.
|
|
1789
|
+
parser.error(str(exc))
|
|
1674
1790
|
|
|
1675
1791
|
# Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
|
|
1676
1792
|
# assets to be already-generated stubs. Image / video are already covered by the
|
|
@@ -88,6 +88,11 @@
|
|
|
88
88
|
"skip_asset_generation": {
|
|
89
89
|
"type": "boolean",
|
|
90
90
|
"description": "All-in-one switch for downstream agents (e.g. template-creator) that only want the DSL shape: every produced AssetRef is marked as already generated with placeholder URLs (image: https://placeholder.local/stub.png, video: stub.mp4, audio: stub.mp3). Implies the equivalent of --stub-image-url + --stub-video-url with sentinel defaults plus the same rewrite for gen-voice / gen-digital-human assets. Useful when the agent only needs to inspect DSL structure or feed it into try_render_local with all assets pre-stubbed."
|
|
91
|
+
},
|
|
92
|
+
"item_count": {
|
|
93
|
+
"type": "integer",
|
|
94
|
+
"minimum": 1,
|
|
95
|
+
"description": "Number of content beats, for templates declaring capabilities.sceneStrategy=countdown — the '一个开场 + N 个同类节拍,倒数编号' shape (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. The accepted range and the default are declared by the chosen template (template_registry prints capabilities.countdown and the count field's bounds); a value outside it fails with the template's own numbers in the message. **Pass the count the user asked for, not the total scene count** — `scenes` is the total and the two are cross-checked, so passing the user's 5 here yields 5 beats while passing it as `scenes` would yield 4. The skeleton only carries each beat's index: fill in the real per-scene content before preparing assets. Keep any count stated in headline/subheadline consistent with this value — gen_script warns on a mismatch but never rewrites your copy."
|
|
91
96
|
}
|
|
92
97
|
},
|
|
93
98
|
"required": [
|
|
@@ -98,7 +103,9 @@
|
|
|
98
103
|
"primary": [
|
|
99
104
|
"topic"
|
|
100
105
|
],
|
|
101
|
-
"advanced": [
|
|
106
|
+
"advanced": [
|
|
107
|
+
"item_count"
|
|
108
|
+
],
|
|
102
109
|
"hidden": [
|
|
103
110
|
"duration",
|
|
104
111
|
"style",
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "gen-script",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "474",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V13",
|
|
6
6
|
"skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
|
|
7
7
|
}
|
|
@@ -2089,6 +2089,12 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
|
2089
2089
|
selected = match_template.load_full_template(selected)
|
|
2090
2090
|
LogPrint(f"📋 auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
|
|
2091
2091
|
binding = match_template.build_binding(selected, dsl)
|
|
2092
|
+
from required_props import validate_required_props
|
|
2093
|
+
try:
|
|
2094
|
+
validate_required_props(selected, binding.get("bindings", []))
|
|
2095
|
+
except ValueError as exc:
|
|
2096
|
+
LogPrint(f"❌ {exc}", file=sys.stderr)
|
|
2097
|
+
sys.exit(2)
|
|
2092
2098
|
return binding
|
|
2093
2099
|
|
|
2094
2100
|
|
|
@@ -2193,6 +2199,10 @@ Examples:
|
|
|
2193
2199
|
args = parser.parse_args()
|
|
2194
2200
|
|
|
2195
2201
|
private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
|
|
2202
|
+
# The registry loader shares the environment-based auth entry point. Keep
|
|
2203
|
+
# explicit CLI credentials consistent with render_job_client's token.
|
|
2204
|
+
if args.priv_token:
|
|
2205
|
+
os.environ["PRIV_TOKEN"] = private_token
|
|
2196
2206
|
|
|
2197
2207
|
# --save-job 默认开启,但若没有 PRIV_TOKEN 则自动降级为关闭,避免阻塞无 token 的本地调试。
|
|
2198
2208
|
if args.save_job and not private_token:
|
|
@@ -2339,6 +2349,20 @@ Examples:
|
|
|
2339
2349
|
render_plan = build_render_plan(dsl, binding)
|
|
2340
2350
|
render_plan["_dsl_assets"] = dsl.get("assets", [])
|
|
2341
2351
|
|
|
2352
|
+
# Job/file mode bypasses auto_bind_template. Validate saved plans too, so a
|
|
2353
|
+
# previously accepted placeholder-only plan cannot silently render again.
|
|
2354
|
+
if args.job_id or args.render_plan:
|
|
2355
|
+
from registry_loader import get_template
|
|
2356
|
+
from required_props import validate_required_props
|
|
2357
|
+
try:
|
|
2358
|
+
template = get_template(render_plan.get("templateId", ""), include_all_statuses=True)
|
|
2359
|
+
if not template:
|
|
2360
|
+
raise ValueError("Cannot validate RenderPlan: template metadata unavailable")
|
|
2361
|
+
validate_required_props(template, render_plan.get("timeline", []))
|
|
2362
|
+
except ValueError as exc:
|
|
2363
|
+
LogPrint(f"❌ {exc}", file=sys.stderr)
|
|
2364
|
+
sys.exit(2)
|
|
2365
|
+
|
|
2342
2366
|
if not args.skip_asset_resolve:
|
|
2343
2367
|
# Stub mode: short-circuit gen-image / gen-video before counting pending.
|
|
2344
2368
|
# CLI flag takes precedence; env vars act as fallback with a visible warning
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Validate template-required scene props before asset generation or rendering.
|
|
2
|
+
|
|
3
|
+
Binding records missingRequiredProps for diagnostics, but a production caller
|
|
4
|
+
must reject those scenes instead of paying for audio and rendering placeholders.
|
|
5
|
+
Existing RenderPlans need the same check because they bypass binding entirely.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# Shared cross-skill lib (see AGENTS.md). Inserted here rather than relying on
|
|
15
|
+
# render_video's module-top setup because this module is imported directly by
|
|
16
|
+
# tests and by prepare-video-assets.
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))
|
|
18
|
+
|
|
19
|
+
from countdown_spec import resolve_countdown_spec # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _payload_prop(props: dict, key: str):
|
|
23
|
+
seen: set[int] = set()
|
|
24
|
+
cursor = props
|
|
25
|
+
for _ in range(9):
|
|
26
|
+
if not isinstance(cursor, dict) or id(cursor) in seen:
|
|
27
|
+
break
|
|
28
|
+
seen.add(id(cursor))
|
|
29
|
+
if cursor.get(key) is not None:
|
|
30
|
+
return cursor[key]
|
|
31
|
+
cursor = cursor.get("templateData")
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _required_value_errors(value, schema: dict, path: str) -> list[str]:
|
|
36
|
+
"""Check required values only, using the template's declared shape and bounds.
|
|
37
|
+
|
|
38
|
+
This is not a full JSON Schema validator: optional fields and undeclared
|
|
39
|
+
extensions are left to the existing DSL validators and template runtime.
|
|
40
|
+
"""
|
|
41
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
42
|
+
return [path]
|
|
43
|
+
kind = schema.get("type")
|
|
44
|
+
number = type(value) in (int, float) and (not isinstance(value, float) or math.isfinite(value))
|
|
45
|
+
matches = {
|
|
46
|
+
"object": isinstance(value, dict), "array": isinstance(value, list),
|
|
47
|
+
"string": isinstance(value, str), "boolean": isinstance(value, bool),
|
|
48
|
+
"number": number,
|
|
49
|
+
"integer": number and (isinstance(value, int) or value.is_integer()),
|
|
50
|
+
}
|
|
51
|
+
if kind in matches and not matches[kind]:
|
|
52
|
+
return [f"{path} (expected {kind})"]
|
|
53
|
+
if number and (kind in ("number", "integer")):
|
|
54
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
55
|
+
return [f"{path} (below minimum)"]
|
|
56
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
57
|
+
return [f"{path} (above maximum)"]
|
|
58
|
+
if isinstance(value, dict) and kind == "object":
|
|
59
|
+
properties = schema.get("properties") or {}
|
|
60
|
+
return [error for key in schema.get("required", [])
|
|
61
|
+
for error in _required_value_errors(value.get(key), properties.get(key, {}), f"{path}.{key}")]
|
|
62
|
+
if isinstance(value, list) and kind == "array" and len(value) < schema.get("minItems", 0):
|
|
63
|
+
return [f"{path} (too few items)"]
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def validate_required_props(template: dict, rows: list[dict]) -> None:
|
|
68
|
+
"""Raise with scene IDs and field names only; never include scene content."""
|
|
69
|
+
required_by_composition: dict[str, set[str]] = {}
|
|
70
|
+
# References reuse a concrete slot's composition, so only concrete slots
|
|
71
|
+
# contribute requirements here. Union if multiple slots share a component.
|
|
72
|
+
for slot in (template.get("slotMapping") or {}).values():
|
|
73
|
+
if not isinstance(slot, dict) or not slot.get("compositionId"):
|
|
74
|
+
continue
|
|
75
|
+
required_by_composition.setdefault(slot["compositionId"], set()).update(
|
|
76
|
+
slot.get("requiredProps") or []
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
failures = []
|
|
80
|
+
for row in rows:
|
|
81
|
+
required = required_by_composition.get(row.get("compositionId"), set())
|
|
82
|
+
required = required | set(row.get("missingRequiredProps") or [])
|
|
83
|
+
props = row.get("props") or {}
|
|
84
|
+
schema = template.get("customPayloadSchema") or {}
|
|
85
|
+
missing = [error for key in sorted(required)
|
|
86
|
+
for error in _required_value_errors(_payload_prop(props, key), schema.get(key, {}), key)]
|
|
87
|
+
if missing:
|
|
88
|
+
failures.append(f"scene {row.get('sceneId', '?')}: {', '.join(missing)}")
|
|
89
|
+
|
|
90
|
+
if failures:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Missing or invalid required template props: " + "; ".join(failures[:10])
|
|
93
|
+
+ (f"; {len(failures)} scenes total" if len(failures) > 10 else "")
|
|
94
|
+
+ ". Fill each scene's customPayload according to the template schema "
|
|
95
|
+
"and rebuild the RenderPlan. Narration or topic text does not populate card data."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
_validate_countdown(template, rows)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _index_of(row: dict, spec: dict):
|
|
102
|
+
"""A beat's countdown index, or None when the row carries no usable item.
|
|
103
|
+
|
|
104
|
+
Returning None rather than indexing straight into the payload keeps a scene
|
|
105
|
+
with a missing/malformed item on the countdown error path (which names the
|
|
106
|
+
scene and says what to fix) instead of an AttributeError from deep inside the
|
|
107
|
+
validator. Reachable whenever a countdown template's point slot does not list
|
|
108
|
+
its item prop in requiredProps, so the earlier per-scene check lets it through.
|
|
109
|
+
"""
|
|
110
|
+
item = _payload_prop(row.get("props") or {}, spec["item_prop"])
|
|
111
|
+
return item.get(spec["index_prop"]) if isinstance(item, dict) else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _validate_countdown(template: dict, rows: list[dict]) -> None:
|
|
115
|
+
"""Check a countdown plan's shape before anything is paid for.
|
|
116
|
+
|
|
117
|
+
Every prop name here comes from the template's own `capabilities.countdown`
|
|
118
|
+
declaration, so this function holds no knowledge of ranking, GitHub, or any
|
|
119
|
+
template id. That matters beyond tidiness: the names used to be hardcoded,
|
|
120
|
+
which meant a second countdown template calling its count `itemCount` would
|
|
121
|
+
find no counts at all, fall back to "however many beats are present", and
|
|
122
|
+
reduce the scene-count check to comparing that number against itself —
|
|
123
|
+
validation silently gone, with nothing on screen or in the logs to say so.
|
|
124
|
+
"""
|
|
125
|
+
spec = resolve_countdown_spec(template)
|
|
126
|
+
if spec is None:
|
|
127
|
+
return
|
|
128
|
+
slots = template.get("slotMapping") or {}
|
|
129
|
+
point_id = (slots.get("point") or {}).get("compositionId")
|
|
130
|
+
opening_id = (slots.get("opening") or {}).get("compositionId")
|
|
131
|
+
ending_id = (slots.get("ending") or {}).get("compositionId")
|
|
132
|
+
points = [row for row in rows if row.get("compositionId") == point_id]
|
|
133
|
+
count_props = [spec["count_prop"], *spec["legacy_count_props"]]
|
|
134
|
+
# Keep old plans usable: infer their count when no explicit count was saved.
|
|
135
|
+
declared = []
|
|
136
|
+
for row in rows:
|
|
137
|
+
props = row.get("props") or {}
|
|
138
|
+
count = next((value for value in (_payload_prop(props, p) for p in count_props)
|
|
139
|
+
if value is not None), None)
|
|
140
|
+
if count is not None:
|
|
141
|
+
if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"Invalid countdown {spec['count_prop']}: expected an integer from "
|
|
144
|
+
f"{spec['minimum']} to {spec['maximum']}"
|
|
145
|
+
)
|
|
146
|
+
declared.append(count)
|
|
147
|
+
count = declared[0] if declared else len(points)
|
|
148
|
+
if any(value != count for value in declared):
|
|
149
|
+
raise ValueError(f"Countdown {spec['count_prop']} must be consistent across all scenes")
|
|
150
|
+
if not spec["minimum"] <= count <= spec["maximum"] or len(points) != count:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"Countdown scene count does not match {spec['count_prop']}; rebuild one opening "
|
|
153
|
+
"plus N beats"
|
|
154
|
+
)
|
|
155
|
+
indexes = [_index_of(row, spec) for row in points]
|
|
156
|
+
if indexes != list(range(count, 0, -1)):
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Countdown scenes must count down from {count} to 1 in "
|
|
159
|
+
f"{spec['item_prop']}.{spec['index_prop']} without gaps or duplicates"
|
|
160
|
+
)
|
|
161
|
+
if not rows or rows[0].get("compositionId") != opening_id or sum(row.get("compositionId") == opening_id for row in rows) != 1:
|
|
162
|
+
raise ValueError("Countdown requires exactly one opening before the beats")
|
|
163
|
+
# A separate ending may hold the final beat, but cannot add another index.
|
|
164
|
+
endings = [row for row in rows if row.get("compositionId") == ending_id]
|
|
165
|
+
if len(endings) > 1 or (endings and (rows[-1] is not endings[0] or _index_of(endings[0], spec) != 1)):
|
|
166
|
+
raise ValueError("Countdown ending may only hold the #1 scene after the countdown")
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "render-video",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "473",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V23",
|
|
6
6
|
"skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
|
|
7
7
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Shared resolver for the ``countdown`` scene strategy.
|
|
2
|
+
|
|
3
|
+
A countdown is the "one opening, then N beats of the same kind, counting down"
|
|
4
|
+
shape: Top-N lists, "5 个技巧", "7 个常见错误". It sits on the same axis as
|
|
5
|
+
``arc`` / ``single`` / ``fixed`` because it is a *shape*, not a subject — which
|
|
6
|
+
is the whole reason it is not called "ranking" and holds no GitHub-specific
|
|
7
|
+
knowledge.
|
|
8
|
+
|
|
9
|
+
None of the existing strategies could express it: ``arc`` clamps to at least 3
|
|
10
|
+
scenes, always appends a ``cta``, and numbers its beats upward; ``fixed`` needs a
|
|
11
|
+
static scene list and so cannot take a count at all.
|
|
12
|
+
|
|
13
|
+
─── Why this module is shared rather than copied ──────────────────────────────
|
|
14
|
+
Two skills need the same answer from the same declaration:
|
|
15
|
+
|
|
16
|
+
* ``gen-script`` — to lay out the scenes and write the count into each payload
|
|
17
|
+
* ``render-video`` — to validate a plan (scene count, countdown completeness)
|
|
18
|
+
before any asset is paid for
|
|
19
|
+
|
|
20
|
+
Copying the resolution into both is how the previous iteration of this feature
|
|
21
|
+
went wrong in the other direction (a Python port of the template's copy rules
|
|
22
|
+
living next to the real ones). ``skills/template-registry/scripts/`` is the
|
|
23
|
+
sanctioned home for cross-skill Python — see AGENTS.md.
|
|
24
|
+
|
|
25
|
+
─── What is declared where ───────────────────────────────────────────────────
|
|
26
|
+
The strategy carries the shape; the template carries every concrete value:
|
|
27
|
+
|
|
28
|
+
"capabilities": {
|
|
29
|
+
"sceneStrategy": "countdown",
|
|
30
|
+
"countdown": {
|
|
31
|
+
"countProp": "topN", // where the count goes in customPayload
|
|
32
|
+
"itemProp": "item", // optional, default "item"
|
|
33
|
+
"indexProp": "rank", // optional, default "rank"
|
|
34
|
+
"legacyCountProps": ["totalItems"],
|
|
35
|
+
"openingSec": 2.5 // optional, default DEFAULT_OPENING_SEC
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Bounds are deliberately **not** repeated here: they are read from
|
|
40
|
+
``customPayloadSchema[countProp]``'s ``minimum`` / ``maximum`` / ``default``,
|
|
41
|
+
which every template already has to author for its own payload contract. Having
|
|
42
|
+
one number in two places is how they drift.
|
|
43
|
+
|
|
44
|
+
Declaring the prop *names* is what keeps this module honest. They used to be
|
|
45
|
+
hardcoded, which meant a second countdown template naming its count
|
|
46
|
+
``itemCount`` would silently lose validation entirely: nothing would be found,
|
|
47
|
+
the count would fall back to "however many cards are present", and the
|
|
48
|
+
"scene count matches the count" check would compare that number to itself.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
#: Opening budget when the template declares none. A generic "an opening is a
|
|
54
|
+
#: beat, not a chapter" value — **not** any one template's measurement. It used
|
|
55
|
+
#: to be 3 hardcoded in gen_script, taken from github-repo-rank's reference film
|
|
56
|
+
#: (2.43s), which put a single template's fact in the CLI.
|
|
57
|
+
DEFAULT_OPENING_SEC = 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CountdownError(ValueError):
|
|
61
|
+
"""Raised for an unusable countdown declaration.
|
|
62
|
+
|
|
63
|
+
A subclass so callers can keep treating it as the ValueError they already
|
|
64
|
+
handle, while a reader can still see that an authoring mistake and a bad
|
|
65
|
+
``--item-count`` are two different things.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_countdown_spec(template: dict | None) -> dict | None:
|
|
70
|
+
"""Resolve a template's countdown declaration, or None if it is not one.
|
|
71
|
+
|
|
72
|
+
Raises CountdownError when ``sceneStrategy=countdown`` is declared but the
|
|
73
|
+
rest of the contract is missing. Failing loudly here is the point: every
|
|
74
|
+
downstream check reads these names, so an incomplete declaration must not
|
|
75
|
+
degrade into "no countdown rules apply".
|
|
76
|
+
"""
|
|
77
|
+
caps = (template or {}).get("capabilities")
|
|
78
|
+
caps = caps if isinstance(caps, dict) else {}
|
|
79
|
+
if caps.get("sceneStrategy") != "countdown":
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
template_id = (template or {}).get("templateId", "?")
|
|
83
|
+
cfg = caps.get("countdown")
|
|
84
|
+
cfg = cfg if isinstance(cfg, dict) else {}
|
|
85
|
+
|
|
86
|
+
count_prop = cfg.get("countProp")
|
|
87
|
+
if not isinstance(count_prop, str) or not count_prop.strip():
|
|
88
|
+
raise CountdownError(
|
|
89
|
+
f"Template '{template_id}' declares capabilities.sceneStrategy=countdown but no "
|
|
90
|
+
"capabilities.countdown.countProp. Declare which customPayload field carries the "
|
|
91
|
+
"item count (github-repo-rank uses \"topN\")."
|
|
92
|
+
)
|
|
93
|
+
count_prop = count_prop.strip()
|
|
94
|
+
|
|
95
|
+
schema = (template or {}).get("customPayloadSchema")
|
|
96
|
+
bounds = schema.get(count_prop) if isinstance(schema, dict) else None
|
|
97
|
+
if not isinstance(bounds, dict):
|
|
98
|
+
raise CountdownError(
|
|
99
|
+
f"Template '{template_id}' declares countdown.countProp='{count_prop}' but "
|
|
100
|
+
f"customPayloadSchema.{count_prop} is missing. The count's bounds and default are "
|
|
101
|
+
"read from there, so that one number is not authored twice."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
maximum, default = bounds.get("maximum"), bounds.get("default")
|
|
105
|
+
minimum = bounds.get("minimum")
|
|
106
|
+
minimum = minimum if type(minimum) is int else 1
|
|
107
|
+
if type(maximum) is not int or type(default) is not int:
|
|
108
|
+
raise CountdownError(
|
|
109
|
+
f"Template '{template_id}': customPayloadSchema.{count_prop} must declare integer "
|
|
110
|
+
"'maximum' and 'default'. Without a maximum the count is unbounded; without a "
|
|
111
|
+
"default there is no answer for a caller that omits the count."
|
|
112
|
+
)
|
|
113
|
+
if not minimum <= default <= maximum:
|
|
114
|
+
raise CountdownError(
|
|
115
|
+
f"Template '{template_id}': customPayloadSchema.{count_prop}.default ({default}) is "
|
|
116
|
+
f"outside its own minimum/maximum ({minimum}–{maximum})."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
legacy = cfg.get("legacyCountProps")
|
|
120
|
+
legacy = [p for p in legacy if isinstance(p, str) and p.strip()] if isinstance(legacy, list) else []
|
|
121
|
+
|
|
122
|
+
opening_sec = cfg.get("openingSec")
|
|
123
|
+
if not (type(opening_sec) in (int, float) and opening_sec > 0):
|
|
124
|
+
opening_sec = DEFAULT_OPENING_SEC
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"template_id": template_id,
|
|
128
|
+
"count_prop": count_prop,
|
|
129
|
+
"legacy_count_props": legacy,
|
|
130
|
+
"item_prop": (cfg.get("itemProp") or "item").strip(),
|
|
131
|
+
"index_prop": (cfg.get("indexProp") or "rank").strip(),
|
|
132
|
+
"minimum": minimum,
|
|
133
|
+
"maximum": maximum,
|
|
134
|
+
"default": default,
|
|
135
|
+
"opening_sec": float(opening_sec),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def resolve_count(spec: dict, requested: int | None, scene_count: int | None) -> int:
|
|
140
|
+
"""Validate a caller-supplied item count against the template's own bounds.
|
|
141
|
+
|
|
142
|
+
``requested`` is the number of content beats the caller asked for — the same
|
|
143
|
+
number the user picked. It is deliberately *not* derived from ``scene_count``:
|
|
144
|
+
a caller passing the user's 5 as a total scene count would produce 4 cards
|
|
145
|
+
with a self-consistent ``count=4`` written everywhere, which no validator can
|
|
146
|
+
catch and the user never sees. So the two are cross-checked instead.
|
|
147
|
+
"""
|
|
148
|
+
count = spec["default"] if requested is None else requested
|
|
149
|
+
if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"item count must be an integer from {spec['minimum']} to {spec['maximum']} "
|
|
152
|
+
f"(declared by template '{spec['template_id']}')"
|
|
153
|
+
)
|
|
154
|
+
if scene_count is not None and scene_count != count + 1:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"--scenes ({scene_count}) conflicts with the item count ({count}): a countdown is "
|
|
157
|
+
f"one opening plus N beats, so --scenes would have to be {count + 1}. Pass only one."
|
|
158
|
+
)
|
|
159
|
+
return count
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def opening_sec(spec: dict, count: int, duration: float) -> float:
|
|
163
|
+
"""Seconds for the single opening scene.
|
|
164
|
+
|
|
165
|
+
``openingSec`` is an upper bound, not a fixed value: on a short video it
|
|
166
|
+
yields to an even split so a long opening budget cannot starve the beats.
|
|
167
|
+
|
|
168
|
+
Worth knowing how provisional this is — ``render_video``'s
|
|
169
|
+
``adjust_timeline_to_audio`` overwrites every scene's duration with the real
|
|
170
|
+
narration length once TTS exists. This number shapes the script the user
|
|
171
|
+
reviews and the duration/cost estimate, not the finished cut.
|
|
172
|
+
"""
|
|
173
|
+
return min(spec["opening_sec"], duration / (count + 1))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def scene_payload(spec: dict, count: int, index: int | None) -> dict:
|
|
177
|
+
"""The countdown part of one scene's customPayload, under declared names.
|
|
178
|
+
|
|
179
|
+
``index`` is None for the opening. For a beat only the index is filled:
|
|
180
|
+
inventing the rest of the item would put fabricated data on screen, so the
|
|
181
|
+
caller is expected to fill it with real content before assets are prepared.
|
|
182
|
+
"""
|
|
183
|
+
payload: dict = {spec["count_prop"]: count}
|
|
184
|
+
for prop in spec["legacy_count_props"]:
|
|
185
|
+
payload[prop] = count
|
|
186
|
+
if index is not None:
|
|
187
|
+
payload[spec["item_prop"]] = {spec["index_prop"]: index}
|
|
188
|
+
return payload
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
"skillName": "template-registry",
|
|
3
3
|
"repoName": "agent-skill-media-maker",
|
|
4
4
|
"skillId": "475",
|
|
5
|
-
"version": "
|
|
5
|
+
"version": "V15",
|
|
6
6
|
"skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
|
|
7
7
|
}
|