aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 13:53:56 -0700
committerAdam Soltys <[email protected]>2026-10-09 13:53:56 -0700
commitf264cf4679d58d965215bd0081a77cdaccc803f0 (patch)
treea9f38edbd77ecc7004cb3823bd2dd9c0d00c0b24
parent2454b9a4c12ad1ae403a20b2793b4c03b1164651 (diff)
downloadcoinosv3-f264cf4679d58d965215bd0081a77cdaccc803f0.tar.gz
coinosv3-f264cf4679d58d965215bd0081a77cdaccc803f0.zip
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.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--src/features/messages.js3
-rw-r--r--tools/video-fallback-test.js2
-rw-r--r--vmedia/server.js36
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)));