@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.
package/dist/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"version": "0.12.
|
|
4
|
-
"generatedAt": "2026-10-
|
|
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`
|
|
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.
|
|
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.
|
|
257
|
-
5.
|
|
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 ` (…, 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`
|
|
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
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**({"
|
|
1221
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1519
|
-
|
|
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:
|
|
1522
|
-
f"
|
|
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(
|
|
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
|
-
|
|
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`
|
|
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": [
|
|
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": {
|
|
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 直接拒绝生成。",
|