device-shots 0.2.1 → 0.4.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 +53 -8
- package/dist/index.js +168 -38
- 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
|
@@ -416,14 +416,28 @@ async function makeStatusBarTransparent(serial, imagePath) {
|
|
|
416
416
|
|
|
417
417
|
// src/devices/discover.ts
|
|
418
418
|
async function discoverDevices(bundleId, platform = "both") {
|
|
419
|
+
const ids = Array.isArray(bundleId) ? bundleId : [bundleId];
|
|
419
420
|
const results = [];
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
421
|
+
const seen = /* @__PURE__ */ new Set();
|
|
422
|
+
for (const id of ids) {
|
|
423
|
+
if (platform === "ios" || platform === "both") {
|
|
424
|
+
const iosDevices = await discoverIosDevices(id);
|
|
425
|
+
for (const d of iosDevices) {
|
|
426
|
+
if (!seen.has(d.captureId)) {
|
|
427
|
+
seen.add(d.captureId);
|
|
428
|
+
results.push(d);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (platform === "android" || platform === "both") {
|
|
433
|
+
const androidDevices = await discoverAndroidDevices(id);
|
|
434
|
+
for (const d of androidDevices) {
|
|
435
|
+
if (!seen.has(d.captureId)) {
|
|
436
|
+
seen.add(d.captureId);
|
|
437
|
+
results.push(d);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
427
441
|
}
|
|
428
442
|
return results;
|
|
429
443
|
}
|
|
@@ -490,6 +504,64 @@ async function frameScreenshots(inputDir, outputDir, force = false) {
|
|
|
490
504
|
const rawFiles = existsSync3(inputDir) ? readdirSync(inputDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
491
505
|
return { framed: framedFiles, skipped: rawFiles - framedFiles };
|
|
492
506
|
}
|
|
507
|
+
async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
508
|
+
if (!await commandExists("magick")) {
|
|
509
|
+
throw new Error("ImageMagick is required for Android framing. Install with: brew install imagemagick");
|
|
510
|
+
}
|
|
511
|
+
const { stdout: identify } = await run("magick", [
|
|
512
|
+
"identify",
|
|
513
|
+
"-format",
|
|
514
|
+
"%wx%h",
|
|
515
|
+
inputPath
|
|
516
|
+
]);
|
|
517
|
+
const match = identify.match(/(\d+)x(\d+)/);
|
|
518
|
+
if (!match) return false;
|
|
519
|
+
const width = parseInt(match[1], 10);
|
|
520
|
+
const height = parseInt(match[2], 10);
|
|
521
|
+
const borderWidth = Math.round(width * 0.018);
|
|
522
|
+
const innerRadius = Math.round(width * 0.045);
|
|
523
|
+
const outerRadius = innerRadius + borderWidth;
|
|
524
|
+
const totalW = width + borderWidth * 2;
|
|
525
|
+
const totalH = height + borderWidth * 2;
|
|
526
|
+
try {
|
|
527
|
+
await runOrFail("magick", [
|
|
528
|
+
// Create black rounded rectangle background
|
|
529
|
+
"-size",
|
|
530
|
+
`${totalW}x${totalH}`,
|
|
531
|
+
"xc:none",
|
|
532
|
+
"-draw",
|
|
533
|
+
`fill black roundrectangle 0,0 ${totalW - 1},${totalH - 1} ${outerRadius},${outerRadius}`,
|
|
534
|
+
// Load screenshot and round its corners
|
|
535
|
+
"(",
|
|
536
|
+
inputPath,
|
|
537
|
+
"-alpha",
|
|
538
|
+
"set",
|
|
539
|
+
"(",
|
|
540
|
+
"+clone",
|
|
541
|
+
"-alpha",
|
|
542
|
+
"extract",
|
|
543
|
+
"-draw",
|
|
544
|
+
`fill black color 0,0 reset`,
|
|
545
|
+
"-draw",
|
|
546
|
+
`fill white roundrectangle 0,0 ${width - 1},${height - 1} ${innerRadius},${innerRadius}`,
|
|
547
|
+
")",
|
|
548
|
+
"-compose",
|
|
549
|
+
"DstIn",
|
|
550
|
+
"-composite",
|
|
551
|
+
")",
|
|
552
|
+
// Composite screenshot centered on black background
|
|
553
|
+
"-gravity",
|
|
554
|
+
"center",
|
|
555
|
+
"-compose",
|
|
556
|
+
"Over",
|
|
557
|
+
"-composite",
|
|
558
|
+
outputPath
|
|
559
|
+
]);
|
|
560
|
+
return true;
|
|
561
|
+
} catch {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
493
565
|
async function frameAllIosScreenshots(screenshotsDir, force = false) {
|
|
494
566
|
const iosDir = join3(screenshotsDir, "ios");
|
|
495
567
|
if (!existsSync3(iosDir)) return 0;
|
|
@@ -508,17 +580,41 @@ async function frameAllIosScreenshots(screenshotsDir, force = false) {
|
|
|
508
580
|
}
|
|
509
581
|
return totalFramed;
|
|
510
582
|
}
|
|
583
|
+
async function frameAllAndroidScreenshots(screenshotsDir, force = false) {
|
|
584
|
+
if (!await commandExists("magick")) return 0;
|
|
585
|
+
const androidDir = join3(screenshotsDir, "android");
|
|
586
|
+
if (!existsSync3(androidDir)) return 0;
|
|
587
|
+
let totalFramed = 0;
|
|
588
|
+
const sizeDirs = readdirSync(androidDir, { withFileTypes: true }).filter(
|
|
589
|
+
(d) => d.isDirectory()
|
|
590
|
+
);
|
|
591
|
+
for (const sizeDir of sizeDirs) {
|
|
592
|
+
const dirPath = join3(androidDir, sizeDir.name);
|
|
593
|
+
const rawFiles = readdirSync(dirPath).filter(
|
|
594
|
+
(f) => f.endsWith(".png") && !f.includes("_framed")
|
|
595
|
+
);
|
|
596
|
+
for (const file of rawFiles) {
|
|
597
|
+
const inputPath = join3(dirPath, file);
|
|
598
|
+
const outputPath = join3(dirPath, file.replace(".png", "_framed.png"));
|
|
599
|
+
if (!force && existsSync3(outputPath)) continue;
|
|
600
|
+
const success = await frameAndroidScreenshot(inputPath, outputPath);
|
|
601
|
+
if (success) totalFramed++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return totalFramed;
|
|
605
|
+
}
|
|
511
606
|
|
|
512
607
|
// src/commands/capture.ts
|
|
513
608
|
async function captureCommand(options) {
|
|
514
609
|
const config = await loadConfig();
|
|
515
610
|
const bundleId = options.bundleId || config.bundleId;
|
|
516
|
-
if (!bundleId) {
|
|
611
|
+
if (!bundleId || Array.isArray(bundleId) && bundleId.length === 0) {
|
|
517
612
|
console.error(
|
|
518
613
|
pc.red("Bundle ID is required. Use --bundle-id or set it in config.")
|
|
519
614
|
);
|
|
520
615
|
process.exit(1);
|
|
521
616
|
}
|
|
617
|
+
const bundleIdDisplay = Array.isArray(bundleId) ? bundleId.join(", ") : bundleId;
|
|
522
618
|
const outputDir = options.output || config.output;
|
|
523
619
|
const platform = options.platform || config.platform;
|
|
524
620
|
const time = options.time || config.time;
|
|
@@ -527,13 +623,13 @@ async function captureCommand(options) {
|
|
|
527
623
|
const devices = await discoverDevices(bundleId, platform);
|
|
528
624
|
spinner.stop();
|
|
529
625
|
if (devices.length === 0) {
|
|
530
|
-
console.error(pc.red(`No devices found with ${
|
|
626
|
+
console.error(pc.red(`No devices found with ${bundleIdDisplay} installed.`));
|
|
531
627
|
console.error("Start a simulator/emulator and install the app first.");
|
|
532
628
|
process.exit(1);
|
|
533
629
|
}
|
|
534
630
|
console.log(
|
|
535
631
|
pc.bold(`
|
|
536
|
-
Detected ${devices.length} device(s) with ${
|
|
632
|
+
Detected ${devices.length} device(s) with ${bundleIdDisplay}:`)
|
|
537
633
|
);
|
|
538
634
|
for (const device of devices) {
|
|
539
635
|
const sizeDir = join4(outputDir, device.platform, device.screenSize);
|
|
@@ -659,16 +755,33 @@ Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
|
659
755
|
Captured ${captured.length} screenshot(s) as '${screenshotName}'.`
|
|
660
756
|
)
|
|
661
757
|
);
|
|
662
|
-
if (shouldFrame
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
758
|
+
if (shouldFrame) {
|
|
759
|
+
if (iosDevices.length > 0) {
|
|
760
|
+
console.log("");
|
|
761
|
+
const s = ora("Framing iOS screenshots...").start();
|
|
762
|
+
try {
|
|
763
|
+
const framed = await frameAllIosScreenshots(outputDir);
|
|
764
|
+
s.succeed(`Framed ${framed} iOS screenshot(s)`);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
s.fail(
|
|
767
|
+
`iOS framing failed: ${error instanceof Error ? error.message : error}`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (androidDevices.length > 0) {
|
|
772
|
+
const s = ora("Framing Android screenshots...").start();
|
|
773
|
+
try {
|
|
774
|
+
const framed = await frameAllAndroidScreenshots(outputDir);
|
|
775
|
+
if (framed > 0) {
|
|
776
|
+
s.succeed(`Framed ${framed} Android screenshot(s)`);
|
|
777
|
+
} else {
|
|
778
|
+
s.info("Android framing skipped (ImageMagick not found)");
|
|
779
|
+
}
|
|
780
|
+
} catch (error) {
|
|
781
|
+
s.fail(
|
|
782
|
+
`Android framing failed: ${error instanceof Error ? error.message : error}`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
672
785
|
}
|
|
673
786
|
}
|
|
674
787
|
}
|
|
@@ -711,23 +824,32 @@ import pc2 from "picocolors";
|
|
|
711
824
|
async function frameCommand(dir, options) {
|
|
712
825
|
const config = await loadConfig();
|
|
713
826
|
const screenshotsDir = dir || options.input || config.output;
|
|
714
|
-
console.log(pc2.bold(`Framing
|
|
715
|
-
const
|
|
827
|
+
console.log(pc2.bold(`Framing screenshots in ${screenshotsDir}...`));
|
|
828
|
+
const iosSpinner = ora2("Framing iOS screenshots (device bezels)...").start();
|
|
716
829
|
try {
|
|
717
|
-
const framed = await frameAllIosScreenshots(
|
|
718
|
-
|
|
719
|
-
|
|
830
|
+
const framed = await frameAllIosScreenshots(screenshotsDir, options.force);
|
|
831
|
+
if (framed > 0) {
|
|
832
|
+
iosSpinner.succeed(`Framed ${framed} iOS screenshot(s)`);
|
|
833
|
+
} else {
|
|
834
|
+
iosSpinner.info("No new iOS screenshots to frame");
|
|
835
|
+
}
|
|
836
|
+
} catch (error) {
|
|
837
|
+
iosSpinner.fail(
|
|
838
|
+
`iOS framing failed: ${error instanceof Error ? error.message : error}`
|
|
720
839
|
);
|
|
840
|
+
}
|
|
841
|
+
const androidSpinner = ora2("Framing Android screenshots (black border)...").start();
|
|
842
|
+
try {
|
|
843
|
+
const framed = await frameAllAndroidScreenshots(screenshotsDir, options.force);
|
|
721
844
|
if (framed > 0) {
|
|
722
|
-
|
|
845
|
+
androidSpinner.succeed(`Framed ${framed} Android screenshot(s)`);
|
|
723
846
|
} else {
|
|
724
|
-
|
|
847
|
+
androidSpinner.info("No new Android screenshots to frame");
|
|
725
848
|
}
|
|
726
849
|
} catch (error) {
|
|
727
|
-
|
|
728
|
-
`
|
|
850
|
+
androidSpinner.fail(
|
|
851
|
+
`Android framing failed: ${error instanceof Error ? error.message : error}`
|
|
729
852
|
);
|
|
730
|
-
process.exit(1);
|
|
731
853
|
}
|
|
732
854
|
}
|
|
733
855
|
|
|
@@ -735,11 +857,11 @@ async function frameCommand(dir, options) {
|
|
|
735
857
|
var program = new Command();
|
|
736
858
|
program.name("device-shots").description(
|
|
737
859
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
738
|
-
).version("0.
|
|
860
|
+
).version("0.4.0");
|
|
739
861
|
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) => {
|
|
740
862
|
await captureCommand({ name, ...opts });
|
|
741
863
|
});
|
|
742
|
-
program.command("frame").description("Frame existing
|
|
864
|
+
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) => {
|
|
743
865
|
await frameCommand(dir, opts);
|
|
744
866
|
});
|
|
745
867
|
program.command("init").description("Create a .device-shotsrc.json config file").action(async () => {
|
|
@@ -748,16 +870,24 @@ program.command("init").description("Create a .device-shotsrc.json config file")
|
|
|
748
870
|
console.log(pc3.yellow(`${configPath} already exists.`));
|
|
749
871
|
return;
|
|
750
872
|
}
|
|
751
|
-
const response = await prompts2(
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
873
|
+
const response = await prompts2([
|
|
874
|
+
{
|
|
875
|
+
type: "text",
|
|
876
|
+
name: "bundleId",
|
|
877
|
+
message: "Production bundle ID (e.g. com.example.myapp)"
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
type: "text",
|
|
881
|
+
name: "devBundleId",
|
|
882
|
+
message: "Dev bundle ID (leave empty to skip)"
|
|
883
|
+
}
|
|
884
|
+
]);
|
|
756
885
|
if (!response.bundleId) {
|
|
757
886
|
console.log("Aborting.");
|
|
758
887
|
return;
|
|
759
888
|
}
|
|
760
|
-
const
|
|
889
|
+
const bundleId = response.devBundleId ? [response.bundleId, response.devBundleId] : response.bundleId;
|
|
890
|
+
const config = createDefaultConfig(bundleId);
|
|
761
891
|
writeFileSync2(configPath, config + "\n");
|
|
762
892
|
console.log(pc3.green(`Created ${configPath}`));
|
|
763
893
|
});
|