2026-08-15 13:41:34 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Regenerate the five F-Droid phone screenshots from public_html/index.html.
|
|
|
|
|
|
|
|
|
|
python3 tools/screenshots.py
|
|
|
|
|
|
2026-08-15 14:51:06 +02:00
|
|
|
They are rendered rather than photographed, so they can be redone whenever the
|
|
|
|
|
UI changes. Output is 1170x2532 — 390x844 CSS pixels at device scale 3, an
|
|
|
|
|
iPhone-sized frame that F-Droid is happy with.
|
|
|
|
|
|
|
|
|
|
Near-exact, not exact: two runs of identical code differ by a 4x13 pixel sliver
|
|
|
|
|
at the delay bar's fill edge, where the scaleX boundary lands mid-pixel and the
|
|
|
|
|
rasteriser rounds it differently. Invisible, but it means a non-empty git diff
|
|
|
|
|
after re-running proves nothing on its own.
|
2026-08-15 13:41:34 +02:00
|
|
|
|
|
|
|
|
Three things bite, and all three fail quietly rather than loudly:
|
|
|
|
|
|
|
|
|
|
1. The capture lands when Chromium's virtual-time budget runs out, not when
|
|
|
|
|
this script finishes. So the page is frozen deliberately — the app's render
|
|
|
|
|
loop is `render(); requestAnimationFrame(loop)`, and replacing
|
|
|
|
|
requestAnimationFrame after the first synchronous render pins the frame.
|
|
|
|
|
|
|
|
|
|
2. CSS transitions don't advance under --virtual-time-budget. A panel caught
|
|
|
|
|
mid-transition photographs in its *old* colour, so transitions are disabled
|
|
|
|
|
outright in the copy being shot.
|
|
|
|
|
|
|
|
|
|
3. Snap-confined Chromium cannot write into hidden directories — not ~/.cache,
|
|
|
|
|
not a dot-directory anywhere, and not the private /tmp it gets given. It
|
|
|
|
|
fails with a bare "Permission denied" and no hint. Both the HTML it reads
|
|
|
|
|
and the PNG it writes therefore live in a plain directory in the repo.
|
|
|
|
|
|
|
|
|
|
The scenes are set up by seeding localStorage before the app boots, so the app
|
|
|
|
|
renders its own state from its own save format rather than having the DOM
|
|
|
|
|
poked from outside. Only the final clicks (open a sheet, start the clock) are
|
|
|
|
|
driven through the real buttons.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
SOURCE = ROOT / "public_html" / "index.html"
|
|
|
|
|
OUT = ROOT / "fastlane" / "metadata" / "android" / "en-US" / "images" / "phoneScreenshots"
|
|
|
|
|
WORK = ROOT / "build-screenshots" # plain name: see note 3 above
|
|
|
|
|
|
|
|
|
|
WIDTH, HEIGHT, SCALE = 390, 844, 3 # -> 1170x2532
|
|
|
|
|
|
|
|
|
|
CHROMIUM = ("chromium-browser", "chromium", "google-chrome", "google-chrome-stable")
|
|
|
|
|
|
|
|
|
|
# st.phase in the app; a running clock is saved as PAUSE, because time that
|
|
|
|
|
# passes while the app is dead can't be charged to anyone
|
|
|
|
|
IDLE, RUN, PAUSE, FLAG = 0, 1, 2, 3
|
|
|
|
|
|
|
|
|
|
MIN = 60_000
|
|
|
|
|
|
|
|
|
|
# A five-point match, mid-game, from the bottom player's side. Panel i shows
|
|
|
|
|
# score[i] as "You" and score[1-i] as "Them", so one array serves both ends.
|
|
|
|
|
MATCH = dict(
|
|
|
|
|
cfg=dict(points=5, base=3 * MIN, delay=12_000),
|
|
|
|
|
game=dict(score=[1, 2], reserve=[14 * MIN, 13 * MIN + 15_000],
|
|
|
|
|
moves=[7, 6], active=1, held=5_000, phase=PAUSE),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
SCENES = [
|
|
|
|
|
# 1 — the clock itself, mid-match, delay running down on the bottom player
|
|
|
|
|
dict(name="1", click="playpause", **MATCH),
|
|
|
|
|
|
|
|
|
|
# 2 — the score sheet, where a finished game is written down
|
|
|
|
|
dict(name="2", click="score", **MATCH),
|
|
|
|
|
|
|
|
|
|
# 3 — the settings sheet. It only opens on a pristine match (the app refuses
|
|
|
|
|
# once a game is under way), so this one starts from a fresh state and
|
|
|
|
|
# seeds no saved game at all.
|
|
|
|
|
dict(name="3", click="settings",
|
|
|
|
|
cfg=dict(points=5, base=3 * MIN, delay=12_000), game=None),
|
|
|
|
|
|
|
|
|
|
# 4 — a single game: no match score, so the score lines aren't there
|
|
|
|
|
dict(name="4", click="playpause",
|
|
|
|
|
cfg=dict(points=1, base=3 * MIN, delay=12_000),
|
|
|
|
|
game=dict(score=[0, 0], reserve=[3 * MIN, 3 * MIN],
|
|
|
|
|
moves=[4, 3], active=1, held=5_000, phase=PAUSE)),
|
|
|
|
|
|
|
|
|
|
# 5 — a flagged clock: the top player's time is gone
|
|
|
|
|
dict(name="5", click=None,
|
|
|
|
|
cfg=dict(points=5, base=3 * MIN, delay=12_000),
|
|
|
|
|
game=dict(score=[1, 2], reserve=[0, 42_000],
|
|
|
|
|
moves=[7, 6], active=0, held=0, phase=FLAG)),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Runs in <head>, before the app's own script, so the app boots into this state
|
|
|
|
|
# and renders it itself.
|
|
|
|
|
SEED = """
|
|
|
|
|
<script>
|
|
|
|
|
(function(){
|
|
|
|
|
try{ localStorage.clear(); }catch(e){}
|
|
|
|
|
var seed = %s;
|
|
|
|
|
for(var k in seed) localStorage.setItem(k, seed[k]);
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# Runs after the app's script, which ends with render(); loop().
|
|
|
|
|
FREEZE = """
|
|
|
|
|
<style>
|
|
|
|
|
/* transitions don't advance under --virtual-time-budget, so a panel caught
|
|
|
|
|
mid-transition would photograph in its old colour */
|
|
|
|
|
*, *::before, *::after{transition:none !important; animation:none !important}
|
|
|
|
|
</style>
|
|
|
|
|
<script>
|
|
|
|
|
(function(){
|
|
|
|
|
// the first render has already happened synchronously; pin it
|
|
|
|
|
window.requestAnimationFrame = function(){ return 0; };
|
|
|
|
|
var click = %s;
|
|
|
|
|
if(click){
|
|
|
|
|
var b = document.getElementById(click);
|
|
|
|
|
if(!b) throw new Error("no such button: " + click);
|
|
|
|
|
b.click();
|
|
|
|
|
}
|
|
|
|
|
document.title = "shot-ready";
|
|
|
|
|
})();
|
|
|
|
|
</script>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_chromium():
|
|
|
|
|
for name in CHROMIUM:
|
|
|
|
|
path = shutil.which(name)
|
|
|
|
|
if path:
|
|
|
|
|
return path
|
|
|
|
|
sys.exit("no chromium found; tried: " + ", ".join(CHROMIUM))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_page(scene, source):
|
|
|
|
|
"""A copy of the app with the scene seeded and the render loop pinned."""
|
|
|
|
|
seed = {
|
|
|
|
|
"bg.points": str(scene["cfg"]["points"]),
|
|
|
|
|
"bg.base": str(scene["cfg"]["base"]),
|
|
|
|
|
"bg.delay": str(scene["cfg"]["delay"]),
|
|
|
|
|
"bg.theme": scene["cfg"].get("theme", "sage"),
|
|
|
|
|
"bg.sound": "1",
|
|
|
|
|
}
|
|
|
|
|
if scene["game"] is not None:
|
|
|
|
|
seed["bg.game"] = json.dumps(dict(v=1, **scene["game"]))
|
|
|
|
|
|
|
|
|
|
html = source.replace("</head>", SEED % json.dumps(seed) + "</head>", 1)
|
|
|
|
|
html = html.replace("</body>", FREEZE % json.dumps(scene["click"]) + "</body>", 1)
|
|
|
|
|
return html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
chromium = find_chromium()
|
|
|
|
|
source = SOURCE.read_text()
|
|
|
|
|
WORK.mkdir(exist_ok=True)
|
|
|
|
|
try:
|
|
|
|
|
for scene in SCENES:
|
|
|
|
|
page = WORK / ("scene-%s.html" % scene["name"])
|
|
|
|
|
page.write_text(build_page(scene, source))
|
|
|
|
|
target = OUT / ("%s.png" % scene["name"])
|
|
|
|
|
shot = WORK / target.name
|
|
|
|
|
|
|
|
|
|
subprocess.run([
|
|
|
|
|
chromium,
|
|
|
|
|
"--headless", "--no-sandbox", "--disable-gpu", "--hide-scrollbars",
|
|
|
|
|
"--window-size=%d,%d" % (WIDTH, HEIGHT),
|
|
|
|
|
"--force-device-scale-factor=%d" % SCALE,
|
|
|
|
|
"--virtual-time-budget=4000",
|
|
|
|
|
"--screenshot=%s" % shot,
|
|
|
|
|
page.as_uri(),
|
|
|
|
|
], check=True, capture_output=True)
|
|
|
|
|
|
|
|
|
|
if not shot.exists():
|
|
|
|
|
sys.exit("chromium wrote nothing for scene %s" % scene["name"])
|
|
|
|
|
shutil.move(str(shot), str(target))
|
|
|
|
|
print("%s %d bytes" % (target.relative_to(ROOT), target.stat().st_size))
|
|
|
|
|
finally:
|
|
|
|
|
shutil.rmtree(WORK, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|