From f264cf4679d58d965215bd0081a77cdaccc803f0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 9 Oct 2026 13:53:56 -0700 Subject: Video stills: past a black opening. vmedia took its still at half a second, and a clip that fades in from black (7 of the 68 made in three hours) gave a black tile. Each frame's mean brightness now comes out of ffmpeg (signalstats YAVG on pipe:2 — /dev/stderr can't be opened from a Bun-spawned pipe), and a dark one sends it further in (2, 5, 10, 20, 40 s) for the first bright frame; a clip dark all the way through (a night scene, a boot screen) gets its brightest frame with the levels stretched (normalize). Stills are made again under a new key and URL (v=4), so neither the server's nor the CDN's black copies are served. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3 --- src/features/messages.js | 3 ++- tools/video-fallback-test.js | 2 +- vmedia/server.js | 36 +++++++++++++++++++++++++++++------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/features/messages.js b/src/features/messages.js index ed4136a..6245c60 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7178,7 +7178,8 @@ export function messagesFeature(ctx) { // for Linux plays its sound over black. const VMEDIA = APP_BASE + '/vmedia/'; const vmediaOk = (url) => /^https:\/\//i.test(url || ''); - const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?u=' + encodeURIComponent(url) : undefined); + // v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served) + const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined); const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) diff --git a/tools/video-fallback-test.js b/tools/video-fallback-test.js index 66becc2..85250c0 100644 --- a/tools/video-fallback-test.js +++ b/tools/video-fallback-test.js @@ -93,7 +93,7 @@ try { console.log('\n[no poster in the post]'); const p0 = await player('garden'); - check('the server\'s still is the poster', p0.poster === 'https://v3.coinos.io/vmedia/thumb.jpg?u=' + encodeURIComponent(CLIP.saved), p0.poster); + check('the server\'s still is the poster', p0.poster === 'https://v3.coinos.io/vmedia/thumb.jpg?v=4&u=' + encodeURIComponent(CLIP.saved), p0.poster); console.log('\n[waiting for a tap]'); await page.evaluate(new Function(`${box('garden')}.scrollIntoView({ block: 'center' })`)); diff --git a/vmedia/server.js b/vmedia/server.js index bf92c6b..c543d6c 100644 --- a/vmedia/server.js +++ b/vmedia/server.js @@ -59,8 +59,11 @@ export async function sourceOk(raw, resolve = (h) => lookup(h, { all: true })) { const NET = ['-protocol_whitelist', 'https,tls,tcp,crypto', '-rw_timeout', '15000000', '-user_agent', 'coinos-vmedia']; // longest side at most 1280, even numbers for the encoder export const SCALE_720 = "scale='trunc(min(1,1280/max(iw,ih))*iw/2)*2':'trunc(min(1,1280/max(iw,ih))*ih/2)*2'"; -export const thumbArgs = (src, at) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src, - '-frames:v', '1', '-vf', "scale='min(480,iw)':-2", '-q:v', '5', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']; +export const thumbArgs = (src, at, lift = false) => ['-hide_banner', '-loglevel', 'error', ...NET, ...(at ? ['-ss', String(at)] : []), '-i', src, + // the frame's mean brightness rides out on stderr (YAVG, 0–255): a clip + // that fades in from black has a black frame at half a second + '-frames:v', '1', '-vf', "scale='min(480,iw)':-2," + (lift ? 'normalize=strength=0.7:smoothing=0,' : '') + "signalstats,metadata=mode=print:key=lavfi.signalstats.YAVG:file='pipe\\:2'", + '-q:v', '5', '-f', 'image2', '-c:v', 'mjpeg', 'pipe:1']; export const clipArgs = (src, out) => ['-hide_banner', '-loglevel', 'error', '-y', ...NET, '-i', src, '-map', '0:v:0', '-map', '0:a:0?', '-t', String(MAX_SECONDS), '-vf', SCALE_720, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '26', '-profile:v', 'high', '-pix_fmt', 'yuv420p', '-threads', '4', @@ -90,18 +93,37 @@ function limiter(n) { // ---- thumbnails ----------------------------------------------------------- const thumbSlots = limiter(THUMB_JOBS); const thumbJobs = new Map(); +// Half a second in, unless that frame is (nearly) black — an intro fading +// in, a title card on black — then further in, keeping the first frame bright +// enough to show what the clip is. A clip shorter than a given point simply +// yields nothing there. A clip dark all the way through (a night scene) gets +// its brightest frame with the levels stretched, so the tile shows what's in +// it rather than a black square. YAVG is limited-range: black is 16. +const DARK = 28; async function makeThumb(src, key) { const out = `${THUMBS}/${key}.jpg`; - for (const at of [0.5, 0]) { // a clip shorter than half a second has only its start - const { p, done } = run(thumbArgs(src, at), 25_000); - const [buf] = await Promise.all([new Response(p.stdout).arrayBuffer(), done]); - if (p.exitCode === 0 && buf.byteLength > 500) { await Bun.write(out, buf); return out; } + let best = null; + const grab = async (at, lift) => { + const { p, done } = run(thumbArgs(src, at, lift), 25_000); + const [buf, err] = await Promise.all([new Response(p.stdout).arrayBuffer(), new Response(p.stderr).text(), done]); + if (p.exitCode !== 0 || buf.byteLength <= 500) return null; + return { buf, at, y: Number((/YAVG=([\d.]+)/.exec(err) || [])[1] ?? 255) }; + }; + for (const at of [0.5, 2, 5, 10, 20, 40, 0]) { + if (at === 0 && best) break; // the very start only when nothing later worked + const got = await grab(at, false); + if (!got) continue; + if (!best || got.y > best.y) best = got; + if (got.y >= DARK) break; } + if (best && best.y < DARK) best = (await grab(best.at, true)) || best; + if (best) { await Bun.write(out, best.buf); return out; } failed.set(key, Date.now()); return null; } +const THUMB_V = 4; // v4: past a dark opening frame (stills made before are made again) async function thumb(src) { - const key = keyOf(src), out = `${THUMBS}/${key}.jpg`; + const key = keyOf('v' + THUMB_V + '|' + src), out = `${THUMBS}/${key}.jpg`; if (existsSync(out)) { touch(out); return out; } if (failedLately(key)) return null; if (!thumbJobs.has(key)) thumbJobs.set(key, thumbSlots(() => makeThumb(src, key)).finally(() => thumbJobs.delete(key))); -- cgit v1.2.3