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