[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6BY3NWNFB97KXS141085GD4":3,"skill-same:01M6BY3NWNFB97KXS141085GD4":78},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":50,"linkOnly":51,"markdownContent":52,"markdownBody":53,"frontMatter":54,"refName":55,"commitSha":56,"skillDirectory":57,"forksCount":58,"repositoryHtmlUrl":59,"repositoryHomepageUrl":60,"ownerHtmlUrl":61,"ownerBlogUrl":12,"ownerDescription":62,"updatedAt":63,"install":64,"resources":77},"01M6BY3NWNFB97KXS141085GD4","timeline-edit-ops",null,"Reference for editing a NodeTool timeline that already exists with edit_timeline ops — animate_clip roles and presets, the easing grammar, per-word\u002Fcharacter 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\u002Fpreview_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\u002Fsandbox-timeline pack instead; see motion-graphics.","nodetool-ai\u002Fnodetool","nodetool-ai","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F164873569?v=4","01M6BXPFDW36K9K1MR8ZH63HG2","nodetool.ai","01M6BXPFJ893XM219GQN60B0WK",628,"AGPL-3.0","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Ftimeline-edit-ops\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000002","文档处理","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49],"timeline","timeline edit ops","edit","ops","only","new","limits","effects","camera2d","motion-graphics","cut","layout","mechanics","full","time","preview","character","graphics","grammar","nodetool","animate","Word","SET","preset","validate","sandbox","2026-11-10 10:12:30",false,"---\nname: timeline-edit-ops\ndescription: Reference for editing a NodeTool timeline that already exists with edit_timeline ops — animate_clip roles and presets, the easing grammar, per-word\u002Fcharacter 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\u002Fpreview_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\u002Fsandbox-timeline pack instead; see motion-graphics.\n---\n\n# Timeline edit ops → the `edit_timeline` reference\n\nThis is the op-by-op contract for changing a timeline document that already\nexists. `motion-graphics` owns the build loop and picks the surface: a new\ncut goes through the `@nodetool-ai\u002Fsandbox-timeline` pack, which handles\ntracks, groups, layout and most of what follows here for you. Reach for this\nskill once that document exists and needs a few changes.\n\nOps are the right tool when the timeline has no authoring code (`code.get`\nanswers `code: null`), or for a deliberate hand tweak on one that does. On a\ncode-backed timeline, an op inside a scene the code tracks marks that scene\n\"edited\": the next `code.set`\u002F`code.edit`\u002F`code.rebake` (`api-timelines`)\nkeeps your edit and reports it in `conflicts` rather than overwriting it,\nunless `force` says otherwise.\n\n## Read before you edit\n\nCall `get_timeline` first. You need the sequence's `fps`, `width`, `height`,\nthe track list with each track's `index` and `type`, and every clip's\n`startMs`, `durationMs`, `mediaType` and existing `animations`. Edit nothing\nuntil you have clip ids from the document — never a guessed one.\n\nSnapshot with `create_timeline_version` before your first edit. Motion work is\niterative and a wrong `mode: \"replace\"` wipes animations somebody wrote.\n\n`edit_timeline` takes a timeline ID and an `ops` array, run in order, up to\n60 per call. Inspect each op's `ok` and the batch's `failed` result before a\ndependent edit. In an in-product agent, use the matching `ui_timeline_*` tool\nfor an individual op:\n\n```json\n{\"timeline_id\":\"\u003Cid>\",\"ops\":[{\"op\":\"animate_clip\",\"target\":\"Title\",\"animations\":[{\"role\":\"in\",\"preset\":\"fade\",\"durationMs\":400}]}]}\n```\n\nThe `animate_clip` input accepts catalog presets or `custom` curves and code,\nplus `stagger` and a typewriter `caret`. It does **not** author `styleTracks`,\n`textAnimator`, or `beat`. `set_clip_params` does **not** author `layout`,\n`repeater`, `motionBlur`, `steppedTime`, `temporalEcho`, `animationLinks`, or\n`transform.depthPx`. `set_clip_params` refuses a call that names one of\nthose keys, and `animate_clip` drops the fields it does not author. For those fields, read `get_timeline`'s `timeline` object, change\nthe needed clip or sequence fields, and call `set_timeline_document` with a\ncomplete document assembled from it. Preserve tracks, clips, markers, tempo,\nsetup, templateId, mediaTracks, transcript, scriptEnabled, and camera2d when\npresent. Include `timeline.updatedAt` as `expected_updated_at` so a\nconcurrent edit is refused. A whole-document write validates the result and\nsnapshots the prior version. Omitted document fields are removed, so never\nsend only the changed clip. The JSON objects in the document sections below\nare fields to merge into that complete document, not `edit_timeline` ops.\nThe editor may lack controls for them even though they render and survive a\nsave.\n\n## Track layering\n\n**Lowest `index` renders on top.** Track 0 covers track 1, which covers track 2\n— Premiere, Resolve and FCP order, and the opposite of a paint stack. The\ncompositor turns the index into `z = 1000 - index`; `preview_timeline_frame`\nreports that `z_index` per layer and lists layers top of the stack first.\n\nFive track types, and the type is a role, not a restriction on what draws:\n\n| Type | Holds | Use for |\n|---|---|---|\n| `video` | picture clips, images | the cut itself |\n| `overlay` | text and shape clips | titles, lower-thirds, scrims, callouts |\n| `audio` | sound | music, voiceover, effects — never drawn |\n| `subtitle` | text | burned-in captions |\n| `midi` | played notes | a synthesized part — never drawn |\n\nRules that follow from the ordering:\n\n- A scrim goes on a **higher** index than the text it sits behind, and a lower\n  index than the picture it darkens — so picture, then scrim, then text, in\n  descending index order.\n- Give each motion element its own overlay track when their timings overlap.\n  Two animated clips on one track that overlap in time trigger an automatic\n  crossfade you did not ask for (see Transitions).\n- Captions are the exception you do not control: word-level captions from a\n  voiceover clip composite above every real track regardless of which track\n  that clip sits on. Do not build a title that expects to cover them.\n- An audio clip never contributes a layer. A frame that looks empty because\n  everything is on audio tracks is a track-type mistake.\n- At most **8 video layers** composite at once, resolved top track first. Text,\n  shape and image clips are not counted, so a stack of titles is unaffected —\n  but nine overlapping video clips means the bottom one silently does not draw.\n  A preview frame is how you find that out.\n\n## Build the elements\n\nThe clips you animate are made with the same `edit_timeline` op list.\n\n```json\n[\n  {\"op\":\"add_text_clip\",\"text\":\"Maya Chen\",\"trackId\":\"\u003Coverlay>\",\"startMs\":4000,\"durationMs\":3000,\"fontSizePx\":64},\n  {\"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}\n]\n```\n\n`add_text_clip` needs only `text`; the style fields (`fontSizePx`, `color`,\n`background`, `stroke`) are read from the op itself or from a `style` bag, and\nwith no `trackId` it lands on an overlay track. `add_shape_clip` takes its\ngeometry in `shape` (or `shapeStyle`, or the op's own keys) with `x`, `y`,\n`width` and `height` as 0..1 fractions of the frame — no geometry at all is a\nfull-frame rect, which is the scrim you usually want.\n\nTracks are the z-order, so how they are made matters:\n\n```json\n[\n  {\"op\":\"add_track\",\"type\":\"overlay\",\"name\":\"Titles\"},\n  {\"op\":\"move_track\",\"target\":\"Picture\",\"toIndex\":2}\n]\n```\n\n`add_track` takes `type` (`video`, `audio`, `overlay`, `subtitle`, `midi`) and an\noptional `name`, and **appends to the bottom** — the highest index, which draws\nlast. A picture track added after the overlays therefore covers them. Add the\noverlays last, or move the picture down afterwards: `move_track` takes a\n`target` and one of `toIndex` (0 is the top), `before` or `after`.\n\n## Animating a clip\n\n`edit_timeline` with `{\"op\": \"animate_clip\", \"target\": \u003Cclip id or name>,\n\"animations\": [...]}`. Each animation is `{role, preset, durationMs?, delayMs?,\neasing?, params?}`. `mode` is `\"replace\"` (default) or `\"add\"`.\n\nFour roles. A clip may carry several, one of each kind or more:\n\n- **`in`** — entrance. `delayMs` offsets from the clip's start.\n- **`out`** — exit. `delayMs` offsets backwards from the clip's **end**, so 0\n  means it finishes exactly as the clip ends. This is the one people get wrong.\n- **`emphasis`** — a beat mid-clip. `delayMs` from the clip's start.\n- **`loop`** — runs continuously; `durationMs` is one cycle, not the total.\n  `kenBurns` is the exception: it runs once across the whole clip and holds.\n\n### The presets\n\n`in` \u002F `out` — `fade` (500ms), `slide` (500ms; `direction` left\u002Fright\u002Fup\u002Fdown,\n`distance` 0–1 of the frame, default 0.3), `pop` (500ms, easeOut; `overshoot`\n1–1.5, default 1.08), `spin` (500ms; `turns` 0–2, default 0.25), `wipe` (500ms;\n`direction`, `softness` 0–0.5, default 0.05), `blur` (500ms; `amount` 0–40,\ndefault 12), `colorFade` (600ms, grayscale blooming into color).\n\n`in` on text only — `typewriter` reveals whole characters in reading order.\nSet `durationMs` to the time for the entire text to appear. Without it, the\ncharacters type 65ms apart and finish within the clip. To set a fixed speed,\nuse `durationMs: 1` with `stagger: {unit: \"character\", offsetMs: 40}`.\n`animate_clip` stores every typewriter in that form. A document you write by\nhand must use it too: a stored plain `durationMs` with no `stagger` shows the\nwhole text at once, with no caret.\n\n`emphasis` — `pulse` (600ms; `intensity` 0–0.5, default 0.06), `flash` (400ms;\n`intensity` 0–1, default 0.6), `shake` (600ms; seeded noise on both axes:\n`intensity` 0–0.2 default 0.02, `frequency` Hz default 8, `seed` default 1,\n`fadeInMs`\u002F`fadeOutMs` default 0; the same seed repeats the same motion),\n`bounce` (600ms; `height` 0–0.3, default 0.05), `squash` (500ms, easeOutBack;\n`amount` 0–0.5, default 0.12), `followPath` (also `loop`; `d` an SVG path in\n0..1 space plus `pathX`\u002F`pathY`\u002F`pathWidth`\u002F`pathHeight`, `orient` turns the\nclip along the tangent, `startT`\u002F`endT` pick a stretch of the path; writes\n`positionX`\u002F`positionY`, so it replaces the clip's position).\n\n`loop` — `kenBurns` (`zoom` 0–1 default 0.12, `direction` in\u002Fout,\n`driftX`\u002F`driftY` −0.2–0.2; one move over the whole clip, with `durationMs`,\n`delayMs` and `stagger` ignored, so the clip's length sets its speed),\n`float` (3000ms; `amplitude` 0–0.2, `frequency` cycles per period default 1,\n`seed` 0 = pure sine, else drift), `breathe` (3000ms; `intensity` 0–0.3), `rotate` (3000ms; `direction` cw\u002Fccw),\n`hueShift` (3000ms; `direction` forward\u002Freverse), `orbit` (3000ms; `degrees`\ndefault 360, `direction` cw\u002Fccw) — which sweeps a 3D clip's camera and does\nnothing on any other clip.\n\nSeventeen named easings: `linear`, `easeIn`, `easeOut`, `easeInOut`,\n`easeOutBack`, `easeOutElastic`, `easeOutBounce`, exponential, quintic, and\ncircular variants (`easeOutExpo`, `easeInOutQuint`, `easeOutCirc`), and `hold`\nfor a segment that keeps its prior keyframe value until the segment ends.\nUnset means the preset's own default, and\nfailing that the role's — `in` gets `easeOut`, `out` gets `easeIn`. That\ndefault is almost always right: an entrance decelerates into place, an exit\naccelerates away. Reach for `easeOutBack` or `easeOutElastic` only when the\npiece is playful; they read as cheap on anything corporate.\n\n`{\"op\": \"list_animation_presets\"}` reports the exact param list when this\nsummary is not enough — the catalog is the authority. It is an `edit_timeline`\nop like every other call here, not a tool of its own.\n\n### Keyframes and the easing grammar\n\n`easing` takes more than the named ids. `cubic-bezier(x1,y1,x2,y2)` is\nthe CSS curve: `cubic-bezier(0.16,1,0.3,1)` is the deceleration most entrances\nwant. `spring(stiffness,damping,mass)` solves a real spring, so\n`spring(180,12,1)` settles with one small overshoot and `spring(180,26,1)` does\nnot overshoot at all. All three constants must be positive. Anything the parser\ncannot read eases linearly and reports `unknown_easing`.\n\nThe presets are where motion starts, not where it stops. A custom animation is\none extra field and it reaches shapes no preset has: an entrance that overshoots\ntwice, a hold in the middle of a move, two channels on different schedules, a\ndecay, an arc, a path draw-on. Reach for one as soon as a preset is close but\nnot right — bending `durationMs` and `easing` around a preset that was never the\nshape you wanted takes longer and lands flatter. `motion-curves` carries the\nchannel table and the worked recipes. Then `{\"role\": \"in\", \"preset\": \"custom\",\n...}` carrying exactly one of\n\n- `curves` — `[{property, keyframes: [{t, value, easing?}]}]`, with `t` running\n  0..1 across the animation's window. The `list_animation_presets` op reports\n  the animatable properties.\n- `code` — a JavaScript body baked into curves once, host-side, at author time.\n  Nothing evaluates at render.\n\nAdd `mask` when a curve drives `wipeProgress`; without it the wipe has no edge\nto run against.\n\nMotion that has to follow a piece of audio is a different call: `bake_audio_animation`\nmeasures an audio clip and writes the curve onto a target clip through the\n`set_baked_animation` op, either following the loudness (`mode: \"envelope\"`) or\npulsing on each onset (`mode: \"beats\"`). Its keyframes sit in the target clip's\nown media, so trimming or splitting that clip re-slices the motion, and the\nanimation carries `bakedFrom` — running it again with different settings\nreplaces that curve instead of stacking a second one, and never touches a curve\nyou keyframed by hand. `set_markers_from_beats` is the other tool: markers to\ncut against, rather than motion to animate with.\n\nHow several animations on one channel combine decides whether the second one\nadds to the first or throws it away:\n\n| Fold | Channels | Result |\n|---|---|---|\n| 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 |\n| multiply | `scale`, `scaleX`, `scaleY`, `opacity`, `saturation`, `contrast`, `cameraZoom` | Values multiply, so two half-opacity ramps give a quarter |\n| min | `wipeProgress` | The tightest wipe wins |\n| replace | `positionX`, `positionY`, `anchorX`, `anchorY`, `trimStart`, `trimEnd` | One value survives — the last animation in document order. The other is discarded |\n\nThe replace row is the trap. Two animations driving `positionX` over the same\ninstants means one of them does nothing, and `validate_timeline` reports\n`replace_curves_overlap`. Move a clip with `offsetX` when you want it to\ncompose with other motion, and with `positionX` only when you mean an absolute\nplacement nothing else touches.\n\n### Durations the contract constrains\n\n`motion-principles` holds the numbers — how long an entrance runs, how long a\ntitle holds. What this contract adds is what the document refuses:\n\n- `in` + `out` together must fit inside the clip. A window that does not fit\n  after its `delayMs` is clamped or never runs, and `validate_timeline` says so\n  as `animation_exceeds_clip`.\n- A `loop` on a clip shorter than one cycle shows a fragment of the motion.\n  `kenBurns` is not cycled: it always spans the clip, so a short clip makes\n  its zoom and drift fast.\n\n### Choosing motion that matches the cut\n\nPick the entrance from the edit, not from variety. A title over a locked-off\nshot can `slide`; a title over a whip pan or a moving camera should `fade` or\n`blur`, because sliding text plus moving picture reads as two unrelated\nmotions. Match the direction to the picture: text sliding against the camera\nmove fights it.\n\nOne motion idea per moment. A clip with `pop` in, `shake` emphasis and `spin`\nout is not three times as expressive.\n\n## Animating typography\n\nText clips take every preset above, and one thing media clips do not:\nper-word stagger. Add `stagger` to an animation on a text clip:\n\n```json\n{\"op\": \"animate_clip\", \"target\": \"Title\", \"animations\": [\n  {\"role\": \"in\", \"preset\": \"pop\", \"durationMs\": 400,\n   \"stagger\": {\"unit\": \"word\", \"offsetMs\": 80, \"from\": \"start\"}}\n]}\n```\n\nEach unit runs the full animation for `durationMs`, offset `offsetMs` from the\nprevious. `from` picks which unit leads: `start` (default), `end`, or `center`.\nA stagger needs at least two units — below that it compiles as an ordinary\nblock animation.\n\n`unit` is one of three, and the count is what the span math is built on:\n\n| `unit` | One unit is | Counted as |\n|---|---|---|\n| `word` | a whitespace-separated word | the words in the clip's text |\n| `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 |\n| `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 |\n\nThe span is `durationMs + offsetMs × (units − 1)`, halved for `from: \"center\"`\n(the middle unit leads and both edges run last, so the largest delay is half\nthe span). Six words at `offsetMs: 200` with a 500ms preset span 1500ms. The\nsame line at `unit: \"character\"` is roughly thirty units, so the same offset\nspans six seconds — character staggers want offsets an order of magnitude\nsmaller than word staggers, and a `line` stagger changes count when the frame\ndoes.\n\n**A span that does not fit the clip is silently compressed, not cut off.** The\nengine shrinks the per-step offset — never the per-word duration — so the last\nword still completes inside the clip. Ask for 200ms of offset on a clip with\nroom for 60ms and you get 60ms: every word still animates, the motion is\nsimply tighter and more simultaneous than you wrote. `validate_timeline`\nreports `stagger_compressed`.\nThis is the most common reason staggered type \"doesn't look staggered\". Work\nout the span, compare it against `durationMs` minus any `delayMs`, and if it\ndoes not fit, lengthen the clip or lower the offset yourself rather than\nletting the clamp choose for you.\n\n`loop` staggers are the exception: the offset is a phase shift, so it is\nneither stretched nor clamped.\n\nWhich offset reads as texture and which reads as one word at a time is in\n`motion-principles`, with the line-length limit that goes with it.\n\nFor several separate clips, `stagger_animations` shifts existing animation\ndelays in the order of `clip_ids` without moving the clips. Give at least two\ndistinct clip IDs, each already animated, and a non-negative `offset_ms`.\nThe in-product tool is `ui_timeline_stagger_animations` with the same fields.\n\n```json\n{\"op\":\"stagger_animations\",\"clip_ids\":[\"logo-id\",\"title-id\",\"tagline-id\"],\"offset_ms\":80}\n```\n\nStagger applies only to text clips and only to transform and opacity motion.\n`wipe`, `blur` and `colorFade` stay block-level even with a stagger set — the\nmask and effect curves are per-layer, not per-word. A stagger on a media clip\nis ignored, not an error, so a \"why isn't it staggering\" bug is usually a clip\nthat is not `mediaType: \"text\"`.\n\nType sizes are authored against the sequence resolution: `fontSizePx` on a\n1920×1080 sequence means the same thing at any preview width.\n\n## Document motion fields\n\nThe fields below belong to clips in the complete timeline document. Merge\nthem into the clip read by `get_timeline`, retaining its other fields and\nanimations, then write the full document with `set_timeline_document`.\n`edit_timeline` and `ui_timeline_animate_clip` do not accept them as animation\ninputs.\n\n`styleTracks` animate visual properties in the same animation window as a\npreset. Each stored animation needs `id`, `role`, `preset`, and `durationMs`.\nTargets include `text.color`, `shape.fill`, `shape.cornerRadius`,\n`clip.borderRadius`, `mask.featherPx`, `effect.\u003Ceffect-id>.\u003Cnumeric-field>`,\nsupported effect colors such as `effect.bg-field.colorB`,\ngradient stops, and compatible `shape.d` or `mask.d` paths. The clip must\nalready have the targeted style, mask, or effect. A per-glyph target such as\n`glyph.color` also needs text `stagger`.\n\n```json\n{\"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\"}]}]}]}\n```\n\n`textAnimator` changes text content over its animation window. `ticker`\nsupports `from`, `to`, `decimals`, `padTo`, `prefix`, and `suffix`.\nSet `groupSeparator` to `\",\"` for grouped thousands such as `2,847`;\nwithout it the output stays ungrouped. `scramble` supports `charset` and\ndeterministic `seed`. It needs a text clip.\nAn animation's `beat` binds its start to a one-based beat index at the live\nsequence tempo; `scope` is `clip` or `sequence`, and `offsetMs` moves it around\nthat beat. `delayMs` still applies on top.\n\n```json\n{\"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}}\n```\n\n`animationLinks` copy a source clip's authored `positionX`, `positionY`,\n`scale`, `rotation`, or `opacity` channel, with optional time offset, loop,\nscale, and offset. A `wiggle` link supplies seeded motion without a render-time\nscript. Links do not follow the source's own links. Resolve each\n`sourceClipId` against the document's clip IDs before writing it. An opacity\nlink replaces the target clip's own opacity while its group and transition\ncoverage still multiply the result.\n\n```json\n{\"animationLinks\":[{\"target\":\"positionX\",\"sourceClipId\":\"leader-id\",\"source\":\"positionX\",\"scale\":0.35},{\"target\":\"rotation\",\"kind\":\"wiggle\",\"amplitude\":0.025,\"frequencyHz\":1.4,\"seed\":8}]}\n```\n\n`layout` turns a group clip into a flex container for its real children\n(their `parentId` names the group): `flexDirection: \"row\" | \"column\"`,\n`gap`, `justifyContent`, `alignItems`, `padding`. A child's own sizing is\n`flexItem` (`grow`, `width`, `position: \"absolute\"` + `inset` to fill the\ncontainer). `repeater.count` includes the original clip and is capped at\n128. `timeStepMs` offsets each copy in time. These are fields on a clip, not\nnew clip records to add by hand.\n\nDo not scroll a still plate by tiling it with a `repeater`. Each copy meets the\nnext at a seam, and the seam shows when the image differs at its edges. A\ngenerated rain plate is denser at the top, so it always does. To make rain\nfall, move one plate to a random offset on `hold` keyframes every two frames.\nThis reads as falling rain and has no seam.\n\n```json\n{\"layout\":{\"display\":\"flex\",\"flexDirection\":\"row\",\"gap\":24},\"repeater\":{\"count\":3,\"positionStep\":{\"x\":180,\"y\":0},\"timeStepMs\":67}}\n```\n\n(`logo-id`\u002F`wordmark-id` are separate clips with `parentId` naming this group.)\n\n`camera2d` is a sequence document field. It needs `position`, `depthPx`, and\npositive `focalLengthPx`. Keyframes use absolute `timeMs` and include position\nand depth. Add `depthPx` to a clip's existing `transform` for perspective;\n`focusDepthPx` and `aperturePx` add depth-of-field blur. `camera2d: null`\nclears the camera.\n\nFor a tilted wall of cards, use a parent group's `rotation`, `rotationX`, and\n`perspective` for the shared tilt, each card's `depthPx` and scroll animation\nfor the separate planes, and `camera2d` for the push and depth blur. Keep a\nshape's normalized geometry inside the frame-sized source raster. Move the\nclip or group with `transform.position` or custom `offsetX`\u002F`offsetY` curves\nto scroll or fly it across the frame; geometry outside that raster is clipped\nbefore the transform places it.\n\n```json\n{\"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}]}}\n```\n\n`motionBlur` is per clip: `samplesPerFrame` is 1–32 and `shutterAngle` is\n0–360 degrees. One sample disables blur for that clip. A higher count sets a\nminimum; the scene renders at its highest requested count, with evenly\nweighted samples across each layer's own shutter window. `steppedTime.fps`\nquantizes a clip's source and animation clock. `temporalEcho` adds delayed\ncopies through `copies`, `intervalMs`, and `opacityDecay`.\n\n```json\n{\"motionBlur\":{\"samplesPerFrame\":8,\"shutterAngle\":180},\"steppedTime\":{\"fps\":12},\"temporalEcho\":{\"copies\":3,\"intervalMs\":80,\"opacityDecay\":0.5}}\n```\n\n## Groups\n\n`{\"op\":\"add_group\",\"name\":\"Lower third\",\"startMs\":4000,\"durationMs\":4000,\"children\":[\"name-id\",\"role-id\"]}` makes a clip with no media whose transform, opacity\nand window every clip naming it inherits. `{\"op\": \"set_parent\", \"target\":\n\"Name plate\", \"parentId\": \"\u003Cgroup id>\"}` adopts a clip that already exists;\n`\"parentId\": null` releases it, and a cycle is refused.\n\nRig a lower third this way: scrim, name, role, each on its own overlay track,\nall three parented to one group. Then animate the **group**. One `slide` in and\none `fade` out on the group moves the assembly together and keeps the parts\nlocked to each other, where animating them separately is three chances to\ndrift.\n\nChildren keep their own tracks, so grouping does not change what covers what.\nA plain group's opacity multiplies into each child. A group with an effect,\nnon-`normal` blend mode, or transition precomposes its children into one\nsurface first. The effect or transition then runs once on the assembled\npicture. Put a `glow` on the group for the whole look, on a child for that\nchild alone.\n\n`parent_missing`, `parent_not_group` and `parent_cycle` all mean the child\nrenders unparented, losing the group's transform, opacity and window.\n\n## Transitions\n\nA transition is between clips, not on one clip. It is authored on the\n**incoming** clip and resolved for both: the compositor finds the clip beneath\nit on the same track and gives that one the complementary half of the cut.\nThe incoming clip may be a group: `set_transition` on the group transitions\nits whole precomposed assembly, with children still parented on their own\ntracks. Its outgoing partner is the previous overlapping sibling on the\ngroup's track under the same parent.\n\nTwo clips on the same track whose times overlap dissolve across the overlap\nwith no tool call at all. The corollary: **an accidental overlap is an\naccidental dissolve.** When two clips look soft where you wanted a hard cut,\ncheck their `startMs` and `durationMs` before looking anywhere else.\n\n`set_transition` picks a cut instead of taking the default:\n`{\"op\": \"set_transition\", \"target\": \"Shot 2\", \"transition\": {\"type\": \"wipe\",\n\"durationMs\": 500, \"direction\": \"left\", \"softness\": 0.1}}`. Pass\n`\"transition\": null` to clear it. The cut plays over the target's head, so\n**overlap the two clips by at least `durationMs`** or there is nothing beneath\nfor it to play against — a transition with nothing under it reads as a fade\nfrom black.\n\n| Type | What moves | Reach for it when |\n|---|---|---|\n| `crossfade` | Incoming fades up | Two shots of the same scene; the safe default |\n| `dipToColor` | Both fade through a solid | A scene change, a chapter break. `color` is yours to pick |\n| `wipe` | Feathered edge reveals the incoming | A graphic or split-screen feel. `softness` 0 is a hard edge |\n| `push` | Both clips travel one frame width | Lateral energy; the two read as one moving picture |\n| `slide` | Only the incoming travels | The new shot arrives over a shot that holds |\n| `zoom` | Outgoing grows, incoming comes in from 0.8 | A push into the next beat |\n| `whip`, `zoomBlur` | Motion blur across a directional or zooming cut | Fast camera energy |\n| `glitch` | Displaced digital cut | A deliberate electronic break |\n| `gradientWipe`, `iris` | Spatial threshold or aperture reveal | Designed graphic transitions |\n| `lightLeak` | Localized color field across the cut | A flare-like bridge |\n\n`direction` on `wipe`, `push`, `slide`, `whip`, and `gradientWipe` names the\nincoming edge. `gradientWipe` also takes `map: \"linear\" | \"radial\" |\n\"noise\"`, `scale`, `seed`, and `softness`. `iris` takes `softness`;\n`lightLeak` takes `color`, `scale`, and `seed`. `easing` takes a named id,\n`cubic-bezier(...)`, or `spring(...)`.\n\n`fadeInMs` and `fadeOutMs` on `set_clip_params` are audio fades. The compositor\ndoes not read them, so they will not fade a picture — for that, animate the\nclip with a `fade` `in` or `out`.\n\nA transition's opacity multiplies with animation opacity rather than replacing\nit, so a clip that both dissolves in and carries a `fade` in ramps twice and\nreads slower and softer than either alone. Pick one.\n\n`validate_timeline` reports `transition_exceeds_duration` when the cut is\nlonger than the clip carrying it, and `unknown_transition` for a type or\ndirection this build cannot draw — which falls back to a cross-fade running\nleft rather than failing the render.\n\n## Masks and mattes\n\n`{\"op\": \"set_mask\", \"target\": \"Title\", \"mask\": {\"kind\": \"rect\", \"y\": 0.2,\n\"height\": 0.6, \"featherPx\": 24}}` cuts the layer to a shape in the clip's own\n0..1 space. `kind` is `rect`, `ellipse` or `path` (with an SVG `d`).\n`featherPx` softens the edge, `invert` keeps the outside instead, and\n`\"mask\": null` clears it. A path that cannot rasterize reports\n`mask_path_invalid` and the layer draws unmasked — the whole picture shows\nthrough, the opposite of what you asked for.\n\n`{\"op\": \"set_matte\", \"target\": \"Fill\", \"matte\": {\"source\": \"Text shape\",\n\"mode\": \"alpha\"}}` uses another clip as this one's transparency. `alpha` reads\nthe source's opacity, `luma` its brightness, and `invert` flips it. **The\nsource clip stops drawing itself.** That is the point, and it is also why a\nmatte pointed at a clip you still wanted on screen reads as a deletion. Reach\nfor it when text has to be filled with moving footage: footage on the fill\nclip, text on the source. `matte_source_missing` is an error, and the layer\nthen draws unmatted, showing everything the matte was hiding.\n\n## Clip effects\n\n`set_effects` replaces the whole chain and applies it in the\norder given; an empty list clears it. The tool also accepts `pixelate`,\n`posterize`, `directionalBlur`, `lensDistortion`, `stylize`, `generator`, `lut`,\nand `grain` alongside `color`, `blur`, `glow`, `dropShadow`, `vignette`,\n`sharpen`, `chromaKey`, `curves`, `levels`, and `liftGammaGain`. Its flat input\nuses `{type, ...typeFields}`. It assigns effect IDs and enables each effect;\nit does not accept stored `id` or `enabled` fields. `validate_timeline`\nreports `unknown_effect` for an unrecognized type. The Canvas 2D preview\napplies all known effects through CPU passes when its host supplies scratch\nsurfaces. `preview_timeline_frame.effects_not_applied` lists unknown types;\n`degraded` reports missing surfaces and other draw limitations.\n\n```json\n{\"op\":\"set_effects\",\"target\":\"Shot 2\",\"effects\":[{\"type\":\"grain\",\"amount\":0.12,\"size\":1.1},{\"type\":\"stylize\",\"mode\":\"dither\",\"amount\":1,\"seed\":4}]}\n```\n\n## Time remap\n\n`{\"op\": \"set_time_remap\", \"target\": \"Shot 3\", \"timeRemap\": {\"keyframes\":\n[{\"t\": 0, \"sourceMs\": 0}, {\"t\": 1, \"sourceMs\": 4000, \"easing\": \"easeInOut\"}]}}`\nmaps timeline time onto source time. `set_time_remap` replaces the whole map\neach time it is called. `t` runs 0..1 over the clip's window and\nmust start at 0, end at 1 and ascend; `sourceMs` says which millisecond of the\nsource plays there, so a descending pair is a reverse and a flat pair is a\nfreeze. `\"timeRemap\": null` clears it, and the remap replaces the clip's rate.\nA repeated or backwards `t` is `time_remap_not_monotonic`, an error.\n\n`split_clip` and `trim_clip` refuse a remapped clip. Both would have to\nre-derive the map, and re-deriving it silently is how a speed ramp turns into a\ndifferent move. Clear the remap, cut, re-apply.\n\n## Compositions\n\nA composition is a group saved as a reusable rig with named parameters.\n`list_compositions` reports what is available, `get_composition` shows one\nrig's parameters and their defaults, and\n`{\"op\":\"insert_composition\",\"composition_id\":\"lower-third\",\"startMs\":4000,\"params\":{\"name\":\"Ada\"}}`\ninstantiates it into the document with fresh clip ids. Use parameter names\nreturned by `get_composition`; `name` here is an example.\n\nBundled examples include `title-card`, `lower-third`, `caption-bar`,\n`callout`, `cta-end-card`, `logo-sting`, `title-slam`, `word-cards`,\n`window-frame`, and `number-ticker`. Use `list_compositions` for the\ntemplates available in the current workspace.\n\nInsert first, then override `params`. The rig's timing and motion are already\nbalanced against each other, and rebuilding it from bare clips throws that\naway. Edit the instantiated clips only where the brief actually differs.\n\nWhen a group should be reused, `save_composition\n{timeline_id, group_target, name, params}` extracts it as a composition.\n`params` map names to typed defaults and JSON pointers into child fields.\n`insert_composition` remaps the group and child clip IDs, including internal\nlayout and animation links, while preserving references to clips outside the\ncomposition.\n\n## Cutting to the beat\n\n`detect_audio_events` on the music clip reports onsets and a tempo. Its\n`onsets.times` are in **seconds** and the ops take milliseconds, so multiply by\n1000. Read the tempo's `reliable` flag before building a grid from `bpm`:\nspeech and room tone produce a confident-looking number from nothing.\n\n`{\"op\":\"set_markers_from_beats\",\"onsets_ms\":[0,500,1000]}` lays the\ngrid down so you can see it, one marker per onset. `{\"op\": \"snap_to_beats\", \"targets\": \"all\",\n\"tolerance_ms\": 60, \"mode\": \"start\", \"action\": \"move\"}` pulls clip edges onto\nit. Either takes `onsets_ms`, or a `bpm` with an `offset_ms`; with `bpm`,\n`set_markers_from_beats` also needs `count`, which an `onsets_ms` grid ignores.\n\nThe default 60ms tolerance is about two frames at 30fps: close enough that a\ncut reads as on the beat, tight enough that it will not drag an edge to a beat\nit was never near. Widen it only when the grid is loose, because a large\ntolerance moves cuts you meant to leave alone. `snap_to_beats` reports every\ntarget, including the ones no beat was in reach of, with the reason.\n\n`action: \"move\"` slides the clip and keeps its length, which shifts everything\ndownstream. `action: \"trim\"` changes the length and leaves the neighbours\nwhere they are. Move a title that has to land on a hit; trim a picture clip\nwhose out point has to meet the next shot.\n\n## Validate the output\n\nTwo checks, and they answer different questions.\n\n**`validate_timeline`** answers \"is this document sound\": unknown animation\npresets and transitions, fades and cuts longer than the clip carrying them,\nclips on tracks the document lacks, overlapping clips, clips shorter than a\nframe, in\u002Fout points that cannot render, duplicate ids. Run it after every batch\nof edits.\n\nIt also names the motion failures that used to be yours to remember. Each\nfinding carries a stable `code`; these are the ones that mean the picture will\nnot perform the motion you wrote:\n\n| Code | Severity | What to do about it |\n|---|---|---|\n| `unknown_animation_preset` | error | The stored `preset` is not one this build ships. Use an id from `list_animation_presets` |\n| `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` |\n| `parent_cycle` | error | A `parentId` chain loops, so the group cannot be resolved at all. Break the loop |\n| `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 |\n| `time_remap_not_monotonic` | error | `t` repeats or goes backwards. `sourceMs` may descend — that is a reverse — but `t` may not |\n| `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 |\n| `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 |\n| `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 |\n| `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 |\n| `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` |\n| `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 |\n| `replace_curves_overlap` | warning | Two animations drive one absolute channel (`positionX\u002FY`, `anchorX\u002FY`, `trimStart\u002FEnd`) at once. The last in document order wins and the other is discarded. Separate them in time or fold them into one curve |\n| `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 |\n| `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` |\n| `unknown_easing` | warning | Outside the grammar, so it eases linearly. Check the spelling: `easeOut`, not `ease-out` |\n| `unknown_transition` | warning | This build cannot draw that `type` or `direction`; it cross-fades left instead. Pick one from the table above |\n| `unknown_effect` | warning | This build cannot apply that effect; the layer draws ungraded |\n| `unknown_shape_kind` | warning | No geometry for that `shapeStyle.kind`, so the shape draws nothing. A scrim that vanished is usually this |\n| `transition_exceeds_duration` | warning | The cut is longer than the clip carrying it. Shorten the transition or lengthen the clip |\n| `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 |\n| `mask_path_invalid` | warning | The `kind` or path `d` cannot rasterize, so the layer draws unmasked and the whole picture shows through |\n| `layer_cap_exceeded` | warning | More video clips overlap at that instant than the compositor draws; the ones on lower tracks are dropped |\n| `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 |\n| `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 |\n\nThe contrast half refuses to guess: a colour it cannot parse, a translucent\nplate or gradient-filled type produces no contrast finding. Type over picture\nwith no plate at all is the case it can name without measuring, and it says so\nas `text_backing_unproven`. Either way a silent `text_illegible` is not a pass\n— that is what the frames are for.\n\n**`preview_timeline_frame`** answers \"what does it look like\". It composites\nthe real frame — every track layered in order, transforms and opacity applied,\nanimations sampled, transitions part way through, text and shapes drawn — at\nthe timecodes you name. It needs no browser, no GPU and no open editor.\n\n```json\n{\"timeline_id\": \"\u003Cid>\", \"times_ms\": [1200, 1450, 3000]}\n```\n\n`range {from_ms, to_ms, count}` samples evenly across a span instead of naming\neach timecode — the way to watch a whole entrance rather than guess three\npoints inside it. `sheet: true` tiles every frame into one labelled contact\nsheet, so a sweep is one image to read instead of a handful of handles.\n\nIt returns an image handle per timecode; call `view_image` on the ones you need\nto see. Beside each frame it lists the layers top of the stack first, with the\nopacity, blend mode and wipe progress each one resolved to — often enough to\ndiagnose a problem without looking at a pixel.\n\n**Sample the middle of a motion, not its ends.** The endpoints are the states\nyou already know: before an entrance the clip is absent, after it the clip is\nat rest. A 400ms fade-in starting at 1000ms tells you nothing at 1000ms or\n1400ms and everything at 1200ms. For each animation you added, preview at:\n\n- the midpoint of the entrance,\n- a held moment between the in and out,\n- the midpoint of the exit,\n- and, for staggered text, a point after the first word lands but before the\n  last does — the frame where half the line is on screen is the one that\n  reveals a stagger that is too slow.\n\nWhat the frames catch that a validator cannot: text over a busy or same-colored\nplate, a lower-third under the wrong element because two tracks are the wrong\nway round, a title that has slid outside the frame, a stagger still finishing\nwhen the clip ends, a scrim covering the face it was supposed to sit beside, an\nelement that never appears because its clip is on an audio track.\n\nEach frame carries `degraded` entries for a difference the Canvas 2D host\ncould not draw, such as a missing scratch surface, a hard mask edge, or an\nunsupported perspective transform. Read the actual entries before deciding\nwhich part of the frame needs a GPU render. Known clip effects have CPU passes.\n\nRead the `skipped` field on any layer that drew nothing — it says whether the\nclip is still `draft`, its asset would not read, or the source had no frame at\nthat time. A missing picture is usually an unrendered clip, not a motion bug.\n\n`effects_not_applied` names enabled effect types the Canvas 2D compositor does\nnot recognize. A known effect such as chroma key, vignette, sharpen, or LUT\nshould render in the frame preview; a missing scratch surface instead appears\nin `degraded`.\n\n\n## The render loop\n\nChange one thing, preview the same timecodes, compare. When motion is wrong,\nthe order to check is: is the element on screen at all (layer present in the\nreport), is it in the right stacking position (`z_index`), is it at the right\nopacity (`opacity`), is it in the right place (look at the frame). Working down\nthat list beats re-authoring the animation.\n\nFor every defect named by a `view_image` look, a preview layer report, or a\nmodel critique of the render, fix it and re-check the same timecode, or tell\nthe user it remains open and why. Never ship past a named defect silently. If\n`view_image` returns no pixels, tell the user you could not see the frames.\nDo not claim a visual check.\n\n`compare_timeline_frames {a, b, times_ms | range}` measures what actually moved\nbetween two documents or two versions. Run it after a change the user did not\nask for (a restructure, a composition insert, a snap pass) so you can say\nwhich frames it touched instead of hoping it touched none.\n\nBefore calling the cut done, render it. `render_timeline` with a\n`preview_scale` below 1 while you are still iterating, then once at full size.\nThe preview answers what one timecode looks like; the render answers whether\nthe file plays.\n\nA whole-document rewrite goes through `set_timeline_document`, which validates\nbefore it writes and snapshots what it replaces. Name that snapshot\n(`snapshot_name: \"before the title pass\"`) so the version list says what you\nwere about to do. For anything smaller, `create_timeline_version` first and\n`edit_timeline` after.\n\nStop when the motion is right, and say what you animated.\n","# Timeline edit ops → the `edit_timeline` reference\n\nThis is the op-by-op contract for changing a timeline document that already\nexists. `motion-graphics` owns the build loop and picks the surface: a new\ncut goes through the `@nodetool-ai\u002Fsandbox-timeline` pack, which handles\ntracks, groups, layout and most of what follows here for you. Reach for this\nskill once that document exists and needs a few changes.\n\nOps are the right tool when the timeline has no authoring code (`code.get`\nanswers `code: null`), or for a deliberate hand tweak on one that does. On a\ncode-backed timeline, an op inside a scene the code tracks marks that scene\n\"edited\": the next `code.set`\u002F`code.edit`\u002F`code.rebake` (`api-timelines`)\nkeeps your edit and reports it in `conflicts` rather than overwriting it,\nunless `force` says otherwise.\n\n## Read before you edit\n\nCall `get_timeline` first. You need the sequence's `fps`, `width`, `height`,\nthe track list with each track's `index` and `type`, and every clip's\n`startMs`, `durationMs`, `mediaType` and existing `animations`. Edit nothing\nuntil you have clip ids from the document — never a guessed one.\n\nSnapshot with `create_timeline_version` before your first edit. Motion work is\niterative and a wrong `mode: \"replace\"` wipes animations somebody wrote.\n\n`edit_timeline` takes a timeline ID and an `ops` array, run in order, up to\n60 per call. Inspect each op's `ok` and the batch's `failed` result before a\ndependent edit. In an in-product agent, use the matching `ui_timeline_*` tool\nfor an individual op:\n\n```json\n{\"timeline_id\":\"\u003Cid>\",\"ops\":[{\"op\":\"animate_clip\",\"target\":\"Title\",\"animations\":[{\"role\":\"in\",\"preset\":\"fade\",\"durationMs\":400}]}]}\n```\n\nThe `animate_clip` input accepts catalog presets or `custom` curves and code,\nplus `stagger` and a typewriter `caret`. It does **not** author `styleTracks`,\n`textAnimator`, or `beat`. `set_clip_params` does **not** author `layout`,\n`repeater`, `motionBlur`, `steppedTime`, `temporalEcho`, `animationLinks`, or\n`transform.depthPx`. `set_clip_params` refuses a call that names one of\nthose keys, and `animate_clip` drops the fields it does not author. For those fields, read `get_timeline`'s `timeline` object, change\nthe needed clip or sequence fields, and call `set_timeline_document` with a\ncomplete document assembled from it. Preserve tracks, clips, markers, tempo,\nsetup, templateId, mediaTracks, transcript, scriptEnabled, and camera2d when\npresent. Include `timeline.updatedAt` as `expected_updated_at` so a\nconcurrent edit is refused. A whole-document write validates the result and\nsnapshots the prior version. Omitted document fields are removed, so never\nsend only the changed clip. The JSON objects in the document sections below\nare fields to merge into that complete document, not `edit_timeline` ops.\nThe editor may lack controls for them even though they render and survive a\nsave.\n\n## Track layering\n\n**Lowest `index` renders on top.** Track 0 covers track 1, which covers track 2\n— Premiere, Resolve and FCP order, and the opposite of a paint stack. The\ncompositor turns the index into `z = 1000 - index`; `preview_timeline_frame`\nreports that `z_index` per layer and lists layers top of the stack first.\n\nFive track types, and the type is a role, not a restriction on what draws:\n\n| Type | Holds | Use for |\n|---|---|---|\n| `video` | picture clips, images | the cut itself |\n| `overlay` | text and shape clips | titles, lower-thirds, scrims, callouts |\n| `audio` | sound | music, voiceover, effects — never drawn |\n| `subtitle` | text | burned-in captions |\n| `midi` | played notes | a synthesized part — never drawn |\n\nRules that follow from the ordering:\n\n- A scrim goes on a **higher** index than the text it sits behind, and a lower\n  index than the picture it darkens — so picture, then scrim, then text, in\n  descending index order.\n- Give each motion element its own overlay track when their timings overlap.\n  Two animated clips on one track that overlap in time trigger an automatic\n  crossfade you did not ask for (see Transitions).\n- Captions are the exception you do not control: word-level captions from a\n  voiceover clip composite above every real track regardless of which track\n  that clip sits on. Do not build a title that expects to cover them.\n- An audio clip never contributes a layer. A frame that looks empty because\n  everything is on audio tracks is a track-type mistake.\n- At most **8 video layers** composite at once, resolved top track first. Text,\n  shape and image clips are not counted, so a stack of titles is unaffected —\n  but nine overlapping video clips means the bottom one silently does not draw.\n  A preview frame is how you find that out.\n\n## Build the elements\n\nThe clips you animate are made with the same `edit_timeline` op list.\n\n```json\n[\n  {\"op\":\"add_text_clip\",\"text\":\"Maya Chen\",\"trackId\":\"\u003Coverlay>\",\"startMs\":4000,\"durationMs\":3000,\"fontSizePx\":64},\n  {\"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}\n]\n```\n\n`add_text_clip` needs only `text`; the style fields (`fontSizePx`, `color`,\n`background`, `stroke`) are read from the op itself or from a `style` bag, and\nwith no `trackId` it lands on an overlay track. `add_shape_clip` takes its\ngeometry in `shape` (or `shapeStyle`, or the op's own keys) with `x`, `y`,\n`width` and `height` as 0..1 fractions of the frame — no geometry at all is a\nfull-frame rect, which is the scrim you usually want.\n\nTracks are the z-order, so how they are made matters:\n\n```json\n[\n  {\"op\":\"add_track\",\"type\":\"overlay\",\"name\":\"Titles\"},\n  {\"op\":\"move_track\",\"target\":\"Picture\",\"toIndex\":2}\n]\n```\n\n`add_track` takes `type` (`video`, `audio`, `overlay`, `subtitle`, `midi`) and an\noptional `name`, and **appends to the bottom** — the highest index, which draws\nlast. A picture track added after the overlays therefore covers them. Add the\noverlays last, or move the picture down afterwards: `move_track` takes a\n`target` and one of `toIndex` (0 is the top), `before` or `after`.\n\n## Animating a clip\n\n`edit_timeline` with `{\"op\": \"animate_clip\", \"target\": \u003Cclip id or name>,\n\"animations\": [...]}`. Each animation is `{role, preset, durationMs?, delayMs?,\neasing?, params?}`. `mode` is `\"replace\"` (default) or `\"add\"`.\n\nFour roles. A clip may carry several, one of each kind or more:\n\n- **`in`** — entrance. `delayMs` offsets from the clip's start.\n- **`out`** — exit. `delayMs` offsets backwards from the clip's **end**, so 0\n  means it finishes exactly as the clip ends. This is the one people get wrong.\n- **`emphasis`** — a beat mid-clip. `delayMs` from the clip's start.\n- **`loop`** — runs continuously; `durationMs` is one cycle, not the total.\n  `kenBurns` is the exception: it runs once across the whole clip and holds.\n\n### The presets\n\n`in` \u002F `out` — `fade` (500ms), `slide` (500ms; `direction` left\u002Fright\u002Fup\u002Fdown,\n`distance` 0–1 of the frame, default 0.3), `pop` (500ms, easeOut; `overshoot`\n1–1.5, default 1.08), `spin` (500ms; `turns` 0–2, default 0.25), `wipe` (500ms;\n`direction`, `softness` 0–0.5, default 0.05), `blur` (500ms; `amount` 0–40,\ndefault 12), `colorFade` (600ms, grayscale blooming into color).\n\n`in` on text only — `typewriter` reveals whole characters in reading order.\nSet `durationMs` to the time for the entire text to appear. Without it, the\ncharacters type 65ms apart and finish within the clip. To set a fixed speed,\nuse `durationMs: 1` with `stagger: {unit: \"character\", offsetMs: 40}`.\n`animate_clip` stores every typewriter in that form. A document you write by\nhand must use it too: a stored plain `durationMs` with no `stagger` shows the\nwhole text at once, with no caret.\n\n`emphasis` — `pulse` (600ms; `intensity` 0–0.5, default 0.06), `flash` (400ms;\n`intensity` 0–1, default 0.6), `shake` (600ms; seeded noise on both axes:\n`intensity` 0–0.2 default 0.02, `frequency` Hz default 8, `seed` default 1,\n`fadeInMs`\u002F`fadeOutMs` default 0; the same seed repeats the same motion),\n`bounce` (600ms; `height` 0–0.3, default 0.05), `squash` (500ms, easeOutBack;\n`amount` 0–0.5, default 0.12), `followPath` (also `loop`; `d` an SVG path in\n0..1 space plus `pathX`\u002F`pathY`\u002F`pathWidth`\u002F`pathHeight`, `orient` turns the\nclip along the tangent, `startT`\u002F`endT` pick a stretch of the path; writes\n`positionX`\u002F`positionY`, so it replaces the clip's position).\n\n`loop` — `kenBurns` (`zoom` 0–1 default 0.12, `direction` in\u002Fout,\n`driftX`\u002F`driftY` −0.2–0.2; one move over the whole clip, with `durationMs`,\n`delayMs` and `stagger` ignored, so the clip's length sets its speed),\n`float` (3000ms; `amplitude` 0–0.2, `frequency` cycles per period default 1,\n`seed` 0 = pure sine, else drift), `breathe` (3000ms; `intensity` 0–0.3), `rotate` (3000ms; `direction` cw\u002Fccw),\n`hueShift` (3000ms; `direction` forward\u002Freverse), `orbit` (3000ms; `degrees`\ndefault 360, `direction` cw\u002Fccw) — which sweeps a 3D clip's camera and does\nnothing on any other clip.\n\nSeventeen named easings: `linear`, `easeIn`, `easeOut`, `easeInOut`,\n`easeOutBack`, `easeOutElastic`, `easeOutBounce`, exponential, quintic, and\ncircular variants (`easeOutExpo`, `easeInOutQuint`, `easeOutCirc`), and `hold`\nfor a segment that keeps its prior keyframe value until the segment ends.\nUnset means the preset's own default, and\nfailing that the role's — `in` gets `easeOut`, `out` gets `easeIn`. That\ndefault is almost always right: an entrance decelerates into place, an exit\naccelerates away. Reach for `easeOutBack` or `easeOutElastic` only when the\npiece is playful; they read as cheap on anything corporate.\n\n`{\"op\": \"list_animation_presets\"}` reports the exact param list when this\nsummary is not enough — the catalog is the authority. It is an `edit_timeline`\nop like every other call here, not a tool of its own.\n\n### Keyframes and the easing grammar\n\n`easing` takes more than the named ids. `cubic-bezier(x1,y1,x2,y2)` is\nthe CSS curve: `cubic-bezier(0.16,1,0.3,1)` is the deceleration most entrances\nwant. `spring(stiffness,damping,mass)` solves a real spring, so\n`spring(180,12,1)` settles with one small overshoot and `spring(180,26,1)` does\nnot overshoot at all. All three constants must be positive. Anything the parser\ncannot read eases linearly and reports `unknown_easing`.\n\nThe presets are where motion starts, not where it stops. A custom animation is\none extra field and it reaches shapes no preset has: an entrance that overshoots\ntwice, a hold in the middle of a move, two channels on different schedules, a\ndecay, an arc, a path draw-on. Reach for one as soon as a preset is close but\nnot right — bending `durationMs` and `easing` around a preset that was never the\nshape you wanted takes longer and lands flatter. `motion-curves` carries the\nchannel table and the worked recipes. Then `{\"role\": \"in\", \"preset\": \"custom\",\n...}` carrying exactly one of\n\n- `curves` — `[{property, keyframes: [{t, value, easing?}]}]`, with `t` running\n  0..1 across the animation's window. The `list_animation_presets` op reports\n  the animatable properties.\n- `code` — a JavaScript body baked into curves once, host-side, at author time.\n  Nothing evaluates at render.\n\nAdd `mask` when a curve drives `wipeProgress`; without it the wipe has no edge\nto run against.\n\nMotion that has to follow a piece of audio is a different call: `bake_audio_animation`\nmeasures an audio clip and writes the curve onto a target clip through the\n`set_baked_animation` op, either following the loudness (`mode: \"envelope\"`) or\npulsing on each onset (`mode: \"beats\"`). Its keyframes sit in the target clip's\nown media, so trimming or splitting that clip re-slices the motion, and the\nanimation carries `bakedFrom` — running it again with different settings\nreplaces that curve instead of stacking a second one, and never touches a curve\nyou keyframed by hand. `set_markers_from_beats` is the other tool: markers to\ncut against, rather than motion to animate with.\n\nHow several animations on one channel combine decides whether the second one\nadds to the first or throws it away:\n\n| Fold | Channels | Result |\n|---|---|---|\n| 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 |\n| multiply | `scale`, `scaleX`, `scaleY`, `opacity`, `saturation`, `contrast`, `cameraZoom` | Values multiply, so two half-opacity ramps give a quarter |\n| min | `wipeProgress` | The tightest wipe wins |\n| replace | `positionX`, `positionY`, `anchorX`, `anchorY`, `trimStart`, `trimEnd` | One value survives — the last animation in document order. The other is discarded |\n\nThe replace row is the trap. Two animations driving `positionX` over the same\ninstants means one of them does nothing, and `validate_timeline` reports\n`replace_curves_overlap`. Move a clip with `offsetX` when you want it to\ncompose with other motion, and with `positionX` only when you mean an absolute\nplacement nothing else touches.\n\n### Durations the contract constrains\n\n`motion-principles` holds the numbers — how long an entrance runs, how long a\ntitle holds. What this contract adds is what the document refuses:\n\n- `in` + `out` together must fit inside the clip. A window that does not fit\n  after its `delayMs` is clamped or never runs, and `validate_timeline` says so\n  as `animation_exceeds_clip`.\n- A `loop` on a clip shorter than one cycle shows a fragment of the motion.\n  `kenBurns` is not cycled: it always spans the clip, so a short clip makes\n  its zoom and drift fast.\n\n### Choosing motion that matches the cut\n\nPick the entrance from the edit, not from variety. A title over a locked-off\nshot can `slide`; a title over a whip pan or a moving camera should `fade` or\n`blur`, because sliding text plus moving picture reads as two unrelated\nmotions. Match the direction to the picture: text sliding against the camera\nmove fights it.\n\nOne motion idea per moment. A clip with `pop` in, `shake` emphasis and `spin`\nout is not three times as expressive.\n\n## Animating typography\n\nText clips take every preset above, and one thing media clips do not:\nper-word stagger. Add `stagger` to an animation on a text clip:\n\n```json\n{\"op\": \"animate_clip\", \"target\": \"Title\", \"animations\": [\n  {\"role\": \"in\", \"preset\": \"pop\", \"durationMs\": 400,\n   \"stagger\": {\"unit\": \"word\", \"offsetMs\": 80, \"from\": \"start\"}}\n]}\n```\n\nEach unit runs the full animation for `durationMs`, offset `offsetMs` from the\nprevious. `from` picks which unit leads: `start` (default), `end`, or `center`.\nA stagger needs at least two units — below that it compiles as an ordinary\nblock animation.\n\n`unit` is one of three, and the count is what the span math is built on:\n\n| `unit` | One unit is | Counted as |\n|---|---|---|\n| `word` | a whitespace-separated word | the words in the clip's text |\n| `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 |\n| `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 |\n\nThe span is `durationMs + offsetMs × (units − 1)`, halved for `from: \"center\"`\n(the middle unit leads and both edges run last, so the largest delay is half\nthe span). Six words at `offsetMs: 200` with a 500ms preset span 1500ms. The\nsame line at `unit: \"character\"` is roughly thirty units, so the same offset\nspans six seconds — character staggers want offsets an order of magnitude\nsmaller than word staggers, and a `line` stagger changes count when the frame\ndoes.\n\n**A span that does not fit the clip is silently compressed, not cut off.** The\nengine shrinks the per-step offset — never the per-word duration — so the last\nword still completes inside the clip. Ask for 200ms of offset on a clip with\nroom for 60ms and you get 60ms: every word still animates, the motion is\nsimply tighter and more simultaneous than you wrote. `validate_timeline`\nreports `stagger_compressed`.\nThis is the most common reason staggered type \"doesn't look staggered\". Work\nout the span, compare it against `durationMs` minus any `delayMs`, and if it\ndoes not fit, lengthen the clip or lower the offset yourself rather than\nletting the clamp choose for you.\n\n`loop` staggers are the exception: the offset is a phase shift, so it is\nneither stretched nor clamped.\n\nWhich offset reads as texture and which reads as one word at a time is in\n`motion-principles`, with the line-length limit that goes with it.\n\nFor several separate clips, `stagger_animations` shifts existing animation\ndelays in the order of `clip_ids` without moving the clips. Give at least two\ndistinct clip IDs, each already animated, and a non-negative `offset_ms`.\nThe in-product tool is `ui_timeline_stagger_animations` with the same fields.\n\n```json\n{\"op\":\"stagger_animations\",\"clip_ids\":[\"logo-id\",\"title-id\",\"tagline-id\"],\"offset_ms\":80}\n```\n\nStagger applies only to text clips and only to transform and opacity motion.\n`wipe`, `blur` and `colorFade` stay block-level even with a stagger set — the\nmask and effect curves are per-layer, not per-word. A stagger on a media clip\nis ignored, not an error, so a \"why isn't it staggering\" bug is usually a clip\nthat is not `mediaType: \"text\"`.\n\nType sizes are authored against the sequence resolution: `fontSizePx` on a\n1920×1080 sequence means the same thing at any preview width.\n\n## Document motion fields\n\nThe fields below belong to clips in the complete timeline document. Merge\nthem into the clip read by `get_timeline`, retaining its other fields and\nanimations, then write the full document with `set_timeline_document`.\n`edit_timeline` and `ui_timeline_animate_clip` do not accept them as animation\ninputs.\n\n`styleTracks` animate visual properties in the same animation window as a\npreset. Each stored animation needs `id`, `role`, `preset`, and `durationMs`.\nTargets include `text.color`, `shape.fill`, `shape.cornerRadius`,\n`clip.borderRadius`, `mask.featherPx`, `effect.\u003Ceffect-id>.\u003Cnumeric-field>`,\nsupported effect colors such as `effect.bg-field.colorB`,\ngradient stops, and compatible `shape.d` or `mask.d` paths. The clip must\nalready have the targeted style, mask, or effect. A per-glyph target such as\n`glyph.color` also needs text `stagger`.\n\n```json\n{\"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\"}]}]}]}\n```\n\n`textAnimator` changes text content over its animation window. `ticker`\nsupports `from`, `to`, `decimals`, `padTo`, `prefix`, and `suffix`.\nSet `groupSeparator` to `\",\"` for grouped thousands such as `2,847`;\nwithout it the output stays ungrouped. `scramble` supports `charset` and\ndeterministic `seed`. It needs a text clip.\nAn animation's `beat` binds its start to a one-based beat index at the live\nsequence tempo; `scope` is `clip` or `sequence`, and `offsetMs` moves it around\nthat beat. `delayMs` still applies on top.\n\n```json\n{\"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}}\n```\n\n`animationLinks` copy a source clip's authored `positionX`, `positionY`,\n`scale`, `rotation`, or `opacity` channel, with optional time offset, loop,\nscale, and offset. A `wiggle` link supplies seeded motion without a render-time\nscript. Links do not follow the source's own links. Resolve each\n`sourceClipId` against the document's clip IDs before writing it. An opacity\nlink replaces the target clip's own opacity while its group and transition\ncoverage still multiply the result.\n\n```json\n{\"animationLinks\":[{\"target\":\"positionX\",\"sourceClipId\":\"leader-id\",\"source\":\"positionX\",\"scale\":0.35},{\"target\":\"rotation\",\"kind\":\"wiggle\",\"amplitude\":0.025,\"frequencyHz\":1.4,\"seed\":8}]}\n```\n\n`layout` turns a group clip into a flex container for its real children\n(their `parentId` names the group): `flexDirection: \"row\" | \"column\"`,\n`gap`, `justifyContent`, `alignItems`, `padding`. A child's own sizing is\n`flexItem` (`grow`, `width`, `position: \"absolute\"` + `inset` to fill the\ncontainer). `repeater.count` includes the original clip and is capped at\n128. `timeStepMs` offsets each copy in time. These are fields on a clip, not\nnew clip records to add by hand.\n\nDo not scroll a still plate by tiling it with a `repeater`. Each copy meets the\nnext at a seam, and the seam shows when the image differs at its edges. A\ngenerated rain plate is denser at the top, so it always does. To make rain\nfall, move one plate to a random offset on `hold` keyframes every two frames.\nThis reads as falling rain and has no seam.\n\n```json\n{\"layout\":{\"display\":\"flex\",\"flexDirection\":\"row\",\"gap\":24},\"repeater\":{\"count\":3,\"positionStep\":{\"x\":180,\"y\":0},\"timeStepMs\":67}}\n```\n\n(`logo-id`\u002F`wordmark-id` are separate clips with `parentId` naming this group.)\n\n`camera2d` is a sequence document field. It needs `position`, `depthPx`, and\npositive `focalLengthPx`. Keyframes use absolute `timeMs` and include position\nand depth. Add `depthPx` to a clip's existing `transform` for perspective;\n`focusDepthPx` and `aperturePx` add depth-of-field blur. `camera2d: null`\nclears the camera.\n\nFor a tilted wall of cards, use a parent group's `rotation`, `rotationX`, and\n`perspective` for the shared tilt, each card's `depthPx` and scroll animation\nfor the separate planes, and `camera2d` for the push and depth blur. Keep a\nshape's normalized geometry inside the frame-sized source raster. Move the\nclip or group with `transform.position` or custom `offsetX`\u002F`offsetY` curves\nto scroll or fly it across the frame; geometry outside that raster is clipped\nbefore the transform places it.\n\n```json\n{\"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}]}}\n```\n\n`motionBlur` is per clip: `samplesPerFrame` is 1–32 and `shutterAngle` is\n0–360 degrees. One sample disables blur for that clip. A higher count sets a\nminimum; the scene renders at its highest requested count, with evenly\nweighted samples across each layer's own shutter window. `steppedTime.fps`\nquantizes a clip's source and animation clock. `temporalEcho` adds delayed\ncopies through `copies`, `intervalMs`, and `opacityDecay`.\n\n```json\n{\"motionBlur\":{\"samplesPerFrame\":8,\"shutterAngle\":180},\"steppedTime\":{\"fps\":12},\"temporalEcho\":{\"copies\":3,\"intervalMs\":80,\"opacityDecay\":0.5}}\n```\n\n## Groups\n\n`{\"op\":\"add_group\",\"name\":\"Lower third\",\"startMs\":4000,\"durationMs\":4000,\"children\":[\"name-id\",\"role-id\"]}` makes a clip with no media whose transform, opacity\nand window every clip naming it inherits. `{\"op\": \"set_parent\", \"target\":\n\"Name plate\", \"parentId\": \"\u003Cgroup id>\"}` adopts a clip that already exists;\n`\"parentId\": null` releases it, and a cycle is refused.\n\nRig a lower third this way: scrim, name, role, each on its own overlay track,\nall three parented to one group. Then animate the **group**. One `slide` in and\none `fade` out on the group moves the assembly together and keeps the parts\nlocked to each other, where animating them separately is three chances to\ndrift.\n\nChildren keep their own tracks, so grouping does not change what covers what.\nA plain group's opacity multiplies into each child. A group with an effect,\nnon-`normal` blend mode, or transition precomposes its children into one\nsurface first. The effect or transition then runs once on the assembled\npicture. Put a `glow` on the group for the whole look, on a child for that\nchild alone.\n\n`parent_missing`, `parent_not_group` and `parent_cycle` all mean the child\nrenders unparented, losing the group's transform, opacity and window.\n\n## Transitions\n\nA transition is between clips, not on one clip. It is authored on the\n**incoming** clip and resolved for both: the compositor finds the clip beneath\nit on the same track and gives that one the complementary half of the cut.\nThe incoming clip may be a group: `set_transition` on the group transitions\nits whole precomposed assembly, with children still parented on their own\ntracks. Its outgoing partner is the previous overlapping sibling on the\ngroup's track under the same parent.\n\nTwo clips on the same track whose times overlap dissolve across the overlap\nwith no tool call at all. The corollary: **an accidental overlap is an\naccidental dissolve.** When two clips look soft where you wanted a hard cut,\ncheck their `startMs` and `durationMs` before looking anywhere else.\n\n`set_transition` picks a cut instead of taking the default:\n`{\"op\": \"set_transition\", \"target\": \"Shot 2\", \"transition\": {\"type\": \"wipe\",\n\"durationMs\": 500, \"direction\": \"left\", \"softness\": 0.1}}`. Pass\n`\"transition\": null` to clear it. The cut plays over the target's head, so\n**overlap the two clips by at least `durationMs`** or there is nothing beneath\nfor it to play against — a transition with nothing under it reads as a fade\nfrom black.\n\n| Type | What moves | Reach for it when |\n|---|---|---|\n| `crossfade` | Incoming fades up | Two shots of the same scene; the safe default |\n| `dipToColor` | Both fade through a solid | A scene change, a chapter break. `color` is yours to pick |\n| `wipe` | Feathered edge reveals the incoming | A graphic or split-screen feel. `softness` 0 is a hard edge |\n| `push` | Both clips travel one frame width | Lateral energy; the two read as one moving picture |\n| `slide` | Only the incoming travels | The new shot arrives over a shot that holds |\n| `zoom` | Outgoing grows, incoming comes in from 0.8 | A push into the next beat |\n| `whip`, `zoomBlur` | Motion blur across a directional or zooming cut | Fast camera energy |\n| `glitch` | Displaced digital cut | A deliberate electronic break |\n| `gradientWipe`, `iris` | Spatial threshold or aperture reveal | Designed graphic transitions |\n| `lightLeak` | Localized color field across the cut | A flare-like bridge |\n\n`direction` on `wipe`, `push`, `slide`, `whip`, and `gradientWipe` names the\nincoming edge. `gradientWipe` also takes `map: \"linear\" | \"radial\" |\n\"noise\"`, `scale`, `seed`, and `softness`. `iris` takes `softness`;\n`lightLeak` takes `color`, `scale`, and `seed`. `easing` takes a named id,\n`cubic-bezier(...)`, or `spring(...)`.\n\n`fadeInMs` and `fadeOutMs` on `set_clip_params` are audio fades. The compositor\ndoes not read them, so they will not fade a picture — for that, animate the\nclip with a `fade` `in` or `out`.\n\nA transition's opacity multiplies with animation opacity rather than replacing\nit, so a clip that both dissolves in and carries a `fade` in ramps twice and\nreads slower and softer than either alone. Pick one.\n\n`validate_timeline` reports `transition_exceeds_duration` when the cut is\nlonger than the clip carrying it, and `unknown_transition` for a type or\ndirection this build cannot draw — which falls back to a cross-fade running\nleft rather than failing the render.\n\n## Masks and mattes\n\n`{\"op\": \"set_mask\", \"target\": \"Title\", \"mask\": {\"kind\": \"rect\", \"y\": 0.2,\n\"height\": 0.6, \"featherPx\": 24}}` cuts the layer to a shape in the clip's own\n0..1 space. `kind` is `rect`, `ellipse` or `path` (with an SVG `d`).\n`featherPx` softens the edge, `invert` keeps the outside instead, and\n`\"mask\": null` clears it. A path that cannot rasterize reports\n`mask_path_invalid` and the layer draws unmasked — the whole picture shows\nthrough, the opposite of what you asked for.\n\n`{\"op\": \"set_matte\", \"target\": \"Fill\", \"matte\": {\"source\": \"Text shape\",\n\"mode\": \"alpha\"}}` uses another clip as this one's transparency. `alpha` reads\nthe source's opacity, `luma` its brightness, and `invert` flips it. **The\nsource clip stops drawing itself.** That is the point, and it is also why a\nmatte pointed at a clip you still wanted on screen reads as a deletion. Reach\nfor it when text has to be filled with moving footage: footage on the fill\nclip, text on the source. `matte_source_missing` is an error, and the layer\nthen draws unmatted, showing everything the matte was hiding.\n\n## Clip effects\n\n`set_effects` replaces the whole chain and applies it in the\norder given; an empty list clears it. The tool also accepts `pixelate`,\n`posterize`, `directionalBlur`, `lensDistortion`, `stylize`, `generator`, `lut`,\nand `grain` alongside `color`, `blur`, `glow`, `dropShadow`, `vignette`,\n`sharpen`, `chromaKey`, `curves`, `levels`, and `liftGammaGain`. Its flat input\nuses `{type, ...typeFields}`. It assigns effect IDs and enables each effect;\nit does not accept stored `id` or `enabled` fields. `validate_timeline`\nreports `unknown_effect` for an unrecognized type. The Canvas 2D preview\napplies all known effects through CPU passes when its host supplies scratch\nsurfaces. `preview_timeline_frame.effects_not_applied` lists unknown types;\n`degraded` reports missing surfaces and other draw limitations.\n\n```json\n{\"op\":\"set_effects\",\"target\":\"Shot 2\",\"effects\":[{\"type\":\"grain\",\"amount\":0.12,\"size\":1.1},{\"type\":\"stylize\",\"mode\":\"dither\",\"amount\":1,\"seed\":4}]}\n```\n\n## Time remap\n\n`{\"op\": \"set_time_remap\", \"target\": \"Shot 3\", \"timeRemap\": {\"keyframes\":\n[{\"t\": 0, \"sourceMs\": 0}, {\"t\": 1, \"sourceMs\": 4000, \"easing\": \"easeInOut\"}]}}`\nmaps timeline time onto source time. `set_time_remap` replaces the whole map\neach time it is called. `t` runs 0..1 over the clip's window and\nmust start at 0, end at 1 and ascend; `sourceMs` says which millisecond of the\nsource plays there, so a descending pair is a reverse and a flat pair is a\nfreeze. `\"timeRemap\": null` clears it, and the remap replaces the clip's rate.\nA repeated or backwards `t` is `time_remap_not_monotonic`, an error.\n\n`split_clip` and `trim_clip` refuse a remapped clip. Both would have to\nre-derive the map, and re-deriving it silently is how a speed ramp turns into a\ndifferent move. Clear the remap, cut, re-apply.\n\n## Compositions\n\nA composition is a group saved as a reusable rig with named parameters.\n`list_compositions` reports what is available, `get_composition` shows one\nrig's parameters and their defaults, and\n`{\"op\":\"insert_composition\",\"composition_id\":\"lower-third\",\"startMs\":4000,\"params\":{\"name\":\"Ada\"}}`\ninstantiates it into the document with fresh clip ids. Use parameter names\nreturned by `get_composition`; `name` here is an example.\n\nBundled examples include `title-card`, `lower-third`, `caption-bar`,\n`callout`, `cta-end-card`, `logo-sting`, `title-slam`, `word-cards`,\n`window-frame`, and `number-ticker`. Use `list_compositions` for the\ntemplates available in the current workspace.\n\nInsert first, then override `params`. The rig's timing and motion are already\nbalanced against each other, and rebuilding it from bare clips throws that\naway. Edit the instantiated clips only where the brief actually differs.\n\nWhen a group should be reused, `save_composition\n{timeline_id, group_target, name, params}` extracts it as a composition.\n`params` map names to typed defaults and JSON pointers into child fields.\n`insert_composition` remaps the group and child clip IDs, including internal\nlayout and animation links, while preserving references to clips outside the\ncomposition.\n\n## Cutting to the beat\n\n`detect_audio_events` on the music clip reports onsets and a tempo. Its\n`onsets.times` are in **seconds** and the ops take milliseconds, so multiply by\n1000. Read the tempo's `reliable` flag before building a grid from `bpm`:\nspeech and room tone produce a confident-looking number from nothing.\n\n`{\"op\":\"set_markers_from_beats\",\"onsets_ms\":[0,500,1000]}` lays the\ngrid down so you can see it, one marker per onset. `{\"op\": \"snap_to_beats\", \"targets\": \"all\",\n\"tolerance_ms\": 60, \"mode\": \"start\", \"action\": \"move\"}` pulls clip edges onto\nit. Either takes `onsets_ms`, or a `bpm` with an `offset_ms`; with `bpm`,\n`set_markers_from_beats` also needs `count`, which an `onsets_ms` grid ignores.\n\nThe default 60ms tolerance is about two frames at 30fps: close enough that a\ncut reads as on the beat, tight enough that it will not drag an edge to a beat\nit was never near. Widen it only when the grid is loose, because a large\ntolerance moves cuts you meant to leave alone. `snap_to_beats` reports every\ntarget, including the ones no beat was in reach of, with the reason.\n\n`action: \"move\"` slides the clip and keeps its length, which shifts everything\ndownstream. `action: \"trim\"` changes the length and leaves the neighbours\nwhere they are. Move a title that has to land on a hit; trim a picture clip\nwhose out point has to meet the next shot.\n\n## Validate the output\n\nTwo checks, and they answer different questions.\n\n**`validate_timeline`** answers \"is this document sound\": unknown animation\npresets and transitions, fades and cuts longer than the clip carrying them,\nclips on tracks the document lacks, overlapping clips, clips shorter than a\nframe, in\u002Fout points that cannot render, duplicate ids. Run it after every batch\nof edits.\n\nIt also names the motion failures that used to be yours to remember. Each\nfinding carries a stable `code`; these are the ones that mean the picture will\nnot perform the motion you wrote:\n\n| Code | Severity | What to do about it |\n|---|---|---|\n| `unknown_animation_preset` | error | The stored `preset` is not one this build ships. Use an id from `list_animation_presets` |\n| `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` |\n| `parent_cycle` | error | A `parentId` chain loops, so the group cannot be resolved at all. Break the loop |\n| `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 |\n| `time_remap_not_monotonic` | error | `t` repeats or goes backwards. `sourceMs` may descend — that is a reverse — but `t` may not |\n| `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 |\n| `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 |\n| `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 |\n| `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 |\n| `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` |\n| `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 |\n| `replace_curves_overlap` | warning | Two animations drive one absolute channel (`positionX\u002FY`, `anchorX\u002FY`, `trimStart\u002FEnd`) at once. The last in document order wins and the other is discarded. Separate them in time or fold them into one curve |\n| `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 |\n| `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` |\n| `unknown_easing` | warning | Outside the grammar, so it eases linearly. Check the spelling: `easeOut`, not `ease-out` |\n| `unknown_transition` | warning | This build cannot draw that `type` or `direction`; it cross-fades left instead. Pick one from the table above |\n| `unknown_effect` | warning | This build cannot apply that effect; the layer draws ungraded |\n| `unknown_shape_kind` | warning | No geometry for that `shapeStyle.kind`, so the shape draws nothing. A scrim that vanished is usually this |\n| `transition_exceeds_duration` | warning | The cut is longer than the clip carrying it. Shorten the transition or lengthen the clip |\n| `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 |\n| `mask_path_invalid` | warning | The `kind` or path `d` cannot rasterize, so the layer draws unmasked and the whole picture shows through |\n| `layer_cap_exceeded` | warning | More video clips overlap at that instant than the compositor draws; the ones on lower tracks are dropped |\n| `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 |\n| `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 |\n\nThe contrast half refuses to guess: a colour it cannot parse, a translucent\nplate or gradient-filled type produces no contrast finding. Type over picture\nwith no plate at all is the case it can name without measuring, and it says so\nas `text_backing_unproven`. Either way a silent `text_illegible` is not a pass\n— that is what the frames are for.\n\n**`preview_timeline_frame`** answers \"what does it look like\". It composites\nthe real frame — every track layered in order, transforms and opacity applied,\nanimations sampled, transitions part way through, text and shapes drawn — at\nthe timecodes you name. It needs no browser, no GPU and no open editor.\n\n```json\n{\"timeline_id\": \"\u003Cid>\", \"times_ms\": [1200, 1450, 3000]}\n```\n\n`range {from_ms, to_ms, count}` samples evenly across a span instead of naming\neach timecode — the way to watch a whole entrance rather than guess three\npoints inside it. `sheet: true` tiles every frame into one labelled contact\nsheet, so a sweep is one image to read instead of a handful of handles.\n\nIt returns an image handle per timecode; call `view_image` on the ones you need\nto see. Beside each frame it lists the layers top of the stack first, with the\nopacity, blend mode and wipe progress each one resolved to — often enough to\ndiagnose a problem without looking at a pixel.\n\n**Sample the middle of a motion, not its ends.** The endpoints are the states\nyou already know: before an entrance the clip is absent, after it the clip is\nat rest. A 400ms fade-in starting at 1000ms tells you nothing at 1000ms or\n1400ms and everything at 1200ms. For each animation you added, preview at:\n\n- the midpoint of the entrance,\n- a held moment between the in and out,\n- the midpoint of the exit,\n- and, for staggered text, a point after the first word lands but before the\n  last does — the frame where half the line is on screen is the one that\n  reveals a stagger that is too slow.\n\nWhat the frames catch that a validator cannot: text over a busy or same-colored\nplate, a lower-third under the wrong element because two tracks are the wrong\nway round, a title that has slid outside the frame, a stagger still finishing\nwhen the clip ends, a scrim covering the face it was supposed to sit beside, an\nelement that never appears because its clip is on an audio track.\n\nEach frame carries `degraded` entries for a difference the Canvas 2D host\ncould not draw, such as a missing scratch surface, a hard mask edge, or an\nunsupported perspective transform. Read the actual entries before deciding\nwhich part of the frame needs a GPU render. Known clip effects have CPU passes.\n\nRead the `skipped` field on any layer that drew nothing — it says whether the\nclip is still `draft`, its asset would not read, or the source had no frame at\nthat time. A missing picture is usually an unrendered clip, not a motion bug.\n\n`effects_not_applied` names enabled effect types the Canvas 2D compositor does\nnot recognize. A known effect such as chroma key, vignette, sharpen, or LUT\nshould render in the frame preview; a missing scratch surface instead appears\nin `degraded`.\n\n\n## The render loop\n\nChange one thing, preview the same timecodes, compare. When motion is wrong,\nthe order to check is: is the element on screen at all (layer present in the\nreport), is it in the right stacking position (`z_index`), is it at the right\nopacity (`opacity`), is it in the right place (look at the frame). Working down\nthat list beats re-authoring the animation.\n\nFor every defect named by a `view_image` look, a preview layer report, or a\nmodel critique of the render, fix it and re-check the same timecode, or tell\nthe user it remains open and why. Never ship past a named defect silently. If\n`view_image` returns no pixels, tell the user you could not see the frames.\nDo not claim a visual check.\n\n`compare_timeline_frames {a, b, times_ms | range}` measures what actually moved\nbetween two documents or two versions. Run it after a change the user did not\nask for (a restructure, a composition insert, a snap pass) so you can say\nwhich frames it touched instead of hoping it touched none.\n\nBefore calling the cut done, render it. `render_timeline` with a\n`preview_scale` below 1 while you are still iterating, then once at full size.\nThe preview answers what one timecode looks like; the render answers whether\nthe file plays.\n\nA whole-document rewrite goes through `set_timeline_document`, which validates\nbefore it writes and snapshots what it replaces. Name that snapshot\n(`snapshot_name: \"before the title pass\"`) so the version list says what you\nwere about to do. For anything smaller, `create_timeline_version` first and\n`edit_timeline` after.\n\nStop when the motion is right, and say what you animated.\n",{"name":5,"description":7},"main","c7226baa82daca42a19a5855c116544f97aa8bac","packages\u002Fsystem-skills\u002Ftimeline-edit-ops",90,"https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool","https:\u002F\u002Fnodetool.ai\u002F","https:\u002F\u002Fgithub.com\u002Fnodetool-ai","No-code AI development","2026-11-12 03:00:58",{"source":8,"skillName":5,"commands":65,"prompt":75,"zipAvailable":76},[66,69,72],{"tool":67,"command":68},"npx","npx skills add nodetool-ai\u002Fnodetool --skill timeline-edit-ops",{"tool":70,"command":71},"bunx","bunx skills add nodetool-ai\u002Fnodetool --skill timeline-edit-ops",{"tool":73,"command":74},"pnpm","pnpm dlx skills add nodetool-ai\u002Fnodetool --skill timeline-edit-ops","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Ftimeline-edit-ops\u002FSKILL.md",true,[],[79,113,140,155,170,194],{"skillNo":80,"name":43,"nameZh":6,"description":81,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":82,"categories":83,"keywords":92,"publishedAt":50},"01M6BY4D93R0Q9400ZHJ04DXP1","Use the NodeTool MCP server to build, validate, run, and debug visual AI workflows, and to work with assets, collections, and jobs. Use when the user mentions NodeTool or asks for a workflow graph, a node, or a media generation run on their NodeTool server.","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fplugins\u002Fchatgpt\u002Fnodetool\u002Fskills\u002Fnodetool\u002FSKILL.md",[84,87],{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",{"categoryNo":88,"categoryName":89,"taxonomyType":90,"taxonomyTypeDesc":91},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",[43,93,94,95,96,48,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112],"MCP","mcp server","workflow","workflows","debug","run","graph","server","Media","assets","work","node","use","visual","generation","collections","media generation","with","for","when",{"skillNo":114,"name":115,"nameZh":6,"description":116,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":117,"categories":118,"keywords":122,"publishedAt":50},"01M6BY4AEB05ZPBRVD078ATR2H","wan-2-6-prompting","Prompt Alibaba's Wan 2.6 video — text-to-video with a global style line plus timing-bracketed shots, and image-to-video that describes only what changes over time. Covers the shot-mode switch per provider, the prompt budget, negative prompts, motion intensity wording, audio input and prompt expansion. Use whenever the model id names a Wan 2.6 video route (alibaba\u002Fwan-2.6\u002Ftext-to-video, alibaba\u002Fwan-2.6\u002Fimage-to-video, wan-video\u002Fwan-2.6-t2v, wan-video\u002Fwan-2.6-i2v, wan-video\u002Fwan2.6-i2v-flash) on generate_video, animate_image or a TextToVideo node. Not for Wan 2.5 or 2.7, or the Wan 2.6 image routes.","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Fwan-2-6-prompting\u002FSKILL.md",[119],{"categoryNo":120,"categoryName":121,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体",[123,124,125,126,127,128,129,130,44,131,132,104,28,133,38,134,135,136,137,138,139],"wan 2 6 prompting","prompting","image","generate","Budget","Video","Audio","text","model","flash","route","line","input","text-to-video","image-to-video","mode","expansion",{"skillNo":141,"name":142,"nameZh":6,"description":143,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":144,"categories":145,"keywords":148,"publishedAt":50},"01M6BY47T4748ZG5ZMTAR97ZWZ","video-workflow","Build a reusable NodeTool video workflow for batches or repeated inputs. For a single finished piece, use a storyboard skill.","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Fvideo-workflow\u002FSKILL.md",[146,147],{"categoryNo":120,"categoryName":121,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":85,"categoryName":86,"taxonomyType":21,"taxonomyTypeDesc":22},[95,128,149,43,105,150,151,152,153,111,154],"video workflow","reusable","storyboard","build","batches","single",{"skillNo":156,"name":157,"nameZh":6,"description":158,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":159,"categories":160,"keywords":162,"publishedAt":50},"01M6BY4587N2SYN315SXGEMZC3","video-clone","Analyze a reference video and recreate its shot structure with a new product or cast in NodeTool.","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Fvideo-clone\u002FSKILL.md",[161],{"categoryNo":120,"categoryName":121,"taxonomyType":21,"taxonomyTypeDesc":22},[128,163,164,165,166,43,167,168,169,110,29],"video clone","clone","product","analyze","reference","cast","structure",{"skillNo":171,"name":172,"nameZh":6,"description":173,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":174,"categories":175,"keywords":177,"publishedAt":50},"01M6BY4257C2KQYYMD9EK6DK8E","video-audio-continuity","Keep sound continuous across a multi-scene piece cut from generated video — why one clip per scene hard-cuts the audio at every boundary, when to write all the scenes into a single generation instead, the sound-design vocabulary that survives a provider's copyright filter, and how to check the length, the aspect ratio and the mix of what came back. Use when a piece has more than one scene and the video model writes its own audio (Seedance 2, Veo 3, MiniMax H3, Kling 2.6 and later), or when a cut's sound drops out at the shot changes.","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Fvideo-audio-continuity\u002FSKILL.md",[176],{"categoryNo":120,"categoryName":121,"taxonomyType":21,"taxonomyTypeDesc":22},[128,129,178,179,180,181,182,183,131,184,185,186,187,188,34,189,190,191,192,193,112],"video audio continuity","continuity","design","boundary","how","why","write","generated","later","back","across","how to","filter","mix","at","veo 3",{"skillNo":195,"name":196,"nameZh":6,"description":197,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":198,"categories":199,"keywords":201,"publishedAt":50},"01M6BY3ZANBSQMZRH4MG55R8Z7","veo-3-prompting","Prompt Google's Veo 3 video line — the five-element shot\u002Fsetting\u002Fsubject\u002Faction\u002Fdialogue structure, the cinematography vocabulary it reads directly, duration and aspect-ratio tradeoffs, the audio and auto-fix switches per provider, and the fixes for visual drift, temporal inconsistency and audio mismatch. Use whenever the model id contains veo3 or veo-3 (fal-ai\u002Fveo3.1 and its fast, lite, image-to-video, first-last-frame and reference-to-video routes, fal-ai\u002Fveo3\u002Fimage-to-video, google\u002Fveo3.1 on atlascloud, google\u002Fveo-3 and google\u002Fveo-3.1 on replicate, kie's veo-3-1, and Gemini's own veo-3.1-generate-preview) on generate_video, animate_image, generate_video_from_references or a TextToVideo node.","https:\u002F\u002Fgithub.com\u002Fnodetool-ai\u002Fnodetool\u002Fblob\u002Fmain\u002Fpackages\u002Fsystem-skills\u002Fveo-3-prompting\u002FSKILL.md",[200],{"categoryNo":120,"categoryName":121,"taxonomyType":21,"taxonomyTypeDesc":22},[202,203,124,193,125,204,205,126,206,207,128,129,44,131,208,209,210,211,212,213,214,169,39,215,216],"veo 3 prompting","veo","google","gemini","fast","first","fal","kie","replicate","fix","auto","references","drift","element","auto-fix"]