✳ OMNIVIEWER MPG viewer MPG converter ← back

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.

On this page What is an MPG file Start codes Packs and PES The sequence header Why 20 GB is hard A streaming walk Seeking with no index Cutting a clip Caveats

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.

CodeNameWhat it begins
00picture_start_codeOne coded frame
01AFslice_start_codeA horizontal strip of that frame
B3sequence_header_codePicture size, frame rate, bitrate
B5extension_start_codeThe MPEG-2 extensions
B8group_start_codeA group of pictures (GOP)
B9iso_11172_end_codeEnd of the stream
BApack_start_codeA pack — the container’s unit
BBsystem_header_start_codeThe stream inventory
BDprivate_stream_1AC-3 / DTS / subpictures on a DVD
BEpadding_streamFiller, to hit a constant rate
C0DFaudio_stream_NAudio PES packets
E0EFvideo_stream_NVideo 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.)

Consequence: you can drop into an MPEG stream at any byte offset, scan forward for 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.

One subtlety worth stating. The video elementary stream is chopped into PES packets with no regard for its own syntax, so a picture header’s four data bytes can begin in one packet and finish in the next — with an audio packet in between. A reader that simply keeps reading past the payload boundary decodes the next PES header as if it were picture data and files that frame under the wrong type. It is about one frame in a few hundred: rare enough to miss, common enough that the I/P/B counts stop adding up to the frame count. The fix is to hold the straddling bytes and resume them at the front of the next payload of the same stream.

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:

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:

  1. Read the first pack. Note its SCR and mux rate.
  2. Guess: offset ≈ known.offset + (targetSCR − known.scr) × muxRate.
  3. Read 256 KB there. Scan for 00 00 01 BA. Decode its real SCR.
  4. 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:

A program stream cut on a pack boundary is a program stream. Packs are self-contained and self-synchronising, so the bytes between any two pack headers form a valid file. Nothing needs rewriting, no header needs patching, and no offsets need fixing — because there are no offsets anywhere to fix.

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

Convert an MPG →MP4, MOV, WebM, GIF, MP3, WAV or a PNG frame — encoded in the tab, no upload, no size cap. See inside an MPEG stream →Packs, PES packets, GOPs and the sequence header decoded field by field.

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.