# Kinema project JSON — format v2

One page spec, extracted from the code (composer `normProj`/`blankProject`,
player `kinema-player.js`). Version stamp: `v: 2`.

## Where it lives

| Consumer | Storage |
|---|---|
| PopUP_CMS page | in-body attribute: `<div class="kinema-stage" data-kinema='{ …project… }' data-kinfmt="raw">` — stored RAW (the DOM escapes attributes when it serializes; nothing pre-escapes). Stages without the marker were written by the old escaping editor; `KinemaCore.readProject` unescapes them on read, and the next editor write migrates them to the marker |
| Composer scratch | localStorage `kinema.v2` (embedded mode: `kinema.v2.embed`) as `{v:2, p:project}` |
| Zinema veil | `content/.popup-zinema.json` (PopUP-side) as `out`/`in` projects |
| ScrollStory block | `block.kinema` (own normalized copy inside story.js) |

## Project

```json
{
  "v": 2,
  "name": "my stage",
  "stage": { "w": 960, "h": 540, "bg": "#0C0B0A", "bgImg": "" },
  "safe":  { "on": true, "margin": 5 },
  "scenes": [ { "id": "s1", "name": "S1", "dur": 3 } ],
  "objects": [ { … } ]
}
```

- `stage.w/h` — design-space size; only the **ratio** reaches the page
  (the wrapper gets `aspect-ratio: w / h`; poses are % so any width works).
  Zinema veils use cover-scaling of a 16:9 stage to the viewport instead.
- `stage.bg` — authored stage backdrop color (optional; `""` = no authored
  backdrop, hosts fall back to their own default, e.g. `data-kinbg`).
  `"transparent"` exports a see-through viewport. `stage.bgImg` — optional
  cover-fit image URL behind the objects (may carry `{{MEDIA_URL}}` tokens).
  Rides into the CSS/HTML/SVG/mo·js exports. The player paints both
  (phase-2 step 1, 2026-09-26) — including ScrollStory, whose vendored copy
  of this same player has rendered stages since 2026-09-29; precedence:
  the host-set `data-kinbg` attribute wins over `stage.bg`.
- `safe` — safe-layout guarantee: ON-pose ink must fit inside
  `margin`% of the stage; refit scales objects and bakes the factor into
  `o.fit` at author time. The player never measures (invariant 3).
- `scenes[]` — sequential chapters, each `dur` in **seconds**; the scene
  list is the timeline (loop = restart after the last).

## Object

```json
{
  "id": "o1", "sceneId": "s1", "type": "text", "name": "title",
  "w": 60, "h": 12,
  "kf": {
    "in":  { "t": 0,   "x": 50, "y": 64, "r": -4, "s": 0.8,  "o": 0 },
    "on":  { "t": 1,   "x": 50, "y": 50, "r": 0,  "s": 1,    "o": 1 },
    "out": { "t": 2.7, "x": 50, "y": 38, "r": 3,  "s": 1.05, "o": 0 }
  },
  "easeIn":  { "k": "bz", "p": [0.22, 1, 0.36, 1] },
  "easeOut": { "k": "bz", "p": [0.55, 0, 1, 0.45] },
  "fit": 1, "fitMode": "auto"
}
```

- **Geometry is % of the stage**: `w`/`h` in %, pose `x`/`y` = center in %,
  `r` degrees, `s` scale multiplier, `o` opacity 0–1, `t` seconds **within
  the owning scene** (`in.t < on.t < out.t`).
- Evaluation (pure function of one time scalar): before `in.t` → IN pose;
  IN→ON lerp with `easeIn`; ON→OUT lerp with `easeOut`; after `out.t` →
  OUT pose.
- `fit` is the baked safe-fit factor (1 = none needed); `fitMode:"off"`
  opts an object out. Semantics (evaluated in `kinema-core.js`): the factor
  scales the **ON pose only** — IN keeps its authored entrance; the OUT
  segment starts *from* the fitted ON scale (`on.s × fit`) and lands on the
  authored OUT pose, so the scale is continuous at the ON keyframe and
  `out.s` is honoured at the end. Internal `_ink`/`_fitBad` keys are
  scratch — never serialized by the bridge.
- One object belongs to ONE scene (`sceneId`); synthesized objects (Zinema
  veils) MUST carry `sceneId` or the paint gate hides them.

### Per-type extras

| type | extra fields |
|---|---|
| `text` | `text`, `font` (e.g. Syne / Space Grotesk / IBM Plex Mono), `fontSize` (% of stage height), `ls` letter-spacing, `color`; measured as ink for safe fit |
| `rect`, `circle` | `bg`, `stroke`, `strokeW` (% of stage width), `radius` (% of width → px); circle forces `border-radius:50%` |
| `star` | `bg`, `stroke`, `strokeW`; fixed 10-point unit star |
| `image` | `src` (may carry `{{MEDIA_URL}}` tokens), `radius`; drawn `object-fit:cover` |

### Easing values

- `{ "k": "bz", "p": [x1,y1,x2,y2] }` — cubic bezier, Newton–Raphson +
  bisection solve; y outside [0,1] gives anticipation / follow-through.
- `{ "k": "lin", "pts": [ …96 samples… ] }` — sampled `linear()` curve,
  emitted by the spring (damped cosine) / bounce generators.
- Legacy named strings are accepted for portability (ScrollStory-era
  configs): `"linear"` | `"ease-out"` | `"ease-in-out"` | `"back"` —
  unknown names fall back to `ease-out`.
- Absent easing = linear.

## Wrapper contract (public side)

```html
<div class="kinema-stage"
     data-kinema='{ …project v2… }'
     data-kinid="kin-abc12"
     data-kinemode="loop"
     data-kinbg="#0C0B0A"
     style="aspect-ratio:16/9;max-width:960px;margin-inline:auto;"></div>
```

- `data-kinemode` — `loop` (default) play+loop while visible · `once` play
  once on enter, final frame persists · `in-hold` entrances only
  (OUT pose := the fitted ON pose — the safe composition) and hold.
- `data-kingate="host"` — the stage never self-activates (no Intersection
  Observer); a host that owns gating itself (ScrollStory's card-active
  logic) drives `el.__kn.play()` / `pause()` instead.
- `data-kinid` — stable identity; the editor replaces in place on edit.
- `data-kinbg` — stage background (CSS default: `var(--kinema-stage-bg,
  #0C0B0A)`; the attribute and an authored `stage.bg` win over it).
- `prefers-reduced-motion` — static fitted ON composition, no clock.
- Removal backup: `<!--KINEMA:base64(of the stage's outerHTML)-->`;
  restore = decode + re-insert + `KinemaPlayer.scan()`.
- Stage API: the player exposes `el.__kn` — `play()`, `pause()`,
  `finish()`, `destroy()` (full teardown: clock, observers, DOM, hooks)
  and the `visible` flag. Zinema pauses swapped-out stages;
  `destroy()` + `KinemaPlayer.scan()` is the rebuild path.
- Hub events: with KinemaHub present a stage posts
  `kinema.stage.finished {kinid}` when its timeline completes (once /
  in-hold end, or `finish()`); loop stages never post. Hosts pace on it
  (ScrollStory hold 'auto' releases when every once-stage has posted).

## Audio (optional, kinema-audio.js)

A project may carry an `audio` key; stages without one are untouched.

```json
"audio": {
  "master": 0.8,
  "music": {
    "src": "{{MEDIA_URL}}/audio/bed.mp3", "volume": 0.5
    // or a zero-asset synth loop: "pattern": { "bpm": 84, "steps": [null, {"voice":"thud","note":"C2"}, …] }
  },
  "voices": { "pop": { "wave": "square", "freq": 880, "decay": 0.09 } },
  "automation": [ { "bus": "music", "param": "gain", "pts": [[0,0],[3,0.6],[36,0]] } ],
  "cues": [ { "t": 12.4, "voice": "pling", "note": "E5", "gain": 0.5 } ]
}
```

- Objects may carry `o.sfx: { "in": …, "on": …, "out": … }` — a voice name
  or `{voice, note, gain}` per keyframe. **Keyframe sfx travels with the
  keyframe when it is retimed** (absolute-time `cues` do not move).
- Voices are tiny synth patches (built-ins: pling · peep · tick · thud ·
  whoosh · chime · pad; `voices` extends/overrides them) — no audio files
  are required; files ride the `{{MEDIA_URL}}` token system.
- Playback is **silent until the page's first user gesture** (browsers
  refuse audio before one); reduced-motion stages never mount audio; the
  director re-syncs on every discontinuity (seek, loop wrap, destroy).
  The CSS/HTML/SVG/mo·js exports ignore audio — it plays through the
  player (and the composer, phase 3) only. See docs/PLAN-audio.md.

## Portability rules

- **One math per deploy**: projects are evaluated by `kinema-core.js`
  (easings, `evalState`, object DOM) — the player, Zinema and the composer
  all delegate to it, so a fix lands once. Anything that reads project JSON
  can reuse `window.KinemaCore`.
- Media URLs inside the JSON use `{{MEDIA_URL}}/media/…` tokens — the host
  substitutes at serve time; base-URL migration has nothing to miss.
- Projects are plain JSON: copy-out to a Zinema veil is a paste, no
  translation layer. ScrollStory consumes this SAME player as a committed
  vendored copy (`assets/kinema/`, phase-2 G4) — its `kinema` block type
  normalizes pasted projects and mounts them through `KinemaPlayer.scan`,
  so a composer-authored project plays identically there.
