
App Store Screenshots
- 4 installs
- 5 repo stars
- Updated June 10, 2026
- framara/app-store-screenshots-skill
Produces localized, caption-framed App Store screenshots from real iOS simulator captures and uploads them to App Store Connect.
About
An end-to-end playbook that captures real localized iPhone/iPad simulator screenshots, composites them into marketing frames with headlines via an HTML/CSS compositor, and uploads them to App Store Connect. A developer or ASO owner uses it to create, localize, or refresh App Store screenshots and resubmit for review.
- Three-layer pipeline: raw captures, captions/design JSON, and a re-render compositor
- Captures via xcrun simctl + XcodeBuildMCP, uploads via the asc CLI
App Store Screenshots by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,606 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/framara/app-store-screenshots-skill --skill app-store-screenshotsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 5 |
| Last updated | June 10, 2026 |
| Repository | framara/app-store-screenshots-skill ↗ |
What it does
Produces localized, caption-framed App Store screenshots from real iOS simulator captures and uploads them to App Store Connect.
Files
App Store Screenshots — capture → frame → upload
Battle-tested end to end on a shipping game (SAPPER, 2026): 7 shots × 10 languages × iPhone + iPad = 140 frames, replaced across 39 App Store Connect locales and resubmitted for review in one day.
The philosophy: App Store screenshots are marketing frames, not raw UI — a big localized headline over a device mock containing a real, localized, real-data capture. Keep three independent layers so each can change cheaply:
1. Raw captures — slow to make, often not regenerable (live data, daily seeds). Archive them in git. 2. Captions + design config — one JSON for all locales and shots. 3. Compositor — scripts/generate.py re-renders every frame in minutes after any copy or design tweak. No recapture needed.
Prerequisites
- Xcode + iOS simulators for the target devices (iPhone 17 Pro Max class
for 6.9", iPad Pro 13" class) — captures use xcrun simctl.
- [XcodeBuildMCP](https://github.com/cameroncooke/XcodeBuildMCP) CLI
(brew install cameroncooke/xcodebuildmcp/xcodebuildmcp) — provides element-ref UI automation (xcodebuildmcp ui-automation snapshot-ui/tap) and bundles the AXe binary the capture scripts depend on, at /opt/homebrew/Cellar/xcodebuildmcp/<ver>/libexec/bundled/axe. (Alternative: install AXe standalone — brew install cameroncooke/axe/axe.)
- [asc CLI](https://github.com/rorkai/App-Store-Connect-CLI), authenticated
against the App Store Connect API — uploads, localizations, builds, review submissions.
- Google Chrome — headless renderer for the frame compositor.
- Python 3 — capture loops, compositor, verification (stdlib only).
- A DEBUG build of the app with a screenshot-mode launch flag (see
Phase 1) installed on the simulators.
References (read the one matching your phase)
references/sizes-and-devices.md— simulators, exact pixel sizes, ASC
display types, device-mock CSS values, type scale tables
references/captions.md— caption anatomy, the narrative arc,
transcreation rules per locale (glossary, line breaks, CJK), captions.json
references/capture-locales.md— per-locale capture loops, iPhone vs iPad
differences, state discriminators, every automation gotcha
references/compositor.md— the HTML/CSS frame generator, auto-fit and
auto-push JS (with the bugs they prevent), render command, review checklist
references/asc-upload.md— locale mapping table, the fan-out abort gotcha
+ mandatory verification, review-submission rebuild (incl. Game Center)
Bundled scripts (copy into the app's repo and adapt)
scripts/generate.py— the frame compositor (HTML/CSS → headless Chrome)scripts/captions.example.json— the copy/config data shapescripts/loop_locales_skeleton.py— per-locale capture loop with the
battle-tested helpers (frame-targeted taps, state assertions, retries)
scripts/verify_asc_screenshots.py— post-upload per-locale verification
Phase summary
0 — Plan. 5–7 shots, caption-led, arc: hook → core choice → retention → secondary mode → social → monetization → trust. Background colors rotate through the app's own palette, never repeating adjacently. No prices in screenshots (they vary by storefront and drift). Captions transcreated per locale with explicit line breaks.
1 — Prepare the app. Add a DEBUG-only launch flag (e.g. -MarketingScreenshots YES) that: hides store prices behind a neutral localized tag; forces the accessibility overlay on custom-drawn surfaces so automation can read them. Make sure a debug menu can reset/replay any state you need to capture.
2 — Capture iPhone. Use a sim whose native screenshot IS the store size (no resizing, ever). Build each app state once in English, then per locale: background the app (state saves on backgrounding!) → terminate → relaunch with -AppleLanguages "(xx)" -AppleLocale xx_XX → re-shoot with locale-independent navigation. Clean status bar via simctl status_bar.
3 — Capture iPad. Same flow, three traps: axe button home is a silent no-op (use button lock to background); the status bar shows a date in the SYSTEM language (switch .GlobalPreferences + reboot per locale); all layout metrics differ from iPhone — re-probe everything.
4 — Frame. generate.py + captions.json → headless Chrome at exact store resolution. Auto-fit shrinks long headlines; auto-push moves the device below wrapping subs. Validate every PNG's dimensions; eyeball the longest Latin locale and one CJK locale on every shot type.
5 — Upload. Stage a hardlink tree keyed by ASC locale codes (app languages fan out: en serves en-US/GB/AU/CA + all store-only locales). Upload with --replace to the display types the listing ALREADY uses. The fan-out aborts all remaining locales on the first delivery failure while exiting 0 — run verify_asc_screenshots.py and re-run the gaps.
6 — Resubmit (if a review was pending). Save the pending submission's item IDs → cancel → new build + screenshots → attach build → recreate submission → re-add every item (the appStoreVersions item only attaches after screenshot assets finish processing — add it last) → audit the count → submit.
Hard-won rules (apply everywhere)
- Frame-target every tap from
axe describe-uioutput; fixed coordinates
break across locales and states.
- The accessibility tree contains ALL layers at once — find a geometric
discriminator (e.g. a known card's y-position) to assert which screen you're on, and verify after every navigation.
- The first tap after a cold launch or transition is often eaten: retry loops
with state assertions, never fire-and-forget.
- Long-press = separate
touch --down, real sleep,touch --up. The
single-command delay form sometimes registers as a tap.
- After any swipe, wait until two consecutive position reads agree before
computing tap targets (content decelerates).
- Anything that reads localized a11y labels (solvers, content checks) runs in
English only; per-locale passes navigate by geometry.
- Verification is part of every phase: look at the captures with your eyes,
measure the frames, list the uploads. Every silent failure we hit was caught by checking, not by exit codes.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What This Repo Is
This is a Claude Code skill (not an application). It documents and tools an end-to-end pipeline for producing localized, caption-framed App Store screenshots (iPhone + iPad) and replacing them on App Store Connect. Installed via npx skills add framara/app-store-screenshots-skill.
Repository Structure
SKILL.md— the entry point Claude Code reads when the skill activates: prerequisites, the six-phase summary, the hard-won automation rules, and the routing list to references.references/— five phase-specific deep dives (sizes-and-devices, captions, capture-locales, compositor, asc-upload). SKILL.md routes to these; keep the routing list in sync.scripts/— working code meant to be COPIED into an app's repo and adapted:generate.py(frame compositor),captions.example.json(data shape),loop_locales_skeleton.py(per-locale capture loop),verify_asc_screenshots.py(post-upload verification).assets/— README imagery only.
Editing Guidelines
- SKILL.md stays concise — it loads into context on every activation. Depth belongs in
references/. - Every rule in SKILL.md's "Hard-won rules" section was learned from a real failure; don't soften or remove them without strong evidence the underlying behavior changed.
- Scripts are stdlib-only Python by design (plus external CLIs:
axe,asc,simctl, Chrome). Keep them dependency-free. - Sizes, display types, and CLI flags drift with new devices and tool versions — when updating, verify against
asc screenshots sizes --alland the current ASC API rather than trusting the tables.
MIT License
Copyright (c) 2026 framara
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
app-store-screenshots-skill
A Claude Code skill for producing localized, caption-framed App Store screenshots (iPhone + iPad) from real simulator captures, and replacing them on App Store Connect.
Battle-tested end to end on a shipping game: 7 shots × 10 languages × iPhone + iPad = 140 frames, uploaded across 39 ASC locales and resubmitted for review in a single day.
!Before / after
What's included
The playbook covers six phases, each with a detailed reference:
- Sizes & devices — exact capture resolutions (1320×2868 / 2064×2752),
which simulators produce them natively, ASC display-type mapping, and the device-mock CSS values that shipped
- Captions — caption anatomy (eyebrow / headline / sub), a 7-shot
narrative arc, and transcreation rules per locale: app-glossary reuse, explicit per-locale line breaks, CJK sizing, formality
- Capture per locale — build app state once, re-shoot per language via
backgrounding + relaunch with locale args; iPhone vs iPad differences (lock-button backgrounding, system-language reboots for the iPad date); every accessibility-automation gotcha we hit
- Compositor — an HTML/CSS frame generator rendered by headless Chrome at
exact store resolution, with headline auto-fit and copy/device overlap auto-push
- ASC upload — app-language → 39-locale fan-out mapping, uploading to the
display types the listing already uses, and the silent fan-out-abort gotcha with a mandatory verification script
- Review resubmission — cancel/rebuild a pending submission without
losing Game Center items
Plus working scripts to copy into an app's repo: the compositor (scripts/generate.py), the captions data shape, a per-locale capture loop skeleton with all the hardened helpers, and the post-upload verifier.
Prerequisites
- Xcode + iOS simulators (iPhone 6.9" and iPad 13" classes)
- XcodeBuildMCP CLI
(brew install cameroncooke/xcodebuildmcp/xcodebuildmcp) — UI automation + bundles the AXe binary the capture scripts use
- asc CLI, authenticated
- Google Chrome (headless frame rendering)
- Python 3 (stdlib only)
Installation
npx skills add framara/app-store-screenshots-skillTo install globally (all projects):
npx skills add framara/app-store-screenshots-skill -gUsage
Once installed, Claude Code uses this skill automatically when you ask things like:
- "Create App Store screenshots for my app in all our languages"
- "Redesign my store screenshots with captions, iPhone and iPad"
- "Replace the screenshots on App Store Connect and resubmit"
Philosophy
App Store screenshots are marketing frames, not raw UI: a big localized headline over a device mock containing a real, localized, real-data capture. The pipeline keeps three layers independent so each changes cheaply:
1. Raw captures — slow to make, often not regenerable; archived in git 2. Captions + design config — one JSON for all locales and shots 3. Compositor — re-renders all frames in minutes after any copy tweak
License
MIT
Uploading to App Store Connect (and resubmitting for review)
Locale mapping: app languages → ASC locales
ASC versions can have ~39 localizations; an app usually translates ~10. Stage an upload tree whose children are ASC locale codes, mapping each app-language screenshot set to every ASC locale it should serve, with English covering all store-only locales:
| App set | ASC locales served |
|---|---|
| en | en-US, en-GB, en-AU, en-CA + every store-only locale (ar-SA, ca, cs, da, el, fi, he, hi, hr, hu, id, ms, nl-NL, no, pl, ro, ru, sk, sv, th, tr, uk, vi) |
| es | es-ES, es-MX |
| fr | fr-FR, fr-CA |
| pt-BR | pt-BR, pt-PT |
| de / it / ja / ko / zh-Hans / zh-Hant | 1:1 |
Get the authoritative locale list first: asc localizations list --version <VERSION_ID> --output json.
Stage with hardlinks (instant, no disk cost):
os.link(f"v2/{app_loc}/{dev}/{f}", f".upload/{asc_loc}/{dev}/{f}")Upload (fan-out mode)
asc screenshots upload --app $APP_ID --version-id $VERSION_ID \
--path .upload --device-type IPHONE_67 --replace
asc screenshots upload --app $APP_ID --version-id $VERSION_ID \
--path .upload --device-type IPAD_PRO_3GEN_129 --replace--replacedeletes the set's existing screenshots first (clean swap).- Use the display types the listing ALREADY uses (see sizes-and-devices.md).
- Screenshots are only editable when the version is editable — a version in
WAITING_FOR_REVIEW must have its review submission canceled first.
⚠️ The fan-out abort gotcha (cost us ~20 locales silently)
On the first asset delivery failure, the CLI abandons ALL remaining locales in the list — and still exits 0, writing only a failure artifact for the one file. --resume <artifact> completes only THAT locale, not the rest.
So verification is mandatory, not optional:
for locale, loc_id in localizations: # all ~39
sets = asc_screenshots_list(loc_id)
for display_type in (IPHONE, IPAD):
assert count == N_SHOTS
assert filenames == sorted(filenames) and filenames[0] == "01-...png"
assert all(assetDeliveryState == "COMPLETE")Re-run only the gap locales (stage a second tree with just those). Repeat verify → fix until all green. Transient VALIDATION_JOB_FAILED deliveries usually succeed on retry; an "ordering" failure after a resume means the re-uploaded file was appended out of order — re---replace that single set (--version-localization <loc_id> --path .upload/<loc>/<dev>).
Review submission rebuild (when a submission was already pending)
The order that works:
# 1. SAVE the pending submission's items before canceling
asc review submissions-items-ids --id $OLD_SUB --output json
# ids are base64("sub|typecode|itemUUID"); typecodes:
# 6=appStoreVersions 9=achievementVersions 11=challengeVersions 12=leaderboardVersions
# 2. cancel → version becomes editable (DEVELOPER_REJECTED)
asc review submissions-update --id $OLD_SUB --canceled=true
# 3. new build + screenshots while editable
asc xcode version bump --type build # keep marketing version
xcodebuild clean archive ... && xcodebuild -exportArchive ...
asc builds upload --app $APP_ID --ipa $IPA # wait for VALID
# (replace screenshots now — see above)
# 4. rebuild the submission
asc versions attach-build --version-id $VID --build $BUILD_ID
asc review submissions-create --app $APP_ID --platform IOS
asc review items-add --submission $NEW_SUB --item-type gameCenterAchievementVersions --item-id <uuid> # × each saved item
# the appStoreVersions item is REJECTED while screenshot assets are still
# processing ("screenshot ... still in progress") — add it LAST, after the
# per-locale verification passes:
asc review items-add --submission $NEW_SUB --item-type appStoreVersions --item-id $VID
# 5. audit count matches the canceled submission, then submit
asc review submissions-items-ids --id $NEW_SUB
asc review submissions-submit --id $NEW_SUB --confirm
asc review status --app $APP_ID # expect WAITING_FOR_REVIEWGame Center note: an app's FIRST leaderboard/achievement/challenge versions can only ship riding an app version in the same submission — never drop them when rebuilding (an app's first GC content must ride an app version).
Archive hygiene after upload
Keep the .xcarchive (dSYMs for crash symbolication): move it to ~/Library/Developer/Xcode/Archives/<date>/App X.Y.Z (build).xcarchive so the Organizer tracks it. The IPA export dir and DerivedData can be deleted.
Writing and localizing captions
Anatomy of one shot's copy
● DAILY CHALLENGE ← eyebrow: 1–3 words, small caps, accent dot
Minesweeper ← headline line 1 (explicit line breaks,
with no edges ← headline line 2 stored as an array)
The farther out you sweep, the more ← sub: one sentence, wraps freely,
every tile is worth. carries the explanation- Headline = the claim. Sub = the proof/detail. Eyebrow = the category.
- Accent color on ONE headline segment max (
<em>in the data), e.g. the
final line of a 3-line stack: "One board. / Everyone. / Every day."
- Voice: calm, direct, short, no exclamation marks, no em dashes, no hype
adjectives. Read each headline aloud; if it sounds like an ad, cut it.
- Numbers shown in ghost elements (oversized translucent score, ∞ glyph) must
match what the device capture actually displays.
The narrative arc that worked (adapt per app)
| # | Role | BG | SAPPER example |
|---|---|---|---|
| 1 | Hook: the twist, mid-action | cream | "Minesweeper with no edges" |
| 2 | The core decision | yellow | "Push your luck, or cash out" |
| 3 | The retention ritual | ink | "One board. Everyone. Every day." |
| 4 | The second mode | cream | "Or sweep forever" |
| 5 | Social/share | ink | "Every run draws a map" |
| 6 | Monetization, honestly | yellow | "Buy it, or earn it" |
| 7 | Trust closer | cream | "No ads. No subscriptions." |
Backgrounds rotate through the brand palette with no adjacent repeats.
Localization = transcreation, not translation
- Brand name stays Latin in every locale.
- Mode/feature names must match the app's own String Catalog glossary
(SAPPER: Endless = es Infinito, fr Sans fin, de Endlos, it Senza fine, pt-BR Infinito, ja エンドレス, ko 엔드리스, zh-Hans 无限, zh-Hant 無限). Open Localizable.xcstrings and reuse, never invent.
- Formality must match the app: SAPPER uses French tu, German du.
- Decide line breaks per locale explicitly. "Or sweep / forever" works in
English; German needs "Oder räum / endlos weiter". Store headlines as arrays of lines, one decision per locale per shot.
- Idioms transcreate: en "Looks good on you" → pt-BR "Ficou bom em você";
en "Every run draws a map" → ja "進んだ跡が、地図になる。"
- CJK: shorter headlines, smaller base size (the glyphs are dense), reduced
eyebrow tracking (.18em vs .32em). Periods 。 count as characters — budget for them.
- Punctuation per script: Chinese comma 「,」, Japanese 「、」「。」 — don't
paste Latin punctuation into CJK lines.
- Languages that will stress the layout: German + Italian (longest words),
French (longest subs). Spot-check those plus one CJK on EVERY shot type after rendering.
Data shape (captions.json)
{
"shots": {
"01-hero": { "raw": "01-board.png", "style": "cream", "ghost": "dot",
"deviceTop": 935 },
"02-choice": { "raw": "02-cashout.png", "style": "yellow",
"ghost": "score", "deviceTop": 1000, "full": true }
},
"baseSize": { "01-hero": 168, "02-choice": 148 },
"cjkSize": { "01-hero": 120, "02-choice": 116 },
"locales": {
"de": {
"01-hero": { "eyebrow": "Tägliches Rätsel",
"h": ["Minesweeper", "ohne Ränder"],
"sub": "Je weiter du kommst, desto mehr ist jede Kachel wert." }
}
}
}One file is the single source of truth for all copy; the compositor reads it. Store-only ASC locales (no app translation) reuse the English entries.
Capturing real localized screenshots per locale
Principle: build state once, re-shoot per locale
App state (runs, scores, banked results) persists across relaunches — but only if the app gets a chance to save. So:
1. Build each capture state ONCE (play the game, bank the score, open the right sheets) in English — any automation that reads accessibility labels (solvers, content checks) must run in English because labels localize. 2. Per locale: background the app → terminate → relaunch with locale args → navigate to the same screens with locale-independent (frame-based) taps → screenshot.
# background FIRST or the in-progress state is lost on terminate:
axe button home --udid $UDID # iPhone
axe button lock --udid $UDID # iPad — 'home' is a SILENT NO-OP on iPads!
sleep 2
xcrun simctl terminate $UDID $BUNDLE_ID
xcrun simctl launch $UDID $BUNDLE_ID -AppleLanguages "(de)" -AppleLocale de_DELocale args pairs used: en/en_US es/es_ES fr/fr_FR de/de_DE it/it_IT pt-BR/pt_BR ja/ja_JP ko/ko_KR zh-Hans/zh_CN zh-Hant/zh_TW.
iPhone vs iPad locale differences
- iPhone status bar shows only the time → app-level
-AppleLanguagesis
enough; the system language never appears.
- iPad status bar shows the DATE in the SYSTEM language ("Wed Jun 10" /
"Miércoles 10 de junio"). Per locale you must also switch the system:
xcrun simctl spawn $UDID defaults write .GlobalPreferences AppleLanguages -array de
xcrun simctl spawn $UDID defaults write .GlobalPreferences AppleLocale -string de_DE
xcrun simctl shutdown $UDID && xcrun simctl boot $UDID # ~20 s
# status_bar override does NOT survive reboot — re-apply it nowsimctl launchon an already-running app does NOT relaunch it (returns the
existing pid, old language) — always terminate first.
Status bar (set once per boot)
xcrun simctl status_bar $UDID override --time 9:41 \
--batteryState discharging --batteryLevel 100 --wifiBars 3 --cellularBars 4
# batteryState 'charged' draws a green charging bolt — don't use itBackgrounding via opening another app (e.g. Safari) leaves a "◀ Safari" breadcrumb in the status bar of subsequent captures — use the home/lock button instead.
The per-locale loop skeleton (full version: scripts/loop_locales_skeleton.py)
for locale in LOCALES:
background_and_terminate() # home btn (iPhone) / lock btn (iPad)
switch_system_language(locale) # iPad only, includes reboot
launch(locale); sleep(5)
frames = describe_ui_button_frames() # axe describe-ui, retry on bad JSON
assert on_home(frames) # see state discriminators below
shot(f"{locale}/00-home.png")
tap_center(resume_card(frames)) # FRAME-TARGETED, never fixed x/y
retry_until(in_game) # first tap is often eaten
shot(f"{locale}/01-board.png")
...
print(f"ok {locale}") # or '!! locale: reason' and continueRun loops in the background, stream ok/!! lines, then fix and re-run ONLY the failed locales.
State discriminators & navigation rules (hard-won)
axe describe-uireturns ALL layers at once (home + game + sheets). To tell
home from in-game, use geometry: e.g. the home card's y < 900 on home but
1000 (pushed off-screen) in game. Find an equivalent invariant per app.
- Identify buttons by FRAME SIZE, not label (labels localize): home rows were
404 pt wide on both devices; sheet buttons ~150–260×52 (iPhone) vs ~258×54 (iPad) — measure with a probe first, then codify with tolerant bounds (a 55-px lower bound silently excluded 54-px iPad buttons).
- The first tap after a cold launch or screen transition is frequently eaten:
loop tap → wait → re-verify → retry (max 4–5).
- Long-press:
axe touch -x X -y Y --down,sleep 1.5,axe touch ... --up
as SEPARATE commands. The single-command --delay form sometimes registers as a tap (which, on a minesweeper mine, ends your run).
- After
axe swipe, content decelerates for a while — wait, then read a
reference element's position twice and require it to be identical before computing any new tap coordinates.
- Sheets/popovers: dismiss by dragging the grabber down
(axe swipe from ~y of the grabber to near the bottom edge).
- If element-level a11y is missing on a custom-drawn view (Canvas), the app
needs a debug a11y overlay — and verify it exposes the FULL visible grid; a clamped/budgeted overlay that silently truncates rows corrupts any logic that reasons about neighbors (we shipped this bug: the overlay capped at 600 elements and vanished on iPad's ~830-tile viewport).
Verification is part of capture
After each loop, open 3–5 captures from different locales and LOOK at them (decode to ~250 px wide). Every silent failure mode we hit — wrong screen captured, Settings opened instead of a sheet, stale language — was caught by eyes, not by exit codes.
The frame compositor (HTML/CSS → headless Chrome)
Reference implementation: scripts/generate.py in this repo (~200 lines, no dependencies beyond Chrome). Copy it into the app's repo and edit the DEVICES table + STYLES + ghosts.
Architecture
captions.json per-shot config + per-locale copy (edit freely)
generate.py template + DEVICES table + render loop (stable)
<loc>/<device>/NN-shot.png (output, exact store px)python3 generate.py [--device iphone|ipad] [locale ...] regenerates any subset in seconds — copy/design changes never require recapturing.
Template structure (one HTML page per frame)
body= canvas at exact store size, brand background (solid/gradient per
shot "style"), optional faint grid texture echoing the app, masked to fade.
.copyblock: eyebrow (dot + small caps),h1with one<span>per
headline line (display:block; white-space:nowrap), .sub.
.device: absolutely positioned mock — dark bezel div, rounded screen-wrap
with overflow:hidden, the raw capture as <img width:100%>, plus a drawn Dynamic Island div on iPhone (sim captures don't include it).
- Ghost elements behind the device (z-index 1): oversized accent circle,
translucent score (must match the captured score!), ∞ glyph, etc.
- Background styles get per-style shadow colors (warm shadow on yellow, rim
light on ink) — one STYLES dict.
The two JS passes (run inside the page before screenshot)
Auto-fit — shrink headline until every line fits:
const h1 = document.querySelector('h1');
const max = h1.clientWidth; // MEASURE — never hardcode!
let size = parseFloat(getComputedStyle(h1).fontSize);
const fits = () => [...h1.querySelectorAll('span')].every(s => s.scrollWidth <= max);
while (!fits() && size > 60) { size -= 4; h1.style.fontSize = size + 'px'; }⚠️ Block-level spans report scrollWidth >= container width even when the text fits. Comparing against a hardcoded constant that is 4 px smaller than the real container made every headline shrink to the floor — measure clientWidth at runtime.
Auto-push — long-locale subs wrap an extra line and slide under the device; push the device down below the copy, but ONLY for cropped shots (full-device shots have fixed tops so they can't slip off-canvas):
if (AUTOPUSH) {
const dev = document.querySelector('.device');
const copy = document.querySelector('.copy');
const minTop = copy.offsetTop + copy.offsetHeight + 60;
if (dev.offsetTop < minTop) dev.style.top = minTop + 'px';
}Rendering
chrome --headless=new --disable-gpu --hide-scrollbars \
--force-device-scale-factor=1 --window-size=$W,$H \
--virtual-time-budget=3000 --screenshot=$OUT file://$HTML--virtual-time-budget guarantees the JS passes run before capture.
Fonts: -apple-system, "SF Pro Display", ..., "PingFang SC", "PingFang TC", "Hiragino Sans", "Apple SD Gothic Neo" — headless Chrome on macOS resolves the native stacks, so Latin and CJK both render Apple-grade with zero font files.
Review checklist after a full render
1. sips -g pixelWidth -g pixelHeight on every PNG — exact store size. 2. Eyeball EN, the longest Latin locale (de or it), and one CJK locale on EVERY shot type (cropped, full-device, 3-line headline). 3. Ghost numbers match the capture's visible numbers per device. 4. Nothing overlaps: sub vs device, eyebrow width, island vs content.
Sizes, simulators, and ASC display types
Capture simulators (native screenshot = exact store size, never resize)
| Store class | Simulator | Native screenshot | Logical pt | ASC display type |
|---|---|---|---|---|
| iPhone 6.9" | iPhone 17 Pro Max | 1320 × 2868 | 440 × 956 @3x | APP_IPHONE_67 and APP_IPHONE_69 both accept it |
| iPad 13" | iPad Pro 13" (M4/M5) | 2064 × 2752 | 1032 × 1376 @2x | APP_IPAD_PRO_3GEN_129 (also APP_IPAD_PRO_129) |
These two classes are all ASC requires for a universal app today — the 6.9" set covers smaller iPhones, the 13" set covers smaller iPads.
⚠️ Upload to whichever display type the listing ALREADY has sets under (check asc screenshots list --version-localization <id>). 1320×2868 is valid under both IPHONE_67 and IPHONE_69 — uploading to the one the listing does not use creates a second parallel set instead of replacing. Example: SAPPER's live sets were APP_IPHONE_67 + APP_IPAD_PRO_3GEN_129.
Full dimension matrix: asc screenshots sizes --all --output json.
Frame canvas = the same store sizes
The compositor renders HTML at exactly 1320×2868 (iPhone) / 2064×2752 (iPad):
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless=new --disable-gpu --hide-scrollbars \
--force-device-scale-factor=1 --window-size=1320,2868 \
--virtual-time-budget=3000 --screenshot=out.png file://frame.htmlValidate every output:
sips -g pixelWidth -g pixelHeight *.png # all must match exactlyDevice mock values that looked right (shipped values, copy as starting point)
| iPhone canvas (1320×2868) | iPad canvas (2064×2752) | |
|---|---|---|
| Device width, cropped board shot | 1064 px | 1560 px |
| Device width, full-device sheet shot | 860 px | 1130 px |
| Bezel: radius / padding / screen radius | 152 / 24 / 128 px | 92 / 34 / 56 px |
| Dynamic Island (drawn over capture) | 300×90 px, top 26, r 46 | none |
| Device top, board shots | 935 px | 990 px |
| Device top, 3-line-headline shot | 1150 px | 1190 px |
| Device top, full-device shots | 1000–1010 px | 1150 px |
Cropped shots bleed off the canvas bottom by design; full-device shots must fully fit (that's why auto-push never applies to them).
Type scale (values that shipped (SAPPER))
| Element | iPhone | iPad |
|---|---|---|
| Eyebrow | 42 px, tracking .32em (CJK .18em), 26 px dot | 54 px, 34 px dot |
| Headline base (per shot) | 138–168 px | ×1.45 of iPhone value |
| Headline CJK base (ja/ko/zh) | 112–120 px | ×1.45 |
| Sub-caption | 58 px / 1.34 | 76 px |
| Copy margins | top 150, sides 100 | top 140, sides 130 |
Headlines always get a JS auto-fit pass on top of these (see compositor ref).
{
"_comment": "Captions + per-shot design config (single source of truth for all copy). Headline arrays = explicit per-locale line breaks; <em> marks the one accent-colored segment. Truncated to 2 shots x 2 locales to show the shape — a real file carries every shot and locale.",
"shots": {
"01-hero": {
"raw": "01-board.png",
"style": "cream",
"ghost": "dot",
"deviceTop": 935
},
"02-choice": {
"raw": "02-cashout.png",
"style": "yellow",
"ghost": "score",
"deviceTop": 1000,
"full": true
}
},
"baseSize": { "01-hero": 168, "02-choice": 148 },
"cjkSize": { "01-hero": 120, "02-choice": 116 },
"locales": {
"en": {
"01-hero": {
"eyebrow": "Daily Challenge",
"h": ["Minesweeper", "with no edges"],
"sub": "The farther out you sweep, the more every tile is worth."
},
"02-choice": {
"eyebrow": "One Choice",
"h": ["Push your luck,", "or cash out"],
"sub": "That one choice is the whole game."
}
},
"ja": {
"01-hero": {
"eyebrow": "デイリーチャレンジ",
"h": ["果てのない", "マインスイーパー"],
"sub": "遠くへ進むほど、1マスの価値が上がる。"
},
"02-choice": {
"eyebrow": "選択はひとつ",
"h": ["粘るか、", "確保するか。"],
"sub": "そのひとつの選択が、ゲームのすべて。"
}
}
}
}
#!/usr/bin/env python3
"""Generate framed App Store screenshots from raw captures + captions.json.
Usage: python3 generate.py [--device iphone|ipad] [locale ...]
Defaults: iphone, all locales. iPhone canvas 1320x2868, iPad 2064x2752.
Requires: Google Chrome (headless rendering).
CUSTOMIZE PER APP: the STYLES dict (brand palette backgrounds), the GHOSTS
dict (decorative elements behind the device), the DEVICES table (per-shot
device tops, ghost score values), and captions.json (all copy + per-shot
config — see captions.example.json for the shape).
EXPECTED LAYOUT (siblings of this script's directory; rename via the DEVICES
'raw'/'out' keys):
<parent>/v2-raw/<locale>/<shot>.png raw iPhone captures
<parent>/v2-raw-ipad/<locale>/<shot>.png raw iPad captures
<script dir>/captions.json copy + config
<script dir>/<locale>/<iphone|ipad>/*.png framed output
"""
import json, os, subprocess, sys
BASE = os.path.dirname(os.path.abspath(__file__))
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
CAP = json.load(open(os.path.join(BASE, "captions.json")))
CJK = {"ja", "ko", "zh-Hans", "zh-Hant"}
TEMPLATE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
:root {{ --ink:#211c15; --cream:#f4eee1; --accent:#f5a524; }}
html,body {{ width:{W}px; height:{H}px; overflow:hidden; }}
body {{ font-family:-apple-system,"SF Pro Display","Helvetica Neue","PingFang SC","PingFang TC","Hiragino Sans","Apple SD Gothic Neo",sans-serif;
-webkit-font-smoothing:antialiased; position:relative; {body_css} }}
.grid-texture {{ position:absolute; inset:0;
background-image:linear-gradient({grid} 1.5px,transparent 1.5px),linear-gradient(90deg,{grid} 1.5px,transparent 1.5px);
background-size:110px 110px; background-position:-55px -55px;
-webkit-mask-image:linear-gradient(180deg,rgba(0,0,0,.9),rgba(0,0,0,.25) 55%,transparent 80%); }}
.copy {{ position:absolute; top:{copytop}px; left:{copyleft}px; right:{copyleft}px; z-index:3; }}
.eyebrow {{ display:flex; align-items:center; gap:22px; font-size:{eyesize}px; font-weight:700;
letter-spacing:{eyetrack}; text-transform:uppercase; color:{eyecolor}; white-space:nowrap; }}
.eyebrow .dot {{ width:{dotsize}px; height:{dotsize}px; border-radius:50%; background:{dotcolor}; flex:none; }}
h1 {{ margin-top:56px; font-size:{hsize}px; line-height:1.04; font-weight:800; letter-spacing:{htrack}; }}
h1 span {{ display:block; white-space:nowrap; }}
h1 em {{ font-style:normal; color:var(--accent); }}
.sub {{ margin-top:56px; font-size:{subsize}px; line-height:1.34; font-weight:500; max-width:{submax}px; color:{subcolor}; }}
.device {{ position:absolute; left:50%; transform:translateX(-50%); top:{devtop}px; width:{devwidth}px; z-index:2; }}
.bezel {{ background:#15120e; border-radius:{bezrad}px; padding:{bezpad}px; box-shadow:{shadow}; }}
.screen-wrap {{ position:relative; border-radius:{scrrad}px; overflow:hidden; }}
.screen-wrap img {{ display:block; width:100%; }}
.island {{ position:absolute; top:26px; left:50%; transform:translateX(-50%);
width:300px; height:90px; border-radius:46px; background:#000; z-index:4; }}
.ghost-dot {{ position:absolute; z-index:1; width:560px; height:560px; border-radius:50%;
background:radial-gradient(circle at 38% 32%,#ffc35c,var(--accent) 70%); right:-140px; top:700px; opacity:.92; }}
.ghost-score {{ position:absolute; z-index:1; right:-60px; top:470px;
font-size:560px; font-weight:800; letter-spacing:-.04em; color:rgba(255,255,255,.22); }}
.ghost-infinity {{ position:absolute; z-index:1; right:-80px; top:560px;
font-size:620px; font-weight:800; color:rgba(245,165,36,.20); }}
</style></head>
<body>
<div class="grid-texture"></div>
<div class="copy">
<div class="eyebrow"><span class="dot"></span>{eyebrow}</div>
<h1>{headline}</h1>
<div class="sub">{sub}</div>
</div>
{ghost}
<div class="device"><div class="bezel"><div class="screen-wrap">
{island}<img src="{img}">
</div></div></div>
<script>
// Shrink the headline until every line fits the copy column.
const h1 = document.querySelector('h1');
const max = h1.clientWidth; // block spans report scrollWidth >= container only when text overflows
let size = parseFloat(getComputedStyle(h1).fontSize);
const fits = () => [...h1.querySelectorAll('span')].every(s => s.scrollWidth <= max);
while (!fits() && size > 60) {{ size -= 4; h1.style.fontSize = size + 'px'; }}
// Same guard for the eyebrow (long German/Italian strings).
const eb = document.querySelector('.eyebrow');
let es = parseFloat(getComputedStyle(eb).fontSize);
while (eb.scrollWidth > max && es > 24) {{ es -= 2; eb.style.fontSize = es + 'px'; }}
// Long-locale subs can wrap an extra line and run under the device. For
// cropped board shots (bottom bleed by design) push the device down to
// clear the copy; full-device shots keep their fixed top.
if ({autopush}) {{
const dev = document.querySelector('.device');
const copy = document.querySelector('.copy');
const minTop = copy.offsetTop + copy.offsetHeight + 60;
if (dev.offsetTop < minTop) dev.style.top = minTop + 'px';
}}
</script>
</body></html>"""
STYLES = {
"cream": dict(
body_css="background:linear-gradient(180deg,#f6f0e3 0%,#efe7d4 100%); color:var(--ink);",
grid="rgba(33,28,21,.055)", eyecolor="var(--ink)", dotcolor="var(--accent)",
subcolor="rgba(33,28,21,.62)",
shadow="0 90px 180px rgba(33,28,21,.38), 0 30px 60px rgba(33,28,21,.22)"),
"yellow": dict(
body_css="background:linear-gradient(175deg,#ffb842 0%,#f5a524 46%,#e08f0a 100%); color:#241a08;",
grid="rgba(36,26,8,.07)", eyecolor="#241a08", dotcolor="#241a08",
subcolor="rgba(36,26,8,.72)",
shadow="0 100px 200px rgba(80,50,0,.45), 0 30px 70px rgba(80,50,0,.3)"),
"ink": dict(
body_css="background:radial-gradient(140% 90% at 50% 0%,#2b251c 0%,#1d1813 60%,#161310 100%); color:var(--cream);",
grid="rgba(244,238,225,.045)", eyecolor="var(--accent)", dotcolor="var(--accent)",
subcolor="rgba(244,238,225,.6)",
shadow="0 0 0 2px rgba(244,238,225,.08), 0 90px 180px rgba(0,0,0,.6)"),
}
GHOSTS = {"dot": '<div class="ghost-dot"></div>',
"score": '<div class="ghost-score">{score}</div>',
"infinity": '<div class="ghost-infinity">∞</div>',
"none": ""}
DEVICES = {
"iphone": dict(W=1320, H=2868, raw="v2-raw", out="iphone",
copytop=150, copyleft=100, copymax=1120, submax=1020,
eyesize=42, dotsize=26, subsize=58, score="146",
bezrad=152, bezpad=24, scrrad=128,
island='<div class="island"></div>',
wide=1064, full=860, sizeScale=1.0,
tops={}),
"ipad": dict(W=2064, H=2752, raw="v2-raw-ipad", out="ipad",
copytop=140, copyleft=130, copymax=1800, submax=1700,
eyesize=54, dotsize=34, subsize=76, score="671",
bezrad=92, bezpad=34, scrrad=56,
island='',
wide=1560, full=1130, sizeScale=1.45,
tops={"01-hero": 990, "02-choice": 1150, "03-daily": 1190,
"04-endless": 990, "05-share": 1150, "06-unlock": 1150,
"07-fair": 990}),
}
args = sys.argv[1:]
device = "iphone"
if args and args[0] == "--device":
device = args[1]; args = args[2:]
DEV = DEVICES[device]
RAW = os.path.join(os.path.dirname(BASE), DEV["raw"])
locales = args or list(CAP["locales"])
jobs = []
for loc in locales:
outdir = os.path.join(BASE, loc, DEV["out"])
srcdir = os.path.join(BASE, "src", device, loc)
os.makedirs(outdir, exist_ok=True); os.makedirs(srcdir, exist_ok=True)
for shot, cfg in CAP["shots"].items():
c = CAP["locales"][loc][shot]
st = STYLES[cfg["style"]]
size = round((CAP["cjkSize"] if loc in CJK else CAP["baseSize"])[shot] * DEV["sizeScale"])
headline = "".join(f"<span>{line}</span>" for line in c["h"])
html = TEMPLATE.format(
eyebrow=c["eyebrow"], headline=headline, sub=c["sub"],
img=os.path.join(RAW, loc, cfg["raw"]),
ghost=GHOSTS[cfg["ghost"]].replace("{score}", DEV["score"]),
devtop=DEV["tops"].get(shot, cfg["deviceTop"]),
devwidth=DEV["full"] if cfg.get("full") else DEV["wide"], hsize=size,
autopush="false" if cfg.get("full") else "true",
W=DEV["W"], H=DEV["H"], copytop=DEV["copytop"], copyleft=DEV["copyleft"],
copymax=DEV["copymax"], submax=DEV["submax"], bezrad=DEV["bezrad"],
eyesize=DEV["eyesize"], dotsize=DEV["dotsize"], subsize=DEV["subsize"],
bezpad=DEV["bezpad"], scrrad=DEV["scrrad"], island=DEV["island"],
htrack="-0.025em" if loc not in CJK else "0",
eyetrack=".32em" if loc not in CJK else ".18em", **st)
src = os.path.join(srcdir, f"{shot}.html")
open(src, "w").write(html)
jobs.append((src, os.path.join(outdir, f"{shot}.png")))
for src, out in jobs:
subprocess.run([CHROME, "--headless=new", "--disable-gpu", "--hide-scrollbars",
"--force-device-scale-factor=1", f"--window-size={DEV['W']},{DEV['H']}",
"--virtual-time-budget=3000", f"--screenshot={out}", f"file://{src}"],
capture_output=True, timeout=120)
print(os.path.relpath(out, BASE), flush=True)
print(f"rendered {len(jobs)} frames")
#!/usr/bin/env python3
"""Per-locale App Store capture loop — skeleton with the battle-tested helpers.
Adapt the NAVIGATE section per app/state. The helpers encode every gotcha we
hit shipping this for real: frame-targeted taps, layered-tree state
assertions, eaten-tap retries, split long-presses, swipe settle checks,
backgrounding before terminate, and iPad system-language reboots.
Requires: AXe (bundled with brew xcodebuildmcp at
/opt/homebrew/Cellar/xcodebuildmcp/<ver>/libexec/bundled/axe, or `brew install
cameroncooke/axe/axe`).
"""
import json, subprocess, sys, time
AXE = "axe" # or the bundled path inside xcodebuildmcp
UDID = "SIMULATOR-UDID-HERE"
APP = "com.example.app"
RAW = "./raw" # output: RAW/<locale>/NN-name.png
IS_IPAD = False # flips backgrounding + system-language logic
REGION = {"en": "en_US", "es": "es_ES", "fr": "fr_FR", "de": "de_DE",
"it": "it_IT", "pt-BR": "pt_BR", "ja": "ja_JP", "ko": "ko_KR",
"zh-Hans": "zh_CN", "zh-Hant": "zh_TW"}
def run(*a): return subprocess.run(a, capture_output=True, text=True, timeout=180)
def axe(*a): return run(AXE, *a, "--udid", UDID)
def shot(path): run("xcrun", "simctl", "io", UDID, "screenshot", path)
def tap(x, y): axe("tap", "-x", str(round(x, 1)), "-y", str(round(y, 1)))
def long_press(x, y, secs=1.5):
"""Guaranteed-duration hold: separate down/up events. The single-command
--delay form sometimes registers as a tap."""
axe("touch", "-x", str(round(x, 1)), "-y", str(round(y, 1)), "--down")
time.sleep(secs)
axe("touch", "-x", str(round(x, 1)), "-y", str(round(y, 1)), "--up")
time.sleep(0.8)
def button_frames(tries=4):
"""All Button frames from the a11y tree. describe-ui returns invalid JSON
mid-animation sometimes — retry, never treat a parse failure as 'empty'."""
for _ in range(tries):
out = run(AXE, "describe-ui", "--udid", UDID).stdout
try:
d = json.loads(out); break
except Exception:
time.sleep(1.2)
else:
return []
res = []
def walk(n):
if n.get("type") == "Button" and n.get("frame"):
f = n["frame"]
res.append((f["x"], f["y"], f["width"], f["height"],
str(n.get("AXLabel") or "")))
for c in n.get("children", []): walk(c)
for r in (d if isinstance(d, list) else [d]): walk(r)
return res
def center(b): return (b[0] + b[2] / 2, b[1] + b[3] / 2)
# --- State discriminators -----------------------------------------------
# The a11y tree contains ALL layers at once (home + game + sheets). Assert
# which screen is frontmost via GEOMETRY (labels localize!). Example: the
# home menu's wide card sits on-screen at home and gets pushed far off-screen
# in-game. Measure your app's equivalent with a probe run, then codify.
def home_card(fr):
cards = [b for b in fr if b[2] > 350 and b[3] > 80]
return min(cards, key=lambda b: b[1]) if cards else None
def on_home(fr):
c = home_card(fr); return c is not None and c[1] < 900 # tune per app
def in_game(fr):
c = home_card(fr); return c is not None and c[1] > 1000 # tune per app
def settled(timeout=8):
"""After a swipe, content decelerates — wait until two consecutive reads
of a reference element's x agree before computing tap targets."""
last = None
for _ in range(int(timeout / 0.6)):
fr = button_frames()
if fr:
cur = round(fr[0][0], 1)
if last is not None and cur == last: return True
last = cur
time.sleep(0.6)
return False
# --- Per-locale lifecycle -------------------------------------------------
def background_app():
# State persists across relaunch ONLY if the app backgrounds first.
# iPad sims have no home button: 'button home' is a SILENT NO-OP there.
axe("button", "lock" if IS_IPAD else "home")
time.sleep(2)
def switch_system_language(loc):
# iPad status bar shows a date in the SYSTEM language — reboot per locale.
run("xcrun", "simctl", "spawn", UDID, "defaults", "write",
".GlobalPreferences", "AppleLanguages", "-array", loc)
run("xcrun", "simctl", "spawn", UDID, "defaults", "write",
".GlobalPreferences", "AppleLocale", "-string", REGION[loc])
run("xcrun", "simctl", "shutdown", UDID); time.sleep(3)
run("xcrun", "simctl", "boot", UDID); time.sleep(14)
# the status-bar override does not survive reboot — re-apply it
run("xcrun", "simctl", "status_bar", UDID, "override", "--time", "9:41",
"--batteryState", "discharging", "--batteryLevel", "100", "--wifiBars", "3")
def relaunch(loc):
background_app()
run("xcrun", "simctl", "terminate", UDID, APP); time.sleep(1)
if IS_IPAD:
switch_system_language(loc)
run("xcrun", "simctl", "launch", UDID, APP,
"-AppleLanguages", f"({loc})", "-AppleLocale", REGION[loc])
time.sleep(6 if IS_IPAD else 5)
# --- Main loop -------------------------------------------------------------
for L in (sys.argv[1:] or list(REGION)):
print(f"=== {L} ===", flush=True)
run("mkdir", "-p", f"{RAW}/{L}")
relaunch(L)
fr = button_frames()
if not on_home(fr):
print(f" !! {L}: not on home after launch"); continue
shot(f"{RAW}/{L}/00-home.png")
# NAVIGATE: enter the next state. First taps after launch/transitions are
# often eaten — always tap-verify-retry, never fire-and-forget.
ok = False
for _ in range(5):
tap(*center(home_card(fr))); time.sleep(2.4)
fr = button_frames()
if in_game(fr): ok = True; break
if not ok:
print(f" !! {L}: never entered game"); continue
time.sleep(1.0)
shot(f"{RAW}/{L}/01-board.png")
# ... more states: open sheets by frame-targeting their buttons (match by
# SIZE ranges measured in a probe run; labels are localized), screenshot,
# dismiss, leaving the state intact for the next locale.
print(f" ok {L}", flush=True)
print("done")
#!/usr/bin/env python3
"""Verify App Store Connect screenshots after an upload — MANDATORY step.
The `asc screenshots upload` fan-out ABORTS all remaining locales on the
first asset delivery failure while still exiting 0, and `--resume` only
finishes the failed locale. This script is how you find what actually landed.
Usage: python3 verify_asc_screenshots.py <VERSION_ID> <N_SHOTS> [DISPLAY_TYPE ...]
Example: python3 verify_asc_screenshots.py <APP_STORE_VERSION_UUID> 7 APP_IPHONE_67 APP_IPAD_PRO_3GEN_129
Requires the `asc` CLI (rorkai/App-Store-Connect-CLI), authenticated.
"""
import json, subprocess, sys
VERSION_ID = sys.argv[1]
N_SHOTS = int(sys.argv[2])
DISPLAY_TYPES = sys.argv[3:] or ["APP_IPHONE_67", "APP_IPAD_PRO_3GEN_129"]
def run(*a):
return subprocess.run(a, capture_output=True, text=True, timeout=120).stdout
locs = {x["attributes"]["locale"]: x["id"]
for x in json.loads(run("asc", "localizations", "list",
"--version", VERSION_ID, "--output", "json"))["data"]}
bad = []
for loc, lid in sorted(locs.items()):
try:
d = json.loads(run("asc", "screenshots", "list",
"--version-localization", lid, "--output", "json"))
except Exception:
bad.append((loc, "unreadable")); continue
summary = {}
for s in d.get("sets", []):
dt = s["set"]["attributes"]["screenshotDisplayType"]
shots = s.get("screenshots") or []
names = [x["attributes"]["fileName"] for x in shots]
states = {x["attributes"].get("assetDeliveryState", {}).get("state")
for x in shots}
summary[dt] = (len(shots), names[0] if names else "-",
names == sorted(names), states)
for dt in DISPLAY_TYPES:
n, first, ordered, states = summary.get(dt, (0, "-", False, set()))
if not (n == N_SHOTS and ordered and states == {"COMPLETE"}):
bad.append((loc, dt, f"count={n} first={first} ordered={ordered} states={states}"))
print(f"locales checked: {len(locs)}")
if bad:
print("PROBLEMS — re-upload these (stage just the gap locales, --replace):")
for b in bad: print(" ", b)
sys.exit(1)
print(f"ALL OK: {N_SHOTS} shots, sorted, COMPLETE, on {', '.join(DISPLAY_TYPES)}")