---
name: gen-script
description: |
  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.

  Use this skill as soon as the user mentions any of these intents:
  - Write a video script, generate a video script, plan a video, write storyboards
  - Create a short video, plan video content, generate a Video DSL
  - Turn a topic into a video structure / video content plan

  Even when the user does not say "generate the DSL", use this skill whenever they want to turn a topic into a structured video plan.

  ⚠️ 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.
triggers:
  - Write a video script, generate a video script, plan a video, write storyboards
  - Create a short video, plan video content, generate a Video DSL
  - Turn a topic into a video structure / video content plan
---

# Video Script Generation Skill

Turns a user-supplied **topic** into a **Video DSL v1alpha1** JSON describing what the final video should look like (scene structure, asset requirements, narration text, visual layout). It never produces any asset itself.

> ⚠️ **Stop-and-confirm gate (mandatory, do not skip).**
> This skill produces a *draft* for the user to review, not an input to chain onward automatically.
> After it returns, show the full script and **stop your turn** — wait for the user to explicitly confirm.
> **Never** call `prepare_video_assets` in the same turn as `gen_script`: asset generation spends
> image and TTS credits that cannot be refunded, and a wrong narration line means paying for the
> whole batch twice. See **"Agent behavior: script confirmation"** below for the required summary format.

## Core concepts

- **Video DSL**: a declarative description language for videos — describes the final structure, not the orchestration calls.
- **AssetRef**: an asset reference declaring which images / audio / video clips are needed; the downstream Render Skill is responsible for producing them.
- **Scene**: the unit cell of the video timeline.
- **Image `payload.model`**: must match the `gen-image` skill and the backend `ModelGenImgDTO`. **Only** values from the LiteLLM `model` allowlist below may be used. When editing narration or prompts you **must not** rewrite this field.

## DSL schema

The full schema is `template-registry` skill's `video_dsl/schema/video-dsl-v1alpha1.json`.

**Per-template reference DSLs do not ship with this package.** `video_dsl/schema/examples/` is a local-only directory: it is absent from the published npm package (and has never existed in the repo), so `template_registry list_examples=true` normally returns nothing. Do not read "no examples found" as "this template is unsupported" — the authoritative per-template contract ships inside the registry's `template.json`, see below.

## Agent behavior: DSL generation when a template is selected

**When the user explicitly specifies a template id, the agent must first read that template's full definition from the registry, then generate the DSL in the template's native shape. The agent must not generate the DSL from scratch ignoring the template definition, and must not produce a generic DSL first and rely on `template-registry` to force-match the template later.**

The intent is 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:

- A template may require fields the generic DSL never emits (a slide id, a word list, an avatar assetRef). `customPayloadSchema` is what declares them.
- Templates differ in `slotMapping` choices, asset-binding styles, and scene organization.

### Mandatory steps

1. Read the requested template's full definition from the registry — `template_registry` with `template_id=<the id>` and `json_output=true` emits that one template whole. This is the shipping source of truth, and unlike the summary list it truncates nothing. Do **not** ask for every template's full definition (`list_templates=true` + `full=true`): the whole registry is far more JSON than one tool result can carry, and the call fails instead of returning the contract.
2. Read its `llmHint` end to end — that is where the template states how its on-screen text and its layouts must be authored.
3. Read its `customPayloadSchema`: every template-specific field lives there, including the enum of legal `customPayload.slideId` values for multi-layout templates.
3b. If the template ships `slideSchemas` (multi-layout templates do), **author `templateData` straight from `slideSchemas[slideId]`** — it is the only place the per-layout field names exist. Do not infer them: the same concept is named `name` in one layout, `label` in another and `era` in a third, and a wrong name is dropped in silence (see below).
4. Combine that with the template's `slotMapping`, `requiredProps`, `optionalProps`, `propExtractors`, `assetRequirements`, `supportedAspectRatios`, `supportedDurations`, `constraints`, and `scenePatterns`.
5. Generate the DSL in the template's native shape — not the generic DSL shape.
6. Make sure the DSL explicitly contains every template-specific field, e.g. `templateData.words`, `customPayload.slideId`, `visuals.avatar.assetRef`.
7. If `template_registry list_examples=true` does return files locally, read them as an extra sample — never as a substitute for step 1.
8. After generation, run the schema check and show the script summary to the user for confirmation.

### Multi-layout templates: pick a layout per scene

When a template's `customPayloadSchema.slideId.enum` holds more than one value (today `html-slide` and `html-slide-blackboard`, 16 layouts each), **every scene must name one**, chosen to fit that scene's information shape — a comparison, a timeline, a code walkthrough and a set of numbers are four different layouts.

`gen_script.py` cannot make that choice: it emits the placeholder `"slideId": "__CHOOSE_SLIDE__"`, and the DSL validator rejects any DSL that still carries it, listing the legal values in the error. Replace every placeholder, and do not reuse one layout for the whole video.

A missing `slideId` is the quietest failure in the pipeline: the renderer drops the entire `templateData` and draws a single centred title — no error, no log, exit code 0.

### Multi-layout templates: field names are per-layout, never guessable

Picking the layout is only half of it. Each layout reads its own field names out of `templateData`, and a field the layout does not read is **dropped without a word** — that slot renders empty, or shows the component's built-in placeholder text, and the render still reports success.

This has shipped broken videos: a 7-scene deck wrote `items[].title` where `html-slide`'s `feature-grid` reads `name`, `items[].description` where `timeline-axis` reads `detail`, and `title` / `subtitle` / `ctaText` where `closing-cta` reads `headline` / `actionLine` / `ctaLabel` / `ctaUrl`. Five of seven scenes rendered as empty cards and placeholder copy — after paying for TTS and the render.

So: **copy the field names out of `slideSchemas[slideId]`.** The DSL validator now rejects unknown field names before any asset is generated and tells you the declared ones, but that check only fires for templates that ship `slideSchemas` — reading the contract is still the primary move, not the fallback.

### On-screen text: the rules live in the template, not here

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.

Two consequences for the agent:

- `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.
- 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.

### Hard constraints

- **Forbidden**: the user specified a template, but the agent generated the DSL without reading that template's full `template.json` (`llmHint` + `customPayloadSchema` included).
- **Forbidden**: leaving any `"__CHOOSE_SLIDE__"` placeholder in the DSL, or shipping scenes with no `customPayload.slideId` on a multi-layout template.
- **Forbidden**: reusing a single `slideId` across the whole video on a multi-layout template because it was the first one in the enum.
- **Forbidden**: the user specified a template, but the agent first generated a generic DSL and then passed `--template-id` to `template-registry` to force-bind it.
- **Forbidden**: continuing into binding / rendering despite knowing that template-specific fields, scene shapes, or binding details are missing.
- **Forbidden**: silently degrading to a generic DSL because the current script cannot support a template, leaving the failure to the downstream stage.

### When a template is not yet supported

"No examples found" is **not** this case — see the note under *DSL schema*; examples do not ship, and `template.json` is the contract.

This case is: the template's `customPayloadSchema` / `slotMapping` demands a shape `gen_script.py` cannot build and the agent cannot hand-assemble. Then say clearly that the template is not yet supported for auto-generation, and name the fields, structure or binding info that are missing. Possible next steps:

1. Ask the user to switch to a template whose shape is already supported.
2. Hand-craft the required DSL structure from `customPayloadSchema` and `slotMapping`, then show it to the user for confirmation.
3. Stop the flow and wait for the user to decide, rather than emitting a DSL that "looks like it matches but cannot render".

### Design principle

When the user specifies a template, `gen-script`'s goal is no longer "produce a generally-compatible DSL" but "produce a template-native DSL that satisfies that template's own declared contract".

## Authentication & environment

This skill does not hit any external API; no token required. The script only does local DSL generation and schema validation.

| Env var | Description | Default |
|---------|-------------|---------|
| `VIDEO_DSL_SCHEMA_PATH` | DSL schema file path. | Reads `template-registry` skill's `video_dsl/schema/video-dsl-v1alpha1.json` by default. |

## Agent behavior: script confirmation

**After the agent has generated a DSL it must first show the script summary to the user and get explicit confirmation before forwarding to downstream skills (template-registry / prepare-video-assets / render-video).** This lets the user review and adjust the scene structure, narration text, etc. before any asset is produced.

### Confirmation flow

1. After the agent generates or modifies the DSL, **show the script summary first; do not pass it downstream yet**.
2. **End your turn there and wait** for the user to explicitly confirm (e.g. "OK", "looks good", "continue with template binding") before handing it to `prepare-video-assets` (or `template-registry` for a list-only lookup).
3. If the user asks for changes (adjust narration, add or remove scenes, change durations), the agent updates the DSL and shows the summary again, then waits for confirmation once more.

> ⚠️ **Never** call `prepare_video_assets` before the user has confirmed — not even when the original
> request was a single end-to-end instruction like "make me a video about X". That request authorizes
> the *pipeline*, not the skipping of its review steps. "Finish the task in one go" does **not** apply
> here: stopping for confirmation **is** the correct completion of this step.

### Showing the script

Show **every scene in full** — do not collapse them with phrases like "scenes 2–6 same as above".
The user is reviewing the narration word by word; a summary they cannot proofread defeats the gate.

> ⚠️ **Read every value back from the returned DSL, never from what you meant to pass.**
> The summary exists so the user can catch a wrong tool call; a summary written from intent hides
> exactly the bug it should surface.
> - Durations ← `scenes[].duration`, **not** your `--duration` argument (`fit-caption` / `fit-narration`
>   templates recompute it and ignore the target you passed).
> - On-screen text ← `textLayers[]`, `customPayload.caption.lines`, `customPayload.carousel.items` —
>   quoted verbatim, with the counts you actually see.
> - If something you intended to set is missing or empty in the returned DSL, that is a failed call:
>   say so and re-run `gen_script.py` with the right flags. Listing caption lines that are not in
>   `customPayload.caption.lines` means the user confirms a script that does not exist and pays to
>   render something else.

### Summary content

The agent should show the following in clear Markdown:

**1. Scene structure**
- Each scene's purpose, duration, and full narration text.

**2. Asset plan**
- Count by type (images, TTS, video, digital human).
- Key descriptions per asset (e.g. an image prompt summary).

**3. Estimated total duration**
- Sum of per-scene durations.

### Summary format example

```markdown
## Script confirmation

### Video info
- Topic: 3 AI study hacks
- Aspect ratio: 9:16 | Estimated duration: 45s

### Scene structure
| # | Purpose | Duration | Narration |
|---|---------|----------|-----------|
| 1 | opening | 5s | Spending hours studying with no real progress? |
| 2 | point | 10s | Hack 1: Feynman + AI — explain a concept in your own words... |
| 3 | point | 10s | Hack 2: spaced repetition with AI — schedule reviews scientifically... |
| 4 | point | 10s | Hack 3: AI mock exam — close gaps anytime, anywhere... |
| 5 | cta | 10s | If this helped, like and follow for more AI study tips! |

### Asset plan
- Images: 5 (one background per scene)
- TTS: 5 segments (one per scene narration)

> Reply "continue" to proceed to template matching, or tell me what to change.
```

## Agent behavior: user-supplied media for carousel/image-driven templates (`--carousel-items` / `--caption-lines`)

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`.

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.

### Mandatory behavior

When the user selects a `carousel-caption` template (or any template whose `assetRequirements` is image/video-only and whose `payloadStyle` is `carousel-caption`):

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).
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`):
   - `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.
   - `durationStrategy: fit-images` → purely visual, captions genuinely optional; duration comes from the image count.
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.
4. Do **not** route these user-provided images through `gen-image`; they are existing assets and go straight into the carousel.
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.

### Command example

User: "用模版 <template-id> 生成视频，图片链接为：https://cdn.example.com/a.jpg，https://cdn.example.com/b.jpg，https://cdn.example.com/c.jpg"

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --topic "图片轮播视频" \
  --template-id <template-id> \
  --carousel-items "https://cdn.example.com/a.jpg" \
  --carousel-items "https://cdn.example.com/b.jpg" \
  --carousel-items "https://cdn.example.com/c.jpg"
```

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.


## Agent behavior: user-supplied images for scene-based templates (`--scene-images`)

The section above covers `carousel-caption` templates. Templates whose `payloadStyle` is
`visual-overlay` (image-slide and friends) have **real scenes**, each with its own background
image and its own narration — for those, user-supplied images go through `--scene-images`,
not `--carousel-items`.

Without this flag the user's images are **ignored entirely** and every scene gets an
AI-generated picture. That failure is quiet: the video renders fine, it just isn't made of
the material the user handed you.

### The mapping rule (positional, then AI fills the rest)

Each entry is one scene's visual, and it is either a **URL** (use that existing asset) or the
literal **`ai`** / **`ai:<prompt>`** (leave that scene to gen-image). Entry i is scene i;
scenes past the last entry still get a generated image.

```
--scene-images A --scene-images B          →  scene 1: A, scene 2: B, scene 3+: AI-generated
--scene-images A --scene-images ai:机房 …  →  scene 1: A, scene 2: AI with the user's prompt
```

Three consequences worth internalising:

- **Two images still produce a full video.** Do not ask the user to "provide enough images"
  or pad the list; partial input is the designed case.
- **Order is meaning, not layout.** Pass the entries in the order the user gave them — an AI
  scene can sit *between* two of their images, and that placement is the point.
- **`ai:<prompt>` is the user's own image prompt for that one scene.** Pass it through
  verbatim; do not rewrite it, and do not apply it to the other scenes. It replaces the
  prompt that would have been derived from the template, but the template's negative prompt
  still applies.

When `--scenes` is absent the scene count is **raised, never lowered**, to fit the images:
more images than the planned scene count grows the plan so none are dropped, but fewer images
does **not** shrink it — handing over 2 images should not turn a 30s five-scene video into a
three-scene one. If the template's scene count is fixed (`sceneStrategy: single` / `fixed`)
and there are still more images than scenes, `gen_script.py` prints a warning naming how many
went unused — surface that to the user instead of pretending everything was used.

### Reading the image descriptions

Asset lines in the user's message carry a **`desc:` field** describing what is in the picture:

```
![4519.png](https://cdn.example.com/4519.png) (1920×1080, desc: 一个人背对镜头站在雾中的松林里)
```

`desc:` is always the **last** item in the metadata parentheses, so everything from `desc:` to
the closing paren is the description — commas inside it are part of the text.

**Use it when writing narration.** This is the whole point of the field: for a
`visual-overlay` template you are writing the words that play over *that* picture, and the
description is the only thing telling you what the viewer will see. Narration that contradicts
the image is the most visible way this pipeline fails.

Two things `desc:` is **not**:

- It is **not an image-generation prompt.** The asset already exists; never route it through
  `gen-image`, and never "improve" the picture to match the text.
- It is **not a headline.** Do not copy it onto the screen as `--headline` /
  `--subheadline`; it is input for you, not on-screen copy.

A line with no `desc:` simply has no description — do not treat the file name in the alt text
(`4519.png`) as one.

### Reading `ai:` lines

A line in the asset list may be an **AI scene** rather than an asset:

```
![a.png](https://cdn.example.com/a.png) (1920×1080, desc: 终端里正在跑安装命令的截图)
ai: 赛博朋克风格的服务器机房
![b.png](https://cdn.example.com/b.png) (1920×1080)
```

That is the user saying "scene 2 is AI-generated, and here is what I want in it". Pass it
straight through as the second `--scene-images` entry (`ai: 赛博朋克风格的服务器机房`),
keeping the position. A bare `ai:` with no text means "this scene is AI-generated, you decide
what it shows" — still pass it, as `ai`, so the position is preserved.

Note the two are different fields on purpose: `desc:` describes an image that **already
exists** (input for your narration), `ai:` prescribes an image that **does not exist yet**
(input for gen-image). Never feed a `desc:` into gen-image, and never write narration that
describes an `ai:` prompt as if it were a picture you have seen.

### Command example

User: "用 image-slide 做一条讲 RAG 的视频，配图用这两张"
+ two asset lines with `desc:`

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --topic "三分钟看懂 RAG" \
  --template-id image-slide \
  --scene-images "https://cdn.example.com/a.png" \
  --scene-images "ai: 赛博朋克风格的服务器机房" \
  --scene-images "https://cdn.example.com/b.png"
```

Then write each scene's narration against that scene's image description, and pass the
narration through `prepare_video_assets`'s `dsl_json` as usual.


## Test mode: skip asset generation (`--stub-image-url` / `--stub-video-url`)

**Purpose**: during dev / debug the user wants to exercise the whole pipeline without burning gen-image / gen-video quota. In the DSL this becomes: image / video AssetRefs are written as `source:"existing"` + `status:"generated"` + `url:<stub>`, no `payload.prompt`; the downstream `prepare-video-assets` resolver skips the matching atomic skill.

### When the agent must add the flag

If the user expresses any of "test", "don't actually generate images / videos", "use a unified image / placeholder / stub URL", "save credits", the agent **must** add the corresponding flag to the `gen_script.py` invocation:

- Image-related intent → add `--stub-image-url <URL>`.
- Video-related intent → add `--stub-video-url <URL>`.
- Both → add both.

Typical phrasings (non-exhaustive):
- "Just testing — don't really generate images, use this URL: <URL>"
- "Use <URL> for every image"
- "Don't burn money on images — placeholder is fine: <URL>"
- "Use this stub for video assets first: <URL>"

### Usage rules

- The URL is passed **verbatim**; do not rewrite the host, path, or query.
- If the user expressed the intent but did not supply a URL, the agent must ask which fallback URL to use — never invent one or recycle one from history.
- Narration audio (`gen-voice`) is unaffected; TTS still runs so the script stays audible.
- If the user does not re-state test mode in the next turn, the agent **must not** carry the previous stub URL forward — default back to real generation.

### Command examples

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --topic "English picture-book story" \
  --template-id picture-book-en \
  --stub-image-url "https://cdn.example.com/placeholder.jpg"
```

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --topic "GitHub project tour" \
  --template-id screen-walkthrough \
  --stub-image-url "https://cdn.example.com/img.jpg" \
  --stub-video-url "https://cdn.example.com/demo.mp4"
```

Env vars `STUB_IMAGE_URL` / `STUB_VIDEO_URL` also work — their priority is lower than the CLI flag.

## Steps

1. **Script path**: read the system-injected `Base directory for this skill: <path>` as `<SkillDir>`. Every command below uses `<SkillDir>/scripts/gen_script.py`; never hard-code paths.
2. **Understand the request**: extract topic, duration, style, audience, etc.
3. **If the user specifies a template, read it first**: load the matching `template.json` via `template-registry` and confirm its template-specific fields and asset requirements. Do not skip this step.
4. **Generate the DSL**: pick the right command or DSL shape for the target template (see the per-template commands below).
5. **Schema validation**: make sure the output DSL conforms to v1alpha1.
6. **User confirmation**: show the script summary and wait for confirmation, per the rules above.

### Generic DSL (no template id, or a template with no special payload)

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --topic "3 AI hacks that double your study efficiency" \
  --duration 30 \
  --style "tech" \
  --ratio "9:16"
```

### Validate an existing DSL via the CLI

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --validate \
  --input my-video.dsl.json
```

### Write to a file (debug only — the normal flow does not need this)

> ⚠️ In the normal agent flow, gen_script writes the DSL JSON to stdout and the agent reads it directly from the tool return value — **no `write_file` and no `--output` is needed**. Only use this when debugging locally:

```bash
python3 <SkillDir>/scripts/gen_script.py \
  --topic "How to code with AI" \
  --output output.dsl.json
```

5. **Result handling**: pass the generated DSL to `template-registry` for template lookup, or directly to `prepare-video-assets` (which auto-invokes template binding when `template_id` is provided).

## Common CLI flags

| Flag | Description | Default |
|------|-------------|---------|
| `--topic` | Video topic (required unless `--validate`). | — |
| `--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` |
| `--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`). | `""` |
| `--duration` | Target duration (seconds). Templates whose `durationStrategy` is `fit-caption` / `fit-narration` recompute the real duration and ignore this value. | `30` |
| `--style` | Style tag. | — |
| `--ratio` | Aspect ratio. | `16:9` |
| `--scenes` | Scene count. | auto-planned |
| `--voice-id` | Narration voice id. | resolved from template + language fallback |
| `--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` |
| `--allow-digital-human` | Whether to allow digital-human assets. | off |
| `--allow-ai-video` | Whether to allow AI-generated video assets. | off |
| `--validate` | Validate-only mode: only validate the input DSL. | — |
| `--input` | Input DSL file path (required in validate mode). | — |
| `-o` / `--output` | Output DSL file path. | stdout |
| `--stub-image-url` | Test mode: every image AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_IMAGE_URL`). | — |
| `--stub-video-url` | Test mode: every video AssetRef is written as existing + generated + this URL, no prompt (env: `STUB_VIDEO_URL`). | — |
| `--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. | — |
| `--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. | — |
| `--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. | — |

## DSL generation principles

Apply the following principles when producing the DSL:

1. **Narrative-first**: opening hook → point expansion → CTA close.
2. **Assets are declared, not executed**: every asset starts as `AssetRef` with `status: planned`; no generation API call from this skill.
3. **Narration drives duration**: each scene's duration should match its narration reading time (≈ 3–4 zh chars/sec, or ~2 words/sec for English).
4. **One complete thought per line — do NOT pre-split at subtitle width.** Separate lines inside `audio.narration.text` with `\n`, and put **one full clause or sentence** on each line (roughly 15–30 Chinese characters, or an equivalent number of English words). A `\n` is a hard cut in the *audio*: TTS treats each line as its own segment and returns a timestamp for it, so lines should fall where a human would actually pause for breath.

   **Do not cut mid-phrase to make lines "subtitle-sized".** The renderer splits each line into on-screen subtitles itself, and it does that better than you can from the text alone: it measures the real glyph widths for the actual font size and frame (`HNSW` is not as wide as four Chinese characters), knows how many characters fit on one line of *this* template and aspect ratio, and picks cut points by punctuation strength (sentence end → colon → comma → enumeration comma → space). It also guarantees no wrapping. Pre-splitting takes all of that away and just makes the captions choppy.

   Measured on a shipped video (2026-09-21): narration written as 6–8 character lines produced 57 subtitle screens in 121.5s — a median of 2.10s each, 33% of them under 1.5s, with `AI Agent` alone on a screen. The renderer's own splitter never even fired.

   Write:
   ```
   "text": "最近刷爆科技圈的 AI Agent 到底厉害在哪\n真正的分水岭不是文案更精准，而是自主执行能力"
   ```
   not:
   ```
   "text": "最近刷爆科技圈的\nAI Agent\n到底厉害在哪\n不是文案更精准\n也不是对话更流畅"
   ```

   **Use full-width punctuation in Chinese narration** (`，。：；？！`), not half-width (`,.:;?!`). Half-width punctuation in Chinese text renders with no trailing space — `第一步,目标感知:只给最终目标` reads cramped on screen.
5. **Moderate scene count**: 30-second videos work well with 4–6 scenes, 60-second videos with 6–10.
6. **Leave room for templates**: pick generic layouts; do not assume a specific template implementation.
7. **Image model allowlist**: every `type: image` + `source: gen-image` `AssetRef`'s `payload.model` **must** be one of the values in the table below. **Never** use display names, short forms, or made-up ids (e.g. `seedream`, `seedream-5`, etc.).

### Allowlist `model` values aligned with gen-image

The roster is owned by the backend catalog (`/model/capabilities`), which `gen-image` resolves at
runtime. A DSL is persisted and replayed later, so write the **full id** — copy verbatim, including
prefix and version — not a short alias:

| `payload.model` | Display name | Provider | Notes |
|-----------------|--------------|----------|-------|
| `doubao/doubao-seedream-5-0-260128` | Seedream 5.0 Lite | Volcano | Default. Highest output resolution, up to 14 reference images. |
| `doubao/doubao-seedream-5-0-pro-260628` | Seedream 5.0 Pro | Volcano | High fidelity: precise element placement, faithful on-image text. Up to 10 reference images, caps out around 2K. Costs noticeably more per image. |

**Agent behavior (avoid accidentally rewriting `model`)**:

- `gen_script.py` already writes a valid `payload.model` (`doubao/doubao-seedream-5-0-260128` unless `DEFAULT_IMAGE_MODEL` overrides it). When the user only asks to refine narration, change `payload.prompt`, add or remove scenes, etc. and does **not** ask to change the image model, the agent **must keep** each image asset's original `payload.model` — do not replace it under the guise of "polishing the script".
- **Only when the user explicitly asks to change the image model** (e.g. switches to the Pro variant), update the corresponding image `AssetRef`'s `payload.model` to the matching row id from the table. Writing a display name or an alias into JSON is wrong.
- When creating a new image `AssetRef`, pick one of the values above for `payload.model`; default to `doubao/doubao-seedream-5-0-260128` to match the script, or to whichever value the user specified.
- If a run fails with an unknown-model error, the catalog has moved on from this table — check `/model/capabilities` rather than guessing a version string.

## Error handling

- **Schema validation failed**: check the DSL JSON shape and required fields against the schema.
- **Scene duration mismatch**: adjust the narration length or the scene duration.
- **`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).
- **`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.
- **`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.

## scripts/ contents

| File | Purpose |
|------|---------|
| `gen_script.py` | Core script — produces the Video DSL JSON from a topic. |

The authoritative per-template contract is the registry's `template.json` (`llmHint` + `customPayloadSchema` + `slideSchemas` + `slotMapping`) — everything you need to author against is in there, so read it rather than inferring field names from the template's name or from another template. `template_registry list_examples=true` lists local reference DSLs when any exist, but that directory does not ship — see *DSL schema* above.
