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. // // Which is also why a capture has to be looked at before it is kept. A frame // with nothing in it -- an element that decoded nothing draws as nothing, and a // lead-in or a fade is black in its own right -- is a black box on the card for // the life of the item, and asking again is exactly what the cache is there to // avoid. So a blank frame is taken somewhere else in the clip instead (see // planFor), and one kept by a version of this that did not check gets measured // once and replaced (see recheck). // --------------------------------------------------------------------------- (function() { const INDEX_KEY = App.constants.POSTER_CACHE_KEY; const ENTRY_PREFIX = `${INDEX_KEY}:`; // Marks an entry whose picture was checked for having a picture in it, // which every capture now is and none of the older ones were. const CHECKED = 1; // 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; // How long a frame gets to be presented once the data for it has arrived. const PRESENT_PATIENCE_MS = 500; // A capture is a handful of seeks into one file. If none of them has // produced a picture by now, the source is not going to give us one. const FRAME_TIMEOUT_MS = 30000; // How bright the brightest sample in a frame has to be for the frame to be // a picture of anything at all. A floor, not a threshold: this is the // difference between a frame and no frame, not between a dark scene and a // bright one. const BLACK_LEVEL = 10; // Every fourth pixel answers that question, and keeps the check off the // cost of a capture. const SAMPLE_STRIDE = 4; // Where to look in a clip whose length nobody will say. const BLIND_MARK_SECONDS = 10; // 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, // Whether anything ever looked at what this entry holds. // Absent on everything written before that was checked -- // see recheck. v: Number(entry.v) || 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(), v: CHECKED }); 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 ------------------------------------------------ // Is there a picture in what was just drawn? // // Two things come out of a capture looking identical, and both are useless. // A video that has decoded nothing draws as nothing: the canvas keeps its // transparent black, which becomes a pure black JPEG about 1.6 KB long -- // large enough to pass for a picture, and kept for the life of the item. // And a frame that genuinely is black (a lead-in, a fade, a cut between // scenes) is no better a thumbnail than that one. The answer to both is to // take the frame somewhere else, so they are the same question. const isBlank = function(ctx, width, 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 px = ctx.getImageData(0, 0, width, height).data; let painted = false; let brightest = 0; for (let i = 0; i < px.length; i += 4 * SAMPLE_STRIDE) { if (px[i + 3] > 0) painted = true; const value = Math.max(px[i], px[i + 1], px[i + 2]); if (value > brightest) brightest = value; } return !painted || brightest <= BLACK_LEVEL; }; // Draws what the element is showing, at card size rather than video size. // Empty when there turns out to be nothing in it, which is the caller's cue // to look elsewhere in the clip rather than keep it. 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)); const ctx = canvas.getContext('2d'); ctx.drawImage(el, 0, 0, canvas.width, canvas.height); if (isBlank(ctx, canvas.width, canvas.height)) return ''; const src = canvas.toDataURL('image/jpeg', QUALITY); if (!src || src.length < 512) throw new Error('the frame came out empty'); return src; }; // Where a frame comes from, and what to do when it comes back blank. A // blank frame means one of two things and each has its own answer: the // element decoded nothing (so play it for a moment -- an element that is // playing has to present frames, which a seeked one is under no obligation // to do), or the frame at that mark really is black (so take it somewhere // else). const planFor = function(length) { if (length > 0 && isFinite(length)) { return [ { at: length * SEEK_FRACTION, play: false }, { at: length * SEEK_FRACTION, play: true }, { at: length * 0.5, play: true } ]; } // Nobody will say how long this is: an extractor that reports no // duration, a file the browser won't commit to a length for. A blind // seek to a fixed mark still beats the opening frame, which is a black // one often enough to be half the reason this check exists -- and a // mark past the end comes back clamped into the file, so the guess // costs nothing. return [ { at: BLIND_MARK_SECONDS, play: false }, { at: 0, play: true } ]; }; 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 budget = null; let patience = null; // The marks to try, and which of them we are on. let plan = []; let step = -1; // 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; // A frame is already being waited for. A completed seek and the // patience timer can both decide it is time, and the second one to // decide must not start a second attempt on the same frame. let pending = false; const done = function(err, src) { if (settled) return; settled = true; if (budget) clearTimeout(budget); if (patience) clearTimeout(patience); try { el.pause(); } catch (stop) { /* never started */ } // 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() { pending = false; let src = ''; try { src = drawFrame(el); } catch (err) { done(err); return; } if (src) { done(null, src); return; } // Nothing in it, so this mark is spent. attempt(); }; // Drawing an element that has not presented a frame gives an empty // canvas, and the engines disagree about when that is: Chromium has // a decoded frame by the time it says a seek is done, while Safari // will not present one for an off-screen paused element until it // has to. `requestVideoFrameCallback` is the browser saying a frame // is on screen now, which is the only direct answer there is; where // there is none, a decoded readyState after a paint is the nearest // thing to one. const whenPresented = function() { if (settled || pending) return; pending = true; let taken = false; const once = function() { if (taken || settled) return; taken = true; take(); }; if (typeof el.requestVideoFrameCallback === 'function') { el.requestVideoFrameCallback(() => once()); // An element that is paused on the frame it already // presented may never present another, so the callback on // its own would wait for good. setTimeout(() => { if (el.readyState >= 2) once(); else pending = false; }, PRESENT_PATIENCE_MS); return; } requestAnimationFrame(() => { if (el.readyState >= 2) once(); else setTimeout(once, PRESENT_PATIENCE_MS); }); }; const attempt = function() { step += 1; if (step >= plan.length) { done(new Error('every frame came out blank')); return; } if (patience) clearTimeout(patience); const mark = plan[step]; target = mark.at; if (mark.play) { const playing = el.play(); if (playing && typeof playing.catch === 'function') playing.catch(() => {}); } if (target > 0 && Math.abs(el.currentTime - target) > 1) { asked = true; try { el.currentTime = target; } catch (err) { done(err); return; } } else { // Already where this attempt wanted to be: all that has // changed is that the element is now playing. whenPresented(); } // 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) whenPresented(); }, SEEK_PATIENCE_MS); }; budget = 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', () => { plan = planFor(Number(el.duration) || Number(duration) || 0); step = -1; attempt(); }); el.addEventListener('seeked', () => { // Not ours: the browser's own seek to zero as the file opens. if (!asked || (target > 0 && el.currentTime <= 0)) return; // Near enough: a seek lands on the nearest frame it can decode, // which is the keyframe before the mark rather than the mark // itself, and a mark past the end comes back clamped to as // close as that file gets. whenPresented(); }); 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 //