How to Localize App Screenshots Without a Mac — Use Every Trick at Once
00要約Overview
01物語Story
Situation
By 1.2.2, all the text — app and listing — is localized. What remains is the screenshots.
Complication
Properly, you'd boot an emulator on a Mac and automate screenshots across language switches. But as 1.1.2 established, there is no Mac at hand. Producing screenshots for fifty locales looked impossible.
Question
Without a Mac, how do you produce screenshots in dozens of languages?
02解決Solution
Criteria
- Works without owning a Mac
- Covers a large number of languages
- Meets Apple's strict size requirements
Answer
Instead of forcing one method to do everything, use all four at once:
- Design the UI to be as language-independent as possible — every word removed from an image is a word that needs neither translating nor generating.
- Shoot major languages (Japanese, English, …) on the real device — switch the language, take real screenshots.
- Batch-convert the rest with Flow — "make this image the X-language version," in bulk.
- Draw annotations onto the English set and reuse it — with illustrations and callouts in place, the details don't need remaking per language.
The generated images are zipped and uploaded to Git, unpacked and arranged by Claude, then sent to App Store Connect by GitHub Actions (split, crop, resize, and up through the store's API).
Reason
Because only a combination clears the no-Mac constraint. Shoot everything on-device and the effort scales with the language count until it crushes you; generate everything with AI and the details smear while the size requirements slip.
Any one method fails.
Together, they clear it.
Flow deserves a special mention: it generates large batches at once, downloads them as a ZIP, and lets you queue the next batch while one is rendering. The throughput is a gift.
Options
- Shoot everything on a real device — faithful, but the effort scales with every language. Not realistic.
- Generate everything with AI — fast, but details break easily and the translation isn't accurate either.
- Have Claude swap the images — Claude's weak spot is that it has no image-generation engine. What came out was awful, so I gave up on it.
03結果Result
Good
Multilingual screenshots, no Mac involved. The language-independent UI and the annotated-English reuse also shrank the sheer volume that needed generating. A mountain of dull work now turns over smoothly with AI in the mix. Flow generates a good number of images on the free tier, and being able to download them as a ZIP is a big plus.
Bad
Because the code isn't fed into the image generation (at translation time), the accuracy is poor. Run an image through the generation AI for fix after fix, and since the text is part of the image, it degrades further each pass. There's plenty of room for improvement.
Follow-up
With app, listing, and screenshots all localized, the product is ready for a worldwide release. Where to aim it — the idea that a narrow niche becomes a market when the world is the denominator — continues in 3.1.2 STP.
■ Reproduce it yourself
A note for anyone who wants to reproduce this setup. Not meant to be read straight through — the prompts show a few lines and scroll. Instructions are in English; the reference code (Gemfile / workflow / Fastfile) is the shared artifact and kept as-is.
First, build the mechanism. Paste the following in one copy (prompt + Gemfile + workflow + Fastfile together).
I want to build a mechanism that auto-uploads App Store Connect screenshots from
GitHub Actions using fastlane (calling spaceship directly).
Assumptions:
- The ASC API key is registered in GitHub Secrets as APPLE_API_KEY_ID /
APPLE_API_KEY_ISSUER_ID / APPLE_API_KEY_P8 (three of them)
- The app is registered on ASC with SKU=<your SKU>, Bundle ID=<your BundleID>
What to build:
1. Gemfile ... pin the fastlane 2.228 line
2. Implement an upload_screenshots_direct lane in fastlane/Fastfile via direct spaceship
- read apps/<SKU>/screenshots/<locale>/*.png
- put iPhone files into the APP_IPHONE_67 set and iPad files into the
APP_IPAD_PRO_3GEN_129 set, replacing and uploading
- skip locales with no local images (= don't delete ones uploaded via another path)
3. One workflow_dispatch workflow for screenshots under .github/workflows/.
Pass APP_SKU / APP_BUNDLE_ID and the three Secrets via env (use a long timeout)
4. apps/<SKU>/screenshots/<locale>/ for 50 locales (with .gitkeep)
Also, write a Python script that reads a ZIP placed in apps/<SKU>/, decides the
locale from the language tag in each filename, splits iPhone into 3 vertically /
iPad into 2 vertically -> center-crops -> resizes to Apple's recommended sizes
(iPhone 1320x2868 / iPad 2752x2064) and writes them out to
apps/<SKU>/screenshots/<locale>/. Make it print a "which file -> which locale"
mapping table before running.
source "https://rubygems.org"
gem "fastlane", "~> 2.228.0"
name: <SKU> — screenshots
on:
workflow_dispatch:
jobs:
upload:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
- name: Run fastlane upload_screenshots_direct
env:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY_ISSUER_ID: ${{ secrets.APPLE_API_KEY_ISSUER_ID }}
APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }}
APP_SKU: <SKU>
APP_BUNDLE_ID: <BundleID>
FASTLANE_SKIP_UPDATE_CHECK: "1"
run: bundle exec fastlane ios upload_screenshots_direct
default_platform(:ios)
platform :ios do
desc "Upload per-locale screenshots via spaceship (bypasses deliver's broken screenshot upload)"
lane :upload_screenshots_direct do |options|
sku = options[:sku] || ENV["APP_SKU"]
bundle_id = options[:bundle_id] || ENV["APP_BUNDLE_ID"]
UI.user_error!("sku is required") if sku.nil? || sku.empty?
UI.user_error!("bundle_id is required") if bundle_id.nil? || bundle_id.empty?
screenshots_path = File.expand_path("../apps/#{sku}/screenshots", __dir__)
UI.user_error!("screenshots not found: #{screenshots_path}") unless Dir.exist?(screenshots_path)
require "spaceship"
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.create(
key_id: ENV.fetch("APPLE_API_KEY_ID"),
issuer_id: ENV.fetch("APPLE_API_KEY_ISSUER_ID"),
key: ENV.fetch("APPLE_API_KEY_P8"),
)
app = Spaceship::ConnectAPI::App.find(bundle_id)
UI.user_error!("app not found for bundle_id=#{bundle_id}") if app.nil?
version = app.get_edit_app_store_version
UI.user_error!("no edit version found on App Store Connect") if version.nil?
UI.message("Writing screenshots to version #{version.version_string} (#{version.app_store_state})")
# 出力ファイル名 -> ASC のスクショ表示タイプ。
# "iPhone" ファイルは 1320x2868 (iPhone 6.9")、Apple が 6.7"/6.9" 共用にする枠へ。
# "iPad" ファイルは 2752x2064、iPad Pro 12.9" 3rd gen 横向き。
device_of = ->(filename) {
case filename
when /iPhone/i then "APP_IPHONE_67"
when /iPad/i then "APP_IPAD_PRO_3GEN_129"
else nil
end
}
summary_lines = ["## Direct screenshot upload to #{version.version_string}", ""]
updated = 0
version.get_app_store_version_localizations.sort_by(&:locale).each do |loc|
locale_dir = File.join(screenshots_path, loc.locale)
unless Dir.exist?(locale_dir)
summary_lines << "- **#{loc.locale}**: skipped (no local directory)"
next
end
pngs = Dir.children(locale_dir).select { |f| f.downcase.end_with?(".png") }.sort
if pngs.empty?
summary_lines << "- **#{loc.locale}**: skipped (no PNGs)"
next
end
groups = pngs.group_by { |f| device_of.call(f) }.reject { |k, _| k.nil? }
if groups.empty?
summary_lines << "- **#{loc.locale}**: skipped (no iPhone/iPad files)"
next
end
existing_sets = loc.get_app_screenshot_sets.group_by(&:screenshot_display_type)
groups.each do |display_type, files|
set = existing_sets[display_type]&.first
if set
# 既存スクショを消してから新しいものに入れ替える。
set.app_screenshots.each do |shot|
shot.delete!
end
else
set = loc.create_app_screenshot_set(attributes: {
screenshotDisplayType: display_type,
})
end
files.each do |filename|
path = File.join(locale_dir, filename)
UI.message(" #{loc.locale}/#{filename} -> #{display_type}")
set.upload_screenshot(path: path, wait_for_processing: true)
end
end
summary_lines << "- **#{loc.locale}**: uploaded #{pngs.length} screenshot(s)"
updated += 1
end
summary_lines << ""
summary_lines << "Updated #{updated} locale(s)."
report = summary_lines.join("\n")
UI.message(report)
step_summary = ENV["GITHUB_STEP_SUMMARY"]
File.write(step_summary, report) if step_summary && !step_summary.empty?
end
end
Then, after placing a ZIP of per-language screenshots in apps/<app>/, paste the following.
I placed a ZIP of per-language screenshot images in apps/<app>/. Process it for the App Store. - decide the locale from the language tag in the filename (ja_01.png / en-US_... / 韓国語.png etc.) - split iPhone into 3 vertically / iPad into 2 vertically, center-crop and resize to Apple's recommended sizes (iPhone 6.9" = 1320x2868 / iPad 13" = 2752x2064) - expand family copies like en-US -> en-AU / en-CA / en-GB - auto-expand "other language" images to every unspecified locale Show a "which file -> which locale" mapping table and let me confirm before running. Once I say OK, generate into apps/<app>/screenshots/<locale>/. Before uploading <app>'s screenshots, check first. List every locale's screenshots folder, and print each locale's count and filenames, so I can confirm you won't delete a locale I don't want touched.
If something's unclear, ask "what are you doing?" and it explains; when it works, ask it to "make this a skill" and it does. Don't /clear or start a new session until the skill is made — memory is lost, so building the skill within Claude's memory (same session) keeps the work stable.
You don't have to localize screenshots. The default-language images are used in common, so for something clear like a game, you can release with a single set that reads through illustration.