Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@
"types": "./src/react-native-overlay.ts",
"default": "./src/react-native-overlay.ts"
},
"./recording-mp4-fixtures": {
"types": "./src/recording/mp4.fixtures.ts",
"default": "./src/recording/mp4.fixtures.ts"
},
"./recording-mp4-duration": {
"types": "./src/recording/mp4-duration.ts",
"default": "./src/recording/mp4-duration.ts"
},
"./recording-output-path": {
"types": "./src/recording/output-path.ts",
"default": "./src/recording/output-path.ts"
Expand Down
79 changes: 79 additions & 0 deletions packages/capture-kit/src/recording/mp4-atoms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, test } from 'vitest';
import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts';
import { findMp4Atom } from './mp4-atoms.ts';
import { mp4Atom, mp4AtomWithExtendedSize } from './mp4.fixtures.ts';

const directory = mkdtempForTestSync('agent-device-mp4-atoms-');

function write(name: string, contents: Buffer): string {
const filePath = path.join(directory, name);
fs.writeFileSync(filePath, contents);
return filePath;
}

describe('findMp4Atom', () => {
test('locates a nested box and reports where its payload starts', () => {
const movieHeader = mp4Atom('mvhd', Buffer.alloc(100));
const file = write(
'nested.mp4',
Buffer.concat([mp4Atom('ftyp', Buffer.alloc(8)), mp4Atom('moov', movieHeader)]),
);
const atom = findMp4Atom(file, ['moov', 'mvhd']);
expect(atom?.type).toBe('mvhd');
expect(atom?.offset).toBe(24);
expect(atom?.size).toBe(108);
expect(findMp4Atom(file, ['moov', 'udta'])).toBeUndefined();
});

test('walks past a 64-bit sized box', () => {
const file = write(
'extended.mp4',
Buffer.concat([
mp4AtomWithExtendedSize('mdat', Buffer.alloc(64)),
mp4Atom('moov', Buffer.alloc(16)),
]),
);
expect(findMp4Atom(file, ['moov'])?.offset).toBe(16 + 64);
});

test('accepts a box whose declared size runs to end of file', () => {
const streamingMdat = Buffer.concat([
Buffer.from([0, 0, 0, 0]),
Buffer.from('mdat', 'latin1'),
Buffer.alloc(32),
]);
const file = write(
'streaming.mp4',
Buffer.concat([mp4Atom('ftyp', Buffer.alloc(8)), streamingMdat]),
);
const atom = findMp4Atom(file, ['mdat']);
expect(atom?.offset).toBe(16);
expect(atom?.size).toBe(streamingMdat.length);
});

test('reports a box that overruns its container and stops looking past it', () => {
const overrunning = Buffer.concat([
Buffer.from([0, 0, 0, 200]),
Buffer.from('wide', 'latin1'),
Buffer.alloc(40),
]);
const file = write(
'overrun.mp4',
Buffer.concat([
mp4Atom('ftyp', Buffer.alloc(8)),
overrunning,
mp4Atom('moov', Buffer.alloc(8)),
]),
);
expect(findMp4Atom(file, ['wide'])?.size).toBe(200);
expect(findMp4Atom(file, ['moov'])).toBeUndefined();
});

test('answers undefined for an absent file, an unreadable path, and a short box', () => {
expect(findMp4Atom(path.join(directory, 'absent.mp4'), ['moov'])).toBeUndefined();
expect(findMp4Atom(directory, ['moov'])).toBeUndefined();
expect(findMp4Atom(write('short.mp4', Buffer.from([0, 0])), ['moov'])).toBeUndefined();
});
});
99 changes: 99 additions & 0 deletions packages/capture-kit/src/recording/mp4-atoms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import fs from 'node:fs';

// Bounded so a corrupt container cannot widen the walk; a movie header past the budget simply
// reads as absent.
const MAX_SIBLINGS_PER_LEVEL = 32;
const ATOM_HEADER_BYTES = 8;
const EXTENDED_SIZE_BYTES = 8;

export type Mp4Atom = Readonly<{
/** Four-character box type, for example `moov`. */
type: string;
/** Byte offset where the box header starts. */
offset: number;
headerSize: number;
/** Total box size in bytes, including the header. */
size: number;
}>;

/** Locates a box by its ancestor-to-leaf type path, for example `['moov', 'mvhd']`. */
export function findMp4Atom(filePath: string, names: readonly string[]): Mp4Atom | undefined {
if (names.length === 0) return undefined;
let fd: number | undefined;
try {
fd = fs.openSync(filePath, 'r');
const root: Mp4Atom = { type: '', offset: 0, headerSize: 0, size: fs.fstatSync(fd).size };
return findDescendant(fd, root, names, 0);
} catch {
return undefined;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}

function findDescendant(
fd: number,
container: Mp4Atom,
names: readonly string[],
depth: number,
): Mp4Atom | undefined {
for (const atom of childAtoms(fd, container)) {
if (atom.type !== names[depth]) continue;
if (depth + 1 === names.length) return atom;
const match = findDescendant(fd, atom, names, depth + 1);
if (match) return match;
}
return undefined;
}

function* childAtoms(fd: number, container: Mp4Atom): Generator<Mp4Atom> {
const end = container.offset + container.size;
let offset = container.offset + container.headerSize;
for (
let seen = 0;
offset + ATOM_HEADER_BYTES <= end && seen < MAX_SIBLINGS_PER_LEVEL;
seen += 1
) {
const atom = readAtomHeader(fd, offset, end);
if (!atom) return;
yield atom;
// A box that claims more room than its container holds, or an unrepresentable 64-bit size,
// ends this level: nothing past it can be located.
if (
!Number.isSafeInteger(atom.size) ||
atom.size < atom.headerSize ||
atom.offset + atom.size > end
)
return;
offset += atom.size;
}
}

function readAtomHeader(fd: number, offset: number, end: number): Mp4Atom | undefined {
const header = readBytes(fd, offset, ATOM_HEADER_BYTES);
if (!header) return undefined;
let size = header.readUInt32BE(0);
let headerSize = ATOM_HEADER_BYTES;
if (size === 1) {
const extended = readBytes(fd, offset + ATOM_HEADER_BYTES, EXTENDED_SIZE_BYTES);
if (!extended) return undefined;
size = Number(extended.readBigUInt64BE(0));
headerSize += EXTENDED_SIZE_BYTES;
}
// A declared size of 0 means the box runs to the end of its container.
return {
type: header.toString('latin1', 4, 8),
offset,
headerSize,
size: size === 0 ? end - offset : size,
};
}

function readBytes(fd: number, offset: number, length: number): Buffer | undefined {
const buffer = Buffer.alloc(length);
try {
return fs.readSync(fd, buffer, 0, length, offset) === length ? buffer : undefined;
} catch {
return undefined;
}
}
72 changes: 72 additions & 0 deletions packages/capture-kit/src/recording/mp4-duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, test } from 'vitest';
import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts';
import { readMp4DurationMs } from './mp4-duration.ts';
import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts';

const directory = mkdtempForTestSync('agent-device-mp4-duration-');

function recording(name: string, movieHeaderPayload: Buffer): string {
const filePath = path.join(directory, `${name}.mp4`);
fs.writeFileSync(
filePath,
Buffer.concat([
mp4Atom('ftyp', Buffer.alloc(24)),
mp4Atom('mdat', Buffer.alloc(16)),
mp4Atom('moov', mp4Atom('mvhd', movieHeaderPayload)),
]),
);
return filePath;
}

describe('readMp4DurationMs', () => {
test('reads a version 0 movie header in its own timescale', () => {
const file = recording(
'v0',
mp4MovieHeader({ version: 0, timescale: 90_000, duration: 630_000 }),
);
expect(readMp4DurationMs(file)).toBe(7_000);
});

test('reports a timeline of zero for a clip of a screen that never changed', () => {
const file = recording('static', mp4MovieHeader({ version: 0, timescale: 1_000, duration: 0 }));
expect(readMp4DurationMs(file)).toBe(0);
});

test('reads the 64-bit duration of a version 1 movie header', () => {
const file = recording('v1', mp4MovieHeader({ version: 1, timescale: 1_000, duration: 9_500 }));
expect(readMp4DurationMs(file)).toBe(9_500);
});

test('rounds a duration that does not divide evenly into milliseconds', () => {
const file = recording('odd', mp4MovieHeader({ version: 0, timescale: 3, duration: 2 }));
expect(readMp4DurationMs(file)).toBe(667);
});

test('answers undefined when the timeline cannot be trusted', () => {
expect(
readMp4DurationMs(
recording('idle', mp4MovieHeader({ version: 0, timescale: 1_000, duration: 0xffff_ffff })),
),
).toBeUndefined();
expect(
readMp4DurationMs(
recording('no-timescale', mp4MovieHeader({ version: 0, timescale: 0, duration: 5_000 })),
),
).toBeUndefined();
const unsupportedVersion = mp4MovieHeader({ version: 0, timescale: 1_000, duration: 5_000 });
unsupportedVersion.writeUInt8(2, 0);
expect(readMp4DurationMs(recording('version-2', unsupportedVersion))).toBeUndefined();
});

test('answers undefined for a file with no movie header or a truncated one', () => {
const withoutMovieHeader = path.join(directory, 'no-moov.mp4');
fs.writeFileSync(withoutMovieHeader, mp4Atom('mdat', Buffer.alloc(16)));
expect(readMp4DurationMs(withoutMovieHeader)).toBeUndefined();

const truncated = path.join(directory, 'truncated.mp4');
fs.writeFileSync(truncated, mp4Atom('moov', mp4Atom('mvhd', Buffer.alloc(24))));
expect(readMp4DurationMs(truncated)).toBeUndefined();
});
});
56 changes: 56 additions & 0 deletions packages/capture-kit/src/recording/mp4-duration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'node:fs';
import { findMp4Atom } from './mp4-atoms.ts';

const MVHD = ['moov', 'mvhd'] as const;
const MVHD_TIMESCALE_VERSION_0 = 12;
const MVHD_DURATION_VERSION_0 = 16;
const MVHD_TIMESCALE_VERSION_1 = 20;
const MVHD_DURATION_VERSION_1 = 24;
const MVHD_FIXED_LAYOUT_BYTES = 32;
const UNKNOWABLE_32_BIT_DURATION = 0xffffffff;

/**
* Duration the MP4 timeline actually covers, in milliseconds, or `undefined` when the container
* cannot answer. A screen-recording timeline can be shorter than the window it was captured in:
* `screenrecord` encodes a frame only when the screen changes.
*/
export function readMp4DurationMs(filePath: string): number | undefined {
const header = readMovieHeader(filePath);
if (!header) return undefined;
const version = header[0];
if (version === 1) {
return movieDurationMs(
header.readUInt32BE(MVHD_TIMESCALE_VERSION_1),
Number(header.readBigUInt64BE(MVHD_DURATION_VERSION_1)),
);
}
if (version !== 0) return undefined;
const duration = header.readUInt32BE(MVHD_DURATION_VERSION_0);
if (duration === UNKNOWABLE_32_BIT_DURATION) return undefined;
return movieDurationMs(header.readUInt32BE(MVHD_TIMESCALE_VERSION_0), duration);
}

function movieDurationMs(timescale: number, duration: number): number | undefined {
// Zero is a real timeline: a clip of a screen that never changed holds one frame.
if (timescale <= 0 || duration < 0 || !Number.isSafeInteger(duration)) return undefined;
return Math.round((duration * 1000) / timescale);
}

function readMovieHeader(filePath: string): Buffer | undefined {
const movieHeader = findMp4Atom(filePath, MVHD);
if (!movieHeader || !Number.isSafeInteger(movieHeader.size)) return undefined;
const payloadOffset = movieHeader.offset + movieHeader.headerSize;
if (movieHeader.size - movieHeader.headerSize < MVHD_FIXED_LAYOUT_BYTES) return undefined;
const buffer = Buffer.alloc(MVHD_FIXED_LAYOUT_BYTES);
let fd: number | undefined;
try {
fd = fs.openSync(filePath, 'r');
return fs.readSync(fd, buffer, 0, buffer.length, payloadOffset) === buffer.length
? buffer
: undefined;
} catch {
return undefined;
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
39 changes: 39 additions & 0 deletions packages/capture-kit/src/recording/mp4.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const ATOM_HEADER_BYTES = 8;
const EXTENDED_SIZE_BYTES = 8;
const MOVIE_HEADER_V0_PAYLOAD_BYTES = 100;
const MOVIE_HEADER_V1_PAYLOAD_BYTES = 112;

export function mp4Atom(type: string, payload: Buffer): Buffer {
const header = Buffer.alloc(ATOM_HEADER_BYTES);
header.writeUInt32BE(ATOM_HEADER_BYTES + payload.length, 0);
header.write(type, 4, 'latin1');
return Buffer.concat([header, payload]);
}

export function mp4AtomWithExtendedSize(type: string, payload: Buffer): Buffer {
const header = Buffer.alloc(ATOM_HEADER_BYTES + EXTENDED_SIZE_BYTES);
header.writeUInt32BE(1, 0);
header.write(type, 4, 'latin1');
header.writeBigUInt64BE(BigInt(header.length + payload.length), ATOM_HEADER_BYTES);
return Buffer.concat([header, payload]);
}

export function mp4MovieHeader(params: {
version: 0 | 1;
timescale: number;
duration: number;
}): Buffer {
const versioned = params.version === 1;
const payload = Buffer.alloc(
versioned ? MOVIE_HEADER_V1_PAYLOAD_BYTES : MOVIE_HEADER_V0_PAYLOAD_BYTES,
);
payload.writeUInt8(params.version, 0);
if (versioned) {
payload.writeUInt32BE(params.timescale, 20);
payload.writeBigUInt64BE(BigInt(params.duration), 24);
} else {
payload.writeUInt32BE(params.timescale, 12);
payload.writeUInt32BE(params.duration, 16);
}
return payload;
}
Loading
Loading