Keep a captured thumbnail only if there is a picture in it

A favorite whose thumbnail is gone gets a frame out of the video instead. The
one this came from got a black box: 1.6 KB of pure black, kept for good, on an
item whose video is black nowhere -- ffmpeg puts its opening frame at YAVG 185
and its quarter mark at 130.

So that was not a frame of the video at all. Drawing a video element that has
not presented a frame gives back the canvas untouched, and transparent black
JPEGs as pure black at about 1.6 KB -- comfortably past the only test a capture
had to pass, which was being over 512 bytes long. Nothing then asked whether
the picture had anything in it, and the cache exists precisely so that nothing
asks again.

The frame now has to be a picture of something, and both ways it can fail to be
one have the same answer: take it somewhere else. The brightest sample in the
drawn frame decides -- an untouched canvas has no painted pixels at all, and a
frame that genuinely is black (a lead-in, a fade, a cut) is no better a
thumbnail than one of nothing. A blank frame moves on: the quarter mark again
with the element playing, since an element that is playing has to present
frames while a seeked one does not, and then the half mark. The frame is taken
on `requestVideoFrameCallback` where there is one -- the browser saying a frame
is on screen now -- rather than on `seeked` alone, which Chromium backs with a
decoded frame but Safari does not for an element that is off-screen and paused.

Nobody will say how long this item is (the extractor reports no duration and
the favorite kept a zero), and a clip with no length had no quarter mark to
seek to, so the capture settled for the first frame the element decoded -- the
opening one, which is the frame most likely to be black. It now seeks blind to
a fixed mark, clamped back into the file if that is past the end.

And the black pictures already kept heal: an index entry carries a marker
saying its picture was checked, every capture writes one, and an entry without
one is measured once -- behind the picture the card has already painted -- then
dropped and taken again if it turns out to be a frame of nothing.

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-21 20:20:05 +00:00
parent 6dac1028a3
commit be058dba37
4 changed files with 547 additions and 27 deletions

View File

@@ -21,10 +21,21 @@ App.posters = App.posters || {};
// 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
@@ -40,9 +51,21 @@ App.posters = App.posters || {};
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 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;
@@ -88,7 +111,11 @@ App.posters = App.posters || {};
parsed.filter((entry) => entry && entry.k).map((entry) => ({
k: String(entry.k),
n: Number(entry.n) || 0,
at: Number(entry.at) || 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 = [];
@@ -110,7 +137,7 @@ App.posters = App.posters || {};
const store = function(key, src) {
const list = readIndex().filter((entry) => entry.k !== key);
list.push({ k: key, n: src.length, at: Date.now() });
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
@@ -186,7 +213,33 @@ App.posters = App.posters || {};
// ---- 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;
@@ -195,15 +248,40 @@ App.posters = App.posters || {};
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 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');
@@ -220,8 +298,11 @@ App.posters = App.posters || {};
'opacity:0;pointer-events:none';
let settled = false;
let timer = null;
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
@@ -229,11 +310,17 @@ App.posters = App.posters || {};
// 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 (timer) clearTimeout(timer);
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');
@@ -242,35 +329,104 @@ App.posters = App.posters || {};
if (err) reject(err);
else resolve(src);
};
const take = function() {
try { done(null, drawFrame(el)); } catch (err) { done(err); }
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();
};
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;
// 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); }
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) take();
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, not the mark itself.
if (asked && el.currentTime < target - 1) return;
take();
// 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();
});
el.addEventListener('loadeddata', () => { if (!asked) take(); });
document.body.appendChild(el);
el.src = url;
@@ -353,4 +509,67 @@ App.posters = App.posters || {};
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;
};
})();

View File

@@ -576,6 +576,15 @@ App.videos = App.videos || {};
const captured = App.posters ? App.posters.cached(video) : '';
if (captured) {
showThumbnail(img, captured, token);
// A picture kept before captures were checked for having anything
// in them can be a frame of nothing -- a black box where the grey
// one used to be. Measured once, behind the picture that is already
// up, and replaced if that is what it turns out to be.
if (typeof App.posters.recheck === 'function') {
App.posters.recheck(video).then((fresh) => {
if (fresh && img.dataset.thumbToken === token) showThumbnail(img, fresh, token);
});
}
return;
}
if (!directUrl) {

BIN
tests/fixtures/dark.mp4 vendored Normal file

Binary file not shown.

292
tests/smoke_poster_black.py Normal file
View File

@@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""A captured thumbnail has to be a picture of something.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_poster_black.py
Two ways a card ends up a black box rather than a picture, both of which the
capture used to keep for the life of the item -- the only test a frame had to
pass was being a few hundred bytes long:
* the frame at the mark is black. A video that opens on a fade, or whose
length nobody will say (so there is no quarter mark to seek to and the
opening frame is all there is), hands over exactly that.
* the element decoded nothing. Drawing one that has not presented a frame
gives an untouched canvas, which is a pure black JPEG about 1.6 KB long.
The fixture is black for its first three and a half seconds -- past the quarter
mark a capture aims for -- and flat orange after, so the picture on the card
says whether anything looked at what it was keeping.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "dark.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
CACHE_KEY = "posterCache"
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [],
}],
}
# Saved with the thumbnail it had at the time, and with nothing known about how
# long it is -- which is the state the item this test comes from is in.
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.removeItem('posterCache');
Object.keys(localStorage).forEach((k) => {
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
});
localStorage.setItem('favorites', JSON.stringify([{
key: 'vid-0',
id: 'vid-0',
url: 'https://fixture.test/v0.mp4',
title: 'kept clip 0',
thumb: 'https://pictures.fixture.test/gone/0.jpg',
channel: channel,
uploader: '',
duration: 0,
isLive: false,
favoriteDate: new Date().toISOString()
}]));
}"""
CAPTURED = """(id) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
if (!card) return false;
const img = card.querySelector('.video-thumb img');
return (img.getAttribute('src') || '').indexOf('data:image/jpeg') === 0;
}"""
# What the card is showing, measured: the brightest sample in it and the
# average, which is all it takes to tell a picture from a black box.
LIGHT = """(id) => new Promise((resolve) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
const img = card ? card.querySelector('.video-thumb img') : null;
const src = img ? (img.getAttribute('src') || '') : '';
if (!src) { resolve(null); return; }
const probe = new Image();
probe.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = probe.naturalWidth;
canvas.height = probe.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(probe, 0, 0);
const px = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let sum = 0, brightest = 0;
for (let i = 0; i < px.length; i += 4) {
const value = Math.max(px[i], px[i + 1], px[i + 2]);
sum += value;
if (value > brightest) brightest = value;
}
resolve({ brightest, mean: Math.round(sum / (px.length / 4)),
bytes: src.length, w: canvas.width, h: canvas.height });
};
probe.onerror = () => resolve(null);
probe.src = src;
})"""
CACHE = """() => JSON.parse(localStorage.getItem('posterCache') || '[]')"""
FORGET = """() => {
localStorage.removeItem('posterCache');
Object.keys(localStorage).forEach((k) => {
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
});
}"""
# The item this test comes from has no duration: the extractor reports none and
# the favorite kept a zero. Where the browser can't supply one either, there is
# no quarter mark to seek to -- and the frame the element decodes on its own is
# the opening one, which is the frame this clip is black for.
NO_DURATION = """
Object.defineProperty(HTMLVideoElement.prototype, 'duration', {
configurable: true,
get: function() { return NaN; }
});
"""
# What the version before this kept: an untouched canvas, JPEG'd. Written over
# the picture in the cache, with the "this was looked at" marker taken off the
# index entry, which is exactly the state those sessions left behind.
POISON = """() => {
const canvas = document.createElement('canvas');
canvas.width = 360;
canvas.height = 203;
const black = canvas.toDataURL('image/jpeg', 0.72);
const index = JSON.parse(localStorage.getItem('posterCache') || '[]');
index.forEach((entry) => {
delete entry.v;
entry.n = black.length;
localStorage.setItem(`posterCache:${entry.k}`, black);
});
localStorage.setItem('posterCache', JSON.stringify(index));
return { black: black, entries: index.length };
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def items():
return [{"id": "vid-0", "title": "clip 0",
"url": "https://fixture.test/v0.mp4",
"thumb": "https://pictures.fixture.test/gone/0.jpg",
"channel": CHANNEL, "duration": 0, "isLive": False, "tags": []}]
def main():
c = Checks()
streamed = []
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
"--autoplay-policy=no-user-gesture-required",
"--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 1180, "height": 920})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
page.route(re.compile(r"pictures\.fixture\.test"),
lambda route: route.fulfill(status=404, body=""))
page.route(re.compile(r"/api/image"),
lambda route: route.fulfill(status=404, body=""))
page.route(re.compile(r"/api/poster"), lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps({"thumb": ""})))
# Byte ranges and all: a response a browser cannot seek in is one it
# will not take a frame out of the middle of.
def stream(route):
streamed.append(route.request.url)
body = open(CLIP, "rb").read()
span = re.match(r"bytes=(\d+)-(\d*)", route.request.headers.get("range") or "")
if span:
start = int(span.group(1))
end = int(span.group(2)) if span.group(2) else len(body) - 1
chunk = body[start:end + 1]
route.fulfill(status=206, body=chunk, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{len(body)}",
"Content-Length": str(len(chunk)),
})
return
route.fulfill(status=200, body=body, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Length": str(len(body)),
})
page.route(re.compile(r"/api/stream"), stream)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
print("\nthe quarter mark of this clip is black")
page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000)
shot = page.evaluate(LIGHT, "vid-0")
c.ok("the card ended up with a captured picture", shot is not None, str(shot))
c.ok("and it is not a black box",
shot and shot["brightest"] > 40, str(shot))
c.ok("it is the orange the clip shows past its lead-in",
shot and shot["mean"] > 60, str(shot))
c.ok("at card width, so it is a thumbnail and not a frame of video",
shot and shot["w"] == 360, str(shot))
print("\nand the picture it kept says it was looked at")
cached = page.evaluate(CACHE)
c.ok("one picture is in the cache", len(cached) == 1, str(cached))
c.ok("marked as checked", cached and cached[0].get("v") == 1, str(cached))
print("\na black picture kept by an earlier version is replaced")
poisoned = page.evaluate(POISON)
c.ok("the cache now holds what that version would have kept",
poisoned["entries"] == 1, str(poisoned["entries"]))
streamed.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
# A flat frame weighs about what a blank one does -- both are a single
# colour -- so what says the card moved on is the picture itself.
page.wait_for_function(
"""([id, black]) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
const img = card && card.querySelector('.video-thumb img');
const src = img ? (img.getAttribute('src') || '') : '';
// The listed picture is on the card while the ladder walks
// down it, so what counts is a captured picture that is not
// the one that was there.
return src.indexOf('data:image/jpeg') === 0 && src !== black;
}""", arg=["vid-0", poisoned["black"]], timeout=60000)
healed = page.evaluate(LIGHT, "vid-0")
c.ok("the card shows a picture again", healed is not None, str(healed))
c.ok("not the black one it was given", healed and healed["brightest"] > 40, str(healed))
c.ok("taken from the video, which was fetched for it",
any("/api/stream" in url for url in streamed), str(streamed[:2]))
kept = page.evaluate(CACHE)
c.ok("and the replacement is what is kept now",
len(kept) == 1 and kept[0].get("v") == 1, str(kept))
print("\nand it is not measured a second time")
streamed.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(2000)
again = page.evaluate(LIGHT, "vid-0")
c.ok("the kept picture is on the card straight away",
again and again["brightest"] > 40, str(again))
c.ok("without going back to the video", streamed == [], str(streamed))
print("\nand a clip nobody will say the length of is not left on frame one")
page.evaluate(FORGET)
page.add_init_script(NO_DURATION)
streamed.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
c.ok("the browser gives no length to go by",
page.evaluate("() => String(document.createElement('video').duration)") == 'NaN')
page.wait_for_function(CAPTURED, arg="vid-0", timeout=60000)
blind = page.evaluate(LIGHT, "vid-0")
c.ok("a frame came out of the video anyway", blind is not None, str(blind))
c.ok("and it is not the black one it opens on",
blind and blind["brightest"] > 40, str(blind))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())