Skip to content

Media Metadata Inspector — TypeScript source

Inspect audio, video and image files at the byte level — MP4 boxes, ID3 tags, WAV chunks, GIF headers — parsed by our own readers, entirely in your browser.

This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Pure media metadata probing — MP4 boxes, ID3v2, WAV, GIF, WebM signatures.
// No React, no DOM. Deterministic; returns null only for inputs too short to
// even carry a signature.

export interface MediaMeta {
  kind: 'mp4' | 'mp3' | 'wav' | 'gif' | 'webm' | 'unknown';
  durationSec: number | null;
  width: number | null;
  height: number | null;
  title?: string;
  artist?: string;
  album?: string;
}

const ascii = (bytes: Uint8Array, start: number, len: number): string =>
  String.fromCharCode(...bytes.subarray(start, start + len));

const u16 = (b: Uint8Array, o: number) => b[o] | (b[o + 1] << 8);
const u32 = (b: Uint8Array, o: number) =>
  (b[o] * 0x1000000 + ((b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3])) >>> 0;

/** MP4 box walk: returns payload offsets for every box of the wanted types. */
function findBoxes(bytes: Uint8Array, start: number, end: number, wanted: string[]): Array<{ type: string; start: number; end: number }> {
  const found: Array<{ type: string; start: number; end: number }> = [];
  let pos = start;
  while (pos + 8 <= end) {
    let size = u32(bytes, pos);
    const headerSize = 8;
    if (size === 1) return found; // 64-bit sizes unsupported — stop safely.
    if (size === 0) size = end - pos; // extends to the end
    if (size < headerSize || pos + size > end) return found;
    const type = ascii(bytes, pos + 4, 4);
    if (wanted.includes(type)) {
      found.push({ type, start: pos + headerSize, end: pos + size });
    }
    pos += size;
  }
  return found;
}

function probeMp4(bytes: Uint8Array): MediaMeta {
  const meta: MediaMeta = { kind: 'mp4', durationSec: null, width: null, height: null };
  const moovs = findBoxes(bytes, 0, bytes.length, ['moov']);
  for (const moov of moovs) {
    const mvhds = findBoxes(bytes, moov.start, moov.end, ['mvhd']);
    for (const mvhd of mvhds) {
      const version = bytes[mvhd.start];
      if (version === 0 && mvhd.end - mvhd.start >= 20) {
        const timescale = u32(bytes, mvhd.start + 12);
        const duration = u32(bytes, mvhd.start + 16);
        if (timescale > 0) meta.durationSec = duration / timescale;
      } else if (version === 1 && mvhd.end - mvhd.start >= 32) {
        const timescale = u32(bytes, mvhd.start + 20);
        const duration =
          u32(bytes, mvhd.start + 24) * 0x100000000 + u32(bytes, mvhd.start + 28);
        if (timescale > 0) meta.durationSec = duration / timescale;
      }
    }
    const traks = findBoxes(bytes, moov.start, moov.end, ['trak']);
    for (const trak of traks) {
      const tkhds = findBoxes(bytes, trak.start, trak.end, ['tkhd']);
      for (const tkhd of tkhds) {
        if (bytes[tkhd.start] === 0 && tkhd.end - tkhd.start >= 84) {
          meta.width = u32(bytes, tkhd.start + 76) / 65536;
          meta.height = u32(bytes, tkhd.start + 80) / 65536;
        }
        break; // First track is enough for v1.
      }
      if (meta.width !== null) break;
    }
  }
  return meta;
}

const syncsafe = (b: Uint8Array, o: number) =>
  (b[o] << 21) | (b[o + 1] << 14) | (b[o + 2] << 7) | b[o + 3];

/** Shared decoder for ID3 UTF-8 frames (stateless without { stream: true }). */
const utf8Decoder = new TextDecoder();

/** Exported pure helper (unit-test surface, mirroring gif-decode's lzwDecode). */
export function decodeId3Text(bytes: Uint8Array, start: number, len: number): string {
  if (len <= 0) return '';
  const encoding = bytes[start];
  const text = bytes.subarray(start + 1, start + len);
  if (encoding === 3) {
    // UTF-8 (ID3v2.4): decode multi-byte sequences, stop at the null terminator.
    const end = text.indexOf(0);
    return utf8Decoder.decode(end === -1 ? text : text.subarray(0, end));
  }
  if (encoding === 1 || encoding === 2) {
    // UTF-16: encoding 1 carries a BOM (sniff endianness, skip it);
    // encoding 2 is BOM-less big-endian per ID3v2.4.
    const bom = encoding === 1 && (text[0] === 0xff || text[0] === 0xfe);
    const start2 = bom ? 2 : 0;
    const littleEndian = bom ? text[0] === 0xff : false;
    let out = '';
    for (let i = start2; i + 1 < text.length; i += 2) {
      const code = littleEndian ? text[i] | (text[i + 1] << 8) : (text[i] << 8) | text[i + 1];
      if (code === 0) break;
      out += String.fromCharCode(code);
    }
    return out;
  }
  let out = '';
  for (const byte of text) {
    if (byte === 0) break;
    out += String.fromCharCode(byte); // ISO-8859-1 / UTF-8 ASCII range
  }
  return out;
}

function probeId3(bytes: Uint8Array): MediaMeta {
  const meta: MediaMeta = { kind: 'mp3', durationSec: null, width: null, height: null };
  const version = bytes[3];
  const tagSize = syncsafe(bytes, 6);
  let pos = 10;
  const end = Math.min(10 + tagSize, bytes.length);
  while (pos + 10 <= end) {
    const id = ascii(bytes, pos, 4);
    if (!/^[A-Z0-9]{4}$/.test(id)) break;
    const size = version === 4 ? syncsafe(bytes, pos + 4) : u32(bytes, pos + 4);
    if (size === 0) break;
    // Clamp the declared size to the tag end so a corrupt frame cannot
    // read metadata out of the audio payload.
    const avail = end - (pos + 10);
    const text = decodeId3Text(bytes, pos + 10, Math.min(size, Math.max(avail, 0)));
    if (id === 'TIT2') meta.title = text;
    else if (id === 'TPE1') meta.artist = text;
    else if (id === 'TALB') meta.album = text;
    pos += 10 + size;
  }
  return meta;
}

/** RIFF/WAV fields are little-endian, unlike MP4/ID3. */
const u32le = (b: Uint8Array, o: number) =>
  (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | b[o + 3] * 0x1000000) >>> 0;

function probeWav(bytes: Uint8Array): MediaMeta {
  const meta: MediaMeta = { kind: 'wav', durationSec: null, width: null, height: null };
  let pos = 12; // past RIFF size + WAVE
  let byteRate = 0;
  while (pos + 8 <= bytes.length) {
    const id = ascii(bytes, pos, 4);
    const size = u32le(bytes, pos + 4);
    if (id === 'fmt ' && pos + 8 + 16 <= bytes.length) {
      byteRate = u32le(bytes, pos + 16); // byteRate sits at fmt payload offset 8
    } else if (id === 'data' && byteRate > 0) {
      meta.durationSec = size / byteRate;
      break;
    }
    pos += 8 + size + (size % 2); // chunks are word-aligned
  }
  return meta;
}

export function probe(bytes: Uint8Array): MediaMeta | null {
  if (bytes.length < 12) return null;

  if (ascii(bytes, 4, 4) === 'ftyp') return probeMp4(bytes);
  if (ascii(bytes, 0, 3) === 'ID3') return probeId3(bytes);
  if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WAVE') return probeWav(bytes);
  if (ascii(bytes, 0, 4) === 'GIF8') {
    return {
      kind: 'gif',
      durationSec: null,
      width: u16(bytes, 6),
      height: u16(bytes, 8),
    };
  }
  if (bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) {
    return { kind: 'webm', durationSec: null, width: null, height: null };
  }
  return { kind: 'unknown', durationSec: null, width: null, height: null };
}

Also available in 9 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →