@clipkit/mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3589 @@
1
+ // AUTO-GENERATED — do not edit by hand.
2
+ // Source: scripts/embed-docs.mjs (run via npm run prebuild).
3
+ // Re-run `npm run build` to regenerate after editing the source docs.
4
+ export const AGENTS_MD = `# Clipkit — Authoring guide (AGENTS.md)
5
+
6
+ > This file is the authoring-time context for AI tools (and humans)
7
+ > writing Clipkit videos. Paste it into a system prompt or attach it
8
+ > to a Claude project. For the **formal protocol spec** — element types,
9
+ > field semantics, conformance levels, normative MUST/SHOULD/MAY — see
10
+ > [\`PROTOCOL.md\`](./PROTOCOL.md).
11
+
12
+ Clipkit is a protocol for describing motion-graphics videos as JSON.
13
+ A \`Source\` document is the list of \`elements\` (with timing and
14
+ animations) that a conforming runtime turns into frames or MP4.
15
+
16
+ There are two layers you work with as an author:
17
+
18
+ 1. **Primitives** — the element types defined by the Clipkit Protocol.
19
+ The formal spec is in [PROTOCOL.md](./PROTOCOL.md). Section 1 below
20
+ is a quick cheat sheet so you don't have to bounce out for common
21
+ lookups, but PROTOCOL.md is the source of truth.
22
+ 2. **Patterns** — authoring-time TypeScript helpers in \`@clipkit/patterns\`
23
+ that produce primitive elements. You never reference patterns in
24
+ a Source JSON; you call them in TS/JS and inline their output.
25
+ Patterns are documented in section 2 (Pattern catalog).
26
+
27
+ Section 3 (Recipes) walks through complete example videos showing how
28
+ patterns compose into a full piece.
29
+
30
+ ---
31
+
32
+ ## 0. Which surface to use
33
+
34
+ Before authoring anything, pick the right surface for your context.
35
+ There are two — they layer cleanly, no overlap.
36
+
37
+ | You are running in… | Use… | How |
38
+ |---|---|---|
39
+ | **Claude Code, Cursor, Replit Agent, or any IDE-embedded AI with file + shell access** | \`@clipkit/cli\` | Write files (\`video.ts\` or a JSON source) using this doc as context. Run \`npx @clipkit/cli render\`, \`validate\`, or \`preview\` via Bash. Skip MCP entirely. |
40
+ | **Claude.ai chat, Claude Desktop without local tools, or any chat-mode AI without filesystem access** | \`@clipkit/mcp-server\` | Call the MCP tools — \`set_project\` to build (the full creative canvas), \`create_promo\` to compose from prebuilt scenes, \`add_element\`/\`edit_element\`/\`delete_element\` to tweak, \`preview_still\`/\`validate_project\`/\`describe_project\` to check, \`open_in_editor\`/\`render_video\` to deliver. The server holds state for you. |
41
+ | **A human developer** | \`@clipkit/cli\` | \`npx @clipkit/cli init my-video\`, edit files, \`npx @clipkit/cli render\`. Same path as IDE-embedded AI. |
42
+ | **CI / a build script** | \`@clipkit/cli\` | One-shot \`clipkit render input.json --out out.mp4\`. |
43
+
44
+ A few clarifications agents sometimes need:
45
+
46
+ - **The CLI and the MCP server are not interchangeable.** They target
47
+ different deployments. CLI assumes you have a shell. MCP assumes you
48
+ don't.
49
+ - **If you can run \`Bash\`, you're in CLI territory.** You don't need
50
+ MCP at all — go straight to files + CLI.
51
+ - **There is no "Clipkit Skill."** The skill concept was considered and
52
+ dropped because everything it would do (auto-load this doc, wrap CLI
53
+ commands as slash commands) is already covered by the CLI and by MCP
54
+ resource-loading. If a user asks about installing the skill, redirect
55
+ them to the CLI.
56
+
57
+ Once you know your surface, continue to §1 for the schema cheat sheet.
58
+
59
+ ---
60
+
61
+ ## 1. Schema cheat sheet
62
+
63
+ Authoritative spec: **[PROTOCOL.md](./PROTOCOL.md)** (CKP/1.0). What
64
+ follows is the at-a-glance reference for things you look up constantly.
65
+
66
+ ### Source root
67
+
68
+ \`\`\`json
69
+ {
70
+ "clipkit_version": "1.0",
71
+ "output_format": "mp4",
72
+ "width": 1920, "height": 1080,
73
+ "duration": 30, "frame_rate": 30,
74
+ "background_color": "#000000",
75
+ "elements": [ /* ... */ ]
76
+ }
77
+ \`\`\`
78
+
79
+ Optional root field \`motion_blur: { "samples": 8, "shutter": 0.5 }\`
80
+ adds whole-frame motion blur at render time — the frame becomes the
81
+ exact average of \`samples\` sub-frame renders across a shutter window
82
+ (\`shutter\` = fraction of the frame interval; 0.5 ≈ a film camera's
83
+ 180° shutter). Use it when fast motion strobes: whip-pans, fast
84
+ travel, spinning elements. Renders cost \`samples\`× the frames, so keep
85
+ samples at 8 (default) unless trails visibly stair-step. Previews play
86
+ unblurred; the blur appears in the exported file.
87
+
88
+ ### Element types
89
+
90
+ | \`type\` | Renders | Key fields |
91
+ |---|---|---|
92
+ | \`shape\` | rect / ellipse / rounded rect, **or** vector paths | primitive: \`shape\`, \`fill_color\`, \`gradient\`, \`border_radius\`; path form: \`paths[]\`, \`view_box\`, \`gradients\` |
93
+ | \`text\` | one-line text | \`text\`, \`spans\`, \`font_family\`, \`font_size\`, \`background_color\`, \`text_shadow\`, \`mask\` |
94
+ | \`image\` | bitmap | \`source\`, \`fit\`, \`crop_*\` |
95
+ | \`video\` | video frame at element time | \`source\`, \`volume\`, \`trim_start\`, \`loop\`, \`fit\`, \`crop_*\` |
96
+ | \`audio\` | non-visual; mixed in export | \`source\`, \`volume\` |
97
+ | \`caption\` | word-timed captions | \`words\`, \`style\`, \`highlight_color\` |
98
+ | \`particles\` | ballistic or convergence | \`rate\`/\`burst\`/\`target_points\`, \`lifetime\`, \`velocity\`, \`z_velocity\` |
99
+ | \`group\` | container; transforms/animates children as one | \`elements[]\`, \`clip\`, \`mask\`, \`time_remap\` |
100
+
101
+ A \`shape\` is **either** a primitive (\`shape: "rectangle" | "ellipse"\`, default
102
+ rectangle) **or** vector geometry (\`paths\`) — never both on one element. When
103
+ \`paths\` is present it wins and the primitive fields (\`shape\`, \`fill_color\`,
104
+ \`gradient\`, \`border_radius\`, \`stroke_*\`) are ignored; geometry and fill come
105
+ from inside each path. There is no \`triangle\`/\`polygon\` primitive — use \`paths\`
106
+ (e.g. \`"M ... L ... L ... Z"\`) for triangles and any arbitrary/morphing shape.
107
+
108
+ \`text\` and \`caption\` take \`background_color\` (+ \`background_border_radius\`,
109
+ \`background_padding\` as a number or \`[x, y]\`) for a solid bg drawn as **one
110
+ band per line**, each shrink-wrapped to that line's glyphs (the social-
111
+ caption look) — don't put a separate shape behind text; the integral bg
112
+ auto-sizes to the wrapped / auto-fit text and moves with it.
113
+
114
+ For text shadows: \`text_shadow\` ({\`color\`, \`offset_x\`, \`offset_y\`, \`blur\`,
115
+ \`opacity\`}, or an **array** for stacked / 3D-extrusion shadows) is
116
+ **per-glyph** — each letter casts its own, tracking per-letter animation.
117
+ Use the \`drop_shadow\` *effect* instead for one soft shadow of the whole
118
+ text silhouette.
119
+
120
+ \`image\` and \`video\` take \`fit\` (CSS \`object-fit\`: \`cover\` default,
121
+ \`contain\`, \`fill\`, \`none\`) and a source **crop** — \`crop_x\` / \`crop_y\` /
122
+ \`crop_width\` / \`crop_height\`, normalized \`0..1\` of the source, origin
123
+ top-left, default \`0,0,1,1\` (whole source). Crop selects a sub-rectangle
124
+ of the media BEFORE \`fit\` maps it into the box; the element box is
125
+ unchanged. Each crop component is keyframeable — animate the origin to
126
+ pan and the size to zoom (Ken Burns) without touching layout.
127
+
128
+ There is deliberately NO nested-composition ("pre-comp") element. Reuse
129
+ is an authoring-time concern — component patterns in \`@clipkit/patterns\`
130
+ (section 2) expand into plain elements before serialization. Nested
131
+ timing (speed-ramp / freeze / reverse a whole scene) is \`time_remap\` on
132
+ a plain \`group\`.
133
+
134
+ ### Shared transform fields (every element)
135
+
136
+ \`x\`, \`y\`, \`x_anchor\` (0..1), \`y_anchor\` (0..1), \`width\`, \`height\`,
137
+ \`rotation\` (degrees), \`scale\`, \`opacity\` (0..1 — CSS convention),
138
+ and the CKP/1.0 3D fields \`x_rotation\` / \`y_rotation\` / \`z_rotation\` /
139
+ \`z\` (see "3D transforms" below).
140
+
141
+ All of \`x\` / \`y\` / \`width\` / \`height\` accept numbers (pixels) or
142
+ length strings — \`"50%"\`, \`"10vw"\`, \`"15vh"\`, \`"5vmin"\`, \`"5vmax"\`.
143
+
144
+ > ⚠️ **\`%\` is relative to the CANVAS, not the parent** (unlike CSS).
145
+ > \`"50%"\` on \`x\` means half the canvas width regardless of nesting.
146
+ > \`vw\`/\`vh\`/\`vmin\`/\`vmax\` likewise resolve against the canvas. There is
147
+ > no parent-relative percentage; use pixels inside groups when you need
148
+ > child-relative sizing.
149
+
150
+ ### Coming from CSS/HTML
151
+
152
+ Clipkit deliberately tracks CSS where an agent's CSS prior would
153
+ otherwise misfire, and deliberately diverges where the protocol earns
154
+ it. Aligned (write them like CSS): **\`opacity\`** is \`0..1\` (default 1);
155
+ **gradient \`angle\`** is the CSS convention (\`0deg\` = to top, clockwise,
156
+ so \`90\` = to right, \`180\`/default = to bottom); **colors** accept hex,
157
+ \`rgb()/rgba()\`, \`hsl()/hsla()\`, the 148 CSS named colors, and
158
+ \`transparent\`. Field name map:
159
+
160
+ | CSS | Clipkit | Note |
161
+ |---|---|---|
162
+ | \`left\` / \`top\` | \`x\` / \`y\` | top-left corner, like CSS |
163
+ | \`width\` / \`height\` | \`width\` / \`height\` | px or canvas-% / vw / vh |
164
+ | \`opacity\` | \`opacity\` | \`0..1\`, default 1 |
165
+ | \`border-radius\` | \`border_radius\` | px |
166
+ | \`color\` / \`background\` | \`fill_color\` | SVG-flavored name |
167
+ | \`border\` color | \`stroke_color\` / \`stroke_width\` | |
168
+ | \`z-index\` | \`z\` (+ \`layer\`) | see below |
169
+ | \`transform: rotate()\` | \`rotation\` (= \`z_rotation\`) | degrees |
170
+ | \`transform: scale()\` | \`scale\` / \`x_scale\` / \`y_scale\` | |
171
+ | \`linear-gradient(θ)\` | \`gradient.angle\` | same θ convention |
172
+ | \`transition\` / \`@keyframes\` | \`animations\` / \`keyframe_animations\` | declarative timeline |
173
+
174
+ **Positioning is the CSS model:** \`x\`/\`y\` place the element's **top-left
175
+ corner** (\`x_anchor\`/\`y_anchor\` default \`0\`), exactly like \`left\`/\`top\` /
176
+ SVG / Canvas. Set \`x_anchor: 0.5, y_anchor: 0.5\` to position by center
177
+ (or \`1\` for the far edge). Rotation and scale always pivot the element's
178
+ center — like CSS \`transform-origin\` — independent of the anchor. So a
179
+ full-frame layer is just \`x:0, y:0, width:W, height:H\`.
180
+
181
+ **Layers (required).** Every element gets its own integer \`layer\` (1..1000),
182
+ like an After Effects layer: **layer 1 is on top**, higher numbers go toward
183
+ the back. Give each element a UNIQUE layer — number them 1..N within a container
184
+ (the top-level \`elements\`, each group's \`elements\`, each group mask's
185
+ \`elements\`). Duplicate layers are a validation error. \`z\` depth still wins over
186
+ \`layer\`; \`layer\` orders elements within equal depth.
187
+
188
+ Kept divergences (intentional — don't "fix" them):
189
+ - **\`z\` not \`z_index\`.** \`z\` is the single depth axis (layering +
190
+ perspective foreshortening); \`layer\` orders within equal depth (lower = front). See "3D
191
+ transforms".
192
+ - **snake_case keys** and SVG-flavored \`fill_color\` / \`stroke_color\`.
193
+ - **Easing names are a superset of CSS** (\`ease-out-back\`, springs, …).
194
+
195
+ ### Expressions (procedural motion)
196
+
197
+ A numeric property can be a **formula** instead of a number or a keyframe
198
+ table — a pure function of the element's own clock. Reach for it on the
199
+ motions that are one line as math and a nightmare as keyframes: bobs,
200
+ orbits, spins, handheld shake, staggered reveals.
201
+
202
+ \`\`\`jsonc
203
+ { "y": { "expr": "540 + sin(t * PI) * 30" } } // bob ±30px at 0.5 Hz
204
+ { "rotation": { "expr": "t * 90" } } // 90°/s spin
205
+ { "x": { "expr": "960 + wiggle(3, 12)" } } // handheld shake, ±12px
206
+ { "opacity": { "expr": "clamp(t / 0.4, 0, 1)" } } // 0.4s linear fade-in
207
+ \`\`\`
208
+
209
+ In scope: \`t\` (element-local seconds), \`dur\`, \`i\` (index in a generated
210
+ set), \`n\` (sibling count), \`value\` (the property's base default); the
211
+ constants \`PI\`/\`TAU\`/\`E\`; and a fixed function set (\`sin cos … clamp lerp
212
+ smoothstep linear ease noise wiggle random\`). Operators \`+ - * / % ^\`,
213
+ comparisons, \`&& || !\`, and \`cond ? a : b\`. The full normative grammar is
214
+ PROTOCOL.md §3.6.
215
+
216
+ Use \`i\` for staggered fleets — give every generated element the **same**
217
+ expression and they self-offset by index:
218
+
219
+ \`\`\`jsonc
220
+ { "y": { "expr": "300 + i * 80" }, // stack by index
221
+ "opacity": { "expr": "clamp((t - i * 0.1) / 0.3, 0, 1)" } } // ripple-in
222
+ \`\`\`
223
+
224
+ The rules that keep it safe and portable (all NORMATIVE):
225
+ - **No cross-element references, no runtime inputs.** An \`expr\` sees only
226
+ its own clock — never another element, the mouse, or audio. (Those are
227
+ "Tier-B" and permanently unsupported. If you're tempted, bake the
228
+ relationship into keyframes, or parent the elements.)
229
+ - **Deterministic.** \`noise\`/\`wiggle\`/\`random\` are seeded hashes, not
230
+ wall-clock RNG, so every render is identical; \`seed\` defaults to \`0\`.
231
+ - **Bakeable.** An expression and a \`Keyframe[]\` are interchangeable — the
232
+ importer/editor can sample one into the other.
233
+ - A typo (unknown name, member access, a string) does **not** stop the
234
+ render — the property silently falls back to its base value. So if a
235
+ move "does nothing," check the formula.
236
+
237
+ ### 3D transforms (CKP/1.0)
238
+
239
+ Every element also takes \`x_rotation\` (tip top edge away), \`y_rotation\`
240
+ (turn right edge away), \`z_rotation\` (alias of \`rotation\` — author one
241
+ or the other, both is a validation error), and \`z\` — **the one depth
242
+ axis** (px toward the viewer). \`z\` is also the stacking control: it
243
+ orders elements always (higher \`z\` = in front), and *additionally*
244
+ foreshortens once a camera is present. There is no separate \`z_index\` —
245
+ use \`z\` for layering, \`layer\` orders within equal depth (lower = front, unique per container). Add a
246
+ Source-level camera for true perspective:
247
+
248
+ \`\`\`jsonc
249
+ { "camera": { "perspective": 1200 }, // px; smaller = stronger
250
+ "elements": [{ "type": "group", "clip": true, "y_rotation": -24,
251
+ "width": 620, "height": 420, "elements": [ /* UI mock */ ] }] }
252
+ \`\`\`
253
+
254
+ The tilted-UI promo recipe: put the screenshot/mock in a \`clip: true\`
255
+ group and rotate THE GROUP — the flattened layer projects as one card.
256
+
257
+ The camera also MOVES (CKP/1.0): \`x\`/\`y\`/\`z\` position and \`x_rotation\`/
258
+ \`y_rotation\`/\`z_rotation\` Euler orientation (all keyframeable) orbit,
259
+ pan, tilt, and dolly the viewpoint — not just push/pull on \`perspective\`.
260
+ A "look at the logo" move is authored as resolved orientation angles
261
+ (there is no \`target\` field). Identity pose = the plain perspective lens.
262
+
263
+ GOTCHAS:
264
+ - **Paint order is by \`z\` depth — always.** Sibling cards paint
265
+ back-to-front by \`z\` (nearer = in front), whether or not there's a
266
+ camera; a camera just adds perspective on top. So adding/removing a
267
+ camera never reorders — only the foreshortening appears. With every
268
+ \`z = 0\` it collapses to \`layer\` order (layer 1 on top, like a plain 2D doc).
269
+ Set \`camera.sort: "paint"\` to force fixed \`layer\` order under a camera.
270
+ The sort is whole-card (no per-pixel depth buffer): interpenetrating or
271
+ depth-ambiguous cards can mis-order — keep cards from crossing, or use
272
+ \`sort: "paint"\`.
273
+ - **Animate 3D via \`keyframe_animations\`** (\`property: "y_rotation"\`),
274
+ not a keyframe array in the field itself (same rule as \`rotation\`).
275
+ - **No camera = affine + flat.** 3D rotations still foreshorten (a
276
+ y-rotated card narrows) but edges stay parallel, and \`z\` orders the
277
+ stack without adding perspective; add the camera for converging
278
+ perspective and parallax.
279
+ - **Glass works in 3D** — tilt a glass pane (\`y_rotation\` + camera)
280
+ and the whole optical model rides the plane: footprint, refraction,
281
+ highlights and shadow all project correctly. Signature move: a glass
282
+ panel swinging over a UI card. (Edge-on with no camera = invisible.)
283
+ - **Plain groups nest 3D for free** (children live in the parent's 3D
284
+ space); \`clip\`/\`mask\` groups flatten, then the layer tilts as a unit.
285
+ Filter fields and \`effects\` evaluate on the PROJECTED pixels — glow
286
+ radius / stroke width stay uniform in screen px on a tilted card.
287
+
288
+ 3D text reveals — \`{ "type": "text-flip", "axis": "x" }\` flips each
289
+ letter in from 90° to flat about its own center (AE's classic 3D
290
+ per-character entrance). \`axis: "y"\` swings units in sideways, \`"z"\`
291
+ spins them in-plane; \`split: "word"\` flips whole words as rigid slabs;
292
+ \`rotation\` sets the start angle (try 180 for a full tumble). Composes
293
+ with \`text-slide\` (units slide AND flip). Pair with a \`camera\` for true
294
+ perspective on the mid-flip letters; without one the flip still reads
295
+ (pure foreshortening). Captions don't take text-* animations.
296
+
297
+ ### Lighting + materials (CKP/1.0)
298
+
299
+ Surfaces can respond to light — PBR. **Opt-in:** with no \`lights\` and no
300
+ \`material\`, everything renders unlit exactly as before. Add scene
301
+ \`lights\` + a \`material\` on an element and the runtime shades it (the
302
+ element's own pixels are the albedo).
303
+
304
+ \`\`\`jsonc
305
+ { "lights": [ { "type": "ambient", "intensity": 0.4 },
306
+ { "type": "directional", "azimuth": 45, "elevation": 30, "intensity": 1.2 } ],
307
+ "environment": { "type": "gradient",
308
+ "stops": [ { "offset": 0, "color": "#0b0f1a" },
309
+ { "offset": 1, "color": "#7da2ff" } ] },
310
+ "camera": { "perspective": 1400 },
311
+ "elements": [ { "type": "group", "clip": true, "y_rotation": -20,
312
+ "material": { "roughness": 0.25, "metalness": 0.6, "reflectivity": 1 },
313
+ "elements": [ /* dark UI */ ] } ] }
314
+ \`\`\`
315
+
316
+ - **\`material\`**: \`roughness\` (0 glossy/tight .. 1 matte), \`metalness\`
317
+ (0 dielectric .. 1 metal), \`reflectivity\` (env-reflection strength),
318
+ \`emissive\` (self-lit), \`normal_map\` (tangent-space normal map URL for
319
+ surface detail/bumps — flat = \`#8080ff\`) + \`normal_scale\`. Scalars
320
+ animatable.
321
+ - **The reflection sweeps with the camera.** Specular + environment
322
+ reflection are view-dependent, so orbiting/dollying the camera slides
323
+ the highlight across a glossy dark UI — the premium look. In 2D (no
324
+ camera) animate the **lights** instead for the sweep.
325
+ - **\`environment\`** is what reflective surfaces mirror: either a gradient
326
+ "sky" (\`{ type: 'gradient', stops }\`, offset 0 = down, 1 = up) or an
327
+ **equirectangular image** (\`{ type: 'image', src }\`) for real
328
+ photographic reflections. Roughness blurs the reflection toward the
329
+ environment average. It's what makes glossy screens/metal look
330
+ expensive.
331
+ - **Works on shapes AND textured surfaces** — images, video, and
332
+ flattened \`clip\` groups light from their own pixels, so a whole UI card
333
+ wrapped in a clip group shades as one lit plane. \`@clipkit/patterns\`
334
+ ships \`litSurface()\` for exactly this.
335
+ - **Bloom** (\`source.bloom { threshold, knee, intensity, radius }\`): a
336
+ whole-frame post that makes BRIGHT things bleed light (specular hits,
337
+ emissive surfaces, bright media) — driven by each pixel's brightness,
338
+ not a per-element knob. For a deliberate per-element halo (even on a
339
+ dark element), use the \`glow\` effect instead.
340
+ - **Editor authoring:** Material is a per-element inspector block; Lights,
341
+ Environment, and Bloom live under Video settings (source scope).
342
+ - Local illumination only (no cast shadows yet). Flat 2.5D surfaces.
343
+
344
+ ### Blend modes + filters (every element)
345
+
346
+ \`blend_mode\`: \`"multiply"\` (darken; white neutral), \`"screen"\`
347
+ (lighten; black neutral), \`"add"\` (glow). Element-local. On a group it
348
+ needs \`clip: true\` or a \`mask\` (the group must flatten to a layer).
349
+
350
+ Filters, all animatable, CSS semantics: \`blur_radius\` (Gaussian σ px),
351
+ \`brightness\` / \`contrast\` / \`saturation\` (multipliers, 1 = unchanged,
352
+ \`saturation: 0\` = grayscale). Work on any element including groups —
353
+ a group filters as one flattened image.
354
+
355
+ There is no \`color_overlay\` field — it decomposes: put a same-sized
356
+ shape of the overlay color on a lower layer, in front of the element (use
357
+ \`opacity\` or \`blend_mode: "multiply"\` for tint strength). For a
358
+ grayscale-then-tint look, combine \`saturation: 0\` with an overlay.
359
+
360
+ ### Stylize effects (every element)
361
+
362
+ \`effects\` is an ordered array of passes, applied after the filters:
363
+
364
+ \`\`\`json
365
+ "effects": [{ "type": "pixelate", "cell_size": 12 }]
366
+ \`\`\`
367
+
368
+ Types: \`pixelate\` (\`cell_size\`), \`dither\` (\`levels\`, 2 = 1-bit retro),
369
+ \`halftone\` (\`cell_size\`, \`angle\`), \`ascii\` (\`cell_size\`). Every param
370
+ takes a number or keyframes (element-local time) — animating
371
+ \`cell_size\` from 1 upward makes a great "depixelate" reveal. Effects
372
+ chain in array order and work on groups (the subtree flattens first).
373
+ Layer styles — \`glow\` (\`radius\`, \`intensity\`, \`color\`), \`drop_shadow\`
374
+ (\`offset_x/y\`, \`blur\`, \`color\`, \`opacity\`), \`stroke\` (\`width\`,
375
+ \`color\`) — composite beneath the element and work on text, images,
376
+ and groups (not just shapes).
377
+
378
+ Group time remapping — \`time_remap\` on a GROUP warps the clock for
379
+ everything inside it: slow a whole composed scene to 20% at the
380
+ dramatic beat, freeze it, or run it backwards — children's animations
381
+ and nested videos all follow — and so does AUDIO, varispeed-style
382
+ (ramps pitch with speed, freezes silent, reverse plays backwards).
383
+ The group's own position/opacity still animate on real time, so you
384
+ can move a frozen scene around. Same keyframe shape as video
385
+ time_remap below.
386
+
387
+ Time remapping — \`time_remap\` on a video element maps element-local
388
+ time → media seconds with plain keyframes: \`[{time: 0, value: 0},
389
+ {time: 1, value: 3}]\` is a 3× speed ramp, a flat segment is a freeze
390
+ frame, decreasing values play in reverse, easings make the ramps
391
+ cinematic. It replaces \`trim_start\`/\`playback_rate\`/\`loop\`. Audio
392
+ follows the warp VARISPEED, like tape: ramps pitch up/down with
393
+ speed, freezes go silent, reverse plays the sound backwards — usually
394
+ exactly the cinematic speed-ramp sound you want.
395
+
396
+ GOTCHA: the runtime keys ONE decoder + texture per unique video URL.
397
+ Two video elements with the same URL but different playheads
398
+ (different \`time_remap\`, trims, or rates) will fight over it — all
399
+ copies display the same frames and seeking thrashes. Give each its
400
+ own URL; a query suffix on the same file (\`clip.mp4?copy=2\`) is
401
+ enough.
402
+
403
+ Trim paths — on any shape path: \`trim_start\`/\`trim_end\` draw only that
404
+ slice of the stroke (animate \`trim_end\` 0\\u21921 for the logo-draws-itself
405
+ reveal), \`trim_offset\` rotates the window with wrap (animate it for
406
+ the traveling-dash "snake"). \`stroke_progress\` is the simple sugar.
407
+ Path morphing: keyframe the \`d\` itself — values with the SAME command
408
+ structure (e.g. two cubics with equal point counts) tween smoothly;
409
+ mismatched structures snap. Author morph targets with matching
410
+ commands, like every motion tool expects.
411
+
412
+ Living motion, one line each — \`drift\` (seeded organic float; the
413
+ "alive logo" look: \`{type: "drift", distance: 20}\`), \`breathe\` (gentle
414
+ scale pulse), \`orbit\` (circular motion: \`distance\` radius,
415
+ \`direction: "left"\` for counter-clockwise). All run the element's full
416
+ life by default and are exactly seeded — same seed, same motion. And
417
+ any keyframe_animation can now \`loop: true\` (repeat) or
418
+ \`loop: "ping-pong"\` (back-and-forth) — build one cycle, loop forever.
419
+
420
+ Motion paths — \`keyframe_animations\` with \`property: "position"\` and
421
+ \`[x, y]\` values moves an element along a bezier path: \`out_tangent\` /
422
+ \`in_tangent\` on the keyframes shape the curve (omit them for straight
423
+ polyline hops), \`auto_orient: true\` turns the element to face its
424
+ travel direction. Speed is arc-length-true: linear easing = constant
425
+ speed; easings shape acceleration along the path. Use for swooping
426
+ entrances, orbit flybys, anything that should move like it has
427
+ momentum instead of lerping x and y separately.
428
+
429
+ 3D paths — use \`[x, y, z]\` values (and optionally \`[dx, dy, dz]\`
430
+ tangents) to carve the path through depth; the path then drives \`z\`
431
+ too, and arc-length speed is true in 3D. Don't mix \`[x, y]\` and
432
+ \`[x, y, z]\` keyframes in one path (validation error). Two gotchas:
433
+ path z is invisible without a \`camera\` (same as the \`z\` field), and
434
+ \`auto_orient\` stays in-plane — it spins \`rotation\` from the xy
435
+ direction but never tilts the element's plane (add \`x_rotation\` /
436
+ \`y_rotation\` yourself if you want banking). Signature move: fly an
437
+ element from deep background to right up against the camera along a
438
+ curve.
439
+
440
+ Procedural noise — \`fractal_noise\` (\`scale\`, \`evolution\`, \`offset_x/y\`,
441
+ \`octaves\`, \`seed\`) fills the element's footprint with seeded grayscale
442
+ fBM: animate \`evolution\` for churning fog/plasma, chain \`levels\` to
443
+ shape contrast and a duotone \`lut\` for color (grayscale has no hue, so
444
+ \`hue_rotate\` alone can't tint it), or put it on TEXT for noise-filled
445
+ type. \`turbulent_displace\` (\`amount\`, \`scale\`, \`evolution\`, \`octaves\`,
446
+ \`seed\`) warps the element's own pixels — wavy text, heat shimmer.
447
+ Same seed = same pixels, always (the noise function is normative).
448
+
449
+ Grading — the \`hue_rotate\` filter field (degrees, joins
450
+ blur/brightness/contrast/saturation on every element) plus two
451
+ effects: \`levels\` (\`in_black\`, \`in_white\`, \`gamma\`, \`out_black\`,
452
+ \`out_white\` — punchy shadows = \`in_black: 0.08, gamma: 1.15\`) and
453
+ \`lut\` (\`source\` = a .cube file URL or data: URI, \`intensity\` to dial
454
+ it back). A LUT is the one-liner for a whole color story
455
+ (teal-orange, film stocks); levels is the surgical tool.
456
+
457
+ Keying — \`chroma_key\` (\`color\`, \`tolerance\`, \`softness\`, \`spill\`)
458
+ removes a screen color by chroma distance: put it on a green-screen
459
+ \`video\` element with \`color\` set to the screen's ACTUAL green (sample
460
+ it — real screens are darker than \`#00FF00\`), then place the video
461
+ over any backdrop. \`spill\` (default 0.5) desaturates green bounce on
462
+ the subject. \`luma_key\` (\`threshold\`, \`softness\`, \`invert\`) drops dark
463
+ pixels — the way to lift white-on-black mattes, flares, and smoke
464
+ stock onto a scene. On a group, the composited children key as one
465
+ layer.
466
+
467
+ \`glass\` is the one effect that reads the BACKDROP (everything drawn
468
+ beneath the element). It applies to SHAPE elements only (the pane
469
+ geometry must be exact; other element types skip it with a warning).
470
+ Defaults give the classic clear liquid-glass button — most uses need
471
+ NO params at all:
472
+
473
+ \`\`\`json
474
+ { "type": "shape", "border_radius": 65, "width": 380, "height": 300,
475
+ "fill_color": "#FFFFFF",
476
+ "effects": [{ "type": "glass" }] }
477
+ \`\`\`
478
+
479
+ Two materials, one dial: \`blur_radius: 0\` (default) = CLEAR glass,
480
+ \`blur_radius > 0\` = FROSTED. \`mode: "dome"\` + \`edge_width\` = the
481
+ shape's radius = a half-sphere magnifier:
482
+
483
+ \`\`\`json
484
+ { "id": "magnifier", "type": "shape", "shape": "ellipse",
485
+ "width": 320, "height": 320, "fill_color": "#FFFFFF",
486
+ "effects": [{ "type": "glass", "mode": "dome",
487
+ "edge_width": 160, "refraction": 25 }] }
488
+ \`\`\`
489
+
490
+ Other dials (reference-tuned defaults — change sparingly):
491
+ \`refraction\` (bend strength, ≈px), \`edge_width\` (lens depth; small =
492
+ flat card with curl at the rim), \`edge_highlight\` (the whole light
493
+ rig), \`dispersion\` (color fringing), \`shadow\` (built-in, outside-only
494
+ — never add a shadow sibling under glass), \`backdrop_saturation\`,
495
+ \`tint\`. The pane's fill color is ignored — only its geometry matters.
496
+ A crisp ring is a stroked transparent sibling above; wrap pane + ring
497
+ in an UNLAYERED group (no clip/mask) to move them as one element.
498
+
499
+ ### Easings (the 36)
500
+
501
+ \`linear\`, \`ease\`, \`ease-in\`, \`ease-out\`, \`ease-in-out\`, plus
502
+ ease-{in,out,in-out}-{cubic, quad, quart, quint, sine, expo, circ,
503
+ back}, plus \`spring\` (damped harmonic oscillator — overshoots ~5% then
504
+ settles; Remotion's signature feel), \`elastic-{in,out,in-out}\` (decaying
505
+ sinusoidal wobble), and \`bounce-{in,out,in-out}\` (ball-drop bounces — the
506
+ easing curves, distinct from the bounce-in/out animation PRESETS, which
507
+ are scale tweens). Two parametric forms also count: \`cubic-bezier(x1, y1,
508
+ x2, y2)\` and \`steps(n)\`.
509
+
510
+ Quick guide:
511
+ - **\`spring\`** — hero text reveals, signature scale-ins
512
+ - **\`ease-out-cubic\`** — smooth, well-behaved ramps; default choice
513
+ - **\`ease-out-quart\`** — fast start, long slow tail; great for measure bars
514
+ - **\`ease-out-back\`** — slight overshoot at the end (UI bounce)
515
+ - **\`linear\`** — when you specifically want no easing
516
+
517
+ ### Particles — two modes
518
+
519
+ Ballistic (emit + physics):
520
+
521
+ \`\`\`json
522
+ {
523
+ "type": "particles",
524
+ "x": 540, "y": 960,
525
+ "burst": true, "burst_count": 140,
526
+ "lifetime": 2.5, "velocity": 900, "spread": 360, "gravity": 700,
527
+ "color": ["#22d3ee", "#ec4899", "#22c55e", "#fbbf24"],
528
+ "particle_shape": "square", "size": 18, "rotation_speed": 540,
529
+ "fade_at": 0.8
530
+ }
531
+ \`\`\`
532
+
533
+ Convergence (assemble into a target shape):
534
+
535
+ \`\`\`json
536
+ {
537
+ "type": "particles",
538
+ "x": 960, "y": 540,
539
+ "burst": true, "burst_count": 500,
540
+ "target_points": [[100, 200], [110, 200], /* ... */ ],
541
+ "scatter_radius": 1100,
542
+ "convergence_easing": "ease-out-quart",
543
+ "lifetime": 1.6, "size": 10, "particle_shape": "circle"
544
+ }
545
+ \`\`\`
546
+
547
+ Depth — add \`z_velocity\` (px/s toward the viewer, can be negative) and
548
+ \`z_spread\` (random vz range) to blow particles out of the screen:
549
+ nearer ones grow, receding ones shrink. Needs a Source \`camera\` to be
550
+ visible (no perspective = no depth cue), and pairs beautifully with a
551
+ tilted emitter (\`x_rotation\` on the particles element): confetti
552
+ erupting off a card's surface. Paint order stays spawn order — depth
553
+ never re-sorts.
554
+
555
+ ### Shape paths — vector graphics
556
+
557
+ \`\`\`json
558
+ {
559
+ "type": "shape",
560
+ "view_box": [0, 0, 180, 180],
561
+ "gradients": [
562
+ { "id": "g1", "type": "linear", "x1": 0, "y1": 0, "x2": 180, "y2": 0,
563
+ "stops": [{ "offset": 0, "color": "#FF4E00" }, { "offset": 1, "color": "#FF1791" }] }
564
+ ],
565
+ "paths": [
566
+ { "d": "M 10 10 L 90 90", "stroke": "url(#g1)", "stroke_width": 5,
567
+ "stroke_progress": [{ "time": 0, "value": 0 }, { "time": 1, "value": 1 }] }
568
+ ]
569
+ }
570
+ \`\`\`
571
+
572
+ \`stroke_progress\` from 0→1 makes the path draw in via dashoffset. Use a
573
+ thick stroke on a small circle for pie/donut charts.
574
+
575
+ ### Diagonal text reveal
576
+
577
+ \`\`\`json
578
+ {
579
+ "type": "text", "text": "Vercel and Clipkit", "font_size": 70,
580
+ "mask": {
581
+ "type": "linear-wipe", "angle": -45,
582
+ "progress": [{ "time": 0, "value": 0 }, { "time": 2.7, "value": 1, "easing": "ease-out-cubic" }],
583
+ "softness": 0.3
584
+ }
585
+ }
586
+ \`\`\`
587
+
588
+ That's the at-a-glance set. For full normative semantics — units, edge
589
+ cases, conformance — go to [PROTOCOL.md](./PROTOCOL.md).
590
+
591
+ ## 2. Pattern catalog
592
+
593
+ Patterns live in \`@clipkit/patterns\`. Each is a TS function that emits
594
+ plain elements — most return \`Element[]\` you spread into your \`elements\`
595
+ array; *component* patterns (IntroCard, LowerThird) return a single
596
+ \`group\` element you push, so the unit moves / animates / time-remaps as
597
+ one. They're opinionated: pick a variant by passing a \`color\` slot
598
+ ("pink" | "green" | "blue" | "lavender" | "purple" | "yellow" | "gray")
599
+ and a \`theme\` (\`'mux'\` is the default; \`'minimal'\` exists too).
600
+
601
+ Common params shared by most patterns:
602
+
603
+ - \`id\`: string — prefix for produced element ids
604
+ - \`theme?: 'mux' | 'minimal'\` — default \`'mux'\`
605
+ - \`color\`: ColorName — picks the accent palette
606
+ - \`time\`, \`duration\`: number — scene-local seconds
607
+ - \`layerBase\`: number — pattern uses a small range starting here
608
+
609
+ ### HeaderBar
610
+
611
+ The scene framing for dashboard-style scenes: white header strip with
612
+ left logo + middle title + right date, colored body fill below.
613
+
614
+ \`\`\`ts
615
+ headerBar({
616
+ id: 'overall',
617
+ title: 'Overall stats',
618
+ dateRange: 'Nov. 17 - Dec. 16 2021',
619
+ bodyColor: 'pink',
620
+ canvasWidth: 1920,
621
+ canvasHeight: 1080,
622
+ logo: { viewBox: [0, 0, 215, 70], paths: MUX_LOGO_PATHS, gradients: [MUX_GRADIENT] },
623
+ time: 4.33,
624
+ duration: 6.0,
625
+ layerBase: 100,
626
+ })
627
+ \`\`\`
628
+
629
+ Use as the first elements of every scene that follows the "logo + title"
630
+ convention.
631
+
632
+ ### StatBlock
633
+
634
+ Themed top border, big spring-counted number, label, optional trend pill.
635
+
636
+ \`\`\`ts
637
+ statBlock({
638
+ id: 'views',
639
+ current: 195654112,
640
+ previous: 159560036, // optional — adds the trend pill
641
+ label: 'Total views',
642
+ color: 'pink',
643
+ x: 120, y: 380,
644
+ width: 1680,
645
+ time: 4.33, duration: 6.0,
646
+ layerBase: 200,
647
+ })
648
+ \`\`\`
649
+
650
+ Stack two with \`y\` 340 px apart for a "Total views / Total minutes watched"
651
+ layout.
652
+
653
+ ### BarChartRow
654
+
655
+ Row with an animated white background bar, top border, value count-up,
656
+ optional icon, label, optional trend pill. Used for "Top 5 by X" lists.
657
+
658
+ \`\`\`ts
659
+ barChartRow({
660
+ id: 'phone',
661
+ value: 130733672,
662
+ max: 130733672, // the leading value in the dataset
663
+ previous: 133478441, // optional — adds trend pill
664
+ label: 'Phone',
665
+ icon: { viewBox: [0, 0, 32, 52], paths: PHONE_ICON_PATHS },
666
+ color: 'green',
667
+ x: 120, y: 320,
668
+ width: 1680,
669
+ time: 10.33, duration: 6.0,
670
+ staggerIndex: 0, // 0..n for cascading entry
671
+ layerBase: 400,
672
+ })
673
+ \`\`\`
674
+
675
+ Loop over your dataset, increment \`y\` by \`168\` and \`staggerIndex\` by 1
676
+ per row.
677
+
678
+ ### RankedList
679
+
680
+ Numbered list of items with rank, name, value, animated measure bar.
681
+ 1 or 2 columns. Used for "Top 10" style scenes.
682
+
683
+ \`\`\`ts
684
+ rankedList({
685
+ id: 'titles',
686
+ items: [
687
+ { label: 'Burgandy Alert', value: 2733413 },
688
+ { label: 'Tough Affection', value: 1694421 },
689
+ // ...
690
+ ],
691
+ color: 'lavender',
692
+ x: 120, y: 320,
693
+ width: 1680,
694
+ columns: 2,
695
+ time: 16.33, duration: 6.0,
696
+ layerBase: 600,
697
+ })
698
+ \`\`\`
699
+
700
+ ### PieCard
701
+
702
+ Animated pie chart (white slice growing over the colored body), big
703
+ percentage label, count-up view count, optional trend pill, optional
704
+ logo on a rounded white square, bottom label. One card per data point;
705
+ position multiple horizontally for a "Top 4 X" scene.
706
+
707
+ \`\`\`ts
708
+ pieCard({
709
+ id: 'chrome',
710
+ value: 2233793,
711
+ total: 3396911, // dataset total — drives the percentage
712
+ previous: 2337628, // optional — adds trend pill
713
+ label: 'Chrome',
714
+ logoUrl: '/chrome.png', // optional — image over the white square
715
+ color: 'blue',
716
+ cx: 300, // center x of the card
717
+ time: 28.33, duration: 6.0,
718
+ staggerIndex: 0,
719
+ layerBase: 1000,
720
+ })
721
+ \`\`\`
722
+
723
+ ### IntroCard (component)
724
+
725
+ Full-frame opening title: themed backdrop, optional all-caps kicker,
726
+ big headline, accent rule wipe, optional subtitle. Entrance + exit
727
+ animation built in. Returns ONE \`group\` element — position, animate, or
728
+ \`time_remap\` the whole card as a unit.
729
+
730
+ \`\`\`ts
731
+ elements.push(introCard({
732
+ id: 'open',
733
+ kicker: 'Mux Data',
734
+ headline: '2021 in review',
735
+ subtitle: 'Your year of video, in numbers',
736
+ color: 'pink',
737
+ canvasWidth: 1920, canvasHeight: 1080,
738
+ time: 0, duration: 4,
739
+ layer: 100,
740
+ }))
741
+ \`\`\`
742
+
743
+ Note components return a single \`Element\` — \`push\`, don't spread.
744
+
745
+ ### LowerThird (component)
746
+
747
+ Broadcast-style name strip: accent bar + white panel + bold name +
748
+ optional role line. Slides in from the left, fades out at the end of
749
+ its window. Returns ONE \`group\` element.
750
+
751
+ \`\`\`ts
752
+ elements.push(lowerThird({
753
+ id: 'speaker',
754
+ name: 'Dana Cruz',
755
+ role: 'Head of Video',
756
+ color: 'blue',
757
+ x: 80, y: 900, // left edge / vertical center
758
+ time: 12, duration: 5,
759
+ layer: 300,
760
+ }))
761
+ \`\`\`
762
+
763
+ ### TiltedShowcase (component)
764
+
765
+ The promo signature shot: a screenshot in a browser-chrome frame,
766
+ tilted in 3D (CKP/1.0) and gently swinging. Returns ONE \`clip: true\`
767
+ group, so the framed card projects as a unit. Pair with a Source-level
768
+ \`camera: { perspective: 1200 }\` for converging perspective.
769
+
770
+ \`\`\`ts
771
+ elements.push(tiltedShowcase({
772
+ id: 'app-shot',
773
+ source: '/screens/dashboard.png',
774
+ color: 'blue',
775
+ x: 960, y: 540, // card center
776
+ width: 760, height: 500,
777
+ tilt: 22, // swings ±22°; 0 = static
778
+ z: 40,
779
+ time: 2, duration: 6,
780
+ layer: 200,
781
+ }))
782
+ \`\`\`
783
+
784
+ ### cameraOrbit (scene camera)
785
+
786
+ Unlike the others, this returns a \`Camera\` for \`source.camera\` (not an
787
+ element): a ready-made keyframed move — orbit (\`yaw\`), constant \`pitch\`
788
+ tilt, \`dolly\` (z), \`truck\` (x). Place content at varied \`z\` for parallax;
789
+ depth-correct occlusion is automatic (§4.4.3).
790
+
791
+ \`\`\`ts
792
+ const source = {
793
+ camera: cameraOrbit({ perspective: 1500, yaw: 40, pitch: 6, dolly: 120, duration: 5 }),
794
+ elements: [ /* cards at different z */ ],
795
+ };
796
+ \`\`\`
797
+
798
+ ### TrendPill (helper)
799
+
800
+ Small pill rendering a signed percentage delta. Used internally by
801
+ StatBlock, BarChartRow, PieCard, but exported so you can place one
802
+ standalone.
803
+
804
+ \`\`\`ts
805
+ trendPill({
806
+ id: 'standalone-trend',
807
+ cx: 1500, cy: 540,
808
+ delta: trendPct(2233793, 2337628), // → -4.4
809
+ color: 'pink',
810
+ time: 4.33, duration: 6.0,
811
+ layerBase: 250,
812
+ })
813
+ \`\`\`
814
+
815
+ ---
816
+
817
+ ## 3. Recipe gallery
818
+
819
+ Example videos demonstrating patterns in context. Source files live in
820
+ \`apps/playground/src/\`:
821
+
822
+ - **\`mux-demo.ts\`** — \`MUX_DEMO\`. 1920×1080, 39 s, 7 scenes (intro, big
823
+ stats, devices, top-10 titles, US heatmap, browsers, outro). The
824
+ full-fidelity reference; every pattern in section 2 shows up here.
825
+ - **\`examples.ts\`** — \`VERCEL_TEMPLATE\`. 1280×720, 6.7 s. Showcases the
826
+ SVG stroke-evolution primitive (Next.js logo paths drawing in), the
827
+ text linear-wipe mask, and keyframe-animated rings.
828
+ - **\`examples.ts\`** — \`CODE_WRAPPED\`. 9:16, 15 s. Particle confetti
829
+ bursts at celebration moments, gradient backgrounds, spring stat
830
+ reveals — the github-unwrapped-style template.
831
+
832
+ When you author a new video, lean on these as references. If a scene
833
+ "looks like X scene in mux-demo," call the same pattern with the same
834
+ shape of args.
835
+
836
+ ### Captions from media (transcription)
837
+
838
+ To caption a video or audio clip, you don't hand-write \`words\` — transcribe
839
+ the media into a \`caption\` element. Runs Whisper **locally** (no API key, no
840
+ upload) via \`@clipkit/speech-to-text\`; needs \`ffmpeg\` on the host.
841
+
842
+ - **CLI** (local files): \`clipkit transcribe <file>\` prints the caption
843
+ \`words[]\` JSON to stdout (progress on stderr). Add \`--element\` for a full
844
+ \`caption\` element ready to drop into a Source, \`--model Xenova/whisper-small\`
845
+ for accuracy or \`…-tiny.en\` for speed, \`--out cap.json\` to write a file.
846
+ - **MCP** (chat-mode agents): the \`transcribe_media\` tool takes a local
847
+ \`path\`, transcribes, and adds a \`caption\` element to the current project
848
+ (\`add: false\` to only return it).
849
+
850
+ Both produce the same thing: a \`caption\` element with \`words: [{ text, start,
851
+ end }]\` (times relative to the caption's \`time\`). The protocol/runtime are
852
+ unchanged — this only *fills* the caption the renderer already supports. Style
853
+ it with the usual \`caption\` fields (\`style\`, \`highlight_color\`, …).
854
+
855
+ ---
856
+
857
+ ## 4. Authoring guidance
858
+
859
+ A few rules of thumb that go beyond the schema:
860
+
861
+ - **Lead with patterns; reach for primitives only for one-offs.** If a
862
+ scene fits HeaderBar + BarChartRow, do that. Don't reinvent.
863
+ - **Stagger entrances.** For lists, use \`staggerIndex\` (or compute a
864
+ \`time: time + i*0.07\` offset for primitives). All-at-once entrances
865
+ look amateurish.
866
+ - **Sound matters.** Set up an \`audio\` element early. The runtime mixes
867
+ it into the export and the playground plays it during preview.
868
+ - **Pace by frames at 30 fps.** Most scenes are 180 frames = 6 s. Title
869
+ intros are 100–130 frames. Outros 120–150. Big stat reveals 180.
870
+ - **Trend pills are optional.** Only add \`previous\` to a pattern if the
871
+ comparison is genuinely interesting; otherwise let the number land
872
+ on its own.
873
+
874
+ ---
875
+
876
+ ## 5. Output format
877
+
878
+ Whatever you generate, the final output the user runs is a Clipkit
879
+ \`Source\` JSON object. Patterns are TypeScript-only — they help you
880
+ construct that JSON but never appear in it.
881
+
882
+ If you're emitting code for the user:
883
+
884
+ \`\`\`ts
885
+ import type { Source } from '@clipkit/protocol';
886
+ import { headerBar, statBlock /* ... */ } from '@clipkit/patterns';
887
+
888
+ export const MY_VIDEO: Source = {
889
+ width: 1920, height: 1080, duration: 30, frame_rate: 30,
890
+ elements: [
891
+ ...headerBar({ ... }),
892
+ ...statBlock({ ... }),
893
+ // ...
894
+ ],
895
+ };
896
+ \`\`\`
897
+
898
+ If you're emitting raw JSON (no TS), inline the patterns' output
899
+ directly. There's no \`{ "type": "stat_block" }\` — only the primitive
900
+ elements the patterns produce.
901
+
902
+ ---
903
+
904
+ ## Appendix — Protocol change checklist (maintainers)
905
+
906
+ > **This section is for editing the protocol itself, not for authoring
907
+ > videos.** If you're writing a Source, ignore it. If you're adding or
908
+ > changing an element type, field, effect, animation preset, output
909
+ > format, etc., work this list — a protocol change "bleeds down" into
910
+ > more places than it looks.
911
+
912
+ **The schema source of truth (\`packages/protocol/src/\`) — all manual:**
913
+
914
+ - \`types.ts\` — the interface / const enum (\`ELEMENT_TYPES\`,
915
+ \`ANIMATION_TYPES\`, \`OUTPUT_FORMATS\`, the effect union, …).
916
+ - \`zod.ts\` — the matching validator. Const-driven enums sync from the
917
+ arrays; a new interface needs a new schema object. **Put the field's
918
+ default in its \`.describe()\` text** — that string is the only place a
919
+ default is declared protocol-side. It's surfaced to agents via
920
+ \`get_schema\` → \`zodToJsonSchema\`, so the wording an agent reads ("…
921
+ (default 1)") comes straight from here. Keep it honest against the
922
+ runtime.
923
+ - \`CLIPKIT_PROTOCOL_VERSION\` in \`types.ts\` — bump only on a real spec
924
+ change.
925
+
926
+ There is **no central defaults table.** (A former \`defaults.ts\` /
927
+ \`applyDefaults()\` was dead code — never imported or called — and has
928
+ been deleted; don't re-add it.) The *effective* default for a field is
929
+ the inline \`?? fallback\` in whichever runtime renderer reads it
930
+ (\`packages/runtime/src/compositor/element-renderers/*.ts\`, e.g.
931
+ \`fillColor = span.fill_color ?? defaults.color\` in \`text.ts\`) — one
932
+ fallback per field, at its point of use. So a new field with a default
933
+ means **two** edits that must agree: the runtime's \`??\` fallback, and
934
+ the \`.describe()\` text in \`zod.ts\` that documents it.
935
+
936
+ **Runtime (\`packages/runtime/src/\`):** a new field on an existing
937
+ element auto-works if the renderer already reads it. A new **element
938
+ type** needs a renderer in \`compositor/element-renderers/\`; a new
939
+ **effect type** needs handling in the effect chain; a new **animation
940
+ preset** needs a handler in \`animation/presets.ts\`.
941
+
942
+ **Editor inspector — mostly automatic.** The registry deriver
943
+ (\`editor-core/src/registry/derive.ts\`) reads the zod schema by
944
+ structure, so a new field gets a working control with no edits. Only
945
+ touch \`registry/overrides.ts\` when the auto-derived control is wrong
946
+ (range, label, section, or it fell back to a raw JSON chip). Verify
947
+ with \`node probes/probe-editor-registry.mjs\` — it asserts every field
948
+ has exactly one knob and lists anything that fell through.
949
+
950
+ **Docs (all hand-written, no codegen):**
951
+
952
+ - \`PROTOCOL.md\` — the formal spec.
953
+ - This file (\`AGENTS.md\`) — authoring patterns, if it's a preset/effect.
954
+ - \`apps/web/content/docs/fields/*.md\` — per-element field reference
955
+ (separate from PROTOCOL.md; easy to miss).
956
+ - \`packages/mcp-server/src/embedded-docs.ts\` — a COPY of the agent
957
+ guidance baked into the MCP server; it does **not** auto-sync with
958
+ this file, so it drifts silently.
959
+
960
+ **Agent / MCP surface:** \`packages/mcp-server/src/tools.ts\` —
961
+ tool input schemas are hand-written. A new top-level Source field or a
962
+ new \`output_format\` needs a parameter added here. Element-field CRUD
963
+ validates against zod automatically.
964
+
965
+ **Situational:** \`apps/web/lib/playground/examples.ts\` (showcase the
966
+ new feature) and \`packages/snapshot-importer/\` (if it should map from
967
+ imported HTML/CSS).
968
+
969
+ The genuinely forgettable manual ones: **the runtime \`??\` fallback that
970
+ sets the field's default, the matching \`.describe()\` default text in
971
+ \`zod.ts\`, the \`content/docs/fields/*.md\` reference, and the MCP
972
+ \`embedded-docs.ts\` copy** — then run the registry probe.
973
+ `;
974
+ export const PROTOCOL_MD = `# The Clipkit Protocol
975
+
976
+ **Version:** 1.0 (CKP/1.0)
977
+ **Status:** Draft
978
+ **License:** Apache-2.0
979
+
980
+ This document specifies the Clipkit Protocol — the JSON-based interchange
981
+ format for describing motion-graphics videos. Documents that conform to
982
+ this protocol can be rendered by any conforming runtime: the reference
983
+ implementation \`@clipkit/runtime\`, future server-side renderers, or
984
+ third-party implementations.
985
+
986
+ The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY**,
987
+ and **REQUIRED** in this document are to be interpreted as described in
988
+ [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
989
+
990
+ The reference implementation lives in [\`packages/protocol\`](./packages/protocol)
991
+ of this repository.
992
+
993
+ ---
994
+
995
+ ## Table of contents
996
+
997
+ 1. [Introduction](#1-introduction)
998
+ 2. [Document structure](#2-document-structure)
999
+ 3. [Coordinate system, units, and types](#3-coordinate-system-units-and-types)
1000
+ 4. [The element model](#4-the-element-model)
1001
+ 5. [Element types](#5-element-types)
1002
+ 6. [Animation](#6-animation)
1003
+ 7. [Time, duration, sequencing](#7-time-duration-sequencing)
1004
+ 8. [Asset references](#8-asset-references)
1005
+ 9. [Output and rendering](#9-output-and-rendering)
1006
+ 10. [Conformance levels](#10-conformance-levels)
1007
+ 11. [Versioning and extensions](#11-versioning-and-extensions)
1008
+ 12. [Implementation notes](#12-implementation-notes-non-normative)
1009
+
1010
+ ---
1011
+
1012
+ ## 1. Introduction
1013
+
1014
+ ### 1.1. Goals
1015
+
1016
+ The Clipkit Protocol aims to be:
1017
+
1018
+ - **JSON-native.** A Clipkit document is plain JSON. Any tool that can
1019
+ write JSON can produce one. AI agents in particular benefit from this
1020
+ — they emit structured data better than they emit code.
1021
+ - **Renderer-agnostic.** The protocol describes *what* a video looks
1022
+ like, not *how* to render it. A conforming runtime may use WebGPU,
1023
+ WebGL2, software rasterization, server-side headless browsers, or any
1024
+ other mechanism.
1025
+ - **Composable.** Documents are built from a small set of primitive
1026
+ element types. Higher-level patterns are authoring concerns — they
1027
+ produce primitive elements; the protocol itself has no notion of
1028
+ "pattern."
1029
+ - **Deterministic.** Given the same document and the same time, every
1030
+ conforming runtime MUST produce the same frame composition. Exact
1031
+ pixel output may differ between rasterization backends, but the
1032
+ scene description is unambiguous.
1033
+
1034
+ ### 1.2. Non-goals
1035
+
1036
+ - **Visual editing format.** Editors MAY store additional state alongside
1037
+ a Source (selection, undo history, asset binaries) but that state is
1038
+ outside this protocol.
1039
+ - **Media container.** Clipkit references external media (images, video,
1040
+ audio) by URL or path. The protocol does not embed binary media.
1041
+ - **Audio mixing graph.** Audio elements are positioned in time; the
1042
+ exact mix algorithm (gains, codecs, bitrates) is implementation-defined.
1043
+
1044
+ ### 1.3. Reference implementation
1045
+
1046
+ \`@clipkit/protocol\` is the canonical TypeScript + Zod implementation of
1047
+ this protocol. Other implementations SHOULD treat it as authoritative on
1048
+ ambiguous points until those points are resolved here.
1049
+
1050
+ ---
1051
+
1052
+ ## 2. Document structure
1053
+
1054
+ ### 2.1. The Source object
1055
+
1056
+ A Clipkit document is a JSON object with the following shape:
1057
+
1058
+ \`\`\`json
1059
+ {
1060
+ "clipkit_version": "1.0",
1061
+ "output_format": "mp4",
1062
+ "width": 1920,
1063
+ "height": 1080,
1064
+ "duration": 30,
1065
+ "frame_rate": 30,
1066
+ "background_color": "#000000",
1067
+ "elements": [ /* ... */ ]
1068
+ }
1069
+ \`\`\`
1070
+
1071
+ #### Fields
1072
+
1073
+ | Field | Type | Required? | Default | Meaning |
1074
+ |---|---|---|---|---|
1075
+ | \`clipkit_version\` | string | SHOULD | \`"1.0"\` | The protocol version this document targets. See §11. |
1076
+ | \`output_format\` | string | MAY | \`"mp4"\` | One of \`"mp4"\` (video) or \`"gif"\` (animated). Clipkit is video-only. |
1077
+ | \`width\` | integer | SHOULD | \`1920\` | Composition width in pixels. MUST be positive. |
1078
+ | \`height\` | integer | SHOULD | \`1080\` | Composition height in pixels. MUST be positive. |
1079
+ | \`duration\` | number \\| \`"auto"\` | SHOULD | \`"auto"\` | Total composition duration in seconds. \`"auto"\` MUST be interpreted as the maximum end-time of any active element. |
1080
+ | \`frame_rate\` | number | SHOULD | \`30\` | Frames per second. MUST be positive. |
1081
+ | \`background_color\` | string | MAY | \`"#000000"\` | Color (§3.4) the frame is cleared to before any element draws. Absent → opaque black. |
1082
+ | \`motion_blur\` | object | MAY | — | Whole-frame motion blur by exact sub-frame supersampling. See "Motion blur" below. |
1083
+ | \`camera\` | object | MAY | — | Scene camera (CKP/1.0): perspective lens + movable pose (position/orientation) + \`sort\`. Absent = identity = exact 2D. See §4.4.2, §4.4.3. |
1084
+ | \`fonts\` | array | MAY | — | Font faces the renderer MUST register before rendering. See "Fonts" below. |
1085
+ | \`elements\` | array | REQUIRED | — | At least one element. See §4–5. |
1086
+
1087
+ #### Motion blur
1088
+
1089
+ \`motion_blur\` is an object with two optional fields:
1090
+
1091
+ | Field | Type | Default | Meaning |
1092
+ |---|---|---|---|
1093
+ | \`samples\` | integer 1–32 | \`8\` | Sub-frame samples rendered per output frame. \`1\` disables blur. |
1094
+ | \`shutter\` | number (0, 1] | \`0.5\` | Fraction of the frame interval the shutter is open. \`0.5\` corresponds to a 180° film shutter. |
1095
+
1096
+ When present with \`samples\` ≥ 2, the renderer MUST produce each output
1097
+ frame as the arithmetic mean of \`samples\` full-scene renders taken at
1098
+ sub-frame times centered on the frame time. For the output frame at
1099
+ time \`t\` with frame rate \`f\` and composition duration \`D\`:
1100
+
1101
+ \`\`\`
1102
+ t_k = clamp(t + ((k + 0.5) / samples − 0.5) × shutter / f, 0, D)
1103
+ for k = 0 … samples−1
1104
+ \`\`\`
1105
+
1106
+ Each sample is a complete render of the composition at \`t_k\` under the
1107
+ normal rendering model (every animated value, transition, and effect is
1108
+ evaluated at \`t_k\`). The output pixel is the per-channel arithmetic
1109
+ mean of the \`samples\` sample pixels in the output color space (8-bit
1110
+ sRGB, after compositing over the opaque background), rounded once,
1111
+ half away from zero. Accumulation MUST be carried at a precision that
1112
+ makes the result exact before that single rounding (e.g. float
1113
+ accumulation of 8-bit samples).
1114
+
1115
+ This is deterministic: the same document produces the same pixels.
1116
+ Media elements evaluate \`t_k\` through their own media-time mapping
1117
+ (§5.3.2); a video whose decoder quantizes to its own frame times
1118
+ contributes the same decoded frame to several samples, which is
1119
+ conformant.
1120
+
1121
+ Motion blur applies at export/render time. Interactive previews MAY
1122
+ render the unblurred scene (a single sample at \`t\`) for speed and MUST
1123
+ NOT be treated as the reference output when \`motion_blur\` is present.
1124
+
1125
+ #### Fonts
1126
+
1127
+ \`fonts\` is an array of font-face objects. The renderer MUST register
1128
+ every face before the first frame renders, so a document carrying its
1129
+ fonts is self-sufficient — text metrics don't depend on what the host
1130
+ environment happens to have installed.
1131
+
1132
+ | Field | Type | Required? | Default | Meaning |
1133
+ |---|---|---|---|---|
1134
+ | \`family\` | string | REQUIRED | — | The \`font_family\` name text elements reference. |
1135
+ | \`weight\` | number \\| string | MAY | \`"normal"\` | CSS font-weight. Variable fonts MAY declare a range (e.g. \`"100 900"\`). |
1136
+ | \`style\` | string | MAY | \`"normal"\` | \`"normal"\` or \`"italic"\`. |
1137
+ | \`src\` | string | REQUIRED | — | URL of the font bytes: absolute, relative (resolved against the document hosting the Source), or a \`data:\` URI. |
1138
+ | \`unicode_range\` | string | MAY | — | CSS unicode-range (e.g. \`"U+0000-00FF, U+0131"\`). Subsetted webfonts ship one file per script under identical \`family\`/\`weight\`/\`style\`; without the range every subset matches every codepoint, and the winning file may lack the glyphs being rendered. Renderers MUST honor it when matching glyphs to faces. |
1139
+
1140
+ Multiple entries MAY share a \`family\` (different weights, styles, or
1141
+ unicode-range subsets). Renderers MUST treat each entry as a distinct
1142
+ face, exactly as CSS treats multiple \`@font-face\` rules.
1143
+
1144
+ ### 2.2. Forward compatibility
1145
+
1146
+ Documents MAY contain additional top-level fields not listed here.
1147
+ Conforming runtimes MUST ignore unknown top-level fields (passthrough).
1148
+ This applies to all objects in this protocol unless otherwise stated.
1149
+
1150
+ ---
1151
+
1152
+ ## 3. Coordinate system, units, and types
1153
+
1154
+ ### 3.1. Coordinate system
1155
+
1156
+ - The origin \`(0, 0)\` is at the **top-left** of the composition.
1157
+ - The positive **x** axis runs **right**.
1158
+ - The positive **y** axis runs **down**.
1159
+ - Positions, sizes, and offsets are in pixels relative to the composition
1160
+ width / height unless an explicit unit suffix is used (§3.3).
1161
+
1162
+ ### 3.2. Anchors
1163
+
1164
+ Most elements support \`x_anchor\` and \`y_anchor\` values in \`[0, 1]\`.
1165
+ These define which point inside the element's bounding box is positioned
1166
+ at \`(x, y)\`:
1167
+
1168
+ - \`0\` = left edge (or top edge) — **the default**
1169
+ - \`0.5\` = center
1170
+ - \`1\` = right edge (or bottom edge)
1171
+
1172
+ By default both anchors are \`0\`, so \`(x, y)\` is the element's **top-left
1173
+ corner** — the CSS \`left\`/\`top\` / SVG / Canvas convention. For example,
1174
+ \`x: 960\` (anchor \`0\`) places the element's left edge at x=960;
1175
+ \`x: 960, x_anchor: 0.5\` places its center at x=960. The anchor only
1176
+ moves where the box sits — rotation and scale always pivot the element's
1177
+ geometric center (§4.4), independent of the anchor.
1178
+
1179
+ ### 3.3. Length values
1180
+
1181
+ Wherever a position or dimension is accepted, a string with one of the
1182
+ following unit suffixes MAY be used:
1183
+
1184
+ | Unit | Meaning |
1185
+ |---|---|
1186
+ | \`"100px"\` | pixels (same as bare number \`100\`) |
1187
+ | \`"50%"\` | percentage of the property's natural reference (width → composition width; etc.) |
1188
+ | \`"10vw"\` | percentage of composition width |
1189
+ | \`"15vh"\` | percentage of composition height |
1190
+ | \`"5vmin"\` | percentage of the smaller composition dimension |
1191
+ | \`"5vmax"\` | percentage of the larger composition dimension |
1192
+
1193
+ A bare number is interpreted as pixels.
1194
+
1195
+ > **Divergence from CSS:** \`%\` and the viewport units resolve against the
1196
+ > **composition (canvas)**, never the parent element. \`"50%"\` is always
1197
+ > half the canvas, even on a child nested inside a group. There is no
1198
+ > parent-relative percentage; use pixels for child-relative sizing inside
1199
+ > groups.
1200
+
1201
+ ### 3.4. Color values
1202
+
1203
+ Colors are CSS-style strings. The reference runtime accepts:
1204
+ - hex — \`"#rgb"\`, \`"#rgba"\`, \`"#rrggbb"\`, \`"#rrggbbaa"\`
1205
+ - \`rgb()\` / \`rgba()\` — comma or space separated, alpha as 0..1 or \`%\`
1206
+ - \`hsl()\` / \`hsla()\` — same separators, optional \`deg\` on the hue
1207
+ - the 148 CSS named colors (\`"red"\`, \`"rebeccapurple"\`, …) and
1208
+ \`"transparent"\`
1209
+
1210
+ Unrecognized strings fall back to white. (Conformance: only hex support
1211
+ is REQUIRED of a runtime; the rest are RECOMMENDED for CSS parity.)
1212
+
1213
+ Internally, all colors flow through rendering as straight-alpha sRGB,
1214
+ premultiplied at the final composition step. Authors do not need to
1215
+ think about this; it is mentioned to document the reference runtime's
1216
+ behavior.
1217
+
1218
+ ### 3.5. Keyframe values
1219
+
1220
+ Many properties accept either a static value or a \`Keyframe[]\` array
1221
+ for animation. The keyframe form is:
1222
+
1223
+ \`\`\`json
1224
+ [
1225
+ { "time": 0, "value": 0, "easing": "ease-out-cubic" },
1226
+ { "time": 1.0, "value": 100, "easing": "ease-out-cubic" }
1227
+ ]
1228
+ \`\`\`
1229
+
1230
+ \`time\` is in seconds, relative to the element's \`time\` (see §7).
1231
+ \`value\` can be a number, a string, or \`[x, y]\` for position-like
1232
+ properties. \`easing\` is OPTIONAL; see §6.4.
1233
+
1234
+ ### 3.6. Expression values
1235
+
1236
+ Any **numeric** property (transform, \`opacity\`, blur/\`brightness\`/
1237
+ \`contrast\`/\`saturation\`, effect params, camera/light numbers — every
1238
+ property whose type admits a number) MAY instead be given as an
1239
+ **expression object**: a closed-form, deterministic function of the
1240
+ element's own clock.
1241
+
1242
+ \`\`\`json
1243
+ { "y": { "expr": "540 + sin(t * PI) * 30" } }
1244
+ { "rotation": { "expr": "t * 90" } }
1245
+ { "x": { "expr": "960 + wiggle(3, 12)" } }
1246
+ \`\`\`
1247
+
1248
+ An expression is a **pure function of element-local time and the
1249
+ element's own index** — nothing else. This is the entire safety
1250
+ boundary, and it is NORMATIVE:
1251
+
1252
+ - It MUST NOT reference any other element (\`ref()\`, \`valueAtTime\`) or
1253
+ read any runtime input (mouse, audio). Those are Tier-B and are
1254
+ permanently unsupported.
1255
+ - It MUST be deterministic: every conforming runtime MUST produce
1256
+ identical results for the same expression and clock. \`noise\`,
1257
+ \`wiggle\`, and \`random\` derive from the protocol's normative
1258
+ value-noise hash (bit-stable across runtimes; seed defaults to \`0\`,
1259
+ never to wall-clock).
1260
+ - Because it is a function of \`t\`/\`i\`, it is **bakeable**: a runtime or
1261
+ tool MAY sample it to a \`Keyframe[]\` at any frame rate. A keyframe
1262
+ table and an expression are two encodings of the same value.
1263
+
1264
+ **Scope** — the only identifiers in scope:
1265
+
1266
+ | Variable | Meaning |
1267
+ |---|---|
1268
+ | \`t\` | element-local time, seconds (\`0\` at the element's \`time\`) |
1269
+ | \`dur\` | element duration, seconds |
1270
+ | \`i\` | element index within its generated/particle set (default \`0\`) |
1271
+ | \`n\` | sibling count (default \`1\`) |
1272
+ | \`value\` | the property's base value (its documented default) |
1273
+
1274
+ Constants: \`PI\`, \`TAU\`, \`E\`. Functions (the ONLY callable identifiers):
1275
+
1276
+ \`\`\`
1277
+ sin cos tan asin acos atan atan2 sinh cosh tanh
1278
+ abs sign sqrt cbrt pow exp log log2 floor ceil round trunc fract
1279
+ min max mod hypot
1280
+ clamp(x,lo,hi) lerp(a,b,u) mix(=lerp) step(edge,x) smoothstep(e0,e1,x)
1281
+ linear(x,x0,x1,y0,y1) // map x∈[x0,x1] → [y0,y1], clamped
1282
+ ease(x,x0,x1,y0,y1) // same, cubic in-out
1283
+ noise(x[,seed]) // value noise, ∈[-1,1]
1284
+ wiggle(freq,amp[,seed]) // amp · fractal noise(t·freq)
1285
+ random(seed) // deterministic [0,1) hash, time-independent
1286
+ \`\`\`
1287
+
1288
+ Operators: \`+ - * / % ^\` (\`^\` = exponent, right-assoc), unary \`-\`,
1289
+ comparisons \`< > <= >= == !=\`, logical \`&& || !\`, and the ternary
1290
+ \`cond ? a : b\`. Nothing else — no member access, assignment, indexing,
1291
+ statements, or string values.
1292
+
1293
+ **Evaluation (NORMATIVE).** A conforming runtime MUST evaluate
1294
+ expressions with a restricted parser/evaluator, NOT a general
1295
+ code-execution facility (\`eval\` / \`Function\`). Any unknown identifier or
1296
+ function, member access, assignment, or string literal is a parse error;
1297
+ a parse error or a non-finite (\`NaN\` / \`±∞\`) result MUST fall back to the
1298
+ property's base value. Expressions are numeric-only in this version;
1299
+ string/text expressions are reserved.
1300
+
1301
+ ---
1302
+
1303
+ ## 4. The element model
1304
+
1305
+ Every element extends a common base:
1306
+
1307
+ \`\`\`ts
1308
+ interface BaseElement {
1309
+ id?: string;
1310
+ name?: string;
1311
+ type: ElementType; // discriminator
1312
+ layer: number; // REQUIRED, unique per container; 1..1000; LOWER draws in front (layer 1 = on top)
1313
+ time?: number | string; // seconds
1314
+ duration?: number | string | "auto" | "end";
1315
+
1316
+ // Transform (numbers OR length strings OR Keyframe[])
1317
+ x?: number | string | Keyframe[];
1318
+ y?: number | string | Keyframe[];
1319
+ x_anchor?: number | string;
1320
+ y_anchor?: number | string;
1321
+ width?: number | string | Keyframe[];
1322
+ height?: number | string | Keyframe[];
1323
+ rotation?: number | Keyframe[]; // degrees, around center
1324
+ scale?: number | Keyframe[];
1325
+
1326
+ // Visual
1327
+ opacity?: number | Keyframe[]; // 0..1 (CSS convention), default 1
1328
+ visible?: boolean; // false skips rendering (§4.2)
1329
+ blend_mode?: 'normal' | 'multiply' | 'screen' | 'add' | 'overlay' | 'hard-light' | 'soft-light'; // §4.5
1330
+ blur_radius?: number | Keyframe[]; // Gaussian σ in px (§4.6)
1331
+ brightness?: number | Keyframe[]; // multiplier, 1 = unchanged (§4.6)
1332
+ contrast?: number | Keyframe[]; // multiplier, 1 = unchanged (§4.6)
1333
+ saturation?: number | Keyframe[]; // multiplier, 1 = unchanged (§4.6)
1334
+ hue_rotate?: number | Keyframe[]; // degrees, default 0 (§4.6)
1335
+ effects?: Effect[]; // ordered stylize passes (§4.7)
1336
+ material?: Material; // PBR material; only shaded under scene lights (§4.8)
1337
+
1338
+ // Animation
1339
+ animations?: Animation[];
1340
+ keyframe_animations?: KeyframeAnimation[];
1341
+ }
1342
+ \`\`\`
1343
+
1344
+ ### 4.1. The \`type\` discriminator
1345
+
1346
+ \`type\` is REQUIRED and identifies which variant the object is. Conforming
1347
+ runtimes MUST recognize the values defined in §5. Documents containing
1348
+ unknown \`type\` values are valid documents; runtimes MAY skip such
1349
+ elements with a warning rather than failing the whole document.
1350
+
1351
+ ### 4.2. Draw order
1352
+
1353
+ Every element owns a **\`layer\`** — a unique integer 1..1000 within its
1354
+ container (the top-level \`elements\`, each group's \`elements\`, each group
1355
+ mask's \`elements\`), like an After Effects layer. **Lower \`layer\` draws
1356
+ in front; layer 1 is on top.** Elements draw back-to-front by **depth**
1357
+ (\`z\`, §4.4), then by \`layer\`:
1358
+
1359
+ \`\`\`
1360
+ draw_key = (depth descending, i.e. farther first), then layer descending
1361
+ (highest layer drawn first/behind, layer 1 drawn last/on top)
1362
+ \`\`\`
1363
+
1364
+ \`z\` is the single depth axis (§4.4.2): with no camera it is pure
1365
+ stacking order (no perspective), with a camera it additionally
1366
+ foreshortens. \`layer\` orders elements *within* equal depth — when depths
1367
+ are equal (e.g. a 2D document where every \`z\` is 0), draw order is
1368
+ exactly layer descending, so layer 1 ends up on top. \`layer\` is
1369
+ **required and unique per container** (duplicate layers are a validation
1370
+ error), so no two elements tie; the sort MUST nonetheless be stable. The
1371
+ same rule applies to a group's children, locally within the group.
1372
+ (There is no separate \`z_index\` field — depth ordering is unified onto
1373
+ \`z\`. \`camera.sort: 'paint'\` opts a camera composition back into fixed
1374
+ \`layer\` order; see §4.4.3.)
1375
+
1376
+ Elements with \`visible: false\` MUST NOT be rendered. Inactive elements
1377
+ (§7.1) MUST NOT be rendered.
1378
+
1379
+ ### 4.3. Anchors and bounding boxes
1380
+
1381
+ The element's bounding box is \`width × height\` pixels, with its
1382
+ anchor point at \`(x, y)\`. The visual center MUST be computed
1383
+ from the anchor:
1384
+
1385
+ \`\`\`
1386
+ center_x = x + (0.5 - x_anchor) * width
1387
+ center_y = y + (0.5 - y_anchor) * height
1388
+ \`\`\`
1389
+
1390
+ Rotation rotates the element around its center.
1391
+
1392
+ ### 4.4. Transform composition
1393
+
1394
+ Every element supports, in addition to position and rotation:
1395
+
1396
+ | Field | Type | Default | Meaning |
1397
+ |---|---|---|---|
1398
+ | \`scale\` | number | \`1\` | Uniform scale factor. |
1399
+ | \`x_scale\`, \`y_scale\` | number \\| \`"N%"\` | \`1\` | Per-axis scale factors; \`"150%"\` ≡ \`1.5\`. |
1400
+ | \`x_skew\`, \`y_skew\` | number (degrees) | \`0\` | Shear, CSS \`skewX\`/\`skewY\` semantics: positive \`x_skew\` moves the bottom edge right; positive \`y_skew\` moves the right edge down. |
1401
+
1402
+ Since CKP/1.0, every element also supports a 3D transform:
1403
+
1404
+ | Field | Type | Default | Meaning |
1405
+ |---|---|---|---|
1406
+ | \`z_rotation\` | number (degrees) \\| Keyframe[] | \`0\` | Rotation in the element's plane. Exact alias for \`rotation\` — authoring BOTH on one element MUST be rejected by validators. |
1407
+ | \`x_rotation\` | number (degrees) \\| Keyframe[] | \`0\` | Rotation around the element's local x axis; positive tips the top edge away from the viewer. |
1408
+ | \`y_rotation\` | number (degrees) \\| Keyframe[] | \`0\` | Rotation around the element's local y axis; positive turns the right edge away from the viewer. |
1409
+ | \`z\` | number (px) \\| Keyframe[] | \`0\` | Depth toward (+) / away from (−) the viewer. The depth axis for §4.2 draw order: higher \`z\` draws nearer the viewer (on top), with \`layer\` ordering elements within equal depth (lower layer in front). Under a camera it additionally drives perspective foreshortening; with no camera it is pure stacking. |
1410
+
1411
+ All are animatable. The effective axis scales are
1412
+ \`sx = scale × x_scale\`, \`sy = scale × y_scale\`.
1413
+
1414
+ #### 4.4.1. The local matrix (normative)
1415
+
1416
+ An element's local transform is the 4×4 matrix, in pixels, with the
1417
+ anchor-derived center \`(cx, cy)\` (§4.3) as the pivot:
1418
+
1419
+ \`\`\`
1420
+ M_local = T(cx, cy, z) · Rz(z_rotation) · Ry(y_rotation) · Rx(x_rotation)
1421
+ · K(x_skew, y_skew) · S(sx, sy, 1) · T(−cx, −cy, 0)
1422
+ \`\`\`
1423
+
1424
+ where \`Rx/Ry/Rz\` are the standard right-handed rotation matrices in
1425
+ the protocol's coordinate system (x right, y DOWN, z toward the
1426
+ viewer; positive \`z_rotation\` is therefore clockwise on screen, as in
1427
+ CKP/1.0), and \`K\` embeds the 2D shear \`[[1, tan(x_skew)],
1428
+ [tan(y_skew), 1]]\` (CSS \`skew(x, y)\` semantics) in the xy plane. With
1429
+ \`x_rotation = y_rotation = z = 0\` this reduces exactly to the CKP/1.0
1430
+ composition **scale → shear → rotate** around the anchor-derived
1431
+ center; documents without 3D fields MUST render identically under
1432
+ both models.
1433
+
1434
+ Group transforms stack multiplicatively onto children:
1435
+ \`M = M_parent · M_local\`, evaluated in the group's local space, and
1436
+ opacities multiply down the group chain. Children of a group that
1437
+ does NOT rasterize to a layer (no \`clip\`, \`mask\`, filter fields, or
1438
+ \`effects\` — see §4.4.3) therefore live in the parent's 3D space:
1439
+ nested 3D rotations compose, with no opt-in flag.
1440
+
1441
+ #### 4.4.2. The camera
1442
+
1443
+ A Source MAY declare one scene camera — a perspective lens (\`perspective\`,
1444
+ \`origin_x\`, \`origin_y\`) plus an optional rigid **pose** (a position and a
1445
+ look orientation) that moves the viewpoint through the scene:
1446
+
1447
+ \`\`\`ts
1448
+ camera?: {
1449
+ perspective: number | Keyframe[]; // focal distance in px, > 0
1450
+ origin_x?: number | string; // default "50%" (canvas center)
1451
+ origin_y?: number | string; // default "50%"
1452
+
1453
+ // Eye position offset from the default eye, px, about the origin. Default 0.
1454
+ x?: number | Keyframe[]; // +x right
1455
+ y?: number | Keyframe[]; // +y down
1456
+ z?: number | Keyframe[]; // +z = eye toward the scene (dolly in)
1457
+
1458
+ // Eye orientation, degrees, Euler (applied Rz·Ry·Rx). Default 0.
1459
+ x_rotation?: number | Keyframe[]; // pitch (tilt)
1460
+ y_rotation?: number | Keyframe[]; // yaw (pan)
1461
+ z_rotation?: number | Keyframe[]; // roll
1462
+
1463
+ sort?: 'depth' | 'paint'; // compositing order, §4.4.3. Default 'depth'.
1464
+ }
1465
+ \`\`\`
1466
+
1467
+ The lens follows CSS Transforms Module Level 2 \`perspective()\`
1468
+ semantics: with \`d = perspective\` and origin \`(ox, oy)\`,
1469
+
1470
+ \`\`\`
1471
+ P = T(ox, oy, 0) · [ 1 0 0 0 ; 0 1 0 0 ; 0 0 1 0 ; 0 0 −1/d 1 ] · T(−ox, −oy, 0)
1472
+ \`\`\`
1473
+
1474
+ The pose is the **inverse of the camera's rigid world transform**, taken
1475
+ about the origin so it composes with the lens. With eye position
1476
+ \`(x, y, z)\` and rotation \`R = Rz·Ry·Rx\`,
1477
+
1478
+ \`\`\`
1479
+ V = T(ox, oy, 0) · R⁻¹ · T(−x, −y, −z) · T(−ox, −oy, 0)
1480
+ \`\`\`
1481
+
1482
+ and the camera matrix applied once at the root is
1483
+
1484
+ \`\`\`
1485
+ camera = P · V
1486
+ \`\`\`
1487
+
1488
+ so every element renders through \`P · V · M_chain · M_local\`, followed
1489
+ by the perspective divide. Orientation is given as explicit Euler angles
1490
+ rather than a look-at target: a target would derive orientation from
1491
+ geometry (hidden runtime math); a "look at this point" gesture is an
1492
+ authoring convenience that resolves to these angles, not a schema field.
1493
+
1494
+ **Identity reduction (normative).** With the pose at its defaults
1495
+ (\`x=y=z=0\`, all rotations \`0\`), \`V = I\` and \`camera = P\` **bit-for-bit**:
1496
+ a document that uses only \`perspective\`/origin renders identically to a
1497
+ pre-pose runtime. Smaller \`d\` = stronger foreshortening; \`perspective\`
1498
+ is animatable (camera push/pull), as are all pose fields (orbit, pan,
1499
+ tilt, dolly).
1500
+
1501
+ **No camera ⇒ camera = I.** 3D rotations still render (affine
1502
+ foreshortening — a y-rotated card narrows but its edges stay parallel).
1503
+ \`z\` has no *perspective* effect without a camera, but it still **orders**
1504
+ (§4.2, §4.4.3): \`z\` is the single depth axis, so without a camera it acts
1505
+ as pure stacking order. A document with no 3D fields (all \`z = 0\`) and no
1506
+ camera renders as pure layer stacking — equal depth collapses to \`layer\`
1507
+ order (descending, so layer 1 is on top).
1508
+
1509
+ #### 4.4.3. Compositing under 3D (normative)
1510
+
1511
+ - **Paint order — depth (2.5D).** \`z\` is the single depth axis and it
1512
+ orders **always**: each sibling draw list (the top-level elements, and
1513
+ the children of each non-flattened group) is painted **back-to-front
1514
+ by depth** — the eye-space \`z\` of the sibling's anchor (its \`z\` with no
1515
+ camera; \`z\` after \`P · V\` under a camera), far cards first. With **no
1516
+ camera** this is pure stacking with no perspective; with a **camera**
1517
+ it is the same ordering plus foreshortening. This is whole-card
1518
+ (per-element) 2.5D sorting — flat cards ordered by distance — NOT a
1519
+ per-pixel depth buffer. The sort is **stable**: equal depths break by
1520
+ \`layer\` order (descending — layer 1 drawn last/on top), so a document
1521
+ where every \`z = 0\` collapses to exact \`layer\` order, and the same
1522
+ Source always yields the same pixels. A flattened group (clip / mask / filters /
1523
+ effects) is a single card and sorts by its own anchor depth as a
1524
+ unit; the §4.4.3 flattening rule means its children are already
1525
+ coplanar inside the flat layer and keep their in-layer order. An
1526
+ element's own internal quads (a particle system's particles, a text
1527
+ element's glyphs) are likewise NOT reached by this sort — they follow
1528
+ their own documented order (e.g. particles in spawn order, §5.13).
1529
+ **Limitation (normative, not a bug):** because the unit of sorting is
1530
+ the whole card, cards that interpenetrate, or whose anchor-depth order
1531
+ disagrees with their true per-pixel order, can be ordered "wrong" at
1532
+ some camera angles. There is no per-pixel resolution in the 2.5D
1533
+ model. \`sort: 'paint'\` opts a camera composition back into fixed
1534
+ \`layer\` order for authors who want explicit, camera-stable
1535
+ layering. There is no depth buffer in the rendering model.
1536
+ - **Flattening at layer boundaries.** A group with \`clip: true\` or
1537
+ \`mask\` renders its children in its own flat 2D layer space exactly
1538
+ as in CKP/1.0, and the finished layer's quad is then transformed by
1539
+ the full matrix chain. 3D declared INSIDE such a subtree composes
1540
+ only within that flat layer's plane; 3D declared ON or ABOVE it
1541
+ projects the layer as a unit. (This is how a clipped UI-mock group
1542
+ tilts as one card.)
1543
+ - **Effect surfaces are screen-space.** Filter fields (\`blur_radius\`,
1544
+ \`brightness\`, \`contrast\`, \`saturation\`, \`hue_rotate\`) and \`effects\`
1545
+ entries evaluate on the element's PROJECTED rendering: the element
1546
+ (or group subtree) draws with its full transform — including 3D and
1547
+ the camera — into a surface-sized layer, and the filter/effect chain
1548
+ runs on those screen-space pixels. This matches CKP/1.0, where the
1549
+ element's own transform is likewise baked into its effect surface
1550
+ before filtering, and means e.g. a glow's radius or a stroke's width
1551
+ is uniform in screen pixels on a tilted card. A shape's native
1552
+ \`shadow\` foreshortens with the element's plane, but its
1553
+ \`offset_x\`/\`offset_y\` translate in the PARENT plane — consistent
1554
+ with CKP/1.0, where a rotated shape's shadow offset stays
1555
+ screen-aligned.
1556
+ - **Glass.** Glass is legal under 3D. The pane is a true plane in the
1557
+ scene: pane-local coordinates come from the inverse of the pane's
1558
+ plane homography (the restriction of the full §4.4 matrix chain to
1559
+ the pane's plane — equivalent to exact per-fragment ray/plane
1560
+ intersection), the §4.7 optical model runs unchanged in that local
1561
+ frame, and refracted sample points map FORWARD through the
1562
+ homography onto the screen-space backdrop snapshot. See §4.7 "Glass
1563
+ under 3D" for the normative model and degenerate cases. With no 3D
1564
+ on the element or its un-flattened chain, the orthographic CKP/1.0
1565
+ path applies bit-for-bit.
1566
+ - **Anti-aliasing.** SDF edge anti-aliasing remains derivative-based
1567
+ and scales naturally under projection; the §1 cross-backend
1568
+ tolerance language applies unchanged.
1569
+
1570
+ ### 4.5. Blend modes
1571
+
1572
+ \`blend_mode\` selects how the element's pixels combine with the pixels
1573
+ already drawn beneath it. It is **element-local**: it changes only this
1574
+ element's compositing math and MUST NOT alter how any other element
1575
+ renders. With premultiplied sources:
1576
+
1577
+ | Value | Color math | Character |
1578
+ |---|---|---|
1579
+ | \`normal\` (default) | \`out = src + dst·(1 − src.a)\` | Standard over. |
1580
+ | \`multiply\` | \`out = src·dst + dst·(1 − src.a)\` | Darkens; white is neutral; uncovered pixels leave the destination unchanged. |
1581
+ | \`screen\` | \`out = src + dst·(1 − src)\` | Lightens; black is neutral. |
1582
+ | \`add\` | \`out = src + dst\` | Linear dodge; overlaps sum toward white (glow). |
1583
+ | \`overlay\` | \`B(cb,cs)\` = \`2·cb·cs\` if \`cb ≤ 0.5\` else \`1 − 2·(1−cb)·(1−cs)\` | Multiply in dark backdrop areas, screen in light ones; boosts contrast. |
1584
+ | \`hard-light\` | \`B(cb,cs)\` = overlay with source and backdrop swapped | Like shining a harsh light through the source. |
1585
+ | \`soft-light\` | \`B(cb,cs)\` per W3C soft-light | A gentler \`hard-light\` (soft dodge/burn). |
1586
+
1587
+ The blend function \`B(cb, cs)\` operates on **straight-alpha** (un-
1588
+ premultiplied) backdrop \`cb\` and source \`cs\` per channel; the result
1589
+ composites via the general separable formula
1590
+ \`co = αs·(1−αb)·cs + αs·αb·B(cb,cs) + (1−αs)·αb·cb\`, with
1591
+ \`αo = αs + αb·(1 − αs)\`. For \`normal\`/\`multiply\`/\`screen\`/\`add\` this
1592
+ reduces to the closed forms above, expressible with fixed-function
1593
+ blending. \`overlay\`/\`hard-light\`/\`soft-light\` are **piecewise on the
1594
+ backdrop (or source)** and cannot be; a conforming runtime isolates
1595
+ the element to its own layer and composites it against a snapshot of
1596
+ the backdrop. The alpha channel always composites normally, so
1597
+ coverage is unaffected by the mode.
1598
+
1599
+ On a \`group\`, \`blend_mode\` applies when the group's flattened layer is
1600
+ composited — which only exists when the group is layered via
1601
+ \`clip: true\` or \`mask\` (§5.8). On an unlayered group the field MUST be
1602
+ ignored (children draw directly to the parent surface with their own
1603
+ modes); runtimes SHOULD warn. Children inside a layered group
1604
+ composite against each other inside the layer, isolated from the
1605
+ backdrop — matching CSS \`isolation: isolate\` semantics.
1606
+
1607
+ ### 4.6. Filters
1608
+
1609
+ Four element-local filter fields, all animatable, all following CSS
1610
+ \`filter\` function semantics:
1611
+
1612
+ | Field | Default | Meaning |
1613
+ |---|---|---|
1614
+ | \`blur_radius\` | \`0\` | Gaussian blur; the value is the standard deviation σ in canvas pixels (CSS \`blur(σ)\`). |
1615
+ | \`brightness\` | \`1\` | Color multiplier: \`c' = c × v\` (CSS \`brightness(v)\`). |
1616
+ | \`contrast\` | \`1\` | Scale around mid-gray: \`c' = (c − 0.5) × v + 0.5\` (CSS \`contrast(v)\`). |
1617
+ | \`saturation\` | \`1\` | Lerp against Rec. 709 luma: \`c' = mix(luma(c), c, v)\`; \`0\` = grayscale (CSS \`saturate(v)\`). |
1618
+ | \`hue_rotate\` | \`0\` | Hue rotation by \`v\` DEGREES: \`c' = M(v) × c\` with the SVG \`feColorMatrix type="hueRotate"\` matrix below (CSS \`hue-rotate(v)\`). |
1619
+
1620
+ The \`hue_rotate\` matrix, NORMATIVE, with \`cosθ\`/\`sinθ\` of the angle:
1621
+
1622
+ \`\`\`
1623
+ M = [ 0.213+0.787cosθ−0.213sinθ 0.715−0.715cosθ−0.715sinθ 0.072−0.072cosθ+0.928sinθ ]
1624
+ [ 0.213−0.213cosθ+0.143sinθ 0.715+0.285cosθ+0.140sinθ 0.072−0.072cosθ−0.283sinθ ]
1625
+ [ 0.213−0.213cosθ−0.787sinθ 0.715−0.715cosθ+0.715sinθ 0.072+0.928cosθ+0.072sinθ ]
1626
+ \`\`\`
1627
+
1628
+ Blur evaluation is a NORMATIVE downsample ladder (so identical sources
1629
+ produce identical pixels everywhere): bilinearly halve the image until
1630
+ the residual \`σ / f ≤ 4\` (\`f\` a power of two, max 16), apply a
1631
+ 25-tap Gaussian (taps at \`σ/f ÷ 4\` spacing over ±3σ, weights
1632
+ \`exp(−d²/2σ²)\` normalized by their sum) horizontally then vertically
1633
+ at the reduced size, and bilinearly upsample at the consuming draw.
1634
+ Sparse full-resolution taps are not an acceptable substitute — they
1635
+ leave a visible σ/4-pixel grid on hard edges.
1636
+
1637
+ A filtered element — any type, \`group\` included, layered or not — is
1638
+ rendered with its normal transform into a transparent offscreen layer,
1639
+ then that layer is composited back through the filter. Filters MUST
1640
+ apply in the order **blur → brightness → contrast → saturation →
1641
+ hue_rotate**, and
1642
+ the color ops MUST operate on straight (unpremultiplied) color so
1643
+ translucent pixels don't skew toward the contrast midpoint. Channel
1644
+ results clamp to [0, 1] before re-premultiplying; the alpha channel is
1645
+ never changed by color ops.
1646
+
1647
+ Filters are element-local: the blur may bleed past the element's box
1648
+ (like CSS \`filter: blur()\`) but never reads or alters other elements'
1649
+ pixels. The element's \`opacity\` applies inside the layer and its
1650
+ \`blend_mode\` applies at the filter composite, so all three features
1651
+ compose. On a group, filtering flattens the subtree first — children
1652
+ are filtered as one image, not individually.
1653
+
1654
+ ### 4.7. Stylize effects
1655
+
1656
+ \`effects\` is an ordered array of stylize passes over the element's
1657
+ rendered pixels, applied AFTER the filter fields:
1658
+
1659
+ \`\`\`
1660
+ layer → blur → brightness → contrast → saturation → hue_rotate → effects[0] → … → effects[n]
1661
+ \`\`\`
1662
+
1663
+ \`\`\`json
1664
+ { "effects": [{ "type": "pixelate", "cell_size": 12 }] }
1665
+ \`\`\`
1666
+
1667
+ Each effect is an object discriminated by \`type\`. Effect params accept
1668
+ \`number | Keyframe[]\`; keyframes evaluate against element-local time.
1669
+ (Effect params are NOT addressable from \`keyframe_animations\` — there
1670
+ is no property-path syntax into the array.) Like filters, effects are
1671
+ element-local, work on every element type (a \`group\` flattens its
1672
+ subtree first), and the element's \`blend_mode\` applies at the final
1673
+ composite. Runtimes encountering an effect \`type\` they don't implement
1674
+ MUST skip that effect (rendering the element without it) and SHOULD
1675
+ warn.
1676
+
1677
+ **Effects read only the element's own rendered pixels — with ONE
1678
+ exclusion: \`glass\`.** Glass additionally reads the element's
1679
+ *backdrop*: the current surface's pixels at the element's position in
1680
+ draw order (§4.2), i.e. everything drawn before it on the same
1681
+ surface. It gets this carve-out because the effect is widely known and
1682
+ in high demand, and there is no proper decomposition — refraction
1683
+ needs the pixels behind the pane — and the alternative, a first-class
1684
+ glass element type, is deliberately not part of this protocol. No
1685
+ other effect type reads the backdrop, and backdrop-sampling blend
1686
+ modes (overlay, soft-light) remain excluded (§4.5). Note the
1687
+ relationship stays one-way: glass READS what is beneath it but never
1688
+ alters how any other element renders.
1689
+
1690
+ Pixel-grid coordinates below are the element's layer pixels at output
1691
+ resolution; cells are aligned to the layer's origin. Color math runs
1692
+ on straight (unpremultiplied) color; "ink" factors scale color and
1693
+ alpha together (premultiplied output).
1694
+
1695
+ #### \`pixelate\`
1696
+
1697
+ | Param | Default | Meaning |
1698
+ |---|---|---|
1699
+ | \`cell_size\` | \`8\` (min 1) | Cell size in canvas pixels. |
1700
+
1701
+ Every pixel takes the color sampled at its cell's center:
1702
+ \`out(p) = src((floor(p / cell) + 0.5) × cell)\`.
1703
+
1704
+ #### \`dither\`
1705
+
1706
+ | Param | Default | Meaning |
1707
+ |---|---|---|
1708
+ | \`levels\` | \`4\` (min 2) | Quantization levels per color channel. |
1709
+
1710
+ Ordered dithering with the 4×4 Bayer matrix
1711
+ \`[0 8 2 10; 12 4 14 6; 3 11 1 9; 15 7 13 5]\`:
1712
+ \`t = (B[y mod 4][x mod 4] + 0.5) / 16\`, then per channel
1713
+ \`c' = clamp(floor(c × (levels−1) + t) / (levels−1), 0, 1)\`.
1714
+ Alpha (coverage) is not dithered.
1715
+
1716
+ #### \`halftone\`
1717
+
1718
+ | Param | Default | Meaning |
1719
+ |---|---|---|
1720
+ | \`cell_size\` | \`8\` (min 2) | Dot-grid cell size in canvas pixels. |
1721
+ | \`angle\` | \`45\` | Grid rotation in degrees. |
1722
+
1723
+ In grid space (pixel coords rotated by \`angle\`), each cell draws a dot
1724
+ at its center, colored with the source sample at that center and sized
1725
+ by its luminance: \`luma = Rec709(c) × α\`, \`r = 0.5 × cell × √luma\`
1726
+ (area-proportional ink). Pixel ink is
1727
+ \`(1 − smoothstep(r−1, r+1, d)) × clamp(r, 0, 1)\` where \`d\` is the
1728
+ grid-space distance to the dot center; outside dots the output is
1729
+ transparent.
1730
+
1731
+ #### \`ascii\`
1732
+
1733
+ | Param | Default | Meaning |
1734
+ |---|---|---|
1735
+ | \`cell_size\` | \`12\` (min 4) | Glyph cell size in canvas pixels. |
1736
+
1737
+ Each cell samples its center color; \`luma = Rec709(c) × α\` selects a
1738
+ glyph by \`i = clamp(floor(luma × 10), 0, 9)\` from the ten-step density
1739
+ ramp \`space . - : = + % * @ #\`. The glyph tints with the cell's color;
1740
+ uninked pixels are transparent. Glyph shapes are NORMATIVE — the
1741
+ embedded 8×8 bitmap font below (one byte per row, MSB = leftmost
1742
+ pixel), upscaled nearest-neighbor to the cell — so the effect never
1743
+ depends on platform fonts:
1744
+
1745
+ | Glyph | Rows (hex) |
1746
+ |---|---|
1747
+ | space | \`00 00 00 00 00 00 00 00\` |
1748
+ | \`.\` | \`00 00 00 00 00 18 18 00\` |
1749
+ | \`-\` | \`00 00 00 7E 00 00 00 00\` |
1750
+ | \`:\` | \`00 18 18 00 00 18 18 00\` |
1751
+ | \`=\` | \`00 00 7E 00 7E 00 00 00\` |
1752
+ | \`+\` | \`00 18 18 7E 18 18 00 00\` |
1753
+ | \`%\` | \`00 C6 CC 18 30 66 C6 00\` |
1754
+ | \`*\` | \`00 66 3C FF 3C 66 00 00\` |
1755
+ | \`@\` | \`7C C6 DE DE DE C0 78 00\` |
1756
+ | \`#\` | \`6C 6C FE 6C FE 6C 6C 00\` |
1757
+
1758
+ #### \`glow\`, \`drop_shadow\`, \`stroke\` (layer styles)
1759
+
1760
+ Layer styles composite BENEATH the element's own pixels (premultiplied
1761
+ under-operator: \`out = content + style × (1 − content.α)\`), on any
1762
+ element type.
1763
+
1764
+ | Effect | Params (defaults) | Math |
1765
+ |---|---|---|
1766
+ | \`glow\` | \`radius\` 20, \`intensity\` 1, \`color\` \`"#FFFFFF"\` | silhouette alpha blurred by σ = radius (§4.6 ladder), × intensity (clamped to 1), × color. |
1767
+ | \`drop_shadow\` | \`offset_x\` 0, \`offset_y\` 12, \`blur\` 18, \`color\` \`"#000000"\`, \`opacity\` 0.6 | silhouette alpha blurred by σ = blur, sampled at p − offset, × color × opacity. |
1768
+ | \`stroke\` | \`width\` 4, \`color\` \`"#FFFFFF"\` | outline band outside the silhouette: max alpha over a 16-tap ring of radius = width, × color. |
1769
+
1770
+ Numeric params are animatable; they chain in array order like all
1771
+ effects (a drop_shadow listed before a glow renders beneath it).
1772
+
1773
+ #### \`chroma_key\`, \`luma_key\` (keying)
1774
+
1775
+ Keying makes pixels of the element's own rendered layer transparent
1776
+ based on their color. All math operates on the STRAIGHT-alpha color
1777
+ \`c = premultiplied.rgb / α\` (with \`c = 0\` where \`α = 0\`); the resulting
1778
+ coverage factor \`a\` scales the pixel's alpha (and its premultiplied
1779
+ color with it).
1780
+
1781
+ **\`chroma_key\`** — params \`color\` (default \`"#00FF00"\`), \`tolerance\`
1782
+ (default \`0.18\`), \`softness\` (default \`0.1\`), \`spill\` (default \`0.5\`).
1783
+ With \`k\` the key color and BT.709 luma \`Y(x) = 0.2126·x.r + 0.7152·x.g
1784
+ + 0.0722·x.b\`:
1785
+
1786
+ \`\`\`
1787
+ CbCr(x) = ( (x.b − Y(x)) / 1.8556 , (x.r − Y(x)) / 1.5748 )
1788
+ d = | CbCr(c) − CbCr(k) | — Euclidean distance
1789
+ a = softness > 0 ? clamp((d − tolerance) / softness, 0, 1)
1790
+ : (d > tolerance ? 1 : 0)
1791
+ \`\`\`
1792
+
1793
+ Spill suppression caps the key color's dominant channel (ties resolve
1794
+ green → red → blue): with \`i\` that channel and \`j, k\` the others,
1795
+ \`c.i −= spill × max(0, c.i − max(c.j, c.k))\`, applied to every pixel
1796
+ regardless of \`d\`. Output: \`α' = α × a\`, color \`c × α'\`.
1797
+
1798
+ **\`luma_key\`** — params \`threshold\` (default \`0.5\`), \`softness\`
1799
+ (default \`0.1\`), \`invert\` (default \`false\`):
1800
+
1801
+ \`\`\`
1802
+ a = softness > 0 ? clamp((Y(c) − threshold) / softness, 0, 1)
1803
+ : (Y(c) > threshold ? 1 : 0)
1804
+ invert → a = 1 − a
1805
+ \`\`\`
1806
+
1807
+ Pixels darker than \`threshold\` are removed (with \`invert\`, brighter).
1808
+ \`tolerance\` / \`softness\` / \`threshold\` / \`spill\` are animatable;
1809
+ \`color\` and \`invert\` are static. Keying reads only the element's own
1810
+ pixels — to key a green-screen video, put the effect on the \`video\`
1811
+ element (\`color\` set to the screen's actual green); a group keys its
1812
+ composited children as one layer.
1813
+
1814
+ #### \`levels\`, \`lut\` (grading)
1815
+
1816
+ Both operate per pixel on the straight-alpha color; alpha is never
1817
+ changed.
1818
+
1819
+ **\`levels\`** — params \`in_black\` 0, \`in_white\` 1, \`gamma\` 1,
1820
+ \`out_black\` 0, \`out_white\` 1 (all animatable, points clamped to
1821
+ [0, 1], gamma > 0). Per channel:
1822
+
1823
+ \`\`\`
1824
+ x = clamp((c − in_black) / (in_white − in_black), 0, 1)
1825
+ y = x^(1/gamma) — gamma > 1 brightens mid-tones
1826
+ out = clamp(out_black + y × (out_white − out_black), 0, 1)
1827
+ \`\`\`
1828
+
1829
+ **\`lut\`** — params \`source\` (URL of a \`.cube\` file — http(s), relative,
1830
+ or \`data:\` URI), \`intensity\` 1 (animatable, 0..1). The file MUST
1831
+ declare \`LUT_3D_SIZE N\` (2 ≤ N ≤ 256) with the default 0..1 domain;
1832
+ data lines are \`r g b\` triples ordered red-fastest, then green, then
1833
+ blue. Values clamp to [0, 1]. The graded color is the TRILINEAR
1834
+ interpolation of the lattice at \`c × (N−1)\` per axis, and the output
1835
+ is \`mix(c, graded, intensity)\`. A LUT that fails to load or parse MUST
1836
+ skip the pass with a warning (the element still renders). The lattice
1837
+ is sampled at the pipeline's working precision (8-bit per channel in
1838
+ the reference runtime).
1839
+
1840
+ #### \`fractal_noise\`, \`turbulent_displace\` (procedural noise)
1841
+
1842
+ Both build on one NORMATIVE noise function so identical documents
1843
+ produce identical pixels on every runtime. All integer math is 32-bit
1844
+ unsigned (wrapping); lattice coordinates convert int→uint by two's
1845
+ complement.
1846
+
1847
+ \`\`\`
1848
+ pcg(v) : s = v × 747796405 + 2891336453 (mod 2³²)
1849
+ w = ((s >> ((s >> 28) + 4)) XOR s) × 277803737 (mod 2³²)
1850
+ → (w >> 22) XOR w
1851
+ h(c, seed) = pcg(c.x XOR pcg(c.y XOR pcg(c.z XOR pcg(seed)))) / (2³²−1)
1852
+ noise(p, seed): value noise — trilinear blend of h at the 8 corners of
1853
+ floor(p)'s lattice cell, weights faded per axis by
1854
+ u = f³(f(6f − 15) + 10)
1855
+ fbm(p, octaves, seed) = Σₒ 0.5° × noise(p × 2°, seed + o) / Σₒ 0.5°
1856
+ for o = 0 … octaves−1 (lacunarity 2, gain 0.5)
1857
+ \`\`\`
1858
+
1859
+ **\`fractal_noise\`** — params \`scale\` 100 (canvas px per lattice cell),
1860
+ \`evolution\` 0, \`offset_x\`/\`offset_y\` 0 (canvas px), \`octaves\` 4
1861
+ (integer 1–8, static), \`seed\` 0 (integer, static; use values < 2²⁴).
1862
+ The element's pixels are replaced by grayscale noise, keeping its
1863
+ alpha footprint:
1864
+
1865
+ \`\`\`
1866
+ v = fbm((p + offset) / scale ⊕ evolution as the 3rd axis, octaves, seed)
1867
+ out = (v, v, v) × α, α unchanged
1868
+ \`\`\`
1869
+
1870
+ Grayscale by design — chain \`levels\` to shape contrast and a \`lut\` to
1871
+ color it (gray has no chroma for \`hue_rotate\` to act on). Animate
1872
+ \`evolution\` for in-place churn, the offsets to scroll. On a text
1873
+ element the glyphs become a noise-filled matte.
1874
+
1875
+ **\`turbulent_displace\`** — params \`amount\` 16 (max displacement,
1876
+ canvas px), \`scale\` 120, \`evolution\` 0, \`octaves\` 2 (1–8, static),
1877
+ \`seed\` 0 (static). Each output pixel samples the element's layer at a
1878
+ noise-displaced position (bilinear, clamped to the layer):
1879
+
1880
+ \`\`\`
1881
+ d = ( fbm(p/scale ⊕ evolution, octaves, seed) − 0.5 ,
1882
+ fbm(p/scale ⊕ evolution, octaves, seed + 7919) − 0.5 ) × 2 × amount
1883
+ out(p) = layer(p + d)
1884
+ \`\`\`
1885
+
1886
+ \`scale\`, \`evolution\`, offsets, and \`amount\` are animatable; \`octaves\`
1887
+ and \`seed\` are static.
1888
+
1889
+ #### \`glass\`
1890
+
1891
+ Liquid glass — a refractive pane over the backdrop, following the
1892
+ widely-adopted liquidglass optical model (reference:
1893
+ github.com/ybouane/liquidglass). Glass applies to \`shape\` elements
1894
+ (primitive rectangle / ellipse and path shapes) and \`text\` elements,
1895
+ where the pane geometry is known from the element's own geometry rather
1896
+ than rasterized alpha.
1897
+
1898
+ Glass is **legal under 3D**: 3D transforms (\`x_rotation\` /
1899
+ \`y_rotation\` / \`z\`) and a camera on a glass-carrying element or its
1900
+ un-flattened ancestor chain are valid. The pane is a true plane in the
1901
+ scene and the runtime projects the §4.7 optical model through the
1902
+ pane's plane homography (see "Glass under 3D" below). With no 3D in
1903
+ play the orthographic path applies bit-for-bit.
1904
+
1905
+ | Param | Default | Meaning |
1906
+ |---|---|---|
1907
+ | \`blur_radius\` | \`0\` | Backdrop Gaussian blur σ in px. \`0\` = CLEAR glass (pure refraction, the default material); \`> 0\` = FROSTED. |
1908
+ | \`refraction\` | \`21\` | Lens bend strength (≈ px of displacement; internally the reference dial = \`refraction / 30\`). The magnitude is used. |
1909
+ | \`edge_width\` | \`40\` | Bevel z-radius — how deep the lens curvature reaches. With \`mode: "dome"\` and \`edge_width\` = the shape's radius, the pane is a half-sphere magnifier. |
1910
+ | \`mode\` | \`"pill"\` | Lens cross-section: \`"pill"\` (biconvex — entry + exit refraction + depth-scaled centre magnification) or \`"dome"\` (flat bottom, curved top — uniform magnification toward the centre). |
1911
+ | \`edge_highlight\` | \`0.35\` | Scales the stock light rig (rim 0.22×, inner glow 0.15×, 1.5px top-biased inner stroke 0.55×, Fresnel). \`0.35\` reproduces the reference defaults exactly. |
1912
+ | \`dispersion\` | \`0.05\` | Chromatic aberration along the surface normal (×18 px, edge-weighted). |
1913
+ | \`shadow\` | \`0.3\` | Drop-shadow opacity, painted ONLY outside the pane's SDF (spread 10 px, vertical offset 1 px — glass never frosts its own shadow). |
1914
+ | \`backdrop_saturation\` | \`1\` | Saturation of the sampled backdrop (\`1\` = unchanged). |
1915
+ | \`tint\` | none | Color drawn over the glass; alpha = strength. Not keyframable in v1. |
1916
+
1917
+ All numeric params are animatable. The pane's \`fill_color\` is unused
1918
+ under glass; its \`opacity\` scales the pane. An \`ellipse\` evaluates as
1919
+ the rounded-rect SDF with \`r = min(half)\` — a circle when square, a
1920
+ stadium otherwise. Pane content (labels, icons) goes on lower layers (nearer the front).
1921
+
1922
+ The model, NORMATIVE, evaluated per pixel in pane-local rotated
1923
+ coordinates \`p\` with half-size \`half\`, corner radius \`r\`, z-radius
1924
+ \`zR = edge_width\`, and the reference dial \`dial = |refraction| / 30\`:
1925
+
1926
+ \`\`\`
1927
+ sdf = roundedRectSDF(p, half, r)
1928
+ inside = −sdf
1929
+ h(d) = √(d × (2·zR − d)) clamped to [0, zR] — half-circle bevel
1930
+ ∇h = central differences of h at step 2 px (the sdf is ANALYTIC:
1931
+ no field facets, no measured-divergence cap, no rim "lip")
1932
+ N = normalize(−∇h, 1)
1933
+ depth = smoothstep(0, zR, inside)
1934
+ edge = smoothstep(0.35 × min(half), 0, inside)
1935
+
1936
+ pill: refr = (2·∇h + ∇h·(h/zR)·0.5) × (1 − 1/1.5) × dial × 30
1937
+ + (−p / half) × dial × 4 × depth
1938
+ dome: refr = −p × dial × depth × 0.35
1939
+
1940
+ chroma = N.xy × dispersion × 18 × (edge×0.7 + 0.3) × 2;
1941
+ R/G/B sample at refr + chroma / refr / refr − chroma
1942
+ col = mix(sharp, frosted, 1 − edge × 0.15)
1943
+ — frosted everywhere, 15% sharp at the rim; with
1944
+ blur_radius 0 both textures are identical (clear glass)
1945
+ col = saturate(col, backdrop_saturation); mix toward tint;
1946
+ × (1 + 0.06 × depth)
1947
+ fresnel = (1 − |N.z|)⁴ × (edge_highlight / 0.35)
1948
+ spec = Blinn-Phong on N: L(0.4,0.7,1)^90 + L(−0.3,−0.5,1)^50×0.3
1949
+ + diffuse L(0.1,0.3,1)^6×0.1 + L(0,0.9,0.4)^120×0.6
1950
+ (specular dial; reference default 0)
1951
+ stroke = 1.5px inner band × (0.4 + 0.6·topBias) × edgeHL × 0.55
1952
+ rim/glow = edge × edgeHL × 0.22 / smoothstep(5,0,inside) × edgeHL × 0.15
1953
+ env = (N.y×0.5 + 0.5) × fresnel × 0.08
1954
+ fin = col + spec + rim + glow + stroke + env, then
1955
+ mix(fin, white, fresnel × 0.2)
1956
+ out = premultiply(fin, aaMask × opacity)
1957
+
1958
+ shadow (sdf > 0 only, offset down by offY):
1959
+ d = max(sdfShadow − 1, 0); s = spread
1960
+ α = (e^(−d²/s²) × 0.65
1961
+ + e^(−0.08·d / max(0.04·s, 0.01)) × 0.35) × shadow
1962
+ \`\`\`
1963
+
1964
+ **Glass under 3D (CKP/1.0, NORMATIVE).** When the pane carries 3D
1965
+ fields or sits under an un-flattened non-affine chain (§4.4), the
1966
+ model above runs unchanged in the pane's LOCAL frame; only the
1967
+ coordinate hand-off changes:
1968
+
1969
+ - Let \`H\` be the pane's plane homography — the 3×3 restriction of the
1970
+ full §4.4 matrix chain (camera included) to the pane's plane,
1971
+ origin at the pane's center, y down. Per fragment, pane-local
1972
+ \`p = (H⁻¹ · (px, 1)).xy / w\`; a non-positive \`w\` lies past the
1973
+ plane's horizon and outputs nothing. This is exactly the camera-ray
1974
+ / pane-plane intersection, in projective form.
1975
+ - Refracted and dispersed sample points (\`p + refr ± chroma\`) map
1976
+ FORWARD through \`H\` to surface px (clamped to the surface) and
1977
+ sample the same screen-space backdrop snapshot. Glass refracts the
1978
+ COMPOSITED IMAGE at the screen plane — it does not re-render the
1979
+ scene from the refracted direction.
1980
+ - The light rig, bevel field and SDF are defined in the pane's local
1981
+ frame and tilt WITH the pane (highlights track the surface, not the
1982
+ screen).
1983
+ - Degenerate case: a singular \`H\` (an edge-on pane with no
1984
+ perspective anywhere in its chain) is invisible — runtimes MUST
1985
+ draw nothing. Under perspective an off-axis edge-on pane projects
1986
+ to a thin wedge; that wedge is correct rendering, not an error.
1987
+ - The shadow term evaluates in pane-local coordinates like everything
1988
+ else, so the shadow projects with the pane.
1989
+
1990
+ With no 3D in play the orthographic path applies unchanged —
1991
+ documents valid in CKP/1.0 render bit-identically.
1992
+
1993
+ ### 4.8. Lighting and materials (CKP/1.0, NORMATIVE)
1994
+
1995
+ A Source MAY declare \`lights\` and an \`environment\`; an element MAY carry
1996
+ a \`material\`. Lighting is **opt-in and additive**: a document with no
1997
+ \`lights\` and no element \`material\` renders **bit-identically** to one
1998
+ without these fields. Only elements with a \`material\` are shaded.
1999
+
2000
+ **Shading model (PBR).** When an element has a \`material\` and the Source
2001
+ has \`lights\`, the element renders its content normally — those pixels are
2002
+ the **albedo** — and the runtime shades each fragment with one model,
2003
+ identical in 2D and 3D:
2004
+
2005
+ \`\`\`
2006
+ out = albedo · (ambient + Σ_dir diffuse(N,L)·Lc) // Lambert
2007
+ + Σ_dir specular_GGX(N,L,V,roughness)·F(V,H,F0)·Lc // Cook-Torrance
2008
+ + (kd·albedo·envAvg + envc·Fr) · reflectivity // environment (IBL)
2009
+ + emissive·albedo
2010
+ \`\`\`
2011
+
2012
+ where \`envc = mix(env(R), envAvg, roughness)\` is the environment sampled
2013
+ along the reflection ray \`R = reflect(−V,N)\` and blurred toward its
2014
+ average by roughness; \`Fr\` is the roughness-aware Schlick–Fresnel at the
2015
+ view angle; \`kd = (1−Fr)·(1−metalness)\` is the dielectric diffuse weight;
2016
+ and \`envAvg\` is the environment's mean color (its diffuse irradiance).
2017
+
2018
+ - **N** is the element's world-space face normal (from its §4.4 3D
2019
+ orientation), perturbed per-fragment by \`material.normal_map\` when
2020
+ present (a tangent-space map sampled in the element's UV space, scaled
2021
+ by \`normal_scale\`; the tangent/bitangent come from the quad's U/V axes).
2022
+ - **V** is the view vector: per-fragment \`normalize(eye − fragmentWorld)\`
2023
+ under a camera, or \`(0,0,1)\` with no camera. This is the only term the
2024
+ camera affects — so specular and reflections **sweep as the camera
2025
+ moves** (3D); with no camera you animate the lights instead (2D). The
2026
+ math is the same.
2027
+ - **F0** (Fresnel base reflectance) is \`0.04\` for dielectric, the albedo
2028
+ for metal, interpolated by \`metalness\`. Fresnel uses Schlick.
2029
+ - **roughness** widens the GGX specular lobe and blurs the environment
2030
+ reflection; **reflectivity** is an art dial over the environment term.
2031
+
2032
+ **Lights** (\`source.lights\`):
2033
+ - \`{ type: 'ambient', color?, intensity? }\` — uniform fill.
2034
+ - \`{ type: 'directional', azimuth?, elevation?, color?, intensity? }\` —
2035
+ a parallel light; \`azimuth\`/\`elevation\` (degrees) give its direction.
2036
+ All scalar fields animatable.
2037
+
2038
+ **Environment** (\`source.environment\`) is what reflective surfaces
2039
+ sample along the reflection vector. Phase 1: \`{ type: 'gradient', stops }\`
2040
+ — a gradient "sky" indexed by the reflection ray's vertical component
2041
+ (offset 0 = looking down, 1 = up), so a surface mirrors the sky when it
2042
+ tilts up and the ground when it tilts down, and the reflection shifts as
2043
+ the surface (or camera) moves. Up to 4 stops. The environment also
2044
+ contributes a diffuse irradiance (its average color) to dielectric
2045
+ surfaces. The other type is \`{ type: 'image', src }\` — an
2046
+ **equirectangular** (2:1 lat-long) image the surface mirrors along the
2047
+ reflection vector (real photographic reflections); roughness blurs it
2048
+ toward the image's average color. Both share one IBL path.
2049
+
2050
+ **Bloom** (\`source.bloom\`, Phase 2) is a whole-frame post-process:
2051
+ pixels brighter than \`threshold\` (luma, soft \`knee\`) are blurred by
2052
+ \`radius\` and added back × \`intensity\`, so bright regions — specular
2053
+ highlights, emissive surfaces, bright media — bleed light across element
2054
+ boundaries. It is **brightness-driven**: the amount each region blooms
2055
+ comes from its own brightness, not a per-element knob (use the
2056
+ per-element \`glow\` effect for deliberate, art-directed halos). Opt-in;
2057
+ absent ⇒ no bloom (byte-identical). All fields animatable.
2058
+
2059
+ **Material** (\`element.material\`): \`roughness\` (0 mirror‑tight .. 1
2060
+ matte), \`metalness\` (0 dielectric .. 1 metal), \`reflectivity\` (env
2061
+ strength), \`emissive\` (self-illumination toward the unlit pixels),
2062
+ \`normal_map\` (tangent-space normal map URL — flat texel = \`#8080ff\`) and
2063
+ \`normal_scale\` (perturbation strength). Scalars animatable. Absent ⇒
2064
+ unlit.
2065
+
2066
+ **Determinism / cost.** No \`material\` ⇒ the element takes the exact
2067
+ unlit path (no shading pass), so unlit content is byte-identical and
2068
+ pays nothing. The model is local illumination (no shadows, no global
2069
+ illumination) — flat 2.5D surfaces lit per-fragment.
2070
+
2071
+ ---
2072
+
2073
+ ## 5. Element types
2074
+
2075
+ CKP/1.0 defines eight element types — \`video\`, \`image\`, \`text\`,
2076
+ \`shape\`, \`audio\`, \`group\`, \`caption\`, and \`particles\`. (The former
2077
+ \`svg\` element was absorbed into \`shape\`, which carries vector \`paths\`.)
2078
+ Each section below specifies the fields, semantics, and required
2079
+ behavior for one type.
2080
+
2081
+ ### 5.1. \`shape\`
2082
+
2083
+ A \`shape\` draws geometry in one of two representations, selected by whether
2084
+ it carries \`paths\`:
2085
+
2086
+ - **Primitive** — \`shape: "rectangle" | "ellipse"\` with optional rounded
2087
+ corners, gradient fills, and stroke. Rendered as a resolution-independent
2088
+ SDF. \`fill_color\`, \`stroke_color\`, \`stroke_width\`, and \`border_radius\` are
2089
+ animatable via \`keyframe_animations\` (e.g. \`property: "border_radius"\`).
2090
+ - **Path** — arbitrary vector geometry via \`paths\`: keyframeable \`d\`
2091
+ morphing, per-sub-path fill/stroke, and stroke trim/draw-on. Rasterized,
2092
+ so resolution is bound by \`view_box\`. Specified in §5.6.
2093
+
2094
+ When \`paths\` is present the primitive fields are ignored.
2095
+
2096
+ \`\`\`ts
2097
+ interface ShapeElement extends BaseElement {
2098
+ type: "shape";
2099
+ // Primitive form (ignored when \`paths\` is present):
2100
+ shape?: "rectangle" | "ellipse"; // default "rectangle"
2101
+ fill_color?: string; // hex, default "#ffffff"
2102
+ gradient?: LinearGradient | RadialGradient; // overrides fill_color
2103
+ stroke_color?: string;
2104
+ stroke_width?: number;
2105
+ border_radius?: number; // PIXELS — see §5.1.2
2106
+ shadow?: BoxShadow; // drop shadow cast by the shape
2107
+ // Path form (§5.6):
2108
+ view_box?: [number, number, number, number]; // default [0, 0, 100, 100]
2109
+ gradients?: PathGradient[];
2110
+ paths?: PathDef[]; // ≥1 when present
2111
+ }
2112
+
2113
+ interface BoxShadow {
2114
+ color: string; // §3.4
2115
+ offset_x?: number; // px. Default 0
2116
+ offset_y?: number; // px. Default 12
2117
+ blur?: number; // Gaussian σ px (0 = crisp). Default 18
2118
+ }
2119
+ \`\`\`
2120
+
2121
+ \`shadow\` casts a soft drop shadow beneath the shape. Under 3D the shadow
2122
+ foreshortens with the element's plane, while \`offset_x\`/\`offset_y\`
2123
+ translate in the parent plane (§4.4.3).
2124
+
2125
+ #### 5.1.1. Gradients
2126
+
2127
+ \`\`\`ts
2128
+ interface LinearGradient {
2129
+ type: "linear";
2130
+ angle?: number; // degrees, CSS linear-gradient() convention:
2131
+ // 0 = to top, clockwise. 90 = to right,
2132
+ // 180 = to bottom (default), 270 = to left.
2133
+ stops: GradientStop[]; // 2..4 stops
2134
+ }
2135
+ interface RadialGradient {
2136
+ type: "radial";
2137
+ cx?: number; // 0..1 of bounding box, default 0.5
2138
+ cy?: number;
2139
+ radius?: number; // 0..1, default 0.5
2140
+ stops: GradientStop[];
2141
+ }
2142
+ interface GradientStop {
2143
+ offset: number; // 0..1
2144
+ color: string; // hex
2145
+ }
2146
+ \`\`\`
2147
+
2148
+ Implementations MUST support at least 4 stops. The gradient direction
2149
+ for linear gradients uses CSS-style angle conventions.
2150
+
2151
+ #### 5.1.2. Corner radius
2152
+
2153
+ \`border_radius\` is in PIXELS, not a normalized 0..1 value. Runtimes MUST
2154
+ clamp the value to half the shorter of \`width\`/\`height\` so that values
2155
+ exceeding the shape produce a pill or circle rather than visual artifacts.
2156
+
2157
+ Conforming runtimes MUST render corner arcs as **true quarter-circles**,
2158
+ not stretched ellipses. This means SDF-based renderers MUST perform
2159
+ corner math in pixel space, not in normalized UV space.
2160
+
2161
+ ### 5.2. \`text\`
2162
+
2163
+ Multi-line text. Text soft-wraps within the box (\`text_wrap\`), honors
2164
+ explicit \`\\n\` breaks, \`line_height\`, and per-line backgrounds. The
2165
+ default font is **Inter** (not a platform stack); \`font_family\` selects
2166
+ another registered family (§2.1 \`fonts\`).
2167
+
2168
+ \`\`\`ts
2169
+ interface TextElement extends BaseElement {
2170
+ type: "text";
2171
+ text?: string; // static text
2172
+ spans?: TextSpan[]; // inline-styled runs (§5.2.4)
2173
+
2174
+ font_family?: string; // registered family name; default Inter
2175
+ font_size?: number | string; // "auto" fits to width
2176
+ font_weight?: number | string; // "400", "bold", etc.
2177
+ font_style?: "normal" | "italic";
2178
+ fill_color?: string;
2179
+ stroke_color?: string;
2180
+ stroke_width?: number;
2181
+ text_align?: "left" | "center" | "right";
2182
+ letter_spacing?: number;
2183
+
2184
+ background_color?: string; // solid bg, SHRINK-WRAPPED to glyphs
2185
+ background_border_radius?: number; // corner radius (px) for the bg
2186
+ background_padding?: number | [number, number]; // bg padding px (or [x,y])
2187
+ text_shadow?: TextShadow | TextShadow[]; // per-glyph shadow(s)
2188
+
2189
+ mask?: TextMask; // reveal mask
2190
+ }
2191
+
2192
+ interface TextShadow {
2193
+ color: string; // §3.4
2194
+ offset_x?: number; // px, text-local frame. Default 0
2195
+ offset_y?: number;
2196
+ blur?: number; // Gaussian softness px (0 = crisp). Default 0
2197
+ opacity?: number; // 0..1, multiplies color alpha. Default 1
2198
+ }
2199
+ \`\`\`
2200
+
2201
+ \`text_shadow\` is a **per-glyph** drop shadow (CSS \`text-shadow\`): each glyph
2202
+ casts its own, so it tracks per-letter animation and overlapping glyphs —
2203
+ unlike the silhouette \`drop_shadow\` effect (§4.7), which shadows the
2204
+ flattened text as one shape. Pass an **array** for stacked shadows, painted
2205
+ back-to-front (list farthest → nearest for a clean 3D extrusion). Shadows
2206
+ are drawn behind every glyph (a two-pass render) so they never get clipped
2207
+ by neighbouring letters. Works the same on \`caption\`. (Reach for the
2208
+ \`drop_shadow\` effect instead when you want one soft shadow of the whole
2209
+ text silhouette.)
2210
+
2211
+ \`background_color\` draws a solid background behind the text as **one band
2212
+ per line**, each shrink-wrapped to that line's glyphs (line width × the
2213
+ font ascent/descent box) — NOT the element's \`width\`/\`height\` box — so
2214
+ centered or ragged multi-line text gets per-line pills, and it tracks
2215
+ wrapping and \`font_size: "auto"\`. \`background_border_radius\` rounds each
2216
+ band; \`background_padding\` (a number, or \`[x, y]\`) insets it outward. It
2217
+ rotates, scales, and skews with the element. For a per-run highlight band
2218
+ use a span \`background\` (§5.2.4); for a drop shadow use a \`drop_shadow\`
2219
+ effect (§4.7). The same fields work on \`caption\` (one band around the
2220
+ caption phrase).
2221
+
2222
+ #### 5.2.1. \`text\` content
2223
+
2224
+ \`text\` (or \`spans\`, §5.2.4) provides the content. If \`spans\` is present it
2225
+ takes precedence over \`text\`. If neither is present the element renders
2226
+ nothing (and runtimes MAY skip the element entirely).
2227
+
2228
+ #### 5.2.2. \`font_size: "auto"\`
2229
+
2230
+ When \`font_size\` is the string \`"auto"\`, the runtime MUST compute a
2231
+ font size such that the rendered text fits inside the element's \`width\`.
2232
+ The exact algorithm is implementation-defined but MUST be deterministic
2233
+ for the same text + font + width inputs.
2234
+
2235
+ #### 5.2.3. Text mask
2236
+
2237
+ \`\`\`ts
2238
+ interface TextMask {
2239
+ type: "linear-wipe";
2240
+ angle?: number; // degrees, default -45
2241
+ progress?: number | Keyframe[]; // 0..1
2242
+ softness?: number; // 0..1, default 0.3
2243
+ }
2244
+ \`\`\`
2245
+
2246
+ When present, the text is rendered into an offscreen surface and
2247
+ multiplied by a linear-gradient alpha mask. \`progress\` controls the
2248
+ reveal position; \`softness\` controls the wipe edge width.
2249
+
2250
+ #### 5.2.4. Spans
2251
+
2252
+ \`spans\` carries inline-styled runs. When present it takes precedence
2253
+ over \`text\`. Runs lay out left-to-right; a span whose
2254
+ \`text\` is exactly \`"\\n"\` is a hard line break. Each span inherits the
2255
+ element's \`font_family\` / \`font_size\` / \`font_weight\` / \`fill_color\` /
2256
+ \`letter_spacing\` unless it overrides them.
2257
+
2258
+ \`\`\`ts
2259
+ interface TextSpan {
2260
+ text: string;
2261
+ font_family?: string;
2262
+ font_size?: number | string;
2263
+ font_weight?: number | string;
2264
+ font_style?: "normal" | "italic";
2265
+ fill_color?: string;
2266
+ letter_spacing?: number; // px tracking; inherits element's
2267
+ background_color?: string; // flat full-line-box band
2268
+ background?: TextSpanBackground; // stylized band; overrides above
2269
+ nowrap?: boolean; // atomic for word-wrap
2270
+ }
2271
+ \`\`\`
2272
+
2273
+ \`letter_spacing\` (element and span level) is pixels of tracking added
2274
+ after EVERY character, including the last — Chrome's model, so boxes
2275
+ measured in a browser reproduce exactly. \`nowrap\` marks the span atomic
2276
+ for word-wrap (CSS \`white-space: nowrap\` semantics): the runtime never
2277
+ breaks inside it. \`background\` draws a band behind the span's glyphs
2278
+ (\`color\`, plus optional \`height_ratio\`, \`inset_y_ratio\`, \`padding_x\`,
2279
+ \`skew_x\`, \`border_radius\` — see the schema for exact semantics).
2280
+
2281
+ ### 5.3. \`image\` and \`video\`
2282
+
2283
+ \`\`\`ts
2284
+ interface ImageElement extends BaseElement {
2285
+ type: "image";
2286
+ source: string; // URL or path
2287
+ fit?: "cover" | "contain" | "fill" | "none"; // default "cover"
2288
+ border_radius?: number; // corner radius px, default 0
2289
+ crop_x?: number; // source crop, normalized 0..1 (see §5.3.1)
2290
+ crop_y?: number;
2291
+ crop_width?: number;
2292
+ crop_height?: number;
2293
+ }
2294
+
2295
+ interface VideoElement extends BaseElement {
2296
+ type: "video";
2297
+ source: string;
2298
+ fit?: "cover" | "contain" | "fill" | "none"; // default "cover"
2299
+ crop_x?: number; // source crop, normalized 0..1 (see §5.3.1)
2300
+ crop_y?: number;
2301
+ crop_width?: number;
2302
+ crop_height?: number;
2303
+ volume?: number | Keyframe[]; // 0..100, default 100 (animatable)
2304
+ playback_rate?: number | Keyframe[]; // media seconds per timeline second, default 1
2305
+ // (schema type; the runtime requires it static — §5.3.2)
2306
+ trim_start?: number; // seconds into the media, default 0
2307
+ trim_duration?: number; // playable media window after trim_start
2308
+ loop?: boolean;
2309
+ }
2310
+ \`\`\`
2311
+
2312
+ Asset reference rules are defined in §8.
2313
+
2314
+ #### 5.3.1. Object fit and source crop
2315
+
2316
+ \`fit\` follows CSS \`object-fit\` against the element box:
2317
+
2318
+ | Value | Behavior |
2319
+ |---|---|
2320
+ | \`cover\` (default) | scale media to fill the box; crop the overflow, centered |
2321
+ | \`contain\` | scale media to fit inside the box; letterbox |
2322
+ | \`fill\` | stretch media to the box exactly |
2323
+ | \`none\` | natural media size, centered, cropped to the box |
2324
+
2325
+ **Source crop.** \`crop_x\` / \`crop_y\` / \`crop_width\` / \`crop_height\` select a normalized
2326
+ sub-rectangle of the **source** media (each in \`0..1\`, origin top-left)
2327
+ that is shown in place of the whole source. The default is the whole
2328
+ source — \`crop_x = 0, crop_y = 0, crop_width = 1, crop_height = 1\` — and
2329
+ omitting the fields is identical to that identity crop.
2330
+
2331
+ Crop applies BEFORE \`fit\`: the cropped sub-rectangle becomes the
2332
+ effective media, and \`fit\` then maps it into the element box exactly as
2333
+ in §5.3.1. The element box (its \`x\` / \`y\` / \`width\` / \`height\`) is
2334
+ **unchanged** — crop only chooses which part of the source fills it, so
2335
+ crop composes orthogonally with transform, \`border_radius\`, filters, and
2336
+ 3D. A runtime MUST clamp the rect to the unit square (\`crop_width\` to
2337
+ \`1 − crop_x\`, \`crop_height\` to \`1 − crop_y\`); a zero-area crop is treated
2338
+ as the identity.
2339
+
2340
+ Each component MAY be keyframed (§6.3). Animating the crop origin pans
2341
+ across the source and animating its size zooms — a Ken Burns move with no
2342
+ change to the element's layout.
2343
+
2344
+ #### 5.3.2. Media time mapping
2345
+
2346
+ The playable *trim window* is:
2347
+
2348
+ \`\`\`
2349
+ window_start = max(0, trim_start)
2350
+ window_length = min(trim_duration ?? ∞, media_duration − window_start)
2351
+ \`\`\`
2352
+
2353
+ The media time sampled at composition time \`t\` is:
2354
+
2355
+ \`\`\`
2356
+ consumed = max(0, t − element.time) × playback_rate
2357
+ media_t = window_start + (consumed mod window_length) if loop
2358
+ = window_start + min(consumed, window_length − ε) otherwise
2359
+ \`\`\`
2360
+
2361
+ i.e. \`loop\` wraps WITHIN the trim window; without \`loop\` the last frame
2362
+ holds. \`playback_rate\` MUST be a static number (keyframed rates are not
2363
+ defined in CKP/1.0).
2364
+
2365
+ **Time remapping.** A video MAY carry \`time_remap\` — a keyframe array
2366
+ (§6.3 semantics: destination-keyframe easing, element-local time) whose
2367
+ VALUES are media times in seconds. When present, it REPLACES the
2368
+ mapping above entirely (\`trim_start\`, \`trim_duration\`,
2369
+ \`playback_rate\`, and \`loop\` are ignored):
2370
+
2371
+ \`\`\`
2372
+ media_t = clamp(interpolate(time_remap, t − element.time), 0, media_duration − ε)
2373
+ \`\`\`
2374
+
2375
+ Speed ramps are steep segments, freeze frames are flat ones, and
2376
+ reverse playback follows decreasing values; easings shape the ramp.
2377
+ Decoders quantize \`media_t\` to the media's own frames, which is
2378
+ conformant (§2.1 motion-blur note applies the same way).
2379
+
2380
+ **Varispeed audio.** Audio under a warped clock — a video's embedded
2381
+ track under \`time_remap\`, or any sound element inside a time-remapped
2382
+ group (§5.8.3) — plays VARISPEED, tape-style: at each instant the
2383
+ audio advances through the media at \`rate(t) = d(media_t)/dt\`, and
2384
+ pitch shifts with the rate (2× plays an octave up, slow-motion plays
2385
+ low). Flat segments (rate 0) are silent; decreasing segments play the
2386
+ media REVERSED at \`|rate|\`. The reference implementation samples the
2387
+ effective media-time function at 10 ms through the full warp chain,
2388
+ splits it into monotonic runs, and schedules each run with a rate
2389
+ curve (reversed runs play a sample-reversed copy of the buffer).
2390
+ Pitch-preserving time-stretch is NOT defined in CKP/1.0. Fade
2391
+ envelopes (\`audio_fade_in\`/\`audio_fade_out\`) are not applied under
2392
+ warps in v1.
2393
+
2394
+ #### 5.3.3. Video audio
2395
+
2396
+ If the media container carries an audio track, conforming Level 3
2397
+ runtimes MUST play/mix its FIRST audio track using the same timing as
2398
+ §5.3.2, with gain \`volume / 100\`. \`playback_rate\` resamples the audio
2399
+ (pitch shifts accordingly; time-stretch is not defined in CKP/1.0).
2400
+ Videos without an audio track are silent — not an error.
2401
+
2402
+ #### 5.3.4. Audio fades
2403
+
2404
+ \`audio_fade_in\` / \`audio_fade_out\` (on both \`audio\` and \`video\`
2405
+ elements, seconds, default 0) shape the gain over the element's
2406
+ TIMELINE window \`[0, L]\`:
2407
+
2408
+ \`\`\`
2409
+ g(τ) = volume/100 × min(1, τ / fade_in) × min(1, (L − τ) / fade_out)
2410
+ \`\`\`
2411
+
2412
+ (each factor is 1 when its fade is 0). The envelope is piecewise linear
2413
+ between its corner points; runtimes MUST reproduce it within normal
2414
+ gain-ramp accuracy, including when playback starts mid-fade (seek).
2415
+
2416
+ ### 5.4. \`audio\`
2417
+
2418
+ \`\`\`ts
2419
+ interface AudioElement extends BaseElement {
2420
+ type: "audio";
2421
+ source: string;
2422
+ volume?: number | Keyframe[]; // 0..100
2423
+ trim_start?: number;
2424
+ trim_duration?: number;
2425
+ loop?: boolean;
2426
+ }
2427
+ \`\`\`
2428
+
2429
+ Audio elements have no visual representation. They contribute to the
2430
+ mixed audio track produced by export-conformant runtimes (§10.3).
2431
+ Preview-only runtimes MAY play them via \`HTMLAudioElement\` or similar.
2432
+
2433
+ ### 5.5. \`caption\`
2434
+
2435
+ Word-timed captions with optional kinetic styling.
2436
+
2437
+ \`\`\`ts
2438
+ interface CaptionElement extends BaseElement {
2439
+ type: "caption";
2440
+ words: { text: string; start: number; end: number }[]; // start/end relative to element.time
2441
+ style?: "tiktok_bounce" | "fade_reveal" | "kinetic_typewriter" | "word_pop";
2442
+ // Windowing — how much of the transcript shows at once (§5.5.1). A whole
2443
+ // transcript otherwise renders as one block; with \`max_length\` set, the words
2444
+ // are split into chunks and only the chunk active at the current time shows.
2445
+ max_length?: number | "auto"; // number = max LETTERS per chunk;
2446
+ // "auto" = a few words per chunk;
2447
+ // absent = show all at once.
2448
+
2449
+ // Text-like styling
2450
+ font_family?: string;
2451
+ font_size?: number | string; // "auto" fits joined words to width
2452
+ font_weight?: number | string;
2453
+ fill_color?: string;
2454
+ highlight_color?: string;
2455
+ highlight_background_color?: string;
2456
+ text_align?: "left" | "center" | "right";
2457
+ }
2458
+ \`\`\`
2459
+
2460
+ \`words[*].start\` and \`words[*].end\` are seconds RELATIVE to the
2461
+ element's \`time\`. The exact kinetic behavior for each \`style\` is
2462
+ defined by the reference implementation; deviations MAY occur in third-
2463
+ party renderers but the timing MUST match.
2464
+
2465
+ #### 5.5.1. Windowing (\`max_length\`)
2466
+
2467
+ A full transcript on one element would render as a single unreadable block.
2468
+ \`max_length\` splits \`words\` into CHUNKS; at any time only the chunk active then
2469
+ is shown (within the element's box, wrapped). Chunking:
2470
+
2471
+ - **number** — grow a chunk word-by-word until adding the next word would exceed
2472
+ this many LETTERS (characters), then start a new chunk.
2473
+ - **\`"auto"\`** — chunk by a few words (and break on pauses) — the speech default.
2474
+ - **absent** — no windowing; the whole transcript shows at once.
2475
+
2476
+ The active chunk at element-local time \`t\` is the last chunk whose first word has
2477
+ started (so a chunk lingers through silent gaps until the next begins). Word
2478
+ kinetics (\`style\`) apply WITHIN the active chunk. Word \`start\`/\`end\` are
2479
+ unchanged — \`max_length\` is a display rule, not a re-timing.
2480
+
2481
+ ### 5.6. \`shape\` paths (vector geometry)
2482
+
2483
+ A \`shape\` (§5.1) that carries \`paths\` renders as arbitrary vector geometry —
2484
+ a restricted SVG-path subset (this absorbs the former standalone \`svg\`
2485
+ element). Conforming runtimes MUST support viewBox-scaled paths with linear
2486
+ gradients, clip-to-path, stroke-dashoffset progress, and per-path opacity.
2487
+
2488
+ The path-form fields live on \`ShapeElement\` (§5.1): \`view_box\`, \`gradients\`,
2489
+ and \`paths\`. Their element types:
2490
+
2491
+ \`\`\`ts
2492
+ interface PathGradient {
2493
+ id: string;
2494
+ type: "linear";
2495
+ x1: number; y1: number; x2: number; y2: number; // viewBox coords
2496
+ stops: GradientStop[];
2497
+ }
2498
+
2499
+ interface PathDef {
2500
+ d: string | Keyframe[]; // SVG path data, or d-string keyframes (§5.6.2)
2501
+ fill?: string; // hex or "url(#gradient-id)"
2502
+ stroke?: string;
2503
+ stroke_width?: number;
2504
+ stroke_progress?: number | Keyframe[]; // 0..1
2505
+ trim_start?: number | Keyframe[]; // §5.6.1
2506
+ trim_end?: number | Keyframe[];
2507
+ trim_offset?: number | Keyframe[];
2508
+ clip_path?: string; // another path that clips this one
2509
+ stroke_linecap?: "butt" | "round" | "square";
2510
+ stroke_linejoin?: "miter" | "round" | "bevel";
2511
+ opacity?: number; // 0..1
2512
+ }
2513
+ \`\`\`
2514
+
2515
+ \`stroke_progress\` MUST drive the standard \`stroke-dasharray\` /
2516
+ \`stroke-dashoffset\` reveal — a \`progress\` of \`0\` shows no stroke; \`1\`
2517
+ shows the entire stroke. Implementations MUST measure path length
2518
+ deterministically (the reference implementation uses
2519
+ \`SVGPathElement.getTotalLength()\`).
2520
+
2521
+ #### 5.6.1. Trim paths
2522
+
2523
+ Each path MAY carry a trim window: only the stroke between
2524
+ \`trim_start\` and \`trim_end\` — fractions of the path's TOTAL LENGTH,
2525
+ 0..1 — is drawn. \`trim_offset\` rotates the window around the path,
2526
+ WRAPPING at the ends (an offset of 1 is a full lap), so an animated
2527
+ offset is the classic traveling-dash "snake" and an animated
2528
+ \`trim_end\` is the draw-on reveal. All three are animatable;
2529
+ \`stroke_progress\` remains as sugar for \`[0, progress]\` and is ignored
2530
+ when any trim field is present. Fill is unaffected — trimming applies
2531
+ to the STROKE only.
2532
+
2533
+ Reference evaluation: with window width \`w = clamp(trim_end, 0, 1) −
2534
+ clamp(trim_start, 0, 1)\` (nothing draws when w ≤ 0; the full stroke
2535
+ when w ≥ 1) and wrapped anchor \`a = (trim_start + trim_offset) mod 1\`,
2536
+ the stroke uses a dash pattern of \`[w·L, L − w·L]\` with dash offset
2537
+ \`−a·L\`, where \`L\` is the path's total length — the pattern's period
2538
+ equals \`L\`, so windows crossing the path's start wrap exactly.
2539
+
2540
+ #### 5.6.2. Path morphing
2541
+
2542
+ A path's \`d\` MAY be a keyframe array whose values are d-strings.
2543
+ Between two keyframes the path MORPHS when the pair is COMPATIBLE —
2544
+ identical command-letter sequences, equal numeric-argument counts, and
2545
+ no arc commands (\`A\`/\`a\`, whose boolean flags cannot interpolate):
2546
+ every numeric argument interpolates with the destination keyframe's
2547
+ easing. An INCOMPATIBLE pair SNAPS: the source value holds until the
2548
+ destination keyframe's time. No path normalization is performed — the
2549
+ protocol stays literal; authors export morph targets with matching
2550
+ command structure (the standard practice).
2551
+
2552
+ ### 5.7. \`particles\`
2553
+
2554
+ A deterministic particle system with two modes: ballistic emission
2555
+ and target-point convergence.
2556
+
2557
+ \`\`\`ts
2558
+ interface ParticlesElement extends BaseElement {
2559
+ type: "particles";
2560
+ // Common
2561
+ size?: number; // pixels, default 12
2562
+ size_variation?: number; // 0..1, default 0.4
2563
+ particle_shape?: "square" | "circle";
2564
+ color?: string | string[]; // array randomizes per particle
2565
+ rotation_speed?: number; // deg/s
2566
+ lifetime?: number; // seconds per particle, default 1.5
2567
+ fade_at?: number; // 0..1 fraction of lifetime where fade begins, default 0.7
2568
+
2569
+ // Ballistic emission
2570
+ rate?: number; // particles per second
2571
+ velocity?: number; // initial speed px/s
2572
+ spread?: number; // cone in degrees, default 360
2573
+ direction?: number; // 0=right, 90=down, -90=up
2574
+ gravity?: number; // px/s², positive=down
2575
+
2576
+ // Depth (CKP/1.0, §5.7.3)
2577
+ z_velocity?: number; // px/s along the plane normal, default 0
2578
+ z_spread?: number; // uniform vz range width px/s, default 0
2579
+
2580
+ // Burst (used by both modes)
2581
+ burst?: boolean;
2582
+ burst_count?: number;
2583
+
2584
+ // Convergence (set target_points to enter convergence mode)
2585
+ target_points?: [number, number][]; // canvas-space targets
2586
+ convergence_easing?: EasingFunction;
2587
+ scatter_radius?: number; // disk radius around emitter
2588
+ }
2589
+ \`\`\`
2590
+
2591
+ #### 5.7.1. Determinism
2592
+
2593
+ Every particle's position, rotation, size, and color MUST be a pure
2594
+ function of \`(element.id, particle_index, age)\`. This means a runtime
2595
+ that seeks to time T MUST produce the same composition as a runtime
2596
+ that played continuously to T. The reference implementation seeds a
2597
+ PRNG with a hash of \`element.id\` and the particle index; third-party
2598
+ runtimes MAY use any algorithm that produces the same output as the
2599
+ reference.
2600
+
2601
+ #### 5.7.2. Convergence mode
2602
+
2603
+ When \`target_points\` is present and non-empty:
2604
+
2605
+ - Each particle \`n\` is assigned the target \`target_points[n % length]\`.
2606
+ - Each particle's start position is randomly placed within a disk of
2607
+ radius \`scatter_radius\` (default = \`max(canvas_width, canvas_height)\`)
2608
+ centered on \`(x, y)\`.
2609
+ - The particle's position is \`lerp(start, target, easing(age/lifetime))\`
2610
+ using \`convergence_easing\` (default \`"ease-out-quart"\`).
2611
+
2612
+ #### 5.7.3. Depth (CKP/1.0)
2613
+
2614
+ Per particle, \`vz = z_velocity + (r − 0.5) × z_spread\` with \`r\` the
2615
+ particle's uniform random draw, and its depth offset is \`vz × age\` px
2616
+ along the emitter plane's normal (+z toward the viewer, §4.4). The
2617
+ offset applies in BOTH modes (it is orthogonal to the in-plane
2618
+ position) and is part of the §5.7.1 determinism contract. There is no
2619
+ z gravity — \`gravity\` stays in-plane y. Like the \`z\` field (§4.4.2),
2620
+ depth has no visual effect without perspective in the chain, and
2621
+ particles draw in spawn order, never depth-sorted among themselves —
2622
+ the §4.4.3 camera sort orders whole elements, not a particle system's
2623
+ internal quads. With both fields absent or 0 the simulation is
2624
+ exactly the 2D one.
2625
+
2626
+ ### 5.8. \`group\`
2627
+
2628
+ \`\`\`ts
2629
+ interface GroupElement extends BaseElement {
2630
+ type: "group";
2631
+ elements: Element[];
2632
+ clip?: boolean; // default false
2633
+ mask?: {
2634
+ mode: "alpha" | "alpha-inverted" | "luma" | "luma-inverted";
2635
+ elements: Element[];
2636
+ };
2637
+ }
2638
+ \`\`\`
2639
+
2640
+ A positioned container. Children's \`x\`/\`y\` are coordinates in the
2641
+ group's LOCAL space, origin at the group's top-left box corner; a
2642
+ child's \`time\` is relative to the group's start; child \`layer\` and \`z\`
2643
+ order locally (§4.2). The group's transform (§4.4) and
2644
+ opacity stack multiplicatively onto children. Percentage/viewport units
2645
+ inside a group still resolve against the COMPOSITION canvas.
2646
+
2647
+ #### 5.8.1. Clipping
2648
+
2649
+ With \`clip: true\` (requires explicit \`width\` and \`height\`), children
2650
+ render into an offscreen layer the size of the group's box; pixels
2651
+ outside the box are discarded (CSS \`overflow: hidden\`). The group's
2652
+ own transform and opacity apply to the composited layer as a whole —
2653
+ opacity therefore applies ONCE to the flattened layer (overlapping
2654
+ semi-transparent children do not double-blend).
2655
+
2656
+ \`border_radius\` (px) rounds the clip box: children are masked to a
2657
+ rounded rectangle, matching a rounded card that clips its content (CSS
2658
+ \`overflow: hidden\` + \`border-radius\`). It is clamped to half the
2659
+ smaller box dimension and is ignored on an unclipped group. (Rounded
2660
+ clipping currently applies to the plain \`clip\` path; a \`mask\` group
2661
+ ignores \`border_radius\` since the mask layer already defines coverage.)
2662
+
2663
+ #### 5.8.2. Masks
2664
+
2665
+ The mask belongs to the group it masks — declared on the masked
2666
+ element, never inferred from siblings or layer adjacency. Mask
2667
+ \`elements\` render into a second box-sized layer using the same local
2668
+ coordinate space and timing rules as children, and may animate.
2669
+ The content layer composites through the mask layer per pixel:
2670
+
2671
+ \`\`\`
2672
+ factor = mask.alpha (alpha)
2673
+ = 1 − mask.alpha (alpha-inverted)
2674
+ = luminance(mask.rgb) (luma; Rec. 709 weights 0.2126/0.7152/0.0722,
2675
+ computed on premultiplied values)
2676
+ = 1 − luminance(mask.rgb) (luma-inverted)
2677
+ output = content × factor
2678
+ \`\`\`
2679
+
2680
+ \`mask\` requires explicit \`width\`/\`height\` and implies clipping (both
2681
+ layers are box-sized).
2682
+
2683
+ #### 5.8.3. Group time remapping
2684
+
2685
+ A group MAY carry \`time_remap\` — a keyframe array (§6.3 semantics)
2686
+ whose VALUES are warped local times in seconds. The group's SUBTREE
2687
+ runs on the warped clock:
2688
+
2689
+ \`\`\`
2690
+ local = t − group_start
2691
+ warped = max(0, interpolate(time_remap, local))
2692
+ \`\`\`
2693
+
2694
+ Children evaluate exactly as if the group's local time were \`warped\`:
2695
+ their \`time\` windows, animations, keyframes, transitions, and nested
2696
+ media all read the warped clock. Nested remapped groups compose (each
2697
+ warps its parent's clock in turn). The group's OWN animated properties
2698
+ (opacity, rotation, scale, position) read REAL time — the container
2699
+ moves on the composition's clock; only its contents are warped.
2700
+
2701
+ Flat segments freeze the subtree, steep segments speed-ramp it, and
2702
+ decreasing values run it backwards. Nested video decodes the frame at
2703
+ its warped media time (through §5.3.2's mapping). Audio inside a
2704
+ remapped subtree follows the varispeed rule (§5.3.2).
2705
+
2706
+ ### 5.9. No nested-composition element
2707
+
2708
+ CKP deliberately has NO \`composition\` (pre-comp) element. Both things a
2709
+ pre-comp bundles are covered by orthogonal features on plain elements:
2710
+
2711
+ - **Reuse** is an authoring-time concern: template functions expand into
2712
+ plain elements before the Source is serialized (see \`@clipkit/patterns\`
2713
+ for the first-party library). The wire format stays fully decomposed —
2714
+ a runtime never resolves references or instantiates templates.
2715
+ - **Nested timing** is \`time_remap\` on a plain \`group\` (§5.8.3).
2716
+
2717
+ A Source containing \`type: "composition"\` is invalid under CKP/1.0.
2718
+
2719
+ ---
2720
+
2721
+ ## 6. Animation
2722
+
2723
+ Every animatable property may be driven in three ways: a static value,
2724
+ a named-preset animation, or a keyframe animation. These compose with
2725
+ the precedence:
2726
+
2727
+ \`\`\`
2728
+ keyframe_animation > named_animation > static_value
2729
+ \`\`\`
2730
+
2731
+ That is, if both a keyframe animation and a named animation target the
2732
+ same property at the same time, the keyframe wins.
2733
+
2734
+ ### 6.1. Static values
2735
+
2736
+ The value as written in JSON. No interpolation; the value is used as-is
2737
+ for the entire duration the element is active.
2738
+
2739
+ ### 6.2. Named animations
2740
+
2741
+ \`\`\`ts
2742
+ interface Animation {
2743
+ type: AnimationType;
2744
+ duration?: number; // seconds; defaults in §6.2.1
2745
+ easing?: Easing; // default "ease-out" unless noted
2746
+ time?: "start" | "end" | number; // start, relative to element.time
2747
+
2748
+ // Parameters read by specific types (ignored otherwise):
2749
+ frequency?: number; // Hz — shake (8), wiggle (2), text-wave (1.5)
2750
+ rotation?: number; // degrees — spin (360), wiggle amplitude (8),
2751
+ // text-flip start angle (90)
2752
+ distance?: number; // px — pan/shift (200), shake (24),
2753
+ // text-slide (40), text-fly (140), text-wave (12)
2754
+ direction?: "left" | "right" | "up" | "down"; // pan/shift/text-slide/text-fly
2755
+ scale?: number; // squash depth 0..1 (0.3)
2756
+ split?: "letter" | "word"; // text-* unit granularity (§6.5)
2757
+ stagger?: number; // text-* seconds between units (§6.5)
2758
+ axis?: "x" | "y" | "z"; // text-flip rotation axis (§6.5, CKP/1.0)
2759
+ }
2760
+ \`\`\`
2761
+
2762
+ \`time: "start"\` (or absent) resolves to local time \`0\`; \`"end"\` to
2763
+ \`element_duration − duration\`; a number is local seconds.
2764
+
2765
+ Normative tween recipes (deltas apply to the listed property; *relative*
2766
+ adds to the static value, *absolute* replaces it during the window):
2767
+
2768
+ | Type | Property | From → To | Mode | Notes |
2769
+ |---|---|---|---|---|
2770
+ | \`fade-in\` / \`fade-out\` | opacity | 0→1 / 1→0 | absolute | |
2771
+ | \`slide-left-in\` | x | −200→0 | relative | starts left, moves right into place |
2772
+ | \`slide-right-in\` | x | +200→0 | relative | |
2773
+ | \`slide-up-in\` | y | +200→0 | relative | starts below, rises |
2774
+ | \`slide-down-in\` | y | −200→0 | relative | |
2775
+ | \`slide-*-out\` | x/y | 0→±200 | relative | motion direction matches the name |
2776
+ | \`scale-in\` / \`scale-out\` | scale | 0→1 / 1→0 | absolute | |
2777
+ | \`rotate-in\` / \`rotate-out\` | rotation | −90→0 / 0→+90 | relative | |
2778
+ | \`bounce-in\` / \`bounce-out\` | scale | 0→1 / 1→0 | absolute | default easing \`ease-out-back\` / \`ease-in-back\` |
2779
+ | \`spin\` | rotation | 0→\`rotation\` | relative | default easing \`linear\` |
2780
+ | \`shake\` | x | oscillation, amplitude \`distance\`→0 | relative | \`sin(2π·frequency·t)\` × eased envelope |
2781
+ | \`wiggle\` | rotation | oscillation, constant amplitude \`rotation\` | relative | same formula |
2782
+ | \`squash\` | y_scale, x_scale | 1→1−\`scale\`→1; 1→1+0.6·\`scale\`→1 | absolute | two half-duration phases; in \`ease-in-quad\`, out \`ease-out-back\` |
2783
+ | \`pan\` | x or y | −\`distance\`/2→+\`distance\`/2 along \`direction\` | relative | drifts through rest position; default easing \`linear\` |
2784
+ | \`shift\` | x or y | 0→\`distance\` along \`direction\` | relative | **fill-forward**: the end value holds for the rest of the element's life |
2785
+ | \`drift\` | x, y | smooth random walk, amplitude \`distance\` (30) | relative | offsets = \`(noise1d(frequency·t, seed) − 0.5) × 2 × distance\` per axis (y uses \`seed + 7919\`); \`frequency\` default 0.5, \`seed\` default 0 |
2786
+ | \`breathe\` | scale | oscillation, amplitude \`scale\` (0.05) | relative | \`scale × sin(2π·frequency·t)\`, \`frequency\` default 0.4 |
2787
+ | \`orbit\` | x, y | circle of radius \`distance\` (40) | relative | \`x += r·sin(2πft + π/2)\`, \`y += ±r·sin(2πft)\` (\`direction: "left"\` flips y = counter-clockwise); \`frequency\` default 0.5 rev/s |
2788
+ | \`text-*\` | per-unit | — | — | §6.5 |
2789
+
2790
+ \`drift\`'s noise is NORMATIVE — the 1D form of §4.7's lattice noise:
2791
+ \`noise1d(x, seed)\` is the quintic-faded linear interpolation between
2792
+ \`h(⌊x⌋)\` and \`h(⌊x⌋+1)\` where \`h(i) = pcg(i XOR pcg(seed)) / (2³²−1)\`
2793
+ and \`pcg\` is §4.7's hash. Same seed → identical motion everywhere.
2794
+
2795
+ #### 6.2.1. Duration defaults
2796
+
2797
+ \`duration\` defaults to \`0.5\`, EXCEPT \`spin\`, \`shake\`, \`wiggle\`, \`pan\`,
2798
+ \`drift\`, \`breathe\`, \`orbit\` and \`text-wave\`, which default to the
2799
+ element's full duration when both \`time\` and \`duration\` are omitted
2800
+ (they read as "for the element's life"). Outside its window a tween stops contributing (the property
2801
+ returns to its static value), except \`shift\`'s documented fill-forward.
2802
+
2803
+ ### 6.3. Keyframe animations
2804
+
2805
+ \`\`\`ts
2806
+ interface KeyframeAnimation {
2807
+ property: string; // name of the BaseElement field
2808
+ loop?: boolean | "ping-pong"; // repeat the pattern (see below)
2809
+ keyframes: Keyframe[]; // monotonically increasing times
2810
+ easing?: EasingFunction; // default per-keyframe
2811
+ }
2812
+ \`\`\`
2813
+
2814
+ For times before the first keyframe, the value is clamped to the first
2815
+ keyframe's value. After the last keyframe, clamped to the last value.
2816
+ Between keyframes \`i\` and \`i+1\`, the value is interpolated using the
2817
+ easing on keyframe \`i+1\` (or the animation's \`easing\` if not specified
2818
+ per-keyframe). A single-keyframe track is a constant.
2819
+
2820
+ #### 6.3.1. Color keyframes
2821
+
2822
+ When EVERY keyframe \`value\` in a track parses as a color (§3.4 — \`#…\`,
2823
+ \`rgb(…)\`, \`rgba(…)\`), the track is a *color track*: values interpolate
2824
+ componentwise in straight-alpha RGB space (alpha included), using the
2825
+ same easing rules. Color tracks are honored on \`fill_color\` (shape and
2826
+ plain text) and \`stroke_color\` (shape). Unparseable colors fall back to
2827
+ opaque white anywhere colors are parsed.
2828
+
2829
+ With \`loop\`, local time folds before interpolation: \`true\` wraps —
2830
+ \`t' = t mod span\` — and \`"ping-pong"\` reflects — \`t' = span − |((t mod
2831
+ 2·span)) − span|\` — where \`span\` is the LAST keyframe's \`time\`. The
2832
+ pattern repeats for the element's whole life; without \`loop\`, time
2833
+ past the last keyframe holds the final value (unchanged default).
2834
+ Looping applies to scalar, color, and \`position\` keyframe animations.
2835
+
2836
+ ### 6.4. Easing functions
2837
+
2838
+ CKP/1.0 defines 36 named easing functions plus two parametric forms.
2839
+ Mathematical definitions are in the reference implementation
2840
+ (\`packages/runtime/src/animation/easings.ts\`); the polynomial/sine/expo/
2841
+ circ/back families follow easings.net.
2842
+
2843
+ \`\`\`
2844
+ linear
2845
+
2846
+ ease, ease-in, ease-out, ease-in-out
2847
+ ease-in-cubic, ease-out-cubic, ease-in-out-cubic
2848
+ ease-in-quad, ease-out-quad, ease-in-out-quad
2849
+ ease-in-quart, ease-out-quart, ease-in-out-quart
2850
+ ease-in-quint, ease-out-quint, ease-in-out-quint
2851
+ ease-in-sine, ease-out-sine, ease-in-out-sine
2852
+ ease-in-expo, ease-out-expo, ease-in-out-expo
2853
+ ease-in-circ, ease-out-circ, ease-in-out-circ
2854
+ ease-in-back, ease-out-back, ease-in-out-back
2855
+
2856
+ elastic-in, elastic-out, elastic-in-out (decaying sinusoidal overshoot)
2857
+ bounce-in, bounce-out, bounce-in-out (piecewise-parabolic ball drop)
2858
+
2859
+ spring (damped harmonic oscillator: mass=1, damping=10, stiffness=100;
2860
+ ~5% overshoot then settles. Remotion's signature feel.)
2861
+ \`\`\`
2862
+
2863
+ Parametric forms (string-valued):
2864
+
2865
+ - \`cubic-bezier(x1, y1, x2, y2)\` — CSS timing-function semantics;
2866
+ \`x1\`/\`x2\` MUST be clamped to \`[0, 1]\`, \`y1\`/\`y2\` are unbounded.
2867
+ - \`steps(n)\` — \`n\` equidistant steps, jump-at-end (CSS \`steps(n, end)\`).
2868
+
2869
+ Unknown easing names MUST fall back to \`linear\` (never error). Output
2870
+ MUST match the reference within ±0.001 at any input value in \`[0, 1]\`.
2871
+
2872
+ ### 6.5. Per-unit text animations
2873
+
2874
+ The \`text-*\` animation types apply to \`text\` elements only (ignored on
2875
+ other element types; \`caption\` elements have their own kinetic system).
2876
+ The text splits into *units* and each unit runs the same animation,
2877
+ offset in time by \`stagger\` seconds per unit index.
2878
+
2879
+ **Unit indexing.** Letter index counts drawn glyphs (whitespace
2880
+ excluded); word index counts whitespace-separated runs. Both run
2881
+ continuously across spans and line breaks. Unit \`u\` starts at
2882
+ \`time + u × stagger\`.
2883
+
2884
+ **Defaults.** \`split\`: \`"word"\` for \`text-appear\`/\`text-slide\`/
2885
+ \`text-fly\`, \`"letter"\` for \`text-typewriter\`/\`text-wave\`/\`text-flip\`.
2886
+ \`stagger\`: \`0.09\` for word splits, \`0.035\` for letter splits.
2887
+ Per-unit \`duration\` default \`0.5\`.
2888
+
2889
+ | Type | Per-unit effect | Defaults |
2890
+ |---|---|---|
2891
+ | \`text-appear\` | opacity 0→1 over \`duration\` | easing \`ease-out-cubic\` |
2892
+ | \`text-slide\` | opacity 0→1 + displaced \`distance\` px opposite \`direction\`, settling at rest | \`distance\` 40, \`direction\` up, easing \`ease-out-cubic\` |
2893
+ | \`text-fly\` | as \`text-slide\`, farther | \`distance\` 140, easing \`ease-out-back\` |
2894
+ | \`text-typewriter\` | opacity steps 0→1 at the unit's start time | no fade |
2895
+ | \`text-wave\` | y offset \`distance × sin(2π·frequency·t − 0.6·u)\` | \`distance\` 12, \`frequency\` 1.5; full-length default (§6.2.1) |
2896
+ | \`text-flip\` | opacity 0→1 + 3D rotation \`rotation × (1−eased)\` degrees about \`axis\` through the unit's center, settling flat (CKP/1.0) | \`rotation\` 90, \`axis\` \`"x"\`, easing \`ease-out-cubic\` |
2897
+
2898
+ Per-unit effects fold into the glyph's tint (opacity) and an
2899
+ element-local offset applied BEFORE the element transform (§4.4), so
2900
+ kinetic type composes with scale/skew/rotation.
2901
+
2902
+ **\`text-flip\` semantics (CKP/1.0).** The rotation pivot is the unit's
2903
+ rest-layout center — the glyph cell's center for letter splits, the
2904
+ word's glyph bounding-box center for word splits — translated by the
2905
+ unit's current per-unit offset, so a unit sliding and flipping stays
2906
+ rigid. A word split rotates the word as ONE slab (its glyphs never
2907
+ splay). When multiple \`text-flip\` animations target an element, word
2908
+ rotations compose OUTSIDE letter rotations. Axis \`"x"\` flips up
2909
+ (rotation about the horizontal axis), \`"y"\` swings in, \`"z"\` spins
2910
+ in-plane. Like all 3D (§4.4.2), the depth component is orthographic
2911
+ without a \`camera\`; the foreshortening is \`cos θ\` exactly. An active
2912
+ \`text-flip\` puts the text element on the full-matrix path; elements
2913
+ without one are unaffected (§4.4.3 cost rules).
2914
+
2915
+ CKP/1.0 defines text animations as entrances only: \`time: "end"\` on a
2916
+ \`text-*\` animation MUST be ignored.
2917
+
2918
+ ### 6.6. Transitions (non-feature)
2919
+
2920
+ CKP/1.0 deliberately defines NO transition primitive — every transition
2921
+ decomposes into existing primitives, and the document is exactly what
2922
+ renders:
2923
+
2924
+ - **Crossfades, pushes, zoom swaps** — two overlapping elements with
2925
+ paired animations (AGENTS.md §"Transitions").
2926
+ - **Wipes (circular, linear, stripe, soft-edged)** — the incoming slide
2927
+ inside a masked group (§5.8.2) whose mask elements animate: a growing
2928
+ ellipse, a sweeping rectangle, a luma gradient band (AGENTS.md
2929
+ §"Wipes").
2930
+
2931
+ An earlier draft reserved a first-class two-layer transition object for
2932
+ wipes; group masks made it unnecessary and it is no longer planned.
2933
+
2934
+ ### 6.7. Spatial motion paths
2935
+
2936
+ A \`keyframe_animations\` entry with \`property: "position"\` moves the
2937
+ element along a path; it overrides the element's \`x\` and \`y\` (and
2938
+ any scalar \`x\`/\`y\` keyframe animations). Keyframe \`value\`s are
2939
+ \`[x, y]\` pairs in canvas pixels — or \`[x, y, z]\` triples for a 3D
2940
+ path (z in pixels, +z toward the viewer, §4.4). A 3D path
2941
+ additionally overrides the element's \`z\` (and any scalar \`z\`
2942
+ keyframe animations); a 2D path leaves \`z\` untouched. All keyframes
2943
+ of one position path MUST agree in dimensionality — mixing \`[x, y]\`
2944
+ and \`[x, y, z]\` in one animation is a validation error (no silent
2945
+ z = 0 promotion).
2946
+
2947
+ \`\`\`json
2948
+ { "property": "position", "auto_orient": true, "keyframes": [
2949
+ { "time": 0, "value": [200, 800], "out_tangent": [240, -300] },
2950
+ { "time": 2, "value": [1700, 300], "in_tangent": [-200, -120],
2951
+ "easing": "ease-in-out" }
2952
+ ] }
2953
+ \`\`\`
2954
+
2955
+ Each consecutive keyframe pair is one CUBIC BEZIER segment with
2956
+ control points
2957
+
2958
+ \`\`\`
2959
+ P0 = a.value P3 = b.value
2960
+ P1 = P0 + a.out_tangent P2 = P3 + b.in_tangent
2961
+ \`\`\`
2962
+
2963
+ where an omitted tangent defaults to the straight-line third-point
2964
+ (\`P1 = P0 + (P3−P0)/3\`, \`P2 = P3 − (P3−P0)/3\`) — a path with no
2965
+ handles is exact polyline motion. On a 3D path, tangents are
2966
+ \`[dx, dy]\` or \`[dx, dy, dz]\`; a 2-component tangent's \`dz\` defaults
2967
+ to the straight-line third-point in z (the omitted-handle rule,
2968
+ applied per axis). 3-component tangents on a 2D path are a
2969
+ validation error.
2970
+
2971
+ Travel is ARC-LENGTH parameterized (NORMATIVE): the destination
2972
+ keyframe's easing maps segment-local time to a fraction \`u\` of the
2973
+ segment's length; the bezier parameter is found on a 64-chord
2974
+ cumulative-length table (curve sampled at \`t = i/64\`, \`i = 0…64\`;
2975
+ linear interpolation between chords). With linear easing the element
2976
+ travels at constant speed however the handles stretch the curve's
2977
+ parameterization. On a 3D path the chord lengths are measured on the
2978
+ 3D curve — constant speed means constant speed through depth too.
2979
+ Before the first keyframe the element holds the first point; after
2980
+ the last, the last point.
2981
+
2982
+ \`auto_orient: true\` adds the path's travel direction —
2983
+ \`atan2(dy, dx)\` of the bezier derivative at the sampled parameter, in
2984
+ degrees — to the element's own \`rotation\`. Orientation is STRICTLY
2985
+ IN-PLANE: on a 3D path the tangent's xy projection is used and \`dz\`
2986
+ is ignored — auto_orient never derives \`x_rotation\` or \`y_rotation\`
2987
+ (a path does not tilt the element's plane). A zero xy derivative
2988
+ (z-only travel or coincident control points) falls back to the
2989
+ segment chord's xy projection. Position values are numbers (pixels);
2990
+ length strings are not valid inside path keyframes.
2991
+
2992
+ Like the \`z\` field itself (§4.4.2), path z has no visual effect
2993
+ without perspective somewhere in the chain — under no camera the
2994
+ element renders at the path's xy projection.
2995
+
2996
+ ---
2997
+
2998
+ ## 7. Time, duration, sequencing
2999
+
3000
+ ### 7.1. Element activity windows
3001
+
3002
+ An element is *active* at composition time \`t\` if:
3003
+
3004
+ \`\`\`
3005
+ start <= t <= start + duration
3006
+ \`\`\`
3007
+
3008
+ where:
3009
+
3010
+ - \`start = element.time\` (default \`0\`).
3011
+ - \`duration = element.duration\` if numeric, else the composition's
3012
+ remaining time if \`"auto"\` or \`"end"\`.
3013
+
3014
+ Inactive elements MUST NOT be rendered.
3015
+
3016
+ ### 7.2. Local time
3017
+
3018
+ Many properties (keyframes, named-animation timing, particle simulation)
3019
+ operate in *local time* — seconds elapsed since the element
3020
+ became active. Local time \`0\` corresponds to composition time
3021
+ \`element.time\`.
3022
+
3023
+ For animation evaluation, local time MUST be clamped to the element's
3024
+ duration:
3025
+
3026
+ \`\`\`
3027
+ local_t = min(t − element.time, element_duration)
3028
+ \`\`\`
3029
+
3030
+ Rationale: the activity check (§7.1) computes the element's end as
3031
+ \`time + duration\` while local time computes \`t − time\`; at exact frame
3032
+ boundaries these two float roundings can disagree by ~1 ulp, leaving an
3033
+ element active at a local time fractionally PAST its duration — which
3034
+ would skip every end-anchored animation for one frame and flash the
3035
+ static value. The clamp makes the boundary frame well-defined.
3036
+
3037
+ ### 7.3. The composition duration
3038
+
3039
+ If \`Source.duration\` is \`"auto"\`, the composition's effective duration
3040
+ is the maximum end time across all elements. Otherwise, it is the
3041
+ declared value.
3042
+
3043
+ Runtimes MUST produce frames from composition time \`0\` to \`duration\`
3044
+ inclusive of \`0\`, exclusive of \`duration\`. At 30 fps and \`duration: 5\`
3045
+ this produces 150 frames at times \`0, 1/30, 2/30, ..., 149/30\`.
3046
+
3047
+ ---
3048
+
3049
+ ## 8. Asset references
3050
+
3051
+ Asset-bearing elements (\`image\`, \`video\`, \`audio\`) carry a \`source\`
3052
+ string identifying the asset. The protocol does not embed binaries.
3053
+
3054
+ ### 8.1. Allowed schemes
3055
+
3056
+ Conforming runtimes MUST attempt to resolve:
3057
+
3058
+ - \`https://\` URLs
3059
+ - \`http://\` URLs (MAY require user opt-in for mixed content)
3060
+
3061
+ Conforming runtimes MAY additionally support:
3062
+
3063
+ - \`file://\` URLs (local file references)
3064
+ - Absolute paths (\`/path/to/file\`)
3065
+ - Relative paths (\`./asset.png\`) — resolved against an
3066
+ implementation-defined base
3067
+ - \`data:\` URIs
3068
+
3069
+ Runtimes MUST fail with a clear error when a \`source\` cannot be
3070
+ resolved. They MUST NOT silently substitute a placeholder.
3071
+
3072
+ ### 8.2. Preloading
3073
+
3074
+ Runtimes SHOULD provide a \`preload\` step that resolves all asset
3075
+ references before rendering begins. This is REQUIRED for export
3076
+ conformance (§10.3) — exports cannot tolerate runtime asset failures.
3077
+
3078
+ ---
3079
+
3080
+ ## 9. Output and rendering
3081
+
3082
+ ### 9.1. Determinism
3083
+
3084
+ Given identical \`(Source, time)\` inputs, every conforming runtime MUST
3085
+ produce visually equivalent frames. "Visually equivalent" means:
3086
+
3087
+ - Same element positions, sizes, rotations, opacities to within ±0.5 px.
3088
+ - Same animation values to within ±0.001 for normalized properties.
3089
+ - Same particle state (positions, alphas, sizes) to within ±0.5 px /
3090
+ ±0.001 alpha.
3091
+ - Color output MAY differ by up to ~1/255 per channel due to
3092
+ rasterization backends. Pixel-exact equivalence is NOT REQUIRED.
3093
+
3094
+ ### 9.2. Frame timing
3095
+
3096
+ A runtime asked to produce frame \`f\` of a composition with \`frame_rate\`
3097
+ FPS MUST render the scene state at composition time:
3098
+
3099
+ \`\`\`
3100
+ t = f / frame_rate
3101
+ \`\`\`
3102
+
3103
+ \`f = 0\` is the first frame.
3104
+
3105
+ ### 9.3. Output formats
3106
+
3107
+ | Format | Behavior |
3108
+ |---|---|
3109
+ | \`mp4\` | Encoded video. H.264 baseline + AAC audio is the recommended baseline. Higher profiles MAY be used. |
3110
+ | \`gif\` | Animated GIF. Audio is silently dropped. |
3111
+
3112
+ Runtimes MAY support output formats beyond these.
3113
+
3114
+ ---
3115
+
3116
+ ## 10. Conformance levels
3117
+
3118
+ CKP defines three nested conformance levels. An implementation MAY claim
3119
+ any level it actually supports.
3120
+
3121
+ ### 10.1. Level 1 — Validation
3122
+
3123
+ The implementation MUST be able to parse a Source JSON object and
3124
+ report whether it is a valid CKP/1.0 document. Specifically:
3125
+
3126
+ - Accept any valid document per §2–8.
3127
+ - Reject documents with invalid \`type\` discriminators on required
3128
+ fields, missing REQUIRED fields, or out-of-range values.
3129
+ - Tolerate unknown additional fields (forward compatibility, §2.2).
3130
+
3131
+ The \`@clipkit/protocol\` package provides Level 1 validation as a
3132
+ reference.
3133
+
3134
+ ### 10.2. Level 2 — Rendering
3135
+
3136
+ The implementation MUST also be able to produce frame images from a
3137
+ valid Source. Specifically:
3138
+
3139
+ - All element types (§5) MUST render.
3140
+ - All animations (§6) MUST animate.
3141
+ - Output MUST satisfy the determinism requirements (§9.1).
3142
+
3143
+ A Level 2 implementation MUST NOT silently skip element types unless
3144
+ the document is at a higher protocol version than the implementation
3145
+ supports.
3146
+
3147
+ ### 10.3. Level 3 — Export
3148
+
3149
+ The implementation MUST also be able to produce encoded output:
3150
+ typically MP4 with mixed audio.
3151
+
3152
+ - All Level 2 requirements.
3153
+ - Audio elements (§5.4) MUST be mixed into the output track.
3154
+ - Output duration MUST match \`Source.duration\` precisely (frame-accurate).
3155
+
3156
+ The reference runtime \`@clipkit/runtime\` is a Level 3 implementation: it
3157
+ sums all sources to the master bus, then applies a fixed peak limiter
3158
+ (transparent below 0 dBFS) so a hot mix is contained rather than
3159
+ hard-clipped. The same limiter runs in preview, so what you hear matches
3160
+ the render. The exact mix algorithm otherwise remains
3161
+ implementation-defined (§1).
3162
+
3163
+ ---
3164
+
3165
+ ## 11. Versioning and extensions
3166
+
3167
+ ### 11.1. The \`clipkit_version\` field
3168
+
3169
+ Documents SHOULD declare their protocol version:
3170
+
3171
+ \`\`\`json
3172
+ { "clipkit_version": "1.0", "elements": [...] }
3173
+ \`\`\`
3174
+
3175
+ Absence is interpreted as \`"1.0"\` for the lifetime of CKP/1.x.
3176
+
3177
+ ### 11.2. Version compatibility
3178
+
3179
+ Versions follow \`MAJOR.MINOR\` (semver-style without patch):
3180
+
3181
+ - **Same MINOR**: runtimes MUST render documents at the same minor
3182
+ version with no warning.
3183
+ - **Higher MINOR**: runtimes MUST attempt to render. They SHOULD warn
3184
+ that unknown fields may be ignored.
3185
+ - **Higher MAJOR**: runtimes MUST refuse to render and report the
3186
+ version mismatch. Major versions indicate breaking changes.
3187
+ - **Lower MAJOR**: runtimes MUST render if they implement the older
3188
+ major version. Backward compatibility within a major line is
3189
+ permanent.
3190
+
3191
+ ### 11.3. Adding fields
3192
+
3193
+ Any minor version MAY add fields to existing element types. Unknown
3194
+ fields in older runtimes pass through harmlessly per §2.2.
3195
+
3196
+ ### 11.4. Adding element types
3197
+
3198
+ Any minor version MAY add new element types. Runtimes that do not
3199
+ recognize a new \`type\` MAY skip the element with a warning.
3200
+
3201
+ ### 11.5. Breaking changes
3202
+
3203
+ Removing fields, changing field types, or changing the meaning of an
3204
+ existing field requires a major-version bump.
3205
+
3206
+ ### 11.6. Extension namespace
3207
+
3208
+ Implementations and tools MAY include vendor-specific fields prefixed
3209
+ with \`x_\`. The protocol reserves bare names; \`x_\` names are
3210
+ implementation-defined and ignored by other implementations. Names
3211
+ the protocol itself defines (e.g. \`x_scale\`, \`x_skew\`, \`x_rotation\`)
3212
+ are bare protocol names, not extensions, regardless of prefix.
3213
+
3214
+ ### 11.7. Version history
3215
+
3216
+ | Version | Additions |
3217
+ |---|---|
3218
+ | 1.0 | Initial protocol, including the 3D transform model (§4.4): \`x_rotation\` / \`y_rotation\` / \`z_rotation\` (alias of \`rotation\`) / \`z\` on every element; Source-level \`camera\`; paint-order + flattening compositing rules (§4.4.3); glass under 3D via the pane-plane homography (§4.7); 3D motion paths (\`[x, y, z]\` position keyframes, §6.7); \`text-flip\` per-unit 3D reveals (§6.5); particle depth (\`z_velocity\` / \`z_spread\`, §5.7.3). These are opt-in and additive — documents that use none of them render the same as a pure-2D source. |
3219
+
3220
+ ---
3221
+
3222
+ ## 12. Implementation notes (non-normative)
3223
+
3224
+ These notes are advisory. They reflect lessons from the reference
3225
+ implementation and MAY help third-party implementers avoid pitfalls.
3226
+
3227
+ ### 12.1. Premultiplied alpha
3228
+
3229
+ All blending in the reference runtime uses premultiplied alpha:
3230
+ textures are uploaded premultiplied, shaders output premultiplied
3231
+ values, and the canvas swap-chain is configured with
3232
+ \`alphaMode: "premultiplied"\`. This avoids the "dark halo" artifacts
3233
+ that appear with straight-alpha blending. Third-party runtimes are
3234
+ free to use any blending convention internally as long as final output
3235
+ matches §9.1.
3236
+
3237
+ ### 12.2. Corner radii on non-square rectangles
3238
+
3239
+ If you implement rounded rectangles with a signed-distance function
3240
+ in shaders, the SDF MUST operate in pixel space, not in normalized UV
3241
+ space. UV space is anisotropic for non-square rectangles, so doing the
3242
+ corner math in UV stretches the arc into an ellipse. Pass the
3243
+ rectangle's \`(width, height)\` to the shader and convert
3244
+ \`uv * size\` before the SDF.
3245
+
3246
+ ### 12.3. Font atlases
3247
+
3248
+ The reference runtime generates a glyph atlas per (family, size,
3249
+ weight). It covers ASCII (0x20–0x7E) only; characters outside this
3250
+ range are silently dropped. Third-party implementations MAY support
3251
+ larger glyph ranges; documents SHOULD avoid relying on non-ASCII text
3252
+ in v1.0 unless the target runtime is known to support it.
3253
+
3254
+ ### 12.4. Particle PRNG
3255
+
3256
+ The reference runtime uses a \`mulberry32\` PRNG seeded by
3257
+ \`FNV-1a(element.id) + n * 0x9e3779b9\`. Third-party runtimes are NOT
3258
+ required to match this exact PRNG, only the *outputs* implied by §5.7.1.
3259
+
3260
+ ---
3261
+
3262
+ ## Appendix A: Reference implementation map
3263
+
3264
+ | Spec section | Reference implementation |
3265
+ |---|---|
3266
+ | §2 Source | \`packages/protocol/src/types.ts\` (Source) + \`zod.ts\` (sourceSchema) |
3267
+ | §3 Units | \`packages/runtime/src/compositor/unit.ts\` |
3268
+ | §4 Element model | \`packages/protocol/src/types.ts\` (BaseElement) |
3269
+ | §5.1 shape | \`packages/runtime/src/compositor/element-renderers/shape.ts\` |
3270
+ | §5.2 text | \`packages/runtime/src/compositor/element-renderers/text.ts\` |
3271
+ | §5.3–5.4 video/image/audio | \`packages/runtime/src/{compositor/element-renderers,audio}/\` |
3272
+ | §5.5 caption | \`packages/runtime/src/compositor/element-renderers/caption.ts\` |
3273
+ | §5.6 shape paths | \`packages/runtime/src/svg/svg-renderer.ts\` |
3274
+ | §5.7 particles | \`packages/runtime/src/compositor/element-renderers/particles.ts\` |
3275
+ | §6 Animation | \`packages/runtime/src/animation/\` |
3276
+ | §10 Conformance | \`packages/protocol/src/validate.ts\` |
3277
+
3278
+ ## Appendix B: Document history
3279
+
3280
+ - *2026-05-28* — CKP/1.0 draft. Initial publication alongside the
3281
+ reference runtime.
3282
+ `;
3283
+ export const BRAND_MD = `# Clipkit — Brand
3284
+
3285
+ > The load-bearing version of the Clipkit brand. AI agents and human
3286
+ > contributors read this; the visual companion (with rendered swatches
3287
+ > and type specimens) lives at [\`brand/board.html\`](./brand/board.html).
3288
+ > When the two disagree, this file wins.
3289
+
3290
+ ---
3291
+
3292
+ ## 1. Essence
3293
+
3294
+ **The video runtime for agents.**
3295
+
3296
+ Clipkit is a developer tool — a GPU-rendered video runtime built on the
3297
+ open Clipkit Protocol — and it should feel like one: **precise, fast,
3298
+ and unfussy**. The visual language borrows from modern dev infrastructure
3299
+ brands — Vercel, Linear, Resend — dark by default, mostly monochrome,
3300
+ with two confident accent colors doing all the talking.
3301
+
3302
+ The runtime turns a **JSON timeline into a rendered video**. That
3303
+ duality — structured code on one side, motion on the other — is the
3304
+ heart of the brand. Wherever possible, show the JSON and the timeline
3305
+ together.
3306
+
3307
+ The Clipkit Protocol is what makes the runtime portable: a documented,
3308
+ versioned JSON format that anyone can implement. It's a credibility
3309
+ feature, not the headline. Lead with the runtime; the protocol shows up
3310
+ as *"how is this possible? → via the open Clipkit Protocol."*
3311
+
3312
+ | Attribute | Value |
3313
+ |---|---|
3314
+ | Personality | Precise · technical · honest · quietly confident |
3315
+ | Reference brands | Vercel, Linear, Resend |
3316
+ | Audience | AI agents and the developers shipping them |
3317
+ | Default theme | Dark |
3318
+ | Mood | Calm surface, punchy accents |
3319
+ | Never | Playful, gradient-heavy, emoji-led, salesy |
3320
+
3321
+ ---
3322
+
3323
+ ## 2. Logo
3324
+
3325
+ Three equal-length rounded bars — **white, yellow, red** — offset
3326
+ horizontally and stacked like clips on a timeline. The mark *is* the
3327
+ product: tracks of media, layered in time.
3328
+
3329
+ ### Construction
3330
+
3331
+ - Three bars of equal width and height.
3332
+ - Bar width = 65% of container width.
3333
+ - Bar height = 29% of container height (~3 stacked bars + 2 small gaps).
3334
+ - Stagger:
3335
+ - **Bar 1 (top)** — white \`#FAFAFA\`, offset right (\`left: 33%\` of container).
3336
+ - **Bar 2 (middle)** — yellow \`#FFB800\`, flush left (\`left: 0\`).
3337
+ - **Bar 3 (bottom)** — red \`#EF4444\`, offset right (\`left: 35%\` of container).
3338
+ - Bar corner radius ≈ 18% of bar height (rounded, not pill-shaped).
3339
+ - Wordmark when locked up: Geist 600, tracking −0.02em, gap 14px from
3340
+ the mark.
3341
+
3342
+ ### Do
3343
+
3344
+ - Keep the three-bar stagger and the white / yellow / red order, top
3345
+ to bottom.
3346
+ - Use the icon alone at small sizes; pair with the wordmark elsewhere.
3347
+ - Give the mark clear space equal to one bar's height on all sides.
3348
+ - Set the wordmark in Geist, weight 600, tight tracking (−0.02em).
3349
+
3350
+ ### Don't
3351
+
3352
+ - Recolor the bars or reorder them (the order encodes the timeline).
3353
+ - Add gradients, glows, bevels, or drop shadows to the mark.
3354
+ - Rotate, skew, or outline the bars.
3355
+ - Set the wordmark in any font other than Geist.
3356
+
3357
+ ### Variants
3358
+
3359
+ | Surface | Bar 1 | Bar 2 | Bar 3 | Wordmark |
3360
+ |---|---|---|---|---|
3361
+ | Dark \`#0A0A0A\` (canonical) | \`#FAFAFA\` | \`#FFB800\` | \`#EF4444\` | \`#FAFAFA\` |
3362
+ | Cream \`#FAF8F3\` | \`#181717\` | \`#FFB800\` | \`#EF4444\` | \`#181717\` |
3363
+ | Red \`#EF4444\` (reversed) | \`#FFFFFF\` | \`#FFE08A\` | \`#7A1414\` | \`#FFFFFF\` |
3364
+
3365
+ ### Sizing
3366
+
3367
+ - Minimum width: **88px** (icon + wordmark lockup) or **24px** (icon
3368
+ alone).
3369
+ - Favicon: icon alone, square crop with 12% padding.
3370
+
3371
+ ---
3372
+
3373
+ ## 3. Color
3374
+
3375
+ Two brand accents, one near-black surface system. Accents are for
3376
+ **emphasis only** — a single red dot or yellow word carries more weight
3377
+ than a field of color. Aim for **90% neutral, 10% accent**.
3378
+
3379
+ ### Brand accents
3380
+
3381
+ | Token | Hex | RGB | OKLCH | Role |
3382
+ |---|---|---|---|---|
3383
+ | \`--red\` | \`#EF4444\` | \`239 68 68\` | \`0.64 0.21 25\` | **Primary.** Playhead, live/record state, key emphasis, the bottom logo bar. |
3384
+ | \`--yellow\` | \`#FFB800\` | \`255 184 0\` | \`0.81 0.17 82\` | **Secondary.** Active caption word, string literals in code, the middle logo bar, selection background. |
3385
+
3386
+ ### Accent variants
3387
+
3388
+ | Variant | Red | Yellow | When to use |
3389
+ |---|---|---|---|
3390
+ | Loud | \`#FF3030\` | \`#FFCA1F\` | Very small UI elements where the canonical hex looks under-saturated. |
3391
+ | Muted | \`#C25555\` | \`#C99A3B\` | Inline text emphasis where the canonical hex is too loud. |
3392
+
3393
+ Selection fill is yellow on dark text: \`background: var(--yellow); color: #181717\`.
3394
+
3395
+ ### Dark surface system (canonical)
3396
+
3397
+ Used everywhere unless explicitly in light mode.
3398
+
3399
+ | Token | Hex | Role |
3400
+ |---|---|---|
3401
+ | \`--bg\` | \`#0A0A0A\` | Page background. |
3402
+ | \`--bg-2\` | \`#111111\` | Slight elevation (subtle stripes, table headers). |
3403
+ | \`--bg-3\` | \`#161616\` | Filled controls inside surfaces. |
3404
+ | \`--surface\` | \`#141414\` | Card / panel background. |
3405
+ | \`--border\` | \`#232323\` | Hairline dividers. |
3406
+ | \`--border-strong\` | \`#2E2E2E\` | Active or hovered borders. |
3407
+ | \`--fg\` | \`#FAFAFA\` | Primary text. |
3408
+ | \`--fg-2\` | \`#E5E5E5\` | Body text. |
3409
+ | \`--muted\` | \`#8A8A8A\` | Secondary text, labels. |
3410
+ | \`--muted-2\` | \`#5F5F5F\` | Tertiary / placeholder text. |
3411
+ | \`--code-bg\` | \`#0E0E0E\` | Code blocks. |
3412
+
3413
+ ### Light surface system (warm cream)
3414
+
3415
+ Used only when a surface explicitly needs to be light — landing-page
3416
+ sections, customer assets, etc.
3417
+
3418
+ | Token | Hex | Role |
3419
+ |---|---|---|
3420
+ | \`--bg\` | \`#FAF8F3\` | Page background. |
3421
+ | \`--bg-2\` | \`#F4F1E9\` | Slight elevation. |
3422
+ | \`--surface\` | \`#FFFFFF\` | Card / panel background. |
3423
+ | \`--border\` | \`#E5E0D2\` | Hairline dividers. |
3424
+ | \`--border-strong\` | \`#D6D0BF\` | Active borders. |
3425
+ | \`--fg\` | \`#181717\` | Primary text. |
3426
+ | \`--muted\` | \`#6A6864\` | Secondary text. |
3427
+ | \`--muted-2\` | \`#9C9A94\` | Tertiary text. |
3428
+
3429
+ ---
3430
+
3431
+ ## 4. Typography
3432
+
3433
+ ### Geist & Geist Mono
3434
+
3435
+ **Geist Sans** for everything human-readable — headlines, body, UI.
3436
+ **Geist Mono** for everything machine — code, labels, eyebrows, metrics,
3437
+ timestamps, captions of intent. The sans/mono split mirrors the
3438
+ product's human-vs-machine duality.
3439
+
3440
+ Headlines run **tight**: weight 600, tracking −0.02em to −0.035em as
3441
+ size grows. Body stays at weight 400, 1.6 line-height. **Never use a
3442
+ third family.**
3443
+
3444
+ ### Type scale
3445
+
3446
+ | Token | Family | Weight | Tracking | Example use |
3447
+ |---|---|---|---|---|
3448
+ | Display | Geist | 600 | −0.035em | Hero headlines, "JSON in. Video out." |
3449
+ | Heading | Geist | 600 | −0.02em | Section headers, "The video runtime for AI" |
3450
+ | Body | Geist | 400 | 0 | Paragraphs. |
3451
+ | Eyebrow / label | Geist Mono | 500 | +0.08em, uppercase | "how it works", "powered by mux data" |
3452
+ | Code | Geist Mono | 400 | 0 | JSON snippets, terminal output. |
3453
+
3454
+ ### Loading Geist
3455
+
3456
+ \`\`\`html
3457
+ <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;900&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
3458
+ \`\`\`
3459
+
3460
+ ### Code syntax coloring (when rendering JSON)
3461
+
3462
+ The brand uses syntax coloring in JSON specimens:
3463
+
3464
+ - **Keys** — \`var(--muted)\` (#8A8A8A) or \`var(--fg-2)\` (#E5E5E5).
3465
+ - **Strings** — \`var(--yellow)\` (#FFB800).
3466
+ - **Numbers** — \`var(--red)\` (#EF4444).
3467
+ - **Booleans / null** — \`var(--muted)\`.
3468
+ - **Punctuation** (\`:\` \`,\` \`{\` \`}\` \`[\` \`]\`) — \`var(--muted-2)\` (#5F5F5F).
3469
+
3470
+ This mapping is the same in the brand board's code block. Use it
3471
+ consistently — it makes JSON specimens immediately recognizable as
3472
+ Clipkit.
3473
+
3474
+ ---
3475
+
3476
+ ## 5. The timeline motif
3477
+
3478
+ The logo's three bars extend into a full visual system: stacked tracks,
3479
+ a red playhead, and offset blocks. Use it for dividers, section markers,
3480
+ hero visuals, and empty states.
3481
+
3482
+ | Motif | Use |
3483
+ |---|---|
3484
+ | **Tracks + playhead** | Hero visuals, loaders, anything showing the product compose. Three stacked tracks (background \`var(--bg-3)\`), each with colored blocks at offset positions; vertical red playhead line crosses them. |
3485
+ | **Frame bars** | Render / GPU contexts. Row of thin vertical bars at varied heights — accent only the first 1–2 bars (white, then yellow). The rest are \`var(--border-strong)\`. |
3486
+ | **Ghost bars** | Oversized, low-opacity background accent behind CTAs. Three logo bars at ~14–60% opacity, scaled up, mostly off-canvas. |
3487
+
3488
+ The motif is not the same as the logo. The logo is locked geometry;
3489
+ the motif is a flexible visual system inspired by it.
3490
+
3491
+ ---
3492
+
3493
+ ## 6. Voice & tone
3494
+
3495
+ Write like a senior engineer explaining a tool to a peer: **direct,
3496
+ concrete, a little opinionated, never hype.** Lead with the technical
3497
+ truth. Respect the reader's intelligence. Admit the limits — the honest
3498
+ comparison with Remotion is a feature, not a risk.
3499
+
3500
+ Short sentences. Active voice. Real numbers over adjectives. When in
3501
+ doubt, show the JSON instead of describing it.
3502
+
3503
+ | Trait | In practice |
3504
+ |---|---|
3505
+ | Direct | "JSON in. Video out." |
3506
+ | Concrete | "renders in 3.4s" not "blazing fast" |
3507
+ | Honest | "If you're hand-authoring, use Remotion." |
3508
+ | Lowercase labels | \`how it works\`, \`mcp\`, \`agents\` |
3509
+
3510
+ ### We say / we don't say
3511
+
3512
+ | We say | Not |
3513
+ |---|---|
3514
+ | The video runtime for agents. | The ultimate AI-powered video platform! |
3515
+ | Describe the video. Render the video. | Create stunning videos in seconds 🚀 |
3516
+ | GPU-rendered. In your browser. | Cloud-based AI rendering pipeline. |
3517
+ | Built on the open Clipkit Protocol. | Proprietary AI technology. |
3518
+ | The schema is the protocol. | Powerful, intuitive, easy-to-use API. |
3519
+ | Pay per second of output. No minimums. | Flexible pricing for teams of all sizes. |
3520
+
3521
+ ### Do
3522
+
3523
+ - Lead with the technical truth.
3524
+ - Show numbers, not adjectives ("renders in 3.4s" not "blazing fast").
3525
+ - Admit limits when they exist (honest Remotion comparison is fine).
3526
+ - Use lowercase for mono labels and eyebrows.
3527
+ - Show JSON instead of describing it when in doubt.
3528
+
3529
+ ### Don't
3530
+
3531
+ - Use emoji.
3532
+ - Use exclamation marks.
3533
+ - Say "blazing," "stunning," "powerful," "intuitive," "easy-to-use," or
3534
+ any near-synonym.
3535
+ - Use marketing-speak superlatives ("the ultimate," "the best,"
3536
+ "next-generation").
3537
+
3538
+ ---
3539
+
3540
+ ## 7. Copy-paste tokens
3541
+
3542
+ Drop this into \`:root\` and you have the dark theme.
3543
+
3544
+ \`\`\`css
3545
+ /* Clipkit — brand tokens (dark, canonical) */
3546
+ --red: #EF4444; --yellow: #FFB800;
3547
+ --bg: #0A0A0A; --bg-2: #111111; --bg-3: #161616;
3548
+ --surface: #141414; --border: #232323; --border-strong: #2E2E2E;
3549
+ --fg: #FAFAFA; --fg-2: #E5E5E5;
3550
+ --muted: #8A8A8A; --muted-2: #5F5F5F; --code-bg: #0E0E0E;
3551
+
3552
+ /* fonts — Geist + Geist Mono (Google Fonts) */
3553
+ --font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
3554
+ --font-mono: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace;
3555
+ \`\`\`
3556
+
3557
+ Light-theme overrides:
3558
+
3559
+ \`\`\`css
3560
+ --bg: #FAF8F3; --surface: #FFFFFF; --border: #E5E0D2;
3561
+ --fg: #181717; --muted: #6A6864;
3562
+ \`\`\`
3563
+
3564
+ ---
3565
+
3566
+ ## 8. One-line brief for an AI
3567
+
3568
+ For pasting into a system prompt when authoring Clipkit-branded assets:
3569
+
3570
+ > Clipkit is a developer tool — "the video runtime for agents." A
3571
+ > GPU-rendered engine that turns a JSON timeline into MP4, built for
3572
+ > LLMs, agents, and the apps that ship them. It's powered by the open
3573
+ > Clipkit Protocol but lead with the runtime, not the protocol — the
3574
+ > protocol is a feature credit. Dark by default (\`#0A0A0A\` bg,
3575
+ > \`#FAFAFA\` text), Geist + Geist Mono. Two accents only: red \`#EF4444\`
3576
+ > (playhead/live/emphasis) and yellow \`#FFB800\` (active caption word /
3577
+ > code strings). ~90% neutral, 10% accent. Logo is three equal,
3578
+ > horizontally-offset rounded bars (white, yellow, red, top to bottom)
3579
+ > like timeline clips. Voice: direct, concrete, honest, no hype, no
3580
+ > emoji. Show JSON, not adjectives.
3581
+
3582
+ ---
3583
+
3584
+ ## Document history
3585
+
3586
+ - *2026-05-28* — Initial port from \`brand/board.html\` (v1 of the brand
3587
+ board).
3588
+ `;
3589
+ //# sourceMappingURL=embedded-docs.js.map