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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-10-20 07:33:38 +00:00
parent e8eac70f4d
commit 6dac1028a3
8 changed files with 702 additions and 16 deletions

View File

@@ -270,9 +270,24 @@ App.videos = App.videos || {};
// being asked.
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
// Last of all, once the listed picture is gone and the page has no
// better one to offer: a frame out of the video itself. Only for something
// the reader kept -- a capture costs a resolve and a stretch of video,
// which is not worth spending on a card that scrolls past once -- and the
// picture is cached, so an item pays for it once and no later session pays
// at all. See App.posters.
const capturePoster = function(img, token) {
const video = img._thumbVideo;
if (!video || !App.posters || !App.favorites || !App.favorites.has(video)) return;
App.posters.capture(video).then((src) => {
if (!src || img.dataset.thumbToken !== token) return;
showThumbnail(img, src, token);
});
};
const repairThumbnail = function(img, token) {
const page = img.dataset.thumbPage || '';
if (!page) return;
if (!page) { capturePoster(img, token); return; }
let pending = posterAsked.get(page);
if (!pending) {
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
@@ -282,7 +297,9 @@ App.videos = App.videos || {};
posterAsked.set(page, pending);
}
pending.then((replacement) => {
if (!replacement || img.dataset.thumbToken !== token) return;
if (img.dataset.thumbToken !== token) return;
// The page had nothing either, so there is one thing left to try.
if (!replacement) { capturePoster(img, token); return; }
// No second repair for this mount: the ladder below must not come
// back here and ask the same page for the same picture forever.
delete img.dataset.thumbPage;
@@ -529,11 +546,14 @@ App.videos = App.videos || {};
// racing the two the first time that host is seen. `page` is optional: the
// item's own page, asked for a replacement if the picture turns out to be
// gone.
App.videos.attachThumbnail = function(img, url, page) {
App.videos.attachThumbnail = function(img, url, page, video) {
// An address that isn't one is the same thing as no thumbnail: the card
// keeps its placeholder rather than chasing it. See usableThumbUrl.
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
if (!img) return;
// The item this picture is of, kept for the capture at the end of the
// ladder (see capturePoster).
img._thumbVideo = video || null;
// Held back until there is an image to caption -- see showThumbnail. An
// item with no thumbnail keeps an empty alt: the card's own title sits
// directly beneath the box, so there is nothing for it to add.
@@ -541,7 +561,30 @@ App.videos = App.videos || {};
img.dataset.alt = img.alt;
img.alt = '';
}
if (!directUrl) return;
// Every attach is a new generation, so work started for a previous one
// stops being able to touch this element.
const token = String(++thumbSeq);
img.dataset.thumbToken = token;
if (page) img.dataset.thumbPage = page;
else delete img.dataset.thumbPage;
// A picture captured for this item earlier wins outright. The cache
// only ever holds one because every route to the listed picture had
// already failed, so going back to the wire for it would be a request
// that is known to fail, on every mount, for the rest of the item's
// life.
const captured = App.posters ? App.posters.cached(video) : '';
if (captured) {
showThumbnail(img, captured, token);
return;
}
if (!directUrl) {
// Nothing listed to fail, so there is nothing to walk a ladder
// down: a frame out of the video is the only picture this item is
// ever going to have.
capturePoster(img, token);
return;
}
// A cross-origin Referer is what most hotlink protection keys on, and an
// image needs none. Sending none is what lets the direct route work at
// all on a fair number of providers -- and the direct route is the one
@@ -551,12 +594,6 @@ App.videos = App.videos || {};
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
const host = imageHostOf(directUrl);
const route = imageRoutes.get(host);
// Every attach is a new generation, so work started for a previous one
// stops being able to touch this element.
const token = String(++thumbSeq);
img.dataset.thumbToken = token;
if (page) img.dataset.thumbPage = page;
else delete img.dataset.thumbPage;
if (route === IMAGE_PROXY) {
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
@@ -590,6 +627,7 @@ App.videos = App.videos || {};
img.dataset.thumbToken = String(++thumbSeq);
delete img.dataset.alt;
delete img.dataset.thumbPage;
img._thumbVideo = null;
detachRetry(img);
};
@@ -1126,7 +1164,7 @@ App.videos = App.videos || {};
// the same frame, so loading a thumbnail for it -- let alone racing one
// -- would be pure waste.
if (!(options && options.skipThumbnail)) {
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v));
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v), v);
}
return card;
};
@@ -1824,7 +1862,7 @@ App.videos = App.videos || {};
if (!card || !v) return;
unfilled.delete(i);
const img = cardRefs(card).img;
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v));
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v), v);
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
// its true aspect ratio, clear the shimmer, then correct the height.
if (img) {