Lossless mp4 concatenation, in the browser or in Node. mp4cat joins mp4 clips by copying their compressed samples onto one timeline and writing a single new mp4 — no ffmpeg, no decode, no re-encode, durations exact by construction.
Zero runtime dependencies. Pure DataView/Uint8Array, so the same module
runs in Node and in a <script type="module"> unchanged. Library + CLI in one
install.
Joining videos usually means shelling out to ffmpeg or re-encoding through
MediaRecorder, which costs time, quality, and (in a browser) isn't even an
option. But when every clip already shares the same codec parameters — the
common case for clips cut from one source, or generated by the same model with
the same settings — none of that is necessary: the compressed frames can be
copied verbatim into one file. mp4cat does exactly that, and only that. If
the clips don't match, it tells you which file differs and how instead of
silently producing a broken file or quietly re-encoding.
| Runtime | Node ≥ 20 or any modern browser · no deps · TypeScript types included |
| Input | progressive or fragmented (fMP4/CMAF) mp4, video+audio or audio-only (m4a) |
| Output | one streamable progressive mp4 — moov first, tracks interleaved, bit-exact sample copies |
| Origin | extracted from nanoodle's Combine node |
npm install mp4catOr, for hacking on it, clone and link (both steps needed):
git clone https://github.com/nanoodlecom/mp4cat
cd mp4cat && npm link
cd /path/to/your-project && npm link mp4catEither way, import { concatMp4 } from "mp4cat" resolves and the mp4cat
CLI lands in node_modules/.bin.
import { readFile, writeFile } from "node:fs/promises";
import { isMp4, mp4Compat, concatMp4 } from "mp4cat";
const clips = await Promise.all(
["a.mp4", "b.mp4"].map(async f => new Uint8Array(await readFile(f)))
);
if (!clips.every(isMp4)) throw new Error("all inputs must be mp4");
const compat = mp4Compat(clips, { names: ["a.mp4", "b.mp4"] });
if (!compat.ok) throw new Error(compat.reason); // e.g. "b.mp4 is 640x480 but a.mp4 is 320x240 — …"
await writeFile("out.mp4", concatMp4(clips));import { isMp4, mp4Compat, concatMp4 } from "./src/index.mjs";
const clips = await Promise.all(
urls.map(async u => new Uint8Array(await (await fetch(u)).arrayBuffer()))
);
if (clips.every(isMp4) && mp4Compat(clips).ok) {
const out = concatMp4(clips);
video.src = URL.createObjectURL(new Blob([out], { type: "video/mp4" }));
}Fragmented input works too — clips recorded with MediaRecorder
(video/mp4 in Safari/Chrome) or fetched from CMAF-style sources parse the
same way; the output is always a plain progressive mp4.
mp4cat a.mp4 b.mp4 c.mp4 -o out.mp4
# inspect files first (no ffprobe needed)
mp4cat --info a.mp4 b.mp4 # human-readable
mp4cat --info --json a.mp4 # machine-readable
# pipe the result somewhere else
mp4cat a.mp4 b.mp4 -o - | some-uploader
# clips authored as chained continuations (clip N+1 starts on clip N's last frame)
mp4cat a.mp4 b.mp4 --dedup -o out.mp4If the inputs don't match, mp4cat exits with an error that names the offending file and the exact difference (resolution, codec, sample rate, …) plus the ffmpeg one-liner to fix it — rather than guessing.
Full TypeScript declarations ship with the package.
isMp4(u8)→boolean— quick sniff: does thisUint8Arraystart with an mp4ftypbox?mp4Compat(bufs, opts?)→{ ok, reason }— strict compatibility gate over an array of mp4 byte buffers.ok: trueonly when every clip has the same track shape (video and audio each all-or-none, at most one of each), the same resolution, byte-identical video codec config (avcC/hvcC/vpcC/av1C— SPS/PPS for H.264), and matching audio codec, sample rate, and channels. Otherwisereasonis one sentence naming the first offending clip and what differs; passopts.names(e.g. filenames) to label clips in it. A false positive would silently corrupt output, so any doubt (including parse errors) fails the gate.mp4ParamsMatch(bufs)→boolean—mp4Compat(bufs).ok, kept for back-compat.concatMp4(buffers, opts?)→Uint8Array— concatenate whole-file mp4 buffers into one streamable mp4:moovwritten beforemdat(faststart) and samples interleaved across tracks in ~0.5 s chunks, so the result plays progressively over HTTP without seeking to the far end of the file.opts.dedup: truedrops each later clip's first video sample — useful when clips are chained continuations where clip N+1's first frame duplicates clip N's last frame. Caution: on most files that first sample is a keyframe — if it's the clip's only keyframe, dropping it makes the rest of that clip undecodable, and audio is not correspondingly trimmed (≈ one frame of A/V drift per seam).dedupis only safe for clips authored with that chaining in mind; default isfalse. Callmp4Compatfirst —concatMp4does not re-check.mp4Info(u8)→{ fragmented, duration, tracks }— friendly probe: per-track codec (4cc + friendly name), duration, sample count, video width/height/fps, audio channels/sample rate. No ffprobe needed.parseMp4(u8)→{ tracks, fragmented }— the underlying parser (track kind, timescale, sample table, dimensions), exported for inspection/debugging. Reads classicmoovsample tables andmoof/traf/trunfragments.
npm test generates its mp4 fixtures at test time with ffmpeg/ffprobe; if
they're not on your PATH, the fixture-based tests skip cleanly with a message
(the library itself never needs ffmpeg). The suite includes full-decode checks
on concatenated output, fragmented and audio-only fixtures, and CLI
end-to-end runs.
- mp4 in, mp4 out. No webm, mov, or mkv. H.264+AAC is the tested path;
the match gate also recognizes
hvcC/vpcC/av1Cvideo anddOps/alacaudio configs and will pass matching clips through unchanged. - Inputs must already match. Same codec config bytes, same resolution,
same audio codec/rate/channels. mp4cat errors rather than silently
re-encoding — the error tells you which clip differs and how; re-encode to
matching parameters first
(e.g.
ffmpeg -i in.mp4 -c:v libx264 -pix_fmt yuv420p -c:a aac out.mp4). - Whole files in memory. Buffers in, buffer out — fine for clips, not designed for multi-gigabyte movies. Outputs past 4 GiB are refused (32-bit chunk offsets) rather than corrupted.
- At most one video and one audio track. Extra tracks (subtitles,
chapters, second audio) are not carried through;
mp4Compatrejects such files. Audio-only (m4a) works; video-only works. - Edit lists are not preserved. Clips relying on
elstfor A/V sync or leading-sample trims may drift slightly at seams. - Timescale mismatches are rescaled with rounding. Clips whose tracks use
a different timescale than the first clip get their sample durations
rescaled to the first clip's timescale via
Math.round, so a tiny drift is possible at seams; byte-identical codec config is still required.
This module is the lossless-remux path of the Combine node in
nanoodle — a no-server, bring-your-own-key visual AI
workflow editor (source). It was
written so generated clips could be joined in the browser tab with exact
durations, after real-time re-recording via MediaRecorder proved lossy and
fragile. Extracted so it can be used anywhere.