Skip to content

About

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**.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

14 Commits

Folders and files

Repository files navigation

Text Behind Subject

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.


Contents


Quick start

git clone <your-fork-url> text-behind-subject
cd text-behind-subject
python3 serve.py

Open 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.


Features

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.


Browser support

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.


How it works

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.


Choosing a model

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
BiRefNet lite 1024 — — MIT Dead end, see below
BiRefNet — — MIT Dead end, see below

Everything is downloaded once and cached by the browser.

The default is measured, not hardcoded

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.

Salient object detection vs depth

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.

Painting the cutout by hand

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.

Picking regions by click

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.

WebGPU is on by default now

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.

Why BiRefNet cannot use WebGPU

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.


Video

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.

Why a session, and not cutout() in a loop

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 caps are about memory, not speed

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.

Export does not inherit the preview's cap

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 only

destination-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.

The H.264 level is computed, not hardcoded

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 encodeQueueSize drain, 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.

Not built for video

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.


Text fills

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.


Text effects

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.

Effects are a function of time, not of a clock

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 timestamps

The 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.

Why the typewriter needed the layout rewritten

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.

Still not a timeline

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.


Repair

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.

512 is a constraint on the crop, not on the image

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.

The mask matters more than the model

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.

Every mark is undoable, no mask is

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.

Marks are undoable, the mask is not

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.

Batch

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.

What it invalidates

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.


Background modes

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.


Rotation

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°.

Slant

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

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.

Camera angle

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.


Fonts

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.


Keyboard

⌘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

Architecture

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.


Design decisions worth knowing about

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.


Testing

sh test/run.sh

698 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 → 255 alpha 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 drawImage calls 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.jpg must 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 stretch must 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.


Deploying

Static hosting, no build command, no output directory. vercel.json carries the whole configuration.

The isolation headers are not optional

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.

No comments in vercel.json

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.

Asset caching

/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.

Routing

/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.

What ships

.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.

Before the first deploy

  • The metadata in index.html hardcodes 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.

Contributing

Issues and pull requests welcome. A few things that will make review quick:

  • Run sh test/run.sh before 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 to run.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-origin or require-corp will block it.

Good first issues, roughly in order of value:

  1. 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.
  2. A timeline — in/out points and keyframed text, so an effect can be placed anywhere in the clip rather than measured from its start.
  3. 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.
  4. 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).
  5. HEVC / AV1 export — roughly half the bitrate at 4K. mp4-muxer already accepts both track types.
  6. Scale handles on the canvas. Rotation has one; selectionBox() in renderer.js is where a corner handle would hang off.
  7. 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.

Licensing

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.

About

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**.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages