videoskilletnotes Open the app ↗
On this page

The editor: a rundown, and an export an NLE can conform

The ask behind this was music videos: a series of clips, set up in advance, played back to back. It has two halves.

They are one project rather than two: the live walk and the offline walk are the same walk on different clocks. Both are built. This page is what still constrains changing them, plus the two boundaries that get proposed again every few months.

Related material in IDEAS.md: Clip cues, Patching into other apps (live routing to Max/TouchDesigner), and Capture / deinterlace (a composite grabber on the way in).

Why this is not an NLE plugin#

The recurring version of this is "the shaders are the value, so put them in something that has a timeline" — After Effects, Premiere, Resolve. Each route was investigated and declined, in order of how fast the finding kills it.

There is no "the shaders" to port. The WGSL is about a third of the simulator. Against twenty-six shaders sit src/core/signal/'s per-frame CPU state (LineState, MixState, RfState, SynthState, AudioState, and the FIR bank, redesigned CPU-side whenever one of the filter five moves) and src/core/gpu/'s pass graph, uniform packing and buffer management. PARAM_DEFS is 228 fields, DEFAULT_CONTROLS 234 keys, and several buffers are state rather than scratch — timingBuf[525..532], persistBufs, storePrev. Lifting the shaders alone lifts nothing that runs.

So the honest version of "put it in an editor" is a deterministic render of frame N handed over as a file, which is the export half below. A native standalone on wgpu stays on the table as a shell decision (see What a desktop shell buys) and never as an integration strategy.

Why the strip stays in the app#

The live app is already dense, a rundown is a lot of new surface, and this repo has a second entry point already. It stays one document, and three of the reasons are load-bearing rather than preferences.

The strip writes; it does not view. Every row it fires goes through funnels the live app owns — writeControls / startGlide for the look, selectSource / loadClip / showRef for the source, setVideoRegion for the cue. A second page needs a second engine, and around it a second copy of useEngine's two thousand lines of source loading, plus the bay, the tempo and the MIDI wiring. Two copies of one contract drift, which is the argument slotView.ts already makes about a much smaller duplication.

/vote/ is the counter-example, and it states the test. The second entry in vite.config.ts exists on an explicit condition — nothing in it should cost the app a byte, so a visitor to index.html never downloads it — and the vote page meets it: it shares Engine and presets, builds its two engines on one device, and needs no part of the panel. The strip fails that test from both ends. It wants nearly all of the panel, and a visitor to the strip page would download the app entire.

The offline half is pinned here regardless. An offline render must adopt the live device rather than create one (adr/0004), and a second tab cannot adopt the first tab's.

What the worry is actually about is screen space, and usePopout already answers it: a same-origin window with the panel portalled into it — same React tree, same engine store, same MIDI, no message plumbing, because the JS heap is shared. The picture goes on the projector and the rundown on the laptop. So the live/edit tension is a mode rather than a page: with the tray shut, the app is what it is today to the byte.

The strip#

The shape is a rundown, not an NLE timeline. Tracks, a playhead and trim handles are built for material that gets rendered once and never touched again; an ordered list of cued states, each of which can also fire on its own, serves setting a piece up and playing it live, which here are the same activity at different speeds.

There is no playhead, and there cannot be one. Row N depends on every row before it and frame N on every frame before it — the phosphor, the frame store, the PLL's lock — so a piece plays from the top or not at all. That is the same property that rules out being a plugin, and no amount of drawing the tray differently touches it.

What the austere drawing was over-claiming is that the tray had to look like a list to stay one. It does not: the shape is a rundown that reads like a filmstrip, and the remaining distance to what an iMovie user expects is cosmetic — cards that show their clip and are as wide as their screen time, transitions drawn between clips rather than as a chip on one, handles to trim with. None of those is a track, a playhead or a ripple edit.

A row is a session plus two fields#

ui/urlParams.ts is "the share-link contract: everything a session can be configured with from the query string". It round-trips the look (#preset, #set), the modulation bay (#mod) and the cue points (#cuea, #cueb), so a row is that snapshot plus how long it holds and how it arrives. New row state belongs in urlParams rather than beside it.

With one exception, and it is the exception the strip's main feature turned on. writeProfileParams drops every source mode a URL cannot carry — LINKABLE filters file, library, browse and screen — so for a while a row captured over a clip recorded the look and nothing about the picture, and derivedLabel called the card "look only", accurately. Rows were a sequence of effects over whatever was on the deck. Row.clip is the fix, narrowed to the one case that was missing: a shelf id.

interface Row {
  id: string
  name: string
  session: string
  clip: { id: string; name: string; seconds: number } | null
  fill:
    | { kind: 'clip' }
    | { kind: 'roll'; origin: PoolOrigin }
    | { kind: 'jitter'; amount: MutateAmount }
  hold: { bars: number | 'clip' | null; drift: number }
  arrive: { seconds: MorphSeconds; transition: TransitionName | null }
}

session uses writeProfileParams rather than writeSessionParams: a row is read back weeks later, so it wants resolved controls with no preset= underneath to re-supply a knob the hand had already put back. That buys three things — a row is shareable on its own, scripts/clips.mjs can drive one with no new contract, and urlParams.test.ts is already the row codec's test.

Twenty rows is past what an address bar carries, so a row is a link and a rundown is a file: JSON in storage.ts, holding rows whose looks are strings.

Three kinds of row, one shape#

A strip of fixed clips at fixed bar counts is a storyboard — the same video every time, which is the opposite of what this app is built around.

Holds are loose by default: "≈N bars" with a drift amount, with exact beat-lock available per row for the cut that has to land on a hit. That is a taste call — a strip whose rows roll and whose holds drift is a pattern, so playing it twice gives two different videos, which is the right default for a tool whose sources include two random-access archives. A hold can also be 'clip', as long as the picture runs; rowRuntime reads the trim first and the clip's own length second, so an in/out pair is also how long the row is up. ui/useTempo.ts supplies the beat from MIDI clock or a tapped DEFAULT_BPM, so bar-relative holds work with no gear attached.

Seeding#

Every roll goes through a seeded RNG, and a take records the seed plus the resolved picks. Without it a take is unreproducible by construction, and the whole point of the export is to re-render a take at quality after performing it.

Storing the resolved picks means storing identity, not urls, for the reason sources/pool.ts gives: a url is a rendering, and the one that worked today 404s when a transcode ladder is rebuilt. PoolRef — origin, title and kind — is the thing to keep. The same seed does not hand back the same file: Commons rolls with gsrsort=random and archive.org's within-page ordering is upstream's too, so a seed reproduces this app's decisions and the recorded PoolRef reproduces the file. That is why the rule is a seed plus the picks, and never either alone. It is adr/0006, because it is the one a later reader would otherwise be within their rights to simplify into Math.random().

src/core/rng.ts holds Rand, rngFor, randomIndex and pickOne, and both pool rolls take a trailing rand through the one rollPool funnel. The signal path rolls too, which nobody predicted: MixState reached for Math.random from inside the frame through the Wow it owns, so a vhs board re-rendered differently every time however clean frame zero was. The engine now hands one per-take generator to all of them.

One walk, two clocks#

Playing the strip is: walk the rows, apply each through the existing writeControls / startGlide funnel, preroll the next row's source. That walk is the same live and offline; only what advances it differs — wall clock with manual override live, the virtual clock offline. The offline walk is nine lines (offlineWalk in ui/stripRun.ts), because advance already took a Clock and never cared where the frame came from.

The modules#

The walk is where an editor gets its bugs, and a browser is an expensive place to find them. So the arithmetic is pure and tested under vitest, and React only carries out what it says.

The walk advances on the engine's frame counter, not on a wall clock. advance takes a frame and a tempo, so "≈4 bars" is arithmetic over frameNo(). That makes the live driver a poll on the tick that already reads the playheads at 10 Hz, and the offline driver a call per rendered frame with nothing else changed. It also means the picture and the rundown freeze together when a tab stops getting frames, where a wall-clock strip would come back having silently skipped four rows nobody saw.

The React shape#

One context per clock. ControlsContext.ts carries the measurement: a controls object that changed identity on every write re-rendered every consumer whatever the compiler had memoized — 19 ms of React per slider write with all the rows mounted, past a frame, dropping one off the WebGPU loop per pointer move. The fix was to split what moves (a subscribe/get ControlStore read through useSyncExternalStore) from what is stable (ControlsApi). ModSlotsContext.ts stays one plain context with no store, because a bay changes when a hand patches it rather than at frame rate.

The strip has three clocks, so it gets three homes rather than one big StripContext: the rundown (moves when a hand edits it — ordinary state behind an API context of stable verbs), which row is up (moves at row boundaries — ordinary state), and how far through the hold (moves every frame — a subscribe/get store, the shape morph.ts's MorphStore already has, with holdProgress as the pure function behind it).

The walk lives outside React, and the compiler decided that. Keeping it in state and mirroring it into a ref for the rAF closure means writing a ref during render, which is one of two patterns that make React Compiler give up on a hook silently; quieting the resulting dependency warning with eslint-disable is worse, since it skips optimisation for the whole hook. Both were tried and pnpm compiler caught both. So the runner is a plain object handed to useState once and read through useSyncExternalStore — and the side benefit is that a driver which is not a hook is a driver a test can drive.

The driver is the only effect. Three things will look like effects and must not become them: the hold's progress is derived from the walk and the frame, a row card's "am I live" is a comparison during render, and persisting the strip belongs in the verb that changed it, the way useTempo writes its tempo in write().

Effects as data is what keeps additions cheap. Preroll, the fault shelf, takes, per-row MIDI and the offline render each landed as a variant on Effect and an arm in stripRun's switch, with advance deciding when. That is why advance returns a list instead of calling the engine, and it is worth defending when the first "it would be simpler to just call it here" arrives.

Adding an async step to a hook is where the compiler bites. An await inside a try, and a variable reassigned from a callback that runs after the render, both make it drop useEngine whole, and nothing else in the build says so.

Preroll depth 1#

Steady-state playback does not care how long the strip is: VideoPump.due() gates on el.currentTime !== slot.lastTime and yields one createImageBitmap per newly decoded source frame, so one clip and forty clips cost the same per frame. All of the cost is at the cut — stopSlot, a new element, the network, the first frame. So a slot holds the live element and the next one, already loaded and seeked to its in-point, and swaps at the boundary. scripts/prerollcheck.mjs measures the cut at 9 ms warm against 58 ms cold on a small file over localhost, which is the least favourable case there is.

stopSlot deliberately leaves a parked element alone, since the load paths stop the slot and then call playUrl.

Transitions are faults that resolve#

A look-morph is not a transition. It walks the resting board from one place to another and the picture stays legible the whole way; nothing about it says a cut happened. So a transition is a fault that happens to resolve — break something, cut while it is broken, let it heal onto the new clip. An NLE cannot ship that, because its transitions are composited over two finished pictures and these are a receiver genuinely losing and regaining its grip.

signal/fault.ts is the envelope, ui/transitions.ts the shelf, and Engine.startFault the one verb between them. Five entries — track, roll, collapse, shuttle, dub — under the T-bar in the deck, each bindable to a MIDI pad, and a row's arrive.transition picks from the same table. No new uniforms, no new pass, no shader work at all.

A transition is two curves and a cut point, where a morph is one walk. The fault ramps up on the outgoing clip and down on the incoming one, and the source swap lands at the peak — the frame where the picture is least legible is the frame that hides the edit.

startFault(plan: {
  peak: Partial<Controls>  // the fault at full depth
  frames: number           // its span
  cut: number              // where the source swap lands, 0..1
  onCut: () => void        // fired once, on the peak frame
}): void

Evaluated where the bay is evaluated: additively over the resting controls, inside the frame, never touching what React renders from. That makes it frame-clocked and therefore already right under the virtual clock, composable with startGlide rather than fighting it (the look walks while the fault cuts), and one object an automation recorder can stamp. The cut is a callback rather than something the panel polls for because the swap has to land on the peak frame and nothing in React runs that often — the same argument setVideoRegion already carries.

Five things the shelf had to get right, and none was guessable from the design:

A pending cut goes stale. Half a second is long enough for the answer to change, so the runner numbers its steps and the cut checks its number before running — on the sink, so the offline walk inherits it. The fault is not cancelled: a fault is a picture effect and should heal rather than vanish, while a cut is a decision, and only decisions go out of date.

One thing the design was right about without saying why: the picture resolves after the board does. The fault is handed back inside the frame it ran, but the phosphor is still holding the band and the PLL is still walking its lock back. A transition ends as a receiver recovering rather than as an effect switching off.

A card must not move because its own text changed#

A row card is shrink-to-fit, so every label in it is load-bearing on layout, and the tray is one horizontal row of them — a card that grows slides every card to its right. The controls that change their own text are exactly the ones a hand clicks repeatedly, so stepping the hold chip walked the ✎, the ⧉ and the ✕ out from under the pointer resting on it. scripts/traylayout.mjs measured all five before they were fixed: the hold and arrival chips at 6.6px, the transition chip at 1.1px, the rename ✎ at 21.8px, and ▶ → ■ at 4.3px.

The cost is real: the feet are six controls, three now held at a fixed width, and together they exceed the card's floor, so cards come out very nearly equal. The variety they used to have was the hold chip being three characters wider on some rows, and that width difference is the shift itself. Cards that say something by their width want the hold to set it.

Two harness lessons came out of the same work, and both generalise. element.click() does no hit-testing, so it reaches a button a hand cannot — the tray harness now measures that every control on a card is inside the card. Reaching a card's chips positionally meant that adding one chip silently shifted three unrelated buttons; they carry data-act names now.

Fixed-framerate export#

Rendering a clip where frame N is a pure function of N, at a constant frame rate, decoupled from whatever the GPU managed in real time.

Most of the precondition was already paid, for reasons that had nothing to do with export. The signal path is a fixed-timestep 60 Hz simulation: artifacts clock off the frame counter (impulseStorm(this.frame / 60), scPhase, shuttlePhase), the modulation bay is const DT = 1 / 60 advanced once per rendered frame, and Engine.step() already forced a full sim step past timeScale and the frame lock. Four things were not pure functions of N.

The video source#

VideoPump.due() gated on el.currentTime, and a <video> advances at wall rate, so an offline loop faster than real time rendered the same input frame hundreds of times. Two routes; the measurement chose between them.

The cheap route is dead. scripts/pullstep.mjs asked the one question the cost model did not answer: a render's seek is forward, by one frame, from where the decoder already is, and if a decoder continued in place the keyframe spacing would stop mattering. It does not. A one-frame forward seek costs what a seek across the whole clip costs — 38 ms against a random seek's 35 ms on a 3s GOP, 183–607 ms on a single-keyframe clip — against a 2–3 ms decode floor. One second of 60 fps take costs 2.3 s of pull on a good clip and 6–11 s on public/test.mp4's structure. Stepping 1:1 through a sparse clip is worse than seeking randomly through it, because each step is one frame further from the single keyframe. And seeked is not a promise that the picture moved: on the all-intra arm createImageBitmap handed back the pre-seek frame about half the time.

Hence the demuxer route. ui/mp4demux.ts is the demuxer — not mp4box.js, for the argument mp4.ts already makes in the other direction — checked against ffprobe on real files by scripts/demuxcheck.mjs. ui/framePull.ts walks a VideoDecoder: ask for a clip time, get the frame a viewer would see there, at 0.85 ms a frame and flat in the keyframe spacing, because nothing seeks. VideoPump has a take mode that asks it instead of the element, startTake switches it on, and renderTake awaits it before each step.

The clock, the automation, and the take's starting state#

Five wall-clock reads move pixels: startGlide stamping the walk's origin, advanceGlide reading it, stabGate, strobeGate and autoLock. Engine.startTake({fps, seed}) points all of them at frame * 1000 / fps and endTake() puts them back. One private now() rather than an argument each, because five unrelated places in the frame is five chances to pass the wrong one. scripts/clockcheck.mjs proves the inversion: sixty frames stepped in no real time finish a one-second morph on the virtual clock and move it 0.03 on the wall clock, with the wall-clock arm as the control.

Live input is recorded as automation rather than stubbed. MIDI and mic/line audio cannot be re-rendered, so ui/automation.ts is a tape of control writes with frame stamps, ui/useAutomation.ts the recorder, and ● in the tray is ▶ with the tape rolling.

Take state is the last piece, and it turns "the same take from the same starting state" into "the same take". Frame N was a function of N and of where the engine happened to be at frame zero — the phosphor still on the glass, the PLL's lock age, the two servos — so two renders with the live loop running between them came out about 5% apart. startTake is one switch over all three of the things a take needs held: the clock counts frames, everything that rolls draws from the seed, and the signal path starts where a fresh engine's does. It leaves the board alone, since the look, the bay and the sources are what a take is.

The reset zeroes every buffer and texture, not the four that carry state. A WebGPU resource is zero-initialized, so zeroing one is the constructed state, with nothing to be wrong about — where a hand-kept list of which buffers survive a frame boundary is wrong exactly once, and the symptom is a take that does not reproduce with no way to see why. It costs one command submission and no frames.

Three things it turned up:

The encoder and the loop#

useCapture.ts was captureStream() + MediaRecorder, which timestamps by wall clock. It is now VideoEncoder with an explicit timestamp: i * 1e6 / fps per frame (ui/record.ts) and an MP4 muxer written for the one shape this needs (ui/mp4.ts) — CFR by construction, and indifferent to how long any frame took. ffprobe reports r_frame_rate == avg_frame_rate == 60/1, which is what constant-framerate is to everything downstream.

ui/render.ts and Engine.pauseLoop/resumeLoop own the loop: renderTake stops it, steps the engine, and hands each frame straight to the encoder, so a take renders as fast as the GPU will go and a slow frame costs the render wall time and the file nothing. RenderLoop.stop() drops a flag rather than cancelling, deliberately, so two already-scheduled chains each land one more frame after pauseLoop() returns — the render waits two animation frames so those land before it.

Three things measurement corrected, and three browser faults:

scripts/rendercheck.mjs is the guard over all of it: two renders of one take come back with the same SHA-256, with 25 ms of real time injected at every yield of the second and the live loop running in between. One unseeded Math.random in a per-frame modulator, or one buffer left out of the reset, and it fails. Byte-identity is within one browser build — the H.264 encoder is Firefox's, and nothing asserts across versions of it.

The Firefox constraint#

Measured on Nightly and re-measured on Nightly 151 by scripts/codeccheck.mjs: importExternalTexture is undefined (bug 1827116), and copyExternalImageToTexture refuses a WebCodecs VideoFrame outright, accepting only ImageBitmap, HTMLImageElement, HTMLCanvasElement and OffscreenCanvas. So the clean decoder path — pull a VideoFrame, hand it to the GPU — routes through createImageBitmap(frame) at 1.0 ms a frame against a decode of 0.53 ms. Affordable offline, and it means the WebCodecs route buys frame-exactness rather than zero-copy. (videopump.ts's direct mode is the capability-gated path for browsers where this does work.)

Two lessons from writing that harness, neither about WebCodecs. It must run over http://localhost, not about:blank — WebCodecs is secure-context only, so a probe that runs somewhere the app never does answers a question nobody asked. And flush() per chunk is not "wait for this frame": a completed flush sets the key-chunk requirement again, so flushing after every decode turns one sequential decode into sixty broken ones. Wait on the output callback, and on dequeue as well, or a decoder holding frames for reordering deadlocks a loop that only listens for output.

What a desktop shell buys#

Nothing for any of the four items above — every one is browser-API work that runs identically in the web app, so an Electron decision is not on the critical path. Where a shell earns its keep is the boundary either side:

Whatever shell it runs in, an offline render must adopt the live device, not create or destroy one (adr/0004).

The offline renderer, outside the browser#

CLI.md is the page for someone who wants to use it. This section is how it is built and what it cost.

scripts/render/ runs the signal path over a file with no browser in the room:

pnpm render in.mp4 out.mov --look='#p=mD.FbQB…'
pnpm render in.mp4 out.mov --preset=wornTape --codec=prores
pnpm render --pattern=bars out.mov --seconds=5 --set=noiseIre:9

It exists because the browser's encoder is what caps picture quality, and that turned out to be a harder ceiling than the bitrate ADR 0008 was arguing about. scripts/enccheck.mjs arm 4 measures the app's own input path — a canvas handed to an encoder, one-pixel alternating chroma, which is what dot crawl is:

codec through the app's pathRGB PSNR
H.264 High 4:2:09.03 dB
VP9 profile 1 4:4:427.66 dB
AV1 4:4:442.63 dB

Measured on Chrome/Linux. On Firefox, which this project develops against, every one of those arms scores ~10 dB: it declines AV1 4:4:4 and subsamples VP9 profile 1 on the way in whatever profile it was asked for. So the 4:4:4 route does not exist in the browser this app is built in, and no amount of muxer work creates one. Here the encoder is ffmpeg, and ProRes 4444 is the default.

Three things about how it is built.

It runs the app's own Engine, not a copy of the pass graph. A renderer whose output disagrees with the tab is worse than no renderer, so vite.render.config.ts bundles core/gpu/pipeline.ts itself — the one thing between Deno and that file being that the pass graph reaches its shaders through Vite's ?raw, which only a Vite build resolves. scripts/gpuprof/graph.ts is the second copy, and can live with mirroring because it times passes rather than producing files anybody keeps.

Deno's WebGPU needed two seams in core and no more. OffscreenCanvas there supports a real WebGPU context, so RenderTarget was already satisfied and initGpu needed nothing. What was missing was a way in and a way out: copyExternalImageToTexture does not exist in Deno, so every source path was closed (Sources.setImagePixels is the same upload one step lower down, on the COPY_DST the slot texture already carried); and a canvas texture comes back RENDER_ATTACHMENT only and cannot be copied out of, so there was no way to see what had been drawn (Engine.readFrame, off faceTex, which now carries COPY_SRC). scripts/render/runtime.ts supplies the three browser globals the engine expects — and a requestAnimationFrame that never fires is the correct stub, not a placeholder, because an offline render owns the clock.

Walking the file from the top is the shape the simulation wants. Frame N is a function of every frame before it, so there is no seeking, and the argument that rules out an NLE plugin above makes a command line the natural shape.

Measured on this machine: ~49 fps at 754x480, so a render runs slightly faster than real time and a minute of footage takes about seventy seconds.

Audio is part of the correctness here. audioBendUs, audioLoad and audioIre drive vertical hold, HV sag and the demodulator's reference, so a look built over a track and rendered in silence comes back with the artifacts that should be pumping sitting still — a render of a different board. The proof is a pair of renders of one look over one clip: with --audio=none twice the files are bit-identical frame for frame, and against --audio=auto they differ at 14.5 dB. The renderer is deterministic, so all of that difference is the sound.

--audio=auto feeds the input's own track in and writes the intercarrier buzz beside it when the look asks for one; buzz, source and none name the halves. Two seams carry it, both in signal/audiostate.ts:

What it cannot promise is the same numbers as a live session: a browser hands the analyser whatever arrived on its own audio clock, where this cuts the window at the frame the render is on. The offline answer is the more defensible one — two renders of a take agree, and two live takes never did — but it is not bit-identical to what the speakers did.

Every tap has to arrive, or the sound drifts against the picture. BuzzRead skips a frame when all three staging buffers are still in flight, which is right live (the audio ring glides over a gap) and wrong in a file, where a dropped frame shortens the track and slides everything after it earlier. Measured: a two-second render writes 63000 samples, exactly 31500 a second, so nothing is dropped — and the renderer says so out loud if the counts ever disagree rather than writing a track that drifts.

--look takes the link whole and reads it with the app's own parser. parseSessionParams is what the app boots from, so the CLI gets the same layering (landing look, then preset, then p=, then set=), the same checksum on the packed form, and the three things a regex was never going to reach: the modulation bay, the source mode and the seed.

The bay is the one that was a bug rather than a gap. Every demo in the README carries a ?mod=, and a routing that never reached the engine rendered the look at its resting frame — a still of a patch that was supposed to wander, which is the same shape of failure silence was. toEngineSlots is the same conversion useModSlots runs each render, so the master amount (--motion) and the tempo lock (--bpm) behave as they do in the panel; a render has no tap and no MIDI clock, so the tempo is whatever the flag says.

Source modes come across for everything that needs nothing fetched — bars, sweep, tv static, vhs static, synth, and B switched off. A link naming one of those renders with no input file at all, which is why one positional argument is an output and two are a file and an output.

The patterns themselves now live once. sources/pattern.ts writes each as pixels and wraps it in a canvas for the app, because the renderer has no canvas and had grown its own copy of the bars — a renderer with its own idea of what bars look like is a renderer whose output cannot be compared with the app's. pattern.spec.ts pins the seven bars, the PLUGE steps and the sweep's rising grating, since that copy is now shared by every screenshot, contact sheet and render made against it.

Two things it does not do yet. --look sets one board for the whole render, where the strip holds a sequence of them. And it fetches nothing, so a link naming a clip, a still or a pool pick renders over whatever file was passed on the command line.

The same binary hosts the app#

videoskillet serve puts the instrument on a local address out of the same executable, and it is here for one source. The Video URL… option shells out to yt-dlp, so a page served from a CDN can never offer it — until now it existed only behind a clone, a pnpm install and a vite plugin. A server on the machine is what that source needs, and the binary is already on the machine.

Three pieces, and the third is the one that had to be designed:

What is left#

Out of scope#

Trim handles used to be on this list, ruled out as a lot of UI for a storyboard. That reasoning was about a strip whose rows were looks; once a row can be a clip, the in/out pair becomes the row's own length. The rest of the section stands — this buys a trim, not a timeline.

What the build order missed#

The build order was a list of hard problems: a deterministic clock, a demuxer, a frame-exact puller, an encoder an editor will conform. All of them shipped, and three ordinary gaps survived the whole list. Each turned up by laying out eight clips and pressing play.

Each is a missing field rather than a contradiction, which is why reading the code found none of them: a design that specifies a type and a shipped thing that omits it read the same from the inside. The lesson is to test the gesture the document opens by asking for.