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 →