vidpickr-mux
v0.1.0
Published
Stream-merge video + audio into MP4 entirely in the browser. Used in production by VidPickr; published standalone so anyone can build a download tool without a server queue.
Maintainers
Readme
vidpickr-mux
Stream-merge a video track and an audio track into a single MP4
entirely in the browser — no server queue, no transcode, no
intermediate file on disk. The bytes go from the source URLs through
the muxer and into a WritableStream that the browser writes to disk
as the merge progresses.
This is the muxing core that powers VidPickr's download flow, extracted as a standalone library.
import { createMuxSession, pickDownloadDestination } from 'vidpickr-mux';
const output = await pickDownloadDestination('movie.mp4');
const session = createMuxSession({
output,
video: { codec: 'avc', width: 1920, height: 1080, description: avcC },
audio: { codec: 'aac', sampleRate: 44100, numberOfChannels: 2 },
fastStart: 'fragmented',
});
// You feed in EncodedVideoChunk / EncodedAudioChunk objects from
// whatever decode/demux pipeline you have:
session.addVideoChunk(chunk);
session.addAudioChunk(chunk);
await session.finalize();
// Done — the file is on disk.Why this exists
Every "free YouTube downloader" works the same way: send the URL to a backend, the backend fetches the streams, re-encodes them, and pipes the result to you. That model has three problems:
- Quality loss — server-side re-encode loses information vs the original.
- Speed — you wait for upload, transcode, then download. The tool's queue is your queue.
- Privacy — your video URL passes through someone else's server, and the file briefly exists on their disk.
In modern browsers (WebCodecs, fragmented MP4 muxing, fetch
streaming, the File System Access API) all of this can run on the
user's own machine. The bytes you save are the exact bytes the
source served — bit-identical, no quality loss, fast.
vidpickr-mux is the part that's reusable across any tool that
needs in-browser MP4 muxing. The YouTube-specific glue (cookies,
SAPISIDHASH, multi-language audio detection, etc.) stays in the
VidPickr server.
Install
npm install vidpickr-mux
# or
pnpm add vidpickr-mux
yarn add vidpickr-muxPeer dependency mp4-muxer is bundled. The package is ESM-only.
Browser support
- Chrome / Edge / Opera 113+ — full support, including a real
streaming download to disk via
showSaveFilePicker. Multi-GB downloads use ~constant RAM. - Firefox 130+ / Safari 17+ — works, but the destination falls
back to a Blob accumulation +
<a download>trigger, so the file lives in RAM until finalize. Fine for files under a few GB. - Older browsers — won't work;
WebCodecsandWritableStreamare hard dependencies.
API
pickDownloadDestination(filename: string): Promise<WritableStream<Uint8Array>>
Returns a writable stream that lands as a downloaded file. On browsers that support the File System Access API, this triggers a native save dialog and writes incrementally to disk. Elsewhere it falls back to a blob-then-anchor download.
The returned stream is yours; close it when you're done writing.
createMuxSession(opts): MuxSession
Low-level mux session. The caller has already produced
EncodedVideoChunk / EncodedAudioChunk objects somehow (e.g.
from a WebCodecs decoder, a custom demuxer, etc.) and just needs
to interleave them into an MP4.
interface MuxSessionOptions {
output: WritableStream<Uint8Array>;
video: {
codec: 'avc' | 'hevc' | 'vp9' | 'av1';
width: number;
height: number;
description?: Uint8Array; // avcC / hvcC / etc, required for AVC + HEVC
frameRate?: number;
};
audio: {
codec: 'aac' | 'opus';
sampleRate: number;
numberOfChannels: number;
description?: Uint8Array;
} | null; // null for video-only
fastStart?: 'fragmented' | 'in-memory';
}
interface MuxSession {
addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;
addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
finalize(): Promise<void>;
}Use fastStart: 'fragmented' (the default) for streaming output —
each fragment is written as it's produced, and the file is playable
mid-write. Use 'in-memory' if you need a single moov box at the
end (smaller header, but the whole thing buffers).
muxStreams(opts): Promise<void> (planned, v0.2)
High-level convenience wrapper:
await muxStreams({
videoUrl: 'https://…',
audioUrl: 'https://…',
filename: 'output.mp4',
onProgress: (p) => console.log(p),
});Currently throws — the v0.1 release ships the muxer + destination
layer plus the session API. The fully-wired demuxer pipeline is on
the v0.2 roadmap; in the meantime see
examples/youtube-style.ts for a
working sketch using createMuxSession directly.
Example: YouTube-style mux
The
examples/youtube-style.ts file
shows a complete pipeline:
- Sniff the MP4 container of each URL to learn codec + track parameters.
- Open a
createMuxSessionwith that config. - Stream both URLs in parallel, parse fragments, and feed them into the session.
finalize()— file is on disk.
Run the live demo by serving the examples/ directory:
cd examples
npx serve .
# → http://localhost:3000/basic.htmlWhat's intentionally out of scope
- Source-specific extractors. Getting the actual stream URLs out of YouTube / Vimeo / wherever is a different problem with different rules per source. This library is the muxing layer only.
- Re-encoding. If you want to change resolution, bitrate, or
codec on the way through, that's
WebCodecs+ a video encoder, not a muxer. We pass bytes through. - DRM. No interest, no support.
License
MIT — see LICENSE.
Built and maintained by the VidPickr team. If this saves you time and you find yourself running a YouTube downloader, consider sending people our way.
