diff options
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/video-player.js | 153 |
1 files changed, 145 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 66099de..0fed88c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -10,13 +10,44 @@ import * as platform from './platform.js'; // Seconds of already-watched video kept in the SourceBuffer, and the queue depth // past which we start making room before being forced to. const BUFFER_BEHIND_S = 60; -// How far past the playhead we are willing to pull. The browser caps a video -// SourceBuffer at a few hundred megabytes and refuses the append that goes -// past, so "as fast as the network allows" is not a strategy for a film: the -// node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the wire, and a -// ten-megabit second fills the ceiling in the first minute. Buffering by time -// rather than by bytes keeps a two-hour film and a two-minute clip alike. +// How far past the playhead we are willing to pull, at the least. The browser +// caps a video SourceBuffer at a few hundred megabytes and refuses the append +// that goes past, so "as fast as the network allows" is not a strategy for a +// film: the node remuxes with `-c copy`, so a 500 MB file puts 500 MB on the +// wire, and a ten-megabit second fills the ceiling in the first minute. +// Buffering by time rather than by bytes keeps a two-hour film and a +// two-minute clip alike. const BUFFER_AHEAD_S = 90; +// …but a bound written in seconds has to be sized for the highest-bitrate file +// in the library, and every ordinary file then holds a fraction of what the +// same browser would have accepted. Measured on harness/mse_harness.mjs +// against a 100 MB ceiling: a 1.0 Mbit/s film reached its ninety seconds on +// 20.7 MB, and a 2.3 Mbit/s one on 45.6 MB — four fifths and a half of that +// ceiling left unused, which is the whole of the margin a phone on a mobile +// network has to ride out a dead spot on. +// +// The quantity the browser limits is BYTES, so that is what is budgeted here +// and converted to seconds with the file's own average bitrate. It can only +// ever RAISE the bound: `BUFFER_AHEAD_S` stays as the floor, so a file whose +// bitrate spends the budget in under ninety seconds keeps exactly the bound it +// had before and nothing that worked pulls less than it used to. +// +// **The budget is discovered, not declared.** The real ceiling is per-engine +// and per-device and cannot be known up front, and there are only two ways to +// find it: overshoot it, or walk up to it. Overshooting costs a refused append +// every time it is attempted — a flat 48 MB budget against a 40 MB ceiling was +// measured here at 1386 refusals on a film that had none before it, which is a +// regression however well the player recovers from each one. So the budget +// starts at zero, which `aheadLimit()` reads as the fixed bound that shipped +// before, and thereafter is never more than one step past the most read-ahead +// this SourceBuffer has actually been seen to accept. +const BUFFER_AHEAD_STEP_BYTES = 4 * 1024 * 1024; +// Where the walk stops even if nothing ever refuses. Memory a phone has to +// find, and past this the gain is a read-ahead nobody needs. +const BUFFER_AHEAD_MAX_BYTES = 48 * 1024 * 1024; +// A sanity bound on what the budget may convert to. A file whose size or +// duration is wrong must not be able to ask for the whole film. +const BUFFER_AHEAD_MAX_S = 600; // While we deliberately hold credit back, the node must still hear from us: its // own stall timeout is two minutes, and a paused film is not a gone viewer. const CREDIT_KEEPALIVE_MS = 20000; @@ -285,6 +316,17 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const appendingRef = useRef(false); const endedRef = useRef(false); const durationRef = useRef(0); + // Bytes of film per second of film, the byte budget the read-ahead is + // currently allowed, and the ceiling that budget may grow to. Zero bitrate + // means "not known yet" — before `stream_init` there is no duration — and a + // zero budget means "nothing learnt yet"; `aheadLimit()` reads either as the + // fixed `BUFFER_AHEAD_S` bound, which is what the player always had. + const bitrateRef = useRef(0); + const aheadBytesRef = useRef(0); + const aheadCapRef = useRef(BUFFER_AHEAD_MAX_BYTES); + // True while an append is known to be refusable and nothing has freed room + // since. See `flushQueue`. + const quotaHoldRef = useRef(false); // Segments the node is allowed to have in flight but has not sent yet, and // when we last said anything to it at all. const outstandingRef = useRef(0); @@ -414,6 +456,9 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { const start = range ? range[0] : sb.buffered.start(0); if (keepFrom - start < 10) return false; sb.remove(start, keepFrom); + // Room is about to appear, which is the one event that makes an append + // refused for quota worth trying again — see `flushQueue`. + quotaHoldRef.current = false; return true; } catch { return false; @@ -428,6 +473,21 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { return Math.max(0, range[1] - v.currentTime); }, [currentRange]); + /** + * Seconds past the playhead this film may be pulled to, right now. + * + * The byte budget is what the browser actually limits; the seconds are what + * the player can compare `bufferedAhead()` against. `BUFFER_AHEAD_S` is the + * floor and never the ceiling — see the constants — so this only ever gives + * a file more than the fixed bound did, never less. + */ + const aheadLimit = useCallback(() => { + const rate = bitrateRef.current; + if (!(rate > 0)) return BUFFER_AHEAD_S; + return Math.max(BUFFER_AHEAD_S, + Math.min(BUFFER_AHEAD_MAX_S, aheadBytesRef.current / rate)); + }, []); + const flushQueue = useCallback(() => { const sb = sbRef.current; if (!sb || appendingRef.current || sb.updating) return; @@ -437,6 +497,17 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } return; } + // An append refused for quota that could evict nothing will be refused + // again until something frees room, and pump() calls this on a clock: the + // retry cost a refused append several times a second for as long as it + // took the film to play far enough for eviction to have anything to drop — + // minutes of it, measured on the harness. `evictBehind` is the only thing + // that ever gives a SourceBuffer room back, so its success is what lifts + // this rather than a timer or the playhead, which moves long before the + // answer changes. It cannot deadlock: a buffer is full of film the + // playhead has not reached, so playback always advances into it and + // eviction always comes. + if (quotaHoldRef.current) return; appendingRef.current = true; const chunk = queueRef.current[0]; try { @@ -446,10 +517,21 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { appendingRef.current = false; if (e.name === 'QuotaExceededError') { quotaRef.current += 1; + // This refusal is the browser stating where its own ceiling is — the + // one number the budget could not be given up front. Take the ceiling + // down below where it broke and leave it there, rather than backing + // off and letting the walk in `pump()` climb to the same place again: + // that sawtooth is a refused append every time round. `aheadLimit()` + // floors at `BUFFER_AHEAD_S`, so the worst this converges to is the + // fixed bound that shipped before. + aheadCapRef.current = Math.max( + 0, aheadBytesRef.current - BUFFER_AHEAD_STEP_BYTES); + aheadBytesRef.current = aheadCapRef.current; // The segment stays at the head of the queue and is tried again once // there is room. Dropping it — which is what this used to do — leaves a // hole in the middle of the film and no error anywhere. if (!evictBehind()) { + quotaHoldRef.current = true; console.warn('[MSE] buffer full and nothing to evict yet'); } return; @@ -494,7 +576,32 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { if (endedRef.current && queueRef.current.length === 0) return; if (!transport || !transport.connected) return; - if (bufferedAhead() > BUFFER_AHEAD_S + // Walk the budget up to one step past the most read-ahead this + // SourceBuffer has been seen to hold without refusing an append — never + // further, so an engine with a small ceiling is overshot by one step + // instead of by the whole budget, and only once, because the refusal takes + // `aheadCapRef` down below where it broke. Read-ahead rather than total + // occupancy, because read-ahead is what this bound governs and what + // eviction leaves alone. + // + // Only while the film is actually playing. What the budget buys is a film + // that survives a dead spot, and a film nobody has started cannot be + // interrupted: growing it there filled a small browser's whole buffer for + // a film left on the loading screen — 40 MB and nine minutes of read-ahead + // where the fixed bound took 8 MB — on a mobile connection paying for it. + // Autoplay is blocked on a phone more often than not, so that is the + // ordinary case and not a corner. Pausing does not *shrink* the budget: + // the lead already paid for is the viewer's. + const vp = videoRef.current; + const rate = (vp && !vp.paused) ? bitrateRef.current : 0; + if (rate > 0) { + aheadBytesRef.current = Math.min( + aheadCapRef.current, + Math.max(aheadBytesRef.current, + bufferedAhead() * rate + BUFFER_AHEAD_STEP_BYTES)); + } + + if (bufferedAhead() > aheadLimit() || queueRef.current.length > QUEUE_HIGH_WATER) { // Far enough ahead. Grant nothing, but do not go silent: two minutes of // silence is how the node decides nobody is watching, and pausing a film @@ -517,7 +624,7 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { lastPokeRef.current = Date.now(); transport.grantStreamCredit(room); } - }, [evictBehind, flushQueue, bufferedAhead]); + }, [evictBehind, flushQueue, bufferedAhead, aheadLimit]); useEffect(() => { let cancelled = false; @@ -538,6 +645,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { lastPokeRef.current = Date.now(); quotaRef.current = 0; stalledRef.current = false; + // The same rule: a new stream starts from a known state. Both are per-film + // — the bitrate obviously, and the budget because what a browser refused + // for a nine-megabit film says nothing about the next one. A budget left + // at the floor by the film before would silently cap every film after it + // for the life of the page. + bitrateRef.current = 0; + aheadBytesRef.current = 0; + aheadCapRef.current = BUFFER_AHEAD_MAX_BYTES; + quotaHoldRef.current = false; // The same shape again, and the seek refs are worse than the others. // Switching film while a seek was in flight leaves `awaitingInit` true, // and only reinitAt() ever lowers it — which the next film does not go @@ -683,6 +799,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { appendingRef.current = false; endedRef.current = false; quotaRef.current = 0; + // The buffer this was holding against has just been emptied, so there is + // room again — and a seek *backwards* would otherwise put the playhead + // below the held position and stop every append for the rest of the + // film. The budget is deliberately kept: what this browser refused is + // still true of it after a seek. + quotaHoldRef.current = false; awaitingInitRef.current = false; seekTargetRef.current = start; console.log('[seek] reinitAt done, start:', start, 'outstanding:', outstandingRef.current, 'queue:', queueRef.current.length); @@ -772,6 +894,15 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { } durationRef.current = msg.duration || 0; + // What the read-ahead budget is spent at. The file's own average, not + // the stream's: on the copy path they are the same thing, and where + // the node re-encodes it sends fewer bytes per second than the source + // holds — so this errs towards buffering less than the budget allows, + // never more. A film whose size or duration is missing keeps the fixed + // bound, which is what `aheadLimit()` does with a zero here. + bitrateRef.current = (entry.size > 0 && durationRef.current > 0) + ? entry.size / durationRef.current + : 0; // Recorded before either branch below: both restart the relay, and the // subtitle sent with it has to be shifted by *this* start, not the one // the previous stream had. @@ -1071,6 +1202,12 @@ function VideoPlayer({ entry, transportRef, gekRef, onClose, onDownload }) { t.sendStreamDiag({ t: +v.currentTime.toFixed(1), ahead: +bufferedAhead().toFixed(1), + // What the read-ahead is allowed to be for this film, and the budget + // it came from. `ahead` on its own cannot say whether a short buffer + // is the gate holding or the network not keeping up, and the node's + // log is the only window there is into a phone. + limit: +aheadLimit().toFixed(0), + budgetMB: +(aheadBytesRef.current / 1048576).toFixed(1), ranges: ranges.trim(), ready: v.readyState, // 0 = nothing, 4 = enough to play through paused: v.paused, |