From 46039039fb907349db8d4cc7a6f1e7baec8238c8 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Wed, 21 Oct 2026 02:58:26 -0700 Subject: [PATCH] Generate App Store header and search creative assets from the marketing pipeline Co-Authored-By: Claude Fable 5.1 --- Marketing/AppStore/README.md | 64 +++- Marketing/AppStore/generate.py | 355 +++++++++++++++++++++-- Marketing/AppStore/manifest.artwork.json | 76 +++++ 3 files changed, 461 insertions(+), 34 deletions(-) create mode 100644 Marketing/AppStore/manifest.artwork.json diff --git a/Marketing/AppStore/README.md b/Marketing/AppStore/README.md index f2c35644..e0ef0a7b 100644 --- a/Marketing/AppStore/README.md +++ b/Marketing/AppStore/README.md @@ -30,6 +30,9 @@ screenshots only. | `output/iphone-6.9/clip-*.mp4` | 886×1920 | 16–20 s app previews (temperature, Night & Dawn, schedule, sleep → Watch) | | `output/iphone-6.9/walkthrough-sleepypod.mp4` | 886×1920 | ~100 s tour of every tab, each control, the Watch page and Health | | `output/social/*-1080x1920.mp4` | 1080×1920 | Each video letterboxed for social, padded with the app background | +| `output/artwork/{dark,light}/header-21x9-3840x1646.png` | 3840×1646 | Product-page header creative asset (temperature dial) | +| `output/artwork/{dark,light}/search-3x2-3840x2560.png` | 3840×2560 | Search-results creative asset (sleep night) | +| `output/artwork/{dark,light}/universal-16x9-5244x2950.png` | 5244×2950 | Universal creative asset, accepted in both placements | Videos use H.264 High, 30 fps, yuv420p, and a silent AAC stereo track, following [Apple's app preview specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/app-preview-specifications). @@ -52,9 +55,60 @@ App Store Connect's Media Manager before submission and add a medium-size export if the slot is not covered. Generated files do not establish upload acceptance. See [Apple's current screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications). -The preview dimensions match Apple's current specification. Dedicated product-page -header and search artwork are not generated here; inspect those optional placements -separately if used. No assets are uploaded or submitted by these scripts. +The preview dimensions match Apple's current specification. Product-page header and +search-results artwork come from `manifest.artwork.json` (see +[Creative assets](#creative-assets-reviewed-october-21-2026)). No assets are uploaded +or submitted by these scripts. + +## Creative assets (reviewed October 21, 2026) + +App Store Connect's optional *creative assets* lead the product page (header) and +replace the screenshot strip in search results on iOS 27 and iPadOS 27 and later; +older devices keep showing screenshots. Apple's +[creative assets specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/creative-assets-specifications) +accept, for images, exactly these placements: + +| Placement | Ratio | Size | Format | +| --- | --- | --- | --- | +| Product page header | 21:9 | 3840×1646 | JPEG or PNG, no alpha | +| Search results | 3:2 | 1920×1280 to 3840×2560 | JPEG or PNG, no alpha | +| Universal (both placements) | 16:9 | 5244×2950 | PNG only, no alpha | + +Video is accepted in the same placements (5–30 s, 30 or 60 fps) but is not produced +here. In-app event cards (1920×1080 to 3840×2160 and 1080×1920 to 2160×3840) are a +separate feature and need an event, so they are not generated. Assets are uploaded +on the version page under *Header and Search Results*, or ahead of time through the +Asset Library, as described in +[Manage your App Store assets](https://developer.apple.com/help/app-store-connect/manage-app-information/manage-your-app-store-assets); +they go through their own review before they can be placed. Apple Ads has no +upload of its own: search results ads show the product page's screenshots or a +custom product page, and Today tab ads are built from a custom product page +([Create Today tab ads](https://searchads.apple.com/help/ad-creative/0086-create-today-tab-ads)), +so nothing extra is generated for Apple Ads. + +Apple's [asset best practices](https://developer.apple.com/app-store/asset-best-practices/) +ask for one clear idea, focal content centred within a safe area, and no pricing, +URLs, copyright marks, unverified awards or Apple badges. Apple publishes no safe-area +pixel values outside its Figma/Sketch templates; the manifests use measured +margins instead (header and search 9 % of the width and 8 % of the height; universal +12 % and 14 %, since Apple shows roughly 84 % of its width in search and 76 % of its +height as a header). Icon, wordmark, caption and the phone's screen stay inside +that area; only the phone body bleeds off the bottom. `--guides` outlines the area +in red for proofing, and `--check-config` rejects pricing symbols, the words Apple's +guidance forbids, a capitalised brand, and any size that is not in the table above. +Preview the result in App Store Connect's product page preview before placing it. + +```sh +uv run Marketing/AppStore/generate.py --manifest Marketing/AppStore/manifest.artwork.json +uv run Marketing/AppStore/generate.py --manifest Marketing/AppStore/manifest.artwork.json --guides --output-dir /tmp/artwork-guides/{appearance} +uv run Marketing/AppStore/generate.py --manifest Marketing/AppStore/manifest.artwork.json --check-config +uv run Marketing/AppStore/generate.py --manifest Marketing/AppStore/manifest.artwork.json --self-test +``` + +Upload the dark set; the light set is for the website. The header and universal +images frame the temperature dial, the search image frames the sleep night, both +from the same raw captures as the screenshots. A caption may contain `\n` to force +its line break. ## Capture states @@ -111,7 +165,9 @@ uv run Marketing/AppStore/generate.py --manifest Marketing/AppStore/manifest.exa ``` Captions, eyebrows and both palettes live in the manifests; `{appearance}` in -a path expands to `dark` or `light`, and `--appearance` renders one set. The +a path expands to `dark` or `light`, and `--appearance` renders one set. A manifest +with `screenshots` renders the portrait sets and one with `artwork` renders the +landscape creative assets. The renderer keeps the full screen inside a drawn phone frame under the caption, on a night-sky gradient taken from the app icon and the app's `indigo`/`violet` colours. The app icon is read from the asset catalog and checked against diff --git a/Marketing/AppStore/generate.py b/Marketing/AppStore/generate.py index 5091a79c..72d7813f 100644 --- a/Marketing/AppStore/generate.py +++ b/Marketing/AppStore/generate.py @@ -5,8 +5,11 @@ # "pillow==11.3.0", # ] # /// -"""Create App Store marketing screenshots from raw sleepypod captures. +"""Create App Store marketing screenshots and creative artwork from raw sleepypod captures. +A manifest with `screenshots` renders the portrait store sets; one with `artwork` +renders the landscape product-page header, search-results and universal images +that App Store Connect calls creative assets. All paths in the manifest are resolved relative to the manifest itself. `{appearance}` in an input or output path expands to the selected appearance (dark or light), so one manifest renders both sets with matching captions. @@ -57,6 +60,11 @@ def parse_args() -> argparse.Namespace: help="Output size as WIDTHxHEIGHT (default: 1320x2868)", ) parser.add_argument('--only', action='append', help='Render only this output stem (repeatable)') + parser.add_argument( + "--guides", + action="store_true", + help="Artwork manifests only: outline the safe area in red for proofing (never upload these)", + ) mode = parser.add_mutually_exclusive_group() mode.add_argument( "--check-config", @@ -88,7 +96,7 @@ def load_manifest(path: Path) -> tuple[dict[str, Any], Path]: manifest = json.load(handle) if manifest.get("format") != 1: raise ValueError("Manifest must declare format: 1") - for key in ("app_name", "icon", "appearances", "screenshots"): + for key in ("app_name", "icon", "appearances"): if key not in manifest: raise ValueError(f"Manifest is missing {key!r}") if manifest["app_name"] != manifest["app_name"].lower(): @@ -101,15 +109,72 @@ def load_manifest(path: Path) -> tuple[dict[str, Any], Path]: raise ValueError(f"appearances.{name}.background must contain at least two colors") for value in [palette[key] for key in APPEARANCE_KEYS if key != "background"] + palette["background"]: ImageColor.getrgb(value) - if not manifest["screenshots"]: - raise ValueError("Manifest has no screenshots") - for index, item in enumerate(manifest["screenshots"]): + if ("screenshots" in manifest) == ("artwork" in manifest): + raise ValueError("Manifest must contain exactly one of 'screenshots' or 'artwork'") + kind = manifest_kind(manifest) + if not manifest[kind]: + raise ValueError(f"Manifest has no {kind}") + for index, item in enumerate(manifest[kind]): for key in ("input", "output", "caption"): if key not in item: - raise ValueError(f"screenshots[{index}] is missing {key!r}") + raise ValueError(f"{kind}[{index}] is missing {key!r}") + if kind == "artwork": + validate_artwork_item(index, item) return manifest, path.parent +def manifest_kind(manifest: dict[str, Any]) -> str: + """'screenshots' for the portrait store sets, 'artwork' for header/search creative.""" + return "artwork" if "artwork" in manifest else "screenshots" + + +# Apple's creative asset specifications (reviewed October 21, 2026): +# https://developer.apple.com/help/app-store-connect/reference/app-information/creative-assets-specifications +# header: 21:9, exactly 3840x1646, JPEG or PNG. search: 3:2, 1920x1280 to 3840x2560, JPEG or PNG. +# universal: 16:9, exactly 5244x2950, PNG only, accepted in both placements. No alpha anywhere. +ARTWORK_PLACEMENTS: dict[str, dict[str, Any]] = { + "header": {"ratio": (21, 9), "min": (3840, 1646), "max": (3840, 1646)}, + "search": {"ratio": (3, 2), "min": (1920, 1280), "max": (3840, 2560)}, + "universal": {"ratio": (16, 9), "min": (5244, 2950), "max": (5244, 2950)}, +} +# Apple's asset guidance forbids pricing, URLs, copyright marks and unverified claims; +# App Store Review Guideline 2.3 covers the rest. Keep the brand plain and the copy descriptive. +ARTWORK_PHONE_WIDTH = 800 # Phone screen width in layout units; widest the text column must allow for. +ARTWORK_FORBIDDEN_WORDS = ("free", "discount", "sale", "award", "best", "editor", "apple", "http", "www", "©", "™", "®") + + +def validate_artwork_item(index: int, item: dict[str, Any]) -> None: + placement = item.get("placement") + if placement not in ARTWORK_PLACEMENTS: + raise ValueError(f"artwork[{index}].placement must be one of {sorted(ARTWORK_PLACEMENTS)}") + spec = ARTWORK_PLACEMENTS[placement] + width, height = parse_artwork_size(str(item.get("size", ""))) + ratio_w, ratio_h = spec["ratio"] + if abs(width / height - ratio_w / ratio_h) > 0.005: + raise ValueError(f"artwork[{index}] {placement} must be {ratio_w}:{ratio_h}; got {width}x{height}") + if not (spec["min"][0] <= width <= spec["max"][0] and spec["min"][1] <= height <= spec["max"][1]): + raise ValueError( + f"artwork[{index}] {placement} must be between {spec['min'][0]}x{spec['min'][1]} and " + f"{spec['max'][0]}x{spec['max'][1]}; got {width}x{height}" + ) + safe = item.get("safe_area", [0.08, 0.08]) + if len(safe) != 2 or not all(0 <= float(value) < 0.45 for value in safe): + raise ValueError(f"artwork[{index}].safe_area must be two fractions below 0.45, e.g. [0.09, 0.08]") + if not str(item["output"]).lower().endswith(".png"): + raise ValueError(f"artwork[{index}].output must be a .png (the universal asset is PNG-only)") + + +def parse_artwork_size(value: str) -> tuple[int, int]: + try: + width_text, height_text = value.lower().split("x", maxsplit=1) + width, height = int(width_text), int(height_text) + except (ValueError, TypeError) as exc: + raise ValueError(f"Invalid artwork size {value!r}; expected WIDTHxHEIGHT") from exc + if width <= height: + raise ValueError("Artwork must be landscape-oriented") + return width, height + + def appearances(manifest: dict[str, Any], requested: list[str] | None) -> list[str]: names = requested or list(manifest["appearances"]) for name in names: @@ -205,18 +270,20 @@ def find_font(size: int, *, bold: bool = False) -> ImageFont.FreeTypeFont | Imag def wrap_text(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str: + """Greedy word wrap; a literal newline in the text forces a break (used to avoid orphans).""" lines: list[str] = [] - current = "" - for word in text.split(): - candidate = word if not current else f"{current} {word}" - if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width: - current = candidate - else: - if current: - lines.append(current) - current = word - if current: - lines.append(current) + for paragraph in text.split("\n"): + current = "" + for word in paragraph.split(): + candidate = word if not current else f"{current} {word}" + if draw.textbbox((0, 0), candidate, font=font)[2] <= max_width: + current = candidate + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) return "\n".join(lines) @@ -374,6 +441,182 @@ def render_one( raise RuntimeError(f"Output verification failed for {output_path}: {result.size}, {result.mode}") +def draw_phone( + canvas: Image.Image, + palette: dict[str, Any], + screenshot: Image.Image, + screen_box: tuple[int, int, int, int], + scale: float, +) -> Image.Image: + """The full capture inside a dark bezel with a soft drop shadow; shared by both layouts.""" + screen_left, screen_top, screen_right, screen_bottom = screen_box + screen_width, screen_height = screen_right - screen_left, screen_bottom - screen_top + bezel = round(24 * scale) + corner = round(screen_width * 0.135) + outer = (screen_left - bezel, screen_top - bezel, screen_right + bezel, screen_bottom + bezel) + + phone_shadow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + ImageDraw.Draw(phone_shadow).rounded_rectangle( + (outer[0] + round(10 * scale), outer[1] + round(34 * scale), outer[2] + round(10 * scale), outer[3] + round(34 * scale)), + radius=corner + bezel, fill=(0, 0, 0, 150), + ) + canvas = Image.alpha_composite(canvas.convert("RGBA"), phone_shadow.filter(ImageFilter.GaussianBlur(round(36 * scale)))) + draw = ImageDraw.Draw(canvas) + bezel_rgb = color(palette["bezel"]) + draw.rounded_rectangle(outer, radius=corner + bezel, fill=bezel_rgb) + draw.rounded_rectangle( + outer, radius=corner + bezel, + outline=blend(bezel_rgb, (255, 255, 255), 0.28), width=max(2, round(4 * scale)), + ) + rgb = canvas.convert("RGB") + rounded_paste(rgb, screenshot.resize((screen_width, screen_height), Image.Resampling.LANCZOS), screen_box, radius=corner) + return rgb + + +def artwork_layout(item: dict[str, Any]) -> tuple[tuple[int, int], tuple[int, int, int, int], float]: + """Canvas size, safe-area box and layout scale for one artwork placement. + + Everything that must survive Apple's device crops (icon, wordmark, caption, the + phone's screen) is laid out inside the safe area; only the phone's lower body may + bleed past it. The scale is pinned so the header and search images share one type size. + """ + width, height = parse_artwork_size(str(item["size"])) + inset_x, inset_y = (float(value) for value in item.get("safe_area", [0.08, 0.08])) + safe = (round(width * inset_x), round(height * inset_y), round(width * (1 - inset_x)), round(height * (1 - inset_y))) + safe_width, safe_height = safe[2] - safe[0], safe[3] - safe[1] + scale = min(safe_width / 3150, safe_height / 1400) + return (width, height), safe, scale + + +def artwork_column_width(safe: tuple[int, int, int, int], scale: float) -> int: + """Width the caption may use: the safe area minus the widest phone and the gap, capped for measure.""" + phone = round(ARTWORK_PHONE_WIDTH * scale * 1.25) + 2 * round(24 * scale) + return min(safe[2] - safe[0] - phone - round(140 * scale), round(2100 * scale)) + + +def render_artwork( + manifest: dict[str, Any], + palette: dict[str, Any], + item: dict[str, Any], + input_path: Path, + output_path: Path, + icon_image: Image.Image, + *, + guides: bool = False, +) -> None: + size, safe, scale = artwork_layout(item) + width, height = size + sky_scale = height / 1646 # Glow and stars track the canvas, not the type. + canvas = add_sky(gradient(size, palette["background"]), palette, manifest["app_name"] + item["output"], sky_scale) + accent, text_rgb, muted = color(palette["accent"]), color(palette["text"]), color(palette["muted"]) + + if not input_path.is_file(): + raise FileNotFoundError(f"Raw screenshot not found: {input_path}") + with Image.open(input_path) as raw: + raw.load() + if raw.width >= raw.height: + raise ValueError(f"Raw screenshot must be portrait-oriented: {input_path}") + screenshot = raw.convert("RGB") + + # Phone on the right: its screen's top two thirds (the part with the control) stay in the + # safe area; a taller canvas shows the whole phone, a header lets the body run off the bottom. + gap = round(140 * scale) + bezel = round(24 * scale) + safe_height = safe[3] - safe[1] + screen_width = round(ARTWORK_PHONE_WIDTH * scale) + screen_height = round(screen_width * screenshot.height / screenshot.width) + if screen_height + 2 * bezel <= safe_height: + # Whole phone fits (3:2 search): let it grow a little to fill the safe height. + screen_height = min(safe_height - 2 * bezel - round(80 * scale), round(screen_height * 1.25)) + screen_width = round(screen_height * screenshot.width / screenshot.height) + screen_top = safe[1] + (safe_height - screen_height) // 2 + else: + # Header: the body runs off the bottom edge, never stopping just short of it. + screen_top = safe[1] + bezel + round(40 * scale) + screen_height = max(screen_height, height - screen_top + round(120 * scale)) + screen_width = round(screen_height * screenshot.width / screenshot.height) + screen_left = safe[2] - bezel - screen_width + screen_box = (screen_left, screen_top, screen_left + screen_width, screen_top + screen_height) + + # Text column on the left, vertically centred in the safe area. + column_left = safe[0] + column_width = min(screen_left - bezel - gap - column_left, artwork_column_width(safe, scale)) + icon_size = round(150 * scale) + name_font = find_font(round(78 * scale), bold=True) + eyebrow_font = find_font(round(40 * scale), bold=True) + subtitle_font = find_font(round(54 * scale)) + probe = ImageDraw.Draw(Image.new("RGB", (1, 1))) + caption, caption_font, spacing = fit_artwork_caption(probe, str(item["caption"]), column_width, scale) + caption_box = probe.multiline_textbbox((0, 0), caption, font=caption_font, spacing=spacing) + caption_height = caption_box[3] - caption_box[1] + eyebrow = str(item.get("eyebrow", "")).upper() + subtitle = str(item.get("subtitle", "")) + block_height = icon_size + round(90 * scale) + if eyebrow: + block_height += round(40 * scale) + round(34 * scale) + block_height += caption_height + if subtitle: + block_height += round(44 * scale) + round(54 * scale) + y = safe[1] + (safe_height - block_height) // 2 + + shadow = Image.new("RGBA", size, (0, 0, 0, 0)) + ImageDraw.Draw(shadow).rounded_rectangle( + (column_left, y + round(10 * scale), column_left + icon_size, y + icon_size + round(10 * scale)), + radius=round(icon_size * 0.225), fill=(0, 0, 0, 90), + ) + canvas = Image.alpha_composite(canvas, shadow.filter(ImageFilter.GaussianBlur(round(12 * scale)))) + rgb = canvas.convert("RGB") + rounded_paste(rgb, icon_image, (column_left, y, column_left + icon_size, y + icon_size), round(icon_size * 0.225)) + canvas = rgb.convert("RGBA") + draw = ImageDraw.Draw(canvas) + name_x = column_left + icon_size + round(40 * scale) + name_box = draw.textbbox((0, 0), manifest["app_name"], font=name_font) + draw.text( + (name_x, y + (icon_size - (name_box[3] - name_box[1])) / 2 - name_box[1]), + manifest["app_name"], font=name_font, fill=text_rgb, + ) + y += icon_size + round(90 * scale) + + if eyebrow: + x = column_left + for character in eyebrow: # Letter-spaced, like the app's Eyebrow style. + draw.text((x, y), character, font=eyebrow_font, fill=accent) + x += draw.textlength(character, font=eyebrow_font) + round(6 * scale) + y += round(40 * scale) + round(34 * scale) + + draw.multiline_text((column_left, y - caption_box[1]), caption, font=caption_font, spacing=spacing, fill=text_rgb) + y += caption_height + if subtitle: + y += round(44 * scale) + subtitle_text = wrap_text(draw, subtitle, subtitle_font, column_width) + draw.multiline_text((column_left, y), subtitle_text, font=subtitle_font, spacing=round(12 * scale), fill=muted) + + rgb = draw_phone(canvas, palette, screenshot, screen_box, scale) + if guides: + ImageDraw.Draw(rgb).rectangle(safe, outline=(255, 64, 64), width=max(3, round(6 * scale))) + + output_path.parent.mkdir(parents=True, exist_ok=True) + rgb.save(output_path, format="PNG", optimize=True) + with Image.open(output_path) as result: + if result.size != size or result.mode != "RGB": + raise RuntimeError(f"Output verification failed for {output_path}: {result.size}, {result.mode}") + + +def fit_artwork_caption( + draw: ImageDraw.ImageDraw, text: str, max_width: int, scale: float +) -> tuple[str, ImageFont.ImageFont, int]: + for raw_size in range(150, 89, -4): + font = find_font(max(24, round(raw_size * scale)), bold=True) + spacing = max(8, round(20 * scale)) + wrapped = wrap_text(draw, text, font, max_width) + if wrapped.count("\n") > 2: + continue + box = draw.multiline_textbbox((0, 0), wrapped, font=font, spacing=spacing) + if box[2] - box[0] <= max_width and box[3] - box[1] <= round(560 * scale): + return wrapped, font, spacing + raise ValueError(f"Caption is too long for the artwork template: {text!r}") + + def output_dir_for(manifest: dict[str, Any], base: Path, override: Path | None, appearance: str) -> Path: if override: return resolve_path(Path.cwd(), override, appearance) @@ -381,33 +624,68 @@ def output_dir_for(manifest: dict[str, Any], base: Path, override: Path | None, def render_manifest( - manifest: dict[str, Any], base: Path, names: list[str], override: Path | None, size: tuple[int, int] + manifest: dict[str, Any], base: Path, names: list[str], override: Path | None, size: tuple[int, int], + *, guides: bool = False, ) -> list[Path]: _, icon = validate_icon(manifest, base) outputs: list[Path] = [] + kind = manifest_kind(manifest) for appearance in names: palette = manifest["appearances"][appearance] output_dir = output_dir_for(manifest, base, override, appearance) - for item in manifest["screenshots"]: + for item in manifest[kind]: destination = output_dir / item["output"] - render_one(manifest, palette, base, item, resolve_path(base, item["input"], appearance), - destination, size, icon) + input_path = resolve_path(base, item["input"], appearance) + if kind == "artwork": + render_artwork(manifest, palette, item, input_path, destination, icon, guides=guides) + else: + render_one(manifest, palette, base, item, input_path, destination, size, icon) outputs.append(destination) return outputs +def check_caption_copy(item: dict[str, Any], *, artwork: bool = False) -> None: + for key in ("eyebrow", "caption", "subtitle"): + text = str(item.get(key, "")) + if "sleepypod" in text.lower() and "sleepypod" not in text: + raise ValueError(f"The brand is lowercase: {text!r}") + if not artwork: + continue + lowered = text.lower() + if any(symbol in text for symbol in "$€£%"): + raise ValueError(f"Artwork copy must not mention pricing: {text!r}") + words = {word.strip(".,!?'\"") for word in lowered.split()} + hit = sorted(word for word in ARTWORK_FORBIDDEN_WORDS if word in words or (len(word) == 1 and word in text)) + if hit: + raise ValueError(f"Artwork copy must not contain {hit} (Apple asset guidance): {text!r}") + + def check_config(manifest: dict[str, Any], base: Path, size: tuple[int, int]) -> str: path, icon = validate_icon(manifest, base) + kind = manifest_kind(manifest) + outputs = [item["output"] for item in manifest[kind]] + if len(set(outputs)) != len(outputs): + raise ValueError(f"Two {kind} entries share an output filename") + if kind == "artwork": + probe = ImageDraw.Draw(Image.new("RGB", (1, 1))) + placements = [] + for item in manifest["artwork"]: + check_caption_copy(item, artwork=True) + item_size, safe, scale = artwork_layout(item) + column_width = artwork_column_width(safe, scale) + fit_artwork_caption(probe, str(item["caption"]), column_width, scale) + placements.append(f"{item['placement']} {item_size[0]}x{item_size[1]}") + return ( + f"PASS {manifest['app_name']}: {len(outputs)} artwork x {len(manifest['appearances'])} appearances " + f"({', '.join(manifest['appearances'])}); icon {path.name} {icon.width}x{icon.height}; " + f"{', '.join(placements)}" + ) scale = size[0] / DEFAULT_SIZE[0] draw = ImageDraw.Draw(Image.new("RGB", size)) margin = round(96 * scale) - outputs = [item["output"] for item in manifest["screenshots"]] - if len(set(outputs)) != len(outputs): - raise ValueError("Two screenshots share an output filename") for item in manifest["screenshots"]: fit_caption(draw, str(item["caption"]), size[0] - 2 * margin, round(330 * scale), scale) - if "sleepypod" in item["caption"].lower() and "sleepypod" not in item["caption"]: - raise ValueError(f"The brand is lowercase: {item['caption']!r}") + check_caption_copy(item) return ( f"PASS {manifest['app_name']}: {len(outputs)} screenshots x {len(manifest['appearances'])} appearances " f"({', '.join(manifest['appearances'])}); icon {path.name} {icon.width}x{icon.height}; captions fit {size[0]}x{size[1]}" @@ -416,6 +694,7 @@ def check_config(manifest: dict[str, Any], base: Path, size: tuple[int, int]) -> def run_self_test(manifest: dict[str, Any], base: Path, names: list[str], size: tuple[int, int]) -> str: _, icon = validate_icon(manifest, base) + kind = manifest_kind(manifest) with tempfile.TemporaryDirectory(prefix="sleepypod-asc-") as temporary: temporary_path = Path(temporary) raw_path = temporary_path / "raw.png" @@ -425,6 +704,21 @@ def run_self_test(manifest: dict[str, Any], base: Path, names: list[str], size: mock_draw.rounded_rectangle((60, 1360, 1260, 2600), radius=60, fill="#0F0F11", outline="#26262A", width=3) mock_draw.text((140, 380), "RAW APP SCREENSHOT", font=find_font(62, bold=True), fill="#ECECEC") mock.save(raw_path, format="PNG") + if kind == "artwork": + sizes = [] + for template in manifest["artwork"]: + item = {**template, "input": str(raw_path), "eyebrow": "Pipeline check", + "caption": "Real captures, framed for the App Store."} + expected, _, _ = artwork_layout(item) + for appearance in names: + output_path = temporary_path / f"self-test-{template['placement']}-{appearance}.png" + render_artwork(manifest, manifest["appearances"][appearance], item, raw_path, output_path, icon) + with Image.open(output_path) as result: + assert result.size == expected + assert result.mode == "RGB" + assert "A" not in result.getbands() + sizes.append(f"{expected[0]}x{expected[1]}") + return f"PASS {', '.join(sizes)} RGB/no alpha for {', '.join(names)}" item = {"input": str(raw_path), "output": "self-test.png", "eyebrow": "Pipeline check", "caption": "Real captures, framed for the App Store."} for appearance in names: @@ -442,19 +736,20 @@ def main() -> None: size = parse_size(args.size) manifest, base = load_manifest(args.manifest) names = appearances(manifest, args.appearance) + kind = manifest_kind(manifest) if args.only: - available = {Path(item['output']).stem for item in manifest['screenshots']} + available = {Path(item['output']).stem for item in manifest[kind]} unknown = set(args.only) - available if unknown: - raise ValueError(f'Unknown screenshot names: {sorted(unknown)}') - manifest['screenshots'] = [item for item in manifest['screenshots'] if Path(item['output']).stem in args.only] + raise ValueError(f'Unknown {kind} names: {sorted(unknown)}') + manifest[kind] = [item for item in manifest[kind] if Path(item['output']).stem in args.only] if args.check_config: print(check_config(manifest, base, size)) return if args.self_test: print(run_self_test(manifest, base, names, size)) return - for path in render_manifest(manifest, base, names, args.output_dir, size): + for path in render_manifest(manifest, base, names, args.output_dir, size, guides=args.guides): print(path) diff --git a/Marketing/AppStore/manifest.artwork.json b/Marketing/AppStore/manifest.artwork.json new file mode 100644 index 00000000..b921d875 --- /dev/null +++ b/Marketing/AppStore/manifest.artwork.json @@ -0,0 +1,76 @@ +{ + "format": 1, + "app_name": "sleepypod", + "icon": { + "path": "../../Sleepypod/Assets.xcassets/AppIcon.appiconset/AppIcon.png", + "pixel_sha256": "e310bb86466500d99569f428ddbad337395af1dfb8ccc7c39c5de587d58e6d1a" + }, + "appearances": { + "dark": { + "accent": "#A58BE0", + "text": "#ECECEC", + "muted": "#8B8B92", + "background": [ + "#0B0B0C", + "#11152E", + "#2A2466" + ], + "glow": "#D9779A", + "bezel": "#050506" + }, + "light": { + "accent": "#34509E", + "text": "#111113", + "muted": "#6B6B70", + "background": [ + "#F6F6F5", + "#E3E6F4", + "#C9C2EE" + ], + "glow": "#F2B596", + "bezel": "#17171A" + } + }, + "output_dir": "output/artwork/{appearance}", + "artwork": [ + { + "placement": "header", + "output": "header-21x9-3840x1646.png", + "size": "3840x1646", + "safe_area": [ + 0.09, + 0.08 + ], + "input": "raw/iphone-6.9/{appearance}/01-temperature.png", + "eyebrow": "Local pod control", + "caption": "Your pod, your temperature,\nyour phone.", + "subtitle": "Dial in each side of the bed, to the degree." + }, + { + "placement": "search", + "output": "search-3x2-3840x2560.png", + "size": "3840x2560", + "safe_area": [ + 0.09, + 0.08 + ], + "input": "raw/iphone-6.9/{appearance}/05-sleep-night.png", + "eyebrow": "Local pod control", + "caption": "Cooler nights,\nclearer mornings.", + "subtitle": "Temperature, schedules and each night's sleep." + }, + { + "placement": "universal", + "output": "universal-16x9-5244x2950.png", + "size": "5244x2950", + "safe_area": [ + 0.12, + 0.14 + ], + "input": "raw/iphone-6.9/{appearance}/01-temperature.png", + "eyebrow": "Local pod control", + "caption": "Your pod, your temperature,\nyour phone.", + "subtitle": "Dial in each side of the bed, to the degree." + } + ] +}