device-shots 0.4.2 → 0.5.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 +7 -9
- package/dist/index.js +63 -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
|
|
@@ -210,15 +209,14 @@ Uses [device-frames-core](https://pypi.org/project/device-frames-core/) to wrap
|
|
|
210
209
|
|
|
211
210
|
### Android — Black frame with rounded corners
|
|
212
211
|
|
|
213
|
-
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.
|
|
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
216
|
| Padding | ~2.5% of image width (equal on all sides) |
|
|
218
|
-
|
|
|
219
|
-
| Outer corner radius | inner radius + padding |
|
|
217
|
+
| Border radius | ~4% of image width (same for screenshot and frame) |
|
|
220
218
|
|
|
221
|
-
For a 1080px wide screenshot, this produces ~27px padding with ~43px
|
|
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
|
|
|
@@ -142,7 +142,7 @@ function getAndroidScreenSize(width, height) {
|
|
|
142
142
|
const longer = Math.max(width, height);
|
|
143
143
|
const ratio = longer / shorter;
|
|
144
144
|
if (ratio >= 1.7) return "phone";
|
|
145
|
-
if (shorter >=
|
|
145
|
+
if (shorter >= 1500) return "tablet-10";
|
|
146
146
|
if (shorter >= 1200) return "tablet-7";
|
|
147
147
|
return "phone";
|
|
148
148
|
}
|
|
@@ -374,49 +374,6 @@ async function captureAndroidScreenshot(serial, outputPath) {
|
|
|
374
374
|
return false;
|
|
375
375
|
}
|
|
376
376
|
}
|
|
377
|
-
async function makeStatusBarTransparent(serial, imagePath) {
|
|
378
|
-
if (!await commandExists("magick")) return false;
|
|
379
|
-
const adb = getAdbPath();
|
|
380
|
-
const STATUS_BAR_HEIGHT_DP = 24;
|
|
381
|
-
const { stdout: densityOutput } = await run(adb, [
|
|
382
|
-
"-s",
|
|
383
|
-
serial,
|
|
384
|
-
"shell",
|
|
385
|
-
"wm",
|
|
386
|
-
"density"
|
|
387
|
-
]);
|
|
388
|
-
const densityMatch = densityOutput.match(/(\d+)\s*$/m);
|
|
389
|
-
if (!densityMatch) return false;
|
|
390
|
-
const density = parseInt(densityMatch[1], 10);
|
|
391
|
-
const statusBarPx = Math.ceil(STATUS_BAR_HEIGHT_DP * density / 160);
|
|
392
|
-
const { stdout: identify } = await run("magick", [
|
|
393
|
-
"identify",
|
|
394
|
-
"-format",
|
|
395
|
-
"%w",
|
|
396
|
-
imagePath
|
|
397
|
-
]);
|
|
398
|
-
const imgWidth = identify.trim();
|
|
399
|
-
if (!imgWidth) return false;
|
|
400
|
-
try {
|
|
401
|
-
await runOrFail("magick", [
|
|
402
|
-
imagePath,
|
|
403
|
-
"-region",
|
|
404
|
-
`${imgWidth}x${statusBarPx}+0+0`,
|
|
405
|
-
"-alpha",
|
|
406
|
-
"set",
|
|
407
|
-
"-channel",
|
|
408
|
-
"A",
|
|
409
|
-
"-evaluate",
|
|
410
|
-
"set",
|
|
411
|
-
"0",
|
|
412
|
-
"+channel",
|
|
413
|
-
imagePath
|
|
414
|
-
]);
|
|
415
|
-
return true;
|
|
416
|
-
} catch {
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
377
|
|
|
421
378
|
// src/devices/discover.ts
|
|
422
379
|
async function discoverDevices(bundleId, platform = "both") {
|
|
@@ -512,6 +469,9 @@ async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
|
512
469
|
if (!await commandExists("magick")) {
|
|
513
470
|
throw new Error("ImageMagick is required for Android framing. Install with: brew install imagemagick");
|
|
514
471
|
}
|
|
472
|
+
const { unlinkSync: unlinkSync2 } = await import("fs");
|
|
473
|
+
const { join: join5 } = await import("path");
|
|
474
|
+
const { tmpdir: tmpdir2 } = await import("os");
|
|
515
475
|
const { stdout: identify } = await run("magick", [
|
|
516
476
|
"identify",
|
|
517
477
|
"-format",
|
|
@@ -523,37 +483,42 @@ async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
|
523
483
|
const width = parseInt(match[1], 10);
|
|
524
484
|
const height = parseInt(match[2], 10);
|
|
525
485
|
const padding = Math.round(width * 0.025);
|
|
526
|
-
const
|
|
527
|
-
const outerRadius = innerRadius + padding;
|
|
486
|
+
const radius = Math.round(width * 0.04);
|
|
528
487
|
const totalW = width + padding * 2;
|
|
529
488
|
const totalH = height + padding * 2;
|
|
489
|
+
const uid = Date.now();
|
|
490
|
+
const tmpMask = join5(tmpdir2(), `ds-mask-${uid}.png`);
|
|
491
|
+
const tmpRounded = join5(tmpdir2(), `ds-rounded-${uid}.png`);
|
|
530
492
|
try {
|
|
531
493
|
await runOrFail("magick", [
|
|
532
|
-
// 1. Create black rounded rectangle background (outer frame)
|
|
533
494
|
"-size",
|
|
534
|
-
`${
|
|
535
|
-
"xc:
|
|
495
|
+
`${width}x${height}`,
|
|
496
|
+
"xc:black",
|
|
497
|
+
"-fill",
|
|
498
|
+
"white",
|
|
536
499
|
"-draw",
|
|
537
|
-
`
|
|
538
|
-
|
|
539
|
-
|
|
500
|
+
`roundrectangle 0,0 ${width - 1},${height - 1} ${radius},${radius}`,
|
|
501
|
+
tmpMask
|
|
502
|
+
]);
|
|
503
|
+
await runOrFail("magick", [
|
|
540
504
|
inputPath,
|
|
505
|
+
tmpMask,
|
|
541
506
|
"-alpha",
|
|
542
|
-
"
|
|
543
|
-
"(",
|
|
544
|
-
"+clone",
|
|
545
|
-
"-alpha",
|
|
546
|
-
"extract",
|
|
547
|
-
"-draw",
|
|
548
|
-
`fill black color 0,0 reset`,
|
|
549
|
-
"-draw",
|
|
550
|
-
`fill white roundrectangle 0,0 ${width - 1},${height - 1} ${innerRadius},${innerRadius}`,
|
|
551
|
-
")",
|
|
507
|
+
"off",
|
|
552
508
|
"-compose",
|
|
553
|
-
"
|
|
509
|
+
"CopyOpacity",
|
|
554
510
|
"-composite",
|
|
555
|
-
|
|
556
|
-
|
|
511
|
+
tmpRounded
|
|
512
|
+
]);
|
|
513
|
+
await runOrFail("magick", [
|
|
514
|
+
"-size",
|
|
515
|
+
`${totalW}x${totalH}`,
|
|
516
|
+
"xc:none",
|
|
517
|
+
"-fill",
|
|
518
|
+
"black",
|
|
519
|
+
"-draw",
|
|
520
|
+
`roundrectangle 0,0 ${totalW - 1},${totalH - 1} ${radius},${radius}`,
|
|
521
|
+
tmpRounded,
|
|
557
522
|
"-gravity",
|
|
558
523
|
"center",
|
|
559
524
|
"-compose",
|
|
@@ -564,6 +529,15 @@ async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
|
564
529
|
return true;
|
|
565
530
|
} catch {
|
|
566
531
|
return false;
|
|
532
|
+
} finally {
|
|
533
|
+
try {
|
|
534
|
+
unlinkSync2(tmpMask);
|
|
535
|
+
} catch {
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
unlinkSync2(tmpRounded);
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
567
541
|
}
|
|
568
542
|
}
|
|
569
543
|
async function frameAllIosScreenshots(screenshotsDir, force = false) {
|
|
@@ -719,15 +693,6 @@ Detected ${devices.length} device(s) with ${bundleIdDisplay}:`)
|
|
|
719
693
|
success = await captureIosScreenshot(device.captureId, tmpPath);
|
|
720
694
|
} else {
|
|
721
695
|
success = await captureAndroidScreenshot(device.captureId, tmpPath);
|
|
722
|
-
if (success) {
|
|
723
|
-
const transparent = await makeStatusBarTransparent(
|
|
724
|
-
device.captureId,
|
|
725
|
-
tmpPath
|
|
726
|
-
);
|
|
727
|
-
if (transparent) {
|
|
728
|
-
s.text = `${icon}: Captured from ${device.displayName} (status bar transparent)`;
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
696
|
}
|
|
732
697
|
if (success) {
|
|
733
698
|
captured.push({
|
|
@@ -877,7 +842,7 @@ async function frameCommand(dir, options) {
|
|
|
877
842
|
var program = new Command();
|
|
878
843
|
program.name("device-shots").description(
|
|
879
844
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
880
|
-
).version("0.
|
|
845
|
+
).version("0.5.1");
|
|
881
846
|
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) => {
|
|
882
847
|
await captureCommand({ name, ...opts });
|
|
883
848
|
});
|
|
@@ -911,4 +876,26 @@ program.command("init").description("Create a .device-shotsrc.json config file")
|
|
|
911
876
|
writeFileSync2(configPath, config + "\n");
|
|
912
877
|
console.log(pc3.green(`Created ${configPath}`));
|
|
913
878
|
});
|
|
879
|
+
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) => {
|
|
880
|
+
const config = await loadConfig();
|
|
881
|
+
const outputDir = opts.output || config.output;
|
|
882
|
+
if (!existsSync5(outputDir)) {
|
|
883
|
+
console.log(pc3.dim(`Nothing to clean \u2014 ${outputDir} does not exist.`));
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (!opts.yes) {
|
|
887
|
+
const response = await prompts2({
|
|
888
|
+
type: "confirm",
|
|
889
|
+
name: "confirm",
|
|
890
|
+
message: `Delete everything in ${outputDir}?`,
|
|
891
|
+
initial: false
|
|
892
|
+
});
|
|
893
|
+
if (!response.confirm) {
|
|
894
|
+
console.log("Aborting.");
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
rmSync(outputDir, { recursive: true, force: true });
|
|
899
|
+
console.log(pc3.green(`Cleaned ${outputDir}`));
|
|
900
|
+
});
|
|
914
901
|
program.parse();
|