diff options
| author | Adam Soltys <[email protected]> | 2026-10-22 15:12:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-22 15:12:04 -0700 |
| commit | 8257a98e8d1891d0795f6b8b0623678245cc8e33 (patch) | |
| tree | 00f6f3ca948785e31dd4544733846b0cdc710118 /src | |
| parent | 520919208e63a08e4eab3102e1986532dddfaf92 (diff) | |
| download | coinosv3-8257a98e8d1891d0795f6b8b0623678245cc8e33.tar.gz coinosv3-8257a98e8d1891d0795f6b8b0623678245cc8e33.zip | |
Media grids: one tile for a clip uploaded twice, and a picture that won't load stays out
- Videos: one person's clips of one size to the byte are one clip. Beatbox
Serenade posted the same video as a note and as a video event, each
upload a fresh Blossom hash, so neither the file nor the words matched.
Only a size the post or the host states counts (a bitrate x duration
guess is marked sizeGuess and never matched).
- Images: a picture whose load failed was hidden by a class the next
repaint rewrote, so the broken-picture glyph came back (a deleted
nostr.media file in #artstr). Failed pictures are now remembered for the
session and left out of the grid.
- A tile fetching its own frame froze the whole tile against repaints, so
one built for the one-to-a-row layout never got its --ar in justified rows
(zero width). Only the player and its stand-ins are frozen now; the
render draws the cost badge itself. Tile pictures no longer size their
tile (a frame-grab <video>'s 320px stretched its row).
- Test fixtures that served one file under several names (or one imeta
size for every clip) now differ per clip.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K5xtiFrkFVsrkWx6JDQjiT
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 43 | ||||
| -rw-r--r-- | src/style.css | 3 |
2 files changed, 36 insertions, 10 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 71d3370..030d9fa 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6716,7 +6716,8 @@ export function messagesFeature(ctx) { const d = {}; if (!have.size && +f.size > 0) d.size = +f.size; // no size, but a bitrate (bits/s) and a duration (Primal posts these): near enough - else if (!have.size && +f.bitrate > 0 && +f.duration > 0) d.size = Math.round(+f.bitrate * +f.duration / 8); + // (a guess: never taken for the file's identity, see mediaItems) + else if (!have.size && +f.bitrate > 0 && +f.duration > 0) { d.size = Math.round(+f.bitrate * +f.duration / 8); d.sizeGuess = true; } if (!have.dur && +f.duration > 0) d.dur = +f.duration; const m = /^(\d+)(?:\.0+)?x(\d+)(?:\.0+)?$/.exec(f.dim || ''); if (!have.w && m) { d.w = +m[1]; d.h = +m[2]; } @@ -6820,14 +6821,14 @@ export function messagesFeature(ctx) { for (const p of parts) { if (o >= buf.length) break; buf.set(p.subarray(0, buf.length - o), o); o += p.length; } const head = readMp4Head(buf); const d = { probed: true, ranges, faststart: head.faststart === true, failedAt: undefined, goneAt: undefined, ...(head.moovEnd ? { moovEnd: head.moovEnd } : {}), ...(head.hevc ? { hevc: true } : {}), ...(head.dur && !(videoInfo.get(url) || {}).dur ? { dur: head.dur } : {}) }; - if (total > 0) d.size = total; + if (total > 0) { d.size = total; d.sizeGuess = undefined; } else { // a server that hides Content-Range from scripts (Primal's) still // says Content-Length to a HEAD, which any page may read try { const hr = await fetch(url, { method: 'HEAD', cache: 'no-store', signal: AbortSignal.timeout(5000) }); const n = +hr.headers.get('content-length'); - if (hr.ok && n > 0) d.size = n; + if (hr.ok && n > 0) { d.size = n; d.sizeGuess = undefined; } } catch {} } if (head.w && !(videoInfo.get(url) || {}).w) { d.w = head.w; d.h = head.h; } @@ -7986,6 +7987,7 @@ export function messagesFeature(ctx) { 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) + const deadTiles = new Set(); // grid pictures that wouldn't load (this session): left out // A picture from a host that won't hand over its bytes (no CORS: primal's // blossom, nostrcheck, most personal sites) can't have its column copy made // here, so the server makes it (pic.webp: 1100px wide, or a 360px square @@ -14021,9 +14023,17 @@ export function messagesFeature(ctx) { // the screen leaves them to the next) if (again && urls.length) said.set(again, ev.id); for (const url of urls) { + if (deadTiles.has(url)) continue; const f = sameFile(url); if (seen.has(f)) continue; seen.add(f); + // the same clip uploaded twice by one person (a note and a video + // event a few minutes apart) gets two hashes but keeps its size to + // the byte + const d = kind === 'videos' ? videoInfo.get(url) || {} : {}; + const twin = d.size && !d.sizeGuess ? ev.pubkey + '|' + d.size : null; + if (twin && seen.has(twin)) continue; + if (twin) seen.add(twin); out.push({ ev, url }); } } @@ -14276,11 +14286,18 @@ export function messagesFeature(ctx) { onLoad: (e) => arFromStill(e.currentTarget, it.url), onError: (e) => { if (fromServer) { serverThumbFailed.add(it.url); render(); return; } // the frame grab's turn + // remembered, not only a class on the tile: the next + // repaint rewrites the class list, and the broken-picture + // glyph came back (a deleted nostr.media file in #artstr) + deadTiles.add(it.url); const b = e.target.closest('.media-tile'); if (b) b.classList.add('broken'); + scheduleRepaint(); } }) // no still to show: the clip's first frame, fetched once the tile is near : h('video', { 'data-src': it.url + '#t=0.1', muted: true, playsinline: true, preload: 'metadata' }), - mediaMode === 'videos' && poster && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null, + // (drawn here whatever the tile shows, so a repaint keeps the one + // the frame-grab added) + mediaMode === 'videos' && videoInfo.get(it.url)?.size && videoHeavy(it.url) ? h('span', { class: 'media-tile-badge' }, heavyLabel(it.url)) : null, mediaMode === 'videos' ? h('span', { class: 'media-tile-play', 'aria-hidden': 'true' }, '▶') : null); })); } @@ -14294,8 +14311,14 @@ export function messagesFeature(ctx) { if (!e.isIntersecting) continue; const v = e.target; tileObs.unobserve(v); - const tile = v.closest('.media-tile'); - if (tile) tile._skipMorph = true; + // the player (and whatever stands in for it) is left alone by + // repaints, which would strip its src; the tile around it keeps up + // with them (its shape when the grid's layout changes, its class). + // Freezing the whole tile kept a tile built for one-to-a-row out of + // the justified rows: no --ar, no width. + v._skipMorph = true; + const standIn = (el) => { el._skipMorph = true; return el; }; + const badge = (el) => { if (!el.parentNode.querySelector('.media-tile-badge')) el.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); }; // A first frame costs the start of the file when the index leads or // the server serves ranges — whatever the clip's size. Only an // index-last file without ranges would have to come down whole for @@ -14313,7 +14336,7 @@ export function messagesFeature(ctx) { img.addEventListener('load', () => arFromStill(img, url), { once: true }); img.src = videoThumbs.get(url); v.replaceWith(img); - if (heavy && d.size) img.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + if (heavy && d.size) badge(img); return; } if (!v.isConnected) return; @@ -14325,7 +14348,7 @@ export function messagesFeature(ctx) { const cap = setTimeout(() => { if (v.readyState >= 2 || !v.isConnected) return; try { v.removeAttribute('src'); v.load(); } catch {} - v.replaceWith(h('span', { class: 'media-tile-cost' }, '')); + v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, ''))); }, 8000); v.addEventListener('loadeddata', () => clearTimeout(cap), { once: true }); } @@ -14346,10 +14369,10 @@ export function messagesFeature(ctx) { scheduleRepaint(); }, { once: true }); v.src = v.getAttribute('data-src'); - if (heavy && d.size) v.after(h('span', { class: 'media-tile-badge' }, heavyLabel(url))); + if (heavy && d.size) badge(v); return; } - v.replaceWith(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : '')); + v.replaceWith(standIn(h('span', { class: 'media-tile-cost' }, d && d.size ? heavyLabel(url) : ''))); }); } }, { rootMargin: '400px 0px' }); diff --git a/src/style.css b/src/style.css index 61d6f01..275bb59 100644 --- a/src/style.css +++ b/src/style.css @@ -1548,6 +1548,9 @@ body::after { .media-grid.justified { display: flex; flex-wrap: wrap; --row-h: 140px; } .media-grid.justified .media-tile { aspect-ratio: var(--ar); flex: var(--ar) 1 calc(var(--ar) * var(--row-h)); min-width: 0; } .media-grid.justified::after { content: ''; flex: 1000 1 0; } +/* the picture fills the tile without sizing it: a frame-grab <video>'s own + 320px would widen its tile, and the row's height with it */ +.media-grid.justified .media-tile > img, .media-grid.justified .media-tile > video { position: absolute; inset: 0; } @media (max-width: 600px) { .media-grid.justified { --row-h: 100px; } } .media-tile img, .media-tile video { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } .media-tile.broken { display: none; } |
