Files
Termind/Scripts/generate-mockuphone-assets.py
T
2026-09-15 16:00:43 +08:00

191 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate front-facing Termind device mockups using MockUPhone templates.
Raw screenshots are read-only inputs in Marketing/Screenshots/Captures. Framed
outputs are written to Marketing/Screenshots/DeviceFrames with a
``-framed.png`` suffix so source and derived marketing assets stay separate.
"""
from __future__ import annotations
import io
import sys
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageOps
REPOSITORY = Path(__file__).resolve().parents[1]
CAPTURES = REPOSITORY / "Marketing" / "Screenshots" / "Captures"
DEVICE_FRAMES = REPOSITORY / "Marketing" / "Screenshots" / "DeviceFrames"
CACHE = REPOSITORY / ".build" / "mockuphone-assets"
MOCKUPHONE = "https://mockuphone.com/images"
FEATURES = ("vault", "sftp", "ssh", "metrics", "assistant")
LOCALES = ("en", "zh-hans", "zh-hant")
@dataclass(frozen=True)
class Device:
name: str
asset: str
screen_size: tuple[int, int]
screen_origin: tuple[int, int]
canvas_size: tuple[int, int]
IPHONE = Device(
name="iPhone 15 Pro Max",
asset="apple-iphone-15-pro-max-black-titanium-portrait.png",
screen_size=(1290, 2796),
screen_origin=(120, 120),
canvas_size=(1530, 3036),
)
IPAD = Device(
name='iPad Pro 13"',
asset="apple-ipadpro13-spacegrey-landscape.png",
screen_size=(2732, 2048),
screen_origin=(113, 120),
canvas_size=(2960, 2290),
)
MAC = Device(
name='MacBook Pro 16"',
asset="apple-macbookpro16-front.png",
screen_size=(3456, 2234),
screen_origin=(441, 312),
canvas_size=(4340, 2860),
)
def template(device: Device, kind: str) -> Image.Image:
CACHE.mkdir(parents=True, exist_ok=True)
destination = CACHE / f"{kind}-{device.asset}"
if not destination.exists():
url = f"{MOCKUPHONE}/{kind}_templates/{device.asset}"
with urllib.request.urlopen(url) as response:
destination.write_bytes(response.read())
image = Image.open(destination).convert("RGBA")
if image.size != device.canvas_size:
raise ValueError(f"Unexpected {device.name} template size: {image.size}")
return image
def fit_screen(source: Path, size: tuple[int, int]) -> Image.Image:
with Image.open(source) as opened:
image = ImageOps.exif_transpose(opened).convert("RGBA")
scale = min(size[0] / image.width, size[1] / image.height)
image = image.resize(
(round(image.width * scale), round(image.height * scale)),
Image.Resampling.LANCZOS,
)
fitted = Image.new("RGBA", size, (0, 0, 0, 255))
fitted.alpha_composite(image, ((size[0] - image.width) // 2, (size[1] - image.height) // 2))
return fitted
def original_wallpaper(size: tuple[int, int]) -> Image.Image:
width, height = size
desktop = Image.new("RGBA", size)
draw = ImageDraw.Draw(desktop)
top = (22, 76, 100)
bottom = (7, 35, 55)
for y in range(height):
amount = y / max(height - 1, 1)
color = tuple(round(a + (b - a) * amount) for a, b in zip(top, bottom))
draw.line((0, y, width, y), fill=(*color, 255))
glow = Image.new("RGBA", size, (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow)
glow_draw.ellipse(
(-round(width * 0.30), -round(height * 0.55), round(width * 0.62), round(height * 0.85)),
fill=(54, 201, 239, 190),
)
glow_draw.ellipse(
(round(width * 0.52), -round(height * 0.18), round(width * 1.18), round(height * 0.78)),
fill=(39, 119, 181, 155),
)
glow_draw.ellipse(
(round(width * 0.62), round(height * 0.58), round(width * 1.06), round(height * 1.18)),
fill=(235, 144, 111, 80),
)
desktop.alpha_composite(glow.filter(ImageFilter.GaussianBlur(round(width * 0.12))))
grain = Image.effect_noise(size, 18).convert("L")
grain_layer = Image.new("RGBA", size, (255, 255, 255, 0))
grain_layer.putalpha(grain.point(lambda value: round(value * 0.055)))
desktop.alpha_composite(grain_layer)
return desktop
def mac_desktop(source: Path, size: tuple[int, int]) -> Image.Image:
desktop = original_wallpaper(size)
with Image.open(source) as opened:
window = ImageOps.exif_transpose(opened).convert("RGBA")
# Make the app the clear subject while retaining enough wallpaper to read
# as a Mac desktop. The original screenshot is never cropped.
scale = min(size[0] * 0.91 / window.width, size[1] * 0.88 / window.height)
window = window.resize(
(round(window.width * scale), round(window.height * scale)),
Image.Resampling.LANCZOS,
)
origin = ((size[0] - window.width) // 2, (size[1] - window.height) // 2)
shadow = Image.new("RGBA", size, (0, 0, 0, 0))
shadow.alpha_composite(Image.new("RGBA", window.size, (16, 29, 40, 110)), origin)
shadow.putalpha(shadow.getchannel("A").filter(ImageFilter.GaussianBlur(34)))
desktop.alpha_composite(shadow)
desktop.alpha_composite(window, origin)
return desktop
def generate(source: Path, destination: Path, device: Device) -> None:
device_mask = template(device, "mockup_mask")
device_front = template(device, "mockup")
screen = mac_desktop(source, device.screen_size) if device == MAC else fit_screen(source, device.screen_size)
screen_layer = Image.new("RGBA", device.canvas_size, (0, 0, 0, 0))
screen_layer.alpha_composite(screen, device.screen_origin)
# This mirrors MockUPhone's front-device pipeline: constrain the inserted
# image to the supplied device mask, then place the bezel above it.
constrained = Image.composite(screen_layer, device_mask, device_mask.getchannel("A"))
constrained.alpha_composite(device_front)
destination.parent.mkdir(parents=True, exist_ok=True)
constrained.save(destination, format="PNG", optimize=True)
def mobile_sources(device_name: str):
for feature in FEATURES:
for locale in LOCALES:
source = CAPTURES / feature / f"{device_name}-{locale}.png"
destination = DEVICE_FRAMES / feature / f"{device_name}-{locale}-framed.png"
yield source, destination
def main() -> int:
jobs = [
*((source, destination, IPHONE) for source, destination in mobile_sources("iphone")),
*((source, destination, IPAD) for source, destination in mobile_sources("ipad")),
*((
CAPTURES / "workspace" / f"mac-{locale}.png",
DEVICE_FRAMES / "workspace" / f"mac-{locale}-framed.png",
MAC,
) for locale in LOCALES),
]
missing = [source for source, _, _ in jobs if not source.exists()]
if missing:
print("Missing raw screenshots:", file=sys.stderr)
for source in missing:
print(f" {source}", file=sys.stderr)
return 66
for source, destination, device in jobs:
generate(source, destination, device)
print(f"Generated {destination.relative_to(REPOSITORY)} ({device.name})")
return 0
if __name__ == "__main__":
raise SystemExit(main())