Files
bgclock/tools/screenshots.py
T

183 lines
6.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Regenerate the five F-Droid phone screenshots from public_html/index.html.
python3 tools/screenshots.py
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.
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()