feat(spf): stream segment fetches via ReadableStream body (#890)

This commit is contained in:
Christian Pillsbury
2026-03-11 12:00:04 -07:00
committed by GitHub
parent 2105010c7f
commit 6fcb8eb989
18 changed files with 1847 additions and 373 deletions
@@ -0,0 +1,107 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Firefox MSE Init Order Repro</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; font-size: 13px; background: #1a1a1a; color: #e0e0e0; padding: 16px; }
h1 { font-size: 15px; margin-bottom: 4px; color: #fff; }
.subtitle { color: #888; font-size: 11px; margin-bottom: 14px; }
h2 { font-size: 11px; font-weight: bold; margin: 12px 0 5px; color: #aaa; text-transform: uppercase; letter-spacing: 0.05em; }
.layout { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
video { width: 100%; aspect-ratio: 16/9; background: #000; display: block; }
.state { background: #222; border-radius: 3px; padding: 6px 8px; margin-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
.state-row { display: flex; justify-content: space-between; font-size: 11px; }
.state-key { color: #777; }
.val { color: #6cf; }
.val-true { color: #6f6; }
.val-false { color: #f66; }
.val-none { color: #666; }
.btns { display: flex; flex-wrap: wrap; gap: 5px; margin: 5px 0; }
button {
background: #2a2a2a; color: #ccc; border: 1px solid #444; border-radius: 3px;
padding: 4px 10px; cursor: pointer; font-family: monospace; font-size: 11px;
}
button:hover:not(:disabled) { background: #383838; border-color: #666; }
button:disabled { opacity: 0.35; cursor: default; }
button.ok { background: #162516; border-color: #3a6a3a; color: #8f8; }
button.bad { background: #251616; border-color: #6a3a3a; color: #f88; }
.log { background: #111; border-radius: 3px; padding: 6px 8px; height: 500px; overflow-y: auto; font-size: 11px; line-height: 1.7; }
.info { color: #6cf; }
.ok { color: #6f6; }
.err { color: #f66; }
.warn { color: #fa6; }
.sep { color: #555; border-top: 1px solid #2a2a2a; margin-top: 2px; padding-top: 4px; }
input { background: #222; border: 1px solid #444; color: #e0e0e0; padding: 4px 8px; border-radius: 3px; font-family: monospace; font-size: 11px; width: 100%; }
.scenario { background: #1e1e1e; border: 1px solid #333; border-radius: 3px; padding: 8px; margin: 5px 0; }
.scenario p { color: #888; font-size: 11px; margin-bottom: 5px; line-height: 1.4; }
.codec-info { background: #1a2a1a; border: 1px solid #2a4a2a; border-radius: 3px; padding: 6px 8px; margin: 5px 0; font-size: 11px; color: #8a8; display: none; }
.codec-info.visible { display: block; }
</style>
</head>
<body>
<h1>Firefox MSE Init Segment Order Reproduction</h1>
<p class="subtitle">
Tests whether appending a video media segment before audio init causes <code>mozHasAudio = false</code> in Firefox.
</p>
<div class="layout">
<div>
<video id="video" controls muted playsinline></video>
<div class="state" id="state"></div>
<h2>1 · Load HLS Playlist</h2>
<input id="url" type="text" value="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8" />
<div class="btns" style="margin-top:5px">
<button id="btn-parse" type="button">Parse HLS</button>
<button id="btn-reset" type="button">Reset All</button>
</div>
<div class="codec-info" id="codec-info"></div>
<h2>2 · Manual Setup</h2>
<div class="btns">
<button id="btn-ms" type="button" disabled>Create MediaSource</button>
<button id="btn-vsb" type="button" disabled>Add Video SB</button>
<button id="btn-asb" type="button" disabled>Add Audio SB</button>
</div>
<h2>3 · Manual Steps</h2>
<div class="btns">
<button id="btn-vinit" type="button" disabled>Append Video Init</button>
<button id="btn-ainit" type="button" disabled>Append Audio Init</button>
</div>
<div class="btns">
<button id="btn-vseg" type="button" disabled>Append Video Seg 1</button>
<button id="btn-aseg" type="button" disabled>Append Audio Seg 1</button>
<button id="btn-play" type="button" disabled>▶ Play</button>
</div>
<h2>4 · Test Scenarios</h2>
<div class="scenario">
<p><strong>Correct order</strong>: Both inits committed, then media segments.</p>
<button id="btn-test-ok" type="button" class="ok" disabled>Run: Correct Order</button>
</div>
<div class="scenario">
<p>
<strong>Bug trigger</strong>: Video init → video segment 1 →
then audio init → audio segment 1.<br>
In Firefox this should leave <code>mozHasAudio = false</code>.
</p>
<button id="btn-test-bad" type="button" class="bad" disabled>Run: Video Seg Before Audio Init</button>
</div>
</div>
<div>
<h2>Log</h2>
<div class="log" id="log"></div>
</div>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -0,0 +1,531 @@
/**
* Firefox MSE Init Segment Order Reproduction Harness
*
* Tests the Firefox bug where appending a video media segment before the
* audio SourceBuffer has received its initialization segment causes
* mozHasAudio to be permanently false.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface PlaylistInfo {
videoCodec: string;
audioCodec: string;
videoInitUrl: string;
audioInitUrl: string;
videoSegmentUrls: string[];
audioSegmentUrls: string[];
}
// ---------------------------------------------------------------------------
// DOM refs
// ---------------------------------------------------------------------------
const video = document.getElementById('video') as HTMLVideoElement;
const logEl = document.getElementById('log')!;
const stateEl = document.getElementById('state')!;
const codecInfoEl = document.getElementById('codec-info')!;
const urlInput = document.getElementById('url') as HTMLInputElement;
const btnParse = document.getElementById('btn-parse') as HTMLButtonElement;
const btnReset = document.getElementById('btn-reset') as HTMLButtonElement;
const btnMs = document.getElementById('btn-ms') as HTMLButtonElement;
const btnVsb = document.getElementById('btn-vsb') as HTMLButtonElement;
const btnAsb = document.getElementById('btn-asb') as HTMLButtonElement;
const btnVinit = document.getElementById('btn-vinit') as HTMLButtonElement;
const btnAinit = document.getElementById('btn-ainit') as HTMLButtonElement;
const btnVseg = document.getElementById('btn-vseg') as HTMLButtonElement;
const btnAseg = document.getElementById('btn-aseg') as HTMLButtonElement;
const btnPlay = document.getElementById('btn-play') as HTMLButtonElement;
const btnTestOk = document.getElementById('btn-test-ok') as HTMLButtonElement;
const btnTestBad = document.getElementById('btn-test-bad') as HTMLButtonElement;
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let info: PlaylistInfo | null = null;
let mediaSource: MediaSource | null = null;
let videoSB: SourceBuffer | null = null;
let audioSB: SourceBuffer | null = null;
let videoSegIdx = 0;
let audioSegIdx = 0;
// ---------------------------------------------------------------------------
// Logging
// ---------------------------------------------------------------------------
function log(msg: string, type: 'info' | 'ok' | 'err' | 'warn' | 'sep' = 'info') {
const el = document.createElement('div');
el.className = type;
const ts = new Date().toLocaleTimeString('en', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
el.textContent = `[${ts}] ${msg}`;
logEl.appendChild(el);
logEl.scrollTop = logEl.scrollHeight;
}
// ---------------------------------------------------------------------------
// State display
// ---------------------------------------------------------------------------
function updateState() {
const rs = ['HAVE_NOTHING', 'HAVE_METADATA', 'HAVE_CURRENT_DATA', 'HAVE_FUTURE_DATA', 'HAVE_ENOUGH_DATA'];
const mozHasAudio = (video as unknown as Record<string, unknown>).mozHasAudio;
const mozHasVideo = (video as unknown as Record<string, unknown>).mozHasVideo;
function row(key: string, val: string, className: string) {
return `<div class="state-row"><span class="state-key">${key}</span><span class="${className}">${val}</span></div>`;
}
function boolRow(key: string, val: unknown) {
if (val === undefined) return row(key, 'n/a', 'val-none');
return row(key, String(val), val ? 'val-true' : 'val-false');
}
function rangeStr(r: TimeRanges | undefined) {
if (!r || r.length === 0) return 'empty';
return Array.from({ length: r.length }, (_, i) => `[${r.start(i).toFixed(2)},${r.end(i).toFixed(2)}]`).join(' ');
}
stateEl.innerHTML = [
row('readyState', rs[video.readyState] ?? String(video.readyState), 'val'),
boolRow('mozHasAudio', mozHasAudio),
boolRow('mozHasVideo', mozHasVideo),
row('ms.readyState', mediaSource?.readyState ?? 'none', mediaSource ? 'val' : 'val-none'),
row('videoSB', videoSB ? rangeStr(videoSB.buffered) : 'none', videoSB ? 'val' : 'val-none'),
row('audioSB', audioSB ? rangeStr(audioSB.buffered) : 'none', audioSB ? 'val' : 'val-none'),
].join('');
}
setInterval(updateState, 250);
for (const evt of ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'waiting', 'stalled', 'error'] as const) {
video.addEventListener(evt, () => {
log(`video: ${evt}`, evt === 'error' ? 'err' : 'info');
updateState();
});
}
// ---------------------------------------------------------------------------
// m3u8 parsing
// ---------------------------------------------------------------------------
function resolveUrl(url: string, base: string): string {
if (url.startsWith('http')) return url;
try {
return new URL(url, base).href;
} catch {
return url;
}
}
function parseMaster(text: string, baseUrl: string) {
const lines = text.split('\n').map((l) => l.trim());
let videoUrl = '';
let audioUrl = '';
let codecsAttr = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (line.startsWith('#EXT-X-MEDIA:') && /TYPE=AUDIO/.test(line) && !audioUrl) {
const m = line.match(/URI="([^"]+)"/);
if (m) audioUrl = resolveUrl(m[1]!, baseUrl);
}
if (line.startsWith('#EXT-X-STREAM-INF:') && !videoUrl) {
const cm = line.match(/CODECS="([^"]+)"/);
if (cm) codecsAttr = cm[1]!;
const next = lines[i + 1];
if (next && !next.startsWith('#')) videoUrl = resolveUrl(next, baseUrl);
}
}
return { videoUrl, audioUrl, codecsAttr };
}
function parseMedia(text: string, baseUrl: string) {
const lines = text.split('\n').map((l) => l.trim());
let initUrl = '';
const segmentUrls: string[] = [];
for (const line of lines) {
if (line.startsWith('#EXT-X-MAP:')) {
const m = line.match(/URI="([^"]+)"/);
if (m) initUrl = resolveUrl(m[1]!, baseUrl);
}
if (line && !line.startsWith('#')) segmentUrls.push(resolveUrl(line, baseUrl));
}
return { initUrl, segmentUrls };
}
function splitCodecs(codecsAttr: string) {
const parts = codecsAttr.split(',').map((c) => c.trim());
const video = parts.filter((c) => /^(avc|hvc|vp0|av0)/i.test(c)).join(',');
const audio = parts.filter((c) => /^(mp4a|ac-3|ec-3|opus)/i.test(c)).join(',');
return { video: video || 'avc1.64001f', audio: audio || 'mp4a.40.2' };
}
// ---------------------------------------------------------------------------
// Parse button
// ---------------------------------------------------------------------------
btnParse.addEventListener('click', async () => {
btnParse.disabled = true;
log('--- Parsing HLS playlist ---', 'sep');
const url = urlInput.value.trim();
log(`GET ${url}`);
try {
const masterText = await fetch(url).then((r) => r.text());
const { videoUrl, audioUrl, codecsAttr } = parseMaster(masterText, url);
if (!videoUrl) throw new Error('No video rendition found in master playlist');
log(`Video playlist: ${videoUrl}`, 'ok');
log(`Audio playlist: ${audioUrl || '(none — muxed?)'}`, audioUrl ? 'ok' : 'warn');
const [videoText, audioText] = await Promise.all([
fetch(videoUrl).then((r) => r.text()),
audioUrl ? fetch(audioUrl).then((r) => r.text()) : Promise.resolve(''),
]);
const videoMedia = parseMedia(videoText, videoUrl);
const audioMedia = audioUrl ? parseMedia(audioText, audioUrl) : { initUrl: '', segmentUrls: [] };
const codecs = splitCodecs(codecsAttr);
log(`Video codec: ${codecs.video}`, 'ok');
log(`Audio codec: ${codecs.audio}`, 'ok');
log(`Video init: ${videoMedia.initUrl}`, 'ok');
log(`Audio init: ${audioMedia.initUrl || '(none)'}`, audioMedia.initUrl ? 'ok' : 'warn');
log(`Video segments: ${videoMedia.segmentUrls.length}, Audio segments: ${audioMedia.segmentUrls.length}`, 'ok');
if (!audioUrl || !audioMedia.initUrl) {
log('⚠ No separate audio playlist — test requires demuxed audio/video HLS', 'warn');
}
info = {
videoCodec: codecs.video,
audioCodec: codecs.audio,
videoInitUrl: videoMedia.initUrl,
audioInitUrl: audioMedia.initUrl,
videoSegmentUrls: videoMedia.segmentUrls.slice(0, 6),
audioSegmentUrls: audioMedia.segmentUrls.slice(0, 6),
};
codecInfoEl.textContent = `video/mp4; codecs="${info.videoCodec}" | audio/mp4; codecs="${info.audioCodec}"`;
codecInfoEl.classList.add('visible');
btnMs.disabled = false;
btnTestOk.disabled = !audioMedia.initUrl;
btnTestBad.disabled = !audioMedia.initUrl;
} catch (e) {
log(`Error: ${e}`, 'err');
btnParse.disabled = false;
}
});
// ---------------------------------------------------------------------------
// Reset
// ---------------------------------------------------------------------------
btnReset.addEventListener('click', () => {
if (mediaSource && mediaSource.readyState === 'open') {
try {
mediaSource.endOfStream();
} catch {
/* ignore */
}
}
video.src = '';
mediaSource = null;
videoSB = null;
audioSB = null;
videoSegIdx = 0;
audioSegIdx = 0;
btnVseg.textContent = 'Append Video Seg 1';
btnAseg.textContent = 'Append Audio Seg 1';
for (const b of [btnMs, btnVsb, btnAsb, btnVinit, btnAinit, btnVseg, btnAseg, btnPlay]) b.disabled = true;
btnParse.disabled = false;
log('--- Reset ---', 'sep');
updateState();
});
// ---------------------------------------------------------------------------
// Manual setup
// ---------------------------------------------------------------------------
btnMs.addEventListener('click', () => {
mediaSource = new MediaSource();
mediaSource.addEventListener(
'sourceopen',
() => {
log('MediaSource: sourceopen', 'ok');
btnVsb.disabled = false;
btnAsb.disabled = false;
updateState();
},
{ once: true }
);
mediaSource.addEventListener('sourceended', () => {
log('MediaSource: sourceended', 'info');
updateState();
});
mediaSource.addEventListener('sourceclose', () => {
log('MediaSource: sourceclose', 'warn');
updateState();
});
video.src = URL.createObjectURL(mediaSource);
btnMs.disabled = true;
log('MediaSource created, attaching to video…');
});
function addSB(type: 'video' | 'audio') {
if (!mediaSource || !info) return;
const mime = type === 'video' ? `video/mp4; codecs="${info.videoCodec}"` : `audio/mp4; codecs="${info.audioCodec}"`;
try {
const sb = mediaSource.addSourceBuffer(mime);
sb.addEventListener('updateend', updateState);
sb.addEventListener('error', (e) => {
log(`${type} SB error: ${e}`, 'err');
updateState();
});
log(`${type} SourceBuffer added: ${mime}`, 'ok');
if (type === 'video') {
videoSB = sb;
btnVsb.disabled = true;
btnVinit.disabled = false;
} else {
audioSB = sb;
btnAsb.disabled = true;
btnAinit.disabled = false;
}
updateState();
} catch (e) {
log(`Failed addSourceBuffer(${mime}): ${e}`, 'err');
}
}
btnVsb.addEventListener('click', () => addSB('video'));
btnAsb.addEventListener('click', () => addSB('audio'));
// ---------------------------------------------------------------------------
// Append helper
// ---------------------------------------------------------------------------
async function append(sb: SourceBuffer, url: string, label: string): Promise<void> {
log(`Fetching ${label}`);
const data = await fetch(url).then((r) => r.arrayBuffer());
log(`${label}: ${data.byteLength} bytes`);
if (sb.updating)
await new Promise<void>((resolve) => {
sb.addEventListener('updateend', () => resolve(), { once: true });
});
return new Promise<void>((resolve, reject) => {
const onEnd = () => {
cleanup();
log(`${label}: updateend ✓`, 'ok');
resolve();
};
const onErr = () => {
cleanup();
reject(new Error(`${label}: SourceBuffer error`));
};
const cleanup = () => {
sb.removeEventListener('updateend', onEnd);
sb.removeEventListener('error', onErr);
};
sb.addEventListener('updateend', onEnd);
sb.addEventListener('error', onErr);
try {
sb.appendBuffer(data);
} catch (e) {
cleanup();
reject(e);
}
});
}
// ---------------------------------------------------------------------------
// Manual step buttons
// ---------------------------------------------------------------------------
btnVinit.addEventListener('click', async () => {
if (!videoSB || !info?.videoInitUrl) return;
btnVinit.disabled = true;
try {
await append(videoSB, info.videoInitUrl, 'video init');
btnVseg.disabled = false;
} catch (e) {
log(`${e}`, 'err');
}
updateState();
});
btnAinit.addEventListener('click', async () => {
if (!audioSB || !info?.audioInitUrl) return;
btnAinit.disabled = true;
try {
await append(audioSB, info.audioInitUrl, 'audio init');
btnAseg.disabled = false;
} catch (e) {
log(`${e}`, 'err');
}
updateState();
});
btnVseg.addEventListener('click', async () => {
if (!videoSB || !info) return;
const url = info.videoSegmentUrls[videoSegIdx];
if (!url) {
log('No more video segments', 'warn');
return;
}
try {
await append(videoSB, url, `video seg ${videoSegIdx + 1}`);
videoSegIdx++;
btnVseg.textContent = `Append Video Seg ${videoSegIdx + 1}`;
btnPlay.disabled = false;
} catch (e) {
log(`${e}`, 'err');
}
updateState();
});
btnAseg.addEventListener('click', async () => {
if (!audioSB || !info) return;
const url = info.audioSegmentUrls[audioSegIdx];
if (!url) {
log('No more audio segments', 'warn');
return;
}
try {
await append(audioSB, url, `audio seg ${audioSegIdx + 1}`);
audioSegIdx++;
btnAseg.textContent = `Append Audio Seg ${audioSegIdx + 1}`;
btnPlay.disabled = false;
} catch (e) {
log(`${e}`, 'err');
}
updateState();
});
btnPlay.addEventListener('click', () => {
video.play().catch((e) => log(`play() rejected: ${e}`, 'warn'));
});
// ---------------------------------------------------------------------------
// Test scenarios
// ---------------------------------------------------------------------------
async function freshSetup(): Promise<void> {
if (mediaSource && mediaSource.readyState === 'open') {
try {
mediaSource.endOfStream();
} catch {
/* ignore */
}
}
video.src = '';
videoSB = null;
audioSB = null;
videoSegIdx = 0;
audioSegIdx = 0;
mediaSource = new MediaSource();
await new Promise<void>((resolve) => {
mediaSource!.addEventListener(
'sourceopen',
() => {
log('MediaSource: sourceopen', 'ok');
videoSB = mediaSource!.addSourceBuffer(`video/mp4; codecs="${info!.videoCodec}"`);
videoSB.addEventListener('updateend', updateState);
log(`Video SB added`, 'ok');
audioSB = mediaSource!.addSourceBuffer(`audio/mp4; codecs="${info!.audioCodec}"`);
audioSB.addEventListener('updateend', updateState);
log(`Audio SB added`, 'ok');
resolve();
},
{ once: true }
);
video.src = URL.createObjectURL(mediaSource!);
});
}
async function runTest(label: string, steps: Array<() => Promise<void>>) {
for (const b of [btnTestOk, btnTestBad, btnParse, btnReset]) b.disabled = true;
log(`--- ${label} ---`, 'sep');
try {
await freshSetup();
for (const step of steps) await step();
updateState();
log(`Test complete — check mozHasAudio above`, 'ok');
video.play().catch(() => {
/* autoplay may be blocked */
});
} catch (e) {
log(`Test error: ${e}`, 'err');
}
for (const b of [btnTestOk, btnTestBad, btnParse, btnReset]) b.disabled = false;
btnPlay.disabled = false;
}
btnTestOk.addEventListener('click', () => {
if (!info) return;
runTest('CORRECT ORDER: both inits → both segments', [
() => {
log('Step 1: append video init');
return append(videoSB!, info!.videoInitUrl, 'video init');
},
() => {
log('Step 2: append audio init');
return append(audioSB!, info!.audioInitUrl, 'audio init');
},
() => {
log('Step 3: append video seg 1');
return append(videoSB!, info!.videoSegmentUrls[0]!, 'video seg 1');
},
() => {
log('Step 4: append audio seg 1');
return append(audioSB!, info!.audioSegmentUrls[0]!, 'audio seg 1');
},
]);
});
btnTestBad.addEventListener('click', () => {
if (!info) return;
runTest('BUG ORDER: video init → video seg → audio init → audio seg', [
() => {
log('Step 1: append video init');
return append(videoSB!, info!.videoInitUrl, 'video init');
},
() => {
log('Step 2: append video seg 1 ← BEFORE audio init');
return append(videoSB!, info!.videoSegmentUrls[0]!, 'video seg 1');
},
() => {
log('Step 3: append audio init (too late?)');
return append(audioSB!, info!.audioInitUrl, 'audio init');
},
() => {
log('Step 4: append audio seg 1');
return append(audioSB!, info!.audioSegmentUrls[0]!, 'audio seg 1');
},
]);
});
@@ -61,7 +61,9 @@ function isLastSegmentAppended(segments: readonly { id: string }[], actor: Sourc
if (segments.length === 0) return true;
const lastSeg = segments[segments.length - 1];
if (!lastSeg) return false;
return actor?.snapshot.context.segments.some((s) => s.id === lastSeg.id) ?? false;
// A partial segment is still streaming — the last segment is not ready until
// its entry is present and not marked partial.
return actor?.snapshot.context.segments.some((s) => s.id === lastSeg.id && !s.partial) ?? false;
}
/**
+49 -18
View File
@@ -6,7 +6,8 @@ import type { AddressableObject, Presentation, ResolvedTrack } from '../../core/
import { isResolvedTrack } from '../../core/types';
import { getSelectedTrack, type TrackSelectionState } from '../../core/utils/track-selection';
import type { SourceBufferActor } from '../media/source-buffer-actor';
import { fetchResolvableBytes } from '../network/fetch';
import { ChunkedStreamIterable, type ChunkedStreamIterableOptions } from '../network/chunked-stream-iterable';
import { fetchResolvable } from '../network/fetch';
import {
type BufferState,
createSegmentLoaderActor,
@@ -29,31 +30,61 @@ const ActorKeyByType = {
// ============================================================================
/**
* Creates a fetch function that transparently samples bandwidth after each
* completed request. Callers receive bytes; throughput tracking is invisible.
* Creates a fetch function that eagerly starts the HTTP request (TTFB is
* awaited), then returns a lazy iterable over the response body that
* transparently samples bandwidth per chunk.
*
* `onSample` is an optional callback invoked after each sample is recorded,
* used for bridging throughput state outward (e.g. migration bridge to global
* state). A callback is used rather than a subscription so that no immediate
* fire occurs at setup time — subscriptions fire on registration and would
* trigger spurious state changes before any work has started.
* Separating connection start from body reading ensures `fetch()` is called
* as soon as the task begins — not deferred until the actor's append loop
* first pulls a chunk. This makes fetch timing predictable and observable
* (e.g. in tests that record fetched URLs) regardless of downstream consumers.
*
* `onSample` bridges throughput state outward; see Phase 2 comment for detail.
*/
type FetchOptions = RequestInit & ChunkedStreamIterableOptions;
function createTrackedFetch(
throughput: WritableState<BandwidthState>,
onSample?: (next: BandwidthState) => void
): (addressable: AddressableObject, options?: RequestInit) => Promise<ArrayBuffer> {
): (addressable: AddressableObject, options?: FetchOptions) => Promise<AsyncIterable<Uint8Array>> {
return async (addressable, options) => {
const start = performance.now();
const data = await fetchResolvableBytes(addressable, options);
const elapsed = performance.now() - start;
const next = sampleBandwidth(throughput.current, elapsed, data.byteLength);
throughput.patch(next);
throughput.flush();
onSample?.(next);
return data;
const { minChunkSize, ...fetchOptions } = options ?? {};
const response = await fetchResolvable(addressable, fetchOptions);
if (!response.body) throw new Error('Response has no body');
const body = response.body;
return {
[Symbol.asyncIterator]: async function* () {
let chunkStart = performance.now();
for await (const chunk of new ChunkedStreamIterable(
body,
...(minChunkSize !== undefined ? [{ minChunkSize }] : [])
)) {
const elapsed = performance.now() - chunkStart;
const next = sampleBandwidth(throughput.current, elapsed, chunk.byteLength);
throughput.patch(next);
throughput.flush();
onSample?.(next);
yield chunk;
chunkStart = performance.now();
}
},
};
};
}
/**
* Non-tracking fetch: eagerly starts the request and returns the response body
* as a lazy chunk iterable. Used for audio tracks which don't sample bandwidth.
* Pass `minChunkSize: Infinity` to accumulate the full body as a single chunk
* (equivalent to arrayBuffer() but through the same streaming path).
*/
async function fetchStream(addressable: AddressableObject, options?: FetchOptions): Promise<AsyncIterable<Uint8Array>> {
const { minChunkSize, ...fetchOptions } = options ?? {};
const response = await fetchResolvable(addressable, fetchOptions);
if (!response.body) throw new Error('Response has no body');
return new ChunkedStreamIterable(response.body, ...(minChunkSize !== undefined ? [{ minChunkSize }] : []));
}
// ============================================================================
// STATE & OWNERS
// ============================================================================
@@ -249,7 +280,7 @@ export function loadSegments(
}
: undefined
)
: fetchResolvableBytes;
: fetchStream;
const segmentLoader = createState<SegmentLoaderActor | undefined>(undefined);
@@ -101,11 +101,16 @@ export interface SegmentLoaderActor {
* operation (if still needed) or preempts it.
*
* @param sourceBufferActor - Shared SourceBufferActor reference (not owned)
* @param fetchBytes - Tracked fetch closure (owns throughput sampling)
* @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).
* Accepts an optional `minChunkSize` in options; init segments pass `Infinity`
* so the entire body accumulates as one chunk before appending.
*/
export function createSegmentLoaderActor(
sourceBufferActor: SourceBufferActor,
fetchBytes: (addressable: AddressableObject, options?: RequestInit) => Promise<ArrayBuffer>
fetchBytes: (
addressable: AddressableObject,
options?: RequestInit & { minChunkSize?: number }
) => Promise<AsyncIterable<Uint8Array>>
): SegmentLoaderActor {
let pendingTasks: LoadTask[] | null = null;
let inFlightInitTrackId: string | null = null;
@@ -115,7 +120,9 @@ export function createSegmentLoaderActor(
let destroyed = false;
const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {
const bufferedIds = new Set(sourceBufferActor.snapshot.context.segments.map((s) => s.id));
// Exclude partial segments — they are still being streamed and must not be
// treated as fully buffered for load planning or buffer window calculations.
const bufferedIds = new Set(sourceBufferActor.snapshot.context.segments.filter((s) => !s.partial).map((s) => s.id));
return allSegments.filter((s) => bufferedIds.has(s.id));
};
@@ -166,6 +173,9 @@ export function createSegmentLoaderActor(
// content in the actor context. Preserves buffered high-quality content during
// ABR downgrades; loads during upgrades and for uncovered positions.
const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);
// Partial segments are still streaming — treat as not buffered so they
// are always re-planned (avoids relying on incomplete data).
if (existing?.partial) return true;
if (!existing?.trackBandwidth || !track.bandwidth) return true;
return track.bandwidth > existing.trackBandwidth;
});
@@ -205,7 +215,12 @@ export function createSegmentLoaderActor(
if (task.type === 'append-init') {
inFlightInitTrackId = task.meta.trackId;
if (!signal.aborted) {
const data = await fetchBytes(task, { signal });
// Init segments are small and need the full body before the
// same-track-seek vs track-switch commit decision can be made.
// minChunkSize: Infinity causes ChunkedStreamIterable to accumulate
// all chunks and yield exactly one — equivalent to arrayBuffer() but
// through the same streaming path as media segments.
const data = await fetchBytes(task, { signal, minChunkSize: Infinity });
// For seeks on the same track: commit even if aborted — avoids re-fetching the
// same init next time. For track switches: don't commit the old track's init;
// the new track's init follows in pendingTasks.
@@ -220,12 +235,14 @@ export function createSegmentLoaderActor(
return;
}
// append-segment
// append-segment: await headers eagerly (starts the HTTP connection and
// records the fetch in observers like tests), then pass the body stream
// directly to the actor so chunks are appended as they arrive.
inFlightSegmentId = task.meta.id;
if (!signal.aborted) {
const data = await fetchBytes(task, { signal });
const stream = await fetchBytes(task, { signal });
if (!signal.aborted) {
await sourceBufferActor.send({ type: 'append-segment', data, meta: task.meta }, signal);
await sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal);
}
}
} finally {
@@ -7,46 +7,12 @@ import { BufferKeyByType, getSelectedTrack, type TrackSelectionState } from '../
import { createSourceBuffer } from '../media/mediasource-setup';
import { createSourceBufferActor, type SourceBufferActor } from '../media/source-buffer-actor';
/** Map track type to SourceBufferActor owner property key. */
const ActorKeyByType = {
video: 'videoBufferActor',
audio: 'audioBufferActor',
} as const;
/**
* Media track type for SourceBuffer setup.
* Text tracks are excluded as they don't use MSE SourceBuffers.
*/
export type MediaTrackType = 'video' | 'audio';
/**
* Setup SourceBuffer task (module-level, pure).
* Creates SourceBuffer for resolved track and waits a frame before completing.
*/
const setupSourceBufferTask = async <T extends MediaTrackType>(
{ currentState, currentOwners }: { currentState: SourceBufferState; currentOwners: SourceBufferOwners },
context: { owners: WritableState<SourceBufferOwners>; config: SourceBufferConfig<T> }
): Promise<void> => {
// Wait for track to be resolved with codecs before creating SourceBuffer
const track = getSelectedTrack(currentState, context.config.type);
if (!track || !isResolvedTrack(track)) return;
if (!track.codecs || track.codecs.length === 0) return;
const mimeCodec = buildMimeCodec(track);
// Create SourceBuffer and its actor together — single owners.patch so
// subscribers see both arrive at the same time with no intermediate state.
const buffer = createSourceBuffer(currentOwners.mediaSource!, mimeCodec);
const actor = createSourceBufferActor(buffer);
const bufferKey = BufferKeyByType[context.config.type];
const actorKey = ActorKeyByType[context.config.type];
context.owners.patch({ [bufferKey]: buffer, [actorKey]: actor });
// Wait a frame to allow async state updates to flush
await new Promise((resolve) => requestAnimationFrame(resolve));
};
/**
* State shape for SourceBuffer setup.
*/
@@ -121,57 +87,71 @@ export function shouldSetupBuffer(owners: SourceBufferOwners, type: MediaTrackTy
}
/**
* Configuration for SourceBuffer setup.
*/
export interface SourceBufferConfig<T extends MediaTrackType = MediaTrackType> {
type: T;
}
/**
* Setup SourceBuffer orchestration.
* Setup all needed SourceBuffers as a single coordinated operation.
*
* Triggers when:
* - MediaSource exists and is in 'open' state
* - Track is selected (same condition as resolveTrack)
* Waits until ALL selected tracks (video and/or audio) are resolved with
* codecs, then creates every SourceBuffer in one synchronous block before
* patching owners. This guarantees that downstream consumers (e.g.
* loadSegments) never see a partial set of SourceBuffers preventing the
* Firefox bug where appending to a video SourceBuffer before the audio
* SourceBuffer exists causes mozHasAudio to be permanently false.
*
* Creates SourceBuffer when track becomes resolved with codecs.
* This allows setupSourceBuffer to run in parallel with resolveTrack.
* Handles video-only, audio-only, and combined presentations correctly:
* only the tracks that are actually selected are waited on and created.
*
* Note: Text tracks don't use SourceBuffers and should be handled separately.
*
* Generic over track type - create one orchestration per track type:
* @example
* const videoCleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
* const audioCleanup = setupSourceBuffer({ state, owners }, { type: 'audio' });
* const cleanup = setupSourceBuffers({ state, owners });
*/
export function setupSourceBuffer<T extends MediaTrackType>(
{
state,
owners,
}: {
state: WritableState<SourceBufferState>;
owners: WritableState<SourceBufferOwners>;
},
config: SourceBufferConfig<T>
): () => void {
let currentTask: Promise<void> | null = null;
export function setupSourceBuffers({
state,
owners,
}: {
state: WritableState<SourceBufferState>;
owners: WritableState<SourceBufferOwners>;
}): () => void {
let setupDone = false;
const cleanup = combineLatest([state, owners]).subscribe(
async ([currentState, currentOwners]: [SourceBufferState, SourceBufferOwners]) => {
// Check orchestration conditions (track selected, MediaSource open)
if (currentTask) return; // Task already in progress
if (!canSetupBuffer(currentState, currentOwners, config.type) || !shouldSetupBuffer(currentOwners, config.type))
return;
if (setupDone) return;
if (!currentOwners.mediaSource) return;
// Invoke task (no abort needed - synchronous SourceBuffer creation + frame wait)
currentTask = setupSourceBufferTask({ currentState, currentOwners }, { owners, config });
const videoSelected = !!currentState.selectedVideoTrackId;
const audioSelected = !!currentState.selectedAudioTrackId;
try {
await currentTask;
} finally {
// Cleanup orchestration state
currentTask = null;
if (!videoSelected && !audioSelected) return;
const videoTrack = videoSelected ? getSelectedTrack(currentState, 'video') : null;
const audioTrack = audioSelected ? getSelectedTrack(currentState, 'audio') : null;
// Wait until every selected track is resolved with codecs before
// creating any SourceBuffer. This is the coordination guarantee.
if (videoSelected && (!videoTrack || !isResolvedTrack(videoTrack) || !videoTrack.codecs?.length)) return;
if (audioSelected && (!audioTrack || !isResolvedTrack(audioTrack) || !audioTrack.codecs?.length)) return;
setupDone = true;
// Create all SourceBuffers synchronously — no await between addSourceBuffer
// calls — then patch owners once so subscribers see all buffers simultaneously.
const patch: Partial<SourceBufferOwners> = {};
if (videoSelected && videoTrack && isResolvedTrack(videoTrack)) {
const buffer = createSourceBuffer(currentOwners.mediaSource!, buildMimeCodec(videoTrack));
patch.videoBuffer = buffer;
patch.videoBufferActor = createSourceBufferActor(buffer);
}
if (audioSelected && audioTrack && isResolvedTrack(audioTrack)) {
const buffer = createSourceBuffer(currentOwners.mediaSource!, buildMimeCodec(audioTrack));
patch.audioBuffer = buffer;
patch.audioBufferActor = createSourceBufferActor(buffer);
}
owners.patch(patch);
// Wait a frame to allow async state updates to flush before downstream
// orchestrations (loadSegments) begin reacting to the new owners.
await new Promise((resolve) => requestAnimationFrame(resolve));
}
);
@@ -189,6 +189,41 @@ describe('hasLastSegmentLoaded', () => {
expect(hasLastSegmentLoaded(state, owners)).toBe(true);
});
it('returns false when last segment is present but marked partial', () => {
const track = makeResolvedVideoTrack(4);
const state: EndOfStreamState = {
selectedVideoTrackId: 'video-1',
presentation: makePresentation(track),
};
// Last segment (seg-3) is in context but still streaming
const actor = createSourceBufferActor(makeSourceBuffer(), {
initTrackId: 'video-1',
segments: [
{ id: 'seg-0', startTime: 0, duration: 2.5, trackId: 'video-1' },
{ id: 'seg-1', startTime: 2.5, duration: 2.5, trackId: 'video-1' },
{ id: 'seg-2', startTime: 5, duration: 2.5, trackId: 'video-1' },
{ id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1', partial: true },
],
});
expect(hasLastSegmentLoaded(state, { videoBufferActor: actor })).toBe(false);
});
it('returns true when last segment is present and partial is cleared', () => {
const track = makeResolvedVideoTrack(4);
const state: EndOfStreamState = {
selectedVideoTrackId: 'video-1',
presentation: makePresentation(track),
};
const actor = createSourceBufferActor(makeSourceBuffer(), {
initTrackId: 'video-1',
segments: [
{ id: 'seg-2', startTime: 5, duration: 2.5, trackId: 'video-1' },
{ id: 'seg-3', startTime: 7.5, duration: 2.5, trackId: 'video-1' }, // partial: undefined = complete
],
});
expect(hasLastSegmentLoaded(state, { videoBufferActor: actor })).toBe(true);
});
it('returns false when video last segment is loaded but audio last segment is not', () => {
const videoTrack = makeResolvedVideoTrack(4);
const presentation = {
@@ -901,3 +901,155 @@ describe('loadSegments forward buffer flushing', () => {
cleanup();
});
});
// ---------------------------------------------------------------------------
// Streaming bandwidth tracking
// ---------------------------------------------------------------------------
describe('loadSegments bandwidth tracking', () => {
function makeStreamingFetch(chunks: Uint8Array[]) {
return vi.fn().mockImplementation(() => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
});
return Promise.resolve(new Response(body));
});
}
it('samples bandwidth per chunk and updates state.bandwidthState', async () => {
const chunkSize = 50_000; // 50 KB — below 128 KB default, so whole segment = one flush
const numChunks = 3;
const chunks = Array.from({ length: numChunks }, () => new Uint8Array(chunkSize).fill(1));
globalThis.fetch = makeStreamingFetch(chunks);
const { loadSegments } = await import('../load-segments');
const { createState: cs } = await import('../../../core/state/create-state');
const segment = { id: 's1', url: 'http://example.com/s1.m4s', startTime: 0, duration: 10 };
const track = {
type: 'video' as const,
id: 'track-1',
url: 'http://example.com/video.m3u8',
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
bandwidth: 1_000_000,
initialization: { url: 'http://example.com/init.mp4' },
segments: [segment],
startTime: 0,
duration: 10,
};
// Seeding bandwidthState activates the onSample bridge → state.bandwidthState updates
const initialBandwidth = {
fastEstimate: 0,
fastTotalWeight: 0,
slowEstimate: 0,
slowTotalWeight: 0,
bytesSampled: 0,
};
const state = cs<SegmentLoadingState>({
preload: 'auto',
selectedVideoTrackId: 'track-1',
currentTime: 0,
bandwidthState: initialBandwidth,
presentation: {
id: 'p1',
url: 'http://example.com/playlist.m3u8',
startTime: 0,
duration: 10,
selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }],
},
});
const { sourceBuffer, actor } = makeSourceBufferWithActor();
const owners = cs<SegmentLoadingOwners>({ videoBuffer: sourceBuffer, videoBufferActor: actor });
const cleanup = loadSegments({ state, owners }, { type: 'video' });
// Wait for both init and segment to be appended (actor context will have 1 segment)
await vi.waitFor(
() => {
expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(1);
},
{ timeout: 3000 }
);
// All bytes (init + segment) should be counted in bytesSampled
const totalExpected = chunkSize * numChunks * 2; // init fetch + segment fetch, each 3×50KB
expect(state.current.bandwidthState?.bytesSampled).toBeGreaterThan(0);
expect(state.current.bandwidthState?.bytesSampled).toBeLessThanOrEqual(totalExpected);
cleanup();
});
it('appended data matches the concatenated streaming chunks', async () => {
const part1 = new Uint8Array([1, 2, 3, 4]);
const part2 = new Uint8Array([5, 6, 7, 8]);
globalThis.fetch = vi.fn().mockImplementation(() => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(part1);
controller.enqueue(part2);
controller.close();
},
});
return Promise.resolve(new Response(body));
});
const { loadSegments } = await import('../load-segments');
const { createState: cs } = await import('../../../core/state/create-state');
const segment = { id: 's1', url: 'http://example.com/s1.m4s', startTime: 0, duration: 10 };
const track = {
type: 'video' as const,
id: 'track-1',
url: 'http://example.com/video.m3u8',
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
bandwidth: 500_000,
initialization: { url: 'http://example.com/init.mp4' },
segments: [segment],
startTime: 0,
duration: 10,
};
const state = cs<SegmentLoadingState>({
preload: 'auto',
selectedVideoTrackId: 'track-1',
currentTime: 0,
presentation: {
id: 'p1',
url: 'http://example.com/playlist.m3u8',
startTime: 0,
duration: 10,
selectionSets: [{ id: 'ss1', type: 'video', switchingSets: [{ id: 'sw1', type: 'video', tracks: [track] }] }],
},
});
const { sourceBuffer, actor } = makeSourceBufferWithActor();
const owners = cs<SegmentLoadingOwners>({ videoBuffer: sourceBuffer, videoBufferActor: actor });
const cleanup = loadSegments({ state, owners }, { type: 'video' });
await vi.waitFor(
() => {
expect(owners.current.videoBufferActor?.snapshot.context.segments).toHaveLength(1);
},
{ timeout: 3000 }
);
// Each response body yields [1,2,3,4,5,6,7,8] — both init and segment appends should match
const calls = (sourceBuffer.appendBuffer as ReturnType<typeof vi.fn>).mock.calls;
for (const [data] of calls) {
expect(Array.from(new Uint8Array(data as ArrayBuffer))).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
}
cleanup();
});
});
@@ -6,7 +6,7 @@ import {
canSetupBuffer,
type SourceBufferOwners,
type SourceBufferState,
setupSourceBuffer,
setupSourceBuffers,
shouldSetupBuffer,
} from '../setup-sourcebuffer';
@@ -238,268 +238,193 @@ describe('shouldSetupBuffer', () => {
});
});
describe('setupSourceBuffer', () => {
describe('setupSourceBuffers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('video track', () => {
it('creates SourceBuffer for resolved video track', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
it('creates video SourceBuffer for video-only source', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack = createResolvedVideoTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const videoTrack = createResolvedVideoTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
const cleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
// Set up conditions
const mediaSource = {} as MediaSource;
owners.patch({ mediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
// Wait for async operation
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'video/mp4; codecs="avc1.42E01E"');
});
cleanup();
const mediaSource = {} as MediaSource;
owners.patch({ mediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
it('updates owners with videoBuffer reference', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const mockBuffer = {
mimeCodec: 'video/mp4; codecs="avc1.42E01E"',
mode: 'segments',
updating: false,
};
vi.mocked(createSourceBuffer).mockReturnValue(mockBuffer as unknown as SourceBuffer);
const videoTrack = createResolvedVideoTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
await vi.waitFor(() => {
const currentOwners = owners.current;
expect(currentOwners.videoBuffer).toBe(mockBuffer);
});
cleanup();
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'video/mp4; codecs="avc1.42E01E"');
expect(owners.current.videoBuffer).toBeDefined();
expect(owners.current.audioBuffer).toBeUndefined();
});
it('does not create if track not resolved', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const unresolvedTrack = {
type: 'video' as const,
id: 'video-1',
url: 'http://example.com/video.m3u8',
bandwidth: 1000000,
mimeType: 'video/mp4',
codecs: ['avc1.42E01E'],
};
const presentation: Presentation = {
id: 'pres-1',
url: 'http://example.com/playlist.m3u8',
selectionSets: [
{
id: 'video-set',
type: 'video',
switchingSets: [
{
id: 'video-switching',
type: 'video',
tracks: [unresolvedTrack],
},
],
},
],
startTime: 0,
};
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation,
selectedVideoTrackId: 'video-1',
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).not.toHaveBeenCalled();
cleanup();
});
it('does not create if track missing codecs', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack: VideoTrack = {
...createResolvedVideoTrack(),
codecs: [],
};
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).not.toHaveBeenCalled();
cleanup();
});
it('does not create multiple buffers (deduplication)', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack = createResolvedVideoTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledTimes(1);
});
// Trigger another update
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).toHaveBeenCalledTimes(1);
cleanup();
});
cleanup();
});
describe('audio track', () => {
it('creates SourceBuffer for resolved audio track', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
it('creates audio SourceBuffer for audio-only source', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const audioTrack = createResolvedAudioTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const audioTrack = createResolvedAudioTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
const cleanup = setupSourceBuffer({ state, owners }, { type: 'audio' });
const mediaSource = {} as MediaSource;
owners.patch({ mediaSource });
state.patch({
presentation: createPresentationWithTracks({ audio: audioTrack }),
selectedAudioTrackId: 'audio-1',
});
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'audio/mp4; codecs="mp4a.40.2"');
});
cleanup();
const mediaSource = {} as MediaSource;
owners.patch({ mediaSource });
state.patch({
presentation: createPresentationWithTracks({ audio: audioTrack }),
selectedAudioTrackId: 'audio-1',
});
it('updates owners with audioBuffer reference', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const mockBuffer = {
mimeCodec: 'audio/mp4; codecs="mp4a.40.2"',
mode: 'segments',
updating: false,
};
vi.mocked(createSourceBuffer).mockReturnValue(mockBuffer as unknown as SourceBuffer);
const audioTrack = createResolvedAudioTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffer({ state, owners }, { type: 'audio' });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation: createPresentationWithTracks({ audio: audioTrack }),
selectedAudioTrackId: 'audio-1',
});
await vi.waitFor(() => {
const currentOwners = owners.current;
expect(currentOwners.audioBuffer).toBe(mockBuffer);
});
cleanup();
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'audio/mp4; codecs="mp4a.40.2"');
expect(owners.current.audioBuffer).toBeDefined();
expect(owners.current.videoBuffer).toBeUndefined();
});
cleanup();
});
describe('multi-track orchestration', () => {
it('creates video and audio track types in parallel', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
it('creates both SourceBuffers together when both tracks are selected', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack = createResolvedVideoTrack();
const audioTrack = createResolvedAudioTrack();
const videoTrack = createResolvedVideoTrack();
const audioTrack = createResolvedAudioTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
// Set up both orchestrations
const videoCleanup = setupSourceBuffer({ state, owners }, { type: 'video' });
const audioCleanup = setupSourceBuffer({ state, owners }, { type: 'audio' });
// Set up conditions
const mediaSource = {} as MediaSource;
owners.patch({ mediaSource });
state.patch({
presentation: createPresentationWithTracks({
video: videoTrack,
audio: audioTrack,
}),
selectedVideoTrackId: 'video-1',
selectedAudioTrackId: 'audio-1',
});
// Wait for both to be created
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledTimes(2);
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'video/mp4; codecs="avc1.42E01E"');
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'audio/mp4; codecs="mp4a.40.2"');
});
// Verify both buffers are set
const currentOwners = owners.current;
expect(currentOwners.videoBuffer).toBeDefined();
expect(currentOwners.audioBuffer).toBeDefined();
videoCleanup();
audioCleanup();
const mediaSource = {} as MediaSource;
owners.patch({ mediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack, audio: audioTrack }),
selectedVideoTrackId: 'video-1',
selectedAudioTrackId: 'audio-1',
});
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledTimes(2);
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'video/mp4; codecs="avc1.42E01E"');
expect(createSourceBuffer).toHaveBeenCalledWith(mediaSource, 'audio/mp4; codecs="mp4a.40.2"');
expect(owners.current.videoBuffer).toBeDefined();
expect(owners.current.audioBuffer).toBeDefined();
});
cleanup();
});
it('waits for audio to resolve before creating video SourceBuffer when both are selected', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack = createResolvedVideoTrack();
const unresolvedAudio = createResolvedAudioTrack();
// Simulate unresolved audio track (no segments/initialization — not a ResolvedTrack)
const {
segments: _s,
initialization: _i,
startTime: _st,
duration: _d,
...unresolvedAudioPartial
} = unresolvedAudio;
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
owners.patch({ mediaSource: {} as MediaSource });
// Both track IDs selected, but audio track is not yet resolved
state.patch({
presentation: createPresentationWithTracks({
video: videoTrack,
audio: unresolvedAudioPartial as AudioTrack,
}),
selectedVideoTrackId: 'video-1',
selectedAudioTrackId: 'audio-1',
});
// Video is resolved but audio is not — neither SourceBuffer should be created yet
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).not.toHaveBeenCalled();
// Now audio resolves
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack, audio: unresolvedAudio }),
});
await vi.waitFor(() => {
expect(createSourceBuffer).toHaveBeenCalledTimes(2);
expect(owners.current.videoBuffer).toBeDefined();
expect(owners.current.audioBuffer).toBeDefined();
});
cleanup();
});
it('does not create SourceBuffer when track has no codecs', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack: VideoTrack = { ...createResolvedVideoTrack(), codecs: [] };
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).not.toHaveBeenCalled();
cleanup();
});
it('does not create SourceBuffers more than once', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack = createResolvedVideoTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
owners.patch({ mediaSource: {} as MediaSource });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
await vi.waitFor(() => expect(createSourceBuffer).toHaveBeenCalledTimes(1));
state.patch({ presentation: createPresentationWithTracks({ video: videoTrack }) });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).toHaveBeenCalledTimes(1);
cleanup();
});
it('does not create without a MediaSource', async () => {
const { createSourceBuffer } = await import('../../media/mediasource-setup');
const videoTrack = createResolvedVideoTrack();
const state = createState<SourceBufferState>({});
const owners = createState<SourceBufferOwners>({});
const cleanup = setupSourceBuffers({ state, owners });
state.patch({
presentation: createPresentationWithTracks({ video: videoTrack }),
selectedVideoTrackId: 'video-1',
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(createSourceBuffer).not.toHaveBeenCalled();
cleanup();
});
});
+29 -16
View File
@@ -1,25 +1,39 @@
/**
* Segment appender helper (P11)
* Segment appender helper.
*
* Appends media segments (ArrayBuffer) to SourceBuffer.
* Appends media data (ArrayBuffer or AsyncIterable<Uint8Array> stream) to a
* SourceBuffer, waiting for `updateend` between calls so the browser can
* process each append before the next one arrives.
*/
/** Data accepted by appendSegment — a full buffer or an async chunk stream. */
export type AppendData = ArrayBuffer | AsyncIterable<Uint8Array>;
/**
* Append a media segment to a SourceBuffer.
* Append media data to a SourceBuffer.
*
* Waits for the SourceBuffer to be ready (not updating), then appends
* the segment data. Returns a promise that resolves when append completes.
* Accepts either a full ArrayBuffer (single append) or an AsyncIterable of
* Uint8Array chunks (one append per chunk, in order). Waits for `updateend`
* between each call so appends are serialized correctly.
*
* @param sourceBuffer - The SourceBuffer to append to
* @param segmentData - The segment data as ArrayBuffer
* @returns Promise that resolves when append completes
*
* @example
* const data = await fetch(segmentUrl).then(r => r.arrayBuffer());
* await appendSegment(videoSourceBuffer, data);
* Errors from the SourceBuffer (`error` event) or from the iterable are
* propagated as rejections.
*/
export async function appendSegment(sourceBuffer: SourceBuffer, segmentData: ArrayBuffer): Promise<void> {
// Wait for SourceBuffer to be ready (not currently updating)
export async function appendSegment(sourceBuffer: SourceBuffer, data: AppendData, signal?: AbortSignal): Promise<void> {
if (data instanceof ArrayBuffer) {
await appendChunk(sourceBuffer, data);
} else {
for await (const chunk of data) {
// Check between chunks so an abort can stop streaming before the next
// appendBuffer call. The current chunk (if any) has already landed in the
// SourceBuffer; the partial: true flag in the actor model reflects this.
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
await appendChunk(sourceBuffer, chunk);
}
}
}
async function appendChunk(sourceBuffer: SourceBuffer, data: ArrayBuffer | Uint8Array<ArrayBufferLike>): Promise<void> {
if (sourceBuffer.updating) {
await new Promise<void>((resolve) => {
const onUpdateEnd = () => {
@@ -30,7 +44,6 @@ export async function appendSegment(sourceBuffer: SourceBuffer, segmentData: Arr
});
}
// Append the segment data
return new Promise<void>((resolve, reject) => {
const onUpdateEnd = () => {
cleanup();
@@ -51,7 +64,7 @@ export async function appendSegment(sourceBuffer: SourceBuffer, segmentData: Arr
sourceBuffer.addEventListener('error', onError);
try {
sourceBuffer.appendBuffer(segmentData);
sourceBuffer.appendBuffer(data as ArrayBuffer);
} catch (error) {
cleanup();
reject(error);
@@ -2,7 +2,7 @@ import type { Actor, ActorSnapshot } from '../../core/actor';
import { createState } from '../../core/state/create-state';
import { SerialRunner, Task } from '../../core/task';
import type { Segment, Track } from '../../core/types';
import { appendSegment } from './append-segment';
import { type AppendData, appendSegment } from './append-segment';
import { flushBuffer } from './buffer-flusher';
// =============================================================================
@@ -20,8 +20,10 @@ export type AppendSegmentMeta = Pick<Segment, 'id' | 'startTime' | 'duration'> &
trackBandwidth?: number;
};
export type AppendInitMessage = { type: 'append-init'; data: ArrayBuffer; meta: { trackId: Track['id'] } };
export type AppendSegmentMessage = { type: 'append-segment'; data: ArrayBuffer; meta: AppendSegmentMeta };
export type { AppendData };
export type AppendInitMessage = { type: 'append-init'; data: AppendData; meta: { trackId: Track['id'] } };
export type AppendSegmentMessage = { type: 'append-segment'; data: AppendData; meta: AppendSegmentMeta };
export type RemoveMessage = { type: 'remove'; start: number; end: number };
export type SourceBufferMessage = AppendInitMessage | AppendSegmentMessage | RemoveMessage;
@@ -31,7 +33,18 @@ export type SourceBufferActorStatus = 'idle' | 'updating' | 'destroyed';
/** Non-finite (extended) data managed by the actor — the XState "context". */
export interface SourceBufferActorContext {
initTrackId?: string | undefined;
segments: Array<Pick<Segment, 'id' | 'startTime' | 'duration'> & { trackId: Track['id']; trackBandwidth?: number }>;
segments: Array<
Pick<Segment, 'id' | 'startTime' | 'duration'> & {
trackId: Track['id'];
trackBandwidth?: number;
/**
* True while a streaming append is in progress for this segment.
* The segment's data is partially present in the SourceBuffer.
* Downstream code must not treat a partial segment as fully buffered.
*/
partial?: boolean;
}
>;
bufferedRanges: BufferedRange[];
}
@@ -79,6 +92,13 @@ interface MessageTaskOptions {
signal: AbortSignal;
getCtx: () => SourceBufferActorContext;
sourceBuffer: SourceBuffer;
/**
* Called when a streaming append transitions to a partial state i.e.
* the first chunk of an AsyncIterable has been committed and the segment
* now has data in the SourceBuffer but is not yet complete. Not called for
* full ArrayBuffer appends (which are atomic).
*/
onPartialContext: (ctx: SourceBufferActorContext) => void;
}
function appendInitTask(
@@ -100,15 +120,13 @@ function appendInitTask(
function appendSegmentTask(
message: AppendSegmentMessage,
{ signal, getCtx, sourceBuffer }: MessageTaskOptions
{ signal, getCtx, sourceBuffer, onPartialContext }: MessageTaskOptions
): Task<SourceBufferActorContext> {
return new Task(
async (taskSignal) => {
const ctx = getCtx();
if (taskSignal.aborted) return ctx;
await appendSegment(sourceBuffer, message.data);
// No abort check here: the physical SourceBuffer has been modified, so
// the model must be updated to match regardless of signal state.
const { meta } = message;
// Remove any existing entry at the same start time (same "slot" in the
// timeline), then record the new segment. Assumes time-aligned segments
@@ -116,6 +134,32 @@ function appendSegmentTask(
// parsed timestamps.
const EPSILON = 0.0001;
const filtered = ctx.segments.filter((s) => Math.abs(s.startTime - meta.startTime) >= EPSILON);
// For streaming data: emit partial state before the first chunk so
// downstream code can see the in-progress segment and treat it as
// incomplete. ArrayBuffer appends are atomic so no partial state is
// needed — context is updated once at task completion.
if (!(message.data instanceof ArrayBuffer)) {
onPartialContext({
...ctx,
segments: [
...filtered,
{
id: meta.id,
startTime: meta.startTime,
duration: meta.duration,
trackId: meta.trackId,
...(meta.trackBandwidth !== undefined && { trackBandwidth: meta.trackBandwidth }),
partial: true,
},
],
bufferedRanges: ctx.bufferedRanges,
});
}
await appendSegment(sourceBuffer, message.data, taskSignal);
// No abort check here: the physical SourceBuffer has been modified, so
// the model must be updated to match regardless of signal state.
return {
...ctx,
segments: [
@@ -234,7 +278,17 @@ export function createSourceBufferActor(
// tick is rejected — the actor is now committed to this operation.
state.patch({ status: 'updating' });
const task = messageToTask(message, { signal, getCtx: () => state.current.context, sourceBuffer });
const onPartialContext = (ctx: SourceBufferActorContext) => {
state.patch({ status: 'updating', context: ctx });
state.flush();
};
const task = messageToTask(message, {
signal,
getCtx: () => state.current.context,
sourceBuffer,
onPartialContext,
});
return runner.schedule(task).then(applyResult).catch(handleError);
},
@@ -265,8 +319,16 @@ export function createSourceBufferActor(
// appends do not fail — but worth revisiting if MSE error recovery lands.
let workingCtx = state.current.context;
// Partial context updates from streaming appends patch state directly so
// external subscribers see in-progress state, but workingCtx is only
// advanced on task completion to preserve batch context threading.
const onPartialContext = (ctx: SourceBufferActorContext) => {
state.patch({ status: 'updating', context: ctx });
state.flush();
};
for (const message of messages.slice(0, -1)) {
const task = messageToTask(message, { signal, getCtx: () => workingCtx, sourceBuffer });
const task = messageToTask(message, { signal, getCtx: () => workingCtx, sourceBuffer, onPartialContext });
const result = runner.schedule(task);
result.then((newCtx) => {
workingCtx = newCtx;
@@ -277,6 +339,7 @@ export function createSourceBufferActor(
signal,
getCtx: () => workingCtx,
sourceBuffer,
onPartialContext,
});
return runner.schedule(lastTask).then(applyResult).catch(handleError);
},
@@ -0,0 +1,162 @@
import { describe, expect, it, vi } from 'vitest';
import { appendSegment } from '../append-segment';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeSourceBuffer(): SourceBuffer {
const listeners: Record<string, EventListener[]> = {};
return {
updating: false,
appendBuffer: vi.fn(() => {
setTimeout(() => {
for (const listener of listeners.updateend ?? []) listener(new Event('updateend'));
}, 0);
}),
addEventListener: vi.fn((type: string, listener: EventListener) => {
listeners[type] ??= [];
listeners[type].push(listener);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
listeners[type] = (listeners[type] ?? []).filter((l) => l !== listener);
}),
} as unknown as SourceBuffer;
}
async function* chunks(...buffers: ArrayBuffer[]): AsyncGenerator<Uint8Array> {
for (const buf of buffers) yield new Uint8Array(buf);
}
// ---------------------------------------------------------------------------
// ArrayBuffer path
// ---------------------------------------------------------------------------
describe('appendSegment', () => {
it('calls appendBuffer once for an ArrayBuffer', async () => {
const sb = makeSourceBuffer();
await appendSegment(sb, new ArrayBuffer(8));
expect(sb.appendBuffer).toHaveBeenCalledTimes(1);
});
it('resolves after updateend for ArrayBuffer', async () => {
const sb = makeSourceBuffer();
await expect(appendSegment(sb, new ArrayBuffer(4))).resolves.toBeUndefined();
});
it('waits for updating=false before appending', async () => {
const listeners: Record<string, EventListener[]> = {};
let updating = true;
const sb = {
get updating() {
return updating;
},
appendBuffer: vi.fn(() => {
setTimeout(() => {
updating = false;
for (const l of listeners.updateend ?? []) l(new Event('updateend'));
}, 0);
}),
addEventListener: vi.fn((type: string, listener: EventListener) => {
listeners[type] ??= [];
listeners[type].push(listener);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
listeners[type] = (listeners[type] ?? []).filter((l) => l !== listener);
}),
} as unknown as SourceBuffer;
// Simulate an external updateend that clears updating
setTimeout(() => {
updating = false;
for (const l of listeners.updateend ?? []) l(new Event('updateend'));
}, 10);
await appendSegment(sb, new ArrayBuffer(4));
expect(sb.appendBuffer).toHaveBeenCalledOnce();
});
// ---------------------------------------------------------------------------
// AsyncIterable path
// ---------------------------------------------------------------------------
it('calls appendBuffer once per chunk for AsyncIterable', async () => {
const sb = makeSourceBuffer();
await appendSegment(sb, chunks(new ArrayBuffer(4), new ArrayBuffer(4), new ArrayBuffer(4)));
expect(sb.appendBuffer).toHaveBeenCalledTimes(3);
});
it('resolves after all chunks are appended', async () => {
const sb = makeSourceBuffer();
await expect(appendSegment(sb, chunks(new ArrayBuffer(4), new ArrayBuffer(4)))).resolves.toBeUndefined();
});
it('propagates errors thrown from the AsyncIterable', async () => {
const sb = makeSourceBuffer();
async function* errorStream(): AsyncGenerator<Uint8Array> {
yield new Uint8Array(4);
throw new Error('stream failed');
}
await expect(appendSegment(sb, errorStream())).rejects.toThrow('stream failed');
});
it('passes chunk bytes through to appendBuffer unchanged', async () => {
const sb = makeSourceBuffer();
const data = new Uint8Array([1, 2, 3, 4]);
await appendSegment(
sb,
(async function* () {
yield data;
})()
);
const appended = (sb.appendBuffer as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
expect(Array.from(new Uint8Array(appended as ArrayBuffer))).toEqual([1, 2, 3, 4]);
});
it('appends chunks in order', async () => {
const appended: number[][] = [];
const listeners: Record<string, EventListener[]> = {};
const sb = {
updating: false,
appendBuffer: vi.fn((data: ArrayBuffer) => {
appended.push(Array.from(new Uint8Array(data)));
setTimeout(() => {
for (const l of listeners.updateend ?? []) l(new Event('updateend'));
}, 0);
}),
addEventListener: vi.fn((type: string, listener: EventListener) => {
listeners[type] ??= [];
listeners[type].push(listener);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
listeners[type] = (listeners[type] ?? []).filter((l) => l !== listener);
}),
} as unknown as SourceBuffer;
const chunk1 = new Uint8Array([1, 2]);
const chunk2 = new Uint8Array([3, 4]);
const chunk3 = new Uint8Array([5, 6]);
await appendSegment(
sb,
(async function* () {
yield chunk1;
yield chunk2;
yield chunk3;
})()
);
expect(appended).toEqual([
[1, 2],
[3, 4],
[5, 6],
]);
});
});
@@ -437,6 +437,158 @@ describe('createSourceBufferActor', () => {
// destroy()
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Partial segment state — streaming AsyncIterable appends
// ---------------------------------------------------------------------------
it('does not emit a partial snapshot for ArrayBuffer appends', async () => {
const sourceBuffer = makeSourceBuffer([[0, 10]]);
const actor = createSourceBufferActor(sourceBuffer);
const snapshots: (typeof actor.snapshot)[] = [];
const unsub = actor.subscribe((s) => snapshots.push(s));
await actor.send(
{
type: 'append-segment',
data: new ArrayBuffer(8),
meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' },
},
neverAborted
);
unsub();
const hadPartial = snapshots.some((s) => s.context.segments.some((seg) => seg.partial));
expect(hadPartial).toBe(false);
actor.destroy();
});
it('emits a partial:true snapshot before completing a streaming append', async () => {
const sourceBuffer = makeSourceBuffer([[0, 10]]);
const actor = createSourceBufferActor(sourceBuffer);
const snapshots: (typeof actor.snapshot)[] = [];
const unsub = actor.subscribe((s) =>
snapshots.push({ ...s, context: { ...s.context, segments: [...s.context.segments] } })
);
async function* twoChunks() {
yield new Uint8Array(4);
yield new Uint8Array(4);
}
await actor.send(
{ type: 'append-segment', data: twoChunks(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } },
neverAborted
);
unsub();
const partialSnapshot = snapshots.find((s) =>
s.context.segments.some((seg) => seg.id === 's1' && seg.partial === true)
);
expect(partialSnapshot).toBeDefined();
actor.destroy();
});
it('clears partial flag on segment after streaming append completes', async () => {
const sourceBuffer = makeSourceBuffer([[0, 10]]);
const actor = createSourceBufferActor(sourceBuffer);
async function* oneChunk() {
yield new Uint8Array(8);
}
await actor.send(
{ type: 'append-segment', data: oneChunk(), meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' } },
neverAborted
);
const seg = actor.snapshot.context.segments.find((s) => s.id === 's1');
expect(seg).toBeDefined();
expect(seg?.partial).toBeUndefined();
actor.destroy();
});
it('leaves partial:true entry in context when streaming append is aborted', async () => {
// Use a controllable iterable that pauses, allowing abort mid-stream
let resolveFirst: () => void;
const firstChunkReady = new Promise<void>((r) => {
resolveFirst = r;
});
async function* pausingStream() {
yield new Uint8Array(4);
// Pause here — abort will fire before the second chunk
await firstChunkReady;
yield new Uint8Array(4);
}
const sourceBuffer = makeSourceBuffer([
[0, 5],
[5, 10],
]);
const actor = createSourceBufferActor(sourceBuffer);
const ac = new AbortController();
const pending = actor.send(
{
type: 'append-segment',
data: pausingStream(),
meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' },
},
ac.signal
);
// Wait until partial state is emitted (first chunk queued)
await vi.waitFor(() => {
expect(actor.snapshot.context.segments.some((s) => s.id === 's1' && s.partial === true)).toBe(true);
});
// Abort — the stream is paused waiting for resolveFirst
ac.abort();
resolveFirst!();
// Let the task settle (it will reject with AbortError — swallow it)
await pending.catch(() => {});
// partial: true entry should remain — accurately reflects data in SourceBuffer
const seg = actor.snapshot.context.segments.find((s) => s.id === 's1');
expect(seg).toBeDefined();
expect(seg?.partial).toBe(true);
actor.destroy();
});
it('replaces a partial:true entry when the same segment is fully re-appended', async () => {
const sourceBuffer = makeSourceBuffer([[0, 10]]);
const actor = createSourceBufferActor(sourceBuffer);
// First: put a partial segment in context via initialContext shortcut
const actorWithPartial = createSourceBufferActor(sourceBuffer, {
segments: [{ id: 's1', startTime: 0, duration: 10, trackId: 'track-1', partial: true }],
});
// Now fully append the same segment (ArrayBuffer path — atomic, no partial)
await actorWithPartial.send(
{
type: 'append-segment',
data: new ArrayBuffer(8),
meta: { id: 's1', startTime: 0, duration: 10, trackId: 'track-1' },
},
neverAborted
);
const seg = actorWithPartial.snapshot.context.segments.find((s) => s.id === 's1');
expect(seg).toBeDefined();
expect(seg?.partial).toBeUndefined();
actorWithPartial.destroy();
actor.destroy();
});
it('destroy() aborts the in-progress operation', async () => {
const sourceBuffer = makeSourceBuffer();
const actor = createSourceBufferActor(sourceBuffer);
@@ -0,0 +1,57 @@
const DEFAULT_MIN_CHUNK_SIZE = 2 ** 17; // 128 KB
export interface ChunkedStreamIterableOptions {
minChunkSize?: number;
}
/**
* Adapts a `ReadableStream<Uint8Array>` (e.g. `response.body`) into an
* `AsyncIterable<Uint8Array>` that yields chunks no smaller than
* `minChunkSize` bytes. Smaller network chunks are accumulated and yielded
* together once the threshold is met. Any remainder is flushed on stream end.
*
* Errors from the underlying stream propagate naturally the reader lock is
* always released via `finally`.
*/
export class ChunkedStreamIterable implements AsyncIterable<Uint8Array> {
readonly minChunkSize: number;
#readableStream: ReadableStream<Uint8Array>;
constructor(
readableStream: ReadableStream<Uint8Array>,
{ minChunkSize = DEFAULT_MIN_CHUNK_SIZE }: ChunkedStreamIterableOptions = {}
) {
this.#readableStream = readableStream;
this.minChunkSize = minChunkSize;
}
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
let pending: Uint8Array | undefined;
const reader = this.#readableStream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
if (pending) yield pending;
break;
}
pending = pending ? concat(pending, value) : value;
if (pending.length >= this.minChunkSize) {
yield pending;
pending = undefined;
}
}
} finally {
reader.releaseLock();
}
}
}
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
const result = new Uint8Array(a.length + b.length);
result.set(a);
result.set(b, a.length);
return result;
}
+22
View File
@@ -4,9 +4,11 @@
* Two-function approach for composability:
* 1. fetchResolvable() - Fetch AddressableObject (handles byte ranges)
* 2. getResponseText() - Extract text from Response
* 3. fetchResolvableStream() - Stream body as Uint8Array chunks
*/
import type { AddressableObject } from '../../core/types';
import { ChunkedStreamIterable, type ChunkedStreamIterableOptions } from './chunked-stream-iterable';
/**
* Minimal Response-like interface for text extraction.
@@ -69,6 +71,26 @@ export async function fetchResolvableBytes(
return response.arrayBuffer();
}
/**
* Fetch resolvable as a stream of Uint8Array chunks.
*
* Convenience wrapper around fetchResolvable that yields the body as chunks
* via ChunkedStreamIterable. Headers are awaited before the first chunk is
* yielded (TTFB is accounted for before iteration begins).
*
* Throws if the response body is null (e.g. non-body HTTP status).
* Errors from the underlying stream propagate naturally as thrown errors.
*/
export async function* fetchResolvableStream(
addressable: AddressableObject,
options?: RequestInit & ChunkedStreamIterableOptions
): AsyncGenerator<Uint8Array> {
const { minChunkSize, ...fetchOptions } = options ?? {};
const response = await fetchResolvable(addressable, fetchOptions);
if (!response.body) throw new Error('Response has no body');
yield* new ChunkedStreamIterable(response.body, ...(minChunkSize !== undefined ? [{ minChunkSize }] : []));
}
/**
* Extract text from Response.
*
@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
import { ChunkedStreamIterable } from '../chunked-stream-iterable';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++]);
} else {
controller.close();
}
},
});
}
function bytes(size: number, fill = 1): Uint8Array {
return new Uint8Array(size).fill(fill);
}
async function collect(iterable: AsyncIterable<Uint8Array>): Promise<Uint8Array[]> {
const result: Uint8Array[] = [];
for await (const chunk of iterable) {
result.push(chunk);
}
return result;
}
function totalBytes(chunks: Uint8Array[]): number {
return chunks.reduce((sum, c) => sum + c.length, 0);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ChunkedStreamIterable', () => {
it('exposes minChunkSize', () => {
const stream = makeStream();
const iterable = new ChunkedStreamIterable(stream, { minChunkSize: 1024 });
expect(iterable.minChunkSize).toBe(1024);
});
it('defaults minChunkSize to 128 KB', () => {
const stream = makeStream();
const iterable = new ChunkedStreamIterable(stream);
expect(iterable.minChunkSize).toBe(2 ** 17);
});
it('yields a single chunk when it meets minChunkSize exactly', async () => {
const minChunkSize = 64;
const stream = makeStream(bytes(64));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(64);
});
it('yields a single chunk when it exceeds minChunkSize', async () => {
const minChunkSize = 64;
const stream = makeStream(bytes(100));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(100);
});
it('accumulates small chunks until minChunkSize is met', async () => {
const minChunkSize = 64;
// 3 × 30-byte chunks — first two should accumulate, third triggers flush at 90 bytes
const stream = makeStream(bytes(30, 1), bytes(30, 2), bytes(30, 3));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(90);
});
it('flushes remaining bytes on stream end even if below minChunkSize', async () => {
const minChunkSize = 128;
const stream = makeStream(bytes(50));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(chunks[0]!.length).toBe(50);
});
it('preserves all bytes across multiple yielded chunks', async () => {
const minChunkSize = 50;
// 3 × 40-byte chunks → first two accumulate to 80 (≥50, yield), third is remainder
const stream = makeStream(bytes(40, 1), bytes(40, 2), bytes(40, 3));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(totalBytes(chunks)).toBe(120);
});
it('concatenates chunk bytes correctly', async () => {
const minChunkSize = 4;
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4]);
const stream = makeStream(a, b);
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(1);
expect(Array.from(chunks[0]!)).toEqual([1, 2, 3, 4]);
});
it('yields nothing for an empty stream', async () => {
const stream = makeStream();
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize: 64 }));
expect(chunks).toHaveLength(0);
});
it('propagates errors from the underlying stream', async () => {
const errorStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error('network failure'));
},
});
await expect(collect(new ChunkedStreamIterable(errorStream, { minChunkSize: 64 }))).rejects.toThrow(
'network failure'
);
});
it('releases the reader lock after normal completion', async () => {
const stream = makeStream(bytes(10));
const iterable = new ChunkedStreamIterable(stream, { minChunkSize: 64 });
await collect(iterable);
// If lock was not released, getReader() would throw
expect(() => stream.getReader()).not.toThrow();
});
it('releases the reader lock after an error', async () => {
const errorStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.error(new Error('fail'));
},
});
const iterable = new ChunkedStreamIterable(errorStream, { minChunkSize: 64 });
await expect(collect(iterable)).rejects.toThrow();
// Lock should be released even though we errored
expect(errorStream.locked).toBe(false);
});
it('handles multiple large chunks correctly', async () => {
const minChunkSize = 50;
// Each chunk already meets minChunkSize → each yielded individually
const stream = makeStream(bytes(60, 1), bytes(70, 2), bytes(80, 3));
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
expect(chunks).toHaveLength(3);
expect(chunks[0]!.length).toBe(60);
expect(chunks[1]!.length).toBe(70);
expect(chunks[2]!.length).toBe(80);
});
});
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AddressableObject } from '../../../core/types';
import type { ResponseLike } from '../fetch';
import { fetchResolvable, getResponseText } from '../fetch';
import { fetchResolvable, fetchResolvableStream, getResponseText } from '../fetch';
describe('fetchResolvable', () => {
beforeEach(() => {
@@ -63,6 +63,74 @@ describe('fetchResolvable', () => {
});
});
describe('fetchResolvableStream', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
function makeBodyStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i++]);
} else {
controller.close();
}
},
});
}
async function collect(gen: AsyncGenerator<Uint8Array>): Promise<Uint8Array[]> {
const result: Uint8Array[] = [];
for await (const chunk of gen) result.push(chunk);
return result;
}
it('yields chunks from the response body', async () => {
const data = new Uint8Array(256).fill(0xff);
const body = makeBodyStream(data);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body));
const chunks = await collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }, { minChunkSize: 128 }));
const total = chunks.reduce((sum, c) => sum + c.length, 0);
expect(total).toBe(256);
});
it('passes the URL and byte-range header through to fetch', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(makeBodyStream()));
await collect(
fetchResolvableStream(
{ url: 'https://example.com/seg.m4s', byteRange: { start: 0, end: 99 } },
{ minChunkSize: 64 }
)
);
const req: Request = fetchSpy.mock.calls[0]![0] as Request;
expect(req.headers.get('Range')).toBe('bytes=0-99');
});
it('throws when the response has no body', async () => {
const nullBodyResponse = new Response(null, { status: 204 });
vi.spyOn(globalThis, 'fetch').mockResolvedValue(nullBodyResponse);
await expect(collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }))).rejects.toThrow(
'Response has no body'
);
});
it('does not pass minChunkSize as a fetch RequestInit option', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(makeBodyStream()));
await collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }, { minChunkSize: 512 }));
// fetch should have been called with a Request, not an object with minChunkSize
const req: Request = fetchSpy.mock.calls[0]![0] as Request;
expect(req).toBeInstanceOf(Request);
});
});
describe('getResponseText', () => {
it('extracts text from ResponseLike', async () => {
const response: ResponseLike = {
@@ -20,7 +20,7 @@ import { loadSegments } from '../features/load-segments';
import type { TextTrackBufferState } from '../features/load-text-track-cues';
import { loadTextTrackCues } from '../features/load-text-track-cues';
import { setupMediaSource } from '../features/setup-mediasource';
import { setupSourceBuffer } from '../features/setup-sourcebuffer';
import { setupSourceBuffers } from '../features/setup-sourcebuffer';
import { setupTextTracks } from '../features/setup-text-tracks';
import { syncSelectedTextTrackFromDom } from '../features/sync-selected-text-track-from-dom';
import { syncTextTrackModes } from '../features/sync-text-track-modes';
@@ -271,9 +271,12 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac
// 4.5. Update MediaSource duration (when presentation duration available)
updateDuration({ state, owners }),
// 5. Setup SourceBuffers (when MediaSource ready and tracks resolved)
setupSourceBuffer({ state, owners }, { type: 'video' }),
setupSourceBuffer({ state, owners }, { type: 'audio' }),
// 5. Setup SourceBuffers (when MediaSource ready and all selected tracks resolved)
// Both SourceBuffers are created in a single synchronous operation to guarantee
// neither is visible to loadSegments before the other exists — preventing the
// Firefox bug where appending video data before audio SB is created causes
// mozHasAudio to be permanently false.
setupSourceBuffers({ state, owners }),
// 5.5. Track currentTime from mediaElement (feeds forward buffer management)
//