UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

1,530 lines (1,317 loc) 59.2 kB
/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { toUint8Array, assert, isU32, last, TransformationMatrix, textEncoder, COLOR_PRIMARIES_MAP, TRANSFER_CHARACTERISTICS_MAP, MATRIX_COEFFICIENTS_MAP, colorSpaceIsComplete, UNDETERMINED_LANGUAGE, assertNever, keyValueIterator, } from '../misc'; import { AudioCodec, generateAv1CodecConfigurationFromCodecString, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, SubtitleCodec, VideoCodec, } from '../codec'; import { formatSubtitleTimestamp } from '../subtitles'; import { Writer } from '../writer'; import { getTrackMetadata, GLOBAL_TIMESCALE, intoTimescale, IsobmffAudioTrackData, IsobmffMuxer, IsobmffSubtitleTrackData, IsobmffTrackData, IsobmffVideoTrackData, Sample, } from './isobmff-muxer'; import { parseAc3SyncFrame, parseEac3SyncFrame, parseOpusIdentificationHeader } from '../codec-data'; import { MetadataTags, RichImageData } from '../metadata'; import { Bitstream } from '../../shared/bitstream'; export class IsobmffBoxWriter { private helper = new Uint8Array(8); private 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>(); constructor(public writer: Writer) {} writeU32(value: number) { this.helperView.setUint32(0, value, false); this.writer.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.writer.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.writer.write(this.helper); } if (text.length % 8 !== 0) { this.writer.write(this.helper.subarray(0, text.length % 8)); } } writeBox(box: Box) { this.offsets.set(box, this.writer.getPos()); if (box.contents && !box.children) { this.writeBoxHeader(box, box.size ?? box.contents.byteLength + 8); this.writer.write(box.contents); } else { const startPos = this.writer.getPos(); this.writeBoxHeader(box, 0); if (box.contents) this.writer.write(box.contents); if (box.children) for (const child of box.children) if (child) this.writeBox(child); const endPos = this.writer.getPos(); const size = box.size ?? endPos - startPos; this.writer.seek(startPos); this.writeBoxHeader(box, size); this.writer.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) { const boxOffset = this.offsets.get(box); assert(boxOffset !== undefined); const endPos = this.writer.getPos(); this.writer.seek(boxOffset); this.writeBox(box); this.writer.seek(endPos); } measureBox(box: Box) { if (box.contents && !box.children) { const 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 (const child of box.children) if (child) result += this.measureBox(child); return result; } } } const bytes = /* #__PURE__ */ new Uint8Array(8); const view = /* #__PURE__ */ new DataView(bytes.buffer); const u8 = (value: number) => { return [(value % 0x100 + 0x100) % 0x100]; }; const u16 = (value: number) => { view.setUint16(0, value, false); return [bytes[0], bytes[1]] as number[]; }; const i16 = (value: number) => { view.setInt16(0, value, false); return [bytes[0], bytes[1]] as number[]; }; const u24 = (value: number) => { view.setUint32(0, value, false); return [bytes[1], bytes[2], bytes[3]] as number[]; }; const u32 = (value: number) => { view.setUint32(0, value, false); return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; }; const i32 = (value: number) => { view.setInt32(0, value, false); return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; }; 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]] as number[]; }; const i64 = (value: number) => { view.setInt32(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]] as number[]; }; const fixed_8_8 = (value: number) => { view.setInt16(0, 2 ** 8 * value, false); return [bytes[0], bytes[1]] as number[]; }; const fixed_16_16 = (value: number) => { view.setInt32(0, 2 ** 16 * value, false); return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; }; const fixed_2_30 = (value: number) => { view.setInt32(0, 2 ** 30 * value, false); return [bytes[0], bytes[1], bytes[2], bytes[3]] as number[]; }; const variableUnsignedInt = (value: number, byteLength?: number) => { const bytes: number[] = []; let remaining = value; do { let byte = remaining & 0x7f; remaining >>= 7; // If this isn't the first byte we're adding (meaning there will be more bytes after it // when we reverse the array), set the continuation bit if (bytes.length > 0) { byte |= 0x80; } bytes.push(byte); if (byteLength !== undefined) { byteLength--; } } while (remaining > 0 || byteLength); // Reverse the array since we built it backwards return bytes.reverse(); }; const ascii = (text: string, nullTerminated = false) => { const bytes = Array(text.length).fill(null).map((_, i) => text.charCodeAt(i)); if (nullTerminated) bytes.push(0x00); return bytes; }; const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => { const theta = rotationInDegrees * (Math.PI / 180); const cosTheta = Math.round(Math.cos(theta)); const sinTheta = Math.round(Math.sin(theta)); // Matrices are post-multiplied in ISOBMFF, meaning this is the transpose of your typical rotation matrix return [ cosTheta, sinTheta, 0, -sinTheta, cosTheta, 0, 0, 0, 1, ]; }; const IDENTITY_MATRIX = /* #__PURE__ */ rotationMatrix(0); 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 interface Box { type: string; contents?: Uint8Array; children?: (Box | null)[]; size?: number; largeSize?: boolean; } type NestedNumberArray = (number | NestedNumberArray)[]; export const box = (type: string, contents?: NestedNumberArray, children?: (Box | null)[]): 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: { isQuickTime: boolean; holdsAvc: boolean; fragmented: boolean; cmaf: 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. const minorVersion = 0x200; if (details.isQuickTime) { return box('ftyp', [ ascii('qt '), // Major brand u32(minorVersion), // Minor version // Compatible brands ascii('qt '), ]); } if (details.fragmented) { if (details.cmaf) { return box('ftyp', [ ascii('iso5'), // Major brand u32(minorVersion), // Minor version // Compatible brands ascii('iso5'), ascii('iso6'), ascii('mp41'), ascii('cmfc'), ascii('dash'), ]); } else { 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'), ]); }; /** Segment Type Box */ export const styp = () => box('styp', [ ascii('iso5'), // Major brand u32(0), // Minor version // Compatible brands ascii('iso5'), ascii('iso6'), ascii('mp41'), ascii('cmfc'), ascii('dash'), ]); /** Segment Index Box */ export const sidx = (muxer: IsobmffMuxer, referencedSize: number) => { let duration = muxer.maxWrittenEndTimestamp - muxer.minWrittenTimestamp; if (!Number.isFinite(duration)) { duration = 0; } return fullBox('sidx', 1, 0, [ u32(1), // Reference ID u32(GLOBAL_TIMESCALE), // Timescale u64(intoTimescale(muxer.minWrittenTimestamp, GLOBAL_TIMESCALE)), // Earliest presentation time u64(0), // First offset u16(0), // Reserved u16(1), // Reference count u32(referencedSize & 0x7fffffff), // Reference type (0) + referenced size u32(intoTimescale(duration, GLOBAL_TIMESCALE)), // Subsegment duration u32(0), // Starts with SAP + SAP type + SAP delta time (no information provided) ]); }; /** 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 = (muxer: IsobmffMuxer) => box('moov', undefined, [ mvhd(muxer.creationTime, muxer.trackDatas), ...muxer.trackDatas.map(x => trak(x, muxer.creationTime)), muxer.isFragmented ? mvex(muxer.trackDatas) : null, udta(muxer), ]); /** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */ export const mvhd = ( creationTime: number, trackDatas: IsobmffTrackData[], ) => { const duration = Math.max( 0, ...trackDatas .map(trackData => ( intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE) + intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE) )), ); const nextTrackId = Math.max(0, ...trackDatas.map(x => x.track.id)) + 1; // Conditionally use u64 if u32 isn't enough const needsU64 = !isU32(creationTime) || !isU32(duration); const 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 ]); }; const presentationSpan = (trackData: IsobmffTrackData) => { if (trackData.samples.length === 0) { return 0; } let minTimestamp = Infinity; let maxEndTimestamp = -Infinity; for (let i = 0; i < trackData.samples.length; i++) { const sample = trackData.samples[i]!; if (sample.timestamp < minTimestamp) { minTimestamp = sample.timestamp; } if (sample.timestamp + sample.duration > maxEndTimestamp) { maxEndTimestamp = sample.timestamp + sample.duration; } } if (minTimestamp === Infinity) { return 0; } return maxEndTimestamp - minTimestamp; }; /** * 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 = (trackData: IsobmffTrackData, creationTime: number) => { const trackMetadata = getTrackMetadata(trackData); const needsEditList = trackData.startTimestampOffset !== null && trackData.startTimestampOffset > 0; return box('trak', undefined, [ tkhd(trackData, creationTime), needsEditList ? edts(trackData, trackData.startTimestampOffset!) : null, mdia(trackData, creationTime), trackMetadata.name !== undefined ? box('udta', undefined, [ box('name', [ // VLC (and Mediabunny) also recognize ©nam ...textEncoder.encode(trackMetadata.name), ]), ]) : null, ]); }; /** Track Header Box: Specifies the characteristics of a single track within a movie. */ export const tkhd = ( trackData: IsobmffTrackData, creationTime: number, ) => { const durationInGlobalTimescale = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE) + intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE); const needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale); const u32OrU64 = needsU64 ? u64 : u32; let matrix: TransformationMatrix; if (trackData.type === 'video') { const rotation = trackData.track.metadata.rotation; matrix = rotationMatrix(rotation ?? 0); } else { matrix = IDENTITY_MATRIX; } let flags = 0x2; // Track in movie if (trackData.track.metadata.disposition?.default !== false) { flags |= 0x1; // Track enabled } return fullBox('tkhd', +needsU64, flags, [ u32OrU64(creationTime), // Creation time u32OrU64(creationTime), // Modification time u32(trackData.track.id), // Track ID u32(0), // Reserved u32OrU64(durationInGlobalTimescale), // Duration Array(8).fill(0), // Reserved u16(0), // Layer u16(trackData.track.id), // Alternate group fixed_8_8(trackData.type === 'audio' ? 1 : 0), // Volume u16(0), // Reserved matrixToBytes(matrix), // Matrix fixed_16_16(trackData.type === 'video' ? trackData.info.width : 0), // Track width fixed_16_16(trackData.type === 'video' ? trackData.info.height : 0), // Track height ]); }; /** Edit Box: Specifies edits to the track's media. */ export const edts = (trackData: IsobmffTrackData, offset: number) => { const startOffset = intoTimescale(offset, GLOBAL_TIMESCALE); const mediaDuration = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE); const needs64Bits = !isU32(startOffset) || !isU32(mediaDuration); const u32OrU64 = needs64Bits ? u64 : u32; const i32OrI64 = needs64Bits ? i64 : i32; return box('edts', undefined, [ fullBox('elst', needs64Bits ? 1 : 0, 0, [ u32(2), // Entry count // #1 u32OrU64(startOffset), // Segment duration i32OrI64(-1), // Media time fixed_16_16(1), // Media rate // #2 u32OrU64(mediaDuration), // Segment duration i32OrI64(0), // Media time fixed_16_16(1), // Media rate ]), ]); }; /** Media Box: Describes and define a track's media type and sample data. */ export const mdia = (trackData: IsobmffTrackData, creationTime: number) => box('mdia', undefined, [ mdhd(trackData, creationTime), hdlr(true, TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type], TRACK_TYPE_TO_HANDLER_NAME[trackData.type]), minf(trackData), ]); /** Media Header Box: Specifies the characteristics of a media, including timescale and duration. */ export const mdhd = ( trackData: IsobmffTrackData, creationTime: number, ) => { // Since the duration represents the raw media duration, edit list offsets are not taken into account here const localDuration = intoTimescale( presentationSpan(trackData), trackData.timescale, ); const needsU64 = !isU32(creationTime) || !isU32(localDuration); const u32OrU64 = needsU64 ? u64 : u32; return fullBox('mdhd', +needsU64, 0, [ u32OrU64(creationTime), // Creation time u32OrU64(creationTime), // Modification time u32(trackData.timescale), // Timescale u32OrU64(localDuration), // Duration u16(getLanguageCodeInt(trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE)), // Language u16(0), // Quality ]); }; const TRACK_TYPE_TO_COMPONENT_SUBTYPE: Record<IsobmffTrackData['type'], string> = { video: 'vide', audio: 'soun', subtitle: 'text', }; const TRACK_TYPE_TO_HANDLER_NAME: Record<IsobmffTrackData['type'], string> = { video: 'MediabunnyVideoHandler', audio: 'MediabunnySoundHandler', subtitle: 'MediabunnyTextHandler', }; /** Handler Reference Box. */ export const hdlr = ( hasComponentType: boolean, handlerType: string, name: string, manufacturer = '\0\0\0\0', ) => fullBox('hdlr', 0, 0, [ hasComponentType ? ascii('mhlr') : u32(0), // Component type ascii(handlerType), // Component subtype ascii(manufacturer), // Component manufacturer u32(0), // Component flags u32(0), // Component flags mask ascii(name, 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 = (trackData: IsobmffTrackData) => box('minf', undefined, [ TRACK_TYPE_TO_HEADER_BOX[trackData.type](), dinf(), stbl(trackData), ]); /** 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 ]); /** Null Media Header Box. */ export const nmhd = () => fullBox('nmhd', 0, 0); const TRACK_TYPE_TO_HEADER_BOX: Record<IsobmffTrackData['type'], () => Box> = { video: vmhd, audio: smhd, subtitle: nmhd, }; /** * 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', undefined, [ 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 = (trackData: IsobmffTrackData) => { const needsCtts = trackData.compositionTimeOffsetTable.length > 1 || trackData.compositionTimeOffsetTable.some(x => x.sampleCompositionTimeOffset !== 0); return box('stbl', undefined, [ stsd(trackData), stts(trackData), needsCtts ? ctts(trackData) : null, needsCtts ? cslg(trackData) : null, stsc(trackData), stsz(trackData), stco(trackData), stss(trackData), ]); }; /** * 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 = (trackData: IsobmffTrackData) => { let sampleDescription: Box; if (trackData.type === 'video') { sampleDescription = videoSampleDescription( videoCodecToBoxName(trackData.track.source._codec, trackData.info.decoderConfig.codec), trackData, ); } else if (trackData.type === 'audio') { const boxName = audioCodecToBoxName(trackData.track.source._codec, trackData.muxer.isQuickTime); assert(boxName); sampleDescription = soundSampleDescription( boxName, trackData, ); } else if (trackData.type === 'subtitle') { sampleDescription = subtitleSampleDescription( SUBTITLE_CODEC_TO_BOX_NAME[trackData.track.source._codec], trackData, ); } assert(sampleDescription!); return fullBox('stsd', 0, 0, [ u32(1), // Entry count ], [ sampleDescription, ]); }; /** Video Sample Description Box: Contains information that defines how to interpret video media data. */ export const videoSampleDescription = ( compressionType: string, trackData: IsobmffVideoTrackData, ) => 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(trackData.info.width), // Width u16(trackData.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[trackData.track.source._codec](trackData), pasp(trackData), colorSpaceIsComplete(trackData.info.decoderConfig.colorSpace) ? colr(trackData) : null, ]); /** Pixel Aspect Ratio Box: Specifies pixel width:height spacing for non-square pixels. */ export const pasp = (trackData: IsobmffVideoTrackData) => { if (trackData.info.pixelAspectRatio.num === trackData.info.pixelAspectRatio.den) { return null; } return box('pasp', [ u32(trackData.info.pixelAspectRatio.num), u32(trackData.info.pixelAspectRatio.den), ]); }; /** Colour Information Box: Specifies the color space of the video. */ export const colr = (trackData: IsobmffVideoTrackData) => box('colr', [ ascii('nclx'), // Colour type u16(COLOR_PRIMARIES_MAP[trackData.info.decoderConfig.colorSpace!.primaries!]), // Colour primaries u16(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace!.transfer!]), // Transfer characteristics u16(MATRIX_COEFFICIENTS_MAP[trackData.info.decoderConfig.colorSpace!.matrix!]), // Matrix coefficients u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7), // Full range flag ]); /** AVC Configuration Box: Provides additional information to the decoder. */ export const avcC = (trackData: IsobmffVideoTrackData) => trackData.info.decoderConfig && box('avcC', [ // For AVC, description is an AVCDecoderConfigurationRecord, so nothing else to do here ...toUint8Array(trackData.info.decoderConfig.description!), ]); /** HEVC Configuration Box: Provides additional information to the decoder. */ export const hvcC = (trackData: IsobmffVideoTrackData) => trackData.info.decoderConfig && box('hvcC', [ // For HEVC, description is an HEVCDecoderConfigurationRecord, so nothing else to do here ...toUint8Array(trackData.info.decoderConfig.description!), ]); /** VP Configuration Box: Provides additional information to the decoder. */ export const vpcC = (trackData: IsobmffVideoTrackData) => { // Reference: https://www.webmproject.org/vp9/mp4/ if (!trackData.info.decoderConfig) { return null; } const decoderConfig = trackData.info.decoderConfig; const parts = decoderConfig.codec.split('.'); // We can derive the required values from the codec string const profile = Number(parts[1]); const level = Number(parts[2]); const bitDepth = Number(parts[3]); const chromaSubsampling = parts[4] ? Number(parts[4]) : 1; // 4:2:0 colocated with luma (0,0) const videoFullRangeFlag = parts[8] ? Number(parts[8]) : Number(decoderConfig.colorSpace?.fullRange ?? 0); const thirdByte = (bitDepth << 4) + (chromaSubsampling << 1) + videoFullRangeFlag; const colourPrimaries = parts[5] ? Number(parts[5]) : decoderConfig.colorSpace?.primaries ? COLOR_PRIMARIES_MAP[decoderConfig.colorSpace.primaries] : 2; // Default to undetermined const transferCharacteristics = parts[6] ? Number(parts[6]) : decoderConfig.colorSpace?.transfer ? TRANSFER_CHARACTERISTICS_MAP[decoderConfig.colorSpace.transfer] : 2; const matrixCoefficients = parts[7] ? Number(parts[7]) : decoderConfig.colorSpace?.matrix ? MATRIX_COEFFICIENTS_MAP[decoderConfig.colorSpace.matrix] : 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 = (trackData: IsobmffVideoTrackData) => { return box('av1C', generateAv1CodecConfigurationFromCodecString(trackData.info.decoderConfig.codec)); }; /** Sound Sample Description Box: Contains information that defines how to interpret sound media data. */ export const soundSampleDescription = ( compressionType: string, trackData: IsobmffAudioTrackData, ) => { let version = 0; let contents: NestedNumberArray; let sampleSizeInBits = 16; const isPcmCodec = (PCM_AUDIO_CODECS as readonly AudioCodec[]).includes(trackData.track.source._codec); if (isPcmCodec) { const codec = trackData.track.source._codec as PcmAudioCodec; const { sampleSize } = parsePcmCodec(codec); sampleSizeInBits = 8 * sampleSize; if (sampleSizeInBits > 16) { version = 1; } } if (trackData.muxer.isQuickTime) { version = 1; } if (version === 0) { contents = [ Array(6).fill(0), // Reserved u16(1), // Data reference index u16(version), // Version u16(0), // Revision level u32(0), // Vendor u16(trackData.info.numberOfChannels), // Number of channels u16(sampleSizeInBits), // Sample size (bits) u16(0), // Compression ID u16(0), // Packet size u16(trackData.info.sampleRate < 2 ** 16 ? trackData.info.sampleRate : 0), // Sample rate (upper) u16(0), // Sample rate (lower) ]; } else { const compressionId = isPcmCodec ? 0 : -2; contents = [ Array(6).fill(0), // Reserved u16(1), // Data reference index u16(version), // Version u16(0), // Revision level u32(0), // Vendor u16(trackData.info.numberOfChannels), // Number of channels u16(Math.min(sampleSizeInBits, 16)), // Sample size (bits) i16(compressionId), // Compression ID u16(0), // Packet size u16(trackData.info.sampleRate < 2 ** 16 ? trackData.info.sampleRate : 0), // Sample rate (upper) u16(0), // Sample rate (lower) isPcmCodec ? [ u32(1), // Samples per packet (must be 1 for uncompressed formats) u32(sampleSizeInBits / 8), // Bytes per packet u32(trackData.info.numberOfChannels * sampleSizeInBits / 8), // Bytes per frame ] : [ u32(0), // Samples per packet (don't bother, still works with 0) u32(0), // Bytes per packet (variable) u32(0), // Bytes per frame (variable) ], u32(2), // Bytes per sample (constant in FFmpeg) ]; } return box(compressionType, contents, [ audioCodecToConfigurationBox(trackData.track.source._codec, trackData.muxer.isQuickTime)?.(trackData) ?? null, ]); }; /** MPEG-4 Elementary Stream Descriptor Box. */ export const esds = (trackData: IsobmffAudioTrackData) => { // We build up the bytes in a layered way which reflects the nested structure let objectTypeIndication: number; switch (trackData.track.source._codec) { case 'aac': { objectTypeIndication = 0x40; }; break; case 'mp3': { objectTypeIndication = 0x6b; }; break; case 'vorbis': { objectTypeIndication = 0xdd; }; break; default: throw new Error(`Unhandled audio codec: ${trackData.track.source._codec}`); } let bytes = [ ...u8(objectTypeIndication), // Object type indication ...u8(0x15), // stream type(6bits)=5 audio, flags(2bits)=1 ...u24(0), // 24bit buffer size ...u32(0), // max bitrate ...u32(0), // avg bitrate ]; if (trackData.info.decoderConfig.description) { const description = toUint8Array(trackData.info.decoderConfig.description); // Add the decoder description to the end bytes = [ ...bytes, ...u8(0x05), // TAG(5) = DecoderSpecificInfo ...variableUnsignedInt(description.byteLength), ...description, ]; } bytes = [ ...u16(1), // ES_ID = 1 ...u8(0x00), // flags etc = 0 ...u8(0x04), // TAG(4) = ES Descriptor ...variableUnsignedInt(bytes.length), ...bytes, ...u8(0x06), // TAG(6) ...u8(0x01), // length ...u8(0x02), // data ]; bytes = [ ...u8(0x03), // TAG(3) = Object Descriptor ...variableUnsignedInt(bytes.length), ...bytes, ]; return fullBox('esds', 0, 0, bytes); }; export const wave = (trackData: IsobmffAudioTrackData) => { return box('wave', undefined, [ frma(trackData), enda(trackData), box('\x00\x00\x00\x00'), // NULL tag at the end ]); }; export const frma = (trackData: IsobmffAudioTrackData) => { return box('frma', [ ascii(audioCodecToBoxName(trackData.track.source._codec, trackData.muxer.isQuickTime)), ]); }; // This box specifies PCM endianness export const enda = (trackData: IsobmffAudioTrackData) => { const { littleEndian } = parsePcmCodec(trackData.track.source._codec as PcmAudioCodec); return box('enda', [ u16(+littleEndian), ]); }; /** Opus Specific Box. */ export const dOps = (trackData: IsobmffAudioTrackData) => { let outputChannelCount = trackData.info.numberOfChannels; // Default PreSkip, should be at least 80 milliseconds worth of playback, measured in 48000 Hz samples let preSkip = 3840; let inputSampleRate = trackData.info.sampleRate; let outputGain = 0; let channelMappingFamily = 0; let channelMappingTable: Uint8Array<ArrayBufferLike> = new Uint8Array(0); // Read preskip and from codec private data from the encoder // https://www.rfc-editor.org/rfc/rfc7845#section-5 const description = trackData.info.decoderConfig?.description; if (description) { assert(description.byteLength >= 18); const bytes = toUint8Array(description); const header = parseOpusIdentificationHeader(bytes); outputChannelCount = header.outputChannelCount; preSkip = header.preSkip; inputSampleRate = header.inputSampleRate; outputGain = header.outputGain; channelMappingFamily = header.channelMappingFamily; if (header.channelMappingTable) { channelMappingTable = header.channelMappingTable; } } // https://www.opus-codec.org/docs/opus_in_isobmff.html return box('dOps', [ u8(0), // Version u8(outputChannelCount), // OutputChannelCount u16(preSkip), // PreSkip u32(inputSampleRate), // InputSampleRate i16(outputGain), // OutputGain u8(channelMappingFamily), // ChannelMappingFamily ...channelMappingTable, ]); }; /** FLAC specific box. */ export const dfLa = (trackData: IsobmffAudioTrackData) => { const description = trackData.info.decoderConfig?.description; assert(description); const bytes = toUint8Array(description); return fullBox('dfLa', 0, 0, [ ...bytes.subarray(4), ]); }; /** PCM Configuration Box, ISO/IEC 23003-5. */ const pcmC = (trackData: IsobmffAudioTrackData) => { const { littleEndian, sampleSize } = parsePcmCodec(trackData.track.source._codec as PcmAudioCodec); const formatFlags = +littleEndian; return fullBox('pcmC', 0, 0, [ u8(formatFlags), u8(8 * sampleSize), ]); }; /** AC3SpecificBox */ const dac3 = (trackData: IsobmffAudioTrackData) => { const frameInfo = parseAc3SyncFrame(trackData.info.firstPacket.data); if (!frameInfo) { throw new Error( 'Couldn\'t extract AC-3 frame info from the audio packet. ' + 'Ensure the packets contain valid AC-3 sync frames (as specified in ETSI TS 102 366).', ); } const bytes = new Uint8Array(3); const bitstream = new Bitstream(bytes); bitstream.writeBits(2, frameInfo.fscod); bitstream.writeBits(5, frameInfo.bsid); bitstream.writeBits(3, frameInfo.bsmod); bitstream.writeBits(3, frameInfo.acmod); bitstream.writeBits(1, frameInfo.lfeon); bitstream.writeBits(5, frameInfo.bitRateCode); bitstream.writeBits(5, 0); // reserved return box('dac3', [...bytes]); }; /** EC3SpecificBox */ const dec3 = (trackData: IsobmffAudioTrackData) => { const frameInfo = parseEac3SyncFrame(trackData.info.firstPacket.data); if (!frameInfo) { throw new Error( 'Couldn\'t extract E-AC-3 frame info from the audio packet. ' + 'Ensure the packets contain valid E-AC-3 sync frames (as specified in ETSI TS 102 366).', ); } // Calculate size let totalBits = 16; // header: data_rate (13) + num_ind_sub (3) for (const sub of frameInfo.substreams) { totalBits += 23; // fixed fields per substream if (sub.numDepSub > 0) { totalBits += 9; // chan_loc } else { totalBits += 1; // reserved } } const size = Math.ceil(totalBits / 8); const bytes = new Uint8Array(size); const bitstream = new Bitstream(bytes); bitstream.writeBits(13, frameInfo.dataRate); bitstream.writeBits(3, frameInfo.substreams.length - 1); // num_ind_sub for (const sub of frameInfo.substreams) { bitstream.writeBits(2, sub.fscod); bitstream.writeBits(5, sub.bsid); bitstream.writeBits(1, 0); // reserved bitstream.writeBits(1, 0); // asvc = 0 bitstream.writeBits(3, sub.bsmod); bitstream.writeBits(3, sub.acmod); bitstream.writeBits(1, sub.lfeon); bitstream.writeBits(3, 0); // reserved bitstream.writeBits(4, sub.numDepSub); if (sub.numDepSub > 0) { bitstream.writeBits(9, sub.chanLoc); } else { bitstream.writeBits(1, 0); // reserved } } return box('dec3', [...bytes]); }; export const subtitleSampleDescription = ( compressionType: string, trackData: IsobmffSubtitleTrackData, ) => box(compressionType, [ Array(6).fill(0), // Reserved u16(1), // Data reference index ], [ SUBTITLE_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec](trackData), ]); export const vttC = (trackData: IsobmffSubtitleTrackData) => box('vttC', [ ...textEncoder.encode(trackData.info.config.description), ]); export const txtC = (textConfig: Uint8Array) => fullBox('txtC', 0, 0, [ ...textConfig, 0, // Text config (null-terminated) ]); /** * 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 = (trackData: IsobmffTrackData) => { return fullBox('stts', 0, 0, [ u32(trackData.timeToSampleTable.length), // Number of entries trackData.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 = (trackData: IsobmffTrackData) => { if (trackData.samples.every(x => x.type === 'key')) return null; // No stss box -> every frame is a key frame const keySamples = [...trackData.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 = (trackData: IsobmffTrackData) => { return fullBox('stsc', 0, 0, [ u32(trackData.compactlyCodedChunkTable.length), // Number of entries trackData.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 = (trackData: IsobmffTrackData) => { if (trackData.type === 'audio' && trackData.info.requiresPcmTransformation) { const { sampleSize } = parsePcmCodec(trackData.track.source._codec as PcmAudioCodec); // With PCM, every sample has the same size return fullBox('stsz', 0, 0, [ u32(sampleSize * trackData.info.numberOfChannels), // Sample size u32(trackData.samples.reduce((acc, x) => acc + intoTimescale(x.duration, trackData.timescale), 0)), ]); } return fullBox('stsz', 0, 0, [ u32(0), // Sample size (0 means non-constant size) u32(trackData.samples.length), // Number of entries trackData.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 = (trackData: IsobmffTrackData) => { if (trackData.finalizedChunks.length > 0 && last(trackData.finalizedChunks)!.offset! >= 2 ** 32) { // If the file is large, use the co64 box return fullBox('co64', 0, 0, [ u32(trackData.finalizedChunks.length), // Number of entries trackData.finalizedChunks.map(x => u64(x.offset!)), // Chunk offset table ]); } return fullBox('stco', 0, 0, [ u32(trackData.finalizedChunks.length), // Number of entries trackData.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 = (trackData: IsobmffTrackData) => { return fullBox('ctts', 1, 0, [ u32(trackData.compositionTimeOffsetTable.length), // Number of entries trackData.compositionTimeOffsetTable.map(x => [ // Time-to-sample table u32(x.sampleCount), // Sample count i32(x.sampleCompositionTimeOffset), // Sample offset ]), ]); }; /** * Composition to Decode Box: Stores information about the composition and display times of the media samples. */ export const cslg = (trackData: IsobmffTrackData) => { let leastDecodeToDisplayDelta = Infinity; let greatestDecodeToDisplayDelta = -Infinity; let compositionStartTime = Infinity; let compositionEndTime = -Infinity; assert(trackData.compositionTimeOffsetTable.length > 0); assert(trackData.samples.length > 0); for (let i = 0; i < trackData.compositionTimeOffsetTable.length; i++) { const entry = trackData.compositionTimeOffsetTable[i]!; leastDecodeToDisplayDelta = Math.min(leastDecodeToDisplayDelta, entry.sampleCompositionTimeOffset); greatestDecodeToDisplayDelta = Math.max(greatestDecodeToDisplayDelta, entry.sampleCompositionTimeOffset); } for (let i = 0; i < trackData.samples.length; i++) { const sample = trackData.samples[i]!; compositionStartTime = Math.min( compositionStartTime, intoTimescale(sample.timestamp, trackData.timescale), ); compositionEndTime = Math.max( compositionEndTime, intoTimescale(sample.timestamp + sample.duration, trackData.timescale), ); } const compositionToDtsShift = Math.max(-leastDecodeToDisplayDelta, 0); if (compositionEndTime >= 2 ** 31) { // For very large files, the composition end time can't be represented in i32, so let's just scrap the box in // that case. QuickTime fails to read the file if there's a cslg box with version 1, so that's sadly not an // option. return null; } return fullBox('cslg', 0, 0, [ i32(compositionToDtsShift), // Composition to DTS shift i32(leastDecodeToDisplayDelta), // Least decode to display delta i32(greatestDecodeToDisplayDelta), // Greatest decode to display delta i32(compositionStartTime), // Composition start time i32(compositionEndTime), // Composition end time ]); }; /** * 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 = (trackDatas: IsobmffTrackData[]) => { return box('mvex', undefined, trackDatas.map(trex)); }; /** Track Extends Box: Contains the default values used by the movie fragments. */ export const trex = (trackData: IsobmffTrackData) => { return fullBox('trex', 0, 0, [ u32(trackData.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, trackDatas: IsobmffTrackData[]) => { return box('moof', undefined, [ mfhd(sequenceNumber), ...trackDatas.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; const byte3 = 0; const byte4 = 0; const 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 = (trackData: IsobmffTrackData) => { return box('traf', undefined, [ tfhd(trackData), tfdt(trackData), trun(trackData), ]); }; /** Track Fragment Header Box: Provides a reference to the extended track, and flags. */ export const tfhd = (trackData: IsobmffTrackData) => { assert(trackData.currentChunk); 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" const referenceSample = trackData.currentChunk.samples[1] ?? trackData.currentChunk.samples[0]!; const referenceSampleInfo = { duration: referenceSample.timescaleUnitsToNextSample, size: referenceSample.size, flags: fragmentSampleFlags(referenceSample), }; return fullBox('tfhd', 0, tfFlags, [ u32(trackData.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 = (trackData: IsobmffTrackData) => { assert(trackData.currentChunk); return fullBox('tfdt', 1, 0, [ u64(intoTimescale(trackData.currentChunk.startTimestamp, trackData.timescale)), // Base Media Decode Time ]); }; /** Track Run Box: Specifies a run of contiguous samples for a given track. */ export const trun = (trackData: IsobmffTrackData) => { assert(trackData.currentChunk); const allSampleDurations = trackData.currentChunk.samples.map(x => x.timescaleUnitsToNextSample); const allSampleSizes = trackData.currentChunk.samples.map(x => x.size); const allSampleFlags = trackData.currentChunk.samples.map(fragmentSampleFlags); const allSampleCompositionTimeOffsets = trackData.currentChunk.samples .map(x => intoTimescale(x.timestamp - x.decodeTimestamp, trackData.timescale)); const uniqueSampleDurations = new Set(allSampleDurations); const uniqueSampleSizes = new Set(allSampleSizes); const uniqueSampleFlags = new Set(allSampleFlags); const uniqueSampleCompositionTimeOffsets = new Set(allSampleCompositionTimeOffsets); const firstSampleFlagsPresent = uniqueSampleFlags.size === 2 && allSampleFlags[0] !== allSampleFlags[1]; const sampleDurationPresent = uniqueSampleDurations.size > 1; const sampleSizePresent = uniqueSampleSizes.size > 1; const sampleFlagsPresent = !firstSampleFlagsPresent && uniqueSampleFlags.size > 1; const 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(trackData.currentChunk.samples.length), // Sample count u32(trackData.currentChunk.offset! - trackData.currentChunk.moofOffset! || 0), // Data offset firstSampleFlagsPresent ? u32(allSampleFlags[0]!) : [], trackData.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 = (trackDatas: IsobmffTrackData[]) => { return box('mfra', undefined, [ ...trackDatas.map(tfra), mfro(), ]); }; /** Track Fragment Random Access Box: Provides pointers to sync samples within the file for random access. */ export const tfra = (trackData: IsobmffTrackData, trackIndex: number) => { const version = 1; // Using this version allows us to use 64-bit time and offset values return fullBox('tfra', version, 0, [ u32(trackData.track.id), // Track ID u32(0b111111), // This specifies that traf number, trun number and sample number are 32-bit ints u32(trackData.finalizedChunks.length), // Number of entries trackData.finalizedChunks.map(chunk => [ u64(intoTimescale(chunk.samples[0]!.timestamp, trackData.timescale)), // Time (in presentation 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 ]); }; /** VTT Empty Cue Box */ export const vtte = () => box('vtte'); /** VTT Cue Box */ export const vttc = ( payload: string, timestamp: number | null, identifier: string | null, settings: string | null, sourceId: number | null, ) => box('vttc', undefined, [ sourceId !== null ? box('vsid', [i32(sourceId)]) : null, identifier !== null ? box('iden', [...textEncoder.encode(identifier)]) : null, timestamp !== null ? box('ctim', [...textEncoder.encode(formatSubtitleTimestamp(timestamp))]) : null, settings !== null ? box('sttg', [...textEncoder.encode(settings)]) : null, box('payl', [...textEncoder.encode(payload)]), ]); /** VTT Additional Text Box */ export const vtta = (notes: string) => box('vtta', [...textEncoder.encode(notes)]); /** User Data Box */ const udta = (muxer: IsobmffMuxer) => { const boxes: Box[] = []; const metadataFormat = muxer.format._options.metadataFormat ?? 'auto'; const metadataTags = muxer.output._metadataTags; // Depending on the format, metadata tags are written differently if (metadataFormat === 'mdir' || (metadataFormat === 'auto' && !muxer.isQuickTime)) { const metaBox = metaMdir(metadataTags); if (metaBox) boxes.push(metaBox); } else if (metadataFormat === 'mdta') { const metaBox = metaMdta(metadataTags); if (metaBox) boxes.push(metaBox); } else if (metadataFormat === 'udta' || (metadataFormat === 'auto' && muxer.isQuickTime)) { addQuickTimeMetadataTagBoxes(boxes, muxer.output._metadataTags); } if (boxes.length === 0) { return null; } return box('udta', undefined, boxes); }; const addQuickTimeMetadataTagBoxes = (boxes: Box[], tags: MetadataTags) => { // https://exiftool.org/TagNames/QuickTime.html (QuickTime UserData Tags) // For QuickTime files, metadata tags are dumped into the udta box for (const { key, value } of keyValueIterator(tags)) { switch (key) { case 'title': { boxes.push(metadataTagStringBoxShort('©nam', value)); }; break; case 'description': { boxes.push(metadataTagStringBoxShort('©des', value)); }; break; case 'artist': { boxes.push(metadataTagStringBoxShort('©ART', value)); }; break; case 'album': { boxes.push(metadataTagStringBoxShort('©alb', value)); }; break; case 'albumArtist': { boxes.push(metadataTagStringBoxShort('albr', value)); }; break; case 'genre': { boxes.push(metadataTagStringBoxShort('©gen', value)); }; break; case 'date': { boxes.push(metadataTagStringBoxShort('©day', value.toISOString().slice(0, 10))); }; break; case 'comment': { boxes.push(metadataTagStringBoxShort('©cmt', v