edit: intégration fond animé et réédition lien signal vega

This commit is contained in:
2026-03-16 20:18:28 +01:00
parent c3e7542ca6
commit e01c10285d
37 changed files with 10193 additions and 4 deletions
@@ -0,0 +1,740 @@
import {
AudioTrack,
GLOBAL_TIMESCALE,
SUPPORTED_AUDIO_CODECS,
SUPPORTED_VIDEO_CODECS,
Sample,
Track,
VideoTrack
} from './muxer';
import {
ascii,
i16,
i32,
intoTimescale,
last,
lastPresentedSample,
u16,
u64,
u8,
u32,
fixed_16_16,
fixed_8_8,
u24,
IDENTITY_MATRIX,
matrixToBytes,
rotationMatrix,
isU32,
TransformationMatrix
} from './misc';
export interface Box {
type: string,
contents?: Uint8Array,
children?: Box[],
size?: number,
largeSize?: boolean
}
type NestedNumberArray = (number | NestedNumberArray)[];
export const box = (type: string, contents?: NestedNumberArray, children?: Box[]): Box => ({
type,
contents: contents && new Uint8Array(contents.flat(10) as number[]),
children
});
/** A FullBox always starts with a version byte, followed by three flag bytes. */
export const fullBox = (
type: string,
version: number,
flags: number,
contents?: NestedNumberArray,
children?: Box[]
) => box(
type,
[u8(version), u24(flags), contents ?? []],
children
);
/**
* File Type Compatibility Box: Allows the reader to determine whether this is a type of file that the
* reader understands.
*/
export const ftyp = (details: {
holdsAvc: boolean,
fragmented: boolean
}) => {
// You can find the full logic for this at
// https://github.com/FFmpeg/FFmpeg/blob/de2fb43e785773738c660cdafb9309b1ef1bc80d/libavformat/movenc.c#L5518
// Obviously, this lib only needs a small subset of that logic.
let minorVersion = 0x200;
if (details.fragmented) return box('ftyp', [
ascii('iso5'), // Major brand
u32(minorVersion), // Minor version
// Compatible brands
ascii('iso5'),
ascii('iso6'),
ascii('mp41')
]);
return box('ftyp', [
ascii('isom'), // Major brand
u32(minorVersion), // Minor version
// Compatible brands
ascii('isom'),
details.holdsAvc ? ascii('avc1') : [],
ascii('mp41')
]);
};
/** Movie Sample Data Box. Contains the actual frames/samples of the media. */
export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSize: reserveLargeSize });
/** Free Space Box: A box that designates unused space in the movie data file. */
export const free = (size: number): Box => ({ type: 'free', size });
/**
* Movie Box: Used to specify the information that defines a movie - that is, the information that allows
* an application to interpret the sample data that is stored elsewhere.
*/
export const moov = (tracks: Track[], creationTime: number, fragmented = false) => box('moov', null, [
mvhd(creationTime, tracks),
...tracks.map(x => trak(x, creationTime)),
fragmented ? mvex(tracks) : null
]);
/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */
export const mvhd = (
creationTime: number,
tracks: Track[]
) => {
let duration = intoTimescale(Math.max(
0,
...tracks.
filter(x => x.samples.length > 0).
map(x => {
const lastSample = lastPresentedSample(x.samples);
return lastSample.presentationTimestamp + lastSample.duration;
})
), GLOBAL_TIMESCALE);
let nextTrackId = Math.max(...tracks.map(x => x.id)) + 1;
// Conditionally use u64 if u32 isn't enough
let needsU64 = !isU32(creationTime) || !isU32(duration);
let u32OrU64 = needsU64 ? u64 : u32;
return fullBox('mvhd', +needsU64, 0, [
u32OrU64(creationTime), // Creation time
u32OrU64(creationTime), // Modification time
u32(GLOBAL_TIMESCALE), // Timescale
u32OrU64(duration), // Duration
fixed_16_16(1), // Preferred rate
fixed_8_8(1), // Preferred volume
Array(10).fill(0), // Reserved
matrixToBytes(IDENTITY_MATRIX), // Matrix
Array(24).fill(0), // Pre-defined
u32(nextTrackId) // Next track ID
]);
};
/**
* Track Box: Defines a single track of a movie. A movie may consist of one or more tracks. Each track is
* independent of the other tracks in the movie and carries its own temporal and spatial information. Each Track Box
* contains its associated Media Box.
*/
export const trak = (track: Track, creationTime: number) => box('trak', null, [
tkhd(track, creationTime),
mdia(track, creationTime)
]);
/** Track Header Box: Specifies the characteristics of a single track within a movie. */
export const tkhd = (
track: Track,
creationTime: number
) => {
let lastSample = lastPresentedSample(track.samples);
let durationInGlobalTimescale = intoTimescale(
lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0,
GLOBAL_TIMESCALE
);
let needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale);
let u32OrU64 = needsU64 ? u64 : u32;
let matrix: TransformationMatrix;
if (track.info.type === 'video') {
matrix = typeof track.info.rotation === 'number' ? rotationMatrix(track.info.rotation) : track.info.rotation;
} else {
matrix = IDENTITY_MATRIX;
}
return fullBox('tkhd', +needsU64, 3, [
u32OrU64(creationTime), // Creation time
u32OrU64(creationTime), // Modification time
u32(track.id), // Track ID
u32(0), // Reserved
u32OrU64(durationInGlobalTimescale), // Duration
Array(8).fill(0), // Reserved
u16(0), // Layer
u16(0), // Alternate group
fixed_8_8(track.info.type === 'audio' ? 1 : 0), // Volume
u16(0), // Reserved
matrixToBytes(matrix), // Matrix
fixed_16_16(track.info.type === 'video' ? track.info.width : 0), // Track width
fixed_16_16(track.info.type === 'video' ? track.info.height : 0) // Track height
]);
};
/** Media Box: Describes and define a track's media type and sample data. */
export const mdia = (track: Track, creationTime: number) => box('mdia', null, [
mdhd(track, creationTime),
hdlr(track.info.type === 'video' ? 'vide' : 'soun'),
minf(track)
]);
/** Media Header Box: Specifies the characteristics of a media, including timescale and duration. */
export const mdhd = (
track: Track,
creationTime: number
) => {
let lastSample = lastPresentedSample(track.samples);
let localDuration = intoTimescale(
lastSample ? lastSample.presentationTimestamp + lastSample.duration : 0,
track.timescale
);
let needsU64 = !isU32(creationTime) || !isU32(localDuration);
let u32OrU64 = needsU64 ? u64 : u32;
return fullBox('mdhd', +needsU64, 0, [
u32OrU64(creationTime), // Creation time
u32OrU64(creationTime), // Modification time
u32(track.timescale), // Timescale
u32OrU64(localDuration), // Duration
u16(0b01010101_11000100), // Language ("und", undetermined)
u16(0) // Quality
]);
};
/** Handler Reference Box: Specifies the media handler component that is to be used to interpret the media's data. */
export const hdlr = (componentSubtype: string) => fullBox('hdlr', 0, 0, [
ascii('mhlr'), // Component type
ascii(componentSubtype), // Component subtype
u32(0), // Component manufacturer
u32(0), // Component flags
u32(0), // Component flags mask
ascii('mp4-muxer-hdlr', true) // Component name
]);
/**
* Media Information Box: Stores handler-specific information for a track's media data. The media handler uses this
* information to map from media time to media data and to process the media data.
*/
export const minf = (track: Track) => box('minf', null, [
track.info.type === 'video' ? vmhd() : smhd(),
dinf(),
stbl(track)
]);
/** Video Media Information Header Box: Defines specific color and graphics mode information. */
export const vmhd = () => fullBox('vmhd', 0, 1, [
u16(0), // Graphics mode
u16(0), // Opcolor R
u16(0), // Opcolor G
u16(0) // Opcolor B
]);
/** Sound Media Information Header Box: Stores the sound media's control information, such as balance. */
export const smhd = () => fullBox('smhd', 0, 0, [
u16(0), // Balance
u16(0) // Reserved
]);
/**
* Data Information Box: Contains information specifying the data handler component that provides access to the
* media data. The data handler component uses the Data Information Box to interpret the media's data.
*/
export const dinf = () => box('dinf', null, [
dref()
]);
/**
* Data Reference Box: Contains tabular data that instructs the data handler component how to access the media's data.
*/
export const dref = () => fullBox('dref', 0, 0, [
u32(1) // Entry count
], [
url()
]);
export const url = () => fullBox('url ', 0, 1); // Self-reference flag enabled
/**
* Sample Table Box: Contains information for converting from media time to sample number to sample location. This box
* also indicates how to interpret the sample (for example, whether to decompress the video data and, if so, how).
*/
export const stbl = (track: Track) => {
const needsCtts = track.compositionTimeOffsetTable.length > 1 ||
track.compositionTimeOffsetTable.some((x) => x.sampleCompositionTimeOffset !== 0);
return box('stbl', null, [
stsd(track),
stts(track),
stss(track),
stsc(track),
stsz(track),
stco(track),
needsCtts ? ctts(track) : null
]);
};
/**
* Sample Description Box: Stores information that allows you to decode samples in the media. The data stored in the
* sample description varies, depending on the media type.
*/
export const stsd = (track: Track) => fullBox('stsd', 0, 0, [
u32(1) // Entry count
], [
track.info.type === 'video'
? videoSampleDescription(
VIDEO_CODEC_TO_BOX_NAME[track.info.codec],
track as VideoTrack
)
: soundSampleDescription(
AUDIO_CODEC_TO_BOX_NAME[track.info.codec],
track as AudioTrack
)
]);
/** Video Sample Description Box: Contains information that defines how to interpret video media data. */
export const videoSampleDescription = (
compressionType: string,
track: VideoTrack
) => box(compressionType, [
Array(6).fill(0), // Reserved
u16(1), // Data reference index
u16(0), // Pre-defined
u16(0), // Reserved
Array(12).fill(0), // Pre-defined
u16(track.info.width), // Width
u16(track.info.height), // Height
u32(0x00480000), // Horizontal resolution
u32(0x00480000), // Vertical resolution
u32(0), // Reserved
u16(1), // Frame count
Array(32).fill(0), // Compressor name
u16(0x0018), // Depth
i16(0xffff) // Pre-defined
], [
VIDEO_CODEC_TO_CONFIGURATION_BOX[track.info.codec](track)
]);
/** AVC Configuration Box: Provides additional information to the decoder. */
export const avcC = (track: VideoTrack) => track.info.decoderConfig && box('avcC', [
// For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here
...new Uint8Array(track.info.decoderConfig.description as ArrayBuffer)
]);
/** HEVC Configuration Box: Provides additional information to the decoder. */
export const hvcC = (track: VideoTrack) => track.info.decoderConfig && box('hvcC', [
// For HEVC, description is a HEVCDecoderConfigurationRecord, so nothing else to do here
...new Uint8Array(track.info.decoderConfig.description as ArrayBuffer)
]);
/** VP9 Configuration Box: Provides additional information to the decoder. */
export const vpcC = (track: VideoTrack) => {
// Reference: https://www.webmproject.org/vp9/mp4/
if (!track.info.decoderConfig) {
return null;
}
let decoderConfig = track.info.decoderConfig;
if (!decoderConfig.colorSpace) {
throw new Error(`'colorSpace' is required in the decoder config for VP9.`);
}
let parts = decoderConfig.codec.split('.');
let profile = Number(parts[1]);
let level = Number(parts[2]);
let bitDepth = Number(parts[3]);
let chromaSubsampling = 0;
let thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + Number(decoderConfig.colorSpace.fullRange);
// Set all to undetermined. We could determine them using the codec color space info, but there's no need.
let colourPrimaries = 2;
let transferCharacteristics = 2;
let matrixCoefficients = 2;
return fullBox('vpcC', 1, 0, [
u8(profile), // Profile
u8(level), // Level
u8(thirdByte), // Bit depth, chroma subsampling, full range
u8(colourPrimaries), // Colour primaries
u8(transferCharacteristics), // Transfer characteristics
u8(matrixCoefficients), // Matrix coefficients
u16(0) // Codec initialization data size
]);
};
/** AV1 Configuration Box: Provides additional information to the decoder. */
export const av1C = () => {
// Reference: https://aomediacodec.github.io/av1-isobmff/
let marker = 1;
let version = 1;
let firstByte = (marker << 7) + version;
// The box contents are not correct like this, but its length is. Getting the values for the last three bytes
// requires peeking into the bitstream of the coded chunks. Might come back later.
return box('av1C', [
firstByte,
0,
0,
0
]);
};
/** Sound Sample Description Box: Contains information that defines how to interpret sound media data. */
export const soundSampleDescription = (
compressionType: string,
track: AudioTrack
) => box(compressionType, [
Array(6).fill(0), // Reserved
u16(1), // Data reference index
u16(0), // Version
u16(0), // Revision level
u32(0), // Vendor
u16(track.info.numberOfChannels), // Number of channels
u16(16), // Sample size (bits)
u16(0), // Compression ID
u16(0), // Packet size
fixed_16_16(track.info.sampleRate) // Sample rate
], [
AUDIO_CODEC_TO_CONFIGURATION_BOX[track.info.codec](track)
]);
/** MPEG-4 Elementary Stream Descriptor Box. */
export const esds = (track: Track) => {
let description = new Uint8Array(track.info.decoderConfig.description as ArrayBuffer);
return fullBox('esds', 0, 0, [
// https://stackoverflow.com/a/54803118
u32(0x03808080), // TAG(3) = Object Descriptor ([2])
u8(0x20 + description.byteLength), // length of this OD (which includes the next 2 tags)
u16(1), // ES_ID = 1
u8(0x00), // flags etc = 0
u32(0x04808080), // TAG(4) = ES Descriptor ([2]) embedded in above OD
u8(0x12 + description.byteLength), // length of this ESD
u8(0x40), // MPEG-4 Audio
u8(0x15), // stream type(6bits)=5 audio, flags(2bits)=1
u24(0), // 24bit buffer size
u32(0x0001FC17), // max bitrate
u32(0x0001FC17), // avg bitrate
u32(0x05808080), // TAG(5) = ASC ([2],[3]) embedded in above OD
u8(description.byteLength), // length
...description,
u32(0x06808080), // TAG(6)
u8(0x01), // length
u8(0x02) // data
]);
};
/** Opus Specific Box. */
export const dOps = (track: AudioTrack) => box('dOps', [
u8(0), // Version
u8(track.info.numberOfChannels), // OutputChannelCount
u16(3840), // PreSkip, should be at least 80 milliseconds worth of playback, measured in 48000 Hz samples
u32(track.info.sampleRate), // InputSampleRate
fixed_8_8(0), // OutputGain
u8(0) // ChannelMappingFamily
]);
/**
* Time-To-Sample Box: Stores duration information for a media's samples, providing a mapping from a time in a media
* to the corresponding data sample. The table is compact, meaning that consecutive samples with the same time delta
* will be grouped.
*/
export const stts = (track: Track) => {
return fullBox('stts', 0, 0, [
u32(track.timeToSampleTable.length), // Number of entries
track.timeToSampleTable.map(x => [ // Time-to-sample table
u32(x.sampleCount), // Sample count
u32(x.sampleDelta) // Sample duration
])
]);
};
/** Sync Sample Box: Identifies the key frames in the media, marking the random access points within a stream. */
export const stss = (track: Track) => {
if (track.samples.every(x => x.type === 'key')) return null; // No stss box -> every frame is a key frame
let keySamples = [...track.samples.entries()].filter(([, sample]) => sample.type === 'key');
return fullBox('stss', 0, 0, [
u32(keySamples.length), // Number of entries
keySamples.map(([index]) => u32(index + 1)) // Sync sample table
]);
};
/**
* Sample-To-Chunk Box: As samples are added to a media, they are collected into chunks that allow optimized data
* access. A chunk contains one or more samples. Chunks in a media may have different sizes, and the samples within a
* chunk may have different sizes. The Sample-To-Chunk Box stores chunk information for the samples in a media, stored
* in a compactly-coded fashion.
*/
export const stsc = (track: Track) => {
return fullBox('stsc', 0, 0, [
u32(track.compactlyCodedChunkTable.length), // Number of entries
track.compactlyCodedChunkTable.map(x => [ // Sample-to-chunk table
u32(x.firstChunk), // First chunk
u32(x.samplesPerChunk), // Samples per chunk
u32(1) // Sample description index
])
]);
};
/** Sample Size Box: Specifies the byte size of each sample in the media. */
export const stsz = (track: Track) => fullBox('stsz', 0, 0, [
u32(0), // Sample size (0 means non-constant size)
u32(track.samples.length), // Number of entries
track.samples.map(x => u32(x.size)) // Sample size table
]);
/** Chunk Offset Box: Identifies the location of each chunk of data in the media's data stream, relative to the file. */
export const stco = (track: Track) => {
if (track.finalizedChunks.length > 0 && last(track.finalizedChunks).offset >= 2**32) {
// If the file is large, use the co64 box
return fullBox('co64', 0, 0, [
u32(track.finalizedChunks.length), // Number of entries
track.finalizedChunks.map(x => u64(x.offset)) // Chunk offset table
]);
}
return fullBox('stco', 0, 0, [
u32(track.finalizedChunks.length), // Number of entries
track.finalizedChunks.map(x => u32(x.offset)) // Chunk offset table
]);
};
/** Composition Time to Sample Box: Stores composition time offset information (PTS-DTS) for a
* media's samples. The table is compact, meaning that consecutive samples with the same time
* composition time offset will be grouped. */
export const ctts = (track: Track) => {
return fullBox('ctts', 0, 0, [
u32(track.compositionTimeOffsetTable.length), // Number of entries
track.compositionTimeOffsetTable.map(x => [ // Time-to-sample table
u32(x.sampleCount), // Sample count
u32(x.sampleCompositionTimeOffset) // Sample offset
])
]);
};
/**
* Movie Extends Box: This box signals to readers that the file is fragmented. Contains a single Track Extends Box
* for each track in the movie.
*/
export const mvex = (tracks: Track[]) => {
return box('mvex', null, tracks.map(trex));
};
/** Track Extends Box: Contains the default values used by the movie fragments. */
export const trex = (track: Track) => {
return fullBox('trex', 0, 0, [
u32(track.id), // Track ID
u32(1), // Default sample description index
u32(0), // Default sample duration
u32(0), // Default sample size
u32(0) // Default sample flags
]);
};
/**
* Movie Fragment Box: The movie fragments extend the presentation in time. They provide the information that would
* previously have been in the Movie Box.
*/
export const moof = (sequenceNumber: number, tracks: Track[]) => {
return box('moof', null, [
mfhd(sequenceNumber),
...tracks.map(traf)
]);
};
/** Movie Fragment Header Box: Contains a sequence number as a safety check. */
export const mfhd = (sequenceNumber: number) => {
return fullBox('mfhd', 0, 0, [
u32(sequenceNumber) // Sequence number
]);
};
const fragmentSampleFlags = (sample: Sample) => {
let byte1 = 0;
let byte2 = 0;
let byte3 = 0;
let byte4 = 0;
let sampleIsDifferenceSample = sample.type === 'delta';
byte2 |= +sampleIsDifferenceSample;
if (sampleIsDifferenceSample) {
byte1 |= 1; // There is redundant coding in this sample
} else {
byte1 |= 2; // There is no redundant coding in this sample
}
// Note that there are a lot of other flags to potentially set here, but most are irrelevant / non-necessary
return byte1 << 24 | byte2 << 16 | byte3 << 8 | byte4;
};
/** Track Fragment Box */
export const traf = (track: Track) => {
return box('traf', null, [
tfhd(track),
tfdt(track),
trun(track)
]);
};
/** Track Fragment Header Box: Provides a reference to the extended track, and flags. */
export const tfhd = (track: Track) => {
let tfFlags = 0;
tfFlags |= 0x00008; // Default sample duration present
tfFlags |= 0x00010; // Default sample size present
tfFlags |= 0x00020; // Default sample flags present
tfFlags |= 0x20000; // Default base is moof
// Prefer the second sample over the first one, as the first one is a sync sample and therefore the "odd one out"
let referenceSample = track.currentChunk.samples[1] ?? track.currentChunk.samples[0];
let referenceSampleInfo = {
duration: referenceSample.timescaleUnitsToNextSample,
size: referenceSample.size,
flags: fragmentSampleFlags(referenceSample)
};
return fullBox('tfhd', 0, tfFlags, [
u32(track.id), // Track ID
u32(referenceSampleInfo.duration), // Default sample duration
u32(referenceSampleInfo.size), // Default sample size
u32(referenceSampleInfo.flags) // Default sample flags
]);
};
/**
* Track Fragment Decode Time Box: Provides the absolute decode time of the first sample of the fragment. This is
* useful for performing random access on the media file.
*/
export const tfdt = (track: Track) => {
return fullBox('tfdt', 1, 0, [
u64(intoTimescale(track.currentChunk.startTimestamp, track.timescale)) // Base Media Decode Time
]);
};
/** Track Run Box: Specifies a run of contiguous samples for a given track. */
export const trun = (track: Track) => {
let allSampleDurations = track.currentChunk.samples.map(x => x.timescaleUnitsToNextSample);
let allSampleSizes = track.currentChunk.samples.map(x => x.size);
let allSampleFlags = track.currentChunk.samples.map(fragmentSampleFlags);
let allSampleCompositionTimeOffsets = track.currentChunk.samples.
map(x => intoTimescale(x.presentationTimestamp - x.decodeTimestamp, track.timescale));
let uniqueSampleDurations = new Set(allSampleDurations);
let uniqueSampleSizes = new Set(allSampleSizes);
let uniqueSampleFlags = new Set(allSampleFlags);
let uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets);
let firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1];
let sampleDurationPresent = uniqueSampleDurations.size > 1;
let sampleSizePresent = uniqueSampleSizes.size > 1;
let sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1;
let sampleCompositionTimeOffsetsPresent =
uniqueSampleCompositionTimeOffsets.size > 1 || [...uniqueSampleCompositionTimeOffsets].some(x => x !== 0);
let flags = 0;
flags |= 0x0001; // Data offset present
flags |= 0x0004 * +firstSampleFlagsPresent; // First sample flags present
flags |= 0x0100 * +sampleDurationPresent; // Sample duration present
flags |= 0x0200 * +sampleSizePresent; // Sample size present
flags |= 0x0400 * +sampleFlagsPresent; // Sample flags present
flags |= 0x0800 * +sampleCompositionTimeOffsetsPresent; // Sample composition time offsets present
return fullBox('trun', 1, flags, [
u32(track.currentChunk.samples.length), // Sample count
u32(track.currentChunk.offset - track.currentChunk.moofOffset || 0), // Data offset
firstSampleFlagsPresent ? u32(allSampleFlags[0]) : [],
track.currentChunk.samples.map((_, i) => [
sampleDurationPresent ? u32(allSampleDurations[i]) : [], // Sample duration
sampleSizePresent ? u32(allSampleSizes[i]) : [], // Sample size
sampleFlagsPresent ? u32(allSampleFlags[i]) : [], // Sample flags
// Sample composition time offsets
sampleCompositionTimeOffsetsPresent ? i32(allSampleCompositionTimeOffsets[i]) : []
])
]);
};
/**
* Movie Fragment Random Access Box: For each track, provides pointers to sync samples within the file
* for random access.
*/
export const mfra = (tracks: Track[]) => {
return box('mfra', null, [
...tracks.map(tfra),
mfro()
]);
};
/** Track Fragment Random Access Box: Provides pointers to sync samples within the file for random access. */
export const tfra = (track: Track, trackIndex: number) => {
let version = 1; // Using this version allows us to use 64-bit time and offset values
return fullBox('tfra', version, 0, [
u32(track.id), // Track ID
u32(0b111111), // This specifies that traf number, trun number and sample number are 32-bit ints
u32(track.finalizedChunks.length), // Number of entries
track.finalizedChunks.map(chunk => [
u64(intoTimescale(chunk.startTimestamp, track.timescale)), // Time
u64(chunk.moofOffset), // moof offset
u32(trackIndex + 1), // traf number
u32(1), // trun number
u32(1) // Sample number
])
]);
};
/**
* Movie Fragment Random Access Offset Box: Provides the size of the enclosing mfra box. This box can be used by readers
* to quickly locate the mfra box by searching from the end of the file.
*/
export const mfro = () => {
return fullBox('mfro', 0, 0, [
// This value needs to be overwritten manually from the outside, where the actual size of the enclosing mfra box
// is known
u32(0) // Size
]);
};
const VIDEO_CODEC_TO_BOX_NAME: Record<typeof SUPPORTED_VIDEO_CODECS[number], string> = {
'avc': 'avc1',
'hevc': 'hvc1',
'vp9': 'vp09',
'av1': 'av01'
};
const VIDEO_CODEC_TO_CONFIGURATION_BOX: Record<typeof SUPPORTED_VIDEO_CODECS[number], (track: VideoTrack) => Box> = {
'avc': avcC,
'hevc': hvcC,
'vp9': vpcC,
'av1': av1C
};
const AUDIO_CODEC_TO_BOX_NAME: Record<typeof SUPPORTED_AUDIO_CODECS[number], string> = {
'aac': 'mp4a',
'opus': 'Opus'
};
const AUDIO_CODEC_TO_CONFIGURATION_BOX: Record<typeof SUPPORTED_AUDIO_CODECS[number], (track: AudioTrack) => Box> = {
'aac': esds,
'opus': dOps
};
@@ -0,0 +1,2 @@
export { Muxer } from './muxer';
export * from './target';
@@ -0,0 +1,117 @@
import { Sample } from './muxer';
let bytes = new Uint8Array(8);
let view = new DataView(bytes.buffer);
export const u8 = (value: number) => {
return [(value % 0x100 + 0x100) % 0x100];
};
export const u16 = (value: number) => {
view.setUint16(0, value, false);
return [bytes[0], bytes[1]];
};
export const i16 = (value: number) => {
view.setInt16(0, value, false);
return [bytes[0], bytes[1]];
};
export const u24 = (value: number) => {
view.setUint32(0, value, false);
return [bytes[1], bytes[2], bytes[3]];
};
export const u32 = (value: number) => {
view.setUint32(0, value, false);
return [bytes[0], bytes[1], bytes[2], bytes[3]];
};
export const i32 = (value: number) => {
view.setInt32(0, value, false);
return [bytes[0], bytes[1], bytes[2], bytes[3]];
};
export const u64 = (value: number) => {
view.setUint32(0, Math.floor(value / 2**32), false);
view.setUint32(4, value, false);
return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]];
};
export const fixed_8_8 = (value: number) => {
view.setInt16(0, 2**8 * value, false);
return [bytes[0], bytes[1]];
};
export const fixed_16_16 = (value: number) => {
view.setInt32(0, 2**16 * value, false);
return [bytes[0], bytes[1], bytes[2], bytes[3]];
};
export const fixed_2_30 = (value: number) => {
view.setInt32(0, 2**30 * value, false);
return [bytes[0], bytes[1], bytes[2], bytes[3]];
};
export const ascii = (text: string, nullTerminated = false) => {
let bytes = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i));
if (nullTerminated) bytes.push(0x00);
return bytes;
};
export const last = <T>(arr: T[]) => {
return arr && arr[arr.length - 1];
};
export const lastPresentedSample = (samples: Sample[]): Sample | undefined => {
let result: Sample | undefined = undefined;
for (let sample of samples) {
if (!result || sample.presentationTimestamp > result.presentationTimestamp) {
result = sample;
}
}
return result;
};
export const intoTimescale = (timeInSeconds: number, timescale: number, round = true) => {
let value = timeInSeconds * timescale;
return round ? Math.round(value) : value;
};
export type TransformationMatrix = [number, number, number, number, number, number, number, number, number];
export const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => {
let theta = rotationInDegrees * (Math.PI / 180);
let cosTheta = Math.cos(theta);
let sinTheta = Math.sin(theta);
// Matrices are post-multiplied in MP4, meaning this is the transpose of your typical rotation matrix
return [
cosTheta, sinTheta, 0,
-sinTheta, cosTheta, 0,
0, 0, 1
];
};
export const IDENTITY_MATRIX = rotationMatrix(0);
export const matrixToBytes = (matrix: TransformationMatrix) => {
return [
fixed_16_16(matrix[0]), fixed_16_16(matrix[1]), fixed_2_30(matrix[2]),
fixed_16_16(matrix[3]), fixed_16_16(matrix[4]), fixed_2_30(matrix[5]),
fixed_16_16(matrix[6]), fixed_16_16(matrix[7]), fixed_2_30(matrix[8])
];
};
export const deepClone = <T>(x: T): T => {
if (!x) return x;
if (typeof x !== 'object') return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
return Object.fromEntries(Object.entries(x).map(([key, value]) => [key, deepClone(value)])) as T;
};
export const isU32 = (value: number) => {
return value >= 0 && value < 2**32;
};
@@ -0,0 +1,842 @@
import { Box, free, ftyp, mdat, mfra, moof, moov } from './box';
import { deepClone, intoTimescale, last, TransformationMatrix } from './misc';
import { ArrayBufferTarget, FileSystemWritableFileStreamTarget, StreamTarget, Target } from './target';
import {
Writer,
ArrayBufferTargetWriter,
StreamTargetWriter,
ChunkedStreamTargetWriter,
FileSystemWritableFileStreamTargetWriter
} from './writer';
export const GLOBAL_TIMESCALE = 1000;
export const SUPPORTED_VIDEO_CODECS = ['avc', 'hevc', 'vp9', 'av1'] as const;
export const SUPPORTED_AUDIO_CODECS = ['aac', 'opus'] as const;
const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
const FIRST_TIMESTAMP_BEHAVIORS = ['strict', 'offset', 'cross-track-offset'] as const;
interface VideoOptions {
codec: typeof SUPPORTED_VIDEO_CODECS[number],
width: number,
height: number,
rotation?: 0 | 90 | 180 | 270 | TransformationMatrix
}
interface AudioOptions {
codec: typeof SUPPORTED_AUDIO_CODECS[number],
numberOfChannels: number,
sampleRate: number
}
type Mp4MuxerOptions<T extends Target> = {
target: T,
video?: VideoOptions,
audio?: AudioOptions,
fastStart: false | 'in-memory' | 'fragmented' | {
expectedVideoChunks?: number,
expectedAudioChunks?: number
},
firstTimestampBehavior?: typeof FIRST_TIMESTAMP_BEHAVIORS[number]
};
export interface Track {
id: number,
info: {
type: 'video',
codec: VideoOptions['codec'],
width: number,
height: number,
rotation: 0 | 90 | 180 | 270 | TransformationMatrix,
decoderConfig: VideoDecoderConfig
} | {
type: 'audio',
codec: AudioOptions['codec'],
numberOfChannels: number,
sampleRate: number,
decoderConfig: AudioDecoderConfig
},
timescale: number,
samples: Sample[],
firstDecodeTimestamp: number,
lastDecodeTimestamp: number,
timeToSampleTable: { sampleCount: number, sampleDelta: number }[];
compositionTimeOffsetTable: { sampleCount: number, sampleCompositionTimeOffset: number }[];
lastTimescaleUnits: number,
lastSample: Sample,
finalizedChunks: Chunk[],
currentChunk: Chunk,
compactlyCodedChunkTable: {
firstChunk: number,
samplesPerChunk: number
}[]
}
export type VideoTrack = Track & { info: { type: 'video' } };
export type AudioTrack = Track & { info: { type: 'audio' } };
export interface Sample {
presentationTimestamp: number,
decodeTimestamp: number,
duration: number,
data: Uint8Array,
size: number,
type: 'key' | 'delta',
timescaleUnitsToNextSample: number
}
interface Chunk {
startTimestamp: number,
samples: Sample[],
offset?: number,
// In the case of a fragmented file, this indicates the position of the moof box pointing to the data in this chunk
moofOffset?: number
}
export class Muxer<T extends Target> {
target: T;
#options: Mp4MuxerOptions<T>;
#writer: Writer;
#ftypSize: number;
#mdat: Box;
#videoTrack: Track = null;
#audioTrack: Track = null;
#creationTime = Math.floor(Date.now() / 1000) + TIMESTAMP_OFFSET;
#finalizedChunks: Chunk[] = [];
// Fields for fragmented MP4:
#nextFragmentNumber = 1;
#videoSampleQueue: Sample[] = [];
#audioSampleQueue: Sample[] = [];
#finalized = false;
constructor(options: Mp4MuxerOptions<T>) {
this.#validateOptions(options);
// Don't want these to be modified from the outside while processing:
options.video = deepClone(options.video);
options.audio = deepClone(options.audio);
options.fastStart = deepClone(options.fastStart);
this.target = options.target;
this.#options = {
firstTimestampBehavior: 'strict',
...options
};
if (options.target instanceof ArrayBufferTarget) {
this.#writer = new ArrayBufferTargetWriter(options.target);
} else if (options.target instanceof StreamTarget) {
this.#writer = options.target.options?.chunked
? new ChunkedStreamTargetWriter(options.target)
: new StreamTargetWriter(options.target);
} else if (options.target instanceof FileSystemWritableFileStreamTarget) {
this.#writer = new FileSystemWritableFileStreamTargetWriter(options.target);
} else {
throw new Error(`Invalid target: ${options.target}`);
}
this.#prepareTracks();
this.#writeHeader();
}
#validateOptions(options: Mp4MuxerOptions<T>) {
if (options.video) {
if (!SUPPORTED_VIDEO_CODECS.includes(options.video.codec)) {
throw new Error(`Unsupported video codec: ${options.video.codec}`);
}
const videoRotation = options.video.rotation;
if (typeof videoRotation === 'number' && ![0, 90, 180, 270].includes(videoRotation)) {
throw new Error(`Invalid video rotation: ${videoRotation}. Has to be 0, 90, 180 or 270.`);
} else if (
Array.isArray(videoRotation) &&
(videoRotation.length !== 9 || videoRotation.some(value => typeof value !== 'number'))
) {
throw new Error(`Invalid video transformation matrix: ${videoRotation.join()}`);
}
}
if (options.audio && !SUPPORTED_AUDIO_CODECS.includes(options.audio.codec)) {
throw new Error(`Unsupported audio codec: ${options.audio.codec}`);
}
if (options.firstTimestampBehavior && !FIRST_TIMESTAMP_BEHAVIORS.includes(options.firstTimestampBehavior)) {
throw new Error(`Invalid first timestamp behavior: ${options.firstTimestampBehavior}`);
}
if (typeof options.fastStart === 'object') {
if (options.video && options.fastStart.expectedVideoChunks === undefined) {
throw new Error(`'fastStart' is an object but is missing property 'expectedVideoChunks'.`);
}
if (options.audio && options.fastStart.expectedAudioChunks === undefined) {
throw new Error(`'fastStart' is an object but is missing property 'expectedAudioChunks'.`);
}
} else if (![false, 'in-memory', 'fragmented'].includes(options.fastStart)) {
throw new Error(`'fastStart' option must be false, 'in-memory', 'fragmented' or an object.`);
}
}
#writeHeader() {
this.#writer.writeBox(ftyp({
holdsAvc: this.#options.video?.codec === 'avc',
fragmented: this.#options.fastStart === 'fragmented'
}));
this.#ftypSize = this.#writer.pos;
if (this.#options.fastStart === 'in-memory') {
this.#mdat = mdat(false);
} else if (this.#options.fastStart === 'fragmented') {
// We write the moov box once we write out the first fragment to make sure we get the decoder configs
} else {
if (typeof this.#options.fastStart === 'object') {
let moovSizeUpperBound = this.#computeMoovSizeUpperBound();
this.#writer.seek(this.#writer.pos + moovSizeUpperBound);
}
this.#mdat = mdat(true); // Reserve large size by default, can refine this when finalizing.
this.#writer.writeBox(this.#mdat);
}
this.#maybeFlushStreamingTargetWriter();
}
#computeMoovSizeUpperBound() {
if (typeof this.#options.fastStart !== 'object') return;
let upperBound = 0;
let sampleCounts = [
this.#options.fastStart.expectedVideoChunks,
this.#options.fastStart.expectedAudioChunks
];
for (let n of sampleCounts) {
if (!n) continue;
// Given the max allowed sample count, compute the space they'll take up in the Sample Table Box, assuming
// the worst case for each individual box:
// stts box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4) * Math.ceil(2/3 * n);
// stss box - 1 entry per sample
upperBound += 4 * n;
// stsc box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4 + 4) * Math.ceil(2/3 * n);
// stsz box - 1 entry per sample
upperBound += 4 * n;
// co64 box - we assume 1 sample per chunk and 64-bit chunk offsets
upperBound += 8 * n;
}
upperBound += 4096; // Assume a generous 4 kB for everything else: Track metadata, codec descriptors, etc.
return upperBound;
}
#prepareTracks() {
if (this.#options.video) {
this.#videoTrack = {
id: 1,
info: {
type: 'video',
codec: this.#options.video.codec,
width: this.#options.video.width,
height: this.#options.video.height,
rotation: this.#options.video.rotation ?? 0,
decoderConfig: null
},
timescale: 11520, // Timescale used by FFmpeg, contains many common frame rates as factors
samples: [],
finalizedChunks: [],
currentChunk: null,
firstDecodeTimestamp: undefined,
lastDecodeTimestamp: -1,
timeToSampleTable: [],
compositionTimeOffsetTable: [],
lastTimescaleUnits: null,
lastSample: null,
compactlyCodedChunkTable: []
};
}
if (this.#options.audio) {
// For the case that we don't get any further decoder details, we can still make a pretty educated guess:
let guessedCodecPrivate = this.#generateMpeg4AudioSpecificConfig(
2, // Object type for AAC-LC, since it's the most common
this.#options.audio.sampleRate,
this.#options.audio.numberOfChannels
);
this.#audioTrack = {
id: this.#options.video ? 2 : 1,
info: {
type: 'audio',
codec: this.#options.audio.codec,
numberOfChannels: this.#options.audio.numberOfChannels,
sampleRate: this.#options.audio.sampleRate,
decoderConfig: {
codec: this.#options.audio.codec,
description: guessedCodecPrivate,
numberOfChannels: this.#options.audio.numberOfChannels,
sampleRate: this.#options.audio.sampleRate
}
},
timescale: this.#options.audio.sampleRate,
samples: [],
finalizedChunks: [],
currentChunk: null,
firstDecodeTimestamp: undefined,
lastDecodeTimestamp: -1,
timeToSampleTable: [],
compositionTimeOffsetTable: [],
lastTimescaleUnits: null,
lastSample: null,
compactlyCodedChunkTable: []
};
}
}
// https://wiki.multimedia.cx/index.php/MPEG-4_Audio
#generateMpeg4AudioSpecificConfig(objectType: number, sampleRate: number, numberOfChannels: number) {
let frequencyIndices =
[96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
let frequencyIndex = frequencyIndices.indexOf(sampleRate);
let channelConfig = numberOfChannels;
let configBits = '';
configBits += objectType.toString(2).padStart(5, '0');
configBits += frequencyIndex.toString(2).padStart(4, '0');
if (frequencyIndex === 15) configBits += sampleRate.toString(2).padStart(24, '0');
configBits += channelConfig.toString(2).padStart(4, '0');
// Pad with 0 bits to fit into a multiple of bytes
let paddingLength = Math.ceil(configBits.length / 8) * 8;
configBits = configBits.padEnd(paddingLength, '0');
let configBytes = new Uint8Array(configBits.length / 8);
for (let i = 0; i < configBits.length; i += 8) {
configBytes[i / 8] = parseInt(configBits.slice(i, i + 8), 2);
}
return configBytes;
}
addVideoChunk(
sample: EncodedVideoChunk,
meta?: EncodedVideoChunkMetadata,
timestamp?: number,
compositionTimeOffset?: number
) {
let data = new Uint8Array(sample.byteLength);
sample.copyTo(data);
this.addVideoChunkRaw(
data, sample.type, timestamp ?? sample.timestamp, sample.duration, meta, compositionTimeOffset
);
}
addVideoChunkRaw(
data: Uint8Array,
type: 'key' | 'delta',
timestamp: number,
duration: number,
meta?: EncodedVideoChunkMetadata,
compositionTimeOffset?: number
) {
this.#ensureNotFinalized();
if (!this.#options.video) throw new Error('No video track declared.');
if (
typeof this.#options.fastStart === 'object' &&
this.#videoTrack.samples.length === this.#options.fastStart.expectedVideoChunks
) {
throw new Error(`Cannot add more video chunks than specified in 'fastStart' (${
this.#options.fastStart.expectedVideoChunks
}).`);
}
let videoSample = this.#createSampleForTrack(
this.#videoTrack, data, type, timestamp, duration, meta, compositionTimeOffset
);
// Check if we need to interleave the samples in the case of a fragmented file
if (this.#options.fastStart === 'fragmented' && this.#audioTrack) {
// Add all audio samples with a timestamp smaller than the incoming video sample
while (
this.#audioSampleQueue.length > 0 &&
this.#audioSampleQueue[0].decodeTimestamp <= videoSample.decodeTimestamp
) {
let audioSample = this.#audioSampleQueue.shift();
this.#addSampleToTrack(this.#audioTrack, audioSample);
}
// Depending on the last audio sample, either add the video sample to the file or enqueue it
if (videoSample.decodeTimestamp <= this.#audioTrack.lastDecodeTimestamp) {
this.#addSampleToTrack(this.#videoTrack, videoSample);
} else {
this.#videoSampleQueue.push(videoSample);
}
} else {
this.#addSampleToTrack(this.#videoTrack, videoSample);
}
}
addAudioChunk(sample: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata, timestamp?: number) {
let data = new Uint8Array(sample.byteLength);
sample.copyTo(data);
this.addAudioChunkRaw(data, sample.type, timestamp ?? sample.timestamp, sample.duration, meta);
}
addAudioChunkRaw(
data: Uint8Array,
type: 'key' | 'delta',
timestamp: number,
duration: number,
meta?: EncodedAudioChunkMetadata
) {
this.#ensureNotFinalized();
if (!this.#options.audio) throw new Error('No audio track declared.');
if (
typeof this.#options.fastStart === 'object' &&
this.#audioTrack.samples.length === this.#options.fastStart.expectedAudioChunks
) {
throw new Error(`Cannot add more audio chunks than specified in 'fastStart' (${
this.#options.fastStart.expectedAudioChunks
}).`);
}
let audioSample = this.#createSampleForTrack(this.#audioTrack, data, type, timestamp, duration, meta);
// Check if we need to interleave the samples in the case of a fragmented file
if (this.#options.fastStart === 'fragmented' && this.#videoTrack) {
// Add all video samples with a timestamp smaller than the incoming audio sample
while (
this.#videoSampleQueue.length > 0 &&
this.#videoSampleQueue[0].decodeTimestamp <= audioSample.decodeTimestamp
) {
let videoSample = this.#videoSampleQueue.shift();
this.#addSampleToTrack(this.#videoTrack, videoSample);
}
// Depending on the last video sample, either add the audio sample to the file or enqueue it
if (audioSample.decodeTimestamp <= this.#videoTrack.lastDecodeTimestamp) {
this.#addSampleToTrack(this.#audioTrack, audioSample);
} else {
this.#audioSampleQueue.push(audioSample);
}
} else {
this.#addSampleToTrack(this.#audioTrack, audioSample);
}
}
#createSampleForTrack(
track: Track,
data: Uint8Array,
type: 'key' | 'delta',
timestamp: number,
duration: number,
meta?: EncodedVideoChunkMetadata | EncodedAudioChunkMetadata,
compositionTimeOffset?: number
) {
let presentationTimestampInSeconds = timestamp / 1e6;
let decodeTimestampInSeconds = (timestamp - (compositionTimeOffset ?? 0)) / 1e6;
let durationInSeconds = duration / 1e6;
let adjusted = this.#validateTimestamp(presentationTimestampInSeconds, decodeTimestampInSeconds, track);
presentationTimestampInSeconds = adjusted.presentationTimestamp;
decodeTimestampInSeconds = adjusted.decodeTimestamp;
if (meta?.decoderConfig) {
if (track.info.decoderConfig === null) {
track.info.decoderConfig = meta.decoderConfig;
} else {
Object.assign(track.info.decoderConfig, meta.decoderConfig);
}
}
let sample: Sample = {
presentationTimestamp: presentationTimestampInSeconds,
decodeTimestamp: decodeTimestampInSeconds,
duration: durationInSeconds,
data: data,
size: data.byteLength,
type: type,
// Will be refined once the next sample comes in
timescaleUnitsToNextSample: intoTimescale(durationInSeconds, track.timescale)
};
return sample;
}
#addSampleToTrack(
track: Track,
sample: Sample
) {
if (this.#options.fastStart !== 'fragmented') {
track.samples.push(sample);
}
const sampleCompositionTimeOffset =
intoTimescale(sample.presentationTimestamp - sample.decodeTimestamp, track.timescale);
if (track.lastTimescaleUnits !== null) {
let timescaleUnits = intoTimescale(sample.decodeTimestamp, track.timescale, false);
let delta = Math.round(timescaleUnits - track.lastTimescaleUnits);
track.lastTimescaleUnits += delta;
track.lastSample.timescaleUnitsToNextSample = delta;
if (this.#options.fastStart !== 'fragmented') {
let lastTableEntry = last(track.timeToSampleTable);
if (lastTableEntry.sampleCount === 1) {
// If we hit this case, we're the second sample
lastTableEntry.sampleDelta = delta;
lastTableEntry.sampleCount++;
} else if (lastTableEntry.sampleDelta === delta) {
// Simply increment the count
lastTableEntry.sampleCount++;
} else {
// The delta has changed, subtract one from the previous run and create a new run with the new delta
lastTableEntry.sampleCount--;
track.timeToSampleTable.push({
sampleCount: 2,
sampleDelta: delta
});
}
const lastCompositionTimeOffsetTableEntry = last(track.compositionTimeOffsetTable);
if (lastCompositionTimeOffsetTableEntry.sampleCompositionTimeOffset === sampleCompositionTimeOffset) {
// Simply increment the count
lastCompositionTimeOffsetTableEntry.sampleCount++;
} else {
// The composition time offset has changed, so create a new entry with the new composition time
// offset
track.compositionTimeOffsetTable.push({
sampleCount: 1,
sampleCompositionTimeOffset: sampleCompositionTimeOffset
});
}
}
} else {
track.lastTimescaleUnits = 0;
if (this.#options.fastStart !== 'fragmented') {
track.timeToSampleTable.push({
sampleCount: 1,
sampleDelta: intoTimescale(sample.duration, track.timescale)
});
track.compositionTimeOffsetTable.push({
sampleCount: 1,
sampleCompositionTimeOffset: sampleCompositionTimeOffset
});
}
}
track.lastSample = sample;
let beginNewChunk = false;
if (!track.currentChunk) {
beginNewChunk = true;
} else {
let currentChunkDuration = sample.presentationTimestamp - track.currentChunk.startTimestamp;
if (this.#options.fastStart === 'fragmented') {
let mostImportantTrack = this.#videoTrack ?? this.#audioTrack;
if (track === mostImportantTrack && sample.type === 'key' && currentChunkDuration >= 1.0) {
beginNewChunk = true;
this.#finalizeFragment();
}
} else {
beginNewChunk = currentChunkDuration >= 0.5; // Chunk is long enough, we need a new one
}
}
if (beginNewChunk) {
if (track.currentChunk) {
this.#finalizeCurrentChunk(track);
}
track.currentChunk = {
startTimestamp: sample.presentationTimestamp,
samples: []
};
}
track.currentChunk.samples.push(sample);
}
#validateTimestamp(presentationTimestamp: number, decodeTimestamp: number, track: Track) {
// Check first timestamp behavior
const strictTimestampBehavior = this.#options.firstTimestampBehavior === 'strict';
const noLastDecodeTimestamp = track.lastDecodeTimestamp === -1;
const timestampNonZero = decodeTimestamp !== 0;
if (strictTimestampBehavior && noLastDecodeTimestamp && timestampNonZero) {
throw new Error(
`The first chunk for your media track must have a timestamp of 0 (received DTS=${decodeTimestamp}).` +
`Non-zero first timestamps are often caused by directly piping frames or audio data from a ` +
`MediaStreamTrack into the encoder. Their timestamps are typically relative to the age of the` +
`document, which is probably what you want.\n\nIf you want to offset all timestamps of a track such ` +
`that the first one is zero, set firstTimestampBehavior: 'offset' in the options.\n`
);
} else if (
this.#options.firstTimestampBehavior === 'offset' ||
this.#options.firstTimestampBehavior === 'cross-track-offset'
) {
if (track.firstDecodeTimestamp === undefined) {
track.firstDecodeTimestamp = decodeTimestamp;
}
let baseDecodeTimestamp: number;
if (this.#options.firstTimestampBehavior === 'offset') {
baseDecodeTimestamp = track.firstDecodeTimestamp;
} else {
// Since each track may have its firstDecodeTimestamp set independently, but the tracks' timestamps come
// from the same clock, we should subtract the earlier of the (up to) two tracks' first timestamps to
// ensure A/V sync.
baseDecodeTimestamp = Math.min(
this.#videoTrack?.firstDecodeTimestamp ?? Infinity,
this.#audioTrack?.firstDecodeTimestamp ?? Infinity
);
}
decodeTimestamp -= baseDecodeTimestamp;
presentationTimestamp -= baseDecodeTimestamp;
}
if (decodeTimestamp < track.lastDecodeTimestamp) {
throw new Error(
`Timestamps must be monotonically increasing ` +
`(DTS went from ${track.lastDecodeTimestamp * 1e6} to ${decodeTimestamp * 1e6}).`
);
}
track.lastDecodeTimestamp = decodeTimestamp;
return { presentationTimestamp, decodeTimestamp };
}
#finalizeCurrentChunk(track: Track) {
if (this.#options.fastStart === 'fragmented') {
throw new Error("Can't finalize individual chunks 'fastStart' is set to 'fragmented'.");
}
if (!track.currentChunk) return;
track.finalizedChunks.push(track.currentChunk);
this.#finalizedChunks.push(track.currentChunk);
if (
track.compactlyCodedChunkTable.length === 0
|| last(track.compactlyCodedChunkTable).samplesPerChunk !== track.currentChunk.samples.length
) {
track.compactlyCodedChunkTable.push({
firstChunk: track.finalizedChunks.length, // 1-indexed
samplesPerChunk: track.currentChunk.samples.length
});
}
if (this.#options.fastStart === 'in-memory') {
track.currentChunk.offset = 0; // We'll compute the proper offset when finalizing
return;
}
// Write out the data
track.currentChunk.offset = this.#writer.pos;
for (let sample of track.currentChunk.samples) {
this.#writer.write(sample.data);
sample.data = null; // Can be GC'd
}
this.#maybeFlushStreamingTargetWriter();
}
#finalizeFragment(flushStreamingWriter = true) {
if (this.#options.fastStart !== 'fragmented') {
throw new Error("Can't finalize a fragment unless 'fastStart' is set to 'fragmented'.");
}
let tracks = [this.#videoTrack, this.#audioTrack].filter((track) => track && track.currentChunk);
if (tracks.length === 0) return;
let fragmentNumber = this.#nextFragmentNumber++;
if (fragmentNumber === 1) {
// Write the moov box now that we have all decoder configs
let movieBox = moov(tracks, this.#creationTime, true);
this.#writer.writeBox(movieBox);
}
// Write out an initial moof box; will be overwritten later once actual chunk offsets are known
let moofOffset = this.#writer.pos;
let moofBox = moof(fragmentNumber, tracks);
this.#writer.writeBox(moofBox);
// Create the mdat box
{
let mdatBox = mdat(false); // Initially assume no fragment is larger than 4 GiB
let totalTrackSampleSize = 0;
// Compute the size of the mdat box
for (let track of tracks) {
for (let sample of track.currentChunk.samples) {
totalTrackSampleSize += sample.size;
}
}
let mdatSize = this.#writer.measureBox(mdatBox) + totalTrackSampleSize;
if (mdatSize >= 2**32) {
// Fragment is larger than 4 GiB, we need to use the large size
mdatBox.largeSize = true;
mdatSize = this.#writer.measureBox(mdatBox) + totalTrackSampleSize;
}
mdatBox.size = mdatSize;
this.#writer.writeBox(mdatBox);
}
// Write sample data
for (let track of tracks) {
track.currentChunk.offset = this.#writer.pos;
track.currentChunk.moofOffset = moofOffset;
for (let sample of track.currentChunk.samples) {
this.#writer.write(sample.data);
sample.data = null; // Can be GC'd
}
}
// Now that we set the actual chunk offsets, fix the moof box
let endPos = this.#writer.pos;
this.#writer.seek(this.#writer.offsets.get(moofBox));
let newMoofBox = moof(fragmentNumber, tracks);
this.#writer.writeBox(newMoofBox);
this.#writer.seek(endPos);
for (let track of tracks) {
track.finalizedChunks.push(track.currentChunk);
this.#finalizedChunks.push(track.currentChunk);
track.currentChunk = null;
}
if (flushStreamingWriter) {
this.#maybeFlushStreamingTargetWriter();
}
}
#maybeFlushStreamingTargetWriter() {
if (this.#writer instanceof StreamTargetWriter) {
this.#writer.flush();
}
}
#ensureNotFinalized() {
if (this.#finalized) {
throw new Error('Cannot add new video or audio chunks after the file has been finalized.');
}
}
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
finalize() {
if (this.#finalized) {
throw new Error('Cannot finalize a muxer more than once.');
}
if (this.#options.fastStart === 'fragmented') {
for (let videoSample of this.#videoSampleQueue) this.#addSampleToTrack(this.#videoTrack, videoSample);
for (let audioSample of this.#audioSampleQueue) this.#addSampleToTrack(this.#audioTrack, audioSample);
this.#finalizeFragment(false); // Don't flush the last fragment as we will flush it with the mfra box soon
} else {
if (this.#videoTrack) this.#finalizeCurrentChunk(this.#videoTrack);
if (this.#audioTrack) this.#finalizeCurrentChunk(this.#audioTrack);
}
let tracks = [this.#videoTrack, this.#audioTrack].filter(Boolean);
if (this.#options.fastStart === 'in-memory') {
let mdatSize: number;
// We know how many chunks there are, but computing the chunk positions requires an iterative approach:
// In order to know where the first chunk should go, we first need to know the size of the moov box. But we
// cannot write a proper moov box without first knowing all chunk positions. So, we generate a tentative
// moov box with placeholder values (0) for the chunk offsets to be able to compute its size. If it then
// turns out that appending all chunks exceeds 4 GiB, we need to repeat this process, now with the co64 box
// being used in the moov box instead, which will make it larger. After that, we definitely know the final
// size of the moov box and can compute the proper chunk positions.
for (let i = 0; i < 2; i++) {
let movieBox = moov(tracks, this.#creationTime);
let movieBoxSize = this.#writer.measureBox(movieBox);
mdatSize = this.#writer.measureBox(this.#mdat);
let currentChunkPos = this.#writer.pos + movieBoxSize + mdatSize;
for (let chunk of this.#finalizedChunks) {
chunk.offset = currentChunkPos;
for (let { data } of chunk.samples) {
currentChunkPos += data.byteLength;
mdatSize += data.byteLength;
}
}
if (currentChunkPos < 2**32) break;
if (mdatSize >= 2**32) this.#mdat.largeSize = true;
}
let movieBox = moov(tracks, this.#creationTime);
this.#writer.writeBox(movieBox);
this.#mdat.size = mdatSize;
this.#writer.writeBox(this.#mdat);
for (let chunk of this.#finalizedChunks) {
for (let sample of chunk.samples) {
this.#writer.write(sample.data);
sample.data = null;
}
}
} else if (this.#options.fastStart === 'fragmented') {
// Append the mfra box to the end of the file for better random access
let startPos = this.#writer.pos;
let mfraBox = mfra(tracks);
this.#writer.writeBox(mfraBox);
// Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size
let mfraBoxSize = this.#writer.pos - startPos;
this.#writer.seek(this.#writer.pos - 4);
this.#writer.writeU32(mfraBoxSize);
} else {
let mdatPos = this.#writer.offsets.get(this.#mdat);
let mdatSize = this.#writer.pos - mdatPos;
this.#mdat.size = mdatSize;
this.#mdat.largeSize = mdatSize >= 2**32; // Only use the large size if we need it
this.#writer.patchBox(this.#mdat);
let movieBox = moov(tracks, this.#creationTime);
if (typeof this.#options.fastStart === 'object') {
this.#writer.seek(this.#ftypSize);
this.#writer.writeBox(movieBox);
let remainingBytes = mdatPos - this.#writer.pos;
this.#writer.writeBox(free(remainingBytes));
} else {
this.#writer.writeBox(movieBox);
}
}
this.#maybeFlushStreamingTargetWriter();
this.#writer.finalize();
this.#finalized = true;
}
}
@@ -0,0 +1,20 @@
export type Target = ArrayBufferTarget | StreamTarget | FileSystemWritableFileStreamTarget;
export class ArrayBufferTarget {
buffer: ArrayBuffer = null;
}
export class StreamTarget {
constructor(public options: {
onData?: (data: Uint8Array, position: number) => void,
chunked?: boolean,
chunkSize?: number
}) {}
}
export class FileSystemWritableFileStreamTarget {
constructor(
public stream: FileSystemWritableFileStream,
public options?: { chunkSize?: number }
) {}
}
@@ -0,0 +1,380 @@
import { Box } from './box';
import { ArrayBufferTarget, FileSystemWritableFileStreamTarget, StreamTarget } from './target';
export abstract class Writer {
pos = 0;
#helper = new Uint8Array(8);
#helperView = new DataView(this.#helper.buffer);
/**
* Stores the position from the start of the file to where boxes elements have been written. This is used to
* rewrite/edit elements that were already added before, and to measure sizes of things.
*/
offsets = new WeakMap<Box, number>();
/** Writes the given data to the target, at the current position. */
abstract write(data: Uint8Array): void;
/** Called after muxing has finished. */
abstract finalize(): void;
/** Sets the current position for future writes to a new one. */
seek(newPos: number) {
this.pos = newPos;
}
writeU32(value: number) {
this.#helperView.setUint32(0, value, false);
this.write(this.#helper.subarray(0, 4));
}
writeU64(value: number) {
this.#helperView.setUint32(0, Math.floor(value / 2**32), false);
this.#helperView.setUint32(4, value, false);
this.write(this.#helper.subarray(0, 8));
}
writeAscii(text: string) {
for (let i = 0; i < text.length; i++) {
this.#helperView.setUint8(i % 8, text.charCodeAt(i));
if (i % 8 === 7) this.write(this.#helper);
}
if (text.length % 8 !== 0) {
this.write(this.#helper.subarray(0, text.length % 8));
}
}
writeBox(box: Box) {
this.offsets.set(box, this.pos);
if (box.contents && !box.children) {
this.writeBoxHeader(box, box.size ?? box.contents.byteLength + 8);
this.write(box.contents);
} else {
let startPos = this.pos;
this.writeBoxHeader(box, 0);
if (box.contents) this.write(box.contents);
if (box.children) for (let child of box.children) if (child) this.writeBox(child);
let endPos = this.pos;
let size = box.size ?? endPos - startPos;
this.seek(startPos);
this.writeBoxHeader(box, size);
this.seek(endPos);
}
}
writeBoxHeader(box: Box, size: number) {
this.writeU32(box.largeSize ? 1 : size);
this.writeAscii(box.type);
if (box.largeSize) this.writeU64(size);
}
measureBoxHeader(box: Box) {
return 8 + (box.largeSize ? 8 : 0);
}
patchBox(box: Box) {
let endPos = this.pos;
this.seek(this.offsets.get(box));
this.writeBox(box);
this.seek(endPos);
}
measureBox(box: Box) {
if (box.contents && !box.children) {
let headerSize = this.measureBoxHeader(box);
return headerSize + box.contents.byteLength;
} else {
let result = this.measureBoxHeader(box);
if (box.contents) result += box.contents.byteLength;
if (box.children) for (let child of box.children) if (child) result += this.measureBox(child);
return result;
}
}
}
/**
* Writes to an ArrayBufferTarget. Maintains a growable internal buffer during the muxing process, which will then be
* written to the ArrayBufferTarget once the muxing finishes.
*/
export class ArrayBufferTargetWriter extends Writer {
#target: ArrayBufferTarget;
#buffer = new ArrayBuffer(2**16);
#bytes = new Uint8Array(this.#buffer);
#maxPos = 0;
constructor(target: ArrayBufferTarget) {
super();
this.#target = target;
}
#ensureSize(size: number) {
let newLength = this.#buffer.byteLength;
while (newLength < size) newLength *= 2;
if (newLength === this.#buffer.byteLength) return;
let newBuffer = new ArrayBuffer(newLength);
let newBytes = new Uint8Array(newBuffer);
newBytes.set(this.#bytes, 0);
this.#buffer = newBuffer;
this.#bytes = newBytes;
}
write(data: Uint8Array) {
this.#ensureSize(this.pos + data.byteLength);
this.#bytes.set(data, this.pos);
this.pos += data.byteLength;
this.#maxPos = Math.max(this.#maxPos, this.pos);
}
finalize() {
this.#ensureSize(this.pos);
this.#target.buffer = this.#buffer.slice(0, Math.max(this.#maxPos, this.pos));
}
}
/**
* Writes to a StreamTarget every time it is flushed, sending out all of the new data written since the
* last flush. This is useful for streaming applications, like piping the output to disk.
*/
export class StreamTargetWriter extends Writer {
#target: StreamTarget;
#sections: {
data: Uint8Array,
start: number
}[] = [];
constructor(target: StreamTarget) {
super();
this.#target = target;
}
write(data: Uint8Array) {
this.#sections.push({
data: data.slice(),
start: this.pos
});
this.pos += data.byteLength;
}
flush() {
if (this.#sections.length === 0) return;
let chunks: {
start: number,
size: number,
data?: Uint8Array
}[] = [];
let sorted = [...this.#sections].sort((a, b) => a.start - b.start);
chunks.push({
start: sorted[0].start,
size: sorted[0].data.byteLength
});
// Figure out how many contiguous chunks we have
for (let i = 1; i < sorted.length; i++) {
let lastChunk = chunks[chunks.length - 1];
let section = sorted[i];
if (section.start <= lastChunk.start + lastChunk.size) {
lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start);
} else {
chunks.push({
start: section.start,
size: section.data.byteLength
});
}
}
for (let chunk of chunks) {
chunk.data = new Uint8Array(chunk.size);
// Make sure to write the data in the correct order for correct overwriting
for (let section of this.#sections) {
// Check if the section is in the chunk
if (chunk.start <= section.start && section.start < chunk.start + chunk.size) {
chunk.data.set(section.data, section.start - chunk.start);
}
}
this.#target.options.onData?.(chunk.data, chunk.start);
}
this.#sections.length = 0;
}
finalize() {}
}
const DEFAULT_CHUNK_SIZE = 2**24;
const MAX_CHUNKS_AT_ONCE = 2;
interface Chunk {
start: number,
written: ChunkSection[],
data: Uint8Array,
shouldFlush: boolean
}
interface ChunkSection {
start: number,
end: number
}
/**
* Writes to a StreamTarget using a chunked approach: Data is first buffered in memory until it reaches a large enough
* size, which is when it is piped to the StreamTarget. This is helpful for reducing the total amount of writes.
*/
export class ChunkedStreamTargetWriter extends Writer {
#target: StreamTarget;
#chunkSize: number;
/**
* The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out.
* A chunk is flushed if all of its contents have been written.
*/
#chunks: Chunk[] = [];
constructor(target: StreamTarget) {
super();
this.#target = target;
this.#chunkSize = target.options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
if (!Number.isInteger(this.#chunkSize) || this.#chunkSize < 2**10) {
throw new Error('Invalid StreamTarget options: chunkSize must be an integer not smaller than 1024.');
}
}
write(data: Uint8Array) {
this.#writeDataIntoChunks(data, this.pos);
this.#flushChunks();
this.pos += data.byteLength;
}
#writeDataIntoChunks(data: Uint8Array, position: number) {
// First, find the chunk to write the data into, or create one if none exists
let chunkIndex = this.#chunks.findIndex(x => x.start <= position && position < x.start + this.#chunkSize);
if (chunkIndex === -1) chunkIndex = this.#createChunk(position);
let chunk = this.#chunks[chunkIndex];
// Figure out how much to write to the chunk, and then write to the chunk
let relativePosition = position - chunk.start;
let toWrite = data.subarray(0, Math.min(this.#chunkSize - relativePosition, data.byteLength));
chunk.data.set(toWrite, relativePosition);
// Create a section describing the region of data that was just written to
let section: ChunkSection = {
start: relativePosition,
end: relativePosition + toWrite.byteLength
};
this.#insertSectionIntoChunk(chunk, section);
// Queue chunk for flushing to target if it has been fully written to
if (chunk.written[0].start === 0 && chunk.written[0].end === this.#chunkSize) {
chunk.shouldFlush = true;
}
// Make sure we don't hold too many chunks in memory at once to keep memory usage down
if (this.#chunks.length > MAX_CHUNKS_AT_ONCE) {
// Flush all but the last chunk
for (let i = 0; i < this.#chunks.length-1; i++) {
this.#chunks[i].shouldFlush = true;
}
this.#flushChunks();
}
// If the data didn't fit in one chunk, recurse with the remaining datas
if (toWrite.byteLength < data.byteLength) {
this.#writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength);
}
}
#insertSectionIntoChunk(chunk: Chunk, section: ChunkSection) {
let low = 0;
let high = chunk.written.length - 1;
let index = -1;
// Do a binary search to find the last section with a start not larger than `section`'s start
while (low <= high) {
let mid = Math.floor(low + (high - low + 1) / 2);
if (chunk.written[mid].start <= section.start) {
low = mid + 1;
index = mid;
} else {
high = mid - 1;
}
}
// Insert the new section
chunk.written.splice(index + 1, 0, section);
if (index === -1 || chunk.written[index].end < section.start) index++;
// Merge overlapping sections
while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) {
chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end);
chunk.written.splice(index + 1, 1);
}
}
#createChunk(includesPosition: number) {
let start = Math.floor(includesPosition / this.#chunkSize) * this.#chunkSize;
let chunk: Chunk = {
start,
data: new Uint8Array(this.#chunkSize),
written: [],
shouldFlush: false
};
this.#chunks.push(chunk);
this.#chunks.sort((a, b) => a.start - b.start);
return this.#chunks.indexOf(chunk);
}
#flushChunks(force = false) {
for (let i = 0; i < this.#chunks.length; i++) {
let chunk = this.#chunks[i];
if (!chunk.shouldFlush && !force) continue;
for (let section of chunk.written) {
this.#target.options.onData?.(
chunk.data.subarray(section.start, section.end),
chunk.start + section.start
);
}
this.#chunks.splice(i--, 1);
}
}
finalize() {
this.#flushChunks(true);
}
}
/**
* Essentially a wrapper around ChunkedStreamTargetWriter, writing directly to disk using the File System Access API.
* This is useful for large files, as available RAM is no longer a bottleneck.
*/
export class FileSystemWritableFileStreamTargetWriter extends ChunkedStreamTargetWriter {
constructor(target: FileSystemWritableFileStreamTarget) {
super(new StreamTarget({
onData: (data, position) => target.stream.write({
type: 'write',
data,
position
}),
chunkSize: target.options?.chunkSize
}));
}
}