ASO tooling: compositores de marketing + captura UITest
- 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
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user