Design a spinning vinyl record label in the browser and export it as MP4, a seamlessly looped GIF, or self-contained embed HTML.
Everything is square (1:1) and built for social posts, Bandcamp headers, Spotify
Canvas and the like. Vanilla JS — no CDN, no webfonts, no runtime dependencies. The
MP4 muxer and the GIF encoder are hand-written and live in src/export/.
npm install
npm run dev # editor at http://localhost:5178
npm test # 260 unit tests
npm run build # static bundle in dist/- Six text slots, each with its own font, weight, size, tracking and colour: title, artist, album (curved along the label rim), studio credits down the left, technical info down the right, and a numbered track list.
- Solid or gradient fills on every element, including the arc text and the label itself.
- Two framing modes — Crop, a close-up where the vinyl bleeds off all four edges and the label fills half the square; or Full disc, the whole record inside the frame with a realistically small label, like a real 12" pressing.
- Turntable physics — spin-up, coast-down, once-per-revolution wobble, and true angular motion blur that resolves to a pixel-crisp still when the platter stops.
- Seamless loops — every export covers a whole number of revolutions, so the last frame hands back to the first with no jump.
- Four presets, randomised studio/pressing credits, and JSON import/export.
- Ten typeface stacks built only from fonts that ship with macOS, Windows and Linux, so the editor, the exported frames and the embed all render identically offline.
| format | notes |
|---|---|
| MP4 | H.264 via WebCodecs, muxed by a hand-written ISO-BMFF writer |
| GIF | global median-cut palette, ordered dithering, loops forever |
| Embed HTML | one self-contained file, no network requests |
| PNG | cued still frame, unblurred |
MP4 — src/export/mp4.js writes ftyp / moov / mdat with a single chunk and
faststart: moov is built twice so the chunk offset is known before it is written.
Requires WebCodecs (Chromium 94+, Safari 16.4+); browsers without it fall back to
MediaRecorder, which may produce WebM instead.
GIF — src/export/gif.js. Frames render at 720px+ and are downsampled for
antialiasing, then one global palette is built by median cut across every frame,
mapped through a 5:5:5-keyed nearest-colour cache, ordered-dithered (Bayer 8×8) and
LZW encoded. Per-frame delays are distributed so rounding error never accumulates,
and a NETSCAPE2.0 block makes it loop forever.
Embed HTML — src/export/embed.js emits one file with the engine inlined and the
config as JSON. The engine is not duplicated: src/export/bundle.js imports the
src/core modules with Vite's ?raw, strips their import/export syntax and
concatenates them, so the embed runs the exact same drawing code as the editor. The
player pauses when scrolled out of view or when the tab is hidden, and honours
prefers-reduced-motion before autoplaying.
Frames are rendered deterministically off-screen at the target resolution — the preview is never screen-captured, so output never depends on window size or frame timing.
The platter is a first-order system (src/core/physics.js): the motor pulls angular
velocity toward the target with time constant spinUp, friction pulls it back to
zero with spinDown. Both are dialled in as "seconds to reach speed".
Rendering splits into three layers, rebuilt only when the config changes:
| layer | contents | behaviour |
|---|---|---|
| background | gradient or solid | fixed; only visible in full-disc framing |
| rotating | grooves, scratches, run-out spiral, label, all text | rotates + blurs |
| static | specular sheen, rim light, vignette | fixed — light doesn't turn with the record |
Per frame the rotating layer is drawn N times across the shutter arc into a scratch
buffer using globalCompositeOperation = 'lighter' at alpha 1/N. Additive
compositing of premultiplied pixels is an exact average; the usual source-over
stack over-weights the last sample and leaves a trailing ghost. Below ~0.35° of
travel per frame it collapses to a single sample, so a stopped record is crisp.
A rotated square bitmap only reliably covers the disc of layerSize/2 around its
centre — outside that, its own empty corners swing into view. The rotating layer is
therefore sized to reach the disc edge, or the frame's corners past which nothing is
ever visible, whichever comes first. In crop framing that is ~1.46× the frame; in
full-disc framing the layer is actually smaller than the frame.
Two physical touches on top of the rotation:
- Wobble — an off-centre spindle hole plus a slightly dished record, giving a once-per-revolution lateral shift and vertical squash. Scaled by current speed, so a stopped record sits perfectly centred.
- Groove asymmetry — a run-out spiral and seeded scratches. A perfectly concentric ring is rotationally symmetric and would look completely still.
Both wobble terms are pure functions of the rotation angle, which is what lets a single revolution loop seamlessly.
Geometry is two numbers, both fractions of the half-frame: discScale (how far the
vinyl extends) and labelScale (the sticker's share of the square). framing picks
which side of the √2 full-bleed threshold discScale lives on, and each mode clamps
to its own range — so the stored mode can never disagree with the geometry it
describes.
Text sits in six named slots, all positioned as fractions of the label radius, so a config renders identically at 360px and 1080px and in either framing:
╭─ album (arc along the rim) ─╮
left │ TITLE │ right
(studio │ artist │ (tech
info) │ ○ spindle │ info)
│ 1. track one │
╰──────2. track two ──────────╯
Every line auto-fits the chord width available at its height, so long text shrinks
instead of overflowing the sticker. Gradient-filled text is drawn as a white mask and
tinted with source-in — a canvas gradient is otherwise baked with the transform at
fill time, and would rotate with every individual glyph of arc text.
src/
core/ render engine — also the source of the embed bundle
util.js clamp, deep clone/merge, seeded PRNG, path get/set
constants.js font stacks, slot definitions, framing modes, RPM presets
color.js paints (solid/gradient), gradient geometry, mask fill
text.js arc layout, auto-fit, tracking, line stacking
physics.js spin-up/coast-down, wobble, motion-blur sampling
renderer.js layers, frame composition, engine
config.js defaults, normalisation, migration, presets, random credits
export/
frames.js seamless loop planner
mp4.js ISO-BMFF muxer + WebCodecs encoder + recorder fallback
gif.js median cut, dithering, LZW, GIF89a writer
bundle.js module-syntax stripper for the embed
embed.js standalone player document
ui/ schema-driven control panel
tests/ 260 vitest tests
Everything under src/core is concatenated verbatim into the embed export, so those
modules must keep each import on a single line and each export as a leading
declaration keyword. tests/embed.test.js enforces this.
Tests are pure — no canvas, no jsdom. The parts that would normally need a browser are covered by testing their output bytes instead:
- the MP4 is walked box-by-box, checking that
stcopoints at the realmdatpayload,stszmatches the sample count andstsslists the key frames; - the GIF LZW stream is round-tripped through a reference decoder written independently in the test file, including the KwKwK case and a stream long enough to exhaust the 4096-code dictionary;
- the embed bundle is evaluated with
new Function, and itscomputeLayoutis asserted to produce geometry identical to the module build.
Any current Chromium, Safari or Firefox runs the editor, the preview and the GIF,
PNG and embed exports. MP4 export additionally needs WebCodecs; without it the app
falls back to MediaRecorder and tells you when the result is WebM rather than MP4.
MIT.