Skip to content

Repository files navigation

Vinyl Spin Studio

Try it live →

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/

Features

  • 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 modesCrop, 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.

Exports

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

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

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

How the spin works

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.

Layout

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.

Source layout

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.

Testing

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 stco points at the real mdat payload, stsz matches the sample count and stss lists 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 its computeLayout is asserted to produce geometry identical to the module build.

Browser support

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.

License

MIT.

About

Design a spinning vinyl record label in the browser and export it as MP4, a seamlessly looped GIF, or self-contained embed HTML. Vanilla JS, zero runtime dependencies — hand-rolled MP4 muxer and GIF encoder included.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages