@nodetool-ai/agents 0.8.4-a24.3 → 0.8.4

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 (135) hide show
  1. package/dist/.nodetool-build-stamp +1 -1
  2. package/dist/_skills/LICENSE-iart-motion-design-skills +21 -0
  3. package/dist/_skills/README.md +211 -0
  4. package/dist/_skills/api-agents/SKILL.md +58 -0
  5. package/dist/_skills/api-apps/SKILL.md +54 -0
  6. package/dist/_skills/api-assets/SKILL.md +66 -0
  7. package/dist/_skills/api-collections/SKILL.md +33 -0
  8. package/dist/_skills/api-games/SKILL.md +81 -0
  9. package/dist/_skills/api-media/SKILL.md +131 -0
  10. package/dist/_skills/api-memory/SKILL.md +72 -0
  11. package/dist/_skills/api-models/SKILL.md +78 -0
  12. package/dist/_skills/api-scripts/SKILL.md +79 -0
  13. package/dist/_skills/api-settings/SKILL.md +49 -0
  14. package/dist/_skills/api-sketches/SKILL.md +61 -0
  15. package/dist/_skills/api-storyboards/SKILL.md +94 -0
  16. package/dist/_skills/api-timelines/SKILL.md +151 -0
  17. package/dist/_skills/api-web/SKILL.md +57 -0
  18. package/dist/_skills/api-workflows/SKILL.md +123 -0
  19. package/dist/_skills/beat-sync-editing/SKILL.md +208 -0
  20. package/dist/_skills/caption-titles/SKILL.md +94 -0
  21. package/dist/_skills/color-motion/SKILL.md +185 -0
  22. package/dist/_skills/commercial-beat-sheet/SKILL.md +338 -0
  23. package/dist/_skills/elevenlabs-audio-prompting/SKILL.md +163 -0
  24. package/dist/_skills/explainer-storyboard/SKILL.md +63 -0
  25. package/dist/_skills/flux-2-klein-prompting/SKILL.md +107 -0
  26. package/dist/_skills/frame-composition/SKILL.md +291 -0
  27. package/dist/_skills/game-direction/SKILL.md +99 -0
  28. package/dist/_skills/gpt-image-2-prompting/SKILL.md +161 -0
  29. package/dist/_skills/hailuo-prompting/SKILL.md +107 -0
  30. package/dist/_skills/kling-video-prompting/SKILL.md +188 -0
  31. package/dist/_skills/launch-commercial/SKILL.md +267 -0
  32. package/dist/_skills/launch-kit/SKILL.md +120 -0
  33. package/dist/_skills/logo-reveal/SKILL.md +225 -0
  34. package/dist/_skills/manimgx/SKILL.md +123 -0
  35. package/dist/_skills/minimax-h3-prompting/SKILL.md +134 -0
  36. package/dist/_skills/motion-ad/SKILL.md +281 -0
  37. package/dist/_skills/motion-background/SKILL.md +145 -0
  38. package/dist/_skills/motion-curves/SKILL.md +302 -0
  39. package/dist/_skills/motion-direction/SKILL.md +259 -0
  40. package/dist/_skills/motion-graphics/SKILL.md +217 -0
  41. package/dist/_skills/motion-principles/SKILL.md +255 -0
  42. package/dist/_skills/music-video-treatment/SKILL.md +40 -0
  43. package/dist/_skills/nano-banana-pro-prompting/SKILL.md +133 -0
  44. package/dist/_skills/native-game/SKILL.md +310 -0
  45. package/dist/_skills/native-game-reference/SKILL.md +879 -0
  46. package/dist/_skills/nodetool-3d-scene/SKILL.md +110 -0
  47. package/dist/_skills/nodetool-api-reference/SKILL.md +338 -0
  48. package/dist/_skills/nodetool-app-builder/SKILL.md +291 -0
  49. package/dist/_skills/nodetool-browser-agent/SKILL.md +119 -0
  50. package/dist/_skills/nodetool-chat-cli/SKILL.md +184 -0
  51. package/dist/_skills/nodetool-custom-node-developer/SKILL.md +294 -0
  52. package/dist/_skills/nodetool-deployment/SKILL.md +195 -0
  53. package/dist/_skills/nodetool-js-scripting/SKILL.md +223 -0
  54. package/dist/_skills/nodetool-model-provider-config/SKILL.md +348 -0
  55. package/dist/_skills/nodetool-rag-indexing/SKILL.md +153 -0
  56. package/dist/_skills/nodetool-sketch/SKILL.md +138 -0
  57. package/dist/_skills/nodetool-skill-author/SKILL.md +125 -0
  58. package/dist/_skills/nodetool-troubleshooter/SKILL.md +370 -0
  59. package/dist/_skills/nodetool-video-post/SKILL.md +149 -0
  60. package/dist/_skills/nodetool-workflow-builder/SKILL.md +295 -0
  61. package/dist/_skills/product-commercial/SKILL.md +132 -0
  62. package/dist/_skills/qwen-image-prompting/SKILL.md +122 -0
  63. package/dist/_skills/script-video/SKILL.md +106 -0
  64. package/dist/_skills/seedance-2-prompting/SKILL.md +194 -0
  65. package/dist/_skills/seedream-prompting/SKILL.md +117 -0
  66. package/dist/_skills/short-film/SKILL.md +123 -0
  67. package/dist/_skills/stable-audio-prompting/SKILL.md +130 -0
  68. package/dist/_skills/storyboard-core/SKILL.md +716 -0
  69. package/dist/_skills/timeline-edit-ops/SKILL.md +736 -0
  70. package/dist/_skills/trailer-template/SKILL.md +291 -0
  71. package/dist/_skills/ugc-video/SKILL.md +186 -0
  72. package/dist/_skills/veo-3-prompting/SKILL.md +140 -0
  73. package/dist/_skills/video-audio-continuity/SKILL.md +90 -0
  74. package/dist/_skills/video-clone/SKILL.md +96 -0
  75. package/dist/_skills/video-workflow/SKILL.md +119 -0
  76. package/dist/_skills/wan-2-6-prompting/SKILL.md +107 -0
  77. package/dist/app-build/bridge.d.ts.map +1 -1
  78. package/dist/app-build/bridge.js +44 -17
  79. package/dist/app-build/bridge.js.map +1 -1
  80. package/dist/capabilities/apps.d.ts.map +1 -1
  81. package/dist/capabilities/apps.js +55 -13
  82. package/dist/capabilities/apps.js.map +1 -1
  83. package/dist/capabilities/apps.specs.d.ts.map +1 -1
  84. package/dist/capabilities/apps.specs.js +5 -3
  85. package/dist/capabilities/apps.specs.js.map +1 -1
  86. package/dist/capabilities/game-asset-browser.d.ts.map +1 -1
  87. package/dist/capabilities/game-asset-browser.js +6 -1
  88. package/dist/capabilities/game-asset-browser.js.map +1 -1
  89. package/dist/capabilities/game.d.ts +1 -1
  90. package/dist/capabilities/game.d.ts.map +1 -1
  91. package/dist/capabilities/game.js +71 -56
  92. package/dist/capabilities/game.js.map +1 -1
  93. package/dist/capabilities/game.specs.js +1 -1
  94. package/dist/capabilities/game.specs.js.map +1 -1
  95. package/dist/capabilities/game3d.d.ts.map +1 -1
  96. package/dist/capabilities/game3d.js +14 -19
  97. package/dist/capabilities/game3d.js.map +1 -1
  98. package/dist/capabilities/gameAssertions.d.ts +52 -0
  99. package/dist/capabilities/gameAssertions.d.ts.map +1 -0
  100. package/dist/capabilities/gameAssertions.js +32 -0
  101. package/dist/capabilities/gameAssertions.js.map +1 -0
  102. package/dist/capabilities/models.d.ts.map +1 -1
  103. package/dist/capabilities/models.js +9 -0
  104. package/dist/capabilities/models.js.map +1 -1
  105. package/dist/capabilities/storyboards.d.ts.map +1 -1
  106. package/dist/capabilities/storyboards.js +36 -11
  107. package/dist/capabilities/storyboards.js.map +1 -1
  108. package/dist/capabilities/storyboards.specs.d.ts.map +1 -1
  109. package/dist/capabilities/storyboards.specs.js +11 -1
  110. package/dist/capabilities/storyboards.specs.js.map +1 -1
  111. package/dist/capabilities/workflows.d.ts.map +1 -1
  112. package/dist/capabilities/workflows.js +1 -0
  113. package/dist/capabilities/workflows.js.map +1 -1
  114. package/dist/capabilities/workflows.specs.d.ts.map +1 -1
  115. package/dist/capabilities/workflows.specs.js +4 -3
  116. package/dist/capabilities/workflows.specs.js.map +1 -1
  117. package/dist/evals/surfaces/game.d.ts.map +1 -1
  118. package/dist/evals/surfaces/game.js +29 -1
  119. package/dist/evals/surfaces/game.js.map +1 -1
  120. package/dist/game-authoring.d.ts +120 -0
  121. package/dist/game-authoring.d.ts.map +1 -1
  122. package/dist/game-build-integrity.d.ts +12 -0
  123. package/dist/game-build-integrity.d.ts.map +1 -0
  124. package/dist/game-build-integrity.js +78 -0
  125. package/dist/game-build-integrity.js.map +1 -0
  126. package/dist/game-examples.d.ts +44 -3
  127. package/dist/game-examples.d.ts.map +1 -1
  128. package/dist/game-examples.js +60 -10
  129. package/dist/game-examples.js.map +1 -1
  130. package/dist/system-skills.d.ts +1 -1
  131. package/dist/system-skills.js +1 -1
  132. package/dist/tools/builtin-tools.d.ts.map +1 -1
  133. package/dist/tools/builtin-tools.js +2 -0
  134. package/dist/tools/builtin-tools.js.map +1 -1
  135. package/package.json +19 -19
@@ -0,0 +1,736 @@
1
+ ---
2
+ name: timeline-edit-ops
3
+ description: Reference for editing a NodeTool timeline that already exists with edit_timeline ops — animate_clip roles and presets, the easing grammar, per-word/character stagger, set_clip_params limits, document-only fields (styleTracks, textAnimator, animationLinks, layout, camera2d, motionBlur, repeater), groups, transitions, masks and mattes, clip effects, time remap, compositions, beat-cutting ops, and validate_timeline's/preview_timeline_frame's full mechanics. Use for a few changes to a cut that already exists — retiming a clip, swapping a preset, nudging a stagger. For a new cut, or a scene with many clips, build it with the @nodetool-ai/sandbox-timeline pack instead; see motion-graphics.
4
+ ---
5
+
6
+ # Timeline edit ops → the `edit_timeline` reference
7
+
8
+ This is the op-by-op contract for changing a timeline document that already
9
+ exists. `motion-graphics` owns the build loop and picks the surface: a new
10
+ cut goes through the `@nodetool-ai/sandbox-timeline` pack, which handles
11
+ tracks, groups, layout and most of what follows here for you. Reach for this
12
+ skill once that document exists and needs a few changes.
13
+
14
+ Ops are the right tool when the timeline has no authoring code (`code.get`
15
+ answers `code: null`), or for a deliberate hand tweak on one that does. On a
16
+ code-backed timeline, an op inside a scene the code tracks marks that scene
17
+ "edited": the next `code.set`/`code.edit`/`code.rebake` (`api-timelines`)
18
+ keeps your edit and reports it in `conflicts` rather than overwriting it,
19
+ unless `force` says otherwise.
20
+
21
+ ## Read before you edit
22
+
23
+ Call `get_timeline` first. You need the sequence's `fps`, `width`, `height`,
24
+ the track list with each track's `index` and `type`, and every clip's
25
+ `startMs`, `durationMs`, `mediaType` and existing `animations`. Edit nothing
26
+ until you have clip ids from the document — never a guessed one.
27
+
28
+ Snapshot with `create_timeline_version` before your first edit. Motion work is
29
+ iterative and a wrong `mode: "replace"` wipes animations somebody wrote.
30
+
31
+ `edit_timeline` takes a timeline ID and an `ops` array, run in order, up to
32
+ 60 per call. Inspect each op's `ok` and the batch's `failed` result before a
33
+ dependent edit. In an in-product agent, use the matching `ui_timeline_*` tool
34
+ for an individual op:
35
+
36
+ ```json
37
+ {"timeline_id":"<id>","ops":[{"op":"animate_clip","target":"Title","animations":[{"role":"in","preset":"fade","durationMs":400}]}]}
38
+ ```
39
+
40
+ The `animate_clip` input accepts catalog presets or `custom` curves and code,
41
+ plus `stagger` and a typewriter `caret`. It does **not** author `styleTracks`,
42
+ `textAnimator`, or `beat`. `set_clip_params` does **not** author `layout`,
43
+ `repeater`, `motionBlur`, `steppedTime`, `temporalEcho`, `animationLinks`, or
44
+ `transform.depthPx`. `set_clip_params` refuses a call that names one of
45
+ those keys, and `animate_clip` drops the fields it does not author. For those fields, read `get_timeline`'s `timeline` object, change
46
+ the needed clip or sequence fields, and call `set_timeline_document` with a
47
+ complete document assembled from it. Preserve tracks, clips, markers, tempo,
48
+ setup, templateId, mediaTracks, transcript, scriptEnabled, and camera2d when
49
+ present. Include `timeline.updatedAt` as `expected_updated_at` so a
50
+ concurrent edit is refused. A whole-document write validates the result and
51
+ snapshots the prior version. Omitted document fields are removed, so never
52
+ send only the changed clip. The JSON objects in the document sections below
53
+ are fields to merge into that complete document, not `edit_timeline` ops.
54
+ The editor may lack controls for them even though they render and survive a
55
+ save.
56
+
57
+ ## Track layering
58
+
59
+ **Lowest `index` renders on top.** Track 0 covers track 1, which covers track 2
60
+ — Premiere, Resolve and FCP order, and the opposite of a paint stack. The
61
+ compositor turns the index into `z = 1000 - index`; `preview_timeline_frame`
62
+ reports that `z_index` per layer and lists layers top of the stack first.
63
+
64
+ Five track types, and the type is a role, not a restriction on what draws:
65
+
66
+ | Type | Holds | Use for |
67
+ |---|---|---|
68
+ | `video` | picture clips, images | the cut itself |
69
+ | `overlay` | text and shape clips | titles, lower-thirds, scrims, callouts |
70
+ | `audio` | sound | music, voiceover, effects — never drawn |
71
+ | `subtitle` | text | burned-in captions |
72
+ | `midi` | played notes | a synthesized part — never drawn |
73
+
74
+ Rules that follow from the ordering:
75
+
76
+ - A scrim goes on a **higher** index than the text it sits behind, and a lower
77
+ index than the picture it darkens — so picture, then scrim, then text, in
78
+ descending index order.
79
+ - Give each motion element its own overlay track when their timings overlap.
80
+ Two animated clips on one track that overlap in time trigger an automatic
81
+ crossfade you did not ask for (see Transitions).
82
+ - Captions are the exception you do not control: word-level captions from a
83
+ voiceover clip composite above every real track regardless of which track
84
+ that clip sits on. Do not build a title that expects to cover them.
85
+ - An audio clip never contributes a layer. A frame that looks empty because
86
+ everything is on audio tracks is a track-type mistake.
87
+ - At most **8 video layers** composite at once, resolved top track first. Text,
88
+ shape and image clips are not counted, so a stack of titles is unaffected —
89
+ but nine overlapping video clips means the bottom one silently does not draw.
90
+ A preview frame is how you find that out.
91
+
92
+ ## Build the elements
93
+
94
+ The clips you animate are made with the same `edit_timeline` op list.
95
+
96
+ ```json
97
+ [
98
+ {"op":"add_text_clip","text":"Maya Chen","trackId":"<overlay>","startMs":4000,"durationMs":3000,"fontSizePx":64},
99
+ {"op":"add_shape_clip","shape":{"kind":"rect","x":0,"y":0.72,"width":1,"height":0.18,"fill":"#000000"},"startMs":4000,"durationMs":3000,"opacity":0.55}
100
+ ]
101
+ ```
102
+
103
+ `add_text_clip` needs only `text`; the style fields (`fontSizePx`, `color`,
104
+ `background`, `stroke`) are read from the op itself or from a `style` bag, and
105
+ with no `trackId` it lands on an overlay track. `add_shape_clip` takes its
106
+ geometry in `shape` (or `shapeStyle`, or the op's own keys) with `x`, `y`,
107
+ `width` and `height` as 0..1 fractions of the frame — no geometry at all is a
108
+ full-frame rect, which is the scrim you usually want.
109
+
110
+ Tracks are the z-order, so how they are made matters:
111
+
112
+ ```json
113
+ [
114
+ {"op":"add_track","type":"overlay","name":"Titles"},
115
+ {"op":"move_track","target":"Picture","toIndex":2}
116
+ ]
117
+ ```
118
+
119
+ `add_track` takes `type` (`video`, `audio`, `overlay`, `subtitle`, `midi`) and an
120
+ optional `name`, and **appends to the bottom** — the highest index, which draws
121
+ last. A picture track added after the overlays therefore covers them. Add the
122
+ overlays last, or move the picture down afterwards: `move_track` takes a
123
+ `target` and one of `toIndex` (0 is the top), `before` or `after`.
124
+
125
+ ## Animating a clip
126
+
127
+ `edit_timeline` with `{"op": "animate_clip", "target": <clip id or name>,
128
+ "animations": [...]}`. Each animation is `{role, preset, durationMs?, delayMs?,
129
+ easing?, params?}`. `mode` is `"replace"` (default) or `"add"`.
130
+
131
+ Four roles. A clip may carry several, one of each kind or more:
132
+
133
+ - **`in`** — entrance. `delayMs` offsets from the clip's start.
134
+ - **`out`** — exit. `delayMs` offsets backwards from the clip's **end**, so 0
135
+ means it finishes exactly as the clip ends. This is the one people get wrong.
136
+ - **`emphasis`** — a beat mid-clip. `delayMs` from the clip's start.
137
+ - **`loop`** — runs continuously; `durationMs` is one cycle, not the total.
138
+ `kenBurns` is the exception: it runs once across the whole clip and holds.
139
+
140
+ ### The presets
141
+
142
+ `in` / `out` — `fade` (500ms), `slide` (500ms; `direction` left/right/up/down,
143
+ `distance` 0–1 of the frame, default 0.3), `pop` (500ms, easeOut; `overshoot`
144
+ 1–1.5, default 1.08), `spin` (500ms; `turns` 0–2, default 0.25), `wipe` (500ms;
145
+ `direction`, `softness` 0–0.5, default 0.05), `blur` (500ms; `amount` 0–40,
146
+ default 12), `colorFade` (600ms, grayscale blooming into color).
147
+
148
+ `in` on text only — `typewriter` reveals whole characters in reading order.
149
+ Set `durationMs` to the time for the entire text to appear. Without it, the
150
+ characters type 65ms apart and finish within the clip. To set a fixed speed,
151
+ use `durationMs: 1` with `stagger: {unit: "character", offsetMs: 40}`.
152
+ `animate_clip` stores every typewriter in that form. A document you write by
153
+ hand must use it too: a stored plain `durationMs` with no `stagger` shows the
154
+ whole text at once, with no caret.
155
+
156
+ `emphasis` — `pulse` (600ms; `intensity` 0–0.5, default 0.06), `flash` (400ms;
157
+ `intensity` 0–1, default 0.6), `shake` (600ms; seeded noise on both axes:
158
+ `intensity` 0–0.2 default 0.02, `frequency` Hz default 8, `seed` default 1,
159
+ `fadeInMs`/`fadeOutMs` default 0; the same seed repeats the same motion),
160
+ `bounce` (600ms; `height` 0–0.3, default 0.05), `squash` (500ms, easeOutBack;
161
+ `amount` 0–0.5, default 0.12), `followPath` (also `loop`; `d` an SVG path in
162
+ 0..1 space plus `pathX`/`pathY`/`pathWidth`/`pathHeight`, `orient` turns the
163
+ clip along the tangent, `startT`/`endT` pick a stretch of the path; writes
164
+ `positionX`/`positionY`, so it replaces the clip's position).
165
+
166
+ `loop` — `kenBurns` (`zoom` 0–1 default 0.12, `direction` in/out,
167
+ `driftX`/`driftY` −0.2–0.2; one move over the whole clip, with `durationMs`,
168
+ `delayMs` and `stagger` ignored, so the clip's length sets its speed),
169
+ `float` (3000ms; `amplitude` 0–0.2, `frequency` cycles per period default 1,
170
+ `seed` 0 = pure sine, else drift), `breathe` (3000ms; `intensity` 0–0.3), `rotate` (3000ms; `direction` cw/ccw),
171
+ `hueShift` (3000ms; `direction` forward/reverse), `orbit` (3000ms; `degrees`
172
+ default 360, `direction` cw/ccw) — which sweeps a 3D clip's camera and does
173
+ nothing on any other clip.
174
+
175
+ Seventeen named easings: `linear`, `easeIn`, `easeOut`, `easeInOut`,
176
+ `easeOutBack`, `easeOutElastic`, `easeOutBounce`, exponential, quintic, and
177
+ circular variants (`easeOutExpo`, `easeInOutQuint`, `easeOutCirc`), and `hold`
178
+ for a segment that keeps its prior keyframe value until the segment ends.
179
+ Unset means the preset's own default, and
180
+ failing that the role's — `in` gets `easeOut`, `out` gets `easeIn`. That
181
+ default is almost always right: an entrance decelerates into place, an exit
182
+ accelerates away. Reach for `easeOutBack` or `easeOutElastic` only when the
183
+ piece is playful; they read as cheap on anything corporate.
184
+
185
+ `{"op": "list_animation_presets"}` reports the exact param list when this
186
+ summary is not enough — the catalog is the authority. It is an `edit_timeline`
187
+ op like every other call here, not a tool of its own.
188
+
189
+ ### Keyframes and the easing grammar
190
+
191
+ `easing` takes more than the named ids. `cubic-bezier(x1,y1,x2,y2)` is
192
+ the CSS curve: `cubic-bezier(0.16,1,0.3,1)` is the deceleration most entrances
193
+ want. `spring(stiffness,damping,mass)` solves a real spring, so
194
+ `spring(180,12,1)` settles with one small overshoot and `spring(180,26,1)` does
195
+ not overshoot at all. All three constants must be positive. Anything the parser
196
+ cannot read eases linearly and reports `unknown_easing`.
197
+
198
+ The presets are where motion starts, not where it stops. A custom animation is
199
+ one extra field and it reaches shapes no preset has: an entrance that overshoots
200
+ twice, a hold in the middle of a move, two channels on different schedules, a
201
+ decay, an arc, a path draw-on. Reach for one as soon as a preset is close but
202
+ not right — bending `durationMs` and `easing` around a preset that was never the
203
+ shape you wanted takes longer and lands flatter. `motion-curves` carries the
204
+ channel table and the worked recipes. Then `{"role": "in", "preset": "custom",
205
+ ...}` carrying exactly one of
206
+
207
+ - `curves` — `[{property, keyframes: [{t, value, easing?}]}]`, with `t` running
208
+ 0..1 across the animation's window. The `list_animation_presets` op reports
209
+ the animatable properties.
210
+ - `code` — a JavaScript body baked into curves once, host-side, at author time.
211
+ Nothing evaluates at render.
212
+
213
+ Add `mask` when a curve drives `wipeProgress`; without it the wipe has no edge
214
+ to run against.
215
+
216
+ Motion that has to follow a piece of audio is a different call: `bake_audio_animation`
217
+ measures an audio clip and writes the curve onto a target clip through the
218
+ `set_baked_animation` op, either following the loudness (`mode: "envelope"`) or
219
+ pulsing on each onset (`mode: "beats"`). Its keyframes sit in the target clip's
220
+ own media, so trimming or splitting that clip re-slices the motion, and the
221
+ animation carries `bakedFrom` — running it again with different settings
222
+ replaces that curve instead of stacking a second one, and never touches a curve
223
+ you keyframed by hand. `set_markers_from_beats` is the other tool: markers to
224
+ cut against, rather than motion to animate with.
225
+
226
+ How several animations on one channel combine decides whether the second one
227
+ adds to the first or throws it away:
228
+
229
+ | Fold | Channels | Result |
230
+ |---|---|---|
231
+ | add | `offsetX`, `offsetY`, `rotation`, `rotationX`, `rotationY`, `blur`, `brightness`, `hue`, `temperature`, `tint`, `cameraAzimuth`, `cameraElevation`, `cameraFov` | Values sum, so a shake and a slide both move the clip |
232
+ | multiply | `scale`, `scaleX`, `scaleY`, `opacity`, `saturation`, `contrast`, `cameraZoom` | Values multiply, so two half-opacity ramps give a quarter |
233
+ | min | `wipeProgress` | The tightest wipe wins |
234
+ | replace | `positionX`, `positionY`, `anchorX`, `anchorY`, `trimStart`, `trimEnd` | One value survives — the last animation in document order. The other is discarded |
235
+
236
+ The replace row is the trap. Two animations driving `positionX` over the same
237
+ instants means one of them does nothing, and `validate_timeline` reports
238
+ `replace_curves_overlap`. Move a clip with `offsetX` when you want it to
239
+ compose with other motion, and with `positionX` only when you mean an absolute
240
+ placement nothing else touches.
241
+
242
+ ### Durations the contract constrains
243
+
244
+ `motion-principles` holds the numbers — how long an entrance runs, how long a
245
+ title holds. What this contract adds is what the document refuses:
246
+
247
+ - `in` + `out` together must fit inside the clip. A window that does not fit
248
+ after its `delayMs` is clamped or never runs, and `validate_timeline` says so
249
+ as `animation_exceeds_clip`.
250
+ - A `loop` on a clip shorter than one cycle shows a fragment of the motion.
251
+ `kenBurns` is not cycled: it always spans the clip, so a short clip makes
252
+ its zoom and drift fast.
253
+
254
+ ### Choosing motion that matches the cut
255
+
256
+ Pick the entrance from the edit, not from variety. A title over a locked-off
257
+ shot can `slide`; a title over a whip pan or a moving camera should `fade` or
258
+ `blur`, because sliding text plus moving picture reads as two unrelated
259
+ motions. Match the direction to the picture: text sliding against the camera
260
+ move fights it.
261
+
262
+ One motion idea per moment. A clip with `pop` in, `shake` emphasis and `spin`
263
+ out is not three times as expressive.
264
+
265
+ ## Animating typography
266
+
267
+ Text clips take every preset above, and one thing media clips do not:
268
+ per-word stagger. Add `stagger` to an animation on a text clip:
269
+
270
+ ```json
271
+ {"op": "animate_clip", "target": "Title", "animations": [
272
+ {"role": "in", "preset": "pop", "durationMs": 400,
273
+ "stagger": {"unit": "word", "offsetMs": 80, "from": "start"}}
274
+ ]}
275
+ ```
276
+
277
+ Each unit runs the full animation for `durationMs`, offset `offsetMs` from the
278
+ previous. `from` picks which unit leads: `start` (default), `end`, or `center`.
279
+ A stagger needs at least two units — below that it compiles as an ordinary
280
+ block animation.
281
+
282
+ `unit` is one of three, and the count is what the span math is built on:
283
+
284
+ | `unit` | One unit is | Counted as |
285
+ |---|---|---|
286
+ | `word` | a whitespace-separated word | the words in the clip's text |
287
+ | `character` | a grapheme cluster — an emoji, or a letter with its combining marks, counts once | every cluster including the spaces between words, which are timed and draw nothing |
288
+ | `line` | a wrapped line | the lines the text wraps to at the sequence's width and the clip's `fontSizePx`, not the newlines you typed |
289
+
290
+ The span is `durationMs + offsetMs × (units − 1)`, halved for `from: "center"`
291
+ (the middle unit leads and both edges run last, so the largest delay is half
292
+ the span). Six words at `offsetMs: 200` with a 500ms preset span 1500ms. The
293
+ same line at `unit: "character"` is roughly thirty units, so the same offset
294
+ spans six seconds — character staggers want offsets an order of magnitude
295
+ smaller than word staggers, and a `line` stagger changes count when the frame
296
+ does.
297
+
298
+ **A span that does not fit the clip is silently compressed, not cut off.** The
299
+ engine shrinks the per-step offset — never the per-word duration — so the last
300
+ word still completes inside the clip. Ask for 200ms of offset on a clip with
301
+ room for 60ms and you get 60ms: every word still animates, the motion is
302
+ simply tighter and more simultaneous than you wrote. `validate_timeline`
303
+ reports `stagger_compressed`.
304
+ This is the most common reason staggered type "doesn't look staggered". Work
305
+ out the span, compare it against `durationMs` minus any `delayMs`, and if it
306
+ does not fit, lengthen the clip or lower the offset yourself rather than
307
+ letting the clamp choose for you.
308
+
309
+ `loop` staggers are the exception: the offset is a phase shift, so it is
310
+ neither stretched nor clamped.
311
+
312
+ Which offset reads as texture and which reads as one word at a time is in
313
+ `motion-principles`, with the line-length limit that goes with it.
314
+
315
+ For several separate clips, `stagger_animations` shifts existing animation
316
+ delays in the order of `clip_ids` without moving the clips. Give at least two
317
+ distinct clip IDs, each already animated, and a non-negative `offset_ms`.
318
+ The in-product tool is `ui_timeline_stagger_animations` with the same fields.
319
+
320
+ ```json
321
+ {"op":"stagger_animations","clip_ids":["logo-id","title-id","tagline-id"],"offset_ms":80}
322
+ ```
323
+
324
+ Stagger applies only to text clips and only to transform and opacity motion.
325
+ `wipe`, `blur` and `colorFade` stay block-level even with a stagger set — the
326
+ mask and effect curves are per-layer, not per-word. A stagger on a media clip
327
+ is ignored, not an error, so a "why isn't it staggering" bug is usually a clip
328
+ that is not `mediaType: "text"`.
329
+
330
+ Type sizes are authored against the sequence resolution: `fontSizePx` on a
331
+ 1920×1080 sequence means the same thing at any preview width.
332
+
333
+ ## Document motion fields
334
+
335
+ The fields below belong to clips in the complete timeline document. Merge
336
+ them into the clip read by `get_timeline`, retaining its other fields and
337
+ animations, then write the full document with `set_timeline_document`.
338
+ `edit_timeline` and `ui_timeline_animate_clip` do not accept them as animation
339
+ inputs.
340
+
341
+ `styleTracks` animate visual properties in the same animation window as a
342
+ preset. Each stored animation needs `id`, `role`, `preset`, and `durationMs`.
343
+ Targets include `text.color`, `shape.fill`, `shape.cornerRadius`,
344
+ `clip.borderRadius`, `mask.featherPx`, `effect.<effect-id>.<numeric-field>`,
345
+ supported effect colors such as `effect.bg-field.colorB`,
346
+ gradient stops, and compatible `shape.d` or `mask.d` paths. The clip must
347
+ already have the targeted style, mask, or effect. A per-glyph target such as
348
+ `glyph.color` also needs text `stagger`.
349
+
350
+ ```json
351
+ {"animations":[{"id":"title-color","role":"in","preset":"fade","durationMs":600,"styleTracks":[{"target":"text.color","keyframes":[{"t":0,"value":"#8bd5ff"},{"t":1,"value":"#ffffff","easing":"easeOutExpo"}]}]}]}
352
+ ```
353
+
354
+ `textAnimator` changes text content over its animation window. `ticker`
355
+ supports `from`, `to`, `decimals`, `padTo`, `prefix`, and `suffix`.
356
+ Set `groupSeparator` to `","` for grouped thousands such as `2,847`;
357
+ without it the output stays ungrouped. `scramble` supports `charset` and
358
+ deterministic `seed`. It needs a text clip.
359
+ An animation's `beat` binds its start to a one-based beat index at the live
360
+ sequence tempo; `scope` is `clip` or `sequence`, and `offsetMs` moves it around
361
+ that beat. `delayMs` still applies on top.
362
+
363
+ ```json
364
+ {"id":"count-up","role":"emphasis","preset":"custom","durationMs":900,"beat":{"index":3,"scope":"sequence","offsetMs":-20},"textAnimator":{"kind":"ticker","from":1,"to":7,"padTo":2}}
365
+ ```
366
+
367
+ `animationLinks` copy a source clip's authored `positionX`, `positionY`,
368
+ `scale`, `rotation`, or `opacity` channel, with optional time offset, loop,
369
+ scale, and offset. A `wiggle` link supplies seeded motion without a render-time
370
+ script. Links do not follow the source's own links. Resolve each
371
+ `sourceClipId` against the document's clip IDs before writing it. An opacity
372
+ link replaces the target clip's own opacity while its group and transition
373
+ coverage still multiply the result.
374
+
375
+ ```json
376
+ {"animationLinks":[{"target":"positionX","sourceClipId":"leader-id","source":"positionX","scale":0.35},{"target":"rotation","kind":"wiggle","amplitude":0.025,"frequencyHz":1.4,"seed":8}]}
377
+ ```
378
+
379
+ `layout` turns a group clip into a flex container for its real children
380
+ (their `parentId` names the group): `flexDirection: "row" | "column"`,
381
+ `gap`, `justifyContent`, `alignItems`, `padding`. A child's own sizing is
382
+ `flexItem` (`grow`, `width`, `position: "absolute"` + `inset` to fill the
383
+ container). `repeater.count` includes the original clip and is capped at
384
+ 128. `timeStepMs` offsets each copy in time. These are fields on a clip, not
385
+ new clip records to add by hand.
386
+
387
+ Do not scroll a still plate by tiling it with a `repeater`. Each copy meets the
388
+ next at a seam, and the seam shows when the image differs at its edges. A
389
+ generated rain plate is denser at the top, so it always does. To make rain
390
+ fall, move one plate to a random offset on `hold` keyframes every two frames.
391
+ This reads as falling rain and has no seam.
392
+
393
+ ```json
394
+ {"layout":{"display":"flex","flexDirection":"row","gap":24},"repeater":{"count":3,"positionStep":{"x":180,"y":0},"timeStepMs":67}}
395
+ ```
396
+
397
+ (`logo-id`/`wordmark-id` are separate clips with `parentId` naming this group.)
398
+
399
+ `camera2d` is a sequence document field. It needs `position`, `depthPx`, and
400
+ positive `focalLengthPx`. Keyframes use absolute `timeMs` and include position
401
+ and depth. Add `depthPx` to a clip's existing `transform` for perspective;
402
+ `focusDepthPx` and `aperturePx` add depth-of-field blur. `camera2d: null`
403
+ clears the camera.
404
+
405
+ For a tilted wall of cards, use a parent group's `rotation`, `rotationX`, and
406
+ `perspective` for the shared tilt, each card's `depthPx` and scroll animation
407
+ for the separate planes, and `camera2d` for the push and depth blur. Keep a
408
+ shape's normalized geometry inside the frame-sized source raster. Move the
409
+ clip or group with `transform.position` or custom `offsetX`/`offsetY` curves
410
+ to scroll or fly it across the frame; geometry outside that raster is clipped
411
+ before the transform places it.
412
+
413
+ ```json
414
+ {"camera2d":{"position":{"x":0,"y":0},"depthPx":0,"focalLengthPx":1400,"keyframes":[{"timeMs":0,"position":{"x":0,"y":0},"depthPx":0},{"timeMs":1200,"position":{"x":90,"y":-20},"depthPx":120}]}}
415
+ ```
416
+
417
+ `motionBlur` is per clip: `samplesPerFrame` is 1–32 and `shutterAngle` is
418
+ 0–360 degrees. One sample disables blur for that clip. A higher count sets a
419
+ minimum; the scene renders at its highest requested count, with evenly
420
+ weighted samples across each layer's own shutter window. `steppedTime.fps`
421
+ quantizes a clip's source and animation clock. `temporalEcho` adds delayed
422
+ copies through `copies`, `intervalMs`, and `opacityDecay`.
423
+
424
+ ```json
425
+ {"motionBlur":{"samplesPerFrame":8,"shutterAngle":180},"steppedTime":{"fps":12},"temporalEcho":{"copies":3,"intervalMs":80,"opacityDecay":0.5}}
426
+ ```
427
+
428
+ ## Groups
429
+
430
+ `{"op":"add_group","name":"Lower third","startMs":4000,"durationMs":4000,"children":["name-id","role-id"]}` makes a clip with no media whose transform, opacity
431
+ and window every clip naming it inherits. `{"op": "set_parent", "target":
432
+ "Name plate", "parentId": "<group id>"}` adopts a clip that already exists;
433
+ `"parentId": null` releases it, and a cycle is refused.
434
+
435
+ Rig a lower third this way: scrim, name, role, each on its own overlay track,
436
+ all three parented to one group. Then animate the **group**. One `slide` in and
437
+ one `fade` out on the group moves the assembly together and keeps the parts
438
+ locked to each other, where animating them separately is three chances to
439
+ drift.
440
+
441
+ Children keep their own tracks, so grouping does not change what covers what.
442
+ A plain group's opacity multiplies into each child. A group with an effect,
443
+ non-`normal` blend mode, or transition precomposes its children into one
444
+ surface first. The effect or transition then runs once on the assembled
445
+ picture. Put a `glow` on the group for the whole look, on a child for that
446
+ child alone.
447
+
448
+ `parent_missing`, `parent_not_group` and `parent_cycle` all mean the child
449
+ renders unparented, losing the group's transform, opacity and window.
450
+
451
+ ## Transitions
452
+
453
+ A transition is between clips, not on one clip. It is authored on the
454
+ **incoming** clip and resolved for both: the compositor finds the clip beneath
455
+ it on the same track and gives that one the complementary half of the cut.
456
+ The incoming clip may be a group: `set_transition` on the group transitions
457
+ its whole precomposed assembly, with children still parented on their own
458
+ tracks. Its outgoing partner is the previous overlapping sibling on the
459
+ group's track under the same parent.
460
+
461
+ Two clips on the same track whose times overlap dissolve across the overlap
462
+ with no tool call at all. The corollary: **an accidental overlap is an
463
+ accidental dissolve.** When two clips look soft where you wanted a hard cut,
464
+ check their `startMs` and `durationMs` before looking anywhere else.
465
+
466
+ `set_transition` picks a cut instead of taking the default:
467
+ `{"op": "set_transition", "target": "Shot 2", "transition": {"type": "wipe",
468
+ "durationMs": 500, "direction": "left", "softness": 0.1}}`. Pass
469
+ `"transition": null` to clear it. The cut plays over the target's head, so
470
+ **overlap the two clips by at least `durationMs`** or there is nothing beneath
471
+ for it to play against — a transition with nothing under it reads as a fade
472
+ from black.
473
+
474
+ | Type | What moves | Reach for it when |
475
+ |---|---|---|
476
+ | `crossfade` | Incoming fades up | Two shots of the same scene; the safe default |
477
+ | `dipToColor` | Both fade through a solid | A scene change, a chapter break. `color` is yours to pick |
478
+ | `wipe` | Feathered edge reveals the incoming | A graphic or split-screen feel. `softness` 0 is a hard edge |
479
+ | `push` | Both clips travel one frame width | Lateral energy; the two read as one moving picture |
480
+ | `slide` | Only the incoming travels | The new shot arrives over a shot that holds |
481
+ | `zoom` | Outgoing grows, incoming comes in from 0.8 | A push into the next beat |
482
+ | `whip`, `zoomBlur` | Motion blur across a directional or zooming cut | Fast camera energy |
483
+ | `glitch` | Displaced digital cut | A deliberate electronic break |
484
+ | `gradientWipe`, `iris` | Spatial threshold or aperture reveal | Designed graphic transitions |
485
+ | `lightLeak` | Localized color field across the cut | A flare-like bridge |
486
+
487
+ `direction` on `wipe`, `push`, `slide`, `whip`, and `gradientWipe` names the
488
+ incoming edge. `gradientWipe` also takes `map: "linear" | "radial" |
489
+ "noise"`, `scale`, `seed`, and `softness`. `iris` takes `softness`;
490
+ `lightLeak` takes `color`, `scale`, and `seed`. `easing` takes a named id,
491
+ `cubic-bezier(...)`, or `spring(...)`.
492
+
493
+ `fadeInMs` and `fadeOutMs` on `set_clip_params` are audio fades. The compositor
494
+ does not read them, so they will not fade a picture — for that, animate the
495
+ clip with a `fade` `in` or `out`.
496
+
497
+ A transition's opacity multiplies with animation opacity rather than replacing
498
+ it, so a clip that both dissolves in and carries a `fade` in ramps twice and
499
+ reads slower and softer than either alone. Pick one.
500
+
501
+ `validate_timeline` reports `transition_exceeds_duration` when the cut is
502
+ longer than the clip carrying it, and `unknown_transition` for a type or
503
+ direction this build cannot draw — which falls back to a cross-fade running
504
+ left rather than failing the render.
505
+
506
+ ## Masks and mattes
507
+
508
+ `{"op": "set_mask", "target": "Title", "mask": {"kind": "rect", "y": 0.2,
509
+ "height": 0.6, "featherPx": 24}}` cuts the layer to a shape in the clip's own
510
+ 0..1 space. `kind` is `rect`, `ellipse` or `path` (with an SVG `d`).
511
+ `featherPx` softens the edge, `invert` keeps the outside instead, and
512
+ `"mask": null` clears it. A path that cannot rasterize reports
513
+ `mask_path_invalid` and the layer draws unmasked — the whole picture shows
514
+ through, the opposite of what you asked for.
515
+
516
+ `{"op": "set_matte", "target": "Fill", "matte": {"source": "Text shape",
517
+ "mode": "alpha"}}` uses another clip as this one's transparency. `alpha` reads
518
+ the source's opacity, `luma` its brightness, and `invert` flips it. **The
519
+ source clip stops drawing itself.** That is the point, and it is also why a
520
+ matte pointed at a clip you still wanted on screen reads as a deletion. Reach
521
+ for it when text has to be filled with moving footage: footage on the fill
522
+ clip, text on the source. `matte_source_missing` is an error, and the layer
523
+ then draws unmatted, showing everything the matte was hiding.
524
+
525
+ ## Clip effects
526
+
527
+ `set_effects` replaces the whole chain and applies it in the
528
+ order given; an empty list clears it. The tool also accepts `pixelate`,
529
+ `posterize`, `directionalBlur`, `lensDistortion`, `stylize`, `generator`, `lut`,
530
+ and `grain` alongside `color`, `blur`, `glow`, `dropShadow`, `vignette`,
531
+ `sharpen`, `chromaKey`, `curves`, `levels`, and `liftGammaGain`. Its flat input
532
+ uses `{type, ...typeFields}`. It assigns effect IDs and enables each effect;
533
+ it does not accept stored `id` or `enabled` fields. `validate_timeline`
534
+ reports `unknown_effect` for an unrecognized type. The Canvas 2D preview
535
+ applies all known effects through CPU passes when its host supplies scratch
536
+ surfaces. `preview_timeline_frame.effects_not_applied` lists unknown types;
537
+ `degraded` reports missing surfaces and other draw limitations.
538
+
539
+ ```json
540
+ {"op":"set_effects","target":"Shot 2","effects":[{"type":"grain","amount":0.12,"size":1.1},{"type":"stylize","mode":"dither","amount":1,"seed":4}]}
541
+ ```
542
+
543
+ ## Time remap
544
+
545
+ `{"op": "set_time_remap", "target": "Shot 3", "timeRemap": {"keyframes":
546
+ [{"t": 0, "sourceMs": 0}, {"t": 1, "sourceMs": 4000, "easing": "easeInOut"}]}}`
547
+ maps timeline time onto source time. `set_time_remap` replaces the whole map
548
+ each time it is called. `t` runs 0..1 over the clip's window and
549
+ must start at 0, end at 1 and ascend; `sourceMs` says which millisecond of the
550
+ source plays there, so a descending pair is a reverse and a flat pair is a
551
+ freeze. `"timeRemap": null` clears it, and the remap replaces the clip's rate.
552
+ A repeated or backwards `t` is `time_remap_not_monotonic`, an error.
553
+
554
+ `split_clip` and `trim_clip` refuse a remapped clip. Both would have to
555
+ re-derive the map, and re-deriving it silently is how a speed ramp turns into a
556
+ different move. Clear the remap, cut, re-apply.
557
+
558
+ ## Compositions
559
+
560
+ A composition is a group saved as a reusable rig with named parameters.
561
+ `list_compositions` reports what is available, `get_composition` shows one
562
+ rig's parameters and their defaults, and
563
+ `{"op":"insert_composition","composition_id":"lower-third","startMs":4000,"params":{"name":"Ada"}}`
564
+ instantiates it into the document with fresh clip ids. Use parameter names
565
+ returned by `get_composition`; `name` here is an example.
566
+
567
+ Bundled examples include `title-card`, `lower-third`, `caption-bar`,
568
+ `callout`, `cta-end-card`, `logo-sting`, `title-slam`, `word-cards`,
569
+ `window-frame`, and `number-ticker`. Use `list_compositions` for the
570
+ templates available in the current workspace.
571
+
572
+ Insert first, then override `params`. The rig's timing and motion are already
573
+ balanced against each other, and rebuilding it from bare clips throws that
574
+ away. Edit the instantiated clips only where the brief actually differs.
575
+
576
+ When a group should be reused, `save_composition
577
+ {timeline_id, group_target, name, params}` extracts it as a composition.
578
+ `params` map names to typed defaults and JSON pointers into child fields.
579
+ `insert_composition` remaps the group and child clip IDs, including internal
580
+ layout and animation links, while preserving references to clips outside the
581
+ composition.
582
+
583
+ ## Cutting to the beat
584
+
585
+ `detect_audio_events` on the music clip reports onsets and a tempo. Its
586
+ `onsets.times` are in **seconds** and the ops take milliseconds, so multiply by
587
+ 1000. Read the tempo's `reliable` flag before building a grid from `bpm`:
588
+ speech and room tone produce a confident-looking number from nothing.
589
+
590
+ `{"op":"set_markers_from_beats","onsets_ms":[0,500,1000]}` lays the
591
+ grid down so you can see it, one marker per onset. `{"op": "snap_to_beats", "targets": "all",
592
+ "tolerance_ms": 60, "mode": "start", "action": "move"}` pulls clip edges onto
593
+ it. Either takes `onsets_ms`, or a `bpm` with an `offset_ms`; with `bpm`,
594
+ `set_markers_from_beats` also needs `count`, which an `onsets_ms` grid ignores.
595
+
596
+ The default 60ms tolerance is about two frames at 30fps: close enough that a
597
+ cut reads as on the beat, tight enough that it will not drag an edge to a beat
598
+ it was never near. Widen it only when the grid is loose, because a large
599
+ tolerance moves cuts you meant to leave alone. `snap_to_beats` reports every
600
+ target, including the ones no beat was in reach of, with the reason.
601
+
602
+ `action: "move"` slides the clip and keeps its length, which shifts everything
603
+ downstream. `action: "trim"` changes the length and leaves the neighbours
604
+ where they are. Move a title that has to land on a hit; trim a picture clip
605
+ whose out point has to meet the next shot.
606
+
607
+ ## Validate the output
608
+
609
+ Two checks, and they answer different questions.
610
+
611
+ **`validate_timeline`** answers "is this document sound": unknown animation
612
+ presets and transitions, fades and cuts longer than the clip carrying them,
613
+ clips on tracks the document lacks, overlapping clips, clips shorter than a
614
+ frame, in/out points that cannot render, duplicate ids. Run it after every batch
615
+ of edits.
616
+
617
+ It also names the motion failures that used to be yours to remember. Each
618
+ finding carries a stable `code`; these are the ones that mean the picture will
619
+ not perform the motion you wrote:
620
+
621
+ | Code | Severity | What to do about it |
622
+ |---|---|---|
623
+ | `unknown_animation_preset` | error | The stored `preset` is not one this build ships. Use an id from `list_animation_presets` |
624
+ | `shape_path_invalid` | error | A `path` shape's `d` cannot be parsed, so the shape draws nothing. `add_shape_clip` stores the path unchecked, so this is where it surfaces. Fix the `d` |
625
+ | `parent_cycle` | error | A `parentId` chain loops, so the group cannot be resolved at all. Break the loop |
626
+ | `matte_source_missing` | error | The matte names a clip the document lacks, or itself. The layer draws unmatted, showing everything the matte was there to hide |
627
+ | `time_remap_not_monotonic` | error | `t` repeats or goes backwards. `sourceMs` may descend — that is a reverse — but `t` may not |
628
+ | `custom_animation_invalid` | error | A stored `preset: "custom"` animation has unusable baked numeric curves and no style track or text animator to render. Re-bake its curves or add a valid typed track |
629
+ | `animation_style_invalid` | error | A style target has no matching clip style, a path cannot morph, a color cannot parse, or a glyph track lacks text stagger. Fix the target or the clip style |
630
+ | `animation_exceeds_clip` | warning | The window does not fit the clip after its delay, so the motion is clamped or never runs. Shorten `durationMs`, cut the delay, or lengthen the clip |
631
+ | `stagger_compressed` | warning | The units did not fit, so the per-unit offset was shrunk. The line lands faster and flatter than you wrote it. Shorten the per-unit `durationMs` or give the clip more time |
632
+ | `typewriter_not_staggered` | warning | A `typewriter` is stored with a plain `durationMs` and no `stagger`. Nothing draws for that time, then the whole text appears with no caret. Store `durationMs: 1` with a `character` stagger, or apply the preset again through `animate_clip` |
633
+ | `animation_holds_rest_before_window` | warning | A custom `out` curve starts away from the channel's rest value after clip start. An `out` does not apply before its window, so the frames before it show the rest value (a gauge drawn from `trimEnd: 0` shows a full ring). Start the window at clip start. For a `trim`, `position` or `anchor` curve, you can instead set the clip's own value to the first keyframe |
634
+ | `replace_curves_overlap` | warning | Two animations drive one absolute channel (`positionX/Y`, `anchorX/Y`, `trimStart/End`) at once. The last in document order wins and the other is discarded. Separate them in time or fold them into one curve |
635
+ | `text_backing_unproven` | warning | Text draws over picture with no `background`, `stroke` or shape behind it, so nothing decides whether it reads. Preview the frame and look, or give it a plate |
636
+ | `text_illegible` | warning | Type under 2.5% of frame height, or under a 3:1 contrast ratio against its own `background` plate or a full-frame shape behind it. Raise `fontSizePx`, darken the scrim, or add a `stroke` |
637
+ | `unknown_easing` | warning | Outside the grammar, so it eases linearly. Check the spelling: `easeOut`, not `ease-out` |
638
+ | `unknown_transition` | warning | This build cannot draw that `type` or `direction`; it cross-fades left instead. Pick one from the table above |
639
+ | `unknown_effect` | warning | This build cannot apply that effect; the layer draws ungraded |
640
+ | `unknown_shape_kind` | warning | No geometry for that `shapeStyle.kind`, so the shape draws nothing. A scrim that vanished is usually this |
641
+ | `transition_exceeds_duration` | warning | The cut is longer than the clip carrying it. Shorten the transition or lengthen the clip |
642
+ | `parent_missing`, `parent_not_group` | warning | The `parentId` names nothing, or names a clip that is not a group. The child renders unparented, losing the group's transform, opacity and window |
643
+ | `mask_path_invalid` | warning | The `kind` or path `d` cannot rasterize, so the layer draws unmasked and the whole picture shows through |
644
+ | `layer_cap_exceeded` | warning | More video clips overlap at that instant than the compositor draws; the ones on lower tracks are dropped |
645
+ | `font_not_portable` | warning | The family is not one NodeTool ships, so every host resolves it against its own installed fonts and the editor and the render can differ. Pick a bundled family for anything you hand back |
646
+ | `field_stripped` | warning | A field the schema drops, lost on the next save. Usually a newer build wrote it; re-author it in this build's grammar |
647
+
648
+ The contrast half refuses to guess: a colour it cannot parse, a translucent
649
+ plate or gradient-filled type produces no contrast finding. Type over picture
650
+ with no plate at all is the case it can name without measuring, and it says so
651
+ as `text_backing_unproven`. Either way a silent `text_illegible` is not a pass
652
+ — that is what the frames are for.
653
+
654
+ **`preview_timeline_frame`** answers "what does it look like". It composites
655
+ the real frame — every track layered in order, transforms and opacity applied,
656
+ animations sampled, transitions part way through, text and shapes drawn — at
657
+ the timecodes you name. It needs no browser, no GPU and no open editor.
658
+
659
+ ```json
660
+ {"timeline_id": "<id>", "times_ms": [1200, 1450, 3000]}
661
+ ```
662
+
663
+ `range {from_ms, to_ms, count}` samples evenly across a span instead of naming
664
+ each timecode — the way to watch a whole entrance rather than guess three
665
+ points inside it. `sheet: true` tiles every frame into one labelled contact
666
+ sheet, so a sweep is one image to read instead of a handful of handles.
667
+
668
+ It returns an image handle per timecode; call `view_image` on the ones you need
669
+ to see. Beside each frame it lists the layers top of the stack first, with the
670
+ opacity, blend mode and wipe progress each one resolved to — often enough to
671
+ diagnose a problem without looking at a pixel.
672
+
673
+ **Sample the middle of a motion, not its ends.** The endpoints are the states
674
+ you already know: before an entrance the clip is absent, after it the clip is
675
+ at rest. A 400ms fade-in starting at 1000ms tells you nothing at 1000ms or
676
+ 1400ms and everything at 1200ms. For each animation you added, preview at:
677
+
678
+ - the midpoint of the entrance,
679
+ - a held moment between the in and out,
680
+ - the midpoint of the exit,
681
+ - and, for staggered text, a point after the first word lands but before the
682
+ last does — the frame where half the line is on screen is the one that
683
+ reveals a stagger that is too slow.
684
+
685
+ What the frames catch that a validator cannot: text over a busy or same-colored
686
+ plate, a lower-third under the wrong element because two tracks are the wrong
687
+ way round, a title that has slid outside the frame, a stagger still finishing
688
+ when the clip ends, a scrim covering the face it was supposed to sit beside, an
689
+ element that never appears because its clip is on an audio track.
690
+
691
+ Each frame carries `degraded` entries for a difference the Canvas 2D host
692
+ could not draw, such as a missing scratch surface, a hard mask edge, or an
693
+ unsupported perspective transform. Read the actual entries before deciding
694
+ which part of the frame needs a GPU render. Known clip effects have CPU passes.
695
+
696
+ Read the `skipped` field on any layer that drew nothing — it says whether the
697
+ clip is still `draft`, its asset would not read, or the source had no frame at
698
+ that time. A missing picture is usually an unrendered clip, not a motion bug.
699
+
700
+ `effects_not_applied` names enabled effect types the Canvas 2D compositor does
701
+ not recognize. A known effect such as chroma key, vignette, sharpen, or LUT
702
+ should render in the frame preview; a missing scratch surface instead appears
703
+ in `degraded`.
704
+
705
+
706
+ ## The render loop
707
+
708
+ Change one thing, preview the same timecodes, compare. When motion is wrong,
709
+ the order to check is: is the element on screen at all (layer present in the
710
+ report), is it in the right stacking position (`z_index`), is it at the right
711
+ opacity (`opacity`), is it in the right place (look at the frame). Working down
712
+ that list beats re-authoring the animation.
713
+
714
+ For every defect named by a `view_image` look, a preview layer report, or a
715
+ model critique of the render, fix it and re-check the same timecode, or tell
716
+ the user it remains open and why. Never ship past a named defect silently. If
717
+ `view_image` returns no pixels, tell the user you could not see the frames.
718
+ Do not claim a visual check.
719
+
720
+ `compare_timeline_frames {a, b, times_ms | range}` measures what actually moved
721
+ between two documents or two versions. Run it after a change the user did not
722
+ ask for (a restructure, a composition insert, a snap pass) so you can say
723
+ which frames it touched instead of hoping it touched none.
724
+
725
+ Before calling the cut done, render it. `render_timeline` with a
726
+ `preview_scale` below 1 while you are still iterating, then once at full size.
727
+ The preview answers what one timecode looks like; the render answers whether
728
+ the file plays.
729
+
730
+ A whole-document rewrite goes through `set_timeline_document`, which validates
731
+ before it writes and snapshots what it replaces. Name that snapshot
732
+ (`snapshot_name: "before the title pass"`) so the version list says what you
733
+ were about to do. For anything smaller, `create_timeline_version` first and
734
+ `edit_timeline` after.
735
+
736
+ Stop when the motion is right, and say what you animated.