Created
April 21, 2025 21:37
-
-
Save NSExceptional/17e38f3b0818f5330bbc9ee444157768 to your computer and use it in GitHub Desktop.
A small class for parsing MP4 file headers, with the ability to check whether the given file has the hvc1 tag. All in-process.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* mp4.ts | |
* media | |
* | |
* Created by Tanner Bennett on 2025-04-19 | |
* Copyright © 2025 Tanner Bennett. All rights reserved. | |
*/ | |
type Atom = { | |
size: number; | |
type: string; | |
offset: number; | |
}; | |
type TopLevelMP4Atoms = | |
'ftyp' | | |
'moov' | | |
'mdat' | | |
'free' | | |
'skip' | | |
'udta' ; | |
type SeekOptions = { | |
reset?: boolean; | |
uptoOffset?: number; | |
}; | |
export default class MP4 { | |
constructor(private readonly file: string) { | |
this.handle = Deno.openSync(this.file, { read: true }); | |
} | |
private handle: Deno.FsFile | |
private get offset(): number { | |
return this.handle.seekSync(0, Deno.SeekMode.Current); | |
} | |
private reset(): void { | |
this.handle.seekSync(0, Deno.SeekMode.Start); | |
} | |
public close(): void { | |
this.handle.close(); | |
} | |
private readBytes(size: number): Uint8Array { | |
const buffer = new Uint8Array(size); | |
const bytesRead = this.handle.readSync(buffer); | |
if (bytesRead === null) { | |
throw new Error('End of file reached'); | |
} | |
return buffer; | |
} | |
private readAtomHeader(): Atom { | |
const offset = this.offset; | |
const header = this.readBytes(8); | |
const size = new DataView(header.buffer).getUint32(0); | |
const type = new TextDecoder().decode(header.subarray(4, 8)); | |
return { size, type, offset }; | |
} | |
/** | |
* Seeks up to the end of the specified atom header. | |
* | |
* If the atom has children or data, the reader will be positioned | |
* at the start of the next child or data, so that you can immediately | |
* read the next atom header or start reading the data. | |
*/ | |
public seekAtom(type: string, options?: SeekOptions): Atom | null { | |
const { reset, uptoOffset } = options || {}; | |
if (reset) { | |
this.reset(); | |
} | |
const shouldLoop = (): boolean => { | |
if (uptoOffset) { | |
return this.offset < uptoOffset; | |
} | |
return true; | |
}; | |
while (shouldLoop()) { | |
const { size, type: nextType } = this.readAtomHeader(); | |
if (nextType === type) { | |
return { type, size, offset: this.offset - 8 }; | |
} | |
// Seek past the current atom (-8 is for the header we already read) | |
this.handle.seekSync(size - 8, Deno.SeekMode.Current); | |
if (size === 0) { | |
break; // End of file | |
} | |
} | |
return null; | |
} | |
/** Traverse a branch of the atom tree. Assumes each atom immediately contains child atoms. */ | |
public traverseAtoms(branch: string[]): Atom | null { | |
if (branch.length === 0) { | |
return null; | |
} | |
if (branch.length === 1) { | |
return this.seekAtom(branch[0]); | |
} | |
const first = branch.shift()!; | |
let currentAtom = this.seekAtom(first); | |
// Logging | |
if (!currentAtom) { | |
console.log(`Could not find first atom in branch:\n${branch.join(' > ')}`); | |
} | |
for (const type of branch) { | |
// No need to check for null at any point here, it's fine | |
// if we come across null right away and keep looping, | |
// `seekAtomWithinAtom` will no-op each time in that case | |
const childAtom = this.seekAtomWithinAtom(currentAtom, type); | |
currentAtom = childAtom; | |
// Logging | |
if (!currentAtom) { | |
console.log(`Could not find atom '${type}' in branch:\n${branch.join(' > ')}`); | |
break; | |
} | |
} | |
return currentAtom; | |
} | |
/** Scan all atoms at the current depth for the given type, up to the end of the parent atom */ | |
public seekAtomWithinAtom(parent: Atom | null, type: string): Atom | null { | |
if (!parent) { | |
return null; | |
} | |
const { size, offset } = parent; | |
const endOffset = offset + size; | |
return this.seekAtom(type, { uptoOffset: endOffset }); | |
} | |
/** This tag is stored as an atom inside moov > trak > mdia > minf > stbl > stsd */ | |
public get hvc1(): boolean { | |
const stsd = this.traverseAtoms(['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd']); | |
if (!stsd) { | |
return false; | |
} | |
// Can't just put hvc1 at the end of the list above, because | |
// stsd is the first and only atom in this list that doesn't | |
// immediately contain other atoms, so we need to seek past | |
// some metadata before we can read the next atom | |
// Skip version/flags (4 bytes) | |
this.handle.seekSync(4, Deno.SeekMode.Current); | |
// Read the number of entries in the stsd atom | |
const stsdHeader = this.readBytes(4); | |
const numEntries = new DataView(stsdHeader.buffer).getUint32(0); | |
if (numEntries === 0) { | |
return false; | |
} | |
const hvc1 = this.seekAtomWithinAtom(stsd, 'hvc1'); | |
return !!hvc1; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment