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:
Simon
2026-09-10 18:47:36 +00:00
parent f0df53365d
commit 764e3416a3
4 changed files with 349 additions and 3 deletions

View File

@@ -316,6 +316,20 @@ App.feed = App.feed || {};
slide._sharedControlCleanups = cleanups; 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 loadSlideSource = function(slide, videoData, autoplay) {
const video = slide.querySelector('.feed-video'); const video = slide.querySelector('.feed-video');
if (!video) return; if (!video) return;
@@ -346,7 +360,7 @@ App.feed = App.feed || {};
const resolved = slide._formatOverride const resolved = slide._formatOverride
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride) ? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
: App.videos.resolveStreamSource(videoData); : App.videos.resolveStreamSource(videoData, { maxHeight: paneHeightCap(slide) });
if (!resolved || !resolved.url) { if (!resolved || !resolved.url) {
// No playable source -- treat exactly like a load failure so the // No playable source -- treat exactly like a load failure so the
// clip is dropped from the queue and the next one takes its place. // 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; const isHls = App.videos.classifySource(resolved).isHls;
video.muted = slide._muted !== false; 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; video._tearingDown = false;
applyResume(video, videoData && videoData.id, resolved.isLive); applyResume(video, videoData && videoData.id, resolved.isLive);
@@ -370,7 +388,16 @@ App.feed = App.feed || {};
}; };
const attachHls = (HlsLib) => { 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; video._hlsPlayer = hls;
hls.loadSource(streamUrl); hls.loadSource(streamUrl);
hls.attachMedia(video); hls.attachMedia(video);
@@ -785,6 +812,10 @@ App.feed = App.feed || {};
// shifts every rendered slide and lands the reader on the wrong videos. // shifts every rendered slide and lands the reader on the wrong videos.
const windowBounds = function(activeIndex) { const windowBounds = function(activeIndex) {
const per = paneCount(); 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 { return {
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))), start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
end: Math.min(stepCount() - 1, end: Math.min(stepCount() - 1,
@@ -857,6 +888,10 @@ App.feed = App.feed || {};
slidesByIndex.forEach((slide, i) => { slidesByIndex.forEach((slide, i) => {
if (i === clamped) return; 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())); const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
panesOf(slide).forEach((pane) => { panesOf(slide).forEach((pane) => {
if (i > clamped && i <= clamped + preloadAhead) { if (i > clamped && i <= clamped + preloadAhead) {

View File

@@ -2003,6 +2003,14 @@ App.videos = App.videos || {};
if (applyPreferredQuality) { if (applyPreferredQuality) {
const preferredQuality = App.storage.getPreferredQuality(); const preferredQuality = App.storage.getPreferredQuality();
preferredHeight = preferredQuality === 'auto' ? null : App.videos.coerceNumber(preferredQuality); 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) => { const sources = App.videos.rankFormats(meta.formats, preferredHeight).map((fmt) => {

176
tests/smoke_reels.py Executable file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Reels split-panel smoke tests.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_reels.py
The load-bearing check is the preload guarantee: every panel must have its
next video buffered before the reader swipes. With locked scrolling that is
the same panel position in the next step, so it holds only while the next
step is both built and preloaded for all of its panes -- which in turn rests
on the floor of one step in windowBounds() and in setActive()'s preloadAhead.
Both shrink as panes are added, and without the floor a wide split would
leave panels with nothing to swipe to.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
LAYOUT = """() => {
const slide = document.querySelector('.feed-slide.is-active');
if (!slide) return null;
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
const bounds = slide.getBoundingClientRect();
return {
count: panes.length,
reported: App.feed.paneCount(),
videos: panes.map(p => p.dataset.videoId),
// Controls positioned for a full viewport end up outside a short pane,
// clipped by its overflow and unreachable.
escaping: panes.reduce((bad, p, i) => {
const pr = p.getBoundingClientRect();
['.feed-fav-btn', '.feed-pip-btn', '.feed-format-btn', '.feed-pane-tools']
.forEach((sel) => {
const el = p.querySelector(sel);
if (!el || el.hidden) return;
const r = el.getBoundingClientRect();
if (r.top < pr.top - 1 || r.bottom > pr.bottom + 1 ||
r.left < pr.left - 1 || r.right > pr.right + 1) bad.push(i + sel);
});
return bad;
}, []),
within_slide: panes.every(p => {
const r = p.getBoundingClientRect();
return r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1;
}),
};
}"""
PRELOAD = """() => {
const active = App.state.feedActiveIndex;
const rows = {};
document.querySelectorAll('.feed-slide').forEach((slide) => {
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
rows[Number(slide.dataset.step) - active] = panes.map((p) => {
const v = p.querySelector('.feed-video');
return !!(v && (v.getAttribute('src') || v._hlsPlayer));
});
});
return { per: App.feed.paneCount(), next: rows[1] || null, current: rows[0] || null };
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
if not condition:
self.failed += 1
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
+ (f" -- {detail}" if detail and not condition else ""))
def open_reels(page):
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
try:
page.wait_for_selector(".video-card", timeout=90000)
except Exception:
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(4000)
page.evaluate("() => App.feed.toggle()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(3000)
def split(page, selector):
page.click(".feed-slide.is-active " + selector)
page.wait_for_timeout(4000)
def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
"--renderer-process-limit=1",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
open_reels(page)
print("\nsingle panel")
one = page.evaluate(LAYOUT)
c.ok("reels opens with one panel", one and one["count"] == 1, str(one))
print("\nsplit right, then split the new panel below")
split(page, ".feed-pane .feed-pane-split-right")
split(page, ".feed-pane:last-of-type .feed-pane-split-down")
three = page.evaluate(LAYOUT)
c.ok("three panels after two splits", three["count"] == 3, str(three["count"]))
c.ok("paneCount agrees with the DOM", three["reported"] == three["count"])
c.ok("every panel shows a different video",
len(set(three["videos"])) == len(three["videos"]), str(three["videos"]))
c.ok("panels fit inside the slide", three["within_slide"])
c.ok("no control escapes its panel", not three["escaping"], str(three["escaping"]))
print("\npreload")
pre = page.evaluate(PRELOAD)
c.ok("the next step exists", pre["next"] is not None)
c.ok("every panel of the current step is loaded", pre["current"] and all(pre["current"]),
str(pre["current"]))
# The point of the exercise: nobody should swipe into an empty panel.
c.ok("every panel has its next video preloaded",
pre["next"] is not None and all(pre["next"]) and len(pre["next"]) == pre["per"],
str(pre["next"]))
print("\none swipe advances every panel")
before = page.evaluate(LAYOUT)["videos"]
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
" s.scrollTop += s.clientHeight; }")
page.wait_for_timeout(3500)
after = page.evaluate(LAYOUT)["videos"]
c.ok("all panels moved on", all(v not in before for v in after if v),
f"{before} -> {after}")
c.ok("still preloaded after the swipe",
all(page.evaluate(PRELOAD)["next"] or [False]))
print("\nper-panel audio")
page.click(".feed-slide.is-active .feed-pane:first-of-type .feed-pane-mute")
page.wait_for_timeout(1200)
page.evaluate("() => App.feed.renderSlides()") # re-activate the step
page.wait_for_timeout(1500)
muted = page.evaluate("""() => Array.from(
document.querySelectorAll('.feed-slide.is-active .feed-pane .feed-video')
).map(v => v.muted)""")
c.ok("only the unmuted panel has sound", muted and muted[0] is False
and all(muted[1:]), str(muted))
print("\nclose a panel")
page.click(".feed-slide.is-active .feed-pane .feed-pane-close")
page.wait_for_timeout(3500)
closed = page.evaluate(LAYOUT)
c.ok("closing collapses the split", closed["count"] == 2, str(closed["count"]))
c.ok("survivors still fit the slide", closed["within_slide"])
browser.close()
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
if __name__ == "__main__":
sys.exit(main())

127
tests/unit_formats.js Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
/* Format selection, tested without a browser.
*
* node tests/unit_formats.js
*
* Picking a rendition is pure: a list of formats in, one URL out. That makes it
* the one part of playback that can be checked in a second, with no server, no
* Chromium and no network -- which matters, because the height cap a split
* reels panel applies is the difference between decoding four 1080p streams and
* four 480p ones.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
// Enough of a browser for videos.js to finish loading. It builds a few
// IntersectionObservers and reads matchMedia at module scope; nothing below
// touches the DOM.
const noop = () => {};
const element = () => ({
style: { setProperty: noop, removeProperty: noop },
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
dataset: {},
querySelector: () => null,
querySelectorAll: () => [],
addEventListener: noop,
removeEventListener: noop,
appendChild: noop,
removeChild: noop,
remove: noop,
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
setAttribute: noop,
removeAttribute: noop,
getAttribute: () => null,
cloneNode: element,
content: { firstElementChild: { cloneNode: element } },
children: [],
childElementCount: 0,
});
const sandbox = {
console,
setTimeout,
clearTimeout,
URL,
Image: function () { return element(); },
IntersectionObserver: function () {
return { observe: noop, unobserve: noop, disconnect: noop };
},
requestAnimationFrame: noop,
requestIdleCallback: noop,
performance: { now: () => 0 },
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
};
sandbox.addEventListener = noop;
sandbox.removeEventListener = noop;
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
sandbox.document = {
getElementById: () => null,
createElement: element,
querySelectorAll: () => [],
addEventListener: noop,
documentElement: element(),
body: element(),
head: element(),
};
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
sandbox.window.location = { href: 'http://localhost/' };
const context = vm.createContext(sandbox);
const load = (file) => vm.runInContext(
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', file), 'utf8'), context, file);
// videos.js reaches for these siblings when a card is built; none of the
// functions under test do.
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
load('videos.js');
const { rankFormats, resolveStreamSource, resolveStreamSources } = sandbox.App.videos;
let failed = 0;
const ok = (label, cond, detail) => {
if (!cond) failed++;
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
};
const formats = [
{ url: 'u240', height: 240, vcodec: 'avc1' },
{ url: 'u480', height: 480, vcodec: 'avc1' },
{ url: 'u720', height: 720, vcodec: 'avc1' },
{ url: 'u1080', height: 1080, vcodec: 'avc1' },
];
const video = { id: 'v1', url: 'https://example.com/watch', meta: { formats: formats } };
const heightOf = (src) => (formats.find((f) => f.url === src.url) || {}).height;
console.log('\nranking');
ok('no ceiling takes the best', rankFormats(formats, null)[0].height === 1080);
ok('a ceiling takes the best at or below it', rankFormats(formats, 720)[0].height === 720);
ok('an exact ceiling is allowed', rankFormats(formats, 480)[0].height === 480);
ok('below every rendition still returns one', rankFormats(formats, 100)[0].height === 240,
String(rankFormats(formats, 100)[0].height));
ok('everything stays reachable as fallback', rankFormats(formats, 480).length === formats.length);
console.log('\nthe cap a split panel applies');
sandbox.App.storage.getPreferredQuality = () => 'auto';
ok('uncapped panel gets the best', heightOf(resolveStreamSource(video)) === 1080);
ok('a quarter-screen panel gets a quarter-screen rendition',
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
ok('the cap survives into the fallback order',
heightOf(resolveStreamSources(video, { maxHeight: 480 })[0]) === 480);
console.log('\nthe cap and the quality preference are both ceilings');
sandbox.App.storage.getPreferredQuality = () => '720';
ok('preference alone caps at 720', heightOf(resolveStreamSource(video)) === 720);
ok('the tighter of the two wins (panel)',
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
sandbox.App.storage.getPreferredQuality = () => '480';
ok('the tighter of the two wins (preference)',
heightOf(resolveStreamSource(video, { maxHeight: 720 })) === 480);
ok('a cap never raises the preference',
heightOf(resolveStreamSource(video, { maxHeight: 2160 })) === 480);
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
process.exit(failed ? 1 : 0);