@remixmate/cli 0.9.40 → 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.40",
4
- "generatedAt": "2026-09-22T08:04:46.896Z",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.40",
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",
@@ -37,6 +37,8 @@
37
37
  "check:render": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/render-template-check.py",
38
38
  "clean": "node scripts/clean.mjs",
39
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",
40
42
  "test:ranking-count": "PYTHONDONTWRITEBYTECODE=1 python3 scripts/test-ranking-count.py"
41
43
  },
42
44
  "keywords": [
@@ -22,6 +22,8 @@ import math
22
22
  import os
23
23
  import sys
24
24
  import uuid
25
+ from copy import deepcopy
26
+ from urllib.parse import urlsplit
25
27
 
26
28
  SCHEMA_PATH = os.environ.get(
27
29
  "VIDEO_DSL_SCHEMA_PATH",
@@ -480,7 +482,7 @@ def _template_needs_image(template_config: dict | None) -> bool:
480
482
  # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
481
483
  # 非 countdown 模板为 None)
482
484
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
483
- # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
485
+ # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption, fit-images}
484
486
  # - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
485
487
  # - duration_model dict|None capabilities.durationModel(fit-caption 估时用的节奏数值,
486
488
  # 由模板声明;缺省则不估时,见 _estimate_fit_caption_duration)
@@ -524,7 +526,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
524
526
  payload_style = "slide" if primary_visual_type == "none" else "visual-overlay"
525
527
 
526
528
  duration_strategy = caps.get("durationStrategy")
527
- if duration_strategy not in ("explicit", "fit-caption"):
529
+ if duration_strategy not in ("explicit", "fit-caption", "fit-images"):
528
530
  duration_strategy = "explicit"
529
531
 
530
532
  fixed_scenes = caps.get("fixedScenes")
@@ -786,6 +788,29 @@ def _estimate_fit_caption_duration(
786
788
  return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)
787
789
 
788
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
+
789
814
  def _build_carousel_caption_dsl(
790
815
  *,
791
816
  template_id: str,
@@ -814,7 +839,12 @@ def _build_carousel_caption_dsl(
814
839
  needsNarration),不再读 input-schema.json,也没有 if template_id==X 分支。
815
840
  轮播素材由调用方在 carousel_items 里直接给 URL(existing 资产),不触发 AI 生图。
816
841
  """
817
- 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")
818
848
 
819
849
  # ── Build assets from carousel_items ──────────────────────────────────
820
850
  assets = []
@@ -822,7 +852,9 @@ def _build_carousel_caption_dsl(
822
852
  for idx, url in enumerate(carousel_items):
823
853
  asset_id = f"carousel-img-{idx + 1:02d}"
824
854
  # Determine if URL is a video (basic heuristic on extension)
825
- 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")
826
858
  assets.append({
827
859
  "assetId": asset_id,
828
860
  "type": "video" if is_video else "image",
@@ -889,6 +921,18 @@ def _build_carousel_caption_dsl(
889
921
  if defaults.get(key):
890
922
  custom_payload[key] = defaults[key]
891
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
+
892
936
  # ── Narration skeleton ────────────────────────────────────────────────
893
937
  narration_text = _localize_label(output_language, "narration_opening",
894
938
  label="", topic=topic, duration=duration)
@@ -898,6 +942,10 @@ def _build_carousel_caption_dsl(
898
942
  # 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长,
899
943
  # 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
900
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)
901
949
  if duration_strategy == "fit-caption":
902
950
  est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
903
951
  if est > 0:
@@ -1631,7 +1679,8 @@ Examples:
1631
1679
  "--carousel-items url1 --carousel-items url2. "
1632
1680
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
1633
1681
  "these URLs are placed directly into "
1634
- "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.",
1635
1684
  )
1636
1685
  parser.add_argument(
1637
1686
  "--scene-images",
@@ -1654,7 +1703,8 @@ Examples:
1654
1703
  help="On-screen caption line. Can be repeated: "
1655
1704
  "--caption-lines 'line1' --caption-lines 'line2'. "
1656
1705
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
1657
- "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. "
1658
1708
  "Line count limits and whether **emphasis** is parsed are declared per "
1659
1709
  "template — see the chosen template's llmHint.",
1660
1710
  )
@@ -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",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V13",
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
  }
@@ -2363,6 +2363,14 @@ Examples:
2363
2363
  LogPrint(f"❌ {exc}", file=sys.stderr)
2364
2364
  sys.exit(2)
2365
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
+
2366
2374
  if not args.skip_asset_resolve:
2367
2375
  # Stub mode: short-circuit gen-image / gen-video before counting pending.
2368
2376
  # CLI flag takes precedence; env vars act as fallback with a visible warning
@@ -17,6 +17,8 @@ from pathlib import Path
17
17
  sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "template-registry" / "scripts"))
18
18
 
19
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
20
22
 
21
23
 
22
24
  def _payload_prop(props: dict, key: str):
@@ -84,6 +86,7 @@ def validate_required_props(template: dict, rows: list[dict]) -> None:
84
86
  schema = template.get("customPayloadSchema") or {}
85
87
  missing = [error for key in sorted(required)
86
88
  for error in _required_value_errors(_payload_prop(props, key), schema.get(key, {}), key)]
89
+ missing += required_payload_errors(template, props)
87
90
  if missing:
88
91
  failures.append(f"scene {row.get('sceneId', '?')}: {', '.join(missing)}")
89
92
 
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V23",
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
  }
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V15",
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