Bring a vertical clip. Captionizer times it word by word, shows you the captions playing live over the video, lets you change the look, and burns them in. All of it in the browser tab. The file never leaves the machine.
npm install
npm run dev # http://localhost:5180The style the app boots with is style.json, at the root of this
repository. Edit it, reload the page, and the captions change. There is no code
to touch and nothing to rebuild.
The page opens on one surface with the sample clip already mounted, captioned and playing. There is no empty state and nothing to click first, and nothing ever navigates: one template, three slots, and only the left one ever swaps.
Switch style is a drawer pinned to the foot of the window. It opens upward over the page, so the video does not move, and it stays open while you click through the fifteen templates and watch each one land live on the frame you are already watching. Download burns the captions in and saves the file.
Playback is the frame itself: click it to pause, click it again to play, and the hairline at the foot of the card is the only playback chrome. There is no scrubber and no timecode. The demo plays muted, because a page that makes noise on load is bad manners and browsers block unmuted autoplay anyway, so Unmute sits in the corner of the frame.
Upload video takes an mp4 or mov and captions it here, in the tab. The limits sit under the button rather than behind a failure: 9:16, mp4 or mov, up to 90s and 250 MB. Everything that can go wrong lands in one modal over an untouched page, so a bad file never costs anyone the page they were on: the video keeps playing behind it, both buttons stay where they are, and dismissing it returns you to exactly where you started. The refusal names what was actually found, so an HEVC clip is told it is HEVC. A clip that is not 9:16 is refused with the rest, because the templates are drawn for portrait and one shape everywhere is what keeps the surface from moving.
While a clip is being transcribed or an export is being burned, the video block is hidden outright and a ring stands in its slot: percentage, the stage it is on, one honest line about where the work is happening, and an elapsed clock next to progress in seconds of video rather than in ours. The gutter and the drawer do not move, so nothing jumps when the video comes back.
Uploaded video is transcribed by Whisper in the browser through the engine's own
WhisperTranscriber. The model is base, chosen on measurement rather than
reputation:
| model | first-run download | inference on a 14.9 s clip | words ending past the audio |
|---|---|---|---|
| tiny | 43.6 MB | 1.14 s | 2, the last one running to 16.56 s |
| base | 77.8 MB | 1.85 s | 0 |
Accuracy did not separate them — both were within one or two tokens of the
reference on a 45 word sample, which is too small a sample to rank models on.
Timing did: tiny left a caption on screen 1.7 seconds after the video ended.
base costs 34 MB and seven tenths of a second to avoid that.
The weights download once and are cached, so the cost falls on the first upload.
The wait names the stage it is on and only ever moves forward, because it is
genuinely long. Full measurements, and the dependency bug that stopped any of
this working until it was actually run:
docs/build/07-transcription-findings.md.
Those two rows are WASM. The transcriber is constructed device: 'auto', and
on a machine that has a GPU that is a different path with different numbers:
222.5 MB of weights instead of 76.0 MB, and about 16% slower, because the
WebGPU build pulls an unquantized encoder. It works, it is not faster here,
and nothing has been pinned either way on the strength of one clip on one GPU:
docs/proof/webgpu-transcription.md.
-
@tscaps/engine0.3.1, pinned exactly, lockfile committed. MIT engine only; the AGPL studio is not a dependency. SeeTHIRD_PARTY_NOTICES.md. -
A caption style is data, and the data is a file.
src/style/types.tscarries the nine design fields,style.jsonis one of them written out, andcompileStyleturns it into CSS custom properties over a stylesheet that is the same constant for every style. Nothing in the app branches on which style is active. The file is fetched at boot rather than imported, so it is a live input and not a build-time constant:docs/proof/style-json.md. -
One render path. The live preview and the export both go through the engine's own rasteriser, so the preview is the export minus the encode.
-
Export quality is a stated decision. The same source produced a 12 MB export from the driver script and a 143 MB export from the in-app path on the founder's machine, because
qualitywas never passed and the actual encoder — a software AVC path here, a hardware one on the founder's Chrome — does not respect the engine's target bitrate the same way on both.buildPipelineinsrc/engine/captionSession.tsnow passes.withQuality('medium')explicitly, in the one call site both export paths already shared, so output size is a decision rather than whatever the local browser's encoder happens to default to:docs/proof/quality-and-bitrate.md. -
A versioned transcript contract.
src/captions/words.tsis the seam every timing source normalises to, validated before anything renders. -
Fonts are bundled and verified, not named and hoped for. Four OFL fonts ship in
public/fonts/: three variable (Geist, Geist Mono, Outfit) and Anton, which has one static weight because that is all Anton has. Seven of the fifteen templates set their type in it, at weight 400, since asking for 900 on a single-weight face gets a synthesised fake bold rather than a heavier cut.src/style/fonts.tsis the single source of the@font-facerules for both the page and the caption stylesheet the engine rasterises, and it proves each family resolved by canvas metrics before the first caption is drawn. A font that did not load is a named error, never a silent substitution. -
Chrome only, stated rather than discovered. Export goes through WebCodecs and the caption raster through SVG
<foreignObject>, and every measurement indocs/proof/was taken in Chrome. Safari and Firefox are untested here and nothing claims them.
Measured export speed, the exported file's ffmpeg metadata, a burned-in proof
frame, and the honest limits of all of it: docs/build/06-slice-findings.md,
which records the eight-template vertical slice this grew out of rather than
today's fifteen. That the captions actually move, measured off exported video
across three passes: docs/proof/motion.md.
style.json is the style Captionizer boots with. It carries the nine fields the
brief names — font, weight, stroke, shadow, highlight colour, animation, words
per line, position, safe-area margins — and it is the only thing that decides
how the captions look when the page opens.
To change the style, edit style.json and reload. That is the whole
procedure. It is fetched from disk on every load with no-store, so a reload
is enough; nothing regenerates it from TypeScript and no build step stands
between the file and the frame.
To start from one of the fifteen, copy it over. presets/ holds every
template in exactly the format style.json takes:
cp presets/neon.json style.json # reload: the page now boots into Neon
cp presets/pop-art.json style.json # back to the heroThe drawer at the foot of the window switches between the same fifteen at
runtime. That is a different thing from the boot style and it does not write to
the file: the drawer is for looking, style.json is for keeping. A preset you
liked in the drawer becomes the boot style by copying its file over.
npm run presets regenerates presets/ from src/style/templates.ts. The test
suite holds every preset identical to the template it came from, through the
real parser, so the two copies cannot drift apart quietly.
A broken style file is an error, not a bad-looking caption.
src/style/parseStyle.ts validates every field and names the one at fault:
style.json: "animation.activeMs" must be a finite number, found the string "260".
style.json: "highlight.activeColor" must be a non-empty string, found nothing.
A font family the app does not bundle is refused too, rather than quietly
substituted by the browser. Four faces ship, and font.family has to name one
of them; the error lists them.
outputs/ holds the same clip in two styles. Both came out of the app's own
export path, and the second was produced by editing style.json and nothing
else.
npm installnpm run dev, and open the page. It loads with the sample clip already captioned in Pop Art, the hero style, which is whatstyle.jsoncurrently says.- Press Download. That burns the captions in and saves
captionizer-pop-art.mp4. This isoutputs/hero-pop-art.mp4. - Now change the style, and only the style:
cp presets/banner.json style.json - Reload the page. The captions are now Banner: a yellow plate with black caps instead of outlined lime over the picture. No code was edited and nothing was rebuilt.
- Press Download again for
captionizer-banner.mp4. This isoutputs/second-banner.mp4. cp presets/pop-art.json style.jsonputs the hero back.
Export needs Chrome or Edge 94 and up, because it goes through WebCodecs. The
committed files were produced headlessly by the same code path rather than by
hand — node scripts/export-from-style-json.mjs outputs/hero-pop-art.mp4 drives
the export harness against whatever style.json says, and never names a
template. It needs Playwright, which is not a dependency of this project; point
PLAYWRIGHT at an installation if it does not resolve.
| Command | What it does |
|---|---|
npm run dev |
the app |
npm run typecheck |
tsc --strict |
npm run check |
Biome format + lint |
npm test |
vitest |
npm run presets |
rewrite presets/ from the templates |
npm run build |
typecheck then production bundle |
This repository is a build assessment. docs/build/ records how the direction
was reached, NOTES.md records where the AI assistance was wrong, SOURCE.md
covers source provenance, and docs/CUT-LOG.md records what was cut and why,
including why none of it was cut for time.
The bundled sample clip is real, founder-supplied footage, transcribed once
through the app's own in-browser Whisper path and committed alongside it so
the page loads captioned with no wait. Provenance and why the material is
non-trivial: SOURCE.md.