From 6dac1028a33fe36a24384430f51392ae5a87cfa5 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Oct 2026 07:33:38 +0000 Subject: [PATCH] Take a thumbnail out of the video when the listed one is gone A favorite outlives its thumbnail. Sources sign their CDN paths and the signature expires, so a video kept months ago has a `thumb` that 404s and a card that is a grey box with a title under it. Asking the source page for a fresh picture covers the case where the page still exists; nothing covered the case where it doesn't. The video does, because playback re-resolves from the page URL every time. So once every route to the listed picture is exhausted, a frame a quarter of the way in becomes the card's picture -- past the black lead-in, and a fair guess at what the video looks like. It goes through our own proxy, so the canvas can be read back, is scaled to card width and JPEG'd to a handful of KB, and is kept: later mounts and later sessions paint it without a request, instead of asking again for a picture that is known to be gone. Only for favorites, once per item, one at a time, and never for a live stream or a manifest. Two things worth knowing, both found with the browser rather than reasoned about: a browser fires `seeked` for its own initial seek to zero, so taking the frame on the first one captures the opening of every video; and the same item is on screen in more than one place, so the captures are deduped by a promise rather than the first asker getting the picture and the rest being told there isn't one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN --- frontend/index.html | 1 + frontend/js/favorites.js | 2 +- frontend/js/feed.js | 4 +- frontend/js/posters.js | 356 ++++++++++++++++++++++++++++++++++ frontend/js/state.js | 5 +- frontend/js/videos.js | 62 ++++-- tests/fixtures/wide.mp4 | Bin 0 -> 8402 bytes tests/smoke_poster_capture.py | 288 +++++++++++++++++++++++++++ 8 files changed, 702 insertions(+), 16 deletions(-) create mode 100644 frontend/js/posters.js create mode 100644 tests/fixtures/wide.mp4 create mode 100644 tests/smoke_poster_capture.py diff --git a/frontend/index.html b/frontend/index.html index 0556773..f95fa80 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -276,6 +276,7 @@ + diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 0ce0de6..64cce58 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -494,7 +494,7 @@ App.favorites = App.favorites || {}; `; const thumb = card.querySelector('img'); if (App.videos && typeof App.videos.attachThumbnail === 'function') { - App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item)); + App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item), item); } card.onclick = () => { if (card.classList.contains('is-loading')) return; diff --git a/frontend/js/feed.js b/frontend/js/feed.js index fd0d063..eae199a 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -807,7 +807,7 @@ App.feed = App.feed || {}; `; const poster = pane.querySelector('.feed-poster'); - App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v)); + App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v); const slideVideo = pane.querySelector('.feed-video'); bindTimeline(pane, slideVideo); bindSharedControls(pane, slideVideo, v); @@ -1584,7 +1584,7 @@ App.feed = App.feed || {}; const titleText = pane.querySelector('.feed-title-text'); if (titleText) titleText.textContent = v.title || ''; const poster = pane.querySelector('.feed-poster'); - if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v)); + if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v); loadSlideSource(pane, v, true); setPipMetadata(v); }; diff --git a/frontend/js/posters.js b/frontend/js/posters.js new file mode 100644 index 0000000..843d556 --- /dev/null +++ b/frontend/js/posters.js @@ -0,0 +1,356 @@ +window.App = window.App || {}; +App.posters = App.posters || {}; + +// --------------------------------------------------------------------------- +// Captured posters +// +// A favorite outlives its thumbnail. The picture a listing hands out is a +// signed CDN path on most sources, and the signature expires -- so a video kept +// months ago has a `thumb` that 404s, and the card that stood for it is a grey +// box with a title under it. Asking the source page for a fresh one (the +// /api/poster repair in videos.js) covers the case where the page still exists +// and still shows a picture; nothing covers the case where it doesn't. +// +// The video itself does, though: it is still playable, because playback +// re-resolves from the page URL every time. So once every route to the listed +// picture is exhausted, a frame a quarter of the way into the clip becomes the +// card's picture -- far enough in to be past a title card or a black lead-in, +// and a fair guess at what the video looks like. +// +// A frame costs a resolve and the stretch of video around that point, which is +// why this happens once per item, for favorites only, and never again: the +// frame is scaled down, JPEG'd, and kept in localStorage, so the next mount and +// every later session paint it straight away without touching the network. +// --------------------------------------------------------------------------- +(function() { + const INDEX_KEY = App.constants.POSTER_CACHE_KEY; + const ENTRY_PREFIX = `${INDEX_KEY}:`; + + // A thumbnail box is around 300 CSS px wide in the grid and half a phone + // screen in the favorites bar, so this is a picture at roughly card size + // and not a frame of video: the point of the cache is to be small. + const WIDTH = 360; + const QUALITY = 0.72; + // How far into the clip the frame comes from. + const SEEK_FRACTION = 0.25; + // What the whole cache may take. Stored base64, so the string is a third + // larger than the JPEG behind it -- around 20 KB an entry, which leaves + // room for a hundred or so of them inside a localStorage budget that is + // shared with the favorites themselves. + const BUDGET_BYTES = 3 * 1024 * 1024; + // How long a seek gets before the frame it was decoding anyway will do. + const SEEK_PATIENCE_MS = 6000; + // A capture is one seek into one file. If that has not produced a frame by + // now, the source is not going to give us one. + const FRAME_TIMEOUT_MS = 20000; + // How many of the ranked sources are worth trying before giving up on an + // item for this session. + const SOURCE_TRIES = 2; + + // Both halves of a 64-bit-ish hash of the item's identity: a cache key has + // to be short enough to be a localStorage key and distinct enough that two + // favorites never share a picture. + const hash = function(text) { + let fnv = 0x811c9dc5; + let djb = 5381; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + fnv = (fnv ^ code) >>> 0; + fnv = (fnv + ((fnv << 1) + (fnv << 4) + (fnv << 7) + (fnv << 8) + (fnv << 24))) >>> 0; + djb = (((djb << 5) + djb) + code) >>> 0; + } + return fnv.toString(16).padStart(8, '0') + djb.toString(16).padStart(8, '0'); + }; + + // The identity to cache under: the item's own URL, normalized the way + // favorites normalize one, so the same video saved from a listing and + // imported from a backup lands on the same picture. + const keyOf = function(video) { + if (!video || typeof video !== 'object') return ''; + const url = video.url || (video.meta && video.meta.url) || ''; + const basis = (url && App.favorites && App.favorites.urlKey(url)) || url || + (App.favorites && App.favorites.getKey(video)) || video.id || ''; + return basis ? hash(String(basis)) : ''; + }; + + // ---- the cache ------------------------------------------------------- + + // [{ k, n, at }], oldest capture first. Read once and kept: a card asks + // whether it has a picture as it mounts, and parsing the index again for + // every card of a scrolling grid is the one thing this must not cost. + let index = null; + + const readIndex = function() { + if (index) return index; + try { + const parsed = JSON.parse(localStorage.getItem(INDEX_KEY)); + index = Array.isArray(parsed) ? + parsed.filter((entry) => entry && entry.k).map((entry) => ({ + k: String(entry.k), + n: Number(entry.n) || 0, + at: Number(entry.at) || 0 + })) : []; + } catch (err) { + index = []; + } + return index; + }; + + const writeIndex = function() { + try { + localStorage.setItem(INDEX_KEY, JSON.stringify(index)); + } catch (err) { /* nothing here is worth failing a card over */ } + }; + + const drop = function(entry) { + try { + localStorage.removeItem(ENTRY_PREFIX + entry.k); + } catch (err) { /* already gone */ } + }; + + const store = function(key, src) { + const list = readIndex().filter((entry) => entry.k !== key); + list.push({ k: key, n: src.length, at: Date.now() }); + index = list; + // Oldest captures go first. Not least-recently-seen: tracking that + // would mean writing to storage every time a card mounts, which is a + // worse trade than occasionally re-capturing a picture. + let total = list.reduce((sum, entry) => sum + entry.n, 0); + while (total > BUDGET_BYTES && index.length > 1) { + const oldest = index.shift(); + total -= oldest.n; + drop(oldest); + } + try { + localStorage.setItem(ENTRY_PREFIX + key, src); + } catch (err) { + // Out of room, and not necessarily because of us -- the store is + // shared with the favorites and everything else. Make room by + // giving up the oldest third of the cache and try once more. + const giveUp = Math.max(1, Math.floor(index.length / 3)); + index.splice(0, giveUp).forEach(drop); + try { + localStorage.setItem(ENTRY_PREFIX + key, src); + } catch (again) { + index = index.filter((entry) => entry.k !== key); + writeIndex(); + return ''; + } + } + writeIndex(); + return src; + }; + + const read = function(key) { + try { + return localStorage.getItem(ENTRY_PREFIX + key) || ''; + } catch (err) { + return ''; + } + }; + + // The picture already captured for this item, if there is one. + App.posters.cached = function(video) { + const key = keyOf(video); + if (!key) return ''; + // The index says what we have without touching the entries, so an item + // with no captured picture -- almost all of them -- costs a set lookup + // rather than a read of a data URL out of storage. + if (!readIndex().some((entry) => entry.k === key)) return ''; + const src = read(key); + if (!src) { + index = readIndex().filter((entry) => entry.k !== key); + writeIndex(); + } + return src; + }; + + App.posters.forget = function(video) { + const key = keyOf(video); + if (!key) return; + readIndex().filter((entry) => entry.k === key).forEach(drop); + index = readIndex().filter((entry) => entry.k !== key); + writeIndex(); + }; + + App.posters.clear = function() { + readIndex().forEach(drop); + index = []; + writeIndex(); + }; + + App.posters.stats = function() { + const list = readIndex(); + return { count: list.length, bytes: list.reduce((sum, entry) => sum + entry.n, 0) }; + }; + + // ---- taking the frame ------------------------------------------------ + + // Draws what the element is showing, at card size rather than video size. + const drawFrame = function(el) { + const width = el.videoWidth; + const height = el.videoHeight; + if (!width || !height) throw new Error('nothing was decoded'); + const scale = Math.min(1, WIDTH / width); + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(width * scale)); + canvas.height = Math.max(1, Math.round(height * scale)); + canvas.getContext('2d').drawImage(el, 0, 0, canvas.width, canvas.height); + // Tainted canvas throws here, which is why the frame is taken from our + // own proxy rather than from the CDN: /api/stream is same-origin, so + // the pixels can be read back. + const src = canvas.toDataURL('image/jpeg', QUALITY); + if (!src || src.length < 512) throw new Error('the frame came out empty'); + return src; + }; + + const grabFrame = function(url, duration) { + return new Promise((resolve, reject) => { + const el = document.createElement('video'); + el.muted = true; + el.defaultMuted = true; + el.playsInline = true; + el.preload = 'auto'; + el.crossOrigin = 'anonymous'; + el.setAttribute('aria-hidden', 'true'); + // Off-screen, but in the document: an element the browser does not + // consider part of a page is under no obligation to decode + // anything. + el.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;' + + 'opacity:0;pointer-events:none'; + + let settled = false; + let timer = null; + let patience = null; + // Where we asked to be, and whether we asked at all. A browser + // fires `seeked` for its own initial seek to zero as the file + // opens, and taking the frame on that one would mean every + // captured picture is the first frame of the video -- usually a + // black one -- however far in we meant to go. + let target = 0; + let asked = false; + const done = function(err, src) { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (patience) clearTimeout(patience); + // Told to stop downloading before it leaves, or the request + // outlives the element that made it. + el.removeAttribute('src'); + try { el.load(); } catch (stop) { /* nothing to stop */ } + el.remove(); + if (err) reject(err); + else resolve(src); + }; + const take = function() { + try { done(null, drawFrame(el)); } catch (err) { done(err); } + }; + + timer = setTimeout(() => done(new Error('no frame in time')), FRAME_TIMEOUT_MS); + el.addEventListener('error', () => done(new Error('the source would not play'))); + el.addEventListener('loadedmetadata', () => { + const length = Number(el.duration) || Number(duration) || 0; + target = (length > 0 && isFinite(length)) ? length * SEEK_FRACTION : 0; + // Nowhere to seek to -- a stream of unknown length -- so the + // first frame it decodes is the one we get. + if (target <= 0) return; + asked = true; + try { el.currentTime = target; } catch (err) { done(err); } + // Some sources cannot be seeked in at all: no byte ranges, or + // a length the browser does not trust. They still have an + // opening frame, and a picture of the opening beats a grey box + // -- but only once the seek has had its chance. + patience = setTimeout(() => { + if (el.readyState >= 2) take(); + }, SEEK_PATIENCE_MS); + }); + el.addEventListener('seeked', () => { + // Near enough: a seek lands on the nearest frame it can decode, + // which is the keyframe before the mark, not the mark itself. + if (asked && el.currentTime < target - 1) return; + take(); + }); + el.addEventListener('loadeddata', () => { if (!asked) take(); }); + + document.body.appendChild(el); + el.src = url; + }); + }; + + const captureOne = async function(video) { + // Favorites keep no resolved formats on purpose (their URLs expire), so + // this is where the item becomes playable again. + try { + await App.videos.ensureFormats(video); + } catch (err) { /* the page-URL fallback below may still play */ } + + const sources = App.videos.resolveStreamSources(video, { + applyPreferredQuality: false, + // The smallest format there is: this is going to end up 360 px + // wide, so a 1080p stream would be bytes spent on pixels that get + // thrown away. + cheapest: true + }).filter((source) => { + // A manifest is a playlist of segments, not something a bare + //