device-shots 0.1.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 +634 -0
- package/package.json +47 -0
- package/vendor/frame.py +120 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { writeFileSync, existsSync as existsSync5 } from "fs";
|
|
6
|
+
import pc3 from "picocolors";
|
|
7
|
+
import prompts2 from "prompts";
|
|
8
|
+
|
|
9
|
+
// src/commands/capture.ts
|
|
10
|
+
import { existsSync as existsSync4, mkdirSync, readdirSync as readdirSync2, renameSync } from "fs";
|
|
11
|
+
import { join as join4 } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { mkdtempSync } from "fs";
|
|
14
|
+
import ora from "ora";
|
|
15
|
+
import pc from "picocolors";
|
|
16
|
+
import prompts from "prompts";
|
|
17
|
+
|
|
18
|
+
// src/config.ts
|
|
19
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
20
|
+
var MODULE_NAME = "device-shots";
|
|
21
|
+
var DEFAULTS = {
|
|
22
|
+
bundleId: "",
|
|
23
|
+
output: "./screenshots",
|
|
24
|
+
platform: "both",
|
|
25
|
+
time: "9:41",
|
|
26
|
+
frame: true
|
|
27
|
+
};
|
|
28
|
+
async function loadConfig() {
|
|
29
|
+
const explorer = cosmiconfig(MODULE_NAME);
|
|
30
|
+
try {
|
|
31
|
+
const result = await explorer.search();
|
|
32
|
+
if (result && result.config) {
|
|
33
|
+
return { ...DEFAULTS, ...result.config };
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
return DEFAULTS;
|
|
38
|
+
}
|
|
39
|
+
function createDefaultConfig(bundleId) {
|
|
40
|
+
return JSON.stringify(
|
|
41
|
+
{
|
|
42
|
+
bundleId,
|
|
43
|
+
output: "./screenshots",
|
|
44
|
+
platform: "both",
|
|
45
|
+
time: "9:41",
|
|
46
|
+
frame: true
|
|
47
|
+
},
|
|
48
|
+
null,
|
|
49
|
+
2
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/exec.ts
|
|
54
|
+
import { execa } from "execa";
|
|
55
|
+
import { existsSync } from "fs";
|
|
56
|
+
import { join } from "path";
|
|
57
|
+
import { homedir } from "os";
|
|
58
|
+
var cachedAdbPath = null;
|
|
59
|
+
function getAdbPath() {
|
|
60
|
+
if (cachedAdbPath) return cachedAdbPath;
|
|
61
|
+
const androidHome = process.env.ANDROID_HOME || join(homedir(), "Library", "Android", "sdk");
|
|
62
|
+
const adbPath = join(androidHome, "platform-tools", "adb");
|
|
63
|
+
if (existsSync(adbPath)) {
|
|
64
|
+
cachedAdbPath = adbPath;
|
|
65
|
+
return adbPath;
|
|
66
|
+
}
|
|
67
|
+
cachedAdbPath = "adb";
|
|
68
|
+
return "adb";
|
|
69
|
+
}
|
|
70
|
+
async function commandExists(cmd) {
|
|
71
|
+
try {
|
|
72
|
+
await execa("which", [cmd]);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function run(cmd, args, options) {
|
|
79
|
+
const result = await execa(cmd, args, {
|
|
80
|
+
reject: false,
|
|
81
|
+
...options
|
|
82
|
+
});
|
|
83
|
+
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
84
|
+
}
|
|
85
|
+
async function runOrFail(cmd, args, options) {
|
|
86
|
+
const result = await execa(cmd, args, options);
|
|
87
|
+
return result.stdout ?? "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/devices/ios.ts
|
|
91
|
+
function sanitizeName(name) {
|
|
92
|
+
return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
|
|
93
|
+
}
|
|
94
|
+
async function discoverIosDevices(bundleId) {
|
|
95
|
+
if (!await commandExists("xcrun")) return [];
|
|
96
|
+
const { stdout } = await run("xcrun", [
|
|
97
|
+
"simctl",
|
|
98
|
+
"list",
|
|
99
|
+
"devices",
|
|
100
|
+
"booted",
|
|
101
|
+
"-j"
|
|
102
|
+
]);
|
|
103
|
+
if (!stdout) return [];
|
|
104
|
+
const data = JSON.parse(stdout);
|
|
105
|
+
const devices = [];
|
|
106
|
+
for (const [, deviceList] of Object.entries(
|
|
107
|
+
data.devices
|
|
108
|
+
)) {
|
|
109
|
+
for (const d of deviceList) {
|
|
110
|
+
if (d.state !== "Booted") continue;
|
|
111
|
+
const { stdout: container } = await run("xcrun", [
|
|
112
|
+
"simctl",
|
|
113
|
+
"get_app_container",
|
|
114
|
+
d.udid,
|
|
115
|
+
bundleId
|
|
116
|
+
]);
|
|
117
|
+
if (container) {
|
|
118
|
+
devices.push({
|
|
119
|
+
platform: "ios",
|
|
120
|
+
safeName: sanitizeName(d.name),
|
|
121
|
+
captureId: d.udid,
|
|
122
|
+
displayName: d.name
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return devices;
|
|
128
|
+
}
|
|
129
|
+
async function setIosStatusBar(time) {
|
|
130
|
+
if (!await commandExists("xcrun")) return;
|
|
131
|
+
await run("xcrun", [
|
|
132
|
+
"simctl",
|
|
133
|
+
"status_bar",
|
|
134
|
+
"booted",
|
|
135
|
+
"override",
|
|
136
|
+
"--time",
|
|
137
|
+
time,
|
|
138
|
+
"--batteryState",
|
|
139
|
+
"charged",
|
|
140
|
+
"--batteryLevel",
|
|
141
|
+
"100",
|
|
142
|
+
"--wifiBars",
|
|
143
|
+
"3",
|
|
144
|
+
"--cellularBars",
|
|
145
|
+
"4",
|
|
146
|
+
"--operatorName",
|
|
147
|
+
""
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
async function clearIosStatusBar() {
|
|
151
|
+
if (!await commandExists("xcrun")) return;
|
|
152
|
+
await run("xcrun", ["simctl", "status_bar", "booted", "clear"]);
|
|
153
|
+
}
|
|
154
|
+
async function captureIosScreenshot(udid, outputPath) {
|
|
155
|
+
try {
|
|
156
|
+
await runOrFail("xcrun", ["simctl", "io", udid, "screenshot", outputPath]);
|
|
157
|
+
return true;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/devices/android.ts
|
|
164
|
+
function sanitizeName2(name) {
|
|
165
|
+
return name.replace(/[ (),]/g, "_").replace(/[^A-Za-z0-9_-]/g, "");
|
|
166
|
+
}
|
|
167
|
+
async function discoverAndroidDevices(bundleId) {
|
|
168
|
+
const adb = getAdbPath();
|
|
169
|
+
const { stdout } = await run(adb, ["devices"]);
|
|
170
|
+
if (!stdout) return [];
|
|
171
|
+
const devices = [];
|
|
172
|
+
const lines = stdout.split("\n").slice(1);
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
const match = line.match(/^(\S+)\s+device$/);
|
|
175
|
+
if (!match) continue;
|
|
176
|
+
const serial = match[1];
|
|
177
|
+
const { stdout: pmPath } = await run(adb, [
|
|
178
|
+
"-s",
|
|
179
|
+
serial,
|
|
180
|
+
"shell",
|
|
181
|
+
"pm",
|
|
182
|
+
"path",
|
|
183
|
+
bundleId
|
|
184
|
+
]);
|
|
185
|
+
if (!pmPath) continue;
|
|
186
|
+
let avdName = "";
|
|
187
|
+
const { stdout: emuName } = await run(adb, [
|
|
188
|
+
"-s",
|
|
189
|
+
serial,
|
|
190
|
+
"emu",
|
|
191
|
+
"avd",
|
|
192
|
+
"name"
|
|
193
|
+
]);
|
|
194
|
+
avdName = emuName.split("\n")[0].trim();
|
|
195
|
+
if (!avdName) {
|
|
196
|
+
const { stdout: model } = await run(adb, [
|
|
197
|
+
"-s",
|
|
198
|
+
serial,
|
|
199
|
+
"shell",
|
|
200
|
+
"getprop",
|
|
201
|
+
"ro.product.model"
|
|
202
|
+
]);
|
|
203
|
+
avdName = model.trim();
|
|
204
|
+
}
|
|
205
|
+
const safeName = sanitizeName2(avdName) || serial;
|
|
206
|
+
devices.push({
|
|
207
|
+
platform: "android",
|
|
208
|
+
safeName,
|
|
209
|
+
captureId: serial,
|
|
210
|
+
displayName: avdName || serial
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return devices;
|
|
214
|
+
}
|
|
215
|
+
async function setAndroidDemoMode(serial, time) {
|
|
216
|
+
const adb = getAdbPath();
|
|
217
|
+
const hhmm = time.replace(":", "");
|
|
218
|
+
const commands = [
|
|
219
|
+
["settings", "put", "global", "sysui_demo_allowed", "1"],
|
|
220
|
+
["am", "broadcast", "-a", "com.android.systemui.demo", "-e", "command", "enter"],
|
|
221
|
+
["am", "broadcast", "-a", "com.android.systemui.demo", "-e", "command", "clock", "-e", "hhmm", hhmm],
|
|
222
|
+
["am", "broadcast", "-a", "com.android.systemui.demo", "-e", "command", "wifi", "-e", "fully", "true"],
|
|
223
|
+
["am", "broadcast", "-a", "com.android.systemui.demo", "-e", "command", "battery", "-e", "level", "100", "-e", "plugged", "false"],
|
|
224
|
+
["am", "broadcast", "-a", "com.android.systemui.demo", "-e", "command", "notifications", "-e", "visible", "false"]
|
|
225
|
+
];
|
|
226
|
+
for (const cmd of commands) {
|
|
227
|
+
await run(adb, ["-s", serial, "shell", ...cmd]);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function clearAndroidDemoMode(serial) {
|
|
231
|
+
const adb = getAdbPath();
|
|
232
|
+
await run(adb, [
|
|
233
|
+
"-s",
|
|
234
|
+
serial,
|
|
235
|
+
"shell",
|
|
236
|
+
"am",
|
|
237
|
+
"broadcast",
|
|
238
|
+
"-a",
|
|
239
|
+
"com.android.systemui.demo",
|
|
240
|
+
"-e",
|
|
241
|
+
"command",
|
|
242
|
+
"exit"
|
|
243
|
+
]);
|
|
244
|
+
}
|
|
245
|
+
async function captureAndroidScreenshot(serial, outputPath) {
|
|
246
|
+
const adb = getAdbPath();
|
|
247
|
+
const deviceTmp = "/sdcard/screenshot_tmp.png";
|
|
248
|
+
try {
|
|
249
|
+
await runOrFail(adb, ["-s", serial, "shell", "screencap", deviceTmp]);
|
|
250
|
+
await runOrFail(adb, ["-s", serial, "pull", deviceTmp, outputPath]);
|
|
251
|
+
await run(adb, ["-s", serial, "shell", "rm", deviceTmp]);
|
|
252
|
+
return true;
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function makeStatusBarTransparent(serial, imagePath) {
|
|
258
|
+
if (!await commandExists("magick")) return false;
|
|
259
|
+
const adb = getAdbPath();
|
|
260
|
+
const STATUS_BAR_HEIGHT_DP = 24;
|
|
261
|
+
const { stdout: densityOutput } = await run(adb, [
|
|
262
|
+
"-s",
|
|
263
|
+
serial,
|
|
264
|
+
"shell",
|
|
265
|
+
"wm",
|
|
266
|
+
"density"
|
|
267
|
+
]);
|
|
268
|
+
const densityMatch = densityOutput.match(/(\d+)\s*$/m);
|
|
269
|
+
if (!densityMatch) return false;
|
|
270
|
+
const density = parseInt(densityMatch[1], 10);
|
|
271
|
+
const statusBarPx = Math.ceil(STATUS_BAR_HEIGHT_DP * density / 160);
|
|
272
|
+
const { stdout: identify } = await run("magick", [
|
|
273
|
+
"identify",
|
|
274
|
+
"-format",
|
|
275
|
+
"%w",
|
|
276
|
+
imagePath
|
|
277
|
+
]);
|
|
278
|
+
const imgWidth = identify.trim();
|
|
279
|
+
if (!imgWidth) return false;
|
|
280
|
+
try {
|
|
281
|
+
await runOrFail("magick", [
|
|
282
|
+
imagePath,
|
|
283
|
+
"-region",
|
|
284
|
+
`${imgWidth}x${statusBarPx}+0+0`,
|
|
285
|
+
"-alpha",
|
|
286
|
+
"set",
|
|
287
|
+
"-channel",
|
|
288
|
+
"A",
|
|
289
|
+
"-evaluate",
|
|
290
|
+
"set",
|
|
291
|
+
"0",
|
|
292
|
+
"+channel",
|
|
293
|
+
imagePath
|
|
294
|
+
]);
|
|
295
|
+
return true;
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/devices/discover.ts
|
|
302
|
+
async function discoverDevices(bundleId, platform = "both") {
|
|
303
|
+
const results = [];
|
|
304
|
+
if (platform === "ios" || platform === "both") {
|
|
305
|
+
const iosDevices = await discoverIosDevices(bundleId);
|
|
306
|
+
results.push(...iosDevices);
|
|
307
|
+
}
|
|
308
|
+
if (platform === "android" || platform === "both") {
|
|
309
|
+
const androidDevices = await discoverAndroidDevices(bundleId);
|
|
310
|
+
results.push(...androidDevices);
|
|
311
|
+
}
|
|
312
|
+
return results;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/framing/frame.ts
|
|
316
|
+
import { existsSync as existsSync3, readdirSync } from "fs";
|
|
317
|
+
import { join as join3, dirname } from "path";
|
|
318
|
+
import { fileURLToPath } from "url";
|
|
319
|
+
|
|
320
|
+
// src/framing/setup.ts
|
|
321
|
+
import { existsSync as existsSync2 } from "fs";
|
|
322
|
+
import { join as join2 } from "path";
|
|
323
|
+
import { homedir as homedir2 } from "os";
|
|
324
|
+
var VENV_DIR = join2(homedir2(), ".device-shots", ".venv");
|
|
325
|
+
var PYTHON_BIN = join2(VENV_DIR, "bin", "python3");
|
|
326
|
+
var PIP_BIN = join2(VENV_DIR, "bin", "pip");
|
|
327
|
+
function getVenvPython() {
|
|
328
|
+
return PYTHON_BIN;
|
|
329
|
+
}
|
|
330
|
+
function isVenvReady() {
|
|
331
|
+
return existsSync2(PYTHON_BIN);
|
|
332
|
+
}
|
|
333
|
+
async function ensureVenv() {
|
|
334
|
+
if (isVenvReady()) return;
|
|
335
|
+
if (!await commandExists("python3")) {
|
|
336
|
+
throw new Error("python3 is required for framing. Please install Python 3.");
|
|
337
|
+
}
|
|
338
|
+
await runOrFail("python3", ["-m", "venv", VENV_DIR]);
|
|
339
|
+
await runOrFail(PIP_BIN, [
|
|
340
|
+
"install",
|
|
341
|
+
"--quiet",
|
|
342
|
+
"device-frames-core",
|
|
343
|
+
"Pillow"
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/framing/frame.ts
|
|
348
|
+
function getFramePyPath() {
|
|
349
|
+
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
350
|
+
const candidates = [
|
|
351
|
+
join3(thisDir, "..", "vendor", "frame.py"),
|
|
352
|
+
join3(thisDir, "..", "..", "vendor", "frame.py")
|
|
353
|
+
];
|
|
354
|
+
for (const c of candidates) {
|
|
355
|
+
if (existsSync3(c)) return c;
|
|
356
|
+
}
|
|
357
|
+
throw new Error(
|
|
358
|
+
"Could not find vendored frame.py. Ensure the vendor/ directory is present."
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
async function frameScreenshots(rawDir, framedDir, force = false) {
|
|
362
|
+
await ensureVenv();
|
|
363
|
+
const framePy = getFramePyPath();
|
|
364
|
+
const python = getVenvPython();
|
|
365
|
+
const args = [framePy, rawDir, framedDir];
|
|
366
|
+
if (force) {
|
|
367
|
+
args.push("--force");
|
|
368
|
+
}
|
|
369
|
+
const output = await runOrFail(python, args);
|
|
370
|
+
if (output) {
|
|
371
|
+
process.stdout.write(output + "\n");
|
|
372
|
+
}
|
|
373
|
+
const framedFiles = existsSync3(framedDir) ? readdirSync(framedDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
374
|
+
const rawFiles = existsSync3(rawDir) ? readdirSync(rawDir).filter((f) => f.endsWith(".png")).length : 0;
|
|
375
|
+
return { framed: framedFiles, skipped: rawFiles - framedFiles };
|
|
376
|
+
}
|
|
377
|
+
async function frameAllIosScreenshots(screenshotsDir, force = false) {
|
|
378
|
+
const iosDir = join3(screenshotsDir, "ios");
|
|
379
|
+
if (!existsSync3(iosDir)) return 0;
|
|
380
|
+
let totalFramed = 0;
|
|
381
|
+
const deviceDirs = readdirSync(iosDir, { withFileTypes: true }).filter((d) => d.isDirectory());
|
|
382
|
+
for (const deviceDir of deviceDirs) {
|
|
383
|
+
const rawDir = join3(iosDir, deviceDir.name, "raw");
|
|
384
|
+
if (!existsSync3(rawDir)) continue;
|
|
385
|
+
const pngFiles = readdirSync(rawDir).filter((f) => f.endsWith(".png"));
|
|
386
|
+
if (pngFiles.length === 0) continue;
|
|
387
|
+
const framedDir = join3(iosDir, deviceDir.name, "framed");
|
|
388
|
+
const { framed } = await frameScreenshots(rawDir, framedDir, force);
|
|
389
|
+
totalFramed += framed;
|
|
390
|
+
}
|
|
391
|
+
return totalFramed;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/commands/capture.ts
|
|
395
|
+
async function captureCommand(options) {
|
|
396
|
+
const config = await loadConfig();
|
|
397
|
+
const bundleId = options.bundleId || config.bundleId;
|
|
398
|
+
if (!bundleId) {
|
|
399
|
+
console.error(
|
|
400
|
+
pc.red("Bundle ID is required. Use --bundle-id or set it in config.")
|
|
401
|
+
);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
const outputDir = options.output || config.output;
|
|
405
|
+
const platform = options.platform || config.platform;
|
|
406
|
+
const time = options.time || config.time;
|
|
407
|
+
const shouldFrame = !options.noFrame && config.frame;
|
|
408
|
+
const spinner = ora("Discovering devices...").start();
|
|
409
|
+
const devices = await discoverDevices(bundleId, platform);
|
|
410
|
+
spinner.stop();
|
|
411
|
+
if (devices.length === 0) {
|
|
412
|
+
console.error(pc.red(`No devices found with ${bundleId} installed.`));
|
|
413
|
+
console.error("Start a simulator/emulator and install the app first.");
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
console.log(
|
|
417
|
+
pc.bold(`
|
|
418
|
+
Detected ${devices.length} device(s) with ${bundleId}:`)
|
|
419
|
+
);
|
|
420
|
+
for (const device of devices) {
|
|
421
|
+
const deviceDir = join4(outputDir, device.platform, device.safeName);
|
|
422
|
+
const rawDir = join4(deviceDir, "raw");
|
|
423
|
+
if (existsSync4(rawDir)) {
|
|
424
|
+
const count = readdirSync2(rawDir).filter(
|
|
425
|
+
(f) => f.endsWith(".png")
|
|
426
|
+
).length;
|
|
427
|
+
console.log(
|
|
428
|
+
` ${pc.dim(device.platform + "/")}${device.safeName} (${device.displayName}) - ${count} screenshot(s)`
|
|
429
|
+
);
|
|
430
|
+
} else {
|
|
431
|
+
console.log(
|
|
432
|
+
` ${pc.dim(device.platform + "/")}${device.safeName} (${device.displayName}) - ${pc.green("new")}`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const existingNames = getExistingScreenshotNames(outputDir, devices);
|
|
437
|
+
if (existingNames.length > 0) {
|
|
438
|
+
console.log(pc.dim("\nExisting screenshots:"));
|
|
439
|
+
for (const name of existingNames) {
|
|
440
|
+
console.log(pc.dim(` - ${name}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
let screenshotName = options.name;
|
|
444
|
+
if (!screenshotName) {
|
|
445
|
+
const response = await prompts({
|
|
446
|
+
type: "text",
|
|
447
|
+
name: "name",
|
|
448
|
+
message: "Screenshot name (e.g. dashboard, sales-report)"
|
|
449
|
+
});
|
|
450
|
+
if (!response.name) {
|
|
451
|
+
console.log("No name provided. Aborting.");
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
screenshotName = response.name;
|
|
455
|
+
}
|
|
456
|
+
screenshotName = screenshotName.replace(/ /g, "-").replace(/[^A-Za-z0-9_-]/g, "");
|
|
457
|
+
const firstDevice = devices[0];
|
|
458
|
+
const existingFile = join4(
|
|
459
|
+
outputDir,
|
|
460
|
+
firstDevice.platform,
|
|
461
|
+
firstDevice.safeName,
|
|
462
|
+
"raw",
|
|
463
|
+
`${screenshotName}_${firstDevice.safeName}.png`
|
|
464
|
+
);
|
|
465
|
+
if (existsSync4(existingFile)) {
|
|
466
|
+
const response = await prompts({
|
|
467
|
+
type: "confirm",
|
|
468
|
+
name: "overwrite",
|
|
469
|
+
message: `Screenshot '${screenshotName}' already exists. Overwrite?`,
|
|
470
|
+
initial: false
|
|
471
|
+
});
|
|
472
|
+
if (!response.overwrite) {
|
|
473
|
+
console.log("Aborting.");
|
|
474
|
+
process.exit(0);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const tmpDir = mkdtempSync(join4(tmpdir(), "device-shots-"));
|
|
478
|
+
const iosDevices = devices.filter((d) => d.platform === "ios");
|
|
479
|
+
const androidDevices = devices.filter((d) => d.platform === "android");
|
|
480
|
+
if (iosDevices.length > 0) {
|
|
481
|
+
const s = ora("Setting clean iOS status bar...").start();
|
|
482
|
+
await setIosStatusBar(time);
|
|
483
|
+
s.succeed("iOS status bar set");
|
|
484
|
+
}
|
|
485
|
+
if (androidDevices.length > 0) {
|
|
486
|
+
const s = ora("Setting clean Android status bar...").start();
|
|
487
|
+
for (const device of androidDevices) {
|
|
488
|
+
await setAndroidDemoMode(device.captureId, time);
|
|
489
|
+
}
|
|
490
|
+
s.succeed("Android demo mode set");
|
|
491
|
+
}
|
|
492
|
+
console.log("");
|
|
493
|
+
const captured = [];
|
|
494
|
+
for (const device of devices) {
|
|
495
|
+
const filename = `${screenshotName}_${device.safeName}.png`;
|
|
496
|
+
const tmpPath = join4(tmpDir, filename);
|
|
497
|
+
const icon = device.platform === "ios" ? "iOS" : "Android";
|
|
498
|
+
const s = ora(`${icon}: Capturing from ${device.displayName}...`).start();
|
|
499
|
+
let success = false;
|
|
500
|
+
if (device.platform === "ios") {
|
|
501
|
+
success = await captureIosScreenshot(device.captureId, tmpPath);
|
|
502
|
+
} else {
|
|
503
|
+
success = await captureAndroidScreenshot(device.captureId, tmpPath);
|
|
504
|
+
if (success) {
|
|
505
|
+
const transparent = await makeStatusBarTransparent(
|
|
506
|
+
device.captureId,
|
|
507
|
+
tmpPath
|
|
508
|
+
);
|
|
509
|
+
if (transparent) {
|
|
510
|
+
s.text = `${icon}: Captured from ${device.displayName} (status bar transparent)`;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (success) {
|
|
515
|
+
captured.push({
|
|
516
|
+
platform: device.platform,
|
|
517
|
+
safeName: device.safeName,
|
|
518
|
+
filename,
|
|
519
|
+
tmpPath
|
|
520
|
+
});
|
|
521
|
+
s.succeed(`${icon}: ${device.displayName}`);
|
|
522
|
+
} else {
|
|
523
|
+
s.fail(`${icon}: Failed to capture from ${device.displayName}`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const file of captured) {
|
|
527
|
+
const destDir = join4(outputDir, file.platform, file.safeName, "raw");
|
|
528
|
+
mkdirSync(destDir, { recursive: true });
|
|
529
|
+
if (file.platform === "ios") {
|
|
530
|
+
mkdirSync(join4(outputDir, file.platform, file.safeName, "framed"), {
|
|
531
|
+
recursive: true
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
renameSync(file.tmpPath, join4(destDir, file.filename));
|
|
535
|
+
}
|
|
536
|
+
if (iosDevices.length > 0) {
|
|
537
|
+
await clearIosStatusBar();
|
|
538
|
+
}
|
|
539
|
+
for (const device of androidDevices) {
|
|
540
|
+
await clearAndroidDemoMode(device.captureId);
|
|
541
|
+
}
|
|
542
|
+
console.log(
|
|
543
|
+
pc.green(
|
|
544
|
+
`
|
|
545
|
+
Captured ${captured.length} screenshot(s) as '${screenshotName}'.`
|
|
546
|
+
)
|
|
547
|
+
);
|
|
548
|
+
if (shouldFrame && iosDevices.length > 0) {
|
|
549
|
+
console.log("");
|
|
550
|
+
const s = ora("Framing iOS screenshots...").start();
|
|
551
|
+
try {
|
|
552
|
+
const framed = await frameAllIosScreenshots(outputDir);
|
|
553
|
+
s.succeed(`Framed ${framed} screenshot(s)`);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
s.fail(
|
|
556
|
+
`Framing failed: ${error instanceof Error ? error.message : error}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function getExistingScreenshotNames(outputDir, devices) {
|
|
562
|
+
const names = /* @__PURE__ */ new Set();
|
|
563
|
+
for (const device of devices) {
|
|
564
|
+
const rawDir = join4(outputDir, device.platform, device.safeName, "raw");
|
|
565
|
+
if (!existsSync4(rawDir)) continue;
|
|
566
|
+
for (const file of readdirSync2(rawDir)) {
|
|
567
|
+
if (!file.endsWith(".png")) continue;
|
|
568
|
+
const base = file.replace(".png", "");
|
|
569
|
+
const suffix = `_${device.safeName}`;
|
|
570
|
+
if (base.endsWith(suffix)) {
|
|
571
|
+
names.add(base.slice(0, -suffix.length));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return [...names].sort();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/commands/frame.ts
|
|
579
|
+
import ora2 from "ora";
|
|
580
|
+
import pc2 from "picocolors";
|
|
581
|
+
async function frameCommand(dir, options) {
|
|
582
|
+
const config = await loadConfig();
|
|
583
|
+
const screenshotsDir = dir || options.input || config.output;
|
|
584
|
+
console.log(pc2.bold(`Framing iOS screenshots in ${screenshotsDir}...`));
|
|
585
|
+
const spinner = ora2("Setting up Python environment...").start();
|
|
586
|
+
try {
|
|
587
|
+
const framed = await frameAllIosScreenshots(
|
|
588
|
+
screenshotsDir,
|
|
589
|
+
options.force
|
|
590
|
+
);
|
|
591
|
+
if (framed > 0) {
|
|
592
|
+
spinner.succeed(`Framed ${framed} screenshot(s)`);
|
|
593
|
+
} else {
|
|
594
|
+
spinner.info("No new screenshots to frame");
|
|
595
|
+
}
|
|
596
|
+
} catch (error) {
|
|
597
|
+
spinner.fail(
|
|
598
|
+
`Framing failed: ${error instanceof Error ? error.message : error}`
|
|
599
|
+
);
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/index.ts
|
|
605
|
+
var program = new Command();
|
|
606
|
+
program.name("device-shots").description(
|
|
607
|
+
"Capture and frame mobile app screenshots from iOS simulators and Android emulators"
|
|
608
|
+
).version("0.1.0");
|
|
609
|
+
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) => {
|
|
610
|
+
await captureCommand({ name, ...opts });
|
|
611
|
+
});
|
|
612
|
+
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) => {
|
|
613
|
+
await frameCommand(dir, opts);
|
|
614
|
+
});
|
|
615
|
+
program.command("init").description("Create a .device-shotsrc.json config file").action(async () => {
|
|
616
|
+
const configPath = ".device-shotsrc.json";
|
|
617
|
+
if (existsSync5(configPath)) {
|
|
618
|
+
console.log(pc3.yellow(`${configPath} already exists.`));
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const response = await prompts2({
|
|
622
|
+
type: "text",
|
|
623
|
+
name: "bundleId",
|
|
624
|
+
message: "App bundle ID (e.g. com.example.myapp)"
|
|
625
|
+
});
|
|
626
|
+
if (!response.bundleId) {
|
|
627
|
+
console.log("Aborting.");
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const config = createDefaultConfig(response.bundleId);
|
|
631
|
+
writeFileSync(configPath, config + "\n");
|
|
632
|
+
console.log(pc3.green(`Created ${configPath}`));
|
|
633
|
+
});
|
|
634
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "device-shots",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Capture and frame mobile app screenshots from iOS simulators and Android emulators",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"device-shots": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"vendor"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"dev": "tsup --watch",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"screenshot",
|
|
21
|
+
"mobile",
|
|
22
|
+
"ios",
|
|
23
|
+
"android",
|
|
24
|
+
"simulator",
|
|
25
|
+
"emulator",
|
|
26
|
+
"device-frame"
|
|
27
|
+
],
|
|
28
|
+
"author": "mjcarnaje",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"commander": "^13.1.0",
|
|
32
|
+
"cosmiconfig": "^9.0.0",
|
|
33
|
+
"execa": "^9.5.2",
|
|
34
|
+
"ora": "^8.2.0",
|
|
35
|
+
"picocolors": "^1.1.1",
|
|
36
|
+
"prompts": "^2.4.2"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.13.14",
|
|
40
|
+
"@types/prompts": "^2.4.9",
|
|
41
|
+
"tsup": "^8.4.0",
|
|
42
|
+
"typescript": "^5.8.2"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/vendor/frame.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Frame iOS screenshots with device bezels.
|
|
3
|
+
|
|
4
|
+
Auto-detects the device model from screenshot resolution and applies
|
|
5
|
+
the matching Apple frame using device-frames-core.
|
|
6
|
+
|
|
7
|
+
Android screenshots are used raw (per Google Play Store guidelines).
|
|
8
|
+
|
|
9
|
+
Requirements:
|
|
10
|
+
pip install device-frames-core Pillow
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from device_frames_core import apply_frame, list_devices
|
|
17
|
+
|
|
18
|
+
VARIATION_PREFERENCE = ["space-black", "black", "space-grey", "silver"]
|
|
19
|
+
IOS_CATEGORIES = {"apple-iphone", "apple-ipad"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_resolution_map():
|
|
23
|
+
"""Map (width, height) -> best matching iOS device info."""
|
|
24
|
+
res_map = {}
|
|
25
|
+
for d in list_devices():
|
|
26
|
+
if d["category"] not in IOS_CATEGORIES:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
key = (d["screen"]["width"], d["screen"]["height"])
|
|
30
|
+
existing = res_map.get(key)
|
|
31
|
+
if existing is None:
|
|
32
|
+
res_map[key] = d
|
|
33
|
+
else:
|
|
34
|
+
for pref in VARIATION_PREFERENCE:
|
|
35
|
+
if pref in d["variation"] and pref not in existing["variation"]:
|
|
36
|
+
res_map[key] = d
|
|
37
|
+
break
|
|
38
|
+
return res_map
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_image_size(path):
|
|
42
|
+
"""Return (width, height) of an image."""
|
|
43
|
+
from PIL import Image
|
|
44
|
+
|
|
45
|
+
with Image.open(path) as img:
|
|
46
|
+
return img.size
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def frame_screenshot(input_path, output_path, res_map):
|
|
50
|
+
"""Frame a single screenshot, auto-detecting the device."""
|
|
51
|
+
width, height = get_image_size(input_path)
|
|
52
|
+
device_info = res_map.get((width, height))
|
|
53
|
+
|
|
54
|
+
if device_info is None:
|
|
55
|
+
print(f" No matching device frame for {width}x{height}, skipping: {input_path}")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
category = device_info["category"]
|
|
59
|
+
device = device_info["device"]
|
|
60
|
+
variation = device_info["variation"]
|
|
61
|
+
|
|
62
|
+
print(f" {Path(input_path).name} -> {device} ({variation})")
|
|
63
|
+
apply_frame(
|
|
64
|
+
screenshot_path=Path(input_path),
|
|
65
|
+
device=device,
|
|
66
|
+
variation=variation,
|
|
67
|
+
output_path=Path(output_path),
|
|
68
|
+
category=category,
|
|
69
|
+
)
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main():
|
|
74
|
+
force = "--force" in sys.argv
|
|
75
|
+
args = [a for a in sys.argv[1:] if a != "--force"]
|
|
76
|
+
|
|
77
|
+
if len(args) < 2:
|
|
78
|
+
print("Usage: frame.py <raw_dir> <framed_dir> [--force]")
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
raw_dir = Path(args[0])
|
|
82
|
+
framed_dir = Path(args[1])
|
|
83
|
+
framed_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
res_map = build_resolution_map()
|
|
86
|
+
|
|
87
|
+
screenshots = sorted(raw_dir.glob("*.png"))
|
|
88
|
+
if not screenshots:
|
|
89
|
+
print("No screenshots found.")
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
|
|
92
|
+
skipped = 0
|
|
93
|
+
to_frame = []
|
|
94
|
+
|
|
95
|
+
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:
|
|
100
|
+
to_frame.append(f)
|
|
101
|
+
|
|
102
|
+
if not to_frame:
|
|
103
|
+
print(f"All {skipped} screenshots already framed. Nothing to do.")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
if skipped:
|
|
107
|
+
print(f"Skipping {skipped} already framed.")
|
|
108
|
+
print(f"Framing {len(to_frame)} screenshots...")
|
|
109
|
+
|
|
110
|
+
success = 0
|
|
111
|
+
for f in to_frame:
|
|
112
|
+
output = framed_dir / f"{f.stem}_framed.png"
|
|
113
|
+
if frame_screenshot(str(f), str(output), res_map):
|
|
114
|
+
success += 1
|
|
115
|
+
|
|
116
|
+
print(f"Done! Framed {success}/{len(to_frame)} screenshots.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
main()
|