Authoring docs/preset-format.md
GFX preset format (gfx@1) #
A preset is a single JSON document validated by PresetSchema (src/lib/platform/engine-schema.ts). The machine-readable schema lives at docs/preset-format.schema.json and is regenerated by npm run gen:schema. The catalog of built-in presets lives at src/lib/presets/*.json and is loaded eagerly by getPresetBySlug.
{
"schema": "gfx@1",
"name": "Human-readable preset name",
"description": "Optional one-line summary.",
"pack": "syntax",
"kind": "deliverable" | "fixture", // optional; defaults to "deliverable"
"state": {/* EngineState — see below */},
"transition": { "from": "preset-slug", "to": "preset-slug", "effect": "mask-wipe", "durationMs": 600, "params": {} } // optional
}
schema is written as gfx@1. A document declaring the Legacy Supers id supers@1 is accepted permanently and folded onto gfx@1 at ingress — the two ids name one identical document shape, so no corpus Preset or saved composition needs migrating (ADR-0053).
pack is required and must name an entry in PACK_REGISTRY; there is no implicit default Pack. kind: "deliverable" is listed in the app catalog and receives the static safety/readability lint, while kind: "fixture" remains loadable for development but is excluded from the listing. Either kind still receives structural, semantic, Pack, and Identity validation.
transition selects the shipped two-snapshot multi-state path (ADR-0026, ADR-0046). from and to resolve to ordinary Presets; effect resolves through the distinct typed transition-Effect registry and is not an effects[] entry. params is validated by the selected renderer and defaults to {}. durationMs drives transition-local progress; a longer composition holds the settled to endpoint after the transition completes.
Shared blocks #
transport #
"transport": {
"orientation": "horizontal" | "vertical",
"durationSeconds": number, // 0.1 – 600
"fps": number, // integer 1 – 120, or an NTSC fractional literal:
// 23.976 | 29.97 | 59.94 (ADR-0042). The literal is
// display only — frame math resolves it to the exact
// rational (30000/1001) via resolveFrameRate.
"format": "webm" | "prores"
}
Orientation is a transport target, not grounds for a sibling deliverable. Author one Pack-neutral Preset and validate it at both native targets; renderer layout and safe-area inputs reflow it, while complete Overlay placement or Diagram geometry snapshots express authored re-staging inside that same Preset.
Transparency and output classification #
The render target clears to transparent premultiplied alpha by default. state.backgroundFill is an optional full-frame fill: a #rrggbb hex (an intentional authored departure), or the sentinel "pack" (ADR-0039 §3), which resolves to the active Pack's mandatory field-treatment core at render time — the pack-neutral spelling every full-frame piece should prefer, so a pack flip re-fields the piece instead of keeping one brand's hex. state.stage also makes the output full-frame because the dimensional stage paints to the frame edges. Ordered state.media.videoTrack.clips[] may supply creator footage beneath all five Layers. Output classification is centralized in src/lib/utils/output-classification.ts:
- no
backgroundFill, depthstage, or complete Video-track coverage → transparent overlay output (gfx-overlay); backgroundFill, a depthstage, or Video clips covering every output frame → opaque full-frame output (gfx-bumper);- a transition is opaque only when both resolved endpoint Presets are opaque.
Unused Media library entries do not affect classification. A Video-track gap stays transparent rather than painting black or holding the preceding frame, so a gapped composition retains alpha. There is no schema enum for overlay/segment/bumper; those are descriptive delivery terms derived from rendered coverage.
media (ADR-0045) #
"media": {
"assets": [
{
"id": "interview-a", // stable, non-empty, unique
"kind": "video", // v1 is video-only
"name": "Interview A", // composition-owned display name
"assetUrl": "/api/user-assets/<sha256>.mp4" // MP4, MOV, or WebM
}
],
"videoTrack": {
"clips": [
{
"id": "opening", // stable, non-empty, unique
"assetId": "interview-a", // resolves to media.assets[].id
"timelineStartFrame": 0, // nonnegative integer
"durationFrames": 180, // positive integer
"sourceStartSeconds": 4.25, // nonnegative media-relative Source time
"audio": {
"enabled": true,
"gain": 1 // 0..4
}
}
]
}
}
media defaults to { "assets": [], "videoTrack": { "clips": [] } }. A Media library entry is composition membership, not embedded bytes: only its stable id, kind, name, and content-addressed assetUrl persist. Probe output such as duration, dimensions, rotation, frame rate, codecs, channels, sample rate, byte size, readiness, and errors is volatile and must not be copied into Preset JSON. The bytes are globally deduplicated; different compositions and entries may reference the same assetUrl with independent IDs and names. Removing an entry never deletes shared bytes. Unused entries are valid.
V1 has exactly one primary Video track. Clips are ordered and non-overlapping half-open intervals:
timeline interval = [timelineStartFrame, timelineStartFrame + durationFrames)
localFrame = outputFrame - timelineStartFrame
Source time = sourceStartSeconds + framesToSeconds(localFrame, transport rate)
requested PTS = media track first PTS + Source time
At a cut, the ending clip is inactive and the next clip is active. The decoder selects the last presentation sample at or before the requested PTS, including CFR, VFR, B-frame, and non-zero-first-PTS inputs. The referenced source range must cover the complete clip; clips must remain inside the composition frame count. Random preview seeks, serial export, and clip audio use this same exact-rational mapping. One resident underlay texture holds only the active frame; a gap supplies no texture. Output applies display rotation and centered cover framing at native target size. Effects and Pack chrome process the GFX result before final-present compositing, never the creator footage.
Each clip's audio is decoded from the same Source interval and placed at the same destination interval. audio.enabled mutes that clip only; audio.gain controls its contribution to the deterministic exact-length 48 kHz stereo mix with derived/manual cues and an eligible bed. Timeline play/loop previews that final mix from the explicit playhead; scrub remains silent.
V1 rejects active Video clips with backgroundFill, stage, or transition Presets. It supports one 1x track, hard cuts, move, left/right trim, slip, snapping, clip removal, and transparent gaps. It excludes multiple Video tracks, overlaps, ripple edits, clip transitions, speed changes, loops/holds, footage grading, depth-stage video planes, live video transitions, silence detection, and automatic cut generation. Those exclusions add behavior around the clip contract rather than adding inert schema now.
Legacy input containing only state.sourceVideo is accepted at the shared ingress and normalized to one deterministic Media library entry plus one frame-0 full-span Video clip, preserving its offset, audio inclusion, and gain. Input containing both state.sourceVideo and state.media is rejected. Canonical parse, runtime state, GET/PUT interchange, GUI export, and first autosave emit only state.media; state.sourceVideo is migration history, not an active alternative contract.
typography #
"typography": {
"fontFamily": "serif" | "sans" | "mono" | "condensed",
"paperColor": "#rrggbb", // optional explicit override
"inkColor": "#rrggbb" // optional explicit override
}
When a color is absent, the active Pack's core fill-treatment / ink-treatment supplies it. An authored color wins intentionally over the Pack.
marks #
"marks": {
"defaults": {
"highlight": { "color": "#rrggbb", "intensity": 0..1 },
"underline": { ... }
// any subset of the registered annotation styles
},
"timings": [
{ "start": 0..1, "duration": 0..1, "ease": "smooth" | "settled" | "sharp" | "bouncy",
"color": "#rrggbb", // optional per-mark override
"intensity": 0..1, // optional per-mark override
"sound": { ... }, // optional per-motion sound override (see Sound)
"cascade": { ... } // optional timing weld (see Animation)
}
]
}
marks.defaults[style] is the fallback color/intensity per style. marks.timings[i] is indexed by the position of each (segment, style) pair in document order and may override color and intensity per mark instance.
surface #
"surface": {
"type": "paper" | "plain" | "newspaper" | "pullquote-on-photo" |
"chapter-card" | "title-sequence" | "type-hero" | "web-document" |
"website-screenshot" | "imessage" | "checklist",
"content": {
"body": "Bracket-tag string (see below)",
"title": "...", // optional
"sourceUrl": "...", // optional
"author": "...", // optional
"source": "...", // optional
"dateLabel": "...", // optional
"logoUrl": "...", // optional uploaded-image URL; checklist uses it in place of title
"imageUrl": "...", // website-screenshot: a content-addressed /api/user-assets capture
"captureAsset": "..." // website-screenshot: a bundled capture slug (capture-assets.ts); never with imageUrl
},
"variant": "...", // optional; website-screenshot: "browser" (default) | "filmed"
"pageAnchor": { "x": 0..1, "y": 0..1 }, // optional; the page point at frame centre in the filmed framing
"enter": { "start": 0..1, "duration": 0..1, "ease": Ease }, // optional
"exit": { "start": 0..1, "duration": 0..1, "ease": Ease }, // optional
"animation": { "channels": { "opacity": [ ... ] } }, // optional (see Animation; opacity only)
"backgroundVisibility": 0..1, // optional
"diagram": [ ... ], // optional Diagram primitive Blocks
"chart": { "mode": "single" | "sequence", "items": [ ... ] } // optional Chart Blocks; plain/paper only
}
Surface-specific content widens this common slot set. In particular, checklist uses items[] for independently timed rows and may use logoUrl for an uploaded mark shown in a neutral circular chip; a failed logo load falls back to title.
surface.diagram — diagram primitives (ADR-0036) #
Five Block types for art-directed, documentary-style diagrams, living on any Surface — full-frame (paper / chapter-card) or over footage (a transparent plain surface carrying only diagram Blocks). Every primitive is positioned explicitly in composition-space fractions (auto-layout is rejected by the ADR). Authored ids remain unchanged: Cascade anchors use { "block": id }, while runtime rows are identified by createTimelineTrackId({ kind: 'block', blockId: id }) and decoded by parseTimelineTrackId. Route is content; stroke is appearance — edge/node/arrowhead looks resolve through Pack Roles, never the schema.
Every primitive also takes an optional "ink": "ink" | "accent" — a Role selection, not a colour: "accent" routes that primitive (label ink, node glyphs, stroke) to the active Pack's core accent-treatment so a diagram carries emphasis hierarchy; absent rides the composition ink. The Pack still owns what accent looks like.
"diagram": [
{ "type": "node", "id": "n1", "position": { "x": 0..1, "y": 0..1 },
"form": "pin" | "box" | "dot", // which form — content, the author picks; HOW it looks is the Pack's Role
"text": "...", // optional in-node text
"scale": 0.25..4, // optional
"enter": { ... }, "exit": { ... }, // optional Transition sugar (start/duration/ease/sound)
"animation": { "channels": { ... }, "cascade": { ... } } }, // full channel set: opacity | x | y | scale | rotation
{ "type": "edge-arrow", "id": "e1",
"from": { "node": "n1" } | { "x": 0..1, "y": 0..1 }, // node ref (validated) or explicit point
"to": { "node": "n2" } | { "x": 0..1, "y": 0..1 },
"route": "straight" | "elbow" | "arc", // authored, never auto-routed
"control": { "x": 0..1, "y": 0..1 }, // optional single control point (elbow corner / arc bow)
"direction": "forward" | "both" | "none", // arrowhead placement; absent → "forward"
"animation": { "channels": { "opacity": [ ... ] }, "cascade": { ... } } }, // stroke elements: opacity only
{ "type": "label", "id": "l1", "position": { "x": 0..1, "y": 0..1 },
"text": "required", "role": "headline" | "caption", // optional; absent → caption at the consumer
"wrap": "auto" | "explicit", // optional; explicit preserves authored line breaks
"scale": 0.25..4,
"maxWidth": 0.03..1, // optional final composition-width fraction; height follows text
"animation": { ... } }, // full channel set
{ "type": "stat-callout", "id": "s1", "position": { "x": 0..1, "y": 0..1 },
"from": number, "to": number, // the count
"format": "integer" | "currency" | "percent" | "timecode", // absent → integer
"label": "caption under the number", // optional
"rollStart": 0..1, "rollWindow": 0..1, // optional count window (counter-roll semantics); holds the landed value
"animation": { ... } }, // full channel set
{ "type": "timeline-segment", "id": "t1",
"from": { "x": 0..1, "y": 0..1 }, "to": { "x": 0..1, "y": 0..1 }, // explicit endpoints — H↔V reflow repositions, never reshapes
"label": "2019 – 2024", // optional
"animation": { "channels": { "opacity": [ ... ] }, "cascade": { ... } } } // stroke: opacity only
]
Parse-time rules: primitive ids are unique within the surface; every edge endpoint { node } ref must resolve to a node primitive in the same diagram. Stroke-drawn primitives (edge-arrow, timeline-segment) reveal by stroke-draw over their enter window and expose only the opacity channel; DOM primitives (node, label, stat-callout) take the full ADR-0035 channel set. Reveal choreography is Cascade chains (node → edge draws to → next node) — see Animation below.
Every primitive may carry orientationOverrides.horizontal and/or .vertical as a complete geometry snapshot. Node and stat-callout snapshots are { "position": { "x", "y" }, "scale"? }; label snapshots also carry optional "maxWidth"; edge-arrow snapshots are { "from", "to", "route", "control"? }; timeline-segment snapshots are { "from", "to" }. A snapshot replaces the shared geometry as one unit rather than inheriting individual fields. Content, timing, animation, ink, form, direction, labels, and values remain shared. The GUI edits shared geometry until Customize horizontal or Customize vertical is enabled; safe-area lint validates resolved points without mutating them.
A Diagram label is the bounded text-container domain. maxWidth is the label's final width as a fraction of the native composition width, independent of scale; changing it reflows lines without changing font size, and the box height follows its content. The Inspector edits the same field agents author. On-canvas west/east handles keep the opposite edge fixed, convert screen movement through the active canvas projection, and write the active orientation's shared or override geometry.
surface.chart — authored statistical graphics (ADR-0048) #
surface.chart is one strict inline declaration shared by agents and the GUI. It is supported on plain and paper Surfaces; semantic validation rejects it elsewhere. Its items are Blocks on the Surface plane, not a sixth Layer or an external data source. single requires one item; sequence requires two to four declaration-ordered items whose [entry.start, exit.end] visibility intervals do not overlap.
"chart": {
"mode": "single" | "sequence",
"items": [
{
"id": "agents-by-count", // unique Block/timeline identity
"type": "bar-chart" | "column-chart" | "line-chart",
"title": "Coding agents run at once",
"data": {
"categories": [{ "id": "one", "label": "1" }], // 1..12, unique ids
"series": [{ // 1..4, unique ids
"id": "responses",
"label": "Responses",
"values": [{ "categoryId": "one", "value": 360 }]
}]
},
"layout": { "mode": "single" | "grouped" | "stacked" }, // bar/column only; omitted for line
"domain": { "min": 0, "max": 400 }, // optional; at least one bound
"labels": { "categories": true, "values": true, "legend": false },
"highlights": [{ "target": { "kind": "datum", "seriesId": "responses", "categoryId": "one" } }],
"callouts": [{
"target": { "kind": "datum", "seriesId": "responses", "categoryId": "one" },
"valueLabel": { "kind": "value" }
}],
"sourceNote": "Source: survey, n=1,104",
"progressBar": true, // optional; absent/false hides it
"fill": { "role": "default" | "series" },
"motion": {
"entry": { "start": 0.00, "duration": 0.03, "ease": "smooth" },
"reveal": { "start": 0.03, "duration": 0.08, "ease": "smooth" },
"emphasis": { "start": 0.11, "duration": 0.04, "ease": "sharp" },
"annotation":{ "start": 0.15, "duration": 0.04, "ease": "smooth" },
"exit": { "start": 0.21, "duration": 0.02, "ease": "smooth" }
}
}
]
}
unit-grid-chart and dot-field-chart use the same common fields but replace layout with:
"normalization": { "total": 360, "unitCount": 100 } // total must equal the explicit part sum; unitCount: integer 10..1000
The strict common limits and unions are:
- 1–12 categories, 1–4 series, exactly one finite datum per declared category in each series, at most 24 highlights, and at most 4 callouts;
- targets
{ kind: "datum", seriesId, categoryId },{ kind: "category-set", seriesId, categoryIds }with 2–12 unique categories, or{ kind: "series-total", seriesId }; - computed
valueLabel{ kind: "value" },{ kind: "percent-of-series-total", precision: 0..4 }, or{ kind: "approximate-fraction-and-percent", maxDenominator: 2..20, precision: 0..4 }; percent formats require a positive series total, and approximate fractions require a target ratio in(0, 1]; - bar/column
singlewith one series orgrouped | stackedwith at least two; stacks are non-negative; line charts connect declaration-order category points and need no layout mode; every explicit finite linear domain must include zero, satisfymin < max, and contain every factual value or stack total; - normalized charts with exactly one non-negative series, positive
total, integerunitCount10–1,000, and explicit parts summing tototalwithinmax(1e-9, abs(total) * 1e-9).
Semantic validation in chart-validation.ts runs at every Preset ingress after structural Zod parsing. It requires unique and complete category/series references; finite values and domains; compatible single, grouped, and non-negative stacked layouts; one non-negative series whose explicit parts sum to the positive normalized total; resolvable datum, category-set, or series-total targets; valid computed-label denominators; and ordered non-overlapping motion phases. Base fill.role: "emphasis" is rejected because emphasis is reserved for choreography. Normalized marks use exact decimal largest-remainder allocation with declaration order as the final tie-breaker and emit exactly unitCount marks, while labels and callouts keep authored/computed facts exact. Value labels reveal at their authored terminal text and never count or crawl through inaccurate intermediate numbers.
An optional progressBar: true draws a subtle Pack-colored ordered-dither strip at the top edge. It advances linearly from entry.start to exit.start, reaching full width as the chart begins to disappear; absent or false renders no strip.
The five motion windows are authored, Pack-invariant, and frame-deterministic. Omitted eases resolve to smooth, smooth, sharp, smooth, smooth; only smooth | sharp is accepted. Gaps hold state. Charts have no orientation-override schema: shared layout reflows the one declaration natively in horizontal and vertical and owns factual scales, zero baselines, chrome, legends, labels, source notes, and callout geometry. Marks render analytically through one instanced WebGPU path with Pack-resolved solid, gradient, or ordered-dither recipes localized to mark masks. The GUI add menu and Chart inspector mutate this same surface.chart.items[] model through bounded authoring helpers; there is no CSV upload, URL fetch, or GUI-only chart state.
overlays #
"overlays": [
{
"type": "lower-third",
"id": "main",
"content": { "kicker": "...", "title": "...", "subtitle": "..." },
"position": {
"anchor": "bottom-left" | "top-right" | "..." | "normalized-rect",
"offset": { "x": 0..1, "y": 0..1 }, // optional; fractions of composition (5% = 0.05)
"rect": { "x": 0..1, "y": 0..1, "width": 0..1, "height": 0..1 }, // optional, anchor === 'normalized-rect'
"scale": 0.1..8, // optional uniform scale about the anchor
"rotation": -360..360, // optional static rotation in degrees about the anchor
"orientationOverrides": { // optional complete placement snapshots
"vertical": {
"anchor": "bottom-center",
"offset": { "x": 0, "y": 0.2 },
"scale": 0.9,
"rotation": 0
}
}
},
"enter": { "start": 0..1, "duration": 0..1, "ease": Ease }, // optional
"exit": { "start": 0..1, "duration": 0..1, "ease": Ease }, // optional
"animation": { "channels": { ... }, "cascade": { ... } } // optional (see Animation)
}
]
The fields directly under position are the shared placement fallback. orientationOverrides.horizontal and .vertical are optional complete placement snapshots with the same anchor / offset or rect / scale / rotation shape; when present, the active transport orientation uses that snapshot instead of shared placement. Animation-channel x/y remain deltas from the resolved placement, while scale/rotation channels seed from it. The GUI edits shared placement until Customize horizontal or Customize vertical is enabled. Platform safe areas validate resolved placement but never clamp or mutate authored geometry.
animation — generalized keyframes + Cascade (ADR-0035) #
Ordered per-channel keyframes[] are the general motion form; the enter/exit Transition shape stays valid as lossless sugar. Declaring animation.channels means the composition takes full ownership of that element's motion — the pipeline's intrinsic enter/exit motion-form does not run. An element with no keyframes renders exactly as today.
"animation": {
"channels": { // overlay: opacity | x | y | scale | rotation; surface: opacity only
"opacity": [
{ "atMs": 0, "value": 0 }, // first keyframe carries no ease
{ "atMs": 300, "value": 1, "ease": "smooth" } // ease = the curve INTO this keyframe
],
"scale": [
{ "atMs": 0, "value": 1 },
{ "atMs": 180, "value": 0.96, "ease": "sharp" },
{ "atMs": 420, "value": 1, "ease": "settled" }
]
},
"cascade": { "anchor": { "overlay": "title" }, "event": "end", "offsetMs": 120 }
}
Keyframes:
atMs— milliseconds from the element's resolved clip start (welded-absolute: authored motion survives re-time without drift). Strictly ascending within a track; a declared track needs ≥ 1 keyframe.value— per channel:opacity0..1 ·x/ysigned composition-fraction deltas from the element'spositionanchor/offset ·scaleabsolute 0.1..8, seeded fromposition.scale·rotationabsolute degrees (unbounded — spins are legal), seeded fromposition.rotation.ease— the constrained enum only (smooth|settled|sharp|bouncy), per segment. No bezier values. The first keyframe of a track carries none.- Surface channels are
opacityonly — surface transforms are camera territory (stage.camera).
Cascade welds an element's enter start to another element's timing (milliseconds, not fractions — a 120 ms stagger stays 120 ms when the piece re-times):
anchor—"surface"|{ "overlay": id }|{ "mark": index }|{ "textAnimation": id }|{ "block": id }(the same identities the timeline rows use;blocknames asurface.diagram[]primitive orsurface.chart.items[]Chart Block).event—"start"|"end"of the anchor's enter.offsetMs— signed milliseconds after (or before) the anchor event.- Allowed on
overlays[].animation,marks.timings[]entries,textAnimations[]entries, andsurface.diagram[].animation. A Chart Block may be an anchor through its intrinsic entry phase, but its fiveChartMotionphases are not generalized keyframe channels and cannot carry Cascade. The surface is the timing root and carries no cascade. - Parse-time rules: every anchor ref must resolve, and anchor chains must be acyclic — a cycle is rejected with an error naming the loop.
textAnimations (per-slot text choreography) #
"textAnimations": [
{
"id": "title-reveal",
"target": { "kind": "surface", "slot": "title" },
"effect": "soft-blur-in",
"enter": { "start": 0.04, "duration": 0.10, "ease": "smooth" },
"exit": { "start": 0.86, "duration": 0.05, "ease": "smooth" }, // optional
"cascade": { "anchor": "surface", "event": "end", "offsetMs": 80 }, // optional timing weld (see Animation)
"params": { "speedMultiplier": 0.72 } // optional, merged over effect runtime
}
]
Each entry binds one TextAnimation to one text slot. The choreography is compiled into GSAP tweens against SplitText-produced unit spans and scrubbed by the shared timeline progress. See ADR-0011.
Fields:
id— stable identity for Inspector selection and its Timeline track.target— discriminated union:{ kind: 'surface', slot }for the active surface,{ kind: 'overlay', overlayId, slot }for an overlay slot.effect— an id fromTEXT_EFFECT_CATALOG(soft-blur-in,per-character-rise,typewriter,bottom-up-letters,top-down-letters,stagger-from-center,stagger-from-edges,mask-reveal-up,line-by-line-slide,per-word-crossfade,spring-scale-in,depth-parallax-words,blur-out-up,shared-axis-y,kinetic-center-build,short-slide-right,short-slide-down,micro-scale-fade,fade-through,scale-down-fade,focus-blur-resolve,shimmer-sweep,shared-axis-x,shared-axis-z).enter— requiredTransition. The compiler scales the effect's per-unitduration_ms/stagger_ms/from→tokeyframes to fit this window.exit— optionalTransition. Without it the text stays visible until preset end.cascade— optionalADR-0035timing weld; when present it anchors this animation's enter start (see Animation).enter.startremains the fallback.params— optional shallow overrides for the effect'sshowcase.runtimeblock (speedMultiplier,holdMs,gapMs,yTravelMultiplier,initialDelayMs).
Parse-time validation:
per-charactereffects accepttitle/kicker/lower-third.titleonly.- Layout-aware renderers (
kinetic-center-build,kinetic-top-build,short-slide-right,short-slide-down) accept title-scale slots only — they reflow the line as words push in. - A target slot may appear at most once in
textAnimations[]. effectmust resolve in the catalog.
When a body has both marks.timings[] and a textAnimations[] entry targeting it, the marks renderer multiplies its drawn alpha by the body's animated unit alpha so marks ride along with their text.
effects (one composition-wide authored list) #
"effects": [
{ "type": "paper-grain", "id": "f1", "params": { ... } },
{ "type": "chromatic-aberration", "id": "f2", "params": { ... } }
]
A single flat list — ADR-0018 collapsed the old per-layer { surface, body, annotations, overlays, frame } object (only frame was ever consumed). Each entry is { type, id, params }. Ordinary entries resolve through an EffectRenderer.schema and run in the final post-process chain; composition-owned entries resolve through composition-effect-registry.ts and alter branch dispatch before that chain. depth-of-field is the current composition-owned Effect. Per-target shader work that needs layer-local knowledge is a shaderPass on the Surface/Overlay renderer, not an Effect (ADR-0005).
stage (optional — dimensional depth stage) #
Opt-in composition-wide 3D compositor (ADR-0028). Omit it and rendering is the flat path, unchanged. When present, the engine places the composition's Layer textures on real 3D planes at their ADR-0021 z, through a perspective camera with a real lens depth-of-field. Camera and focus drive frame-deterministically off the timeline.
"stage": {
"type": "depth", // open string, registry-validated at load time
"camera": { // optional; defaults shown
"move": "static" | "push" | "drift", // default "static"; composes on top of the pose
"amount": 0..1, // dolly / lateral parallax strength (default 0.5)
"ease": "smooth" | "settled" | "sharp" | "bouncy", // default "smooth"
"pose": { // optional rest pose (ADR-0057); absent = the frontal camera
"yaw": -60..60, // degrees; + swings the eye to the page's right (default 0)
"pitch": -45..45, // degrees; + lifts the eye above the page (default 0)
"roll": -30..30, // degrees; + tilts the horizon clockwise (default 0)
"distance": 0.25..2, // fraction of the rest distance (default 1)
"aim": { "x": 0..1, "y": 0..1 } // page point the camera orbits and looks at (default centre)
},
"travel": { // optional: one move from the rest pose
"to": { "yaw"?, "pitch"?, "roll"?, "distance"?, "aim"?: { "x"?, "y"? } }, // fields left out hold
"start": 0..1, "duration": 0..1, // timeline fractions (defaults 0, 1)
"ease": "smooth" | "settled" | "sharp" | "bouncy" // default "smooth"
}
},
"focus": { // optional; defaults shown
"focusZ": 0..1, // in-focus depth (0 near … 1 far; default 0)
"aperture": 0..1, // max blur / circle-of-confusion (default 0.6)
"band": 0..1, // hyperfocal half-width: depths within it stay sharp (default 0)
"pull": { "from": 0..1, "to": 0..1, "start": 0..1, "duration": 0..1 } // optional rack focus
},
"backdrop": { // optional image on the far (backdrop) plane
"image": { "asset": "substrate-slug" }, // registered substrate (substrate-textures.ts); absent → backgroundFill colour
"contrast": 0..1 // centre darken of the image for near-plane text legibility (default 0)
}
}
Focus follows the aim: focusZ 0 keeps the aimed page point sharp (the Surface plane's centre under the frontal camera), focusZ 1 reaches the backdrop straight behind it, and a travel that moves the aim racks focus with it. A pose widens the stage's depth encoding to the distances the move can reach and scales the lens with the camera's nearness — the circle of confusion per world unit grows as one over the focus distance squared, as a thin lens does, so a camera at distance 0.5 defocuses the same page four times as hard as the rest camera; with no pose or travel every existing Preset renders pixel-identical.
With stage present: Overlays ride a 3D plane. An Overlay that declares neither z nor pose shares the merged Overlay plane at the Layer default depth (0.7); an Overlay with an explicit z or a pose rides its own posed plane (ADR-0057) — z is signed (0 the Surface plane, 1 the backdrop, negative lifts toward the camera down to −1) and pose: { "yaw": -60..60, "pitch": -45..45, "roll": -30..30 } turns the plane in degrees about the Overlay's rendered centre, relative to the Surface plane (positive yaw turns its right edge away from the page's front, positive pitch leans its top edge away, positive roll turns it clockwise). A posed plane is placed in the posed camera's frame (the pose and travel): the Overlay's position means where it sits in the delivered frame under whatever pose is authored, at its depth, so safe areas hold and the page moves behind a card that stays put; the legacy move: push | drift still parallaxes it as a world-fixed plane, exactly as an explicit z behaved before. At most four Overlays may be posed; a fifth is a semantic error. Posed planes parallax, defocus, occlude, and cast shadow per pixel through the depth-tested stage (real depth test, so intersecting planes resolve per pixel); the active Pack's light-treatment Role becomes a real scene key light (received rake + cast plane-to-plane shadow; no Role → unlit); surface-local shader passes still run on the surface plane, but environment-painting passes (environment: true — the chapter-card / title-sequence / type-hero / pullquote painted backdrops) are superseded by the real backdrop plane. Surface enter/exit visibility is forwarded into the stage as GPU alpha, so authored Surface fades work without duplicating them as text animations.
captions (optional — the SRT caption track) #
A time-coded caption track (creator blocks, 2026-07-09). Cues carry absolute milliseconds (SRT's own clock) — captions are welded to speech and do not stretch when the transport re-times. Rendered topmost (above overlays) in every render path; the active cue/word is a pure function of the timeline clock (no tweens — export == preview). Three equal import lanes write the same shape: agents author cues[] directly, scripts/srt-to-captions.mjs <file.srt> [--preset <path>] [--style …] converts SRT/VTT, and the GUI's Captions inspector carries an SRT editor that round-trips this data as subtitle text.
"captions": {
"style": "karaoke" | "word-pop" | "pack",
// karaoke — full spoken line in the social register (heavy white type, hard
// outline; pack-independent by intent) with the currently-spoken
// word on an accent pill. Per-word timing derives proportionally
// by word length within each cue (the schema stays pure SRT data).
// word-pop — only the current word, popping in big (120ms eased pop).
// pack — the line dressed by the active Pack (core ink + font-treatment).
"accent": "#rrggbb", // optional active-word accent; absent → #ffd608
"y": 0..1, // optional band centre (fraction of height); absent →
// 0.8 horizontal, 0.75 vertical (C5's vertical band —
// clear of the platform expanded-description occlusion)
"scale": 0.25..4, // optional size multiplier; absent → 1
"cues": [
{ "id": "cue-1", "startMs": 400, "endMs": 1900, "text": "Here's the thing" }
]
}
Parse-time rules: cue ids unique; every cue must end after it starts. Cue enter/exit are hard cuts (broadcast-faithful); there are no per-cue transitions. The timeline shows one Captions rail — each cue is a draggable clip (move retimes, trim adjusts start/end in ms).
Sound (ADR-0033) #
Sound is a timed-cue orchestration domain, not a sixth Layer. Motion primitives emit semantic sound events at their own frame; automatic cues are derived from motion at render time and never stored. The schema carries three things:
Defaults are engine-level. Every sound event carries one engine-default sample (DEFAULT_EVENT_SAMPLES in sound-cues.ts) selected from Foley's 28-cue library. scripts/gen-foley-sounds.mjs creates the checked-in WAV renders that preview and export consume as identical fixed bytes; all 28 foley-* samples remain selectable for per-motion personalization. The iMessage bubbles and tapbacks lock their Apple signature samples at derivation (MESSAGE_SAMPLES / TAPBACK_SAMPLES) — chat-surface sounds never leak into the general vocabulary. There is no per-Layer sample bundle or palette field; that indirection was removed 2026-07-02 after GUI testing because it made "what does this play" illegible (see ADR-0033 amendments). Personalisation is per motion, via the override below or by selecting any cue on the timeline's Sound rail.
Defaults follow the motion's character, not just its window: sliding elements whoosh with their enter/exit, but types whose motion doesn't displace air emit nothing by default — Overlays like washi-tape (press-on), watermark (fade), cursor-trail (glide), and fade-entrance Surfaces like imessage and chapter-card. Their sound is opt-in via the per-motion override below (see OVERLAY_EVENT_DEFAULTS / SURFACE_EVENT_DEFAULTS in sound-cues.ts).
sound — per motion. Any motion window (a Transition — surface/overlay/text-animation enter/exit — a marks.timings[] entry, or a chat message's enter) may override the engine-default event/sample:
"sound": {
"mute": true, // silence this one motion
"event": "impact", // swap which event it emits (whoosh-in | whoosh-out | impact | tick | click | pop | send | swipe | scratch | draw | sub-drop | sting)
"sample": "asset-slug" // lock a specific audio asset instead of the event's engine-default sample
}
audioCues — manual cues + the bed. Top-level on state, peer to textAnimations. Holds only what has no motion to ride:
"audioCues": [
{ "id": "outro-sting", "assetSlug": "sting-brass-01", "start": 0.9, "duration": 0.08 },
{ "id": "bed", "kind": "bed", "assetSlug": "bed-warm-keys", "start": 0, "duration": 1, "volume": 0.6 }
]
kind defaults to "cue". assetSlug names a bundled audio asset directly, so manual cues do not use the event-default sample mapping. Parse-time rules: ids unique; at most one bed; a bed requires backgroundFill or complete Video-track coverage (full-frame segments/bumpers only — a transparent Overlay or gapped Video track keeps alpha).
Surface variants #
paper— card chrome with paper-grain shader and fly-in/out animation. Slots:title,sourceUrl,author,source,dateLabel. Supportsenter,exit,backgroundVisibility.plain— transparent background that hosts a body without chrome. Slots:author,source,dateLabel(decorative metadata only).newspaper— a broadsheet page photographed up close: full-bleed crop, intrinsic newsprint physics, fully Pack-immune (ADR-0056). Slots:title,kicker,author,affiliation,source,dateLabel,body. Thetitleaccepts the bracket-tag mark syntax (the Surface declarestitleMarks); a headline mark indexes before the body's marks inmarks.timings[]. Noenter/exit— the page is a cut with a slow camera push. Full-frame — Presets declarebackgroundFill.pullquote-on-photo— pullquote staged against the dimensional depth stage's photographic backdrop.chapter-card— full-frame chapter introduction Surface.brand-mark— full-frame chapter break carrying a registered brand silhouette in the active Pack's accent; variantsyntax-fm.title-sequence— full-frame title-sequence Surface.type-hero— full-frame typographic hero; variantssingle/pair.web-document— structured site mock selected bysurface.site.website-screenshot— a stored website capture. Slots:sourceUrl(author-time capture URL) and exactly one capture source —imageUrl(content-addressed/api/user-assets/...bytes, the GUI's capture) orcaptureAsset(a bundled capture fromsrc/lib/platform/capture-assets.ts, the corpus form, authored withscripts/capture-website.ts <url> --out src/lib/assets/captures/<slug>.png --scale 2 --width 2560 --height 2000). Framings viavariant:browser(default) presents the 1440×900 capture in controls-only browser chrome and reflows one browser-plus-plate stack across both transports;filmed(ADR-0057) lays the capture at native density — one capture pixel per frame pixel, scaled up only when the capture is smaller than the frame — with no chrome,pageAnchorchoosing which page point sits at frame centre — the frame is a crop into the page for the depth stage's camera to film. The Surface plane itself stays frame-sized (a capture wider than the frame only widens the croppageAnchorcan choose), so the author keeps the camera's footprint on the page;pnpm verify-presetsfails the Preset (rule WS7) when a frame corner leaves the page anywhere in the authored move, in either orientation.imessage— choreographed Messages conversation;chrome: "window" | "none".checklist— timed checklist/progress Surface;chrome: "window" | "none".
Pack immunity is declared by each Pipeline's Identity Spec and derived at runtime. PACK_IMMUNE_PIPELINE_KEYS is the complete authority; this catalog intentionally does not copy that set into prose.
Block variants #
paragraph— text run insidecontent.body(the bracket-tag string).node,edge-arrow,label,stat-callout,timeline-segment— shipped diagram primitives (ADR-0036), carried insurface.diagram[](see the Diagram primitives section above).bar-chart,column-chart,line-chart,unit-grid-chart,dot-field-chart— shipped factual Chart Blocks (ADR-0048), carried insurface.chart.items[]and edited through the shared Chart inspector/authoring helpers. A mermaid-style auto-layout Block is explicitly rejected;imageandcoderemain possible future additive variants.
Annotation styles #
Decorative (additive on the marked target): highlight, underline, strike, circle, box, side-note.
Focal (transforms surroundings): magnify, lift-out, tear-out, isolate. magnify selects a native-pixel circle for compact marks or a bounded rounded rectangle for wrapped phrases; mark intensity controls a constrained optical scale, and its scanner reticle/ripple use the resolved mark color.
The bracket-tag body format expresses marks inline. Stacked styles nest tags around the same run: [magnify][side-note]quote[/side-note][/magnify] parses to markStyles: ['magnify', 'side-note'] on the segment. Decorative-under-focal stack order is enforced in the composition shader, so authoring order within nested tags does not affect rendering. A Surface whose definition declares titleMarks (the newspaper) accepts the same syntax in title; its headline marks come first in marks.timings[], before the body's. On every other Surface, mark syntax in a title is a lint error (rubric A3).
Overlay types (v1) #
lower-third— content{ kicker, title, subtitle? }; variantsstandard/cinematic; default position{ anchor: 'bottom-left', offset: { x: 0.0625, y: 0.0625 } }.washi-tape,watermark,shader-fill,cursor-trail— graphic overlay families with renderer-declared content schemas.counter— numeric build; variantslot-machine-roll.instance-stack— repeated-instance motion; variantsvertical-stack/horizontal-train.text-3d— dimensional text; variantcylinder-axis-y.youtube-subscribe,instagram-follow— platform creator-action overlays.achievement— content{ variant: 'checklist-complete' | 'unlocked', kicker, title, beat };beatis the draggable focal moment shared by intrinsic variant choreography and derived sound.source-url— content{ url }; Pack-resolved URL plate centered horizontally across awebsite-screenshotSurface's top edge at a 50% overlap, with normal transition/keyframe/Cascade timing.tweet-stack— content{ posts: [{ id, url, displayName, handle, body, dateLabel, avatarUrl?, verified }], pileStart, pileWindow, spread }; 2–8 baked X posts arrive sequentially during the draggable pile window, settle into a deterministic overlapping cluster, and reflow for both orientations.urlmust be anx.comortwitter.comstatus share URL. The inspector imports public posts through X oEmbed once; exports use only the baked content and never fetch live X data.
Effect types (v1) #
paper-grain— params{ warmth: 0..1, density: 0..1 }; multiplies a 2-octave value-noise grain into the composed frame texture.chromatic-aberration— params{ strength: 0..1, radial: 0..1 }; R/B channel split, uniform-horizontal (0) to lens-style radial (1).dithering— params{ mode: 'random'|'2x2'|'4x4'|'8x8', pxSize: 1..64, colorSteps: 1..7, originalColors: bool, inverted: bool, colorFront/colorBack/colorHighlight: '#rrggbb' }; pixelizes the frame into a dither-cell grid and posterizes luminance against a hash or Bayer threshold — the frame's own colors, or a front/back/highlight palette masked to the content silhouette. Ported from@paper-design/shadersimage-dithering (Apache-2.0).halftone-dots— params{ dotType: 'classic'|'gooey'|'holes'|'soft', grid: 'square'|'hex', size: 0..1, radius: 0..2, contrast: 0..1, originalColors: bool, inverted: bool, colorFront/colorBack: '#rrggbb' }; print-style dot screen — each cell's dot radius tracks sampled luminance (dark → big ink dot), in the frame's own colors or ink-on-paper palette, masked to the content silhouette. Ported from@paper-design/shadershalftone-dots (Apache-2.0); its grain sub-features are omitted (composepaper-grainin the chain instead).halftone-cmyk— params{ cmykType: 'dots'|'ink'|'sharp', size: 0..1, contrast: 0..2, softness: 0..1, gridNoise: 0..1, colorBack/colorC/colorM/colorY/colorK: '#rrggbb', floodC/M/Y/K: -1..1, gainC/M/Y/K: -1..1 }; CMYK press separation — four rotated ink screens at the classic angles (C 15° / M 75° / Y 0° / K 45°) multiply over the paper color, masked to the content silhouette. Ported from@paper-design/shadershalftone-cmyk (Apache-2.0); grain sub-features omitted (composepaper-grain), noise texture replaced with a procedural hash.water— params{ size: 0.01..7, highlights: 0..1, layering: 0..1, edges: 0..1, caustic: 0..1, waves: 0..1, speed: 0..3, colorHighlight: '#rrggbb' }; time-driven water-surface refraction — layered caustic octave fields + simplex waves displace the sampling UV, with caustic glints masked to the content silhouette. Animates offctx.timestamp × speed(frame-deterministic, ADR-0012). Ported from@paper-design/shaderswater (Apache-2.0); its standalonecolorBackfill is dropped (use the composition'sbackgroundFill), and the displacement field is soft-knee bounded so default params keep glyphs coherent.fluted-glass— params{ shape: 'lines'|'linesIrregular'|'wave'|'zigzag'|'pattern', distortionShape: 'prism'|'lens'|'contour'|'cascade'|'flat', size: 0..1, angle: 0..180, distortion: 0..1, shift: -1..1, stretch: 0..1, blur: 0..1, edges: 0..1, shadows: 0..1, highlights: 0..1, marginLeft/Right/Top/Bottom: 0..1, colorShadow/colorHighlight: '#rrggbb' }; ribbed architectural glass — rotated flutes each apply a refraction profile with shadow gradients, boundary highlights, optional directional blur, and margins scoping the pane; masked to the content silhouette. Ported from@paper-design/shadersfluted-glass (Apache-2.0); grain sub-features omitted (composepaper-grain),colorBackdropped (usebackgroundFill).refractive-lens— params{ shape: 'circle'|'rounded-rect', region: { x, y, width, height }, magnification: 1..2.4, thickness/refraction/roughness/dispersion/reflection/rimLight/tintStrength/edgeFlatness: 0..1, bevel: 0.02..1, tint: '#rrggbb' }; independently authored local clear glass with native-pixel SDF geometry, bounded reconstruction/refraction, restrained dispersion and reflection, exact outside-region pass-through, and input-silhouette alpha preservation.frosted-glass— params{ region: { x, y, width, height }, coverage: 0..1, contrast: 0.05..1, roughness/haze/refraction/tintStrength/highlight: 0..1, detailScale: 0.25..4, tint: '#rrggbb', seed: 0..65535, growFrom/growTo: 0..1, melt?: { center: { x, y }, radius, softness, from, to } }; independently authored deterministic three-scale frost with weighted transmission blur, relief refraction, authored growth, and optional progress-addressed melt/refreeze.growTo > growFromandmelt.to > melt.fromare required. Both optical Effects interpret local regions against canonical 3840x2160 geometry and preserve their pixel size and aspect across native targets;{ x: 0, y: 0, width: 1, height: 1 }remains target-filling.fluid-ripple— one authored impulse time/position/strength plus seed, radius, damping, wave speed, refraction, and highlight controls; a 60 Hz reset/replay kernel resolves seekable modal state and the GPU evaluates radial refraction without growing alpha.cloth-bend— top/left/both pin selection, one authored gust, seed, stiffness, damping, folds, perspective, and shadow; fixed-step spring state drives bounded fold/bend sampling.tiled-deformation—gridorhextopology with seed, columns, lift, bevel, perspective, reveal window, and light angle; a deterministic radial field lifts tiles without pointer/hover semantics.heatmap— params{ colors: ['#rrggbb' × 2..10], contour: 0..1, wave: 0..1, angle: 0..360, noise: 0..1, speed: 0..3 }; thermal read of the frame — luminance indexes an N-stop cold→hot gradient cascade, content edges add contour heat, and a heat band travels alongangle, time-driven offctx.timestamp × speed(frame-deterministic, ADR-0012). Adapted from@paper-design/shadersheatmap (Apache-2.0): the gradient cascade, traveling band, and grain hashes are the source's; its CPU-preprocessed glow pipeline and logo choreography are replaced by direct frame-luminance heat (single-pass effect contract).crt-screen— params{ scanlinePitchPx: 2..24, scanlineStrength: 0..1, bloomThreshold: 0..1, bloomStrength: 0..1, vignette: 0..1 }; restrained full-frame terminal glass — static scanline raster, bright-pass phosphor bloom, corner vignette. Mission console, not arcade: no curvature, no mask, no signal artifacts.ntsc-signal— params{ lines: 160..1080, lumaBandwidthMhz: 1..6, chromaBandwidthMhz: 0.1..1.5, separation: 0..1, chromaDelayUs: 0..0.8, phaseJitter: 0..1, ghost: 0..1, ghostDelayUs: 0..3, noise: 0..1, interlace: bool }; the cable/decoder half of the two-stage CRT pipeline — each scanline is re-encoded into an NTSC composite signal (YIQ on the 3.579545 MHz subcarrier, 180° phase flip per line/frame) and decoded back through imperfect FIR filters, so color bleed, dot crawl, rainbowing on fine detail, right-lagging chroma, cable ghosting, and decoder-shaped snow are emergent, not painted. Modeled from the NTSC standard (no ported shader code). Temporal terms ride a deterministic ~30 Hz frame clock offctx.timestamp(ADR-0012);interlaceis the stateless 480i field-bob approximation. Run FIRST in the chain, ahead ofcrt-tube.crt-tube— params{ mask: 'slot'|'shadow'|'grille', maskPitchPx: 3..24, maskStrength: 0..1, lines: 160..1080, focus: 0..1, curvature: 0..1, bezel: 0..1, halation: 0..1, vignette: 0..1, interlace: bool }; the display half — gaussian-beam scanlines resampled to the virtual raster (focusis spot size; bright lines swell), slot/shadow/grille phosphor mask (luminance-compensated: strength is texture, not dimming), barrel curvature, rounded-glass bezel with inner shadow, bright-pass halation. One param surface spans blurry '80s consumer slot-mask to sharp late-era aperture-grille; run it alone (nontsc-signal) for the clean analog-RGB monitor look. Keeplinesmatched withntsc-signal.lines. Distinct register from the restrainedcrt-screen. Also consumed as thecrt-terminalPack'schromerecipe on opaque pieces (atcurvature: 0).
Body text format #
Paragraphs are separated by two or more newlines (\n\n). Every registered Annotation style is a valid bracket tag. Nesting expresses style stacking. Plain [ characters not followed by a recognized tag are preserved as text.
Example (src/lib/presets/research-paper-critique.json):
"body": "We trained the model on the WMT 2014 set.\n\nFor each task we used the [highlight]base Transformer model[/highlight] without tuning, relying on [underline]attention dropout[/underline] and label smoothing instead.\n\nResults on the WMT 2014 [circle]English-to-German[/circle] task are reported using BLEU."
Three marks → three entries in marks.timings. The third timing in that preset carries a "color": "#de263a" override that re-tints the circle mark away from marks.defaults.circle.color.
Validation #
Validation has two ordered layers:
PresetSchemavalidates and transforms the structuralgfx@1JSON shape.validatePresetSemantics(preset)validates registry and cross-domain meaning: the Pack slug; Surface registration/variant; Overlay type/content; post-process and composition Effect type/params; Stage type; substrate asset; Overlay/Effect IDs; text-animation Overlay targets; strict Chart surface/data/domain/target/normalization/fill/motion rules; transition lane; and, when a resolver is supplied, transition Preset references.
parsePreset(value) runs both layers and returns the parsed Preset or throws with a path-qualified multi-line summary. Built-in catalog loading, scripts/verify-presets.ts, and user-composition list/load/create/update paths run the same semantic gate, so unknown primitives or malformed renderer params fail before rendering or persistence. Media validation additionally requires unique asset/clip IDs, resolved clip references, ordered non-overlap, composition bounds, and sufficient Source coverage. User-composition writes require every referenced Media asset to exist and pass the server video probe; unused entries may remain for later use, and stored compositions remain readable if bytes later go missing so the GUI can repair them. applyPreset(preset) clones the parsed state into engineState in place, preserving object identity at the top level.
Standalone interchange uses the wire representation documented here: the GUI's JSON import/export controls and GET/PUT /api/user-compositions/<slug> consume and return this exact shape. Agents should use that API rather than editing the metadata wrappers under user-compositions/. Media asset bytes and volatile probes are never embedded in this JSON. The full GUI, agent, and automated-render workflows are documented in user-composition-workflows.md.