Screenshots: demo-library generator, per-form-factor targets, listing notes
This commit is contained in:
Executable
+203
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user