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 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 shorter = Math.min(width, height);
144
- const longer = Math.max(width, height);
145
- const ratio = longer / shorter;
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 (shorter >= 1500) return "tablet-10";
148
- if (shorter >= 1200) return "tablet-7";
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
- if (output) {
434
- process.stdout.write(output + "\n");
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
- const framedFiles = existsSync3(outputDir) ? readdirSync(outputDir).filter((f) => f.endsWith(".png")).length : 0;
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 (!force && existsSync3(outputPath)) continue;
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.6.1");
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.6.1",
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
- res_map = build_resolution_map()
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
- sys.exit(1)
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
- framed_name = f"{f.stem}_framed.png"
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__":