# pixi-effects — full documentation for AI agents Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API / media-tools references. Generated by scripts/build-llms.mjs; do not edit. > Written for pixi-effects 0.45.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/ # pixi-effects — write a video as data You describe a video as a tree of plain objects; the library plays it in a canvas and exports it (MP4/WebM/MOV). You never write per-frame code (except inside a `canvas` layer, which you add only for a picture nothing else draws). **Read `reference/quickstart.md` first (2 KB): the commands, a working title card, and what goes wrong most.** For anything it does not answer, ask the docs: `npx pixi-effects help ` prints the sections about a word (mask, threeD, keyframes, music …); `npx pixi-effects recipe` lists the tested recipes (a title, a lower third, a chart, kinetic type, a 2.5D scene …) and `npx pixi-effects recipe ` prints one with its code. `reference/cheatsheet.md` (35 KB) and `reference/pitfalls.md` (41 KB, numbered) are for looking things up when a warning or the picture surprises you: search them for the word, do not read them end to end. A simple title needs the quick start and one recipe, nothing more. ## Workflow 1. **Start from the template:** `npx pixi-effects new my-video` writes `my-video.html` (or copy `template.html` by hand); it also has the shared loader (`
` + `pixi-effects/loader.css`: HTML and CSS only, animating just `transform` / `opacity`, so it keeps moving while the page builds the video; `movie.init()` fades it out). Keep it; a loader of your own is fine (`pe-loader--custom`) as long as it only animates `transform` / `opacity`, never with JavaScript or `requestAnimationFrame`; it loads the library from a CDN, collects every console warning into `window.__logs`, and exposes `window.movie`). One HTML file, plain JS. Default canvas 1280×720 @ 30 fps. 2. **Give the movie a poster time.** In `movie.init` add `poster: `: the most striking moment (a title fully on screen, not the first frame, which is often black). The canvas shows that picture before play (the playhead stays at 0 and play starts from 0), and the check writes it as `poster.jpg`. 3. **Write the composition as data.** Anything repeated (letters, bars, particles, scenes) is a JS function or loop that returns specs — never copy-paste objects. Derive coordinates and times from constants/data, not magic numbers. 4. **Show the human, EARLY: the first address goes out within two minutes.** Do it right after step 1, BEFORE you write the brief or collect assets: the copied template is already a page. Run `npx pixi-effects view my-video.html` (`--no-open` only prints the address; in the background, it keeps running), tell the human the address in your first message, and keep editing the same file: it opens their browser on the page with a scrubbable, zoomable timeline under it (click / drag to seek, + / − or Ctrl/⌘ + wheel to zoom) and runs until Ctrl-C. When the folder changes the viewer shows "Updated hh:mm:ss — reload (R)" and nothing else: the picture they are looking at never switches by itself, a reload returns to the frame they were on, and the switch "auto" (off by default) reloads only while the movie is paused. Do not restart it after every edit; they see the work grow instead of waiting for the end. **Deliver the file:** `npx pixi-effects render my-video.html -o my-video.mp4` renders the page headless to mp4 / webm / mov / mkv and exits 0 only when the file is written; run it AFTER the check below passes. **Run the check — one command does steps 3, 4 and the export:** `npx pixi-effects check my-video.html` (Node ≥ 22 and Chrome installed). **If `npx` says command not found, or prints nothing** (your npm may hold back a release that is a few days old, or an old release is cached): `npm i pixi-effects`, then `node node_modules/pixi-effects/ai/tools/check.mjs my-video.html` (the same for `render.mjs` and `view.mjs`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and writes `timeline.html` (every layer as a bar on a time axis: ◆ keyframes, shaded transitions; hand it to the human to open) and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out//sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. A key a layer does not have (a typo in a layer key, a keyframe property or a text style key; `anchorX` outside `initial`; `pitch` outside `sfx`) is a warning that names the key and what you probably meant: treat it like any other warning. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely. The same judgement is in the page: `await movie.review({ at: 'title@end' })` returns `{ frames, problems, review, fonts, audio, at }` (what `check` prints, as data) and `movie.resolveAt('3.5, title@end')` turns moments into frames. **Or by hand: run it and read the logs first.** `window.__ready` must be `true` and `window.__logs` should be `[]`. A pixi-effects warning is an instruction: fix what it names. 5. **Look at it** — you cannot judge motion from code. The library has the tools built in: - `await movie.contactSheet({ count: 6, as: 'dataURL' })` → ONE image of several labelled frames (pass `frames: [...]` / `times: [...]` to choose; include the middle of every transition and the last second). Save it and view it. - `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, overlapping other text, or hidden / struck through by a picture: an opaque shape or image drawn OVER part of it, or a thin line drawn over its body; a shadow, a glow, a ring or a backdrop under it is not reported). Run it at several frames; fix every issue it names. - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar). - `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix. - **A moment, a movement, the sound (`pixi-effects check` options):** `--at 3.5,title@end,50%,f120` writes those pictures to `check-out//frames/*.png` (and `at.png`; with `--out DIR` they land in `DIR/frames/` and `DIR/at.png`, there is no `/` inside DIR); add `--crop X,Y,W,H` (canvas pixels) to get each picture as just that part, enlarged up to 4x, so a 40 px figure, a fine line or a texture can be judged (the contact sheet tiles are too small for that): the last frame of a layer is `name@end`, the middle `name@mid`. `--seam` (for a movie made to loop) compares the last frame with the first against the steps between ordinary neighbouring frames and says `the loop closes` or `JUMP` with the numbers (a jump is a problem, exit 1); `--onion 1:3` writes `onion.png`, eight frames of seconds 1–3 overlaid (the later the stronger): a trail that shows a movement's path and easing (even steps = constant speed, bunched = slow). `report.audio` now has `loudness` (`integratedLufs`, `truePeakDb`, `clippedSamples`, `silences`), `scenes` (per named top-level composition), `cues` (when each sound starts) and `notes` (advice: quiet or loud for web video, little headroom, silent stretches; web video is typically −14 to −16 LUFS); `waveform.png` draws the mix with the scene edges and the cues. A web font that failed to load fails the check; a text layer none of whose fonts is installed is listed for review. - **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await inspectStops(movie)` (from `pixi-effects/present`) lists the stops where the picture is still changing (a stop landing before its animation ends); the check prints it as a review list: move the stop later or flag a settled one `pdf: true`. `await stopImages(movie, { which: 'pages' })` / `exportPDF(movie)` give the pages themselves. From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 scripts/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off. 6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100). **While you iterate, do not export the whole movie:** `check --no-export --at ` takes seconds (3 to 10 s on the gallery pieces: most of it is loading the page and measuring the sound); to see motion, `render({ range: [from, to] })` / `pixi-effects render --range 10:15` / `--scene name` exports only that part (picture and sound); `draft: true` / `--draft` is half size, low quality and no motion blur: a much smaller file (a piece that asks for motion blur renders much faster as a draft: 4 s of a firework piece took 14.0 s, and 8.2 s as a draft, about 7 s of either being the browser's start-up), but the drawing itself is not faster. Export the whole movie once, at the end. ## For a film (more than a title card): direct it first **Read `reference/direction.md` before writing a composition for a product film, a company introduction, a launch or a data story.** It is renderer-neutral: write `brief.md` (the one sentence to remember, the REAL assets with paths, what must never be invented; if a needed asset is missing, stop and ask), `style-guide.md` (palette and type taken from the real source, a motion rule per class of object) and `shotlist.md` (a row per beat with entry and exit state) next to the page; then build, then review the rendered frames cold. What is specific to this library: - **Time and sound:** pick a tempo whose bar is a round number of seconds (96 bpm: 2.5 s; 120 bpm: 2 s), put every cut on a bar line and every sfx on its beat. `npx pixi-effects check` prints a `music` read-back (bars per track, what starts on which beat, the drum span): verify the sound with it. - **Interactive pages (a person clicks, hovers, drags):** `movie.pick(x, y)` says what is under a point, `movie.hover({ onEnter, onLeave })` and the recipes `pick-click`, `drag-drop`, `drag-3d` build on it; **test the page with `check --do "hover Lime; click Lime; drag card > 770,240; wait 1"`**: real mouse and key events on the page (a point is `x,y` in canvas pixels or a layer name, the middle of its box), run after everything else, and the report says per step what was under the pointer, which variables changed (`score 0→1`), whether the page warned, and keeps one picture each in `steps/` (look at them). A step on a layer that is not there, or a point outside the picture, is a problem. To do the same by hand (a browser tool, Chrome's protocol; serve the folder over http: a model, an image or a module beside the page does not load from file://): `const r = canvas.getBoundingClientRect(); canvas.dispatchEvent(new PointerEvent('pointerdown', { clientX: r.left + x * r.width / W, clientY: r.top + y * r.height / H, bubbles: true, pointerId: 1 }))` (then `pointermove`, `pointerup`, with a `settle` of 300 ms after a `setVars` rebuild), and read `movie.vars`, `movie.currentFrame`, `movie.isPlaying`, `movie.pick(x, y)` and the canvas's `style.cursor`. A canvas for touch has `style="touch-action: none"`. - **Review:** `check` prints a `motion` line (how much of the movie moves, when the last movement ends, the longest still stretch) and lists, for review, a plain-colour last frame, a first second with nothing on screen, and a hold of 3 s or more: `OK` does not mean the film is rich, only that nothing is broken, so read those lines and the contact sheet. `check --at 3.5,title@end,50% --pick 640,360` also says what is under that canvas point at each moment (the layers, top first; a three layer names the object) — a layout question you can answer without looking; `check --at 3.5,title@end,50%` writes the contact sheet and single frames; look at mid-transition frames of the exported file too. Frames are seekable and deterministic here: render the same range twice and compare the frames if you doubt it. - **Formats:** make the formats the human asked for, one if they did not say. When a vertical cut is wanted it is its own composition: one source, a layout function, `?f=9x16` (recipe `two-formats`); `check` and `render` take `--query f=9x16`. - Fit a picture into a rectangle with the `photo()` helper of that recipe (`scale: 'cover'` fits the PARENT only); designed pictures (posters, flyers, screenshots) are shown whole, never cropped. ## If your shell is a sandbox that is not the person's computer A browser chat's code execution is a small container (often root, a weak CPU, a closed network), not the person's computer, and `check` / `render` there stall (Chrome refuses to start as root without `--no-sandbox`; with the CDNs unreachable `check` waits minutes and only says "timed out"). **Do not use the workflow below there: follow the chat way** (https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md), even though you have a shell. No `npx`, no `npm i`, no `check` / `render` / `view`: take the template from the chat guide above (or copy `template.html` of this skill), edit the composition, look things up in `reference/*.md` with your file tools, and give the person the page as it is (the CDN imports stay: their browser reaches them). **Do not start Chrome or render an MP4 there** (a weak container: very slow): the person presses the download button in the player bar and their browser exports it on their own computer; warnings reach you through the red box of the chat template, which they paste back. If something stalls, stop after two tries (no wrapper for Chrome, no rewired import map) and say what you did not check. ## Look at footage before you use it If you are handed a 3D model (.glb / .gltf), `probe model.glb` lists its clip names and its size before you write any code (recipe `gltf-in-space`). If you are handed a video or a sound (a clip to put in the video, a reference to match), look at it first, with no ffmpeg needed: `npx pixi-effects probe clip.mp4` (size, length, rotation, whether it has sound), `npx pixi-effects frames clip.mp4 --sheet -o looks` (a contact sheet: open the PNG), `npx pixi-effects sound clip.mp4` (loudness, peak, silences), `npx pixi-effects trim clip.mp4 3 8 -o cut.mp4` to cut it (by copy, starting on the next key frame, and it says the real times; `--exact` re-encodes), `npx pixi-effects concat a.mp4 b.mp4 -o both.mp4` to join clips by copy (same codecs, size, rotation and sound, or it says what differs) and `npx pixi-effects audio clip.mp4 -o sound.m4a` for the sound alone. A clip you have not looked at is a clip you do not know: phone clips are often rotated, screen recordings often have no sound. `frames`, `sound` and `trim --exact` use Chrome; if ffmpeg is installed and the job is a long transcode, use ffmpeg. `npx pixi-effects help media` has the reference. The same command checks a file you MADE before it goes somewhere with rules (a stock site, a platform, a client): `npx pixi-effects probe out.mp4 --expect "size=3840x2160 fps=30 frames=300 bitrate>=35M audio=none"` measures the file itself and prints ok / FAIL per number (exit 1 if any fails). A render's `bitrate` is a ceiling, not a target: a quiet picture is written far under it, so ask `probe`, do not trust the setting. ## The rules that cause most failures - **Keyframe `at` starts at 0 when the layer appears** (negative = back from the layer's end). A layer's own `at` and a transition's `at` are composition time. - **Layers are hidden, not removed, after their lifespan; z-order is array order.** Switch scenes by stacking layers with `at`/`duration` over an opaque background rect. - **Angles are degrees. `+z` is toward the viewer. Numbers can be expressions** (`'GW/2 - w/2'`; no `pi`; no per-frame variable). - **Anchors**: rect/circle/ellipse are centred on `x,y`; text and image are top-left (use `anchorX/anchorY: 0.5`); compositions use `pivotX/pivotY`. A bar that grows from its base needs `anchorY: 1`. - **Sound effects need no files:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` — presets `click pop swoosh swipe hit riser chime beep coin glitch typewriter`; knobs `pitch`, `brightness`, `seed`. The layer lasts as long as the sound (`duration` stretches it); a `riser` is loudest at its end (`at = hit − duration`), a `swoosh` 0.16 s in. A music file shorter than its layer goes silent — `loop: true`. - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.** **One mask for several layers: name the layer and write its name as the `mask`** (`mask: 'band'`; `{ layer, channel: 'luma', invert: true }`; a list intersects); the named layer is not drawn and must live as long as its users. A **luma wipe** is a transition: `{ kind: 'luma', map: 'radial' | 'linear' | 'diagonal' | '', softness, flip }`. - **2.5D needs `threeD: true`** on each layer plus a `{ type: 'camera' }` layer; camera props go in `initial`/keyframes; keep every `z` below the camera distance (≈ 989 at 720p, fov 40); under a dolly zoom only `z = 0` stays fixed; **depth of field** is two camera props, `focus` (a threeD layer's name or a z) and `aperture` (30 default, 0 off), and a rack focus is a keyframe on `focus`. - **Group / parent layers = `{ type: 'null', name: 'rig' }`** (draws nothing, only moves) + `parent: 'rig'` on the layers that should follow it: their `x`/`y` are then measured from the null's origin, and the null's position / rotation / scale / alpha carry them (orbits, clock hands, grouped fades). Nulls chain. Only `null` layers can be parents; not for `threeD` layers. - **Time:** `speed` (slow motion, backward) and `time` (a curve of the layer's own seconds: freeze, rewind, ramp) on video, audio-file and composition layers; put what you want to rewind in a composition. Children of such a composition are in ITS seconds. `ease: 'cubic-bezier(.4, 0, .2, 1)'` is the CSS curve. See the recipe `rewind-and-replay` and pitfalls 69–73. - **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` from `pixi-effects/present` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes). (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.) - **Draw it yourself (`canvas` layer):** when nothing in the library draws what you need (an afterimage, rings, a brush stroke, a pixel sprite, a chart), `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … } }`: your function of the time draws into the layer's own cleared 2D canvas and it is a normal layer. Pure function of `t` and `info.params` (`info.random()` is seeded; no `Math.random` / `Date.now`); a simulation is computed in `setup` and read by `info.frame`. `info.time` is the movie's clock (several layers share it; never add `at` by hand), `info.canvas(w, h)` makes sprites and masks in `setup`, data shared by several canvas layers lives in module scope, and `import { ease, noise, noise2, random, samplePath } from 'pixi-effects'` gives the library's easing, seeded noise and a path walked by length. `check` prints a `canvas` line (deterministic, slowest draw in ms) and does NOT see text drawn in a canvas. `help canvas`, recipes `canvas-trail`, `canvas-life`, `canvas-text-mask`, `canvas-text-on-path`, `canvas-bars`. - **Shader layer and warp:** `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040' } }` draws a Shadertoy-style formula (GLSL ES 3.00; `iTime` `iResolution` `iFrame` are declared; keyframes move `'uniforms.speed'`; opaque by default; it cannot read the layers behind it; a compile error comes back in your own line numbers). `filters: [{ type: 'warp', kind: 'wave' | 'haze', strength: 6 }]` bends a layer's picture like water or heat. - **Light, shadow, fog (2.5D):** a `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` plus a `spot` / `point` / `parallel` light (`castsShadows: true` on the light AND on the `threeD` layers that cast) shades every `threeD` layer by angle and distance; `lit: false` opts a layer out; camera `fogNear` / `fogFar` / `fogColor` fade what is far. What no light reaches is black (add the ambient light); layers are drawn in distance order (no depth buffer). `check --at` prints how lit each layer is. - **Blend modes:** `blendMode` takes 18 CSS names (`add` `screen` `multiply` `overlay` `soft-light` `hard-light` `color-dodge` `color-burn` `darken` `lighten` `difference` `exclusion` `hue` `saturation` `color` `luminosity` `linear-burn`, hyphenated) on any layer: glows and light leaks (`add`, `color-dodge`), colour casts and contrast (`soft-light`, `overlay`), vignettes (`multiply`). It blends with everything below the layer; on a composition each child blends (as one picture: `threeD: true` or a filter); 10+ of the new modes on screen at once warn; it works with every mask. - **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump. ## Layout sanity (the cheap bugs a screenshot catches) Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width as ~0.6 × `fontSize` per glyph (monospace) or ~0.8 (heavy sans) and leave margin; captions stay above the bottom 60 px; nothing important within 5 % of the frame edge; a `threeD` layer can be sorted in front of text. ## Files - For an AI in a chat with no shell, the short version is https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md (it copies https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/chat-template.html: one self-contained page that loads from a single CDN and shows warnings in a red box). - `reference/cheatsheet.md` — every layer type, prop, default and rule on one page. - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal. - `reference/pitfalls.md` — the full list of real mistakes, with status. - `reference/direction.md` — how to direct a film (any renderer): brief, style guide, shot list, assets rule, sound on the beat grid, review protocol, formats, gates. - `npx pixi-effects check` — the one-command review (see Workflow step 5). - `template.html` — the starting file. (It loads the released library from a CDN, so it works from any folder.) - `scripts/save-image.py` — save a `dataURL` result (contact sheet / snapshot) to a PNG. - Full reference: https://yjmtmtk.github.io/pixi-effects/docs/dsl.md and https://yjmtmtk.github.io/pixi-effects/docs/api.md # pixi-effects quick start npx pixi-effects new my-video # a page to edit (--size 1080x1920 --duration 20) npx pixi-effects view my-video.html # look at it while you edit npx pixi-effects check my-video.html # warnings, layout, sound, a contact sheet: LOOK at the PNG npx pixi-effects help # the docs about a word: mask, threeD, keyframes, music npx pixi-effects recipe [id] # tested pieces to copy A video is data: `sequences` (text, shape, image, video, audio, composition, camera), each with `at`, `duration`, `initial`, `keyframes`. ```js // @recipe quickstart return [ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#10162b' } }, { type: 'text', text: 'Hello', at: 0.3, duration: 4, style: { fontSize: 140, fill: '#ffffff', fontWeight: '800' }, initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { y: 'GH/2 + 40', alpha: 0 }, to: { y: 'GH/2', alpha: 1 }, duration: 0.6, ease: 'power3.out' }] }, ]; ``` What goes wrong most: - A keyframe `at` is seconds from the layer's own start; a layer's `at` and a transition's `at` are movie seconds. - A layer stays hidden after its time; z-order is array order; a scene is layers with `at`/`duration` over an opaque rect. - Numbers may be expressions (`'GW/2 - w/2'`); angles are degrees. - Anchors: rect and circle are centred on x,y; text and image are top-left (use `anchorX: 0.5`). - A warning is an instruction: `window.__logs` must be `[]`. Fix what it names. - Sound needs no files: `{ type: 'audio', sfx: 'swoosh', at: 2 }`, or `music: { bpm, tracks }`. - Never say done before you have looked at the check's PNG: it cannot judge a line break, a font, or how a fade ends. - Exist already (`help `): `spans` (colour part of a text line), `vars` + `--var name=value` / `--size 1080x1920` (one page, many versions). - Anything else: `help ` first; the cheatsheet is for grep, not for reading. # pixi-effects cheatsheet A video is **data**: a tree of plain objects. Everything below is a JS object you put in `composition.sequences`. Build repeated structure with JS functions and loops; the spec is just objects. ## Conventions (state once, never guess) | Thing | Rule | |---|---| | Canvas | pixels, origin top-left, `+x` right, `+y` down. Typical: 1280×720 @ 30 fps | | Angles | **degrees**: `rotation`, `skew*`, `rotationX`, `rotationY` | | Depth (`z`) | `+z` toward the viewer (bigger, nearer) | | Numbers | any number may be an **expression string**: `'GW/2 - w/2'`, `'min(W,H)*0.4'` (build-time only; no per-frame time variable). Functions: `min max abs floor ceil round sqrt pow sin cos tan lerp clamp smoothstep mod step`, `PI`, and **seeded** `rand(seed)` (0…1) / `noise(x, seed)` (−1…1): give each layer its own seed, never `Math.random()`. **Relative values** work in a keyframe's `to` / `from` / `set` (not in `initial`, a style, or the start of a `from`+`to`): `'+=36'` / `'-=36'` are measured from where the layer stands, and the right side is an expression (`'-=GW*0.1'`). A slide-in is `from: { x: '-=36' }`, a nudge `to: { y: '+=40' }`; keyframes one after another stack; with `repeat` the same move replays from the same start (it does not stack). Expressions work in layer `x y` and the other numeric props, **not** in `line.from / to`, `polygon.points` or `path.d`: compute those in JS (`const W = 1920`) | | Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill` (or `initial: { fill }`; a `fill` keyframe animates it), shape colour is `fillColor`/`strokeColor`, image colour is `tint` | | Z-order | **array order**, later = on top. A layer is hidden (not removed) outside `[at, at+duration)` and keeps its last values | | Time of a sequence's `at` | seconds from the start of its **parent composition** (default 0) | | `from` before its `at` | a keyframe's `from` holds **from the start of the layer**, not from its `at` (so a title waits invisible with `from: { alpha: 0 }`), and it replaces `initial`: `initial: { alpha: 0 }` with `{ at: 2, from: { alpha: 1 }, … }` shows the layer at alpha 1 from 0 s. To appear only at 2 s give the layer `at: 2`, or start from the same value in both | | Time of a keyframe's `at` | seconds from the start of **its own layer**; **negative = back from the layer's end** | | Time of a transition's `at` | seconds from the start of the **parent composition**; negative = back from its end | | `duration` | defaults to the parent composition's duration | | Time **inside** a layer | `speed` (a multiplier on the layer: 1 = as is, 2 = twice as fast, 0.5 = slow motion, **negative = backward**) and `time` (seconds of the layer's own content, in `initial` / keyframes: a video's or audio file's position in the file, a composition's local playhead; **negative = back from the end of the content**) on **video, audio file and composition** layers. **The layers inside a composition write `at` / `duration` / `keyframes` in THAT composition's own seconds** (its local time); the composition's own `at`, `duration` and keyframes stay in the outer time. `duration` is always the layer's life in the outer time (`{ duration: 4, speed: 2 }` lives 4 s in the movie and plays 8 s of content; its children, with no `duration` of their own, last those 8 s) | ## Expressions Operators `+ - * /`, parentheses, unary `-`. Functions `min max abs floor ceil round sqrt pow sin cos tan` (radians; there is **no `pi`**: write `3.14159`). | Variable | Meaning | |---|---| | `W`, `H` | parent composition size | | `GW`, `GH` | root (movie) size | | `w`, `h` | this layer's own size (image/video natural size, text size **after** its style is applied, shape bounds) | | `cover`, `contain` | scale that makes the layer cover / fit the parent (`scale: 'cover'`) | | `t`, `d`, `T` | layer start time, layer duration, parent duration | | your own names | `movie.init({ vars: { shift: 0, title: 'NORD' } })`: a number is a name in an expression (`'W/2 + shift'`), any value is `{title}` inside a string (`text: '{title}'`, `fillColor: '{accent}'`). Reserved: the names above, `value`, the functions, `PI`. `await movie.setVars({ shift: 40 })` builds again at the same frame; `await movie.resize(w, h)` makes `W` / `H` follow a window. `render --var title=SÜD --size 1080x1920`, `check --size 1920x1080,1080x1920` | ## Common fields (every layer) ``` name, at, duration, initial: { ...props applied before any keyframe }, keyframes: [ { at, duration, ease, set | to | from | (from + to), repeat?, yoyo?, repeatDelay? } ], filters: [ { type: 'chromaKey', keyColor, threshold, smoothing, spill } | { type: 'glow', name?, outerStrength: 3, color: '#ffd166' } /* any named filter, see below */ | { type: 'custom', name, filter: } ], mask: | 'matteName' | { layer: 'matteName' | , channel: 'alpha' | 'luma', invert: true } | [ …names or { layer: 'name', … } ], // an inline mask with no `at` of its own starts and ends with the layer it masks (its keyframes count from that layer's start); an explicit mask `at` is composition time. A NAME points at a sibling layer (the matte, shared by any number of layers, NOT drawn itself, alive for its own `at`/`duration`); a list is an intersection; subtract = `invert: true` filterArea: { x, y, width, height } // in the layer's OWN coordinates; lets blur/glow draw past the layer's bounds threeD: true // opt into 2.5D (see below) blendMode: 'normal' | 'add' | 'screen' | 'multiply' | 'overlay' | 'soft-light' | 'hard-light' | 'color-dodge' | 'color-burn' | 'darken' | 'lighten' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity' | 'linear-burn' // CSS names (hyphenated); blends with EVERYTHING BELOW the layer; add / screen brighten (glows), multiply darkens, soft-light / overlay for colour casts and contrast, color-dodge for light sources; on a composition the children each blend (to blend it as one picture: threeD: true or a filter); the 14 new ones cost ~2 ms a layer on WebGL (10+ on screen warns) ``` Animatable props (`initial` / keyframes): `x y alpha rotation scale scaleX scaleY pivotX pivotY anchorX anchorY skewX skewY tint width height visible autoAlpha`, plus for shapes their style/geometry, for text `fill`, for audio `volume`, for 3D `z rotationX rotationY`, filter params as `'filters..'`, three objects as `'three..'`. **These go in `initial` / keyframes, never at the top level of a shape or text layer** (top-level `x`, `rotation`, `strokeColor`, `fillAlpha` each warn "is not a … key"): the top level of a shape holds its geometry (`width height radius d startAngle endAngle cornerRadius anchorX anchorY`), `trimStart` / `trimEnd`, `strokeCap` / `strokeJoin`, `fillGradient`. Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate to), `from` (animate from), `from`+`to`; a value may be relative to where the layer stands (`from: { x: '-=36' }` slides in from 36 px to the left of its place). `from` / `from+to` show their start value from the layer's start, so a delayed fade-in needs no `initial.alpha`. `repeat` (finite extra plays), `yoyo`, `repeatDelay` loop any of them (endless repeats are not allowed). `ease` = an ease name (`'none'`, `'power2.out'`, `'expo.out'`, `'back.out(1.7)'`, `'elastic.out(1,0.5)'`, `'sine.inOut'`, `'bounce.out'`, **`'cubic-bezier(.4, 0, .2, 1)'`** (CSS syntax, x1 and x2 in 0…1; `.4, 0, .2, 1` is a gentle in-out; `.2, .8, .2, 1` is FRONT-LOADED, not slow motion: half of the change in the first 13 % of the time, 90 % by 39 %, 99 % by 74 %, then a long settle, so what sits at the END of the content gets the glide; `.2, .8, .5, 1` has a shorter tail). `power1…4` are the powers 2…5: `power2` is a cubic, `power2.in` has done only 12.5 % at half time (42 % at three quarters)… and springs: `'spring(mass, stiffness, damping)'` or `'spring.gentle' | 'snappy' | 'bouncy' | 'wobbly' | 'slow'`; **`duration: 'auto'` = the time the spring takes to settle** (presets, settle time / highest value reached as a multiple of the target: `snappy` 0.42 s / 1.01, `gentle` 0.72 s / 1.02, `bouncy` 1.08 s / 1.28, `slow` 1.29 s / 1.00, `wobbly` 1.61 s / 1.40: time the next beat from these) (real physics; with a number `duration` is the settle time and only the damping ratio shapes it; a damping ratio of 1 or more never overshoots, so `spring(1,170,26)` does not bounce: use damping below 20 at stiffness 170; `'auto'` works on keyframes only, not in `animateText` / `orbit` tweens; a `stagger` ease may be a spring, its delays are held inside the spread). Default ease is linear. ## Layer types **text** — `text`, `resolution` (1–8: draw at N× the pixels so text inside a parent scaled up N× stays sharp), `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */, time: 'hh:mm:ss'|'h:mm:ss'|'hh:mm'|'mm:ss'|'m:ss' /* the value in SECONDS as a clock */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change). **Colour inside one line:** `spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'a'", fill: '#ffd166', fontWeight: '700' }, ',\n next line']` instead of `text` (strings use the layer's `style`; a span changes only fill / fontWeight / fontStyle / fontVariant / letterSpacing / dropShadow, never a size, font or stroke (those are the layer's `style`); `\n` starts a line; `style.align: 'left'` for code; `visibleChars` types over all the spans; it becomes a group of text layers `.0…`). **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`, `scaleMode: 'nearest'` (hard square pixels when scaled up: pixel art; default `'linear'`, smooth; it belongs to the asset). Default anchor top-left; natural size = `w`,`h`. **video** — `asset`, `loop`, `audio`, `volume`, **`speed`**; `initial: { scale: 'cover' }`. `speed: 0.5` slow motion, `speed: -1` backward (it starts at |speed| × `duration`, so a 3 s file with no `duration` plays whole, backward); with no `duration` the layer lasts the file's length ÷ |speed|. For anything that is not one straight speed — a freeze, a ramp, a loop — animate **`time`** (seconds in the file) like any property: `keyframes: [{ at: 0, from: { time: 0 }, to: { time: 2 }, duration: 1 }, { at: 1, to: { time: 2 }, duration: 0.5 }, { at: 1.5, to: { time: 6 }, duration: 3, ease: 'power2.inOut' }]` (play 2 s, hold, then accelerate). `repeat` loops it, `yoyo` ping-pongs. `speed` itself is not animatable (it warns): animate `time`. The sound follows (pitch too, like a tape). **audio** — a file: `asset`, `loop`, `volume`, `speed` (the pitch follows; `time` keyframes work as for video; not for `sfx` / `music`: change an sfx's `pitch` instead, or put it in a composition and remap that); `volume` (top level, or in `initial` like every other layer's starting values); fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). Omit `duration` for a one-shot clip: it lasts exactly as long as the clip. With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`. **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. **The mix's loudness in one line:** `movie.init({ loudness: -16 })` brings the whole mix to -16 LUFS (web video), up or down; `check` says when a mix is quiet. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser; at most 10 s); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset; above 1 is allowed: a sparse track of clicks measured −32 LUFS at 0.5 and needed about 2 to reach −24 LUFS). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms. **Or music with no file — `music`:** `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4 Dm7:4 G7:4' }, { inst: 'bass', notes: 'c2:2 g1:2 …' }], drums: { kick: 'x...x...', snare: '....x...', hat: 'x.x.x.x.' } } }` — a tune written as text (notes in LOWERCASE `c4:2` in beats, chords start with a CAPITAL `Am7` `G7` `C@4` `Am7/e` (`g7` is a very high note, not G7), `[c4 e4 g4]`, rest `_`, hold `~`, `!` accent; `|` ignored). Instruments `keys pluck pad bass sub lead bell musicbox`; drums `kick snare hat openhat clap rim tom crash shaker sleigh` as 16th-note step strings (`x` hit, `o` soft, `.` none; a list of `{ from, to, … }` for sections). Score options `bpm` (or `[[beat, bpm], …]` for a ritardando) `meter` (beats in a bar, default 4: names the bars in `check`'s read-back, no effect on the sound) `swing reverb (0–0.6) humanize drumVol grid transpose tail seed`; track options `vol` (or `[[beat, level], …]` for a fade) `pan step strum legato tone reverb attack release ring transpose`. A plain capital letter is a major chord (`G`, `Bb`, `F#`; `Ebmaj7`, `F#m7`), `E5` is root and fifth only (e3 b3), every chord token is struck again (`~` holds), a track can start late with a rest (`_:32 e4:1 …`), `f#3` / `bb2` are sharps and flats, c4 is middle C, chord names are voiced around octave 3 (good ranges for notes: bass c1–c3, keys / pluck c3–c6, pad c2–c5, lead c4–c6, bell c5–c7). **Drums repeat by themselves** (one bar is enough) and `drums: { from: 32, to: 64, … }` counts BEATS, not bars (`drums` is one object, or a list of them for sections). `check` reads the score back (bars per track, what starts in each bar and on which beat: `Am(2)`; drum span): trust that over counting by hand; `await musicEvents(music)` gives the same as data. Lasts its notes + tail, ends with the movie, `loop: true` repeats; peaks −6 dBFS at `volume: 1`. `await musicEnvelope(music, { frameRate })` → an envelope for `react()`; `await musicBuffer(music)` → a Web Audio `AudioBuffer` to play yourself. **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`: | shape | geometry (top level **or** in `initial`) | |---|---| | rect | `width height cornerRadius anchorX anchorY` | | circle | `radius anchorX anchorY` | | ellipse | `radiusX radiusY anchorX anchorY` | | arc | `radius innerRadius startAngle endAngle` — degrees, **0° = 3 o'clock, clockwise**; centred on `x, y`; stroke only = an open arc line (`strokeCap: 'round'`), with a fill = a sector, with `innerRadius` = a donut slice; a sweep ≥ 360° = a full circle. A progress ring: `startAngle: -90` and animate `endAngle` from −90 to 270 | | line | `from: [x,y] to: [x,y]` — plain canvas coordinates (omit `x,y`; if you give them they place the line's midpoint). Stroke in `initial`: `strokeColor`, `strokeWidth` | | polygon | `points: [[x,y],…] open` — canvas coordinates like `line` | | path | `d` (SVG path data) — canvas coordinates like `line`; leave `x` / `y` out and it sits where `d` says; give them (even `y: 0`) and they place its MIDDLE, not an offset (measured: `y: 0` put a square's centre on the top edge). **Holes:** several sub-paths are each filled alone (a donut written as two circles is a disc) unless `fillRule: 'evenodd'`: the biggest is the shape, the others are holes. **Morph:** `morphTo: 'M…Z'` + animate `morph` 0 → 1 (points half way between, closed outlines lined up so they do not twist; `morphPoints` 8–2048; two outlines with the same number of corners are paired corner to corner, so a quad stays a sharp quad) | **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. A `path` with several sub-paths walks them in order as one length; `trimEach: true` trims each on its own (all draw on together). Use `strokeCap: 'round'`. **Dashes:** there is no dash option; write the outline as alternating sub-paths (`d: 'M 0 0 L 10 0 M 20 0 L 30 0 …'`, or sample the curve) and `trimEnd` 0 → 1 walks them in order, so the dashes draw on one after another (as in `route-atlas`). Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg; DEFAULT 90 = top→bottom, 0 = left→right: a wide gradient slid sideways as a matte needs angle: 0 */, center?, innerRadius?, radius? /* radial, 0–1 of the SHAPE's own bounds, not the canvas: a glow on a 1500 px circle is placed with fractions of that circle */ }` — stops may have alpha, so a radial transparent→dark is a vignette. **Animate it** with a partial gradient in a keyframe (`to: { fillGradient: { angle: 360, stops: [...] } }`: keys you leave out stay; the same number of stops; `type` cannot change; a start gradient is required; use `colorSpace: 'oklch'` for a vivid fade). A text layer takes `fillGradient` too (LINEAR only: angle and stops; type / center / radius are ignored with a warning; letters re-rasterised per change). `from` alone runs from that gradient to the layer's own. The names `'fillGradient.angle'` / `gradientAngle` are not properties: write `fillGradient: { angle }`. Stroke ends and corners: `strokeCap: 'butt'|'round'|'square'`, `strokeJoin: 'miter'|'round'|'bevel'` (top level or in `initial`; not animated — `round` keeps sharp corners like an M from poking out). **composition** — `width height duration sequences transitions`, **`speed`** (the composition's own time: its content plays backward, in slow motion, … — `time` keyframes give a freeze, a rewind, a ramp; a composition's sound and sfx follow; its own x / alpha keyframes stay in the outer time; **children are placed in the composition's local seconds**; the content is as long as the larger of `duration` and the farthest `time` it reaches (`speed: 2` → twice `duration`; `time` keyframes → their largest number), and children default to that length. A child whose life ends exactly where a held `time` stands is hidden during the hold (a life is [at, at + duration)): give it a `duration` that reaches past. `time` keyframes and ordinary ones (`x`, `alpha`, a `wiggle`) may share one `keyframes` list. A sound inside goes silent while the clock stands still). With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit. **canvas** — `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … }, setup?, params?, seed?, resolution?, smoothing? }`: a picture drawn by YOUR function of the time into the layer's own cleared canvas (`t` = the layer's seconds; `info` = `{ frame, duration, progress, width, height, resolution, frameRate, params, state, random, start, time, canvas }`: `time` is the MOVIE's seconds (`start + t`; share it between layers instead of adding `at` by hand), `canvas(w, h)` an offscreen canvas for sprites and masks made in `setup`). It is a normal layer (initial, keyframes, mask, filters, blendMode …); `params` are numbers keyframes animate as `'params.x'`; no `Math.random` / `Date.now` (`info.random()` is seeded); a simulation is computed in `setup` and read by `info.frame`; it cannot see what is below it. Data several canvas layers share lives in module scope; `import { ease, noise, noise2, random, rand, samplePath } from 'pixi-effects'` for easing by keyframe name, seeded noise and a path walked by length (`samplePath(d).at(u)` / `.trim(a, b)`). `check` draws it twice per moment (one draw thrown away first) and reports a picture that differs, with how much; it prints a `canvas` line when all pass and does NOT see text drawn in a canvas. **shader** — `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040', pos: [0.5, 0.5] }, width?, height?, resolution?: 0.5, transparent?: false }` — a Shadertoy fragment shader drawn into a layer (GLSL ES 3.00): `iResolution` `iTime` (the layer's own seconds) `iFrame` and your uniforms are declared for you; keyframes move uniforms as `'uniforms.speed'` and components as `'uniforms.tint.0'`; opaque unless `transparent: true`; it cannot read what is behind it (no `iChannel` / `iMouse` / `iDate`); no `while` or non-constant `for`; a compile error is reported in your own line numbers and the layer is a checkerboard. **null + `parent`** — `{ type: 'null', name: 'rig', initial: { x, y }, keyframes: [...] }` draws nothing; layers with `parent: 'rig'` are drawn inside it and follow its `x y rotation scale skew alpha pivotX pivotY`. Their `x / y` are measured **from the null's origin** (null at (640,360), child `x: 200` → drawn at 840), so rotating the null makes the child circle it. Nulls chain (`parent` on a null). Children are drawn at the null's place in the stack, together. Only `null` layers can be parents; not for `threeD` layers; a mask follows its layer. **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`. **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`. **In the composition's space:** `followCamera: true` (the layer's PerspectiveCamera is the composition's `camera`; the scene is in composition px with y flipped: the composition's `(x, y, z)` is `(x, -y, z)`; give the layer the composition's size), `followLights: true` (the `light` layers become three lights) and `followFog: true` (the camera's fog becomes the scene's `Fog`: close to, not equal to, the card fog); a light's `castsShadows` casts in three (mark your meshes `castShadow` / `receiveShadow`); `ctx.space` has the raw numbers. **glTF:** `import { threeGltf, threeFrameCamera } from 'pixi-effects/three'`: `const m = await threeGltf(url, { animation: 'Run' | 0 | false, speed, offset, loop })` in `setup` (`ctx.scene.add(m.object)`; `m.fit(px)` scales the longest side to px; `m.clips` names the clips), `update: (t) => m.pose(t)` (a function of t: never `mixer.update(dt)`), `threeFrameCamera(ctx.camera, m.object)` frames the whole model from a three-quarter view. Needs `"three/addons/"` in the import map. Recipe `gltf-in-space`. ## Camera flight and a camera shake `...cameraPath({ points: [[x, y, z], …], duration, ease, look: 'ahead' | [x, y, z], lookAhead: 0.08, smooth: true, from, to, frameRate })` → camera keyframes (`x y z lookAtX lookAtY lookAtZ`) along a smooth curve at even speed (`ease: 'power2.in'` accelerates). A handheld shake goes on the camera's **offsets** so it never collides with the move: `...wiggle({ duration, props: { offsetX: { around: 0, amp: 5 }, offsetY: {…} } })` (`offsetX/Y/Z`, `lookOffsetX/Y/Z` are added to the position and the look-at point). A threeD layer the camera passes on purpose: `hideBehindCamera: true` (hidden quietly behind the camera, no warning). Recipe `camera-fly-through`. ## 2.5D in one paragraph Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY` (centre rotation with `anchorX/Y: 0.5` or `pivotX/Y`). Add a `{ type: 'camera' }` layer for a view. A `threeD` layer at `z: 0` with the default camera looks identical to a 2D one. Consecutive `threeD` layers are drawn farthest-first (equal `z` keeps array order); non-`threeD` layers ignore the camera and keep array order. Layers at/behind the camera plane are hidden. Not supported on `threeD` layers: masks, transitions, `filterArea`. **Light:** `{ type: 'light', kind: 'ambient' | 'point' | 'spot' | 'parallel', falloff?: 'none' | 'smooth' | 'inverseSquare', castsShadows?, initial: { x, y, z, lookAtX, lookAtY, lookAtZ, intensity, color, coneAngle, coneFeather, radius, falloffDistance, shadowDarkness, shadowDiffusion } }` lights every `threeD` layer of the composition (`lit: false` opts out; what no light reaches is black, so add `kind: 'ambient'`); a shadow needs `castsShadows: true` on the light AND on the layer (hard by default, `shadowDiffusion` softens; up to 4 casters per receiver). **Fog:** camera `initial: { fogNear, fogFar, fogColor, fogAmount }` (any one turns it on). ## Poster `movie.init({ …, poster: 9.5 })` names the moment (seconds; negative = from the end) that stands for the movie: the canvas shows it before play (the playhead stays at 0, play starts from 0), `movie.poster` / `movie.posterFrame` read it, `await movie.posterImage({ as: 'dataURL', type: 'image/jpeg', scale: 0.5 })` is the picture. Pick the most striking moment, not frame 0 (which is often black). ## Player `new Controller(movie, { canvas, theme: { accent: '#ff4d6d' } })` (import from `pixi-effects/controller`; theme keys `accent foreground track barBackground trackHeight font`, or CSS `--mc-accent` …; `controller.setTheme(…)`). Your own player: `movie.play() pause() gotoFrame(f) volume muted toggleMute()`, state `currentFrame totalFrames frameRate duration isPlaying`, events `ready frame play pause ended seeking seeked volumechange error progress` (`
` (with `position: relative`). The wrapper is removed on `destroy()`. ### Theme: change the colours of the bar The bar is blue by default. Every colour and the track thickness are CSS custom properties with the old values as defaults, so there are two ways to change them: ```js new Controller(movie, { canvas, theme: { accent: '#ff4d6d', foreground: '#ffe9a8', trackHeight: 5 } }); // in code controller.setTheme({ accent: '#7bd88f' }); // later, live; only the keys you give change controller.setTheme({ accent: null }); // null = back to the default ``` ```css :root { --mc-accent: #ff4d6d; } /* or in your page CSS: the canvas's parent, or :root */ ``` | `theme` key | CSS property | What | Default | |---|---|---|---| | `accent` | `--mc-accent` | progress bar, its thumb, focus rings, the export button | `#007AFF` | | `foreground` | `--mc-fg` | icons, the time, the volume slider | `#fff` | | `track` | `--mc-track` | the unfilled part of the progress / volume bars | `rgba(255,255,255,0.25)` | | `barBackground` | `--mc-bar-bg` | the background behind the buttons (any CSS background, e.g. a gradient) | black fading to transparent upward | | `trackHeight` | `--mc-track-height` | thickness of the progress bar (a number is px) | `3px` | | `font` | `--mc-font` | `font-family` of the bar | system UI font | A misspelt key warns with the valid ones. To restyle more than the theme covers, set the `--mc-*` custom properties (or override the `.movie-controller` classes) in your own CSS. ### `controller.destroy(): void` Idempotent. Removes: - the controller bar DOM - all listeners (Movie events, document keydown/pointerdown/fullscreenchange, wrapper pointermove/mouseleave) - the auto-hide timer - the wrapper, if it was created here - the injected stylesheet (ref-counted across multiple controllers) If the controller still owns `document.fullscreenElement`, it calls `exitFullscreen()`. ### Export popover Format options: **MP4**, **WebM**, **MOV** (mp4 ↔ avc/aac, webm ↔ vp9/opus, mov ↔ avc/aac). Quality options: **Low**, **Medium**, **High** (default), **Very High**. These map directly to `Movie.render()`'s `format` and `video.bitrate` / `audio.bitrate` parameters. Selection persists for the lifetime of the controller instance (no localStorage). The download is triggered by an in-page `` click, so the file lands in the browser's default download location with a name like `movie-{YYYYMMDD-HHMMSS}.{ext}`. ### Keyboard shortcuts Active when `keyboard: true` and the key target is not ``/`