diff --git a/frontend/js/posters.js b/frontend/js/posters.js index 843d556..944f534 100644 --- a/frontend/js/posters.js +++ b/frontend/js/posters.js @@ -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; - asked = true; - try { el.currentTime = target; } catch (err) { done(err); } + // 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) 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; + }; })(); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 52a1d90..fc62318 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -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) { diff --git a/tests/fixtures/dark.mp4 b/tests/fixtures/dark.mp4 new file mode 100644 index 0000000..c293ad3 Binary files /dev/null and b/tests/fixtures/dark.mp4 differ diff --git a/tests/smoke_poster_black.py b/tests/smoke_poster_black.py new file mode 100644 index 0000000..c0164ee --- /dev/null +++ b/tests/smoke_poster_black.py @@ -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())