rework mse buffer handling

AuthorKonata <konata@posteo.jp>
Date
Commit23f423351b95bcffe06e8001b3db336c8d874019
Parent81d9c77
1 file changed, 127 insertions(+), 64 deletions(-)
▾Mwebclient/src/App.tsx
@@ -43,6 +43,25 @@ import { formatFilename, handleUnauthorized, LocalStorageValues, setUnauthorized
export type FlatFileListing = [string, Directory | MediaFile][];
const BUFFER_AHEAD_TARGET = 30; //stop appending once this much media sits past the playhead
const BUFFER_BEHIND_TARGET = 20; //keep this much behind it, so short backwards seeks stay in the buffer
const BUFFER_BEHIND_MIN = 2; //how far quota pressure may shrink that before we give up
const BUFFER_BEHIND_SLACK = 5; //let the behind-window overshoot this much before trimming it again
const BUFFER_POLL_INTERVAL = 250; //ms between buffer checks while the append loop is parked
const RANGE_START_TOLERANCE = 0.25; //seconds of slack when deciding which range a time sits in
const SEEK_MIN_AHEAD = 0.5; //playable seconds a seek target needs ahead of it, or it lands on the buffer edge
//how much contiguous media is buffered ahead of `time`. buffered is a *list* of disjoint ranges, so
//end(0) is only the playhead's range while the buffer happens to be contiguous - once a gap appears
//it reports the end of a range behind the playhead and the caller's arithmetic goes negative
function bufferedAhead(ranges: TimeRanges, time: number): number {
for (let i = 0; i < ranges.length; i++) {
//tolerance at the start: the playhead can sit a frame before a range boundary
if (time >= ranges.start(i) - RANGE_START_TOLERANCE && time <= ranges.end(i)) return ranges.end(i) - time;
}
return 0; //the playhead is starving, so append immediately
}
const App: Component = () => {
const [showFileBrowser, setShowFileBrowser] = createSignal(false);
const [currentDir, setCurrentDir] = createSignal(decodePath(window.location.hash.slice(1)));
@@ -313,47 +332,38 @@ const App: Component = () => {
});
}
//wait until playback time is progressed far enough to appending to the buffer again
//keep only 30 seconds of buffer ahead
function waitForBufferLimit() {
return new Promise((resolve) => {
//shared cleanup so whichever of timeupdate/abort fires first removes both listeners
const cleanup = () => {
videoElement.removeEventListener("timeupdate", cb);
signal.removeEventListener("abort", onAbort);
};
//after stopPlaying pauses the element, timeupdate never fires again, so also resolve on abort
//to avoid parking the append loop forever holding the reader/SourceBuffer
const onAbort = () => {
cleanup();
resolve(undefined);
//park the append loop until there is room ahead of the playhead again. the interval is what
//guarantees a wakeup: timeupdate does not fire at all while the element is paused (and is only
//loosely specified otherwise), so it must not be the sole liveness signal - it and seeking
//merely make the wakeup prompt. also resolves on abort/teardown so the loop can never park
//forever holding the reader, the SourceBuffer and the server-side ffmpeg process
function waitForBufferSpace() {
return new Promise<void>((resolve) => {
//every listener is registered against this controller, so one abort() detaches them all and
//no separate "already settled" bookkeeping is needed - resolving and clearing twice are no-ops.
//note the outer abort listener must be scoped to it too: that signal outlives a single wait
//(it is the whole track's), so a listener left on it would accumulate once per chunk
const controller = new AbortController();
const finish = () => {
clearInterval(timer);
controller.abort();
resolve();
};
const cb = () => {
const check = () => {
try {
if (
sourceBuffer.buffered.length === 0 ||
sourceBuffer.buffered.end(0) - videoElement.currentTime < 30
) {
cleanup();
resolve(undefined);
return true;
}
return false;
if (signal.aborted || thisMediaSource.readyState !== "open" || videoElement.error) return finish();
if (bufferedAhead(sourceBuffer.buffered, videoElement.currentTime) < BUFFER_AHEAD_TARGET) finish();
} catch (_e) {
cleanup();
resolve(undefined);
//reading buffered throws once the SourceBuffer is detached - nothing left to wait for
finish();
}
};
//if already aborted, don't wait for a timeupdate that will never come
if (signal.aborted) {
resolve(undefined);
return;
}
//check once immediately before starting the event listener
if (!cb()) {
videoElement.addEventListener("timeupdate", cb);
signal.addEventListener("abort", onAbort, { once: true });
}
const timer = setInterval(check, BUFFER_POLL_INTERVAL);
videoElement.addEventListener("timeupdate", check, { signal: controller.signal });
videoElement.addEventListener("seeking", check, { signal: controller.signal });
signal.addEventListener("abort", finish, { signal: controller.signal });
//check once immediately, so a loop that already has room never waits an interval
check();
});
}
@@ -376,6 +386,50 @@ const App: Component = () => {
}
}
//drop everything more than `keepBehind` seconds before the playhead. returns false when there
//was nothing worth dropping, so the caller knows to stop retrying instead of spinning.
//minGain is the smallest removal considered worthwhile: the steady-state trim wants slack, or it
//would issue a remove() for a few milliseconds on every single chunk, while a quota retry passes 0
//because any freed byte is worth having
async function evictBehind(keepBehind: number, minGain: number): Promise<boolean> {
const ranges = sourceBuffer.buffered;
if (ranges.length === 0) return false;
const cutoff = videoElement.currentTime - keepBehind;
if (cutoff - ranges.start(0) <= minGain) return false;
await saveUpdate(() => sourceBuffer.remove(ranges.start(0), cutoff));
//remove() is asynchronous, so a caller about to retry an append has to wait it out
await waitUntilUpdateDone();
return true;
}
//what MSE actually enforces is a *byte* budget per SourceBuffer, and it varies by browser,
//codec and bitrate - the time window above is only a proxy for it. so treat QuotaExceededError
//as "make room and retry", which is what the spec intends, rather than as fatal: letting it
//propagate ends the stream early and truncates the track with no user-visible error
async function appendWithEviction(chunk: BufferSource) {
let keepBehind = BUFFER_BEHIND_TARGET;
while (true) {
try {
await saveUpdate(() => sourceBuffer.appendBuffer(chunk));
return;
} catch (e) {
if (!(e instanceof Error && e.name === "QuotaExceededError")) throw e;
//keep tightening the behind-window until a removal actually frees something. a single
//halving is not enough: early in a track the playhead has less than keepBehind seconds
//behind it, so the first cutoff lands before the buffer start and frees nothing even
//though a tighter one still would
let freed = false;
while (!freed && keepBehind > BUFFER_BEHIND_MIN) {
keepBehind = Math.max(BUFFER_BEHIND_MIN, Math.floor(keepBehind / 2));
freed = await evictBehind(keepBehind, 0);
}
//nothing behind the playhead left to give up - the configured bitrate simply needs more
//than the browser's per-SourceBuffer byte budget for BUFFER_AHEAD_TARGET seconds
if (!freed) throw e;
}
}
}
while (true) {
const { done, value } = await reader.read();
if (done || thisMediaSource.readyState !== "open") {
@@ -384,24 +438,24 @@ const App: Component = () => {
break;
}
const CHUNK_SIZE = 1000000; // 1MB chunks
for (let offset = 0; offset < value.length; offset += CHUNK_SIZE) {
const chunk = value.slice(offset, offset + CHUNK_SIZE);
await saveUpdate(() => sourceBuffer.appendBuffer(chunk));
//keep last 20 seconds of buffer
await saveUpdate(() => {
if (sourceBuffer.buffered.length > 0 && videoElement.currentTime - sourceBuffer.buffered.start(0) > 20) {
return sourceBuffer.remove(0, videoElement.currentTime - 20);
}
});
await waitForBufferLimit();
}
//a fetch body never yields SharedArrayBuffer-backed views, so narrowing to ArrayBuffer is safe
await appendWithEviction(value as Uint8Array<ArrayBuffer>);
//trim eagerly as well, so the quota is normally never reached in the first place
await evictBehind(BUFFER_BEHIND_TARGET, BUFFER_BEHIND_SLACK);
await waitForBufferSpace();
}
// Wait for buffer to finish updating
await waitUntilUpdateDone();
} catch (error) {
if (!(error instanceof Error && error.name === "AbortError")) {
if (error instanceof Error && error.name === "QuotaExceededError") {
//endOfStream() below makes what is already buffered play out and then simply stop, which
//is indistinguishable from the track ending - so say why it was cut short
toast(
"Playback was cut short: the browser's media buffer is full, try a lower bitrate in the transcoding settings",
"error",
error,
);
} else if (!(error instanceof Error && error.name === "AbortError")) {
console.error(error);
}
} finally {
@@ -567,22 +621,31 @@ const App: Component = () => {
setPlayerState("buffered", reconcile(timeRangesToArray(videoElement.buffered)));
}
//whether the target can be played straight out of the buffer, or needs a fresh transcode seeked with -ss.
//"buffered" is not the same question as "playable from": a target landing on the trailing edge of a range
//is inside it, yet seeking there stalls with nothing ahead to play and nothing to trigger a re-fetch
function needMSESeek(seekTo: number) {
function isInTimeRanges(timeRanges: TimeRanges, targetTime: number): boolean {
for (let i = 0; i < timeRanges.length; i++) {
if (targetTime >= timeRanges.start(i) && targetTime <= timeRanges.end(i)) {
return true;
}
}
// Special case: if seeking to exactly 0 and the first buffered range starts very close to 0
// (accounting for common MSE container overhead), treat it as within range
if (targetTime === 0 && timeRanges.length > 0 && timeRanges.start(0) < 0.1) {
return true;
}
return false;
if (mediaSource === null) return false;
const ranges = videoElement.buffered;
//while the source is still open the append loop is alive, so a target near the buffer head only has to
//wait for the next chunk. once it is "ended" nothing more will ever arrive - endOfStream() has run, which
//also happens when the stream died early - and seeking to the edge would stall there for good
const willRefill = mediaSource.readyState === "open";
//the track's own duration, not mediaSource.duration: endOfStream() redefines the media end as whatever
//was buffered, so after a stream dies early the two are equal and a truncated track is indistinguishable
//from a complete one - which is exactly the case where a seek has to force a fresh transcode
const trackDuration = playerState.currentTrack?.metadata.duration;
const mediaEnd = trackDuration && Number.isFinite(trackDuration) ? trackDuration : mediaSource.duration;
//RANGE_START_TOLERANCE covers container/frame-alignment slack at a range start - MSE containers do not
//begin at exactly 0, which is why seeking to the very start used to need a special case here
for (let i = 0; i < ranges.length; i++) {
//no headroom demanded where the range already reaches the end of the media either: there is nothing
//further to buffer there, so requiring it would re-transcode the whole track just to seek near the end
const atMediaEnd = Number.isFinite(mediaEnd) && ranges.end(i) >= mediaEnd - SEEK_MIN_AHEAD;
const minAhead = willRefill || atMediaEnd ? 0 : SEEK_MIN_AHEAD;
if (seekTo >= ranges.start(i) - RANGE_START_TOLERANCE && seekTo <= ranges.end(i) - minAhead) return false;
}
return mediaSource !== null && !isInTimeRanges(videoElement.buffered, seekTo);
return true;
}
// Initialize app