A frame taken out of a video is kept for good: that is what makes a dead favorite cheap to scroll past, and it is also the one thing that can go wrong with no way out. A picture from the wrong moment, or one kept by a version of this that would keep a frame with nothing in it, stays on the card for the life of the item. So Settings grows a Captured Thumbnails section: what is kept and how much room it takes, and a button that throws the lot away. The cards on screen go back to the start of the thumbnail ladder rather than waiting to be scrolled out of the window and back, which is where a fresh capture comes from, and the session's memory of which items there was no point asking about goes with the pictures -- otherwise "take them again" would quietly skip every item that failed once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
584 lines
26 KiB
JavaScript
584 lines
26 KiB
JavaScript
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();
|
|
};
|
|
|
|
// Throws every captured picture away, and with it this session's memory of
|
|
// which items there was no point asking about. That memory is the whole
|
|
// reason a dead favorite is cheap to scroll past, so it only goes when the
|
|
// reader asks for the pictures to be taken again (Settings -> Captured
|
|
// Thumbnails) -- otherwise an item that failed once would be retried on
|
|
// every mount for the rest of the session.
|
|
App.posters.clear = function() {
|
|
readIndex().forEach(drop);
|
|
index = [];
|
|
writeIndex();
|
|
failed.clear();
|
|
rechecks.clear();
|
|
};
|
|
|
|
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
|
|
// <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;
|
|
};
|
|
|
|
// ---- pictures kept before captures were checked ----------------------
|
|
|
|
// Decodes a kept picture and says whether there is one in it -- the same
|
|
// test a fresh capture passes, run on the stored JPEG rather than on a
|
|
// frame of video.
|
|
const hasPicture = function(src) {
|
|
return new Promise((resolve) => {
|
|
const probe = new Image();
|
|
probe.onload = function() {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = probe.naturalWidth || 1;
|
|
canvas.height = probe.naturalHeight || 1;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(probe, 0, 0);
|
|
try {
|
|
resolve(!isBlank(ctx, canvas.width, canvas.height));
|
|
} catch (err) {
|
|
// No way to tell, so leave it where it is.
|
|
resolve(true);
|
|
}
|
|
};
|
|
probe.onerror = () => resolve(false);
|
|
probe.src = src;
|
|
});
|
|
};
|
|
|
|
// One measurement per item per session, however many cards show it -- and
|
|
// all of them wait on that one and get the replacement. The same item is on
|
|
// screen in more than one place (the favorites bar and the grid behind it),
|
|
// so the first asker getting the picture and the rest being told the kept
|
|
// one stands would leave the black box up on every card but one.
|
|
const rechecks = new Map();
|
|
|
|
// A picture kept by a version of this that did not check its captures can
|
|
// be a frame of nothing -- pure black, and kept for the life of the item,
|
|
// since the only test it had to pass was being a few hundred bytes long.
|
|
// Each unchecked entry is measured once, after the card has already painted
|
|
// what there is, and one with nothing in it is dropped and taken again.
|
|
// Resolves with a replacement picture, or '' when the kept one stands.
|
|
App.posters.recheck = function(video) {
|
|
const key = keyOf(video);
|
|
if (!key) return Promise.resolve('');
|
|
const running = rechecks.get(key);
|
|
if (running) return running;
|
|
const entry = readIndex().find((item) => item.k === key);
|
|
if (!entry || entry.v) return Promise.resolve('');
|
|
const src = read(key);
|
|
if (!src) return Promise.resolve('');
|
|
const run = hasPicture(src).then((keep) => {
|
|
if (keep) {
|
|
// Nothing wrong with it. Said so, so no later session measures
|
|
// this one again.
|
|
entry.v = CHECKED;
|
|
writeIndex();
|
|
return '';
|
|
}
|
|
App.posters.forget(video);
|
|
return App.posters.capture(video);
|
|
}, () => '');
|
|
rechecks.set(key, run);
|
|
return run;
|
|
};
|
|
})();
|