device-shots 0.3.0 → 0.4.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 +53 -8
- package/dist/index.js +60 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# device-shots
|
|
2
2
|
|
|
3
|
-
A CLI tool that captures screenshots from running iOS simulators and Android emulators, then
|
|
3
|
+
A CLI tool that captures screenshots from running iOS simulators and Android emulators, then frames them for store-ready use. iOS screenshots get real device bezels, Android screenshots get a clean black border with rounded corners. Built for developers who need store-ready screenshots without manual work.
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
@@ -9,7 +9,8 @@ A CLI tool that captures screenshots from running iOS simulators and Android emu
|
|
|
9
9
|
3. **Captures screenshots** — Takes screenshots from every discovered device in one go
|
|
10
10
|
4. **Transparent Android status bar** — Automatically makes the Android status bar area transparent using ImageMagick
|
|
11
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. **
|
|
12
|
+
6. **Frames Android screenshots** — Adds a black border with rounded corners for a clean device-like look (requires ImageMagick)
|
|
13
|
+
7. **Organizes by screen size** — Saves screenshots into store-aligned size buckets (e.g. `6.9`, `6.3`, `phone`) instead of device names
|
|
13
14
|
|
|
14
15
|
## Use cases
|
|
15
16
|
|
|
@@ -28,8 +29,8 @@ A CLI tool that captures screenshots from running iOS simulators and Android emu
|
|
|
28
29
|
|
|
29
30
|
### Optional
|
|
30
31
|
|
|
31
|
-
- **ImageMagick** — needed
|
|
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
|
|
32
|
+
- **ImageMagick** — needed for Android status bar transparency and Android framing (black border + rounded corners). Install with `brew install imagemagick`
|
|
33
|
+
- **Python 3** — needed for iOS screenshot framing with device bezels. The tool auto-creates a virtual environment at `~/.device-shots/.venv` and installs `device-frames-core` and `Pillow` automatically on first use
|
|
33
34
|
|
|
34
35
|
## Install
|
|
35
36
|
|
|
@@ -67,7 +68,7 @@ device-shots capture checkout -b com.example.myapp --time "10:30"
|
|
|
67
68
|
### Frame existing screenshots
|
|
68
69
|
|
|
69
70
|
```bash
|
|
70
|
-
# Frame all unframed iOS
|
|
71
|
+
# Frame all unframed screenshots (iOS + Android) in ./.screenshots
|
|
71
72
|
device-shots frame
|
|
72
73
|
|
|
73
74
|
# Frame from a specific directory
|
|
@@ -83,11 +84,11 @@ device-shots frame --force
|
|
|
83
84
|
device-shots init
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
Creates a `.device-shotsrc.json` in the current directory so you don't have to pass `--bundle-id` every time:
|
|
87
|
+
Creates a `.device-shotsrc.json` in the current directory so you don't have to pass `--bundle-id` every time. It will ask for both production and dev bundle IDs:
|
|
87
88
|
|
|
88
89
|
```json
|
|
89
90
|
{
|
|
90
|
-
"bundleId": "com.example.myapp",
|
|
91
|
+
"bundleId": ["com.example.myapp", "com.example.myapp.dev"],
|
|
91
92
|
"output": "./.screenshots",
|
|
92
93
|
"platform": "both",
|
|
93
94
|
"time": "9:41",
|
|
@@ -95,6 +96,14 @@ Creates a `.device-shotsrc.json` in the current directory so you don't have to p
|
|
|
95
96
|
}
|
|
96
97
|
```
|
|
97
98
|
|
|
99
|
+
A single string also works if you only have one bundle ID:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"bundleId": "com.example.myapp"
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
98
107
|
## Config file
|
|
99
108
|
|
|
100
109
|
The tool uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig), so you can configure it in any of these ways:
|
|
@@ -105,6 +114,20 @@ The tool uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig), so you
|
|
|
105
114
|
- `device-shots.config.js`
|
|
106
115
|
- `"device-shots"` key in `package.json`
|
|
107
116
|
|
|
117
|
+
## Multiple bundle IDs
|
|
118
|
+
|
|
119
|
+
Most apps have separate bundle IDs for production and development builds. You can pass an array to `bundleId` in the config:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"bundleId": ["com.example.myapp", "com.example.myapp.dev"]
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
During discovery, the tool tries each bundle ID on every running device and captures from whichever build is installed. This means it works regardless of whether you have the dev or production build running.
|
|
128
|
+
|
|
129
|
+
The CLI `--bundle-id` flag accepts a single ID for quick one-off captures.
|
|
130
|
+
|
|
108
131
|
## Output structure
|
|
109
132
|
|
|
110
133
|
Screenshots are organized by platform and screen size bucket, matching App Store and Play Store requirements:
|
|
@@ -125,7 +148,9 @@ Screenshots are organized by platform and screen size bucket, matching App Store
|
|
|
125
148
|
├── android/
|
|
126
149
|
│ └── phone/ # Pixel 9 Pro, etc.
|
|
127
150
|
│ ├── dashboard.png
|
|
128
|
-
│
|
|
151
|
+
│ ├── dashboard_framed.png
|
|
152
|
+
│ ├── settings.png
|
|
153
|
+
│ └── settings_framed.png
|
|
129
154
|
└── metadata.json
|
|
130
155
|
```
|
|
131
156
|
|
|
@@ -175,6 +200,26 @@ Tracks which physical device was used for each size bucket:
|
|
|
175
200
|
}
|
|
176
201
|
```
|
|
177
202
|
|
|
203
|
+
## Framing
|
|
204
|
+
|
|
205
|
+
Both platforms get framed automatically after capture (disable with `--no-frame`).
|
|
206
|
+
|
|
207
|
+
### iOS — Device bezels
|
|
208
|
+
|
|
209
|
+
Uses [device-frames-core](https://pypi.org/project/device-frames-core/) to wrap screenshots in realistic Apple device frames (iPhone, iPad). The device model is auto-detected from the screenshot resolution. Requires Python 3 (venv is managed automatically).
|
|
210
|
+
|
|
211
|
+
### Android — Black border with rounded corners
|
|
212
|
+
|
|
213
|
+
Uses ImageMagick to add a black bezel-like border with rounded corners. Dimensions scale proportionally to the screenshot:
|
|
214
|
+
|
|
215
|
+
| Property | Value |
|
|
216
|
+
|----------|-------|
|
|
217
|
+
| Border width | ~1.8% of image width |
|
|
218
|
+
| Inner corner radius | ~4.5% of image width |
|
|
219
|
+
| Outer corner radius | inner radius + border width |
|
|
220
|
+
|
|
221
|
+
For a 1080px wide screenshot, this produces a ~19px border with ~49px rounded corners.
|
|
222
|
+
|
|
178
223
|
## License
|
|
179
224
|
|
|
180
225
|
MIT
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
existsSync as existsSync4,
|
|
12
12
|
mkdirSync,
|
|
13
13
|
readdirSync as readdirSync2,
|
|
14
|
-
|
|
14
|
+
copyFileSync,
|
|
15
|
+
unlinkSync,
|
|
15
16
|
readFileSync,
|
|
16
17
|
writeFileSync
|
|
17
18
|
} from "fs";
|
|
@@ -416,14 +417,28 @@ async function makeStatusBarTransparent(serial, imagePath) {
|
|
|
416
417
|
|
|
417
418
|
// src/devices/discover.ts
|
|
418
419
|
async function discoverDevices(bundleId, platform = "both") {
|
|
420
|
+
const ids = Array.isArray(bundleId) ? bundleId : [bundleId];
|
|
419
421
|
const results = [];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
422
|
+
const seen = /* @__PURE__ */ new Set();
|
|
423
|
+
for (const id of ids) {
|
|
424
|
+
if (platform === "ios" || platform === "both") {
|
|
425
|
+
const iosDevices = await discoverIosDevices(id);
|
|
426
|
+
for (const d of iosDevices) {
|
|
427
|
+
if (!seen.has(d.captureId)) {
|
|
428
|
+
seen.add(d.captureId);
|
|
429
|
+
results.push(d);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (platform === "android" || platform === "both") {
|
|
434
|
+
const androidDevices = await discoverAndroidDevices(id);
|
|
435
|
+
for (const d of androidDevices) {
|
|
436
|
+
if (!seen.has(d.captureId)) {
|
|
437
|
+
seen.add(d.captureId);
|
|
438
|
+
results.push(d);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
427
442
|
}
|
|
428
443
|
return results;
|
|
429
444
|
}
|
|
@@ -594,12 +609,13 @@ async function frameAllAndroidScreenshots(screenshotsDir, force = false) {
|
|
|
594
609
|
async function captureCommand(options) {
|
|
595
610
|
const config = await loadConfig();
|
|
596
611
|
const bundleId = options.bundleId || config.bundleId;
|
|
597
|
-
if (!bundleId) {
|
|
612
|
+
if (!bundleId || Array.isArray(bundleId) && bundleId.length === 0) {
|
|
598
613
|
console.error(
|
|
599
614
|
pc.red("Bundle ID is required. Use --bundle-id or set it in config.")
|
|
600
615
|
);
|
|
601
616
|
process.exit(1);
|
|
602
617
|
}
|
|
618
|
+
const bundleIdDisplay = Array.isArray(bundleId) ? bundleId.join(", ") : bundleId;
|
|
603
619
|
const outputDir = options.output || config.output;
|
|
604
620
|
const platform = options.platform || config.platform;
|
|
605
621
|
const time = options.time || config.time;
|
|
@@ -608,13 +624,13 @@ async function captureCommand(options) {
|
|
|
608
624
|
const devices = await discoverDevices(bundleId, platform);
|
|
609
625
|
spinner.stop();
|
|
610
626
|
if (devices.length === 0) {
|
|
611
|
-
console.error(pc.red(`No devices found with ${
|
|
627
|
+
console.error(pc.red(`No devices found with ${bundleIdDisplay} installed.`));
|
|
612
628
|
console.error("Start a simulator/emulator and install the app first.");
|
|
613
629
|
process.exit(1);
|
|
614
630
|
}
|
|
615
631
|
console.log(
|
|
616
632
|
pc.bold(`
|
|
617
|
-
Detected ${devices.length} device(s) with ${
|
|
633
|
+
Detected ${devices.length} device(s) with ${bundleIdDisplay}:`)
|
|
618
634
|
);
|
|
619
635
|
for (const device of devices) {
|
|
620
636
|
const sizeDir = join4(outputDir, device.platform, device.screenSize);
|
|
@@ -690,7 +706,7 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
690
706
|
const captured = [];
|
|
691
707
|
for (const device of devices) {
|
|
692
708
|
const filename = `${screenshotName}.png`;
|
|
693
|
-
const tmpPath = join4(tmpDir, `${device.platform}_${device.screenSize}_${filename}`);
|
|
709
|
+
const tmpPath = join4(tmpDir, `${device.platform}_${device.screenSize}_${device.safeName}_${filename}`);
|
|
694
710
|
const icon = device.platform === "ios" ? "iOS" : "Android";
|
|
695
711
|
const s = ora(
|
|
696
712
|
`${icon}: Capturing from ${device.displayName}...`
|
|
@@ -722,10 +738,25 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
722
738
|
s.fail(`${icon}: Failed to capture from ${device.displayName}`);
|
|
723
739
|
}
|
|
724
740
|
}
|
|
741
|
+
const movedBuckets = /* @__PURE__ */ new Set();
|
|
725
742
|
for (const file of captured) {
|
|
743
|
+
const bucketKey = `${file.platform}/${file.screenSize}`;
|
|
744
|
+
if (movedBuckets.has(bucketKey)) {
|
|
745
|
+
try {
|
|
746
|
+
unlinkSync(file.tmpPath);
|
|
747
|
+
} catch {
|
|
748
|
+
}
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
movedBuckets.add(bucketKey);
|
|
726
752
|
const destDir = join4(outputDir, file.platform, file.screenSize);
|
|
727
753
|
mkdirSync(destDir, { recursive: true });
|
|
728
|
-
|
|
754
|
+
const destPath = join4(destDir, file.filename);
|
|
755
|
+
copyFileSync(file.tmpPath, destPath);
|
|
756
|
+
try {
|
|
757
|
+
unlinkSync(file.tmpPath);
|
|
758
|
+
} catch {
|
|
759
|
+
}
|
|
729
760
|
}
|
|
730
761
|
updateMetadata(outputDir, devices);
|
|
731
762
|
if (iosDevices.length > 0) {
|
|
@@ -842,11 +873,11 @@ async function frameCommand(dir, options) {
|
|
|
842
873
|
var program = new Command();
|
|
843
874
|
program.name("device-shots").description(
|
|
844
875
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
845
|
-
).version("0.
|
|
876
|
+
).version("0.4.1");
|
|
846
877
|
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) => {
|
|
847
878
|
await captureCommand({ name, ...opts });
|
|
848
879
|
});
|
|
849
|
-
program.command("frame").description("Frame existing
|
|
880
|
+
program.command("frame").description("Frame existing screenshots with device bezels or borders").argument("[dir]", "Screenshots directory").option("-i, --input <dir>", "Screenshots directory").option("-f, --force", "Re-frame existing screenshots").action(async (dir, opts) => {
|
|
850
881
|
await frameCommand(dir, opts);
|
|
851
882
|
});
|
|
852
883
|
program.command("init").description("Create a .device-shotsrc.json config file").action(async () => {
|
|
@@ -855,16 +886,24 @@ program.command("init").description("Create a .device-shotsrc.json config file")
|
|
|
855
886
|
console.log(pc3.yellow(`${configPath} already exists.`));
|
|
856
887
|
return;
|
|
857
888
|
}
|
|
858
|
-
const response = await prompts2(
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
889
|
+
const response = await prompts2([
|
|
890
|
+
{
|
|
891
|
+
type: "text",
|
|
892
|
+
name: "bundleId",
|
|
893
|
+
message: "Production bundle ID (e.g. com.example.myapp)"
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
type: "text",
|
|
897
|
+
name: "devBundleId",
|
|
898
|
+
message: "Dev bundle ID (leave empty to skip)"
|
|
899
|
+
}
|
|
900
|
+
]);
|
|
863
901
|
if (!response.bundleId) {
|
|
864
902
|
console.log("Aborting.");
|
|
865
903
|
return;
|
|
866
904
|
}
|
|
867
|
-
const
|
|
905
|
+
const bundleId = response.devBundleId ? [response.bundleId, response.devBundleId] : response.bundleId;
|
|
906
|
+
const config = createDefaultConfig(bundleId);
|
|
868
907
|
writeFileSync2(configPath, config + "\n");
|
|
869
908
|
console.log(pc3.green(`Created ${configPath}`));
|
|
870
909
|
});
|