Fit the rendition to the panel it plays in
Four panels stutter, and the reason isn't scheduling: a quarter-screen panel was still being handed a full-screen stream. Decoding 1080p into a quarter of the screen costs exactly what decoding it full size costs, and four of those at once is past what most GPUs will decode in hardware -- after which it falls back to software and the wheels come off. So a split panel now caps by its own height in device pixels, rounded up to the next standard rendition, and hls.js is told the same thing through capLevelToPlayerSize since an adaptive stream picks its own. Four panels on a 1080p screen land near 480p each: roughly a quarter of the pixels to decode. Its buffers shrink too -- several instances each holding a minute of video is memory and demuxing for footage nobody has reached. The preloaded step keeps its guarantee but gets cheaper with it: those panes use preload=metadata rather than auto, so every panel still has its next video ready to start instantly without four more streams competing for bandwidth with the four being watched. The floors that make that preload guarantee hold -- one step, in both windowBounds and preloadAhead -- now say so. Both are divided by the pane count, and dropping either below one would leave a panel with nothing buffered to swipe to. Two tests. tests/unit_formats.js runs the rendition maths in node with no browser, server or network, in under a second: picking a format is a list in and a URL out, and it is the cheapest thing in the repo to assert. tests/smoke_reels.py covers the panels themselves -- splitting, nesting, controls staying inside short panes, one swipe advancing every panel, per-panel audio surviving re-activation, and the preload guarantee. What none of this establishes is whether four streams now play smoothly on real hardware. Headless Chromium has no GPU decode, so it cannot say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -316,6 +316,20 @@ App.feed = App.feed || {};
|
||||
slide._sharedControlCleanups = cleanups;
|
||||
};
|
||||
|
||||
// The tallest rendition worth decoding for this pane: its own height in
|
||||
// device pixels, rounded up to the next common rendition so a pane a little
|
||||
// over 360px doesn't get 360p. Returns 0 (no cap) for a single full-screen
|
||||
// pane, which is the old behaviour.
|
||||
const RENDITION_STEPS = [240, 360, 480, 720, 1080, 1440, 2160];
|
||||
|
||||
const paneHeightCap = function(pane) {
|
||||
if (paneCount() <= 1) return 0;
|
||||
const box = pane.getBoundingClientRect();
|
||||
if (!box.height) return 0;
|
||||
const needed = box.height * (window.devicePixelRatio || 1);
|
||||
return RENDITION_STEPS.find((step) => step >= needed) || 0;
|
||||
};
|
||||
|
||||
const loadSlideSource = function(slide, videoData, autoplay) {
|
||||
const video = slide.querySelector('.feed-video');
|
||||
if (!video) return;
|
||||
@@ -346,7 +360,7 @@ App.feed = App.feed || {};
|
||||
|
||||
const resolved = slide._formatOverride
|
||||
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
|
||||
: App.videos.resolveStreamSource(videoData);
|
||||
: App.videos.resolveStreamSource(videoData, { maxHeight: paneHeightCap(slide) });
|
||||
if (!resolved || !resolved.url) {
|
||||
// No playable source -- treat exactly like a load failure so the
|
||||
// clip is dropped from the queue and the next one takes its place.
|
||||
@@ -359,7 +373,11 @@ App.feed = App.feed || {};
|
||||
const isHls = App.videos.classifySource(resolved).isHls;
|
||||
|
||||
video.muted = slide._muted !== false;
|
||||
video.preload = 'auto';
|
||||
// The step being watched buffers properly; the one preloaded behind it
|
||||
// only needs enough to start instantly on the swipe. With four panes
|
||||
// that is the difference between four extra streams downloading and
|
||||
// four holding a few seconds each.
|
||||
video.preload = autoplay ? 'auto' : 'metadata';
|
||||
video._tearingDown = false;
|
||||
applyResume(video, videoData && videoData.id, resolved.isLive);
|
||||
|
||||
@@ -370,7 +388,16 @@ App.feed = App.feed || {};
|
||||
};
|
||||
|
||||
const attachHls = (HlsLib) => {
|
||||
const hls = new HlsLib();
|
||||
const split = paneCount() > 1;
|
||||
const hls = new HlsLib(split ? {
|
||||
// Never fetch a rendition larger than the pane it draws into.
|
||||
capLevelToPlayerSize: true,
|
||||
// Several streams at once, each holding a minute of video, is
|
||||
// memory and demuxing work for footage nobody has reached yet.
|
||||
maxBufferLength: 10,
|
||||
maxMaxBufferLength: 20,
|
||||
backBufferLength: 10
|
||||
} : {});
|
||||
video._hlsPlayer = hls;
|
||||
hls.loadSource(streamUrl);
|
||||
hls.attachMedia(video);
|
||||
@@ -785,6 +812,10 @@ App.feed = App.feed || {};
|
||||
// shifts every rendered slide and lands the reader on the wrong videos.
|
||||
const windowBounds = function(activeIndex) {
|
||||
const per = paneCount();
|
||||
// The floor of one step is not a rounding guard: it is what guarantees
|
||||
// the step after this one always exists, however many panes there are.
|
||||
// Every panel's next video lives in that step, so lowering it below one
|
||||
// would leave a panel with nothing buffered to swipe to.
|
||||
return {
|
||||
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
|
||||
end: Math.min(stepCount() - 1,
|
||||
@@ -857,6 +888,10 @@ App.feed = App.feed || {};
|
||||
|
||||
slidesByIndex.forEach((slide, i) => {
|
||||
if (i === clamped) return;
|
||||
// Same floor, same reason: at least the next step is preloaded, so
|
||||
// every panel has its next video ready before the swipe. It is
|
||||
// preloaded for *all* of that step's panes, which is what makes the
|
||||
// guarantee hold per panel rather than only for the first.
|
||||
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
|
||||
panesOf(slide).forEach((pane) => {
|
||||
if (i > clamped && i <= clamped + preloadAhead) {
|
||||
|
||||
@@ -2003,6 +2003,14 @@ App.videos = App.videos || {};
|
||||
if (applyPreferredQuality) {
|
||||
const preferredQuality = App.storage.getPreferredQuality();
|
||||
preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality);
|
||||
// A caller showing this in a fraction of the screen -- a split reels
|
||||
// panel -- caps it further. Decoding a 1080p stream into a quarter
|
||||
// of a phone screen costs the same as decoding it full size, and
|
||||
// four of those at once is what makes a split view stutter.
|
||||
const maxHeight = App.videos.coerceNumber(options && options.maxHeight);
|
||||
if (maxHeight > 0) {
|
||||
preferredHeight = preferredHeight ? Math.min(preferredHeight, maxHeight) : maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => {
|
||||
|
||||
Reference in New Issue
Block a user