mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 02:14:06 +00:00
feat(spf): stream segment fetches via ReadableStream body (#890)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user