diff --git a/examples/media-player/media-player.ts b/examples/media-player/media-player.ts index caf317d..1721456 100644 --- a/examples/media-player/media-player.ts +++ b/examples/media-player/media-player.ts @@ -83,26 +83,32 @@ const initMediaPlayer = async (resource: File | string) => { pause(); } + const dirHandle = await showDirectoryPicker({ mode: 'read' }); + void videoFrameIterator?.return(); void audioBufferIterator?.return(); asyncId++; fileLoaded = false; - fileNameElement.textContent = resource instanceof File ? resource.name : resource; + fileNameElement.textContent = 'pish'; // resource instanceof File ? resource.name : resource; horizontalRule.style.display = ''; loadingElement.style.display = ''; playerContainer.style.display = 'none'; errorElement.textContent = ''; warningElement.textContent = ''; - let start = 0; + const start = 0; let videoTrack: InputVideoTrack | null = null; let audioTrack: InputAudioTrack | null = null; - if (typeof resource === 'string' && resource.includes('.m3u8')) { + if (true || typeof resource === 'string' && resource.includes('.m3u8')) { const input = new Input({ - entryPath: resource, - source: ({ path }) => new UrlSource(path), + entryPath: 'playlist.m3u8', + source: async ({ path }) => { + const fileHandle = await dirHandle.getFileHandle(path); + const file = await fileHandle.getFile(); + return new BlobSource(file); + }, formats: ALL_FORMATS, }); // const variant = (await manifestInput.getVariants())[0]!; @@ -132,9 +138,9 @@ const initMediaPlayer = async (resource: File | string) => { console.log(videoTrack, audioTrack, totalDuration); - start = totalDuration - 2; + // start = totalDuration - 2; - totalDuration += 3600; + // totalDuration += 3600; // https://test-streams.mux.dev/test_001/stream.m3u8 // https://test-streams.mux.dev/test_001/stream_1000k_48k_640x360_050.ts @@ -764,6 +770,10 @@ document.addEventListener('dragover', (event) => { event.dataTransfer!.dropEffect = 'copy'; }); +document.addEventListener('click', () => { + void initMediaPlayer(); +}, { once: true }); + document.addEventListener('drop', (event) => { event.preventDefault(); const files = event.dataTransfer?.files; diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts index ca4d90d..8dc6c17 100644 --- a/examples/procedural-generation/procedural-generation.ts +++ b/examples/procedural-generation/procedural-generation.ts @@ -8,6 +8,7 @@ import { getFirstEncodableAudioCodec, getFirstEncodableVideoCodec, OutputFormat, + HlsOutputFormat, } from 'mediabunny'; const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement; @@ -66,6 +67,11 @@ const generateVideo = async () => { let progressInterval = -1; try { + const dirHandle = await showDirectoryPicker({ mode: 'readwrite' }); + for await (const [name, handle] of dirHandle) { + await dirHandle.removeEntry(name, { recursive: true }); + } + // Let's set some DOM state renderButton.disabled = true; renderButton.textContent = 'Generating...'; @@ -86,8 +92,19 @@ const generateVideo = async () => { // Create a new output file output = new Output({ - target: new BufferTarget(), // Stored in memory - format: new Mp4OutputFormat(), + rootPath: 'playlist.m3u8', + target: ({ path }) => { + const target = new BufferTarget(); + target.onfinalized = async () => { + const fileHandle = await dirHandle.getFileHandle(path, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(target.buffer!); + await writable.close(); + }; + + return target; + }, // Stored in memory + format: new HlsOutputFormat(), }); // Retrieve the first video codec supported by this browser that can be contained in the output format @@ -103,6 +120,7 @@ const generateVideo = async () => { const canvasSource = new CanvasSource(renderCanvas, { codec: videoCodec, bitrate: QUALITY_HIGH, + keyFrameInterval: 2, }); output.addVideoTrack(canvasSource, { frameRate }); diff --git a/src/adts/adts-muxer.ts b/src/adts/adts-muxer.ts index bc0c55b..2e28864 100644 --- a/src/adts/adts-muxer.ts +++ b/src/adts/adts-muxer.ts @@ -20,7 +20,7 @@ import { Writer } from '../writer'; export class AdtsMuxer extends Muxer { private format: AdtsOutputFormat; - private writer: Writer; + private writer!: Writer; private header: Uint8Array | null = null; private headerBitstream: Bitstream | null = null; private inputIsAdts: boolean | null = null; @@ -29,14 +29,19 @@ export class AdtsMuxer extends Muxer { super(output); this.format = format; - this.writer = output._writer; } async start() { + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); + if (!metadataTagsAreEmpty(this.output._metadataTags)) { const id3Writer = new Id3V2Writer(this.writer); id3Writer.writeId3V2Tag(this.output._metadataTags); } + + release(); } async getMimeType() { diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts index 83cdfe9..3fdd862 100644 --- a/src/flac/flac-muxer.ts +++ b/src/flac/flac-muxer.ts @@ -33,7 +33,7 @@ const STREAMINFO_SIZE = 38; const STREAMINFO_BLOCK_SIZE = 34; export class FlacMuxer extends Muxer { - private writer: Writer; + private writer!: Writer; private metadataWritten = false; private blockSizes: number[] = []; @@ -48,12 +48,16 @@ export class FlacMuxer extends Muxer { constructor(output: Output, format: FlacOutputFormat) { super(output); - this.writer = output._writer; this.format = format; } async start() { + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); this.writer.write(FLAC_HEADER); + + release(); } writeHeader({ diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts new file mode 100644 index 0000000..c04e7bd --- /dev/null +++ b/src/hls/hls-muxer.ts @@ -0,0 +1,394 @@ +import { validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; +import { EncodedAudioPacketSource, EncodedVideoPacketSource, MediaSource } from '../media-source'; +import { assert, joinPaths, last, textEncoder } from '../misc'; +import { Muxer } from '../muxer'; +import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output'; +import { MpegTsOutputFormat } from '../output-format'; +import { EncodedPacket } from '../packet'; +import { SubtitleCue, SubtitleMetadata } from '../subtitles'; +import { Writer } from '../writer'; + +type HlsTrackData = { + track: OutputTrack; + packets: EncodedPacket[]; + info: { + type: 'video'; + decoderConfig: VideoDecoderConfig; + } | { + type: 'audio'; + decoderConfig: AudioDecoderConfig; + }; +}; +type HlsVideoTrackData = HlsTrackData & { info: { type: 'video' } }; +type HlsAudioTrackData = HlsTrackData & { info: { type: 'audio' } }; + +export class HlsMuxer extends Muxer { + targetSegmentDuration = 2; + trackDatas: HlsTrackData[] = []; + currentSegmentStartTimestamp: number | null = null; + nextSegmentId = 1; + writtenSegments: { + path: string; + duration: number; + }[] = []; + + constructor(output: Output) { + if (typeof output.target !== 'function') { + throw new TypeError('HLS outputs require `OutputOptions.target` to be a function.'); + } + + super(output); + } + + async start(): Promise { + // Nada + } + + async getMimeType(): Promise { + throw new Error('TODO'); + } + + private allTracksAreKnown() { + for (const track of this.output._tracks) { + if (!track.source._closed && !this.trackDatas.some(x => x.track === track)) { + return false; // We haven't seen a sample from this open track yet + } + } + + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + override async onTrackClose(track: OutputTrack) { + const release = await this.mutex.acquire(); + + try { + if (!this.trackDatas.some(x => x.track === track)) { + return; + } + + await this.ting(); + } finally { + release(); + } + } + + getVideoTrackData(track: OutputVideoTrack, meta?: EncodedVideoChunkMetadata) { + let trackData = this.trackDatas.find(x => x.track === track) as HlsVideoTrackData; + if (trackData) { + return trackData; + } + + validateVideoChunkMetadata(meta); + + assert(meta); + assert(meta?.decoderConfig); + + trackData = { + track, + packets: [], + info: { + type: 'video', + decoderConfig: meta.decoderConfig, + }, + }; + this.trackDatas.push(trackData); + + return trackData; + } + + getAudioTrackData(track: OutputAudioTrack, meta?: EncodedAudioChunkMetadata) { + let trackData = this.trackDatas.find(x => x.track === track) as HlsAudioTrackData; + if (trackData) { + return trackData; + } + + validateAudioChunkMetadata(meta); + + assert(meta); + assert(meta?.decoderConfig); + + trackData = { + track, + packets: [], + info: { + type: 'audio', + decoderConfig: meta.decoderConfig, + }, + }; + this.trackDatas.push(trackData); + + return trackData; + } + + async addEncodedVideoPacket( + track: OutputVideoTrack, + packet: EncodedPacket, + meta?: EncodedVideoChunkMetadata, + ) { + const release = await this.mutex.acquire(); + + try { + const trackData = this.getVideoTrackData(track, meta); + + const timestamp = this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key'); + const adjustedPacket = packet.clone({ timestamp }); + + trackData.packets.push(adjustedPacket); + + if (this.currentSegmentStartTimestamp === null) { + this.currentSegmentStartTimestamp = adjustedPacket.timestamp; + } else { + this.currentSegmentStartTimestamp = Math.min( + this.currentSegmentStartTimestamp, + adjustedPacket.timestamp, + ); + } + + await this.ting(); + } finally { + release(); + } + } + + async addEncodedAudioPacket( + track: OutputAudioTrack, + packet: EncodedPacket, + meta?: EncodedAudioChunkMetadata, + ) { + const release = await this.mutex.acquire(); + + try { + const trackData = this.getAudioTrackData(track, meta); + + const timestamp = this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key'); + const adjustedPacket = packet.clone({ timestamp }); + + trackData.packets.push(adjustedPacket); + + if (this.currentSegmentStartTimestamp === null) { + this.currentSegmentStartTimestamp = adjustedPacket.timestamp; + } else { + this.currentSegmentStartTimestamp = Math.min( + this.currentSegmentStartTimestamp, + adjustedPacket.timestamp, + ); + } + + await this.ting(); + } finally { + release(); + } + } + + async addSubtitleCue( + track: OutputSubtitleTrack, + cue: SubtitleCue, + meta?: SubtitleMetadata, + ) { + + } + + async ting(isFinalCall = false) { + assert(this.currentSegmentStartTimestamp !== null); + + if (!this.allTracksAreKnown()) { + return; + } + + while (true) { + const currentSegmentEndTimestamp = this.currentSegmentStartTimestamp + this.targetSegmentDuration; + + let videoKeyEndTimestamp: number | null = null; + let videoEndTimestamp: number | null = null; + let audioEndTimestamp: number | null = null; + let flushAllVideo = false; + let flushAllAudio = false; + + for (const trackData of this.trackDatas) { + for (let i = 0; i < trackData.packets.length; i++) { + const packet = trackData.packets[i]!; + const endTimestamp = packet.timestamp + packet.duration; + + if (trackData.info.type === 'video') { + videoEndTimestamp = Math.max(videoEndTimestamp ?? -Infinity, endTimestamp); + + if (i > 0 && packet.type === 'key') { + if ( + videoKeyEndTimestamp !== null + && packet.timestamp > currentSegmentEndTimestamp + ) { + break; + } + + videoKeyEndTimestamp = packet.timestamp; + } + } else if (trackData.info.type === 'audio') { + if ( + audioEndTimestamp !== null + && packet.timestamp > currentSegmentEndTimestamp + ) { + break; + } + + audioEndTimestamp = packet.timestamp; + + const endTimestamp = packet.timestamp + packet.duration; + if (endTimestamp <= currentSegmentEndTimestamp) { + audioEndTimestamp = Math.max(audioEndTimestamp ?? -Infinity, endTimestamp); + if (i === trackData.packets.length - 1) { + flushAllAudio = true; + } + } + } + } + } + + let endTimestamp: number | null = null; + if (videoKeyEndTimestamp !== null && videoEndTimestamp! > currentSegmentEndTimestamp) { + endTimestamp = videoKeyEndTimestamp; + } else { + if (isFinalCall) { + endTimestamp = videoEndTimestamp; + flushAllVideo = true; + } + + if (audioEndTimestamp !== null) { + endTimestamp = Math.max(endTimestamp ?? -Infinity, audioEndTimestamp); + } + } + + if (endTimestamp === null) { + return; + } else { + if (!isFinalCall && endTimestamp < currentSegmentEndTimestamp) { + return; + } + + for (const trackData of this.trackDatas) { + const closed = trackData.track.source._closed || isFinalCall; + if (!closed && !trackData.packets.some(x => x.timestamp >= endTimestamp)) { + return; + } + } + } + + assert(this.output._rootPath !== null); + const segmentPath = joinPaths(this.output._rootPath, `segment-${this.nextSegmentId}.ts`); + const target = await this.output._getTarget({ path: segmentPath }); + this.nextSegmentId++; + + const output = new Output({ + format: new MpegTsOutputFormat(), + target, + }); + + try { + const packetSources = new Map(); + + for (const trackData of this.trackDatas) { + if (trackData.packets.length === 0) { + continue; + } + + if (trackData.info.type === 'video') { + const outputTrack = trackData.track as OutputVideoTrack; + const source = new EncodedVideoPacketSource(outputTrack.source._codec); + output.addVideoTrack(source, outputTrack.metadata); + + packetSources.set(trackData, source); + } else if (trackData.info.type === 'audio') { + const outputTrack = trackData.track as OutputAudioTrack; + const source = new EncodedAudioPacketSource(outputTrack.source._codec); + output.addAudioTrack(source, outputTrack.metadata); + + packetSources.set(trackData, source); + } + } + + await output.start(); + + for (const trackData of this.trackDatas) { + const source = packetSources.get(trackData); + if (!source) { + continue; + } + + if (trackData.info.type === 'video') { + const videoPacketSource = source as EncodedVideoPacketSource; + const meta = { decoderConfig: trackData.info.decoderConfig }; + + while (trackData.packets.length > 0) { + const nextPacket = trackData.packets[0]!; + if (!flushAllVideo && nextPacket.timestamp >= endTimestamp) { + break; + } + + trackData.packets.shift(); + await videoPacketSource.add(nextPacket, meta); + } + + videoPacketSource.close(); + } else if (trackData.info.type === 'audio') { + const audioPacketSource = source as EncodedAudioPacketSource; + const meta = { decoderConfig: trackData.info.decoderConfig }; + + while (trackData.packets.length > 0) { + const nextPacket = trackData.packets[0]!; + if (!flushAllAudio && nextPacket.timestamp >= endTimestamp) { + break; + } + + trackData.packets.shift(); + await audioPacketSource.add(nextPacket, meta); + } + + audioPacketSource.close(); + } + } + + await output.finalize(); + } catch (e) { + await output.cancel(); + throw e; + } + + this.writtenSegments.push({ + path: segmentPath, + duration: endTimestamp - this.currentSegmentStartTimestamp, + }); + + this.currentSegmentStartTimestamp = endTimestamp; + } + } + + async finalize() { + const release = await this.mutex.acquire(); + + await this.ting(true); + + let targetDuration = this.targetSegmentDuration; + for (const segment of this.writtenSegments) { + targetDuration = Math.max(targetDuration, segment.duration); + } + + const playlist = '#EXTM3U\n' + + '#EXT-X-VERSION:3\n' + + '#EXT-X-PLAYLIST-TYPE:VOD\n' + + `#EXT-X-TARGETDURATION:${+targetDuration.toPrecision(13)}\n` + + '\n' + + (this.writtenSegments + .map(segment => ( + `#EXTINF:${+segment.duration.toPrecision(13)}\n` + + `${segment.path}\n` + )) + .join('')) + + '\n' + + '#EXT-X-ENDLIST\n'; + + const rootWriter = await this.output._getRootWriter(); + rootWriter.write(textEncoder.encode(playlist)); + + release(); + } +} diff --git a/src/hls/hls-segmented-input.ts b/src/hls/hls-segmented-input.ts index c321c90..9493278 100644 --- a/src/hls/hls-segmented-input.ts +++ b/src/hls/hls-segmented-input.ts @@ -409,6 +409,12 @@ export class HlsSegmentedInput extends SegmentedInput { } else if (line === '#EXT-X-ENDLIST') { this.streamHasEnded = true; break; // No need to keep reading after this + } else if (line.startsWith('#EXT-X-PLAYLIST-TYPE')) { + const type = line.slice(21); + if (type.toLowerCase() === 'vod') { + // A VOD playlist cannot be updated per spec so we can be sure the stream has ended + this.streamHasEnded = true; + } } } } diff --git a/src/index.ts b/src/index.ts index d6db2d9..3583a73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ export { AdtsOutputFormatOptions, FlacOutputFormat, FlacOutputFormatOptions, + HlsOutputFormat, IsobmffOutputFormat, IsobmffOutputFormatOptions, MkvOutputFormat, diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index b7a6158..99c96e3 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -150,10 +150,10 @@ export const intoTimescale = (timeInSeconds: number, timescale: number, round = export class IsobmffMuxer extends Muxer { format: IsobmffOutputFormat; - private writer: Writer; - private boxWriter: IsobmffBoxWriter; - private fastStart: NonNullable; - isFragmented: boolean; + private writer!: Writer; + private boxWriter!: IsobmffBoxWriter; + private fastStart!: NonNullable; + isFragmented!: boolean; isQuickTime: boolean; @@ -179,27 +179,26 @@ export class IsobmffMuxer extends Muxer { super(output); this.format = format; - this.writer = output._writer; - this.boxWriter = new IsobmffBoxWriter(this.writer); - this.isQuickTime = format instanceof MovOutputFormat; - - // 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 instanceof BufferTargetWriter ? 'in-memory' : false; - this.fastStart = format._options.fastStart ?? fastStartDefault; - this.isFragmented = this.fastStart === 'fragmented'; - - if (this.fastStart === 'in-memory' || this.isFragmented) { - this.writer.ensureMonotonicity = true; - } - this.minimumFragmentDuration = format._options.minimumFragmentDuration ?? 1; } async start() { const release = await this.mutex.acquire(); + 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 instanceof BufferTargetWriter ? 'in-memory' : false; + this.fastStart = this.format._options.fastStart ?? fastStartDefault; + this.isFragmented = this.fastStart === 'fragmented'; + + if (this.fastStart === 'in-memory' || this.isFragmented) { + this.writer.ensureMonotonicity = true; + } + const holdsAvc = this.output._tracks.some(x => x.type === 'video' && x.source._codec === 'avc'); // Write the header diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 0af05ae..242cf19 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -129,8 +129,8 @@ const TRACK_TYPE_MAP: Record = { }; export class MatroskaMuxer extends Muxer { - private writer: Writer; - private ebmlWriter: EBMLWriter; + private writer!: Writer; + private ebmlWriter!: EBMLWriter; private format: WebMOutputFormat | MkvOutputFormat; private trackDatas: MatroskaTrackData[] = []; @@ -158,18 +158,18 @@ export class MatroskaMuxer extends Muxer { constructor(output: Output, format: MkvOutputFormat) { super(output); - this.writer = output._writer; this.format = format; + } + async start() { + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); this.ebmlWriter = new EBMLWriter(this.writer); if (this.format._options.appendOnly) { this.writer.ensureMonotonicity = true; } - } - - async start() { - const release = await this.mutex.acquire(); this.writeEBMLHeader(); diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index 59d3da3..b36cc7f 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -19,8 +19,8 @@ import { Id3V2Writer } from '../id3'; export class Mp3Muxer extends Muxer { private format: Mp3OutputFormat; - private writer: Writer; - private mp3Writer: Mp3Writer; + private writer!: Writer; + private mp3Writer!: Mp3Writer; private xingFrameData: XingFrameData | null = null; private frameCount = 0; private framePositions: number[] = []; @@ -30,15 +30,20 @@ export class Mp3Muxer extends Muxer { super(output); this.format = format; - this.writer = output._writer; - this.mp3Writer = new Mp3Writer(output._writer); } async start() { + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); + this.mp3Writer = new Mp3Writer(this.writer); + if (!metadataTagsAreEmpty(this.output._metadataTags)) { const id3Writer = new Id3V2Writer(this.writer); id3Writer.writeId3V2Tag(this.output._metadataTags); } + + release(); } async getMimeType() { diff --git a/src/mpeg-ts/mpeg-ts-muxer.ts b/src/mpeg-ts/mpeg-ts-muxer.ts index 102843a..93253b8 100644 --- a/src/mpeg-ts/mpeg-ts-muxer.ts +++ b/src/mpeg-ts/mpeg-ts-muxer.ts @@ -71,7 +71,7 @@ type QueuedPacket = { export class MpegTsMuxer extends Muxer { private format: MpegTsOutputFormat; - private writer: Writer; + private writer!: Writer; private trackDatas: MpegTsTrackData[] = []; private tablesWritten = false; @@ -90,12 +90,15 @@ export class MpegTsMuxer extends Muxer { super(output); this.format = format; - this.writer = output._writer; - this.writer.ensureMonotonicity = true; } async start() { - // Nothing to do here + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); + this.writer.ensureMonotonicity = true; + + release(); } async getMimeType() { diff --git a/src/ogg/ogg-muxer.ts b/src/ogg/ogg-muxer.ts index 7bceb75..4758f0e 100644 --- a/src/ogg/ogg-muxer.ts +++ b/src/ogg/ogg-muxer.ts @@ -59,7 +59,7 @@ type Packet = { export class OggMuxer extends Muxer { private format: OggOutputFormat; - private writer: Writer; + private writer!: Writer; private trackDatas: OggTrackData[] = []; private bosPagesWritten = false; @@ -72,13 +72,15 @@ export class OggMuxer extends Muxer { super(output); this.format = format; - this.writer = output._writer; - - this.writer.ensureMonotonicity = true; // Ogg is always monotonically written! } async start() { - // Nothin' + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); + this.writer.ensureMonotonicity = true; // Ogg is always monotonically written! + + release(); } async getMimeType() { diff --git a/src/output-format.ts b/src/output-format.ts index adef3a9..216cc6c 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -28,6 +28,7 @@ import { OggMuxer } from './ogg/ogg-muxer'; import { Output, TrackType } from './output'; import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer'; import { WaveMuxer } from './wave/wave-muxer'; +import { HlsMuxer } from './hls/hls-muxer'; /** * Specifies an inclusive range of integers. @@ -1081,3 +1082,46 @@ export class MpegTsOutputFormat extends OutputFormat { return true; } } + +export class HlsOutputFormat extends OutputFormat { + _createMuxer(output: Output): Muxer { + return new HlsMuxer(output); + } + + get _name() { + return 'HTTP Live Streaming (HLS)'; + } + + get fileExtension() { + return '.m3u8'; + } + + get mimeType() { + return 'application/vnd.apple.mpegurl'; + } + + getSupportedCodecs(): MediaCodec[] { + // TODO this should vary based on the HLS "variant" + return [ + ...VIDEO_CODECS.filter(codec => ['avc', 'hevc'].includes(codec)), + ...AUDIO_CODECS.filter(codec => ['aac', 'mp3', 'ac3', 'eac3'].includes(codec)), + ]; + } + + getSupportedTrackCounts(): TrackCountLimits { + return { + video: { min: 0, max: Infinity }, + audio: { min: 0, max: Infinity }, + subtitle: { min: 0, max: Infinity }, + total: { min: 1, max: Infinity }, + }; + } + + get supportsVideoRotationMetadata(): boolean { + return false; // TODO this is not true with fmp4 + } + + get supportsTimestampedMediaData(): boolean { + return true; // I guess?? + } +} diff --git a/src/output.ts b/src/output.ts index bdaaa87..b386433 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 { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc'; +import { assert, AsyncMutex, isIso639Dash2LanguageCode, MaybePromise, Rotation } from './misc'; import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; @@ -14,6 +14,10 @@ import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-s import { Target } from './target'; import { Writer } from './writer'; +export type TargetRequest = { + path: string; +}; + /** * The options for creating an Output object. * @group Output files @@ -26,7 +30,8 @@ export type OutputOptions< /** The format of the output file. */ format: F; /** The target to which the file will be written. */ - target: T; + target: T | ((request: TargetRequest) => MaybePromise); + rootPath?: string; }; /** @@ -154,14 +159,16 @@ export class Output< /** The format of the output file. */ format: F; /** The target to which the file will be written. */ - target: T; + target: T | ((request: TargetRequest) => MaybePromise); /** The current state of the output. */ state: 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized' = 'pending'; + /** @internal */ + _rootPath: string | null; /** @internal */ _muxer: Muxer; /** @internal */ - _writer: Writer; + _rootWriterPromise: Promise | null = null; /** @internal */ _tracks: OutputTrack[] = []; /** @internal */ @@ -186,22 +193,52 @@ export class Output< if (!(options.format instanceof OutputFormat)) { throw new TypeError('options.format must be an OutputFormat.'); } - if (!(options.target instanceof Target)) { + if (!(options.target instanceof Target) && typeof options.target !== 'function') { throw new TypeError('options.target must be a Target.'); } - - if (options.target._output) { - throw new Error('Target is already used for another output.'); + if (options.target instanceof Target) { + if (options.target._output) { + throw new Error('Target is already used for another output.'); + } + options.target._output = this; + } + if (options.rootPath !== undefined && typeof options.rootPath !== 'string') { + throw new TypeError('options.rootPath, when provided, must be a string.'); + } + if (typeof options.target === 'function' && options.rootPath === undefined) { + throw new Error('options.rootPath must be provided when options.target is a function.'); } - options.target._output = this; this.format = options.format; this.target = options.target; - this._writer = options.target._createWriter(); + this._rootPath = options.rootPath ?? null; this._muxer = options.format._createMuxer(this); } + _getTarget(request: TargetRequest) { + assert(typeof this.target === 'function'); + + return this.target(request); + } + + _getRootWriter() { + return this._rootWriterPromise ??= (async () => { + let writer: Writer; + + if (typeof this.target === 'function') { + assert(this._rootPath !== null); + const rootTarget = await this._getTarget({ path: this._rootPath }); + writer = rootTarget._createWriter(); + } else { + writer = this.target._createWriter(); + } + + writer.start(); + return writer; + })(); + } + /** Adds a video track to the output with the given source. Can only be called before the output is started. */ addVideoTrack(source: VideoSource, metadata: VideoTrackMetadata = {}) { if (!(source instanceof VideoSource)) { @@ -400,7 +437,6 @@ export class Output< return this._startPromise = (async () => { this.state = 'started'; - this._writer.start(); const release = await this._mutex.acquire(); @@ -445,7 +481,9 @@ export class Output< const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close await Promise.all(promises); - await this._writer.close(); + if (this._rootWriterPromise) { + await (await this._rootWriterPromise).close(); + } release(); })(); @@ -477,8 +515,12 @@ export class Output< await this._muxer.finalize(); - await this._writer.flush(); - await this._writer.finalize(); + if (this._rootWriterPromise) { + console.log('HERE HERE'); + const rootWriter = await this._rootWriterPromise; + await rootWriter.flush(); + await rootWriter.finalize(); + } this.state = 'finalized'; diff --git a/src/target.ts b/src/target.ts index aff12b2..d61df17 100644 --- a/src/target.ts +++ b/src/target.ts @@ -35,6 +35,8 @@ export abstract class Target { * gets called *extremely* often. */ onwrite: ((start: number, end: number) => unknown) | null = null; + + onfinalized: (() => unknown) | null = null; } /** diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index d5d3663..5a5a77e 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -21,8 +21,8 @@ import { Id3V2Writer } from '../id3'; export class WaveMuxer extends Muxer { private format: WavOutputFormat; private isRf64: boolean; - private writer: Writer; - private riffWriter: RiffWriter; + private writer!: Writer; + private riffWriter!: RiffWriter; private headerWritten = false; private dataSize = 0; private sampleRate: number | null = null; @@ -38,13 +38,18 @@ export class WaveMuxer extends Muxer { super(output); this.format = format; - this.writer = output._writer; - this.riffWriter = new RiffWriter(output._writer); this.isRf64 = !!format._options.large; } async start() { - // Nothing needed here - we'll write the header with the first sample + const release = await this.mutex.acquire(); + + this.writer = await this.output._getRootWriter(); + this.riffWriter = new RiffWriter(this.writer); + + // No writing needed here - we'll write the header with the first sample + + release(); } async getMimeType() { diff --git a/src/writer.ts b/src/writer.ts index 73a62db..fbbf026 100644 --- a/src/writer.ts +++ b/src/writer.ts @@ -175,6 +175,7 @@ export class BufferTargetWriter extends Writer { async finalize() { this.ensureSize(this.pos); this.target.buffer = this.buffer.slice(0, Math.max(this.maxPos, this.pos)); + this.target.onfinalized?.(); } async close() {} @@ -470,7 +471,9 @@ export class StreamTargetWriter extends Writer { assert(this.writer); await this.writer.ready; - return this.writer.close(); + await this.writer.close(); + + this.target.onfinalized?.(); } async close() { @@ -500,6 +503,10 @@ export class NullTargetWriter extends Writer { } async flush() {} - async finalize() {} + + async finalize() { + this.target.onfinalized?.(); + } + async close() {} }