device-shots 0.4.1 → 0.5.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 +9 -11
- package/dist/index.js +54 -76
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,10 +7,9 @@ A CLI tool that captures screenshots from running iOS simulators and Android emu
|
|
|
7
7
|
1. **Discovers devices** — Finds all running iOS simulators and Android emulators that have your app installed
|
|
8
8
|
2. **Cleans up status bars** — Sets a uniform status bar (9:41, full battery, full signal) on all devices before capturing
|
|
9
9
|
3. **Captures screenshots** — Takes screenshots from every discovered device in one go
|
|
10
|
-
4. **
|
|
11
|
-
5. **Frames
|
|
12
|
-
6. **
|
|
13
|
-
7. **Organizes by screen size** — Saves screenshots into store-aligned size buckets (e.g. `6.9`, `6.3`, `phone`) instead of device names
|
|
10
|
+
4. **Frames iOS screenshots** — Wraps iOS screenshots in device bezels (iPhone/iPad frames) using [device-frames-core](https://pypi.org/project/device-frames-core/)
|
|
11
|
+
5. **Frames Android screenshots** — Adds uniform black padding with matching rounded corners on both the screenshot and frame (requires ImageMagick)
|
|
12
|
+
6. **Organizes by screen size** — Saves screenshots into store-aligned size buckets (e.g. `6.9`, `6.3`, `phone`) instead of device names
|
|
14
13
|
|
|
15
14
|
## Use cases
|
|
16
15
|
|
|
@@ -29,7 +28,7 @@ A CLI tool that captures screenshots from running iOS simulators and Android emu
|
|
|
29
28
|
|
|
30
29
|
### Optional
|
|
31
30
|
|
|
32
|
-
- **ImageMagick** — needed for Android
|
|
31
|
+
- **ImageMagick** — needed for Android screenshot framing (black border + rounded corners). Install with `brew install imagemagick`
|
|
33
32
|
- **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
|
|
34
33
|
|
|
35
34
|
## Install
|
|
@@ -208,17 +207,16 @@ Both platforms get framed automatically after capture (disable with `--no-frame`
|
|
|
208
207
|
|
|
209
208
|
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
209
|
|
|
211
|
-
### Android — Black
|
|
210
|
+
### Android — Black frame with rounded corners
|
|
212
211
|
|
|
213
|
-
Uses ImageMagick to
|
|
212
|
+
Uses ImageMagick to clip the screenshot to rounded corners (overflow hidden) and place it on a black rounded rectangle with uniform padding on all sides. The screenshot and frame share the same border radius. Dimensions scale proportionally:
|
|
214
213
|
|
|
215
214
|
| Property | Value |
|
|
216
215
|
|----------|-------|
|
|
217
|
-
|
|
|
218
|
-
|
|
|
219
|
-
| Outer corner radius | inner radius + border width |
|
|
216
|
+
| Padding | ~2.5% of image width (equal on all sides) |
|
|
217
|
+
| Border radius | ~4% of image width (same for screenshot and frame) |
|
|
220
218
|
|
|
221
|
-
For a 1080px wide screenshot, this produces
|
|
219
|
+
For a 1080px wide screenshot, this produces ~27px uniform padding with ~43px rounded corners.
|
|
222
220
|
|
|
223
221
|
## License
|
|
224
222
|
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { writeFileSync as writeFileSync2, existsSync as existsSync5 } from "fs";
|
|
5
|
+
import { writeFileSync as writeFileSync2, existsSync as existsSync5, rmSync } from "fs";
|
|
6
6
|
import pc3 from "picocolors";
|
|
7
7
|
import prompts2 from "prompts";
|
|
8
8
|
|
|
@@ -139,6 +139,9 @@ function getIosScreenSize(width, height) {
|
|
|
139
139
|
}
|
|
140
140
|
function getAndroidScreenSize(width, height) {
|
|
141
141
|
const shorter = Math.min(width, height);
|
|
142
|
+
const longer = Math.max(width, height);
|
|
143
|
+
const ratio = longer / shorter;
|
|
144
|
+
if (ratio >= 1.7) return "phone";
|
|
142
145
|
if (shorter >= 1800) return "tablet-10";
|
|
143
146
|
if (shorter >= 1200) return "tablet-7";
|
|
144
147
|
return "phone";
|
|
@@ -371,49 +374,6 @@ async function captureAndroidScreenshot(serial, outputPath) {
|
|
|
371
374
|
return false;
|
|
372
375
|
}
|
|
373
376
|
}
|
|
374
|
-
async function makeStatusBarTransparent(serial, imagePath) {
|
|
375
|
-
if (!await commandExists("magick")) return false;
|
|
376
|
-
const adb = getAdbPath();
|
|
377
|
-
const STATUS_BAR_HEIGHT_DP = 24;
|
|
378
|
-
const { stdout: densityOutput } = await run(adb, [
|
|
379
|
-
"-s",
|
|
380
|
-
serial,
|
|
381
|
-
"shell",
|
|
382
|
-
"wm",
|
|
383
|
-
"density"
|
|
384
|
-
]);
|
|
385
|
-
const densityMatch = densityOutput.match(/(\d+)\s*$/m);
|
|
386
|
-
if (!densityMatch) return false;
|
|
387
|
-
const density = parseInt(densityMatch[1], 10);
|
|
388
|
-
const statusBarPx = Math.ceil(STATUS_BAR_HEIGHT_DP * density / 160);
|
|
389
|
-
const { stdout: identify } = await run("magick", [
|
|
390
|
-
"identify",
|
|
391
|
-
"-format",
|
|
392
|
-
"%w",
|
|
393
|
-
imagePath
|
|
394
|
-
]);
|
|
395
|
-
const imgWidth = identify.trim();
|
|
396
|
-
if (!imgWidth) return false;
|
|
397
|
-
try {
|
|
398
|
-
await runOrFail("magick", [
|
|
399
|
-
imagePath,
|
|
400
|
-
"-region",
|
|
401
|
-
`${imgWidth}x${statusBarPx}+0+0`,
|
|
402
|
-
"-alpha",
|
|
403
|
-
"set",
|
|
404
|
-
"-channel",
|
|
405
|
-
"A",
|
|
406
|
-
"-evaluate",
|
|
407
|
-
"set",
|
|
408
|
-
"0",
|
|
409
|
-
"+channel",
|
|
410
|
-
imagePath
|
|
411
|
-
]);
|
|
412
|
-
return true;
|
|
413
|
-
} catch {
|
|
414
|
-
return false;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
377
|
|
|
418
378
|
// src/devices/discover.ts
|
|
419
379
|
async function discoverDevices(bundleId, platform = "both") {
|
|
@@ -519,21 +479,13 @@ async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
|
519
479
|
if (!match) return false;
|
|
520
480
|
const width = parseInt(match[1], 10);
|
|
521
481
|
const height = parseInt(match[2], 10);
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
const
|
|
525
|
-
const
|
|
526
|
-
const totalH = height + borderWidth * 2;
|
|
482
|
+
const padding = Math.round(width * 0.025);
|
|
483
|
+
const radius = Math.round(width * 0.04);
|
|
484
|
+
const totalW = width + padding * 2;
|
|
485
|
+
const totalH = height + padding * 2;
|
|
527
486
|
try {
|
|
487
|
+
const tmpRounded = inputPath.replace(".png", "_rounded_tmp.png");
|
|
528
488
|
await runOrFail("magick", [
|
|
529
|
-
// Create black rounded rectangle background
|
|
530
|
-
"-size",
|
|
531
|
-
`${totalW}x${totalH}`,
|
|
532
|
-
"xc:none",
|
|
533
|
-
"-draw",
|
|
534
|
-
`fill black roundrectangle 0,0 ${totalW - 1},${totalH - 1} ${outerRadius},${outerRadius}`,
|
|
535
|
-
// Load screenshot and round its corners
|
|
536
|
-
"(",
|
|
537
489
|
inputPath,
|
|
538
490
|
"-alpha",
|
|
539
491
|
"set",
|
|
@@ -544,13 +496,20 @@ async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
|
544
496
|
"-draw",
|
|
545
497
|
`fill black color 0,0 reset`,
|
|
546
498
|
"-draw",
|
|
547
|
-
`fill white roundrectangle 0,0 ${width - 1},${height - 1} ${
|
|
499
|
+
`fill white roundrectangle 0,0 ${width - 1},${height - 1} ${radius},${radius}`,
|
|
548
500
|
")",
|
|
549
501
|
"-compose",
|
|
550
502
|
"DstIn",
|
|
551
503
|
"-composite",
|
|
552
|
-
|
|
553
|
-
|
|
504
|
+
tmpRounded
|
|
505
|
+
]);
|
|
506
|
+
await runOrFail("magick", [
|
|
507
|
+
"-size",
|
|
508
|
+
`${totalW}x${totalH}`,
|
|
509
|
+
"xc:none",
|
|
510
|
+
"-draw",
|
|
511
|
+
`fill black roundrectangle 0,0 ${totalW - 1},${totalH - 1} ${radius},${radius}`,
|
|
512
|
+
tmpRounded,
|
|
554
513
|
"-gravity",
|
|
555
514
|
"center",
|
|
556
515
|
"-compose",
|
|
@@ -558,6 +517,11 @@ async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
|
558
517
|
"-composite",
|
|
559
518
|
outputPath
|
|
560
519
|
]);
|
|
520
|
+
try {
|
|
521
|
+
const { unlinkSync: unlinkSync2 } = await import("fs");
|
|
522
|
+
unlinkSync2(tmpRounded);
|
|
523
|
+
} catch {
|
|
524
|
+
}
|
|
561
525
|
return true;
|
|
562
526
|
} catch {
|
|
563
527
|
return false;
|
|
@@ -716,15 +680,6 @@ Detected ${devices.length} device(s) with ${bundleIdDisplay}:`)
|
|
|
716
680
|
success = await captureIosScreenshot(device.captureId, tmpPath);
|
|
717
681
|
} else {
|
|
718
682
|
success = await captureAndroidScreenshot(device.captureId, tmpPath);
|
|
719
|
-
if (success) {
|
|
720
|
-
const transparent = await makeStatusBarTransparent(
|
|
721
|
-
device.captureId,
|
|
722
|
-
tmpPath
|
|
723
|
-
);
|
|
724
|
-
if (transparent) {
|
|
725
|
-
s.text = `${icon}: Captured from ${device.displayName} (status bar transparent)`;
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
683
|
}
|
|
729
684
|
if (success) {
|
|
730
685
|
captured.push({
|
|
@@ -765,12 +720,13 @@ Detected ${devices.length} device(s) with ${bundleIdDisplay}:`)
|
|
|
765
720
|
for (const device of androidDevices) {
|
|
766
721
|
await clearAndroidDemoMode(device.captureId);
|
|
767
722
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
)
|
|
773
|
-
|
|
723
|
+
const skippedDupes = captured.length - movedBuckets.size;
|
|
724
|
+
let msg = `
|
|
725
|
+
Captured ${movedBuckets.size} screenshot(s) as '${screenshotName}'.`;
|
|
726
|
+
if (skippedDupes > 0) {
|
|
727
|
+
msg += ` (${skippedDupes} duplicate bucket(s) skipped)`;
|
|
728
|
+
}
|
|
729
|
+
console.log(pc.green(msg));
|
|
774
730
|
if (shouldFrame) {
|
|
775
731
|
if (iosDevices.length > 0) {
|
|
776
732
|
console.log("");
|
|
@@ -873,7 +829,7 @@ async function frameCommand(dir, options) {
|
|
|
873
829
|
var program = new Command();
|
|
874
830
|
program.name("device-shots").description(
|
|
875
831
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
876
|
-
).version("0.
|
|
832
|
+
).version("0.5.0");
|
|
877
833
|
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) => {
|
|
878
834
|
await captureCommand({ name, ...opts });
|
|
879
835
|
});
|
|
@@ -907,4 +863,26 @@ program.command("init").description("Create a .device-shotsrc.json config file")
|
|
|
907
863
|
writeFileSync2(configPath, config + "\n");
|
|
908
864
|
console.log(pc3.green(`Created ${configPath}`));
|
|
909
865
|
});
|
|
866
|
+
program.command("clean").description("Delete all screenshots and metadata").option("-o, --output <dir>", "Screenshots directory to clean").option("-y, --yes", "Skip confirmation").action(async (opts) => {
|
|
867
|
+
const config = await loadConfig();
|
|
868
|
+
const outputDir = opts.output || config.output;
|
|
869
|
+
if (!existsSync5(outputDir)) {
|
|
870
|
+
console.log(pc3.dim(`Nothing to clean \u2014 ${outputDir} does not exist.`));
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
if (!opts.yes) {
|
|
874
|
+
const response = await prompts2({
|
|
875
|
+
type: "confirm",
|
|
876
|
+
name: "confirm",
|
|
877
|
+
message: `Delete everything in ${outputDir}?`,
|
|
878
|
+
initial: false
|
|
879
|
+
});
|
|
880
|
+
if (!response.confirm) {
|
|
881
|
+
console.log("Aborting.");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
886
|
+
console.log(pc3.green(`Cleaned ${outputDir}`));
|
|
887
|
+
});
|
|
910
888
|
program.parse();
|