Files
spoopyghosty0--frameport/scripts/showcase/web.py
T
spoopyghosty0 27694243f2 Screenshots: copy an image to the clipboard; holiday showers; perfect pacing stays
- Screenshots tab: Copy image (a button on the card under the pointer, the right-click menu and the viewer) puts
  the full-size screenshot on this PC's clipboard (core/clipboard.py: Windows/WSL PowerShell bitmap + file,
  macOS osascript, Linux wl-copy/xclip; Flet's set_image in web mode)
- Clicking a holiday badge showers the window: New Year confetti, winter snow, Valentine hearts, digits of pi,
  clovers, hopping eggs, bouncing pumpkins (moved like the confetti: one page update per phase)
- Perfect pacing after 2.5 minutes on target; the Perfect pacing pill and line stay until the rate slips
- Full charge: only the ring pulses (with a glow behind it), the percentage stays put
- Upload hop a little slower
2026-10-09 15:59:02 -04:00

537 lines
24 KiB
Python

"""Serve the real GUI in web mode with a pretend Frame and drive it with headless Chromium (Playwright), for docs
screenshots and the demo tour.
`Server(...).run(script)` owns the main thread (Flet's `ft.run` must) and runs `script(server)` in a thread; the
script opens browser sessions with `server.session(...)`: one Playwright context + page and the FramePortApp that
page started (one app per browser session). Elements are found by name through Flutter's semantics tree (the
accessibility DOM Flutter web builds on request): visible text, a tooltip or a semantics label; the pointer events
still go to Flutter's canvas, so hover effects and gestures behave as with a real mouse.
"""
from __future__ import annotations
import json
import math
import os
import queue
import socket
import threading
import time
import traceback
from dataclasses import dataclass
from pathlib import Path
import flet as ft
from frameport.ui.app import FramePortApp
from showcase import fakes
PORT = int(os.environ.get("FRAMEPORT_SHOWCASE_PORT", "8571"))
CHROMIUM_ARGS = ["--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--font-render-hinting=none"]
REPO = Path(__file__).resolve().parents[2]
# Flutter builds its semantics DOM only once something asks for it (a screen reader): the placeholder button does.
# The semantics nodes then sit above the canvas; with pointer-events off they don't take the mouse.
_ENABLE_SEMANTICS = """() => {
const p = document.querySelector('flt-semantics-placeholder');
if (p) p.click();
if (!document.getElementById('fp-sem-style')) {
const s = document.createElement('style');
s.id = 'fp-sem-style';
s.textContent = 'flt-semantics-host, flt-semantics, flt-semantics * { pointer-events: none !important; }';
document.head.appendChild(s);
}
return !!p;
}"""
# every named node: its own text, aria-label or the text of its children, with its box on screen
_NODES = """() => [...document.querySelectorAll('flt-semantics')].map(e => {
const r = e.getBoundingClientRect();
const own = [...e.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join(' ').trim();
return {label: (e.getAttribute('aria-label') || '').trim(), text: own, all: (e.innerText || '').trim(),
role: e.getAttribute('role') || '', tappable: e.hasAttribute('flt-tappable'),
x: r.x, y: r.y, w: r.width, h: r.height};
}).filter(n => n.w > 0 && n.h > 0)"""
# the visible pointer for recordings (headless Chromium draws none): an arrow that follows the mouse, a ring on press
CURSOR_JS = """(() => {
if (window.__fpCursor) return;
window.__fpCursor = true;
const add = () => {
const c = document.createElement('div');
c.id = 'fp-cursor';
c.innerHTML = '<svg width="28" height="28" viewBox="0 0 28 28"><path d="M4 2 L4 22 L9.5 17 L13 25 L16.5 23.5 ' +
'L13 15.8 L20.5 15.8 Z" fill="#fff" stroke="#111" stroke-width="1.6" stroke-linejoin="round"/></svg>';
Object.assign(c.style, {position: 'fixed', left: '-40px', top: '-40px', width: '28px', height: '28px',
pointerEvents: 'none', zIndex: 2147483647, filter: 'drop-shadow(0 2px 3px rgba(0,0,0,.45))',
transform: 'translate(-3px,-2px)'});
document.documentElement.appendChild(c);
const ring = (x, y) => {
const r = document.createElement('div');
Object.assign(r.style, {position: 'fixed', left: (x - 18) + 'px', top: (y - 18) + 'px', width: '36px',
height: '36px', borderRadius: '50%', border: '3px solid rgba(255,140,40,.95)', pointerEvents: 'none',
zIndex: 2147483646, transition: 'transform .45s ease-out, opacity .45s ease-out', transform: 'scale(.4)',
opacity: '1'});
document.documentElement.appendChild(r);
requestAnimationFrame(() => { r.style.transform = 'scale(1.5)'; r.style.opacity = '0'; });
setTimeout(() => r.remove(), 600);
};
const move = e => { c.style.left = e.clientX + 'px'; c.style.top = e.clientY + 'px'; };
window.addEventListener('pointermove', move, true);
window.addEventListener('mousemove', move, true);
window.addEventListener('pointerdown', e => ring(e.clientX, e.clientY), true);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add();
})();"""
@dataclass
class Box:
x: float
y: float
w: float
h: float
@property
def center(self) -> tuple[float, float]:
return self.x + self.w / 2, self.y + self.h / 2
class NotFound(LookupError):
pass
def names_of(node: dict) -> list[str]:
"""What a semantics node is called: its label (tooltip), its own text and each line of its children's text
(a card reads "Click to open…\\nQuest\\nBatman: Arkham Shadow\\nWorks")."""
out = [node.get("label") or "", node.get("text") or ""]
for key in ("label", "text", "all"):
out += (node.get(key) or "").splitlines()
return [s.strip().casefold() for s in out if s and s.strip()]
def _contains(outer: dict, inner: dict) -> bool:
"""outer's box holds inner's centre and is bigger: a container of inner (a list around its row)."""
cx, cy = inner["x"] + inner["w"] / 2, inner["y"] + inner["h"] / 2
return (outer["x"] <= cx <= outer["x"] + outer["w"] and outer["y"] <= cy <= outer["y"] + outer["h"]
and outer["w"] * outer["h"] > inner["w"] * inner["h"] * 1.2)
def innermost(nodes: list[dict]) -> list[dict]:
"""The nodes that don't contain another one of them (a list's text includes its rows': the row wins)."""
return [n for n in nodes if not any(m is not n and _contains(n, m) for m in nodes)]
def pick(nodes: list[dict], name: str, nth: int = 0, exact: bool | None = None) -> dict | None:
"""The nth node called `name`: exact names first, then names starting with it, then containing it (unless
exact). Tappable nodes (buttons, cards) win, top to bottom and left to right; else the plain text itself, i.e.
the smallest node (the containers around it carry its text too)."""
want = name.strip().casefold()
tests = [lambda s: s == want]
if not exact:
tests += [lambda s: s.startswith(want), lambda s: want in s]
for test in tests:
hits = [n for n in nodes if any(test(s) for s in names_of(n))]
tappable = innermost([n for n in hits if n.get("tappable")])
if tappable:
tappable.sort(key=lambda n: (round(n["y"]), round(n["x"]), n["w"] * n["h"]))
return tappable[nth] if nth < len(tappable) else None
if hits:
hits.sort(key=lambda n: (n["w"] * n["h"], round(n["y"]), round(n["x"])))
return hits[nth] if nth < len(hits) else None
return None
def ease(t: float) -> float:
"""Ease-in-out (smoothstep of a smoothstep): slow start, fast middle, gentle landing."""
t = max(0.0, min(1.0, t))
t = t * t * (3 - 2 * t)
return t * t * (3 - 2 * t)
def human_path(start: tuple[float, float], end: tuple[float, float], steps: int, bend: float = 0.12
) -> list[tuple[float, float]]:
"""Points from start to end along a slight arc (a quadratic Bezier whose control point sits `bend` of the
distance off the straight line), spaced by `ease`: the path a hand takes, not a ruler line."""
(x0, y0), (x1, y1) = start, end
dx, dy = x1 - x0, y1 - y0
dist = math.hypot(dx, dy)
cx, cy = (x0 + x1) / 2 - dy * bend, (y0 + y1) / 2 + dx * bend # perpendicular offset
if dist < 1:
return [end] * max(1, steps)
out = []
for i in range(1, steps + 1):
t = ease(i / steps)
out.append(((1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1,
(1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1))
return out
def move_duration(dist: float) -> float:
"""Seconds for a pointer hop of `dist` px: ~0.45 s for short hops, up to ~0.9 s across the window."""
return max(0.35, min(0.9, 0.3 + dist / 1800))
class Session:
"""One browser page showing one FramePortApp."""
def __init__(self, server: Server, ctx, page, app: FramePortApp, cursor: bool):
self.server, self.ctx, self.page, self.app = server, ctx, page, app
self.mouse_at = (0.0, 0.0)
self.cursor = cursor
self.semantics = False
self.browser = None
self.park_at: tuple[float, float] | None = None
def sleep(self, seconds: float) -> None:
"""Wait while the browser keeps going (Playwright's event loop runs, so a screencast keeps its frames)."""
if seconds > 0:
self.page.wait_for_timeout(seconds * 1000)
# ------------------------------------------------------------ finding things
def enable_semantics(self) -> None:
if not self.semantics:
self.semantics = bool(self.page.evaluate(_ENABLE_SEMANTICS))
self.sleep(0.5)
def nodes(self) -> list[dict]:
self.enable_semantics()
return self.page.evaluate(_NODES)
def find(self, name: str, within: Box | None = None, nth: int = 0, exact: bool | None = None,
timeout: float = 8.0) -> Box:
"""The box of the element called `name` (its text, tooltip or label; exact match first, then the start of
the text, then anywhere in it), optionally inside `within`; the smallest tappable one wins ties. Waits up
to `timeout` s for it to appear."""
deadline = time.monotonic() + timeout
while True:
vw, vh = self.viewport
nodes = [n for n in self.nodes() if 0 <= n["x"] + n["w"] / 2 <= vw and 0 <= n["y"] + n["h"] / 2 <= vh]
if within:
nodes = [n for n in nodes if within.x <= n["x"] + n["w"] / 2 <= within.x + within.w
and within.y <= n["y"] + n["h"] / 2 <= within.y + within.h]
hit = pick(nodes, name, nth, exact)
if hit:
return Box(hit["x"], hit["y"], hit["w"], hit["h"])
if time.monotonic() > deadline:
raise NotFound(f"no element named {name!r} on screen")
self.sleep(0.3)
def dialog(self, timeout: float = 8.0) -> Box:
"""The box of the topmost open dialog (Flutter labels a dialog's surface "Alert")."""
deadline = time.monotonic() + timeout
while True:
boxes = [n for n in self.nodes() if n["label"] == "Alert"]
if boxes:
n = boxes[-1]
return Box(n["x"], n["y"], n["w"], n["h"])
if time.monotonic() > deadline:
raise NotFound("no dialog open")
self.sleep(0.3)
def exists(self, name: str, timeout: float = 0.0) -> bool:
try:
self.find(name, timeout=timeout)
return True
except NotFound:
return False
@property
def viewport(self) -> tuple[int, int]:
v = self.page.viewport_size
return v["width"], v["height"]
# ------------------------------------------------------------ the pointer
def point(self, target) -> tuple[float, float]:
"""A target: a name (see find), a Box, an (x, y) pair, or {"name": …, "dx": …, "dy": …, "nth": …}
(dx/dy: offset from the box's centre in px, or as a fraction of its size when |d| < 1)."""
if isinstance(target, (tuple, list)) and len(target) == 2 and all(isinstance(v, (int, float)) for v in target):
return float(target[0]), float(target[1])
if isinstance(target, Box):
return target.center
if isinstance(target, str):
return self.find(target).center
if isinstance(target, dict):
box = self.find(target["name"], nth=target.get("nth", 0), exact=target.get("exact"))
cx, cy = box.center
dx, dy = target.get("dx", 0), target.get("dy", 0)
dx = dx * box.w if abs(dx) < 1 else dx
dy = dy * box.h if abs(dy) < 1 else dy
return cx + dx, cy + dy
raise TypeError(f"not a target: {target!r}")
def move(self, target, duration: float | None = None, smooth: bool = True) -> tuple[float, float]:
x, y = self.point(target)
if not smooth:
self.page.mouse.move(x, y)
else:
dist = math.hypot(x - self.mouse_at[0], y - self.mouse_at[1])
duration = move_duration(dist) if duration is None else duration
steps = max(2, int(duration * 60))
t0 = time.monotonic()
for i, (px, py) in enumerate(human_path(self.mouse_at, (x, y), steps)):
self.page.mouse.move(px, py)
wait = t0 + duration * (i + 1) / steps - time.monotonic()
if wait > 0:
self.sleep(wait)
self.mouse_at = (x, y)
return x, y
def click(self, target, button: str = "left", smooth: bool = True, pause: float = 0.15) -> None:
self.move(target, smooth=smooth)
if isinstance(target, (str, dict)): # the layout moved while the pointer travelled (a list reloaded): follow
x, y = self.point(target)
if math.hypot(x - self.mouse_at[0], y - self.mouse_at[1]) > 6:
self.move((x, y), duration=0.25)
self.sleep(pause)
self.page.mouse.down(button=button)
self.sleep(0.07)
self.page.mouse.up(button=button)
def drag(self, points: list, smooth: bool = True) -> None:
self.move(points[0], smooth=smooth)
self.sleep(0.2)
self.page.mouse.down()
for p in points[1:]:
self.move(p, smooth=smooth, duration=0.6)
self.sleep(0.2)
self.page.mouse.up()
def type(self, text: str, delay: float = 0.11) -> None:
self.page.keyboard.type(text, delay=int(delay * 1000))
def press(self, key: str) -> None:
self.page.keyboard.press(key)
def scroll(self, dy: float, at=None) -> None:
if at is not None:
self.move(at)
for _ in range(max(1, int(abs(dy) // 100))):
self.page.mouse.wheel(0, 100 if dy > 0 else -100)
self.sleep(0.05)
def park(self, away: bool = False) -> None:
"""The pointer somewhere it hovers nothing: the sidebar's empty part (videos: the drawn cursor stays in the
picture), or with `away` off the page (still pictures: nothing hovered, whatever the layout)."""
if away:
self.page.mouse.move(-40, -40)
self.mouse_at = (-40.0, -40.0)
return
if self.park_at is None: # just below the sidebar's last tab (Settings): nothing there takes the pointer
try:
tab = self.find("Settings", timeout=3)
self.park_at = (tab.x + tab.w * 0.6, tab.y + tab.h + 44)
except NotFound:
self.park_at = self.server.park_point(self.viewport)
self.page.mouse.move(*self.park_at)
self.mouse_at = self.park_at
# ------------------------------------------------------------ pictures
def settle(self, timeout: float = 8.0, interval: float = 0.15, quiet: int = 4, mask: list[Box] = ()) -> bool:
"""Wait until the page stops changing: `quiet` screenshots in a row (interval apart) are identical (masked
boxes, e.g. a live chart, ignored). False when it was still changing at `timeout`."""
deadline, last, same = time.monotonic() + timeout, None, 0
while time.monotonic() < deadline:
png = self._raw(mask)
same = same + 1 if png == last else 0
if same >= quiet:
return True
last = png
self.sleep(interval)
return False
def _raw(self, mask: list[Box] = ()) -> bytes:
if not mask:
return self.page.screenshot(animations="disabled", caret="hide")
from io import BytesIO
from PIL import Image, ImageDraw
im = Image.open(BytesIO(self.page.screenshot(caret="hide")))
d = ImageDraw.Draw(im)
for b in mask:
d.rectangle((b.x, b.y, b.x + b.w, b.y + b.h), fill=(0, 0, 0))
return im.tobytes()
def shot(self, path: Path, clip: Box | None = None, settle: bool = True) -> Path:
if settle:
self.settle()
path.parent.mkdir(parents=True, exist_ok=True)
kw = {"clip": {"x": clip.x, "y": clip.y, "width": clip.w, "height": clip.h}} if clip else {}
self.page.screenshot(path=str(path), caret="hide", **kw)
return path
def close(self) -> None:
"""Close the window and leave nothing behind for the next one: the job queue is shared by every session
(a pretend install would show in the next window's Activity), the live stream's ffmpeg and the monitor
stream would run on, and a closed page must not get job events."""
app = self.app
try:
app.jobs.unsubscribe(app._on_job)
for job in list(app.jobs.jobs):
if job.active:
app.jobs.cancel(job)
deadline = time.monotonic() + 5
while any(j.active for j in app.jobs.jobs) and time.monotonic() < deadline:
self.sleep(0.1)
app.jobs.clear_finished()
fakes.stop_fake_live(app)
app.stop_monitor()
except Exception: # noqa: BLE001
pass
for thing in (self.ctx, self.browser):
try:
if thing is not None:
thing.close()
except Exception: # noqa: BLE001
pass
class Server:
"""The GUI on 127.0.0.1:PORT (web mode) with fakes installed; `run(script)` serves until the script returns."""
def __init__(self, port: int = PORT, fake_frame: bool = True, monitor_session=None, game: str | None = None,
scale: float = 1.0):
self.port, self.fake_frame, self.scale = port, fake_frame, scale
self.monitor_session = monitor_session or fakes.FakeMonitorSession
self.apps: queue.Queue = queue.Queue()
self.errors: list[str] = []
self.playwright = None
self.game = game
def park_point(self, viewport: tuple[int, int]) -> tuple[float, float]:
return 130 * self.scale, viewport[1] * 0.62
def _app_main(self, page: ft.Page) -> None:
try:
app = FramePortApp(page)
if self.fake_frame:
fakes.attach_fake_frame(app, self.monitor_session)
except Exception: # noqa: BLE001
self.errors.append("startup: " + traceback.format_exc())
return
self.apps.put(app)
def session(self, viewport: tuple[int, int], cursor: bool = False, video_dir: Path | None = None,
device_scale: float = 1.0) -> Session:
"""A new browser context + page (fresh app). `video_dir`: Playwright's own recorder (fallback capture)."""
while not self.apps.empty(): # apps from sessions already closed
self.apps.get_nowait()
kw = {}
if video_dir is not None:
kw = {"record_video_dir": str(video_dir), "record_video_size": {"width": viewport[0],
"height": viewport[1]}}
# a browser of its own per window: windows sharing one browser (its GPU process) sometimes drew text with
# glyphs from the wrong place ("/sdcard/ʍvies")
browser = self.playwright.chromium.launch(args=CHROMIUM_ARGS)
# (clipboard: the Screenshots tab's Copy image writes an image to the browser's clipboard in web mode)
ctx = browser.new_context(viewport={"width": viewport[0], "height": viewport[1]},
device_scale_factor=device_scale, permissions=["clipboard-read", "clipboard-write"],
**kw)
if cursor:
ctx.add_init_script(CURSOR_JS)
page = ctx.new_page()
page.goto(f"http://127.0.0.1:{self.port}", wait_until="networkidle", timeout=120_000)
app = self.apps.get(timeout=120)
s = Session(self, ctx, page, app, cursor)
s.browser = browser
# web fonts: text measured before they load wraps or overlaps ("Q / uest"): a text-heavy first screen (the
# Settings page) takes them before the steps start
s.sleep(1.0)
app.go("settings")
s.sleep(2.0)
s.enable_semantics()
s.park()
return s
def run(self, script) -> int:
"""Serve; `script(server) -> exit code` runs in a thread with the browser ready; the process ends with it."""
from frameport.ui.app import serialize_flet_updates
serialize_flet_updates() # as the app's main() does: patches and property writes don't interleave
if self.fake_frame:
fakes.install_fakes(self.game, self.monitor_session)
def runner():
from playwright.sync_api import sync_playwright
for _ in range(90):
try:
socket.create_connection(("127.0.0.1", self.port), timeout=1).close()
break
except OSError:
time.sleep(1)
time.sleep(1.5)
code = 1
try:
with sync_playwright() as p:
self.playwright = p
code = script(self) or 0
except Exception: # noqa: BLE001
self.errors.append("script: " + traceback.format_exc())
for e in self.errors:
print("ERROR", e, flush=True)
os._exit(1 if self.errors else code)
from frameport.ui.app import assets_dir
threading.Thread(target=runner, daemon=True).start()
ft.run(self._app_main, view=ft.AppView.WEB_BROWSER, port=self.port, assets_dir=assets_dir())
return 1
class Screencast:
"""The page's frames as Chrome paints them (CDP Page.startScreencast: JPEGs with timestamps), written to a folder.
Sharper than Playwright's own recorder (VP8 at a low bit rate). Chrome sends a frame only when the page changed,
so the frames come with their times and are turned into a constant frame rate later (postprod.frames_to_video).
Frames only arrive while Playwright runs (Session.sleep, mouse moves), never during time.sleep."""
def __init__(self, session: Session, folder: Path, quality: int = 92):
import base64
self._b64 = base64.b64decode
self.session, self.folder, self.quality = session, Path(folder), quality
self.folder.mkdir(parents=True, exist_ok=True)
self.frames: list[tuple[float, str]] = [] # (wall-clock time, file name)
self.cdp = session.ctx.new_cdp_session(session.page)
self.cdp.on("Page.screencastFrame", self._frame)
self.running = False
def _frame(self, params: dict) -> None:
name = f"{len(self.frames):06d}.jpg"
(self.folder / name).write_bytes(self._b64(params["data"]))
self.frames.append((float(params["metadata"].get("timestamp") or time.time()), name))
try:
self.cdp.send("Page.screencastFrameAck", {"sessionId": params["sessionId"]})
except Exception: # noqa: BLE001 (stopping)
pass
def start(self) -> None:
vw, vh = self.session.viewport
scale = self.session.page.evaluate("window.devicePixelRatio") or 1
self.cdp.send("Page.startScreencast", {"format": "jpeg", "quality": self.quality,
"maxWidth": int(vw * scale), "maxHeight": int(vh * scale),
"everyNthFrame": 1})
self.running = True
def stop(self) -> None:
if self.running:
self.session.sleep(0.3)
self.cdp.send("Page.stopScreencast")
self.running = False
def write_index(self) -> Path:
"""frames.json: [[time, file], …] for postprod."""
path = self.folder / "frames.json"
path.write_text(json.dumps(self.frames))
return path
def call(session: Session, fn, *args, **kwargs):
"""Run an app action from the script thread, logging (not raising) its errors so one broken step doesn't end a
whole render."""
try:
return fn(*args, **kwargs)
except Exception: # noqa: BLE001
session.server.errors.append(f"{getattr(fn, '__name__', fn)}: " + traceback.format_exc())
return None