diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index 796661e..058fe50 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -11,9 +11,10 @@ import { OutputVideoTrack, TrackType, } from '../output'; -import { HlsOutputFormat, HlsOutputFormatOptions, OutputFormat } from '../output-format'; +import { HlsOutputFormat, HlsOutputFormatOptions, HlsOutputSegmentInfo, OutputFormat } from '../output-format'; import { EncodedPacket } from '../packet'; import { SubtitleCue, SubtitleMetadata } from '../subtitles'; +import { Target } from '../target'; type HlsTrackData = { track: OutputTrack; @@ -59,7 +60,7 @@ export class HlsMuxer extends Muxer { getPlaylistPath: NonNullable; getSegmentPath: NonNullable; - targetSegmentDuration = 2; + targetSegmentDuration: number; trackDatas: HlsTrackData[] = []; playlists: Playlist[] = []; @@ -73,6 +74,7 @@ export class HlsMuxer extends Muxer { super(output); this.format = format; + this.targetSegmentDuration = format._options.targetDuration ?? 5; this.getPlaylistPath = format._options.getPlaylistPath ?? (({ n }) => `playlist-${n}.m3u8`); @@ -303,10 +305,10 @@ export class HlsMuxer extends Muxer { let candidateScore = -Infinity; for (const track of tracks) { - if (track.type === 'video') { + if (track.isVideoTrack()) { videoCount++; requiresRotationMetadata ||= (track.metadata.rotation ?? 0) !== 0; - } else if (track.type === 'audio') { + } else if (track.isAudioTrack()) { audioCount++; } @@ -715,14 +717,16 @@ export class HlsMuxer extends Muxer { } // We can finalize a new segment! Let's first get the path - const relativeSegmentPath = await this.getSegmentPath({ + const segmentInfo: HlsOutputSegmentInfo = { n: playlist.nextSegmentId, format: playlist.segmentFormat, playlist: { n: playlist.id, tracks: playlist.tracks, }, - }); + }; + + const relativeSegmentPath = await this.getSegmentPath(segmentInfo); if (typeof relativeSegmentPath !== 'string') { throw new TypeError('options.getSegmentPath must return or resolve to a string'); } @@ -737,6 +741,8 @@ export class HlsMuxer extends Muxer { playlist.nextSegmentId++; let segmentSize = 0; + let outputTarget: Target | null = null; + const output = new Output({ format: playlist.segmentFormat, rootPath: fullSegmentPath, @@ -744,6 +750,7 @@ export class HlsMuxer extends Muxer { const target = await this.output._getTarget(request); if (request.path === fullSegmentPath) { + outputTarget = target; target.onwrite = (_, end) => segmentSize = Math.max(segmentSize, end); } @@ -803,6 +810,9 @@ export class HlsMuxer extends Muxer { throw e; } + assert(outputTarget); + this.format._options.onSegment?.(outputTarget, segmentInfo); + if (videoEndIndex > 0) { assert(videoTrack); videoTrack.packets.splice(0, videoEndIndex); @@ -883,7 +893,12 @@ export class HlsMuxer extends Muxer { + '\n' + '#EXT-X-ENDLIST\n'; - const target = await this.output._getTarget({ path: playlistPath }); + this.format._options.onPlaylist?.(playlistText, { + n: playlist.id, + tracks: playlist.tracks, + }); + + const target = await this.output._getTarget({ path: playlistPath, isRoot: false }); const writer = target._createWriter(); writer.write(textEncoder.encode(playlistText)); await writer.finalize(); @@ -942,8 +957,8 @@ export class HlsMuxer extends Muxer { masterPlaylistText += `,CODECS="${codecs.join(',')}"`; - const videoTrack = decl.playlist.tracks.find(x => x.type === 'video'); - if (videoTrack) { + const videoTrack = decl.playlist.tracks.find(x => x.isVideoTrack()); + if (videoTrack?.isVideoTrack()) { const trackData = this.trackDatas.find(x => x.track === videoTrack) as HlsVideoTrackData | undefined; const decoderConfig = trackData?.info.decoderConfig; @@ -1059,6 +1074,8 @@ export class HlsMuxer extends Muxer { } } + this.format._options.onMaster?.(masterPlaylistText); + const rootWriter = await this.output._getRootWriter(); rootWriter.write(textEncoder.encode(masterPlaylistText)); })(); diff --git a/src/index.ts b/src/index.ts index 977f0e7..8b9083b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,11 @@ if ((globalThis as Record)[MEDIABUNNY_LOADED_SYMBOL]) { export { Output, OutputOptions, + OutputTrack, + OutputVideoTrack, + OutputAudioTrack, + OutputSubtitleTrack, + OutputTrackGroup, BaseTrackMetadata, VideoTrackMetadata, AudioTrackMetadata, @@ -136,7 +141,6 @@ export { prefer, } from './misc'; export { - OutputTrackGroup, TrackType, ALL_TRACK_TYPES, } from './output'; diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index 99c96e3..6198bed 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -199,7 +199,7 @@ export class IsobmffMuxer extends Muxer { this.writer.ensureMonotonicity = true; } - const holdsAvc = this.output._tracks.some(x => x.type === 'video' && x.source._codec === 'avc'); + const holdsAvc = this.output._tracks.some(x => x.isVideoTrack() && x.source._codec === 'avc'); // Write the header { diff --git a/src/media-source.ts b/src/media-source.ts index 39b136c..233b56f 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -10,6 +10,7 @@ import { buildAacAudioSpecificConfig, parseAacAudioSpecificConfig } from '../sha import { AUDIO_CODECS, AudioCodec, + MediaCodec, parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, @@ -62,6 +63,8 @@ import { * @public */ export abstract class MediaSource { + /** @internal */ + abstract readonly _codec: MediaCodec; /** @internal */ _connectedTrack: OutputTrack | null = null; /** @internal */ @@ -154,7 +157,7 @@ export abstract class VideoSource extends MediaSource { /** @internal */ override _connectedTrack: OutputVideoTrack | null = null; /** @internal */ - _codec: VideoCodec; + override readonly _codec: VideoCodec; /** Internal constructor. */ constructor(codec: VideoCodec) { @@ -1475,7 +1478,7 @@ export abstract class AudioSource extends MediaSource { /** @internal */ override _connectedTrack: OutputAudioTrack | null = null; /** @internal */ - _codec: AudioCodec; + override readonly _codec: AudioCodec; /** Internal constructor. */ constructor(codec: AudioCodec) { @@ -2459,7 +2462,7 @@ export abstract class SubtitleSource extends MediaSource { /** @internal */ override _connectedTrack: OutputSubtitleTrack | null = null; /** @internal */ - _codec: SubtitleCodec; + override readonly _codec: SubtitleCodec; /** Internal constructor. */ constructor(codec: SubtitleCodec) { diff --git a/src/output-format.ts b/src/output-format.ts index 6e5541b..41c70db 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -30,6 +30,7 @@ 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 { Target } from './target'; /** * Specifies an inclusive range of integers. @@ -1099,7 +1100,7 @@ export type HlsOutputPlaylistInfo = { tracks: OutputTrack[]; }; -type HlsOutputSegmentInfo = { +export type HlsOutputSegmentInfo = { n: number; format: OutputFormat; playlist: HlsOutputPlaylistInfo; @@ -1107,8 +1108,13 @@ type HlsOutputSegmentInfo = { export type HlsOutputFormatOptions = { segmentFormats: OutputFormat[]; + targetDuration?: number; getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise; getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise; + + onMaster?: (content: string) => unknown; + onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown; + onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown; }; export class HlsOutputFormat extends OutputFormat { @@ -1127,12 +1133,27 @@ export class HlsOutputFormat extends OutputFormat { ) { throw new TypeError('options.segmentFormats must be a non-empty array of OutputFormat instances.'); } + if ( + options.targetDuration !== undefined + && (typeof options.targetDuration !== 'number' || options.targetDuration <= 0) + ) { + throw new TypeError('options.targetDuration, when provided, must be a positive number.'); + } if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') { throw new TypeError('options.getPlaylistPath, when provided, must be a function.'); } if (options.getSegmentPath !== undefined && typeof options.getSegmentPath !== 'function') { throw new TypeError('options.getSegmentPath, when provided, must be a function.'); } + if (options.onMaster !== undefined && typeof options.onMaster !== 'function') { + throw new TypeError('options.onMaster, when provided, must be a function.'); + } + if (options.onPlaylist !== undefined && typeof options.onPlaylist !== 'function') { + throw new TypeError('options.onPlaylist, when provided, must be a function.'); + } + if (options.onSegment !== undefined && typeof options.onSegment !== 'function') { + throw new TypeError('options.onSegment, when provided, must be a function.'); + } super(); diff --git a/src/output.ts b/src/output.ts index 59e90ec..405ba61 100644 --- a/src/output.ts +++ b/src/output.ts @@ -16,6 +16,7 @@ import { Writer } from './writer'; export type TargetRequest = { path: string; + isRoot: boolean; }; /** @@ -47,27 +48,101 @@ export const ALL_TRACK_TYPES = ['video', 'audio', 'subtitle'] as const; */ export type TrackType = typeof ALL_TRACK_TYPES[number]; -export type OutputTrack = { - id: number; - output: Output; - type: TrackType; -} & ({ - type: 'video'; - source: VideoSource; - metadata: VideoTrackMetadata; -} | { - type: 'audio'; - source: AudioSource; - metadata: AudioTrackMetadata; -} | { - type: 'subtitle'; - source: SubtitleSource; - metadata: SubtitleTrackMetadata; -}); +/** + * Represents a track added to an {@link Output}. + * @group Output files + * @public + */ +export abstract class OutputTrack { + /** @internal */ + readonly id: number; + /** The {@link Output} this track belongs to. */ + readonly output: Output; + /** The type of this track. */ + readonly type: TrackType; + /** The media source providing data for this track. */ + readonly source: MediaSource; + /** The metadata associated with this track. */ + readonly metadata: BaseTrackMetadata; -export type OutputVideoTrack = OutputTrack & { type: 'video' }; -export type OutputAudioTrack = OutputTrack & { type: 'audio' }; -export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' }; + /** @internal */ + protected constructor( + id: number, + output: Output, + type: TrackType, + source: MediaSource, + metadata: BaseTrackMetadata, + ) { + this.id = id; + this.output = output; + this.type = type; + this.source = source; + this.metadata = metadata; + } + + /** Returns true if and only if this track is a video track. */ + isVideoTrack(): this is OutputVideoTrack { + return this.type === 'video'; + } + + /** Returns true if and only if this track is an audio track. */ + isAudioTrack(): this is OutputAudioTrack { + return this.type === 'audio'; + } + + /** Returns true if and only if this track is a subtitle track. */ + isSubtitleTrack(): this is OutputSubtitleTrack { + return this.type === 'subtitle'; + } +} + +/** + * An {@link OutputTrack} containing video data. + * @group Output files + * @public + */ +export class OutputVideoTrack extends OutputTrack { + declare readonly type: 'video'; + declare readonly source: VideoSource; + declare readonly metadata: VideoTrackMetadata; + + /** @internal */ + constructor(id: number, output: Output, source: VideoSource, metadata: VideoTrackMetadata) { + super(id, output, 'video', source, metadata); + } +} + +/** + * An {@link OutputTrack} containing audio data. + * @group Output files + * @public + */ +export class OutputAudioTrack extends OutputTrack { + declare readonly type: 'audio'; + declare readonly source: AudioSource; + declare readonly metadata: AudioTrackMetadata; + + /** @internal */ + constructor(id: number, output: Output, source: AudioSource, metadata: AudioTrackMetadata) { + super(id, output, 'audio', source, metadata); + } +} + +/** + * An {@link OutputTrack} containing subtitle data. + * @group Output files + * @public + */ +export class OutputSubtitleTrack extends OutputTrack { + declare readonly type: 'subtitle'; + declare readonly source: SubtitleSource; + declare readonly metadata: SubtitleTrackMetadata; + + /** @internal */ + constructor(id: number, output: Output, source: SubtitleSource, metadata: SubtitleTrackMetadata) { + super(id, output, 'subtitle', source, metadata); + } +} export class OutputTrackGroup { /** @internal */ @@ -246,7 +321,7 @@ export class Output< } assert(this._rootPath !== null); - const returnValue = this._target({ path: this._rootPath }); + const returnValue = this._target({ path: this._rootPath, isRoot: true }); if (returnValue instanceof Promise) { throw new TypeError( 'Output.target cannot be used when the target function resolves asynchronously.', @@ -310,7 +385,7 @@ export class Output< if (typeof this._target === 'function') { assert(this._rootPath !== null); - const rootTarget = await this._getTarget({ path: this._rootPath }); + const rootTarget = await this._getTarget({ path: this._rootPath, isRoot: true }); writer = rootTarget._createWriter(); } else { writer = this._target._createWriter(); @@ -346,7 +421,9 @@ export class Output< const metadataCopy = { ...metadata }; metadataCopy.group ??= this._defaultTrackGroup; - this._addTrack('video', source, metadataCopy); + return this._addTrack(new OutputVideoTrack( + this._tracks.length + 1, this, source, metadataCopy, + )); } /** Adds an audio track to the output with the given source. Can only be called before the output is started. */ @@ -359,7 +436,9 @@ export class Output< const metadataCopy = { ...metadata }; metadataCopy.group ??= this._defaultTrackGroup; - this._addTrack('audio', source, metadataCopy); + return this._addTrack(new OutputAudioTrack( + this._tracks.length + 1, this, source, metadataCopy, + )); } /** Adds a subtitle track to the output with the given source. Can only be called before the output is started. */ @@ -372,7 +451,9 @@ export class Output< const metadataCopy = { ...metadata }; metadataCopy.group ??= this._defaultTrackGroup; - this._addTrack('subtitle', source, metadataCopy); + return this._addTrack(new OutputSubtitleTrack( + this._tracks.length + 1, this, source, metadataCopy, + )); } /** @@ -392,26 +473,26 @@ export class Output< } /** @internal */ - private _addTrack(type: OutputTrack['type'], source: MediaSource, metadata: BaseTrackMetadata) { + private _addTrack(track: T) { if (this.state !== 'pending') { throw new Error('Cannot add track after output has been started or canceled.'); } - if (source._connectedTrack) { + if (track.source._connectedTrack) { throw new Error('Source is already used for a track.'); } // Verify maximum track count constraints const supportedTrackCounts = this.format.getSupportedTrackCounts(); const presentTracksOfThisType = this._tracks.reduce( - (count, track) => count + (track.type === type ? 1 : 0), + (count, t) => count + (t.type === track.type ? 1 : 0), 0, ); - const maxCount = supportedTrackCounts[type].max; + const maxCount = supportedTrackCounts[track.type].max; if (presentTracksOfThisType === maxCount) { throw new Error( maxCount === 0 - ? `${this.format._name} does not support ${type} tracks.` - : (`${this.format._name} does not support more than ${maxCount} ${type} track` + ? `${this.format._name} does not support ${track.type} tracks.` + : (`${this.format._name} does not support more than ${maxCount} ${track.type} track` + `${maxCount === 1 ? '' : 's'}.`), ); } @@ -423,15 +504,7 @@ export class Output< ); } - const track = { - id: this._tracks.length + 1, - output: this, - type, - source: source as unknown, - metadata, - } as OutputTrack; - - if (track.type === 'video') { + if (track.isVideoTrack()) { const supportedVideoCodecs = this.format.getSupportedVideoCodecs(); if (supportedVideoCodecs.length === 0) { @@ -446,7 +519,7 @@ export class Output< + this.format._codecUnsupportedHint(track.source._codec), ); } - } else if (track.type === 'audio') { + } else if (track.isAudioTrack()) { const supportedAudioCodecs = this.format.getSupportedAudioCodecs(); if (supportedAudioCodecs.length === 0) { @@ -461,7 +534,7 @@ export class Output< + this.format._codecUnsupportedHint(track.source._codec), ); } - } else if (track.type === 'subtitle') { + } else if (track.isSubtitleTrack()) { const supportedSubtitleCodecs = this.format.getSupportedSubtitleCodecs(); if (supportedSubtitleCodecs.length === 0) { @@ -479,7 +552,9 @@ export class Output< } this._tracks.push(track); - source._connectedTrack = track; + track.source._connectedTrack = track; + + return track; } /** diff --git a/test/node/hls-output.test.ts b/test/node/hls-output.test.ts index 70acd07..5dd79c0 100644 --- a/test/node/hls-output.test.ts +++ b/test/node/hls-output.test.ts @@ -831,6 +831,7 @@ const setUpSegmentationEnvironment = async (options: { const output = new Output({ format: new HlsOutputFormat({ segmentFormats: [new MpegTsOutputFormat()], // No ADTS for simplicity + targetDuration: 2, }), target: (request) => { const target = new BufferTarget(); @@ -1731,3 +1732,65 @@ segment-1-1.ts `, ); }); + +test('onSegment, onPlaylist, onMaster events', async () => { + const onSegment = vi.fn(); + const onPlaylist = vi.fn(); + const onMaster = vi.fn(); + + const output = new Output({ + format: new HlsOutputFormat({ + segmentFormats: [new MpegTsOutputFormat()], + targetDuration: 2, + onSegment, + onPlaylist, + onMaster, + }), + target: () => new BufferTarget(), + rootPath: '', + }); + + const source = videoSource(); + output.addVideoTrack(source); + + await output.start(); + + // First segment + 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); + expect(onSegment).toHaveBeenCalledTimes(0); + + // Second segment starts, first one is finalized + await source.add(new EncodedPacket(avcPacketData, 'key', 2, 0), avcMetadata); + expect(onSegment).toHaveBeenCalledTimes(1); + expect(onSegment.mock.calls[0]![1]).toEqual(expect.objectContaining({ n: 1 })); + + 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); + + expect(onPlaylist).not.toHaveBeenCalled(); + expect(onMaster).not.toHaveBeenCalled(); + + await output.finalize(); + + // Second segment finalized on close + expect(onSegment).toHaveBeenCalledTimes(2); + expect(onSegment.mock.calls[1]![1]).toEqual(expect.objectContaining({ n: 2 })); + + // Both segment calls should have a Target as the first argument + expect(onSegment.mock.calls[0]![0]).toBeDefined(); + expect(onSegment.mock.calls[1]![0]).toBeDefined(); + + // Playlist and master should have been called once each + expect(onPlaylist).toHaveBeenCalledTimes(1); + expect(typeof onPlaylist.mock.calls[0]![0]).toBe('string'); + expect(onPlaylist.mock.calls[0]![0]).toContain('#EXTM3U'); + expect(onPlaylist.mock.calls[0]![1]).toEqual(expect.objectContaining({ n: 1 })); + + expect(onMaster).toHaveBeenCalledTimes(1); + expect(typeof onMaster.mock.calls[0]![0]).toBe('string'); + expect(onMaster.mock.calls[0]![0]).toContain('#EXTM3U'); +}); diff --git a/todo.txt b/todo.txt index 6979828..cc24b55 100644 --- a/todo.txt +++ b/todo.txt @@ -6,10 +6,6 @@ Also, why not just have the packet metadata on the packet? I think that would ma - Add an HLS "live mode". Challenge, where does bitrate come from? -events: -- onPlaylist -- onSegment -- onMaster - - hls muxer starting offset -- hls demuxer offset by segment number * targetduration \ No newline at end of file +- hls demuxer offset by segment number * targetduration +- fmp4 muxing (CMAF??) \ No newline at end of file