@remixmate/cli 0.9.23 → 0.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.23",
4
- "generatedAt": "2026-09-06T12:35:46.901Z",
3
+ "version": "0.9.25",
4
+ "generatedAt": "2026-09-08T15:32:17.519Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -388,25 +388,32 @@
388
388
  },
389
389
  "headline": {
390
390
  "type": "string",
391
- "description": "On-screen headline (recommended 4-12 chars / ~3 words). Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer."
391
+ "description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
392
392
  },
393
393
  "subheadline": {
394
394
  "type": "string",
395
- "description": "On-screen subheadline (project name / slogan / source, e.g. 'Pixelle-Video'). Stored at meta.subheadline and pushed into every scene's textLayers[role=subheadline] so the template can render it as the top small-text. **Must** be set when the user explicitly provided a subtitle / project name. Note: this is the on-screen subheadline, not the CC subtitle (global.subtitle) — they are independent."
395
+ "description": "On-screen subheadline: the line under the headline, saying why the viewer should care. Multi-line via \\n. Stored at meta.subheadline and pushed into every scene's textLayers[role=subheadline] as the top small-text. **Must** be set when the user explicitly provided a subtitle / slogan. **Never a bare URL** — text layers are painted on screen, where a link is neither readable nor clickable; say what the thing is instead. How many lines the template keeps, and whether it parses **emphasis**, is declared in that template's llmHint (gen_script prints it in full on stderr). Note: this is the on-screen subheadline, not the CC subtitle (global.subtitle) — they are independent."
396
396
  },
397
397
  "carousel_items": {
398
398
  "type": "array",
399
399
  "items": {
400
400
  "type": "string"
401
401
  },
402
- "description": "Media URLs for the template's image/video carousel (e.g. spotlight-card's middle carousel). When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for carousel-style templates (spotlight-card, etc.). Each element is a full URL string."
402
+ "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 directly into 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."
403
+ },
404
+ "scene_images": {
405
+ "type": "array",
406
+ "items": {
407
+ "type": "string"
408
+ },
409
+ "description": "Per-scene visuals for a visual-overlay template (image-slide etc.) — the counterpart of carousel_items for templates that have real scenes. Each element is EITHER a full URL (use that existing asset for the scene, no gen-image call) OR the literal string 'ai' / 'ai:<prompt>' (leave that scene to gen-image; the text after the colon, when present, is the user's own image prompt for it). Mapping is POSITIONAL: element i is scene i, so an AI scene can sit anywhere in the order — pass ['url1','ai','url2'] verbatim when the user arranged it that way. Scenes past the last element still get an AI-generated image, so 2 images still produce a full video. When `scenes` is not set the scene count is raised (never lowered) to fit the elements, so nothing the user arranged is dropped and handing over fewer images does not shrink the video. **Must** pass when the user supplies images for such a template — otherwise their images are ignored entirely and every scene is AI-generated. Do NOT pass for carousel-caption templates; those take carousel_items."
403
410
  },
404
411
  "caption_lines": {
405
412
  "type": "array",
406
413
  "items": {
407
414
  "type": "string"
408
415
  },
409
- "description": "Bottom typewriter text lines for templates that support a caption/typewriter area (e.g. spotlight-card). Each element is one line of text, max 10 lines. Supports **emphasis** syntax (rendered with accent color). **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, e.g. spotlight-card): 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."
416
+ "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). 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."
410
417
  },
411
418
  "stub_image_url": {
412
419
  "type": "string",
@@ -441,6 +448,7 @@
441
448
  "headline",
442
449
  "subheadline",
443
450
  "carousel_items",
451
+ "scene_images",
444
452
  "caption_lines",
445
453
  "stub_image_url",
446
454
  "stub_video_url",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.23",
3
+ "version": "0.9.25",
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",
@@ -45,7 +45,7 @@ The full schema is `template-registry` skill's `video_dsl/schema/video-dsl-v1alp
45
45
 
46
46
  The intent is to reuse the validated example shapes already in the repo and to avoid the failure mode where "the generic DSL looks compatible on the surface, but is missing template-specific fields, has the wrong nesting, or binds incorrectly — only to fail later at binding or render time". Examples:
47
47
 
48
- - `html-slide` depends on `scene.templateData.slideId`.
48
+ - A template may require fields the generic DSL never emits (a slide id, a word list, an avatar assetRef) — its own `dsl-example.json` is the only place that shows which.
49
49
  - Different templates' `binding.json` may reflect different `slotMapping` choices, asset-binding styles, and scene organization.
50
50
 
51
51
  ### Mandatory steps
@@ -58,6 +58,15 @@ The intent is to reuse the validated example shapes already in the repo and to a
58
58
  6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `templateData.slideId`, `visuals.avatar.assetRef`.
59
59
  7. After generation, run the schema check and show the script summary to the user for confirmation.
60
60
 
61
+ ### On-screen text: the rules live in the template, not here
62
+
63
+ How the on-screen text layers should be *written* — whether the headline is a hook or a product name, how many subheadline lines survive, whether `**emphasis**` is parsed, where a link is allowed to appear — is a property of each template's layout, and its single source of truth is that template's `llmHint` in `template.json`. This skill deliberately does not restate any of it: a copy here would drift from the registry, and the registry is what actually renders.
64
+
65
+ Two consequences for the agent:
66
+
67
+ - `list_templates`' table truncates `llmHint` to 200 chars for browsing, so the summary you picked the template from is usually **not** the whole rule. `gen_script.py` re-prints the chosen template's `llmHint` in full on stderr before it assembles the DSL — read it there and correct yourself before you show the user a confirmation summary.
68
+ - Multi-line on-screen text uses real newlines. A literal `\n` typed inside shell single quotes arrives as two characters, so `gen_script.py` folds `\n` back into a newline for `--headline` / `--subheadline`; both `'a\nb'` and `$'a\nb'` therefore work.
69
+
61
70
  ### Hard constraints
62
71
 
63
72
  - **Forbidden**: the user specified a template, but the agent generated the DSL without reading any of the existing examples under `template-registry/video_dsl/schema/examples/`.
@@ -159,7 +168,7 @@ The agent should show the following in clear Markdown:
159
168
 
160
169
  ## Agent behavior: user-supplied media for carousel/image-driven templates (`--carousel-items` / `--caption-lines`)
161
170
 
162
- Some templates are **media-driven, not prompt-driven**: their on-screen content comes entirely from media URLs the user already has, and the skill does **not** generate any image. These are the templates whose `capabilities.payloadStyle` is `carousel-caption` (e.g. `adaptive-image-video`, `spotlight-card`).
171
+ Some templates are **media-driven, not prompt-driven**: their on-screen content comes entirely from media URLs the user already has, and the skill does **not** generate any image. These are the templates whose `capabilities.payloadStyle` is `carousel-caption`.
163
172
 
164
173
  For these templates the picture comes from `customPayload.carousel.items`, which is filled **only** from the `--carousel-items` flag. If the user gives you images but you do not pass `--carousel-items`, the carousel is empty and the result is a **black, 2-second clip** (with `durationStrategy: fit-caption`, an empty carousel + empty caption degrades to the 1s headline-intro + 1s tail minimum). `gen_script.py` now hard-fails in this case instead of producing the degenerate video.
165
174
 
@@ -169,26 +178,130 @@ When the user selects a `carousel-caption` template (or any template whose `asse
169
178
 
170
179
  1. **Extract every media URL the user provided** (image or video links in the prompt) and pass each one as a separate `--carousel-items <url>` flag — preserve the user's order, and pass the URLs **verbatim** (do not rewrite host/path/query).
171
180
  2. Pass each on-screen caption line as `--caption-lines '<text>'`. Whether this is optional depends on the template's `capabilities.durationStrategy`, **not** on `needsNarration` (every `carousel-caption` template has `needsNarration: false`):
172
- - `durationStrategy: fit-caption` (e.g. `spotlight-card`) → **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.
173
- - `durationStrategy: fit-images` (e.g. `adaptive-image-video` / `image-to-video`) → purely visual, captions genuinely optional; duration comes from the image count.
181
+ - `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.
182
+ - `durationStrategy: fit-images` → purely visual, captions genuinely optional; duration comes from the image count.
174
183
  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.
175
184
  4. Do **not** route these user-provided images through `gen-image`; they are existing assets and go straight into the carousel.
176
185
  5. Nothing downstream fills these in for you. `gen_script.py` routes on `capabilities.payloadStyle` alone — omitting the flags does **not** fall back to a generic path that generates images or writes copy; it assembles an empty carousel / empty caption. There is no auto-generation of caption text anywhere in the pipeline.
177
186
 
178
187
  ### Command example
179
188
 
180
- User: "用模版 adaptive-image-video 生成视频,图片链接为:https://cdn.example.com/a.jpg,https://cdn.example.com/b.jpg,https://cdn.example.com/c.jpg"
189
+ User: "用模版 <template-id> 生成视频,图片链接为:https://cdn.example.com/a.jpg,https://cdn.example.com/b.jpg,https://cdn.example.com/c.jpg"
181
190
 
182
191
  ```bash
183
192
  python3 <SkillDir>/scripts/gen_script.py \
184
193
  --topic "图片轮播视频" \
185
- --template-id adaptive-image-video \
194
+ --template-id <template-id> \
186
195
  --carousel-items "https://cdn.example.com/a.jpg" \
187
196
  --carousel-items "https://cdn.example.com/b.jpg" \
188
197
  --carousel-items "https://cdn.example.com/c.jpg"
189
198
  ```
190
199
 
191
- This template auto-adapts per-image hold time to the image count (1 image = 8s, 2 = 4s each, 3+ = 3s each), so 3 images yields a ~9s video instead of the 2s black clip.
200
+ Every URL the user gave becomes one carousel item, in order. How long each item holds is the template's business `fit-images` templates derive it from the item count, `fit-caption` templates from `capabilities.durationModel`; either way you pass the URLs and let the template decide.
201
+
202
+
203
+ ## Agent behavior: user-supplied images for scene-based templates (`--scene-images`)
204
+
205
+ The section above covers `carousel-caption` templates. Templates whose `payloadStyle` is
206
+ `visual-overlay` (image-slide and friends) have **real scenes**, each with its own background
207
+ image and its own narration — for those, user-supplied images go through `--scene-images`,
208
+ not `--carousel-items`.
209
+
210
+ Without this flag the user's images are **ignored entirely** and every scene gets an
211
+ AI-generated picture. That failure is quiet: the video renders fine, it just isn't made of
212
+ the material the user handed you.
213
+
214
+ ### The mapping rule (positional, then AI fills the rest)
215
+
216
+ Each entry is one scene's visual, and it is either a **URL** (use that existing asset) or the
217
+ literal **`ai`** / **`ai:<prompt>`** (leave that scene to gen-image). Entry i is scene i;
218
+ scenes past the last entry still get a generated image.
219
+
220
+ ```
221
+ --scene-images A --scene-images B → scene 1: A, scene 2: B, scene 3+: AI-generated
222
+ --scene-images A --scene-images ai:机房 … → scene 1: A, scene 2: AI with the user's prompt
223
+ ```
224
+
225
+ Three consequences worth internalising:
226
+
227
+ - **Two images still produce a full video.** Do not ask the user to "provide enough images"
228
+ or pad the list; partial input is the designed case.
229
+ - **Order is meaning, not layout.** Pass the entries in the order the user gave them — an AI
230
+ scene can sit *between* two of their images, and that placement is the point.
231
+ - **`ai:<prompt>` is the user's own image prompt for that one scene.** Pass it through
232
+ verbatim; do not rewrite it, and do not apply it to the other scenes. It replaces the
233
+ prompt that would have been derived from the template, but the template's negative prompt
234
+ still applies.
235
+
236
+ When `--scenes` is absent the scene count is **raised, never lowered**, to fit the images:
237
+ more images than the planned scene count grows the plan so none are dropped, but fewer images
238
+ does **not** shrink it — handing over 2 images should not turn a 30s five-scene video into a
239
+ three-scene one. If the template's scene count is fixed (`sceneStrategy: single` / `fixed`)
240
+ and there are still more images than scenes, `gen_script.py` prints a warning naming how many
241
+ went unused — surface that to the user instead of pretending everything was used.
242
+
243
+ ### Reading the image descriptions
244
+
245
+ Asset lines in the user's message carry a **`desc:` field** describing what is in the picture:
246
+
247
+ ```
248
+ ![4519.png](https://cdn.example.com/4519.png) (1920×1080, desc: 一个人背对镜头站在雾中的松林里)
249
+ ```
250
+
251
+ `desc:` is always the **last** item in the metadata parentheses, so everything from `desc:` to
252
+ the closing paren is the description — commas inside it are part of the text.
253
+
254
+ **Use it when writing narration.** This is the whole point of the field: for a
255
+ `visual-overlay` template you are writing the words that play over *that* picture, and the
256
+ description is the only thing telling you what the viewer will see. Narration that contradicts
257
+ the image is the most visible way this pipeline fails.
258
+
259
+ Two things `desc:` is **not**:
260
+
261
+ - It is **not an image-generation prompt.** The asset already exists; never route it through
262
+ `gen-image`, and never "improve" the picture to match the text.
263
+ - It is **not a headline.** Do not copy it onto the screen as `--headline` /
264
+ `--subheadline`; it is input for you, not on-screen copy.
265
+
266
+ A line with no `desc:` simply has no description — do not treat the file name in the alt text
267
+ (`4519.png`) as one.
268
+
269
+ ### Reading `ai:` lines
270
+
271
+ A line in the asset list may be an **AI scene** rather than an asset:
272
+
273
+ ```
274
+ ![a.png](https://cdn.example.com/a.png) (1920×1080, desc: 终端里正在跑安装命令的截图)
275
+ ai: 赛博朋克风格的服务器机房
276
+ ![b.png](https://cdn.example.com/b.png) (1920×1080)
277
+ ```
278
+
279
+ That is the user saying "scene 2 is AI-generated, and here is what I want in it". Pass it
280
+ straight through as the second `--scene-images` entry (`ai: 赛博朋克风格的服务器机房`),
281
+ keeping the position. A bare `ai:` with no text means "this scene is AI-generated, you decide
282
+ what it shows" — still pass it, as `ai`, so the position is preserved.
283
+
284
+ Note the two are different fields on purpose: `desc:` describes an image that **already
285
+ exists** (input for your narration), `ai:` prescribes an image that **does not exist yet**
286
+ (input for gen-image). Never feed a `desc:` into gen-image, and never write narration that
287
+ describes an `ai:` prompt as if it were a picture you have seen.
288
+
289
+ ### Command example
290
+
291
+ User: "用 image-slide 做一条讲 RAG 的视频,配图用这两张"
292
+ + two asset lines with `desc:`
293
+
294
+ ```bash
295
+ python3 <SkillDir>/scripts/gen_script.py \
296
+ --topic "三分钟看懂 RAG" \
297
+ --template-id image-slide \
298
+ --scene-images "https://cdn.example.com/a.png" \
299
+ --scene-images "ai: 赛博朋克风格的服务器机房" \
300
+ --scene-images "https://cdn.example.com/b.png"
301
+ ```
302
+
303
+ Then write each scene's narration against that scene's image description, and pass the
304
+ narration through `prepare_video_assets`'s `dsl_json` as usual.
192
305
 
193
306
 
194
307
  ## Test mode: skip asset generation (`--stub-image-url` / `--stub-video-url`)
@@ -244,7 +357,7 @@ Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is low
244
357
  5. **Schema validation**: make sure the output DSL conforms to v1alpha1.
245
358
  6. **User confirmation**: show the script summary and wait for confirmation, per the rules above.
246
359
 
247
- ### Generic-template DSL (image-slide / knowledge-slides, etc.)
360
+ ### Generic DSL (no template id, or a template with no special payload)
248
361
 
249
362
  ```bash
250
363
  python3 <SkillDir>/scripts/gen_script.py \
@@ -282,7 +395,7 @@ python3 <SkillDir>/scripts/gen_script.py \
282
395
  | `--topic` | Video topic (required unless `--validate`). | — |
283
396
  | `--platform` | Target platform: `douyin` / `xiaohongshu` / `bilibili` / `wechat` / `youtube` / `generic`. | `generic` |
284
397
  | `--headline` | On-screen main title (4–12 chars / ~3 words). Written to `meta.headline` + `textLayers[role=headline]`. **Pass it whenever the user gave a title** — otherwise headline falls back to the long-form topic and overflows the top text layer. | falls back to `--topic` |
285
- | `--subheadline` | On-screen subtitle / project name / slogan. Written to `meta.subheadline` + `textLayers[role=subheadline]`. Not the same thing as CC subtitles (`global.subtitle`). | `""` |
398
+ | `--subheadline` | On-screen subtitle / slogan. Written to `meta.subheadline` + `textLayers[role=subheadline]`. Multi-line via `\n`; how many lines survive and whether `**emphasis**` renders is declared per template — see its `llmHint`. Not the same thing as CC subtitles (`global.subtitle`). | `""` |
286
399
  | `--duration` | Target duration (seconds). Templates whose `durationStrategy` is `fit-caption` / `fit-narration` recompute the real duration and ignore this value. | `30` |
287
400
  | `--style` | Style tag. | — |
288
401
  | `--ratio` | Aspect ratio. | `16:9` |
@@ -295,8 +408,9 @@ python3 <SkillDir>/scripts/gen_script.py \
295
408
  | `-o` / `--output` | Output DSL file path. | stdout |
296
409
  | `--stub-image-url` | Test mode: every image AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_IMAGE_URL`). | — |
297
410
  | `--stub-video-url` | Test mode: every video AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_VIDEO_URL`). | — |
298
- | `--carousel-items` | Repeatable. Media URL placed directly into `customPayload.carousel.items` for `carousel-caption` templates (e.g. `adaptive-image-video`, `spotlight-card`). Bypasses gen-image. **Required** for `carousel-caption` templates when the user supplies images. | — |
299
- | `--caption-lines` | Repeatable. On-screen typewriter caption line for `carousel-caption` templates `customPayload.caption.lines` (max 10 lines). Supports `**emphasis**`. **Required** for `durationStrategy: fit-caption` templates (`spotlight-card`) — write the lines yourself if the user did not supply them. Optional only for `fit-images` templates. | — |
411
+ | `--carousel-items` | Repeatable. Media URL placed directly into `customPayload.carousel.items` for `carousel-caption` templates. Bypasses gen-image. **Required** for `carousel-caption` templates when the user supplies images. | — |
412
+ | `--scene-images` | Repeatable. One scene's visual for a **`visual-overlay`** template (image-slide etc.), 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. | — |
413
+ | `--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. | — |
300
414
 
301
415
  ## DSL generation principles
302
416
 
@@ -338,7 +452,7 @@ prefix and version — not a short alias:
338
452
  - **Scene duration mismatch**: adjust the narration length or the scene duration.
339
453
  - **Invalid platform**: look at the supported-platform list.
340
454
  - **`carousel-caption template ... needs visual or text content`**: you passed neither `--carousel-items` nor `--caption-lines`. Pass the user's media URLs (and caption lines where the template requires them).
341
- - **`template ... is typewriter-driven ... but --caption-lines is empty`**: a `fit-caption` template (e.g. `spotlight-card`) got no caption. Write the copy yourself if the user did not supply it, then pass one `--caption-lines` per line.
455
+ - **`template ... is typewriter-driven ... but --caption-lines is empty`**: a `fit-caption` template got no caption. Write the copy yourself if the user did not supply it, then pass one `--caption-lines` per line.
342
456
  - **`duration Ns is below/above template ... supportedDurations`**: the assembled DSL falls outside the range the template declares it was designed for. Below the minimum usually means the content is too thin (add caption lines / narration / scenes); above the maximum means trimming content or lowering `--duration`. This is enforced at generation time on purpose — a degenerate video still costs full render credits.
343
457
 
344
458
  ## scripts/ contents
@@ -207,9 +207,10 @@ def validate_dsl(dsl: dict) -> list:
207
207
  Historical rule set (preserved verbatim by ``validate_structural``):
208
208
  version + meta(title) + global presence + scene count 1–20 + each
209
209
  scene's ``id`` and ``purpose``. Asset integrity, enum membership and
210
- picture-book-en checks are intentionally NOT enforced here — those
211
- live in ``validate_dsl`` / ``validate_integrity`` and only run at
212
- their respective historical call sites.
210
+ capabilities-driven checks (e.g. narrationLanguageStrict) are
211
+ intentionally NOT enforced here — those live in ``validate_dsl`` /
212
+ ``validate_integrity`` and only run at their respective historical
213
+ call sites.
213
214
  """
214
215
  from video_dsl.runtime.dsl_validator import ( # noqa: E402
215
216
  validate_structural,
@@ -312,6 +313,27 @@ def generate_image_prompt(
312
313
  return {"prompt": prompts.get(purpose, f"{topic}相关配图{style_suffix}")}
313
314
 
314
315
 
316
+ def _parse_ai_slot(slot: str) -> tuple[bool, str]:
317
+ """解析 --scene-images 的一项:是不是 AI 段,以及用户给的提示词。
318
+
319
+ 返回 ``(is_ai, prompt)``:
320
+
321
+ - ``"https://…/a.png"`` → ``(False, "")`` 用已有素材
322
+ - ``"ai"`` → ``(True, "")`` 这一段交给 gen-image,提示词由模板推
323
+ - ``"ai: 深色调机房"`` → ``(True, "深色调机房")``
324
+
325
+ 大小写不敏感。判定刻意收得很紧(整项等于 ``ai``,或以 ``ai:`` 开头):URL 才是这个
326
+ 参数的常态,把 ``ai`` 判宽了会让 ``https://ai.example.com/x.png`` 这种正常地址被
327
+ 误当成"生成一段",用户的图就此消失。
328
+ """
329
+ text = (slot or "").strip()
330
+ if text.lower() == "ai":
331
+ return True, ""
332
+ if text[:3].lower() == "ai:":
333
+ return True, text[3:].strip()
334
+ return False, ""
335
+
336
+
315
337
  def _load_template_config(template_id: str) -> dict | None:
316
338
  """Load template definition for the given template ID.
317
339
 
@@ -365,7 +387,7 @@ def _template_primary_visual_type(template_config: dict | None) -> str:
365
387
  返回值之一:
366
388
  - "image" : 模板要求图片素材(默认通用 DSL 也是图片)
367
389
  - "video" : 模板要求视频素材(assetRequirements 含 video 但不含 image)
368
- - "none" : 模板不需要任何视觉素材(如 html-slide audio)
390
+ - "none" : 模板不需要任何视觉素材(assetRequirements 只有 audio)
369
391
  """
370
392
  if not template_config:
371
393
  return "image" # 默认:通用 DSL 走图片
@@ -388,8 +410,8 @@ def _template_needs_narration(template_config: dict | None) -> bool:
388
410
  - 不生成 gen-voice 素材;
389
411
  - 场景不写 ``audio.narration``。
390
412
  于是骨架的 narrationSceneCount=0,ab-agent 的 prepare_video_assets 预校验
391
- 会直接放行,不再强制用户为每个场景填旁白(与 spotlight-card 等无配音模板
392
- 走的是同一条 pass-through 路径)。
413
+ 会直接放行,不再强制用户为每个场景填旁白 —— 所有 needsNarration=false 的模板
414
+ 走的是同一条 pass-through 路径。
393
415
 
394
416
  兼容历史脏数据:部分模板的 ``capabilities`` 是空列表 ``[]``(而非 dict),
395
417
  统一按"未声明"处理 → 返回 True。仅当显式为 JSON ``false`` 时才关闭旁白。
@@ -426,6 +448,8 @@ def _template_needs_image(template_config: dict | None) -> bool:
426
448
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
427
449
  # - duration_strategy str capabilities.durationStrategy ∈ {explicit, fit-caption}
428
450
  # - payload_defaults dict capabilities.payloadDefaults(carousel-caption 的轮播/字幕默认值)
451
+ # - duration_model dict|None capabilities.durationModel(fit-caption 估时用的节奏数值,
452
+ # 由模板声明;缺省则不估时,见 _estimate_fit_caption_duration)
429
453
  # - primary_visual_type / default_layout 由 assetRequirements / capabilities 推导
430
454
 
431
455
 
@@ -469,12 +493,78 @@ def _resolve_contract(template_config: dict | None) -> dict:
469
493
  "fixed_scenes": fixed_scenes,
470
494
  "payload_style": payload_style,
471
495
  "duration_strategy": duration_strategy,
496
+ "duration_model": caps.get("durationModel"),
472
497
  "payload_defaults": payload_defaults,
473
498
  "primary_visual_type": primary_visual_type,
474
499
  "default_layout": caps.get("defaultLayout"),
475
500
  }
476
501
 
477
502
 
503
+ def _normalize_linebreaks(value: str | None) -> str:
504
+ """把画面文字里的**字面量** ``\\n`` 折成真换行。
505
+
506
+ ``textLayers[].content`` 用真换行分行,但调用方是在拼 shell 命令行的模型:
507
+ ``--subheadline 'A\\nB'`` 在 POSIX 单引号里传进来的是**反斜杠 + n 两个字符**,
508
+ 不是换行。不折的话它会原样渲进画面(副标题上真的印着一个 ``\\n``),而这既不
509
+ 报错也不出现在确认摘要里 —— 属于"只有渲完看片才发现"的那类坑。
510
+
511
+ 反过来说,画面文案里没有任何理由需要一个字面 ``\\n``,所以这个折叠是无损的。
512
+ 真换行(``$'a\\nb'`` 或参数里直接带换行)本来就能用,不受影响。
513
+ """
514
+ return (value or "").replace("\\n", "\n").strip()
515
+
516
+
517
+ def _echo_template_llm_hint(template_config: dict | None, template_id: str | None) -> None:
518
+ """把模板自己声明的 ``llmHint`` 原样打到 stderr。
519
+
520
+ **本函数刻意不认识任何一个具体模板。** 每个模板的画面文字该怎么写(标题写钩子
521
+ 还是写产品名、副标题几行、支持不支持 ``**强调**``)是模板的事实,真源在它自己的
522
+ ``template.json`` —— 与 ``imageStyleGuide`` 由 ``prompt_enhancer`` 通用消费、
523
+ ``capabilities`` 由 ``_resolve_contract`` 通用归一化是同一条纪律:**CLI 出机制,
524
+ 模板出内容**。把某个模板的版式规则抄进 skill 文档或这里的分支,等于在 registry
525
+ 之外开第二份会漂移的事实。
526
+
527
+ 之所以要在这里再打一遍:``list_templates`` 的表格视图把 llmHint 截到 200 字
528
+ (便于浏览),而真正的写作规范往往长得多(现网最长的已有 1400+ 字)。调用方
529
+ 多半是照着那份被截断的摘要选完模板就直接来调 gen_script 的,规则的后半截它从没
530
+ 见过。这里在装配 DSL 之前把全文补给它,让它能在确认摘要之前自己纠偏。
531
+ """
532
+ hint = (template_config or {}).get("llmHint")
533
+ if isinstance(hint, str) and hint.strip():
534
+ print(
535
+ f"ℹ️ template '{template_id}' llmHint (authoring rules declared by the "
536
+ f"template itself — follow them):\n {hint.strip()}",
537
+ file=sys.stderr,
538
+ )
539
+
540
+
541
+ def _warn_on_url_text_layer(headline: str, subheadline: str) -> None:
542
+ """画面文字整条是一个裸 URL 时出声提醒(不阻断)。
543
+
544
+ 这是 DSL 层的事实、不是某个模板的版式:``textLayers`` 是**画在画面上**的字,
545
+ 而观众没法点视频里的链接、也很少有人会照着念一串 URL 抄下来。链接的正确去处
546
+ 因模板而异(打字机行、结尾 CTA、简介),所以这里只说"这不该出现在画面标题里",
547
+ 不替模板规定它该去哪 —— 那句话由模板的 llmHint 自己讲。
548
+
549
+ 只 warn 不 exit:这是文风问题,照样渲得出一条能看的片子,与"caption 为空渲出
550
+ 黑屏"那种结构性失败不同级。
551
+ """
552
+ for flag, value in (("--headline", headline), ("--subheadline", subheadline)):
553
+ lines = [ln.strip() for ln in (value or "").split("\n") if ln.strip()]
554
+ if lines and all(
555
+ ln.startswith(("http://", "https://", "www.", "github.com/")) for ln in lines
556
+ ):
557
+ print(
558
+ f"⚠️ {flag} is nothing but a URL ({value.strip()!r}). "
559
+ "textLayers are drawn on screen — a link there is unreadable and "
560
+ "unclickable, and it spends the frame's most-read text on something "
561
+ "the viewer cannot act on.\n"
562
+ " Write what the thing is / why it matters instead, and see the "
563
+ "template's llmHint above for where the link belongs.",
564
+ file=sys.stderr,
565
+ )
566
+
567
+
478
568
  def _enforce_supported_duration(
479
569
  dsl: dict, template_config: dict | None, template_id: str | None
480
570
  ) -> None:
@@ -482,8 +572,8 @@ def _enforce_supported_duration(
482
572
 
483
573
  这条约束此前只在 template-library 的 CI(``check-dsl-examples.mjs``)对仓库里的
484
574
  示例 DSL 生效,运行时链路(gen_script → prepare_video_assets → render_video)没有
485
- 任何一环校验它。于是 spotlight-card(``min: 10``)可以静默产出 5s 成片——模板自己
486
- 声明"我不是为 5s 设计的",却没人拦。这里在 DSL 出厂前补上同一道栅栏。
575
+ 任何一环校验它。于是一个声明了 ``supportedDurations.min`` 的模板照样可以静默产出
576
+ 低于下限的成片 —— 模板自己说了"我不是为这么短设计的",却没人拦。这里在 DSL 出厂前补上同一道栅栏。
487
577
 
488
578
  越界即报错退出,而不是静默出片:时长不足通常意味着内容(打字机文案 / 旁白 / 场景)
489
579
  根本没填够,继续往下走只会烧掉渲染积分换一条废片。
@@ -556,64 +646,93 @@ def _plain_len(text: str) -> int:
556
646
  return len((text or "").replace("**", ""))
557
647
 
558
648
 
559
- # fit-caption 时长估算常量 —— 与 template-library spotlight-card/utils/timing.ts
560
- # + estimateDuration.ts 保持同一套数值,避免上游估时与组件实际耗时漂移。
561
- _MAX_CPS_CAP = 60
562
- _DEFAULT_TAIL_HOLD_SEC = 1.5
563
- _TAIL_PADDING_SEC = 1.0
564
- _HEADLINE_INTRO_SEC = 1.0
565
- _CAROUSEL_MIN_PER_ITEM_SEC = 2.0
566
- _CAROUSEL_MIN_PER_VIDEO_SEC = 3.5
649
+ # ── fit-caption 估时 ───────────────────────────────────────────────────────
650
+ #
651
+ # 这里以前钉着一组常量(打字收尾 1.5s、标题入场 1.0s、每张图最少 2.0s …),注释写
652
+ # 明"与 template-library 里那个模板组件的 timing.ts 保持同一套数值"。也就是说
653
+ # **一个具体模板的组件内部节奏被手抄进了 CLI**:模板调一次 tailHold,这边不跟着改
654
+ # 就开始漂,而漂移的表现是"估时和组件实际耗时对不上",没有任何门禁看得见。
655
+ #
656
+ # 现在数值由模板在 ``capabilities.durationModel`` 里自己声明,本文件只实现算法:
657
+ # 各分量并行、取 max、加收尾留白。没声明的模板不估时(见 _estimate_fit_caption_duration)。
658
+ # 与 ``payloadDefaults`` / ``imageStyleGuide`` 同一条纪律:模板出数值,CLI 出机制。
567
659
 
568
660
 
569
- def _estimate_caption_natural_sec(caption: dict) -> float:
661
+ def _estimate_caption_natural_sec(caption: dict, model: dict) -> float:
570
662
  """打字机自然节奏耗时(startDelay + 打字 + 行间停顿 + 收尾),秒。"""
571
- lines = (caption.get("lines") or [])[:10]
663
+ max_lines = model.get("maxLines")
664
+ lines = caption.get("lines") or []
665
+ if isinstance(max_lines, int) and max_lines > 0:
666
+ lines = lines[:max_lines]
572
667
  if not lines:
573
668
  return 0.0
574
- natural_cps = max(4, min(80, caption.get("charsPerSec", 28)))
575
- line_gap_sec = caption.get("lineGapMs", 350) / 1000.0
576
- start_delay_sec = caption.get("startDelayMs", 600) / 1000.0
669
+ cps = caption.get("charsPerSec", model.get("charsPerSec"))
670
+ if not cps:
671
+ return 0.0
672
+ lo, hi = model.get("charsPerSecMin"), model.get("charsPerSecMax")
673
+ if lo is not None:
674
+ cps = max(lo, cps)
675
+ if hi is not None:
676
+ cps = min(hi, cps)
677
+ line_gap_sec = caption.get("lineGapMs", model.get("lineGapMs", 0)) / 1000.0
678
+ start_delay_sec = caption.get("startDelayMs", model.get("startDelayMs", 0)) / 1000.0
577
679
  total_chars = sum(_plain_len(t) for t in lines)
578
680
  inter_line_sec = line_gap_sec * max(0, len(lines) - 1)
579
- fixed_overhead = start_delay_sec + inter_line_sec + _DEFAULT_TAIL_HOLD_SEC
580
- return fixed_overhead + total_chars / natural_cps
681
+ fixed_overhead = start_delay_sec + inter_line_sec + model.get("tailHoldSec", 0)
682
+ return fixed_overhead + total_chars / cps
581
683
 
582
684
 
583
- def _estimate_carousel_min_sec(carousel: dict) -> float:
685
+ def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
584
686
  """轮播至少需要的秒数(每张图/视频的最小停留,减去重叠的过渡)。"""
585
687
  items = carousel.get("items") or []
586
688
  n = len(items)
587
689
  if n == 0:
588
690
  return 0.0
589
- transition_sec = carousel.get("transitionMs", 400) / 1000.0
691
+ transition_sec = carousel.get("transitionMs", model.get("transitionMs", 0)) / 1000.0
590
692
  explicit_pacing = carousel.get("pacing")
591
693
  any_explicit = any(it.get("holdSec") is not None for it in items)
592
694
  has_default_hold = carousel.get("defaultHoldSec") is not None
593
695
  pacing = explicit_pacing or ("fixed" if (any_explicit or has_default_hold) else "auto")
594
696
  if pacing == "fixed":
595
- default_hold = carousel.get("defaultHoldSec", 3.0)
697
+ default_hold = carousel.get("defaultHoldSec", model.get("defaultHoldSec", 0))
596
698
  total = sum(it.get("holdSec", default_hold) for it in items)
597
699
  return total - (n - 1) * transition_sec
598
- total = sum(
599
- _CAROUSEL_MIN_PER_VIDEO_SEC if it.get("kind") == "video" else _CAROUSEL_MIN_PER_ITEM_SEC
600
- for it in items
601
- )
700
+ per_image = model.get("minPerImageSec", 0)
701
+ per_video = model.get("minPerVideoSec", per_image)
702
+ total = sum(per_video if it.get("kind") == "video" else per_image for it in items)
602
703
  return total - (n - 1) * transition_sec
603
704
 
604
705
 
605
- def _estimate_fit_caption_duration(custom_payload: dict) -> float:
706
+ def _estimate_fit_caption_duration(
707
+ custom_payload: dict, duration_model: dict | None, template_id: str | None
708
+ ) -> float:
606
709
  """fit-caption 策略:按打字机 + 轮播 + 标题入场推算建议时长(秒)。
607
710
 
608
- 等价于 template-library estimateSpotlightCardDuration().recommendedSec:
609
- 各组件并行,取 max 再加收尾留白。
711
+ 算法与模板导出的 ``capabilities.durationEstimator`` 等价(各分量并行、取 max、
712
+ 加收尾留白),但**每一个数值都来自模板声明的 ``capabilities.durationModel``**。
713
+ 模板没声明就不估 —— 返回 0,调用方沿用传入的 targetDuration 并收到一条 warning。
714
+ 宁可退回调用方的时长,也不拿一组从别的模板抄来的数字去替这个模板做决定。
610
715
  """
611
- caption = custom_payload.get("caption") or {}
612
- carousel = custom_payload.get("carousel") or {}
613
- caption_sec = _estimate_caption_natural_sec(caption)
614
- carousel_sec = _estimate_carousel_min_sec(carousel)
615
- recommended = max(_HEADLINE_INTRO_SEC, caption_sec, carousel_sec) + _TAIL_PADDING_SEC
616
- return recommended
716
+ if not isinstance(duration_model, dict) or not duration_model:
717
+ print(
718
+ f"⚠️ template '{template_id}' declares durationStrategy=fit-caption but no "
719
+ "capabilities.durationModel, so gen_script cannot compute the fitted "
720
+ "duration and falls back to the requested --duration.\n"
721
+ " Fix in the template (not here): add capabilities.durationModel with the "
722
+ "component's own pacing numbers (headlineIntroSec / tailPaddingSec / "
723
+ "caption.{charsPerSec,lineGapMs,startDelayMs,tailHoldSec,maxLines} / "
724
+ "carousel.{transitionMs,defaultHoldSec,minPerImageSec,minPerVideoSec}).",
725
+ file=sys.stderr,
726
+ )
727
+ return 0.0
728
+ caption_sec = _estimate_caption_natural_sec(
729
+ custom_payload.get("caption") or {}, duration_model.get("caption") or {}
730
+ )
731
+ carousel_sec = _estimate_carousel_min_sec(
732
+ custom_payload.get("carousel") or {}, duration_model.get("carousel") or {}
733
+ )
734
+ headline_intro = duration_model.get("headlineIntroSec", 0)
735
+ return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)
617
736
 
618
737
 
619
738
  def _build_carousel_caption_dsl(
@@ -636,8 +755,9 @@ def _build_carousel_caption_dsl(
636
755
  narration_enabled: bool,
637
756
  payload_defaults: dict,
638
757
  duration_strategy: str | None,
758
+ duration_model: dict | None,
639
759
  ) -> dict:
640
- """carousel-caption 装配(spotlight-card 类:单场景、图/视频轮播 + 打字机)。
760
+ """carousel-caption 装配(单场景、图/视频轮播 + 打字机文字)。
641
761
 
642
762
  所有差异来自归一化 contract(capabilities.payloadDefaults / durationStrategy /
643
763
  needsNarration),不再读 input-schema.json,也没有 if template_id==X 分支。
@@ -702,12 +822,21 @@ def _build_carousel_caption_dsl(
702
822
  caption_config["lines"] = caption_lines
703
823
 
704
824
  # ── Build customPayload ───────────────────────────────────────────────
825
+ #
826
+ # background / headlineStyle 只在模板自己声明了 payloadDefaults 时才写进去。
827
+ # 这里原本硬编码着 ``{"preset": "grid-particles"}`` / ``{"pill": True}`` 作为
828
+ # 兜底 —— 那两个值是**某一个**模板的词汇(一个背景 preset 枚举成员 + 一个它
829
+ # 顶部装饰的开关)。同为 carousel-caption 但没声明这两个键的模板既不认识也不读
830
+ # 它们,等于往它的 DSL 里塞两坨死配置(payload 契约门禁只查"声明了却没人消费",
831
+ # 查不到"没声明却被硬塞进来");而下一个模板如果恰好也有 background、枚举却不同,
832
+ # 拿到的就是一个它不认识的值。模板的默认值归模板声明,CLI 只负责透传。
705
833
  custom_payload = {
706
- "background": defaults.get("background", {"preset": "grid-particles"}),
707
- "headlineStyle": defaults.get("headlineStyle", {"pill": True}),
708
834
  "carousel": carousel_config,
709
835
  "caption": caption_config,
710
836
  }
837
+ for key in ("background", "headlineStyle"):
838
+ if defaults.get(key):
839
+ custom_payload[key] = defaults[key]
711
840
 
712
841
  # ── Narration skeleton ────────────────────────────────────────────────
713
842
  narration_text = _localize_label(output_language, "narration_opening",
@@ -715,11 +844,11 @@ def _build_carousel_caption_dsl(
715
844
 
716
845
  # ── Effective scene duration ──────────────────────────────────────────
717
846
  # fit-caption 策略:时长由打字机自然节奏决定,而不是盲取调用方传入的 targetDuration。
718
- # 这是 spotlight-card 这类打字机/画面驱动模板的核心——没有旁白来"撑"时长,
847
+ # 这是打字机/画面驱动模板的核心——没有旁白来"撑"时长,
719
848
  # 必须由 caption 自己定,否则文字打完后画面会静止到 targetDuration。
720
849
  effective_duration = duration
721
850
  if duration_strategy == "fit-caption":
722
- est = _estimate_fit_caption_duration(custom_payload)
851
+ est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
723
852
  if est > 0:
724
853
  effective_duration = int(math.ceil(est))
725
854
  print(
@@ -728,7 +857,7 @@ def _build_carousel_caption_dsl(
728
857
  file=sys.stderr,
729
858
  )
730
859
 
731
- # ── Build scene (spotlight-card uses a single scene) ──────────────────
860
+ # ── Build scene (sceneStrategy=single: one scene carries everything) ──
732
861
  text_layers = [
733
862
  {"role": "headline", "content": headline, "animation": "slide-up"},
734
863
  ]
@@ -839,6 +968,7 @@ def build_dsl(
839
968
  subheadline: str | None = None,
840
969
  carousel_items: list[str] | None = None,
841
970
  caption_lines: list[str] | None = None,
971
+ scene_images: list[str] | None = None,
842
972
  ) -> dict:
843
973
  """Build a complete Video DSL JSON.
844
974
 
@@ -857,6 +987,11 @@ def build_dsl(
857
987
  通过 role=headline / role=subheadline 抽出对应的 props(如 titleText / projectName)。
858
988
  注意:subtitle(CC 字幕)由 global.subtitle 与 render-plan.subtitleSegments
859
989
  单独承载,与 subheadline 完全是两个东西,命名上刻意分开避免歧义。
990
+
991
+ scene_images 是用户**自带的配图**(visual-overlay 模板专用),按顺序占位:第 i 张
992
+ 图给第 i 个场景,没被占到的场景照常走 gen-image 补图。这条规则不是随便定的——
993
+ 见 docs/asset-annotation-design.md §6:另外两种立场(模型自由挑选 / 严格一一对应)
994
+ 都会让"只传两张图"要么失去可预期性,要么直接跑不通。
860
995
  """
861
996
  # Load template config(registry 单一数据源)→ 归一化 contract
862
997
  template_config = _load_template_config(template_id) if template_id else None
@@ -883,8 +1018,10 @@ def build_dsl(
883
1018
  )
884
1019
 
885
1020
  # 缺省值:headline 退回 topic,subheadline 默认空字符串
886
- effective_headline = (headline or "").strip() or topic
887
- effective_subheadline = (subheadline or "").strip()
1021
+ effective_headline = _normalize_linebreaks(headline) or topic
1022
+ effective_subheadline = _normalize_linebreaks(subheadline)
1023
+ _echo_template_llm_hint(template_config, template_id)
1024
+ _warn_on_url_text_layer(effective_headline, effective_subheadline)
888
1025
 
889
1026
  # ── carousel-caption 模板(单场景、图/视频轮播 + 打字机)单独装配并直接返回 ──
890
1027
  if payload_style == "carousel-caption":
@@ -911,13 +1048,13 @@ def build_dsl(
911
1048
  file=sys.stderr,
912
1049
  )
913
1050
  sys.exit(1)
914
- # 兜底校验 ②:durationStrategy=fit-caption 的模板(spotlight-card 类)由打字机
1051
+ # 兜底校验 ②:durationStrategy=fit-caption 的模板由打字机
915
1052
  # 文案驱动节奏 —— caption 就是内容本体,不是可选装饰。caption_lines 为空时上面的
916
1053
  # "两者皆空" 检查放行,产出的却是「顶部标题 + 轮播、底部一个字都没有」的退化片:
917
1054
  # 估时掉到轮播地板值(2 张图 ≈ 5s),而调用方往往还在确认摘要里描述了一段
918
1055
  # 根本没进 DSL 的文案,用户在确认环节也看不出来。所以这里必须硬失败。
919
1056
  # 注意:只卡 fit-caption。carousel-caption 里 durationStrategy=fit-images 的
920
- # 纯视觉模板(image-to-video 等)本来就允许无文案,不受影响。
1057
+ # 纯视觉模板本来就允许无文案,不受影响。
921
1058
  if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
922
1059
  print(
923
1060
  "❌ template "
@@ -955,13 +1092,51 @@ def build_dsl(
955
1092
  narration_enabled=needs_narration,
956
1093
  payload_defaults=contract["payload_defaults"],
957
1094
  duration_strategy=contract["duration_strategy"],
1095
+ duration_model=contract["duration_model"],
958
1096
  )
959
1097
  _enforce_supported_duration(carousel_dsl, template_config, template_id)
960
1098
  return carousel_dsl
961
1099
 
962
1100
  # ── 其余模板:统一场景规划(arc 叙事弧 / single / fixed)+ 统一装配循环 ──────
1101
+ #
1102
+ # scene_images 的每一项要么是一条 URL(用已有素材),要么是 `ai` / `ai:<提示词>`
1103
+ # (这一段留给 gen-image)。两者共用同一个位置序列 —— 用户在界面上排出来的顺序
1104
+ # 就是它,AI 段能插在任意位置,而不是只能挂在末尾。
1105
+ provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
1106
+
963
1107
  scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
964
1108
 
1109
+ # 条目比场景多时**抬高**场景数,让每一条都有地方放。
1110
+ #
1111
+ # 只抬高、不压低:给 2 张图不该把一条 30s 的片子从 5 段压成 3 段。用户给图表达的是
1112
+ # "这几张都要用上",不是"整条片子改成这么多段"——按条目数直接改写场景数,会让
1113
+ # "多传了两张图"变成"视频结构被换掉了",而他根本没要求这个。
1114
+ #
1115
+ # 反过来条目多于场景时必须抬:不抬的话第 N+1 条之后会被静默丢掉,而用户在成片里是
1116
+ # 看不出"我的图去哪了"的。arc 之外的策略场景数由模板钉死,抬不动(下面警告兜底)。
1117
+ if (
1118
+ provided_slots
1119
+ and scene_count is None
1120
+ and contract["scene_strategy"] == "arc"
1121
+ and primary_visual_type == "image"
1122
+ and has_visual
1123
+ and len(provided_slots) > len(scene_plans)
1124
+ ):
1125
+ scene_plans = _plan_contract_scenes(
1126
+ contract, topic, duration, len(provided_slots), output_language
1127
+ )
1128
+
1129
+ # 排完之后仍然装不下的(single / fixed 模板,或用户显式指定了更少的场景数)就明说。
1130
+ # 静默丢弃用户自带的素材是这条链上最难被发现的一类错。
1131
+ if provided_slots and len(provided_slots) > len(scene_plans):
1132
+ print(
1133
+ f"\u26a0\ufe0f {len(provided_slots)} scene-image entries provided but the template "
1134
+ f"plans only {len(scene_plans)} scene(s); the extra "
1135
+ f"{len(provided_slots) - len(scene_plans)} will not be used. "
1136
+ f"Raise --scenes or pick a template with more scenes.",
1137
+ file=sys.stderr,
1138
+ )
1139
+
965
1140
  assets = []
966
1141
  scenes = []
967
1142
 
@@ -980,7 +1155,22 @@ def build_dsl(
980
1155
 
981
1156
  # 仅 visual-overlay 模板生成视觉素材(slide 无视觉素材,carousel-caption 已提前返回)
982
1157
  if has_visual and primary_visual_type == "image":
983
- if stub_image_url:
1158
+ slot = provided_slots[idx] if idx < len(provided_slots) else ""
1159
+ slot_is_ai, slot_prompt = _parse_ai_slot(slot)
1160
+ if slot and not slot_is_ai:
1161
+ # 用户自带的图排在 stub 之前:stub 是"别烧配额"的测试开关,而用户给的
1162
+ # 图同样一分钱不花,没有理由拿占位图把真素材盖掉。
1163
+ assets.append({
1164
+ "assetId": visual_asset_id,
1165
+ "type": "image",
1166
+ "source": "existing",
1167
+ "status": "generated",
1168
+ "url": slot,
1169
+ })
1170
+ # stub 盖得住 AI 段,盖不住用户自带的图。差别在于花不花钱:--stub-image-url
1171
+ # 的全部意义就是"这一趟别调 gen-image",而 AI 段恰恰是要调的那种;反过来
1172
+ # 用户自带的 URL 一分钱不花,拿占位图把真素材盖掉纯属损失。
1173
+ elif stub_image_url:
984
1174
  assets.append({
985
1175
  "assetId": visual_asset_id,
986
1176
  "type": "image",
@@ -991,6 +1181,13 @@ def build_dsl(
991
1181
  else:
992
1182
  image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
993
1183
 
1184
+ # 用户在 AI 段里写了提示词就用他的。**只替换主体,不动 negativePrompt /
1185
+ # guidanceScale**:那两项是模板的护栏("不要文字水印、不要变形"),把它们
1186
+ # 一起丢掉会让用户随手写的一句话换来一张带乱码文字的图,而他并没有要求
1187
+ # 关掉护栏——他只是想说这一段画什么。
1188
+ if slot_prompt:
1189
+ image_result = {**image_result, "prompt": slot_prompt}
1190
+
994
1191
  img_payload = {
995
1192
  "prompt": image_result["prompt"],
996
1193
  "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
@@ -1068,8 +1265,9 @@ def build_dsl(
1068
1265
  text_layers = [
1069
1266
  {"role": "badge", "content": plan["label"], "animation": "slide-up"},
1070
1267
  ]
1071
- # 给所有 point 场景补一个 headline textLayer,让 screen-walkthrough
1072
- # 等模板的 propExtractors 直接拿到主标题,不再依赖 binding 兜底逻辑。
1268
+ # 给所有 point 场景补一个 headline textLayer:凡是把 titleText 从
1269
+ # textLayers[role=headline] 抽出来的 propExtractors 都能直接拿到主标题,
1270
+ # 不再依赖 binding 的兜底逻辑。
1073
1271
  text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
1074
1272
  if effective_subheadline:
1075
1273
  text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
@@ -1103,18 +1301,25 @@ def build_dsl(
1103
1301
  }
1104
1302
  scenes.append(scene)
1105
1303
  else:
1106
- # The "no-visual" branch labels each scene with a layout hint.
1107
- # Historical fallback was hardcoded "html-slide" — when no template
1108
- # was specified, every audio-only scene defaulted to html-slide
1109
- # styling. We replace the hardcoded fallback chain with:
1304
+ # The "no-visual" branch labels each scene with a layout hint:
1110
1305
  # 1. template.capabilities.defaultLayout ⇐ the template tells
1111
1306
  # remixmate what layout name it wants on its no-visual scenes
1112
1307
  # 2. template_id ⇐ legacy: pass id as
1113
1308
  # layout name (existing behavior when no capability declared)
1114
- # 3. "html-slide" absolute fallback
1115
- # for the no-template case (preserves prior behavior)
1309
+ # 3. "text-overlay" no-template case
1310
+ #
1311
+ # ⚠️ 第 3 档以前硬编码着模板库里某个具体模板的 id —— 没选模板时,每个纯
1312
+ # 音频场景都被贴上那个名字。那是这份脚本唯一一处"认识某个模板"的可执行代码:
1313
+ # 没有模板参与的通用 DSL 不该知道模板库里有谁,模板换名 / 下架都会让这个
1314
+ # 字面量变成谎话。改成 schema 自己的枚举值(Scene.layout 的合法取值是
1315
+ # full-visual / text-overlay / … 这一组,模板 id 从来就不在里面,写进去反而
1316
+ # 会让 dsl_validator 报一条 layout 枚举 warning)。
1317
+ #
1318
+ # 这个字段是纯描述性的:match_template 只把它抄进 binding 的 layoutVariant,
1319
+ # 而 layoutVariant 在 ab-render 与 template-library 里都没有任何读取方
1320
+ # (已核对),渲染走的是 slotMapping / compositionId。所以换值不改成片。
1116
1321
  _layout_cfg = (template_config or {}).get("capabilities") or {}
1117
- layout = _layout_cfg.get("defaultLayout") or template_id or "html-slide"
1322
+ layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
1118
1323
  text_layers = []
1119
1324
  if plan["purpose"] == "opening":
1120
1325
  text_layers = [
@@ -1260,19 +1465,34 @@ Examples:
1260
1465
  default=None,
1261
1466
  help="Media URL for the template's carousel/gallery. Can be repeated: "
1262
1467
  "--carousel-items url1 --carousel-items url2. "
1263
- "For carousel-caption templates (capabilities.payloadStyle=carousel-caption, "
1264
- "e.g. spotlight-card), these URLs are placed directly into "
1468
+ "For templates declaring capabilities.payloadStyle=carousel-caption, "
1469
+ "these URLs are placed directly into "
1265
1470
  "customPayload.carousel.items as existing assets, bypassing AI image generation entirely.",
1266
1471
  )
1472
+ parser.add_argument(
1473
+ "--scene-images",
1474
+ action="append",
1475
+ default=None,
1476
+ help="One scene's visual for a visual-overlay template (image-slide etc.). "
1477
+ "Can be repeated: --scene-images url1 --scene-images ai --scene-images url2. "
1478
+ "Each entry is either a URL (use that existing asset) or the literal 'ai' / "
1479
+ "'ai:<prompt>' (leave this scene to gen-image, optionally with the user's prompt). "
1480
+ "Mapping is POSITIONAL: the i-th entry is the i-th scene's background, so an AI "
1481
+ "scene can sit anywhere in the order, not just at the end; scenes past the last "
1482
+ "entry still get an AI-generated image. When --scenes is not given the scene count "
1483
+ "is raised (never lowered) to fit the entries. "
1484
+ "Not for carousel-caption templates — those take --carousel-items instead.",
1485
+ )
1267
1486
  parser.add_argument(
1268
1487
  "--caption-lines",
1269
1488
  action="append",
1270
1489
  default=None,
1271
- help="Bottom typewriter text line. Can be repeated: "
1490
+ help="On-screen caption line. Can be repeated: "
1272
1491
  "--caption-lines 'line1' --caption-lines 'line2'. "
1273
- "For carousel-caption templates (capabilities.payloadStyle=carousel-caption, "
1274
- "e.g. spotlight-card), these are placed into customPayload.caption.lines. "
1275
- "Supports **emphasis** syntax.",
1492
+ "For templates declaring capabilities.payloadStyle=carousel-caption, "
1493
+ "these are placed into customPayload.caption.lines. "
1494
+ "Line count limits and whether **emphasis** is parsed are declared per "
1495
+ "template — see the chosen template's llmHint.",
1276
1496
  )
1277
1497
 
1278
1498
  args = parser.parse_args()
@@ -1384,6 +1604,7 @@ Examples:
1384
1604
  subheadline=args.subheadline,
1385
1605
  carousel_items=args.carousel_items,
1386
1606
  caption_lines=args.caption_lines,
1607
+ scene_images=args.scene_images,
1387
1608
  )
1388
1609
 
1389
1610
  # Post-process for --skip-asset-generation: rewrite all gen-voice / gen-digital-human
@@ -58,25 +58,32 @@
58
58
  },
59
59
  "headline": {
60
60
  "type": "string",
61
- "description": "On-screen headline (recommended 4-12 chars / ~3 words). Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer."
61
+ "description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
62
62
  },
63
63
  "subheadline": {
64
64
  "type": "string",
65
- "description": "On-screen subheadline (project name / slogan / source, e.g. 'Pixelle-Video'). Stored at meta.subheadline and pushed into every scene's textLayers[role=subheadline] so the template can render it as the top small-text. **Must** be set when the user explicitly provided a subtitle / project name. Note: this is the on-screen subheadline, not the CC subtitle (global.subtitle) — they are independent."
65
+ "description": "On-screen subheadline: the line under the headline, saying why the viewer should care. Multi-line via \\n. Stored at meta.subheadline and pushed into every scene's textLayers[role=subheadline] as the top small-text. **Must** be set when the user explicitly provided a subtitle / slogan. **Never a bare URL** — text layers are painted on screen, where a link is neither readable nor clickable; say what the thing is instead. How many lines the template keeps, and whether it parses **emphasis**, is declared in that template's llmHint (gen_script prints it in full on stderr). Note: this is the on-screen subheadline, not the CC subtitle (global.subtitle) — they are independent."
66
66
  },
67
67
  "carousel_items": {
68
68
  "type": "array",
69
69
  "items": {
70
70
  "type": "string"
71
71
  },
72
- "description": "Media URLs for the template's image/video carousel (e.g. spotlight-card's middle carousel). When provided together with a template_id whose capabilities.payloadStyle=carousel-caption, these URLs are placed directly into customPayload.carousel.items as existing assets — NO AI image generation is triggered. **Must** pass when the user explicitly provides image/video URLs for carousel-style templates (spotlight-card, etc.). Each element is a full URL string."
72
+ "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 directly into 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."
73
+ },
74
+ "scene_images": {
75
+ "type": "array",
76
+ "items": {
77
+ "type": "string"
78
+ },
79
+ "description": "Per-scene visuals for a visual-overlay template (image-slide etc.) — the counterpart of carousel_items for templates that have real scenes. Each element is EITHER a full URL (use that existing asset for the scene, no gen-image call) OR the literal string 'ai' / 'ai:<prompt>' (leave that scene to gen-image; the text after the colon, when present, is the user's own image prompt for it). Mapping is POSITIONAL: element i is scene i, so an AI scene can sit anywhere in the order — pass ['url1','ai','url2'] verbatim when the user arranged it that way. Scenes past the last element still get an AI-generated image, so 2 images still produce a full video. When `scenes` is not set the scene count is raised (never lowered) to fit the elements, so nothing the user arranged is dropped and handing over fewer images does not shrink the video. **Must** pass when the user supplies images for such a template — otherwise their images are ignored entirely and every scene is AI-generated. Do NOT pass for carousel-caption templates; those take carousel_items."
73
80
  },
74
81
  "caption_lines": {
75
82
  "type": "array",
76
83
  "items": {
77
84
  "type": "string"
78
85
  },
79
- "description": "Bottom typewriter text lines for templates that support a caption/typewriter area (e.g. spotlight-card). Each element is one line of text, max 10 lines. Supports **emphasis** syntax (rendered with accent color). **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, e.g. spotlight-card): 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."
86
+ "description": "On-screen caption lines for templates that declare a caption area (capabilities.payloadDefaults.caption). 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."
80
87
  },
81
88
  "stub_image_url": {
82
89
  "type": "string",
@@ -111,6 +118,7 @@
111
118
  "headline",
112
119
  "subheadline",
113
120
  "carousel_items",
121
+ "scene_images",
114
122
  "caption_lines",
115
123
  "stub_image_url",
116
124
  "stub_video_url",