# Motion Studio — Scene Specification for AI Agents

**You are reading the scene-file specification for Motion Studio, a browser-based motion-template editor.** Your job: given a description of the motion the user wants (e.g. "album art in an Apple-style cover flow", "photos drifting slowly in a ring", "a deck of cards being dealt"), produce a **complete, valid scene JSON** that imports directly into Motion Studio and reproduces that effect. Pick the closest template, start from the closest preset in the library below, then tune the numbered parameters within their stated ranges. Output only legal values — every field, range, and default in §3–§5 of this document is generated from the app's own schema (see Authority below); if a value here disagrees with the app, the app wins.

<!-- SPEC:AUTO header BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
- **Engine version:** `1.0.0` · **Scene schema version:** `10`
- **Seven templates:** `orbit`, `carousel`, `stack`, `band`, `coverflow`, `grid`, `sphere`
- **Authority:** generated from the app's own schema at `motion-studio@d294e83` (`sha256:448c03150707152de42be71fc2a88361b1182ad2185cc360cec784a9ad7c531f`). If any value here disagrees with the app, the app wins.
<!-- SPEC:AUTO header END -->

---

<!-- TOC BEGIN -->
**Contents** — decision path first, detail after.

| | |
|---|---|
| [Quick start](#quick-start--read-this-first) | pick the template, the checklist, the response contract |
| [1. How to deliver this scene](#1-how-to-deliver-this-scene) | what the user does with what you emit |
| [2. What Motion Studio does not do](#2-what-motion-studio-does-not-do) | check this before you build, not after |
| [3. Scene JSON format](#3-scene-json-format) | the envelope, the three pitfalls, the shared fields |
| [4. Worked examples](#4-worked-examples--user-intent--complete-scene-json) | complete scenes you can copy and tune |
| [5. Preset library](#5-preset-library-29-presets) | 29 validated starting points |
| [6. Template parameter schemas](#6-template-parameter-schemas) | every field, range and default, per template |
| [7. Canvas & background](#7-canvas--background) | aspect ratio, fps, background |
| [8. Assets](#8-assets) | images and video in a scene |
<!-- TOC END -->

<!-- QUICKSTART BEGIN -->

## Quick start — read this first

Three things, in order: pick the template, follow the checklist, obey the response contract. Everything below §1 is detail you consult once you know what you're building.

### Step 1 — Pick the template

| the user is asking for… | `templateId` |
|---|---|
| Apple Music / Cover Flow, a focused card with angled neighbours | `coverflow` |
| a ring, a carousel of cards going around, a ferris wheel, a top-down circle | `orbit` |
| a marquee, a ticker, a strip scrolling horizontally or at an angle | `carousel` |
| dealing, a deck, cards flying off one at a time | `stack` |
| a curved image band, a fisheye strip, an arc of photos | `band` |
| an image wall, a spatial gallery, a camera touring a plane of photos | `grid` |
| a globe, a sphere of images, a spherical cloud | `sphere` |

Those seven strings are the **only** legal values of `templateId`. Modes within a template — `grid`'s `cameraMode`, for instance — are fields **inside** `config`. Compound ids of the form "grid.route" or "grid-pan" do not exist and will not import. If you land on `grid`, read the three-mode table in §6.6 before choosing a preset: the mode decides whether the camera moves by itself, whether the viewer can drag, and whether mp4 export is available.

If the request doesn't land on one of the seven, say so — see the response contract below.

### Step 2 — Checklist

1. **Confirm the request fits one of the seven templates.** If it doesn't, stop and say which capability is missing.
2. **Pick the closest preset** from §5 — presets are validated, hand-tuned starting points; starting from one beats assembling a config from defaults.
3. **Copy that preset's complete `config`**, then tune only what the user's request actually calls for, keeping every value inside the range in §6.
4. **Add the `interaction` block** — it is never in a template's schema, and a config without it is incomplete. Defaults are in §3; `grid`'s `spotlight` preset is the one that ships `"drag": false`.
5. **Fill in `canvas`, `assets`, `meta`** to match §3's shape, then validate against [`/scene-schema.json`](/scene-schema.json) if you can run a validator.
6. **Emit the JSON** per the response contract — nothing else in the reply.

### Step 3 — Response contract

**When you can build the scene:**

- Reply with **exactly one fenced ` ```json ` block** and nothing else — no preamble, no explanation after it, no summary of what you did.
- The block must be **valid JSON**, not JSONC: **no comments**, no trailing commas, no unquoted keys. (§5 of this document shows presets in `jsonc` with `//` comments for human readability — those comments must never appear in what you emit.)
- Use straight double quotes, and no ellipses or placeholders — every value must be a real, legal value.

**Never map a verb onto the nearest-looking field.**

This is the failure a schema validator cannot catch: the JSON is legal, imports fine, and does something the user did not ask for. If a word in the request has no field that means it, **say which capability is missing outside the JSON** — do not reach for the field whose name sounds closest.

- "pause on hover" → there is **no hover-pause in Motion Studio**. `interaction.cursor.hover` is a *cursor effect* — the card scales up near the pointer — and it has nothing to do with playback. Same for `cursor.gravity` (cards drift toward the pointer) and `cursor.face` (cards tilt to face the pointer). None of the three start, stop, or slow the loop.
- "stop at the end" → every scene is a seamless loop; there is no end state and no stop.
- "click a card to open it" → cards are not links; there is no click target, no per-card action.
- "make card 3 wait" → no per-card timing (§2).

The test to apply before you write a field: *can I point at the field whose documented effect is the thing the user asked for?* If the honest answer is "no, but this one is close", that is the moment to stop and report — not the moment to guess.

**When the request is ambiguous:**

- Ask **at most one** clarifying question, and only when you genuinely cannot choose (e.g. no hint of layout at all). Do not emit a partial scene alongside it. If a reasonable default exists, use it and proceed instead of asking.

**When Motion Studio cannot do what was asked:**

- **Do not emit JSON.** A plausible-looking scene that doesn't do the requested thing is worse than no scene.
- Name the specific missing capability — text or typography, per-card timing, video editing, 3D models, audio, arbitrary paths — and, if one exists, the nearest thing the seven templates can do. §2 lists what the product does not do.

<!-- QUICKSTART END -->

---


## 1. How to deliver this scene

1. Open https://motion-studio-ecru.vercel.app
2. Click "Import" in the top bar.
3. Paste the complete scene JSON — the scene loads immediately and stays fully editable. Nothing is uploaded; the app runs entirely in the browser.

---


## 2. What Motion Studio does not do

The seven templates above are the whole surface. If the user's request needs something in this list, **say so and report back — do not pick the nearest template and hope**. A scene that silently drops half the brief is worse than a clear "this tool can't do that".

- **No text animation.** Cards carry images and video only. There is no text layer, no kinetic type, no word-by-word or letter-by-letter reveal, no captions or subtitles.
- **No path animation.** Motion is whatever the chosen template's geometry does (ring, line, arc, deck, plane, globe). You cannot author a custom path, waypoints, or keyframes for an individual card.
- **No scene transitions.** One scene is one template running one loop. There is no cutting between scenes, no cross-fade between templates, no multi-shot sequence in a single file.
- **No audio.** No music, no sound effects, no beat sync. mp4 exports are silent.
- **No per-card timing.** Every card follows the same timeline; you cannot delay, hold, or re-order one card independently of the rest.
- **No masks, filters or blend modes**, and no colour grading of the imported images.
- **No 3D models, particles, or physics** beyond each template's own built-in motion.

What it *does* do: one template, fully parameterised, exported either as a seamless mp4 loop or as a live embed that stays editable. If the brief fits that shape, it fits Motion Studio.


## 3. Scene JSON format

A scene file is a single JSON object. This exact shape is what Motion Studio's own "Export → JSON" produces and what "Import" reads:

```jsonc
{
  "version": 2,
  "templateId": "coverflow",            // one of the 7 template ids
  "config": { /* all of that template's parameters + the interaction block */ },
  "canvas": {
    "aspectRatio": "1:1",               // "1:1" | "9:16" | "16:9"
    "fps": 30,                          // 15 | 25 | 30 | 60
    "background": { /* see §7 */ }
  },
  "assets": [],                         // [] = built-in placeholder images cycle; see §8
  "meta": {
    "engine": "1.0.0",
    "schemaVersion": 10,
    "template": "coverflow",            // same as templateId
    "created": "2026-01-01T00:00:00.000Z"  // ISO timestamp; any valid ISO string is fine
  }
}
```

`"version": 2` — a constant required by the importer. It is NOT `meta.schemaVersion` (which is 10). Always emit exactly 2.

**Required to import:** `templateId` (must be one of the seven) and `config`. Everything else is filled from defaults if omitted, but always emit the full object above — it's what a real export looks like and avoids surprises. `canvas.aspectRatio` / `canvas.fps` outside their allowed sets are ignored (the current value is kept), so only use the listed values.

### ⚠️ Three pitfalls — get these wrong and the scene is incomplete

1. **Every `config` must contain an `interaction` block.** It is NOT listed in any template's schema — Motion Studio injects it into every config. Emit it verbatim (defaults shown; change only if the user asks for cursor/drag behavior):
   ```jsonc
   "interaction": {
     "drag": true,            // pointer/touch drag + flick (on by default)
     "scroll": false,         // wheel scrubbing
     "onRelease": "resume",   // after a drag, resume autoplay ("resume" is the only supported value)
     "cursor": {
       "gravity": { "enabled": false, "strength": 50, "radius": 350 },  // cards pulled toward cursor
       "hover":   { "enabled": false, "strength": 50, "radius": 350 },  // card scales up near cursor
       "face":    { "enabled": false, "strength": 50, "radius": 350 }   // card tilts to face cursor
     }
   }
   ```
   `strength` and `radius` ranges: `strength` 0–100, `radius` a px reach (≈50–800 is sensible; 350 is the default).
   **All three `cursor` entries are pointer-proximity effects on the cards. None of them touches playback.** `hover` scales a card up near the pointer, `gravity` pulls cards toward it, `face` tilts cards to look at it — the loop keeps running through all of them. There is no hover-pause, hover-stop or hover-slow anywhere in Motion Studio; if that is what was asked for, report it as missing rather than enabling `hover`.
   **`drag` does not mean the same thing on every template.** On most of them it is the 1-D time drag (scrub the timeline by dragging). On templates that own their own drag gesture it controls *that* gesture instead, and the 1-D driver is not attached at all:
   - **`grid`** with `cameraMode: "pan"` (freehand) or `"parallax"` (infinity) — `drag` controls the 2-D spatial pan. Both ship `"drag": true`. With `cameraMode: "route"` (spotlight) it is the ordinary 1-D time drag, and that preset ships `"drag": false`.
   - **`sphere`** — `drag` controls trackball rotation of the globe. Ships `"drag": true`.
   Every other template uses the 1-D time drag with `"drag": true`.

2. **Hidden and locked fields must still be present.** They never appear in the editor panel but are part of the config:
   - **All templates:** `easeCustom` (default `[0.42, 0, 0.58, 1]`) — the cubic-bezier used only when `ease` is `"custom"`.
   - **Stack only:** `jitterAmount` (default `0`), `jitterSpeed` (default `2`) — hidden but read by the math; and `rotationX`, `rotationY`, `rotationZ` (all `0`), `perspective` (`1000`) — locked, always present.

3. **`ease` and `hold` only take effect when `motion` is `"step"`.** They're still valid fields at any time, but they're inert under `motion: "continuous"`. (Note: `band` and `coverflow` have no `hold` field at all — see their tables.)

### Shared fields (same meaning across templates that have them)

| field | type | range / options | effect |
|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | aspect ratio of each card |
| `count` | int | see per-template range | number of cards (absent on `band`) |
| `cardSize` | int | see per-template range | card width in px (height derived from ratio) |
| `cornerRadius` | int | 0–50 | card corner rounding px. On `band` this rounds the corners of the card's **visible** outline (card box ∩ arc band), not the card element itself — same range, same "percent of the short side" reading. |
| `direction` | toggle | `forward` `reverse` | travel/rotation direction. **`forward` = cards travel left→right on screen** (band, carousel at `angle: 0`, and orbit's front-facing cards). Four templates differ — see their own tables: `coverflow` and `sphere` run the other way, `stack` doesn't use it as a screen direction, and `grid` has no `direction` field at all. `reverse` always mirrors whatever `forward` does. |
| `motion` | toggle | `continuous` `step` | smooth vs card-by-card stepping |
| `ease` | enum | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | step easing (only when `motion: step`) |
| `easeCustom` | array | `[x1,y1,x2,y2]` | cubic-bezier when `ease: custom` |
| `hold` | int | 0–80 | pause fraction between steps (only `motion: step`; not on band/coverflow) |
| `cycles` | int | 1–6 | full loops packed into one `duration` |
| `duration` | int | 2–60 | seconds for the whole loop |
| `zoom` | int | **not the same everywhere** — 50–300 on six templates, 20–600 on `grid`; see the divergence table below | viewport zoom % |
| `offsetX` / `offsetY` | int | -500–500 | viewport shift px |

#### Field names that get invented

These are wrong spellings seen in real agent output. The right-hand column is the only accepted form; the wrong one fails the import outright (`additionalProperties: false`):

<!-- SPEC:AUTO misnomers BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| written by mistake | the real key | where | note |
|---|---|---|---|
| `gridColumns` ❌ | `columns` ✅ | `grid` | no template prefixes its own fields |
| `gridRows` ❌ | `rows` ✅ | `grid` | same — the template id is not part of the key |
| `sphereRadius` ❌ | `radius` ✅ | `sphere` | same |
| `gapX` ❌ | `gap` ✅ | `grid` | one gap, both axes |
| `gapY` ❌ | `gap` ✅ | `grid` | one gap, both axes |

`grid` has **no `count` field** — the number of cards is `columns` × `rows`. Adding `count` to a `grid` config fails the import.
<!-- SPEC:AUTO misnomers END -->

#### Ranges that differ by template

A field with the same name does not always have the same range. These are the ones that differ — generated from the schema, so a future divergence shows up here on its own:

<!-- SPEC:AUTO range-divergence BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | range depends on the template |
|---|---|
| `cardSize` | **120–1200** on `orbit`, `carousel`, `stack` · **30–500** on `band` · **60–200** on `coverflow` · **60–400** on `grid` · **24–400** on `sphere` |
| `count` | **3–30** on `orbit`, `coverflow` · **1–30** on `carousel` · **3–10** on `stack` · **12–400** on `sphere` |
| `depth` | **-100–100** on `carousel` · **0–100** on `grid` |
| `duration` | **2–60** on `orbit`, `carousel`, `stack`, `band`, `coverflow` · **4–120** on `sphere` |
| `gap` | **-300–400** on `carousel` · **-30–40** on `band` · **-80–40** on `coverflow` · **-40–120** on `grid` |
| `highlightScale` | **1–1.3** on `grid` · **1–3** on `sphere` |
| `perspective` | **400–1600** on `orbit` · **1000–10000** on `carousel` · **2000–10000** on `coverflow` |
| `rotationX` | **-45–45** on `orbit` · **-180–180** on `carousel` |
| `rotationY` | **-45–45** on `orbit` · **-180–180** on `carousel` |
| `rotationZ` | **-45–45** on `orbit` · **-180–180** on `carousel` |
| `zoom` | **50–300** on `orbit`, `carousel`, `stack`, `band`, `coverflow`, `sphere` · **20–600** on `grid` |

Every other slider has the same range everywhere it appears — those are in §3's shared table and in each template's own table in §6. When in doubt the per-template table in §6 wins: it is generated straight from the app's schema.
<!-- SPEC:AUTO range-divergence END -->

The `ease` enum values are GSAP ease names; the label a human sees is in parentheses: `none`(Linear) `power2.inOut`(Smooth) `power4.inOut`(Sharp) `expo.out`(Snap) `back.out`(Overshoot) `elastic.out`(Bounce) `custom`(Custom).

---


## 4. Worked examples — user intent → complete scene JSON

Each is a full, importable file. Copy, then tune to the user's specifics.

### Example A — "Apple-style cover flow of album covers, square, autoplay"

Closest template `coverflow`, closest preset `coverflow`:

```json
{
  "version": 2,
  "templateId": "coverflow",
  "config": {
    "count": 15, "cardRatio": "1:1", "cardSize": 150, "cornerRadius": 0, "gap": -56,
    "sideRotation": -49, "depthFalloff": 14, "focusEmphasis": 8,
    "direction": "forward", "motion": "step", "ease": "expo.out", "easeCustom": [0.42, 0, 0.58, 1],
    "cycles": 1, "duration": 26, "zoom": 267, "offsetX": 0, "offsetY": 0, "perspective": 2000,
    "interaction": {
      "drag": true, "scroll": false, "onRelease": "resume",
      "cursor": {
        "gravity": { "enabled": false, "strength": 50, "radius": 350 },
        "hover":   { "enabled": false, "strength": 50, "radius": 350 },
        "face":    { "enabled": false, "strength": 50, "radius": 350 }
      }
    }
  },
  "canvas": {
    "aspectRatio": "1:1", "fps": 30,
    "background": { "type": "solid", "solid": { "color": "#F7F0E8" },
      "gradient": { "mode": "linear", "stops": [{ "pos": 0, "color": "#000000" }, { "pos": 100, "color": "#FFFFFF" }], "angle": 180, "center": { "x": 50, "y": 50 }, "radius": 20 },
      "image": { "src": null, "name": null } }
  },
  "assets": [],
  "meta": { "engine": "1.0.0", "schemaVersion": 10, "template": "coverflow", "created": "2026-01-01T00:00:00.000Z" }
}
```

### Example B — "Photos drifting slowly around a ring, portrait, calm"

Closest template `orbit`, closest preset `drift`, slowed slightly:

```json
{
  "version": 2,
  "templateId": "orbit",
  "config": {
    "cardRatio": "3:4", "count": 8, "cardSize": 290, "cornerRadius": 16,
    "orbitRadius": 485, "planeX": 0, "planeZ": 0, "orient": 0, "perspective": 1000,
    "direction": "forward", "motion": "continuous", "ease": "power2.inOut", "easeCustom": [0.42, 0, 0.58, 1],
    "hold": 50, "cycles": 1, "duration": 36,
    "jitterAmount": 0, "jitterSpeed": 2, "rotationDrift": 0,
    "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 20, "scale": 70,
    "rotationX": 0, "rotationY": 0, "rotationZ": 0, "zoom": 130, "offsetX": 0, "offsetY": 0,
    "interaction": {
      "drag": true, "scroll": false, "onRelease": "resume",
      "cursor": {
        "gravity": { "enabled": false, "strength": 50, "radius": 350 },
        "hover":   { "enabled": false, "strength": 50, "radius": 350 },
        "face":    { "enabled": false, "strength": 50, "radius": 350 }
      }
    }
  },
  "canvas": {
    "aspectRatio": "16:9", "fps": 30,
    "background": { "type": "solid", "solid": { "color": "#111111" },
      "gradient": { "mode": "linear", "stops": [{ "pos": 0, "color": "#000000" }, { "pos": 100, "color": "#FFFFFF" }], "angle": 180, "center": { "x": 50, "y": 50 }, "radius": 20 },
      "image": { "src": null, "name": null } }
  },
  "assets": [],
  "meta": { "engine": "1.0.0", "schemaVersion": 10, "template": "orbit", "created": "2026-01-01T00:00:00.000Z" }
}
```

### Example C — "A deck of cards being dealt downward, vertical video"

Closest template `stack`, closest preset `down`, on a 9:16 canvas:

```json
{
  "version": 2,
  "templateId": "stack",
  "config": {
    "count": 6, "cardSize": 360, "cardRatio": "4:3", "cornerRadius": 6,
    "stackOffsetX": 0, "stackOffsetY": -36, "stackDepth": 60,
    "exitDirection": "down", "exitDistance": 400,
    "direction": "forward", "motion": "step", "ease": "power2.inOut", "easeCustom": [0.42, 0, 0.58, 1],
    "hold": 40, "cycles": 1, "duration": 8,
    "jitterAmount": 0, "jitterSpeed": 2, "rotationDrift": 0,
    "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 53, "scale": 70,
    "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000,
    "zoom": 200, "offsetX": 0, "offsetY": 0,
    "interaction": {
      "drag": true, "scroll": false, "onRelease": "resume",
      "cursor": {
        "gravity": { "enabled": false, "strength": 50, "radius": 350 },
        "hover":   { "enabled": false, "strength": 50, "radius": 350 },
        "face":    { "enabled": false, "strength": 50, "radius": 350 }
      }
    }
  },
  "canvas": {
    "aspectRatio": "9:16", "fps": 30,
    "background": { "type": "solid", "solid": { "color": "#F7F0E8" },
      "gradient": { "mode": "linear", "stops": [{ "pos": 0, "color": "#000000" }, { "pos": 100, "color": "#FFFFFF" }], "angle": 180, "center": { "x": 50, "y": 50 }, "radius": 20 },
      "image": { "src": null, "name": null } }
  },
  "assets": [],
  "meta": { "engine": "1.0.0", "schemaVersion": 10, "template": "stack", "created": "2026-01-01T00:00:00.000Z" }
}
```

---


## 5. Preset library (29 presets)

Each preset is a **validated, hand-tuned starting point** — copy its `config`, append the `interaction` block, and adjust. The value shown is the **complete effective config**: the preset's own values merged over the schema defaults (so hidden/locked fields are already filled in). Drop it straight into a scene file's `config` and add the `interaction` block. ⭐ marks the template's default "opening look".

<!-- SPEC:AUTO presets-orbit BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### orbit (11)

```jsonc
// drift — slow large cards gently drifting
{ "cardRatio": "4:3", "count": 8, "cardSize": 290, "cornerRadius": 16, "orbitRadius": 485, "planeX": 0, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "continuous", "ease": "power2.inOut",
  "easeCustom": [0.42,0,0.58,1], "hold": 50, "cycles": 1, "duration": 30, "jitterAmount": 0, "jitterSpeed": 2,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 20, "scale": 70, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 130, "offsetX": 0, "offsetY": 0 }

// bounce ⭐ — stepped springy entrance (back.out)
{ "cardRatio": "3:4", "count": 8, "cardSize": 230, "cornerRadius": 2, "orbitRadius": 288, "planeX": 0, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "step", "ease": "back.out",
  "easeCustom": [0.42,0,0.58,1], "hold": 80, "cycles": 1, "duration": 16, "jitterAmount": 0, "jitterSpeed": 2,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 70, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 130, "offsetX": 0, "offsetY": 0 }

// breathe-right — jitter "breathing" on a tilted plane
{ "cardRatio": "3:4", "count": 16, "cardSize": 230, "cornerRadius": 6, "orbitRadius": 380, "planeX": 0,
  "planeZ": -45, "orient": 0, "perspective": 1000, "direction": "forward", "motion": "continuous",
  "ease": "back.out", "easeCustom": [0.42,0,0.58,1], "hold": 80, "cycles": 1, "duration": 30, "jitterAmount": 16,
  "jitterSpeed": 0.5, "rotationDrift": 10, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 10,
  "rotationX": 0, "rotationY": 0, "rotationZ": 0, "zoom": 130, "offsetX": 0, "offsetY": 0 }

// side — orbit pushed off to the right edge
{ "cardRatio": "3:4", "count": 10, "cardSize": 260, "cornerRadius": 16, "orbitRadius": 442, "planeX": 0, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "continuous", "ease": "back.out",
  "easeCustom": [0.42,0,0.58,1], "hold": 80, "cycles": 1, "duration": 26, "jitterAmount": 0, "jitterSpeed": 0.5,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 75, "scale": 30, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 182, "offsetX": 500, "offsetY": 0 }

// circle — flat top-down ring (planeX 90)
{ "cardRatio": "1:1", "count": 8, "cardSize": 360, "cornerRadius": 6, "orbitRadius": 417, "planeX": 90, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "continuous", "ease": "power2.inOut",
  "easeCustom": [0.42,0,0.58,1], "hold": 72, "cycles": 1, "duration": 16, "jitterAmount": 0, "jitterSpeed": 0.5,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 77, "scale": 55, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 100, "offsetX": 0, "offsetY": 0 }

// ferris — vertical ferris wheel (planeZ 90)
{ "cardRatio": "3:4", "count": 12, "cardSize": 226, "cornerRadius": 6, "orbitRadius": 252, "planeX": 0, "planeZ": 90,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "continuous", "ease": "back.out",
  "easeCustom": [0.42,0,0.58,1], "hold": 80, "cycles": 1, "duration": 16, "jitterAmount": 0, "jitterSpeed": 2,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 27, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 130, "offsetX": 0, "offsetY": 0 }

// tilt — tilted plane with dynamic lean
{ "cardRatio": "1:1", "count": 12, "cardSize": 231, "cornerRadius": 6, "orbitRadius": 417, "planeX": 0, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "step", "ease": "expo.out",
  "easeCustom": [0.42,0,0.58,1], "hold": 80, "cycles": 1, "duration": 16, "jitterAmount": 0, "jitterSpeed": 0.5,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 100, "tiltZ": 100, "fade": 100, "scale": 100, "rotationX": 22,
  "rotationY": 0, "rotationZ": 0, "zoom": 100, "offsetX": 0, "offsetY": 86 }

// corner — huge cards, top-down ring pushed into a corner
{ "cardRatio": "1:1", "count": 8, "cardSize": 740, "cornerRadius": 4, "orbitRadius": 551, "planeX": 90, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "step", "ease": "power2.inOut",
  "easeCustom": [0.42,0,0.58,1], "hold": 72, "cycles": 1, "duration": 16, "jitterAmount": 0, "jitterSpeed": 0.5,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 55, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 119, "offsetX": -500, "offsetY": 500 }

// ring — reverse-spinning tilted ring with drift
{ "cardRatio": "1:1", "count": 11, "cardSize": 554, "cornerRadius": 8, "orbitRadius": 424, "planeX": 31, "planeZ": 0,
  "orient": 0, "perspective": 1600, "direction": "reverse", "motion": "step", "ease": "power2.inOut",
  "easeCustom": [0.42,0,0.58,1], "hold": 72, "cycles": 1, "duration": 16, "jitterAmount": 0, "jitterSpeed": 0.5,
  "rotationDrift": 15, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 10, "rotationX": 21, "rotationY": 0,
  "rotationZ": 0, "zoom": 92, "offsetX": 0, "offsetY": 146 }

// concentric — cards facing center on a flat ring (orient 100)
{ "cardRatio": "1:1", "count": 15, "cardSize": 314, "cornerRadius": 10, "orbitRadius": 605, "planeX": 90,
  "planeZ": 0, "orient": 100, "perspective": 1000, "direction": "forward", "motion": "continuous",
  "ease": "power2.inOut", "easeCustom": [0.42,0,0.58,1], "hold": 72, "cycles": 1, "duration": 16, "jitterAmount": 0,
  "jitterSpeed": 0.5, "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 77, "scale": 55,
  "rotationX": 0, "rotationY": 0, "rotationZ": 0, "zoom": 71, "offsetX": 0, "offsetY": 0 }

// spin — upright cards spinning quickly
{ "cardRatio": "3:4", "count": 10, "cardSize": 178, "cornerRadius": 6, "orbitRadius": 445, "planeX": 0, "planeZ": 0,
  "orient": 0, "perspective": 1000, "direction": "forward", "motion": "continuous", "ease": "expo.out",
  "easeCustom": [0.42,0,0.58,1], "hold": 80, "cycles": 1, "duration": 10, "jitterAmount": 0, "jitterSpeed": 0.5,
  "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 100, "rotationX": 0, "rotationY": 0,
  "rotationZ": 0, "zoom": 85, "offsetX": 0, "offsetY": 0 }
```
<!-- SPEC:AUTO presets-orbit END -->
<!-- SPEC:AUTO presets-carousel BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### carousel (3)

```jsonc
// domino — shingled cards toppling (rotateY, reverse)
{ "cardRatio": "3:4", "count": 15, "cardSize": 442, "cornerRadius": 6, "gap": -255, "angle": 198, "depth": 0,
  "stacking": "forward", "perspective": 10000, "rotationX": 0, "rotationY": 41, "rotationZ": 0,
  "direction": "reverse", "motion": "step", "ease": "power4.inOut", "easeCustom": [0.42,0,0.58,1], "hold": 50,
  "cycles": 1, "duration": 25, "lift": 0, "tiltX": 0, "tiltY": 76, "tiltZ": 100, "fade": 0, "sizeGradient": 0,
  "zoom": 100, "offsetX": 0, "offsetY": 0 }

// flip ⭐ — stepped card flip (rotateY) with center lift
{ "cardRatio": "4:3", "count": 11, "cardSize": 161, "cornerRadius": 12, "gap": -99, "angle": 146, "depth": 0,
  "stacking": "forward", "perspective": 3138, "rotationX": 0, "rotationY": -43, "rotationZ": 0,
  "direction": "forward", "motion": "step", "ease": "power4.inOut", "easeCustom": [0.42,0,0.58,1], "hold": 50,
  "cycles": 1, "duration": 18, "lift": 82, "tiltX": 100, "tiltY": 0, "tiltZ": 0, "fade": 0, "sizeGradient": 0,
  "zoom": 300, "offsetX": 0, "offsetY": 125 }

// swipe — big cards sliding full-bleed (custom ease)
{ "cardRatio": "3:4", "count": 6, "cardSize": 580, "cornerRadius": 6, "gap": 19, "angle": 0, "depth": 0,
  "stacking": "forward", "perspective": 10000, "rotationX": 0, "rotationY": 0, "rotationZ": 0,
  "direction": "forward", "motion": "step", "ease": "custom", "easeCustom": [0.77,0,0,0.9902514494889937],
  "hold": 50, "cycles": 1, "duration": 12, "lift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 100, "fade": 0,
  "sizeGradient": 0, "zoom": 100, "offsetX": 0, "offsetY": 0 }
```
<!-- SPEC:AUTO presets-carousel END -->
<!-- SPEC:AUTO presets-stack BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### stack (5)

```jsonc
// down ⭐ — deck dealt downward
{ "cardRatio": "4:3", "count": 6, "cardSize": 360, "cornerRadius": 6, "stackOffsetX": 0, "stackOffsetY": -36,
  "stackDepth": 60, "exitDirection": "down", "exitDistance": 400, "direction": "forward", "motion": "step",
  "ease": "power2.inOut", "easeCustom": [0.42,0,0.58,1], "hold": 40, "cycles": 1, "duration": 8, "jitterAmount": 0,
  "jitterSpeed": 2, "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 53, "scale": 70, "zoom": 200,
  "offsetX": 0, "offsetY": 0, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000 }

// up — deck dealt upward
{ "cardRatio": "4:3", "count": 6, "cardSize": 360, "cornerRadius": 6, "stackOffsetX": 0, "stackOffsetY": 30,
  "stackDepth": 60, "exitDirection": "up", "exitDistance": 400, "direction": "forward", "motion": "step",
  "ease": "power2.inOut", "easeCustom": [0.42,0,0.58,1], "hold": 40, "cycles": 1, "duration": 8, "jitterAmount": 0,
  "jitterSpeed": 2, "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 53, "scale": 70, "zoom": 200,
  "offsetX": 0, "offsetY": 0, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000 }

// fling — card flung to the right (elastic, continuous)
{ "cardRatio": "4:3", "count": 10, "cardSize": 421, "cornerRadius": 6, "stackOffsetX": -80, "stackOffsetY": 80,
  "stackDepth": 169, "exitDirection": "right", "exitDistance": 100, "direction": "forward", "motion": "continuous",
  "ease": "elastic.out", "easeCustom": [0.42,0,0.58,1], "hold": 40, "cycles": 1, "duration": 10, "jitterAmount": 0,
  "jitterSpeed": 2, "rotationDrift": 0, "tiltX": 0, "tiltY": 100, "tiltZ": 0, "fade": 100, "scale": 38, "zoom": 167,
  "offsetX": 0, "offsetY": 0, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000 }

// bounce — card dealt down with an elastic bounce + drift
{ "cardRatio": "4:3", "count": 6, "cardSize": 442, "cornerRadius": 6, "stackOffsetX": 0, "stackOffsetY": -26,
  "stackDepth": 66, "exitDirection": "down", "exitDistance": 100, "direction": "forward", "motion": "step",
  "ease": "elastic.out", "easeCustom": [0.42,0,0.58,1], "hold": 40, "cycles": 1, "duration": 14, "jitterAmount": 0,
  "jitterSpeed": 2, "rotationDrift": 15, "tiltX": 100, "tiltY": 0, "tiltZ": 0, "fade": 100, "scale": 70, "zoom": 214,
  "offsetX": 0, "offsetY": 0, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000 }

// reverse — deck run in reverse (continuous)
{ "cardRatio": "4:3", "count": 9, "cardSize": 421, "cornerRadius": 6, "stackOffsetX": 80, "stackOffsetY": -73,
  "stackDepth": 169, "exitDirection": "down", "exitDistance": 100, "direction": "reverse", "motion": "continuous",
  "ease": "elastic.out", "easeCustom": [0.42,0,0.58,1], "hold": 40, "cycles": 1, "duration": 14, "jitterAmount": 0,
  "jitterSpeed": 2, "rotationDrift": 0, "tiltX": 0, "tiltY": 0, "tiltZ": 0, "fade": 75, "scale": 70, "zoom": 192,
  "offsetX": 0, "offsetY": 4, "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000 }
```
<!-- SPEC:AUTO presets-stack END -->
<!-- SPEC:AUTO presets-band BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### band (3)

```jsonc
// pinch ⭐ — flat strip pinched tall at center (curve 0, tall gradient)
{ "cardRatio": "3:4", "cardSize": 384, "cornerRadius": 0, "lens": 0, "curve": 0, "heightGradient": 442,
  "axisRotation": 0, "cardTilt": 0, "gap": 25, "direction": "forward", "motion": "step", "ease": "expo.out",
  "easeCustom": [0.42,0,0.58,1], "cycles": 1, "duration": 28, "zoom": 116, "offsetX": 0, "offsetY": 0 }

// horizon — full arc horizon band (curve 100)
{ "cardRatio": "4:3", "cardSize": 378, "cornerRadius": 0, "lens": 0, "curve": 100, "heightGradient": 200,
  "axisRotation": 0, "cardTilt": 0, "gap": 12, "direction": "forward", "motion": "continuous", "ease": "expo.out",
  "easeCustom": [0.42,0,0.58,1], "cycles": 1, "duration": 16, "zoom": 87, "offsetX": 0, "offsetY": 387 }

// stream — dense stream of small cards
{ "cardRatio": "4:3", "cardSize": 256, "cornerRadius": 0, "lens": 0, "curve": 0, "heightGradient": 442,
  "axisRotation": 0, "cardTilt": 0, "gap": 8, "direction": "forward", "motion": "continuous", "ease": "expo.out",
  "easeCustom": [0.42,0,0.58,1], "cycles": 1, "duration": 28, "zoom": 116, "offsetX": 0, "offsetY": 0 }
```
<!-- SPEC:AUTO presets-band END -->
<!-- SPEC:AUTO presets-coverflow BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### coverflow (3)

```jsonc
// coverflow ⭐ — classic inward-tilting cover flow (sideRotation -49)
{ "count": 15, "cardRatio": "1:1", "cardSize": 150, "cornerRadius": 0, "gap": -56, "sideRotation": -49,
  "depthFalloff": 14, "focusEmphasis": 8, "direction": "forward", "motion": "step", "ease": "expo.out",
  "easeCustom": [0.42,0,0.58,1], "cycles": 1, "duration": 26, "zoom": 267, "offsetX": 0, "offsetY": 0,
  "perspective": 2000 }

// louver — outward-tilting louvered blinds (sideRotation +44, gap -80)
{ "count": 19, "cardRatio": "1:1", "cardSize": 164, "cornerRadius": 4, "gap": -80, "sideRotation": 44,
  "depthFalloff": 14, "focusEmphasis": 0, "direction": "forward", "motion": "step", "ease": "power4.inOut",
  "easeCustom": [0.42,0,0.58,1], "cycles": 1, "duration": 34, "zoom": 236, "offsetX": 0, "offsetY": 0,
  "perspective": 2000 }

// single — single focused card, steep side rotation (3:4)
{ "count": 15, "cardRatio": "3:4", "cardSize": 200, "cornerRadius": 6, "gap": -56, "sideRotation": -85,
  "depthFalloff": 62, "focusEmphasis": 8, "direction": "forward", "motion": "step", "ease": "power4.inOut",
  "easeCustom": [0.42,0,0.58,1], "cycles": 1, "duration": 19, "zoom": 300, "offsetX": 0, "offsetY": 0,
  "perspective": 2000 }
```
<!-- SPEC:AUTO presets-coverflow END -->
<!-- SPEC:AUTO presets-grid BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### grid (3)

```jsonc
// spotlight ⭐ — dense 12x12 read from a close shot, others dimmed hard.
// grid's opening look, and a route-mode LOOK (auto-cruises its waypoints).
// (Earlier `tour` and `drift` were retired: the Stage 2 pin-board editor
// hands route authorship back to the user, so pre-picked-route presets no
// longer earn their place — what survives is the one that's really a LOOK.)
// spotlight ships "drag": false in its interaction block; it is the only
// grid preset that does. (cameraMode is omitted → defaults to "route".)
// Both other grid presets ship "drag": true — freehand ("pan") and
// infinity ("parallax"), each drag-driven; see their own blocks below.
// 
// The two fields are an AND gate, and they answer different questions.
// "cameraMode" decides WHICH KIND of drag the preset has: "route" uses the
// same 1-D time drag every other template uses (scrubbing the waypoint
// cruise), while "pan" and "parallax" replace it with grid's own 2-D camera
// drag. "interaction.drag" decides WHETHER the viewer can drag at all.
// Dragging happens only when both agree — a "pan" scene with "drag": false
// is a camera nobody can move, and a "route" scene with "drag": true lets
// the viewer scrub the cruise by hand. spotlight sets it false on purpose:
// a 1-D time cursor has no coherent reading against a 2-D waypoint route
// (drag a card left and it moves right, and the mapping flips with each
// leg's direction), so this LOOK plays itself.
// 
// When you emit a grid scene, set "drag" to match the camera you want —
// false for a route-mode look that plays itself, true for a pan or parallax
// scene the viewer drives (keep the rest of the interaction block at its
// defaults).
// ⚠ interaction differs from the default: "drag": false
{ "cardRatio": "1:1", "columns": 12, "rows": 12, "cardSize": 120, "cornerRadius": 4, "gap": 16,
  "waypoints": [26,33,90,111,61], "travelDuration": 1.2, "dwell": 1.2, "ease": "power4.inOut",
  "easeCustom": [0.42,0,0.58,1], "highlightScale": 1.22, "dimOthers": 55, "cameraMode": "route", "depth": 60,
  "density": 50, "sway": 30, "scatterSeed": 1, "zoom": 480, "offsetX": 0, "offsetY": 0 }

// freehand — a draggable field. cameraMode "pan": no auto-cruise, the camera
// rests at the plane centre and the viewer drags it in 2-D (mouse+touch,
// with release inertia), in both the app and the exported embed. Odd 9x9
// so a single card sits dead-centre at rest; gentle emphasis. All auto-motion
// controls are HIDDEN (timeline, play, Route board, ease, Duration/Dwell) and
// mp4 export is disabled for this look (no loop length); json/embed export
// unaffected. waypoints/travel/dwell/ease are carried in the scene JSON but
// inert in freehand. Ships "drag": true. The 2-D pan itself is the
// "cameraMode":"pan" layer, and "interaction.drag" is the switch that arms
// it — both are needed, so setting "drag": false here would leave a camera
// the viewer cannot move (see spotlight's block for the AND gate). freehand's
// interaction block is identical to infinity's.
{ "cardRatio": "1:1", "columns": 9, "rows": 9, "cardSize": 140, "cornerRadius": 4, "gap": 20,
  "waypoints": [30,40,50], "travelDuration": 1.6, "dwell": 0.8, "ease": "power2.inOut",
  "easeCustom": [0.42,0,0.58,1], "highlightScale": 1.1, "dimOthers": 20, "cameraMode": "pan", "depth": 60,
  "density": 50, "sway": 30, "scatterSeed": 1, "zoom": 400, "offsetX": 0, "offsetY": 0 }

// infinity — cameraMode "parallax": cards scattered across 3 depth layers
// (near/mid/far) instead of a lattice, viewer drags through the field in
// 2-D (mouse+touch, release inertia — same drag driver as freehand). 10x10
// = 100 cards, dense enough to read as a scattered field without exceeding
// the perf note's ≤144-card guidance. depth:65/density:50/sway:30 are
// hand-tuned starting values; scatterSeed:7 is a fixed opening seed for the
// deterministic PRNG (same seed → same scatter, always). All auto-motion
// controls are HIDDEN (no loop/duration, same reason as freehand) AND
// arrival emphasis is hidden too (highlightScale/dimOthers inert — no
// "camera visits a card" concept here); mp4 export is disabled for this
// look (no fixed duration), same as freehand — json/embed export
// unaffected. waypoints/travelDuration/dwell/ease/highlightScale/dimOthers
// are carried in the scene JSON but inert in infinity (kept at neutral/
// freehand-like values so switching presets is lossless).
// Like freehand — and UNLIKE spotlight — infinity ships "drag": true: its
// whole interaction model IS the drag (scatter+depth has no coherent
// "route" to auto-cruise), so it takes the same "drag": true every non-grid
// template uses. Its interaction block is byte-identical to freehand's;
// "drag" is the single field spotlight's differs in (scroll false and the
// cursor sub-block all off in all three).
// `sway` (hover micro-follow) is reproduced in the exported embed too —
// app and embed match. `cardSize` (130) is IGNORED in parallax: infinity
// bakes a fixed card width (slider hidden), so the scatter period and the
// zoom floor stay constant no matter what cardSize a scene carries. Two
// cards of the SAME image are placed apart (best-effort, seeded) so photo
// duplicates don't land adjacent. On mobile the exported embed OWNS
// touch-drag inside the canvas (no page scroll / rubber-band / back-gesture);
// touches OUTSIDE the canvas scroll the host page normally.
{ "cardRatio": "1:1", "columns": 10, "rows": 10, "cardSize": 130, "cornerRadius": 8, "gap": 20,
  "waypoints": [11,44,88], "travelDuration": 1.6, "dwell": 0.8, "ease": "power2.inOut",
  "easeCustom": [0.42,0,0.58,1], "highlightScale": 1.08, "dimOthers": 0, "cameraMode": "parallax", "depth": 65,
  "density": 50, "sway": 30, "scatterSeed": 7, "zoom": 240, "offsetX": 0, "offsetY": 0 }
```
<!-- SPEC:AUTO presets-grid END -->
---

<!-- SPEC:AUTO presets-sphere BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
### sphere (1)

```jsonc
// globe ⭐ — the opening look.
{ "cardRatio": "3:4", "count": 120, "cardSize": 96, "cornerRadius": 4, "radius": 442, "tilt": 18, "duration": 32,
  "direction": "forward", "farScale": 55, "farOpacity": 35, "highlightScale": 1.6, "dimOthers": 40, "zoom": 100,
  "rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 1000, "offsetX": 0, "offsetY": 0 }
```
<!-- SPEC:AUTO presets-sphere END -->


## 6. Template parameter schemas

Every parameter, with type · range/options · default · effect.

**Emitting all of a template's fields in `config` (plus the `interaction` block) is recommended, not required.** The importer fills anything you omit from the schema defaults, and `scene-schema.json` will **not** reject a config for missing fields — it checks the values you do send (range, locked constants, fields that belong to a different template). Emit the full set anyway when you can: it makes the scene explicit and reproducible, and it does not silently change meaning if a default is retuned in a later release.

### 6.1 `orbit` — cards on a rotating 3D ring

Cards ride a circular orbit; the plane can tilt to read as a carousel, a ferris wheel, or a flat top-down ring.

<!-- SPEC:AUTO orbit BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `4:3` | card aspect |
| `count` | slider | 3–30 | `7` | cards on the orbit |
| `cardSize` | slider | 120–1200 | `230` | card width px |
| `cornerRadius` | slider | 0–50 | `0` | corner rounding px |
| `orbitRadius` | slider | 100–700 | `485` | radius of the ring |
| `planeX` | slider | -90–90 | `0` | tilt orbit plane about X (90 = flat/top-down ring) |
| `planeZ` | slider | -90–90 | `0` | tilt orbit plane about Z (90 = vertical ferris wheel) |
| `orient` | slider | 0–100 | `0` | blend cards from upright billboard (0) to facing orbit center (100) |
| `perspective` | slider | 400–1600 (step 10) | `1000` | camera distance px (lower = more dramatic) — panel shows this as a 0–100 strength; scene JSON always stores px |
| `direction` | toggle | `forward` `reverse` | `forward` | spin direction. `forward` = the cards on the **front** of the ring travel left→right |
| `motion` | toggle | `continuous` `step` | `continuous` | smooth vs stepped |
| `ease` | ease | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | `power2.inOut` | step easing (step only) |
| `easeCustom` | hidden |  | `[0.42,0,0.58,1]` | custom bezier (hidden) |
| `hold` | slider | 0–80 | `50` | inter-step pause (step only) |
| `cycles` | slider | 1–6 | `1` | loops per duration |
| `duration` | slider | 2–60 | `8` | seconds/loop |
| `jitterAmount` | slider | 0–50 | `0` | random positional wobble strength |
| `jitterSpeed` | slider | 0.5–8 (step 0.5) | `2` | wobble frequency |
| `rotationDrift` | slider | 0–15 | `0` | slow random rotation wobble (deg) |
| `tiltX` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `tiltY` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `tiltZ` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `fade` | slider | 0–100 | `35` | opacity falloff for back cards |
| `scale` | slider | 10–100 | `55` | back-card size as % of front |
| `rotationX` | slider | -45–45 | `0` | static group pose rotation |
| `rotationY` | slider | -45–45 | `0` | static group pose rotation |
| `rotationZ` | slider | -45–45 | `0` | static group pose rotation |
| `zoom` | slider | 50–300 | `100` | viewport zoom % |
| `offsetX` | slider | -500–500 | `0` | viewport shift px |
| `offsetY` | slider | -500–500 | `0` | viewport shift px |
<!-- SPEC:AUTO orbit END -->

<!-- SPEC:AUTO fieldlist-orbit BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `orbit`'s `config` has exactly these 30 keys, plus `interaction`.**

`cardRatio` `count` `cardSize` `cornerRadius` `orbitRadius` `planeX` `planeZ` `orient` `perspective` `direction` `motion` `ease` `easeCustom` `hold` `cycles` `duration` `jitterAmount` `jitterSpeed` `rotationDrift` `tiltX` `tiltY` `tiltZ` `fade` `scale` `rotationX` `rotationY` `rotationZ` `zoom` `offsetX` `offsetY`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 30 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-orbit END -->


### 6.2 `carousel` — straight-line infinite marquee

Cards travel along one axis and wrap seamlessly. `perspective` doubles as the strength knob for `depth`'s real z-recession.

<!-- SPEC:AUTO carousel BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `4:3` | card aspect |
| `count` | slider | 1–30 | `6` | cards in the marquee |
| `cardSize` | slider | 120–1200 | `230` | card width px |
| `cornerRadius` | slider | 0–50 | `0` | corner rounding px |
| `gap` | slider | -300–400 | `40` | spacing px along travel (negative = overlap/shingle) |
| `angle` | slider | 0–360 | `0` | travel direction (0 = rightward, clockwise from +x) |
| `depth` | slider | -100–100 | `0` | linear z-recession across the deck (0 = flat) |
| `stacking` | toggle | `forward` `backward` | `forward` | which card sits on top under overlap |
| `perspective` | slider | 1000–10000 (step 10) | `10000` | camera distance (10000 = flat, 1000 = strong vanishing) — panel shows this as a 0–100 strength; scene JSON always stores px |
| `rotationX` | slider | -180–180 | `0` | static per-card pose rotation |
| `rotationY` | slider | -180–180 | `0` | static per-card pose rotation |
| `rotationZ` | slider | -180–180 | `0` | static per-card pose rotation |
| `direction` | toggle | `forward` `reverse` | `forward` | travel direction along the `angle` axis. At `angle: 0` (horizontal) `forward` = left→right |
| `motion` | toggle | `continuous` `step` | `continuous` | smooth vs stepped |
| `ease` | ease | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | `power2.inOut` | step easing (step only) |
| `easeCustom` | hidden |  | `[0.42,0,0.58,1]` | custom bezier (hidden) |
| `hold` | slider | 0–80 | `50` | inter-step pause (step only) |
| `cycles` | slider | 1–6 | `1` | loops per duration |
| `duration` | slider | 2–60 | `8` | seconds/loop |
| `lift` | slider | 0–100 | `0` | center-most card rises screen-up (100 = one card-height) |
| `tiltX` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `tiltY` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `tiltZ` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `fade` | slider | 0–100 | `0` | center-anchored opacity gradient |
| `sizeGradient` | slider | -100–100 | `0` | size ramp across the track (smallest 0.3x) |
| `zoom` | slider | 50–300 | `100` | viewport zoom % |
| `offsetX` | slider | -500–500 | `0` | viewport shift px |
| `offsetY` | slider | -500–500 | `0` | viewport shift px |
<!-- SPEC:AUTO carousel END -->

<!-- SPEC:AUTO fieldlist-carousel BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `carousel`'s `config` has exactly these 28 keys, plus `interaction`.**

`cardRatio` `count` `cardSize` `cornerRadius` `gap` `angle` `depth` `stacking` `perspective` `rotationX` `rotationY` `rotationZ` `direction` `motion` `ease` `easeCustom` `hold` `cycles` `duration` `lift` `tiltX` `tiltY` `tiltZ` `fade` `sizeGradient` `zoom` `offsetX` `offsetY`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 28 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-carousel END -->


### 6.3 `stack` — a dealt/undealt deck

Cards stack with a per-slot offset; the top card exits in a chosen direction and the deck advances.

<!-- SPEC:AUTO stack BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `4:3` | card aspect |
| `count` | slider | 3–10 | `5` | cards in the deck |
| `cardSize` | slider | 120–1200 | `230` | card width px |
| `cornerRadius` | slider | 0–50 | `8` | corner rounding px |
| `stackOffsetX` | slider | -80–80 | `0` | per-card horizontal offset in the deck |
| `stackOffsetY` | slider | -80–80 | `-30` | per-card vertical offset in the deck |
| `stackDepth` | slider | 0–200 | `60` | z-spacing between stacked cards |
| `exitDirection` | select | `right` `left` `up` `down` | `down` | direction the top card exits |
| `exitDistance` | slider | 100–800 | `400` | how far the exiting card travels px |
| `direction` | toggle | `forward` `reverse` | `forward` | deal order — **not** a screen direction. Where a card leaves the frame is `exitDirection`, not this field |
| `motion` | toggle | `continuous` `step` | `step` | smooth vs stepped |
| `ease` | ease | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | `power2.inOut` | step easing (step only) |
| `easeCustom` | hidden |  | `[0.42,0,0.58,1]` | custom bezier (hidden) |
| `hold` | slider | 0–80 | `40` | inter-step pause (step only) |
| `cycles` | slider | 1–6 | `1` | loops per duration |
| `duration` | slider | 2–60 | `8` | seconds/loop |
| `jitterAmount` | hidden |  | `0` | must be present; hidden |
| `jitterSpeed` | hidden |  | `2` | must be present; hidden |
| `rotationDrift` | slider | 0–15 | `0` | slow random rotation wobble (deg) |
| `tiltX` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `tiltY` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `tiltZ` | slider | 0–100 | `0` | dynamic lean reacting to motion |
| `fade` | slider | 0–100 | `35` | opacity falloff down the stack |
| `scale` | slider | 10–100 | `70` | back-card size as % of front |
| `zoom` | slider | 50–300 | `100` | viewport zoom % |
| `offsetX` | slider | -500–500 | `0` | viewport shift px |
| `offsetY` | slider | -500–500 | `0` | viewport shift px |

**Locked** — these are not panel controls, but they **are** part of `stack`'s exported `config` and must be present at exactly these values:

| field | value | effect |
|---|---|---|
| `rotationX` | `0` | must be present; locked at 0 |
| `rotationY` | `0` | must be present; locked at 0 |
| `rotationZ` | `0` | must be present; locked at 0 |
| `perspective` | `1000` | must be present; locked at 1000 |
<!-- SPEC:AUTO stack END -->

<!-- SPEC:AUTO fieldlist-stack BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `stack`'s `config` has exactly these 31 keys, plus `interaction`.**

`cardRatio` `count` `cardSize` `cornerRadius` `stackOffsetX` `stackOffsetY` `stackDepth` `exitDirection` `exitDistance` `direction` `motion` `ease` `easeCustom` `hold` `cycles` `duration` `jitterAmount` `jitterSpeed` `rotationDrift` `tiltX` `tiltY` `tiltZ` `fade` `scale` `zoom` `offsetX` `offsetY` `rotationX` `rotationY` `rotationZ` `perspective`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 31 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-stack END -->


### 6.4 `band` — fisheye arc band (masked infinite strip)

An infinite repeating strip cropped to a curved arc silhouette. **No `count`, `perspective`, `jitter`, `fade`, `scale`, or `hold`** — the strip is infinite rather than a fixed N cards, and the arc read comes from the mask, not from any per-card 3D pose. There is also no `rotationX/Y/Z` and no `tiltX/Y/Z`: band's `axisRotation` rotates the whole band, and `cardTilt` leans the cards — neither is the other templates' field.


<!-- SPEC:AUTO band BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `3:4` | card aspect |
| `cardSize` | slider | 30–500 | `56` | strip card width px |
| `cornerRadius` | slider | 0–50 | `0` | corner rounding — percent of the shorter side of the VISIBLE outline (card box ∩ arc band), not of the card element |
| `lens` | slider | -100–100 | `0` | distortion of the card CONTENT (positive = fisheye, negative = inverse fisheye, `0` = off, no WebGL layer at all). Decoupled from `heightGradient` (Pinch), which shapes the FRAME; on browsers without WebGL2 it is treated as 0 |
| `curve` | slider | -100–100 | `55` | arc curvature of the band |
| `heightGradient` | slider | -650–650 | `60` | how much card height grows toward center |
| `axisRotation` | slider | -180–180 | `0` | rigid rotation of the whole strip’s axis in degrees — card positions and orientations follow the axis |
| `cardTilt` | slider | -30–30 | `0` | per-card tilt degrees — every card rotates the same angle in place, relative to the strip |
| `gap` | slider | -30–40 | `-10` | spacing px (negative = overlap) |
| `direction` | toggle | `forward` `reverse` | `forward` | scroll direction. `forward` = cards travel left→right |
| `motion` | toggle | `continuous` `step` | `continuous` | smooth vs stepped |
| `ease` | ease | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | `power2.inOut` | step easing (step only) |
| `easeCustom` | hidden |  | `[0.42,0,0.58,1]` | custom bezier (hidden) |
| `cycles` | slider | 1–6 | `1` | loops per duration |
| `duration` | slider | 2–60 | `8` | seconds/loop |
| `zoom` | slider | 50–300 | `100` | viewport zoom % |
| `offsetX` | slider | -500–500 | `0` | viewport shift px |
| `offsetY` | slider | -500–500 | `0` | viewport shift px |
<!-- SPEC:AUTO band END -->

<!-- SPEC:AUTO fieldlist-band BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `band`'s `config` has exactly these 18 keys, plus `interaction`.**

`cardRatio` `cardSize` `cornerRadius` `lens` `curve` `heightGradient` `axisRotation` `cardTilt` `gap` `direction` `motion` `ease` `easeCustom` `cycles` `duration` `zoom` `offsetX` `offsetY`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 18 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-band END -->


### 6.5 `coverflow` — Apple-Music-style focus carousel

A bounded cyclic carousel of `count` cards; the center card faces front, side cards rotate and recede. `perspective` has a floor of 2000.

<!-- SPEC:AUTO coverflow BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `count` | slider | 3–30 | `7` | cards in the cyclic carousel |
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `3:4` | card aspect |
| `cardSize` | slider | 60–200 | `140` | card width px |
| `cornerRadius` | slider | 0–50 | `0` | corner rounding px |
| `gap` | slider | -80–40 | `-30` | spacing px (negative = the classic overlap stack) |
| `sideRotation` | slider | -85–85 | `-58` | side-card rotation (negative = inward/classic, positive = outward) |
| `depthFalloff` | slider | 0–100 | `35` | how fast side cards recede and shrink |
| `focusEmphasis` | slider | 0–100 | `50` | center card pop-forward (translateZ) |
| `direction` | toggle | `forward` `reverse` | `forward` | travel direction (`forward` = cards travel right→left; `reverse` = left→right) |
| `motion` | toggle | `continuous` `step` | `step` | smooth vs stepped (cover-flow reads step-wise) |
| `ease` | ease | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | `power2.inOut` | step easing (step only) |
| `easeCustom` | hidden |  | `[0.42,0,0.58,1]` | custom bezier (hidden) |
| `cycles` | slider | 1–6 | `1` | loops per duration |
| `duration` | slider | 2–60 | `8` | seconds/loop |
| `zoom` | slider | 50–300 | `100` | viewport zoom % |
| `offsetX` | slider | -500–500 | `0` | viewport shift px |
| `offsetY` | slider | -500–500 | `0` | viewport shift px |
| `perspective` | slider | 2000–10000 (step 10) | `2000` | camera distance (floor 2000) — panel shows this as a 0–100 strength; scene JSON always stores px |
<!-- SPEC:AUTO coverflow END -->

<!-- SPEC:AUTO fieldlist-coverflow BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `coverflow`'s `config` has exactly these 18 keys, plus `interaction`.**

`count` `cardRatio` `cardSize` `cornerRadius` `gap` `sideRotation` `depthFalloff` `focusEmphasis` `direction` `motion` `ease` `easeCustom` `cycles` `duration` `zoom` `offsetX` `offsetY` `perspective`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 18 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-coverflow END -->


### 6.6 `grid` — card plane toured by an automatic camera

A card plane much larger than the canvas. It has **three camera modes**, selected by the `cameraMode` field:
- **`route`** (default) — an automatic camera visits a sequence of cards (`waypoints`) on a loop, dwelling at each. This is what the `spotlight` preset uses.
- **`pan`** — no auto-cruise; the camera sits still at the plane centre and the viewer **drags it around in 2-D** (mouse + touch), with release inertia. This is what the `freehand` preset uses. `waypoints` are ignored for camera motion in this mode (but preserved in the scene, so switching back to a `route` preset restores them). Draggable in the exported embed too.
- **`parallax`** — cards are scattered across three depth layers instead of a lattice, and the viewer **drags through the scattered field in 2-D** (mouse + touch, release inertia — reuses the same pan driver as `pan` mode); near layers move faster and are sharper, far layers move slower and blur. This is what the `infinity` preset uses. Card positions are a **deterministic seeded PRNG** keyed by `scatterSeed` (same seed → same scatter, every time, in both the app and the exported embed). `waypoints`/`highlightScale`/`dimOthers` are inert in this mode (no auto-cruise, no central emphasis — see the Depth fields below).

The three modes at a glance. `cameraMode` is a field **inside** `config`, not part of `templateId` — the id is always the string `grid`:

| `cameraMode` | auto-motion | `interaction.drag` | mp4 export | reach for it when |
|---|---|---|---|---|
| `route` (default) | yes — camera cruises `waypoints`, dwelling at each | `false` (the camera drives itself) | yes — the loop has a length | the user wants a guided tour of specific cards |
| `pan` | none | `true` (this is the 2-D drag) | **no** — no loop length | the user wants to explore a wall of images themselves |
| `parallax` | none | `true` (this is the 2-D drag) | **no** — no loop length | the user wants a scattered, depth-layered field rather than a lattice |

**Fields that go inert per mode.** They stay in the config and still validate — they simply stop doing anything, so setting them to satisfy a request is a silent no-op. Tune something that is live in the mode you chose, or change mode:

| field | `route` | `pan` | `parallax` |
|---|---|---|---|
| `waypoints` | **live** — the tour | inert (kept in the file; restored if you switch back) | inert |
| `travelDuration` / `dwell` | **live** — they set the loop length | inert (no loop) | inert (no loop) |
| `ease` / `easeCustom` | **live** — easing between waypoints | inert | inert |
| `highlightScale` / `dimOthers` | **live** — emphasis at the visited card | inert (no visited card) | inert (no central emphasis) |
| `depth` | inert | inert | **live** — layer separation |
| `density` / `sway` / `scatterSeed` | inert | inert | **live** — the scatter |
| `interaction.drag` | should be `false` | **live** — this *is* the 2-D pan | **live** — this *is* the 2-D pan |
| `interaction.scroll` | usable (wheel scrubs the tour) | **forced off** — the wheel belongs to the pan driver | **forced off** — same |

`drag` and `cameraMode` have to agree: a `pan`/`parallax` scene with `"drag": false` is a camera nobody can move, and a `route` scene with `"drag": true` lets the viewer fight the auto-cruise. The three presets in §5 ship the correct pairing — copy one rather than assembling this yourself. json and embed export work in all three modes; only mp4 is affected.

**The card count is derived — `columns × rows` — there is no `count` field.** Uploading images never changes the card count (images cycle `assets[i % assets.length]`, exactly like every other template).


<!-- SPEC:AUTO grid-floor BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
`grid` cannot be small. `columns` is 5–20 and `rows` is 5–20, so the smallest legal wall is 5 × 5 = **25 cards**. If the user has fewer than 25 images, do **not** pad the grid to reach the floor and do not emit a smaller one — it will not validate. Say that `grid` starts at 25 cards and offer a template that has a `count` field instead (`orbit`, `carousel`, `stack`, `coverflow`, `sphere`).
<!-- SPEC:AUTO grid-floor END -->

**There is also no `duration` field.** The loop length is derived: `waypoints.length × (travelDuration + dwell)`. `travelDuration` is the time for ONE leg between two waypoints.

<!-- SPEC:AUTO grid BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `1:1` | card aspect |
| `columns` | slider | 5–20 | `8` | cards across |
| `rows` | slider | 5–20 | `8` | cards down |
| `cardSize` | slider | 60–400 | `160` | card width px. **Ignored in `parallax`** — infinity bakes a fixed card width (slider hidden), so the scatter period and zoom floor stay constant regardless of this value |
| `cornerRadius` | slider | 0–50 | `0` | corner rounding px |
| `gap` | slider | -40–120 | `24` | edge-to-edge spacing px (negative overlaps) |
| `waypoints` | waypointBoard |  | `[9,21,42,54]` | camera tour order; index = `row × columns + column`. **One cell holds at most one waypoint** — a repeated index is dropped (first occurrence kept), and an index outside the current grid is dropped too |
| `travelDuration` | slider | 0.2–20 (step 0.1) | `1.6` | seconds per leg (NOT the loop length) |
| `dwell` | slider | 0–10 (step 0.1) | `0.8` | seconds paused at each waypoint |
| `ease` | ease | `none` `power2.inOut` `power4.inOut` `expo.out` `back.out` `elastic.out` `custom` | `power2.inOut` | easing of each leg |
| `easeCustom` | hidden |  | `[0.42,0,0.58,1]` | custom bezier (hidden) |
| `highlightScale` | slider | 1–1.3 (step 0.01) | `1.08` | scale of the card under the camera (`1` = off) |
| `dimOthers` | slider | 0–100 | `0` | how far non-central cards fade (`0` = off) |
| `cameraMode` | hidden |  | `route` | camera behaviour: `route` = auto-cruise the `waypoints`; `pan` = static camera the viewer drags in 2-D (no auto-cruise); `parallax` = cards scattered across 3 depth layers, viewer drags through the field (no auto-cruise, no central emphasis). Hidden field — set by the preset, not shown in the panel. Old scenes without it load as `route` |
| `depth` | slider | 0–100 | `60` | how strongly far layers slow down and blur relative to near layers, in `parallax` mode (`0` = layers move/render almost alike; `100` = strong separation). Panel-hidden outside `parallax` (`showIf cameraMode:"parallax"`), but still present in the config |
| `density` | slider | 0–100 | `50` | how MANY cards are on screen in `parallax` mode — density drives the card COUNT (`0` = few/sparse floating, `100` = many/dense wall), NOT plane scale. The scatter plane is a fixed size; the count is decoupled from `columns`/`rows` (which are inert + panel-hidden in `parallax`). Growing density adds tail cards deterministically (existing cards stay put — same `scatterSeed` sequence). Cards are placed to avoid >50% pairwise overlap AND to keep two cards of the SAME image from landing adjacent (both best-effort, seeded — deterministic in app and embed). Panel-hidden outside `parallax` |
| `sway` | slider | 0–100 | `30` | hover micro-follow strength in `parallax` mode. MOVEMENT-based: while the cursor MOVES over the canvas the field micro-shifts OPPOSITE the cursor ("peek out the window"); when the cursor stops or leaves, the offset eases back to 0. Reproduced in the exported embed too (`0` = off). Panel-hidden outside `parallax` |
| `scatterSeed` | hidden |  | `1` | seeds the deterministic scatter PRNG for `parallax` mode's card layout — same seed always produces the same scatter (app and embed both derive it from the same source, byte-identical). Locked field, not shown in the panel; set by the preset (or a future "reshuffle" action) |
| `zoom` | slider | 20–600 | `400` | camera height (viewport zoom %). Higher = closer. See the dynamic floor note below |
| `offsetX` | slider | -500–500 | `0` | viewport shift px (also dynamically clamped — see below) |
| `offsetY` | slider | -500–500 | `0` | viewport shift px (also dynamically clamped — see below) |
<!-- SPEC:AUTO grid END -->

<!-- SPEC:AUTO fieldlist-grid BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `grid`'s `config` has exactly these 21 keys, plus `interaction`.**

`cardRatio` `columns` `rows` `cardSize` `cornerRadius` `gap` `waypoints` `travelDuration` `dwell` `ease` `easeCustom` `highlightScale` `dimOthers` `cameraMode` `depth` `density` `sway` `scatterSeed` `zoom` `offsetX` `offsetY`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 21 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-grid END -->


> **`cameraMode: "pan"`** turns the whole template into a draggable field: no timeline/loop, so ALL auto-motion controls are **hidden** in the editor (timeline bar, play control, the Route waypoint board, the `ease` dropdown, and Duration/Dwell) and mp4 export is disabled — json/embed still work. The camera rests at the plane centre and dragging pans it with release inertia. Emphasis still applies — whichever card the drag brings to the centre lights up. `waypoints` (and travel/dwell/ease) are still carried in the scene JSON — hidden UI, not dropped data — so switching back to a `route` preset restores the route.

> **`cameraMode: "parallax"`** (the `infinity` preset) replaces the lattice with a **scattered 3-layer depth field**: each card is randomly assigned a layer (near/mid/far) and a plane position, both derived deterministically from `scatterSeed` — reshuffling means changing the seed, not re-rolling randomly on every load. Like `pan`, ALL auto-motion controls are hidden (no loop/duration — same reason as `pan`) and mp4 export is disabled; json/embed still work. Unlike `pan`, arrival emphasis is also off entirely — `highlightScale`/`dimOthers` are hidden and inert, since there's no "camera visiting a card" to emphasize. The camera is drag-driven exactly like `pan` (same 2-D drag + release-inertia layer, reused rather than duplicated), plus each layer's `speed` differs so near cards track the drag faster than far cards (the parallax feel) and far cards pick up blur controlled by `depth`. `sway` layers a small movement-based hover offset on top of the drag (the field micro-shifts OPPOSITE the cursor while it moves, easing back to 0 at rest) — reproduced in both the app AND the exported embed. The `density` field drives the card COUNT here (not `columns`×`rows`), on a fixed-size scatter plane. Recommended canvas: **16:9** at `zoom` ~240 (the `infinity` preset ships `zoom: 240`; canvas aspect is not set by presets — config-only — so switch the canvas to 16:9 yourself for the intended framing).

> Emphasis is **distance-driven, not waypoint-driven**: any card the camera passes directly over lights up in transit, not only the waypoints themselves. This is intended. (`route`/`pan` only — `parallax` has emphasis off entirely, see above.)

> **Dynamic zoom/offset floor.** The plane tiles infinitely to fill the canvas, but only down to the zoom where one plane period still covers the viewport; below that the backdrop would show. Motion Studio therefore computes a *dynamic* lower bound for `zoom` (and a shrinking range for `offsetX/offsetY`) from `columns`, `rows`, `cardSize`, `gap` and the canvas size, and clamps on load. **You do not need to compute this** — emit any `zoom` in 20–600 and any offset in ±500; a value too far out is silently raised/pulled to the nearest safe one. Staying at or above the `400` default keeps a comfortably close shot on the default 8×8 plane.

---

### 6.7 `sphere` — cards on a slowly turning globe (schema 7+)

Cards are spread evenly over a sphere (Fibonacci distribution) which turns about a tilted axis. Cards **always face the camera** — they are only translated, never rotated — so there is no back face and no mirroring, at any orientation.

<!-- SPEC:AUTO sphere BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
| field | type | range / options | default | effect |
|---|---|---|---|---|
| `cardRatio` | select | `1:1` `4:3` `3:4` `16:9` `9:16` | `3:4` | aspect ratio of each card |
| `count` | slider | 12–400 | `120` | cap is visual / low-end-machine headroom, not perf — 500 cards still hit ~59fps in testing |
| `cardSize` | slider | 24–400 | `96` | card width; height from `cardRatio` |
| `cornerRadius` | slider | 0–50 | `4` | percent of the shorter side |
| `radius` | slider | 150–900 | `442` | sphere radius. Default from a packing estimate; it is a plain slider, not derived |
| `tilt` | slider | -60–60 | `18` | axis tilt in degrees. Only readable in MOTION (a still frame can't show it) |
| `duration` | slider | 4–120 | `32` | seconds for **one full turn** |
| `direction` | toggle | `forward` `reverse` | `forward` | `forward` = front face travels right→left (opposite of `orbit`); `reverse` = left→right |
| `farScale` | slider | 10–100 | `55` | far-side card scale, percent |
| `farOpacity` | slider | 0–100 | `35` | far-side card opacity, percent |
| `highlightScale` | slider | 1–3 (step 0.01) | `1.6` | hovered card multiplier (same meaning as grid's) |
| `dimOthers` | slider | 0–100 | `40` | percent the non-hovered cards dim by |
| `zoom` | slider | 50–300 | `100` | viewport zoom % |

**Locked** — these are not panel controls, but they **are** part of `sphere`'s exported `config` and must be present at exactly these values:

| field | value | effect |
|---|---|---|
| `rotationX` | `0` | must be present; locked at 0 |
| `rotationY` | `0` | must be present; locked at 0 |
| `rotationZ` | `0` | must be present; locked at 0 |
| `perspective` | `1000` | must be present; locked at 1000 |
| `offsetX` | `0` | must be present; locked at 0 |
| `offsetY` | `0` | must be present; locked at 0 |
<!-- SPEC:AUTO sphere END -->

<!-- SPEC:AUTO fieldlist-sphere BEGIN — 由 scripts/render-spec-tables.mjs 從 agent-spec.json 生成,不要手改 -->
**Closed field list — `sphere`'s `config` has exactly these 19 keys, plus `interaction`.**

`cardRatio` `count` `cardSize` `cornerRadius` `radius` `tilt` `duration` `direction` `farScale` `farOpacity` `highlightScale` `dimOthers` `zoom` `rotationX` `rotationY` `rotationZ` `perspective` `offsetX` `offsetY`

No other key is legal here. The scene schema is `additionalProperties: false`, so **one extra key fails the whole import** — including a key that is perfectly valid on a different template. If the user asks for something none of these 19 keys expresses, say so instead of borrowing a field from elsewhere (see the response contract in Quick start).
<!-- SPEC:AUTO fieldlist-sphere END -->


`sphere` has no `offsetX` / `offsetY` — omit both fields.

`direction: forward` turns the globe so its **front face travels right→left** — the opposite of `orbit`'s ring. (Measured, not inferred: both were run from the shipping build's own math modules.)

Three things worth knowing when authoring a sphere scene:

- **The loop is seamless by construction.** One full turn *is* the period, so the last frame meets the first exactly — `duration` is the only thing that sets loop length, and mp4 export is available.
- **`interaction.drag` means "rotate the globe"** for this template, not the 1-D timeline drag every other template uses. Default `true`. Dragging is a trackball: it works identically at any orientation, including upside down.
- **`interaction.cursor.hover.enabled` is opt-in (default `false`).** When on, hovering a card eases the rotation to a stop and applies `highlightScale` / `dimOthers`. Exports (mp4 / embed) never have a cursor, so they never see it.


## 7. Canvas & background

`canvas` = `{ aspectRatio, fps, background }`.
- `aspectRatio`: `"1:1"` (1080×1080), `"9:16"` (1080×1920), `"16:9"` (1920×1080).
- `fps`: `15`, `25`, `30`, or `60` (visual/timing only).
- `background.type`: `"solid"`, `"gradient"`, `"image"`, or `"none"` (transparent). Always emit the full background object so switching type never loses the others' settings:

```jsonc
{
  "type": "solid",                       // solid | gradient | image | none
  "solid":  { "color": "#F7F0E8" },
  "gradient": {
    "mode": "linear",                    // linear | radial
    "stops": [{ "pos": 0, "color": "#000000" }, { "pos": 100, "color": "#FFFFFF" }],  // 2–5 stops
    "angle": 180,                        // linear angle deg
    "center": { "x": 50, "y": 50 },      // radial center %
    "radius": 20                         // radial radius % (0–150)
  },
  "image": { "src": null, "name": null } // src = a data URL or image URL
}
```


## 8. Assets

`assets` is an array of the images that cycle through the cards: `[{ "name": "photo.png", "dataURL": "data:image/png;base64,..." }]`. Card *i* shows `assets[i % assets.length]`.

- **Empty `assets: []`** is valid and common — Motion Studio falls back to a built-in placeholder color cycle, so the motion is fully visible without any images. When generating a scene from a text description alone (no user images), emit `assets: []`.
- To embed real images, base64-encode them as `dataURL`. Order matters — it's the cycle order across cards.

### 8.1 Asset type — images and video (schema 5+)

Each asset carries a `type`, `"image"` or `"video"`:

```json
"assets": [
  { "name": "photo.png", "dataURL": "data:image/png;base64,...", "type": "image" },
  { "name": "clip.mp4",  "dataURL": "data:video/mp4;base64,...", "type": "video" }
]
```

- **`type` is required in schema 5.** A pre-5 scene whose assets have no `type` is read as `"image"` (backward compatible), but new scenes should set it explicitly.
- **Video cards** play as a muted, looping, autoplaying `<video>`, cropped `object-fit: cover` to the card (same as images). They cycle `assets[i % assets.length]` and work in **every template** — mix image and video assets freely in one list.
- **Hard limits for a valid video scene** (the app enforces these at upload; a hand-authored scene must stay within them or it will be rejected/oversized): **≤ 4 unique video clips per scene · each ≤ 5 s · ≤ 720p · total inlined video base64 ≤ ~6 MB.** Video is far heavier than images (a compressed clip is ~10–50× an image), so inline only a few short, aggressively-compressed clips. Motion Studio re-encodes uploaded video to H.264 ≤720p; a hand-authored scene should inline clips already within these bounds.
- **Export:** both the mp4 and the embed export support video cards (video-carrying scenes render slower and the mp4 file is larger). `dataURL` is the sole source of pixels — no external URLs (self-contained, like images).

### 8.2 Factory demo photos — what the app shows before the user uploads anything

The app ships eight built-in **demo rolls** (sets of stock photos) and puts one on the canvas automatically, so a first-time visitor sees a finished-looking piece rather than placeholder colour blocks. Which roll appears is chosen per preset, with a per-template default as the fallback.

**This changes nothing about the scene JSON format.** A factory roll's photos are packaged into `assets` exactly like uploaded ones — same `{ name, dataURL, type }` shape, same base64 inlining, in every export (scene JSON / embed / mp4). There is no "demo photo" marker in the file and no field was added: `schemaVersion` was **7** when the sphere template landed (see §6.7) and is **10** today — the moves since then were band geometry, nothing to do with assets.

What this means when you author a scene by hand:

- **Still emit `assets: []` when the user gave you no images.** The built-in rolls are an *app-runtime* convenience, not something a scene file can request. A scene with `assets: []` renders as the placeholder colour cycle wherever it is played back (embed, mp4, `?render=1`) — the app never injects demo photos into a scene it is handed.
- **A scene that arrives with assets is always treated as the user's own.** Importing any scene with a non-empty `assets` array puts the app into its protected mode, where switching presets never replaces the images. So a scene you generate for a user is safe: their pictures will not be swapped out by a preset click.
- **The user can get the demo rolls back** via the assets panel's `clear all`, which empties the list and restores the built-in roll for the current preset. Uploading, deleting, or reordering any asset opts out of the demo rolls for the rest of that session.

Current roll assignment (informational — it affects only what the *app* shows on arrival, never a scene file you author):

| template | roll | per-preset override |
|---|---|---|
| orbit | `still-nature` | — (whole family) |
| carousel | `tokyo` | — |
| coverflow | `riso` | — |
| stack | `still-catalog` | — |
| band | `riso` | — |
| grid | `still-dark` | `grid-freehand` → `life-scatter`, `grid-infinity` → `life-scatter` |
| sphere | `sphere-universe` | — (a composite roll: two source sets deterministically interleaved) |

Two behaviours worth knowing because they shape how a grid or band scene reads:

- **Grid does not assign photos to cells by `assets[i % assets.length]`.** For its grid-plane presets (`spotlight`, `freehand`) the card→asset mapping is a seeded deterministic scatter that avoids putting the same photo in neighbouring cells — because plain modulo resonates with the column count and can make every column a single repeated photo. It is deterministic (same scene → same layout, app and embed identical). Grid's `infinity` preset keeps the plain modulo mapping. You don't control this from the scene file; it's derived from `scatterSeed`, `columns`, `rows` and the asset count.
- **Band crops hard horizontally.** Band's card box is much taller than it is wide (its `heightGradient` is added on top of the `cardRatio`-derived height), so a portrait image cover-fits to a narrow vertical strip. When picking images for a band scene, prefer full-bleed patterns/textures over centred single subjects — a centred portrait loses its subject to the crop.

