"""Turn raw phone screenshots into captioned store screenshots. python tools/store_screenshots.py Writes docs/store/screenshots/android/*.png (1080x1920, Google Play) and docs/store/screenshots/ios/*.png (1206x2622, App Store iPhone 6.1/6.3-inch, the size App Store Connect asks for). 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', (1206, 2622))): (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()