device-shots 0.6.1 → 0.7.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/dist/index.js +60 -13
- package/package.json +3 -2
- package/vendor/frame.py +38 -8
package/dist/index.js
CHANGED
|
@@ -139,13 +139,49 @@ function getIosScreenSize(width, height) {
|
|
|
139
139
|
const h = Math.max(width, height);
|
|
140
140
|
return IOS_RESOLUTION_MAP[`${w}x${h}`] ?? `${w}x${h}`;
|
|
141
141
|
}
|
|
142
|
+
var ANDROID_RESOLUTION_MAP = {
|
|
143
|
+
// Phones — Pixel series
|
|
144
|
+
"1080x2400": "phone",
|
|
145
|
+
// Pixel 9, 8, 7, 6
|
|
146
|
+
"1344x2992": "phone",
|
|
147
|
+
// Pixel 8 Pro, Pixel 9 Pro XL
|
|
148
|
+
"1280x2856": "phone",
|
|
149
|
+
// Pixel 9 Pro
|
|
150
|
+
"1440x3120": "phone",
|
|
151
|
+
// Pixel 7 Pro, 6 Pro, Samsung Galaxy S24 Ultra
|
|
152
|
+
"1080x2340": "phone",
|
|
153
|
+
// Pixel 5, Samsung Galaxy S24, S23
|
|
154
|
+
"1080x2310": "phone",
|
|
155
|
+
// Pixel 4a, 5a
|
|
156
|
+
"1080x2280": "phone",
|
|
157
|
+
// Pixel 4
|
|
158
|
+
"1080x1920": "phone",
|
|
159
|
+
// Pixel (1st gen), Nexus 5X, many older phones
|
|
160
|
+
// Phones — Samsung Galaxy series
|
|
161
|
+
"1440x3200": "phone",
|
|
162
|
+
// Samsung Galaxy S22 Ultra, S21 Ultra
|
|
163
|
+
"1440x2960": "phone",
|
|
164
|
+
// Samsung Galaxy S9, S8
|
|
165
|
+
// 7" tablets
|
|
166
|
+
"1200x1920": "tablet-7",
|
|
167
|
+
// Nexus 7 (2013), Android Studio "Medium Tablet" AVD
|
|
168
|
+
"800x1280": "tablet-7",
|
|
169
|
+
// Nexus 7 (2012)
|
|
170
|
+
// 10" tablets
|
|
171
|
+
"1600x2560": "tablet-10",
|
|
172
|
+
// Pixel Tablet, Samsung Galaxy Tab S series
|
|
173
|
+
"1200x2000": "tablet-10"
|
|
174
|
+
// Samsung Galaxy Tab A8
|
|
175
|
+
};
|
|
142
176
|
function getAndroidScreenSize(width, height) {
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
const
|
|
177
|
+
const w = Math.min(width, height);
|
|
178
|
+
const h = Math.max(width, height);
|
|
179
|
+
const mapped = ANDROID_RESOLUTION_MAP[`${w}x${h}`];
|
|
180
|
+
if (mapped) return mapped;
|
|
181
|
+
const ratio = h / w;
|
|
146
182
|
if (ratio >= 1.7) return "phone";
|
|
147
|
-
if (
|
|
148
|
-
if (
|
|
183
|
+
if (w >= 1500) return "tablet-10";
|
|
184
|
+
if (w >= 1200) return "tablet-7";
|
|
149
185
|
return "phone";
|
|
150
186
|
}
|
|
151
187
|
|
|
@@ -376,7 +412,7 @@ async function discoverDevices(bundleId, platform = "both") {
|
|
|
376
412
|
}
|
|
377
413
|
|
|
378
414
|
// src/framing/frame.ts
|
|
379
|
-
import { existsSync as existsSync3, readdirSync } from "fs";
|
|
415
|
+
import { existsSync as existsSync3, readdirSync, statSync } from "fs";
|
|
380
416
|
import { join as join3, dirname } from "path";
|
|
381
417
|
import { fileURLToPath } from "url";
|
|
382
418
|
|
|
@@ -408,6 +444,11 @@ async function ensureVenv() {
|
|
|
408
444
|
}
|
|
409
445
|
|
|
410
446
|
// src/framing/frame.ts
|
|
447
|
+
var RESULT_PREFIX = "__DEVICE_SHOTS_RESULT__";
|
|
448
|
+
function needsFraming(inputPath, outputPath, force) {
|
|
449
|
+
if (force || !existsSync3(outputPath)) return true;
|
|
450
|
+
return statSync(inputPath).mtimeMs > statSync(outputPath).mtimeMs;
|
|
451
|
+
}
|
|
411
452
|
function getFramePyPath() {
|
|
412
453
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
413
454
|
const candidates = [
|
|
@@ -430,12 +471,18 @@ async function frameScreenshots(inputDir, outputDir, force = false) {
|
|
|
430
471
|
args.push("--force");
|
|
431
472
|
}
|
|
432
473
|
const output = await runOrFail(python, args);
|
|
433
|
-
|
|
434
|
-
|
|
474
|
+
const lines = (output ?? "").split("\n");
|
|
475
|
+
const human = lines.filter((l) => !l.startsWith(RESULT_PREFIX)).join("\n").trimEnd();
|
|
476
|
+
if (human) {
|
|
477
|
+
process.stdout.write(human + "\n");
|
|
478
|
+
}
|
|
479
|
+
const resultLine = lines.find((l) => l.startsWith(RESULT_PREFIX));
|
|
480
|
+
if (!resultLine) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
"frame.py finished without reporting a result. The vendored frame.py may be out of date."
|
|
483
|
+
);
|
|
435
484
|
}
|
|
436
|
-
|
|
437
|
-
const rawFiles = existsSync3(inputDir) ? readdirSync(inputDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
438
|
-
return { framed: framedFiles, skipped: rawFiles - framedFiles };
|
|
485
|
+
return JSON.parse(resultLine.slice(RESULT_PREFIX.length));
|
|
439
486
|
}
|
|
440
487
|
async function frameAndroidScreenshot(inputPath, outputPath) {
|
|
441
488
|
if (!await commandExists("magick")) {
|
|
@@ -547,7 +594,7 @@ async function frameAllAndroidScreenshots(screenshotsDir, force = false) {
|
|
|
547
594
|
for (const file of rawFiles) {
|
|
548
595
|
const inputPath = join3(dirPath, file);
|
|
549
596
|
const outputPath = join3(dirPath, file.replace(".png", "_framed.png"));
|
|
550
|
-
if (!
|
|
597
|
+
if (!needsFraming(inputPath, outputPath, force)) continue;
|
|
551
598
|
const success = await frameAndroidScreenshot(inputPath, outputPath);
|
|
552
599
|
if (success) totalFramed++;
|
|
553
600
|
}
|
|
@@ -805,7 +852,7 @@ async function frameCommand(dir, options) {
|
|
|
805
852
|
var program = new Command();
|
|
806
853
|
program.name("device-shots").description(
|
|
807
854
|
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
808
|
-
).version("0.
|
|
855
|
+
).version("0.7.0");
|
|
809
856
|
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) => {
|
|
810
857
|
await captureCommand({ name, ...opts });
|
|
811
858
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "device-shots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Capture and frame mobile app screenshots from iOS simulators and Android emulators",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsup",
|
|
16
16
|
"dev": "tsup --watch",
|
|
17
|
-
"typecheck": "tsc --noEmit"
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"prepublishOnly": "tsup"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [
|
|
20
21
|
"screenshot",
|
package/vendor/frame.py
CHANGED
|
@@ -10,6 +10,7 @@ Requirements:
|
|
|
10
10
|
pip install device-frames-core Pillow
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import json
|
|
13
14
|
import sys
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
@@ -18,6 +19,9 @@ from device_frames_core import apply_frame, list_devices
|
|
|
18
19
|
VARIATION_PREFERENCE = ["space-black", "black", "space-grey", "silver"]
|
|
19
20
|
IOS_CATEGORIES = {"apple-iphone", "apple-ipad"}
|
|
20
21
|
|
|
22
|
+
# Machine-readable summary line, parsed by src/framing/frame.ts.
|
|
23
|
+
RESULT_PREFIX = "__DEVICE_SHOTS_RESULT__"
|
|
24
|
+
|
|
21
25
|
|
|
22
26
|
def build_resolution_map():
|
|
23
27
|
"""Map (width, height) -> best matching iOS device info."""
|
|
@@ -46,6 +50,28 @@ def get_image_size(path):
|
|
|
46
50
|
return img.size
|
|
47
51
|
|
|
48
52
|
|
|
53
|
+
def is_framed_output(path):
|
|
54
|
+
"""True if path is one of our own framed outputs rather than a raw capture."""
|
|
55
|
+
return path.stem.endswith("_framed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def needs_framing(raw_path, framed_path, force):
|
|
59
|
+
"""True if raw_path has no current framed output.
|
|
60
|
+
|
|
61
|
+
Existence alone is not enough: `capture` overwrites a raw screenshot in
|
|
62
|
+
place, leaving a stale framed output next to it.
|
|
63
|
+
"""
|
|
64
|
+
if force or not framed_path.exists():
|
|
65
|
+
return True
|
|
66
|
+
return raw_path.stat().st_mtime > framed_path.stat().st_mtime
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def emit_result(framed, skipped, failed):
|
|
70
|
+
"""Report counts to the Node caller, which owns user-facing totals."""
|
|
71
|
+
payload = {"framed": framed, "skipped": skipped, "failed": failed}
|
|
72
|
+
print(f"{RESULT_PREFIX} {json.dumps(payload)}")
|
|
73
|
+
|
|
74
|
+
|
|
49
75
|
def frame_screenshot(input_path, output_path, res_map):
|
|
50
76
|
"""Frame a single screenshot, auto-detecting the device."""
|
|
51
77
|
width, height = get_image_size(input_path)
|
|
@@ -82,25 +108,28 @@ def main():
|
|
|
82
108
|
framed_dir = Path(args[1])
|
|
83
109
|
framed_dir.mkdir(parents=True, exist_ok=True)
|
|
84
110
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
screenshots = sorted(raw_dir.glob("*.png"))
|
|
111
|
+
# raw_dir and framed_dir are usually the same directory, so our own
|
|
112
|
+
# framed outputs would otherwise be picked up as inputs to re-frame.
|
|
113
|
+
screenshots = sorted(f for f in raw_dir.glob("*.png") if not is_framed_output(f))
|
|
88
114
|
if not screenshots:
|
|
89
115
|
print("No screenshots found.")
|
|
90
|
-
|
|
116
|
+
emit_result(0, 0, 0)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
res_map = build_resolution_map()
|
|
91
120
|
|
|
92
121
|
skipped = 0
|
|
93
122
|
to_frame = []
|
|
94
123
|
|
|
95
124
|
for f in screenshots:
|
|
96
|
-
|
|
97
|
-
if not force and (framed_dir / framed_name).exists():
|
|
98
|
-
skipped += 1
|
|
99
|
-
else:
|
|
125
|
+
if needs_framing(f, framed_dir / f"{f.stem}_framed.png", force):
|
|
100
126
|
to_frame.append(f)
|
|
127
|
+
else:
|
|
128
|
+
skipped += 1
|
|
101
129
|
|
|
102
130
|
if not to_frame:
|
|
103
131
|
print(f"All {skipped} screenshots already framed. Nothing to do.")
|
|
132
|
+
emit_result(0, skipped, 0)
|
|
104
133
|
return
|
|
105
134
|
|
|
106
135
|
if skipped:
|
|
@@ -114,6 +143,7 @@ def main():
|
|
|
114
143
|
success += 1
|
|
115
144
|
|
|
116
145
|
print(f"Done! Framed {success}/{len(to_frame)} screenshots.")
|
|
146
|
+
emit_result(success, skipped, len(to_frame) - success)
|
|
117
147
|
|
|
118
148
|
|
|
119
149
|
if __name__ == "__main__":
|