# The Editor (8-Mode Studio)

One React app, eight creative modes — Video, Audio, Image, Brand, Slides, Diagram, Workflow, and Write — all mounted inside a single `ContextProvider` sharing one `UndoableState` and one asset store, so every file a user makes anywhere in the suite saves, undoes, and cross-references seamlessly.

**On this page**

- [Overview](#overview)
- [Stack & versions](#stack--versions)
- [The 8-mode architecture](#the-8-mode-architecture)
- [Mode: Video (the Remotion timeline)](#mode-video-the-remotion-timeline)
- [Mode: Audio Studio](#mode-audio-studio)
- [Mode: Image Studio](#mode-image-studio)
- [Mode: Slide Studio](#mode-slide-studio)
- [Mode: Brand Studio](#mode-brand-studio)
- [Mode: Diagram Studio](#mode-diagram-studio)
- [Mode: Workflow Studio & the CrFlow canvas](#mode-workflow-studio--the-crflow-canvas)
- [Mode: Write](#mode-write)
- [Cross-cutting: the animation engine](#cross-cutting-the-animation-engine)
- [Cross-cutting: grid, snapping & markers](#cross-cutting-grid-snapping--markers)
- [Cross-cutting: video AI tools](#cross-cutting-video-ai-tools)
- [Cross-cutting: transcript & comments](#cross-cutting-transcript--comments)
- [Cross-cutting: the export suite](#cross-cutting-the-export-suite)
- [Cross-cutting: activity bus](#cross-cutting-activity-bus)
- [Cross-cutting: JSON console & Agent panel](#cross-cutting-json-console--agent-panel)
- [Cross-cutting: theming bridge](#cross-cutting-theming-bridge)
- [Configuration](#configuration)
- [Build, dev & testing](#build-dev--testing)
- [Gotchas](#gotchas)

## Overview

The studio is a **separate** React Router single-page app in [`editor/`](../../editor), built on [Remotion](https://www.remotion.dev/). It deploys as its own Coolify app at `editor.corereflex.com` and talks to the CoreReflex API over credentialed CORS (`VITE_CORE_API_BASE_URL`). The same composition code under `editor/src/remotion` is bundled by the [Render Worker](render-worker.md) to produce final MP4s — which is why **preview == export** is a recurring invariant below, and why the worker must be redeployed after any change under `editor/src/remotion`.

The editor opens primed by URL params (`from=dashboard`, `compositionId`, `mode=<studio>`, `documentId=<write doc>`) so the dashboard and studio behave as one product. The `StudioBar` (`editor/src/editor/studio-bar/studio-bar.tsx`) mirrors the dashboard's top bar and reads auth cross-subdomain (`/api/auth/me`; the session cookie is scoped to `.corereflex.com`).

## Stack & versions

| Component | Version / rule |
|-----------|----------------|
| Remotion | **4.0.483**, pinned exact — the render worker's `@remotion/bundler`/`renderer` MUST match the editor's `remotion` version exactly (lockstep rule; a 4.0.463 ≠ 4.0.482 mismatch has broken video bundling before) |
| React / React DOM | 19.x |
| React Router | **v8** (`@react-router/dev` build; NOT Next.js — never introduce it) |
| Vite | 8.x · TypeScript 6 · Tailwind v4 (`@theme` tokens) |
| Node | **≥ 22** for the editor build and for the root test suite (several `test/*.test.js` files import editor `.ts` modules directly via native type stripping) |
| Dependencies | **Zero-new-deps rule**: hand-roll everything. `@excalidraw/excalidraw` (MIT) is the whitelisted exception. `@xyflow/react` was whitelisted in 2026-07, then **removed** — replaced by the in-house CrFlow engine. Any new library needs an explicit owner call naming it. |

Brand palette: lime `#c7ff3d` accent, cyan `#59d5e8` timeline, charcoal `#111412` — see [`DESIGN.md`](../../DESIGN.md). Reusable micro-interaction classes `.cr-press` / `.cr-cta` live in `editor-starter.css`, gated behind `prefers-reduced-motion`.

## The 8-mode architecture

`editor/src/editor/studio-mode.tsx` defines the workspace-level mode (distinct from `EditMode`, which is the canvas *tool* state):

```ts
export type StudioMode =
  | 'video' | 'audio' | 'image' | 'brand'
  | 'slides' | 'diagram' | 'workflow' | 'write';
```

Key rules, all enforced in code:

- **One provider, one state.** Every mode mounts inside the SAME `ContextProvider` (`editor/src/editor/editor.tsx`) and shares one `UndoableState`/asset store. That single fact is what makes saving/retrieving files seamless across modes: an Image Studio design, an audio session, a slide deck, a diagram, and the video timeline all persist in the same composition manifest and share one 50-entry undo history.
- **Transient selection lives in the mode context.** `activeDesignId`, `activeBrandKitId`, `activeDeckId`/`activeSlideId`, `activeAudioSessionId`, `activeDiagramId`, `activeRecipeId`/`activeRunId`, `activeContentDocId` are UI selection (like a tool state), so they live in `StudioModeContext`, not in undoable state. Convenience `openX(id)` helpers switch mode and select in one call — this is how e.g. the dashboard's "open document" deep link works.
- **Feature flags gate every mode.** `FEATURE_AUDIO_STUDIO`, `FEATURE_IMAGE_STUDIO`, `FEATURE_BRAND_STUDIO`, `FEATURE_SLIDE_STUDIO`, `FEATURE_DIAGRAM_STUDIO`, `FEATURE_WORKFLOW_STUDIO`, `FEATURE_WRITE_STUDIO` in `editor/src/editor/flags.ts` (all currently `true`; flip one `false` to ship dark). A `?mode=` deep link whose flag is off is ignored (stays on Video) rather than landing on a blank Inspector. `?documentId=` wins over `?mode=` when both are present.
- **Workflow and Write never mutate `UndoableState`.** They read the shared project and hand off *into* it (Workflow navigates to a produced composition; Write adds a deck via the Slide Studio's own reducer). Everything else dispatches through the shared `setState` so it undoes/redoes/persists with the manifest.
- **Lazy chunks; the player never unmounts.** `top-panel.tsx` code-splits every non-video mode with `React.lazy`, so video-only users never download them and the Remotion render bundle never imports them. The video canvas + Remotion `<Player>` stay mounted (hidden with `display:none`) in all other modes, so switching back to Video is instant and the playback controls never see a null player.
- **The timeline is a video-mode construct.** `video-timeline-dock.tsx` drops the playback bar + timeline entirely in the still-canvas/document modes (Image, Slides, Brand, Write, Workflow, Diagram). Audio Studio mounts its **own** dock over the same Timeline engine, scoped to one session.
- **Cross-surface undo is classified.** `audio-studio/undo-surface.ts` diff-classifies (by object identity) which surface an undo/redo actually touched, so an undo pressed in Video that reverts an Audio edit raises a toast instead of silently changing an invisible surface.

## Mode: Video (the Remotion timeline)

The default mode: a multi-track timeline over Remotion compositions.

- **Timeline** — branded playhead (`timeline/playhead.tsx`), razor/split at playhead, rolling edits, marquee selection, filmstrip/waveform previews, per-track hide/mute, fade handles, zoom slider. **Trim-to-playhead** (EDIT-029): bare `Q`/`W` set the selection's in/out point at the playhead through the full `extendLeft`/`extendRight` clamp logic (`keyboard-shortcuts/trim-to-playhead.tsx`).
- **Separate / re-attach audio** — right-click a clip to detach its audio to its own track (`separate-audio.ts`, `audioDetached` flags), edit independently, re-group. Mirrors pro NLEs.
- **Transitions** — dissolves, dips, wipes with `⟡` badges (`timeline-track-transitions.tsx`).
- **Project color grade** — one cinematic look project-wide (`FEATURE_PROJECT_COLOR_GRADE`).
- **Captions** — background transcription with progress (`captioning/caption-section.tsx`), styling controls behind `FEATURE_CAPTIONS_*` flags, SRT/VTT serializers (`captioning/caption-serializers.ts`).
- **Recorder** (`FEATURE_RECORDER`) — in-browser screen/webcam/mic capture that lands on the timeline. A companion MV3 Chrome extension exists under [`extension/`](../../extension) (unpacked dev build).
- **Per-clip + master audio FX** — the `audioFx` rack (`audio-fx/audio-fx.ts`: Compress, Limit, Clip, Maximize, De-esser, Denoiser, Reverb + presets incl. Podcast voice) lives on the item model, so voice, soundtrack, and detached audio each master independently. Real DSP runs only on the render worker: `render-worker/audio-fx-service.js` exposes `POST /audio-fx` (protected by `RENDER_SHARED_SECRET` via `x-render-token` — it fetches URLs server-side, so an open endpoint would be an SSRF risk), runs the same ffmpeg chain as the master export, and stores a content-addressed MinIO derivative whose URL rides in the manifest — so preview and export are byte-identical. The app-side seam `src/audio-fx-engine.js` is a no-op with a clear, actionable error until `AUDIO_FX_URL` is configured (the worker opts in via `AUDIO_FX_PORT`).
- **Persona Studio** (`FEATURE_PERSONA_STUDIO`, `editor/src/editor/persona/`) — **live**: build a consented likeness + voice clone and render it speaking a line straight onto the timeline, driving `/api/personas`, `/api/imagen`, `/api/import` from the composition inspector.
- **Script Studio** (`FEATURE_SCRIPT_STUDIO`, `editor/src/editor/script/`) — **live**: draft a spoken-voice script from a brief, edit lines, save; sits next to the Persona panel in the composition inspector.

Also in the video-mode toolbox but documented under cross-cutting sections below: keyframe animation, chroma key, speed ramps, extend-with-AI, markers, grid snapping, transcript panel, comments.

## Mode: Audio Studio

`editor/src/editor/audio-studio/` — a multitrack audio-session mode (Fairlight × Logic hybrid) built **on the shared track/item engine**, not a parallel one. See `planning/AUDIO_PILLAR.md` for the phased plan.

**Sessions on the shared engine.** At runtime, a session's tracks/items live in `UndoableState.tracks/items` tagged with `TrackType.sessionId`, so the whole existing timeline machinery (drag, marquee, split, fades) operates on them. `timeline-scope.tsx` tells a Timeline instance which surface it edits (default `'video'`, so every pre-existing mount keeps its behavior; the audio dock provides a session scope). Scope is for **geometry and reads only** — mutations must write back through `scoped-track-writes.ts` (`mergeScopedTracks`/`mergeScopedItems`), never by assigning a filtered tracks array, or one drag in the Audio section would delete the entire video timeline. New tracks created inside a session scope are stamped with the `sessionId` on merge.

**The at-rest manifest split — deploy-skew invariant.** `session-manifest-split.ts`:

- `splitSessionsForSave()` moves session tracks/items into `manifest.audioSessions[id].{tracks,items}` before persisting, so the saved `manifest.tracks` **never** contains a session track. Every downstream consumer (render-worker, manifest-builder, render-qa, json-engine, shorts, recipe-runner) — *including a stale-deployed render worker that has never heard of audio sessions* — is safe by construction.
- `inflateSessionsOnLoad()` reverses it. Both are idempotent; `split(inflate(m))` round-trips; pre-session manifests pass through byte-identically.
- **Server mirror:** [`src/audio-sessions.js`](../../src/audio-sessions.js) is the lockstep JS port (prod API runs plain Node, no TS). It provides the read-path lenses (`videoTracks`, `sessionItemIds` — the session predicate is always *track*-based, `track.sessionId`; items carry no marker) and `splitSessionsFromManifest()`, which the composition save API (`src/api.js`) applies as belt-and-suspenders normalization: even if a client saves the illegal tagged-root-tracks shape, the server splits it before persisting.

**Playback: the live session audio engine** (`engine/`, `FEATURE_SESSION_AUDIO_ENGINE`). Session playback runs through our own `AudioContext` graph — the same sample-interpolated curve math as the offline bounce (`setValueCurveAtTime` over decoded PCM in `clip-graph.ts`), synced to the session player's clock — while Remotion's media audio is force-muted *under the session player only*. This fixes the zipper-clicks of per-frame stepped gain and buffering skips, and makes pan/pan-automation audible in preview. Failure honesty: an undecodable clip is skipped with one `console.warn`; an engine-level failure returns `false` from `start()` so the hook falls back to Remotion audio — never silence. One shared `AudioContext` for the whole editor lifetime.

**Mixer** (`mixer/`) — one channel strip per session track + a master strip, with live level meters (`meter-store.ts`, peak tracking). The mixer panel sits outside the session dock's scoped contexts and reads via `getSessionTracks`.

**Automation** (`automation/`) — volume (0..2 multiplier, composes multiplicatively with clip volume) and pan (-1..1) lanes: keyframes sorted by frame, linearly interpolated, endpoint-clamped. `evaluate-automation.ts` is the ONE function used by both the live preview and the bounce, so what you hear while editing is exactly what lands in the WAV. Includes auto-duck (`auto-duck.ts`).

**Recording** (`recording/use-track-record.ts`) — `getUserMedia` → `MediaRecorder`, blob kept as-is (webm-opus, no transcode), real duration probed via mediabunny, asset cached locally then uploaded, `AudioItem` placed at the session playhead through `addItem({sessionId})` — a full-state write via the scoped merge.

**Bounce** (`bounce/`) — the only bridge from a session into the video timeline: offline mix (`mix-session.ts`) → LUFS normalization (`loudness.ts`) → WAV encode → `cacheAssetLocally` → **one atomic setState commit** (asset + `session.flattenedAssetId` + hot-swap of every video clip bound via `item.audioSessionId` = one undo entry) → background upload. `bounce-contract.ts` is the locked semantics. Re-bouncing kills stale derivatives; the session header shows bounce/consumer state.

**Clip editor** (`clip-editor/`) — per-clip waveform, fades, gain controls in a dockable panel. The shell uses the shared `DockLayout` (`workspace/dock-layout.tsx`), same as Slide Studio.

## Mode: Image Studio

`editor/src/editor/image-studio/` — Canva-style still-design editing on a **hand-rolled 2D canvas** (`design-canvas.tsx` + `draw-design.ts`; no Konva, no fabric). Designs are `ImageStudioDesign` documents in `UndoableState`; every mutation dispatches through the shared `setState` so it undoes/persists with the manifest.

- **Generate** (`generate-section.tsx`, `generate-image-client.ts`) — text-to-image into the design, via the server's image generation API.
- **AI Edit** (`ai-edit-section.tsx`) — mask-brush generative fill + object erase (`mask-canvas.ts`: strokes captured in the node's local unrotated space so the mask survives moves/rotations, rasterized white-on-black at working-image size), expand/outpaint to a target aspect (`expand-image.ts` — pure layout math, pads around the original pixels and lets the model fill the new region), and a conversational assist box.
- **Background removal** (`remove-bg-client.ts`), **still upscale** (`upscale-still.ts` — v1 fully client-side lanczos-style resample + hand-rolled unsharp mask; new asset, originals never replaced; reroutes server-side when the GPU SR service deploys), **filters/color grade** (`image-filter-presets.ts`, `compose-filters.ts`).
- **SVG import** (`rasterize-svg.ts`) — an SVG is rasterized to PNG once on import (export-clean, untainted canvas); downstream it's an ordinary image asset.
- **Layers panel** (`layers-panel.tsx`) — show/hide/lock/reorder/rename, shared with Slide Studio.
- **Flatten to asset** (`export-design-to-asset.ts`) — renders the design into the shared Media library so the video timeline can use it; the pattern the audio bounce mirrors.

## Mode: Slide Studio

`editor/src/editor/slide-studio/` — a deck is an ordered set of slides; **each slide is an `ImageStudioDesign`**, so the center reuses `<DesignCanvas/>` unchanged and every Image Studio tool works on slides. Reducers in `state/actions/slide-deck.ts` (add/duplicate/delete slide, notes, deck-authoritative sizing).

- **Presenter** (`slide-presenter.tsx`) — fullscreen stage portaled over `document.body`. Load-bearing details (do not simplify): exit is driven by the `fullscreenchange` event, not Esc keydown (browsers consume the first Esc without a key event; the Esc branch is only the non-fullscreen fallback); `requestFullscreen` failure degrades to a plain overlay; `data-kbd-island` + focus-lock keep global editor shortcuts out; the next slide's images prefetch so advancing is instant. Pure navigation logic in `presenter-navigation.ts` (node-tested).
- **PPTX export** (`export/pptx-export.ts`) — a dependency-free OPC writer: each slide is flattened by the same renderer the PDF export uses and embedded as one full-bleed JPEG, so the deck opens in PowerPoint/Keynote/Google Slides pixel-identical to the editor (v1 is "present anywhere", not round-trip editable). Office is strict: the slideMaster, slideLayout, theme (complete `a:fmtScheme`), and per-slide content-type Overrides are ALL required — omit any and you get a repair prompt.
- Multi-format export of decks (PDF/zip-of-images/SVG) via the shared [export suite](#cross-cutting-the-export-suite).

## Mode: Brand Studio

`editor/src/editor/brand-studio/` — brand kits (logo, palette, guide) on the same shared state. The centre is a pure-DOM brand-guide preview (code-split safe; no Remotion imports).

- **Logo batch studio** (`logo-batch.tsx`) — a batch is N distinct logo *concepts*, each rendered as a 4-up treatment viewpane (Colour · Grayscale · B&W stamp · Reverse) derived **client-side** from the colour master (`logo-treatments.ts`), so every treatment is the same mark. Per-concept reprompt/regenerate/discard/approve; approving registers the colour master as the kit logo and extracts the palette (`extract-palette.ts`).
- **Vectorization** (`logo-vectorize.ts`, `vectorize-client.ts`) — raster logo → SVG via the server seam.
- **Brand pack export** (`export-brand-pack.ts`) — ZIP of logo PNG, vectorized SVG, `palette.json`, mockups (`mockups.ts`), and a self-contained brand-guide HTML. The store-mode ZIP writer was extracted to `export/zip.ts` (shared with PPTX).
- Brand kits participate in the platform's Brand cascade (account → brand → project); the panel reads workspace brand context via `workspace-brand-client.ts` and admin flows via `brand-admin-client.ts`.

## Mode: Diagram Studio

`editor/src/editor/diagram-studio/` — the whiteboard/diagram room on **Excalidraw** (MIT, bundled with attribution), skinned with the editor's dark palette and brand accent. Lives entirely in its lazy chunk.

Persistence model — Excalidraw is **uncontrolled** (it owns the live scene and its own undo stack); we snapshot into `UndoableState.diagrams` on a ~900 ms idle debounce, keyed by a cheap element-version fingerprint so pointer-churn never spams the undo stack. Three load-bearing guards:

1. **The HYDRATION GATE in the save loop — do not remove.** Excalidraw fires `onChange` with an **empty scene while mounting**, before `initialData` hydrates. Committing that would wipe the document. `onSceneChange` ignores changes until the fingerprint matches the loaded doc (the "hydration echo") or a 1.5 s grace period passes (covers Excalidraw-internal normalization).
2. **`data-kbd-island`** on the canvas shell — the marker `utils/is-event-target-input-element.ts` checks so global editor shortcuts (undo, delete, space-to-play) stay out of embedded editors that own their keyboard. Also used by the slide presenter, comments composer, and the workflow graph.
3. **Self-hosted fonts** — Excalidraw lazy-loads fonts from `window.EXCALIDRAW_ASSET_PATH` (set to `/excalidraw/`); [`editor/scripts/copy-excalidraw-assets.mjs`](../../editor/scripts/copy-excalidraw-assets.mjs) (wired as `predev`/`prebuild`) copies the package's font dir into the gitignored `public/excalidraw/fonts`, so production never touches a CDN.

Saved scenes are sanitized (tombstoned elements stripped, orphaned embedded files pruned). PNG/SVG exports go through `importAssetToLibrary` → CoreReflex storage + the shared Media browser, recorded on the doc as `flattenedAssetId` — the same linked-file pattern as designs and audio sessions.

## Mode: Workflow Studio & the CrFlow canvas

`editor/src/editor/workflow-studio/` — the production cockpit: promotes Autopilot recipes into a first-class, inspectable staged-pipeline view. Pick an intent (starter), see its pipeline as a **live node graph**, fill the brief (pre-seeded from the project's active brand), run it, watch stage state paint onto nodes in real time (accent pulse = running, solid = done, red = failed, amber = paused for approval), then jump into the Video editor on the produced composition. Polling stops on terminal states or `awaiting_human` (approve / send-back via `recipes/recipe-client.ts`). **This mode only reads the shared project and navigates; it never mutates `UndoableState`.**

The `custom` intent opens the **pipeline builder** (`graph/pipeline-builder.tsx`): a ComfyUI-*pattern* canvas (clean-room — the pattern of node-wired generative pipelines only; ComfyUI is GPL, never bundle or copy its code) where stage nodes from a palette are wired by typed artifact ports into a staged recipe spec, validated again server-side and run by the same runner as every starter.

- `graph/graph-model.ts` — pure graph math: a staged recipe is already a dataflow graph (`requires` → `produces`), nodes are laid out dependency-layered left→right. No canvas imports.
- `graph/stage-templates.ts` — the node palette; each of the ten templates mirrors a proven server stage shape from `src/recipe-starters.js`.
- `graph/assemble-spec.ts` — wired graph → recipe spec.
- **Parity test** ([`test/workflow-builder-parity.test.js`](../../test/workflow-builder-parity.test.js)): the spec the editor assembles MUST pass the server's `validateSpec` with every step kind whitelisted in the runner's `STEP_KINDS` (`src/recipe-runner.js`) — one maximal graph exercises all ten templates, so client templates cannot drift from the server.

**CrFlow** (`graph/flow/`) is the in-house node-canvas engine that replaced `@xyflow/react` (removed 2026-07-06; owner call: fully white-label, no third-party canvas dep). It renders custom nodes inside a pan/zoom transform layer, routes bezier edges between measured handle centres, and supports node-drag, connect-drag, selection, and delete when the consumer wires `onNodesChange`/`onConnect` (read-only callers get a static pan/zoom graph). Structure:

- `geometry.ts` — pure canvas math (viewport transform, `bezierPath`, `screenToFlow`, `zoomAbout`, `fitViewport`), no React/DOM, node-tested (`geometry.test.ts`).
- `changes.ts` — pure reducers folding position/select/remove change records into controlled node/edge arrays (React Flow's controlled-state model, re-implemented).
- `cr-flow.tsx` — the engine. Handle graph positions are cached as node-local offsets (measured once per layout, zoom-invariant), so dragging re-routes edges with zero DOM reads.
- `chrome.tsx` — `Background`, `Controls`, `MiniMap`; `handle.tsx`, `context.ts`, `types.ts`, `state.ts` (incl. `useNodesState`/`useEdgesState`/`addEdge` conveniences), `index.ts` barrel.

## Mode: Write

`editor/src/editor/write/` — the templated copywriting pillar. Three columns: template library (left), the produced document (center, editable), variable-slot form + tone/length + Generate/Save (right). Templates use `{{slot}}` interpolation; generation/persistence go through `content-client.ts` (`/api/content/*`). **Never touches the Remotion editor state** — it produces structured content (`ContentBlock[]`) handed downstream:

- **Send to Script** (`sendToScript`) — feeds the video scripting stage.
- **Send to Slides** (`blocks-to-deck.ts`) — pure construction: each h1/h2 starts a new slide of `ImageStudioDesign` text nodes, added to `UndoableState.decks` through the same reducer Slide Studio uses (this is the one place Write writes shared state, and it does it via the Slides reducer, gated on `FEATURE_SLIDE_STUDIO`).
- **DOCX export** (`blocks-to-docx.ts`) — zero-dependency Word writer (OPC ZIP of three XML parts, hand-rolled CRC32/STORED entries) validated against `unzip -t` + CRC + XML well-formedness.
- **Print/PDF** (`print-document.ts`), markdown round-trip (`blocksToMarkdown`/`markdownToBlocks`).

## Cross-cutting: the animation engine

Per-item keyframe animation, hand-rolled and dependency-free. **One easing authority** drives everything.

**The core: `editor/src/editor/motion/keyframes.ts`.** Seconds-based `Motion`/`MotionTrack`/`valueAt` for design/slide motion, plus the single easing vocabulary the whole app shares:

- `Easing` = named presets (`linear`, `ease`, `ease-in`, `ease-out`, `ease-in-out`, stepped `hold`/`step-start`/`step-end`, `bounce`) or a custom `{bezier:[p1x,p1y,p2x,p2y]}`.
- `easingFn()` — the ONE resolver (WebKit-style Newton-Raphson cubic-bezier solver; Penner bounce).
- `NAMED_BEZIER` — the named cubic presets as raw control points, the single source of truth for both the runtime functions and the curve editor's draggable handles.
- `easingToBezier()` — easing → control points, or `null` for the non-bezier presets (bounce/hold/steps), which the curve editor then shows as a read-only sampled shape.
- `EASING_NAMES` — the closed vocabulary the JSON command layer validates against and `describeSchema()` advertises to the agent.

**Timeline items: `editor/src/editor/animation/`.** Frame-based counterpart, same easing authority, same source-leaving segment convention (a key's easing governs the segment LEAVING it):

- `interpolate-keyframes.ts` — deterministic read side. Item keyframes are **item-local integer frames** (0 = the item's first frame, because each layer renders inside `<Sequence from={item.from}>`). Exact-hit contract: a frame landing exactly on a key returns that key's value (explicit guard, so stepped easings can't leak the destination value).
- `keyframe-edit.ts` — the pure authoring ops behind the Inspector's **stopwatch** (animate on/off, baking values so nothing jumps) and **diamond** (add/remove key at the playhead): `enableKeyframes`/`disableKeyframes`/`toggleAnimation`, `setKeyframe`, `addKeyframeAtFrame`/`removeKeyframeAtFrame`, `retimeKeyframeFrame` (drag a summary-lane column), `setColumnEasing` (easing for every key on a frame), `setKeyEasing` (one property's key — what the curve editor writes), and compound-property helpers (`authorCompoundKeyframe` etc.) that keep e.g. Position=[left,top] key-sets symmetric, After Effects-style. Every transform is **identity-preserving** (same ref on no-op) so it flows through `changeItem` without polluting the undo stack. New keys default to `ease-in-out` (deliberate: pros opt *into* linear).
- `resolve-animated-item.ts` — the render/preview seam: overrides an item's keyframed properties at a frame, lazily copying only when a value actually differs (ironclad no-op for non-animated items). Exports **`ANIMATABLE_PROPS`** = `top, left, width, height, opacity, rotation, borderRadius` — the closed parameter space the JSON layer validates.
- `use-animated-item.ts` feeds it Remotion's `useCurrentFrame()`; `easing-presets.ts` is the one preset list shared by the keyframe lane's context menu and the curve editor's preset row.

**Authoring UI:**

- **Keyframe lane** (`timeline/timeline-item/keyframe-lane.tsx`, EDIT-004/006/008) — a collapsed lane along the bottom of a clip: one diamond per frame carrying a key on ANY property; diamonds are draggable (retimes the whole column, snaps to the playhead) with a right-click easing-preset menu.
- **Curve editor** (`inspector/curve-editor.tsx`, EDIT-007) — the Inspector's **Curve tab**: a normalized cubic-bezier graph for one segment's easing on the unit square (P0=(0,0), P3=(1,1), draggable P1/P2, vertical margin so overshoot/anticipate handles stay on-screen). Grabbing a handle on a named preset converts it to the visually-identical `{bezier}`; it writes exactly what the interpolator and render consume via `setKeyEasing`, so preview == export with zero engine changes. Per-key *value* tangents would need a data-model change — tracked separately, not built.

## Cross-cutting: grid, snapping & markers

**Source-aware grid** (`timeline/grid/`): the timeline measures in frames at composition fps; the grid lets the editor read and snap that axis two ways — SMPTE `HH:MM:SS:FF` timecode with drop-frame for 29.97/59.94 (`timecode.ts`) or musical `bars:beats:ticks` (`musical.ts`). `grid-config.ts` is pure config with divide-by-zero clamps; `grid.ts` is the facade (`gridStepFrames`, `formatGridTime`, `snapFrameToGrid`, zoom-aware `gridLines`, higher-cap `gridSnapFrames` for snapping). Project fps is render-switchable. Audio sessions carry their own `gridMode` (BPM sessions get the musical grid).

**Drag snapping — shipped.** `timeline/utils/snap-points.ts` merges grid-line frames (`withGridSnapPoints`) and marker frames (`withMarkerSnapPoints`) into the item-edge snap resolver used by both clip drag (`utils/drag/use-timeline-item-drag.ts`) and trim handles (`timeline-item-extend-handles/on-extend-handler.ts`). Item edges win ties over grid lines; markers only add new targets. Keyboard: `[`/`]` nudge the selection to the previous/next gridline (`nudge-to-gridline.tsx`, EDIT-019).

**Markers** (EDIT-032/033/034): `Marker[]` on `UndoableState.markers` (additive manifest field, ascending by frame — no migration needed). Pure list ops in `state/actions/markers.ts`; ruler flags in `timeline/ticks/markers-overlay.tsx` (click seeks; chapters wear their own color); shortcuts in `keyboard-shortcuts/marker-shortcuts.tsx` — `M` drops a marker at the playhead, `⌥←`/`⌥→` jump between markers. Not yet built: a marker list panel, WebVTT chapter export, range markers, import (EDIT-035–038).

**The `TIMELINE_HORIZONTAL_PADDING` gotcha.** The timeline body is inset by `TIMELINE_HORIZONTAL_PADDING` (= ⌈playhead width / 2⌉ + 5 = **15 px**, `editor/src/editor/constants.ts`). Any element positioned from a frame→pixel calculation against the scrollable container (marker flags, drag overlays, marquee seek, playhead-follow) must add it, or it lands 15 px left of the tick it belongs to. Every consumer imports the constant — do the same.

## Cross-cutting: video AI tools

- **Chroma key** (`items/video/chroma-key.ts` + `chroma-keyed-canvas.tsx`, `inspector/controls/chroma-key-controls.tsx`) — a per-pixel keyer measuring distance in the **CbCr chroma plane** (not RGB), so the key survives uneven lighting the way OBS/ffmpeg keyers do; similarity/smoothness/spill controls. The `<Video>` element is made invisible (still drives playback/audio/frames) and every decoded frame is drawn through the keyer onto a canvas carrying the same `innerStyle`, via Remotion's `onVideoFrame` — runs in the preview AND the render worker, so preview == export.
- **Speed ramps** (`items/video/speed-ramp.ts`, `inspector/controls/speed-ramp-controls.tsx`) — a ramp is approximated as contiguous pieces with different flat `playbackRate`s (exact source-time partitions placed back-to-back; later clips ripple to absorb the length change). Full keyframed time-remapping is deliberately out of scope — a per-frame rate function breaks trim/split source-window math, caption sync, and A/V parity.
- **Extend with AI** (`items/video/extend-clip.ts` + `generation/use-extend-clip.ts`, `inspector/controls/extend-clip-controls.tsx`) — extract the clip's own last frame, upload it, generate a continuation with it as the start image (the same image-to-video chaining mechanism the Director uses), probe the MP4, and insert the new clip right after the source with the source's geometry, pushing later clips right.

## Cross-cutting: transcript & comments

- **Transcript panel** (`inspector/controls/caption-controls/transcript-panel.tsx`) — the Descript workflow: caption words as an editable transcript. Click a word to seek; select a run (click + shift-click) and delete — the source clip ripple-closes and captions compress in the same commit (`state/actions/remove-ranges-with-captions.ts`). Filler words/long pauses are tinted via `cleanup/detect-fillers.ts` with one-click remove-all chips; SRT/VTT export.
- **Comments panel** (`comments/`) — timecode/element-anchored threads on the saved composition with @mentions and resolve, against the server comments API. Anchors store **milliseconds, never frames** (fps is render-switchable); an element anchor that no longer resolves renders a detached chip instead of throwing. SSE is an enhancement — the list is fetch-correct without it. Pure thread grouping in `comment-threads.ts` (orphaned replies promote to roots so nothing disappears).

## Cross-cutting: the export suite

`editor/src/editor/export/` — studio-wide multi-format export, all zero-dep:

| Module | What it does |
|--------|--------------|
| `design-export.ts` | Entry points: `exportDesignAs` (Image Studio) / `exportDeckAs` (Slide Studio). Formats: `png · jpg · webp · svg · pdf · pptx`; decks → multi-page PDF or a zip of per-slide files. |
| `raster.ts` | One renderer (`drawDesign` — the same path the live canvas uses) feeds every raster format, so PNG/JPG/WebP are pixel-identical to the screen. |
| `svg-export.ts` | Real vector re-emission (`<rect>` crisp, `<text>` selectable), images embedded as data URLs, ColorGrade as a CSS filter on the root group. |
| `pdf-export.ts` | Hand-rolled PDF: each page embeds one JPEG via `DCTDecode` (raw JPEG bytes straight into the stream — no re-compression, no zlib), MediaBox = pixel size. |
| `pptx-export.ts` | The OPC deck writer (see [Slide Studio](#mode-slide-studio)). |
| `zip.ts` | Store-mode ZIP writer (correct CRC32 + central directory), shared by PPTX, brand pack, and DOCX. |

SVG **import** lives in `image-studio/rasterize-svg.ts`; DOCX export in `write/blocks-to-docx.ts`; the brand pack in `brand-studio/export-brand-pack.ts`. Rule: every export also lands in CoreReflex storage + the Media browser where a flatten/`importAssetToLibrary` path exists.

## Cross-cutting: activity bus

`editor/src/editor/activity/` — the global feedback layer, driven by a module-level bus (additive and load-path-safe; nothing in cold load depends on it):

- `activity-store.ts` — pure, React-free store (node-tested): `begin`/`update`/`end` track in-flight work, `track(label, work)` wraps an async unit, `log` records events; progress stays indeterminate unless every active unit is determinate.
- `activity-bus.ts` — app-wide singleton + React bindings; `activity-bar.tsx` — the thin accent top progress bar; `event-log.tsx` — a browsable rolling log; `activity-tasks-bridge.tsx` mirrors render/caption/upload tasks in.
- `notify.ts` — **the** way to surface a message: wraps the sonner toaster AND records to the event log. Prefer `notify.success/error/info` over importing `toast` directly (older modules still import `toast`; new code shouldn't).

New flows hook in via `activityBus.begin/end` + `notify` — see the bounce and recording flows for the pattern.

## Cross-cutting: JSON console & Agent panel

**The JSON engine is wired, in three places.** The headless engine (`editor/src/editor/json-api/` — `evaluate(state, request) → {state, response}`; queries/`describe` read-only, commands/batch atomic) now runs:

1. **In-editor**: the JSON console (`console/json-console.tsx`, `FEATURE_JSON_CONSOLE`) — type a JSON request, get a structured result; commands apply through the editor's real `setState`, so each is exactly ONE undo step and preview == the live document. Toggled from the StudioBar via a module-level ref (no prop drilling).
2. **Server-side**: [`src/json-engine.js`](../../src/json-engine.js) is a verbatim framework-free port kept in lockstep (prod API runs plain Node; the editor TS files are the canonical authoring copy — change a command there, mirror it, and `test/json-engine.test.js` + the reducer-parity suite guard it). [`src/json-api.js`](../../src/json-api.js) exposes it at `POST /api/json` over stored compositions (row-locked persist) or an inline manifest.
3. **MCP**: the zero-dependency `@corereflex/mcp` server ([`mcp/`](../../mcp)) exposes the API — including the JSON engine — as agent tools. See [Platform API](platform-api.md) and [JSON Engine](json-engine.md).

**Agent panel** (`agent/agent-panel.tsx`, `FEATURE_AGENT_PANEL`, AGENT-094) — the suite-wide AI agent's face: chat in → plan card out (summary, steps, cost range, dry-run change list, confirmations, safety flags), clarifying questions with typed choices, approve-and-run with the returned state applied as ONE undoable commit (⌘Z undoes the whole run). Client-driven by design — the director-overlay lesson: **no browser surface may call a blocking server produce endpoint** (`/api/director/produce` 502s in prod); the panel plans and executes against the live inline state. Deferred verbs (shorts, publish, upscale, film hand-offs) come back annotated and run via their durable paths; live progress rides `agent/agent-activity-bridge.tsx` onto the activity bar.

## Cross-cutting: theming bridge

The editor is a separate origin from `corereflex.com`, so the white-label theme is bridged via a **`cr-theme` cookie scoped to `.corereflex.com`** (localStorage fallback for same-origin dev). In [`editor/src/root.tsx`](../../editor/src/root.tsx):

- An inline `<style>` paints `html{background:var(--color-editor-starter-bg,#0a0e16)}` on the first frame — never a white flash.
- The inline `THEME_BOOT` script runs in `<head>` before the bundle: it parses the cookie, sets `data-theme` on `<html>`, and maps site token names (`--accent`, `--bg`, `--secondary`, `--ink`, `--danger`) onto the editor's `--color-editor-starter-*` tokens — so the editor matches whatever preset/custom brand the user picked on the site before hydration.
- `<html suppressHydrationWarning>` because THEME_BOOT intentionally makes the attributes differ from the static render.

The server-side half of the early-paint pattern (for dashboard pages) lives in `src/server.js`'s HTML handler.

## Configuration

| Variable | Where | Effect |
|----------|-------|--------|
| `VITE_CORE_API_BASE_URL` | editor build env | Base URL for every API call (`''` = same origin in dev). |
| `AUDIO_FX_URL` | API app env | Points the app's `src/audio-fx-engine.js` seam at a deployed worker `/audio-fx`; unset → clear actionable error, never silent failure. |
| `AUDIO_FX_PORT` | render worker env | Worker opts in to serving `/audio-fx`. |
| `RENDER_SHARED_SECRET` | both | Protects `/audio-fx` (via `x-render-token`) and the render seam. |

Feature flags are compile-time constants in `editor/src/editor/flags.ts` — the full studio-mode set plus per-control flags (captions styling, inspector controls, `FEATURE_JSON_CONSOLE`, `FEATURE_AGENT_PANEL`, `FEATURE_AUTOPILOT`, `FEATURE_SESSION_AUDIO_ENGINE`, …). Notable flags currently **off**: `FEATURE_NEW_MEDIA_TAGS`, `FEATURE_SHIFT_KEY_TO_OVERRIDE_ASPECT_RATIO_LOCK`.

## Build, dev & testing

From `editor/`:

```
npm install
npm run dev          # react-router dev (predev copies Excalidraw fonts)
npm run build        # react-router build (prebuild copies Excalidraw fonts)
npm run typecheck    # react-router typegen && tsc --noEmit
```

Node **≥ 22** required. After any change under `editor/src/remotion`, **redeploy the render worker** (version lockstep — see [Render Worker](render-worker.md)).

Testing — pure editor modules are covered from the **root** suite:

```
npm test             # node --test over test/*.test.js (repo root)
```

Root tests import editor `.ts` modules directly (native type stripping): `test/workflow-builder-parity.test.js` (templates ↔ `STEP_KINDS`), `test/json-engine.test.js` + the reducer-parity runner (`editor/src/editor/json-api/reducer-parity.*`), and others. Co-located `*.test.ts` files inside `editor/src` (keyframes, grid, geometry/changes, snap-points, loudness, pptx, activity-store, …) follow the same pure-module, `node --test` discipline. UI verification is manual/browser-driven — there is no editor component-test harness.

## Gotchas

- **Remotion lockstep**: editor `remotion` and worker `@remotion/bundler`/`renderer` versions must match exactly.
- **Never remove the Diagram Studio hydration gate** (`diagram-studio.tsx`) — Excalidraw mounts with an empty scene; committing it wipes the doc.
- **Never write a filtered tracks array back into state** — session-scoped mutations must go through `mergeScopedTracks`/`mergeScopedItems`, and read-path lenses (`videoTracks` etc.) are for reads only.
- **`manifest.tracks` must never contain a `sessionId`-tagged track at rest** — the editor split + the server's save normalization both enforce it; keep them in lockstep (`session-manifest-split.ts` ⇄ `src/audio-sessions.js`).
- **+15 px `TIMELINE_HORIZONTAL_PADDING`** on anything frame-positioned against the timeline container.
- **Bare-letter shortcuts must exclude Shift** (`M` vs `Shift+M` — a shifted letter still reports the same `e.key` letter) and check `isEventTargetInputElement` (which also honors `data-kbd-island`).
- **`⌥+Arrow` is reserved** for marker navigation — don't bind it to scrubbing.
- **Keyframe frames are item-local integers**; convert the global playhead with `toItemLocalFrame` before authoring.
- **Comment anchors are milliseconds, never frames** — fps can change per render.
- **JSON engine changes must be mirrored** in `src/json-engine.js` and covered by the parity tests.
- **No browser surface calls blocking server produce endpoints** — plan/generate/poll/assemble client-side (Director/Agent pattern).
- **The stale `React Flow` mention** in `workflow-studio.tsx`'s header comment is historical — the canvas is CrFlow (`graph/flow/`); `@xyflow/react` is gone from `package.json`.

Related: [JSON Engine](json-engine.md) · [Platform API, SDK & MCP](platform-api.md) · [Render Worker](render-worker.md) · [Architecture](architecture.md) · [Data Model](data-model.md) · [`../REMOTION-LICENSE.md`](../REMOTION-LICENSE.md)
