/** * Stitches a list of MP4 chunks into a single Media Source Extensions * timeline. Each input chunk is remuxed via mediabunny into fragmented MP4 * and appended to one shared SourceBuffer with a per-chunk * `timestampOffset`, producing one continuous virtual video. * * For player-switch we tear this object down and build a fresh one: it's * the well-trodden MSE path and avoids the corner cases of re-using a * SourceBuffer across heterogeneous content. Cached codec + smart ingest * order from the priority time keep the rebuild fast. */ import { ALL_FORMATS, AppendOnlyStreamTarget, Conversion, ConversionCanceledError, Input, Mp4OutputFormat, Output, UrlSource, type UrlSourceOptions } from 'mediabunny'; export type Chunk = { src: string; duration: number }; export type ChunkedMediaSourceOptions = { urlOptions?: UrlSourceOptions; codecOverride?: string; priorityTime?: number; onCodecResolved?: (codec: string) => void; onIngestProgress?: (chunkIndex: number, fraction: number) => void; onError?: (err: unknown) => void; }; const sbAvailable = typeof MediaSource !== 'undefined'; function locateChunk(offsets: number[], t: number): number { if (offsets.length === 0) return 0; const tt = Math.max(0, t); for (let i = offsets.length - 1; i >= 0; i--) { if (tt >= offsets[i]) return i; } return 0; } function ingestOrder(n: number, target: number): number[] { if (n <= 0) return []; const t = Math.max(0, Math.min(target, n - 1)); const out: number[] = [t]; for (let i = t + 1; i < n; i++) out.push(i); for (let i = t - 1; i >= 0; i--) out.push(i); return out; } export class ChunkedMediaSource { private static globalCachedCodec: string | null = null; static getCachedCodec(): string | null { return ChunkedMediaSource.globalCachedCodec; } readonly mediaSource: MediaSource; readonly url: string; private chunks: Chunk[]; private opts: ChunkedMediaSourceOptions; private sourceBuffer: SourceBuffer | null = null; private appendChain: Promise = Promise.resolve(); private destroyed = false; private startedPromise: Promise | null = null; private currentConv: Conversion | null = null; private resolvedCodec: string | null = null; constructor(chunks: Chunk[], opts: ChunkedMediaSourceOptions = {}) { if (!sbAvailable) { throw new Error('MediaSource is not available in this environment'); } this.chunks = chunks; this.opts = opts; this.mediaSource = new MediaSource(); this.url = URL.createObjectURL(this.mediaSource); } get codec(): string | null { return this.resolvedCodec; } start(): Promise { if (this.startedPromise) return this.startedPromise; this.startedPromise = this.run(); return this.startedPromise; } destroy(): void { if (this.destroyed) return; this.destroyed = true; try { this.currentConv?.cancel(); } catch { /* ignore */ } try { if (this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream(); } } catch { /* ignore */ } try { URL.revokeObjectURL(this.url); } catch { /* ignore */ } } private async waitSourceOpen(): Promise { if (this.mediaSource.readyState === 'open') return; await new Promise((resolve) => { this.mediaSource.addEventListener('sourceopen', () => resolve(), { once: true }); }); } private async probeCodec(chunk: Chunk): Promise { if (this.opts.codecOverride) return this.opts.codecOverride; if (ChunkedMediaSource.globalCachedCodec) return ChunkedMediaSource.globalCachedCodec; const input = new Input({ formats: ALL_FORMATS, source: new UrlSource(chunk.src, this.opts.urlOptions) }); const videoTrack = await input.getPrimaryVideoTrack(); const audioTrack = await input.getPrimaryAudioTrack(); const videoCodec = videoTrack ? await videoTrack.getCodecParameterString() : null; const audioCodec = audioTrack ? await audioTrack.getCodecParameterString() : null; const parts = [videoCodec, audioCodec].filter((s): s is string => !!s); if (!parts.length) { throw new Error('Could not determine codec for first chunk'); } const mime = `video/mp4; codecs="${parts.join(', ')}"`; ChunkedMediaSource.globalCachedCodec = mime; return mime; } private appendBytes(data: Uint8Array): Promise { const sb = this.sourceBuffer; if (!sb) return Promise.resolve(); this.appendChain = this.appendChain.then( () => new Promise((resolve, reject) => { if (this.destroyed) return resolve(); const cleanup = () => { sb.removeEventListener('updateend', onEnd); sb.removeEventListener('error', onError); }; const onEnd = () => { cleanup(); resolve(); }; const onError = () => { cleanup(); reject(new Error('SourceBuffer append error')); }; sb.addEventListener('updateend', onEnd); sb.addEventListener('error', onError); try { sb.appendBuffer(data as unknown as BufferSource); } catch (err) { cleanup(); reject(err); } }) ); return this.appendChain; } private async run(): Promise { try { await this.waitSourceOpen(); if (this.destroyed) return; if (!this.chunks.length) { try { this.mediaSource.endOfStream(); } catch { /* ignore */ } return; } const codec = await this.probeCodec(this.chunks[0]); if (this.destroyed) return; this.resolvedCodec = codec; ChunkedMediaSource.globalCachedCodec = codec; this.opts.onCodecResolved?.(codec); this.sourceBuffer = this.mediaSource.addSourceBuffer(codec); this.sourceBuffer.mode = 'segments'; const offsets: number[] = []; let acc = 0; for (const c of this.chunks) { offsets.push(acc); acc += c.duration; } try { this.mediaSource.duration = acc; } catch { /* ignored */ } const priority = this.opts.priorityTime ?? 0; const order = ingestOrder(this.chunks.length, locateChunk(offsets, priority)); for (const i of order) { if (this.destroyed) return; await this.ingestChunk(this.chunks[i], i, offsets[i]); } if (!this.destroyed) { await this.appendChain.catch(() => {}); if (this.mediaSource.readyState === 'open') { try { this.mediaSource.endOfStream(); } catch { /* ignore */ } } } } catch (err) { if (this.destroyed) return; if (err instanceof ConversionCanceledError) return; this.opts.onError?.(err); try { if (this.mediaSource.readyState === 'open') { this.mediaSource.endOfStream('decode'); } } catch { /* ignore */ } } } private async ingestChunk(chunk: Chunk, index: number, timestampOffset: number): Promise { await this.appendChain.catch(() => {}); if (this.destroyed) return; const sb = this.sourceBuffer; if (!sb) return; sb.timestampOffset = timestampOffset; const input = new Input({ formats: ALL_FORMATS, source: new UrlSource(chunk.src, this.opts.urlOptions) }); const writable = new WritableStream({ write: async (data) => { if (this.destroyed) return; await this.appendBytes(data); } }); const output = new Output({ target: new AppendOnlyStreamTarget(writable), format: new Mp4OutputFormat({ fastStart: 'fragmented', minimumFragmentDuration: 0.5 }) }); const conv = await Conversion.init({ input, output }); if (!conv.isValid) { throw new Error(`Conversion invalid for chunk ${index} (${chunk.src})`); } conv.onProgress = (p) => { this.opts.onIngestProgress?.(index, p); }; this.currentConv = conv; try { await conv.execute(); } catch (err) { if (err instanceof ConversionCanceledError) return; throw err; } finally { if (this.currentConv === conv) this.currentConv = null; } this.opts.onIngestProgress?.(index, 1); } }