How to open an MPEG file with no index
An MP4 has a moov box. A ZIP has a central directory. An MPEG program stream has nothing — no index, no seek table, no duration field. This is a tour of what is actually inside a .mpg, then the streaming, memory-bounded algorithm that reads one far larger than RAM, seeks to any moment in it, and cuts a decodable clip out of the middle — with real JavaScript you can lift.
What is an MPG file?
A file called .mpg or .mpeg is almost always an MPEG program stream: video and audio multiplexed together, as defined by ISO/IEC 11172-1 (MPEG-1 Systems, 1993) or ISO/IEC 13818-1 (MPEG-2 Systems, 1995). The video inside it is MPEG-1 or MPEG-2 Video; the audio is usually MPEG Layer II, sometimes Layer III, and on a DVD often AC-3.
The design goal was storage and playback from a disc, on hardware with almost no memory. That single constraint explains everything strange about the format. A player in 1993 could not hold an index, could not seek cheaply, and could not be trusted to start at the beginning — a Video CD had to survive being joined in the middle. So the container was made self-synchronising and stateless: every few kilobytes, the stream restates enough about itself for a decoder to pick it up cold.
That is why an .mpg has no header worth the name, no metadata section, no tags, and no place to record how long it runs. What it has instead is start codes, repeated forever.
You still meet these files constantly: camcorder tapes someone digitised, DVDs ripped before streaming existed, Video CDs, CCTV exports, and broadcast archives. And you meet a second awkward fact with them — no browser will play one. Chrome, Safari and Firefox decode H.264, VP8, VP9 and AV1; none of them has ever shipped an MPEG-1 decoder for <video>.
Start codes: the whole trick
Everything MPEG writes is framed by a start code: the three bytes 00 00 01 followed by one byte that says what comes next.
| Code | Name | What it begins |
|---|---|---|
00 | picture_start_code | One coded frame |
01–AF | slice_start_code | A horizontal strip of that frame |
B3 | sequence_header_code | Picture size, frame rate, bitrate |
B5 | extension_start_code | The MPEG-2 extensions |
B8 | group_start_code | A group of pictures (GOP) |
B9 | iso_11172_end_code | End of the stream |
BA | pack_start_code | A pack — the container’s unit |
BB | system_header_start_code | The stream inventory |
BD | private_stream_1 | AC-3 / DTS / subpictures on a DVD |
BE | padding_stream | Filler, to hit a constant rate |
C0–DF | audio_stream_N | Audio PES packets |
E0–EF | video_stream_N | Video PES packets |
The property that makes this useful is a rule on the encoder: it may never emit 00 00 01 inside coded data. Where the bit pattern would occur naturally, the encoder stuffs bits to break it. (H.264 later solved the same problem the other way round, with emulation-prevention bytes inserted at write time and stripped at read time.)
00 00 01, and be certain the byte after it is a real start code and not a coincidence in the middle of a picture. No other information is required. This is the entire basis of everything below.Finding one is a three-byte scan:
/** Index of the next 00 00 01 at or after `from`, or -1. */
function findStartCode(b, from, limit = b.length) {
const end = Math.min(limit, b.length) - 2;
for (let i = Math.max(0, from); i < end; i++) {
if (b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1) return i;
}
return -1;
}
Packs and PES packets
A program stream is a flat run of packs. Each pack is a BA header followed by some PES packets, then the next pack. Packs are typically about 2 KB — a Video CD used exactly 2324 bytes, one CD-ROM Mode 2 sector.
The pack header exists to carry a clock: the system clock reference (SCR), a 33-bit counter ticking at 90 kHz. It tells the decoder when these bytes should be leaving the buffer. It is also, as we will see, the only navigational aid in the entire file.
MPEG-1 and MPEG-2 lay the pack header out differently, and they are told apart by the bits immediately after the start code: 0010 means MPEG-1, 01 means MPEG-2. The 33-bit SCR is split across three fields separated by marker bits, which is why reading it is arithmetic rather than shifting — bit 32 would fall off the end of a 32-bit shift in JavaScript:
function decodePackHeader(b, p) {
const first = b[p + 4];
if ((first & 0xf0) === 0x20) { // MPEG-1: 8 bytes after the code
const scr =
((first & 0x0e) / 2) * 0x40000000 + // SCR[32..30]
(b[p + 5] * 0x00400000) +
(((b[p + 6] & 0xfe) / 2) * 0x8000) + // SCR[29..15]
(b[p + 7] * 128) +
((b[p + 8] & 0xfe) / 2); // SCR[14..0]
const muxRate =
(((b[p + 9] & 0x7f) << 15) | (b[p + 10] << 7) | (b[p + 11] >> 1)) * 50;
return { version: 1, scr: scr / 90000, muxRate, length: 12 };
}
// MPEG-2 is 10 bytes plus up to 7 stuffing bytes, and adds a 9-bit extension.
...
}
Inside a pack sit PES packets (Packetized Elementary Stream). Each has a stream id — E0 for the first video stream, C0 for the first audio stream — a 16-bit length, and optional presentation and decoding timestamps (PTS/DTS) in the same 33-bit, marker-separated encoding as the SCR.
The length field is what makes an exact walk possible. You never guess where the next unit starts; the current one tells you. That matters more than it sounds: it means a well-formed program stream accounts for every byte, so any place the walk has to resync is a place the file is genuinely damaged, not a place the reader got confused.
The sequence header, and the field everybody misreads
Twelve bytes, inside the video stream, repeated every GOP or two — that is the whole picture description:
function decodeSequenceHeader(b, p) {
const q = p + 4;
const width = (b[q] << 4) | (b[q + 1] >> 4); // 12 bits
const height = ((b[q + 1] & 0x0f) << 8) | b[q + 2]; // 12 bits
const aspectCode = b[q + 3] >> 4; // 4 bits
const frameRateCode = b[q + 3] & 0x0f; // 4 bits
const bitRateUnits = (b[q + 4] << 10) | (b[q + 5] << 2) | (b[q + 6] >> 6);
return {
width, height,
fps: [null, 24000/1001, 24, 25, 30000/1001, 30, 50, 60000/1001, 60][frameRateCode],
// 0x3FFFF is saturation, not a measurement: it means "variable".
bitRate: bitRateUnits === 0x3ffff ? null : bitRateUnits * 400,
};
}
Two traps live here. The frame rate is a code, not a number — MPEG-1 permits exactly eight rates and there is no way to express any other, which is why so much old footage is 29.97 rather than 30. And aspect_ratio_information in MPEG-1 is a pel aspect ratio: the shape of one sample, not of the picture. A 352×240 stream with code 8 is not a 1.47:1 movie; it is a 4:3 picture made of non-square pixels. MPEG-2 quietly redefined the same four bits to mean the display aspect ratio instead, so the same code means different things depending on whether a sequence extension follows.
A group of pictures (B8) carries a timecode and one bit that matters: closed_gop. A closed GOP decodes without reference to anything before it, which is precisely what makes it a legal place to start.
Why 20 GB is hard
Everything above is cheap to parse. The problem is the arithmetic of size. A 20 GB program stream holds roughly ten million packs and several hundred thousand frames. Three things go wrong at once:
- You cannot read it into memory. Obvious, but it rules out every library whose API takes a buffer.
- You cannot build a row per pack. Ten million objects is gigabytes of JavaScript heap on its own, before any of them is rendered.
- You cannot seek. There is no index. Nothing in the file says where minute forty is. Asking for a frame in the middle appears to require having walked the first half.
The first two are solved by streaming and decimation. The third is the interesting one.
A streaming walk with flat memory
Read fixed blocks; parse only the units that begin inside the block; report how far you got and start the next read there. Because a PES packet is at most 65 541 bytes, reading a block plus that much overlap guarantees any unit starting inside the block is complete in the buffer, so no unit is ever split and no state has to be carried across the seam:
const BLOCK = 4 * 1024 * 1024;
const OVERLAP = 66 * 1024; // ≥ the 65 541-byte maximum PES packet
async function scanWholeFile(file, onProgress) {
const st = newScanState(); // plain counters, no per-pack objects
let pos = 0;
while (pos < file.size) {
const soft = Math.min(BLOCK, file.size - pos);
const buf = new Uint8Array(
await file.slice(pos, Math.min(file.size, pos + soft + OVERLAP)).arrayBuffer());
const used = scanBlock(buf, pos, soft, st); // parses units starting < soft
if (used <= 0) break; // no progress: stop, don't spin
pos += used;
onProgress(pos / file.size);
}
return st;
}
Peak memory is one block plus the overlap — about 4 MB — whatever the file’s size. The counters are exact: every pack, every PES packet, every frame and its type, the bytes each elementary stream takes.
The row list for a structure view is the part that must not be exact. Keep the first fifteen hundred rows verbatim (the head of a file is where the structure anyone is looking for actually is), then decimate — and when the array fills, drop every other row from the tail and double the stride:
function rowSink(CAP = 6000, HEAD = 1500) {
let rows = [], total = 0, stride = 1;
return {
unit(u) {
total++;
if ((total - 1) % stride !== 0) return;
rows.push(u);
if (rows.length >= CAP) {
rows = rows.slice(0, HEAD)
.concat(rows.slice(HEAD).filter((_, i) => i % 2 === 0));
stride *= 2;
}
},
result() { return { rows, total, stride }; },
};
}
Two passes, then, with very different costs. A bounded prefix — the first 2 MB, one slice read, whatever the file size — is enough for the system header, the sequence header, every stream id and the first GOP, which is everything a user interface needs on open. The whole-file pass is opt-in, because reading twenty gigabytes is a thing to be asked for rather than sprung on someone.
Seeking in a file with no index
Here is the part that looks impossible and is not. There is no seek table, but there is a clock in every pack header, and the mux rate is close to constant by construction — that is what the pack clock is for. So a byte offset for a moment can be estimated, and an estimate can be corrected against ground truth:
- Read the first pack. Note its SCR and mux rate.
- Guess:
offset ≈ known.offset + (targetSCR − known.scr) × muxRate. - Read 256 KB there. Scan for
00 00 01 BA. Decode its real SCR. - Measure the error. If it is more than a quarter-second, aim again from the pack you just landed on.
Four probes is plenty: each correction divides the error, and a constant-bitrate stream is usually within a second on the first guess. That is one megabyte read to locate any moment in a twenty-gigabyte file.
async function packAt(file, off, PROBE = 262144) {
const b = new Uint8Array(await file.slice(off, off + PROBE).arrayBuffer());
let p = 0;
for (;;) {
p = findStartCode(b, p, b.length);
if (p < 0 || p + 14 > b.length) return null;
if (b[p + 3] === 0xba) {
const pack = decodePackHeader(b, p);
if (pack) return { offset: off + p, scr: pack.scr, muxRate: pack.muxRate };
}
p += 3;
}
}
async function seekToTime(file, seconds) {
const first = await packAt(file, 0);
const target = first.scr + seconds;
let best = first, rate = first.muxRate;
for (let i = 0; i < 4; i++) {
if (Math.abs(best.scr - target) < 0.25) break;
const guess = Math.round(best.offset + (target - best.scr) * rate);
const landed = await packAt(file, Math.max(0, Math.min(file.size - 1, guess)));
if (!landed) break;
if (landed.muxRate) rate = landed.muxRate;
if (Math.abs(landed.scr - target) >= Math.abs(best.scr - target)) { best = landed; break; }
best = landed;
}
return best;
}
If a whole-file scan has already run, it can leave SCR↔offset landmarks every few megabytes, and the first guess starts from an interpolation between two of them instead of from the front of the file. But the landmarks are an optimisation, not a requirement — the loop above works on a file nothing has ever read.
Cutting a clip that actually decodes
Now the payoff, and it rests on one property of the container:
So converting thirty seconds out of a twenty-gigabyte capture does not mean feeding a decoder twenty gigabytes. Seek to the moment, walk forward duration × muxRate bytes, snap to the next pack header, and slice. What comes out is a few megabytes and it is a movie.
One refinement makes the difference between "valid" and "watchable". A decoder handed an arbitrary pack still has to wait for the next sequence header to learn the picture size, and until it arrives it emits invalid frame dimensions and drops frames. Since the sequence header repeats every GOP, backing the cut up to the pack that carries the next one costs well under a second of extra data and makes the window decode cleanly from its first frame:
// Return the last pack header at or before the next sequence header in the probe.
async function packBeforeSequence(file, off, PROBE = 262144) {
const b = new Uint8Array(await file.slice(off, off + PROBE).arrayBuffer());
const packs = [];
let p = 0;
for (;;) {
p = findStartCode(b, p, b.length);
if (p < 0 || p + 14 > b.length) break;
const code = b[p + 3];
if (code === 0xba) {
const pack = decodePackHeader(b, p);
if (pack) packs.push({ offset: off + p, scr: pack.scr, muxRate: pack.muxRate });
} else if (code === 0xb3 && packs.length) {
return packs[packs.length - 1]; // the pack carrying the sequence header
}
p += 3;
}
return packs[0] || null;
}
This is exactly how OmniViewer’s PREVIEW and CONVERT tabs work. The encoder — a real FFmpeg build compiled to WebAssembly, running inside a dedicated worker — is never handed the file. It is handed a window of a few megabytes, and it neither knows nor cares that the file it came from was twenty gigabytes. That is also why there is no upload: the whole operation is a slice, a decode and a blob, and none of those needs a server.
Caveats, honestly
- Duration is inferred, always. Nothing in the container records it. Best evidence is the SCR at both ends of a full scan; next best is the first and last PTS; next is frames divided by the frame rate; last is file size divided by the declared bitrate, which for a variable-bitrate stream is a guess. A tool that prints one number without saying which it used is hiding the difference.
- The SCR wraps. Thirty-three bits at 90 kHz is about 26.5 hours. Long captures wrap and the clock goes backwards; a production seeker has to detect the discontinuity rather than conclude the file is corrupt.
- Mux rate can lie. Variable-bitrate muxes restate it per pack, and some encoders write a nominal value everywhere. The correction loop above tolerates this because it re-aims from measured clocks, but the first guess can be far off.
- A cut clip starts with a partial audio frame. Video is fixed by the sequence-header alignment; audio is not, because Layer II frames do not align with packs. The decoder resyncs on the next frame, which costs about 24 ms and one harmless warning.
- Transport streams are a different walk.
.tsand.m2tscarry the same video and audio in fixed 188-byte packets addressed by PID, with PAT/PMT tables mapping the programs. Same codecs, different container — not a bigger version of this one.
OmniViewer opens every file format in your browser — MPG, MP4, MP3, PNG, ZIP and more — powered by the same windowed engine as fastjsonviewer.com and hugecsv.com. MPEG program streams are one of the formats with dedicated tooling.