Screenshots: demo-library generator, per-form-factor targets, listing notes

This commit is contained in:
2026-10-14 17:52:40 +02:00
parent d845a7646e
commit 67c2cd61db
3 changed files with 234 additions and 2 deletions
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Builds the synthetic file library the store screenshots are taken over.
python3 tools/make-demo-library.py [output-dir] # default: ./demo-files
Why this exists: published screenshots are public. Shooting them over a real device's
storage would put someone's real file names, folder names and document titles on the Play
listing. Everything here is generated, so every pixel of every screenshot is fake by
construction.
Output lands in ./demo-files/ *inside* the repo and is git-ignored: it is regenerated on
demand and never committed. Push it to a device with:
adb push demo-files /sdcard/Download/PlainFiles-Demo
"""
import os
import shutil
import sys
import zipfile
from PIL import Image, ImageDraw, ImageFont
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
# Distinct hues so the grid view reads as eight different pictures rather than a wall of one
# colour. (name, background hue, accent hue, motif)
SCENES = [
("dawn-ridge", (28, 78, 150), (255, 214, 140), "peaks"),
("pine-lake", (18, 96, 92), (198, 240, 200), "waves"),
("desert-road", (196, 120, 48), (255, 236, 196), "peaks"),
("neon-city", (78, 40, 130), (240, 120, 220), "bars"),
("quiet-harbour", (36, 62, 110), (170, 220, 255), "waves"),
("autumn-field", (150, 84, 36), (255, 208, 120), "bars"),
("snow-pass", (110, 124, 148), (245, 250, 255), "peaks"),
("orchard", (52, 108, 60), (214, 246, 170), "bars"),
]
def font(path, size):
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
def vertical_gradient(width, height, top, bottom):
image = Image.new("RGB", (width, height), top)
draw = ImageDraw.Draw(image)
for y in range(height):
blend = y / max(height - 1, 1)
draw.line(
[(0, y), (width, y)],
fill=tuple(int(top[i] + (bottom[i] - top[i]) * blend) for i in range(3)),
)
return image
def draw_motif(draw, width, height, kind, colour):
if kind == "peaks":
base = height * 0.78
for offset, scale in ((0.05, 0.42), (0.42, 0.58), (0.74, 0.34)):
apex = base - height * scale
draw.polygon(
[
(width * offset, base),
(width * (offset + 0.20), apex),
(width * (offset + 0.40), base),
],
fill=colour,
)
elif kind == "waves":
for index in range(5):
y = height * (0.55 + index * 0.08)
amplitude = height * 0.03
points = [
(x, y + amplitude * ((x // 40) % 2 * 2 - 1)) for x in range(0, width + 40, 40)
]
draw.line(points, fill=colour, width=6)
else: # bars
for index in range(7):
x = width * (0.10 + index * 0.12)
bar = height * (0.20 + (index % 3) * 0.14)
draw.rounded_rectangle(
[x, height * 0.80 - bar, x + width * 0.06, height * 0.80],
radius=8,
fill=colour,
)
def make_photos(root):
folder = os.path.join(root, "Photos")
os.makedirs(folder, exist_ok=True)
label_font = font(FONT_BOLD, 54)
for index, (name, top, accent, motif) in enumerate(SCENES, start=1):
width, height = 1600, 1200
image = vertical_gradient(width, height, top, tuple(int(c * 0.45) for c in top))
draw = ImageDraw.Draw(image)
draw.ellipse([width * 0.72, height * 0.12, width * 0.92, height * 0.32], fill=accent)
draw_motif(draw, width, height, motif, accent)
draw.text((60, height - 120), f"Sample {index:02d} - {name}", font=label_font, fill=accent)
image.save(os.path.join(folder, f"sample-{index:02d}-{name}.png"), "PNG")
def write(path, text):
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
def make_text_files(root):
folder = os.path.join(root, "Documents")
os.makedirs(folder, exist_ok=True)
write(
os.path.join(folder, "offsite-notes.txt"),
"Team offsite - notes\n"
"====================\n\n"
"Nothing in this file is real: the whole library the screenshots are taken over\n"
"is generated by tools/make-demo-library.py, so publishing them leaks nobody's\n"
"data.\n\n"
"1. Agenda\n2. Travel\n3. Follow-ups\n",
)
write(
os.path.join(folder, "budget.csv"),
"item,quantity,unit_cost\n"
"laptop stand,2,39.00\n"
"usb-c hub,3,54.50\n"
"headset,1,229.00\n"
"markers,12,2.15\n",
)
write(
os.path.join(folder, "itinerary.md"),
"# Itinerary\n\n| Day | Item |\n| --- | --- |\n"
"| 1 | Arrival |\n| 2 | Workshops |\n| 3 | Departure |\n",
)
# Stand-ins for binary formats: what matters for a screenshot is the icon and the name
# in the list, and a real PDF or DOCX would add a build dependency for no visible gain.
write(os.path.join(folder, "quarterly-report.pdf"), "%PDF-1.4\n% demo placeholder\n")
write(os.path.join(folder, "service-agreement.docx"), "PK demo placeholder\n")
def make_sized_file(path, megabytes):
"""A file of a given size, so the usage screen has something worth ranking."""
chunk = b"\x00" * (1024 * 1024)
with open(path, "wb") as handle:
for _ in range(megabytes):
handle.write(chunk)
def make_media(root):
video = os.path.join(root, "Video")
os.makedirs(video, exist_ok=True)
for index in range(1, 4):
make_sized_file(os.path.join(video, f"clip-{index:02d}.mp4"), 24 + index * 6)
music = os.path.join(root, "Music")
os.makedirs(music, exist_ok=True)
for index in range(1, 6):
make_sized_file(os.path.join(music, f"track-{index:02d}.mp3"), 4 + index)
def make_archive(root):
folder = os.path.join(root, "Backups")
os.makedirs(folder, exist_ok=True)
with zipfile.ZipFile(os.path.join(folder, "config-backup.zip"), "w", zipfile.ZIP_DEFLATED) as bundle:
bundle.writestr("settings.json", '{\n "theme": "system",\n "sort": "name"\n}\n')
bundle.writestr("bookmarks.txt", "Photos\nDocuments\nBackups\n")
bundle.writestr("notes/readme.txt", "Synthetic contents.\n")
def make_downloads(root):
folder = os.path.join(root, "Download")
os.makedirs(folder, exist_ok=True)
write(os.path.join(folder, "welcome.txt"), "Generated by tools/make-demo-library.py\n")
make_sized_file(os.path.join(folder, "installer.bin"), 18)
def main():
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
root = sys.argv[1] if len(sys.argv) > 1 else os.path.join(repo, "demo-files")
if os.path.isdir(root):
shutil.rmtree(root)
os.makedirs(root)
make_photos(root)
make_text_files(root)
make_media(root)
make_archive(root)
make_downloads(root)
total = 0
for base, _, names in os.walk(root):
for name in names:
total += os.path.getsize(os.path.join(base, name))
print(f"Wrote {root} ({total / (1024 * 1024):.1f} MB)")
if __name__ == "__main__":
main()
+6 -2
View File
@@ -4,7 +4,7 @@
#
# ./tools/make-store-screenshots.sh # phone -> 1080x2160
# FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh # 7-inch -> 1200x1920
# FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh # 10-inch -> 1600x2560
# FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh # 10-inch -> 2560x1600
#
# store-assets/raw-screenshots/<form>/ the raw captures (git-ignored)
# store-assets/screenshots/<form>/ what gets uploaded (committed)
@@ -14,6 +14,10 @@
# 1080x2400 is 2.22:1 and is therefore REJECTED, which is why the phone path crops system
# chrome (status + navigation bars) before clamping to 2:1. A tablet frame is already inside
# the 2:1 cap on every profile the SDK ships, so its crop defaults to 0 and is opt-in.
#
# Play accepts portrait or landscape for the tablet slots; the targets below match the AVDs
# this repo is tested against (the 10-inch one is a landscape profile), and the extent step
# normalises to them either way.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
@@ -23,7 +27,7 @@ form="${FORM_FACTOR:-phone}"
case "$form" in
phone) target_w=1080; target_h=2160; default_status=93; default_nav=130 ;;
tablet-7in) target_w=1200; target_h=1920; default_status=0; default_nav=0 ;;
tablet-10in) target_w=1600; target_h=2560; default_status=0; default_nav=0 ;;
tablet-10in) target_w=2560; target_h=1600; default_status=0; default_nav=0 ;;
*) echo "Unknown FORM_FACTOR '$form' (phone|tablet-7in|tablet-10in)" >&2; exit 1 ;;
esac