Local-First 3D Video Studio

Mockup Studio

A browser-only studio that places a screenshot or screen recording onto a procedurally generated device, animates it, and encodes an MP4 or WebM on your own machine: no backend, no upload, no account.

TypeScript
React 19
Three.js
WebCodecs
IndexedDB
Live: Local buildCode
A procedurally generated phone rendered at an angle in the studio, showing a real app screenshot on its screen

Employer signal

What This Project Shows

This is the project where I built a system around one invariant and then spent most of the work proving it held: 66 tracked files and nine commits inside one 6-hour-40-minute window, with 3,650 lines under tests/ against 6,645 under src/, counted off the checked-in files rather than from a run. The rendered scene is a pure function of (project, t), so the preview and the exported file are the same code path, and the hardest bug in it was a case where that guarantee quietly stopped being true one layer below my own code, in how WebGL allocates a texture.

Problem

What Needed To Be Solved

A mockup video makes a promise: what you arranged on screen is what lands in the file. The easy way to keep it is to screen-record the preview, which makes the output hostage to whatever else the machine is doing: a dropped frame is invisible until someone plays the clip. Doing the whole job in the browser keeps the media on the user's machine, but it removes the server you would otherwise fall back on when a browser cannot encode the codec you asked for.

Approach

How I Built The Solution

Every non-trivial decision (motion, keyframes, easing, lighting, framing, fit, gradients, device specs, bitrate planning) lives DOM-free in a core module and is unit-tested in plain Node, with Vitest configured environment: 'node'; the Three.js layer only consumes values already computed and checked. That is what makes the invariant enforced rather than habitual.

Outcome

What It Demonstrates

A hand-written Vite plugin emits a service worker precaching the real build output, so it is built to run offline, that path has no test and no recorded run. The rest is test-backed: MP4/H.264 and WebM/VP9 encode with no server involved, and an end-to-end test registers a request listener and asserts nothing but blob: and data: URLs leaves the local origin after load. The checked-in suite declares 301 unit cases and 58 end-to-end cases. For MP4 the test refuses to accept that the file plays, since a file can play and still carry the wrong codec or a frame count that disagrees with the requested fps: it walks the container's boxes by hand, reads the avcC box, and asserts avc1 at 1280×720 with exactly 24 samples for a one-second 24fps clip: no test binaries, no added dependency. What is not there is worth naming: no CI and no committed run record, Playwright configured for Chromium only, and no evidence about Safari, WebKit being the obvious risk for a client-side encoder. The lesson is the one the stretched-media bug taught: an assertion that would pass on broken output is not a test, and the only way to know is to break the fix on purpose and watch it fail.

Evidence From Source

A bug one layer below my own code

Media rendered stretched on every device but the first. The geometry was right and the canvas was resized to the right shape: the corruption was in WebGL storage. A CanvasTexture allocates its GPU storage once, from the image size at first upload, and thereafter only sub-images into that allocation, so the screen texture stayed pinned to the launch device's 831×1800 portrait shape and every device chosen afterwards was squeezed into it and stretched back out. A square in the source measured 3.83:1 on screen. Disposing the texture when the canvas changes size forces a fresh allocation, and the texture object stays valid so materials keep working.

A regression test proven able to fail

The obvious assertion cannot catch that bug: media drawn with `cover` fills the screen whether or not its aspect survived. The test instead draws a magenta square into the source image, scans the live WebGL canvas for its bounding box, and asserts the marker comes back square across seven device-transition routes, and the fix was reverted on purpose to confirm all seven fail without it. The suite had a second version of the same problem: the encode test preferred WebM whenever both containers were available, so the MP4 path was never exercised on a machine with an H.264 encoder, and the environment this was first built in had none, so the only branch that had ever run was the codec-unavailable one. Parameterising it over both containers is what made the MP4 assertions mean anything.