# Capture raw screenshots

> HTML version: https://storeshot.art/docs/capture/

StoreShot turns raw app screenshots into store images: it adds the background, the caption and the
device at every size each store requires. This page shows how to take those raw screenshots with
the tools you already have. Nothing here needs a StoreShot key; upload the files afterwards
([next steps](#next-upload-and-create-a-set)).

The [StoreShot CLI](/docs/cli/#capture-the-raw-screenshots) (2.60.0 or later) wraps these
recipes in one command, `storeshot capture ios|android|web`, with device presets, a status bar
cleanup that is always undone, and a `captions.json` that `storeshot set` reads.

## What to capture

- One unedited screenshot per screen, in the order the listing should show them, all in portrait.
- PNG straight from the simulator, emulator or browser. JPEG and WebP work too, HEIC does not; see
  the [upload limits](/docs/uploads/#limits).
- Any phone or tablet size works: StoreShot renders every store size from it. An image whose short
  edge is under 300 px or over 2600 px, or that is much longer and thinner than a phone, is refused
  with `NOT_A_SCREENSHOT`.
- A tidy status bar (9:41, full signal and battery) and no personal data on screen.

## iOS Simulator

Needs macOS with Xcode. Boot a simulator (open the Simulator app, or run
`xcrun simctl boot "iPhone 16 Pro"`), open the screen you want, then:

<!-- doctest: covered-by=packages/cli/test/monorepo/recipes.test.ts -->
```sh
# The booted simulators: with several, use a UDID from this list instead of "booted"
xcrun simctl list devices booted
# A clean status bar: 9:41, full signal and battery
xcrun simctl status_bar booted override --time 9:41 --dataNetwork wifi --wifiMode active \
  --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100
mkdir -p screens
xcrun simctl io booted screenshot --type=png screens/01-map.png
# Put the status bar back
xcrun simctl status_bar booted clear
```

- With several simulators booted, `booted` picks one of them: pass a UDID from the list instead.
- `status_bar … clear` removes every override, including ones you set yourself.
- Some simulator runtimes ignore status bar overrides (reported for iOS 16.1, 16.2 and 17): check
  the PNG.
- Simulator sizes such as 1320 × 2868 (iPhone 16 Pro Max) need not be App Store sizes: StoreShot
  renders those.
- Physical iPhones and iPads have no command line screenshot tool: use the simulator.

## Android emulator or device

Needs the Android SDK platform-tools (`adb`) on your `PATH`, and an emulator or a device with USB
debugging on. Demo mode gives a clean status bar:

<!-- doctest: covered-by=packages/cli/test/monorepo/recipes.test.ts -->
```sh
adb devices
SERIAL=emulator-5554 # a serial from the list above
# A clean status bar: demo mode with 9:41, full signal and battery, no notification icons
adb -s "$SERIAL" shell settings put global sysui_demo_allowed 1
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command enter
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command clock -e hhmm 0941
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command network -e wifi show -e level 4
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command network -e mobile show -e datatype none -e level 4
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command battery -e level 100 -e plugged false
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command notifications -e visible false
mkdir -p screens
adb -s "$SERIAL" exec-out screencap -p > screens/01-map.png
# Leave demo mode
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit
```

- `adb exec-out` passes the PNG bytes unchanged. Windows PowerShell's `>` re-encodes them: there,
  run `adb shell screencap -p /sdcard/screen.png` and `adb pull /sdcard/screen.png` instead.
- Demo mode differs between Android versions and device makers, and some ignore parts of it.
  Physical devices may need "Allow demo mode" in the developer options.
- The last command ends demo mode, also one you had turned on yourself.
  `adb shell settings delete global sysui_demo_allowed` removes the permission again.

## Web builds with Playwright

An Expo, React Native or Flutter web build can be captured in a browser at the exact pixel size of
a StoreShot device. Build and serve it first:

- Expo or React Native: `npx expo export -p web`, then serve `dist/` (for example with
  `npx serve dist`).
- Flutter: `flutter build web`, then serve `build/web/`.

Install Playwright in your project (`npm install -D playwright`, then
`npx playwright install chromium`), save this as `capture-web.mjs`, list your screens in `pages`,
and run it with `BASE_URL=http://localhost:3000 node capture-web.mjs`:

<!-- doctest: covered-by=packages/cli/test-dist/web/capture-web.test.mjs -->
```js
// One PNG per page at the exact pixel size of a StoreShot device (viewport × pixel ratio).
// BASE_URL=http://localhost:3000 DEVICE=app_store_1290x2796 node capture-web.mjs
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';

const PRESETS = {
  app_store_1284x2778: { width: 428, height: 926, dpr: 3 },
  app_store_1290x2796: { width: 430, height: 932, dpr: 3 },
  app_store_1179x2556: { width: 393, height: 852, dpr: 3 },
  app_store_2064x2752: { width: 1032, height: 1376, dpr: 2 },
  app_store_1668x2388: { width: 834, height: 1194, dpr: 2 },
  google_play_1080x1920: { width: 360, height: 640, dpr: 3 },
  google_play_1080x2400: { width: 360, height: 800, dpr: 3 },
  google_play_1080x2160: { width: 360, height: 720, dpr: 3 },
  google_play_1200x1920: { width: 600, height: 960, dpr: 2 },
  google_play_1600x2560: { width: 800, height: 1280, dpr: 2 },
};
// The screens to capture, in listing order (Flutter's default URLs look like /#/settings).
const pages = [
  { path: '/', name: '01-home' },
  { path: '/settings', name: '02-settings' },
];
const base = process.env.BASE_URL ?? 'http://localhost:3000';
const preset = PRESETS[process.env.DEVICE ?? 'app_store_1290x2796'];
const out = process.env.OUT ?? 'screens';
// Expo/React Native web: a testID such as [data-testid="home-screen"]; Flutter web: flutter-view
const waitFor = process.env.WAIT_FOR ?? 'body';

mkdirSync(out, { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: { width: preset.width, height: preset.height },
  deviceScaleFactor: preset.dpr,
  isMobile: true,
  hasTouch: true,
  reducedMotion: 'reduce',
  serviceWorkers: 'block',
});
for (const { path, name } of pages) {
  const page = await context.newPage();
  await page.goto(new URL(path, base).href, { waitUntil: 'load' });
  await page.waitForSelector(waitFor, { state: 'visible' });
  await page.evaluate(() => document.fonts.ready);
  await page.waitForTimeout(Number(process.env.DELAY_MS ?? 300));
  await page.screenshot({ path: `${out}/${name}.png`, animations: 'disabled', caret: 'hide' });
  await page.close();
}
await browser.close();
```

The viewport and pixel ratio of each StoreShot device (`DEVICE`):

| Device | Viewport (CSS px) | Pixel ratio | PNG |
|---|---|---|---|
| `app_store_1284x2778` | 428 × 926 | 3 | 1284 × 2778 |
| `app_store_1290x2796` | 430 × 932 | 3 | 1290 × 2796 |
| `app_store_1179x2556` | 393 × 852 | 3 | 1179 × 2556 |
| `app_store_2064x2752` | 1032 × 1376 | 2 | 2064 × 2752 |
| `app_store_1668x2388` | 834 × 1194 | 2 | 1668 × 2388 |
| `google_play_1080x1920` | 360 × 640 | 3 | 1080 × 1920 |
| `google_play_1080x2400` (deprecated) | 360 × 800 | 3 | 1080 × 2400 |
| `google_play_1080x2160` | 360 × 720 | 3 | 1080 × 2160 |
| `google_play_1200x1920` | 600 × 960 | 2 | 1200 × 1920 |
| `google_play_1600x2560` | 800 × 1280 | 2 | 1600 × 2560 |

Waiting for the screen:

- The script waits for the `load` event, for the `WAIT_FOR` selector, for web fonts, then
  `DELAY_MS` (300 by default). `networkidle` is not reliable with development servers.
- Expo and React Native web render `testID` as `data-testid`: use
  `WAIT_FOR='[data-testid="home-screen"]'`.
- Flutter web: use `WAIT_FOR=flutter-view` (older releases: `flt-glass-pane`) and `DELAY_MS=1000`,
  because the canvas paints after the element exists.

Limitations:

- A web build is not the native app: fonts, platform controls, safe areas and some plugins differ.
  Prefer the simulator or emulator for the final listing.
- Only screens with their own URL open this way. For screens behind taps or a login, add those
  steps to the script before `page.screenshot`, or use Maestro.
- Flutter's default URLs contain `#`: use paths such as `/#/settings`.

## Maestro flows

[Maestro](https://maestro.dev/) drives the app on a simulator or emulator, so it reaches screens
behind taps. Add `takeScreenshot` where you want a raw screenshot:

```yaml
appId: com.example.trailmate
---
- launchApp
- takeScreenshot: 01-map
- tapOn: 'Offline maps'
- takeScreenshot: 02-offline
```

Run the flow and collect the PNGs:

<!-- doctest: covered-by=packages/cli/test/monorepo/recipes.test.ts -->
```sh
maestro test --test-output-dir maestro-out flow.yaml
mkdir -p screens
find maestro-out -name '*.png' -exec cp {} screens/ \;
ls screens
```

- Maestro writes the screenshots into the `takeScreenshot` folder of its output; where that folder
  is depends on the Maestro version and on `--test-output-dir`, so the commands search for them.
- Maestro does not change the status bar: run the iOS or Android commands above before and after
  the flow.

## Next: upload and create a set

Upload each screenshot and create a [screenshot set](/docs/screenshot-sets/) with one caption per
screen:

- with `curl`: steps 3–6 of the [quickstart for AI agents](/docs/agents-quickstart/);
- from an AI coding tool: the [MCP server](/docs/mcp/) (`upload_screenshot`, then
  `create_screenshot_set`);
- from a terminal or CI: the [CLI](/docs/cli/) (`storeshot set`, then `storeshot pull`);
- from TypeScript or JavaScript: the [SDK](/docs/sdk/).
