diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts index b54f9ef..cd50c07 100644 --- a/examples/procedural-generation/procedural-generation.ts +++ b/examples/procedural-generation/procedural-generation.ts @@ -13,6 +13,7 @@ import { MpegTsOutputFormat, AdtsOutputFormat, StreamTarget, + CmafOutputFormat, } from 'mediabunny'; const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement; @@ -119,8 +120,8 @@ const generateVideo = async () => { */ }, format: new HlsOutputFormat({ - segmentFormats: [new AdtsOutputFormat(), new MpegTsOutputFormat()], - // singleFilePerPlaylist: true, + segmentFormat: new CmafOutputFormat(), + singleFilePerPlaylist: true, getPlaylistPath: info => `sussex-${info.n}.m3u8`, }), }); @@ -146,7 +147,7 @@ const generateVideo = async () => { // For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext let audioBufferSource: AudioBufferSource | null = null; - let audioBufferSource2: AudioBufferSource | null = null; + const audioBufferSource2: AudioBufferSource | null = null; // Retrieve the first audio codec supported by this browser that can be contained in the output format const audioCodec = await getFirstEncodableAudioCodec(output.format.getSupportedAudioCodecs(), { @@ -160,11 +161,13 @@ const generateVideo = async () => { }); output.addAudioTrack(audioBufferSource); + /* audioBufferSource2 = new AudioBufferSource({ codec: audioCodec, bitrate: QUALITY_HIGH, }); output.addAudioTrack(audioBufferSource2, { languageCode: 'esp' }); + */ } else { alert('Your browser doesn\'t support audio encoding, so we won\'t include audio in the output file.'); } @@ -208,8 +211,8 @@ const generateVideo = async () => { await audioBufferSource.add(audioBuffer); audioBufferSource.close(); - await audioBufferSource2!.add(audioBuffer); - audioBufferSource2!.close(); + // await audioBufferSource2!.add(audioBuffer); + // audioBufferSource2!.close(); } clearInterval(progressInterval); diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 58f70d9..122b1c3 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -1,6 +1,6 @@ import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source'; -import { arrayArgmax, assert, findLastIndex, joinPaths, textEncoder, UNDETERMINED_LANGUAGE } from '../misc'; +import { arrayArgmax, assert, findLastIndex, joinPaths, textEncoder, toArray, UNDETERMINED_LANGUAGE } from '../misc'; import { Muxer } from '../muxer'; import { Output, @@ -11,11 +11,17 @@ import { OutputVideoTrack, TrackType, } from '../output'; -import { HlsOutputFormat, HlsOutputFormatOptions, HlsOutputSegmentInfo, OutputFormat } from '../output-format'; +import { + HlsOutputFormat, + HlsOutputFormatOptions, + HlsOutputPlaylistInfo, + HlsOutputSegmentInfo, + OutputFormat, +} from '../output-format'; import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { SubtitleCue, SubtitleMetadata } from '../subtitles'; -import { Target } from '../target'; +import { NullTarget, Target } from '../target'; type HlsTrackData = { track: OutputTrack; @@ -34,6 +40,13 @@ type HlsTrackData = { type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } }; type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } }; +type PlaylistSegment = { + path: string; + duration: number; + byteSize: number; + byteOffset: number | null; +}; + type Playlist = { id: number; path: string; @@ -43,12 +56,8 @@ type Playlist = { currentSegmentStartTimestamp: number | null; currentSegmentStartTimestampIsFixed: boolean; nextSegmentId: number; - writtenSegments: { - path: string; - duration: number; - byteSize: number; - byteOffset: number | null; - }[]; + initSegment: PlaylistSegment | null; + writtenSegments: PlaylistSegment[]; peakBitrate: number | null; averageBitrate: number | null; @@ -71,6 +80,7 @@ export class HlsMuxer extends Muxer { format: HlsOutputFormat; getPlaylistPath: NonNullable; getSegmentPath: NonNullable; + getInitPath: NonNullable; targetSegmentDuration: number; trackDatas: HlsTrackData[] = []; @@ -96,6 +106,8 @@ export class HlsMuxer extends Muxer { ?? (info => info.isSingleFile ? `segments-${info.playlist.n}${info.format.fileExtension}` : `segment-${info.playlist.n}-${info.n}${info.format.fileExtension}`); + this.getInitPath = format._options.getInitPath + ?? (playlist => `init-${playlist.n}${playlist.segmentFormat.fileExtension}`); } async start(): Promise { @@ -348,7 +360,7 @@ export class HlsMuxer extends Muxer { codecs.push(track.source._codec); } - for (const format of this.format._options.segmentFormats) { + for (const format of toArray(this.format._options.segmentFormat)) { const supportedCodecs = format.getSupportedCodecs(); const trackCounts = format.getSupportedTrackCounts(); @@ -387,21 +399,15 @@ export class HlsMuxer extends Muxer { throw new Error('Internal error: track is already registered in a playlist.'); // Should be unreachable } + const format = deduceSegmentFormat(tracks); + const id = this.playlists.length + 1; const path = await this.getPlaylistPath({ n: id, tracks, + segmentFormat: format, }); - if (typeof path !== 'string') { - throw new TypeError('options.getPlaylistPath must return or resolve to a string'); - } - if (/[\n\r"]/.test(path)) { - throw new TypeError( - 'Playlist paths cannot contain line feed, carriage return, or double quote characters.', - ); - } - - const format = deduceSegmentFormat(tracks); + validatePlaylistPath(path); const playlist: Playlist = { id: this.playlists.length + 1, @@ -411,6 +417,7 @@ export class HlsMuxer extends Muxer { currentSegmentStartTimestamp: null, currentSegmentStartTimestampIsFixed: false, nextSegmentId: 1, + initSegment: null, writtenSegments: [], peakBitrate: null, averageBitrate: null, @@ -762,10 +769,7 @@ export class HlsMuxer extends Muxer { n: playlist.nextSegmentId, format: playlist.segmentFormat, isSingleFile: true, - playlist: { - n: playlist.id, - tracks: playlist.tracks, - }, + playlist: toPlaylistInfo(playlist), }; relativeSegmentPath = await this.getSegmentPath(segmentInfo); @@ -797,10 +801,7 @@ export class HlsMuxer extends Muxer { n: playlist.nextSegmentId, format: playlist.segmentFormat, isSingleFile: false, - playlist: { - n: playlist.id, - tracks: playlist.tracks, - }, + playlist: toPlaylistInfo(playlist), }; relativeSegmentPath = await this.getSegmentPath(segmentInfo); @@ -818,7 +819,7 @@ export class HlsMuxer extends Muxer { format: playlist.segmentFormat, rootPath: fullSegmentPath, target: async (request) => { - if (request.path === fullSegmentPath) { + if (request.isRoot) { if (playlist.singleFile) { const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); slice.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end); @@ -835,6 +836,55 @@ export class HlsMuxer extends Muxer { return this.output._getTarget(request); }, + initTarget: async () => { + if (playlist.initSegment) { + // We already have an init segment from a previous segment + return new NullTarget(); + } + + if (playlist.singleFile) { + playlist.initSegment = { + path: playlist.singleFile.path, + duration: 0, + byteSize: 0, + byteOffset: 0, + }; + + const slice = playlist.singleFile.target.slice(playlist.singleFile.nextOffset); + slice.onwrite = (_, end) => { + playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); + }; + slice.onfinalized = () => { + playlist.singleFile!.nextOffset = playlist.initSegment!.byteSize; + }; + + return slice; + } else { + const playlistInfo = toPlaylistInfo(playlist); + const path = await this.getInitPath(playlistInfo); + validateInitPath(path); + + playlist.initSegment = { + path, + duration: 0, + byteSize: 0, + byteOffset: null, + }; + + const target = await this.output._getTarget({ + path, + isRoot: false, + }); + target.onwrite = (_, end) => { + playlist.initSegment!.byteSize = Math.max(playlist.initSegment!.byteSize, end); + }; + target.onfinalized = () => { + this.format._options.onInit?.(target, playlistInfo); + }; + + return target; + } + }, }); let maxEndTimestamp = -Infinity; @@ -987,13 +1037,28 @@ export class HlsMuxer extends Muxer { if (isKeyPacketsOnly || hasByteOffsets) { version = 4; } + if (playlist.initSegment) { + version = 5; + } + if (playlist.initSegment && !isKeyPacketsOnly) { + // "if it contains the EXT-X-MAP tag in a Media Playlist that does not contain EXT-X-I-FRAMES-ONLY" + version = 6; + } const playlistPath = joinPaths(this.output._rootPath!, playlist.path); const playlistText = '#EXTM3U\n' + `#EXT-X-VERSION:${version}\n` + '#EXT-X-PLAYLIST-TYPE:VOD\n' + `#EXT-X-TARGETDURATION:${Math.ceil(targetDuration)}\n` // Must be a "decimal-integer" + + '#EXT-X-INDEPENDENT-SEGMENTS\n' // TODO not when live + (isKeyPacketsOnly ? '#EXT-X-I-FRAMES-ONLY\n' : '') + + (playlist.initSegment + ? (`#EXT-X-MAP:URI="${playlist.initSegment.path}"` + + (playlist.initSegment.byteOffset !== null + ? `,BYTERANGE="${playlist.initSegment.byteSize}@${playlist.initSegment.byteOffset}"` + : '') + + '\n') + : '') + '\n' + (playlist.writtenSegments .map(segment => ( @@ -1007,10 +1072,7 @@ export class HlsMuxer extends Muxer { + (playlist.writtenSegments.length > 0 ? '\n' : '') + '#EXT-X-ENDLIST\n'; - this.format._options.onPlaylist?.(playlistText, { - n: playlist.id, - tracks: playlist.tracks, - }); + this.format._options.onPlaylist?.(playlistText, toPlaylistInfo(playlist)); const target = await this.output._getTarget({ path: playlistPath, isRoot: false }); const writer = new Writer(target); @@ -1220,6 +1282,17 @@ export class HlsMuxer extends Muxer { } } +const validatePlaylistPath = (path: string) => { + if (typeof path !== 'string') { + throw new TypeError('options.getPlaylistPath must return or resolve to a string'); + } + if (/[\n\r"]/.test(path)) { + throw new TypeError( + 'Playlist paths cannot contain line feed, carriage return, or double quote characters.', + ); + } +}; + const validateSegmentPath = (path: string) => { if (typeof path !== 'string') { throw new TypeError('options.getSegmentPath must return or resolve to a string'); @@ -1230,3 +1303,22 @@ const validateSegmentPath = (path: string) => { ); } }; + +const validateInitPath = (path: string) => { + if (typeof path !== 'string') { + throw new TypeError('options.getInitPath must return or resolve to a string'); + } + if (/[\n\r"]/.test(path)) { + throw new TypeError( + 'Init paths cannot contain line feed, carriage return, or double quote characters.', + ); + } +}; + +const toPlaylistInfo = (playlist: Playlist): HlsOutputPlaylistInfo => { + return { + n: playlist.id, + tracks: playlist.tracks, + segmentFormat: playlist.segmentFormat, + }; +}; diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index c54a5a6..7eb7e09 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -89,7 +89,7 @@ export class HlsSegmentedInput extends SegmentedInput { this.nextLines = null; if (!lines) { - using ref = await this.demuxer.input._getSourceUncached({ path: this.path }); + using ref = await this.demuxer.input._getSourceUncached({ path: this.path, isRoot: false }); const reader = new Reader(ref.source); const slice = await reader.requestEntireFile(); @@ -559,7 +559,7 @@ export class HlsSegmentedInput extends SegmentedInput { const stream = createAes128CbcDecryptStream(ciphertextReader, async () => { using keyRef = await this.input._getSourceCached( - { path: hlsSegment.encryption!.keyUri }, + { path: hlsSegment.encryption!.keyUri, isRoot: false }, ENCRYPTION_KEY_CACHE_GROUP, ); const keyReader = new Reader(keyRef.source); diff --git a/src/index.ts b/src/index.ts index 8b9083b..ecd863a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ export { OutputFormat, AdtsOutputFormat, AdtsOutputFormatOptions, + CmafOutputFormat, + CmafOutputFormatOptions, FlacOutputFormat, FlacOutputFormatOptions, HlsOutputFormat, diff --git a/src/input-format.ts b/src/input-format.ts index 4a4fc99..ecda005 100644 --- a/src/input-format.ts +++ b/src/input-format.ts @@ -103,7 +103,7 @@ export class Mp4InputFormat extends IsobmffInputFormat { if (slice instanceof Promise) slice = await slice; if (!slice) return false; - return readAscii(slice, 4) === 'moof'; // Not a legal segment start, but seen in practice (sigh) + return readAscii(slice, 4) === 'moof'; // Seen in HLS for example } get name() { diff --git a/src/input.ts b/src/input.ts index dbe5f23..abcaef9 100644 --- a/src/input.ts +++ b/src/input.ts @@ -28,6 +28,7 @@ export const ENCRYPTION_KEY_CACHE_GROUP = 2; export type SourceRequest = { path: string; + isRoot: boolean; }; const sourceRequestsAreEqual = (a: SourceRequest, b: SourceRequest) => { @@ -248,7 +249,7 @@ export class Input implements Disposable { this.onSource?.(ref.source, null); } else { assert(this._entryPath !== null); - ref = await this._getSourceUncached({ path: this._entryPath }); + ref = await this._getSourceUncached({ path: this._entryPath, isRoot: true }); this._sourceRefs.push(ref); } @@ -277,7 +278,7 @@ export class Input implements Disposable { assert(this._entryPath !== null); - const source = this._source({ path: this._entryPath }); + const source = this._source({ path: this._entryPath, isRoot: true }); if (source instanceof Promise) { throw new TypeError( 'Input.source cannot be used when the source function resolves asynchronously.' diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index b2546b1..5ee7f5e 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -57,7 +57,7 @@ export class IsobmffBoxWriter { */ offsets = new WeakMap(); - constructor(private writer: Writer) {} + constructor(public writer: Writer) {} writeU32(value: number) { this.helperView.setUint32(0, value, false); @@ -279,6 +279,7 @@ 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 @@ -296,14 +297,27 @@ export const ftyp = (details: { } if (details.fragmented) { - return box('ftyp', [ - ascii('iso5'), // Major brand - u32(minorVersion), // Minor version - // Compatible brands - ascii('iso5'), - ascii('iso6'), - ascii('mp41'), - ]); + 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', [ @@ -316,6 +330,38 @@ export const ftyp = (details: { ]); }; +/** 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 }); diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index 760e638..bdc863a 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -6,13 +6,27 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes'; +import { + Box, + free, + ftyp, + IsobmffBoxWriter, + mdat, + mfra, + moof, + moov, + sidx, + styp, + vtta, + vttc, + vtte, +} from './isobmff-boxes'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output'; import { Writer } from '../writer'; -import { BufferTarget } from '../target'; +import { BufferTarget, Target } from '../target'; import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc'; -import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat } from '../output-format'; +import { IsobmffOutputFormatOptions, IsobmffOutputFormat, MovOutputFormat, CmafOutputFormat } from '../output-format'; import { inlineTimestampRegex, SubtitleConfig, SubtitleCue, SubtitleMetadata } from '../subtitles'; import { aacChannelMap, aacFrequencyTable, buildAacAudioSpecificConfig } from '../../shared/aac-misc'; import { @@ -151,12 +165,15 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round = export class IsobmffMuxer extends Muxer { format: IsobmffOutputFormat; - private writer!: Writer; - private boxWriter!: IsobmffBoxWriter; + private writer: Writer | null = null; + private boxWriter: IsobmffBoxWriter | null = null; + private initWriter: Writer | null = null; + private initBoxWriter: IsobmffBoxWriter | null = null; private fastStart!: NonNullable; isFragmented!: boolean; isQuickTime: boolean; + isCmaf: boolean; private auxTarget = new BufferTarget(); private auxWriter = new Writer(this.auxTarget); @@ -174,53 +191,90 @@ export class IsobmffMuxer extends Muxer { private nextFragmentNumber = 1; // Only relevant for fragmented files, to make sure new fragments start with the highest timestamp seen so far private maxWrittenTimestamp = -Infinity; + minWrittenTimestamp = Infinity; + maxWrittenEndTimestamp = -Infinity; private minimumFragmentDuration: number; + private segmentHeaderSize: number | null = null; constructor(output: Output, format: IsobmffOutputFormat) { super(output); this.format = format; this.isQuickTime = format instanceof MovOutputFormat; - this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? 1; + this.isCmaf = format instanceof CmafOutputFormat; + this.minimumFragmentDuration = format._options.minimumFragmentDuration + ?? (format instanceof CmafOutputFormat ? Infinity : 1); } async start() { const release = await this.mutex.acquire(); - this.writer = await this.output._getRootWriter(); - this.boxWriter = new IsobmffBoxWriter(this.writer); + if (!this.isCmaf) { + this.writer = await this.output._getRootWriter(); + this.boxWriter = new IsobmffBoxWriter(this.writer); - // If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as the - // memory usage remains identical - const fastStartDefault = this.writer.target instanceof BufferTarget ? 'in-memory' : false; - this.fastStart = this.format._options.fastStart ?? fastStartDefault; - this.isFragmented = this.fastStart === 'fragmented'; + // If the fastStart option isn't defined, enable in-memory fast start if the target is an ArrayBuffer, as + // the memory usage remains identical + this.fastStart = this.format._options.fastStart + ?? (this.writer.target instanceof BufferTarget ? 'in-memory' : false); + this.isFragmented = this.fastStart === 'fragmented'; + } else { + this.fastStart = 'fragmented'; + this.isFragmented = true; + } if (this.fastStart === 'in-memory' || this.isFragmented) { - this.writer.ensureMonotonicity(); + this.writer?.ensureMonotonicity(); + } + + if (this.isCmaf) { + if (this.output._initTarget === null) { + throw new Error( + `CMAF outputs require the initTarget field in OutputOptions to be set; the init segment` + + ` will be written to it.`, + ); + } + + // Set up the init writer to which we'll write the init segment + const initTarget = this.output._initTarget instanceof Target + ? this.output._initTarget + : await this.output._initTarget(); + const initWriter = new Writer(initTarget); + initWriter.start(); + + this.initWriter = initWriter; + this.initBoxWriter = new IsobmffBoxWriter(initWriter); } const holdsAvc = this.output._tracks.some(x => x.isVideoTrack() && x.source._codec === 'avc'); // Write the header { + const boxWriter = this.initBoxWriter ?? this.boxWriter; + assert(boxWriter); + if (this.format._options.onFtyp) { - this.writer.startTrackingWrites(); + boxWriter.writer.startTrackingWrites(); } - this.boxWriter.writeBox(ftyp({ + boxWriter.writeBox(ftyp({ isQuickTime: this.isQuickTime, holdsAvc: holdsAvc, fragmented: this.isFragmented, + cmaf: this.isCmaf, })); if (this.format._options.onFtyp) { - const { data, start } = this.writer.stopTrackingWrites(); + const { data, start } = boxWriter.writer.stopTrackingWrites(); this.format._options.onFtyp(data, start); } - } - this.ftypSize = this.writer.getPos(); + this.ftypSize = boxWriter.writer.getPos(); + + if (this.isCmaf) { + await this.initWriter!.flush(); + } + } if (this.fastStart === 'in-memory') { // We're write at finalization @@ -239,6 +293,9 @@ export class IsobmffMuxer extends Muxer { } else if (this.isFragmented) { // We write the moov box once we write out the first fragment to make sure we get the decoder configs } else { + assert(this.writer); + assert(this.boxWriter); + if (this.format._options.onMdat) { this.writer.startTrackingWrites(); } @@ -247,7 +304,7 @@ export class IsobmffMuxer extends Muxer { this.boxWriter.writeBox(this.mdat); } - await this.writer.flush(); + await this.writer?.flush(); release(); } @@ -1003,11 +1060,14 @@ export class IsobmffMuxer extends Muxer { if (this.isFragmented) { this.maxWrittenTimestamp = Math.max(this.maxWrittenTimestamp, sample.timestamp); + this.maxWrittenEndTimestamp = Math.max(this.maxWrittenEndTimestamp, sample.timestamp + sample.duration); + this.minWrittenTimestamp = Math.min(this.minWrittenTimestamp, sample.timestamp); } } private async finalizeCurrentChunk(trackData: IsobmffTrackData) { assert(!this.isFragmented); + assert(this.writer); if (!trackData.currentChunk) return; @@ -1078,26 +1138,51 @@ export class IsobmffMuxer extends Muxer { } } - private async finalizeFragment(flushWriter = true) { + private async finalizeFragment(flushWriter = !this.isCmaf) { assert(this.isFragmented); const fragmentNumber = this.nextFragmentNumber++; if (fragmentNumber === 1) { + const boxWriter = this.initBoxWriter ?? this.boxWriter; + assert(boxWriter); + if (this.format._options.onMoov) { - this.writer.startTrackingWrites(); + boxWriter.writer.startTrackingWrites(); } // Write the moov box now that we have all decoder configs const movieBox = moov(this); - this.boxWriter.writeBox(movieBox); + boxWriter.writeBox(movieBox); if (this.format._options.onMoov) { - const { data, start } = this.writer.stopTrackingWrites(); + const { data, start } = boxWriter.writer.stopTrackingWrites(); this.format._options.onMoov(data, start); } + + if (this.isCmaf) { + assert(this.initWriter); + await this.initWriter.flush(); + await this.initWriter.finalize(); // Init segment is done + + // Only now, init the main writer; this way the init writer is fully done before the main writer is + // even acquired + this.writer = await this.output._getRootWriter(); + this.boxWriter = new IsobmffBoxWriter(this.writer); + + this.writer.ensureMonotonicity(); + + const stypSize = this.boxWriter.measureBox(styp()); + const sidxSize = this.boxWriter.measureBox(sidx(this, 0)); + this.segmentHeaderSize = stypSize + sidxSize; + + this.writer.seek(this.segmentHeaderSize); // Make room for the header to be written later + } } + assert(this.writer); + assert(this.boxWriter); + // Not all tracks need to be present in every fragment const tracksInFragment = this.trackDatas.filter(x => x.currentChunk); @@ -1179,6 +1264,9 @@ export class IsobmffMuxer extends Muxer { } private async registerSampleFastStartReserve(trackData: IsobmffTrackData, sample: Sample) { + assert(this.writer); + assert(this.boxWriter); + if (this.allTracksAreKnown()) { if (!this.mdat) { // We finally know all tracks, let's reserve space for the moov box @@ -1296,6 +1384,9 @@ export class IsobmffMuxer extends Muxer { } } + assert(this.writer); + assert(this.boxWriter); + if (this.fastStart === 'in-memory') { this.mdat = mdat(false); let mdatSize: number; @@ -1359,15 +1450,27 @@ export class IsobmffMuxer extends Muxer { this.format._options.onMdat(data, start); } } else if (this.isFragmented) { - // Append the mfra box to the end of the file for better random access - const startPos = this.writer.getPos(); - const mfraBox = mfra(this.trackDatas); - this.boxWriter.writeBox(mfraBox); + if (this.isCmaf) { + const contentSize = this.segmentHeaderSize !== null + ? this.writer.getPos() - this.segmentHeaderSize + : 0; - // Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size - const mfraBoxSize = this.writer.getPos() - startPos; - this.writer.seek(this.writer.getPos() - 4); - this.boxWriter.writeU32(mfraBoxSize); + this.writer.seek(0); + + // Write styp and sidx to the start; we recently made space for these + this.boxWriter.writeBox(styp()); + this.boxWriter.writeBox(sidx(this, contentSize)); + } else { + // Append the mfra box to the end of the file for better random access + const startPos = this.writer.getPos(); + const mfraBox = mfra(this.trackDatas); + this.boxWriter.writeBox(mfraBox); + + // Patch the 'size' field of the mfro box at the end of the mfra box now that we know its actual size + const mfraBoxSize = this.writer.getPos() - startPos; + this.writer.seek(this.writer.getPos() - 4); + this.boxWriter.writeU32(mfraBoxSize); + } } else { assert(this.mdat); diff --git a/src/misc.ts b/src/misc.ts index ba82b56..0f411f8 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -1150,3 +1150,11 @@ export const rejectAfter = (ms: number, message = 'Promise rejected') => { setTimeout(() => reject(new Error(message)), ms); }); }; + +export const toArray = (x: T | T[]) => { + if (Array.isArray(x)) { + return x; + } else { + return [x]; + } +}; diff --git a/src/output-format.ts b/src/output-format.ts index 3af1219..f0f63cd 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -29,7 +29,7 @@ import { Output, OutputTrack, TrackType } from './output'; import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer'; import { WaveMuxer } from './wave/wave-muxer'; import { HlsMuxer } from './hls/hls-muxer'; -import { MaybePromise } from './misc'; +import { MaybePromise, toArray } from './misc'; import { Target } from './target'; /** @@ -333,6 +333,66 @@ export class Mp4OutputFormat extends IsobmffOutputFormat { } } +/** + * CMAF-specific output options. + * @group Output formats + * @public + */ +export type CmafOutputFormatOptions = Omit & { + /** + * Controls the minimum duration of each fragment, in seconds. New fragments will only be created when the current + * fragment is longer than this value. Defaults to `Infinity`, meaning the file will contain only one fragment. + */ + minimumFragmentDuration?: number; +}; + +/** + * Creates a single Common Media Application Format (CMAF) segment. An init segment will be written to the + * {@link Target} specified in {@link OutputOptions.initTarget}. Supports most codecs. + * @group Output formats + * @public + */ +export class CmafOutputFormat extends IsobmffOutputFormat { + /** Creates a new {@link CmafOutputFormat} configured with the specified `options`. */ + constructor(options?: CmafOutputFormatOptions) { + super(options); + } + + /** @internal */ + get _name() { + return 'CMAF'; + } + + get fileExtension() { + return '.m4s'; + } + + get mimeType() { + return 'video/mp4'; + } + + getSupportedCodecs(): MediaCodec[] { + return [ + ...VIDEO_CODECS, + ...NON_PCM_AUDIO_CODECS, + + // These are supported via ISO/IEC 23003-5: + 'pcm-s16', + 'pcm-s16be', + 'pcm-s24', + 'pcm-s24be', + 'pcm-s32', + 'pcm-s32be', + 'pcm-f32', + 'pcm-f32be', + 'pcm-f64', + 'pcm-f64be', + + ...SUBTITLE_CODECS, + ]; + } +} + /** * QuickTime File Format (QTFF), often called MOV. Supports all video and audio codecs, but not subtitle codecs. * @group Output formats @@ -1098,6 +1158,7 @@ export class MpegTsOutputFormat extends OutputFormat { export type HlsOutputPlaylistInfo = { n: number; tracks: OutputTrack[]; + segmentFormat: OutputFormat; }; export type HlsOutputSegmentInfo = { @@ -1108,16 +1169,18 @@ export type HlsOutputSegmentInfo = { }; export type HlsOutputFormatOptions = { - segmentFormats: OutputFormat[]; + segmentFormat: OutputFormat | OutputFormat[]; targetDuration?: number; singleFilePerPlaylist?: boolean; getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise; + getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; onMaster?: (content: string) => unknown; onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown; // Document how this is called for the single-file mode onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown; + onInit?: (target: Target, info: HlsOutputPlaylistInfo) => unknown; }; export class HlsOutputFormat extends OutputFormat { @@ -1130,11 +1193,16 @@ export class HlsOutputFormat extends OutputFormat { throw new TypeError('options must be an object.'); } if ( - !Array.isArray(options.segmentFormats) - || options.segmentFormats.length === 0 - || !options.segmentFormats.every(format => format instanceof OutputFormat) + !(options.segmentFormat instanceof OutputFormat) + && ( + !Array.isArray(options.segmentFormat) + || options.segmentFormat.length === 0 + || !options.segmentFormat.every(format => format instanceof OutputFormat) + ) ) { - throw new TypeError('options.segmentFormats must be a non-empty array of OutputFormat instances.'); + throw new TypeError( + 'options.segmentFormat must be an OutputFormat or a non-empty array of OutputFormat instances.', + ); } if ( options.targetDuration !== undefined @@ -1160,6 +1228,9 @@ export class HlsOutputFormat extends OutputFormat { if (options.onSegment !== undefined && typeof options.onSegment !== 'function') { throw new TypeError('options.onSegment, when provided, must be a function.'); } + if (options.onInit !== undefined && typeof options.onInit !== 'function') { + throw new TypeError('options.onInit, when provided, must be a function.'); + } super(); @@ -1185,7 +1256,7 @@ export class HlsOutputFormat extends OutputFormat { } getSupportedCodecs(): MediaCodec[] { - const uniqueCodecs = new Set(this._options.segmentFormats.flatMap(x => x.getSupportedCodecs())); + const uniqueCodecs = new Set(toArray(this._options.segmentFormat).flatMap(x => x.getSupportedCodecs())); return [...uniqueCodecs]; } @@ -1194,7 +1265,7 @@ export class HlsOutputFormat extends OutputFormat { let supportsAudio = false; let supportsSubtitle = false; - for (const format of this._options.segmentFormats) { + for (const format of toArray(this._options.segmentFormat)) { const trackCounts = format.getSupportedTrackCounts(); supportsVideo ||= trackCounts.video.max > 0; supportsAudio ||= trackCounts.audio.max > 0; @@ -1210,7 +1281,7 @@ export class HlsOutputFormat extends OutputFormat { } get supportsVideoRotationMetadata(): boolean { - return this._options.segmentFormats.some(format => format.supportsVideoRotationMetadata); + return toArray(this._options.segmentFormat).some(format => format.supportsVideoRotationMetadata); } get supportsTimestampedMediaData(): boolean { diff --git a/src/output.ts b/src/output.ts index c80d0d0..67cdbe8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation } from './misc'; +import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation, toArray } from './misc'; import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; @@ -14,27 +14,6 @@ import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-s import { Target } from './target'; import { Writer } from './writer'; -export type TargetRequest = { - path: string; - isRoot: boolean; -}; - -/** - * The options for creating an Output object. - * @group Output files - * @public - */ -export type OutputOptions< - F extends OutputFormat = OutputFormat, - T extends Target = Target, -> = { - /** The format of the output file. */ - format: F; - /** The target to which the file will be written. */ - target: T | ((request: TargetRequest) => MaybePromise); - rootPath?: string; -}; - /** * List of all track types. * @group Miscellaneous @@ -163,12 +142,8 @@ export const outputTracksArePairable = (a: OutputTrack, b: OutputTrack) => { return false; } - const aGroups = Array.isArray(a.metadata.group) - ? a.metadata.group - : [a.metadata.group!]; - const bGroups = Array.isArray(b.metadata.group) - ? b.metadata.group - : [b.metadata.group!]; + const aGroups = toArray(a.metadata.group!); + const bGroups = toArray(b.metadata.group!); for (const aGroup of aGroups) { const pairableInSameGroup = a.type !== b.type @@ -282,6 +257,28 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { } }; +export type TargetRequest = { + path: string; + isRoot: boolean; +}; + +/** + * The options for creating an Output object. + * @group Output files + * @public + */ +export type OutputOptions< + F extends OutputFormat = OutputFormat, + T extends Target = Target, +> = { + /** The format of the output file. */ + format: F; + /** The target to which the file will be written. */ + target: T | ((request: TargetRequest) => MaybePromise); + rootPath?: string; + initTarget?: T | (() => MaybePromise); +}; + /** * Main class orchestrating the creation of a new media file. * @group Output files @@ -301,6 +298,8 @@ export class Output< /** @internal */ _rootPath: string | null; /** @internal */ + _initTarget: T | (() => MaybePromise) | null; + /** @internal */ _muxer: Muxer; /** @internal */ _rootWriterPromise: Promise | null = null; @@ -353,7 +352,7 @@ export class Output< throw new TypeError('options.format must be an OutputFormat.'); } if (!(options.target instanceof Target) && typeof options.target !== 'function') { - throw new TypeError('options.target must be a Target.'); + throw new TypeError('options.target must be a Target or a function that returns or resolves to a Target.'); } if (options.target instanceof Target) { if (options.target._output) { @@ -367,11 +366,22 @@ export class Output< if (typeof options.target === 'function' && options.rootPath === undefined) { throw new Error('options.rootPath must be provided when options.target is a function.'); } + if ( + options.initTarget !== undefined + && !(options.initTarget instanceof Target) + && typeof options.initTarget !== 'function' + ) { + throw new Error( + 'options.getInitTarget, when provided, must be a Target or a function that returns or resolves to' + + ' a Target.', + ); + } this.format = options.format; this._target = options.target; this._rootPath = options.rootPath ?? null; + this._initTarget = options.initTarget ?? null; this._muxer = options.format._createMuxer(this); } @@ -612,12 +622,14 @@ export class Output< const release = await this._mutex.acquire(); - await this._muxer.start(); + try { + await this._muxer.start(); - const promises = this._tracks.map(track => track.source._start()); - await Promise.all(promises); - - release(); + const promises = this._tracks.map(track => track.source._start()); + await Promise.all(promises); + } finally { + release(); + } })(); } @@ -641,7 +653,12 @@ export class Output< console.warn('Output has already been canceled.'); return this._cancelPromise; } else if (this.state === 'finalizing' || this.state === 'finalized') { - console.warn('Output has already been finalized.'); + // Don't wanna warn when finalizing since that shows a warning when finalization fails and then cancel + // is called + if (this.state === 'finalized') { + console.warn('Output has already been finalized.'); + } + return; } @@ -650,14 +667,16 @@ export class Output< const release = await this._mutex.acquire(); - const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close - await Promise.all(promises); + try { + const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close + await Promise.all(promises); - if (this._rootWriterPromise) { - await (await this._rootWriterPromise).close(); + if (this._rootWriterPromise) { + await (await this._rootWriterPromise).close(); + } + } finally { + release(); } - - release(); })(); } @@ -682,20 +701,22 @@ export class Output< const release = await this._mutex.acquire(); - const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(false)); - await Promise.all(promises); + try { + const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(false)); + await Promise.all(promises); - await this._muxer.finalize(); + await this._muxer.finalize(); - if (this._rootWriterPromise) { - const rootWriter = await this._rootWriterPromise; - await rootWriter.flush(); - await rootWriter.finalize(); + if (this._rootWriterPromise) { + const rootWriter = await this._rootWriterPromise; + await rootWriter.flush(); + await rootWriter.finalize(); + } + + this.state = 'finalized'; + } finally { + release(); } - - this.state = 'finalized'; - - release(); })(); } } diff --git a/src/target.ts b/src/target.ts index 468d5d8..ee1e45e 100644 --- a/src/target.ts +++ b/src/target.ts @@ -304,7 +304,10 @@ export class StreamTarget extends Target { } assert(this._streamWriter); - if (this._sections.length === 0) return; + + if (this._sections.length === 0) { + return; + } const chunks: { start: number; diff --git a/test/browser/cmaf.test.ts b/test/browser/cmaf.test.ts new file mode 100644 index 0000000..036da0c --- /dev/null +++ b/test/browser/cmaf.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from 'vitest'; +import { Output } from '../../src/output.js'; +import { CmafOutputFormat } from '../../src/output-format.js'; +import { BufferTarget } from '../../src/target.js'; +import { CanvasSource } from '../../src/media-source.js'; +import { QUALITY_HIGH } from '../../src/encode.js'; +import { Input } from '../../src/input.js'; +import { BufferSource } from '../../src/source.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; + +test('CMAF throws without initTarget', async () => { + const output = new Output({ + format: new CmafOutputFormat(), + target: new BufferTarget(), + }); + + const canvas = new OffscreenCanvas(640, 480); + const videoSource = new CanvasSource(canvas, { + codec: 'avc', + bitrate: QUALITY_HIGH, + }); + output.addVideoTrack(videoSource); + + await expect(output.start()).rejects.toThrow('initTarget'); +}); + +test('CMAF with video track', async () => { + const initTarget = new BufferTarget(); + let initTargetCalled = false; + + const output = new Output({ + format: new CmafOutputFormat(), + target: new BufferTarget(), + initTarget: () => { + initTargetCalled = true; + return initTarget; + }, + }); + + const canvas = new OffscreenCanvas(640, 480); + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#ff0000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const videoSource = new CanvasSource(canvas, { + codec: 'avc', + bitrate: QUALITY_HIGH, + }); + output.addVideoTrack(videoSource); + + await output.start(); + + const fps = 30; + const frameDuration = 1 / fps; + for (let i = 0; i < fps; i++) { + await videoSource.add(i * frameDuration, frameDuration); + } + + await output.finalize(); + + expect(initTargetCalled).toBe(true); + + // Reading the segment without initInput should throw because the moov box is in the init segment + using segmentInput = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + await expect(segmentInput.getTracks()).rejects.toThrow('initInput'); + + // Reading with initInput should work + using initInput = new Input({ + source: new BufferSource(initTarget.buffer!), + formats: ALL_FORMATS, + }); + + using segmentInputWithInit = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + initInput, + }); + + const tracks = await segmentInputWithInit.getTracks(); + expect(tracks).toHaveLength(1); + expect(tracks[0]!.isVideoTrack()).toBe(true); +}); + +test('CMAF with empty video track', async () => { + const initTarget = new BufferTarget(); + + const output = new Output({ + format: new CmafOutputFormat(), + target: new BufferTarget(), + initTarget, + }); + + const canvas = new OffscreenCanvas(640, 480); + const videoSource = new CanvasSource(canvas, { + codec: 'avc', + bitrate: QUALITY_HIGH, + }); + output.addVideoTrack(videoSource); + + await output.start(); + await output.finalize(); + + expect(initTarget.buffer).toBeDefined(); + expect(output.target.buffer).toBeDefined(); +}); diff --git a/test/node/aes.test.ts b/test/node/aes.test.ts index 28661ea..ca497e3 100644 --- a/test/node/aes.test.ts +++ b/test/node/aes.test.ts @@ -27,7 +27,7 @@ test('createAesDecryptStream', async () => { const source = new BufferSource(ciphertext); const reader = new Reader(source); - const stream = createAes128CbcDecryptStream(reader, () => ({ key, iv })); + const stream = createAes128CbcDecryptStream(reader, () => ({ key, iv }), () => {}); const streamReader = stream.getReader(); const chunks: Uint8Array[] = []; diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index 85e7d80..dc5c4c2 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; import { Output, OutputTrackGroup } from '../../src/output.js'; -import { HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js'; +import { CmafOutputFormat, HlsOutputFormat, MpegTsOutputFormat } from '../../src/output-format.js'; import { BufferTarget, NullTarget, StreamTarget, StreamTargetChunk } from '../../src/target.js'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js'; import { HlsMuxer } from '../../src/hls/hls-muxer.js'; @@ -19,7 +19,7 @@ const audioSource = (codec: AudioCodec = 'aac') => new EncodedAudioPacketSource( test('Playlist assignment, single video', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -41,7 +41,7 @@ test('Playlist assignment, single video', async () => { test('Playlist assignment, single audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -63,7 +63,7 @@ test('Playlist assignment, single audio', async () => { test('Playlist assignment, multiple video', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -91,7 +91,7 @@ test('Playlist assignment, multiple video', async () => { test('Playlist assignment, multiple audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -119,7 +119,7 @@ test('Playlist assignment, multiple audio', async () => { test('Playlist assignment, multiple video with different metadata #1', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -155,7 +155,7 @@ test('Playlist assignment, multiple video with different metadata #1', async () test('Playlist assignment, multiple video with different metadata #2', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -191,7 +191,7 @@ test('Playlist assignment, multiple video with different metadata #2', async () test('Playlist assignment, multiple audio with different metadata', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -227,7 +227,7 @@ test('Playlist assignment, multiple audio with different metadata', async () => test('Playlist assignment, video and audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -250,7 +250,7 @@ test('Playlist assignment, video and audio', async () => { test('Playlist assignment, one video and multiple audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -292,7 +292,7 @@ test('Playlist assignment, one video and multiple audio', async () => { test('Playlist assignment, multiple video and one audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -334,7 +334,7 @@ test('Playlist assignment, multiple video and one audio', async () => { test('Playlist assignment, multiple video and audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -388,7 +388,7 @@ test('Playlist assignment, multiple video and audio', async () => { test('Playlist assignment, video and audio in different groups', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -421,7 +421,7 @@ test('Playlist assignment, video and audio in different groups', async () => { test('Playlist assignment, multiple video and audio in pairs', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -467,7 +467,7 @@ test('Playlist assignment, multiple video and audio in pairs', async () => { test('Playlist assignment, multiple video and audio with some unpaired', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -525,7 +525,7 @@ test('Playlist assignment, multiple video and audio with some unpaired', async ( test('Playlist assignment, multiple video and audio with multiple groups', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -594,7 +594,7 @@ test('Playlist assignment, multiple video and audio with multiple groups', async test('Playlist assignment, video with multiple audio codecs', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -639,7 +639,7 @@ test('Playlist assignment, video with multiple audio codecs', async () => { test('Playlist assignment, audio with multiple video codecs', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -677,7 +677,7 @@ test('Playlist assignment, audio with multiple video codecs', async () => { test('Playlist assignment, multiple video with conflicting audio interests', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -730,7 +730,7 @@ test('Playlist assignment, video paired with video', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -767,7 +767,7 @@ test('Playlist assignment, audio paired with audio', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -830,7 +830,7 @@ const setUpSegmentationEnvironment = async (options: { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], // No ADTS for simplicity + segmentFormat: new MpegTsOutputFormat(), // No ADTS for simplicity }), target: (request) => { const target = new BufferTarget(); @@ -940,6 +940,7 @@ test('Segmentation, empty', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXT-X-ENDLIST `, @@ -969,6 +970,7 @@ test('Segmentation, simple', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1005,6 +1007,7 @@ test('Segmentation, reaching until end of second segment', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1043,6 +1046,7 @@ test('Segmentation, reaching until end of second segment with a final key packet #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1076,6 +1080,7 @@ test('Segmentation, reaching until end of second segment with a final key packet #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1114,6 +1119,7 @@ test('Segmentation, reaching until end of second segment with packet durations', #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1154,6 +1160,7 @@ test('Segmentation, reaching until end of second segment with packet durations ( #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1187,6 +1194,7 @@ test('Segmentation, only one key packet', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:4 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:3.5, segment-1-1.ts @@ -1228,6 +1236,7 @@ test('Segmentation, key packets before the end of a segment (maximized segment d #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.5, segment-1-1.ts @@ -1270,6 +1279,7 @@ test('Segmentation, full segment duration recovery after shorter segment', async #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.5, segment-1-1.ts @@ -1303,6 +1313,7 @@ test('Segmentation, packet start timestamp intersecting with end timestamp of pr #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1331,6 +1342,7 @@ test('Segmentation, last video packet is included', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.5, segment-1-1.ts @@ -1362,13 +1374,13 @@ test('Segmentation, video not lining up with segment boundaries', async () => { await env.output.finalize(); expect(env.segmentCount).toBe(3); - console.log(await env.lastSegmentVideoTimestamps); expect(await env.lastSegmentVideoTimestamps).toEqual([3.6, 4.05]); expect(env.result).toBe(`#EXTM3U #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.8, segment-1-1.ts @@ -1404,13 +1416,13 @@ test('Segmentation, audio not lining up with segment boundaries', async () => { await env.output.finalize(); expect(env.segmentCount).toBe(3); - console.log(await env.lastSegmentAudioTimestamps); expect(await env.lastSegmentAudioTimestamps).toEqual([3.6, 4.05]); expect(env.result).toBe(`#EXTM3U #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.8, segment-1-1.ts @@ -1446,6 +1458,7 @@ test('Segmentation, non-zero start time', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1481,6 +1494,7 @@ test('Segmentation, B-frames before key frame', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1522,6 +1536,7 @@ test('Segmentation, dual-track, single segment', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1575,6 +1590,7 @@ test('Segmentation, dual-track, video dictates the segmentation', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:3 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.5, segment-1-1.ts @@ -1630,6 +1646,7 @@ test('Segmentation, dual-track, video dictates the segmentation, inverted', asyn #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:3 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.5, segment-1-1.ts @@ -1676,6 +1693,7 @@ test('Segmentation, dual-track, audio ending after video', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1732,6 +1750,7 @@ test('Segmentation, dual-track, audio ending after video in separate segment', a #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:3 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:2, segment-1-1.ts @@ -1768,6 +1787,7 @@ test('Segmentation, dual-track, end timestamp with duration', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.8, segment-1-1.ts @@ -1808,6 +1828,7 @@ test('Segmentation, dual-track, closing writes segment', async () => { #EXT-X-VERSION:3 #EXT-X-PLAYLIST-TYPE:VOD #EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS #EXTINF:1.5, segment-1-1.ts @@ -1824,7 +1845,7 @@ test('onSegment, onPlaylist, onMaster events', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), onSegment, onPlaylist, onMaster, @@ -1884,7 +1905,7 @@ test('Single-file mode', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), singleFilePerPlaylist: true, }), target: (request) => { @@ -1932,7 +1953,7 @@ test('StreamTarget, write is called for each target', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: (request) => { writeCounts.set(request.path, 0); @@ -1976,7 +1997,7 @@ test('I-frame stream', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), onMaster: (text) => { masterText = text; }, onPlaylist: (text) => { playlistText = text; }, }), @@ -2013,7 +2034,7 @@ test('I-frame stream, pairing warning', async () => { const output = new Output({ format: new HlsOutputFormat({ - segmentFormats: [new MpegTsOutputFormat()], + segmentFormat: new MpegTsOutputFormat(), }), target: () => new NullTarget(), rootPath: '', @@ -2037,3 +2058,113 @@ test('I-frame stream, pairing warning', async () => { warnSpy.mockRestore(); }); + +test('CMAF segmentation', async () => { + let playlistText: string | null = null; + const writtenPaths = new Set(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new CmafOutputFormat(), + }), + target: (request) => { + writtenPaths.add(request.path); + const target = new BufferTarget(); + + if (request.path.includes('playlist')) { + target.onfinalized = () => { + playlistText = new TextDecoder().decode(target.buffer!); + }; + } + + return target; + }, + rootPath: '', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata); + + await output.finalize(); + + expect(writtenPaths).toContain('init-1.m4s'); + expect(writtenPaths).toContain('segment-1-1.m4s'); + expect(writtenPaths).toContain('segment-1-2.m4s'); + + expect(playlistText).toBe(`#EXTM3U +#EXT-X-VERSION:6 +#EXT-X-PLAYLIST-TYPE:VOD +#EXT-X-TARGETDURATION:2 +#EXT-X-INDEPENDENT-SEGMENTS +#EXT-X-MAP:URI="init-1.m4s" + +#EXTINF:2, +segment-1-1.m4s +#EXTINF:1.5, +segment-1-2.m4s + +#EXT-X-ENDLIST +`, + ); +}); + +test('CMAF segmentation, single file per playlist', async () => { + let playlistText: string | null = null; + const writtenPaths = new Set(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormat: new CmafOutputFormat(), + singleFilePerPlaylist: true, + }), + target: (request) => { + writtenPaths.add(request.path); + const target = new BufferTarget(); + + if (request.path.includes('playlist')) { + target.onfinalized = () => { + playlistText = new TextDecoder().decode(target.buffer!); + }; + } + + return target; + }, + rootPath: '', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + await source.add(new EncodedPacket(avcPacketData, 'key', 0, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 0.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 1.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 2.5, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3, 0), avcMetadata); + await source.add(new EncodedPacket(avcPacketData, 'delta', 3.5, 0), avcMetadata); + + await output.finalize(); + + // Init and segment files should have been written + expect(writtenPaths).toContain('segments-1.m4s'); + expect(writtenPaths).not.toContain('init-1.m4s'); + + expect(playlistText).not.toBeNull(); + expect(playlistText!.match(/#EXT-X-BYTERANGE/g)).toHaveLength(2); + expect(playlistText).toContain('#EXT-X-VERSION:6'); + expect(playlistText).toContain('#EXT-X-MAP:URI='); +}); diff --git a/todo.txt b/todo.txt index d08aba7..c0f22a4 100644 --- a/todo.txt +++ b/todo.txt @@ -5,5 +5,3 @@ Also, why not just have the packet metadata on the packet? I think that would ma - getCodecParamterString() hack feels dirty; idk. well its not really a hack but, surface it over a different field? - Add an HLS "live mode". Challenge, where does bitrate come from? - -- fmp4 muxing (CMAF??) \ No newline at end of file