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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user