We built a 4K video editor that never uploads your file
Tuneshot turns an audio track plus some photos into a YouTube-ready MP4, and cuts and merges video by timestamp. The part worth writing about is the constraint we started with: the server does nothing but serve static fi
Tuneshot turns an audio track plus some photos into a YouTube-ready MP4, and cuts and merges video by timestamp. The part worth writing about is the constraint we started with: the server does nothing but serve static files. No upload endpoint, no render queue, no S3 bucket, no FFmpeg process on a box somewhere. Every frame is encoded on the visitor's own device.
Server-side video rendering is expensive, and its cost scales exactly linearly with usage. Rendering on the client costs nothing per render. And once you commit to it, the privacy story gets to be literally true rather than a policy promise: there is no server-side copy of your file because there is no server-side anything.
Here's what that actually took.
WebCodecs is the whole reason this is possible
For years the only way to do real video work in a browser was ffmpeg.wasm — a full transcoder compiled to WebAssembly. It works, but you're shipping megabytes of WASM and running software encoding on the CPU.
WebCodecs changed the economics. It exposes the browser's own VideoEncoder, VideoDecoder, AudioEncoder and AudioDecoder — the same hardware-accelerated codecs the browser uses to play video. You get near-native encode speed with nothing to download.
What WebCodecs does not give you is containers. It hands you encoded chunks; it has no idea what an MP4 is. So you still need something to mux. We use mediabunny for the container layer, which handles muxing/demuxing and gives us a Target abstraction for where the output goes — which turns out to matter a lot.
Problem 1: an hour-long render does not fit in RAM
The naive approach accumulates encoded chunks in an array and makes a Blob at the end. This works great in testing with a 30-second clip and falls over the moment someone renders a real video. A 4K render can run to gigabytes, and the tab dies.
The fix is the Origin Private File System — a private, origin-scoped filesystem with a synchronous access handle available inside workers. You stream the output to disk as you encode it, and never hold more than a chunk in memory.
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle("renders", { create: true });
const handle = await dir.getFileHandle(fileName, { create: true });
const access = await handle.createSyncAccessHandle();
access.truncate(0);
const writable = new WritableStream<StreamTargetChunk>({
write(chunk) {
access.write(chunk.data, { at: chunk.position });
},
close: () => { access.flush(); access.close(); },
abort: () => { access.flush(); access.close(); },
});
return new StreamTarget(writable, { chunked: true, chunkSize: 8 * 1024 * 1024 });
Three things in there took longer to work out than they should have:
createSyncAccessHandle() is worker-only, and TypeScript doesn't know it exists. It isn't in the DOM lib, because the DOM lib describes the window context. We declare a minimal local type for it. Mildly annoying, easy to mistake for a configuration problem.
Chunks arrive with a position, not in order. A muxer goes back and patches headers once it knows final durations and offsets. If you treat the stream as append-only you get a corrupt file whose corruption depends on the input — which is a wonderful way to lose a day. access.write(data, { at: position }) handles it; a naive WritableStream pipe does not.
OPFS is not always there. Private browsing, locked-down enterprise profiles, and some embedded webviews either don't expose it or throw on first use. So the sink is an interface with two implementations: the OPFS one above, and an in-memory BufferTarget fallback. The fallback means small renders still work in a private window, and the code that calls it doesn't care which one it got:
export type OutputSink = {
target: Target;
onDisk: boolean; // false = in-memory fallback
finish(): Promise<Blob>;
discard(): Promise<void>;
};
The onDisk flag exists purely so the UI can warn before someone starts an hour-long render in a private window.
Problem 2: merged clips don't agree on anything
This was the genuinely hard one.
One AAC track in an MP4 has one sample rate and one channel count. But if someone merges a 44.1 kHz mono voice memo with a 48 kHz stereo camera clip, the decoded audio arrives in two different layouts and has to come out as one. So you resample and remix on the fly.
Writing a linear resampler is easy. Writing one that doesn't click at clip boundaries is the part nobody warns you about.
A resampler is stateful: to interpolate the first output sample of a chunk, you need the last input sample of the previous chunk. Keep that state across chunks — correct, and necessary, or you get a click at every chunk boundary. But if you keep it across a cut, you've just interpolated between the end of clip A and the start of clip B, which blends two unrelated waveforms into a sample that existed in neither. It's quiet, it's only at the joins, and it sounds like a faint tick that you'll spend an afternoon convincing yourself you imagined.
The fix is a one-line policy, written down where someone will read it: state persists across chunks, and a new normalizer instance is created per clip.
/**
* Resampling is linear and continuous across chunks; create a new instance per clip
* so interpolation never blends across a cut.
*/
export class AudioNormalizer {
private prev: Float32Array | null = null;
// passthrough when srcRate === outRate && srcChannels === outChannels
}
The other half of the win is the passthrough flag: when a clip already matches the output layout, process() returns the same object it was handed. Most clips in a typical merge match, so most of the time this does nothing at all, which is exactly what you want from a conversion layer.
Problem 3: lossless and frame-accurate are mutually exclusive
Cutting video forces a choice people don't expect, so we stopped hiding it and shipped both modes.
Fast mode copies packets. No decode, no re-encode, no generation loss, and it runs at close to disk speed. The catch: you can only cut at keyframes. Ask for 00:01:07 and you get the nearest keyframe, which might be a second away.
Exact mode decodes and re-encodes. You get the frame you asked for, plus normalisation of mixed resolutions, frame rates and audio formats across clips. The catch: it takes real time, and you take one generation of quality loss.
There is no third option that is both. Every tool in this space has to make this choice; naming the two modes and explaining the tradeoff in one sentence each turned out to be better UX than picking a default and hoping.
The same "don't do work you don't need to" logic runs through the audio path: MP3 and AAC inputs are copied bit-for-bit rather than re-encoded, because re-encoding audio that's already in a container-legal format is pure loss for zero benefit. Only WAV and FLAC get converted — to AAC, or to lossless PCM in a .mov if you want to keep everything.
Problem 4: don't render 30 fps of a photograph
A still image with no animation is the same frame repeated. Encoding it at 30 fps means encoding 1,800 identical frames per minute so the encoder can throw almost all of them away.
So the renderer picks a frame rate from the content: a single photo with no visualizer takes a 1 fps path; anything with real motion renders at 30; plain hard cuts between stills sit at 10. On a long "audio + one cover image" render — the single most common thing anyone does with the tool — this makes a large difference to render time.
Audio visualizers are driven by an FFT of the actual decoded track rather than a canned animation, which is the one place we do spend real CPU. It's worth it: a waveform that matches the music is the entire point.
The build constraints, which bit back
The site is a Next.js static export (output: "export"). That has consequences worth knowing before you choose it:
- No request-time work anywhere. The build fails if a page needs it. This is a feature — it's what guarantees there's no server doing anything — but it means every "just add an API route" instinct has to be unlearned.
- Cache Components / PPR can't be combined with export. Next tells you so, fairly clearly: "PPR cannot be enabled in export mode."
-
Metadata routes need
export const dynamic = "force-static"orsitemap.tsandrobots.tswon't export.
And one footgun that cost an evening and has nothing to do with video:
Tailwind v4's scanner honours parent-directory .gitignore files. A .gitignore in my home directory — created by a virtualenv years ago — contained a single line: *. Tailwind walked up, found it, concluded every source file was ignored, and generated no utility classes at all. The CSS built fine. It was just empty. The fix is a !* line at the top of the project's own .gitignore. If your Tailwind v4 build silently produces no styles, check every .gitignore above you before you check anything else.
What's still hard
Honest list, because I'd want one:
- Codec support is uneven. WebCodecs is well supported in Chromium; elsewhere it varies by codec rather than by browser, so feature detection has to be per-codec, not per-browser.
- Memory pressure is invisible. OPFS solved output size. Decoded frames in flight are still RAM, and there's no good cross-browser signal for "you are about to be killed."
-
Cancellation. Our worker RPC implements cancel as
worker.terminate(). Blunt, but a half-cancelled encoder pipeline is worse. - No server means no telemetry. When a render fails on someone's phone, I find out if they tell me. That's the deal I signed up for, and I'd sign it again, but it does make debugging a conversation rather than a dashboard.
Was it worth it
For this product, unambiguously. Rendering cost is zero, it scales to any number of users on a static host, and "your files never leave your device" is a fact about the architecture rather than a sentence in a privacy policy.
The honest caveat: this only works because the workload is embarrassingly local. One user, one file, no collaboration, no shared state. The moment you need server-side anything — transcoding farms, team libraries, cross-device sync — the whole argument collapses and you should just rent the servers.
If you want to poke at it, it's at tuneshot.online — free, no signup, no watermark. Open your network tab while you render something; that's the part I'm proud of.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.