@remixmate/cli 0.9.50 → 0.9.52

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.
Files changed (32) hide show
  1. package/README.md +14 -26
  2. package/dist/manifest.json +27 -14
  3. package/package.json +10 -13
  4. package/skills/doc-parse/SKILL.md +6 -6
  5. package/skills/doc-parse/scripts/extract_via_render.py +3 -2
  6. package/skills/doc-parse/skill.json +1 -1
  7. package/skills/gen-script/SKILL.md +19 -18
  8. package/skills/gen-script/scripts/gen_script.py +170 -38
  9. package/skills/gen-script/skill.json +14 -4
  10. package/skills/gen-script/version.json +1 -1
  11. package/skills/prepare-video-assets/SKILL.md +3 -3
  12. package/skills/prepare-video-assets/skill.json +1 -1
  13. package/skills/prepare-video-assets/version.json +1 -1
  14. package/skills/render-video/scripts/render_video.py +53 -42
  15. package/skills/render-video/version.json +1 -1
  16. package/skills/template-registry/README.md +4 -15
  17. package/skills/template-registry/SKILL.md +20 -52
  18. package/skills/template-registry/scripts/countdown_spec.py +56 -6
  19. package/skills/template-registry/scripts/list_templates.py +133 -8
  20. package/skills/template-registry/scripts/registry_loader.py +3 -4
  21. package/skills/template-registry/skill.json +2 -2
  22. package/skills/template-registry/version.json +1 -1
  23. package/skills/template-registry/video_dsl/README.md +10 -53
  24. package/skills/template-registry/video_dsl/runtime/__init__.py +0 -1
  25. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +108 -35
  26. package/skills/template-registry/video_dsl/runtime/narration_budget.py +129 -0
  27. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +1 -1
  28. package/skills/template-registry/scripts/check_contracts.py +0 -179
  29. package/skills/template-registry/scripts/sync_registry.py +0 -44
  30. package/skills/template-registry/scripts/template_paths.py +0 -72
  31. package/skills/template-registry/scripts/verify_props_contract.py +0 -273
  32. package/skills/template-registry/video_dsl/runtime/template_binder.py +0 -87
@@ -7,8 +7,8 @@
7
7
  编译时间线,最终调用 Remotion 渲染引擎输出视频。
8
8
 
9
9
  用法:
10
- python render_video.py --dsl video.dsl.json --template-id screen-walkthrough
11
- python render_video.py --dsl video.dsl.json --template-id screen-walkthrough --resolve-only
10
+ python render_video.py --dsl video.dsl.json --template-id <templateId>
11
+ python render_video.py --dsl video.dsl.json --template-id <templateId> --resolve-only
12
12
  python render_video.py --render-plan video.render-plan.json
13
13
 
14
14
  环境变量:
@@ -53,7 +53,7 @@ _SHARED_LIB_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-
53
53
  # Single sys.path setup for both cross-skill import surfaces of the shared lib:
54
54
  # - <shared> for `video_dsl.runtime.*`
55
55
  # - <shared>/scripts for `registry_loader`, `match_template`,
56
- # `render_job_client`, `template_paths`
56
+ # `render_job_client`, `countdown_spec`
57
57
  # Each deeper function used to repeat its own sys.path.insert; consolidating
58
58
  # here keeps the module's import side-effects in one place and matches
59
59
  # Python's "set up sys.path once at module top" idiom.
@@ -516,7 +516,7 @@ def _hydrate_dsl_payload(
516
516
  ) -> None:
517
517
  """按模板声明把源文件的每一页填进 ``customPayload``(就地改 dsl)。
518
518
 
519
- 只对声明了 ``capabilities.payloadHydration`` 的模板生效(目前 ppt-to-video);
519
+ 只对声明了 ``capabilities.payloadHydration`` 的模板生效;
520
520
  其余模板这里是零成本 no-op —— 声明驱动,不写 ``if template_id == X``(AGENTS.md)。
521
521
 
522
522
  失败一律 exit 2 并把「下一步做什么」打全:这条路上所有失败形态的共同点是
@@ -573,9 +573,12 @@ def _compose_image_prompts(dsl: dict, template_id: str | None) -> None:
573
573
  LogPrint(f" {line}", file=sys.stderr)
574
574
 
575
575
 
576
- def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> None:
576
+ def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int], step_field: Optional[str]) -> None:
577
577
  """动作条数与旁白句数不等时出声。
578
578
 
579
+ 动作数组放在哪个字段由模板的 ``capabilities.stepAlignment.payloadField`` 声明
580
+ (``step_field``);没声明的模板不检查。
581
+
579
582
  为什么值得单独打一条:这一类缺陷**渲得出来**。它不报错、不退非零、renderJob
580
583
  completed、漏斗记 rendered —— 2026-09-23 线上那条鸡兔同笼成片就是这样交付的,
581
584
  10 步板面配 10 句旁白,听起来该一一对应,实际板面在音频中点就走完了。
@@ -585,8 +588,9 @@ def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> No
585
588
  真正需要拦的是"作者以为一一对应、实际不是",而那个判断只有作者做得了 ——
586
589
  所以这里给的是两个数字,不是一个结论。
587
590
  """
588
- steps = ((entry.get("props") or {}).get("steps")
589
- or (entry.get("props") or {}).get("beats"))
591
+ if not step_field:
592
+ return
593
+ steps = (entry.get("props") or {}).get(step_field)
590
594
  if not isinstance(steps, list) or not steps:
591
595
  return
592
596
  sentences = (sentence_indices[-1] + 1) if sentence_indices else 0
@@ -1360,7 +1364,12 @@ def resolve_assets(render_plan: dict, private_token: str, max_retries: int, time
1360
1364
  return render_plan
1361
1365
 
1362
1366
 
1363
- def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_plan: dict) -> None:
1367
+ def _auto_highlight_map(
1368
+ entry: dict,
1369
+ narration_asset: Optional[dict],
1370
+ render_plan: dict,
1371
+ line_sync: dict[str, str],
1372
+ ) -> None:
1364
1373
  """Auto-fill ``templateData.highlightMap`` for any template that uses
1365
1374
  structured ``{intro, items, outro}`` narration and ships per-line cards.
1366
1375
 
@@ -1371,6 +1380,10 @@ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_pla
1371
1380
  this auto-fill. Adding a new template that follows the same pattern needs
1372
1381
  NO change to this function.
1373
1382
 
1383
+ ``line_sync`` is the template's ``lineSyncSlides`` declaration (slideId →
1384
+ card-array key). It caps the map at the card count so trailing outro lines
1385
+ don't point past the last card; slides it doesn't list get no cap.
1386
+
1374
1387
  Skipped silently when:
1375
1388
  - narration asset has no narrationLineCount (structured form wasn't used)
1376
1389
  - author already supplied a highlightMap (explicit wins)
@@ -1405,23 +1418,13 @@ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_pla
1405
1418
  intro_lines = int(narration_asset.get("narrationIntroLines") or 0)
1406
1419
  item_count = line_count - intro_lines
1407
1420
  # outro counts toward line_count too; cap item_count at the authored
1408
- # items-array length so trailing outro lines don't spill into card
1409
- # indices.
1410
- # ⚠️ 这份键表必须与模板侧 utils/slidePayload.ts 的 CARD_ARRAY_KEYS 完全一致:
1411
- # 两处是同一算法的两份实现(这里是生产路径,那边是摊平 binder 形态下的本地兜底)。
1412
- # 2026-09-15 修复:此前这里少了 steps / annotations,于是 step-flow 与
1413
- # code-highlight 拿不到下面那行的上限裁剪,旁白带 outro 行时 highlightMap 会多出
1414
- # 一个指向数组外的下标 —— 那一行播放期间"全部卡片变暗、没有一张点亮"。
1415
- # 顺序也有意义:取第一个命中的键,新键一律追加在末尾。
1416
- # template-library 的 `pnpm validate:contracts` 现在会双向核对这两份表。
1417
- items_key = next(
1418
- (
1419
- k
1420
- for k in ("concepts", "pillars", "eras", "items", "steps", "annotations")
1421
- if isinstance(tdata.get(k), list)
1422
- ),
1423
- None,
1424
- )
1421
+ # card-array length so trailing outro lines don't spill into card
1422
+ # indices. 数组键读模板的 lineSyncSlides 声明(按 slideId),CLI 不保留键表副本
1423
+ # —— 以前这里有一份模板 CARD_ARRAY_KEYS 的手抄,已经漂移过两次。
1424
+ slide_id = props.get("slideId") or tdata.get("slideId")
1425
+ items_key = line_sync.get(slide_id) if isinstance(slide_id, str) else None
1426
+ if items_key and not isinstance(tdata.get(items_key), list):
1427
+ items_key = None
1425
1428
  if items_key:
1426
1429
  item_count = min(item_count, len(tdata[items_key]))
1427
1430
 
@@ -1440,21 +1443,25 @@ def _auto_highlight_map(entry: dict, narration_asset: Optional[dict], render_pla
1440
1443
  })
1441
1444
 
1442
1445
 
1443
- def _template_default_tail_pad_frames(template_id: str, fps: int) -> int | None:
1446
+ def _timing_template(template_id: str) -> dict:
1447
+ """排轴阶段要读的模板声明;取不到就返回空 dict,走全局默认,不因为这个挡渲染。"""
1448
+ if not template_id:
1449
+ return {}
1450
+ try:
1451
+ from registry_loader import get_template # type: ignore
1452
+ return get_template(template_id, include_all_statuses=True) or {}
1453
+ except Exception: # noqa: BLE001
1454
+ return {}
1455
+
1456
+
1457
+ def _template_default_tail_pad_frames(tpl: dict, fps: int) -> int | None:
1444
1458
  """模板声明的旁白尾巴默认值(``capabilities.defaultTailPadSec``),换算成帧。
1445
1459
 
1446
1460
  场景自己的 ``customPayload.tailPadSec`` 仍然优先;这里只替换全局 1.5s 兜底。
1447
1461
  为什么要模板级:逐场景设置靠模型照着 llmHint 抄,实际经常漏 —— 2026-09-24
1448
1462
  线上《狼来了》绘本每页结尾 1.9s 空白,86s 里 15s 没有声音。
1449
1463
  """
1450
- if not template_id:
1451
- return None
1452
- try:
1453
- from registry_loader import get_template # type: ignore
1454
- tpl = get_template(template_id, include_all_statuses=True)
1455
- except Exception: # noqa: BLE001 - 取不到模板就走全局默认,不因为这个挡渲染
1456
- return None
1457
- value = ((tpl or {}).get("capabilities") or {}).get("defaultTailPadSec")
1464
+ value = (tpl.get("capabilities") or {}).get("defaultTailPadSec")
1458
1465
  if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
1459
1466
  return None
1460
1467
  return int(round(float(value) * fps))
@@ -1469,7 +1476,11 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1469
1476
  assets_map = {a["assetId"]: a for a in render_plan["resolvedAssets"]}
1470
1477
  current_frame = 0
1471
1478
  adjusted = 0
1472
- default_tail_pad = _template_default_tail_pad_frames(render_plan.get("templateId", ""), fps)
1479
+ from video_dsl.runtime.dsl_validator import line_sync_slides, step_alignment_field
1480
+ timing_tpl = _timing_template(render_plan.get("templateId", ""))
1481
+ line_sync = line_sync_slides(timing_tpl)
1482
+ step_field = step_alignment_field(timing_tpl)
1483
+ default_tail_pad = _template_default_tail_pad_frames(timing_tpl, fps)
1473
1484
  if default_tail_pad is None:
1474
1485
  default_tail_pad = int(fps * 1.5)
1475
1486
 
@@ -1533,7 +1544,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1533
1544
  }
1534
1545
  for i, s in enumerate(tts_subs)
1535
1546
  ]
1536
- _warn_step_sentence_mismatch(entry, sentence_indices)
1547
+ _warn_step_sentence_mismatch(entry, sentence_indices, step_field)
1537
1548
  else:
1538
1549
  # No TTS timestamps — re-distribute existing subtitle
1539
1550
  # lines across the REAL audio duration, by character
@@ -1605,7 +1616,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1605
1616
  cursor += seg_frames
1606
1617
  entry["subtitleSegments"] = new_subs
1607
1618
 
1608
- _auto_highlight_map(entry, narration_asset, render_plan)
1619
+ _auto_highlight_map(entry, narration_asset, render_plan, line_sync)
1609
1620
 
1610
1621
  # Re-anchor subtitles for scenes that weren't handled in the narration block above.
1611
1622
  # This covers scenes without narration or with invalid duration.
@@ -2317,8 +2328,8 @@ def main():
2317
2328
  formatter_class=argparse.RawDescriptionHelpFormatter,
2318
2329
  epilog="""
2319
2330
  Examples:
2320
- python render_video.py --dsl video.dsl.json --template-id html-slide
2321
- python render_video.py --dsl video.dsl.json --template-id html-slide --resolve-only
2331
+ python render_video.py --dsl video.dsl.json --template-id <templateId>
2332
+ python render_video.py --dsl video.dsl.json --template-id <templateId> --resolve-only
2322
2333
  python render_video.py --render-plan video.render-plan.json
2323
2334
  """,
2324
2335
  )
@@ -2328,7 +2339,7 @@ Examples:
2328
2339
  parser.add_argument(
2329
2340
  "--source-document-url", default=None,
2330
2341
  help="Source document URL (.pptx) for templates that build their per-scene payload from a "
2331
- "document (capabilities.payloadHydration, e.g. ppt-to-video). Overrides "
2342
+ "document (capabilities.payloadHydration). Overrides "
2332
2343
  "meta.sourceDocument in the DSL. The layout data is fetched here and written into "
2333
2344
  "every scene's customPayload — it is deliberately never routed through the model.",
2334
2345
  )
@@ -2522,8 +2533,8 @@ Examples:
2522
2533
 
2523
2534
  # ── 按引用注入模板声明的大 payload(必须在校验之前)─────────────
2524
2535
  #
2525
- # ppt-to-video 这类模板的画面数据(customPayload.slide:一整页的节点树、
2526
- # 坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2536
+ # 按引用注入的模板画面数据(payloadHydration.payloadField 指定的字段:一整页
2537
+ # 的节点树、坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2527
2538
  # 而它体积大到不能过模型(一页几百 KB,23 页好几 MB)。所以 DSL 里只带一个
2528
2539
  # 引用(meta.sourceDocument),在这里把全量取回来填进每个场景。
2529
2540
  #
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V30",
5
+ "version": "V31",
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
  }
@@ -17,7 +17,7 @@ HTTP 拉取带磁盘缓存(TTL + ETag/304);瞬时故障时降级复用同
17
17
 
18
18
  ab-api 响应包装格式 `{code, msg, data}` 由 `registry_loader._fetch_http` 透明拆封;静态 JSON endpoint 也支持(直接返回 registry 文档)。
19
19
 
20
- > 早期文档曾描述本 skill 通过 `@ab-templates/metadata` npm 包 / monorepo 文件消费 registry —— 这些本地来源已移除,运行时只走 ab-api HTTP。monorepo 文件仅供 `check_contracts.py` / `sync_registry.py` 等 monorepo-only 维护脚本使用。
20
+ > 早期文档曾描述本 skill 通过 `@ab-templates/metadata` npm 包 / monorepo 文件消费 registry —— 这些本地来源已移除,运行时只走 ab-api HTTP。配套的 monorepo-only 维护脚本(`check_contracts.py` / `sync_registry.py`)也已随之删除,契约对账改由 template-library 的 `generateManifest` / `validate:contracts` 在上游负责。
21
21
 
22
22
  ## 新增模板工作流
23
23
 
@@ -30,17 +30,8 @@ ab-api 响应包装格式 `{code, msg, data}` 由 `registry_loader._fetch_http`
30
30
 
31
31
  ## 契约一致性
32
32
 
33
- template-library 仓库内置契约验证器,确保:
34
-
35
- - 每个 `template.json` 中引用的 `compositionId` 都存在于 remotion-renderer 的 `manifest.ts`
36
- - manifest 中的每个 compositionId 都被某个 `template.json` 引用
37
- - 所有 `template.json` 通过 JSON Schema 校验
38
-
39
- 本地可运行 `check_contracts.py` 做跨仓库一致性检查(从 monorepo `<repo>/remotion-renderer/src/core/compositions/manifest.ts` 读 manifest):
40
-
41
- ```bash
42
- python3 <SkillDir>/scripts/check_contracts.py
43
- ```
33
+ 模板契约(schema、compositionId、slideSchemas、lineSyncSlides 等)由 template-library
34
+ 仓库自己的 `pnpm check:all` 校验。本仓不保留任何模板级校验或模板数据副本。
44
35
 
45
36
  ## 共享 Python 模块
46
37
 
@@ -48,12 +39,10 @@ python3 <SkillDir>/scripts/check_contracts.py
48
39
 
49
40
  | 模块 | 谁在用 | 作用 |
50
41
  |---|---|---|
51
- | `template_paths.py` | registry_loader / template_binder / gen-script / check_contracts | 统一计算 monorepo / template-library 路径,代替 4 个 caller 各自数 `..` |
52
42
  | `registry_loader.py` | template-registry / match_template / render-video / gen-script / dsl_validator | 加载 registry(HTTP + cache + file) |
53
43
  | `match_template.py` | render-video(`auto_bind_template`) | DSL → TemplateBinding 算法 |
54
44
  | `render_job_client.py` | render-video / export-jianying | ab-api renderJob HTTP 客户端 |
55
- | `check_contracts.py` | 维护脚本 | template-library ↔ remotion-renderer 契约校验 |
56
- | `sync_registry.py` | 维护脚本 | 把 registry 摘要落盘供 LLM prompt 使用 |
45
+ | `countdown_spec.py` | gen-script / render-video | 解析 `capabilities.countdown` 声明 |
57
46
 
58
47
  sibling skill 通过 `sys.path.insert(0, "<…>/template-registry/scripts")` 然后直接 `import` 模块名,模式见 `render_video.py` 顶部的"Single sys.path setup"段。
59
48
 
@@ -25,23 +25,7 @@ Stores every video-template definition, loads a template by **template-id**, and
25
25
 
26
26
  Template metadata is authored in the standalone **template-library** monorepo, published to ab-api, and served to the CLI over HTTP. At runtime template-registry loads the registry from a single source — the ab-api endpoint (see the env table below) — which returns every template definition (full slotMapping, compositions, etc.) including the caller's private/tenant templates.
27
27
 
28
- > The `template-library/packages/metadata/registry.json` file below is the authoring layout in the monorepo. It is **not** read at runtime anymore; only monorepo-only maintenance tooling (`check_contracts.py`, `sync_registry.py`) touches it directly.
29
-
30
- ```
31
- template-library/packages/
32
- ├── templates/src/
33
- │ ├── html-slide/ # Knowledge-board template
34
- │ │ ├── Composition.tsx
35
- │ │ └── template.json
36
- │ ├── image-slide/ # Basic image + narration template (includes the tech variant)
37
- │ │ └── ...
38
- │ ├── picture-book-en/ # English picture-book video template
39
- │ │ └── ...
40
- │ └── screen-walkthrough/ # Screen-recording walkthrough template
41
- │ └── ...
42
- └── metadata/
43
- └── registry.json # Aggregated registry (every template.json merged)
44
- ```
28
+ > Each template's `template.json` (authored in template-library, aggregated into `registry.json` there) is the single source of truth for everything template-specific: `llmHint`, `capabilities`, `customPayloadSchema`, `slideSchemas`, `slotMapping`. This skill and the rest of the CLI only implement the generic mechanisms those declarations drive — they never name individual templates, so read the template's own definition (see *Steps*) instead of looking for per-template rules here.
45
29
 
46
30
  ## Agent behavior: image URL handling
47
31
 
@@ -77,7 +61,8 @@ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDE
77
61
  > The Python binding logic that maps DSL → TemplateBinding lives in `scripts/match_template.py` but is **not exposed as a CLI** — it is only consumed as a Python library by `render-video`'s `render_video.py` via `import match_template`.
78
62
 
79
63
  1. **List available templates**: run `remixmate template-registry --list-templates` to view the templates in the registry along with their supported aspect ratios / style tags, and decide which `templateId` to pick.
80
- 2. **Read that template's contract**: run `remixmate template-registry --template-id <id> --json-output` for the full definition (llmHint, `customPayloadSchema` with the legal `slideId` values, `slotMapping`, `variants`). The list view only carries a 200-char llmHint preview, which is not enough to author against.
64
+ 2. **Read that template's contract**: run `remixmate template-registry --template-id <id>` for the full definition (llmHint, `customPayloadSchema` with the legal `slideId` values, `slotMapping`, `variants`). The list view only carries a 200-char llmHint preview, which is not enough to author against. Read it **once per conversation** — it does not change between turns.
65
+ 2b. **Multi-layout templates: expand only the layouts you use.** For templates that ship `slideSchemas`, step 2 returns a per-layout **menu** (description, field names, card-list key / item fields / `maxItems`, and a `narration` note on layouts that must split narration into `items`). Choose layouts and draft the script from the menu; then, right before writing `templateData`, call once more with `--slide-id <id>` for every chosen layout (repeatable) to get their exact field schemas.
81
66
  3. **Write the DSL**: when generating the Video DSL, put the chosen `templateId` into `meta.templateId` (the canonical location). Use `meta.templateVariant` / `renderHints.templateVariant` to explicitly select a variant. The legacy `renderHints.templatePreference[0]` is still tolerated by `match_template.py` and `dsl_validator._pick_template_id` during transition, but new authors should write `meta.templateId`.
82
67
  4. **Produce the TemplateBinding**: there is no standalone CLI for DSL → TemplateBinding; `prepare_video_assets` calls `match_template.build_binding(template, dsl)` inline during the asset-resolution pipeline and embeds the binding into the RenderPlan it hands to the renderer — no separate `*.binding.json` file is written.
83
68
 
@@ -89,20 +74,6 @@ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDE
89
74
  remixmate template-registry --list-templates
90
75
  ```
91
76
 
92
- ### Sync the registry cache (optional, used for offline / LLM prompt)
93
-
94
- ```bash
95
- python3 <SkillDir>/scripts/sync_registry.py
96
- ```
97
-
98
- `sync_registry.py` and `check_contracts.py` are **maintenance helpers** for template-library, not skill entry points. Keep invoking them directly via `python3`.
99
-
100
- ### Verify template / Remotion contract consistency
101
-
102
- ```bash
103
- python3 <SkillDir>/scripts/check_contracts.py
104
- ```
105
-
106
77
  ### Build a binding locally (debug only, not part of the render pipeline)
107
78
 
108
79
  ```python
@@ -111,7 +82,7 @@ sys.path.insert(0, "<SkillDir>/scripts")
111
82
  from match_template import build_binding, load_full_template, load_registry
112
83
 
113
84
  dsl = json.load(open("my-video.dsl.json"))
114
- template = next(t for t in load_registry() if t["templateId"] == "image-slide")
85
+ template = next(t for t in load_registry() if t["templateId"] == "<templateId>")
115
86
  binding = build_binding(load_full_template(template), dsl)
116
87
  print(json.dumps(binding, ensure_ascii=False, indent=2))
117
88
  ```
@@ -121,13 +92,14 @@ print(json.dumps(binding, ensure_ascii=False, indent=2))
121
92
  | Flag | Description | Default |
122
93
  |------|-------------|---------|
123
94
  | `--list-templates` | List every available template as a **summary** (templateId / name / description / aspect ratios / language / status / styleTags / variantIds / the `capabilities` keys that drive authoring — `payloadStyle` / `needsNarration` / `durationStrategy` / `narrationDriver` — and llmHint truncated to 200 chars). | — |
124
- | `--template-id <id>` | Print that template's **full definition** — llmHint in full, `customPayloadSchema`, `slotMapping`, `compositions`, `variants`. Repeatable. | — |
95
+ | `--template-id <id>` | Print that template's **full definition** — llmHint in full, `customPayloadSchema`, `slotMapping`, `compositions`, `variants` — as compact JSON. Multi-layout templates return `slideSchemas` as a menu. Repeatable. | — |
96
+ | `--slide-id <slideId>` | With `--template-id`: expand these layouts' full `slideSchemas` (repeatable; `all` = every layout, large). Unknown ids fail with the legal list. | menu |
125
97
  | `--full` | List mode: emit full definitions instead of summaries. Refuses when the result would exceed 60 K characters. | off |
126
98
  | `--filter-tag` | Keep only templates whose `styleTags` match this substring (case-insensitive). | none |
127
99
  | `--filter-aspect` | Keep only templates declaring this aspect ratio (e.g. `9:16`). | none |
128
100
  | `--filter-language` | Keep only templates whose `contentLanguage` includes this code (`zh`/`en`); language-agnostic templates always show. | none |
129
101
  | `--include-beta` | Also show `status: beta` templates (same effect as `ENABLE_BETA_TEMPLATES=1`). | off |
130
- | `--json-output` | Emit `{ "templates": [...] }` instead of the table — summaries, or full definitions under `--template-id` / `--full`. | off |
102
+ | `--json-output` | List mode: emit `{ "templates": [...] }` summaries (or full definitions with `--full`) instead of the table. Detail mode (`--template-id`) is always compact JSON. | off |
131
103
 
132
104
  ### List vs. detail
133
105
 
@@ -135,9 +107,12 @@ A registry row is 4–17 KB of JSON, so dumping every full definition at once ov
135
107
 
136
108
  ```bash
137
109
  remixmate template-registry --list-templates # choose
138
- remixmate template-registry --template-id html-slide --json-output # then read its contract
110
+ remixmate template-registry --template-id <templateId> # then read its contract (slide menu)
111
+ remixmate template-registry --template-id <templateId> --slide-id key-points --slide-id timeline # exact schemas of the chosen layouts
139
112
  ```
140
113
 
114
+ Detail mode used to pretty-print, which nearly doubled the size (html-slide: 37 K → 65 K characters) and pushed the plain call past the agent's tool-result cap — the result was spilled to a file the agent cannot open, so it re-called with other flags, and re-read the whole contract on the next turn too (session review 2026-09-27). Output is now always compact, and `slideSchemas` — two thirds of a multi-layout template — is a menu until you name the layouts.
115
+
141
116
  This mirrors the list/detail split described in `scripts/registry_loader.py` (P1.2), realized CLI-side so it holds even while the backend still serves one merged registry payload.
142
117
 
143
118
  ## Props extraction rules
@@ -151,7 +126,7 @@ This mirrors the list/detail split described in `scripts/registry_loader.py` (P1
151
126
  - Two forms are supported: `{ from: "textLayers", role: "..." }` matches a textLayer by role; `{ from: "a.b.c" }` reads a value from the scene by dotted path.
152
127
  2. **`customPayload` full-field pass-through (no allowlist check)**
153
128
  - First `scene.customPayload.templateData.*` is injected; then `scene.customPayload.*` (except `templateData` itself). The latter overrides same-named values from the former, but neither can override fields already produced by propExtractors.
154
- - This is the channel for templates such as html-slide that define their own sub-schema and need to pass `slideId` / `items` / `highlightMap`, etc.
129
+ - This is the channel for templates that define their own sub-schema (multi-layout templates pass `slideId`, their per-layout `templateData`, `highlightMap`, etc.).
155
130
  - `scene.templateData` (without the `customPayload` wrapper) is also recognized as a `templateData` source — equivalent shorthand.
156
131
  - **Note**: `requiredProps` / `optionalProps` are currently **just template-schema documentation**; they trigger no implicit pass-through. A field reaches `props` either through a propExtractor or through `customPayload`.
157
132
 
@@ -165,11 +140,11 @@ This mirrors the list/detail split described in `scripts/registry_loader.py` (P1
165
140
 
166
141
  **Common pitfall**: writing the same field in both `propExtractors` and `customPayload` — when they collide, `propExtractors` wins. Keep each field in exactly one place. `build_binding` now **detects this**: any prop name that is both declared in `propExtractors` and hit by `customPayload` pass-through (top-level, or `templateData` when the extractor reads from elsewhere) is recorded on the binding row as `dualChannelProps` and printed as a stderr warning; `test-template-pipeline.py` surfaces it as a non-fatal authoring warning. A field placed in its extractor's own declared source (e.g. extractor `from:"templateData.slideId"` + value in `templateData.slideId`) is **not** flagged — that is the legitimate source, not a double-write.
167
142
 
168
- > Historical issue: an early html-slide `point` slot declared `slideId` as `propExtractors.slideId={from:"templateData.slideId"}` and also marked it as `requiredProps`. But every existing DSL wrote `slideId` at `customPayload.slideId` (top-level, not inside templateData), so the propExtractor returned nothing and rendering fell back to the generic layout. The convention now is unified: **slideId flows through customPayload top-level pass-through**, propExtractors no longer declares it. New templates must follow the same convention.
143
+ > Convention: **`slideId` flows through `customPayload` top-level pass-through**; templates do not declare it in `propExtractors`. An earlier template did both, the extractor read `templateData.slideId` while every DSL wrote `customPayload.slideId`, and every scene silently fell back to the generic layout.
169
144
 
170
- ## Narration for multi-card scenes (html-slide template)
145
+ ## Narration for multi-card scenes (`lineSyncSlides`)
171
146
 
172
- When `html-slide` selects `demo-concept-overview` / `feature-grid` / `timeline` / `column-compare` (all "many-card sequential highlight" slides), you **must** use structured narration to avoid highlight/audio drift:
147
+ A template that highlights cards one by one in step with the narration declares which layouts do so in `lineSyncSlides` (`slideId` → the `templateData` array holding the cards). For those layouts you **must** use structured narration, one line per card, to avoid highlight/audio drift:
173
148
 
174
149
  ```jsonc
175
150
  "audio": {
@@ -189,13 +164,13 @@ When `html-slide` selects `demo-concept-overview` / `feature-grid` / `timeline`
189
164
  ```
190
165
 
191
166
  Rules:
192
- - `items.length` must equal `customPayload.templateData.<concepts|items>.length` (demo-concept-overview uses `concepts`; the other three slides use `items`), otherwise `dsl_validator` rejects it.
193
- - Do not write `templateData.highlightMap`; `render_video.py` builds it automatically from the per-line timestamps that TTS returns.
167
+ - `items.length` must equal the length of `customPayload.templateData[lineSyncSlides[slideId]]`, otherwise `dsl_validator` rejects it.
168
+ - Do not write `templateData.highlightMap`; `render_video.py` builds it automatically from the per-line timestamps that TTS returns (capped at the declared card array's length). Template-specific exceptions (e.g. how an `outro` line should map) are in the template's `llmHint`.
194
169
  - If the author explicitly provides a `highlightMap`, the system keeps it as-is.
195
170
  - The legacy flat `narration.text` form still works but the author then has to keep `highlightMap` in sync with the subtitle segmentation (error-prone, not recommended).
196
171
  - `narration.languageBoost` (optional, also accepted at `global.narration`) pins the TTS language hint (`auto` / `Chinese` / `English` / …). Leave it unset unless auto-detection gets it wrong — the service applies its own default, and a scene-level value overrides the global one, exactly like `speed`.
197
172
 
198
- ## Image sources (picture-book-en and every other template)
173
+ ## Image sources
199
174
 
200
175
  Image assets support two source forms:
201
176
 
@@ -232,16 +207,9 @@ Image assets support two source forms:
232
207
  - Local paths (`./image.png`, `/tmp/...`) are forbidden; HTTPS URLs only.
233
208
  - Both forms can be mixed inside the same DSL — each asset decides independently.
234
209
 
235
- ## Picture-book narration rule (picture-book-en template)
236
-
237
- With `picture-book-en`, **narration reads English only, never Chinese**:
210
+ ## Template-declared narration and text-layer constraints
238
211
 
239
- - `audio.narration.text` and the corresponding `gen-voice` asset's `payload.text` **must contain only the English source** (ASCII letters + standard English punctuation); any CJK character or Chinese punctuation is forbidden.
240
- - The Chinese translation appears only on screen via `textLayers[role=subheadline].content` (mapped to `chineseTitle` / `chineseText`); it never enters TTS.
241
- - Layout is fixed:
242
- - **Cover**: top half is the title area (English `englishTitle` on top in a large round-bold font; Chinese `chineseTitle` below in a smaller font); bottom half is the cover image / video.
243
- - **Content page**: top half is the text area (English `englishText` on top in a large round-bold font; Chinese `chineseText` below); bottom half is the illustration.
244
- - `textLayers` role mapping is mandatory: `role=headline` carries the English text, `role=subheadline` carries the Chinese text.
212
+ Some templates constrain what narration or on-screen text may contain — e.g. `capabilities.narrationLanguageStrict` (narration and its `gen-voice` text must be in one language) and `capabilities.textLayerRules` (which `textLayers` roles are required, and in which language). `dsl_validator` enforces the declarations; the template's `llmHint` explains how to write for them.
245
213
 
246
214
  ## Error handling
247
215
 
@@ -29,10 +29,12 @@ The strategy carries the shape; the template carries every concrete value:
29
29
  "sceneStrategy": "countdown",
30
30
  "countdown": {
31
31
  "countProp": "topN", // where the count goes in customPayload
32
- "itemProp": "item", // optional, default "item"
33
- "indexProp": "rank", // optional, default "rank"
32
+ "itemProp": "item", // where one beat's item goes
33
+ "indexProp": "rank", // the index field inside that item
34
34
  "legacyCountProps": ["totalItems"],
35
- "openingSec": 2.5 // optional, default DEFAULT_OPENING_SEC
35
+ "openingSec": 2.5, // optional, default DEFAULT_OPENING_SEC
36
+ "countPatterns": ["..."] // optional: regexes that find a count stated
37
+ // in on-screen copy (group 1 = the number)
36
38
  }
37
39
  }
38
40
 
@@ -50,6 +52,8 @@ the count would fall back to "however many cards are present", and the
50
52
 
51
53
  from __future__ import annotations
52
54
 
55
+ import re
56
+
53
57
  #: Opening budget when the template declares none. A generic "an opening is a
54
58
  #: beat, not a chapter" value — **not** any one template's measurement. It used
55
59
  #: to be 3 hardcoded in gen_script, taken from github-repo-rank's reference film
@@ -88,10 +92,23 @@ def resolve_countdown_spec(template: dict | None) -> dict | None:
88
92
  raise CountdownError(
89
93
  f"Template '{template_id}' declares capabilities.sceneStrategy=countdown but no "
90
94
  "capabilities.countdown.countProp. Declare which customPayload field carries the "
91
- "item count (github-repo-rank uses \"topN\")."
95
+ "item count."
92
96
  )
93
97
  count_prop = count_prop.strip()
94
98
 
99
+ # itemProp / indexProp 同样必须声明:缺省值只能是某个具体模板的字段名,
100
+ # 那等于把那个模板写进 CLI。
101
+ names: dict[str, str] = {}
102
+ for key in ("itemProp", "indexProp"):
103
+ value = cfg.get(key)
104
+ if not isinstance(value, str) or not value.strip():
105
+ raise CountdownError(
106
+ f"Template '{template_id}' declares capabilities.sceneStrategy=countdown but no "
107
+ f"capabilities.countdown.{key}. Declare the customPayload field name — the CLI "
108
+ "does not guess one."
109
+ )
110
+ names[key] = value.strip()
111
+
95
112
  schema = (template or {}).get("customPayloadSchema")
96
113
  bounds = schema.get(count_prop) if isinstance(schema, dict) else None
97
114
  if not isinstance(bounds, dict):
@@ -119,6 +136,22 @@ def resolve_countdown_spec(template: dict | None) -> dict | None:
119
136
  legacy = cfg.get("legacyCountProps")
120
137
  legacy = [p for p in legacy if isinstance(p, str) and p.strip()] if isinstance(legacy, list) else []
121
138
 
139
+ patterns = []
140
+ for raw in cfg.get("countPatterns") or []:
141
+ try:
142
+ compiled = re.compile(raw, re.I) if isinstance(raw, str) else None
143
+ except re.error as exc:
144
+ raise CountdownError(
145
+ f"Template '{template_id}': countdown.countPatterns entry {raw!r} is not a valid "
146
+ f"regex ({exc})."
147
+ ) from None
148
+ if compiled is None or compiled.groups < 1:
149
+ raise CountdownError(
150
+ f"Template '{template_id}': countdown.countPatterns entry {raw!r} must be a regex "
151
+ "string with a capturing group for the number."
152
+ )
153
+ patterns.append(compiled)
154
+
122
155
  opening_sec = cfg.get("openingSec")
123
156
  if not (type(opening_sec) in (int, float) and opening_sec > 0):
124
157
  opening_sec = DEFAULT_OPENING_SEC
@@ -127,8 +160,9 @@ def resolve_countdown_spec(template: dict | None) -> dict | None:
127
160
  "template_id": template_id,
128
161
  "count_prop": count_prop,
129
162
  "legacy_count_props": legacy,
130
- "item_prop": (cfg.get("itemProp") or "item").strip(),
131
- "index_prop": (cfg.get("indexProp") or "rank").strip(),
163
+ "item_prop": names["itemProp"],
164
+ "index_prop": names["indexProp"],
165
+ "count_patterns": patterns,
132
166
  "minimum": minimum,
133
167
  "maximum": maximum,
134
168
  "default": default,
@@ -136,6 +170,22 @@ def resolve_countdown_spec(template: dict | None) -> dict | None:
136
170
  }
137
171
 
138
172
 
173
+ def stated_counts(spec: dict, text: str) -> set[int]:
174
+ """Counts that on-screen copy states, found with the template's ``countPatterns``.
175
+
176
+ How a count is worded (``Top 5`` / ``前 5 名`` / ``5 个技巧``) is the
177
+ template's copy convention, so the patterns are declared there; a template
178
+ that declares none gets no check.
179
+ """
180
+ found: set[int] = set()
181
+ for pattern in spec.get("count_patterns") or []:
182
+ for match in pattern.finditer(text or ""):
183
+ for group in match.groups():
184
+ if group is not None and group.isdigit():
185
+ found.add(int(group))
186
+ return found
187
+
188
+
139
189
  def resolve_count(spec: dict, requested: int | None, scene_count: int | None) -> int:
140
190
  """Validate a caller-supplied item count against the template's own bounds.
141
191