19e80c912b
- testCaptureScreenshots en PortfolioJournalUITests: captura tabs con datos demo, idioma vía SCREENSHOT_LANG (usa el modo --screenshots del commit siguiente) - Scripts/aso: compositores de screenshots de App Store (frame_layout con perspectiva 3D + panorama multi-panel, variantes premium/tilt), layouts JSON iPhone/iPad, y asc.py (helper API App Store Connect para estado/screenshots) - Fix test roto pre-existente: OnboardingQuickStartView.appSettingsURL restaurado Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
411 lines
13 KiB
Python
411 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Frame raw app screenshots for App Store marketing artwork.
|
|
|
|
Requirements:
|
|
pip install Pillow
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
import textwrap
|
|
import warnings
|
|
from pathlib import Path
|
|
from typing import Iterable, Sequence
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
|
|
DEFAULT_GRAD_TOP = "#0B5FFF"
|
|
DEFAULT_GRAD_BOTTOM = "#00C2A8"
|
|
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
|
|
|
|
|
|
FONT_CANDIDATES = (
|
|
# CJK-capable macOS fonts first.
|
|
"/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
|
|
"/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc",
|
|
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
"/System/Library/Fonts/PingFang.ttc",
|
|
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
|
"/Library/Fonts/Arial Unicode.ttf",
|
|
# Strong Latin fallbacks common on macOS.
|
|
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
|
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
"/System/Library/Fonts/SFNS.ttf",
|
|
)
|
|
|
|
|
|
def parse_hex_color(value: str) -> tuple[int, int, int]:
|
|
"""Return an RGB tuple from #RRGGBB."""
|
|
if not HEX_RE.fullmatch(value):
|
|
raise argparse.ArgumentTypeError(f"Expected #RRGGBB hex color, got {value!r}")
|
|
value = value.lstrip("#")
|
|
return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4))
|
|
|
|
|
|
def find_repo_root(start: Path) -> Path:
|
|
for path in (start, *start.parents):
|
|
if (path / ".git").exists():
|
|
return path
|
|
return start
|
|
|
|
|
|
def find_brand_colors(repo_root: Path) -> tuple[str | None, str | None]:
|
|
"""Look for SwiftUI Color.appPrimary/appSecondary hex values, then assets."""
|
|
primary = secondary = None
|
|
|
|
for swift_file in repo_root.rglob("*.swift"):
|
|
try:
|
|
text = swift_file.read_text(encoding="utf-8")
|
|
except UnicodeDecodeError:
|
|
text = swift_file.read_text(errors="ignore")
|
|
|
|
primary_match = re.search(r"appPrimary\s*=\s*Color\(hex:\s*\"(#[0-9A-Fa-f]{6})\"", text)
|
|
secondary_match = re.search(r"appSecondary\s*=\s*Color\(hex:\s*\"(#[0-9A-Fa-f]{6})\"", text)
|
|
if primary_match:
|
|
primary = primary_match.group(1).upper()
|
|
if secondary_match:
|
|
secondary = secondary_match.group(1).upper()
|
|
if primary and secondary:
|
|
return primary, secondary
|
|
|
|
for contents_file in repo_root.glob("**/*.colorset/Contents.json"):
|
|
name = contents_file.parent.name.lower()
|
|
if "primary" not in name and "secondary" not in name:
|
|
continue
|
|
color = read_xcasset_hex(contents_file)
|
|
if not color:
|
|
continue
|
|
if "primary" in name and not primary:
|
|
primary = color
|
|
elif "secondary" in name and not secondary:
|
|
secondary = color
|
|
|
|
return primary, secondary
|
|
|
|
|
|
def read_xcasset_hex(path: Path) -> str | None:
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return None
|
|
|
|
for color_entry in data.get("colors", []):
|
|
components = color_entry.get("color", {}).get("components", {})
|
|
red, green, blue = components.get("red"), components.get("green"), components.get("blue")
|
|
if red is None or green is None or blue is None:
|
|
continue
|
|
try:
|
|
values = [component_to_byte(channel) for channel in (red, green, blue)]
|
|
except ValueError:
|
|
continue
|
|
return "#{:02X}{:02X}{:02X}".format(*values)
|
|
return None
|
|
|
|
|
|
def component_to_byte(value: str | float | int) -> int:
|
|
if isinstance(value, str) and value.startswith("0x"):
|
|
return int(value, 16)
|
|
number = float(value)
|
|
if number <= 1:
|
|
number *= 255
|
|
return max(0, min(255, round(number)))
|
|
|
|
|
|
def print_brand_colors(repo_root: Path) -> tuple[str, str]:
|
|
primary, secondary = find_brand_colors(repo_root)
|
|
top = primary or DEFAULT_GRAD_TOP
|
|
bottom = secondary or DEFAULT_GRAD_BOTTOM
|
|
source = "found" if primary and secondary else "default"
|
|
print(f"Brand colors ({source}): appPrimary={top} appSecondary={bottom}")
|
|
return top, bottom
|
|
|
|
|
|
def make_vertical_gradient(size: tuple[int, int], top: str, bottom: str) -> Image.Image:
|
|
width, height = size
|
|
top_rgb = parse_hex_color(top)
|
|
bottom_rgb = parse_hex_color(bottom)
|
|
gradient = Image.new("RGB", size)
|
|
draw = ImageDraw.Draw(gradient)
|
|
|
|
for y in range(height):
|
|
ratio = y / max(1, height - 1)
|
|
color = tuple(round(top_rgb[i] + (bottom_rgb[i] - top_rgb[i]) * ratio) for i in range(3))
|
|
draw.line([(0, y), (width, y)], fill=color)
|
|
|
|
return gradient.convert("RGBA")
|
|
|
|
|
|
def load_font(size: int) -> ImageFont.ImageFont:
|
|
for font_path in FONT_CANDIDATES:
|
|
path = Path(font_path)
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
return ImageFont.truetype(str(path), size=size)
|
|
except OSError:
|
|
continue
|
|
|
|
warnings.warn(
|
|
"No CJK-capable macOS font found; falling back to PIL default. "
|
|
"Japanese or other non-Latin text may not render correctly.",
|
|
RuntimeWarning,
|
|
)
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def text_bbox(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
|
|
if not text:
|
|
return 0, 0
|
|
left, top, right, bottom = draw.multiline_textbbox((0, 0), text, font=font, spacing=8, align="center")
|
|
return right - left, bottom - top
|
|
|
|
|
|
def wrap_headline(
|
|
text: str,
|
|
font: ImageFont.ImageFont,
|
|
max_width: int,
|
|
draw: ImageDraw.ImageDraw,
|
|
) -> str:
|
|
if contains_cjk(text):
|
|
return wrap_cjk_text(text, font, max_width, draw)
|
|
return wrap_word_text(text, font, max_width, draw)
|
|
|
|
|
|
def contains_cjk(text: str) -> bool:
|
|
return any(
|
|
"\u3040" <= char <= "\u30ff"
|
|
or "\u3400" <= char <= "\u4dbf"
|
|
or "\u4e00" <= char <= "\u9fff"
|
|
or "\uf900" <= char <= "\ufaff"
|
|
for char in text
|
|
)
|
|
|
|
|
|
def wrap_word_text(
|
|
text: str,
|
|
font: ImageFont.ImageFont,
|
|
max_width: int,
|
|
draw: ImageDraw.ImageDraw,
|
|
) -> str:
|
|
words = text.split()
|
|
if not words:
|
|
return text
|
|
|
|
lines: list[str] = []
|
|
current = words[0]
|
|
for word in words[1:]:
|
|
candidate = f"{current} {word}"
|
|
if text_bbox(draw, candidate, font)[0] <= max_width:
|
|
current = candidate
|
|
else:
|
|
lines.append(current)
|
|
current = word
|
|
lines.append(current)
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def wrap_cjk_text(
|
|
text: str,
|
|
font: ImageFont.ImageFont,
|
|
max_width: int,
|
|
draw: ImageDraw.ImageDraw,
|
|
) -> str:
|
|
lines: list[str] = []
|
|
current = ""
|
|
|
|
for char in text:
|
|
if char.isspace():
|
|
if current:
|
|
lines.append(current)
|
|
current = ""
|
|
continue
|
|
|
|
candidate = f"{current}{char}"
|
|
if current and text_bbox(draw, candidate, font)[0] > max_width:
|
|
lines.append(current)
|
|
current = char
|
|
else:
|
|
current = candidate
|
|
|
|
if current:
|
|
lines.append(current)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def fit_headline(
|
|
headline: str,
|
|
canvas_size: tuple[int, int],
|
|
max_width: int,
|
|
max_height: int,
|
|
draw: ImageDraw.ImageDraw,
|
|
) -> tuple[str, ImageFont.ImageFont, int, int]:
|
|
_, height = canvas_size
|
|
max_size = max(34, round(height * 0.052))
|
|
min_size = 28
|
|
|
|
for size in range(max_size, min_size - 1, -2):
|
|
font = load_font(size)
|
|
wrapped = wrap_headline(headline, font, max_width, draw)
|
|
text_width, text_height = text_bbox(draw, wrapped, font)
|
|
if text_width <= max_width and text_height <= max_height:
|
|
return wrapped, font, text_width, text_height
|
|
|
|
font = load_font(min_size)
|
|
wrapped = wrap_headline(headline, font, max_width, draw)
|
|
text_width, text_height = text_bbox(draw, wrapped, font)
|
|
return wrapped, font, text_width, text_height
|
|
|
|
|
|
def rounded_image(image: Image.Image, radius: int) -> Image.Image:
|
|
image = image.convert("RGBA")
|
|
mask = Image.new("L", image.size, 0)
|
|
draw = ImageDraw.Draw(mask)
|
|
draw.rounded_rectangle((0, 0, image.width, image.height), radius=radius, fill=255)
|
|
image.putalpha(mask)
|
|
return image
|
|
|
|
|
|
def paste_with_shadow(
|
|
canvas: Image.Image,
|
|
screenshot: Image.Image,
|
|
position: tuple[int, int],
|
|
radius: int,
|
|
) -> None:
|
|
x, y = position
|
|
shadow_offset = max(10, round(canvas.width * 0.014))
|
|
shadow_blur = max(18, round(canvas.width * 0.025))
|
|
shadow_opacity = 105
|
|
|
|
shadow = Image.new("RGBA", screenshot.size, (0, 0, 0, 0))
|
|
mask = Image.new("L", screenshot.size, 0)
|
|
ImageDraw.Draw(mask).rounded_rectangle((0, 0, screenshot.width, screenshot.height), radius=radius, fill=shadow_opacity)
|
|
shadow.putalpha(mask)
|
|
shadow = shadow.filter(ImageFilter.GaussianBlur(shadow_blur))
|
|
|
|
canvas.alpha_composite(shadow, (x, y + shadow_offset))
|
|
canvas.alpha_composite(rounded_image(screenshot, radius), position)
|
|
|
|
|
|
def resize_screenshot(image: Image.Image, canvas_width: int, canvas_height: int) -> Image.Image:
|
|
target_width = round(canvas_width * 0.78)
|
|
target_height = round(canvas_height * 0.72)
|
|
scale = min(target_width / image.width, target_height / image.height, 1.0)
|
|
new_size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
|
|
if new_size == image.size:
|
|
return image.convert("RGBA")
|
|
return image.convert("RGBA").resize(new_size, Image.Resampling.LANCZOS)
|
|
|
|
|
|
def frame_screenshot(
|
|
input_path: Path,
|
|
output_path: Path,
|
|
width: int,
|
|
height: int,
|
|
headline: str,
|
|
grad_top: str,
|
|
grad_bottom: str,
|
|
device: str | None = None,
|
|
) -> None:
|
|
del device # Reserved for future per-device tuning.
|
|
canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
horizontal_margin = round(width * 0.08)
|
|
top_margin = round(height * 0.052)
|
|
headline_area_height = round(height * 0.14)
|
|
max_text_width = width - horizontal_margin * 2
|
|
wrapped, font, _, text_height = fit_headline(
|
|
headline,
|
|
(width, height),
|
|
max_text_width,
|
|
headline_area_height,
|
|
draw,
|
|
)
|
|
headline_y = top_margin + max(0, (headline_area_height - text_height) // 2)
|
|
draw.multiline_text(
|
|
(width // 2, headline_y),
|
|
wrapped,
|
|
font=font,
|
|
fill=(255, 255, 255, 255),
|
|
anchor="ma",
|
|
align="center",
|
|
spacing=max(8, round(height * 0.006)),
|
|
)
|
|
|
|
screenshot = resize_screenshot(Image.open(input_path), width, height)
|
|
screenshot_x = (width - screenshot.width) // 2
|
|
min_screenshot_y = round(height * 0.22)
|
|
screenshot_y = max(top_margin + headline_area_height + round(height * 0.035), min_screenshot_y)
|
|
available_bottom = height - round(height * 0.045)
|
|
if screenshot_y + screenshot.height > available_bottom:
|
|
screenshot_y = max(round(height * 0.19), available_bottom - screenshot.height)
|
|
|
|
radius = max(24, round(width * 0.03))
|
|
paste_with_shadow(canvas, screenshot, (screenshot_x, screenshot_y), radius)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
canvas.convert("RGB").save(output_path, "PNG")
|
|
|
|
|
|
def positive_int(value: str) -> int:
|
|
number = int(value)
|
|
if number <= 0:
|
|
raise argparse.ArgumentTypeError("Value must be greater than zero")
|
|
return number
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Create gradient App Store marketing screenshots from raw PNG captures.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=textwrap.dedent(
|
|
"""\
|
|
Example:
|
|
python Scripts/aso/frame_screenshot.py \\
|
|
--input raw.png --output framed.png --width 1320 --height 2868 \\
|
|
--headline "Watch your wealth grow" --grad-top "#3B82F6" --grad-bottom "#10B981"
|
|
"""
|
|
),
|
|
)
|
|
parser.add_argument("--input", required=True, type=Path, help="Raw app screenshot PNG.")
|
|
parser.add_argument("--output", required=True, type=Path, help="Framed output PNG.")
|
|
parser.add_argument("--width", required=True, type=positive_int, help="Final canvas width.")
|
|
parser.add_argument("--height", required=True, type=positive_int, help="Final canvas height.")
|
|
parser.add_argument("--headline", required=True, help="Localized headline text.")
|
|
parser.add_argument("--grad-top", required=True, help="Top gradient color as #RRGGBB.")
|
|
parser.add_argument("--grad-bottom", required=True, help="Bottom gradient color as #RRGGBB.")
|
|
parser.add_argument("--device", choices=("iphone", "ipad"), help="Optional device family hint.")
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
parse_hex_color(args.grad_top)
|
|
parse_hex_color(args.grad_bottom)
|
|
print_brand_colors(find_repo_root(Path(__file__).resolve()))
|
|
|
|
frame_screenshot(
|
|
input_path=args.input,
|
|
output_path=args.output,
|
|
width=args.width,
|
|
height=args.height,
|
|
headline=args.headline,
|
|
grad_top=args.grad_top,
|
|
grad_bottom=args.grad_bottom,
|
|
device=args.device,
|
|
)
|
|
print(f"Wrote {args.output} ({args.width}x{args.height})")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|