Load a split step's videos together, and three steps ahead
Preloading was counted in steps and then shared out between the panels, so with four panels only the very next step was ready and each panel did its resolving and buffering at the moment it was swiped to -- four panels, four starts in a row. It is now counted per panel: every panel keeps its next three videos ready (N * 3 for a step of N), the rendered window reaches far enough forward to hold them, and the step right behind the one being watched buffers for real rather than taking its metadata alone. Their formats are resolved up front and all at once, rather than one panel at a time as each is swiped to. The listing is fetched several pages at a time to keep up: a page number is claimed before its request goes out, which is what lets pages overlap, and finished pages wait their turn so the reader still gets them in order. A page whose request fails puts its number back rather than leaving a hole. How far ahead to run scales with the split, since a step of N panels gets through a page N times as fast. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
332
tests/smoke_parallel.py
Normal file
332
tests/smoke_parallel.py
Normal file
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A split step loads its videos together and ahead of time, not one by one.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_parallel.py
|
||||
|
||||
The listing and the media are fixtures, so what is measured is the feed and not
|
||||
somebody's CDN: every clip takes the same MEDIA_DELAY_MS to arrive, which is
|
||||
far longer than a step that was preloaded needs to start.
|
||||
|
||||
Four panels, which is the case this is for. With one step preloaded between
|
||||
them, a panel did its loading at the moment it was swiped to -- on a real CDN
|
||||
that is four starts in a row, and here it is four late ones. The second swipe
|
||||
is where it shows: the first lands on the step that was preloaded either way,
|
||||
and it comes quickly enough that the step after it cannot have loaded since.
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
PER_PAGE = 12
|
||||
PAGES = 8
|
||||
MEDIA_DELAY_MS = 2000 # what a clip costs if it isn't already loading
|
||||
READY_MS = 400 # a start this quick means the clip was already there
|
||||
|
||||
# One second of black, 64x64, h264 -- a real file, because the panels have to
|
||||
# decode a frame from it. Same blob as smoke_open.py.
|
||||
MP4_B64 = (
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||
)
|
||||
|
||||
def make_server():
|
||||
"""Serves the fixture clip, after a delay, however many at a time."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
time.sleep(MEDIA_DELAY_MS / 1000)
|
||||
body = base64.b64decode(MP4_B64)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Accept-Ranges", "none")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_HEAD(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
# Which videos were asked to resolve their formats. Prefilled formats make the
|
||||
# answer immediate, but the asking is what says when the feed goes looking.
|
||||
SPY = """() => {
|
||||
window.__resolved = [];
|
||||
const real = App.videos.ensureFormats;
|
||||
App.videos.ensureFormats = function(video) {
|
||||
if (video && video.id) window.__resolved.push(String(video.id));
|
||||
return real.apply(this, arguments);
|
||||
};
|
||||
}"""
|
||||
|
||||
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
|
||||
step: Number(slide.dataset.step),
|
||||
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
|
||||
.map((pane) => pane.classList.contains('is-loaded')),
|
||||
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
|
||||
}))"""
|
||||
|
||||
WATCH = """() => {
|
||||
window.__t0 = performance.now();
|
||||
window.__started = {};
|
||||
document.addEventListener('playing', (e) => {
|
||||
const pane = e.target.closest && e.target.closest('.feed-pane');
|
||||
if (!pane) return;
|
||||
const id = pane.dataset.videoId;
|
||||
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
|
||||
}, true);
|
||||
}"""
|
||||
|
||||
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
|
||||
|
||||
CLICK = """(sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return 'missing';
|
||||
el.click(); // dispatched, so the pointer never wakes the HUD
|
||||
return 'ok';
|
||||
}"""
|
||||
|
||||
|
||||
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 make_items(page_no, media):
|
||||
"""One page of the listing, formats already resolved so nothing but the
|
||||
media itself stands between a panel and its first frame."""
|
||||
start = (page_no - 1) * PER_PAGE
|
||||
items = []
|
||||
for i in range(PER_PAGE):
|
||||
n = start + i
|
||||
url = f"{media}/v{n}.mp4"
|
||||
items.append({
|
||||
"id": f"fix-{n}",
|
||||
"title": f"Fixture {n}",
|
||||
"url": "https://example.test/watch/%d" % n,
|
||||
"channel": "test",
|
||||
"duration": 1,
|
||||
"thumb": "",
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": url,
|
||||
"http_headers": {},
|
||||
"isLive": False,
|
||||
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||
"vcodec": "avc1", "acodec": "none"}],
|
||||
},
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
httpd = make_server()
|
||||
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
# Listing requests are parked as they arrive and answered a beat later, so
|
||||
# how many the feed has out at once is something the test can see.
|
||||
parked = []
|
||||
busiest = {"max": 0}
|
||||
|
||||
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=768",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 800, "height": 900})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
def park(route):
|
||||
parked.append(route)
|
||||
busiest["max"] = max(busiest["max"], len(parked))
|
||||
|
||||
def answer():
|
||||
while parked:
|
||||
route = parked.pop(0)
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
page_no = int(body.get("page", 1) or 1)
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps({
|
||||
"items": make_items(page_no, media) if page_no <= PAGES else [],
|
||||
"pageInfo": {"hasNextPage": page_no < PAGES},
|
||||
}))
|
||||
|
||||
def settle(ms):
|
||||
"""Let the page run, answering listing requests as they pile up."""
|
||||
for _ in range(max(1, ms // 100)):
|
||||
page.wait_for_timeout(100)
|
||||
answer()
|
||||
|
||||
page.route("**/api/videos", park)
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
settle(1500)
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.evaluate(SPY)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
settle(3000)
|
||||
|
||||
print("\nsplitting the step four ways")
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-right")
|
||||
settle(1500)
|
||||
for nth in (1, 2):
|
||||
page.evaluate(CLICK, f".feed-slide.is-active .feed-pane:nth-of-type({nth}) .feed-pane-split-down")
|
||||
settle(1500)
|
||||
settle(3000)
|
||||
per = page.evaluate("() => App.feed.paneCount()")
|
||||
c.ok("four panels", per == 4, str(per))
|
||||
|
||||
panes = page.evaluate(PANES)
|
||||
active = page.evaluate("() => App.state.feedActiveIndex")
|
||||
ahead = [s for s in panes if s["step"] > active]
|
||||
loaded_ahead = sum(1 for s in ahead for is_loaded in s["loaded"] if is_loaded)
|
||||
want = per * 3
|
||||
print(f" ({loaded_ahead} videos loading ahead, over {len(ahead)} steps)")
|
||||
c.ok(f"every panel keeps its next three videos ready ({want} of them)",
|
||||
loaded_ahead >= want, f"loaded {loaded_ahead} of {sum(len(s['loaded']) for s in ahead)}")
|
||||
|
||||
resolved = set(page.evaluate("() => window.__resolved"))
|
||||
missing = [i for s in ahead for i in s["ids"] if i not in resolved]
|
||||
c.ok("and their formats were asked for up front, not at load time",
|
||||
not missing, f"never resolved: {missing[:4]}")
|
||||
|
||||
def swipe_and_time(label, watch_ms):
|
||||
page.evaluate(WATCH)
|
||||
page.evaluate(SWIPE)
|
||||
settle(watch_ms)
|
||||
started = page.evaluate("() => window.__started")
|
||||
ids = page.evaluate("""() => Array.from(
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-pane')).map(p => p.dataset.videoId)""")
|
||||
times = [started[i] for i in ids if i in started]
|
||||
print(f" ({label}: started at {sorted(times)} ms after the swipe)")
|
||||
return ids, times
|
||||
|
||||
print("\nswiping to the next step")
|
||||
# Measured briefly, and on purpose: the step after this one must not get
|
||||
# a quiet moment to load in, or the swipe below tests nothing.
|
||||
ids, times = swipe_and_time("first swipe", 800)
|
||||
c.ok("it landed on the next step",
|
||||
page.evaluate("() => App.state.feedActiveIndex") == active + 1)
|
||||
c.ok("all four panels started, together",
|
||||
len(times) == len(ids) and (max(times) - min(times)) < READY_MS,
|
||||
f"{len(times)} of {len(ids)} started; at {sorted(times)}")
|
||||
|
||||
print("\nand straight on to the one after it")
|
||||
ids, times = swipe_and_time("second swipe", 3000)
|
||||
c.ok("it landed there too",
|
||||
page.evaluate("() => App.state.feedActiveIndex") == active + 2)
|
||||
c.ok("every panel of it is playing",
|
||||
page.evaluate("""() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-video'))
|
||||
.every(v => !v.paused)"""))
|
||||
c.ok("all four started, together",
|
||||
len(times) == len(ids) and (max(times) - min(times)) < READY_MS,
|
||||
f"{len(times)} of {len(ids)} started; at {sorted(times)}")
|
||||
c.ok(f"and at once, because the clips were already there (a clip takes {MEDIA_DELAY_MS}ms)",
|
||||
times and max(times) < READY_MS,
|
||||
f"slowest {max(times) if times else 'n/a'}ms")
|
||||
|
||||
print("\nfeeding the split")
|
||||
c.ok("more than one page of listing is fetched at a time",
|
||||
busiest["max"] > 1, f"most at once: {busiest['max']}")
|
||||
now_active = page.evaluate("() => App.state.feedActiveIndex")
|
||||
c.ok("and the buffer stays ahead of what a swipe eats",
|
||||
page.evaluate("() => App.state.loadedVideos.length") > (now_active + 1) * per + per * 3,
|
||||
str(page.evaluate("() => App.state.loadedVideos.length")))
|
||||
|
||||
print("\nclosing a panel")
|
||||
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
|
||||
settle(2000)
|
||||
c.ok("what is left plays on", page.evaluate("""() => {
|
||||
const v = document.querySelector('.feed-slide.is-active .feed-video');
|
||||
return !!v && !v.paused;
|
||||
}"""))
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
httpd.shutdown()
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user