Skip to content
Back to skills

Seek Engine

BSecurity

Build, debug or speed up the seek(t) render engine: render contract, canvas vs page capture, workers/subframes/chunks, determinism fixes, Remotion or HyperFrames handoff. Use for 'render is slow', 'frames differ', 'use Remotion'.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 7, 2026
designgoshellnodespringawstestinggitsecurityperformance

Works with

  • cli

Security analysis

B84/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 3 files and shows the line behind each finding

Scanned October 7, 2026

npx -y skills add yazzang-homelab/motion-studio --skill seek-engine --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Seek Engine?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Seek Engine
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/yazzang-homelab-seek-engine/badge)](https://www.skillsdirectory.com/skills/yazzang-homelab-seek-engine)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: seek-engine
description: "Build, debug or speed up the seek(t) render engine: render contract, canvas vs page capture, workers/subframes/chunks, determinism fixes, Remotion or HyperFrames handoff. Use for 'render is slow', 'frames differ', 'use Remotion'."
argument-hint: "[symptom or goal]"
---

# Seek engine (course step 07)

A film here is a program, not a video. The page paints the exact frame for any time `t`; the renderer walks time,
captures each frame and pipes it into ffmpeg. Nothing depends on a clock, so a render is identical on every run and a
fix is a one-line edit plus a re-render. This is route A, the route Opus picks by default. Use it unless the user
names Remotion or HyperFrames (then read [references/route-b.md](references/route-b.md)).

Run everything from the film project root (the folder with `studio.json`). If there is none, run
`/motion-studio:studio-init` first. Files of this skill live in `${CLAUDE_SKILL_DIR}`; the relative links below point
there (`${CLAUDE_SKILL_DIR}/references/determinism.md`, `${CLAUDE_SKILL_DIR}/references/route-b.md`).

## 1. The two halves

| Half | Files | Job |
|---|---|---|
| Page | `index.html`, `film/film.js`, `lib/runtime.js` (+ `lib/*.js`) | `boot(film)` loads `studio.json`, `audio/beats.json` (optional) and bundled fonts, then exposes `window.seek(t)` and `window.__studio` |
| Driver | `tools/render.mjs` (+ `tools/studio.mjs`) | serves the project on `http://127.0.0.1:<random>`, launches a headless browser (Windows: Playwright's headless shell, never an installed Chrome or Edge; macOS and Linux: Chrome, Edge, then the bundled one), opens N worker pages, captures frames in order, encodes one H.264 stream |

The page is always served over http. `file://` breaks `fetch('studio.json')`, ES modules and paths containing `#` or `%`.

## 2. The render contract

| Member | Contract |
|---|---|
| `?format=9x16&scale=1&render=1` | query the driver sends; `format` defaults to `studio.primaryFormat`, `scale` 0.1–1 |
| `window.seek(t, blur?)` | synchronous paint of time `t`; loop films wrap with `loopT`, others clamp to `[0, duration]`; returns `true`. The optional second argument `{ frameT, sub, subs }` says this paint is subframe `sub` of `subs` of the output frame centred on `frameT` (page capture sends it for every screenshot) |
| `__studio.ready` | Promise; resolves after config, beat grid, fonts (`loadFonts` asserts every face) and `setup()` are done |
| `__studio.error` | string stack when boot failed (the driver prints it) |
| `__studio.meta` | `{ title, duration, fps, format, width, height, logicalWidth, logicalHeight, scale, bpm, loop, capture }` |
| `__studio.frame({ t, sub, shutter, fps, wrap })` | PNG data URL of frame `t`; `sub > 1` accumulates centred subframes in the page (motion blur); `wrap: false` paints the raw time clamped to `[0, duration]` even in a loop film |
| `__studio.hash(t, { wrap, sub, shutter, fps })` | sha256 of the raw pixels after `paint(t)` (determinism checks); `hash({ t, wrap: false })` is accepted too. `hash(0)` versus `hash(duration, { wrap: false })` is the loop-closure invariant |
| `__studio.pixels(t, w)` | small RGBA thumbnail as base64 (critique metrics); always wraps in loop films |
| `__studio.cues()` / `shots()` / `grid` | SFX cues, shot windows, beat grid as JSON (read by `sfx.mjs`, `stills.mjs --shots`, `critique.mjs`). `shots()` is in time order and lists scenes only, never `overlays`; `grid` carries `hits` |
| `__studio.textUse()` / `coverage(family, weight, style, chars)` / `textTrack` | the critique's glyph check. With `window.__TEXT_TRACK__ = true` before load (or `?texttrack=1`) `lib/draw.js` records the characters `text()`, `kinetic()` and `textWidth()` draw per font; `textUse()` resolves `[{ family, weight, style, chars }]` for the frames painted so far (`seek()` through the film first), `coverage()` resolves `{ missing, checked }` for one face. Off by default, never changes a pixel; `text()` and `kinetic()` do not throw for an unregistered family |

What a scene sees in `c` while a motion-blurred frame is made: `c.t` is the time being painted (the SUBFRAME time, so
motion blurs by itself), `c.frameT` the output frame's centre time (the same for every subframe), `c.frame` the output frame
index `floor(frameT x fps)` (the same for every subframe), `c.sub` / `c.subs` the subframe index and count. Frame-indexed
noise uses `c.frame` (`grain(g, W, H, c.frame)` then looks the same in stills, preview and the final), and "on twos" uses
`stepTime(c.frameLt, 12)`. Details: `docs/ARCHITECTURE.md` section 5.2 in the plugin.

Film code obeys the rules in [references/determinism.md](references/determinism.md). The short form: every value is a
function of `t`; seeded randomness per element with `rngFor(name, i)`; no `Math.random`, `Date`, `performance.now`,
timers, `requestAnimationFrame`, CSS transitions or state carried between frames; fonts and assets are local files.
`npm run lint` checks this, and the plugin hook lints each edit to `film/`, `lib/` and `index.html`.

## 3. Commands

| Goal | Command |
|---|---|
| Live preview (scrub, Space, arrows, `[`/`]` shots, F formats, G safe guides) | `npm run preview` |
| Environment check (Node, Playwright, browser, ffmpeg, fonts, keys present) | `npm run doctor` |
| Install the Windows default browser (Playwright's headless shell, about 115 MB) | `npx playwright install chromium-headless-shell` (`npm run setup:browser`) |
| Fast pacing check (half scale, 30 fps, no blur) | `npm run animatic` (writes `out/animatic/<fmt>/silent.mp4`) |
| Render the primary format | `npm run render` |
| Render a time range for a check | `node tools/render.mjs --from 4 --to 6 --draft --out out/check` (writes `out/check/<fmt>/clip_4.00-6.00.mp4`) |
| Render every format | `npm run render:all` |
| Final render, all formats (blocked until the critique gate passes; about 4 min for the 12 s demo, so run it in the background) | `npm run render:final` |
| Long form, resumable segments | `node tools/render.mjs --format all --final --chunk 10` |
| Per-frame hash manifest | `node tools/render.mjs --hash` (writes `frames.sha256`; a range writes `clip_<from>-<to>.sha256`) |

Output per format: `out/<fmt>/silent.mp4`, `out/<fmt>/poster.png` (at `studio.poster` or 0.35 x duration) and
`out/<fmt>/render.json` (fps, frames, workers, browser version, capture, encode settings, seconds). `mix.mjs` reads
`render.json` for the exact audio length. Everything is built in `out/<fmt>/.staging/` and published as one unit: the
old files move aside first, the new ones move in with `silent.mp4` last, and any failure puts the old ones back. If
another program holds an output open (a video player, an image viewer on Windows), the render waits up to 30 s
(`MOTION_PUBLISH_WAIT_MS`), warns at the start which files are open, and otherwise stops with `cannot replace <file>:
EBUSY ...` and KEEPS the finished render in `out/<fmt>/.staging/` (the previous outputs stay untouched). Close the
program and copy the files from `.staging/`, or render again. A full render with `--no-poster` removes the previous
`poster.png`, and one without `--hash` removes the previous `frames.sha256`, so no output describes an older cut.

A range render is partial only when `--from`/`--to` leave frames out; it writes `clip_<from>-<to>.mp4` with its own
`.json` and `.sha256` instead, never replaces `silent.mp4`, and cannot be `--final`. A range that covers the whole film
(or a `--to` past the end) is a full render. Keep check renders under `--out out/check` so `out/<fmt>/` holds only the
real film.

## 4. Capture modes

| Mode | Set by | How a frame is made | Use for |
|---|---|---|---|
| `canvas` (default) | nothing | `__studio.frame` paints `sub` subframes at `t + ((j + 0.5) / sub - 0.5) * shutter / fps`, sums pixels in the page, returns one PNG | everything drawn on `<canvas id="stage">` |
| `page` | factory returns `capture: 'page'` or `studio.json` `"capture": "page"` | `seek(t)` then `page.screenshot` per subframe; ffmpeg `tmix` + `select` blends each group | DOM films (styled elements updated from `t`) |

`shutter` 0.5 is a 180-degree shutter: the blur spans half a frame interval. Fast whips and pans can show ghost copies
at `--sub 4`; raise to `--sub 8` for those films. Page capture is slower (one screenshot per subframe) and inherits
DOM rendering differences, so prefer canvas.

## 5. Performance knobs

Paint count = frames x subframes. A 12 s film at 60 fps with `--sub 4` is 2,880 paints per format.

| Flag | Default | Effect | When |
|---|---|---|---|
| `--workers N` | `min(4, max(1, floor(cpus / 3)))` | parallel pages in one browser feed one ordered encoder | raise on big machines; lower if RAM is tight |
| `--scale s` | 1 | canvas = even(round(W x s)); 0.5 = a quarter of the pixels | drafts, stills of long films |
| `--sub N` | `studio.subframes` (4) | subframes per frame | `--sub 1` for drafts; 8 for fast motion |
| `--fps N` | `studio.fps` (60) | output frame rate | 30 for animatics |
| `--draft` | off | `encode.previewPreset` + CRF 23 | any non-final render |
| `--crf N` / `--preset p` | 16 / slow | x264 quality / speed | leave for finals |
| `--chunk S` | off | resumable `out/<fmt>/.parts/seg_<a>_<b>.mp4`, then concat `-c copy` | films over ~30 s, a final of three formats, overnight runs (an interruption then resumes instead of restarting) |
| `--no-poster` | poster on | skip the poster still | batch checks |

Measured on the reference laptop (12-thread i7-1255U, installed Chrome, 1080x1920, 60 fps, 4 subframes, 4 workers; the Windows
default is now the headless shell, whose per-frame speed was not re-measured, and which adds 15 to 25 s per tool run for
start-up on a machine with real-time antivirus, measured):

| Setup | Time per output frame | Time for 15 s at 60 fps |
|---|---|---|
| the course's screenshot-per-subframe loop (capture only, synthetic scene of 200 arcs, text and a gradient) | about 1,575 ms | about 24 min |
| in-page accumulation with 4 pages (the same synthetic scene, capture only, pixel-identical) | about 148 ms | about 2.2 min |
| this renderer end to end on the template's 12 s demo film, final x264 encode | 123 ms in 9x16, 75 ms in 1x1, 118 ms in 16x9 | 1.8 min in 9x16 (about 4 min for the three formats of the 12 s film) |

The first two rows are the capture-only benchmark that chose the design; the last row is what a real film costs, and is
the figure to quote as an estimate (it rises with scene complexity and with other programs loading the machine, up to
about 5 min for the three formats). x264 `-preset slow` encodes about 18 fps at that size, so capture is the
bottleneck. Progress, fps and ETA print on stderr once per film-second. Start a final render in the background and poll
the log; never wait for it in the foreground.

## 6. Proving determinism

1. `npm run critique` runs the determinism check: `__studio.hash` at times that straddle every shot boundary and cue
   (plus and minus one frame), pass A in order, pass B shuffled, pass C after a reload. `out/review/<fmt>/metrics.json`
   lists every mismatching `t`.
2. After a range render, prove the range is deterministic with two hashed renders of the same range. Vary the worker
   count so parallelism is covered too:
   `node tools/render.mjs --from 0 --to 2 --hash --draft --no-poster --json --workers 1 --out out/check/a` and
   `node tools/render.mjs --from 0 --to 2 --hash --draft --no-poster --json --workers 4 --out out/check/b`.
   A range never touches `silent.mp4`. Each run writes `out/check/<a|b>/<fmt>/clip_0.00-2.00.mp4` plus `.json` and
   `.sha256` (one line per frame: `index t sha256`), and its `--json` line reports `framesDigest`, the sha256 of the
   hash column joined by newlines. Equal `framesDigest` values in every format mean identical frames. If they
   differ, compare the two `.sha256` files: `git diff --no-index --quiet out/check/a/<fmt>/clip_0.00-2.00.sha256
   out/check/b/<fmt>/clip_0.00-2.00.sha256` exits 0 when they match and `git diff --no-index` shows the first frame
   that differs. Never `md5`, which does not exist on Windows or Linux.
3. Do not hash the mp4: x264 output changes with the thread count, so mp4 hashes differ across machines. The
   `framesDigest` covers the frame PNGs that go into the encoder, which is what determinism is about.

The promise is bit-identical frames on the same machine and browser build (recorded in `render.json`). Across
machines, fonts rasterize differently (DirectWrite, CoreText, FreeType); compare visually.

## 7. Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `Executable doesn't exist ... chromium_headless_shell`, or `could not launch a browser: no safe browser found.` (Windows) | Playwright's headless shell is not downloaded (`npm install` does not fetch it) | `npx playwright install chromium-headless-shell`. On macOS and Linux an installed Chrome or Edge also works; on Windows an installed one is a guarded opt-in (`studio.json` `"browser": "chrome"`, `MOTION_BROWSER=chrome`) because each fresh-profile launch counts as a failed Windows logon |
| Windows account locked, Security log events 4625 from `chrome.exe`, or `refusing to start chrome: N launches ...` | an installed Chrome or Edge was started with a fresh profile (blank-password test = failed logon) | switch to the headless shell as above and unset `MOTION_BROWSER` / `MOTION_CHROME_PATH`; the confirm commands and the reasons are in `${CLAUDE_SKILL_DIR}/../studio-init/references/troubleshooting.md`, "Windows account gets locked out while rendering or testing" |
| `ffmpeg not found` | not on PATH, `ffmpeg-static` missing | `npm i -D ffmpeg-static` or set `FFMPEG_PATH`; `npm run doctor` prints per-OS commands |
| `spawn EINVAL` in your own script on Windows | spawning `npm`/`npx` `.cmd` shims without a shell | use `spawn('npm.cmd', args, { shell: true })`; the tools already do |
| `font missing: 700 64px "X"` or ready timeout | family not bundled or misspelled | `npm run fonts -- add "X:400,700"`; use the names in `studio.json` `brand.fonts` |
| blank or partial frames, page errors in the log | exception inside a scene draw | the driver prints `__studio.error` and console errors; reproduce with `npm run preview` at that time |
| an element is missing only on some frames | a NaN input (draw helpers skip NaN draws) | log the input in preview; usually `t - from` before a scene starts or a division by zero |
| hashes differ in pass B (shuffled) | state carried between frames | move caches and counters into the factory or `setup()` ([determinism](references/determinism.md)) |
| still elements jitter while others move | one RNG stream shared across elements | `rngFor(name, i)` per element, created inside the draw |
| accent color shifted in a player | encode without BT.709 tags | use `tools/render.mjs`; if you re-encode, keep the four `bt709` tags |
| `width not divisible by 2` in custom encodes | odd dimensions | the renderer pads to even; keep scales that give even sizes |
| `film failed to load (<url>): the page threw while booting: ...` or `window.__studio was never created within 15 s` | an exception at import (bad import path, syntax error) or `index.html` never calls `boot(film)` | fix the first `pageerror:` / `console:` line the message lists; a 404 on an import shows up there too |
| `film failed to load ...: __studio.ready rejected: <text>` | `setup()`, the beat grid or `loadFonts` threw (`font missing: <spec>`) | the text names the cause; `__studio.error` carries the stack |
| `note: the browser did not close within N s; killed it` | the installed Chrome's profile cleanup is slow on Windows (measured); the tools bound every browser and context close | harmless, the output is already written; `MOTION_CLOSE_TIMEOUT_MS` (default 8000) changes the wait |
| a frame takes longer than 2 minutes and the render aborts | a scene draw is extremely slow or stuck | reproduce that time in `npm run preview`; `MOTION_FRAME_TIMEOUT_MS` (default 120000) changes the limit |
| `--final renders the whole film; drop --from/--to` | a range combined with `--final` | final renders are always the full film; use a range without `--final` for checks |
| `cannot replace <file>: EBUSY ... The finished render is kept in out/<fmt>/.staging` | a player or viewer holds `silent.mp4`, `poster.png` or `render.json` open (Windows) | close it and copy the files from `.staging/`, or render again; `MOTION_PUBLISH_WAIT_MS` (default 30000) sets how long a held file is waited for |
| `film failed to load ...: __studio.ready rejected: <cause> (__studio.error: ...)` | the boot promise rejected; the cause comes first | fix the named cause; a `the page threw while booting: ...` line means an import or syntax error before `boot()` finished |
| a request for `/.env`, `/.git/config` or an 8.3 short name of one returns 404 | the tools' static server hides every dot-file and dot-directory of the project, also through symlinks, junctions and 8.3 names, and on Windows any request path containing a backslash or `:` | expected; keep film assets out of dot-folders |
| hero type looks soft | the context was scaled to size the text, or DOM `will-change` on a scaled subtree | set the font size instead of scaling; never `will-change` on scaled elements |

For hard cases delegate to the `render-engineer` subagent with the failing command, its stderr and `render.json`.

## 8. Route B handoff

Only when the user names Remotion or HyperFrames. Both need Node 22 or newer; Remotion needs a Company License for
for-profit organizations with more than 3 employees. Follow [references/route-b.md](references/route-b.md) step by
step: pre-flight, install, explicit invocation, preview in the background, render only after the user's OK.

Next: `/motion-studio:springs` to make the motion feel physical, then `/motion-studio:critique-loop` to review the frames.

Files in this skill

  • SKILL.md17.5 KB
  • references/determinism.md9.3 KB
  • references/route-b.md7.1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.