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