device-shots 0.1.0 → 0.2.1
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 +179 -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.5/ # iPhone 14 Plus, 13 Pro Max
|
|
121
|
+
│ │ ├── dashboard.png
|
|
122
|
+
│ │ └── dashboard_framed.png
|
|
123
|
+
│ └── 6.3/ # iPhone 16 Pro, 16, 15 Pro, 15
|
|
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, 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max |
|
|
139
|
+
| `6.5` | iPhone 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max, XS Max |
|
|
140
|
+
| `6.3` | iPhone 16 Pro, 16, 15 Pro, 15, 14 Pro |
|
|
141
|
+
| `6.1` | iPhone 14, 13, 12, X, XS, 12 mini, 13 mini |
|
|
142
|
+
| `5.5` | iPhone 8 Plus, 7 Plus |
|
|
143
|
+
| `4.7` | iPhone SE (3rd/2nd), iPhone 8 |
|
|
144
|
+
| `13` | iPad Pro 13", iPad Air 13" |
|
|
145
|
+
| `11` | iPad Pro 11", iPad Air 11", iPad mini 6th gen |
|
|
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,55 @@ 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, 16 Plus, 15 Pro Max, 15 Plus, 14 Pro Max
|
|
100
|
+
"1320x2868": "6.9",
|
|
101
|
+
"1290x2796": "6.9",
|
|
102
|
+
"1260x2736": "6.9",
|
|
103
|
+
// 6.5" — iPhone 14 Plus, 13 Pro Max, 12 Pro Max, 11 Pro Max, XS Max, XR
|
|
104
|
+
"1284x2778": "6.5",
|
|
105
|
+
"1242x2688": "6.5",
|
|
106
|
+
// 6.3" — iPhone 16 Pro, 16, 15 Pro, 15, 14 Pro
|
|
107
|
+
"1206x2622": "6.3",
|
|
108
|
+
"1179x2556": "6.3",
|
|
109
|
+
// 6.1" — iPhone 14, 13, 13 Pro, 12, 12 Pro, X, XS, 11 Pro, 12 mini, 13 mini
|
|
110
|
+
"1170x2532": "6.1",
|
|
111
|
+
"1125x2436": "6.1",
|
|
112
|
+
"1080x2340": "6.1",
|
|
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
|
+
// 4.0" — iPhone SE (1st), iPhone 5/5s/5c
|
|
118
|
+
"640x1136": "4.0",
|
|
119
|
+
"640x1096": "4.0",
|
|
120
|
+
// iPad 13" — iPad Pro 13" (M4/M3/M2/M1), iPad Air 13", iPad Pro 12.9" (older)
|
|
121
|
+
"2064x2752": "13",
|
|
122
|
+
"2048x2732": "13",
|
|
123
|
+
// iPad 11" — iPad Pro 11", iPad Air 11", iPad 10th gen, iPad mini 6th gen
|
|
124
|
+
"1488x2266": "11",
|
|
125
|
+
"1668x2420": "11",
|
|
126
|
+
"1668x2388": "11",
|
|
127
|
+
"1640x2360": "11",
|
|
128
|
+
// iPad 10.5" — iPad Pro 10.5", iPad Air 3rd gen, iPad 7th-9th gen
|
|
129
|
+
"1668x2224": "10.5",
|
|
130
|
+
// iPad 9.7" — iPad Pro 9.7", iPad Air 1-2, older iPad mini
|
|
131
|
+
"1536x2048": "9.7",
|
|
132
|
+
"1536x2008": "9.7"
|
|
133
|
+
};
|
|
134
|
+
function getIosScreenSize(width, height) {
|
|
135
|
+
const w = Math.min(width, height);
|
|
136
|
+
const h = Math.max(width, height);
|
|
137
|
+
return IOS_RESOLUTION_MAP[`${w}x${h}`] ?? `${w}x${h}`;
|
|
138
|
+
}
|
|
139
|
+
function getAndroidScreenSize(width, height) {
|
|
140
|
+
const shorter = Math.min(width, height);
|
|
141
|
+
if (shorter >= 1800) return "tablet-10";
|
|
142
|
+
if (shorter >= 1200) return "tablet-7";
|
|
143
|
+
return "phone";
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
// src/devices/ios.ts
|
|
91
147
|
function sanitizeName(name) {
|
|
92
148
|
return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
|
|
@@ -115,17 +171,63 @@ async function discoverIosDevices(bundleId) {
|
|
|
115
171
|
bundleId
|
|
116
172
|
]);
|
|
117
173
|
if (container) {
|
|
174
|
+
const resolution = await getResolutionViaTestCapture(d.udid);
|
|
175
|
+
const screenSize = resolution ? getIosScreenSize(resolution.width, resolution.height) : sanitizeName(d.name);
|
|
118
176
|
devices.push({
|
|
119
177
|
platform: "ios",
|
|
120
178
|
safeName: sanitizeName(d.name),
|
|
121
179
|
captureId: d.udid,
|
|
122
|
-
displayName: d.name
|
|
180
|
+
displayName: d.name,
|
|
181
|
+
screenSize,
|
|
182
|
+
resolution: resolution ?? { width: 0, height: 0 }
|
|
123
183
|
});
|
|
124
184
|
}
|
|
125
185
|
}
|
|
126
186
|
}
|
|
127
187
|
return devices;
|
|
128
188
|
}
|
|
189
|
+
async function getResolutionViaTestCapture(udid) {
|
|
190
|
+
try {
|
|
191
|
+
const tmpPath = `/tmp/device-shots-probe-${udid}.png`;
|
|
192
|
+
await runOrFail("xcrun", ["simctl", "io", udid, "screenshot", tmpPath]);
|
|
193
|
+
if (await commandExists("magick")) {
|
|
194
|
+
const { stdout } = await run("magick", [
|
|
195
|
+
"identify",
|
|
196
|
+
"-format",
|
|
197
|
+
"%wx%h",
|
|
198
|
+
tmpPath
|
|
199
|
+
]);
|
|
200
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
201
|
+
await run("rm", ["-f", tmpPath]);
|
|
202
|
+
if (match) {
|
|
203
|
+
return {
|
|
204
|
+
width: parseInt(match[1], 10),
|
|
205
|
+
height: parseInt(match[2], 10)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const { stdout } = await run("sips", [
|
|
210
|
+
"-g",
|
|
211
|
+
"pixelWidth",
|
|
212
|
+
"-g",
|
|
213
|
+
"pixelHeight",
|
|
214
|
+
tmpPath
|
|
215
|
+
]);
|
|
216
|
+
await run("rm", ["-f", tmpPath]);
|
|
217
|
+
const wMatch = stdout.match(/pixelWidth:\s*(\d+)/);
|
|
218
|
+
const hMatch = stdout.match(/pixelHeight:\s*(\d+)/);
|
|
219
|
+
if (wMatch && hMatch) {
|
|
220
|
+
return {
|
|
221
|
+
width: parseInt(wMatch[1], 10),
|
|
222
|
+
height: parseInt(hMatch[1], 10)
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
await run("rm", ["-f", tmpPath]);
|
|
227
|
+
} catch {
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
129
231
|
async function setIosStatusBar(time) {
|
|
130
232
|
if (!await commandExists("xcrun")) return;
|
|
131
233
|
await run("xcrun", [
|
|
@@ -164,6 +266,16 @@ async function captureIosScreenshot(udid, outputPath) {
|
|
|
164
266
|
function sanitizeName2(name) {
|
|
165
267
|
return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
|
|
166
268
|
}
|
|
269
|
+
async function getAndroidResolution(serial) {
|
|
270
|
+
const adb = getAdbPath();
|
|
271
|
+
const { stdout } = await run(adb, ["-s", serial, "shell", "wm", "size"]);
|
|
272
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
273
|
+
if (!match) return null;
|
|
274
|
+
return {
|
|
275
|
+
width: parseInt(match[1], 10),
|
|
276
|
+
height: parseInt(match[2], 10)
|
|
277
|
+
};
|
|
278
|
+
}
|
|
167
279
|
async function discoverAndroidDevices(bundleId) {
|
|
168
280
|
const adb = getAdbPath();
|
|
169
281
|
const { stdout } = await run(adb, ["devices"]);
|
|
@@ -203,11 +315,15 @@ async function discoverAndroidDevices(bundleId) {
|
|
|
203
315
|
avdName = model.trim();
|
|
204
316
|
}
|
|
205
317
|
const safeName = sanitizeName2(avdName) || serial;
|
|
318
|
+
const resolution = await getAndroidResolution(serial);
|
|
319
|
+
const screenSize = resolution ? getAndroidScreenSize(resolution.width, resolution.height) : "phone";
|
|
206
320
|
devices.push({
|
|
207
321
|
platform: "android",
|
|
208
322
|
safeName,
|
|
209
323
|
captureId: serial,
|
|
210
|
-
displayName: avdName || serial
|
|
324
|
+
displayName: avdName || serial,
|
|
325
|
+
screenSize,
|
|
326
|
+
resolution: resolution ?? { width: 0, height: 0 }
|
|
211
327
|
});
|
|
212
328
|
}
|
|
213
329
|
return devices;
|
|
@@ -358,11 +474,11 @@ function getFramePyPath() {
|
|
|
358
474
|
"Could not find vendored frame.py. Ensure the vendor/ directory is present."
|
|
359
475
|
);
|
|
360
476
|
}
|
|
361
|
-
async function frameScreenshots(
|
|
477
|
+
async function frameScreenshots(inputDir, outputDir, force = false) {
|
|
362
478
|
await ensureVenv();
|
|
363
479
|
const framePy = getFramePyPath();
|
|
364
480
|
const python = getVenvPython();
|
|
365
|
-
const args = [framePy,
|
|
481
|
+
const args = [framePy, inputDir, outputDir];
|
|
366
482
|
if (force) {
|
|
367
483
|
args.push("--force");
|
|
368
484
|
}
|
|
@@ -370,22 +486,24 @@ async function frameScreenshots(rawDir, framedDir, force = false) {
|
|
|
370
486
|
if (output) {
|
|
371
487
|
process.stdout.write(output + "\n");
|
|
372
488
|
}
|
|
373
|
-
const framedFiles = existsSync3(
|
|
374
|
-
const rawFiles = existsSync3(
|
|
489
|
+
const framedFiles = existsSync3(outputDir) ? readdirSync(outputDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
490
|
+
const rawFiles = existsSync3(inputDir) ? readdirSync(inputDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
375
491
|
return { framed: framedFiles, skipped: rawFiles - framedFiles };
|
|
376
492
|
}
|
|
377
493
|
async function frameAllIosScreenshots(screenshotsDir, force = false) {
|
|
378
494
|
const iosDir = join3(screenshotsDir, "ios");
|
|
379
495
|
if (!existsSync3(iosDir)) return 0;
|
|
380
496
|
let totalFramed = 0;
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
497
|
+
const sizeDirs = readdirSync(iosDir, { withFileTypes: true }).filter(
|
|
498
|
+
(d) => d.isDirectory()
|
|
499
|
+
);
|
|
500
|
+
for (const sizeDir of sizeDirs) {
|
|
501
|
+
const dirPath = join3(iosDir, sizeDir.name);
|
|
502
|
+
const pngFiles = readdirSync(dirPath).filter(
|
|
503
|
+
(f) => f.endsWith(".png") && !f.includes("_framed")
|
|
504
|
+
);
|
|
386
505
|
if (pngFiles.length === 0) continue;
|
|
387
|
-
const
|
|
388
|
-
const { framed } = await frameScreenshots(rawDir, framedDir, force);
|
|
506
|
+
const { framed } = await frameScreenshots(dirPath, dirPath, force);
|
|
389
507
|
totalFramed += framed;
|
|
390
508
|
}
|
|
391
509
|
return totalFramed;
|
|
@@ -418,18 +536,17 @@ async function captureCommand(options) {
|
|
|
418
536
|
Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
419
537
|
);
|
|
420
538
|
for (const device of devices) {
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
(f) => f.endsWith(".png")
|
|
539
|
+
const sizeDir = join4(outputDir, device.platform, device.screenSize);
|
|
540
|
+
if (existsSync4(sizeDir)) {
|
|
541
|
+
const count = readdirSync2(sizeDir).filter(
|
|
542
|
+
(f) => f.endsWith(".png") && !f.includes("_framed")
|
|
426
543
|
).length;
|
|
427
544
|
console.log(
|
|
428
|
-
` ${pc.dim(device.platform + "/")}${device.
|
|
545
|
+
` ${pc.dim(device.platform + "/")}${device.screenSize} ${pc.dim("(" + device.displayName + ")")} - ${count} screenshot(s)`
|
|
429
546
|
);
|
|
430
547
|
} else {
|
|
431
548
|
console.log(
|
|
432
|
-
` ${pc.dim(device.platform + "/")}${device.
|
|
549
|
+
` ${pc.dim(device.platform + "/")}${device.screenSize} ${pc.dim("(" + device.displayName + ")")} - ${pc.green("new")}`
|
|
433
550
|
);
|
|
434
551
|
}
|
|
435
552
|
}
|
|
@@ -458,9 +575,8 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
458
575
|
const existingFile = join4(
|
|
459
576
|
outputDir,
|
|
460
577
|
firstDevice.platform,
|
|
461
|
-
firstDevice.
|
|
462
|
-
|
|
463
|
-
`${screenshotName}_${firstDevice.safeName}.png`
|
|
578
|
+
firstDevice.screenSize,
|
|
579
|
+
`${screenshotName}.png`
|
|
464
580
|
);
|
|
465
581
|
if (existsSync4(existingFile)) {
|
|
466
582
|
const response = await prompts({
|
|
@@ -492,10 +608,12 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
492
608
|
console.log("");
|
|
493
609
|
const captured = [];
|
|
494
610
|
for (const device of devices) {
|
|
495
|
-
const filename = `${screenshotName}
|
|
496
|
-
const tmpPath = join4(tmpDir, filename);
|
|
611
|
+
const filename = `${screenshotName}.png`;
|
|
612
|
+
const tmpPath = join4(tmpDir, `${device.platform}_${device.screenSize}_${filename}`);
|
|
497
613
|
const icon = device.platform === "ios" ? "iOS" : "Android";
|
|
498
|
-
const s = ora(
|
|
614
|
+
const s = ora(
|
|
615
|
+
`${icon}: Capturing from ${device.displayName}...`
|
|
616
|
+
).start();
|
|
499
617
|
let success = false;
|
|
500
618
|
if (device.platform === "ios") {
|
|
501
619
|
success = await captureIosScreenshot(device.captureId, tmpPath);
|
|
@@ -514,25 +632,21 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
514
632
|
if (success) {
|
|
515
633
|
captured.push({
|
|
516
634
|
platform: device.platform,
|
|
517
|
-
|
|
635
|
+
screenSize: device.screenSize,
|
|
518
636
|
filename,
|
|
519
637
|
tmpPath
|
|
520
638
|
});
|
|
521
|
-
s.succeed(`${icon}: ${device.displayName}`);
|
|
639
|
+
s.succeed(`${icon}: ${device.screenSize} (${device.displayName})`);
|
|
522
640
|
} else {
|
|
523
641
|
s.fail(`${icon}: Failed to capture from ${device.displayName}`);
|
|
524
642
|
}
|
|
525
643
|
}
|
|
526
644
|
for (const file of captured) {
|
|
527
|
-
const destDir = join4(outputDir, file.platform, file.
|
|
645
|
+
const destDir = join4(outputDir, file.platform, file.screenSize);
|
|
528
646
|
mkdirSync(destDir, { recursive: true });
|
|
529
|
-
if (file.platform === "ios") {
|
|
530
|
-
mkdirSync(join4(outputDir, file.platform, file.safeName, "framed"), {
|
|
531
|
-
recursive: true
|
|
532
|
-
});
|
|
533
|
-
}
|
|
534
647
|
renameSync(file.tmpPath, join4(destDir, file.filename));
|
|
535
648
|
}
|
|
649
|
+
updateMetadata(outputDir, devices);
|
|
536
650
|
if (iosDevices.length > 0) {
|
|
537
651
|
await clearIosStatusBar();
|
|
538
652
|
}
|
|
@@ -561,19 +675,35 @@ Captured ${captured.length} screenshot(s) as '${screenshotName}'.`
|
|
|
561
675
|
function getExistingScreenshotNames(outputDir, devices) {
|
|
562
676
|
const names = /* @__PURE__ */ new Set();
|
|
563
677
|
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
|
-
}
|
|
678
|
+
const sizeDir = join4(outputDir, device.platform, device.screenSize);
|
|
679
|
+
if (!existsSync4(sizeDir)) continue;
|
|
680
|
+
for (const file of readdirSync2(sizeDir)) {
|
|
681
|
+
if (!file.endsWith(".png") || file.includes("_framed")) continue;
|
|
682
|
+
names.add(file.replace(".png", ""));
|
|
573
683
|
}
|
|
574
684
|
}
|
|
575
685
|
return [...names].sort();
|
|
576
686
|
}
|
|
687
|
+
function updateMetadata(outputDir, devices) {
|
|
688
|
+
const metaPath = join4(outputDir, "metadata.json");
|
|
689
|
+
let metadata = { ios: {}, android: {} };
|
|
690
|
+
if (existsSync4(metaPath)) {
|
|
691
|
+
try {
|
|
692
|
+
metadata = JSON.parse(readFileSync(metaPath, "utf-8"));
|
|
693
|
+
} catch {
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (const device of devices) {
|
|
697
|
+
const entry = {
|
|
698
|
+
device: device.displayName,
|
|
699
|
+
id: device.captureId,
|
|
700
|
+
resolution: `${device.resolution.width}x${device.resolution.height}`
|
|
701
|
+
};
|
|
702
|
+
metadata[device.platform][device.screenSize] = entry;
|
|
703
|
+
}
|
|
704
|
+
mkdirSync(outputDir, { recursive: true });
|
|
705
|
+
writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + "\n");
|
|
706
|
+
}
|
|
577
707
|
|
|
578
708
|
// src/commands/frame.ts
|
|
579
709
|
import ora2 from "ora";
|
|
@@ -605,7 +735,7 @@ async function frameCommand(dir, options) {
|
|
|
605
735
|
var program = new Command();
|
|
606
736
|
program.name("device-shots").description(
|
|
607
737
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
608
|
-
).version("0.1
|
|
738
|
+
).version("0.2.1");
|
|
609
739
|
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
740
|
await captureCommand({ name, ...opts });
|
|
611
741
|
});
|
|
@@ -628,7 +758,7 @@ program.command("init").description("Create a .device-shotsrc.json config file")
|
|
|
628
758
|
return;
|
|
629
759
|
}
|
|
630
760
|
const config = createDefaultConfig(response.bundleId);
|
|
631
|
-
|
|
761
|
+
writeFileSync2(configPath, config + "\n");
|
|
632
762
|
console.log(pc3.green(`Created ${configPath}`));
|
|
633
763
|
});
|
|
634
764
|
program.parse();
|