# Atomic Events — Design Spec

Status: **Stage A + Stage B implemented (2026-07-27).** Motivated by a future **Glitch
Studio GUI** that edits each video occurrence as a self-contained object.

Implemented:
- Per-occurrence presentation resolves via `transformer.resolve_video_presentation`
  (precedence: inline/GUI override > shorthand prefix > videos.json > default; video
  `end_on` default = `next_video`, `[narration:]` runs to end).
- events.json is materialized/atomic: `derive_events` writes `handle/cutout/layer/end_on/
  take`; `events_to_marker_timings` round-trips them as overrides.
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
  alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on,
  take, volume** (numeric keys `take`/`volume` coerced to float). Unknown keys are ignored.
- The key-reuse collision validator hard-error was removed (reuse is legal now).

`volume` is **sparse/overridable**: the renderer reads `VideoEvent.volume` (line ~1571),
which is the events.json override if present else the videos.json default — so a videos.json
change keeps propagating, and events.json only stores `volume` when it's actually overridden
(inline/GUI/manual). `derive_events` materializes it only when overridden; `_EVENT_OVERRIDE_KEYS`
round-trips it. This is the template for the remaining globals.

Deferred (follow-ups): inline/per-event override of the *other* globals (skip/zoom/
use_audio_channels/pause_narration) — same renderer-plumbing pattern as volume, per site;
per-segment narration voiceover volume (render currently uses `narration_videos[0].volume`
only); stripping the moved fields from videos.json (kept as fallback defaults); a validator
warning for unknown inline keys.

## Problem

Presentation/timing properties (`cutout`, `layer`, `end_on`, `take`, `pause_narration`)
live on the **videos.json handle**, but they are really properties of *where a clip is
used*, not of the file. The shorthand prefix (`vst:` = square/above, `vsb:` =
square/below) is per-marker, but `_project_markers_to_videos` collapses it onto the
single handle record (last-wins). So one handle used two ways collides:

- `[vst:glitch_ccd_binning]` (above) and `[vsb:glitch_ccd_binning]` (below) → videos.json
  can only store `layer: below`, so the first occurrence renders under the slide (hidden).
- video5 has 5 such collisions today (glitch_ccd_binning, pexels/12471039…,
  mainvideopart1, shotnoiseacc, slide_periodogram).

A stopgap validator hard-error (`validate_project`, gnommo/validator.py) currently blocks
render on these. This spec removes the *cause* so that guard is no longer needed.

The naive fixes are both rejected: copying the file/handle (duplication on disk), and a
"hybrid override + materialize" layer (too much indirection). Instead: **the per-occurrence
properties move onto the event.**

## Field homes

**videos.json — asset + global defaults (one value per handle):**
`source_file`, `output_file`/`processed_file`, `filter`, `has_audio`, `is_shared`,
`src_mtime`, `duration` (probed; asset-only, never per-event), and the globals
`zoom`, `skip`, `volume`, `use_audio_channels`.

**events.json — per-occurrence (one value per event):**
`handle` (the video id, **prefix-free**), `cutout`, `layer`, `end_on`, `take`,
`pause_narration`.

**Resolution order for a rendered clip:** event field (if set) → videos.json value (for the
globals) → config default. The per-occurrence fields have no videos.json fallback — they
are always materialized onto the event at build time.

Notes:
- `end_on` **defaults to `next_video`** for videos when unspecified (was implicitly
  `next_slide`). Existing videos.json `end_on` values are migrated onto events explicitly,
  so current projects keep their behavior; only *new* unspecified markers get the new default.
- `take` is the event-level cut length, only meaningful when `end_on=take`; otherwise the
  end is implicit from `end_on` and `take` stays null.
- `skip` stays a global (asset trim-in) while `take` is per-event — a deliberate asymmetry:
  "where this asset generally starts" vs. "how long this occurrence plays."
- `zoom`/`volume`/`use_audio_channels` stay global but are inline-overridable per event
  (below), so they can diverge without a videos.json copy.

## Authoring: shorthand + inline overloads

The manuscript stays the compact authoring surface. The shorthand letters encode
`cutout`+`layer` (and `pause_narration` via the `…p:` variants). Anything the letters
don't encode — chiefly `end_on`, and any per-event override of a global — is given as
inline **`key=value`** pairs (simplified from the earlier `{"json":"form"}`):

```
[vsb:glitch_ccd_binning2]                          # square/below, end_on defaults to next_video
[vsb:glitch_ccd_binning2, end_on=next_video]       # + explicit end_on
[vsb:glitch_ccd_binning2, take=5, volume=0.5]      # + per-event overrides of globals
[video:glitch_ccd_binning2, cutout=square, layer=below]   # generic; equivalent to [vsb:…]
```

Rules:
- The first token inside `[]` is `prefix:handle` (handle may contain `/`, e.g. `pexels/123`).
- Remaining comma-separated tokens are `key=value`. Values are type-inferred: numeric →
  float, `true`/`false` → bool, else string. Allowed keys: `cutout`, `layer`, `end_on`,
  `take`, `skip`, `zoom`, `volume`, `use_audio_channels`, `pause_narration`,
  `always_visible`.
- An inline key overrides whatever the shorthand implied (e.g. `[vst:x, layer=below]` →
  above from the prefix, then below from the override). Last-writer-wins, prefix first.
- `[video:handle, …]` is the fully-explicit form the GUI round-trips: no prefix magic, every
  presentation field named.

Why `key=value` over JSON: no braces/quotes to escape inside `[]`, one obvious separator,
and it reads cleanly in a script. The GUI still stores the resolved values as real JSON
fields on the event — the manuscript form is just sugar that populates them.

## Build-time materialization

At build (`build_render_plan` / scaffold construction), each video marker resolves to an
atomic event dict:

```json
{
  "type": "video",
  "handle": "glitch_ccd_binning",
  "cutout": "square",
  "layer": "above",
  "end_on": "next_video",
  "take": null,
  "pause_narration": 0.0,
  "narration_time": 0.0, "adjustment": 0.0, "final_time": 0.0,
  "mapping": "exact", "confidence": 1.0, "context": "…"
}
```

`id` (currently `"vst:glitch_ccd_binning"`) is replaced by `handle` + explicit fields. The
render pass reads presentation straight off the event and no longer consults the prefix or
the videos.json presentation fields. `merge_events` must preserve manual event edits (the
GUI's writes) across rebuilds, the same way it preserves `adjustment` today.

## Code touchpoints

- **models.py** — `VideoSource` sheds `cutout`/`layer`/`end_on`/`take`/`pause_narration`
  (or they become defaults-only); `VideoEvent` already carries `cutout`/`layer`/`end_on` —
  extend to `take`/`pause_narration` sourced from the event, not the handle.
- **parser.py `parse_manuscript`** — extend the marker grammar to accept
  `prefix:handle, key=value, …`; update the malformed-marker detector (which today flags
  spaces/commas inside `[]`).
- **transformer.py `_extract_video_events`** — resolve `cutout/layer/end_on/take/
  pause_narration` from (prefix ∪ inline overrides), not from `video_source`.
- **scaffold.py** — event schema: `handle` + presentation fields; `merge_events` preserves
  GUI edits; migration for existing events.json.
- **cli.py** — retire `_project_markers_to_videos` and `_writeback_video_metadata` (they
  project/writeback per-handle presentation) in favor of seeding event fields.
- **validator.py** — **remove** the key-reuse collision hard-error (reuse is legal now).
- **renderer.py** — read presentation from the event (mostly already does via `VideoEvent`).

## Migration

Existing projects (video0–video6, …) have presentation on the handle and prefixed `id`s in
events.json. A one-shot migration, run on build:

1. For each video event, split the prefixed `id` into `handle` + implied `cutout`/`layer`.
2. Fill `end_on`/`take`/`pause_narration` from the handle's current videos.json values
   (preserving today's behavior — including handles that explicitly set `next_slide`).
3. Strip the moved fields from videos.json handles (leave the globals).
4. Idempotent: a second run is a no-op once events carry `handle`.

## Staging

- **Stage A** — schema split + per-event resolution from the shorthand prefix, migration,
  remove the collision guard. Shorthand-only authoring keeps working; the 5 video5
  collisions resolve. (This is the part that fixes the bug.)
- **Stage B** — the inline `key=value` overload grammar + malformed-marker updates.

Keep the validator collision hard-error in place **until Stage A lands** — removing it
earlier would let the hidden-overlay bug back in on video5.

## Open questions

- `always_visible`, `use_audio_channels`: confirmed as inline-overridable globals — do any
  need to become fully per-event?
- Does the GUI want events fully flattened (every field present) or sparse (only overrides,
  inherit the rest)? Affects whether the build writes defaults explicitly.
