zdymak 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zdymak",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Generate premium, spec-compliant App Store & Google Play preview videos (and store assets) from screenshots — one config-driven CLI across all your projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,23 +1,27 @@
1
1
  /**
2
- * Capture mode (mode B) — for devs who'd rather grab frames from a running app than hand-manage a
3
- * screenshots folder. Project-agnostic on purpose: it does NOT build your app (that's your toolchain).
4
- * It snapshots or screen-records whatever is on the BOOTED simulator / connected device, strips the
5
- * alpha channel (Apple & Play reject transparency on screenshots), and writes store-ready PNGs you then
6
- * reference from your config's `scenes`.
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.
7
5
  *
8
- * zdymak capture --platform ios --name welcome # snap the booted iOS simulator
9
- * zdymak capture --platform android --name welcome # snap the connected device/emulator
10
- * zdymak capture --platform ios --record --out shots/rec # screen-record (stop with Ctrl-C)
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.
11
13
  *
12
- * The two-mode contract: capture writes PNGs; the video engine consumes PNGs. So capture output drops
13
- * straight into `screenshotsDir`, and mode A (bring-your-own-screenshots) and mode B share one pipeline.
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)
14
17
  */
15
18
  import { spawn, spawnSync } from 'node:child_process';
16
19
  import fs from 'node:fs';
17
20
  import path from 'node:path';
18
21
  import { createCanvas, loadImage } from '@napi-rs/canvas';
22
+ import { rgbPngBuffer } from '../png.mjs';
19
23
 
20
- /** Flatten alpha over black and rewrite as an opaque PNG (App Store & Play reject alpha on screenshots). */
24
+ /** Flatten over black and rewrite as a NO-ALPHA RGB PNG (App Store & Play reject alpha on screenshots). */
21
25
  async function stripAlpha(file) {
22
26
  const img = await loadImage(file);
23
27
  const c = createCanvas(img.width, img.height);
@@ -25,18 +29,56 @@ async function stripAlpha(file) {
25
29
  ctx.fillStyle = '#000';
26
30
  ctx.fillRect(0, 0, img.width, img.height);
27
31
  ctx.drawImage(img, 0, 0);
28
- fs.writeFileSync(file, c.toBuffer('image/png'));
32
+ fs.writeFileSync(file, rgbPngBuffer(c)); // colour-type-2 PNG, no alpha channel
29
33
  }
30
34
 
31
35
  function sh(cmd, args) {
32
- const r = spawnSync(cmd, args, { encoding: 'utf8' });
36
+ const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
33
37
  if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim()}`);
34
38
  return r.stdout;
35
39
  }
40
+ const out2 = (cmd, args) => (spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).stdout || '');
41
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
42
+ const udidRe = /\(([0-9A-Fa-f-]{36})\)/;
43
+
44
+ /** Boot (or reuse/create) an iOS simulator → UDID. Prefers --udid, then an already-booted sim, then a
45
+ * device matching --device (default iPhone 16 Pro Max), creating one if needed. */
46
+ function bootIosSim(flags) {
47
+ if (flags.udid) {
48
+ spawnSync('xcrun', ['simctl', 'boot', flags.udid], { stdio: 'ignore' });
49
+ spawnSync('xcrun', ['simctl', 'bootstatus', flags.udid, '-b'], { stdio: 'ignore' });
50
+ return flags.udid;
51
+ }
52
+ if (!flags.device) {
53
+ const booted = out2('xcrun', ['simctl', 'list', 'devices', 'booted']).match(udidRe);
54
+ if (booted) return booted[1];
55
+ }
56
+ const name = flags.device || 'iPhone 16 Pro Max';
57
+ let udid = out2('xcrun', ['simctl', 'list', 'devices', 'available'])
58
+ .split('\n').find((l) => l.includes(name) && udidRe.test(l))?.match(udidRe)?.[1];
59
+ if (!udid) {
60
+ const devtype = out2('xcrun', ['simctl', 'list', 'devicetypes']).split('\n')
61
+ .find((l) => l.includes(name))?.match(/(com\.apple[^\s)]+)/)?.[1];
62
+ const runtime = out2('xcrun', ['simctl', 'list', 'runtimes', 'ios']).split('\n').reverse()
63
+ .find((l) => /com\.apple[^\s)]+/.test(l))?.match(/(com\.apple[^\s)]+)/)?.[1];
64
+ if (!devtype || !runtime) throw new Error(`Could not resolve a simulator for "${name}".`);
65
+ udid = sh('xcrun', ['simctl', 'create', 'zdymak-capture', devtype, runtime]).trim();
66
+ }
67
+ spawnSync('xcrun', ['simctl', 'boot', udid], { stdio: 'ignore' });
68
+ spawnSync('xcrun', ['simctl', 'bootstatus', udid, '-b'], { stdio: 'ignore' });
69
+ return udid;
70
+ }
71
+
72
+ const simctlOk = () => spawnSync('xcrun', ['simctl', 'help'], { stdio: 'ignore' }).status === 0;
36
73
 
37
74
  async function captureIos(flags) {
38
- if (spawnSync('xcrun', ['simctl', 'help'], { stdio: 'ignore' }).status !== 0) {
39
- throw new Error('xcrun/simctl not found install Xcode command-line tools.');
75
+ if (!simctlOk()) {
76
+ // Common when xcode-select points at the CommandLineTools (no simctl): fall back to Xcode.app.
77
+ const xc = '/Applications/Xcode.app/Contents/Developer';
78
+ if (!process.env.DEVELOPER_DIR && fs.existsSync(xc)) process.env.DEVELOPER_DIR = xc;
79
+ if (!simctlOk()) {
80
+ throw new Error('xcrun/simctl unavailable — point DEVELOPER_DIR at Xcode (e.g. xcode-select -s /Applications/Xcode.app).');
81
+ }
40
82
  }
41
83
  const outDir = path.resolve(flags.out || 'shots');
42
84
  fs.mkdirSync(outDir, { recursive: true });
@@ -50,12 +92,70 @@ async function captureIos(flags) {
50
92
  return;
51
93
  }
52
94
 
95
+ // FULL WORKFLOW: drive the app through screens by a launch-arg HANDLE, capturing each.
96
+ // zdymak capture --platform ios --bundle com.x.app --arg -screen --states a,b,c --suffix -light
97
+ // [--build --project X.xcodeproj --scheme S] [--device "iPhone 16 Pro Max"] [--settle 3]
98
+ if (flags.states) {
99
+ if (!flags.bundle || !flags.arg) {
100
+ throw new Error('state capture needs --bundle <id> and --arg <launch-handle> (e.g. -marketingScreen).');
101
+ }
102
+ const states = flags.states.split(',').map((s) => s.trim()).filter(Boolean);
103
+ const suffix = flags.suffix || '';
104
+ const settle = Number(flags.settle || 4);
105
+ const udid = bootIosSim(flags);
106
+
107
+ if (flags.build !== undefined) {
108
+ if (!flags.project || !flags.scheme) throw new Error('--build needs --project <.xcodeproj> and --scheme <name>.');
109
+ const dd = path.join(outDir, '.dd');
110
+ console.log(`▶︎ Building ${flags.scheme} for the simulator (this is the slow step)…`);
111
+ sh('xcodebuild', ['build', '-project', flags.project, '-scheme', flags.scheme, '-configuration', 'Debug',
112
+ '-destination', `id=${udid}`, '-derivedDataPath', dd, '-allowProvisioningUpdates']);
113
+ const app = out2('bash', ['-lc', `ls -dt "${dd}"/Build/Products/Debug-iphonesimulator/*.app 2>/dev/null | head -1`]).trim();
114
+ if (!app) throw new Error(`No .app under ${dd}/Build/Products/Debug-iphonesimulator`);
115
+ console.log(`▶︎ Installing ${path.basename(app)}…`);
116
+ sh('xcrun', ['simctl', 'install', udid, app]);
117
+ }
118
+
119
+ // Pin Apple's canonical marketing status bar: 9:41, full signal/wifi, FULL battery but NOT charging
120
+ // (a charging bolt reads as a simulator override; Apple's own shots show an unplugged full battery).
121
+ spawnSync('xcrun', ['simctl', 'status_bar', udid, 'override', '--time', '9:41',
122
+ '--batteryState', 'discharging', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'], { stdio: 'ignore' });
123
+
124
+ console.log(`▶︎ Driving ${states.length} screens via "${flags.arg} <id>" on ${flags.bundle}…`);
125
+ for (const st of states) {
126
+ spawnSync('xcrun', ['simctl', 'terminate', udid, flags.bundle], { stdio: 'ignore' });
127
+ sh('xcrun', ['simctl', 'launch', udid, flags.bundle, flags.arg, st]);
128
+ await sleep(settle * 1000);
129
+ const out = path.join(outDir, `${st}${suffix}.png`);
130
+ sh('xcrun', ['simctl', 'io', udid, 'screenshot', out]);
131
+ await stripAlpha(out);
132
+ console.log(` ✓ ${st}${suffix}.png`);
133
+ }
134
+ console.log(`Done → ${outDir}`);
135
+ return;
136
+ }
137
+
138
+ // Single snapshot of whatever is on the booted sim.
53
139
  const out = path.join(outDir, `${flags.name || 'shot'}.png`);
54
140
  sh('xcrun', ['simctl', 'io', 'booted', 'screenshot', out]);
55
141
  await stripAlpha(out);
56
142
  console.log(`✓ ${out} (alpha stripped, store-ready)`);
57
143
  }
58
144
 
145
+ /** Toggle Android SystemUI Demo Mode → a clean, Play-native status bar (Google's own convention):
146
+ * pinned clock, full battery UNPLUGGED (no charging), full signal/wifi, notifications hidden. */
147
+ function androidDemo(on, flags) {
148
+ const b = (...args) => spawnSync('adb', ['shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', ...args], { stdio: 'ignore' });
149
+ if (!on) return void b('-e', 'command', 'exit');
150
+ spawnSync('adb', ['shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1'], { stdio: 'ignore' });
151
+ b('-e', 'command', 'enter');
152
+ b('-e', 'command', 'clock', '-e', 'hhmm', (flags.time || '09:41').replace(':', ''));
153
+ b('-e', 'command', 'battery', '-e', 'level', '100', '-e', 'plugged', 'false'); // full, NOT charging
154
+ b('-e', 'command', 'network', '-e', 'wifi', 'show', '-e', 'level', '4');
155
+ b('-e', 'command', 'network', '-e', 'mobile', 'show', '-e', 'datatype', 'none', '-e', 'level', '4');
156
+ b('-e', 'command', 'notifications', '-e', 'visible', 'false');
157
+ }
158
+
59
159
  async function captureAndroid(flags) {
60
160
  if (spawnSync('adb', ['version'], { stdio: 'ignore' }).status !== 0) {
61
161
  throw new Error('adb not found — install Android platform-tools and connect a device/emulator.');
@@ -74,18 +174,69 @@ async function captureAndroid(flags) {
74
174
  return;
75
175
  }
76
176
 
77
- const name = flags.name || 'shot';
78
- const out = path.join(outDir, `${name}.png`);
79
- const buf = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
80
- if (buf.status !== 0) throw new Error(`adb screencap failed: ${buf.stderr}`);
81
- fs.writeFileSync(out, buf.stdout);
82
- await stripAlpha(out);
83
- console.log(`✓ ${out} (alpha stripped, store-ready)`);
177
+ const grab = async (file) => {
178
+ const b = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
179
+ if (b.status !== 0) throw new Error(`adb screencap failed: ${b.stderr}`);
180
+ fs.writeFileSync(file, b.stdout);
181
+ await stripAlpha(file);
182
+ };
183
+
184
+ androidDemo(true, flags); // clean marketing status bar (Google convention)
185
+ try {
186
+ // Full workflow: drive the app through screens via an intent-extra HANDLE (--component + --arg).
187
+ if (flags.states) {
188
+ if (!flags.component || !flags.arg) {
189
+ throw new Error('android state capture needs --component <pkg/activity> and --arg <extra-key>.');
190
+ }
191
+ const states = flags.states.split(',').map((s) => s.trim()).filter(Boolean);
192
+ const suffix = flags.suffix || '';
193
+ const settle = Number(flags.settle || 4);
194
+ console.log(`▶︎ Driving ${states.length} screens via "--es ${flags.arg} <id>" on ${flags.component}…`);
195
+ for (const st of states) {
196
+ sh('adb', ['shell', 'am', 'start', '-n', flags.component, '--es', flags.arg, st]);
197
+ await sleep(settle * 1000);
198
+ const out = path.join(outDir, `${st}${suffix}.png`);
199
+ await grab(out);
200
+ console.log(` ✓ ${st}${suffix}.png`);
201
+ }
202
+ console.log(`Done → ${outDir}`);
203
+ } else {
204
+ const out = path.join(outDir, `${flags.name || 'shot'}.png`);
205
+ await grab(out);
206
+ console.log(`✓ ${out} (alpha stripped, store-ready)`);
207
+ }
208
+ } finally {
209
+ androidDemo(false, flags);
210
+ }
84
211
  }
85
212
 
213
+ /**
214
+ * Platform dispatch. NOTE — there is intentionally NO `--platform macos` capture:
215
+ *
216
+ * macOS has no `simctl io screenshot` / `adb screencap` equivalent for snapshotting a *driven* app.
217
+ * Reading a specific native window's pixels requires a macOS TCC permission grant — either **Screen
218
+ * Recording** (for the `screencapture` CLI, which otherwise returns black frames) or **Accessibility**
219
+ * (to drive the UI + use XCUITest's own screenshot). Neither is a clean one-command capture like the
220
+ * mobile SDKs give us.
221
+ *
222
+ * The robust, TCC-correct way to capture a Mac app's marketing screens is an **XCUITest** run on the
223
+ * native-macOS build that drives the same launch-arg handle (`-marketingScreen <id>`) and saves each
224
+ * screen as an **XCTAttachment** — the sandboxed Mac test-runner can't write PNGs into your repo — then
225
+ * exports them from the `.xcresult`. That lives project-side (see Asilak's `Scripts/capture-mac.sh`,
226
+ * which already owns the one-time Accessibility grant + signing). zdymak still **composes** the Mac
227
+ * screenshots/reels from those captures (premium/bleed at 2880×1800, etc.).
228
+ *
229
+ * Folding that xcodebuild-test + xcresult-export flow into zdymak would just wrap an app-specific test
230
+ * with no real gain; a `screencapture` path would need a *second* TCC grant and is fragile. So Mac
231
+ * capture is deliberately left to the project's XCUITest script; zdymak captures iOS/Android (clean CLI)
232
+ * and composes every platform. (Web/Playwright capture is on the roadmap.)
233
+ */
86
234
  export async function runCapture(flags) {
87
235
  const platform = flags.platform;
88
236
  if (platform === 'ios') return captureIos(flags);
89
237
  if (platform === 'android') return captureAndroid(flags);
90
- throw new Error('capture needs --platform ios|android. Boot a simulator/emulator (or connect a device) first, navigate to the screen, then run capture --name <screen>.');
238
+ if (platform === 'macos' || platform === 'mac') {
239
+ 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.');
240
+ }
241
+ throw new Error('capture needs --platform 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).');
91
242
  }
package/src/cli.mjs CHANGED
@@ -23,8 +23,15 @@ function parseFlags(argv) {
23
23
  const rest = [];
24
24
  for (let i = 0; i < argv.length; i++) {
25
25
  const a = argv[i];
26
- if (a.startsWith('--')) flags[a.slice(2)] = argv[++i];
27
- else rest.push(a);
26
+ if (a.startsWith('--')) {
27
+ const next = argv[i + 1];
28
+ // Boolean flag when followed by nothing or another --flag (e.g. `--build --project …`); else it
29
+ // takes the next token as its value. Single-dash values like `-marketingScreen` ARE values.
30
+ if (next === undefined || next.startsWith('--')) flags[a.slice(2)] = true;
31
+ else flags[a.slice(2)] = argv[++i];
32
+ } else {
33
+ rest.push(a);
34
+ }
28
35
  }
29
36
  return { flags, rest };
30
37
  }
@@ -125,7 +132,10 @@ Usage:
125
132
  zdymak video [--config <path>] [--target <ids>] [--out <dir>]
126
133
  zdymak screenshots [--config <path>] [--out <dir>]
127
134
  zdymak specs
128
- zdymak capture --platform ios|android --name <screen> [--record] [--out <dir>]
135
+ zdymak capture --platform ios --bundle <id> --arg <handle> --states <a,b,c> [--suffix -light]
136
+ [--build --project <.xcodeproj> --scheme <name>] [--device <sim>] [--out <dir>]
137
+ # full workflow: start the app, drive each screen by a launch handle, snap store-ready PNGs
138
+ zdymak capture --platform ios|android --name <screen> # single snapshot of the booted device
129
139
  zdymak help
130
140
 
131
141
  Defaults: --config ${DEFAULT_CONFIG}. Needs ffmpeg on PATH (or $FFMPEG).