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

@@ -276,6 +276,7 @@
<script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/posters.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script>

View File

@@ -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;

View File

@@ -807,7 +807,7 @@ App.feed = App.feed || {};
</div>
`;
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);
};

356
frontend/js/posters.js Normal file
View File

@@ -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
// <video> can open outside Safari, and the player's hls.js is a
// heavier thing than a thumbnail is worth.
const kind = App.videos.classifySource(source);
return !kind.isHls && !source.isLive;
}).slice(0, SOURCE_TRIES);
for (let i = 0; i < sources.length; i++) {
const url = App.videos.buildStreamUrlFromSource(sources[i]);
if (!url) continue;
try {
return await grabFrame(url, Number(video.duration) || 0);
} catch (err) {
// Try the next format; if that was the last one, the item has
// no picture and no amount of asking changes it.
}
}
return '';
};
// The same item is on screen in more than one place -- the favorites bar
// and the grid behind it, a card and the reel's own poster -- and all of
// them ask. They wait on the one capture and all get the picture; the
// alternative is the first one to ask getting a picture and the rest being
// told there isn't one.
const inFlight = new Map();
// And one try per item per session once it has come back empty: a failed
// capture is a resolve plus a download, and a grid that scrolls past the
// same dead favorite ten times must not pay for it ten times.
const failed = new Set();
// One capture at a time. The reader is watching something; a thumbnail has
// no business competing with it for bandwidth.
let queue = Promise.resolve();
App.posters.capture = function(video) {
const key = keyOf(video);
if (!key) return Promise.resolve('');
const have = App.posters.cached(video);
if (have) return Promise.resolve(have);
const running = inFlight.get(key);
if (running) return running;
if (failed.has(key)) return Promise.resolve('');
// A live stream has no quarter mark, and whatever it is showing now is
// not what it will be showing when the card is next looked at.
if (video.isLive || (video.meta && video.meta.isLive)) return Promise.resolve('');
const run = queue.then(() => captureOne(video)).then((src) => {
inFlight.delete(key);
if (!src) {
failed.add(key);
return '';
}
return store(key, src);
}, () => {
inFlight.delete(key);
failed.add(key);
return '';
});
inFlight.set(key, run);
queue = run.catch(() => '');
return run;
};
})();

View File

@@ -26,7 +26,10 @@ App.constants = {
PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
SEARCH_HISTORY_KEY: 'searchHistory',
SAVED_SEARCHES_KEY: 'savedSearches'
SAVED_SEARCHES_KEY: 'savedSearches',
// Thumbnails captured out of a video whose listed picture is gone:
// the index, plus one entry per picture under `<key>:<hash>`.
POSTER_CACHE_KEY: 'posterCache'
};
// Lazily injects hls.js the first time a stream actually needs it. Sessions

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) {