diff options
| author | Adam Soltys <[email protected]> | 2026-10-18 06:05:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-18 06:05:25 -0700 |
| commit | 4807f138feba04a60292886a196d614e2254810b (patch) | |
| tree | d9e7ee76fc61381ea9c5916050ed0d9f1553da73 | |
| parent | b9297719d294779a2ea14c77a67975308bace65c (diff) | |
| download | coinosv3-4807f138feba04a60292886a196d614e2254810b.tar.gz coinosv3-4807f138feba04a60292886a196d614e2254810b.zip | |
Clips too big to play as they are (past 1080p, over the data budget, unmeasurable) stream as HLS from vmedia and play by themselves instead of waiting behind a tap-to-play cover; with Data Saver or a slow link the cover stays.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
| -rw-r--r-- | src/features/messages.js | 30 | ||||
| -rw-r--r-- | tools/video-fallback-test.js | 6 | ||||
| -rw-r--r-- | tools/video-hls-play-test.js | 26 |
3 files changed, 47 insertions, 15 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 693c12f..66bee68 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6728,17 +6728,28 @@ export function messagesFeature(ctx) { // Data Saver on, or a slow link: the connection to be careful with const frugalData = () => { try { const c = navigator.connection; return !!(c && (c.saveData || /(^|-)2g$|^3g$/.test(c.effectiveType || ''))); } catch { return false; } }; const tappedClips = new Set(); // asked for by hand this session: plays, whatever it costs - function videoHeavy(url) { - if (tappedClips.has(url) || largeVideosOk()) return false; + // A clip too big to play as it is: over the budget, past 1080p, its index + // at the end of a big file — or (known) unmeasurable. Such a clip plays as + // an HLS stream where it can (playSrc); else it waits for a tap. + function bigClip(url, known = false) { const d = videoInfo.get(url) || {}; - if (d.stream) return false; // a stream takes the rendition the connection carries if (d.w && d.h && Math.min(d.w, d.h) > 1080) return true; - if (!d.size) return true; // nothing known: don't spend data blind + if (!d.size) return !known || d.probed || probeFailedLately(d); // nothing known: don't spend data blind if (d.size > videoBudget()) return true; // the whole file before the first frame: only worth it for a small one if (d.probed && !d.faststart && !d.ranges && d.size > 8e6) return true; return false; } + function videoHeavy(url) { + if (tappedClips.has(url) || largeVideosOk()) return false; + const d = videoInfo.get(url) || {}; + if (d.stream) return false; // a stream takes the rendition the connection carries + // one that streams costs what the connection carries: it plays like any + // other (Adam, 2026-10-18) — but not over Data Saver or a slow link, + // where even a stream is the reader's call + if (streamsHere(url) && !frugalData()) return false; + return bigClip(url); + } const fmtBytes = (n) => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : n >= 1e6 ? Math.round(n / 1e6) + ' MB' : Math.max(1, Math.round(n / 1e3)) + ' KB'; function heavyLabel(url) { const d = videoInfo.get(url) || {}; @@ -7886,10 +7897,15 @@ export function messagesFeature(ctx) { try { return !!document.createElement('video').canPlayType('application/vnd.apple.mpegurl'); } catch { return false; } })(); const longClip = (url) => { const d = videoInfo.get(url) || {}; return d.dur ? d.dur >= HLS_MIN_S : (d.size || 0) >= HLS_MIN_BYTES; }; - // what a player for this clip loads: the stream for a long one; the copy, - // once the original is known not to show here + // Long, or too big to play as it is (a 4K clip, a 140 MB one: these + // waited for a tap with their cost on them) — streamed at a rendition the + // connection carries instead. A small short clip plays as it is: one + // request, its own quality, no work for the server. + const streamsHere = (url) => vmediaOk(url) && !isHls(url) && canHls() && !hlsFailed.has(url) && (longClip(url) || bigClip(url, true)); + // what a player for this clip loads: the stream for a long or big one; + // the copy, once the original is known not to show here const playSrc = (url) => !vmediaOk(url) || isHls(url) ? url - : longClip(url) && canHls() && !hlsFailed.has(url) ? hlsOf(url) + : streamsHere(url) ? hlsOf(url) : !h264Failed.has(url) && (noPicture.has(url) || ((videoInfo.get(url) || {}).hevc && !canHevc())) ? h264Of(url) : url; // A player whose clip turns out to have no picture it can show (its // metadata says zero width) switches to the copy; if the copy fails too, diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js index 85250c0..93726e1 100644 --- a/tools/video-fallback-test.js +++ b/tools/video-fallback-test.js @@ -37,6 +37,12 @@ const server = Bun.serve({ port: 0, fetch: (req) => { } }); const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); const page = await browser.newPage(); +// a browser without HLS of its own (Firefox): a clip too big to play as it +// is waits for a tap here, rather than streaming (video-hls-play-test) +await page.evaluateOnNewDocument(() => { + const can = HTMLMediaElement.prototype.canPlayType; + HTMLMediaElement.prototype.canPlayType = function (t) { return /mpegurl/i.test(t) ? '' : can.call(this, t); }; +}); await page.setRequestInterception(true); const asked = []; // [kind, clip url] page.on('request', (r) => { diff --git a/tools/video-hls-play-test.js b/tools/video-hls-play-test.js index 5bf6809..f1aea1c 100644 --- a/tools/video-hls-play-test.js +++ b/tools/video-hls-play-test.js @@ -1,5 +1,6 @@ // A long clip in the feed plays as HLS from vmedia (a rendition the -// connection carries, in pieces made as watched), a short one as it is, +// connection carries, in pieces made as watched), and so does one too big +// to play as it is (past 1080p: it used to wait for a tap); a short one as it is, // and a post linking an .m3u8 stream plays that. vmedia runs here for real, // on a local copy of the clip. // Run: bun tools/video-hls-play-test.js @@ -22,9 +23,13 @@ const SHORT = ff(DIR + '/coinos-hls-short.mp4', ['-c:v', 'libx264']); Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440', '-t', '100', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-long.mp4']); const LONG = readFileSync(DIR + '/coinos-hls-long.mp4'); +// short but past 1080p: it used to wait for a tap with its cost on it +Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=1440x1440:rate=10', '-f', 'lavfi', '-i', 'sine=frequency=440', + '-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart', DIR + '/coinos-hls-big.mp4']); +const BIG = readFileSync(DIR + '/coinos-hls-big.mp4'); // vmedia itself, reading the clip from here -const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response(LONG) }); -const LOCAL_SRC = `http://127.0.0.1:${files.port}/long.mp4`; +const files = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: (req) => new Response(new URL(req.url).pathname === '/big.mp4' ? BIG : LONG) }); +const local = (src) => src.replace('https://media.test/', `http://127.0.0.1:${files.port}/`); process.env.VMEDIA_LOCAL = '1'; process.env.DATA = DIR + '/coinos-hls-vmedia'; const vm = await import('../vmedia/server.js'); @@ -34,8 +39,8 @@ const JPEG = Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); const now = Math.floor(Date.now() / 1000); const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); -const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8' }; -const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300)]; +const CLIP = { long: 'https://media.test/long.mp4', short: 'https://media.test/short.mp4', stream: 'https://media.test/show/master.m3u8', big: 'https://media.test/big.mp4' }; +const seeded = [post('lecture ' + CLIP.long, 100), post('quick ' + CLIP.short, 200), post('episode ' + CLIP.stream, 300), post('square ' + CLIP.big, 400)]; const html = await buildHtml({ minify: true, pwa: false }); const server = Bun.serve({ port: 0, fetch: (req) => { @@ -49,7 +54,7 @@ await page.setRequestInterception(true); const asked = []; // [kind, clip url] // vmedia's answers, from the real thing; the clip's host serves the files const vmedia = async (u) => { - if (u.pathname === '/vmedia/hls.m3u8' && u.searchParams.get('u') === CLIP.long) u.searchParams.set('u', LOCAL_SRC); + if (u.pathname === '/vmedia/hls.m3u8') u.searchParams.set('u', local(u.searchParams.get('u'))); const res = await vm.handle(new Request('http://127.0.0.1' + u.pathname + u.search)); return { status: res.status, contentType: res.headers.get('content-type') || 'text/plain', body: Buffer.from(await res.arrayBuffer()), headers: { 'access-control-allow-origin': '*' } }; }; @@ -64,7 +69,7 @@ page.on('request', async (r) => { asked.push([r.resourceType(), r.url()]); // the stream a post links: vmedia's of the long clip, served from its host if (u.pathname === '/show/master.m3u8') { const x = new URL('https://v3.coinos.io/vmedia/hls.m3u8'); x.searchParams.set('u', CLIP.long); return r.respond(await vmedia(x)); } - return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : SHORT, headers: { 'access-control-allow-origin': '*' } }); + return r.respond({ status: 200, contentType: 'video/mp4', body: u.pathname === '/long.mp4' ? LONG : u.pathname === '/big.mp4' ? BIG : SHORT, headers: { 'access-control-allow-origin': '*' } }); } r.continue(); }); @@ -99,7 +104,7 @@ try { await page.reload({ waitUntil: 'domcontentloaded' }); await waitText('receive', 20000); await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); - check('the three clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 3, null, 20000), + check('the four clips are in the feed', await waitFor(() => document.querySelectorAll('.note-video-box video').length >= 4, null, 20000), String(await page.evaluate(() => document.querySelectorAll('.note-video-box video').length))); console.log('\n[a long clip]'); @@ -108,6 +113,11 @@ try { JSON.stringify(await player('lecture'))); check('...in its pieces', asked.some(([k, p]) => k === 'hls' && /\.ts$/.test(p)), JSON.stringify(asked.filter(([k]) => k === 'hls').slice(0, 4))); + console.log('\n[a short clip past 1080p]'); + await page.evaluate(new Function(`${box('square')}.scrollIntoView({ block: 'center' })`)); + check('plays by itself as HLS, no tap-to-play cover', await waitFor(new Function(`const b = ${box('square')}, v = b?.querySelector('video'); return v && v.getAttribute('src').includes('/vmedia/hls.m3u8') && v.videoWidth > 0 && v.currentTime > 0.3 && !b.querySelector('.vid-heavy');`), null, 20000), + JSON.stringify(await player('square'))); + console.log('\n[a short clip]'); await show('quick'); check('plays the original', await waitFor(new Function(`const v = ${box('quick')}?.querySelector('video'); return v && v.getAttribute('src') === '${CLIP.short}' && v.videoWidth > 0;`), null, 15000), |
