@remixmate/cli 0.9.40 → 0.9.42
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.
- package/dist/manifest.json +4 -4
- package/package.json +3 -1
- package/skills/gen-script/scripts/gen_script.py +56 -6
- package/skills/gen-script/skill.json +2 -2
- package/skills/gen-script/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +8 -0
- package/skills/render-video/scripts/required_props.py +3 -0
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +17 -23
- package/skills/template-registry/video_dsl/runtime/payload_contract.py +82 -0
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"version": "0.9.42",
|
|
4
|
+
"generatedAt": "2026-09-22T11:06:24.930Z",
|
|
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
|
|
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).
|
|
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.
|
|
3
|
+
"version": "0.9.42",
|
|
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 =
|
|
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.
|
|
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
|
|
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).
|
|
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": "
|
|
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": "
|
|
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": "
|
|
5
|
+
"version": "V17",
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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,22 @@ 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")
|
|
737
|
+
custom = dict(custom) if isinstance(custom, dict) else {}
|
|
738
|
+
if not custom.get("templateData") and isinstance(scene.get("templateData"), dict):
|
|
739
|
+
custom["templateData"] = scene["templateData"]
|
|
740
|
+
errors += [ValidationError(f"scenes[{idx}].customPayload", message)
|
|
741
|
+
for message in required_payload_errors(tpl, custom)]
|
|
745
742
|
if isinstance(tpl.get("slideSchemas"), dict):
|
|
746
|
-
return
|
|
743
|
+
return errors # 多版式字段走 _check_slide_payload_fields
|
|
747
744
|
cps = tpl.get("customPayloadSchema")
|
|
748
745
|
if not isinstance(cps, dict) or not cps:
|
|
749
|
-
return
|
|
746
|
+
return errors
|
|
750
747
|
|
|
751
748
|
root = {"type": "object", "properties": cps}
|
|
752
|
-
errors: list[ValidationError] = []
|
|
753
749
|
for idx, scene in enumerate(dsl.get("scenes", []) or []):
|
|
754
750
|
custom = scene.get("customPayload")
|
|
755
751
|
if not isinstance(custom, dict) or not custom:
|
|
@@ -1053,5 +1049,3 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1053
1049
|
errors += _check_line_sync_narration(dsl)
|
|
1054
1050
|
errors += _check_slide_cross_field(dsl)
|
|
1055
1051
|
return errors
|
|
1056
|
-
|
|
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
|