Files
jacuzzi/tests/smoke_rotate_smooth.py
Simon 6d9cc301a7 Turn the phone in one step, not through every shape
A turn is not one shape change: the viewport passes through every shape
between the one it left and the one it lands in, and each of them
re-letterboxes the picture somewhere else. Painted, that run of hard jumps
is the stutter.

So for the length of a turn the slides are held at the size they already
had, and the moment the viewport stops moving they take the size it ended
at -- measured, not guessed. The picture moves exactly once, straight into
the position and size it then keeps. The device's own `orientationchange`
starts the hold, which is the earliest anything can know a turn is coming
and the one signal that separates it from a window being dragged wider;
failing that, the viewport swapping its long side for its short one does. A
viewport that only changes height is the browser's chrome sliding away, and
is never held.

Underneath: snapping is off while the slides are held, so the container
cannot re-anchor on the old height at the same time; a scroll arriving
during the window is put back in the event rather than a frame later, so
the stale position is never painted; and the per-frame realign only writes
what actually differs instead of costing a layout sixty times a second.

Measured over a recorded turn: the picture moves 12 times -> 2, once per
turn; the worst of those was a 660px jump between two intermediate shapes
and is now the one step into the final size; the reel is never off its step
(was 660px out); and the clip never pauses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-19 15:39:36 +00:00

241 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Turning the phone moves the picture once, not a dozen times.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_rotate_smooth.py
Its sibling smoke_rotate.py asks that a turn keep the same video. This one
asks what the turn looks like while it happens.
A turn is not one shape change: the viewport passes through every shape
between the one it left and the one it lands in, and each one re-letterboxes
the picture somewhere else. Painted, that run of jumps is the stutter. So the
slides are held at the size the turn is heading for from its first frame: the
picture steps straight into the position and size it will keep, and stays
there -- no fade, no intermediate shapes, nothing dimmed.
A viewport that only changes height is the browser's chrome sliding away, not
a wrist, and must not be pinned: every scroll on a phone does that.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
PORTRAIT = {"width": 430, "height": 930}
LANDSCAPE = {"width": 930, "height": 430}
# The shapes a viewport goes through on its way round.
TURN = [{"width": 560, "height": 800}, {"width": 700, "height": 660},
{"width": 830, "height": 520}, LANDSCAPE]
# The chrome sliding away: height only, and nothing announced it.
CHROME = {"width": 430, "height": 830}
STATUS = {"online": True, "channels": [{
"id": CHANNEL, "name": "Fixture Clips",
"description": "A channel that only exists for this test.", "options": []}]}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
# Samples every frame: how much of the reel is on screen, what shape the
# picture has, and whether it is still where it belongs.
WATCH = """() => {
window.__seen = [];
window.__watch = true;
const scroller = document.getElementById('feed-scroll');
const tick = function() {
const active = document.querySelector('.feed-slide.is-active');
const video = active ? active.querySelector('.feed-video') : null;
const box = video ? video.getBoundingClientRect() : null;
window.__seen.push({
opacity: Number(getComputedStyle(scroller).opacity),
pinned: document.getElementById('feed-view').classList.contains('is-turning'),
shape: box ? [Math.round(box.width), Math.round(box.height)] : null,
top: active ? Math.round(active.getBoundingClientRect().top) : null,
paused: video ? video.paused : null
});
if (window.__watch) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}"""
STOP = "() => { window.__watch = false; return window.__seen; }"
REST = """() => {
const scroller = document.getElementById('feed-scroll');
const style = getComputedStyle(scroller);
return {
opacity: Number(style.opacity),
snap: style.scrollSnapType,
overflow: style.overflowY,
shape: (function() {
const box = document.querySelector('.feed-slide.is-active .feed-video');
const rect = box ? box.getBoundingClientRect() : null;
return rect ? [Math.round(rect.width), Math.round(rect.height)] : null;
})(),
turning: document.getElementById('feed-view').classList.contains('is-turning'),
videoId: App.state.feedActiveVideoId,
index: App.state.feedActiveIndex,
scrollTop: Math.round(scroller.scrollTop),
expected: App.state.feedActiveIndex * scroller.clientHeight
};
}"""
GO = """(step) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = step * scroller.clientHeight;
}"""
# The container's own re-anchor, on a position measured against the height
# from before the turn, arriving while the new one is already in place.
STALE = """(oldHeight) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = App.state.feedActiveIndex * oldHeight;
}"""
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": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
def shape_changes(seen):
"""The frames where the picture changed shape, with how lit they were."""
out = []
for a, b in zip(seen, seen[1:]):
if a["shape"] and b["shape"] and a["shape"] != b["shape"]:
out.append({"from": a["shape"], "to": b["shape"], "opacity": b["opacity"]})
return out
def do_turn(page, sizes, old_height, announced=True):
page.evaluate(WATCH)
page.wait_for_timeout(120)
if announced:
page.evaluate("() => window.dispatchEvent(new Event('orientationchange'))")
for i, size in enumerate(sizes):
page.set_viewport_size(size)
if i == 1:
page.evaluate(STALE, old_height)
page.wait_for_timeout(70)
page.wait_for_timeout(1400)
return page.evaluate(STOP)
def main():
c = Checks()
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=PORTRAIT)
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"\.mp4"), lambda route: route.fulfill(
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
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)
page.wait_for_timeout(500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(800)
for step in (1, 2, 3):
page.evaluate(GO, step)
page.wait_for_timeout(250)
page.wait_for_timeout(800)
before = page.evaluate(REST)
print("\nturning it sideways, one shape at a time")
seen = do_turn(page, TURN, PORTRAIT["height"])
changes = shape_changes(seen)
rest = page.evaluate(REST)
c.ok("the picture moved once, not through every shape on the way",
len(changes) == 1, str(changes))
c.ok("and the one step it took was straight into the size it keeps",
bool(changes) and changes[0]["to"] == rest["shape"],
f"{changes} -> {rest['shape']}")
c.ok("nothing was dimmed to cover it",
all(s["opacity"] == 1 for s in seen),
str(min(s["opacity"] for s in seen)))
c.ok("the reel stayed pinned to its step the whole way",
all(s["top"] in (None, 0) for s in seen),
str(sorted({s["top"] for s in seen})))
c.ok("the slides were held only for the turn, not after it",
0 < len([s for s in seen if s["pinned"]]) < len(seen) * 0.75,
f"{len([s for s in seen if s['pinned']])} of {len(seen)} frames")
c.ok("the clip never paused for it",
not any(s["paused"] for s in seen), "")
c.ok("and the feed is no longer marked as turning", not rest["turning"], str(rest))
c.ok("snapping is back on", "y" in rest["snap"], str(rest["snap"]))
c.ok("and so is scrolling", rest["overflow"] == "scroll", str(rest["overflow"]))
c.ok("on the same video it was on",
rest["videoId"] == before["videoId"],
f"{before['videoId']} -> {rest['videoId']}")
c.ok("sitting on its snap point",
abs(rest["scrollTop"] - rest["expected"]) <= 1, str(rest))
print("\nand back upright")
seen = do_turn(page, list(reversed(TURN[:-1])) + [PORTRAIT], LANDSCAPE["height"])
changes = shape_changes(seen)
back = page.evaluate(REST)
c.ok("one step on the way back too", len(changes) == 1, str(changes))
c.ok("into the size it keeps",
bool(changes) and changes[0]["to"] == back["shape"],
f"{changes} -> {back['shape']}")
c.ok("still the same video", back["videoId"] == before["videoId"], str(back))
print("\nthe browser's chrome sliding away is not a turn")
seen = do_turn(page, [CHROME], PORTRAIT["height"], announced=False)
c.ok("the slides were never pinned for it",
not any(s["pinned"] for s in seen), "")
page.set_viewport_size(PORTRAIT)
page.wait_for_timeout(1200)
chrome = page.evaluate(REST)
c.ok("and it is still on the same video",
chrome["videoId"] == before["videoId"], str(chrome))
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())