device-shots 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/dist/index.js +174 -49
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# device-shots
|
|
2
|
+
|
|
3
|
+
A CLI tool that captures screenshots from running iOS simulators and Android emulators, then optionally frames iOS screenshots with device bezels. Built for developers who need store-ready screenshots without manual work.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
1. **Discovers devices** — Finds all running iOS simulators and Android emulators that have your app installed
|
|
8
|
+
2. **Cleans up status bars** — Sets a uniform status bar (9:41, full battery, full signal) on all devices before capturing
|
|
9
|
+
3. **Captures screenshots** — Takes screenshots from every discovered device in one go
|
|
10
|
+
4. **Transparent Android status bar** — Automatically makes the Android status bar area transparent using ImageMagick
|
|
11
|
+
5. **Frames iOS screenshots** — Wraps iOS screenshots in device bezels (iPhone/iPad frames) using [device-frames-core](https://pypi.org/project/device-frames-core/)
|
|
12
|
+
6. **Organizes by screen size** — Saves screenshots into store-aligned size buckets (e.g. `6.9`, `6.7`, `phone`) instead of device names
|
|
13
|
+
|
|
14
|
+
## Use cases
|
|
15
|
+
|
|
16
|
+
- Generating App Store and Play Store screenshots across multiple device sizes at once
|
|
17
|
+
- Keeping consistent, reproducible screenshot sets for your app listing
|
|
18
|
+
- Automating screenshot capture in CI/CD pipelines
|
|
19
|
+
- Quickly re-capturing screenshots after UI changes
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
### Required
|
|
24
|
+
|
|
25
|
+
- **Node.js 18+**
|
|
26
|
+
- **Xcode Command Line Tools** (for iOS) — provides `xcrun simctl`
|
|
27
|
+
- **Android SDK** (for Android) — provides `adb`. The tool looks for it at `$ANDROID_HOME/platform-tools/adb` or `~/Library/Android/sdk/platform-tools/adb`
|
|
28
|
+
|
|
29
|
+
### Optional
|
|
30
|
+
|
|
31
|
+
- **ImageMagick** — needed to make Android status bars transparent. Install with `brew install imagemagick`
|
|
32
|
+
- **Python 3** — needed for iOS screenshot framing. The tool auto-creates a virtual environment at `~/.device-shots/.venv` and installs `device-frames-core` and `Pillow` automatically on first use
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g device-shots
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or run directly with `npx`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npx device-shots capture --bundle-id com.example.myapp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
### Capture screenshots
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Interactive — prompts for screenshot name
|
|
52
|
+
device-shots capture --bundle-id com.example.myapp
|
|
53
|
+
|
|
54
|
+
# Non-interactive — provide name directly
|
|
55
|
+
device-shots capture dashboard --bundle-id com.example.myapp
|
|
56
|
+
|
|
57
|
+
# iOS only, custom output directory
|
|
58
|
+
device-shots capture home -b com.example.myapp -p ios -o ./.store-assets
|
|
59
|
+
|
|
60
|
+
# Skip framing
|
|
61
|
+
device-shots capture login -b com.example.myapp --no-frame
|
|
62
|
+
|
|
63
|
+
# Custom status bar time
|
|
64
|
+
device-shots capture checkout -b com.example.myapp --time "10:30"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Frame existing screenshots
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Frame all unframed iOS screenshots in ./.screenshots
|
|
71
|
+
device-shots frame
|
|
72
|
+
|
|
73
|
+
# Frame from a specific directory
|
|
74
|
+
device-shots frame ./.my-screenshots
|
|
75
|
+
|
|
76
|
+
# Re-frame everything (overwrite existing framed images)
|
|
77
|
+
device-shots frame --force
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Initialize config
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
device-shots init
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Creates a `.device-shotsrc.json` in the current directory so you don't have to pass `--bundle-id` every time:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"bundleId": "com.example.myapp",
|
|
91
|
+
"output": "./.screenshots",
|
|
92
|
+
"platform": "both",
|
|
93
|
+
"time": "9:41",
|
|
94
|
+
"frame": true
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Config file
|
|
99
|
+
|
|
100
|
+
The tool uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig), so you can configure it in any of these ways:
|
|
101
|
+
|
|
102
|
+
- `.device-shotsrc.json`
|
|
103
|
+
- `.device-shotsrc.yaml`
|
|
104
|
+
- `.device-shotsrc.js`
|
|
105
|
+
- `device-shots.config.js`
|
|
106
|
+
- `"device-shots"` key in `package.json`
|
|
107
|
+
|
|
108
|
+
## Output structure
|
|
109
|
+
|
|
110
|
+
Screenshots are organized by platform and screen size bucket, matching App Store and Play Store requirements:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
.screenshots/
|
|
114
|
+
├── ios/
|
|
115
|
+
│ ├── 6.9/ # iPhone 16 Pro Max
|
|
116
|
+
│ │ ├── dashboard.png
|
|
117
|
+
│ │ ├── dashboard_framed.png
|
|
118
|
+
│ │ ├── settings.png
|
|
119
|
+
│ │ └── settings_framed.png
|
|
120
|
+
│ ├── 6.7/ # iPhone 16 Plus, 15 Pro Max
|
|
121
|
+
│ │ ├── dashboard.png
|
|
122
|
+
│ │ └── dashboard_framed.png
|
|
123
|
+
│ └── 6.3/ # iPhone 16 Pro
|
|
124
|
+
│ └── ...
|
|
125
|
+
├── android/
|
|
126
|
+
│ └── phone/ # Pixel 9 Pro, etc.
|
|
127
|
+
│ ├── dashboard.png
|
|
128
|
+
│ └── settings.png
|
|
129
|
+
└── metadata.json
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Screen size buckets
|
|
133
|
+
|
|
134
|
+
**iOS** — Maps to App Store Connect display size categories:
|
|
135
|
+
|
|
136
|
+
| Bucket | Devices |
|
|
137
|
+
|--------|---------|
|
|
138
|
+
| `6.9` | iPhone 16 Pro Max |
|
|
139
|
+
| `6.7` | iPhone 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max |
|
|
140
|
+
| `6.5` | iPhone 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max |
|
|
141
|
+
| `6.3` | iPhone 16 Pro |
|
|
142
|
+
| `6.1` | iPhone 16, 15, 15 Pro, 14, 14 Pro, 13, 13 Pro |
|
|
143
|
+
| `5.5` | iPhone 8 Plus, 7 Plus |
|
|
144
|
+
| `13` | iPad Pro 13" |
|
|
145
|
+
| `11` | iPad Pro 11", iPad Air |
|
|
146
|
+
|
|
147
|
+
**Android** — Categorized by form factor:
|
|
148
|
+
|
|
149
|
+
| Bucket | Criteria |
|
|
150
|
+
|--------|----------|
|
|
151
|
+
| `phone` | Shorter screen dimension < 1200px |
|
|
152
|
+
| `tablet-7` | Shorter dimension 1200–1799px |
|
|
153
|
+
| `tablet-10` | Shorter dimension 1800px+ |
|
|
154
|
+
|
|
155
|
+
### metadata.json
|
|
156
|
+
|
|
157
|
+
Tracks which physical device was used for each size bucket:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"ios": {
|
|
162
|
+
"6.9": {
|
|
163
|
+
"device": "iPhone 16 Pro Max",
|
|
164
|
+
"id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
|
|
165
|
+
"resolution": "1320x2868"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"android": {
|
|
169
|
+
"phone": {
|
|
170
|
+
"device": "Pixel 9 Pro",
|
|
171
|
+
"id": "emulator-5554",
|
|
172
|
+
"resolution": "1080x2340"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { writeFileSync, existsSync as existsSync5 } from "fs";
|
|
5
|
+
import { writeFileSync as writeFileSync2, existsSync as existsSync5 } from "fs";
|
|
6
6
|
import pc3 from "picocolors";
|
|
7
7
|
import prompts2 from "prompts";
|
|
8
8
|
|
|
9
9
|
// src/commands/capture.ts
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
existsSync as existsSync4,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync as readdirSync2,
|
|
14
|
+
renameSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync
|
|
17
|
+
} from "fs";
|
|
11
18
|
import { join as join4 } from "path";
|
|
12
19
|
import { tmpdir } from "os";
|
|
13
20
|
import { mkdtempSync } from "fs";
|
|
@@ -20,7 +27,7 @@ import { cosmiconfig } from "cosmiconfig";
|
|
|
20
27
|
var MODULE_NAME = "device-shots";
|
|
21
28
|
var DEFAULTS = {
|
|
22
29
|
bundleId: "",
|
|
23
|
-
output: "
|
|
30
|
+
output: "./.screenshots",
|
|
24
31
|
platform: "both",
|
|
25
32
|
time: "9:41",
|
|
26
33
|
frame: true
|
|
@@ -40,7 +47,7 @@ function createDefaultConfig(bundleId) {
|
|
|
40
47
|
return JSON.stringify(
|
|
41
48
|
{
|
|
42
49
|
bundleId,
|
|
43
|
-
output: "
|
|
50
|
+
output: "./.screenshots",
|
|
44
51
|
platform: "both",
|
|
45
52
|
time: "9:41",
|
|
46
53
|
frame: true
|
|
@@ -87,6 +94,50 @@ async function runOrFail(cmd, args, options) {
|
|
|
87
94
|
return result.stdout ?? "";
|
|
88
95
|
}
|
|
89
96
|
|
|
97
|
+
// src/devices/screen-sizes.ts
|
|
98
|
+
var IOS_RESOLUTION_MAP = {
|
|
99
|
+
// 6.9" — iPhone 16 Pro Max
|
|
100
|
+
"1320x2868": "6.9",
|
|
101
|
+
// 6.7" — iPhone 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max
|
|
102
|
+
"1290x2796": "6.7",
|
|
103
|
+
// 6.5" — iPhone 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max
|
|
104
|
+
"1284x2778": "6.5",
|
|
105
|
+
"1242x2688": "6.5",
|
|
106
|
+
// 6.3" — iPhone 16 Pro
|
|
107
|
+
"1206x2622": "6.3",
|
|
108
|
+
// 6.1" — iPhone 16, 15, 15 Pro, 14, 14 Pro, 13, 13 Pro, 12, 12 Pro
|
|
109
|
+
"1179x2556": "6.1",
|
|
110
|
+
"1170x2532": "6.1",
|
|
111
|
+
// 5.8" — iPhone X, XS, 11 Pro
|
|
112
|
+
"1125x2436": "5.8",
|
|
113
|
+
// 5.5" — iPhone 8 Plus, 7 Plus, 6s Plus
|
|
114
|
+
"1242x2208": "5.5",
|
|
115
|
+
// 4.7" — iPhone SE (3rd/2nd), iPhone 8, 7, 6s
|
|
116
|
+
"750x1334": "4.7",
|
|
117
|
+
// iPad 13" — iPad Pro 13" (M4), iPad Pro 12.9" (older)
|
|
118
|
+
"2064x2752": "13",
|
|
119
|
+
"2048x2732": "13",
|
|
120
|
+
// iPad 11" — iPad Pro 11", iPad Air
|
|
121
|
+
"1668x2388": "11",
|
|
122
|
+
"1668x2224": "11",
|
|
123
|
+
"1640x2360": "11",
|
|
124
|
+
// iPad 10.9" — iPad Air (5th), iPad (10th)
|
|
125
|
+
"2360x1640": "11",
|
|
126
|
+
// iPad mini
|
|
127
|
+
"1488x2266": "8.3"
|
|
128
|
+
};
|
|
129
|
+
function getIosScreenSize(width, height) {
|
|
130
|
+
const w = Math.min(width, height);
|
|
131
|
+
const h = Math.max(width, height);
|
|
132
|
+
return IOS_RESOLUTION_MAP[`${w}x${h}`] ?? `${w}x${h}`;
|
|
133
|
+
}
|
|
134
|
+
function getAndroidScreenSize(width, height) {
|
|
135
|
+
const shorter = Math.min(width, height);
|
|
136
|
+
if (shorter >= 1800) return "tablet-10";
|
|
137
|
+
if (shorter >= 1200) return "tablet-7";
|
|
138
|
+
return "phone";
|
|
139
|
+
}
|
|
140
|
+
|
|
90
141
|
// src/devices/ios.ts
|
|
91
142
|
function sanitizeName(name) {
|
|
92
143
|
return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
|
|
@@ -115,17 +166,63 @@ async function discoverIosDevices(bundleId) {
|
|
|
115
166
|
bundleId
|
|
116
167
|
]);
|
|
117
168
|
if (container) {
|
|
169
|
+
const resolution = await getResolutionViaTestCapture(d.udid);
|
|
170
|
+
const screenSize = resolution ? getIosScreenSize(resolution.width, resolution.height) : sanitizeName(d.name);
|
|
118
171
|
devices.push({
|
|
119
172
|
platform: "ios",
|
|
120
173
|
safeName: sanitizeName(d.name),
|
|
121
174
|
captureId: d.udid,
|
|
122
|
-
displayName: d.name
|
|
175
|
+
displayName: d.name,
|
|
176
|
+
screenSize,
|
|
177
|
+
resolution: resolution ?? { width: 0, height: 0 }
|
|
123
178
|
});
|
|
124
179
|
}
|
|
125
180
|
}
|
|
126
181
|
}
|
|
127
182
|
return devices;
|
|
128
183
|
}
|
|
184
|
+
async function getResolutionViaTestCapture(udid) {
|
|
185
|
+
try {
|
|
186
|
+
const tmpPath = `/tmp/device-shots-probe-${udid}.png`;
|
|
187
|
+
await runOrFail("xcrun", ["simctl", "io", udid, "screenshot", tmpPath]);
|
|
188
|
+
if (await commandExists("magick")) {
|
|
189
|
+
const { stdout } = await run("magick", [
|
|
190
|
+
"identify",
|
|
191
|
+
"-format",
|
|
192
|
+
"%wx%h",
|
|
193
|
+
tmpPath
|
|
194
|
+
]);
|
|
195
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
196
|
+
await run("rm", ["-f", tmpPath]);
|
|
197
|
+
if (match) {
|
|
198
|
+
return {
|
|
199
|
+
width: parseInt(match[1], 10),
|
|
200
|
+
height: parseInt(match[2], 10)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
const { stdout } = await run("sips", [
|
|
205
|
+
"-g",
|
|
206
|
+
"pixelWidth",
|
|
207
|
+
"-g",
|
|
208
|
+
"pixelHeight",
|
|
209
|
+
tmpPath
|
|
210
|
+
]);
|
|
211
|
+
await run("rm", ["-f", tmpPath]);
|
|
212
|
+
const wMatch = stdout.match(/pixelWidth:\s*(\d+)/);
|
|
213
|
+
const hMatch = stdout.match(/pixelHeight:\s*(\d+)/);
|
|
214
|
+
if (wMatch && hMatch) {
|
|
215
|
+
return {
|
|
216
|
+
width: parseInt(wMatch[1], 10),
|
|
217
|
+
height: parseInt(hMatch[1], 10)
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
await run("rm", ["-f", tmpPath]);
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
129
226
|
async function setIosStatusBar(time) {
|
|
130
227
|
if (!await commandExists("xcrun")) return;
|
|
131
228
|
await run("xcrun", [
|
|
@@ -164,6 +261,16 @@ async function captureIosScreenshot(udid, outputPath) {
|
|
|
164
261
|
function sanitizeName2(name) {
|
|
165
262
|
return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
|
|
166
263
|
}
|
|
264
|
+
async function getAndroidResolution(serial) {
|
|
265
|
+
const adb = getAdbPath();
|
|
266
|
+
const { stdout } = await run(adb, ["-s", serial, "shell", "wm", "size"]);
|
|
267
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
268
|
+
if (!match) return null;
|
|
269
|
+
return {
|
|
270
|
+
width: parseInt(match[1], 10),
|
|
271
|
+
height: parseInt(match[2], 10)
|
|
272
|
+
};
|
|
273
|
+
}
|
|
167
274
|
async function discoverAndroidDevices(bundleId) {
|
|
168
275
|
const adb = getAdbPath();
|
|
169
276
|
const { stdout } = await run(adb, ["devices"]);
|
|
@@ -203,11 +310,15 @@ async function discoverAndroidDevices(bundleId) {
|
|
|
203
310
|
avdName = model.trim();
|
|
204
311
|
}
|
|
205
312
|
const safeName = sanitizeName2(avdName) || serial;
|
|
313
|
+
const resolution = await getAndroidResolution(serial);
|
|
314
|
+
const screenSize = resolution ? getAndroidScreenSize(resolution.width, resolution.height) : "phone";
|
|
206
315
|
devices.push({
|
|
207
316
|
platform: "android",
|
|
208
317
|
safeName,
|
|
209
318
|
captureId: serial,
|
|
210
|
-
displayName: avdName || serial
|
|
319
|
+
displayName: avdName || serial,
|
|
320
|
+
screenSize,
|
|
321
|
+
resolution: resolution ?? { width: 0, height: 0 }
|
|
211
322
|
});
|
|
212
323
|
}
|
|
213
324
|
return devices;
|
|
@@ -358,11 +469,11 @@ function getFramePyPath() {
|
|
|
358
469
|
"Could not find vendored frame.py. Ensure the vendor/ directory is present."
|
|
359
470
|
);
|
|
360
471
|
}
|
|
361
|
-
async function frameScreenshots(
|
|
472
|
+
async function frameScreenshots(inputDir, outputDir, force = false) {
|
|
362
473
|
await ensureVenv();
|
|
363
474
|
const framePy = getFramePyPath();
|
|
364
475
|
const python = getVenvPython();
|
|
365
|
-
const args = [framePy,
|
|
476
|
+
const args = [framePy, inputDir, outputDir];
|
|
366
477
|
if (force) {
|
|
367
478
|
args.push("--force");
|
|
368
479
|
}
|
|
@@ -370,22 +481,24 @@ async function frameScreenshots(rawDir, framedDir, force = false) {
|
|
|
370
481
|
if (output) {
|
|
371
482
|
process.stdout.write(output + "\n");
|
|
372
483
|
}
|
|
373
|
-
const framedFiles = existsSync3(
|
|
374
|
-
const rawFiles = existsSync3(
|
|
484
|
+
const framedFiles = existsSync3(outputDir) ? readdirSync(outputDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
485
|
+
const rawFiles = existsSync3(inputDir) ? readdirSync(inputDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
375
486
|
return { framed: framedFiles, skipped: rawFiles - framedFiles };
|
|
376
487
|
}
|
|
377
488
|
async function frameAllIosScreenshots(screenshotsDir, force = false) {
|
|
378
489
|
const iosDir = join3(screenshotsDir, "ios");
|
|
379
490
|
if (!existsSync3(iosDir)) return 0;
|
|
380
491
|
let totalFramed = 0;
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
492
|
+
const sizeDirs = readdirSync(iosDir, { withFileTypes: true }).filter(
|
|
493
|
+
(d) => d.isDirectory()
|
|
494
|
+
);
|
|
495
|
+
for (const sizeDir of sizeDirs) {
|
|
496
|
+
const dirPath = join3(iosDir, sizeDir.name);
|
|
497
|
+
const pngFiles = readdirSync(dirPath).filter(
|
|
498
|
+
(f) => f.endsWith(".png") && !f.includes("_framed")
|
|
499
|
+
);
|
|
386
500
|
if (pngFiles.length === 0) continue;
|
|
387
|
-
const
|
|
388
|
-
const { framed } = await frameScreenshots(rawDir, framedDir, force);
|
|
501
|
+
const { framed } = await frameScreenshots(dirPath, dirPath, force);
|
|
389
502
|
totalFramed += framed;
|
|
390
503
|
}
|
|
391
504
|
return totalFramed;
|
|
@@ -418,18 +531,17 @@ async function captureCommand(options) {
|
|
|
418
531
|
Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
419
532
|
);
|
|
420
533
|
for (const device of devices) {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
(f) => f.endsWith(".png")
|
|
534
|
+
const sizeDir = join4(outputDir, device.platform, device.screenSize);
|
|
535
|
+
if (existsSync4(sizeDir)) {
|
|
536
|
+
const count = readdirSync2(sizeDir).filter(
|
|
537
|
+
(f) => f.endsWith(".png") && !f.includes("_framed")
|
|
426
538
|
).length;
|
|
427
539
|
console.log(
|
|
428
|
-
` ${pc.dim(device.platform + "/")}${device.
|
|
540
|
+
` ${pc.dim(device.platform + "/")}${device.screenSize} ${pc.dim("(" + device.displayName + ")")} - ${count} screenshot(s)`
|
|
429
541
|
);
|
|
430
542
|
} else {
|
|
431
543
|
console.log(
|
|
432
|
-
` ${pc.dim(device.platform + "/")}${device.
|
|
544
|
+
` ${pc.dim(device.platform + "/")}${device.screenSize} ${pc.dim("(" + device.displayName + ")")} - ${pc.green("new")}`
|
|
433
545
|
);
|
|
434
546
|
}
|
|
435
547
|
}
|
|
@@ -458,9 +570,8 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
458
570
|
const existingFile = join4(
|
|
459
571
|
outputDir,
|
|
460
572
|
firstDevice.platform,
|
|
461
|
-
firstDevice.
|
|
462
|
-
|
|
463
|
-
`${screenshotName}_${firstDevice.safeName}.png`
|
|
573
|
+
firstDevice.screenSize,
|
|
574
|
+
`${screenshotName}.png`
|
|
464
575
|
);
|
|
465
576
|
if (existsSync4(existingFile)) {
|
|
466
577
|
const response = await prompts({
|
|
@@ -492,10 +603,12 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
492
603
|
console.log("");
|
|
493
604
|
const captured = [];
|
|
494
605
|
for (const device of devices) {
|
|
495
|
-
const filename = `${screenshotName}
|
|
496
|
-
const tmpPath = join4(tmpDir, filename);
|
|
606
|
+
const filename = `${screenshotName}.png`;
|
|
607
|
+
const tmpPath = join4(tmpDir, `${device.platform}_${device.screenSize}_${filename}`);
|
|
497
608
|
const icon = device.platform === "ios" ? "iOS" : "Android";
|
|
498
|
-
const s = ora(
|
|
609
|
+
const s = ora(
|
|
610
|
+
`${icon}: Capturing from ${device.displayName}...`
|
|
611
|
+
).start();
|
|
499
612
|
let success = false;
|
|
500
613
|
if (device.platform === "ios") {
|
|
501
614
|
success = await captureIosScreenshot(device.captureId, tmpPath);
|
|
@@ -514,25 +627,21 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
514
627
|
if (success) {
|
|
515
628
|
captured.push({
|
|
516
629
|
platform: device.platform,
|
|
517
|
-
|
|
630
|
+
screenSize: device.screenSize,
|
|
518
631
|
filename,
|
|
519
632
|
tmpPath
|
|
520
633
|
});
|
|
521
|
-
s.succeed(`${icon}: ${device.displayName}`);
|
|
634
|
+
s.succeed(`${icon}: ${device.screenSize} (${device.displayName})`);
|
|
522
635
|
} else {
|
|
523
636
|
s.fail(`${icon}: Failed to capture from ${device.displayName}`);
|
|
524
637
|
}
|
|
525
638
|
}
|
|
526
639
|
for (const file of captured) {
|
|
527
|
-
const destDir = join4(outputDir, file.platform, file.
|
|
640
|
+
const destDir = join4(outputDir, file.platform, file.screenSize);
|
|
528
641
|
mkdirSync(destDir, { recursive: true });
|
|
529
|
-
if (file.platform === "ios") {
|
|
530
|
-
mkdirSync(join4(outputDir, file.platform, file.safeName, "framed"), {
|
|
531
|
-
recursive: true
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
642
|
renameSync(file.tmpPath, join4(destDir, file.filename));
|
|
535
643
|
}
|
|
644
|
+
updateMetadata(outputDir, devices);
|
|
536
645
|
if (iosDevices.length > 0) {
|
|
537
646
|
await clearIosStatusBar();
|
|
538
647
|
}
|
|
@@ -561,19 +670,35 @@ Captured ${captured.length} screenshot(s) as '${screenshotName}'.`
|
|
|
561
670
|
function getExistingScreenshotNames(outputDir, devices) {
|
|
562
671
|
const names = /* @__PURE__ */ new Set();
|
|
563
672
|
for (const device of devices) {
|
|
564
|
-
const
|
|
565
|
-
if (!existsSync4(
|
|
566
|
-
for (const file of readdirSync2(
|
|
567
|
-
if (!file.endsWith(".png")) continue;
|
|
568
|
-
|
|
569
|
-
const suffix = `_${device.safeName}`;
|
|
570
|
-
if (base.endsWith(suffix)) {
|
|
571
|
-
names.add(base.slice(0, -suffix.length));
|
|
572
|
-
}
|
|
673
|
+
const sizeDir = join4(outputDir, device.platform, device.screenSize);
|
|
674
|
+
if (!existsSync4(sizeDir)) continue;
|
|
675
|
+
for (const file of readdirSync2(sizeDir)) {
|
|
676
|
+
if (!file.endsWith(".png") || file.includes("_framed")) continue;
|
|
677
|
+
names.add(file.replace(".png", ""));
|
|
573
678
|
}
|
|
574
679
|
}
|
|
575
680
|
return [...names].sort();
|
|
576
681
|
}
|
|
682
|
+
function updateMetadata(outputDir, devices) {
|
|
683
|
+
const metaPath = join4(outputDir, "metadata.json");
|
|
684
|
+
let metadata = { ios: {}, android: {} };
|
|
685
|
+
if (existsSync4(metaPath)) {
|
|
686
|
+
try {
|
|
687
|
+
metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
688
|
+
} catch {
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
for (const device of devices) {
|
|
692
|
+
const entry = {
|
|
693
|
+
device: device.displayName,
|
|
694
|
+
id: device.captureId,
|
|
695
|
+
resolution: `${device.resolution.width}x${device.resolution.height}`
|
|
696
|
+
};
|
|
697
|
+
metadata[device.platform][device.screenSize] = entry;
|
|
698
|
+
}
|
|
699
|
+
mkdirSync(outputDir, { recursive: true });
|
|
700
|
+
writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
701
|
+
}
|
|
577
702
|
|
|
578
703
|
// src/commands/frame.ts
|
|
579
704
|
import ora2 from "ora";
|
|
@@ -605,7 +730,7 @@ async function frameCommand(dir, options) {
|
|
|
605
730
|
var program = new Command();
|
|
606
731
|
program.name("device-shots").description(
|
|
607
732
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
608
|
-
).version("0.
|
|
733
|
+
).version("0.2.0");
|
|
609
734
|
program.command("capture").description("Capture screenshots from running devices").argument("[name]", "Screenshot name").option("-b, --bundle-id <id>", "App bundle ID").option("-o, --output <dir>", "Output directory").option("-p, --platform <platform>", "ios, android, or both").option("--no-frame", "Skip framing after capture").option("--time <time>", "Status bar time", "9:41").action(async (name, opts) => {
|
|
610
735
|
await captureCommand({ name, ...opts });
|
|
611
736
|
});
|
|
@@ -628,7 +753,7 @@ program.command("init").description("Create a .device-shotsrc.json config file")
|
|
|
628
753
|
return;
|
|
629
754
|
}
|
|
630
755
|
const config = createDefaultConfig(response.bundleId);
|
|
631
|
-
|
|
756
|
+
writeFileSync2(configPath, config + "\n");
|
|
632
757
|
console.log(pc3.green(`Created ${configPath}`));
|
|
633
758
|
});
|
|
634
759
|
program.parse();
|