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
128 lines
5.0 KiB
JavaScript
Executable File
128 lines
5.0 KiB
JavaScript
Executable File
#!/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);
|