Store screenshots for Google Play and the App Store
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BIN
docs/store/screenshots/android/01_home.png
Normal file
|
After Width: | Height: | Size: 571 KiB |
BIN
docs/store/screenshots/android/02_player.png
Normal file
|
After Width: | Height: | Size: 870 KiB |
BIN
docs/store/screenshots/android/03_album.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
docs/store/screenshots/android/04_explore.png
Normal file
|
After Width: | Height: | Size: 603 KiB |
BIN
docs/store/screenshots/android/05_sound.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
docs/store/screenshots/ios/01_home.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/store/screenshots/ios/02_player.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/store/screenshots/ios/03_album.png
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
docs/store/screenshots/ios/04_explore.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
111
tools/store_screenshots.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Turn raw phone screenshots into captioned store screenshots.
|
||||
|
||||
python tools/store_screenshots.py <folder with 01_home.png ... from `adb exec-out screencap -p`>
|
||||
|
||||
Writes docs/store/screenshots/android/*.png (1080x1920, Google Play) and
|
||||
docs/store/screenshots/ios/*.png (1320x2868, App Store 6.9-inch). The Android status and
|
||||
navigation bars are cropped off, so the iOS set shows only the app. Screens marked
|
||||
android_only are left out of the iOS set.
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / 'docs' / 'store' / 'screenshots'
|
||||
FONT_BOLD = 'C:/Windows/Fonts/segoeuib.ttf'
|
||||
FONT_SEMI = 'C:/Windows/Fonts/seguisb.ttf'
|
||||
|
||||
GROUND = (11, 26, 20)
|
||||
EMBLEM = (31, 74, 46)
|
||||
SUN = (245, 179, 53)
|
||||
CREAM = (251, 247, 238)
|
||||
MUTED = (220, 229, 223)
|
||||
|
||||
# file, headline, sub line, android_only
|
||||
SCREENS = [
|
||||
('01_home.png', 'Badaga songs from the Nilgiris', 'Free to listen, with no ads', False),
|
||||
('02_player.png', 'The music of home, anywhere', 'Background play and lock-screen controls', False),
|
||||
('03_album.png', 'Albums from Badaga singers', 'Download them and listen offline', False),
|
||||
('04_explore.png', 'Songs for every occasion', 'Weddings, festivals, devotional and more', False),
|
||||
('05_sound.png', 'Clear sound, even from old cassettes', 'Equalizer presets and volume boost', True),
|
||||
]
|
||||
|
||||
# Phone is 1080x2340: status bar above, gesture/navigation bar below.
|
||||
CROP = (0, 110, 1080, 2215)
|
||||
|
||||
|
||||
def background(w, h):
|
||||
img = Image.new('RGB', (w, h), GROUND)
|
||||
glow = Image.new('RGB', (w, h), GROUND)
|
||||
d = ImageDraw.Draw(glow)
|
||||
d.ellipse((-w * 0.4, -h * 0.25, w * 1.4, h * 0.55), fill=EMBLEM)
|
||||
return Image.blend(img, glow.filter(ImageFilter.GaussianBlur(w // 4)), 0.9)
|
||||
|
||||
|
||||
def wrap(draw, text, font, max_w):
|
||||
words, lines, line = text.split(), [], ''
|
||||
for word in words:
|
||||
test = f'{line} {word}'.strip()
|
||||
if draw.textlength(test, font=font) <= max_w:
|
||||
line = test
|
||||
else:
|
||||
lines.append(line)
|
||||
line = word
|
||||
return lines + [line]
|
||||
|
||||
|
||||
def compose(shot, head, sub, w, h):
|
||||
img = background(w, h)
|
||||
d = ImageDraw.Draw(img)
|
||||
margin = round(w * 0.08)
|
||||
head_font = ImageFont.truetype(FONT_BOLD, round(w * 0.072))
|
||||
sub_font = ImageFont.truetype(FONT_SEMI, round(w * 0.038))
|
||||
|
||||
y = round(h * 0.055)
|
||||
d.rounded_rectangle((margin, y, margin + round(w * 0.09), y + round(w * 0.012)), radius=8, fill=SUN)
|
||||
y += round(w * 0.05)
|
||||
for line in wrap(d, head, head_font, w - 2 * margin):
|
||||
d.text((margin, y), line, font=head_font, fill=CREAM)
|
||||
y += round(head_font.size * 1.18)
|
||||
y += round(w * 0.012)
|
||||
d.text((margin, y), sub, font=sub_font, fill=MUTED)
|
||||
y += round(sub_font.size * 2.8)
|
||||
|
||||
# Screenshot in a rounded phone-like frame, filling the rest of the height.
|
||||
screen = shot.crop(CROP)
|
||||
avail_h = h - y - round(h * 0.04)
|
||||
scale = min(avail_h / screen.height, (w - 2 * margin) / screen.width)
|
||||
sw, sh = round(screen.width * scale), round(screen.height * scale)
|
||||
screen = screen.resize((sw, sh), Image.LANCZOS)
|
||||
bezel = round(w * 0.014)
|
||||
radius = round(w * 0.06)
|
||||
x0 = (w - sw) // 2
|
||||
shadow = Image.new('L', (w, h), 0)
|
||||
ImageDraw.Draw(shadow).rounded_rectangle(
|
||||
(x0 - bezel, y - bezel + 20, x0 + sw + bezel, y + sh + bezel + 20), radius=radius + bezel, fill=160)
|
||||
img.paste((0, 0, 0), mask=shadow.filter(ImageFilter.GaussianBlur(30)))
|
||||
d = ImageDraw.Draw(img)
|
||||
d.rounded_rectangle((x0 - bezel, y - bezel, x0 + sw + bezel, y + sh + bezel),
|
||||
radius=radius + bezel, fill=(5, 13, 9), outline=(42, 69, 55), width=3)
|
||||
mask = Image.new('L', (sw, sh), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, sw, sh), radius=radius, fill=255)
|
||||
img.paste(screen, (x0, y), mask)
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
src = pathlib.Path(sys.argv[1])
|
||||
for target, size in (('android', (1080, 1920)), ('ios', (1320, 2868))):
|
||||
(OUT / target).mkdir(parents=True, exist_ok=True)
|
||||
for name, head, sub, android_only in SCREENS:
|
||||
if android_only and target == 'ios':
|
||||
continue
|
||||
shot = Image.open(src / name).convert('RGB')
|
||||
compose(shot, head, sub, *size).save(OUT / target / name, optimize=True)
|
||||
print(f'{target}: {sorted(p.name for p in (OUT / target).glob("*.png"))}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||