Skip to content

nanoodlecom/mp4cat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mp4cat

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.

Why

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

Install

npm install mp4cat

Or, 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 mp4cat

Either way, import { concatMp4 } from "mp4cat" resolves and the mp4cat CLI lands in node_modules/.bin.

Quickstart (library, Node)

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));

Quickstart (library, browser)

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.

Quickstart (CLI)

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.mp4

If 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.

API

Full TypeScript declarations ship with the package.

  • isMp4(u8)boolean — quick sniff: does this Uint8Array start with an mp4 ftyp box?
  • mp4Compat(bufs, opts?){ ok, reason } — strict compatibility gate over an array of mp4 byte buffers. ok: true only 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. Otherwise reason is one sentence naming the first offending clip and what differs; pass opts.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)booleanmp4Compat(bufs).ok, kept for back-compat.
  • concatMp4(buffers, opts?)Uint8Array — concatenate whole-file mp4 buffers into one streamable mp4: moov written before mdat (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: true drops 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). dedup is only safe for clips authored with that chaining in mind; default is false. Call mp4Compat first — concatMp4 does 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 classic moov sample tables and moof/traf/trun fragments.

Tests

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.

Limitations (honest ones)

  • mp4 in, mp4 out. No webm, mov, or mkv. H.264+AAC is the tested path; the match gate also recognizes hvcC/vpcC/av1C video and dOps/alac audio 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; mp4Compat rejects such files. Audio-only (m4a) works; video-only works.
  • Edit lists are not preserved. Clips relying on elst for 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.

Provenance

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.

License

MIT

About

Lossless mp4 concatenation in the browser or in Node — zero dependencies, no ffmpeg, no re-encode

Topics

Resources

License

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors