@remixmate/cli 0.12.6 → 0.12.8

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.12.6",
4
- "generatedAt": "2026-10-18T13:29:08.016Z",
3
+ "version": "0.12.8",
4
+ "generatedAt": "2026-10-20T23:46:18.482Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -592,7 +592,38 @@
592
592
  },
593
593
  "background_preset": {
594
594
  "type": "string",
595
- "description": "Scene background preset, one of the template's customPayloadSchema.background.preset values. Written to every scene's customPayload.background.preset. **Must** pass when the user picked a background: the replicate card shows `背景样式: 纯色(solid)` → pass `solid`. Without it the scene keeps the template default. Requires template_id; unsupported values are rejected. `image` / `video` also need customPayload.background.assetRef on each scene."
595
+ "description": "Scene background preset, one of the template's customPayloadSchema.background.preset values. Written to every scene's customPayload.background.preset. **Must** pass when the user picked a background: the replicate card shows `背景样式: 纯色(solid)` → pass `solid`. Without it the scene keeps the template default. Requires template_id; unsupported values are rejected. `image` / `video` need the user's file: pass it as background_asset (which also sets the preset)."
596
+ },
597
+ "sequence_preset": {
598
+ "type": "string",
599
+ "description": "Image-sequence style preset, one of the template's customPayloadSchema.sequence.preset values (image-to-video: vlog / news / poster / cinematic / fast-cut). Written to customPayload.sequence.preset; the preset sets transitions, motion, text layout and pacing (fast-cut shortens every image to 1.5s, and the scene duration follows). **Must** pass when the user picked a style preset: the replicate card shows `风格预设: 快剪(fast-cut)` → pass `fast-cut`; `none` keeps the template defaults. Without it the choice is silently dropped. Only for templates declaring capabilities.payloadDefaults.sequence; unsupported values are rejected."
600
+ },
601
+ "sequence_fit": {
602
+ "type": "string",
603
+ "description": "How image-sequence media fill the frame, one of customPayloadSchema.sequence.fit (cover = fill the frame and crop, contain = show the whole picture over a blurred backdrop). Written to customPayload.sequence.fit and beats the preset's own fit. **Must** pass when the user picked it: `显示方式: 完整显示(contain)` → pass `contain`; `auto` keeps the template / preset default. Only for templates declaring capabilities.payloadDefaults.sequence."
604
+ },
605
+ "background_asset": {
606
+ "type": "string",
607
+ "description": "URL of the user's background image or video (the replicate card's 背景素材 / 背景图片 asset line → its URL). Added as an existing asset and referenced from every scene's customPayload.background (assetRef for an image, videoAssetRef for a video; .mp4/.mov/.webm/.m4v count as video); the background preset becomes image / video, so background_preset can be omitted. **Must** pass when the user gave a background file — without it a preset of image / video renders the template's plain default. Requires template_id; rejected when the template has no background of that kind."
608
+ },
609
+ "payload": {
610
+ "type": "array",
611
+ "items": {
612
+ "type": "string"
613
+ },
614
+ "description": "Template-specific card choices as `KEY=VALUE`, written to every scene's customPayload and locked so the dsl_json you save cannot overwrite them. KEY is a dotted path into the template's customPayloadSchema (e.g. `grade=四年级`, `watermarkText=WorkBuddy 实战课`, `preset=course`, `carousel.fit=cover`, `templateData.revealMode=progressive`, `cardStyle.championFx=false`); VALUE is the id inside the card line's parentheses, or the text verbatim for free-text fields. Booleans take true/false, numbers are range-checked, enums are checked — an undeclared key or value is rejected with the allowed list. **Must** pass every card line that the system prompt maps to a payload key; without it the choice is silently dropped. Requires template_id."
615
+ },
616
+ "motion": {
617
+ "type": "string",
618
+ "enum": [
619
+ "static",
620
+ "kenburns-in",
621
+ "kenburns-out",
622
+ "pan-left",
623
+ "pan-right",
624
+ "float"
625
+ ],
626
+ "description": "Camera motion for every scene (scenes[].animationHints.motion), replacing the skeleton's alternating push-in / pull-out. **Must** pass when the user picked one: `运镜效果: 缓慢推近(kenburns-in)` → `kenburns-in`."
596
627
  },
597
628
  "font_id": {
598
629
  "type": "string",
@@ -639,7 +670,7 @@
639
670
  "items": {
640
671
  "type": "string"
641
672
  },
642
- "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."
673
+ "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. Image sequences (payloadDefaults.sequence) accept video URLs too (.mp4/.mov/.webm): gen_script probes each clip's real length, marks it type=video and plays it whole and muted; an unreadable video is rejected."
643
674
  },
644
675
  "scene_images": {
645
676
  "type": "array",
@@ -653,7 +684,7 @@
653
684
  "items": {
654
685
  "type": "string"
655
686
  },
656
- "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."
687
+ "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. A replicate asset line's `(… desc: <text>)` suffix is the user's caption for that image: pass it here in the same order (use an empty string for images without one). 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."
657
688
  },
658
689
  "stub_image_url": {
659
690
  "type": "string",
@@ -693,6 +724,11 @@
693
724
  "speed",
694
725
  "variant",
695
726
  "background_preset",
727
+ "sequence_preset",
728
+ "sequence_fit",
729
+ "background_asset",
730
+ "payload",
731
+ "motion",
696
732
  "font_id",
697
733
  "font_name",
698
734
  "list_fonts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
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",
@@ -78,7 +78,7 @@ The intent is to avoid the failure mode where "the generic DSL looks compatible
78
78
  4. Combine that with the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, `supportedDurations`, `constraints`, and `scenePatterns`.
79
79
  5. Generate the DSL in the template's native shape — not the generic DSL shape.
80
80
  6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `customPayload.slideId`, `visuals.avatar.assetRef`.
81
- 7. After generation, run the schema check and show the script summary to the user for confirmation.
81
+ 7. After generation, run `gen_script.py --validate -i <dsl.json>` and fix every error it reports, then show the script summary to the user for confirmation. It checks the template contract, not just the DSL shape: undeclared `customPayload` field names, values outside a declared `enum` (e.g. `layout: "bogus"`, `style.preset: "nwes"`), and every `assetRef` / `*AssetRef` (`logoAssetRef`, `avatarAssetRef` …) pointing at an asset that `assets[]` does not declare. These are the same checks render-video runs before spending credits; catching them here saves a confirmed script from being rejected later.
82
82
 
83
83
  ### Document-driven templates: pass the source URL, don't author the payload
84
84
 
@@ -123,7 +123,7 @@ Picking the layout is only half of it. Each layout reads its own field names out
123
123
 
124
124
  This has shipped broken videos: a 7-scene deck guessed generic names (`title`, `description`, `ctaText`) where the chosen layouts read different ones. Five of seven scenes rendered as empty cards and placeholder copy — after paying for TTS and the render. Which names each layout reads is the template's fact; the template's `llmHint` calls out the usual traps.
125
125
 
126
- So: **copy the field names out of `slideSchemas[slideId]`.** The DSL validator now rejects unknown field names before any asset is generated and tells you the declared ones, but that check only fires for templates that ship `slideSchemas` — reading the contract is still the primary move, not the fallback.
126
+ So: **copy the field names out of `slideSchemas[slideId]`.** The DSL validator (`gen_script.py --validate`, and again in render-video) rejects unknown field names and out-of-enum values before any asset is generated and tells you the declared ones — per layout from `slideSchemas` for multi-layout templates, from `customPayloadSchema` for every other template. Reading the contract is still the primary move, not the fallback: the validator checks names and enums, not whether the content fits the layout.
127
127
 
128
128
  ### On-screen text: the rules live in the template, not here
129
129
 
@@ -253,8 +253,9 @@ When the user selects a `carousel-caption` template (or any template whose `asse
253
253
  - `durationStrategy: fit-caption` → **caption lines are mandatory.** The template has no narration and the typewriter copy is both the content and the clock: it is what the video says *and* what decides how long it runs. **If the user did not supply the copy, write it yourself** from the material you researched (repo README, page screenshots, the topic) and pass it. `gen_script.py` hard-fails on an empty caption for these templates.
254
254
  - `durationStrategy: fit-images` → purely visual, captions genuinely optional; duration comes from the image count.
255
255
  3. **Never call gen_script for a `carousel-caption` template without `--carousel-items`.** If the user picked such a template but provided no media, ask them for the image/video URLs first — do not generate an empty carousel.
256
- 4. Do **not** route these user-provided images through `gen-image`; they are existing assets and go straight into the carousel.
257
- 5. Nothing downstream fills these in for you. `gen_script.py` routes on `capabilities.payloadStyle` alone — omitting the flags does **not** fall back to a generic path that generates images or writes copy; it assembles an empty carousel / empty caption. There is no auto-generation of caption text anywhere in the pipeline.
256
+ 4. Image sequences (`payloadDefaults.sequence`, e.g. image-to-video) take videos as well: pass the clip URL in `--carousel-items` like an image. gen_script probes its real length and writes `type: "video"` + `sourceDurationSec`, so the scene is timed by the clip, not as a 3-second still. A replicate asset line `![name](url) (…, desc: <text>)` carries the user's caption for that item — pass it as the matching `--caption-lines` entry (empty string for items without one).
257
+ 5. Do **not** route these user-provided images through `gen-image`; they are existing assets and go straight into the carousel.
258
+ 6. Nothing downstream fills these in for you. `gen_script.py` routes on `capabilities.payloadStyle` alone — omitting the flags does **not** fall back to a generic path that generates images or writes copy; it assembles an empty carousel / empty caption. There is no auto-generation of caption text anywhere in the pipeline.
258
259
 
259
260
  ### Command example
260
261
 
@@ -283,6 +284,12 @@ Without this flag the user's images are **ignored entirely** and every scene get
283
284
  AI-generated picture. That failure is quiet: the video renders fine, it just isn't made of
284
285
  the material the user handed you.
285
286
 
287
+ **One shared background.** A template that declares `capabilities.backgroundStrategy: "shared"`
288
+ (e.g. an audiogram whose whole video is one "show poster") gets a skeleton with a single
289
+ `img-background` asset that every scene references, instead of one image per scene. Keep it
290
+ that way; switch to another image only where the template's `llmHint` says you may (such as a
291
+ new chapter). `--scene-images` still maps the user's own images one per scene.
292
+
286
293
  ### The mapping rule (positional, then AI fills the rest)
287
294
 
288
295
  Each entry is one scene's visual, and it is either a **URL** (use that existing asset) or the
@@ -473,8 +480,13 @@ python3 <SkillDir>/scripts/gen_script.py \
473
480
  | `--scenes` | Scene count. | auto-planned |
474
481
  | `--voice-id` | Narration voice id. | resolved from template + language fallback |
475
482
  | `--speed` | Narration speech rate (0.5–2.0), written to `global.narration.speed` and applied when `prepare_video_assets` calls gen-voice. Pass it whenever the user picked a speed (the replicate form's 语速 field sends one). Above ~1.3 subtitle alignment drifts and the delivery turns mechanical — shorten the script instead. | `1.0` |
476
- | `--variant` | Template visual variant id (a key of the template's `variants`; `template-registry --list-templates` prints them as `variantIds`). Written to `meta.templateVariant`, which takes precedence over the fuzzy `--style` match. Pass it whenever the user picked a style — the replicate card shows it as `名字(id)`, e.g. `视觉风格: 紫色科技(tech-purple)`; pass the id in the parentheses. Requires `--template-id`; unknown ids are rejected. | template default |
477
- | `--background-preset` | Scene background preset, one of the template's `customPayloadSchema.background.preset` values. Written to every scene's `customPayload.background.preset` (other keys of the template's default background are kept). Pass it whenever the user picked a background, e.g. `背景样式: 纯色(solid)` → `solid`. `image` / `video` also need `customPayload.background.assetRef`. Requires `--template-id`; unsupported values are rejected. | template default |
483
+ | `--variant` | Template visual variant id (a key of the template's `variants`; `template-registry --list-templates` prints them as `variantIds`). Written to `meta.templateVariant`, which takes precedence over the fuzzy `--style` match, and the variant's own `imageStyleGuide` is what the skeleton's image prompts / `negativePrompt` are built from (pass it to `gen_script` rather than setting `meta.templateVariant` afterwards, or the prompts keep the template's default look). Pass it whenever the user picked a style — the replicate card shows it as `名字(id)`, e.g. `视觉风格: 紫色科技(tech-purple)`; pass the id in the parentheses. Requires `--template-id`; unknown ids are rejected. | template default |
484
+ | `--background-preset` | Scene background preset, one of the template's `customPayloadSchema.background.preset` values. Written to every scene's `customPayload.background.preset` (other keys of the template's default background are kept). Pass it whenever the user picked a background, e.g. `背景样式: 纯色(solid)` → `solid`. `image` / `video` need the user's file: pass `--background-asset` (which sets the preset itself). Requires `--template-id`; unsupported values are rejected. | template default |
485
+ | `--sequence-preset` | Image-sequence style preset, one of the template's `customPayloadSchema.sequence.preset` values (image-to-video: `vlog` / `news` / `poster` / `cinematic` / `fast-cut`). Written to `customPayload.sequence.preset`; the template's declared defaults are then left out so they do not shadow the preset, and the scene duration uses the preset's pacing (`durationModel.sequence.presets`). Pass it whenever the user picked a preset, e.g. `风格预设: 快剪(fast-cut)` → `fast-cut`; `none` = template defaults. | template defaults |
486
+ | `--sequence-fit` | Image-sequence fill mode, one of `customPayloadSchema.sequence.fit` (`cover` fill and crop / `contain` whole picture over a blurred backdrop), written to `customPayload.sequence.fit`; beats the preset's fit. `auto` = template / preset default. | template / preset default |
487
+ | `--background-asset` | URL of the user's background image or video (`.mp4/.mov/.webm/.m4v` → video). Added as existing asset `asset-background`; every scene's `customPayload.background` gets `preset: image` + `assetRef`, or `preset: video` + `videoAssetRef`. Conflicts with a different `--background-preset`; rejected when the template's background enum lacks that kind. | none |
488
+ | `--payload KEY=VALUE` | Template-specific card choice, repeatable. `KEY` is a dotted path in `customPayloadSchema` (or `capabilities.frameLevelKeys`), e.g. `grade=四年级`, `watermarkText=…`, `preset=course`, `carousel.fit=cover`, `caption.charsPerSec=20`, `templateData.revealMode=progressive`. Value is converted to the declared type (true/false, numbers) and checked against enum / range; undeclared keys are rejected. Written to every scene with sibling keys kept, applied before fit-caption duration estimation. | template default |
489
+ | `--motion` | Camera motion for every scene (`animationHints.motion`): `static` / `kenburns-in` / `kenburns-out` / `pan-left` / `pan-right` / `float`. | skeleton alternates in / out |
478
490
  | `--font-id` | Font library key (`font.uniq_id`), written to `global.font.fontId`; ab-render resolves and installs the real font at render time. Pass it whenever the user picked a font — the replicate card shows it as `名字(id)`, e.g. `字体: <名字>(<uniqId>)`; pass the id in the parentheses. Never pass a CSS family or the display name: an unknown key falls back to the template font with no error. | template font |
479
491
  | `--font-name` | Display name of the picked font → `global.font.fontName` (display only, never used for rendering). Pass it with `--font-id`. | — |
480
492
  | `--list-fonts` | Print the live font library (`/font/page`: id, name, lang, category, license) and exit; no script is generated and `--topic` is not needed. The ids it prints are the only valid `--font-id` values — use it when the user asks which fonts exist or names a font without its id. | — |
@@ -482,7 +494,7 @@ python3 <SkillDir>/scripts/gen_script.py \
482
494
  | `--template-candidates` | Repeatable. Template ids offered to the user, recommended first → `meta.templateSelection.candidates`. With `user-confirmed`, `--template-id` must be one of them. | — |
483
495
  | `--allow-digital-human` | Whether to allow digital-human assets. | off |
484
496
  | `--allow-ai-video` | Whether to allow AI-generated video assets. | off |
485
- | `--validate` | Validate-only mode: only validate the input DSL. | — |
497
+ | `--validate` | Validate-only mode: validate the input DSL against the DSL schema **and** the template contract (customPayload field names, enum values, asset references). Warnings are printed but do not fail. | — |
486
498
  | `--input` | Input DSL file path (required in validate mode). | — |
487
499
  | `-o` / `--output` | Output DSL file path. | stdout |
488
500
  | `--stub-image-url` | Test mode: every image AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_IMAGE_URL`). | — |
@@ -491,6 +503,8 @@ python3 <SkillDir>/scripts/gen_script.py \
491
503
  | `--scene-images` | Repeatable. One scene's visual for a **`visual-overlay`** template, mapped **positionally**. Each entry is a URL (that existing asset) or `ai` / `ai:<prompt>` (that scene goes to gen-image, optionally with the user's own prompt) — so an AI scene can sit anywhere in the order. Scenes past the last entry still get AI images. Scene count is raised (never lowered) to fit the entries when `--scenes` is absent. **Required** whenever the user supplies images for such a template — omitting it silently ignores every image they gave. | — |
492
504
  | `--caption-lines` | Repeatable. On-screen caption line for `carousel-caption` templates → `customPayload.caption.lines`. Line limits and `**emphasis**` support are declared per template (see its `llmHint`). **Required** for `durationStrategy: fit-caption` templates — write the lines yourself if the user did not supply them. Optional only for `fit-images` templates. | — |
493
505
 
506
+ Values written by `--background-preset`, `--background-asset`, `--payload` and `--motion` are listed in `meta.lockedScenePaths` (scene-relative paths such as `customPayload.carousel.fit`, `animationHints.motion`). When the agent later saves its own `dsl_json` in merge mode, ab-agent restores those paths from this skeleton after merging, so a model-written `carousel` / `templateData` / `background` object cannot wipe a card choice. Do not hand-write these keys in `dsl_json`; pass the flag.
507
+
494
508
  ## DSL generation principles
495
509
 
496
510
  Apply the following principles when producing the DSL:
@@ -302,6 +302,33 @@ def validate_dsl(dsl: dict) -> list:
302
302
  return errors_as_strings(validate_structural(dsl))
303
303
 
304
304
 
305
+ def validate_authored_dsl(dsl: dict) -> tuple[list, list]:
306
+
307
+
308
+
309
+
310
+
311
+
312
+
313
+
314
+
315
+ from video_dsl.runtime.dsl_validator import (
316
+ validate_integrity,
317
+ validate_structural,
318
+ errors_as_strings,
319
+ )
320
+ found, seen = [], set()
321
+
322
+ for e in validate_structural(dsl) + validate_integrity(dsl):
323
+ key = (e.path, e.message)
324
+ if key not in seen:
325
+ seen.add(key)
326
+ found.append(e)
327
+ errors = [e for e in found if getattr(e, "severity", "error") == "error"]
328
+ warnings = [e for e in found if getattr(e, "severity", "error") != "error"]
329
+ return errors_as_strings(errors), errors_as_strings(warnings)
330
+
331
+
305
332
  def plan_scenes(
306
333
  topic: str,
307
334
  duration: int,
@@ -609,6 +636,8 @@ def _template_needs_image(template_config: dict | None) -> bool:
609
636
 
610
637
 
611
638
 
639
+
640
+
612
641
 
613
642
 
614
643
 
@@ -674,6 +703,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
674
703
  "duration_model": caps.get("durationModel"),
675
704
  "payload_defaults": payload_defaults,
676
705
  "primary_visual_type": primary_visual_type,
706
+ "background_strategy": "shared" if caps.get("backgroundStrategy") == "shared" else "per-scene",
677
707
  "default_layout": caps.get("defaultLayout"),
678
708
  "authored_scenes": caps.get("authoredScenes") is True,
679
709
 
@@ -1059,15 +1089,63 @@ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int =
1059
1089
  rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
1060
1090
  if rule_hold is None:
1061
1091
  raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
1062
- base_hold = sequence.get("defaultHoldSec", rule_hold)
1063
- transition_ms = sequence.get("transitionMs", timing.get("transitionMs"))
1092
+
1093
+
1094
+ preset_timing = (timing.get("presets") or {}).get(sequence.get("preset")) or {}
1095
+ base_hold = sequence.get("defaultHoldSec", preset_timing.get("defaultHoldSec", rule_hold))
1096
+ transition_ms = sequence.get("transitionMs", preset_timing.get("transitionMs", timing.get("transitionMs")))
1064
1097
  margin = timing.get("minHoldMarginFrames")
1065
1098
  if transition_ms is None or margin is None:
1066
1099
  raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
1067
1100
 
1068
1101
  min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
1069
- return sum(max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
1070
- for item in sequence["items"]) / fps
1102
+
1103
+ def item_frames(item: dict) -> int:
1104
+ if item.get("type") == "video":
1105
+
1106
+ return max(1, math.floor(item.get("holdSec", item["sourceDurationSec"]) * fps + 0.5))
1107
+ return max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
1108
+
1109
+ return sum(item_frames(item) for item in sequence["items"]) / fps
1110
+
1111
+
1112
+ def _probe_sequence_video_duration(url: str) -> float | None:
1113
+
1114
+
1115
+
1116
+
1117
+
1118
+ probe_dir = os.path.join(os.path.dirname(__file__), "..", "..", "render-video", "scripts")
1119
+ if os.path.isdir(probe_dir) and os.path.abspath(probe_dir) not in sys.path:
1120
+ sys.path.insert(0, os.path.abspath(probe_dir))
1121
+ try:
1122
+ from _video_probe import probe_video_duration
1123
+ except ImportError:
1124
+ return None
1125
+ return probe_video_duration(url)
1126
+
1127
+
1128
+ def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: str,
1129
+ template_id: str, passthrough: str) -> str | None:
1130
+
1131
+
1132
+
1133
+
1134
+
1135
+ if not value or value == passthrough:
1136
+ return None
1137
+ prop = (sequence_schema.get("properties") or {}).get(key) or {}
1138
+ allowed = [str(v) for v in prop.get("enum") or []]
1139
+ if value not in allowed:
1140
+ raise ValueError(
1141
+ f"{flag} {value!r} is not supported by template {template_id!r}; "
1142
+ f"choose one of: {', '.join(allowed + [passthrough]) if allowed else '(this template has no such option)'}"
1143
+ )
1144
+ return value
1145
+
1146
+
1147
+
1148
+ PayloadWrites = list[tuple[list[str], object]]
1071
1149
 
1072
1150
 
1073
1151
  def _build_carousel_caption_dsl(
@@ -1091,6 +1169,10 @@ def _build_carousel_caption_dsl(
1091
1169
  payload_defaults: dict,
1092
1170
  duration_strategy: str | None,
1093
1171
  duration_model: dict | None,
1172
+ sequence_schema: dict | None = None,
1173
+ sequence_preset: str | None = None,
1174
+ sequence_fit: str | None = None,
1175
+ payload_writes: PayloadWrites | None = None,
1094
1176
  ) -> dict:
1095
1177
 
1096
1178
 
@@ -1104,16 +1186,30 @@ def _build_carousel_caption_dsl(
1104
1186
  raise ValueError("Image sequence needs --carousel-items; caption text alone cannot supply images")
1105
1187
  if sequence_mode and len(caption_lines) > len(carousel_items):
1106
1188
  raise ValueError("Image sequence has more --caption-lines than images; use at most one caption per image")
1189
+ if not sequence_mode and (sequence_preset not in (None, "none") or sequence_fit not in (None, "auto")):
1190
+ raise ValueError(f"--sequence-preset / --sequence-fit only apply to image-sequence templates; {template_id!r} is not one")
1191
+ if sequence_mode:
1192
+ sequence_preset = _sequence_choice(sequence_schema or {}, "preset", sequence_preset,
1193
+ "--sequence-preset", template_id, "none")
1194
+ sequence_fit = _sequence_choice(sequence_schema or {}, "fit", sequence_fit,
1195
+ "--sequence-fit", template_id, "auto")
1107
1196
 
1108
1197
 
1109
1198
  assets = []
1110
1199
  carousel_asset_ids = []
1200
+ video_seconds: dict[str, float] = {}
1111
1201
  for idx, url in enumerate(carousel_items):
1112
1202
  asset_id = f"carousel-img-{idx + 1:02d}"
1113
1203
 
1114
1204
  is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
1115
1205
  if sequence_mode and is_video:
1116
- raise ValueError("Image sequence requires image URLs in --carousel-items, not video URLs")
1206
+ seconds = _probe_sequence_video_duration(url)
1207
+ if not seconds or seconds <= 0:
1208
+ raise ValueError(
1209
+ f"Cannot read the duration of video {url!r}; an image sequence needs it to time the clip. "
1210
+ f"Check the URL is reachable, or leave this video out."
1211
+ )
1212
+ video_seconds[asset_id] = round(float(seconds), 3)
1117
1213
  assets.append({
1118
1214
  "assetId": asset_id,
1119
1215
  "type": "video" if is_video else "image",
@@ -1184,15 +1280,25 @@ def _build_carousel_caption_dsl(
1184
1280
 
1185
1281
 
1186
1282
  if sequence_mode:
1187
- sequence_config = defaults["sequence"]
1283
+
1284
+
1285
+
1286
+ sequence_config = {"preset": sequence_preset} if sequence_preset else defaults["sequence"]
1287
+ if sequence_fit:
1288
+ sequence_config["fit"] = sequence_fit
1188
1289
  sequence_config["items"] = [
1189
1290
  {"assetRef": asset_id,
1190
- **({"caption": caption_lines[index]} if index < len(caption_lines) else {})}
1191
- for index, (asset_id, _) in enumerate(carousel_asset_ids)
1291
+ **({"type": "video", "sourceDurationSec": video_seconds[asset_id]} if kind == "video" else {}),
1292
+ **({"caption": caption_lines[index]} if index < len(caption_lines) and caption_lines[index] else {})}
1293
+ for index, (asset_id, kind) in enumerate(carousel_asset_ids)
1192
1294
  ]
1193
1295
  custom_payload = {"sequence": sequence_config}
1194
1296
 
1195
1297
 
1298
+
1299
+ _write_payload(custom_payload, payload_writes or [])
1300
+
1301
+
1196
1302
  narration_text = _localize_label(output_language, "narration_opening",
1197
1303
  label="", topic=_clip(topic, _PLACEHOLDER_TOPIC_CHARS), duration=duration)
1198
1304
 
@@ -1442,12 +1548,29 @@ def _template_selection(
1442
1548
  return selection
1443
1549
 
1444
1550
 
1551
+
1552
+
1553
+
1554
+
1555
+
1556
+ LOCKED_SCENE_PATHS_KEY = "lockedScenePaths"
1557
+ BACKGROUND_ASSET_ID = "asset-background"
1558
+ _VIDEO_EXTENSIONS = (".mp4", ".mov", ".webm", ".avi", ".m4v")
1559
+
1560
+
1561
+ def _lock_scene_path(dsl: dict, path: str) -> None:
1562
+ locks = dsl.setdefault("meta", {}).setdefault(LOCKED_SCENE_PATHS_KEY, [])
1563
+ if path not in locks:
1564
+ locks.append(path)
1565
+
1566
+
1445
1567
  def _apply_look(
1446
1568
  dsl: dict,
1447
1569
  template_config: dict | None,
1448
1570
  template_id: str | None,
1449
1571
  variant: str | None,
1450
1572
  background_preset: str | None,
1573
+ background_asset: str | None = None,
1451
1574
  ) -> None:
1452
1575
 
1453
1576
 
@@ -1461,10 +1584,17 @@ def _apply_look(
1461
1584
 
1462
1585
 
1463
1586
 
1464
- if not variant and not background_preset:
1587
+
1588
+
1589
+
1590
+
1591
+ if not variant and not background_preset and not background_asset:
1465
1592
  return
1466
1593
  if not template_id or template_config is None:
1467
- raise ValueError("--variant / --background-preset need --template-id: the allowed values come from the template.")
1594
+ raise ValueError(
1595
+ "--variant / --background-preset / --background-asset need --template-id: "
1596
+ "the allowed values come from the template."
1597
+ )
1468
1598
 
1469
1599
  if variant:
1470
1600
  variants = list((template_config.get("variants") or {}).keys())
@@ -1475,6 +1605,22 @@ def _apply_look(
1475
1605
  )
1476
1606
  dsl.setdefault("meta", {})["templateVariant"] = variant
1477
1607
 
1608
+ asset_url = (background_asset or "").strip()
1609
+ if asset_url:
1610
+ kind = "video" if urlsplit(asset_url).path.lower().endswith(_VIDEO_EXTENSIONS) else "image"
1611
+ if background_preset and background_preset != kind:
1612
+ raise ValueError(
1613
+ f"--background-preset {background_preset!r} conflicts with --background-asset, which is "
1614
+ f"{'a video' if kind == 'video' else 'an image'}; drop --background-preset or pass {kind!r}."
1615
+ )
1616
+ if kind not in _template_background_presets(template_config):
1617
+ raise ValueError(
1618
+ f"template {template_id!r} has no {kind} background, so --background-asset cannot be "
1619
+ f"{'a video' if kind == 'video' else 'an image'}; "
1620
+ f"its backgrounds are: {', '.join(_template_background_presets(template_config)) or '(none)'}"
1621
+ )
1622
+ background_preset = kind
1623
+
1478
1624
  if background_preset:
1479
1625
  presets = _template_background_presets(template_config)
1480
1626
  if background_preset not in presets:
@@ -1482,19 +1628,175 @@ def _apply_look(
1482
1628
  f"--background-preset {background_preset!r} is not supported by template {template_id!r}; "
1483
1629
  f"choose one of: {', '.join(presets) or '(this template has no background option)'}"
1484
1630
  )
1631
+
1632
+ ref_key = "videoAssetRef" if background_preset == "video" else "assetRef"
1485
1633
  for scene in dsl.get("scenes", []):
1486
1634
  payload = scene.setdefault("customPayload", {})
1487
1635
  existing = payload.get("background") if isinstance(payload.get("background"), dict) else {}
1488
- payload["background"] = {**existing, "preset": background_preset}
1489
- if background_preset in ("image", "video"):
1636
+ background = {**existing, "preset": background_preset}
1637
+ if asset_url:
1638
+ background[ref_key] = BACKGROUND_ASSET_ID
1639
+ payload["background"] = background
1640
+ _lock_scene_path(dsl, "customPayload.background.preset")
1641
+ if asset_url:
1642
+ dsl.setdefault("assets", []).append({
1643
+ "assetId": BACKGROUND_ASSET_ID,
1644
+ "type": background_preset,
1645
+ "source": "existing",
1646
+ "status": "generated",
1647
+ "url": asset_url,
1648
+ })
1649
+ _lock_scene_path(dsl, f"customPayload.background.{ref_key}")
1650
+ elif background_preset in ("image", "video"):
1490
1651
  print(
1491
- f"ℹ️ background preset {background_preset!r} needs a user asset: set "
1492
- f"customPayload.background.assetRef on each scene to that asset's id, "
1493
- f"otherwise the template falls back to its default background.",
1652
+ f"ℹ️ background preset {background_preset!r} needs a user asset: pass --background-asset "
1653
+ f"<url>, otherwise the template falls back to its default background.",
1494
1654
  file=sys.stderr,
1495
1655
  )
1496
1656
 
1497
1657
 
1658
+ _TRUE_WORDS = ("true", "1", "yes", "on", "开", "是", "显示")
1659
+ _FALSE_WORDS = ("false", "0", "no", "off", "关", "否", "隐藏")
1660
+
1661
+
1662
+ def _payload_node(template_config: dict, parts: list[str], spec: str) -> dict:
1663
+
1664
+
1665
+
1666
+
1667
+
1668
+
1669
+
1670
+ schema = template_config.get("customPayloadSchema") or {}
1671
+ frame = ((template_config.get("capabilities") or {}).get("frameLevelKeys") or {}).get("keys") or []
1672
+ head = parts[0]
1673
+ if isinstance(schema, dict) and isinstance(schema.get(head), dict):
1674
+ node = schema[head]
1675
+ elif head in frame:
1676
+ node = {}
1677
+ else:
1678
+ declared = sorted(set(k for k in schema if not k.startswith("_")) | set(frame))
1679
+ raise ValueError(
1680
+ f"--payload {spec!r}: {head!r} is not a customPayload key of this template; "
1681
+ f"declared keys: {', '.join(declared) or '(none)'}"
1682
+ )
1683
+ for depth, part in enumerate(parts[1:], start=1):
1684
+ props = node.get("properties")
1685
+ if isinstance(props, dict) and isinstance(props.get(part), dict):
1686
+ node = props[part]
1687
+ elif isinstance(props, dict) and props and node.get("additionalProperties") is not True:
1688
+ raise ValueError(
1689
+ f"--payload {spec!r}: {'.'.join(parts[:depth])} has no {part!r}; "
1690
+ f"declared: {', '.join(sorted(props))}"
1691
+ )
1692
+ else:
1693
+ node = {}
1694
+ return node
1695
+
1696
+
1697
+ def _coerce_payload_value(raw: str, node: dict, spec: str):
1698
+
1699
+ declared = node.get("type")
1700
+ types = [declared] if isinstance(declared, str) else list(declared or [])
1701
+ if types and not set(types) & {"string", "boolean", "number", "integer"}:
1702
+ raise ValueError(
1703
+ f"--payload {spec!r}: this key holds {'/'.join(types)}, not a single value; "
1704
+ f"set one of its fields instead (KEY.FIELD=VALUE)."
1705
+ )
1706
+ value: object = raw
1707
+ if "boolean" in types:
1708
+ word = raw.strip().lower()
1709
+ if word in _TRUE_WORDS:
1710
+ value = True
1711
+ elif word in _FALSE_WORDS:
1712
+ value = False
1713
+ elif "string" not in types:
1714
+ raise ValueError(f"--payload {spec!r}: expected true or false")
1715
+ if value is raw and ("number" in types or "integer" in types):
1716
+ try:
1717
+ number = float(raw)
1718
+ except ValueError:
1719
+ if "string" not in types:
1720
+ raise ValueError(f"--payload {spec!r}: expected a number") from None
1721
+ else:
1722
+ if "integer" in types and "number" not in types:
1723
+ if not number.is_integer():
1724
+ raise ValueError(f"--payload {spec!r}: expected a whole number")
1725
+ value = int(number)
1726
+ else:
1727
+ value = int(number) if number.is_integer() else number
1728
+ lo, hi = node.get("minimum"), node.get("maximum")
1729
+ if (lo is not None and value < lo) or (hi is not None and value > hi):
1730
+ raise ValueError(f"--payload {spec!r}: must be within {lo}..{hi}")
1731
+ enum = node.get("enum")
1732
+ if isinstance(enum, list) and value not in enum:
1733
+ raise ValueError(f"--payload {spec!r}: choose one of: {', '.join(str(v) for v in enum)}")
1734
+ return value
1735
+
1736
+
1737
+ def _apply_scene_payload(
1738
+ dsl: dict,
1739
+ template_config: dict | None,
1740
+ template_id: str | None,
1741
+ payload_specs: list[str] | None,
1742
+ motion: str | None,
1743
+ ) -> None:
1744
+
1745
+
1746
+
1747
+
1748
+
1749
+
1750
+
1751
+ if not payload_specs and not motion:
1752
+ return
1753
+ if not template_id or template_config is None:
1754
+ raise ValueError("--payload / --motion need --template-id: the allowed keys come from the template.")
1755
+
1756
+ writes = _parse_payload_specs(template_config, payload_specs)
1757
+ if motion:
1758
+ allowed = ("static", "kenburns-in", "kenburns-out", "pan-left", "pan-right", "float")
1759
+ if motion not in allowed:
1760
+ raise ValueError(f"--motion {motion!r}: choose one of: {', '.join(allowed)}")
1761
+
1762
+ for scene in dsl.get("scenes", []):
1763
+ _write_payload(scene.setdefault("customPayload", {}), writes)
1764
+ if motion:
1765
+ hints = scene.get("animationHints") if isinstance(scene.get("animationHints"), dict) else {}
1766
+ scene["animationHints"] = {**hints, "motion": motion}
1767
+ for parts, _ in writes:
1768
+ _lock_scene_path(dsl, "customPayload." + ".".join(parts))
1769
+ if motion:
1770
+ _lock_scene_path(dsl, "animationHints.motion")
1771
+
1772
+
1773
+ def _parse_payload_specs(template_config: dict, payload_specs: list[str] | None) -> PayloadWrites:
1774
+
1775
+ writes: PayloadWrites = []
1776
+ for spec in payload_specs or []:
1777
+ key, sep, raw = spec.partition("=")
1778
+ parts = [p.strip() for p in key.split(".")]
1779
+ if not sep or not all(parts):
1780
+ raise ValueError(f"--payload {spec!r}: expected KEY=VALUE, e.g. grade=四年级 or carousel.fit=cover")
1781
+ raw = raw.strip()
1782
+ if not raw:
1783
+ raise ValueError(f"--payload {spec!r}: empty value; leave the flag out to keep the template default")
1784
+ node = _payload_node(template_config, parts, spec)
1785
+ writes.append((parts, _coerce_payload_value(raw, node, spec)))
1786
+ return writes
1787
+
1788
+
1789
+ def _write_payload(custom_payload: dict, writes: PayloadWrites) -> None:
1790
+
1791
+ for parts, value in writes:
1792
+ node = custom_payload
1793
+ for part in parts[:-1]:
1794
+ if not isinstance(node.get(part), dict):
1795
+ node[part] = {}
1796
+ node = node[part]
1797
+ node[parts[-1]] = value
1798
+
1799
+
1498
1800
  def build_dsl(
1499
1801
  topic: str,
1500
1802
  duration: int,
@@ -1518,6 +1820,10 @@ def build_dsl(
1518
1820
  scene_images: list[str] | None = None,
1519
1821
  item_count: int | None = None,
1520
1822
  source_document_url: str | None = None,
1823
+ variant: str | None = None,
1824
+ sequence_preset: str | None = None,
1825
+ sequence_fit: str | None = None,
1826
+ payload_writes: PayloadWrites | None = None,
1521
1827
  ) -> dict:
1522
1828
 
1523
1829
 
@@ -1655,10 +1961,17 @@ def build_dsl(
1655
1961
  payload_defaults=contract["payload_defaults"],
1656
1962
  duration_strategy=contract["duration_strategy"],
1657
1963
  duration_model=contract["duration_model"],
1964
+ sequence_schema=((template_config or {}).get("customPayloadSchema") or {}).get("sequence"),
1965
+ sequence_preset=sequence_preset,
1966
+ sequence_fit=sequence_fit,
1967
+ payload_writes=payload_writes,
1658
1968
  )
1659
1969
  _enforce_supported_duration(carousel_dsl, template_config, template_id)
1660
1970
  return carousel_dsl
1661
1971
 
1972
+ if sequence_preset not in (None, "none") or sequence_fit not in (None, "auto"):
1973
+ raise ValueError(f"--sequence-preset / --sequence-fit only apply to image-sequence templates; {template_id!r} is not one")
1974
+
1662
1975
 
1663
1976
 
1664
1977
 
@@ -1708,16 +2021,33 @@ def build_dsl(
1708
2021
 
1709
2022
 
1710
2023
 
1711
- image_style_guide = resolve_style_guide(template_config) if _HAS_ENHANCER else {}
2024
+
2025
+
2026
+
2027
+ image_style_guide = (
2028
+ resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
2029
+ )
1712
2030
  image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
1713
2031
  image_ratio = image_style_guide.get("ratio") or ratio
1714
2032
 
1715
2033
  tail_pad_sec = _nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None
2034
+
2035
+
2036
+
2037
+
2038
+ shared_background = (
2039
+ contract["background_strategy"] == "shared"
2040
+ and primary_visual_type == "image"
2041
+ and not provided_slots
2042
+ )
2043
+ shared_background_planned = False
1716
2044
  for idx, plan in enumerate(scene_plans):
1717
2045
  scene_id = f"scene-{idx + 1:02d}"
1718
2046
 
1719
2047
  if primary_visual_type == "video":
1720
2048
  visual_asset_id = f"video-{scene_id}"
2049
+ elif shared_background:
2050
+ visual_asset_id = "img-background"
1721
2051
  else:
1722
2052
  visual_asset_id = f"img-{scene_id}"
1723
2053
  narration_asset_id = f"narration-{scene_id}" if needs_narration else None
@@ -1733,7 +2063,11 @@ def build_dsl(
1733
2063
  scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
1734
2064
 
1735
2065
 
1736
- if scene_has_visual and primary_visual_type == "image":
2066
+ if scene_has_visual and shared_background and shared_background_planned:
2067
+ pass
2068
+ elif scene_has_visual and primary_visual_type == "image":
2069
+ if shared_background:
2070
+ shared_background_planned = True
1737
2071
  slot = provided_slots[idx] if idx < len(provided_slots) else ""
1738
2072
  slot_is_ai, slot_prompt = _parse_ai_slot(slot)
1739
2073
  if slot and not slot_is_ai:
@@ -1760,6 +2094,12 @@ def build_dsl(
1760
2094
  else:
1761
2095
  if image_compose:
1762
2096
  image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
2097
+ elif shared_background:
2098
+
2099
+
2100
+ image_result = generate_image_prompt(
2101
+ "point", topic, style, narration_text="", style_guide=image_style_guide,
2102
+ )
1763
2103
  else:
1764
2104
  image_result = generate_image_prompt(
1765
2105
  plan["purpose"], topic, style,
@@ -2082,7 +2422,8 @@ Examples:
2082
2422
  "--variant", default=None,
2083
2423
  help="Template visual variant id (a key of the template's `variants`, listed as variantIds by "
2084
2424
  "template-registry --list-templates). Written to meta.templateVariant, which overrides the "
2085
- "fuzzy --style match. When the user picked a style in the replicate card (shown as "
2425
+ "fuzzy --style match, and the variant's imageStyleGuide drives the skeleton's image "
2426
+ "prompts. When the user picked a style in the replicate card (shown as "
2086
2427
  "`名字(id)`, e.g. `紫色科技(tech-purple)`), pass that id. Requires --template-id.",
2087
2428
  )
2088
2429
  parser.add_argument(
@@ -2091,6 +2432,38 @@ Examples:
2091
2432
  "Written to every scene's customPayload.background.preset. When the user picked a background in "
2092
2433
  "the replicate card (e.g. `纯色(solid)`), pass that id. Requires --template-id.",
2093
2434
  )
2435
+ parser.add_argument(
2436
+ "--sequence-preset", default=None,
2437
+ help="Image-sequence style preset (one of the template's customPayloadSchema.sequence.preset enum, "
2438
+ "e.g. vlog / news / poster / cinematic / fast-cut), written to customPayload.sequence.preset; "
2439
+ "`none` keeps the template defaults. When the user picked a style preset in the replicate card "
2440
+ "(e.g. `快剪(fast-cut)`), pass that id. Only for templates declaring payloadDefaults.sequence.",
2441
+ )
2442
+ parser.add_argument(
2443
+ "--sequence-fit", default=None,
2444
+ help="How image-sequence media fill the frame (customPayloadSchema.sequence.fit enum: cover = fill and crop, "
2445
+ "contain = show whole with a blurred backdrop), written to customPayload.sequence.fit; `auto` keeps "
2446
+ "the template / preset default. When the user picked it in the replicate card, pass that id.",
2447
+ )
2448
+ parser.add_argument(
2449
+ "--background-asset", default=None,
2450
+ help="URL of the user's background image or video (.mp4/.mov/.webm → video). Added as an existing "
2451
+ "asset and referenced from every scene's customPayload.background (assetRef for an image, "
2452
+ "videoAssetRef for a video); the preset becomes image / video. Requires --template-id and a "
2453
+ "template whose background enum offers that kind.",
2454
+ )
2455
+ parser.add_argument(
2456
+ "--payload", action="append", default=None, metavar="KEY=VALUE",
2457
+ help="Set a template customPayload field on every scene, e.g. --payload grade=四年级 "
2458
+ "--payload carousel.fit=cover --payload templateData.revealMode=progressive. KEY is a dotted path "
2459
+ "checked against the template's customPayloadSchema; VALUE is converted to its declared type "
2460
+ "(true/false, numbers) and checked against its enum / range. Repeatable. Requires --template-id.",
2461
+ )
2462
+ parser.add_argument(
2463
+ "--motion", default=None,
2464
+ help="Camera motion for every scene (animationHints.motion): static / kenburns-in / kenburns-out / "
2465
+ "pan-left / pan-right / float. Replaces the skeleton's alternating default.",
2466
+ )
2094
2467
  parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
2095
2468
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
2096
2469
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
@@ -2189,7 +2562,9 @@ Examples:
2189
2562
  sys.exit(1)
2190
2563
  with open(args.input, "r", encoding="utf-8") as f:
2191
2564
  dsl = json.load(f)
2192
- errors = validate_dsl(dsl)
2565
+ errors, warnings = validate_authored_dsl(dsl)
2566
+ for w in warnings:
2567
+ print(f"⚠️ {w}", file=sys.stderr)
2193
2568
  if errors:
2194
2569
  print("❌ DSL validation failed:", file=sys.stderr)
2195
2570
  for err in errors:
@@ -2292,6 +2667,16 @@ Examples:
2292
2667
  file=sys.stderr,
2293
2668
  )
2294
2669
 
2670
+ try:
2671
+
2672
+
2673
+ payload_writes = (
2674
+ _parse_payload_specs(_load_template_config(args.template_id) or {}, args.payload)
2675
+ if args.payload and args.template_id else []
2676
+ )
2677
+ except ValueError as exc:
2678
+ parser.error(str(exc))
2679
+
2295
2680
  try:
2296
2681
  dsl = build_dsl(
2297
2682
  topic=args.topic,
@@ -2316,6 +2701,10 @@ Examples:
2316
2701
  scene_images=args.scene_images,
2317
2702
  item_count=args.item_count,
2318
2703
  source_document_url=args.source_document_url,
2704
+ variant=args.variant,
2705
+ sequence_preset=args.sequence_preset,
2706
+ sequence_fit=args.sequence_fit,
2707
+ payload_writes=payload_writes,
2319
2708
  )
2320
2709
  except ValueError as exc:
2321
2710
 
@@ -2324,13 +2713,16 @@ Examples:
2324
2713
  parser.error(str(exc))
2325
2714
 
2326
2715
  try:
2716
+ look_config = _load_template_config(args.template_id) if args.template_id else None
2327
2717
  _apply_look(
2328
2718
  dsl,
2329
- _load_template_config(args.template_id) if args.template_id else None,
2719
+ look_config,
2330
2720
  args.template_id,
2331
2721
  args.variant,
2332
2722
  args.background_preset,
2723
+ args.background_asset,
2333
2724
  )
2725
+ _apply_scene_payload(dsl, look_config, args.template_id, args.payload, args.motion)
2334
2726
  except ValueError as exc:
2335
2727
  parser.error(str(exc))
2336
2728
 
@@ -54,7 +54,38 @@
54
54
  },
55
55
  "background_preset": {
56
56
  "type": "string",
57
- "description": "Scene background preset, one of the template's customPayloadSchema.background.preset values. Written to every scene's customPayload.background.preset. **Must** pass when the user picked a background: the replicate card shows `背景样式: 纯色(solid)` → pass `solid`. Without it the scene keeps the template default. Requires template_id; unsupported values are rejected. `image` / `video` also need customPayload.background.assetRef on each scene."
57
+ "description": "Scene background preset, one of the template's customPayloadSchema.background.preset values. Written to every scene's customPayload.background.preset. **Must** pass when the user picked a background: the replicate card shows `背景样式: 纯色(solid)` → pass `solid`. Without it the scene keeps the template default. Requires template_id; unsupported values are rejected. `image` / `video` need the user's file: pass it as background_asset (which also sets the preset)."
58
+ },
59
+ "sequence_preset": {
60
+ "type": "string",
61
+ "description": "Image-sequence style preset, one of the template's customPayloadSchema.sequence.preset values (image-to-video: vlog / news / poster / cinematic / fast-cut). Written to customPayload.sequence.preset; the preset sets transitions, motion, text layout and pacing (fast-cut shortens every image to 1.5s, and the scene duration follows). **Must** pass when the user picked a style preset: the replicate card shows `风格预设: 快剪(fast-cut)` → pass `fast-cut`; `none` keeps the template defaults. Without it the choice is silently dropped. Only for templates declaring capabilities.payloadDefaults.sequence; unsupported values are rejected."
62
+ },
63
+ "sequence_fit": {
64
+ "type": "string",
65
+ "description": "How image-sequence media fill the frame, one of customPayloadSchema.sequence.fit (cover = fill the frame and crop, contain = show the whole picture over a blurred backdrop). Written to customPayload.sequence.fit and beats the preset's own fit. **Must** pass when the user picked it: `显示方式: 完整显示(contain)` → pass `contain`; `auto` keeps the template / preset default. Only for templates declaring capabilities.payloadDefaults.sequence."
66
+ },
67
+ "background_asset": {
68
+ "type": "string",
69
+ "description": "URL of the user's background image or video (the replicate card's 背景素材 / 背景图片 asset line → its URL). Added as an existing asset and referenced from every scene's customPayload.background (assetRef for an image, videoAssetRef for a video; .mp4/.mov/.webm/.m4v count as video); the background preset becomes image / video, so background_preset can be omitted. **Must** pass when the user gave a background file — without it a preset of image / video renders the template's plain default. Requires template_id; rejected when the template has no background of that kind."
70
+ },
71
+ "payload": {
72
+ "type": "array",
73
+ "items": {
74
+ "type": "string"
75
+ },
76
+ "description": "Template-specific card choices as `KEY=VALUE`, written to every scene's customPayload and locked so the dsl_json you save cannot overwrite them. KEY is a dotted path into the template's customPayloadSchema (e.g. `grade=四年级`, `watermarkText=WorkBuddy 实战课`, `preset=course`, `carousel.fit=cover`, `templateData.revealMode=progressive`, `cardStyle.championFx=false`); VALUE is the id inside the card line's parentheses, or the text verbatim for free-text fields. Booleans take true/false, numbers are range-checked, enums are checked — an undeclared key or value is rejected with the allowed list. **Must** pass every card line that the system prompt maps to a payload key; without it the choice is silently dropped. Requires template_id."
77
+ },
78
+ "motion": {
79
+ "type": "string",
80
+ "enum": [
81
+ "static",
82
+ "kenburns-in",
83
+ "kenburns-out",
84
+ "pan-left",
85
+ "pan-right",
86
+ "float"
87
+ ],
88
+ "description": "Camera motion for every scene (scenes[].animationHints.motion), replacing the skeleton's alternating push-in / pull-out. **Must** pass when the user picked one: `运镜效果: 缓慢推近(kenburns-in)` → `kenburns-in`."
58
89
  },
59
90
  "font_id": {
60
91
  "type": "string",
@@ -70,12 +101,18 @@
70
101
  },
71
102
  "template_source": {
72
103
  "type": "string",
73
- "enum": ["user-explicit", "user-confirmed", "auto"],
104
+ "enum": [
105
+ "user-explicit",
106
+ "user-confirmed",
107
+ "auto"
108
+ ],
74
109
  "description": "How template_id was chosen; recorded at meta.templateSelection.source. **Always pass it with template_id.** `user-explicit`: the user named this template — its id or name, a pasted template detail-page prompt or link, or it was settled earlier in this conversation. `user-confirmed`: the user did not name one, you offered candidates and the user picked or approved this one in the chat. `auto`: you picked it yourself and the user has not confirmed — gen_script refuses, so instead of calling it, show the candidates and end the turn. Never mark a template user-confirmed that the user has not seen and approved."
75
110
  },
76
111
  "template_candidates": {
77
112
  "type": "array",
78
- "items": { "type": "string" },
113
+ "items": {
114
+ "type": "string"
115
+ },
79
116
  "description": "The template ids you offered the user, recommended first; recorded at meta.templateSelection.candidates. Pass with template_source=user-confirmed — template_id must then be one of them (a template the user named outside the shortlist is user-explicit)."
80
117
  },
81
118
  "source_document_url": {
@@ -95,7 +132,7 @@
95
132
  "items": {
96
133
  "type": "string"
97
134
  },
98
- "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."
135
+ "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. Image sequences (payloadDefaults.sequence) accept video URLs too (.mp4/.mov/.webm): gen_script probes each clip's real length, marks it type=video and plays it whole and muted; an unreadable video is rejected."
99
136
  },
100
137
  "scene_images": {
101
138
  "type": "array",
@@ -109,7 +146,7 @@
109
146
  "items": {
110
147
  "type": "string"
111
148
  },
112
- "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."
149
+ "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. A replicate asset line's `(… desc: <text>)` suffix is the user's caption for that image: pass it here in the same order (use an empty string for images without one). 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."
113
150
  },
114
151
  "stub_image_url": {
115
152
  "type": "string",
@@ -149,6 +186,11 @@
149
186
  "speed",
150
187
  "variant",
151
188
  "background_preset",
189
+ "sequence_preset",
190
+ "sequence_fit",
191
+ "background_asset",
192
+ "payload",
193
+ "motion",
152
194
  "font_id",
153
195
  "font_name",
154
196
  "list_fonts",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V21",
5
+ "version": "V22",
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
  }
@@ -779,6 +779,12 @@ def _probe_video_duration(url: str):
779
779
  return probe_video_duration(url)
780
780
 
781
781
 
782
+ def _probe_video_meta(url: str) -> dict:
783
+
784
+ from _video_probe import probe_video_meta
785
+ return probe_video_meta(url)
786
+
787
+
782
788
  def build_render_plan(dsl: dict, binding: dict) -> dict:
783
789
 
784
790
  fps = dsl.get("global", {}).get("fps", 30)
@@ -868,11 +874,35 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
868
874
 
869
875
 
870
876
 
877
+
878
+
879
+
880
+
881
+
882
+
883
+
884
+ meta_by_url: dict = {}
871
885
  for aid, asset in assets_by_id.items():
872
- if asset["type"] == "video" and asset["source"] == "existing" and not asset.get("duration") and asset.get("url"):
873
- probed = _probe_video_duration(asset["url"])
874
- if probed:
875
- asset["duration"] = probed
886
+ url = asset.get("url")
887
+ if asset["type"] != "video" or asset["source"] != "existing" or not url:
888
+ continue
889
+ need_size = not (asset.get("width") and asset.get("height"))
890
+ need_duration = not asset.get("duration")
891
+ if not (need_size or need_duration):
892
+ continue
893
+ meta = meta_by_url.get(url)
894
+ if meta is None and need_size:
895
+ meta = meta_by_url[url] = _probe_video_meta(url)
896
+ meta = meta or {}
897
+ if need_size and meta.get("width") and meta.get("height"):
898
+ asset["width"], asset["height"] = meta["width"], meta["height"]
899
+ if need_duration:
900
+ if meta.get("duration_ms"):
901
+ asset["duration"] = meta["duration_ms"] / 1000.0
902
+ else:
903
+ probed = _probe_video_duration(url)
904
+ if probed:
905
+ asset["duration"] = probed
876
906
 
877
907
  timeline = []
878
908
  current_frame = 0
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V28",
5
+ "version": "V29",
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
  }
@@ -1014,6 +1014,10 @@ def _walk_payload(
1014
1014
 
1015
1015
 
1016
1016
 
1017
+
1018
+
1019
+
1020
+
1017
1021
  errors = _check_payload_object(
1018
1022
  data,
1019
1023
  schema,
@@ -1086,6 +1090,10 @@ def _check_payload_object(
1086
1090
  "the slide renders its built-in placeholder instead of your content.",
1087
1091
  ))
1088
1092
 
1093
+ for key, sub in merged_props.items():
1094
+ if key in data and isinstance(sub, dict):
1095
+ errors += _enum_errors(data[key], sub, path=f"{path}.{key}", scene=scene, key=key)
1096
+
1089
1097
 
1090
1098
 
1091
1099
  for key, sub in props.items():
@@ -1104,6 +1112,41 @@ def _check_payload_object(
1104
1112
  return errors
1105
1113
 
1106
1114
 
1115
+ def _enum_errors(
1116
+ value: object, schema: dict, *, path: str, scene: str, key: str
1117
+ ) -> list[ValidationError]:
1118
+
1119
+
1120
+
1121
+
1122
+
1123
+ enum = schema.get("enum")
1124
+ if isinstance(enum, list) and enum:
1125
+ if value is None or isinstance(value, (dict, list)) or value in enum:
1126
+ return []
1127
+ hint = ""
1128
+ if isinstance(value, str):
1129
+ hit = difflib.get_close_matches(
1130
+ value, [e for e in enum if isinstance(e, str)], n=1, cutoff=0.6
1131
+ )
1132
+ if hit:
1133
+ hint = f" Did you mean '{hit[0]}'?"
1134
+ allowed = ", ".join(repr(e) for e in enum)
1135
+ return [ValidationError(
1136
+ path,
1137
+ f"scene '{scene}': '{key}' is {value!r}, which is not one of [{allowed}] — the "
1138
+ "template ignores an unknown value and falls back to its default, so the look "
1139
+ "you asked for silently never appears." + hint,
1140
+ )]
1141
+ items = schema.get("items")
1142
+ if schema.get("type") == "array" and isinstance(items, dict) and isinstance(value, list):
1143
+ errors: list[ValidationError] = []
1144
+ for i, item in enumerate(value):
1145
+ errors += _enum_errors(item, items, path=f"{path}[{i}]", scene=scene, key=f"{key}[{i}]")
1146
+ return errors
1147
+ return []
1148
+
1149
+
1107
1150
  def _is_blank(value: object) -> bool:
1108
1151
 
1109
1152
 
@@ -59,13 +59,22 @@ def required_payload_errors(template: dict, props: dict) -> list[str]:
59
59
 
60
60
  def asset_references(value, path: str = "", *, bound_props: bool = False):
61
61
 
62
+
63
+
64
+
65
+
66
+
62
67
  if isinstance(value, list):
63
68
  for index, item in enumerate(value):
64
69
  yield from asset_references(item, f"{path}[{index}]", bound_props=bound_props)
65
70
  elif isinstance(value, dict):
66
71
  for key, child in value.items():
67
72
  child_path = f"{path}.{key}" if path else key
68
- if key == "assetRef" or (bound_props and key.endswith("AssetId")):
73
+ if (
74
+ key == "assetRef"
75
+ or key.endswith("AssetRef")
76
+ or (bound_props and key.endswith("AssetId"))
77
+ ):
69
78
  if isinstance(child, str) and child.strip():
70
79
  yield child_path, child
71
80
  else:
@@ -462,7 +462,9 @@ def compose_dsl_image_prompts(dsl: dict, template: Optional[dict]) -> list[str]:
462
462
 
463
463
 
464
464
  meta = dsl.get("meta") or {}
465
- variant_id = str(meta.get("variantId") or "")
465
+
466
+
467
+ variant_id = str(meta.get("templateVariant") or meta.get("variantId") or "")
466
468
  if not is_compose_mode(resolve_style_guide(template, variant_id)):
467
469
  return []
468
470
 
@@ -55,6 +55,11 @@
55
55
  },
56
56
  "audience": { "type": "string" },
57
57
  "description": { "type": "string" },
58
+ "lockedScenePaths": {
59
+ "type": "array",
60
+ "items": { "type": "string" },
61
+ "description": "gen_script 用 CLI 参数(--payload / --background-asset / --background-preset / --motion)写进每个场景的值,按场景相对路径记录(如 customPayload.carousel.fit、animationHints.motion)。merge 模式合并模型提交的 DSL 后,ab-agent 按这张表把骨架里的值写回,模型写的同名对象盖不掉用户在参数卡里的选择。"
62
+ },
58
63
  "templateSelection": {
59
64
  "type": "object",
60
65
  "description": "模板是怎么定下来的(gen_script --template-source / --template-candidates 写入)。user-explicit = 用户点名;user-confirmed = agent 推荐、用户确认。未经确认的 auto 选择不会出现在这里:gen_script 直接拒绝生成。",