aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 06:05:25 -0700
committerAdam Soltys <[email protected]>2026-10-18 06:05:25 -0700
commit4807f138feba04a60292886a196d614e2254810b (patch)
treed9e7ee76fc61381ea9c5916050ed0d9f1553da73
parentb9297719d294779a2ea14c77a67975308bace65c (diff)
downloadcoinosv3-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.js30
-rw-r--r--tools/video-fallback-test.js6
-rw-r--r--tools/video-hls-play-test.js26
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),