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.
Files changed (3) hide show
  1. package/README.md +53 -8
  2. package/dist/index.js +168 -38
  3. 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 optionally frames iOS screenshots with device bezels. Built for developers who need store-ready screenshots without manual work.
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. **Organizes by screen size** — Saves screenshots into store-aligned size buckets (e.g. `6.9`, `6.7`, `phone`) instead of device names
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 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
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 screenshots in ./.screenshots
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
- └── settings.png
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
- if (platform === "ios" || platform === "both") {
421
- const iosDevices = await discoverIosDevices(bundleId);
422
- results.push(...iosDevices);
423
- }
424
- if (platform === "android" || platform === "both") {
425
- const androidDevices = await discoverAndroidDevices(bundleId);
426
- results.push(...androidDevices);
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 ${bundleId} installed.`));
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 ${bundleId}:`)
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 && iosDevices.length > 0) {
663
- console.log("");
664
- const s = ora("Framing iOS screenshots...").start();
665
- try {
666
- const framed = await frameAllIosScreenshots(outputDir);
667
- s.succeed(`Framed ${framed} screenshot(s)`);
668
- } catch (error) {
669
- s.fail(
670
- `Framing failed: ${error instanceof Error ? error.message : error}`
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 iOS screenshots in ${screenshotsDir}...`));
715
- const spinner = ora2("Setting up Python environment...").start();
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
- screenshotsDir,
719
- options.force
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
- spinner.succeed(`Framed ${framed} screenshot(s)`);
845
+ androidSpinner.succeed(`Framed ${framed} Android screenshot(s)`);
723
846
  } else {
724
- spinner.info("No new screenshots to frame");
847
+ androidSpinner.info("No new Android screenshots to frame");
725
848
  }
726
849
  } catch (error) {
727
- spinner.fail(
728
- `Framing failed: ${error instanceof Error ? error.message : error}`
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.2.1");
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 iOS screenshots with device bezels").argument("[dir]", "Screenshots directory").option("-i, --input <dir>", "Screenshots directory").option("-f, --force", "Re-frame existing screenshots").action(async (dir, opts) => {
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
- type: "text",
753
- name: "bundleId",
754
- message: "App bundle ID (e.g. com.example.myapp)"
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 config = createDefaultConfig(response.bundleId);
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "device-shots",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Capture and frame mobile app screenshots from iOS simulators and Android emulators",
5
5
  "type": "module",
6
6
  "bin": {