@remixmate/cli 0.9.39 → 0.9.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.40",
4
+ "generatedAt": "2026-09-22T08:04:46.896Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -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.40",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,13 +28,16 @@
28
28
  "test:narration-speed": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-speed.py",
29
29
  "test:narration-tts": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-narration-tts.py",
30
30
  "test:render-resume": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-resume.py",
31
+ "test:required-props": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-required-props.py",
31
32
  "test:render-plan": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py",
32
33
  "test:render-plan:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-render-plan-snapshot.py --update",
33
34
  "test:contracts": "PYTHONDONTWRITEBYTECODE=1 python3 skills/template-registry/scripts/check_contracts.py",
34
35
  "regress": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/regress.py",
35
36
  "regress:update": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/regress.py --update",
36
37
  "check:render": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/render-template-check.py",
37
- "clean": "node scripts/clean.mjs"
38
+ "clean": "node scripts/clean.mjs",
39
+ "test:countdown": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-countdown-strategy.py",
40
+ "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
38
41
  },
39
42
  "keywords": [
40
43
  "claude-code",
@@ -17,6 +17,7 @@ Environment variables:
17
17
 
18
18
  import argparse
19
19
  import json
20
+ import re
20
21
  import math
21
22
  import os
22
23
  import sys
@@ -41,6 +42,21 @@ _SHARED_SCRIPTS_DIR = os.path.join(
41
42
  if os.path.isdir(_SHARED_SCRIPTS_DIR):
42
43
  sys.path.insert(0, os.path.abspath(_SHARED_SCRIPTS_DIR))
43
44
 
45
+ # countdown 策略的声明解析。与下面几个共享依赖同样守护式导入:本文件全篇假定
46
+ # 共享目录**可能**不在(见 _SHARED_SCRIPTS_DIR 的 isdir 判断),硬导入会让共享目录
47
+ # 缺失时连非 countdown 模板一起起不来。缺失只影响 countdown 这一条路,并在真的
48
+ # 用到时明确报错。
49
+ try:
50
+ from countdown_spec import ( # noqa: E402 (needs the sys.path setup above)
51
+ opening_sec as countdown_opening_sec,
52
+ resolve_count as resolve_countdown_count,
53
+ resolve_countdown_spec,
54
+ scene_payload as countdown_scene_payload,
55
+ )
56
+ _HAS_COUNTDOWN = True
57
+ except ImportError:
58
+ _HAS_COUNTDOWN = False
59
+
44
60
  try:
45
61
  from video_dsl.runtime.prompt_enhancer import build_enhanced_prompt
46
62
  _HAS_ENHANCER = True
@@ -459,8 +475,10 @@ def _template_needs_image(template_config: dict | None) -> bool:
459
475
  #
460
476
  # 归一化 contract 字段(全部可缺省,缺省值保证"未声明能力"的模板走通用图文旁白):
461
477
  # - needs_narration bool capabilities.needsNarration,默认 True
462
- # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed},默认 arc
478
+ # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed, countdown},默认 arc
463
479
  # - fixed_scenes list[dict] capabilities.fixedScenes(single/fixed 用;缺省单 opening)
480
+ # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
481
+ # 非 countdown 模板为 None)
464
482
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
465
483
  # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
466
484
  # - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
@@ -469,6 +487,20 @@ def _template_needs_image(template_config: dict | None) -> bool:
469
487
  # - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导
470
488
 
471
489
 
490
+ def _resolve_countdown(template_config: dict | None) -> dict:
491
+ """countdown 声明解析;共享模块缺失时明确报错而不是静默换一种形状。
492
+
493
+ 退回 ``arc`` 会是最坏的处理:一个倒数模板会拿到「至少 3 段 + 末尾 cta + 升序编号」
494
+ 的场景表,既没有数量也没有倒数索引,然后一路通过到渲染。
495
+ """
496
+ if not _HAS_COUNTDOWN:
497
+ raise ValueError(
498
+ "capabilities.sceneStrategy=countdown needs the shared countdown_spec module "
499
+ "from skills/template-registry/scripts/, which is not importable here."
500
+ )
501
+ return resolve_countdown_spec(template_config)
502
+
503
+
472
504
  def _resolve_contract(template_config: dict | None) -> dict:
473
505
  """把 template.json 的 capabilities 归一化成一份装配 contract。
474
506
 
@@ -482,7 +514,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
482
514
  needs_narration = caps.get("needsNarration", True) is not False
483
515
 
484
516
  scene_strategy = caps.get("sceneStrategy")
485
- if scene_strategy not in ("arc", "single", "fixed"):
517
+ if scene_strategy not in ("arc", "single", "fixed", "countdown"):
486
518
  scene_strategy = "arc"
487
519
 
488
520
  primary_visual_type = _template_primary_visual_type(template_config)
@@ -507,6 +539,9 @@ def _resolve_contract(template_config: dict | None) -> dict:
507
539
  "needs_narration": needs_narration,
508
540
  "scene_strategy": scene_strategy,
509
541
  "fixed_scenes": fixed_scenes,
542
+ # 声明解析放共享模块:render-video 的计划校验要读同一份声明,
543
+ # 两边各抄一遍正是这个功能上一版栽过的跟头。
544
+ "countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
510
545
  "payload_style": payload_style,
511
546
  "duration_strategy": duration_strategy,
512
547
  "duration_model": caps.get("durationModel"),
@@ -982,6 +1017,61 @@ def _build_custom_payload(template_config: dict | None) -> dict:
982
1017
  return {}
983
1018
 
984
1019
 
1020
+ def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
1021
+ """``sceneStrategy=countdown``:一个开场 + N 个同类节拍,倒数编号,没有 cta。
1022
+
1023
+ 为什么不能复用 ``arc``:``plan_scenes`` 写死了「至少 3 个场景」(Top 1 只需要 2 个)、
1024
+ 无条件在末尾 append 一个 ``cta``(榜单类模板往往不要片尾),而且 ``content_scenes
1025
+ = scene_count - 2``、编号从 #1 升序 —— 倒数要的正好相反。``fixed`` 则要一份静态
1026
+ 场景表,表达不了动态的 N。所以 countdown 是 sceneStrategy 轴上的第四种形状,
1027
+ 而不是某个模板的特例。
1028
+ """
1029
+ head_sec = countdown_opening_sec(spec, count, duration)
1030
+ beat_sec = (duration - head_sec) / count
1031
+ return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
1032
+ {"purpose": "point", "duration": beat_sec, "label": f"#{index}", "countdownIndex": index}
1033
+ for index in range(count, 0, -1)
1034
+ ]
1035
+
1036
+
1037
+ def _warn_on_count_mismatch(headline: str, subheadline: str, count: int) -> None:
1038
+ """画面文字里写的数量与实际节拍数不一致时出声提醒(不阻断、不改写)。
1039
+
1040
+ **刻意只 warn 不改写。** 这里曾经有一对 ``_rank_copy`` / ``_rank_title``,是
1041
+ github-repo-rank 的 ``payload.ts`` 里 ``formatRankCopy`` / ``formatRankTitle``
1042
+ 的逐行 Python 移植 —— 同样的三个正则,在两个仓库、两种语言里各存一份。那是本文件
1043
+ 在 ``_echo_template_llm_hint`` 里写明要避免的事:**CLI 出机制,模板出内容**。标题
1044
+ 该怎么写、数量该以什么措辞出现(``Top 5`` / ``前 5 名`` / ``5 个项目``)是模板的
1045
+ 版式事实,真源是模板自己。
1046
+
1047
+ 去掉那份移植不会让成片出错:模板在渲染时本来就会同步(github-repo-rank 在
1048
+ ``Opening.tsx`` / ``Cover.tsx`` 里做),而且它**必须**保留这个能力 —— 手写 DSL、
1049
+ 历史计划、ab-web 改过的标题都不经过 gen_script。CLI 这一份自始至终只影响用户在
1050
+ 确认脚本那一步看到的文字,为它复制一套会漂移的正则不值得。
1051
+
1052
+ 换成告警的另一个好处:静默改写调用方给的文案本身就不妥(同 ``_warn_on_url_text_layer``
1053
+ 的分寸)。把数量告诉它,让它自己把文案写对,比替它改更不容易出意外。
1054
+
1055
+ 这里认的措辞(``Top N`` / ``N 个项目`` / ``前 N 名``)只用来**发现不一致**,
1056
+ 不用来生成文案,所以它不构成一份需要跟模板同步的版式规则。
1057
+ """
1058
+ pattern = r"(?<![a-zA-Z0-9_])top\s*([0-9]+)(?![a-zA-Z0-9_])|(?:^|[^0-9])([0-9]+)\s*(?:个(?=\s*(?:开源项目|项目|仓库|工具))|(?=\s*名))"
1059
+ for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
1060
+ stated = {int(g) for match in re.finditer(pattern, value or "", flags=re.I)
1061
+ for g in match.groups() if g is not None}
1062
+ if stated and count not in stated:
1063
+ print(
1064
+ f"⚠️ {flag} says {sorted(stated)} item(s) but --item-count is {count}. "
1065
+ "The rendered beat count follows --item-count, so the on-screen text is the "
1066
+ "part that is wrong.\n"
1067
+ " Rewrite it to state the count you asked for (or leave the count out "
1068
+ "and let the template append it). gen_script deliberately does not "
1069
+ "rewrite your copy: how this template words a count is declared by the "
1070
+ "template, not by the CLI.",
1071
+ file=sys.stderr,
1072
+ )
1073
+
1074
+
985
1075
  def build_dsl(
986
1076
  topic: str,
987
1077
  duration: int,
@@ -1003,6 +1093,7 @@ def build_dsl(
1003
1093
  carousel_items: list[str] | None = None,
1004
1094
  caption_lines: list[str] | None = None,
1005
1095
  scene_images: list[str] | None = None,
1096
+ item_count: int | None = None,
1006
1097
  ) -> dict:
1007
1098
  """Build a complete Video DSL JSON.
1008
1099
 
@@ -1034,6 +1125,12 @@ def build_dsl(
1034
1125
  # Load template config(registry 单一数据源)→ 归一化 contract
1035
1126
  template_config = _load_template_config(template_id) if template_id else None
1036
1127
  contract = _resolve_contract(template_config)
1128
+ countdown = contract["countdown"]
1129
+ if countdown is None and item_count is not None:
1130
+ raise ValueError(
1131
+ "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
1132
+ )
1133
+ beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
1037
1134
 
1038
1135
  primary_visual_type = contract["primary_visual_type"]
1039
1136
  payload_style = contract["payload_style"]
@@ -1061,6 +1158,8 @@ def build_dsl(
1061
1158
  effective_subheadline = _normalize_linebreaks(subheadline)
1062
1159
  _echo_template_llm_hint(template_config, template_id)
1063
1160
  _warn_on_url_text_layer(effective_headline, effective_subheadline)
1161
+ if beat_count is not None:
1162
+ _warn_on_count_mismatch(effective_headline, effective_subheadline, beat_count)
1064
1163
 
1065
1164
  # ── carousel-caption 模板(单场景、图/视频轮播 + 打字机)单独装配并直接返回 ──
1066
1165
  if payload_style == "carousel-caption":
@@ -1143,7 +1242,10 @@ def build_dsl(
1143
1242
  # 就是它,AI 段能插在任意位置,而不是只能挂在末尾。
1144
1243
  provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
1145
1244
 
1146
- scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1245
+ if beat_count is not None:
1246
+ scene_plans = _plan_countdown_scenes(countdown, beat_count, duration, effective_headline)
1247
+ else:
1248
+ scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1147
1249
 
1148
1250
  # 条目比场景多时**抬高**场景数,让每一条都有地方放。
1149
1251
  #
@@ -1384,6 +1486,12 @@ def build_dsl(
1384
1486
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
1385
1487
 
1386
1488
  custom_payload = _build_custom_payload(template_config)
1489
+ if beat_count is not None:
1490
+ # Field names come from the template's countdown declaration — this
1491
+ # function does not know what a "rank" or a "topN" is.
1492
+ custom_payload.update(
1493
+ countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
1494
+ )
1387
1495
 
1388
1496
  scene = {
1389
1497
  "id": scene_id,
@@ -1459,6 +1567,7 @@ Examples:
1459
1567
  parser.add_argument("--style", default="", help="Style tag")
1460
1568
  parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
1461
1569
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1570
+ parser.add_argument("--item-count", type=int, default=None, help="Number of content beats, for templates declaring capabilities.sceneStrategy=countdown (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. Bounds and the default are declared by the template — this flag has no range of its own. Pass the count the user asked for, NOT the total scene count; --scenes is the total and the two are cross-checked.")
1462
1571
  parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1463
1572
  parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices prints the live catalog.")
1464
1573
  parser.add_argument(
@@ -1649,28 +1758,35 @@ Examples:
1649
1758
  file=sys.stderr,
1650
1759
  )
1651
1760
 
1652
- 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
- )
1761
+ try:
1762
+ dsl = build_dsl(
1763
+ topic=args.topic,
1764
+ duration=args.duration,
1765
+ style=args.style,
1766
+ ratio=args.ratio,
1767
+ resolution=args.resolution,
1768
+ voice_id=args.voice_id,
1769
+ speed=args.speed,
1770
+ font_id=args.font_id,
1771
+ font_name=args.font_name,
1772
+ scene_count=args.scenes,
1773
+ allow_digital_human=args.allow_digital_human,
1774
+ allow_ai_video=args.allow_ai_video,
1775
+ template_id=args.template_id,
1776
+ stub_image_url=stub_image_url,
1777
+ stub_video_url=stub_video_url,
1778
+ headline=args.headline,
1779
+ subheadline=args.subheadline,
1780
+ carousel_items=args.carousel_items,
1781
+ caption_lines=args.caption_lines,
1782
+ scene_images=args.scene_images,
1783
+ item_count=args.item_count,
1784
+ )
1785
+ except ValueError as exc:
1786
+ # Covers both a bad --item-count and an unusable countdown declaration
1787
+ # (CountdownError subclasses ValueError), so an authoring mistake surfaces
1788
+ # as a message instead of a traceback.
1789
+ parser.error(str(exc))
1674
1790
 
1675
1791
  # Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
1676
1792
  # assets to be already-generated stubs. Image / video are already covered by the
@@ -88,6 +88,11 @@
88
88
  "skip_asset_generation": {
89
89
  "type": "boolean",
90
90
  "description": "All-in-one switch for downstream agents (e.g. template-creator) that only want the DSL shape: every produced AssetRef is marked as already generated with placeholder URLs (image: https://placeholder.local/stub.png, video: stub.mp4, audio: stub.mp3). Implies the equivalent of --stub-image-url + --stub-video-url with sentinel defaults plus the same rewrite for gen-voice / gen-digital-human assets. Useful when the agent only needs to inspect DSL structure or feed it into try_render_local with all assets pre-stubbed."
91
+ },
92
+ "item_count": {
93
+ "type": "integer",
94
+ "minimum": 1,
95
+ "description": "Number of content beats, for templates declaring capabilities.sceneStrategy=countdown — the '一个开场 + N 个同类节拍,倒数编号' shape (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. The accepted range and the default are declared by the chosen template (template_registry prints capabilities.countdown and the count field's bounds); a value outside it fails with the template's own numbers in the message. **Pass the count the user asked for, not the total scene count** — `scenes` is the total and the two are cross-checked, so passing the user's 5 here yields 5 beats while passing it as `scenes` would yield 4. The skeleton only carries each beat's index: fill in the real per-scene content before preparing assets. Keep any count stated in headline/subheadline consistent with this value — gen_script warns on a mismatch but never rewrites your copy."
91
96
  }
92
97
  },
93
98
  "required": [
@@ -98,7 +103,9 @@
98
103
  "primary": [
99
104
  "topic"
100
105
  ],
101
- "advanced": [],
106
+ "advanced": [
107
+ "item_count"
108
+ ],
102
109
  "hidden": [
103
110
  "duration",
104
111
  "style",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V12",
5
+ "version": "V13",
6
6
  "skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
7
7
  }
@@ -2089,6 +2089,12 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
2089
2089
  selected = match_template.load_full_template(selected)
2090
2090
  LogPrint(f"📋 auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
2091
2091
  binding = match_template.build_binding(selected, dsl)
2092
+ from required_props import validate_required_props
2093
+ try:
2094
+ validate_required_props(selected, binding.get("bindings", []))
2095
+ except ValueError as exc:
2096
+ LogPrint(f"❌ {exc}", file=sys.stderr)
2097
+ sys.exit(2)
2092
2098
  return binding
2093
2099
 
2094
2100
 
@@ -2193,6 +2199,10 @@ Examples:
2193
2199
  args = parser.parse_args()
2194
2200
 
2195
2201
  private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
2202
+ # The registry loader shares the environment-based auth entry point. Keep
2203
+ # explicit CLI credentials consistent with render_job_client's token.
2204
+ if args.priv_token:
2205
+ os.environ["PRIV_TOKEN"] = private_token
2196
2206
 
2197
2207
  # --save-job 默认开启,但若没有 PRIV_TOKEN 则自动降级为关闭,避免阻塞无 token 的本地调试。
2198
2208
  if args.save_job and not private_token:
@@ -2339,6 +2349,20 @@ Examples:
2339
2349
  render_plan = build_render_plan(dsl, binding)
2340
2350
  render_plan["_dsl_assets"] = dsl.get("assets", [])
2341
2351
 
2352
+ # Job/file mode bypasses auto_bind_template. Validate saved plans too, so a
2353
+ # previously accepted placeholder-only plan cannot silently render again.
2354
+ if args.job_id or args.render_plan:
2355
+ from registry_loader import get_template
2356
+ from required_props import validate_required_props
2357
+ try:
2358
+ template = get_template(render_plan.get("templateId", ""), include_all_statuses=True)
2359
+ if not template:
2360
+ raise ValueError("Cannot validate RenderPlan: template metadata unavailable")
2361
+ validate_required_props(template, render_plan.get("timeline", []))
2362
+ except ValueError as exc:
2363
+ LogPrint(f"❌ {exc}", file=sys.stderr)
2364
+ sys.exit(2)
2365
+
2342
2366
  if not args.skip_asset_resolve:
2343
2367
  # Stub mode: short-circuit gen-image / gen-video before counting pending.
2344
2368
  # CLI flag takes precedence; env vars act as fallback with a visible warning
@@ -0,0 +1,166 @@
1
+ """Validate template-required scene props before asset generation or rendering.
2
+
3
+ Binding records missingRequiredProps for diagnostics, but a production caller
4
+ must reject those scenes instead of paying for audio and rendering placeholders.
5
+ Existing RenderPlans need the same check because they bypass binding entirely.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ # Shared cross-skill lib (see AGENTS.md). Inserted here rather than relying on
15
+ # render_video's module-top setup because this module is imported directly by
16
+ # tests and by prepare-video-assets.
17
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))
18
+
19
+ from countdown_spec import resolve_countdown_spec # noqa: E402
20
+
21
+
22
+ def _payload_prop(props: dict, key: str):
23
+ seen: set[int] = set()
24
+ cursor = props
25
+ for _ in range(9):
26
+ if not isinstance(cursor, dict) or id(cursor) in seen:
27
+ break
28
+ seen.add(id(cursor))
29
+ if cursor.get(key) is not None:
30
+ return cursor[key]
31
+ cursor = cursor.get("templateData")
32
+ return None
33
+
34
+
35
+ def _required_value_errors(value, schema: dict, path: str) -> list[str]:
36
+ """Check required values only, using the template's declared shape and bounds.
37
+
38
+ This is not a full JSON Schema validator: optional fields and undeclared
39
+ extensions are left to the existing DSL validators and template runtime.
40
+ """
41
+ if value is None or (isinstance(value, str) and not value.strip()):
42
+ return [path]
43
+ kind = schema.get("type")
44
+ number = type(value) in (int, float) and (not isinstance(value, float) or math.isfinite(value))
45
+ matches = {
46
+ "object": isinstance(value, dict), "array": isinstance(value, list),
47
+ "string": isinstance(value, str), "boolean": isinstance(value, bool),
48
+ "number": number,
49
+ "integer": number and (isinstance(value, int) or value.is_integer()),
50
+ }
51
+ if kind in matches and not matches[kind]:
52
+ return [f"{path} (expected {kind})"]
53
+ if number and (kind in ("number", "integer")):
54
+ if "minimum" in schema and value < schema["minimum"]:
55
+ return [f"{path} (below minimum)"]
56
+ if "maximum" in schema and value > schema["maximum"]:
57
+ return [f"{path} (above maximum)"]
58
+ if isinstance(value, dict) and kind == "object":
59
+ properties = schema.get("properties") or {}
60
+ return [error for key in schema.get("required", [])
61
+ for error in _required_value_errors(value.get(key), properties.get(key, {}), f"{path}.{key}")]
62
+ if isinstance(value, list) and kind == "array" and len(value) < schema.get("minItems", 0):
63
+ return [f"{path} (too few items)"]
64
+ return []
65
+
66
+
67
+ def validate_required_props(template: dict, rows: list[dict]) -> None:
68
+ """Raise with scene IDs and field names only; never include scene content."""
69
+ required_by_composition: dict[str, set[str]] = {}
70
+ # References reuse a concrete slot's composition, so only concrete slots
71
+ # contribute requirements here. Union if multiple slots share a component.
72
+ for slot in (template.get("slotMapping") or {}).values():
73
+ if not isinstance(slot, dict) or not slot.get("compositionId"):
74
+ continue
75
+ required_by_composition.setdefault(slot["compositionId"], set()).update(
76
+ slot.get("requiredProps") or []
77
+ )
78
+
79
+ failures = []
80
+ for row in rows:
81
+ required = required_by_composition.get(row.get("compositionId"), set())
82
+ required = required | set(row.get("missingRequiredProps") or [])
83
+ props = row.get("props") or {}
84
+ schema = template.get("customPayloadSchema") or {}
85
+ missing = [error for key in sorted(required)
86
+ for error in _required_value_errors(_payload_prop(props, key), schema.get(key, {}), key)]
87
+ if missing:
88
+ failures.append(f"scene {row.get('sceneId', '?')}: {', '.join(missing)}")
89
+
90
+ if failures:
91
+ raise ValueError(
92
+ "Missing or invalid required template props: " + "; ".join(failures[:10])
93
+ + (f"; {len(failures)} scenes total" if len(failures) > 10 else "")
94
+ + ". Fill each scene's customPayload according to the template schema "
95
+ "and rebuild the RenderPlan. Narration or topic text does not populate card data."
96
+ )
97
+
98
+ _validate_countdown(template, rows)
99
+
100
+
101
+ def _index_of(row: dict, spec: dict):
102
+ """A beat's countdown index, or None when the row carries no usable item.
103
+
104
+ Returning None rather than indexing straight into the payload keeps a scene
105
+ with a missing/malformed item on the countdown error path (which names the
106
+ scene and says what to fix) instead of an AttributeError from deep inside the
107
+ validator. Reachable whenever a countdown template's point slot does not list
108
+ its item prop in requiredProps, so the earlier per-scene check lets it through.
109
+ """
110
+ item = _payload_prop(row.get("props") or {}, spec["item_prop"])
111
+ return item.get(spec["index_prop"]) if isinstance(item, dict) else None
112
+
113
+
114
+ def _validate_countdown(template: dict, rows: list[dict]) -> None:
115
+ """Check a countdown plan's shape before anything is paid for.
116
+
117
+ Every prop name here comes from the template's own `capabilities.countdown`
118
+ declaration, so this function holds no knowledge of ranking, GitHub, or any
119
+ template id. That matters beyond tidiness: the names used to be hardcoded,
120
+ which meant a second countdown template calling its count `itemCount` would
121
+ find no counts at all, fall back to "however many beats are present", and
122
+ reduce the scene-count check to comparing that number against itself —
123
+ validation silently gone, with nothing on screen or in the logs to say so.
124
+ """
125
+ spec = resolve_countdown_spec(template)
126
+ if spec is None:
127
+ return
128
+ slots = template.get("slotMapping") or {}
129
+ point_id = (slots.get("point") or {}).get("compositionId")
130
+ opening_id = (slots.get("opening") or {}).get("compositionId")
131
+ ending_id = (slots.get("ending") or {}).get("compositionId")
132
+ points = [row for row in rows if row.get("compositionId") == point_id]
133
+ count_props = [spec["count_prop"], *spec["legacy_count_props"]]
134
+ # Keep old plans usable: infer their count when no explicit count was saved.
135
+ declared = []
136
+ for row in rows:
137
+ props = row.get("props") or {}
138
+ count = next((value for value in (_payload_prop(props, p) for p in count_props)
139
+ if value is not None), None)
140
+ if count is not None:
141
+ if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
142
+ raise ValueError(
143
+ f"Invalid countdown {spec['count_prop']}: expected an integer from "
144
+ f"{spec['minimum']} to {spec['maximum']}"
145
+ )
146
+ declared.append(count)
147
+ count = declared[0] if declared else len(points)
148
+ if any(value != count for value in declared):
149
+ raise ValueError(f"Countdown {spec['count_prop']} must be consistent across all scenes")
150
+ if not spec["minimum"] <= count <= spec["maximum"] or len(points) != count:
151
+ raise ValueError(
152
+ f"Countdown scene count does not match {spec['count_prop']}; rebuild one opening "
153
+ "plus N beats"
154
+ )
155
+ indexes = [_index_of(row, spec) for row in points]
156
+ if indexes != list(range(count, 0, -1)):
157
+ raise ValueError(
158
+ f"Countdown scenes must count down from {count} to 1 in "
159
+ f"{spec['item_prop']}.{spec['index_prop']} without gaps or duplicates"
160
+ )
161
+ if not rows or rows[0].get("compositionId") != opening_id or sum(row.get("compositionId") == opening_id for row in rows) != 1:
162
+ raise ValueError("Countdown requires exactly one opening before the beats")
163
+ # A separate ending may hold the final beat, but cannot add another index.
164
+ endings = [row for row in rows if row.get("compositionId") == ending_id]
165
+ if len(endings) > 1 or (endings and (rows[-1] is not endings[0] or _index_of(endings[0], spec) != 1)):
166
+ raise ValueError("Countdown ending may only hold the #1 scene after the countdown")
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V21",
5
+ "version": "V23",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }
@@ -0,0 +1,188 @@
1
+ """Shared resolver for the ``countdown`` scene strategy.
2
+
3
+ A countdown is the "one opening, then N beats of the same kind, counting down"
4
+ shape: Top-N lists, "5 个技巧", "7 个常见错误". It sits on the same axis as
5
+ ``arc`` / ``single`` / ``fixed`` because it is a *shape*, not a subject — which
6
+ is the whole reason it is not called "ranking" and holds no GitHub-specific
7
+ knowledge.
8
+
9
+ None of the existing strategies could express it: ``arc`` clamps to at least 3
10
+ scenes, always appends a ``cta``, and numbers its beats upward; ``fixed`` needs a
11
+ static scene list and so cannot take a count at all.
12
+
13
+ ─── Why this module is shared rather than copied ──────────────────────────────
14
+ Two skills need the same answer from the same declaration:
15
+
16
+ * ``gen-script`` — to lay out the scenes and write the count into each payload
17
+ * ``render-video`` — to validate a plan (scene count, countdown completeness)
18
+ before any asset is paid for
19
+
20
+ Copying the resolution into both is how the previous iteration of this feature
21
+ went wrong in the other direction (a Python port of the template's copy rules
22
+ living next to the real ones). ``skills/template-registry/scripts/`` is the
23
+ sanctioned home for cross-skill Python — see AGENTS.md.
24
+
25
+ ─── What is declared where ───────────────────────────────────────────────────
26
+ The strategy carries the shape; the template carries every concrete value:
27
+
28
+ "capabilities": {
29
+ "sceneStrategy": "countdown",
30
+ "countdown": {
31
+ "countProp": "topN", // where the count goes in customPayload
32
+ "itemProp": "item", // optional, default "item"
33
+ "indexProp": "rank", // optional, default "rank"
34
+ "legacyCountProps": ["totalItems"],
35
+ "openingSec": 2.5 // optional, default DEFAULT_OPENING_SEC
36
+ }
37
+ }
38
+
39
+ Bounds are deliberately **not** repeated here: they are read from
40
+ ``customPayloadSchema[countProp]``'s ``minimum`` / ``maximum`` / ``default``,
41
+ which every template already has to author for its own payload contract. Having
42
+ one number in two places is how they drift.
43
+
44
+ Declaring the prop *names* is what keeps this module honest. They used to be
45
+ hardcoded, which meant a second countdown template naming its count
46
+ ``itemCount`` would silently lose validation entirely: nothing would be found,
47
+ the count would fall back to "however many cards are present", and the
48
+ "scene count matches the count" check would compare that number to itself.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ #: Opening budget when the template declares none. A generic "an opening is a
54
+ #: beat, not a chapter" value — **not** any one template's measurement. It used
55
+ #: to be 3 hardcoded in gen_script, taken from github-repo-rank's reference film
56
+ #: (2.43s), which put a single template's fact in the CLI.
57
+ DEFAULT_OPENING_SEC = 3
58
+
59
+
60
+ class CountdownError(ValueError):
61
+ """Raised for an unusable countdown declaration.
62
+
63
+ A subclass so callers can keep treating it as the ValueError they already
64
+ handle, while a reader can still see that an authoring mistake and a bad
65
+ ``--item-count`` are two different things.
66
+ """
67
+
68
+
69
+ def resolve_countdown_spec(template: dict | None) -> dict | None:
70
+ """Resolve a template's countdown declaration, or None if it is not one.
71
+
72
+ Raises CountdownError when ``sceneStrategy=countdown`` is declared but the
73
+ rest of the contract is missing. Failing loudly here is the point: every
74
+ downstream check reads these names, so an incomplete declaration must not
75
+ degrade into "no countdown rules apply".
76
+ """
77
+ caps = (template or {}).get("capabilities")
78
+ caps = caps if isinstance(caps, dict) else {}
79
+ if caps.get("sceneStrategy") != "countdown":
80
+ return None
81
+
82
+ template_id = (template or {}).get("templateId", "?")
83
+ cfg = caps.get("countdown")
84
+ cfg = cfg if isinstance(cfg, dict) else {}
85
+
86
+ count_prop = cfg.get("countProp")
87
+ if not isinstance(count_prop, str) or not count_prop.strip():
88
+ raise CountdownError(
89
+ f"Template '{template_id}' declares capabilities.sceneStrategy=countdown but no "
90
+ "capabilities.countdown.countProp. Declare which customPayload field carries the "
91
+ "item count (github-repo-rank uses \"topN\")."
92
+ )
93
+ count_prop = count_prop.strip()
94
+
95
+ schema = (template or {}).get("customPayloadSchema")
96
+ bounds = schema.get(count_prop) if isinstance(schema, dict) else None
97
+ if not isinstance(bounds, dict):
98
+ raise CountdownError(
99
+ f"Template '{template_id}' declares countdown.countProp='{count_prop}' but "
100
+ f"customPayloadSchema.{count_prop} is missing. The count's bounds and default are "
101
+ "read from there, so that one number is not authored twice."
102
+ )
103
+
104
+ maximum, default = bounds.get("maximum"), bounds.get("default")
105
+ minimum = bounds.get("minimum")
106
+ minimum = minimum if type(minimum) is int else 1
107
+ if type(maximum) is not int or type(default) is not int:
108
+ raise CountdownError(
109
+ f"Template '{template_id}': customPayloadSchema.{count_prop} must declare integer "
110
+ "'maximum' and 'default'. Without a maximum the count is unbounded; without a "
111
+ "default there is no answer for a caller that omits the count."
112
+ )
113
+ if not minimum <= default <= maximum:
114
+ raise CountdownError(
115
+ f"Template '{template_id}': customPayloadSchema.{count_prop}.default ({default}) is "
116
+ f"outside its own minimum/maximum ({minimum}–{maximum})."
117
+ )
118
+
119
+ legacy = cfg.get("legacyCountProps")
120
+ legacy = [p for p in legacy if isinstance(p, str) and p.strip()] if isinstance(legacy, list) else []
121
+
122
+ opening_sec = cfg.get("openingSec")
123
+ if not (type(opening_sec) in (int, float) and opening_sec > 0):
124
+ opening_sec = DEFAULT_OPENING_SEC
125
+
126
+ return {
127
+ "template_id": template_id,
128
+ "count_prop": count_prop,
129
+ "legacy_count_props": legacy,
130
+ "item_prop": (cfg.get("itemProp") or "item").strip(),
131
+ "index_prop": (cfg.get("indexProp") or "rank").strip(),
132
+ "minimum": minimum,
133
+ "maximum": maximum,
134
+ "default": default,
135
+ "opening_sec": float(opening_sec),
136
+ }
137
+
138
+
139
+ def resolve_count(spec: dict, requested: int | None, scene_count: int | None) -> int:
140
+ """Validate a caller-supplied item count against the template's own bounds.
141
+
142
+ ``requested`` is the number of content beats the caller asked for — the same
143
+ number the user picked. It is deliberately *not* derived from ``scene_count``:
144
+ a caller passing the user's 5 as a total scene count would produce 4 cards
145
+ with a self-consistent ``count=4`` written everywhere, which no validator can
146
+ catch and the user never sees. So the two are cross-checked instead.
147
+ """
148
+ count = spec["default"] if requested is None else requested
149
+ if type(count) is not int or not spec["minimum"] <= count <= spec["maximum"]:
150
+ raise ValueError(
151
+ f"item count must be an integer from {spec['minimum']} to {spec['maximum']} "
152
+ f"(declared by template '{spec['template_id']}')"
153
+ )
154
+ if scene_count is not None and scene_count != count + 1:
155
+ raise ValueError(
156
+ f"--scenes ({scene_count}) conflicts with the item count ({count}): a countdown is "
157
+ f"one opening plus N beats, so --scenes would have to be {count + 1}. Pass only one."
158
+ )
159
+ return count
160
+
161
+
162
+ def opening_sec(spec: dict, count: int, duration: float) -> float:
163
+ """Seconds for the single opening scene.
164
+
165
+ ``openingSec`` is an upper bound, not a fixed value: on a short video it
166
+ yields to an even split so a long opening budget cannot starve the beats.
167
+
168
+ Worth knowing how provisional this is — ``render_video``'s
169
+ ``adjust_timeline_to_audio`` overwrites every scene's duration with the real
170
+ narration length once TTS exists. This number shapes the script the user
171
+ reviews and the duration/cost estimate, not the finished cut.
172
+ """
173
+ return min(spec["opening_sec"], duration / (count + 1))
174
+
175
+
176
+ def scene_payload(spec: dict, count: int, index: int | None) -> dict:
177
+ """The countdown part of one scene's customPayload, under declared names.
178
+
179
+ ``index`` is None for the opening. For a beat only the index is filled:
180
+ inventing the rest of the item would put fabricated data on screen, so the
181
+ caller is expected to fill it with real content before assets are prepared.
182
+ """
183
+ payload: dict = {spec["count_prop"]: count}
184
+ for prop in spec["legacy_count_props"]:
185
+ payload[prop] = count
186
+ if index is not None:
187
+ payload[spec["item_prop"]] = {spec["index_prop"]: index}
188
+ return payload
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V14",
5
+ "version": "V15",
6
6
  "skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
7
7
  }