aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-22 12:12:46 -0700
committerAdam Soltys <[email protected]>2026-10-22 12:12:46 -0700
commit520919208e63a08e4eab3102e1986532dddfaf92 (patch)
treec19db012627d1ccf34c5daa632719cdc77126274
parent1ea1784c4ad7d6715c0efdcd0078019c692eefe5 (diff)
downloadcoinosv3-520919208e63a08e4eab3102e1986532dddfaf92.tar.gz
coinosv3-520919208e63a08e4eab3102e1986532dddfaf92.zip
Videos grid: a clip the player can't load leaves the grid instead of sitting there as a blank tile
A deleted file on Primal's blossom answers 404 without CORS headers, so the probe saw only a quiet host (not a 404), the server had no still for it, and the tile's fallback player failed with nothing listening: a blank box (Adam's iPhone). The player's error now hides the tile and marks the clip gone for a day, as a 404 the probe could read already did. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01K5xtiFrkFVsrkWx6JDQjiT
-rw-r--r--src/features/messages.js12
-rw-r--r--tools/video-tile-poster-test.js36
2 files changed, 42 insertions, 6 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 2b175e2..71d3370 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -14333,6 +14333,18 @@ export function messagesFeature(ctx) {
// (a plain load: shown, not kept — a player's pixels can only be
// read from a CORS load, and those fail on a cached plain copy)
v.muted = true;
+ // a clip the player can't load either is gone as far as the grid
+ // goes: a deleted file whose 404 came without CORS headers
+ // (Primal's blossom) only looked like a quiet host to the probe,
+ // and its tile sat blank. Left out for a day, like a 404 the
+ // probe could read.
+ v.addEventListener('error', () => {
+ if (!v.isConnected || !v.getAttribute('src')) return; // let go of by the eight-second cap
+ noteVideoInfo(url, { goneAt: Date.now() });
+ const b = v.closest('.media-tile');
+ if (b) b.classList.add('broken');
+ scheduleRepaint();
+ }, { once: true });
v.src = v.getAttribute('data-src');
if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url)));
return;
diff --git a/tools/video-tile-poster-test.js b/tools/video-tile-poster-test.js
index 3b5fe93..1f2cb47 100644
--- a/tools/video-tile-poster-test.js
+++ b/tools/video-tile-poster-test.js
@@ -1,7 +1,8 @@
// Videos grid: a tile shows the server's still (vmedia thumb.jpg, cached at
// the CDN) at once, instead of waiting on the browser's own probe and frame
// grab — and never touches the clip itself for it. A clip the server has no
-// picture for falls back to the frame grab.
+// picture for falls back to the frame grab, and one the browser can't load
+// either (deleted, its 404 without CORS headers) leaves the grid.
// Run: bun tools/video-tile-poster-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
@@ -13,12 +14,15 @@ const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? '
const JPG = '/tmp/coinos-video-tile-poster.jpg';
Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=orange:s=270x480', '-frames:v', '1', JPG]);
const JPEG = Buffer.from(await Bun.file(JPG).arrayBuffer());
+const MP4 = '/tmp/coinos-video-tile-poster.mp4';
+Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'color=c=blue:s=360x640:d=1', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', MP4]);
+const CLIPBYTES = Buffer.from(await Bun.file(MP4).arrayBuffer());
const AUTHOR = (() => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; })();
const now = Math.floor(Date.now() / 1000);
const CLIP = (n) => 'https://media.test/' + n + '.mp4';
const post = (n, ago) => finalizeEvent({ kind: 1, created_at: now - ago, content: 'clip ' + n + ' ' + CLIP(n),
tags: [['imeta', 'url ' + CLIP(n), 'm video/mp4', 'size 900000', 'dim 360x640']] }, AUTHOR.sk);
-const POSTS = [post('a', 100), post('b', 200), post('nopic', 300)];
+const POSTS = [post('a', 100), post('b', 200), post('nopic', 300), post('dead', 400)];
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -31,10 +35,16 @@ page.on('request', (r) => {
if (u.pathname.startsWith('/vmedia/thumb.jpg')) {
const src = u.searchParams.get('u');
asked.push(['thumb', src]);
- if (src === CLIP('nopic')) return r.respond({ status: 404, body: 'No picture' });
+ if (src === CLIP('nopic') || src === CLIP('dead')) return r.respond({ status: 404, body: 'No picture' });
return r.respond({ status: 200, contentType: 'image/jpeg', body: JPEG, headers: { 'access-control-allow-origin': '*' } });
}
- if (u.host === 'media.test') { asked.push(['clip', r.url()]); return r.abort(); }
+ if (u.host === 'media.test') {
+ asked.push(['clip', r.url()]);
+ // deleted, and the host's 404 says nothing to scripts (Primal's blossom)
+ if (u.pathname === '/dead.mp4') return r.respond({ status: 404, body: 'not found' });
+ if (u.pathname === '/nopic.mp4') return r.respond({ status: 200, contentType: 'video/mp4', body: CLIPBYTES, headers: { 'access-control-allow-origin': '*' } });
+ return r.abort();
+ }
r.continue();
});
await page.evaluateOnNewDocument(() => {
@@ -79,17 +89,31 @@ try {
await waitText('receive', 20000);
await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
- check('the Videos grid has the three clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length === 3, 15000),
+ check('the Videos grid has the clips', await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000),
String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
check('two tiles show the server\'s still, loaded', await waitFor(() => [...document.querySelectorAll('.media-tile.video img')]
.filter((i) => i.src.includes('/vmedia/thumb.jpg') && i.naturalWidth > 0).length === 2, 8000),
JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('.media-tile.video')].map((t) => (t.querySelector('img')?.src || t.querySelector('video') ? 'video' : t.innerHTML).slice(-40)))));
check('the clip without one falls back to the frame grab', await waitFor(() => [...document.querySelectorAll('.media-tile.video')]
.some((t) => !t.querySelector('img') && (t.querySelector('video') || t.querySelector('.media-tile-cost'))), 8000));
+ check('the deleted clip leaves the grid instead of sitting there blank', await waitFor(() => {
+ const tiles = [...document.querySelectorAll('.media-grid .media-tile.video')].filter((t) => getComputedStyle(t).display !== 'none');
+ return tiles.length === 3 && !document.querySelector('.media-grid video[src*="dead"]');
+ }, 10000), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
await sleep(1500);
- const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic'));
+ const clipHits = asked.filter(([k, u]) => k === 'clip' && !u.includes('nopic') && !u.includes('dead'));
check('the pictured clips themselves were never fetched for a still', clipHits.length === 0, clipHits.map((x) => x[1]).join(', '));
check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+ // remembered: the next visit leaves it out from the start
+ await sleep(2000);
+ asked.length = 0;
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]')?.click());
+ await waitFor(() => document.querySelectorAll('.media-grid .media-tile.video').length >= 3, 15000);
+ await sleep(3000);
+ check('...and stays out after a reload', await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length) === 3
+ && !asked.some(([k, u]) => k === 'clip' && u.includes('dead')), String(await page.evaluate(() => document.querySelectorAll('.media-grid .media-tile.video').length)));
} finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ video tiles show the server\'s still at once' : '\n❌ failed');
process.exit(ok ? 0 : 1);