zdymak 0.7.0 → 0.13.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/LICENSE +21 -21
- package/README.md +733 -211
- package/SKILL.md +183 -84
- package/bin/zdymak.mjs +3 -3
- package/package.json +66 -40
- package/src/canvas.mjs +116 -116
- package/src/capture/index.mjs +318 -242
- package/src/capture/web.mjs +137 -0
- package/src/cli.mjs +298 -165
- package/src/config.mjs +159 -96
- package/src/destinations.mjs +103 -0
- package/src/easings.mjs +74 -0
- package/src/effects.mjs +335 -0
- package/src/encode.mjs +70 -44
- package/src/fonts.mjs +42 -42
- package/src/frames.mjs +186 -168
- package/src/graphic.mjs +107 -77
- package/src/index.mjs +27 -12
- package/src/montage.mjs +428 -0
- package/src/png.mjs +50 -50
- package/src/premium.mjs +362 -351
- package/src/reel.mjs +366 -245
- package/src/screenshots.mjs +118 -66
- package/src/specs.mjs +123 -88
- package/src/statusbar.mjs +163 -0
- package/src/still.mjs +72 -62
- package/src/transitions.mjs +653 -0
- package/src/validate.mjs +98 -0
- package/src/video.mjs +243 -240
- package/types/index.d.ts +559 -0
package/src/capture/index.mjs
CHANGED
|
@@ -1,242 +1,318 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Capture mode (mode B) — grab store-ready PNGs from a running app instead of hand-managing a folder.
|
|
3
|
-
* Strips the alpha channel (Apple & Play reject transparency) and writes into your `capturesDir`, so the
|
|
4
|
-
* capture → compose (`build`/`screenshots`) chain is one pipeline.
|
|
5
|
-
*
|
|
6
|
-
* FULL WORKFLOW (start app → drive by a handle → snap each screen):
|
|
7
|
-
* zdymak capture --platform ios --bundle com.x.app --arg -marketingScreen \
|
|
8
|
-
* --states welcome,today,study,answer --suffix -light \
|
|
9
|
-
* --build --project App.xcodeproj --scheme App --out marketing/ios/captures
|
|
10
|
-
* → boots a sim, (optionally builds+installs), then for each state relaunches the app with
|
|
11
|
-
* `<arg> <state>` and screenshots it. The app just needs a launch-arg "handle" that routes to a
|
|
12
|
-
* seeded screen (e.g. reads `-marketingScreen <id>` from UserDefaults). Then `zdymak build` composes.
|
|
13
|
-
*
|
|
14
|
-
* Simpler modes:
|
|
15
|
-
* zdymak capture --platform ios|android --name welcome # single snapshot of the booted device
|
|
16
|
-
* zdymak capture --platform ios --record --out shots/rec # screen-record (stop with Ctrl-C)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
ctx
|
|
31
|
-
ctx.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Capture mode (mode B) — grab store-ready PNGs from a running app instead of hand-managing a folder.
|
|
3
|
+
* Strips the alpha channel (Apple & Play reject transparency) and writes into your `capturesDir`, so the
|
|
4
|
+
* capture → compose (`build`/`screenshots`) chain is one pipeline.
|
|
5
|
+
*
|
|
6
|
+
* FULL WORKFLOW (start app → drive by a handle → snap each screen):
|
|
7
|
+
* zdymak capture --platform ios --bundle com.x.app --arg -marketingScreen \
|
|
8
|
+
* --states welcome,today,study,answer --suffix -light \
|
|
9
|
+
* --build --project App.xcodeproj --scheme App --out marketing/ios/captures
|
|
10
|
+
* → boots a sim, (optionally builds+installs), then for each state relaunches the app with
|
|
11
|
+
* `<arg> <state>` and screenshots it. The app just needs a launch-arg "handle" that routes to a
|
|
12
|
+
* seeded screen (e.g. reads `-marketingScreen <id>` from UserDefaults). Then `zdymak build` composes.
|
|
13
|
+
*
|
|
14
|
+
* Simpler modes:
|
|
15
|
+
* zdymak capture --platform ios|android --name welcome # single snapshot of the booted device
|
|
16
|
+
* zdymak capture --platform ios --record --out shots/rec # screen-record (stop with Ctrl-C)
|
|
17
|
+
* zdymak capture --platform web --url http://localhost:3000 --states /,/today # Playwright (see web.mjs)
|
|
18
|
+
*/
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
|
23
|
+
import { rgbPngBuffer } from '../png.mjs';
|
|
24
|
+
import { captureWeb } from './web.mjs';
|
|
25
|
+
|
|
26
|
+
/** Flatten over black and rewrite as a NO-ALPHA RGB PNG (App Store & Play reject alpha on screenshots). */
|
|
27
|
+
async function stripAlpha(file) {
|
|
28
|
+
const img = await loadImage(file);
|
|
29
|
+
const c = createCanvas(img.width, img.height);
|
|
30
|
+
const ctx = c.getContext('2d');
|
|
31
|
+
ctx.fillStyle = '#000';
|
|
32
|
+
ctx.fillRect(0, 0, img.width, img.height);
|
|
33
|
+
ctx.drawImage(img, 0, 0);
|
|
34
|
+
fs.writeFileSync(file, rgbPngBuffer(c)); // colour-type-2 PNG, no alpha channel
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sh(cmd, args) {
|
|
38
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
39
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim()}`);
|
|
40
|
+
return r.stdout;
|
|
41
|
+
}
|
|
42
|
+
const out2 = (cmd, args) => (spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).stdout || '');
|
|
43
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
const udidRe = /\(([0-9A-Fa-f-]{36})\)/;
|
|
45
|
+
|
|
46
|
+
/** Boot (or reuse/create) an iOS simulator → UDID. Prefers --udid, then an already-booted sim, then a
|
|
47
|
+
* device matching --device (default iPhone 16 Pro Max), creating one if needed. */
|
|
48
|
+
/**
|
|
49
|
+
* Boot (or reuse/create) a simulator. Returns `{ udid, booted, created }` — the two booleans are the
|
|
50
|
+
* TEARDOWN receipt: they say what this process changed, so the cleanup can undo exactly that and leave a
|
|
51
|
+
* simulator the user already had running exactly as they left it.
|
|
52
|
+
*/
|
|
53
|
+
function bootIosSim(flags) {
|
|
54
|
+
if (flags.udid) {
|
|
55
|
+
const wasBooted = out2('xcrun', ['simctl', 'list', 'devices', 'booted']).includes(flags.udid);
|
|
56
|
+
spawnSync('xcrun', ['simctl', 'boot', flags.udid], { stdio: 'ignore' });
|
|
57
|
+
spawnSync('xcrun', ['simctl', 'bootstatus', flags.udid, '-b'], { stdio: 'ignore' });
|
|
58
|
+
return { udid: flags.udid, booted: !wasBooted, created: false };
|
|
59
|
+
}
|
|
60
|
+
if (!flags.device) {
|
|
61
|
+
const booted = out2('xcrun', ['simctl', 'list', 'devices', 'booted']).match(udidRe);
|
|
62
|
+
if (booted) return { udid: booted[1], booted: false, created: false }; // the user's own sim — leave it alone
|
|
63
|
+
}
|
|
64
|
+
const name = flags.device || 'iPhone 16 Pro Max';
|
|
65
|
+
let created = false;
|
|
66
|
+
let udid = out2('xcrun', ['simctl', 'list', 'devices', 'available'])
|
|
67
|
+
.split('\n').find((l) => l.includes(name) && udidRe.test(l))?.match(udidRe)?.[1];
|
|
68
|
+
if (!udid) {
|
|
69
|
+
const devtype = out2('xcrun', ['simctl', 'list', 'devicetypes']).split('\n')
|
|
70
|
+
.find((l) => l.includes(name))?.match(/(com\.apple[^\s)]+)/)?.[1];
|
|
71
|
+
const runtime = out2('xcrun', ['simctl', 'list', 'runtimes', 'ios']).split('\n').reverse()
|
|
72
|
+
.find((l) => /com\.apple[^\s)]+/.test(l))?.match(/(com\.apple[^\s)]+)/)?.[1];
|
|
73
|
+
if (!devtype || !runtime) throw new Error(`Could not resolve a simulator for "${name}".`);
|
|
74
|
+
udid = sh('xcrun', ['simctl', 'create', 'zdymak-capture', devtype, runtime]).trim();
|
|
75
|
+
created = true;
|
|
76
|
+
}
|
|
77
|
+
spawnSync('xcrun', ['simctl', 'boot', udid], { stdio: 'ignore' });
|
|
78
|
+
spawnSync('xcrun', ['simctl', 'bootstatus', udid, '-b'], { stdio: 'ignore' });
|
|
79
|
+
return { udid, booted: true, created };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Undo the setup. A capture run boots a simulator (~1.7GB resident), overrides its status bar and may
|
|
84
|
+
* CREATE a throwaway device — leaving all three behind is how a machine ends up with a pile of booted
|
|
85
|
+
* `zdymak-capture` sims and a permanently faked status bar. Only what this run changed is reverted;
|
|
86
|
+
* `--keep` skips it when you want to inspect the device afterwards.
|
|
87
|
+
*/
|
|
88
|
+
function teardownIosSim({ udid, booted, created }, flags) {
|
|
89
|
+
if (!udid || flags.keep) {
|
|
90
|
+
if (flags.keep) console.log(' (--keep: simulator left booted)');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
spawnSync('xcrun', ['simctl', 'status_bar', udid, 'clear'], { stdio: 'ignore' });
|
|
94
|
+
if (!booted) return; // it was already running before us — not ours to shut down
|
|
95
|
+
spawnSync('xcrun', ['simctl', 'shutdown', udid], { stdio: 'ignore' });
|
|
96
|
+
if (created) spawnSync('xcrun', ['simctl', 'delete', udid], { stdio: 'ignore' });
|
|
97
|
+
console.log(` ↩ tore down the simulator${created ? ' (and deleted the temporary device)' : ''}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const simctlOk = () => spawnSync('xcrun', ['simctl', 'help'], { stdio: 'ignore' }).status === 0;
|
|
101
|
+
|
|
102
|
+
async function captureIos(flags) {
|
|
103
|
+
if (!simctlOk()) {
|
|
104
|
+
// Common when xcode-select points at the CommandLineTools (no simctl): fall back to Xcode.app.
|
|
105
|
+
const xc = '/Applications/Xcode.app/Contents/Developer';
|
|
106
|
+
if (!process.env.DEVELOPER_DIR && fs.existsSync(xc)) process.env.DEVELOPER_DIR = xc;
|
|
107
|
+
if (!simctlOk()) {
|
|
108
|
+
throw new Error('xcrun/simctl unavailable — point DEVELOPER_DIR at Xcode (e.g. xcode-select -s /Applications/Xcode.app).');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const outDir = path.resolve(flags.out || 'shots');
|
|
112
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
113
|
+
// --clean removes stale capture images first so the folder holds ONLY this run's screenshots. Keeps the
|
|
114
|
+
// `.dd` build cache (and any subdirs) so a rebuild stays incremental — only loose PNG/MOV files are cleared.
|
|
115
|
+
if (flags.clean) {
|
|
116
|
+
let cleared = 0;
|
|
117
|
+
for (const f of fs.readdirSync(outDir)) {
|
|
118
|
+
if (/\.(png|mov)$/i.test(f)) { fs.rmSync(path.join(outDir, f), { force: true }); cleared++; }
|
|
119
|
+
}
|
|
120
|
+
console.log(`🧹 cleaned ${cleared} stale capture(s) in ${path.relative(process.cwd(), outDir) || outDir}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (flags.record !== undefined && !flags.states) {
|
|
124
|
+
const out = path.join(outDir, `${flags.name || 'recording'}.mov`);
|
|
125
|
+
console.log(`▶︎ Recording the booted iOS simulator → ${out}\n Interact with the app, then press Ctrl-C to stop.`);
|
|
126
|
+
const proc = spawn('xcrun', ['simctl', 'io', 'booted', 'recordVideo', '--codec=h264', '--force', out], { stdio: 'inherit' });
|
|
127
|
+
await new Promise((res) => proc.on('close', res));
|
|
128
|
+
console.log(`✓ Saved ${out}. Extract frames with: ffmpeg -i ${out} -vf fps=2 ${outDir}/frame-%03d.png`);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// FULL WORKFLOW: drive the app through screens by a launch-arg HANDLE, capturing each.
|
|
133
|
+
// zdymak capture --platform ios --bundle com.x.app --arg -screen --states a,b,c --suffix -light
|
|
134
|
+
// [--build --project X.xcodeproj --scheme S] [--device "iPhone 16 Pro Max"] [--settle 3]
|
|
135
|
+
if (flags.states) {
|
|
136
|
+
if (!flags.bundle || !flags.arg) {
|
|
137
|
+
throw new Error('state capture needs --bundle <id> and --arg <launch-handle> (e.g. -marketingScreen).');
|
|
138
|
+
}
|
|
139
|
+
const states = flags.states.split(',').map((s) => s.trim()).filter(Boolean);
|
|
140
|
+
const suffix = flags.suffix || '';
|
|
141
|
+
const settle = Number(flags.settle || 4);
|
|
142
|
+
const sim = bootIosSim(flags);
|
|
143
|
+
const udid = sim.udid;
|
|
144
|
+
try {
|
|
145
|
+
if (flags.build !== undefined) {
|
|
146
|
+
if (!flags.project || !flags.scheme) throw new Error('--build needs --project <.xcodeproj> and --scheme <name>.');
|
|
147
|
+
const dd = path.join(outDir, '.dd');
|
|
148
|
+
console.log(`▶︎ Building ${flags.scheme} for the simulator (this is the slow step)…`);
|
|
149
|
+
sh('xcodebuild', ['build', '-project', flags.project, '-scheme', flags.scheme, '-configuration', 'Debug',
|
|
150
|
+
'-destination', `id=${udid}`, '-derivedDataPath', dd, '-allowProvisioningUpdates']);
|
|
151
|
+
const app = out2('bash', ['-lc', `ls -dt "${dd}"/Build/Products/Debug-iphonesimulator/*.app 2>/dev/null | head -1`]).trim();
|
|
152
|
+
if (!app) throw new Error(`No .app under ${dd}/Build/Products/Debug-iphonesimulator`);
|
|
153
|
+
console.log(`▶︎ Installing ${path.basename(app)}…`);
|
|
154
|
+
sh('xcrun', ['simctl', 'install', udid, app]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Apple's canonical marketing status bar: 9:41, full signal/wifi, FULL battery but NOT charging
|
|
158
|
+
// (a charging bolt reads as a simulator override; Apple's own shots show an unplugged full battery).
|
|
159
|
+
const pinStatusBar = () =>
|
|
160
|
+
spawnSync('xcrun', ['simctl', 'status_bar', udid, 'override', '--time', '9:41',
|
|
161
|
+
'--batteryState', 'discharging', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'], { stdio: 'ignore' });
|
|
162
|
+
pinStatusBar();
|
|
163
|
+
|
|
164
|
+
// --record turns each screen into a short CLIP (real motion) instead of a still. The app must move
|
|
165
|
+
// during the window: pass the reel handle (default `-marketingReel`) so the harness auto-animates
|
|
166
|
+
// (auto-flip the card, auto-scroll a list, reveal the paywall). The reel engine (`zdymak reel`) then
|
|
167
|
+
// composites these clips on the matte. See SKILL/README "produce mode".
|
|
168
|
+
const recording = flags.record !== undefined;
|
|
169
|
+
const dur = Number(flags.duration || 3);
|
|
170
|
+
const reelArg = flags['reel-arg'] || '-marketingReel';
|
|
171
|
+
const verb = recording ? `Recording ${dur}s clips` : 'Driving';
|
|
172
|
+
console.log(`▶︎ ${verb} for ${states.length} screens via "${flags.arg} <id>" on ${flags.bundle}…`);
|
|
173
|
+
for (const st of states) {
|
|
174
|
+
spawnSync('xcrun', ['simctl', 'terminate', udid, flags.bundle], { stdio: 'ignore' });
|
|
175
|
+
const launch = ['simctl', 'launch', udid, flags.bundle, flags.arg, st];
|
|
176
|
+
if (recording) launch.push(reelArg, 'YES'); // tell the harness to auto-animate this screen
|
|
177
|
+
sh('xcrun', launch);
|
|
178
|
+
await sleep(settle * 1000);
|
|
179
|
+
// Re-assert RIGHT BEFORE capture: a launch + the settle lets the sim re-sync the battery to the host
|
|
180
|
+
// (a charging bolt creeps back on later screens). Re-pinning per screen keeps every shot clean.
|
|
181
|
+
pinStatusBar();
|
|
182
|
+
if (recording) {
|
|
183
|
+
const out = path.join(outDir, `${st}${suffix}.mov`);
|
|
184
|
+
const rec = spawn('xcrun', ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', out], { stdio: 'ignore' });
|
|
185
|
+
await sleep(dur * 1000);
|
|
186
|
+
rec.kill('SIGINT'); // simctl finalizes the mp4/mov on SIGINT
|
|
187
|
+
await new Promise((res) => rec.on('close', res));
|
|
188
|
+
console.log(` ✓ ${st}${suffix}.mov (${dur}s)`);
|
|
189
|
+
} else {
|
|
190
|
+
await sleep(500); // let the pinned bar paint before the screenshot
|
|
191
|
+
const out = path.join(outDir, `${st}${suffix}.png`);
|
|
192
|
+
sh('xcrun', ['simctl', 'io', udid, 'screenshot', out]);
|
|
193
|
+
await stripAlpha(out);
|
|
194
|
+
console.log(` ✓ ${st}${suffix}.png`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
console.log(`Done → ${outDir}`);
|
|
198
|
+
} finally {
|
|
199
|
+
// Always: a thrown build/launch error must not strand a booted sim with a faked status bar.
|
|
200
|
+
teardownIosSim(sim, flags);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Single snapshot of whatever is on the booted sim.
|
|
205
|
+
const out = path.join(outDir, `${flags.name || 'shot'}.png`);
|
|
206
|
+
sh('xcrun', ['simctl', 'io', 'booted', 'screenshot', out]);
|
|
207
|
+
await stripAlpha(out);
|
|
208
|
+
console.log(`✓ ${out} (alpha stripped, store-ready)`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Toggle Android SystemUI Demo Mode → a clean, Play-native status bar (Google's own convention):
|
|
212
|
+
* pinned clock, full battery UNPLUGGED (no charging), full signal/wifi, notifications hidden. */
|
|
213
|
+
function androidDemo(on, flags) {
|
|
214
|
+
const b = (...args) => spawnSync('adb', ['shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', ...args], { stdio: 'ignore' });
|
|
215
|
+
if (!on) return void b('-e', 'command', 'exit');
|
|
216
|
+
spawnSync('adb', ['shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1'], { stdio: 'ignore' });
|
|
217
|
+
b('-e', 'command', 'enter');
|
|
218
|
+
b('-e', 'command', 'clock', '-e', 'hhmm', (flags.time || '09:41').replace(':', ''));
|
|
219
|
+
b('-e', 'command', 'battery', '-e', 'level', '100', '-e', 'plugged', 'false'); // full, NOT charging
|
|
220
|
+
b('-e', 'command', 'network', '-e', 'wifi', 'show', '-e', 'level', '4');
|
|
221
|
+
b('-e', 'command', 'network', '-e', 'mobile', 'show', '-e', 'datatype', 'none', '-e', 'level', '4');
|
|
222
|
+
b('-e', 'command', 'notifications', '-e', 'visible', 'false');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function captureAndroid(flags) {
|
|
226
|
+
if (spawnSync('adb', ['version'], { stdio: 'ignore' }).status !== 0) {
|
|
227
|
+
throw new Error('adb not found — install Android platform-tools and connect a device/emulator.');
|
|
228
|
+
}
|
|
229
|
+
const outDir = path.resolve(flags.out || 'shots');
|
|
230
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
231
|
+
// --clean removes stale capture images first so the folder holds ONLY this run's screenshots. Keeps the
|
|
232
|
+
// `.dd` build cache (and any subdirs) so a rebuild stays incremental — only loose PNG/MOV files are cleared.
|
|
233
|
+
if (flags.clean) {
|
|
234
|
+
let cleared = 0;
|
|
235
|
+
for (const f of fs.readdirSync(outDir)) {
|
|
236
|
+
if (/\.(png|mov)$/i.test(f)) { fs.rmSync(path.join(outDir, f), { force: true }); cleared++; }
|
|
237
|
+
}
|
|
238
|
+
console.log(`🧹 cleaned ${cleared} stale capture(s) in ${path.relative(process.cwd(), outDir) || outDir}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (flags.record !== undefined) {
|
|
242
|
+
const remote = '/sdcard/zdymak-rec.mp4';
|
|
243
|
+
const out = path.join(outDir, `${flags.name || 'recording'}.mp4`);
|
|
244
|
+
console.log(`▶︎ Recording the device → ${out}\n Interact with the app, then press Ctrl-C to stop.`);
|
|
245
|
+
const proc = spawn('adb', ['shell', 'screenrecord', remote], { stdio: 'inherit' });
|
|
246
|
+
await new Promise((res) => proc.on('SIGINT', res).on('close', res));
|
|
247
|
+
sh('adb', ['pull', remote, out]);
|
|
248
|
+
console.log(`✓ Saved ${out}. Extract frames with: ffmpeg -i ${out} -vf fps=2 ${outDir}/frame-%03d.png`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const grab = async (file) => {
|
|
253
|
+
const b = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
|
|
254
|
+
if (b.status !== 0) throw new Error(`adb screencap failed: ${b.stderr}`);
|
|
255
|
+
fs.writeFileSync(file, b.stdout);
|
|
256
|
+
await stripAlpha(file);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
androidDemo(true, flags); // clean marketing status bar (Google convention)
|
|
260
|
+
try {
|
|
261
|
+
// Full workflow: drive the app through screens via an intent-extra HANDLE (--component + --arg).
|
|
262
|
+
if (flags.states) {
|
|
263
|
+
if (!flags.component || !flags.arg) {
|
|
264
|
+
throw new Error('android state capture needs --component <pkg/activity> and --arg <extra-key>.');
|
|
265
|
+
}
|
|
266
|
+
const states = flags.states.split(',').map((s) => s.trim()).filter(Boolean);
|
|
267
|
+
const suffix = flags.suffix || '';
|
|
268
|
+
const settle = Number(flags.settle || 4);
|
|
269
|
+
console.log(`▶︎ Driving ${states.length} screens via "--es ${flags.arg} <id>" on ${flags.component}…`);
|
|
270
|
+
for (const st of states) {
|
|
271
|
+
sh('adb', ['shell', 'am', 'start', '-n', flags.component, '--es', flags.arg, st]);
|
|
272
|
+
await sleep(settle * 1000);
|
|
273
|
+
const out = path.join(outDir, `${st}${suffix}.png`);
|
|
274
|
+
await grab(out);
|
|
275
|
+
console.log(` ✓ ${st}${suffix}.png`);
|
|
276
|
+
}
|
|
277
|
+
console.log(`Done → ${outDir}`);
|
|
278
|
+
} else {
|
|
279
|
+
const out = path.join(outDir, `${flags.name || 'shot'}.png`);
|
|
280
|
+
await grab(out);
|
|
281
|
+
console.log(`✓ ${out} (alpha stripped, store-ready)`);
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
androidDemo(false, flags);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Platform dispatch. NOTE — there is intentionally NO `--platform macos` capture:
|
|
290
|
+
*
|
|
291
|
+
* macOS has no `simctl io screenshot` / `adb screencap` equivalent for snapshotting a *driven* app.
|
|
292
|
+
* Reading a specific native window's pixels requires a macOS TCC permission grant — either **Screen
|
|
293
|
+
* Recording** (for the `screencapture` CLI, which otherwise returns black frames) or **Accessibility**
|
|
294
|
+
* (to drive the UI + use XCUITest's own screenshot). Neither is a clean one-command capture like the
|
|
295
|
+
* mobile SDKs give us.
|
|
296
|
+
*
|
|
297
|
+
* The robust, TCC-correct way to capture a Mac app's marketing screens is an **XCUITest** run on the
|
|
298
|
+
* native-macOS build that drives the same launch-arg handle (`-marketingScreen <id>`) and saves each
|
|
299
|
+
* screen as an **XCTAttachment** — the sandboxed Mac test-runner can't write PNGs into your repo — then
|
|
300
|
+
* exports them from the `.xcresult`. That lives project-side (see Asilak's `Scripts/capture-mac.sh`,
|
|
301
|
+
* which already owns the one-time Accessibility grant + signing). zdymak still **composes** the Mac
|
|
302
|
+
* screenshots/reels from those captures (premium/bleed at 2880×1800, etc.).
|
|
303
|
+
*
|
|
304
|
+
* Folding that xcodebuild-test + xcresult-export flow into zdymak would just wrap an app-specific test
|
|
305
|
+
* with no real gain; a `screencapture` path would need a *second* TCC grant and is fragile. So Mac
|
|
306
|
+
* capture is deliberately left to the project's XCUITest script; zdymak captures iOS/Android/web (clean
|
|
307
|
+
* CLI) and composes every platform.
|
|
308
|
+
*/
|
|
309
|
+
export async function runCapture(flags) {
|
|
310
|
+
const platform = flags.platform;
|
|
311
|
+
if (platform === 'ios') return captureIos(flags);
|
|
312
|
+
if (platform === 'android') return captureAndroid(flags);
|
|
313
|
+
if (platform === 'web') return captureWeb(flags, { stripAlpha, sleep });
|
|
314
|
+
if (platform === 'macos' || platform === 'mac') {
|
|
315
|
+
throw new Error('macOS capture is intentionally out of scope (see the note above runCapture): use an XCUITest capture (e.g. Scripts/capture-mac.sh) that drives the launch-arg handle + exports .xcresult attachments, then `zdymak build` composes the Mac assets.');
|
|
316
|
+
}
|
|
317
|
+
throw new Error('capture needs --platform ios|android|web. For ios/android boot a simulator/emulator (or connect a device) first, then run a single --name <screen> or the full-workflow form (--bundle --arg --states); for web pass --url (+ --states).');
|
|
318
|
+
}
|