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.
Files changed (3) hide show
  1. package/README.md +53 -8
  2. package/dist/index.js +60 -21
  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
@@ -11,7 +11,8 @@ import {
11
11
  existsSync as existsSync4,
12
12
  mkdirSync,
13
13
  readdirSync as readdirSync2,
14
- renameSync,
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
- 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);
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 ${bundleId} installed.`));
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 ${bundleId}:`)
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
- renameSync(file.tmpPath, join4(destDir, file.filename));
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.3.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 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) => {
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
- type: "text",
860
- name: "bundleId",
861
- message: "App bundle ID (e.g. com.example.myapp)"
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 config = createDefaultConfig(response.bundleId);
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "device-shots",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Capture and frame mobile app screenshots from iOS simulators and Android emulators",
5
5
  "type": "module",
6
6
  "bin": {