@remixmate/cli 0.9.39 → 0.9.41

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.39",
4
- "generatedAt": "2026-09-21T14:35:49.704Z",
3
+ "version": "0.9.41",
4
+ "generatedAt": "2026-10-08T23:39:24.336Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -464,7 +464,7 @@
464
464
  "items": {
465
465
  "type": "string"
466
466
  },
467
- "description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
467
+ "description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed into customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise customPayload.carousel.items, as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
468
468
  },
469
469
  "scene_images": {
470
470
  "type": "array",
@@ -478,7 +478,7 @@
478
478
  "items": {
479
479
  "type": "string"
480
480
  },
481
- "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). Each element is one line. Line-count limits and whether **emphasis** is parsed are declared by the template — read its llmHint, which gen_script prints in full on stderr. **Must** pass when the user explicitly provides bullet-point text / bottom copy. **Also must pass — written by you — when the user did NOT provide any copy but the template is typewriter-driven** (capabilities.durationStrategy=fit-caption): such templates have no narration, so these lines are both the video's content and the thing that decides its duration. Draft them from the material you researched (repo README, page screenshots, the topic). Leaving this empty for a fit-caption template is rejected: nothing auto-generates caption text, and an empty caption renders a titled carousel with no words at all."
481
+ "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). For image sequences (payloadDefaults.sequence), lines map in order to sequence.items[].caption, with at most one line per image. Otherwise each element is one line. Line-count limits and whether **emphasis** is parsed are declared by the template — read its llmHint, which gen_script prints in full on stderr. **Must** pass when the user explicitly provides bullet-point text / bottom copy. **Also must pass — written by you — when the user did NOT provide any copy but the template is typewriter-driven** (capabilities.durationStrategy=fit-caption): such templates have no narration, so these lines are both the video's content and the thing that decides its duration. Draft them from the material you researched (repo README, page screenshots, the topic). Leaving this empty for a fit-caption template is rejected: nothing auto-generates caption text, and an empty caption renders a titled carousel with no words at all."
482
482
  },
483
483
  "stub_image_url": {
484
484
  "type": "string",
@@ -491,6 +491,11 @@
491
491
  "skip_asset_generation": {
492
492
  "type": "boolean",
493
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."
494
499
  }
495
500
  },
496
501
  "required": [
@@ -501,7 +506,9 @@
501
506
  "primary": [
502
507
  "topic"
503
508
  ],
504
- "advanced": [],
509
+ "advanced": [
510
+ "item_count"
511
+ ],
505
512
  "hidden": [
506
513
  "duration",
507
514
  "style",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.39",
3
+ "version": "0.9.41",
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,18 @@
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:prepare-contracts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-prepare-contracts.py",
41
+ "test:image-sequence": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-image-sequence.py",
42
+ "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
38
43
  },
39
44
  "keywords": [
40
45
  "claude-code",
@@ -17,10 +17,13 @@ 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
23
24
  import uuid
25
+ from copy import deepcopy
26
+ from urllib.parse import urlsplit
24
27
 
25
28
  SCHEMA_PATH = os.environ.get(
26
29
  "VIDEO_DSL_SCHEMA_PATH",
@@ -41,6 +44,21 @@ _SHARED_SCRIPTS_DIR = os.path.join(
41
44
  if os.path.isdir(_SHARED_SCRIPTS_DIR):
42
45
  sys.path.insert(0, os.path.abspath(_SHARED_SCRIPTS_DIR))
43
46
 
47
+ # countdown 策略的声明解析。与下面几个共享依赖同样守护式导入:本文件全篇假定
48
+ # 共享目录**可能**不在(见 _SHARED_SCRIPTS_DIR 的 isdir 判断),硬导入会让共享目录
49
+ # 缺失时连非 countdown 模板一起起不来。缺失只影响 countdown 这一条路,并在真的
50
+ # 用到时明确报错。
51
+ try:
52
+ from countdown_spec import ( # noqa: E402 (needs the sys.path setup above)
53
+ opening_sec as countdown_opening_sec,
54
+ resolve_count as resolve_countdown_count,
55
+ resolve_countdown_spec,
56
+ scene_payload as countdown_scene_payload,
57
+ )
58
+ _HAS_COUNTDOWN = True
59
+ except ImportError:
60
+ _HAS_COUNTDOWN = False
61
+
44
62
  try:
45
63
  from video_dsl.runtime.prompt_enhancer import build_enhanced_prompt
46
64
  _HAS_ENHANCER = True
@@ -459,16 +477,32 @@ def _template_needs_image(template_config: dict | None) -> bool:
459
477
  #
460
478
  # 归一化 contract 字段(全部可缺省,缺省值保证"未声明能力"的模板走通用图文旁白):
461
479
  # - needs_narration bool capabilities.needsNarration,默认 True
462
- # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed},默认 arc
480
+ # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed, countdown},默认 arc
463
481
  # - fixed_scenes list[dict] capabilities.fixedScenes(single/fixed 用;缺省单 opening)
482
+ # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
483
+ # 非 countdown 模板为 None)
464
484
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
465
- # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
485
+ # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption, fit-images}
466
486
  # - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
467
487
  # - duration_model dict|None capabilities.durationModel(fit-caption 估时用的节奏数值,
468
488
  # 由模板声明;缺省则不估时,见 _estimate_fit_caption_duration)
469
489
  # - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导
470
490
 
471
491
 
492
+ def _resolve_countdown(template_config: dict | None) -> dict:
493
+ """countdown 声明解析;共享模块缺失时明确报错而不是静默换一种形状。
494
+
495
+ 退回 ``arc`` 会是最坏的处理:一个倒数模板会拿到「至少 3 段 + 末尾 cta + 升序编号」
496
+ 的场景表,既没有数量也没有倒数索引,然后一路通过到渲染。
497
+ """
498
+ if not _HAS_COUNTDOWN:
499
+ raise ValueError(
500
+ "capabilities.sceneStrategy=countdown needs the shared countdown_spec module "
501
+ "from skills/template-registry/scripts/, which is not importable here."
502
+ )
503
+ return resolve_countdown_spec(template_config)
504
+
505
+
472
506
  def _resolve_contract(template_config: dict | None) -> dict:
473
507
  """把 template.json 的 capabilities 归一化成一份装配 contract。
474
508
 
@@ -482,7 +516,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
482
516
  needs_narration = caps.get("needsNarration", True) is not False
483
517
 
484
518
  scene_strategy = caps.get("sceneStrategy")
485
- if scene_strategy not in ("arc", "single", "fixed"):
519
+ if scene_strategy not in ("arc", "single", "fixed", "countdown"):
486
520
  scene_strategy = "arc"
487
521
 
488
522
  primary_visual_type = _template_primary_visual_type(template_config)
@@ -492,7 +526,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
492
526
  payload_style = "slide" if primary_visual_type == "none" else "visual-overlay"
493
527
 
494
528
  duration_strategy = caps.get("durationStrategy")
495
- if duration_strategy not in ("explicit", "fit-caption"):
529
+ if duration_strategy not in ("explicit", "fit-caption", "fit-images"):
496
530
  duration_strategy = "explicit"
497
531
 
498
532
  fixed_scenes = caps.get("fixedScenes")
@@ -507,6 +541,9 @@ def _resolve_contract(template_config: dict | None) -> dict:
507
541
  "needs_narration": needs_narration,
508
542
  "scene_strategy": scene_strategy,
509
543
  "fixed_scenes": fixed_scenes,
544
+ # 声明解析放共享模块:render-video 的计划校验要读同一份声明,
545
+ # 两边各抄一遍正是这个功能上一版栽过的跟头。
546
+ "countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
510
547
  "payload_style": payload_style,
511
548
  "duration_strategy": duration_strategy,
512
549
  "duration_model": caps.get("durationModel"),
@@ -751,6 +788,29 @@ def _estimate_fit_caption_duration(
751
788
  return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)
752
789
 
753
790
 
791
+ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int = 30) -> float:
792
+ """Apply template-declared image timing, in frames just like the renderer.
793
+
794
+ Hold tiers and transition margins belong to durationModel.sequence; never
795
+ infer them from a template id or silently use the caller's target duration.
796
+ """
797
+ timing = (model or {}).get("sequence", {})
798
+ tiers = timing.get("holdSecByCount", [])
799
+ count = len(sequence["items"])
800
+ rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
801
+ if rule_hold is None:
802
+ raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
803
+ base_hold = sequence.get("defaultHoldSec", rule_hold)
804
+ transition_ms = sequence.get("transitionMs", timing.get("transitionMs"))
805
+ margin = timing.get("minHoldMarginFrames")
806
+ if transition_ms is None or margin is None:
807
+ raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
808
+ # Python round uses ties-to-even; JS Math.round uses floor(x + 0.5).
809
+ min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
810
+ return sum(max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
811
+ for item in sequence["items"]) / fps
812
+
813
+
754
814
  def _build_carousel_caption_dsl(
755
815
  *,
756
816
  template_id: str,
@@ -779,7 +839,12 @@ def _build_carousel_caption_dsl(
779
839
  needsNarration),不再读 input-schema.json,也没有 if template_id==X 分支。
780
840
  轮播素材由调用方在 carousel_items 里直接给 URL(existing 资产),不触发 AI 生图。
781
841
  """
782
- defaults = payload_defaults or {}
842
+ defaults = deepcopy(payload_defaults or {})
843
+ sequence_mode = isinstance(defaults.get("sequence"), dict)
844
+ if sequence_mode and not carousel_items:
845
+ raise ValueError("Image sequence needs --carousel-items; caption text alone cannot supply images")
846
+ if sequence_mode and len(caption_lines) > len(carousel_items):
847
+ raise ValueError("Image sequence has more --caption-lines than images; use at most one caption per image")
783
848
 
784
849
  # ── Build assets from carousel_items ──────────────────────────────────
785
850
  assets = []
@@ -787,7 +852,9 @@ def _build_carousel_caption_dsl(
787
852
  for idx, url in enumerate(carousel_items):
788
853
  asset_id = f"carousel-img-{idx + 1:02d}"
789
854
  # Determine if URL is a video (basic heuristic on extension)
790
- is_video = any(url.lower().endswith(ext) for ext in (".mp4", ".mov", ".webm", ".avi"))
855
+ is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
856
+ if sequence_mode and is_video:
857
+ raise ValueError("Image sequence requires image URLs in --carousel-items, not video URLs")
791
858
  assets.append({
792
859
  "assetId": asset_id,
793
860
  "type": "video" if is_video else "image",
@@ -854,6 +921,18 @@ def _build_carousel_caption_dsl(
854
921
  if defaults.get(key):
855
922
  custom_payload[key] = defaults[key]
856
923
 
924
+ # The carousel-caption capability also covers full-frame image sequences.
925
+ # Its declared sequence defaults select that payload shape; do not send
926
+ # carousel entrance/exit or a typewriter caption block to such components.
927
+ if sequence_mode:
928
+ sequence_config = defaults["sequence"]
929
+ sequence_config["items"] = [
930
+ {"assetRef": asset_id,
931
+ **({"caption": caption_lines[index]} if index < len(caption_lines) else {})}
932
+ for index, (asset_id, _) in enumerate(carousel_asset_ids)
933
+ ]
934
+ custom_payload = {"sequence": sequence_config}
935
+
857
936
  # ── Narration skeleton ────────────────────────────────────────────────
858
937
  narration_text = _localize_label(output_language, "narration_opening",
859
938
  label="", topic=topic, duration=duration)
@@ -863,6 +942,10 @@ def _build_carousel_caption_dsl(
863
942
  # 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长,
864
943
  # 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
865
944
  effective_duration = duration
945
+ if duration_strategy == "fit-images":
946
+ if not sequence_mode:
947
+ raise ValueError("fit-images requires capabilities.payloadDefaults.sequence")
948
+ effective_duration = _estimate_fit_images_duration(custom_payload["sequence"], duration_model)
866
949
  if duration_strategy == "fit-caption":
867
950
  est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
868
951
  if est > 0:
@@ -982,6 +1065,61 @@ def _build_custom_payload(template_config: dict | None) -> dict:
982
1065
  return {}
983
1066
 
984
1067
 
1068
+ def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
1069
+ """``sceneStrategy=countdown``:一个开场 + N 个同类节拍,倒数编号,没有 cta。
1070
+
1071
+ 为什么不能复用 ``arc``:``plan_scenes`` 写死了「至少 3 个场景」(Top 1 只需要 2 个)、
1072
+ 无条件在末尾 append 一个 ``cta``(榜单类模板往往不要片尾),而且 ``content_scenes
1073
+ = scene_count - 2``、编号从 #1 升序 —— 倒数要的正好相反。``fixed`` 则要一份静态
1074
+ 场景表,表达不了动态的 N。所以 countdown 是 sceneStrategy 轴上的第四种形状,
1075
+ 而不是某个模板的特例。
1076
+ """
1077
+ head_sec = countdown_opening_sec(spec, count, duration)
1078
+ beat_sec = (duration - head_sec) / count
1079
+ return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
1080
+ {"purpose": "point", "duration": beat_sec, "label": f"#{index}", "countdownIndex": index}
1081
+ for index in range(count, 0, -1)
1082
+ ]
1083
+
1084
+
1085
+ def _warn_on_count_mismatch(headline: str, subheadline: str, count: int) -> None:
1086
+ """画面文字里写的数量与实际节拍数不一致时出声提醒(不阻断、不改写)。
1087
+
1088
+ **刻意只 warn 不改写。** 这里曾经有一对 ``_rank_copy`` / ``_rank_title``,是
1089
+ github-repo-rank 的 ``payload.ts`` 里 ``formatRankCopy`` / ``formatRankTitle``
1090
+ 的逐行 Python 移植 —— 同样的三个正则,在两个仓库、两种语言里各存一份。那是本文件
1091
+ 在 ``_echo_template_llm_hint`` 里写明要避免的事:**CLI 出机制,模板出内容**。标题
1092
+ 该怎么写、数量该以什么措辞出现(``Top 5`` / ``前 5 名`` / ``5 个项目``)是模板的
1093
+ 版式事实,真源是模板自己。
1094
+
1095
+ 去掉那份移植不会让成片出错:模板在渲染时本来就会同步(github-repo-rank 在
1096
+ ``Opening.tsx`` / ``Cover.tsx`` 里做),而且它**必须**保留这个能力 —— 手写 DSL、
1097
+ 历史计划、ab-web 改过的标题都不经过 gen_script。CLI 这一份自始至终只影响用户在
1098
+ 确认脚本那一步看到的文字,为它复制一套会漂移的正则不值得。
1099
+
1100
+ 换成告警的另一个好处:静默改写调用方给的文案本身就不妥(同 ``_warn_on_url_text_layer``
1101
+ 的分寸)。把数量告诉它,让它自己把文案写对,比替它改更不容易出意外。
1102
+
1103
+ 这里认的措辞(``Top N`` / ``N 个项目`` / ``前 N 名``)只用来**发现不一致**,
1104
+ 不用来生成文案,所以它不构成一份需要跟模板同步的版式规则。
1105
+ """
1106
+ pattern = r"(?<![a-zA-Z0-9_])top\s*([0-9]+)(?![a-zA-Z0-9_])|(?:^|[^0-9])([0-9]+)\s*(?:个(?=\s*(?:开源项目|项目|仓库|工具))|(?=\s*名))"
1107
+ for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
1108
+ stated = {int(g) for match in re.finditer(pattern, value or "", flags=re.I)
1109
+ for g in match.groups() if g is not None}
1110
+ if stated and count not in stated:
1111
+ print(
1112
+ f"⚠️ {flag} says {sorted(stated)} item(s) but --item-count is {count}. "
1113
+ "The rendered beat count follows --item-count, so the on-screen text is the "
1114
+ "part that is wrong.\n"
1115
+ " Rewrite it to state the count you asked for (or leave the count out "
1116
+ "and let the template append it). gen_script deliberately does not "
1117
+ "rewrite your copy: how this template words a count is declared by the "
1118
+ "template, not by the CLI.",
1119
+ file=sys.stderr,
1120
+ )
1121
+
1122
+
985
1123
  def build_dsl(
986
1124
  topic: str,
987
1125
  duration: int,
@@ -1003,6 +1141,7 @@ def build_dsl(
1003
1141
  carousel_items: list[str] | None = None,
1004
1142
  caption_lines: list[str] | None = None,
1005
1143
  scene_images: list[str] | None = None,
1144
+ item_count: int | None = None,
1006
1145
  ) -> dict:
1007
1146
  """Build a complete Video DSL JSON.
1008
1147
 
@@ -1034,6 +1173,12 @@ def build_dsl(
1034
1173
  # Load template config(registry 单一数据源)→ 归一化 contract
1035
1174
  template_config = _load_template_config(template_id) if template_id else None
1036
1175
  contract = _resolve_contract(template_config)
1176
+ countdown = contract["countdown"]
1177
+ if countdown is None and item_count is not None:
1178
+ raise ValueError(
1179
+ "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
1180
+ )
1181
+ beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
1037
1182
 
1038
1183
  primary_visual_type = contract["primary_visual_type"]
1039
1184
  payload_style = contract["payload_style"]
@@ -1061,6 +1206,8 @@ def build_dsl(
1061
1206
  effective_subheadline = _normalize_linebreaks(subheadline)
1062
1207
  _echo_template_llm_hint(template_config, template_id)
1063
1208
  _warn_on_url_text_layer(effective_headline, effective_subheadline)
1209
+ if beat_count is not None:
1210
+ _warn_on_count_mismatch(effective_headline, effective_subheadline, beat_count)
1064
1211
 
1065
1212
  # ── carousel-caption 模板(单场景、图/视频轮播 + 打字机)单独装配并直接返回 ──
1066
1213
  if payload_style == "carousel-caption":
@@ -1143,7 +1290,10 @@ def build_dsl(
1143
1290
  # 就是它,AI 段能插在任意位置,而不是只能挂在末尾。
1144
1291
  provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
1145
1292
 
1146
- scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1293
+ if beat_count is not None:
1294
+ scene_plans = _plan_countdown_scenes(countdown, beat_count, duration, effective_headline)
1295
+ else:
1296
+ scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1147
1297
 
1148
1298
  # 条目比场景多时**抬高**场景数,让每一条都有地方放。
1149
1299
  #
@@ -1384,6 +1534,12 @@ def build_dsl(
1384
1534
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
1385
1535
 
1386
1536
  custom_payload = _build_custom_payload(template_config)
1537
+ if beat_count is not None:
1538
+ # Field names come from the template's countdown declaration — this
1539
+ # function does not know what a "rank" or a "topN" is.
1540
+ custom_payload.update(
1541
+ countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
1542
+ )
1387
1543
 
1388
1544
  scene = {
1389
1545
  "id": scene_id,
@@ -1459,6 +1615,7 @@ Examples:
1459
1615
  parser.add_argument("--style", default="", help="Style tag")
1460
1616
  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
1617
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1618
+ 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
1619
  parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1463
1620
  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
1621
  parser.add_argument(
@@ -1522,7 +1679,8 @@ Examples:
1522
1679
  "--carousel-items url1 --carousel-items url2. "
1523
1680
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
1524
1681
  "these URLs are placed directly into "
1525
- "customPayload.carousel.items as existing assets, bypassing AI image generation entirely.",
1682
+ "customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise "
1683
+ "customPayload.carousel.items, as existing assets without AI image generation.",
1526
1684
  )
1527
1685
  parser.add_argument(
1528
1686
  "--scene-images",
@@ -1545,7 +1703,8 @@ Examples:
1545
1703
  help="On-screen caption line. Can be repeated: "
1546
1704
  "--caption-lines 'line1' --caption-lines 'line2'. "
1547
1705
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
1548
- "these are placed into customPayload.caption.lines. "
1706
+ "these are placed into customPayload.caption.lines, or sequence.items[].caption "
1707
+ "in image order when payloadDefaults.sequence is declared. "
1549
1708
  "Line count limits and whether **emphasis** is parsed are declared per "
1550
1709
  "template — see the chosen template's llmHint.",
1551
1710
  )
@@ -1649,28 +1808,35 @@ Examples:
1649
1808
  file=sys.stderr,
1650
1809
  )
1651
1810
 
1652
- dsl = build_dsl(
1653
- topic=args.topic,
1654
- duration=args.duration,
1655
- style=args.style,
1656
- ratio=args.ratio,
1657
- resolution=args.resolution,
1658
- voice_id=args.voice_id,
1659
- speed=args.speed,
1660
- font_id=args.font_id,
1661
- font_name=args.font_name,
1662
- scene_count=args.scenes,
1663
- allow_digital_human=args.allow_digital_human,
1664
- allow_ai_video=args.allow_ai_video,
1665
- template_id=args.template_id,
1666
- stub_image_url=stub_image_url,
1667
- stub_video_url=stub_video_url,
1668
- headline=args.headline,
1669
- subheadline=args.subheadline,
1670
- carousel_items=args.carousel_items,
1671
- caption_lines=args.caption_lines,
1672
- scene_images=args.scene_images,
1673
- )
1811
+ try:
1812
+ dsl = build_dsl(
1813
+ topic=args.topic,
1814
+ duration=args.duration,
1815
+ style=args.style,
1816
+ ratio=args.ratio,
1817
+ resolution=args.resolution,
1818
+ voice_id=args.voice_id,
1819
+ speed=args.speed,
1820
+ font_id=args.font_id,
1821
+ font_name=args.font_name,
1822
+ scene_count=args.scenes,
1823
+ allow_digital_human=args.allow_digital_human,
1824
+ allow_ai_video=args.allow_ai_video,
1825
+ template_id=args.template_id,
1826
+ stub_image_url=stub_image_url,
1827
+ stub_video_url=stub_video_url,
1828
+ headline=args.headline,
1829
+ subheadline=args.subheadline,
1830
+ carousel_items=args.carousel_items,
1831
+ caption_lines=args.caption_lines,
1832
+ scene_images=args.scene_images,
1833
+ item_count=args.item_count,
1834
+ )
1835
+ except ValueError as exc:
1836
+ # Covers both a bad --item-count and an unusable countdown declaration
1837
+ # (CountdownError subclasses ValueError), so an authoring mistake surfaces
1838
+ # as a message instead of a traceback.
1839
+ parser.error(str(exc))
1674
1840
 
1675
1841
  # Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
1676
1842
  # assets to be already-generated stubs. Image / video are already covered by the
@@ -61,7 +61,7 @@
61
61
  "items": {
62
62
  "type": "string"
63
63
  },
64
- "description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
64
+ "description": "Media URLs for the template's image/video carousel. When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed into customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise customPayload.carousel.items, as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for such a template. Each element is a full URL string."
65
65
  },
66
66
  "scene_images": {
67
67
  "type": "array",
@@ -75,7 +75,7 @@
75
75
  "items": {
76
76
  "type": "string"
77
77
  },
78
- "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). Each element is one line. Line-count limits and whether **emphasis** is parsed are declared by the template — read its llmHint, which gen_script prints in full on stderr. **Must** pass when the user explicitly provides bullet-point text / bottom copy. **Also must pass — written by you — when the user did NOT provide any copy but the template is typewriter-driven** (capabilities.durationStrategy=fit-caption): such templates have no narration, so these lines are both the video's content and the thing that decides its duration. Draft them from the material you researched (repo README, page screenshots, the topic). Leaving this empty for a fit-caption template is rejected: nothing auto-generates caption text, and an empty caption renders a titled carousel with no words at all."
78
+ "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). For image sequences (payloadDefaults.sequence), lines map in order to sequence.items[].caption, with at most one line per image. Otherwise each element is one line. Line-count limits and whether **emphasis** is parsed are declared by the template — read its llmHint, which gen_script prints in full on stderr. **Must** pass when the user explicitly provides bullet-point text / bottom copy. **Also must pass — written by you — when the user did NOT provide any copy but the template is typewriter-driven** (capabilities.durationStrategy=fit-caption): such templates have no narration, so these lines are both the video's content and the thing that decides its duration. Draft them from the material you researched (repo README, page screenshots, the topic). Leaving this empty for a fit-caption template is rejected: nothing auto-generates caption text, and an empty caption renders a titled carousel with no words at all."
79
79
  },
80
80
  "stub_image_url": {
81
81
  "type": "string",
@@ -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": "V12",
5
+ "version": "V14",
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,28 @@ 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
+
2366
+ from video_dsl.runtime.payload_contract import plan_asset_errors
2367
+ reference_errors = plan_asset_errors(render_plan)
2368
+ if reference_errors:
2369
+ LogPrint("❌ RenderPlan asset references are invalid:", file=sys.stderr)
2370
+ for error in reference_errors[:10]:
2371
+ LogPrint(f" - {error}", file=sys.stderr)
2372
+ sys.exit(2)
2373
+
2342
2374
  if not args.skip_asset_resolve:
2343
2375
  # Stub mode: short-circuit gen-image / gen-video before counting pending.
2344
2376
  # CLI flag takes precedence; env vars act as fallback with a visible warning
@@ -0,0 +1,169 @@
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
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry"))
21
+ from video_dsl.runtime.payload_contract import required_payload_errors # noqa: E402
22
+
23
+
24
+ def _payload_prop(props: dict, key: str):
25
+ seen: set[int] = set()
26
+ cursor = props
27
+ for _ in range(9):
28
+ if not isinstance(cursor, dict) or id(cursor) in seen:
29
+ break
30
+ seen.add(id(cursor))
31
+ if cursor.get(key) is not None:
32
+ return cursor[key]
33
+ cursor = cursor.get("templateData")
34
+ return None
35
+
36
+
37
+ def _required_value_errors(value, schema: dict, path: str) -> list[str]:
38
+ """Check required values only, using the template's declared shape and bounds.
39
+
40
+ This is not a full JSON Schema validator: optional fields and undeclared
41
+ extensions are left to the existing DSL validators and template runtime.
42
+ """
43
+ if value is None or (isinstance(value, str) and not value.strip()):
44
+ return [path]
45
+ kind = schema.get("type")
46
+ number = type(value) in (int, float) and (not isinstance(value, float) or math.isfinite(value))
47
+ matches = {
48
+ "object": isinstance(value, dict), "array": isinstance(value, list),
49
+ "string": isinstance(value, str), "boolean": isinstance(value, bool),
50
+ "number": number,
51
+ "integer": number and (isinstance(value, int) or value.is_integer()),
52
+ }
53
+ if kind in matches and not matches[kind]:
54
+ return [f"{path} (expected {kind})"]
55
+ if number and (kind in ("number", "integer")):
56
+ if "minimum" in schema and value < schema["minimum"]:
57
+ return [f"{path} (below minimum)"]
58
+ if "maximum" in schema and value > schema["maximum"]:
59
+ return [f"{path} (above maximum)"]
60
+ if isinstance(value, dict) and kind == "object":
61
+ properties = schema.get("properties") or {}
62
+ return [error for key in schema.get("required", [])
63
+ for error in _required_value_errors(value.get(key), properties.get(key, {}), f"{path}.{key}")]
64
+ if isinstance(value, list) and kind == "array" and len(value) < schema.get("minItems", 0):
65
+ return [f"{path} (too few items)"]
66
+ return []
67
+
68
+
69
+ def validate_required_props(template: dict, rows: list[dict]) -> None:
70
+ """Raise with scene IDs and field names only; never include scene content."""
71
+ required_by_composition: dict[str, set[str]] = {}
72
+ # References reuse a concrete slot's composition, so only concrete slots
73
+ # contribute requirements here. Union if multiple slots share a component.
74
+ for slot in (template.get("slotMapping") or {}).values():
75
+ if not isinstance(slot, dict) or not slot.get("compositionId"):
76
+ continue
77
+ required_by_composition.setdefault(slot["compositionId"], set()).update(
78
+ slot.get("requiredProps") or []
79
+ )
80
+
81
+ failures = []
82
+ for row in rows:
83
+ required = required_by_composition.get(row.get("compositionId"), set())
84
+ required = required | set(row.get("missingRequiredProps") or [])
85
+ props = row.get("props") or {}
86
+ schema = template.get("customPayloadSchema") or {}
87
+ missing = [error for key in sorted(required)
88
+ for error in _required_value_errors(_payload_prop(props, key), schema.get(key, {}), key)]
89
+ missing += required_payload_errors(template, props)
90
+ if missing:
91
+ failures.append(f"scene {row.get('sceneId', '?')}: {', '.join(missing)}")
92
+
93
+ if failures:
94
+ raise ValueError(
95
+ "Missing or invalid required template props: " + "; ".join(failures[:10])
96
+ + (f"; {len(failures)} scenes total" if len(failures) > 10 else "")
97
+ + ". Fill each scene's customPayload according to the template schema "
98
+ "and rebuild the RenderPlan. Narration or topic text does not populate card data."
99
+ )
100
+
101
+ _validate_countdown(template, rows)
102
+
103
+
104
+ def _index_of(row: dict, spec: dict):
105
+ """A beat's countdown index, or None when the row carries no usable item.
106
+
107
+ Returning None rather than indexing straight into the payload keeps a scene
108
+ with a missing/malformed item on the countdown error path (which names the
109
+ scene and says what to fix) instead of an AttributeError from deep inside the
110
+ validator. Reachable whenever a countdown template's point slot does not list
111
+ its item prop in requiredProps, so the earlier per-scene check lets it through.
112
+ """
113
+ item = _payload_prop(row.get("props") or {}, spec["item_prop"])
114
+ return item.get(spec["index_prop"]) if isinstance(item, dict) else None
115
+
116
+
117
+ def _validate_countdown(template: dict, rows: list[dict]) -> None:
118
+ """Check a countdown plan's shape before anything is paid for.
119
+
120
+ Every prop name here comes from the template's own `capabilities.countdown`
121
+ declaration, so this function holds no knowledge of ranking, GitHub, or any
122
+ template id. That matters beyond tidiness: the names used to be hardcoded,
123
+ which meant a second countdown template calling its count `itemCount` would
124
+ find no counts at all, fall back to "however many beats are present", and
125
+ reduce the scene-count check to comparing that number against itself —
126
+ validation silently gone, with nothing on screen or in the logs to say so.
127
+ """
128
+ spec = resolve_countdown_spec(template)
129
+ if spec is None:
130
+ return
131
+ slots = template.get("slotMapping") or {}
132
+ point_id = (slots.get("point") or {}).get("compositionId")
133
+ opening_id = (slots.get("opening") or {}).get("compositionId")
134
+ ending_id = (slots.get("ending") or {}).get("compositionId")
135
+ points = [row for row in rows if row.get("compositionId") == point_id]
136
+ count_props = [spec["count_prop"], *spec["legacy_count_props"]]
137
+ # Keep old plans usable: infer their count when no explicit count was saved.
138
+ declared = []
139
+ for row in rows:
140
+ props = row.get("props") or {}
141
+ count = next((value for value in (_payload_prop(props, p) for p in count_props)
142
+ if value is not None), None)
143
+ if count is not None:
144
+ if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
145
+ raise ValueError(
146
+ f"Invalid countdown {spec['count_prop']}: expected an integer from "
147
+ f"{spec['minimum']} to {spec['maximum']}"
148
+ )
149
+ declared.append(count)
150
+ count = declared[0] if declared else len(points)
151
+ if any(value != count for value in declared):
152
+ raise ValueError(f"Countdown {spec['count_prop']} must be consistent across all scenes")
153
+ if not spec["minimum"] <= count <= spec["maximum"] or len(points) != count:
154
+ raise ValueError(
155
+ f"Countdown scene count does not match {spec['count_prop']}; rebuild one opening "
156
+ "plus N beats"
157
+ )
158
+ indexes = [_index_of(row, spec) for row in points]
159
+ if indexes != list(range(count, 0, -1)):
160
+ raise ValueError(
161
+ f"Countdown scenes must count down from {count} to 1 in "
162
+ f"{spec['item_prop']}.{spec['index_prop']} without gaps or duplicates"
163
+ )
164
+ if not rows or rows[0].get("compositionId") != opening_id or sum(row.get("compositionId") == opening_id for row in rows) != 1:
165
+ raise ValueError("Countdown requires exactly one opening before the beats")
166
+ # A separate ending may hold the final beat, but cannot add another index.
167
+ endings = [row for row in rows if row.get("compositionId") == ending_id]
168
+ if len(endings) > 1 or (endings and (rows[-1] is not endings[0] or _index_of(endings[0], spec) != 1)):
169
+ 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": "V21",
5
+ "version": "V24",
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": "V14",
5
+ "version": "V16",
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
  }
@@ -300,29 +300,17 @@ def _check_asset_enums(dsl: dict) -> list[ValidationError]:
300
300
  def _check_scene_asset_refs(dsl: dict) -> list[ValidationError]:
301
301
  """render-video 历史:scene 引用的 assetRef 必须在 assets[] 声明过。
302
302
 
303
- 检查范围:
304
- - scene.audio.narration.assetRef
305
- - scene.visuals.background.assetRef
303
+ 包括 customPayload / templateData 中的嵌套 assetRef 与 global 引用。
306
304
  """
307
305
  errors: list[ValidationError] = []
308
306
  declared = {
309
307
  a.get("assetId") for a in (dsl.get("assets") or []) if a.get("assetId")
310
308
  }
311
- for idx, scene in enumerate(dsl.get("scenes", []) or []):
312
- sid = scene.get("id", f"scenes[{idx}]")
313
- nar = (scene.get("audio") or {}).get("narration") or {}
314
- nar_ref = nar.get("assetRef")
315
- if nar_ref and nar_ref not in declared:
316
- errors.append(ValidationError(
317
- f"scenes[{idx}].audio.narration.assetRef",
318
- f"scene '{sid}': narration.assetRef '{nar_ref}' not found in assets[]",
319
- ))
320
- bg_ref = ((scene.get("visuals") or {}).get("background") or {}).get("assetRef")
321
- if bg_ref and bg_ref not in declared:
322
- errors.append(ValidationError(
323
- f"scenes[{idx}].visuals.background.assetRef",
324
- f"scene '{sid}': visuals.background.assetRef '{bg_ref}' not found in assets[]",
325
- ))
309
+ from .payload_contract import asset_references
310
+ for key in ("scenes", "global"):
311
+ for path, ref in asset_references(dsl.get(key), key):
312
+ if ref not in declared:
313
+ errors.append(ValidationError(path, "assetRef not found in assets[]"))
326
314
  return errors
327
315
 
328
316
 
@@ -742,14 +730,19 @@ def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
742
730
  tpl = _get_template_cfg(_pick_template_id(dsl))
743
731
  if not tpl:
744
732
  return []
733
+ from .payload_contract import required_payload_errors
734
+ errors: list[ValidationError] = []
735
+ for idx, scene in enumerate(dsl.get("scenes", []) or []):
736
+ custom = scene.get("customPayload") or {"templateData": scene.get("templateData")}
737
+ errors += [ValidationError(f"scenes[{idx}].customPayload", message)
738
+ for message in required_payload_errors(tpl, custom)]
745
739
  if isinstance(tpl.get("slideSchemas"), dict):
746
- return [] # 多版式模板走 _check_slide_payload_fields
740
+ return errors # 多版式字段走 _check_slide_payload_fields
747
741
  cps = tpl.get("customPayloadSchema")
748
742
  if not isinstance(cps, dict) or not cps:
749
- return []
743
+ return errors
750
744
 
751
745
  root = {"type": "object", "properties": cps}
752
- errors: list[ValidationError] = []
753
746
  for idx, scene in enumerate(dsl.get("scenes", []) or []):
754
747
  custom = scene.get("customPayload")
755
748
  if not isinstance(custom, dict) or not custom:
@@ -1054,4 +1047,3 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1054
1047
  errors += _check_slide_cross_field(dsl)
1055
1048
  return errors
1056
1049
 
1057
-
@@ -0,0 +1,82 @@
1
+ """Template-declared content requirements and recursive asset references.
2
+
3
+ Shared by DSL preparation and saved RenderPlan validation. Requirements use a
4
+ small JSON Schema subset; values never appear in diagnostic messages.
5
+ """
6
+
7
+
8
+ def payload_values(props: dict) -> dict:
9
+ """Flatten templateData wrappers, with non-null outer props taking precedence."""
10
+ layers, seen = [], set()
11
+ while isinstance(props, dict) and id(props) not in seen and len(layers) < 10:
12
+ layers.append(props)
13
+ seen.add(id(props))
14
+ props = props.get("templateData")
15
+ result = {}
16
+ for layer in reversed(layers):
17
+ result.update({k: v for k, v in layer.items() if k != "templateData" and v is not None})
18
+ return result
19
+
20
+
21
+ def requirement_errors(value, schema: dict, path: str = "customPayload") -> list[str]:
22
+ """Enforce type/const/enum/required/properties/items/minItems/anyOf."""
23
+ errors = []
24
+ kind = schema.get("type")
25
+ matches = {"object": isinstance(value, dict), "array": isinstance(value, list),
26
+ "string": isinstance(value, str)}
27
+ if kind in matches and not matches[kind]:
28
+ return [f"{path} (expected {kind})"]
29
+ if "const" in schema and value != schema["const"]:
30
+ return [f"{path} (unexpected value)"]
31
+ if "enum" in schema and value not in schema["enum"]:
32
+ return [f"{path} (unexpected value)"]
33
+ if isinstance(value, dict):
34
+ for key in schema.get("required", []):
35
+ if value.get(key) is None or value.get(key) == "":
36
+ errors.append(f"{path}.{key} (required)")
37
+ for key, child in schema.get("properties", {}).items():
38
+ if key in value:
39
+ errors += requirement_errors(value[key], child, f"{path}.{key}")
40
+ if isinstance(value, list):
41
+ if len(value) < schema.get("minItems", 0):
42
+ errors.append(f"{path} (too few items)")
43
+ if "items" in schema:
44
+ for index, item in enumerate(value):
45
+ errors += requirement_errors(item, schema["items"], f"{path}[{index}]")
46
+ if "anyOf" in schema:
47
+ branches = [requirement_errors(value, branch, path) for branch in schema["anyOf"]]
48
+ if not branches or all(branches):
49
+ alternatives = " OR ".join("; ".join(branch) for branch in branches)
50
+ errors.append(f"{path} must satisfy one content alternative: {alternatives}")
51
+ return errors
52
+
53
+
54
+ def required_payload_errors(template: dict, props: dict) -> list[str]:
55
+ caps = template.get("capabilities")
56
+ schema = caps.get("payloadRequirements") if isinstance(caps, dict) else None
57
+ return requirement_errors(payload_values(props), schema) if isinstance(schema, dict) else []
58
+
59
+
60
+ def asset_references(value, path: str = "", *, bound_props: bool = False):
61
+ """Walk nested assetRef fields; bound props also use *AssetId names."""
62
+ if isinstance(value, list):
63
+ for index, item in enumerate(value):
64
+ yield from asset_references(item, f"{path}[{index}]", bound_props=bound_props)
65
+ elif isinstance(value, dict):
66
+ for key, child in value.items():
67
+ child_path = f"{path}.{key}" if path else key
68
+ if key == "assetRef" or (bound_props and key.endswith("AssetId")):
69
+ if isinstance(child, str) and child.strip():
70
+ yield child_path, child
71
+ else:
72
+ yield from asset_references(child, child_path, bound_props=bound_props)
73
+
74
+
75
+ def plan_asset_errors(plan: dict) -> list[str]:
76
+ declared = {asset.get("assetId") for asset in plan.get("resolvedAssets", []) if isinstance(asset, dict)}
77
+ errors = []
78
+ for index, row in enumerate(plan.get("timeline", [])):
79
+ for path, ref in asset_references(row.get("props", {}), f"timeline[{index}].props", bound_props=True):
80
+ if ref not in declared:
81
+ errors.append(f"{path} references an asset missing from resolvedAssets[]")
82
+ return errors