@remixmate/cli 0.12.7 → 0.12.9

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.7",
4
- "generatedAt": "2026-10-20T11:33:13.520Z",
3
+ "version": "0.12.9",
4
+ "generatedAt": "2026-10-21T03:02:36.247Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
@@ -592,7 +592,49 @@
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`."
627
+ },
628
+ "ending_payload": {
629
+ "type": "array",
630
+ "items": {
631
+ "type": "string"
632
+ },
633
+ "description": "Like payload, but `KEY=VALUE` is written only to the ending scene and locked there (e.g. `recap.cta=关注我,每周更新开源热榜` for github-repo-rank's 片尾引导). On countdown templates (Top-N lists) it also appends the ending scene after #1 — fill that scene's narration (a closing line) and its customPayload.item with the #1 entry. Without it a countdown ends on the #1 card. Requires template_id."
634
+ },
635
+ "avatar_id": {
636
+ "type": "string",
637
+ "description": "Digital-human avatar id (`dh_xxx`, the replicate card's 数字人主讲 / 数字人讲师 line) for templates declaring capabilities.digitalHuman (image-slide, ppt-to-video, html-slide, …). Adds one gen-digital-human asset referenced by every scene's visuals.avatar.assetRef: one continuous presenter whose lips follow the narration. Placement stays the template default; adjust with payload `digitalHuman.<field>=…`. Copy the id verbatim, never invent one. Costs digital-human credits, so pass it only when the user picked an avatar. Requires template_id."
596
638
  },
597
639
  "font_id": {
598
640
  "type": "string",
@@ -639,7 +681,7 @@
639
681
  "items": {
640
682
  "type": "string"
641
683
  },
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."
684
+ "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
685
  },
644
686
  "scene_images": {
645
687
  "type": "array",
@@ -653,7 +695,7 @@
653
695
  "items": {
654
696
  "type": "string"
655
697
  },
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."
698
+ "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
699
  },
658
700
  "stub_image_url": {
659
701
  "type": "string",
@@ -693,6 +735,13 @@
693
735
  "speed",
694
736
  "variant",
695
737
  "background_preset",
738
+ "sequence_preset",
739
+ "sequence_fit",
740
+ "background_asset",
741
+ "payload",
742
+ "ending_payload",
743
+ "avatar_id",
744
+ "motion",
696
745
  "font_id",
697
746
  "font_name",
698
747
  "list_fonts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
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",
@@ -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
 
@@ -480,7 +481,14 @@ python3 <SkillDir>/scripts/gen_script.py \
480
481
  | `--voice-id` | Narration voice id. | resolved from template + language fallback |
481
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` |
482
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 |
483
- | `--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 |
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
+ | `--ending-payload KEY=VALUE` | Same as `--payload`, but written only to the ending scene, e.g. `recap.cta=关注我,每周更新开源热榜`. On countdown templates it also appends an ending scene after #1 (it carries `item.rank=1`; fill its narration and `item`). Errors when the script has no ending scene. | none |
490
+ | `--avatar-id` | Digital-human avatar id (`dh_xxx`) for templates declaring `capabilities.digitalHuman`. Adds asset `asset-avatar` (`type: avatar`, `source: gen-digital-human`, `payload.avatarId`) and sets `visuals.avatar.assetRef` on every scene whose slot reads it — one continuous presenter driven by the narration. Placement: template default, or `--payload digitalHuman.<field>=…` (a photo-only presenter is `--payload digitalHuman.posterUrl=<url>` without this flag). | none |
491
+ | `--motion` | Camera motion for every scene (`animationHints.motion`): `static` / `kenburns-in` / `kenburns-out` / `pan-left` / `pan-right` / `float`. | skeleton alternates in / out |
484
492
  | `--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 |
485
493
  | `--font-name` | Display name of the picked font → `global.font.fontName` (display only, never used for rendering). Pass it with `--font-id`. | — |
486
494
  | `--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. | — |
@@ -497,6 +505,8 @@ python3 <SkillDir>/scripts/gen_script.py \
497
505
  | `--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. | — |
498
506
  | `--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. | — |
499
507
 
508
+ Values written by `--background-preset`, `--background-asset`, `--payload`, `--ending-payload`, `--avatar-id` 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. Templates that author their own scenes (`meta.authoredScenes`, e.g. screen-walkthrough) are not merged: there ab-agent writes each locked value that the skeleton holds on every scene (with one value) onto every submitted scene, and adds the skeleton assets those values reference (`asset-background`, `asset-avatar`) when the submitted `assets` lacks them.
509
+
500
510
  ## DSL generation principles
501
511
 
502
512
  Apply the following principles when producing the DSL:
@@ -1089,15 +1089,63 @@ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int =
1089
1089
  rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
1090
1090
  if rule_hold is None:
1091
1091
  raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
1092
- base_hold = sequence.get("defaultHoldSec", rule_hold)
1093
- 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")))
1094
1097
  margin = timing.get("minHoldMarginFrames")
1095
1098
  if transition_ms is None or margin is None:
1096
1099
  raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
1097
1100
 
1098
1101
  min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
1099
- return sum(max(min_frames, math.floor(item.get("holdSec", base_hold) * fps + 0.5))
1100
- 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]]
1101
1149
 
1102
1150
 
1103
1151
  def _build_carousel_caption_dsl(
@@ -1121,6 +1169,10 @@ def _build_carousel_caption_dsl(
1121
1169
  payload_defaults: dict,
1122
1170
  duration_strategy: str | None,
1123
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,
1124
1176
  ) -> dict:
1125
1177
 
1126
1178
 
@@ -1134,16 +1186,30 @@ def _build_carousel_caption_dsl(
1134
1186
  raise ValueError("Image sequence needs --carousel-items; caption text alone cannot supply images")
1135
1187
  if sequence_mode and len(caption_lines) > len(carousel_items):
1136
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")
1137
1196
 
1138
1197
 
1139
1198
  assets = []
1140
1199
  carousel_asset_ids = []
1200
+ video_seconds: dict[str, float] = {}
1141
1201
  for idx, url in enumerate(carousel_items):
1142
1202
  asset_id = f"carousel-img-{idx + 1:02d}"
1143
1203
 
1144
1204
  is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
1145
1205
  if sequence_mode and is_video:
1146
- 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)
1147
1213
  assets.append({
1148
1214
  "assetId": asset_id,
1149
1215
  "type": "video" if is_video else "image",
@@ -1214,15 +1280,25 @@ def _build_carousel_caption_dsl(
1214
1280
 
1215
1281
 
1216
1282
  if sequence_mode:
1217
- 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
1218
1289
  sequence_config["items"] = [
1219
1290
  {"assetRef": asset_id,
1220
- **({"caption": caption_lines[index]} if index < len(caption_lines) else {})}
1221
- 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)
1222
1294
  ]
1223
1295
  custom_payload = {"sequence": sequence_config}
1224
1296
 
1225
1297
 
1298
+
1299
+ _write_payload(custom_payload, payload_writes or [])
1300
+
1301
+
1226
1302
  narration_text = _localize_label(output_language, "narration_opening",
1227
1303
  label="", topic=_clip(topic, _PLACEHOLDER_TOPIC_CHARS), duration=duration)
1228
1304
 
@@ -1354,7 +1430,12 @@ def _build_custom_payload(template_config: dict | None) -> dict:
1354
1430
  return {}
1355
1431
 
1356
1432
 
1357
- def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
1433
+ def _plan_countdown_scenes(
1434
+ spec: dict, count: int, duration: float, headline: str, ending: bool = False
1435
+ ) -> list:
1436
+
1437
+
1438
+
1358
1439
 
1359
1440
 
1360
1441
 
@@ -1364,11 +1445,14 @@ def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: st
1364
1445
 
1365
1446
 
1366
1447
  head_sec = countdown_opening_sec(spec, count, duration)
1367
- beat_sec = (duration - head_sec) / count
1368
- return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
1448
+ beat_sec = (duration - head_sec) / (count + (1 if ending else 0))
1449
+ plans = [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
1369
1450
  {"purpose": "point", "duration": beat_sec, "label": f"#{index}", "countdownIndex": index}
1370
1451
  for index in range(count, 0, -1)
1371
1452
  ]
1453
+ if ending:
1454
+ plans.append({"purpose": "ending", "duration": beat_sec, "label": "#1", "countdownIndex": 1})
1455
+ return plans
1372
1456
 
1373
1457
 
1374
1458
  def _warn_on_count_mismatch(spec: dict, headline: str, subheadline: str, count: int) -> None:
@@ -1472,12 +1556,29 @@ def _template_selection(
1472
1556
  return selection
1473
1557
 
1474
1558
 
1559
+
1560
+
1561
+
1562
+
1563
+
1564
+ LOCKED_SCENE_PATHS_KEY = "lockedScenePaths"
1565
+ BACKGROUND_ASSET_ID = "asset-background"
1566
+ _VIDEO_EXTENSIONS = (".mp4", ".mov", ".webm", ".avi", ".m4v")
1567
+
1568
+
1569
+ def _lock_scene_path(dsl: dict, path: str) -> None:
1570
+ locks = dsl.setdefault("meta", {}).setdefault(LOCKED_SCENE_PATHS_KEY, [])
1571
+ if path not in locks:
1572
+ locks.append(path)
1573
+
1574
+
1475
1575
  def _apply_look(
1476
1576
  dsl: dict,
1477
1577
  template_config: dict | None,
1478
1578
  template_id: str | None,
1479
1579
  variant: str | None,
1480
1580
  background_preset: str | None,
1581
+ background_asset: str | None = None,
1481
1582
  ) -> None:
1482
1583
 
1483
1584
 
@@ -1491,10 +1592,17 @@ def _apply_look(
1491
1592
 
1492
1593
 
1493
1594
 
1494
- if not variant and not background_preset:
1595
+
1596
+
1597
+
1598
+
1599
+ if not variant and not background_preset and not background_asset:
1495
1600
  return
1496
1601
  if not template_id or template_config is None:
1497
- raise ValueError("--variant / --background-preset need --template-id: the allowed values come from the template.")
1602
+ raise ValueError(
1603
+ "--variant / --background-preset / --background-asset need --template-id: "
1604
+ "the allowed values come from the template."
1605
+ )
1498
1606
 
1499
1607
  if variant:
1500
1608
  variants = list((template_config.get("variants") or {}).keys())
@@ -1505,6 +1613,22 @@ def _apply_look(
1505
1613
  )
1506
1614
  dsl.setdefault("meta", {})["templateVariant"] = variant
1507
1615
 
1616
+ asset_url = (background_asset or "").strip()
1617
+ if asset_url:
1618
+ kind = "video" if urlsplit(asset_url).path.lower().endswith(_VIDEO_EXTENSIONS) else "image"
1619
+ if background_preset and background_preset != kind:
1620
+ raise ValueError(
1621
+ f"--background-preset {background_preset!r} conflicts with --background-asset, which is "
1622
+ f"{'a video' if kind == 'video' else 'an image'}; drop --background-preset or pass {kind!r}."
1623
+ )
1624
+ if kind not in _template_background_presets(template_config):
1625
+ raise ValueError(
1626
+ f"template {template_id!r} has no {kind} background, so --background-asset cannot be "
1627
+ f"{'a video' if kind == 'video' else 'an image'}; "
1628
+ f"its backgrounds are: {', '.join(_template_background_presets(template_config)) or '(none)'}"
1629
+ )
1630
+ background_preset = kind
1631
+
1508
1632
  if background_preset:
1509
1633
  presets = _template_background_presets(template_config)
1510
1634
  if background_preset not in presets:
@@ -1512,19 +1636,271 @@ def _apply_look(
1512
1636
  f"--background-preset {background_preset!r} is not supported by template {template_id!r}; "
1513
1637
  f"choose one of: {', '.join(presets) or '(this template has no background option)'}"
1514
1638
  )
1639
+
1640
+ ref_key = "videoAssetRef" if background_preset == "video" else "assetRef"
1515
1641
  for scene in dsl.get("scenes", []):
1516
1642
  payload = scene.setdefault("customPayload", {})
1517
1643
  existing = payload.get("background") if isinstance(payload.get("background"), dict) else {}
1518
- payload["background"] = {**existing, "preset": background_preset}
1519
- if background_preset in ("image", "video"):
1644
+ background = {**existing, "preset": background_preset}
1645
+ if asset_url:
1646
+ background[ref_key] = BACKGROUND_ASSET_ID
1647
+ payload["background"] = background
1648
+ _lock_scene_path(dsl, "customPayload.background.preset")
1649
+ if asset_url:
1650
+ dsl.setdefault("assets", []).append({
1651
+ "assetId": BACKGROUND_ASSET_ID,
1652
+ "type": background_preset,
1653
+ "source": "existing",
1654
+ "status": "generated",
1655
+ "url": asset_url,
1656
+ })
1657
+ _lock_scene_path(dsl, f"customPayload.background.{ref_key}")
1658
+ elif background_preset in ("image", "video"):
1520
1659
  print(
1521
- f"ℹ️ background preset {background_preset!r} needs a user asset: set "
1522
- f"customPayload.background.assetRef on each scene to that asset's id, "
1523
- f"otherwise the template falls back to its default background.",
1660
+ f"ℹ️ background preset {background_preset!r} needs a user asset: pass --background-asset "
1661
+ f"<url>, otherwise the template falls back to its default background.",
1524
1662
  file=sys.stderr,
1525
1663
  )
1526
1664
 
1527
1665
 
1666
+ _TRUE_WORDS = ("true", "1", "yes", "on", "开", "是", "显示")
1667
+ _FALSE_WORDS = ("false", "0", "no", "off", "关", "否", "隐藏")
1668
+
1669
+
1670
+ def _payload_node(template_config: dict, parts: list[str], spec: str) -> dict:
1671
+
1672
+
1673
+
1674
+
1675
+
1676
+
1677
+
1678
+ schema = template_config.get("customPayloadSchema") or {}
1679
+ frame = ((template_config.get("capabilities") or {}).get("frameLevelKeys") or {}).get("keys") or []
1680
+ head = parts[0]
1681
+ if isinstance(schema, dict) and isinstance(schema.get(head), dict):
1682
+ node = schema[head]
1683
+ elif head in frame:
1684
+ node = {}
1685
+ else:
1686
+ declared = sorted(set(k for k in schema if not k.startswith("_")) | set(frame))
1687
+ raise ValueError(
1688
+ f"--payload {spec!r}: {head!r} is not a customPayload key of this template; "
1689
+ f"declared keys: {', '.join(declared) or '(none)'}"
1690
+ )
1691
+ for depth, part in enumerate(parts[1:], start=1):
1692
+ props = node.get("properties")
1693
+ if isinstance(props, dict) and isinstance(props.get(part), dict):
1694
+ node = props[part]
1695
+ elif isinstance(props, dict) and props and node.get("additionalProperties") is not True:
1696
+ raise ValueError(
1697
+ f"--payload {spec!r}: {'.'.join(parts[:depth])} has no {part!r}; "
1698
+ f"declared: {', '.join(sorted(props))}"
1699
+ )
1700
+ else:
1701
+ node = {}
1702
+ return node
1703
+
1704
+
1705
+ def _coerce_payload_value(raw: str, node: dict, spec: str):
1706
+
1707
+ declared = node.get("type")
1708
+ types = [declared] if isinstance(declared, str) else list(declared or [])
1709
+ if types and not set(types) & {"string", "boolean", "number", "integer"}:
1710
+ raise ValueError(
1711
+ f"--payload {spec!r}: this key holds {'/'.join(types)}, not a single value; "
1712
+ f"set one of its fields instead (KEY.FIELD=VALUE)."
1713
+ )
1714
+ value: object = raw
1715
+ if "boolean" in types:
1716
+ word = raw.strip().lower()
1717
+ if word in _TRUE_WORDS:
1718
+ value = True
1719
+ elif word in _FALSE_WORDS:
1720
+ value = False
1721
+ elif "string" not in types:
1722
+ raise ValueError(f"--payload {spec!r}: expected true or false")
1723
+ if value is raw and ("number" in types or "integer" in types):
1724
+ try:
1725
+ number = float(raw)
1726
+ except ValueError:
1727
+ if "string" not in types:
1728
+ raise ValueError(f"--payload {spec!r}: expected a number") from None
1729
+ else:
1730
+ if "integer" in types and "number" not in types:
1731
+ if not number.is_integer():
1732
+ raise ValueError(f"--payload {spec!r}: expected a whole number")
1733
+ value = int(number)
1734
+ else:
1735
+ value = int(number) if number.is_integer() else number
1736
+ lo, hi = node.get("minimum"), node.get("maximum")
1737
+ if (lo is not None and value < lo) or (hi is not None and value > hi):
1738
+ raise ValueError(f"--payload {spec!r}: must be within {lo}..{hi}")
1739
+ enum = node.get("enum")
1740
+ if isinstance(enum, list) and value not in enum:
1741
+ raise ValueError(f"--payload {spec!r}: choose one of: {', '.join(str(v) for v in enum)}")
1742
+ return value
1743
+
1744
+
1745
+ def _apply_scene_payload(
1746
+ dsl: dict,
1747
+ template_config: dict | None,
1748
+ template_id: str | None,
1749
+ payload_specs: list[str] | None,
1750
+ motion: str | None,
1751
+ ) -> None:
1752
+
1753
+
1754
+
1755
+
1756
+
1757
+
1758
+
1759
+ if not payload_specs and not motion:
1760
+ return
1761
+ if not template_id or template_config is None:
1762
+ raise ValueError("--payload / --motion need --template-id: the allowed keys come from the template.")
1763
+
1764
+ writes = _parse_payload_specs(template_config, payload_specs)
1765
+ if motion:
1766
+ allowed = ("static", "kenburns-in", "kenburns-out", "pan-left", "pan-right", "float")
1767
+ if motion not in allowed:
1768
+ raise ValueError(f"--motion {motion!r}: choose one of: {', '.join(allowed)}")
1769
+
1770
+ for scene in dsl.get("scenes", []):
1771
+ _write_payload(scene.setdefault("customPayload", {}), writes)
1772
+ if motion:
1773
+ hints = scene.get("animationHints") if isinstance(scene.get("animationHints"), dict) else {}
1774
+ scene["animationHints"] = {**hints, "motion": motion}
1775
+ for parts, _ in writes:
1776
+ _lock_scene_path(dsl, "customPayload." + ".".join(parts))
1777
+ if motion:
1778
+ _lock_scene_path(dsl, "animationHints.motion")
1779
+
1780
+
1781
+ def _parse_payload_specs(template_config: dict, payload_specs: list[str] | None) -> PayloadWrites:
1782
+
1783
+ writes: PayloadWrites = []
1784
+ for spec in payload_specs or []:
1785
+ key, sep, raw = spec.partition("=")
1786
+ parts = [p.strip() for p in key.split(".")]
1787
+ if not sep or not all(parts):
1788
+ raise ValueError(f"--payload {spec!r}: expected KEY=VALUE, e.g. grade=四年级 or carousel.fit=cover")
1789
+ raw = raw.strip()
1790
+ if not raw:
1791
+ raise ValueError(f"--payload {spec!r}: empty value; leave the flag out to keep the template default")
1792
+ node = _payload_node(template_config, parts, spec)
1793
+ writes.append((parts, _coerce_payload_value(raw, node, spec)))
1794
+ return writes
1795
+
1796
+
1797
+ def _write_payload(custom_payload: dict, writes: PayloadWrites) -> None:
1798
+
1799
+ for parts, value in writes:
1800
+ node = custom_payload
1801
+ for part in parts[:-1]:
1802
+ if not isinstance(node.get(part), dict):
1803
+ node[part] = {}
1804
+ node = node[part]
1805
+ node[parts[-1]] = value
1806
+
1807
+
1808
+ def _template_has_ending_slot(template_config: dict | None) -> bool:
1809
+ return isinstance(((template_config or {}).get("slotMapping") or {}).get("ending"), dict)
1810
+
1811
+
1812
+ def _apply_ending_payload(
1813
+ dsl: dict,
1814
+ template_config: dict | None,
1815
+ template_id: str | None,
1816
+ payload_specs: list[str] | None,
1817
+ ) -> None:
1818
+
1819
+
1820
+
1821
+
1822
+
1823
+
1824
+
1825
+ if not payload_specs:
1826
+ return
1827
+ if not template_id or template_config is None:
1828
+ raise ValueError("--ending-payload needs --template-id: the allowed keys come from the template.")
1829
+ try:
1830
+ writes = _parse_payload_specs(template_config, payload_specs)
1831
+ except ValueError as exc:
1832
+ raise ValueError(str(exc).replace("--payload ", "--ending-payload ", 1)) from None
1833
+ endings = [s for s in dsl.get("scenes", []) if s.get("purpose") == "ending"]
1834
+ if not endings:
1835
+ raise ValueError(
1836
+ "--ending-payload: this script has no ending scene to write to "
1837
+ f"(scene purposes: {', '.join(str(s.get('purpose')) for s in dsl.get('scenes', []))})."
1838
+ )
1839
+ for scene in endings:
1840
+ _write_payload(scene.setdefault("customPayload", {}), writes)
1841
+ for parts, _ in writes:
1842
+ _lock_scene_path(dsl, "customPayload." + ".".join(parts))
1843
+
1844
+
1845
+ AVATAR_ASSET_ID = "asset-avatar"
1846
+
1847
+
1848
+ def _slot_extracts(template_config: dict | None, purpose: str, source: str) -> bool:
1849
+
1850
+ mapping = (template_config or {}).get("slotMapping")
1851
+ if not isinstance(mapping, dict):
1852
+ return False
1853
+ slot = mapping.get(purpose) or mapping.get("default") or {}
1854
+ seen: set = set()
1855
+ while isinstance(slot, dict) and "$ref" in slot and slot["$ref"] not in seen:
1856
+ seen.add(slot["$ref"])
1857
+ slot = mapping.get(slot["$ref"]) or {}
1858
+ extractors = slot.get("propExtractors") if isinstance(slot, dict) else None
1859
+ return isinstance(extractors, dict) and any(
1860
+ isinstance(ex, dict) and ex.get("from") == source for ex in extractors.values()
1861
+ )
1862
+
1863
+
1864
+ def _apply_avatar(
1865
+ dsl: dict,
1866
+ template_config: dict | None,
1867
+ template_id: str | None,
1868
+ avatar_id: str | None,
1869
+ ) -> None:
1870
+
1871
+
1872
+
1873
+
1874
+
1875
+
1876
+ if not avatar_id:
1877
+ return
1878
+ if not template_id or template_config is None:
1879
+ raise ValueError("--avatar-id needs --template-id: the template decides where the presenter shows.")
1880
+ support = ((template_config.get("capabilities") or {}).get("digitalHuman") or {}).get("support")
1881
+ if support not in ("optional", "required"):
1882
+ raise ValueError(f"--avatar-id: template {template_id!r} does not declare capabilities.digitalHuman")
1883
+ scenes = [
1884
+ s for s in dsl.get("scenes", [])
1885
+ if _slot_extracts(template_config, str(s.get("purpose")), "visuals.avatar.assetRef")
1886
+ ]
1887
+ if not scenes:
1888
+ raise ValueError(f"--avatar-id: no scene of template {template_id!r} reads visuals.avatar.assetRef")
1889
+ if not any((s.get("audio") or {}).get("narration") for s in scenes):
1890
+ raise ValueError("--avatar-id: the presenter is driven by the narration, and this script has none")
1891
+ for scene in scenes:
1892
+ visuals = scene.get("visuals") if isinstance(scene.get("visuals"), dict) else {}
1893
+ scene["visuals"] = {**visuals, "avatar": {"assetRef": AVATAR_ASSET_ID}}
1894
+ dsl.setdefault("assets", []).append({
1895
+ "assetId": AVATAR_ASSET_ID,
1896
+ "type": "avatar",
1897
+ "source": "gen-digital-human",
1898
+ "status": "planned",
1899
+ "payload": {"avatarId": avatar_id},
1900
+ })
1901
+ _lock_scene_path(dsl, "visuals.avatar.assetRef")
1902
+
1903
+
1528
1904
  def build_dsl(
1529
1905
  topic: str,
1530
1906
  duration: int,
@@ -1549,6 +1925,10 @@ def build_dsl(
1549
1925
  item_count: int | None = None,
1550
1926
  source_document_url: str | None = None,
1551
1927
  variant: str | None = None,
1928
+ sequence_preset: str | None = None,
1929
+ sequence_fit: str | None = None,
1930
+ payload_writes: PayloadWrites | None = None,
1931
+ countdown_ending: bool = False,
1552
1932
  ) -> dict:
1553
1933
 
1554
1934
 
@@ -1587,6 +1967,10 @@ def build_dsl(
1587
1967
  )
1588
1968
  beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
1589
1969
 
1970
+ countdown_ending = bool(countdown_ending and countdown)
1971
+ if countdown_ending and not _template_has_ending_slot(template_config):
1972
+ raise ValueError(f"--ending-payload: template {template_id!r} has no ending slot")
1973
+
1590
1974
  primary_visual_type = contract["primary_visual_type"]
1591
1975
  payload_style = contract["payload_style"]
1592
1976
 
@@ -1686,10 +2070,17 @@ def build_dsl(
1686
2070
  payload_defaults=contract["payload_defaults"],
1687
2071
  duration_strategy=contract["duration_strategy"],
1688
2072
  duration_model=contract["duration_model"],
2073
+ sequence_schema=((template_config or {}).get("customPayloadSchema") or {}).get("sequence"),
2074
+ sequence_preset=sequence_preset,
2075
+ sequence_fit=sequence_fit,
2076
+ payload_writes=payload_writes,
1689
2077
  )
1690
2078
  _enforce_supported_duration(carousel_dsl, template_config, template_id)
1691
2079
  return carousel_dsl
1692
2080
 
2081
+ if sequence_preset not in (None, "none") or sequence_fit not in (None, "auto"):
2082
+ raise ValueError(f"--sequence-preset / --sequence-fit only apply to image-sequence templates; {template_id!r} is not one")
2083
+
1693
2084
 
1694
2085
 
1695
2086
 
@@ -1698,7 +2089,9 @@ def build_dsl(
1698
2089
  provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
1699
2090
 
1700
2091
  if beat_count is not None:
1701
- scene_plans = _plan_countdown_scenes(countdown, beat_count, duration, effective_headline)
2092
+ scene_plans = _plan_countdown_scenes(
2093
+ countdown, beat_count, duration, effective_headline, ending=countdown_ending
2094
+ )
1702
2095
  else:
1703
2096
  scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1704
2097
 
@@ -2150,7 +2543,41 @@ Examples:
2150
2543
  "Written to every scene's customPayload.background.preset. When the user picked a background in "
2151
2544
  "the replicate card (e.g. `纯色(solid)`), pass that id. Requires --template-id.",
2152
2545
  )
2546
+ parser.add_argument(
2547
+ "--sequence-preset", default=None,
2548
+ help="Image-sequence style preset (one of the template's customPayloadSchema.sequence.preset enum, "
2549
+ "e.g. vlog / news / poster / cinematic / fast-cut), written to customPayload.sequence.preset; "
2550
+ "`none` keeps the template defaults. When the user picked a style preset in the replicate card "
2551
+ "(e.g. `快剪(fast-cut)`), pass that id. Only for templates declaring payloadDefaults.sequence.",
2552
+ )
2553
+ parser.add_argument(
2554
+ "--sequence-fit", default=None,
2555
+ help="How image-sequence media fill the frame (customPayloadSchema.sequence.fit enum: cover = fill and crop, "
2556
+ "contain = show whole with a blurred backdrop), written to customPayload.sequence.fit; `auto` keeps "
2557
+ "the template / preset default. When the user picked it in the replicate card, pass that id.",
2558
+ )
2559
+ parser.add_argument(
2560
+ "--background-asset", default=None,
2561
+ help="URL of the user's background image or video (.mp4/.mov/.webm → video). Added as an existing "
2562
+ "asset and referenced from every scene's customPayload.background (assetRef for an image, "
2563
+ "videoAssetRef for a video); the preset becomes image / video. Requires --template-id and a "
2564
+ "template whose background enum offers that kind.",
2565
+ )
2566
+ parser.add_argument(
2567
+ "--payload", action="append", default=None, metavar="KEY=VALUE",
2568
+ help="Set a template customPayload field on every scene, e.g. --payload grade=四年级 "
2569
+ "--payload carousel.fit=cover --payload templateData.revealMode=progressive. KEY is a dotted path "
2570
+ "checked against the template's customPayloadSchema; VALUE is converted to its declared type "
2571
+ "(true/false, numbers) and checked against its enum / range. Repeatable. Requires --template-id.",
2572
+ )
2573
+ parser.add_argument(
2574
+ "--motion", default=None,
2575
+ help="Camera motion for every scene (animationHints.motion): static / kenburns-in / kenburns-out / "
2576
+ "pan-left / pan-right / float. Replaces the skeleton's alternating default.",
2577
+ )
2153
2578
  parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
2579
+ parser.add_argument("--avatar-id", default=None, help="Digital-human avatar id (dh_xxx) for templates declaring capabilities.digitalHuman: adds one gen-digital-human asset referenced by every scene's visuals.avatar.assetRef (one continuous presenter driven by the narration). Placement stays the template default unless set with --payload digitalHuman.<field>=...")
2580
+ parser.add_argument("--ending-payload", action="append", default=None, metavar="KEY=VALUE", help="Like --payload, but written only to the ending scene (e.g. recap.cta=关注我,每周更新开源热榜). On countdown templates it also appends the ending scene after #1. Repeatable.")
2154
2581
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
2155
2582
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
2156
2583
  parser.add_argument(
@@ -2353,6 +2780,16 @@ Examples:
2353
2780
  file=sys.stderr,
2354
2781
  )
2355
2782
 
2783
+ try:
2784
+
2785
+
2786
+ payload_writes = (
2787
+ _parse_payload_specs(_load_template_config(args.template_id) or {}, args.payload)
2788
+ if args.payload and args.template_id else []
2789
+ )
2790
+ except ValueError as exc:
2791
+ parser.error(str(exc))
2792
+
2356
2793
  try:
2357
2794
  dsl = build_dsl(
2358
2795
  topic=args.topic,
@@ -2378,6 +2815,10 @@ Examples:
2378
2815
  item_count=args.item_count,
2379
2816
  source_document_url=args.source_document_url,
2380
2817
  variant=args.variant,
2818
+ sequence_preset=args.sequence_preset,
2819
+ sequence_fit=args.sequence_fit,
2820
+ payload_writes=payload_writes,
2821
+ countdown_ending=bool(args.ending_payload),
2381
2822
  )
2382
2823
  except ValueError as exc:
2383
2824
 
@@ -2386,13 +2827,18 @@ Examples:
2386
2827
  parser.error(str(exc))
2387
2828
 
2388
2829
  try:
2830
+ look_config = _load_template_config(args.template_id) if args.template_id else None
2389
2831
  _apply_look(
2390
2832
  dsl,
2391
- _load_template_config(args.template_id) if args.template_id else None,
2833
+ look_config,
2392
2834
  args.template_id,
2393
2835
  args.variant,
2394
2836
  args.background_preset,
2837
+ args.background_asset,
2395
2838
  )
2839
+ _apply_scene_payload(dsl, look_config, args.template_id, args.payload, args.motion)
2840
+ _apply_ending_payload(dsl, look_config, args.template_id, args.ending_payload)
2841
+ _apply_avatar(dsl, look_config, args.template_id, args.avatar_id)
2396
2842
  except ValueError as exc:
2397
2843
  parser.error(str(exc))
2398
2844
 
@@ -54,7 +54,49 @@
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`."
89
+ },
90
+ "ending_payload": {
91
+ "type": "array",
92
+ "items": {
93
+ "type": "string"
94
+ },
95
+ "description": "Like payload, but `KEY=VALUE` is written only to the ending scene and locked there (e.g. `recap.cta=关注我,每周更新开源热榜` for github-repo-rank's 片尾引导). On countdown templates (Top-N lists) it also appends the ending scene after #1 — fill that scene's narration (a closing line) and its customPayload.item with the #1 entry. Without it a countdown ends on the #1 card. Requires template_id."
96
+ },
97
+ "avatar_id": {
98
+ "type": "string",
99
+ "description": "Digital-human avatar id (`dh_xxx`, the replicate card's 数字人主讲 / 数字人讲师 line) for templates declaring capabilities.digitalHuman (image-slide, ppt-to-video, html-slide, …). Adds one gen-digital-human asset referenced by every scene's visuals.avatar.assetRef: one continuous presenter whose lips follow the narration. Placement stays the template default; adjust with payload `digitalHuman.<field>=…`. Copy the id verbatim, never invent one. Costs digital-human credits, so pass it only when the user picked an avatar. Requires template_id."
58
100
  },
59
101
  "font_id": {
60
102
  "type": "string",
@@ -70,12 +112,18 @@
70
112
  },
71
113
  "template_source": {
72
114
  "type": "string",
73
- "enum": ["user-explicit", "user-confirmed", "auto"],
115
+ "enum": [
116
+ "user-explicit",
117
+ "user-confirmed",
118
+ "auto"
119
+ ],
74
120
  "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
121
  },
76
122
  "template_candidates": {
77
123
  "type": "array",
78
- "items": { "type": "string" },
124
+ "items": {
125
+ "type": "string"
126
+ },
79
127
  "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
128
  },
81
129
  "source_document_url": {
@@ -95,7 +143,7 @@
95
143
  "items": {
96
144
  "type": "string"
97
145
  },
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."
146
+ "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
147
  },
100
148
  "scene_images": {
101
149
  "type": "array",
@@ -109,7 +157,7 @@
109
157
  "items": {
110
158
  "type": "string"
111
159
  },
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."
160
+ "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
161
  },
114
162
  "stub_image_url": {
115
163
  "type": "string",
@@ -149,6 +197,13 @@
149
197
  "speed",
150
198
  "variant",
151
199
  "background_preset",
200
+ "sequence_preset",
201
+ "sequence_fit",
202
+ "background_asset",
203
+ "payload",
204
+ "ending_payload",
205
+ "avatar_id",
206
+ "motion",
152
207
  "font_id",
153
208
  "font_name",
154
209
  "list_fonts",
@@ -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 直接拒绝生成。",