blanchon's picture
Initial CS2 render dataset viewer
31d3580
/**
* 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);
}
}