Spaces:
Running
Running
| /** | |
| * 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<void> = Promise.resolve(); | |
| private destroyed = false; | |
| private startedPromise: Promise<void> | 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<void> { | |
| 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<void> { | |
| if (this.mediaSource.readyState === 'open') return; | |
| await new Promise<void>((resolve) => { | |
| this.mediaSource.addEventListener('sourceopen', () => resolve(), { once: true }); | |
| }); | |
| } | |
| private async probeCodec(chunk: Chunk): Promise<string> { | |
| 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<void> { | |
| const sb = this.sourceBuffer; | |
| if (!sb) return Promise.resolve(); | |
| this.appendChain = this.appendChain.then( | |
| () => | |
| new Promise<void>((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<void> { | |
| 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<void> { | |
| 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<Uint8Array>({ | |
| 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); | |
| } | |
| } | |