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
293 lines
12 KiB
Python
293 lines
12 KiB
Python
#!/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())
|