Put text behind the subject of a photo or a video, entirely in the browser.
No build step, no framework, no npm install, no server, no API key. Open the
folder, start a static server, and it runs. Segmentation happens on your own
machine, so no image or video ever leaves it.
Two pages, one static site:
| Route | File | What it is |
|---|---|---|
/ |
index.html |
Landing page — what the tool does and who it is for |
/editor |
editor/index.html |
The editor itself |
The editor lives in its own directory and is served by directory index, so its
own references are root-absolute (/css/styles.css, /js/main.js). Relative
paths would resolve against /editor/ and 404.
background → text layers → alpha-cut subject on top
That is the whole trick. The cutout is your original frame with everything except the subject knocked out of the alpha channel. Painting it back over the text is what puts the text behind.
- Quick start · Features · Browser support
- How it works · Choosing a model
- Video · Text fills · Text effects · Repair
- Background modes · Rotation · Fonts
- Architecture · Design decisions
- Testing · Deploying · Contributing · Licensing
git clone <your-fork-url> text-behind-subject
cd text-behind-subject
python3 serve.pyOpen http://localhost:8080 for the landing page, or http://localhost:8080/editor to go straight to the editor, drop in a photo and start typing.
serve.py resolves extensionless paths to their .html file, so /editor
works locally exactly as it does on the deployment. Without that the links
would work live and 404 on your machine.
python3 -m http.server works too, but serve.py earns its place twice over:
It binds to localhost. crypto.randomUUID() is gated behind a secure
context — https:// or http://localhost, but not http://192.168.x.x.
There is a fallback in js/uid.js so a LAN address no longer crashes the
editor, but localhost is the path of least surprise. Use --lan to bind all
interfaces for testing on a phone.
It sends the cross-origin isolation headers. Without
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp, onnxruntime cannot use
SharedArrayBuffer and silently drops to a single thread:
WebAssembly multi-threading is not supported in the current environment. Falling back to single-threading.
Segmentation still works, several times slower. Every remote host the page
touches (jsdelivr, staticimgly, fonts.googleapis, fonts.gstatic) sends
cross-origin-resource-policy: cross-origin, so strict require-corp is safe
in Safari as well as Chrome. Add a CDN that does not set CORP and its requests
will start failing — python3 serve.py --plain drops the headers so you can
confirm that is the cause.
Opening editor/index.html over file:// will not work; ES modules need an
HTTP origin, and its asset paths are root-absolute. The landing page renders over file://, but its links are absolute
(/editor), so navigation only works over HTTP.
Images — drop a JPG, PNG, or WebP. The subject is cut out automatically and text goes behind it. Exports at full native resolution, up to 8192px.
Video — drop an MP4, MOV, or WebM. Every frame is cut, text is composited behind the subject on all of them, and the result plays back in the canvas. Export writes an MP4 at the source's own resolution, up to 4K and beyond.
Six segmentation models, chosen automatically from what your browser can actually do, with a manual override and graceful fallback when one fails.
Depth mode for scenes with no single subject: occlude by distance instead, with a cutoff you control, or click individual objects to pick them.
Paint the cutout by hand when neither model asks the right question. No download, no inference — Add and Erase brushes, adjustable size, live overlay, and the area you paint is used exactly as painted. It is the path that always works.
Four background modes — keep the original, go transparent, fill with a colour, or drop in a different image entirely.
Full text control — eleven bundled typefaces plus your own font files, per-layer colour, opacity, blend modes, stroke, shadow, tracking, leading, and free rotation by drag, keyboard, or exact numeric entry.
Every 2D transform there is — rotation, slant, and width together cover the whole affine space, so any combination of turning, leaning, widening and moving is reachable.
Camera angle goes past affine: Turn and Pitch yaw and tilt the text in 3D so the far edge is genuinely smaller and the edges converge on a vanishing point, with Lens setting how much depth the camera has. Rendered as a subdivided grid of affine cells, because canvas cannot express a homography.
Repair — paint over anything unwanted and it is removed and rebuilt from the pixels around it, at native resolution. Runs locally on LaMa.
Batch repair — mark once, then run the same marks across a folder of images and download the results as a ZIP.
Four fill modes — flat colour, a two-stop gradient, the photo itself showing through the glyphs, or knockout, which cuts the letters clean through to transparency.
Twelve text effects on video — typewriter, scramble, wipes, and per-letter rise, fade and pop, with a delay, a duration, and a stagger you control.
Undo that behaves. Typing a word is one undo, not eight. Dragging a slider is one entry, not a hundred. Cmd+Z after clicking around undoes your last edit, not the click.
| Images | Video preview | MP4 export | |
|---|---|---|---|
| Chrome / Edge 94+ | yes | yes | yes |
| Safari 17+ | yes | yes | yes |
| Firefox | yes | no | no |
Video needs requestVideoFrameCallback and WebCodecs, which Firefox does not
yet ship in a usable form. The image editor works everywhere. The app detects
all of this and says so in the UI rather than failing at the moment you press
export.
Best results on a machine with WebGPU and shader-f16 — Apple Silicon and most
current discrete GPUs. See below for why that matters.
Three drawing operations in a fixed order, every frame:
background → text layers → alpha-cut subject on top
Everything else in the project serves that one line. renderer.js owns it, and
because layer geometry is stored resolution-independently, the exact same draw
calls produce the 900px preview and the 4K export.
The picker lives in the Cutout panel. Sizes are per-device because
transformers.js gates fp16 behind WebGPU's shader-f16 and falls back to fp32
on WASM — quoting one number would be wrong about half the time.
| Model | Device | Size | Licence | Notes |
|---|---|---|---|---|
| BEN2 | WebGPU only | 219 MB | MIT | Best in practice. The default wherever WebGPU reports shader-f16 |
| BiRefNet lite 512 | WASM only | 192 MB | MIT | The run-anywhere baseline. Less reliable on edges and hair |
| Depth Anything v2 | WASM q8 / WebGPU | 28 / 50 MB | Apache-2.0 | For scenes with no single subject |
| ISNet | WASM | 22 MB | Non-commercial | Smallest download |
| — | — | MIT | Dead end, see below | |
| — | — | MIT | Dead end, see below |
Everything is downloaded once and cached by the browser.
resolveDefault(runtime) returns BEN2 when the adapter reports WebGPU with
shader-f16, and BiRefNet lite 512 otherwise. So an M-series Mac starts on the
good model and a machine without WebGPU starts on one that works.
This replaced a hardcoded constant with a bad failure mode. BEN2 ships fp16 only, WebGPU used to default off, and the two together meant BEN2 resolved as unusable on every machine including the ones that run it best. The good model was one unlabelled checkbox away and nothing pointed at the checkbox.
Because BEN2 has no WASM build, the retry after a WebGPU failure is per-model
rather than only per-device: planFallback() tries the same model on WASM
first, then whatever the model names in its fallback field. Without that
second step the default would turn a shader failure into a dead end instead of
a downgrade.
The first four models all do salient object detection: they answer "what is THE subject". That works on a portrait or a product shot. On a landscape with several scattered buildings there is no single salient object, so they half-pick a couple and the effect falls apart. A bigger model does not fix this — it is the wrong question.
Depth mode asks a different one: what is NEAR. It estimates a depth map, and everything closer than the cutoff occludes the text. Depth cutoff chooses how much of the scene comes forward and Edge feather softens the transition. Both re-cut from a cached depth map, so they respond instantly — inference runs once per image, not once per drag.
The Cutout panel's Paint button skips every model. Paint what should sit in front of the text and that area is used exactly as painted — Add and Erase brushes, adjustable size, with a live overlay so you can see the selection as you make it.
This exists because both model paths ask a question, and on some images neither question has a good answer. Salient detection asks "what is THE subject"; depth asks "what is NEAR". On a landscape that is one continuous surface receding into fog there is no salient object, and a region grown from any seed leaks across the whole gradient — adjacent pixels differ by about one depth level, so nothing stops the fill except total wander from the seed. That is not a tuning problem. Painting asks a third question: this, exactly this, goes in front.
It also needs no download, which makes it the path that always works.
Strokes are replayed in order, so an erase stroke clears what earlier ones set and painting back over it restores them. Erase has to be a brush rather than just undo, because painting a selection by hand means overshooting, and undo would throw away good work along with the mistake.
The mask is rasterised at reduced resolution on purpose: alphaFromMask draws
it up to full size with smoothing on, and that bilinear ramp across the boundary
IS the soft edge. Edge softness sets how hard it lands.
A single depth cutoff cannot select two objects at different depths. With one building at 0.70 and another at 0.45, a cutoff of 0.60 takes only the first and 0.40 takes the second plus every blade of grass nearer than 0.40 — which buries the text. No value works. That is a structural limit of thresholding, not a tuning problem.
Tick Pick regions and click objects instead — though on a scene of smooth gradients, painting by hand is the more predictable tool. Each click flood-fills from that pixel across similar depth, so each object is found on its own terms. Click a picked object again to drop it.
Two bounds govern the fill, and both are load-bearing:
- Edge sensitivity (
step) — how far depth may change between adjacent pixels. Small, so the fill stops at a discontinuity like a roof edge against the hillside behind it. - Surface tolerance (
drift) — how far depth may wander from the seed overall. Larger, so a surface receding from the camera is absorbed whole.
With only step, a fill leaks across the whole image through gradual ground.
With only drift, it stops halfway along any receding roof.
It used to be off, because ORT's WebGPU backend crashes on BiRefNet's decoder
Concat. That protection moved onto the model itself: every BiRefNet variant
carries webgpuBroken and resolves to WASM whatever the global flag says. The
off-switch was guarding a case already handled per-model, and the only things it
cost were the two models that benefit — BEN2, which cannot run without WebGPU at
all, and Depth, which is roughly twice the speed with it.
ONNX Runtime generates WGSL for a Concat node as one else if branch per
input. Graphs with 125+ Concat inputs blow WGSL's hard 127-statement chaining
limit:
Error while parsing WGSL: statement nesting depth / chaining length exceeds limit of 127
The shader fails to compile, every pipeline built afterwards is invalid, and the console fills with hundreds of cascading validation errors. It happens inside ORT, so there is nothing to patch here. Four independent failures, three of them hard limits rather than fixable bugs:
| Error | Cause |
|---|---|
storage buffers (17) exceeds max per-stage limit (8) |
Hard WebGPU device limit |
storage buffers (65) exceeds max (8) |
Same, worse |
WGSL nesting depth exceeds 127 |
Concat with 125 inputs |
no matching constructor for i32(vec4<u32>) |
Type bug in ORT's generated shader |
All BiRefNet variants therefore carry webgpuBroken: true. The 1024px and full
builds carry wasmHeavy as well — WebGPU refuses them and the fp32 weights
exhaust the WASM heap — so resolveModel reports them as unusable rather than
letting you spend 224 MB finding out.
One wrinkle worth knowing: WebGPU reports shader failures through
uncapturederror, which is asynchronous and does not reject the inference
promise. You get a garbage tensor rather than an exception. watchDevice()
listens on the device so those become real errors the fallback can catch.
Drop a clip and it runs four phases, each visible in the progress ring:
decode -> cut every frame -> play back from cache -> encode
Decode uses a <video> element driven by requestVideoFrameCallback. It fires
once per presented frame and reports the real mediaTime, which is enough to
capture a clip and needs no demuxer.
cutout() loads the model on every call. Invisible for one photo, ruinous at 30
frames a second — from_pretrained would cost more than the inference itself.
Worse, independent per-frame masks flicker: nothing in a still-image model
connects frame 47 to frame 46, so edges crawl and hair boils even on a
motionless subject.
openSession() in frames.js fixes both. The model is opened once, and an
exponential moving average carries the alpha channel forward between frames.
Edge smoothing is that weight — 0 is per-frame independent and visibly
boils; too high smears the edge behind a moving subject, which reads as motion
blur that somehow only affects the cutout.
The Clip panel reports what the smoothing actually bought, measured rather than
asserted: edge movement 0.089 raw → 0.031 smoothed. Setting that slider by eye
against a looping preview is guesswork at 30fps.
Frames are cut sequentially on purpose. They share one smoothing buffer, so frame N depends on N−1 — running them concurrently would thrash the GPU and blend frames in completion order, which is the flicker this exists to remove.
PREVIEW_MAX_EDGE (720) and MAX_FRAMES (90) in video.js. Every frame is
held as an ImageBitmap, twice over — source and cutout — so 90 frames of 1080p
would be roughly 1.5 GB before anything is composited. At 720p it is under
400 MB, which a tab survives.
Raising them is the obvious first thing to want and the first thing to break the tab. Streaming frames to disk, or holding only the masks and re-decoding on seek, is the real fix.
It did once, and that made exports visibly soft. The reasoning was wrong: the 720px cap exists because the preview holds every frame at once, but export handles one frame at a time and discards it after encoding.
There are three ways back to the original pixels, and openBestReader()
degrades quietly through them:
| Path | How | When |
|---|---|---|
| sequential | mp4box demux into VideoDecoder |
MP4 and MOV. One forward pass |
| seek | <video>.currentTime |
WebM, or anything mp4box rejects |
| preview | the cached 720px frames | when neither can open the file |
Sequential exists because seeking does not scale: every currentTime assignment
decodes forward from the previous keyframe, so a 90-frame export re-decodes the
same GOP over and over — tolerable at 720p, minutes at 4K.
Each step down is a speed or quality loss, never a failure. An export that refuses to run is worse than one that runs soft, and the toast reports which path ran plus ms/frame so a slow or soft result is explainable.
Masks stay at 720px and are upscaled, which sounds lossy but is not the part that matters:
ctx.drawImage(fullFrame, 0, 0, W, H); // native-resolution colour
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(lowResSubject, 0, 0, W, H); // multiplies alpha onlydestination-in keeps the destination's colour and multiplies its alpha by the
source's, so the result is native pixels wearing the upscaled mask. Drawing the
720px cutout up directly would work too — and would carry its blurry 720px
colour with it. That was the actual cause of the soft export.
It costs little because BEN2 resizes its input to about 1024px internally anyway. Feeding it a 720px frame rather than a 4K one barely moves where the boundary lands, and the bilinear ramp across it becomes the soft edge.
A codec string carries a level, and the level is a hard ceiling rather than a
hint. This project started with three fixed strings topping out at
avc1.640028 — High profile, Level 4.0 — which caps at 8192 macroblocks. That
is 1080p. Anything larger made isConfigSupported reject every candidate, and
export failed outright rather than degrading.
levelFor() derives the lowest level satisfying both ceilings:
target level codec bitrate
720p30 3.1 avc1.64001f 5.5 Mbps
1080p30 4.0 avc1.640028 12.4 Mbps
1440p30 5.0 avc1.640032 22.1 Mbps
4K30 5.1 avc1.640033 49.8 Mbps
4K60 5.2 avc1.640034 99.5 Mbps
Both ceilings, because frame size alone picks the wrong answer. DCI 4K (4096×2160) is 34560 macroblocks, which fits Level 5.1's 36864 — but at 30fps that is 1,036,800 macroblocks per second against 5.1's limit of 983,040, so it needs 5.2. Checking only the frame size produces a config the encoder rejects.
Three more details that are easy to get wrong:
- Both axes must be even. H.264 stores chroma at half resolution and the
encoder rejects an odd dimension.
evenSize()rounds down rather than up, so nothing is letterboxed in. - The profile is probed, not assumed. Hardware support varies enough that a hardcoded string fails on somebody's machine.
- The encoder queue needs backpressure. Without the
encodeQueueSizedrain, frames pile up faster than they encode and a long clip exhausts memory before a single chunk reaches the muxer.
Bitrate is 0.2 bits per pixel per second. Text over photography is close to the worst case for a block transform: hard high-contrast edges sit exactly where DCT ringing shows, and mosquito noise around glyphs is the first thing anyone notices. Photographic video tolerates 0.12; lettering does not.
MP4 has no alpha channel. Transparent background mode composites onto black, so exporting from it warns first.
Text can animate — see text effects — but there is no timeline and there are no keyframes, so each layer gets one effect that plays once and then holds. Depth mode and click-to-pick regions are still-image only: the flood fill picks an object at one instant and a moving subject leaves it behind. Both are disabled while a clip is loaded.
A glyph is a shape, and what gets poured into it is a separate question from what colour the layer is.
| Mode | What fills the letters |
|---|---|
| Solid | The layer colour. Default |
| Gradient | Two stops and an angle, built in the layer's own frame so the ramp turns with the text |
| Image | The photo or video frame, or the replacement backdrop |
| Knockout | Nothing — the letters erase what is under them |
Image fill is pinned to the frame, not to the glyphs. The photo is painted through the letters at the position it would have occupied as a background, so the text reads as a window cut in the scene rather than as a texture glued to the lettering. Rotate the layer and the window turns while the photo behind it stays put.
That distinction is the whole implementation. fillText with a
CanvasPattern paints in the layer's rotated frame, so a pattern left alone
rotates with the text — the wrong answer, and wrong in a way that looks
deliberate until you compare it against the background. CanvasPattern.setTransform
takes a matrix that maps pattern space into the current user space, so the fix
is to hand it the inverse of the layer transform composed with the bitmap's
fit-to-canvas scale:
const fit = { a: W / src.width, b: 0, c: 0, d: H / src.height, e: 0, f: 0 };
pattern.setTransform(matMul(matInvert(local), fit));The matrices are plain {a,b,c,d,e,f} objects rather than DOMMatrix.
setTransform accepts a DOMMatrix2DInit dict, so nothing is lost, and the
arithmetic runs under Node — which is what lets the inverse be tested without a
browser. A wrong inverse still draws the photo through the letters, just from
the wrong place, so it is exactly the kind of bug that needs a test rather than
an eyeball.
Knockout is a composite operation, not a blend mode. Every entry in
BLEND_MODES leaves the destination alpha alone, so none of them can express
"remove this". It is destination-out, and it overrides the layer's blend mode
rather than combining with it.
Because the paint order is background → text → subject, a knockout layer cuts through the background and the subject is still drawn over the top. The exported PNG keeps that alpha. MP4 does not — there is no alpha channel in H.264, so exporting a knockout to video warns first and fills the holes with black.
Two combinations that do nothing, and why they are not bugs: knockout in transparent background mode has nothing left to cut through, and image fill before a file loads falls back to the flat colour rather than painting nothing.
Twelve effects, all driven by clip time:
| Kind | Effects |
|---|---|
| Whole layer | Fade in, rise up, drop in, slide in, pop in |
| Per letter | Letters fade, letters rise, letters pop, scramble |
| Reveal | Typewriter, wipe across, wipe up |
Each layer has its own delay, duration and — for the per-letter ones — stagger, which is the fraction of the window spent handing off between glyphs. At 0 they all move together; at 0.9 the last letter barely starts before the first has finished.
drawScene is handed the clip position in seconds and everything follows from
it. No animation loop, no state carried between frames, nothing read from
performance.now().
That is not fastidiousness. The encoder renders frames as fast as it can drain its queue, so its frames have no relationship to real time at all — anything driven by a wall clock would export at a completely different speed than it previewed at, and a slower machine would export a different video than a fast one. Both paths derive time the same way, from the frame index and the nominal fps:
time = frameIndex / fps // preview
time = i / fps // encoder, matching its own timestampsThe same reasoning governs the scramble effect, which is the only one that
looks random. Its characters come from a hash of (layer id, glyph index, tick), so re-rendering a frame gives the same letters every time. With
Math.random() the text would reshuffle on every repaint — dragging an
unrelated slider would change the letters — and the exported frame would never
match the previewed one. It also has to survive being called twice per frame,
since stroke and fill walk the glyphs separately.
A still image passes time: null, and every effect resolves to its finished
state. Frame zero would be the obvious choice and it is the wrong one: adding
a fade to a photo would make the text vanish.
Drawing a growing substring with textAlign: 'center' centres the substring,
so a centred typewriter slides sideways as it types. Every frame is correctly
centred and the effect is unusable.
Lines are now anchored by hand — each one's x is computed from the alignment and
that line's full measured width, and everything is drawn with
textAlign: 'left' from there. For a static layer the result is identical by
construction. For a typewriter the text fills in from a fixed left edge, and for
the per-letter effects it makes a glyph's own x offset mean something at all.
Per-character offsets come from measuring prefixes rather than summing individual characters, so kerning and letter-spacing land where the whole-line draw would have put them. Those are cached by font string and line, because the per-glyph effects re-measure every line on every frame and a 4K export is 90 of those.
Text is one effect per layer, played once, then held. There are no keyframes, no in and out points, and no way to move text along a path over the clip. Text also cannot animate on a still image, by design.
The 90-frame cap is the real ceiling here — about three seconds at 30fps, for the preview and the export alike, since the encoder walks the same frame array. A two-second delay on a 1.2-second effect does not leave much clip. The Motion panel reports the effect's window against the clip's measured length and says so when it overruns, rather than letting the export be the first time you find out.
Paint over something and it is removed and rebuilt from what surrounds it — a stray object, a blemish, a mark left by whatever produced the image. Runs on LaMa through onnxruntime-web, locally, like everything else here. 198 MB, downloaded once and cached.
Still images only. There is no timeline, so a moving region has nothing to track.
This is the whole feature, and it is where a repair is either invisible or obviously a patch.
LaMa's ONNX export takes a fixed 512×512 input — the FFT ops in its Fourier convolutions are why dynamic shapes do not export cleanly. Every browser implementation of it treats that as a constraint on the photo: resize the whole thing to 512, inpaint, scale back up.
On a 2662×1568 frame that is a 5× downscale. The repaired region comes back soft against everything around it, which reads worse than the mark did.
But the model does not need the whole photo. It needs context around the hole. A 70px mark gets a 512px window of native pixels centred on it, inferred, and composited straight back:
mark 70px -> 512px native window -> scale 1, nothing resampled
mark 800px -> 1280px window -> that crop alone is downscaled
mark huge -> overlapping tiles
Only the crop is ever scaled, and only when the mark is itself bigger than a
tile. cropWindows() in mask.js decides this, and it is pure arithmetic so
the decision is tested rather than eyeballed — a wrong window does not throw, it
just quietly produces a worse result.
The same reasoning as export not inheriting the preview's cap, applied to a different constraint.
Three things separate a clean removal from a haunted one, and none of them are the model:
Edge margin. A mark is anti-aliased, and usually carries a soft shadow or glow past its visible edge. Mask exactly what you can see and you leave a rim of tinted pixels outside the hole — which the model reads as genuine surrounding content and faithfully propagates inward. You get a correctly-inpainted ghost of the thing you were removing. The mask is grown before inference, and the overlay shows that margin so it does not read as the brush being imprecise.
Tone match. LaMa reconstructs the whole tile, so the pixels it returns outside the hole should equal the ones that went in, and very nearly do. The difference is a measurement of the model's drift, sampled on a ring just around the hole and subtracted from the invented pixels. Without it an anatomically perfect repair still reads as a patch, because it is faintly the wrong colour.
Feathered seam. Composited through a smoothstep ramp that lives strictly inside the marked region, so the repair never paints over pixels that were never marked.
Four independent sets of authoring marks share one history: text layers, repair marks, hand-painted cutout strokes, and depth region picks. Cmd+Z steps back through them one mark at a time, and they do not disturb each other.
What makes that affordable is the same rule throughout — store what was authored, recompute what was derived. A brush stroke is a handful of fractional coordinates; a region pick is two numbers. The full-resolution masks they produce are rebuilt on demand and never enter a snapshot, alongside the bitmaps.
That rule is also the bug it is easiest to reintroduce. Undo restores the marks; only rebuilding restores what they produced. A history entry that pops the strokes without re-deriving the cutout leaves the overlay correct and the occlusion stale, which reads as undo being broken rather than as a stale derivation.
Strokes are stored the way every other geometry here is — fractional coordinates, radius as a fraction of height — so they cost nothing to snapshot and go on the undo stack as document state. Cmd+Z takes back a brush drag.
The full-resolution Uint8Array they rasterise to does not, under the same rule
that keeps bitmaps out of history. It is recomputed on demand.
Mark a watermark on one image, add a folder of others, and the same marks run across all of them. Results come back as a ZIP.
The marks transfer for free, because strokes are already stored as fractions of width and height rather than pixels. A badge at 95% across and 92% down is in the bottom-right corner of a 4000px frame and a 1200px one alike, and each image rasterises the same strokes at its own resolution. Nothing is resized to match.
Aspect ratio is what breaks that, not size. Fractional coordinates track the corners, so a corner mark survives any shape — but a mark out in open frame lands somewhere else entirely on 4:5 than it did on 16:9. The result is a batch of images with a hole in the wrong place and the watermark still present, and nothing about it looks like an error. Every queued file is measured against the reference frame and anything beyond 2% is flagged in the list before the run starts. A one-pixel rounding difference is not flagged; a portrait crop is.
Images are repaired one at a time. Each holds a full-resolution ImageData plus
its distance field while it is being worked on, and they would queue on the GPU
anyway — so concurrency would multiply peak memory for no throughput. One
failure does not stop the run, since a batch of forty that dies on the ninth
because of one corrupt file is worse than one that finishes and says which file
failed.
Two smaller things that decide whether a batch is usable:
Output matches the input format. A 400 KB JPEG should not come back as a 12 MB PNG purely because PNG is what the canvas defaults to. Across forty photos that is the difference between a download that works and one that does not. PNG input stays lossless.
Duplicate filenames are numbered, not dropped. A ZIP keyed by filename
silently discards collisions, and picking images out of two folders is the
ordinary way to end up with two IMG_0001.jpg. Losing files from a batch
without saying so is the worst available outcome.
The ZIP is stored rather than deflated — every entry is already a compressed JPEG or PNG, so deflate would spend real time to save nothing.
A repair replaces the photo, so the cutout is dropped rather than kept — the subject was segmented from pixels that no longer exist, and compositing that stale silhouette over a changed background would be wrong in a way that is hard to see. The segmentation source is re-pointed at the repaired pixels too, or re-running the cutout would go back to the original file and bring the mark back with it.
The segmentation pass already separates subject from background, so removing the background is a question of what gets painted underneath rather than any extra inference.
| Mode | What it paints |
|---|---|
| Original | The photo, untouched. Default |
| Transparent | Nothing — the cleared canvas is the background, and the exported PNG keeps its alpha |
| Colour | A flat fill |
| Image | An uploaded backdrop, cover-fitted and centre-cropped |
Text layers keep working in all four, so text can sit behind the subject over a new backdrop. The preview shows a checkerboard in transparent mode, since there is otherwise no way to tell transparent from white.
Every mode except Original needs a cutout first. The subject bitmap is what
survives when the photo goes, so with no cutout there is nothing to keep.
effectiveBgMode() falls back to the photo until the subject lands, which also
covers the gap between loading a new image and inference finishing — otherwise
replacing an image while in transparent mode flashes a blank frame.
Export naming follows what the file actually is: -cutout.png when the
background is gone and there are no text layers, -text-behind.png otherwise.
Three ways to set an angle, because none covers every case alone:
Drag the handle. Select a layer and a knob appears on a stem above the box.
Drag it and the text follows the pointer freely. Hold ⇧ to snap to 15°, which
is how you land on 45 or 90 without watching the readout.
Type an exact angle. The readout beside the Rotation slider is an input, not
a label. Out-of-range values are accepted and folded on commit, so typing 270
gives -90 — the same pose, expressed the way the slider can show it.
Tap [ and ] for 1° steps, or ⇧[ / ⇧] for 15°.
Rotation turns the whole block; Slant shears it, which is the oblique a display face gets when it has no true italic. Positive leans right.
It is composed onto the right of the layer matrix, so it applies to the glyph coordinates before anything else — the text leans in its own frame and the leaning block then rotates. Composed the other way, rotating a slanted layer would change which direction it leans.
Two consequences worth knowing. The shear pivots on the middle of the block
rather than the baseline, so slanting does not also shift the text off its
anchor. And a slanted block leans out past its measured width, so the selection
box and the hit target are widened by slantOverhang() — widened rather than
sheared, because shearing the box would drag the rotate handle off at an angle,
and the handle is a control rather than part of the glyphs it describes.
Clamped to ±60°. tan runs away at 90°, which would give a matrix nothing can
invert — and the image fill places its pattern through exactly that inverse.
Width scales horizontally without touching the height, which is the last degree of freedom a 2D affine has. Together with position, rotation, size and slant it covers every affine transform there is, so any combination of turning, leaning, widening and moving is reachable.
Scale is applied before rotation, so it stretches the layer's own axes rather than the canvas's: a widened word stays widened along its own baseline however it is then turned.
Rotation turns text in the plane of the screen and slant shears it, but both are affine: parallel lines stay parallel and the far end of a word is exactly as big as the near end. A camera looking at a sign from an angle does something neither can — it makes the far end genuinely smaller, and the top and bottom edges converge toward a vanishing point.
Turn yaws the text about its vertical axis, Pitch about its horizontal one, and Lens sets how much depth the camera has: wide is violent convergence, long flattens back toward the affine case.
Affine has six degrees of freedom and canvas setTransform takes exactly those
six numbers. A homography has eight, and the two extra terms are the perspective
divide — there is no way to smuggle them into a 2x3 matrix.
What canvas can do is draw a great many small affine pieces. A homography is
locally almost affine, so the text is rendered flat into a scratch canvas and
warped through a grid, each cell mapped by its own affine transform. The error
per cell falls with the square of the cell size, so subdividing converges on the
real projection rather than approximating it — t19.js asserts exactly that,
measuring the worst cell at two subdivision levels.
Three things this cost:
A scratch canvas, reused. Allocating one per layer per frame is invisible on a still and ruinous on a 90-frame 4K export.
Half a pixel of overlap between cells. Adjacent triangles share an edge, and antialiasing on both sides of it leaves a visible hairline seam through the glyph. Each clip path is nudged outward from its centroid to hide it.
A perspective-aware hit test. A tilted layer is no longer a rectangle on screen: the near edge draws larger than its box and the far edge smaller. Testing the untilted box would miss the near edge and falsely hit empty canvas past the far one. The pointer is projected back through the inverse homography instead — which is why the inverse is tested as heavily as the forward map. Text that draws perfectly and cannot be clicked reads as the layer being locked.
Untilted layers keep the old path exactly: no scratch, no grid, no per-cell draw. Perspective is one more stage, not a replacement, so rotation, slant, width and the motion effects all still compose on top of it.
Angles are stored folded into [-180, 180) by normalizeAngle(). Rotation is
modular — 270° and −90° are the same pose — and one canonical value is what
stops the slider and the number field disagreeing about which to display.
Two details in the handle worth knowing:
The grab offset is captured on pointerdown. Without it, grabbing the knob
slightly off-centre snaps the text to the cursor before the drag starts, which
reads as a glitch. Storing layer.rotation - pointerAngle and adding it back
each frame means the text turns from where it was.
The handle is hit-tested before the text. It sits outside the bounding box, so a plain hit test would treat the knob as empty canvas and deselect the layer on mousedown.
Eleven faces ship from Google Fonts, Manrope included at its full 200–800 range.
Add font file loads faces you hold a licence for but nobody can hotlink — Neue Montreal is the case in point: a commercial Pangram Pangram release, absent from Google Fonts and every free CDN, so it cannot be bundled or linked here. Supply the file and it behaves like any built-in face, exports included. A PNG contains rasterised glyphs, not an embedded font.
Accepts .woff2, .woff, .otf, .ttf, several at once. Files are kept in
IndexedDB so they survive a reload, and the whole path degrades to session-only
if IndexedDB is unavailable rather than failing. Select a custom face and
Remove appears next to the picker; any layer still using it moves to a
bundled family rather than silently rendering in the browser fallback.
One file becomes one entry, named from the filename. Upload
PPNeueMontreal-Book.otf and PPNeueMontreal-Bold.otf and you get two dropdown
entries. Inferring families and weights from filenames guesses wrong often
enough to be worse than being predictable.
⌘Z / ⇧⌘Z |
Undo / redo |
⌘S |
Export PNG |
⌫ |
Delete selected layer |
↑↓←→ |
Nudge (hold ⇧ for 10×) |
[ / ] |
Rotate 1° (hold ⇧ for 15°) |
| drag handle | Rotate freely (hold ⇧ to snap to 15°) |
| drag / paste | Load a file |
Vanilla ES modules, no bundler. ~5,900 lines of JavaScript across nineteen files.
The landing page shares nothing with the editor — separate HTML, separate stylesheet, separate script — so a change to one cannot break the other.
| File | Job |
|---|---|
index.html |
Landing page |
css/landing.css |
Landing styles, including the liquid-glass filter setup |
js/landing.js |
Landing behaviour: menu, scroll lock, reveal, glass alignment |
editor/index.html |
The editor |
css/styles.css |
Editor UI |
js/range-fill.js |
Filled slider tracks — plain script, nothing imports it |
vercel.json |
Clean URLs, isolation headers, asset caching |
.vercelignore |
Keeps dev files out of the deployment |
| File | Job |
|---|---|
js/store.js |
Undoable state, history stack, commit coalescing |
js/media.js |
Bitmaps, background and clip state — outside the undo system |
js/renderer.js |
Canvas compositing, background modes, text, fills, hit testing |
js/effects.js |
Time-based text effects: easing, staggering, deterministic scramble |
js/perspective.js |
Projection, homography and its inverse, grid subdivision |
js/mask.js |
Repair geometry: strokes, distance fields, dilation, crop windows, seams |
js/inpaint.js |
LaMa via onnxruntime-web: weights cache, session, tile inference |
js/batch.js |
Batch queue, aspect-drift detection, output naming, ZIP packaging |
js/segment.js |
Model registry, capability resolution, still-image cutouts |
js/frames.js |
Frame-sequence session: model held open, temporal smoothing |
js/video.js |
Video decode, seek reader, frame-rate measurement |
js/demux.js |
mp4box demuxing into a VideoDecoder for sequential reads |
js/encode.js |
MP4 export: H.264 level selection, muxing |
js/export.js |
Full-resolution PNG render |
js/controls.js |
Rail bindings and the state→DOM sync pass |
js/interact.js |
Click to select, drag to move, rotate handle, keyboard |
js/regions.js |
Depth region growing for click-to-select |
js/fonts.js |
Font catalogue, load-before-draw guard, custom font files |
js/uid.js |
Layer ids, with a non-secure-context fallback |
js/main.js |
Wiring, canvas sizing, render loop, clip playback |
serve.py |
Dev server: localhost binding, isolation headers, clean URLs |
tools/cutout.mjs |
Node CLI — native ORT, runs models the browser cannot |
Third-party code is loaded from jsdelivr at runtime, never vendored or
installed: transformers.js and @imgly/background-removal for segmentation,
mp4-muxer and mp4box for video export, onnxruntime-web for repair, and
fflate for batch ZIPs. That is what keeps "no build step"
true.
Bitmaps are not in the undo stack. store.js snapshots layers only.
Snapshotting a 4000×3000 ImageBitmap on every keystroke exhausts memory within
a few dozen edits. Media lives in media.js and is never cloned.
Selection is not in the undo stack either. activeId is UI state. If it were
snapshotted, the first Cmd+Z after clicking around would undo the click rather
than the edit — the single most common complaint about hand-rolled undo.
input previews, change commits. A range input fires input on every
pixel of a drag; committing there would put ~100 entries on the stack per drag.
Typing is handled separately with a 700ms coalescing key, so a word is one undo
rather than eight.
Geometry is resolution-independent. Position is a fraction of the image
(0–1); size, tracking, stroke, and shadow are percentages of font size or image
height. The preview canvas and the 4K export run identical draw calls with a
different W/H, so what you see is what you get, with no scaling maths and no
drift.
Clip frames are owned by the clip. seekFrame() assigns media.source and
media.subject by reference rather than going through the setters, because those
close whatever they replace — which here would destroy a frame the clip still
needs on its next loop. clearClip() is the only place clip bitmaps are closed.
sh test/run.sh698 logic tests across nineteen files, running under plain Node with no test framework and no dependencies. Test files symlink the real modules, so there is nothing to keep in sync.
What is covered: history semantics (coalescing, redo invalidation, selection
reconciliation on undo), renderer geometry (multi-line measurement, rotated and
aligned hit testing, linear scaling between preview and export), all three
uid() fallback paths, model resolution per device, the depth alpha ramp,
background modes and paint order, rotation and angle folding, video frame-rate
measurement under dropped frames, the flicker metric, H.264 level selection,
the affine inverse behind image fills, every effect's plan across its
delay, duration and stagger, and the whole repair-mask pipeline — exact
euclidean distance transforms, circular dilation, feather ramps, tone matching,
and native-resolution crop-window selection, plus batch aspect-drift detection
and filename collision handling.
Several tests exist specifically to pin bugs that were shipped and then found:
- A hard
0 → 255alpha flip must score as maximum flicker, not perfect stability. - 4K must never be offered a Level 4.0 codec string that cannot encode it.
- Colour must be laid down before alpha is multiplied in — reversing those two
drawImagecalls yields an empty frame. - Timestamp/frame misalignment must be refused, or every mask offsets by one frame.
- A partially typed line must keep the full line's anchor, or a centred typewriter slides sideways as it types.
- The typewriter's reveal count must round, not floor — flooring leaves the last character permanently missing, because progress never exceeds 1.
- Knockout text must warn before an MP4 export even when the background mode is opaque, since the glyphs punch through on their own.
- A small mark on a large photo must resolve to
scale === 1. Anything else means the repair is being done from a downscale. - Dilation must be circular, not square, or the corners of a mask grow further than its edges.
- The feather ramp must never extend outside the marked region, or a repair paints over pixels the user did not mark.
- Two files named
IMG_0001.jpgmust both survive into the ZIP. Keyed by name, one would silently vanish. - A one-pixel dimension difference must not be reported as an aspect mismatch, or the warning becomes noise and stops being read.
- Undo must rebuild the painted cutout, not just restore the strokes. The mask is derived state, and a stale one reads as undo being broken.
- A garbage
stretchmust fall back to 1 rather than collapsing the text to nothing or flipping it inside out. - Under a camera angle the far edge must be strictly SHORTER than the near one, and the top and bottom edges must stop being parallel. That is the whole difference between perspective and a shear.
- Subdividing the warp grid further must strictly reduce the worst-cell error, or the approach is approximating rather than converging.
- The inverse homography must round-trip to within a millionth of a pixel, or a tilted layer draws correctly and cannot be selected.
- Undoing a paint stroke must leave the region picks alone, and vice versa — four mark sets share one history and must not disturb each other.
- Marks must be deep-cloned into history, or editing one reaches back and rewrites the past.
Not covered: canvas painting and real inference need a browser. The mp4box
integration in demux.js is written against the spec rather than a live run —
if it fails it logs a warning and falls back to seeking, so export still works.
Check the console for "Sequential decode unavailable" to know which path you
got.
Static hosting, no build command, no output directory. vercel.json carries the
whole configuration.
The same pair serve.py sends locally has to be sent in production:
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }Without them onnxruntime cannot reach SharedArrayBuffer and drops to a single thread. Nothing breaks — segmentation just runs several times slower, and the only clue is a console warning. A host that cannot set response headers cannot serve this properly. GitHub Pages is the notable one; Vercel, Netlify and Cloudflare Pages all can.
require-corp also means every cross-origin subresource must send CORP. The
CDNs this page uses (jsdelivr, huggingface, fonts.googleapis, fonts.gstatic)
all do. Add one that does not and its requests start failing — run
python3 serve.py --plain to confirm that is the cause.
Vercel validates the file against a strict schema and rejects unknown keys, so
the "//" comment trick fails with should NOT have additional property //.
Anything worth explaining about the config is explained here instead.
/assets/* is served public, max-age=31536000, immutable. Safe because every
filename carries its own width (cliff-house-960w.webp), so a changed image is
a changed URL. The HTML is left on Vercel's default so a deploy is visible
immediately.
/editor resolves by directory index to editor/index.html — Vercel and
serve.py both do this, so links behave identically in each. cleanUrls is
still set so any future top-level page can drop its .html.
.vercelignore keeps test/, tools/, serve.py and the docs out of the
deployment — about 46 files. LICENSE is deliberately not excluded: there
is no build step, so the browser downloads js/*.js verbatim and serving the
site distributes the source, which MIT requires the licence text to accompany.
- The metadata in
index.htmlhardcodes the production origin in four places:canonical,og:url,og:image,twitter:image. Crawlers fetch those from their own servers, so a relative path renders a blank card. - Social previews cannot be tested locally. Deploy first, then re-scrape through the Facebook debugger, X validator and LinkedIn post inspector. LinkedIn caches for about a week and ignores changes until you run it.
- The footer's social links and the newsletter form are unwired placeholders.
Issues and pull requests welcome. A few things that will make review quick:
- Run
sh test/run.shbefore opening a PR. It takes under a second. - Add a test for anything with logic in it. The suite has no framework —
copy the shape of any existing
test/tN.js, add it torun.sh, and symlink the module you need. - Comments should explain why, not what. The codebase leans heavily on this. If you worked out a non-obvious constraint, write it down; the next person will otherwise "simplify" it back into a bug.
- No build step. If a change needs one, it is probably the wrong change.
Third-party code goes through a runtime CDN import, and the host must send
cross-origin-resource-policy: cross-originorrequire-corpwill block it.
Good first issues, roughly in order of value:
- Streaming decode for the preview, to lift the 90-frame and 720px caps. This is now the most valuable thing on the list: text effects exist, and three seconds is not much clip to play them in.
- A timeline — in/out points and keyframed text, so an effect can be placed anywhere in the clip rather than measured from its start.
- Repair on video — the mask is already static-friendly, but a moving background under it needs per-frame inference and temporal smoothing to stop the patch boiling. Depends on the streaming decode above to be worth it.
- A video-native matting model (RVM carries recurrent state between frames; SAM 2 tracks arbitrary objects from a click and would fit the existing click-to-pick UI unusually well).
- HEVC / AV1 export — roughly half the bitrate at 4K.
mp4-muxeralready accepts both track types. - Scale handles on the canvas. Rotation has one;
selectionBox()inrenderer.jsis where a corner handle would hang off. - Multi-select, session restore, curved text along a path, and a custom bitmap for the image fill rather than only the frame or the backdrop.
The project code is released under the MIT Licence — see LICENSE.
The models are separately licensed and not distributed here; they are downloaded from Hugging Face at runtime:
| Model | Licence | Commercial use |
|---|---|---|
| BEN2 | MIT | Yes |
| BiRefNet (all variants) | MIT | Yes |
| Depth Anything v2 small | Apache-2.0 | Yes |
ISNet, via @imgly/background-removal |
Non-commercial | Needs a licence from IMG.LY |
| LaMa (repair) | Apache-2.0 | Yes |
onnxruntime-web (MIT) and fflate (MIT) are loaded at runtime from jsdelivr.
The default is BEN2 and its fallback is BiRefNet, so the out-of-the-box path
carries no restriction on any machine. ISNet is the only entry that does; drop
it from MODELS in segment.js if you would rather it were not selectable at
all. (RMBG-1.4 was removed for the same reason — non-commercial, and
superseded by BiRefNet.)
Bundled fonts come from Google Fonts under the SIL Open Font License. Font files you add yourself are stored only in your own browser and are never uploaded.