blob: 260ed3b39227197a03c8ed36b135b1eed3720bb0 [file] [edit]
// mp4-generator.js — In-memory fragmented MP4 generator for MSE layout tests.
//
// Generates valid fMP4 init segments and media segments that go through the real
// platform parser pipeline (SourceBufferParserAVFObjC / AVStreamDataParser), unlike
// mock-media-source.js which uses a synthetic format bypassing all real parsing.
//
// Usage:
// const { init, media } = MP4.samples({
// timescale: 1000,
// track: { id: 1, type: 'video' },
// samples: [
// { pts: 0, dts: 0, duration: 1000, isSync: true },
// { pts: 1000, dts: 1000, duration: 1000, isSync: false },
// ]
// });
// sourceBuffer.appendBuffer(init);
// // ... wait for updateend ...
// sourceBuffer.appendBuffer(media);
//
// In-band CEA-608 captions: add a second track with type 'captions' and supply
// caption samples built by Captions.buildPaintOnCueSamples() from caption-generator.js.
//
// <script src="caption-generator.js"></script>
// <script src="mp4-generator.js"></script>
// const captionSamples = Captions.buildPaintOnCueSamples({ cues: [...], totalDurationTicks });
// const { init, media } = MP4.samples({
// timescale: 30000,
// tracks: [{ id: 1, type: 'video' }, { id: 2, type: 'captions' }],
// trackSamples: {
// 1: videoSamples,
// 2: captionSamples.map(s => ({ duration: s.duration, data: s.data, isSync: true, dts: FRAME_DURATION, pts: FRAME_DURATION })),
// },
// });
const MP4 = (function() {
// ========================================================================
// Encoding Utilities
// ========================================================================
function uint8(v) {
return new Uint8Array([v & 0xFF]);
}
function uint16(v) {
return new Uint8Array([(v >> 8) & 0xFF, v & 0xFF]);
}
function uint32(v) {
return new Uint8Array([
(v >> 24) & 0xFF, (v >> 16) & 0xFF,
(v >> 8) & 0xFF, v & 0xFF
]);
}
function int32(v) {
const buf = new ArrayBuffer(4);
new DataView(buf).setInt32(0, v, false);
return new Uint8Array(buf);
}
function uint64(v) {
const hi = Math.floor(v / 0x100000000);
const lo = v >>> 0;
return new Uint8Array([
(hi >> 24) & 0xFF, (hi >> 16) & 0xFF, (hi >> 8) & 0xFF, hi & 0xFF,
(lo >> 24) & 0xFF, (lo >> 16) & 0xFF, (lo >> 8) & 0xFF, lo & 0xFF
]);
}
function fourCC(str) {
return new Uint8Array([
str.charCodeAt(0), str.charCodeAt(1),
str.charCodeAt(2), str.charCodeAt(3)
]);
}
function concat(...arrays) {
let totalLength = 0;
for (const arr of arrays)
totalLength += arr.byteLength;
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr instanceof Uint8Array ? arr : new Uint8Array(arr), offset);
offset += arr.byteLength;
}
return result;
}
function zeros(n) {
return new Uint8Array(n);
}
// ========================================================================
// Box Primitives (ISO 14496-12)
// ========================================================================
function box(type, ...payloads) {
const payload = concat(...payloads);
const size = 8 + payload.byteLength;
return concat(uint32(size), fourCC(type), payload);
}
function fullBox(type, version, flags, ...payloads) {
return box(type, uint8(version), new Uint8Array([
(flags >> 16) & 0xFF, (flags >> 8) & 0xFF, flags & 0xFF
]), ...payloads);
}
// ========================================================================
// Hardcoded Codec Configuration
// ========================================================================
// Generated with: ffmpeg -f lavfi -i "color=c=black:s=640x480:r=24" -frames:v 2
// -c:v libx264 -profile:v main -level 3.0 -bf 0 -refs 1
// -movflags frag_keyframe+empty_moov+default_base_moof
// H.264 Main Profile, Level 3.0, 640x480
const H264_SPS = new Uint8Array([
0x67, 0x4D, 0x40, 0x1E, 0xDA, 0x02, 0x80, 0xF6,
0xC0, 0x44, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00,
0x00, 0x03, 0x00, 0xC0, 0x3C, 0x58, 0xBA, 0x80
]);
const H264_PPS = new Uint8Array([0x68, 0xEF, 0x0F, 0x2C, 0x80]);
const VIDEO_WIDTH = 640;
const VIDEO_HEIGHT = 480;
const VIDEO_TIMESCALE_DEFAULT = 19200;
// AAC-LC AudioSpecificConfig: 44100 Hz, stereo
const AAC_AUDIO_SPECIFIC_CONFIG = new Uint8Array([0x12, 0x10]);
const AUDIO_SAMPLE_RATE = 44100;
const AUDIO_CHANNELS = 2;
const AUDIO_TIMESCALE_DEFAULT = 44100;
const VIDEO_CODEC_STRING = 'avc1.4d401e';
const AUDIO_CODEC_STRING = 'mp4a.40.2';
// Valid H.264 sample data generated by libx264 for a black 640x480 frame.
// Real NAL units with 4-byte length prefixes that the decoder can process
// without triggering MEDIA_ERR_DECODE errors.
const VIDEO_SYNC_SAMPLE = new Uint8Array([
0x00, 0x00, 0x00, 0x64, 0x65, 0x88, 0x84, 0x0B,
0xFF, 0xFE, 0xF6, 0xAE, 0xFC, 0xCB, 0x2B, 0x74,
0x7E, 0x95, 0x2E, 0x1D, 0x59, 0x7B, 0xB3, 0x51,
0xF2, 0xE8, 0x49, 0x72, 0xFD, 0x88, 0x30, 0x7D,
0x68, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00,
0x00, 0x08, 0x07, 0xEA, 0x2F, 0x7A, 0xE6, 0x31,
0x2D, 0xF2, 0x90, 0x00, 0x00, 0x04, 0xF0, 0x01,
0x1C, 0x06, 0x88, 0x3B, 0x42, 0xBA, 0x22, 0xE2,
0x10, 0x23, 0x83, 0x3C, 0x4B, 0x08, 0x50, 0xE9,
0x19, 0x40, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x00,
0x03, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00,
0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0x12, 0xF1
]);
const VIDEO_NONSYNC_SAMPLE = new Uint8Array([
0x00, 0x00, 0x00, 0x11, 0x41, 0x9A, 0x26, 0x21,
0x5F, 0xFE, 0x38, 0x40, 0x00, 0x00, 0x03, 0x00,
0x00, 0x03, 0x00, 0x05, 0x4C
]);
// Valid AAC-LC silence frame for 44100 Hz stereo, generated by libfdk-aac.
const AUDIO_SAMPLE_DATA = new Uint8Array([
0x21, 0x10, 0x04, 0x60, 0x8C, 0x1C
]);
// ========================================================================
// Init Segment Box Builders
// ========================================================================
function ftyp() {
return box('ftyp',
fourCC('isom'),
uint32(1),
fourCC('isom'), fourCC('iso6'), fourCC('dsms'),
fourCC('msix'), fourCC('dash')
);
}
function mvhd(timescale, duration) {
return fullBox('mvhd', 0, 0,
uint32(0), uint32(0),
uint32(timescale), uint32(duration),
uint32(0x00010000), // rate = 1.0
uint16(0x0100), // volume = 1.0
zeros(10),
uint32(0x00010000), zeros(4), zeros(4),
zeros(4), uint32(0x00010000), zeros(4),
zeros(4), zeros(4), uint32(0x40000000),
zeros(24),
uint32(0xFFFF)
);
}
function tkhd(trackId, duration, width, height, isAudio) {
return fullBox('tkhd', 0, 0x000003,
uint32(0), uint32(0),
uint32(trackId),
zeros(4),
uint32(duration),
zeros(8),
uint16(0),
uint16(isAudio ? 1 : 0),
uint16(isAudio ? 0x0100 : 0),
zeros(2),
uint32(0x00010000), zeros(4), zeros(4),
zeros(4), uint32(0x00010000), zeros(4),
zeros(4), zeros(4), uint32(0x40000000),
uint32((isAudio ? 0 : width) << 16),
uint32((isAudio ? 0 : height) << 16)
);
}
function mdhd(timescale, duration) {
return fullBox('mdhd', 0, 0,
uint32(0), uint32(0),
uint32(timescale), uint32(duration),
uint16(0x55C4), // language = 'und'
zeros(2)
);
}
function hdlr(handlerType, name) {
const nameBytes = new Uint8Array(name.length + 1);
for (let i = 0; i < name.length; i++)
nameBytes[i] = name.charCodeAt(i);
return fullBox('hdlr', 0, 0,
zeros(4), fourCC(handlerType), zeros(12), nameBytes
);
}
function vmhd() {
return fullBox('vmhd', 0, 1, zeros(8));
}
function smhd() {
return fullBox('smhd', 0, 0, zeros(4));
}
function dinf() {
return box('dinf',
fullBox('dref', 0, 0, uint32(1), fullBox('url ', 0, 1))
);
}
function avcC() {
return box('avcC',
uint8(1), uint8(0x4D), uint8(0x40), uint8(0x1E),
uint8(0xFF), uint8(0xE1),
uint16(H264_SPS.byteLength), H264_SPS,
uint8(1),
uint16(H264_PPS.byteLength), H264_PPS
);
}
function avc1() {
return box('avc1',
zeros(6), uint16(1), zeros(16),
uint16(VIDEO_WIDTH), uint16(VIDEO_HEIGHT),
uint32(0x00480000), uint32(0x00480000),
zeros(4), uint16(1), zeros(32),
uint16(0x0018), uint16(0xFFFF),
avcC()
);
}
function esds() {
return fullBox('esds', 0, 0,
new Uint8Array([
0x03, 0x19, 0x00, 0x00, 0x00,
0x04, 0x11, 0x40, 0x15,
0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x05, AAC_AUDIO_SPECIFIC_CONFIG.byteLength,
]),
AAC_AUDIO_SPECIFIC_CONFIG,
new Uint8Array([0x06, 0x01, 0x02])
);
}
function mp4a() {
return box('mp4a',
zeros(6), uint16(1), zeros(8),
uint16(AUDIO_CHANNELS), uint16(16), zeros(4),
uint32(AUDIO_SAMPLE_RATE << 16),
esds()
);
}
// Closed-caption sample entry. QuickTime/ISO form: 6-byte reserved + 2-byte
// data_reference_index, with no codec-specific fields. Valid for both 'c608'
// and 'c708' — only the fourcc differs.
function c608Entry() {
return box('c608', zeros(6), uint16(1));
}
// Null media header — 12-byte fullbox with no payload. ISO BMFF standard
// header for non-av tracks (subtitles, metadata, captions).
function nmhd() {
return fullBox('nmhd', 0, 0);
}
function stsd(trackType) {
let entry;
if (trackType === 'video')
entry = avc1();
else if (trackType === 'captions')
entry = c608Entry();
else if (trackType === 'audio')
entry = mp4a();
else
throw new Error(`stsd: unknown track type '${trackType}'`);
return fullBox('stsd', 0, 0,
uint32(1),
entry
);
}
function stbl(trackType) {
return box('stbl',
stsd(trackType),
fullBox('stts', 0, 0, uint32(0)),
fullBox('stsc', 0, 0, uint32(0)),
fullBox('stsz', 0, 0, uint32(0), uint32(0)),
fullBox('stco', 0, 0, uint32(0))
);
}
function minf(trackType) {
let header;
if (trackType === 'video')
header = vmhd();
else if (trackType === 'captions')
header = nmhd();
else if (trackType === 'audio')
header = smhd();
else
throw new Error(`minf: unknown track type '${trackType}'`);
return box('minf',
header,
dinf(), stbl(trackType)
);
}
function mdia(trackType, timescale, duration) {
let handlerType, handlerName;
if (trackType === 'video') {
handlerType = 'vide';
handlerName = 'VideoHandler';
} else if (trackType === 'captions') {
handlerType = 'clcp';
handlerName = 'Closed Caption Media Handler';
} else if (trackType === 'audio') {
handlerType = 'soun';
handlerName = 'SoundHandler';
} else
throw new Error(`mdia: unknown track type '${trackType}'`);
return box('mdia',
mdhd(timescale, duration),
hdlr(handlerType, handlerName),
minf(trackType)
);
}
function trak(trackDef) {
let defaultTimescale;
if (trackDef.type === 'video')
defaultTimescale = VIDEO_TIMESCALE_DEFAULT;
else if (trackDef.type === 'captions')
defaultTimescale = VIDEO_TIMESCALE_DEFAULT;
else if (trackDef.type === 'audio')
defaultTimescale = AUDIO_TIMESCALE_DEFAULT;
else
throw new Error(`trak: unknown track type '${trackDef.type}'`);
// 'captions' and 'audio' both want width/height = 0. Only 'video' carries geometry.
const isGeometric = trackDef.type === 'video';
const timescale = trackDef.timescale || defaultTimescale;
return box('trak',
tkhd(trackDef.id, 0, VIDEO_WIDTH, VIDEO_HEIGHT, !isGeometric),
mdia(trackDef.type, timescale, 0)
);
}
function trex(trackId) {
return fullBox('trex', 0, 0,
uint32(trackId), uint32(1), uint32(0), uint32(0), uint32(0)
);
}
function moov(movieTimescale, tracks) {
return box('moov',
mvhd(movieTimescale, 0),
box('mvex', ...tracks.map(t => trex(t.id))),
...tracks.map(t => trak(t))
);
}
// ========================================================================
// Media Segment Box Builders
// ========================================================================
function mfhd(sequenceNumber) {
return fullBox('mfhd', 0, 0, uint32(sequenceNumber));
}
function tfhd(trackId) {
return fullBox('tfhd', 0, 0x020000, uint32(trackId));
}
function tfdt(baseDecodeTime, use64bit) {
if (use64bit)
return fullBox('tfdt', 1, 0, uint64(baseDecodeTime));
return fullBox('tfdt', 0, 0, uint32(baseDecodeTime));
}
function sampleDataSize(isSync, trackType) {
if (trackType === 'video')
return isSync ? VIDEO_SYNC_SAMPLE.byteLength : VIDEO_NONSYNC_SAMPLE.byteLength;
if (trackType === 'audio')
return AUDIO_SAMPLE_DATA.byteLength;
throw new Error(`sampleDataSize: unknown track type '${trackType}'`);
}
function trun(samples, dataOffset, trackType) {
const needsSigned = samples.some(s => (s.compositionTimeOffset || 0) < 0);
const version = needsSigned ? 1 : 0;
// data-offset | sample-duration | sample-size | sample-flags | sample-cto
const flags = 0x01 | 0x100 | 0x200 | 0x400 | 0x800;
const entries = [];
for (const sample of samples) {
const size = sample.size || (sample.data ? sample.data.byteLength : sampleDataSize(sample.isSync, trackType));
// ISO 14496-12: sample_depends_on (bits 25-24), sample_is_non_sync_sample (bit 16)
const sampleFlags = sample.isSync ? 0x01000000 : 0x02010000;
const cto = sample.compositionTimeOffset || 0;
entries.push(concat(
uint32(sample.duration), uint32(size), uint32(sampleFlags),
needsSigned ? int32(cto) : uint32(cto)
));
}
return fullBox('trun', version, flags,
uint32(samples.length), int32(dataOffset), ...entries
);
}
function buildMdatData(samples, trackType) {
const parts = [];
for (const sample of samples) {
if (sample.data)
parts.push(sample.data);
else if (trackType === 'video')
parts.push(sample.isSync ? VIDEO_SYNC_SAMPLE : VIDEO_NONSYNC_SAMPLE);
else if (trackType === 'audio')
parts.push(AUDIO_SAMPLE_DATA.slice(0, sample.size || AUDIO_SAMPLE_DATA.byteLength));
else
throw new Error(`buildMdatData: unknown track type '${trackType}'`);
}
return concat(...parts);
}
// ========================================================================
// Public API
// ========================================================================
return {
VIDEO_CODEC: VIDEO_CODEC_STRING,
AUDIO_CODEC: AUDIO_CODEC_STRING,
VIDEO_TYPE: `video/mp4; codecs="${VIDEO_CODEC_STRING}"`,
AUDIO_TYPE: `audio/mp4; codecs="${AUDIO_CODEC_STRING}"`,
AV_TYPE: `video/mp4; codecs="${VIDEO_CODEC_STRING},${AUDIO_CODEC_STRING}"`,
// Concatenate Uint8Arrays. Exposed for tests that need to combine
// init and media segments into a single appendBuffer call.
concat,
// Generate a valid fMP4 initialization segment.
//
// options.timescale - Movie-level timescale (default: first track's timescale)
// options.tracks[] - Array of track definitions:
// .id - Track ID (integer)
// .type - 'video', 'audio', or 'captions'
// .timescale - Track timescale (optional, defaults per type)
initSegment(options) {
const tracks = options.tracks || [{ id: 1, type: 'video' }];
for (const trackDef of tracks) {
if (!trackDef.timescale) {
if (trackDef.type === 'video' || trackDef.type === 'captions')
trackDef.timescale = VIDEO_TIMESCALE_DEFAULT;
else if (trackDef.type === 'audio')
trackDef.timescale = AUDIO_TIMESCALE_DEFAULT;
else
throw new Error(`initSegment: unknown track type '${trackDef.type}'`);
}
}
const movieTimescale = options.timescale || tracks[0].timescale;
return concat(ftyp(), moov(movieTimescale, tracks));
},
// Generate a valid fMP4 media segment (moof + mdat).
//
// options.sequenceNumber - Fragment sequence number (default: 1)
// options.tracks[] - Array of track data:
// .id - Track ID (must match init segment)
// .type - 'video' or 'audio'
// .baseDecodeTime - Base decode time in track timescale units
// .samples[] - Array of sample descriptors:
// .duration - Sample duration in timescale units
// .compositionTimeOffset - CTO (PTS = DTS + CTO), default 0
// .isSync - true for sync/keyframe samples
// .size - Override sample data size (optional)
// .data - Override sample data (Uint8Array, optional)
mediaSegment(options) {
const seqNum = options.sequenceNumber || 1;
const tracks = options.tracks;
const trafs = [];
const mdatParts = [];
for (const track of tracks) {
const trackType = track.type || 'video';
const use64bit = track.baseDecodeTime > 0xFFFFFFFF;
const sampleData = buildMdatData(track.samples, trackType);
trafs.push({
trackId: track.id, sampleData, trackType,
samples: track.samples, use64bit,
baseDecodeTime: track.baseDecodeTime,
});
mdatParts.push(sampleData);
}
const mdatBox = box('mdat', concat(...mdatParts));
const mfhdBox = mfhd(seqNum);
// First pass: build moof to measure its size (data_offset depends on it).
const dummyTrafs = trafs.map(t => box('traf',
tfhd(t.trackId),
tfdt(t.baseDecodeTime, t.use64bit),
trun(t.samples, 0, t.trackType)
));
const moofSize = box('moof', mfhdBox, ...dummyTrafs).byteLength;
// Second pass: build traf boxes with correct data_offsets.
let mdatPayloadOffset = 0;
const finalTrafs = trafs.map(t => {
const dataOffset = moofSize + 8 + mdatPayloadOffset;
mdatPayloadOffset += t.sampleData.byteLength;
return box('traf',
tfhd(t.trackId),
tfdt(t.baseDecodeTime, t.use64bit),
trun(t.samples, dataOffset, t.trackType)
);
});
return concat(box('moof', mfhdBox, ...finalTrafs), mdatBox);
},
// Convenience: generate init + media from a flat sample list.
//
// options.timescale - Timescale for all timestamps (default: 1000)
// options.track - Single track: { id, type }
// options.tracks - Or multiple tracks: [{ id, type }, ...]
// options.samples - For single track: [{ pts, dts, duration, isSync }, ...]
// options.trackSamples - For multiple tracks: { trackId: [samples], ... }
//
// Returns { init: Uint8Array, media: Uint8Array, mimeType: string }
samples(options) {
const timescale = options.timescale || 1000;
function convertSamples(rawSamples) {
return (rawSamples || []).map(s => ({
duration: s.duration,
compositionTimeOffset: (s.pts !== undefined && s.dts !== undefined) ? (s.pts - s.dts) : 0,
isSync: s.isSync !== undefined ? s.isSync : !!(s.flags & 1),
size: s.size,
data: s.data,
}));
}
function minDts(rawSamples) {
if (!rawSamples || !rawSamples.length)
return 0;
return Math.max(0, Math.min(...rawSamples.map(s => s.dts !== undefined ? s.dts : s.pts)));
}
if (options.track) {
const track = { id: options.track.id || 1, type: options.track.type || 'video', timescale };
const init = this.initSegment({ timescale, tracks: [track] });
const media = this.mediaSegment({
sequenceNumber: options.sequenceNumber || 1,
tracks: [{
id: track.id, type: track.type,
baseDecodeTime: minDts(options.samples),
samples: convertSamples(options.samples),
}]
});
const mimeType = (track.type === 'video') ? this.VIDEO_TYPE : this.AUDIO_TYPE;
return { init, media, mimeType };
}
const tracks = (options.tracks || []).map(t => ({
id: t.id, type: t.type || 'video', timescale,
}));
const init = this.initSegment({ timescale, tracks });
const trackSamples = options.trackSamples || {};
const media = this.mediaSegment({
sequenceNumber: options.sequenceNumber || 1,
tracks: tracks.map(t => ({
id: t.id, type: t.type,
baseDecodeTime: minDts(trackSamples[t.id]),
samples: convertSamples(trackSamples[t.id]),
})),
});
const hasVideo = tracks.some(t => t.type === 'video');
const hasAudio = tracks.some(t => t.type === 'audio');
let mimeType;
if (hasVideo && hasAudio)
mimeType = this.AV_TYPE;
else if (hasVideo)
mimeType = this.VIDEO_TYPE;
else
mimeType = this.AUDIO_TYPE;
return { init, media, mimeType };
},
// Generate only a media segment from a flat sample list (for appends
// after the init segment has already been sent).
mediaSamples(options) {
const timescale = options.timescale || 1000;
const track = options.track || { id: 1, type: 'video' };
const rawSamples = options.samples || [];
const samples = rawSamples.map(s => ({
duration: s.duration,
compositionTimeOffset: (s.pts !== undefined && s.dts !== undefined) ? (s.pts - s.dts) : 0,
isSync: s.isSync !== undefined ? s.isSync : !!(s.flags & 1),
size: s.size,
data: s.data,
}));
const baseDecodeTime = rawSamples.length
? Math.max(0, Math.min(...rawSamples.map(s => s.dts !== undefined ? s.dts : s.pts)))
: 0;
return this.mediaSegment({
sequenceNumber: options.sequenceNumber || 1,
tracks: [{
id: track.id || 1, type: track.type || 'video',
baseDecodeTime, samples,
}]
});
},
};
})();