@remixmate/cli 0.9.49 → 0.9.51

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 (29) hide show
  1. package/README.md +14 -26
  2. package/dist/manifest.json +18 -8
  3. package/package.json +9 -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 +13 -11
  8. package/skills/gen-script/scripts/gen_script.py +106 -25
  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 +77 -44
  15. package/skills/render-video/version.json +1 -1
  16. package/skills/template-registry/README.md +3 -14
  17. package/skills/template-registry/SKILL.md +12 -49
  18. package/skills/template-registry/scripts/countdown_spec.py +56 -6
  19. package/skills/template-registry/scripts/registry_loader.py +3 -4
  20. package/skills/template-registry/version.json +1 -1
  21. package/skills/template-registry/video_dsl/README.md +10 -53
  22. package/skills/template-registry/video_dsl/runtime/__init__.py +0 -1
  23. package/skills/template-registry/video_dsl/runtime/dsl_validator.py +44 -35
  24. package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +1 -1
  25. package/skills/template-registry/scripts/check_contracts.py +0 -179
  26. package/skills/template-registry/scripts/sync_registry.py +0 -44
  27. package/skills/template-registry/scripts/template_paths.py +0 -72
  28. package/skills/template-registry/scripts/verify_props_contract.py +0 -273
  29. package/skills/template-registry/video_dsl/runtime/template_binder.py +0 -87
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scenes": {
36
36
  "type": "number",
37
- "description": "Scene count. Only applies to templates with capabilities.sceneStrategy=arc (the default); single / fixed / countdown templates own their own count. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20) — a clamp is reported on stderr and is NOT an error, so always read the real count back from the returned DSL (scenes[].length) before describing the script to the user. For a template that maps one scene per source page (e.g. ppt-to-video), pass the page count you saw in doc_parse's summary."
37
+ "description": "Scene count. Only applies to templates with capabilities.sceneStrategy=arc (the default); single / fixed / countdown templates own their own count. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20) — a clamp is reported on stderr and is NOT an error, so always read the real count back from the returned DSL (scenes[].length) before describing the script to the user. For a template that maps one scene per source page (capabilities.payloadHydration), pass the page count you saw in doc_parse's summary."
38
38
  },
39
39
  "voice_id": {
40
40
  "type": "string",
@@ -44,13 +44,21 @@
44
44
  "type": "number",
45
45
  "description": "Narration speech rate, 0.5-2.0 (default 1.0). Stored at global.narration.speed and applied when prepare_video_assets synthesizes the voice-over. **Must** pass when the user picked a speech rate — a template's 语速 / speed parameter arrives as a multiplier like 1.1, and without this flag it is silently dropped and the narration comes back at 1.0. Above ~1.3 subtitle alignment drifts and the delivery sounds mechanical; suggest shortening the script instead of raising it further."
46
46
  },
47
+ "variant": {
48
+ "type": "string",
49
+ "description": "Template visual variant id (a key of the template's variants; template_registry lists them as variantIds). Written to meta.templateVariant, which beats the fuzzy `style` match. **Must** pass when the user picked a style: the replicate card shows it as `名字(id)` — e.g. `视觉风格: 紫色科技(tech-purple)` or `板面颜色: 黑板(black-board)` → pass the id in the parentheses. Without it the choice is silently dropped. Requires template_id; an id the template does not declare is rejected."
50
+ },
51
+ "background_preset": {
52
+ "type": "string",
53
+ "description": "Scene background preset, one of the template's customPayloadSchema.background.preset values. Written to every scene's customPayload.background.preset. **Must** pass when the user picked a background: the replicate card shows `背景样式: 纯色(solid)` → pass `solid`. Without it the scene keeps the template default. Requires template_id; unsupported values are rejected. `image` / `video` also need customPayload.background.assetRef on each scene."
54
+ },
47
55
  "template_id": {
48
56
  "type": "string",
49
- "description": "Template id (e.g. html-slide). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
57
+ "description": "Template id (from template_registry). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
50
58
  },
51
59
  "source_document_url": {
52
60
  "type": "string",
53
- "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). **Must** pass it for such a template: it is recorded at meta.sourceDocument, and prepare_video_assets then fetches the layout itself and fills every scene's customPayload.slide. Pass the same URL you gave doc_parse. You are NOT expected to author or even read that layout data — one page is hundreds of KB, so it never travels through the conversation; without this reference the DSL is rejected downstream after you have already written all the narration."
61
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration). **Must** pass it for such a template: it is recorded at meta.sourceDocument, and prepare_video_assets then fetches the layout itself and fills the customPayload field the template declares (payloadHydration.payloadField) on every scene. Pass the same URL you gave doc_parse. You are NOT expected to author or even read that layout data — one page is hundreds of KB, so it never travels through the conversation; without this reference the DSL is rejected downstream after you have already written all the narration."
54
62
  },
55
63
  "headline": {
56
64
  "type": "string",
@@ -72,7 +80,7 @@
72
80
  "items": {
73
81
  "type": "string"
74
82
  },
75
- "description": "Per-scene visuals for a visual-overlay template (image-slide etc.) — the counterpart of carousel_items for templates that have real scenes. Each element is EITHER a full URL (use that existing asset for the scene, no gen-image call) OR the literal string 'ai' / 'ai:<prompt>' (leave that scene to gen-image; the text after the colon, when present, is the user's own image prompt for it). Mapping is POSITIONAL: element i is scene i, so an AI scene can sit anywhere in the order — pass ['url1','ai','url2'] verbatim when the user arranged it that way. Scenes past the last element still get an AI-generated image, so 2 images still produce a full video. When `scenes` is not set the scene count is raised (never lowered) to fit the elements, so nothing the user arranged is dropped and handing over fewer images does not shrink the video. **Must** pass when the user supplies images for such a template — otherwise their images are ignored entirely and every scene is AI-generated. Do NOT pass for carousel-caption templates; those take carousel_items."
83
+ "description": "Per-scene visuals for a visual-overlay template (capabilities.payloadStyle=visual-overlay) — the counterpart of carousel_items for templates that have real scenes. Each element is EITHER a full URL (use that existing asset for the scene, no gen-image call) OR the literal string 'ai' / 'ai:<prompt>' (leave that scene to gen-image; the text after the colon, when present, is the user's own image prompt for it). Mapping is POSITIONAL: element i is scene i, so an AI scene can sit anywhere in the order — pass ['url1','ai','url2'] verbatim when the user arranged it that way. Scenes past the last element still get an AI-generated image, so 2 images still produce a full video. When `scenes` is not set the scene count is raised (never lowered) to fit the elements, so nothing the user arranged is dropped and handing over fewer images does not shrink the video. **Must** pass when the user supplies images for such a template — otherwise their images are ignored entirely and every scene is AI-generated. Do NOT pass for carousel-caption templates; those take carousel_items."
76
84
  },
77
85
  "caption_lines": {
78
86
  "type": "array",
@@ -117,6 +125,8 @@
117
125
  "scenes",
118
126
  "voice_id",
119
127
  "speed",
128
+ "variant",
129
+ "background_preset",
120
130
  "template_id",
121
131
  "source_document_url",
122
132
  "headline",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V16",
5
+ "version": "V17",
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
  }
@@ -73,7 +73,7 @@ The asset resolver invokes the following skills based on `AssetRef` declarations
73
73
  ```bash
74
74
  python3 <SkillDir>/scripts/prepare_video_assets.py \
75
75
  --dsl-json '<DSL_JSON_STRING>' \
76
- --template-id html-slide
76
+ --template-id <templateId>
77
77
  ```
78
78
 
79
79
  - `<DSL_JSON_STRING>` is the JSON the agent received from `gen_script` — pass inline; **do not** write it to disk.
@@ -86,7 +86,7 @@ python3 <SkillDir>/scripts/prepare_video_assets.py \
86
86
  ```bash
87
87
  python3 <SkillDir>/scripts/prepare_video_assets.py \
88
88
  --dsl my-video.dsl.json \
89
- --template-id html-slide
89
+ --template-id <templateId>
90
90
  ```
91
91
 
92
92
  `--template-id` automatically calls template-registry internally; or pass `--binding my-video.binding.json` if you have a pre-generated binding.
@@ -162,7 +162,7 @@ During dev / debug the user may want to exercise the pipeline without burning ge
162
162
  ```bash
163
163
  python3 <SkillDir>/scripts/prepare_video_assets.py \
164
164
  --dsl-json '<DSL_JSON_STRING>' \
165
- --template-id picture-book-en \
165
+ --template-id <templateId> \
166
166
  --stub-image-url "https://cdn.example.com/placeholder.jpg"
167
167
  ```
168
168
 
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "source_document_url": {
37
37
  "type": "string",
38
- "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). Normally unnecessary: gen_script already recorded it at meta.sourceDocument when you passed source_document_url there, and the cached skeleton carries it through. Pass it here only to recover when the skeleton lacks the reference — it saves a full gen_script round trip. The layout data is fetched here and written into every scene's customPayload.slide; you neither author nor see it."
38
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration). Normally unnecessary: gen_script already recorded it at meta.sourceDocument when you passed source_document_url there, and the cached skeleton carries it through. Pass it here only to recover when the skeleton lacks the reference — it saves a full gen_script round trip. The layout data is fetched here and written into the customPayload field the template declares (payloadHydration.payloadField) on every scene; you neither author nor see it."
39
39
  },
40
40
  "binding_json": {
41
41
  "type": "string",
@@ -2,6 +2,6 @@
2
2
  "skillName": "prepare-video-assets",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "476",
5
- "version": "V3",
5
+ "version": "V4",
6
6
  "skillDescription": "Asset-preparation skill: resolves and generates every asset (image / audio / video) referenced by a Video DSL, persists a RenderPlan to the database, and returns a `job_id` for the subsequent `render_video` call.\n\nUse this skill as soon as the user mentions any of these intents:\n- Generate / prepare video assets, resolve assets, render-ready\n- \"Make me a video about X\" (the agent calls gen_script → prepare_video_assets → render_video)\n- Regenerate one asset (image / audio) for a specific scene\n\nNext step: after the user confirms the resolved assets, call `render_video` with the `job_id` returned by this skill.\n\n⚠️ Stop-and-confirm gate: this skill runs only after the user has confirmed the script, and after it returns you must show the resolved assets and wait for the user's explicit confirmation. Never call `render_video` in the same turn."
7
7
  }
@@ -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.
@@ -1873,6 +1884,11 @@ def _persist_plan_checkpoint(render_plan: dict, job_id: Optional[int], private_t
1873
1884
  LogPrint(f" ⚠️ RenderPlan checkpoint failed (jobId={job_id}): {exc}", file=sys.stderr)
1874
1885
 
1875
1886
 
1887
+
1888
+ def _builtin_dynamic_render_enabled() -> bool:
1889
+ """内置模板是否也走 /renderDraft(Phase 3b 路由翻转开关,默认关)。"""
1890
+ return os.environ.get("REMIXMATE_BUILTIN_DYNAMIC_RENDER", "").strip().lower() in {"1", "true", "yes", "on"}
1891
+
1876
1892
  def render_with_remote_api(
1877
1893
  render_plan: dict,
1878
1894
  output_path: str,
@@ -1941,6 +1957,13 @@ def render_with_remote_api(
1941
1957
  status_path = "/renderStatus"
1942
1958
 
1943
1959
  source_oss_key = None
1960
+ # 内置模板是否也走动态渲染(Phase 3b 路由翻转,ab-platform/docs/
1961
+ # template-authoring-page-review.md §7.2)。默认关:翻转依赖 ab-render 以服务身份取
1962
+ # 内置源码(DRAFT_BUILTIN_SOURCE_TOKEN ↔ ab-api ab_render.service_token),服务端没配好
1963
+ # 就打开,普通用户渲染内置模板会全部失败。服务端就绪后设 REMIXMATE_BUILTIN_DYNAMIC_RENDER=1;
1964
+ # 回退 = 取消该变量,内置模板回到 /render。
1965
+ builtin_dynamic = _builtin_dynamic_render_enabled()
1966
+ is_builtin_dynamic = False
1944
1967
  template_id = render_plan.get("templateId", "")
1945
1968
  if template_id:
1946
1969
  try:
@@ -1950,8 +1973,12 @@ def render_with_remote_api(
1950
1973
  # 是另一个模板的画面。
1951
1974
  _meta = get_template(template_id, include_all_statuses=True)
1952
1975
  if _meta and not _meta.get("isBuiltin"):
1953
- # hasSource 是新字段;sourceOssKey 兼容尚未升级的 ab-api
1954
- source_oss_key = _meta.get("hasSource") or _meta.get("sourceOssKey")
1976
+ source_oss_key = _meta.get("hasSource")
1977
+ elif _meta and builtin_dynamic:
1978
+ # 内置模板:客户端拿不到也不需要 sourceOssKey(源码保护),只要知道
1979
+ # 它在 registry 里;ab-render 按 templateKey 以服务身份取包。
1980
+ source_oss_key = True
1981
+ is_builtin_dynamic = True
1955
1982
  except Exception:
1956
1983
  source_oss_key = None
1957
1984
 
@@ -1970,6 +1997,10 @@ def render_with_remote_api(
1970
1997
  # 不传则 ab-render 退回截首帧 / 不渲。
1971
1998
  if cover_composition_id:
1972
1999
  payload["coverCompositionId"] = cover_composition_id
2000
+ if is_builtin_dynamic:
2001
+ # 与预构建 /render 的 MainVideo 语义对齐:不把 entry.props 摊平到顶层
2002
+ # (摊平会让同名键覆盖平台注入的 typography / variantId 等)。
2003
+ payload["spreadEntryProps"] = False
1973
2004
  render_path = "/renderDraft"
1974
2005
  status_path = "/renderDraftStatus"
1975
2006
  else:
@@ -1988,6 +2019,8 @@ def render_with_remote_api(
1988
2019
  if pending_resume:
1989
2020
  # 续跑:下面那堆"正在提交"的日志会误导人,跳过
1990
2021
  LogPrint(f"🎬 Remote render task already exists, will resume polling", file=sys.stderr)
2022
+ elif is_builtin_dynamic:
2023
+ LogPrint(f" Builtin template (dynamic bundle): {template_id}", file=sys.stderr)
1991
2024
  elif source_oss_key:
1992
2025
  LogPrint(f" Private template (OSS dynamic bundle): {template_id}", file=sys.stderr)
1993
2026
  else:
@@ -2295,8 +2328,8 @@ def main():
2295
2328
  formatter_class=argparse.RawDescriptionHelpFormatter,
2296
2329
  epilog="""
2297
2330
  Examples:
2298
- python render_video.py --dsl video.dsl.json --template-id html-slide
2299
- 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
2300
2333
  python render_video.py --render-plan video.render-plan.json
2301
2334
  """,
2302
2335
  )
@@ -2306,7 +2339,7 @@ Examples:
2306
2339
  parser.add_argument(
2307
2340
  "--source-document-url", default=None,
2308
2341
  help="Source document URL (.pptx) for templates that build their per-scene payload from a "
2309
- "document (capabilities.payloadHydration, e.g. ppt-to-video). Overrides "
2342
+ "document (capabilities.payloadHydration). Overrides "
2310
2343
  "meta.sourceDocument in the DSL. The layout data is fetched here and written into "
2311
2344
  "every scene's customPayload — it is deliberately never routed through the model.",
2312
2345
  )
@@ -2500,8 +2533,8 @@ Examples:
2500
2533
 
2501
2534
  # ── 按引用注入模板声明的大 payload(必须在校验之前)─────────────
2502
2535
  #
2503
- # ppt-to-video 这类模板的画面数据(customPayload.slide:一整页的节点树、
2504
- # 坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2536
+ # 按引用注入的模板画面数据(payloadHydration.payloadField 指定的字段:一整页
2537
+ # 的节点树、坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2505
2538
  # 而它体积大到不能过模型(一页几百 KB,23 页好几 MB)。所以 DSL 里只带一个
2506
2539
  # 引用(meta.sourceDocument),在这里把全量取回来填进每个场景。
2507
2540
  #
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V29",
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
  }
@@ -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
 
@@ -89,20 +73,6 @@ the registry is loaded **only** from the ab-api HTTP endpoint — explicit `VIDE
89
73
  remixmate template-registry --list-templates
90
74
  ```
91
75
 
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
76
  ### Build a binding locally (debug only, not part of the render pipeline)
107
77
 
108
78
  ```python
@@ -111,7 +81,7 @@ sys.path.insert(0, "<SkillDir>/scripts")
111
81
  from match_template import build_binding, load_full_template, load_registry
112
82
 
113
83
  dsl = json.load(open("my-video.dsl.json"))
114
- template = next(t for t in load_registry() if t["templateId"] == "image-slide")
84
+ template = next(t for t in load_registry() if t["templateId"] == "<templateId>")
115
85
  binding = build_binding(load_full_template(template), dsl)
116
86
  print(json.dumps(binding, ensure_ascii=False, indent=2))
117
87
  ```
@@ -135,7 +105,7 @@ A registry row is 4–17 KB of JSON, so dumping every full definition at once ov
135
105
 
136
106
  ```bash
137
107
  remixmate template-registry --list-templates # choose
138
- remixmate template-registry --template-id html-slide --json-output # then read its contract
108
+ remixmate template-registry --template-id <templateId> --json-output # then read its contract
139
109
  ```
140
110
 
141
111
  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.
@@ -151,7 +121,7 @@ This mirrors the list/detail split described in `scripts/registry_loader.py` (P1
151
121
  - 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
122
  2. **`customPayload` full-field pass-through (no allowlist check)**
153
123
  - 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.
124
+ - This is the channel for templates that define their own sub-schema (multi-layout templates pass `slideId`, their per-layout `templateData`, `highlightMap`, etc.).
155
125
  - `scene.templateData` (without the `customPayload` wrapper) is also recognized as a `templateData` source — equivalent shorthand.
156
126
  - **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
127
 
@@ -165,11 +135,11 @@ This mirrors the list/detail split described in `scripts/registry_loader.py` (P1
165
135
 
166
136
  **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
137
 
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.
138
+ > 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
139
 
170
- ## Narration for multi-card scenes (html-slide template)
140
+ ## Narration for multi-card scenes (`lineSyncSlides`)
171
141
 
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:
142
+ 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
143
 
174
144
  ```jsonc
175
145
  "audio": {
@@ -189,13 +159,13 @@ When `html-slide` selects `demo-concept-overview` / `feature-grid` / `timeline`
189
159
  ```
190
160
 
191
161
  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.
162
+ - `items.length` must equal the length of `customPayload.templateData[lineSyncSlides[slideId]]`, otherwise `dsl_validator` rejects it.
163
+ - 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
164
  - If the author explicitly provides a `highlightMap`, the system keeps it as-is.
195
165
  - 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
166
  - `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
167
 
198
- ## Image sources (picture-book-en and every other template)
168
+ ## Image sources
199
169
 
200
170
  Image assets support two source forms:
201
171
 
@@ -232,16 +202,9 @@ Image assets support two source forms:
232
202
  - Local paths (`./image.png`, `/tmp/...`) are forbidden; HTTPS URLs only.
233
203
  - Both forms can be mixed inside the same DSL — each asset decides independently.
234
204
 
235
- ## Picture-book narration rule (picture-book-en template)
236
-
237
- With `picture-book-en`, **narration reads English only, never Chinese**:
205
+ ## Template-declared narration and text-layer constraints
238
206
 
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.
207
+ 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
208
 
246
209
  ## Error handling
247
210