zdymak 0.4.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 +1 -1
- package/src/capture/index.mjs +89 -15
- package/src/cli.mjs +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zdymak",
|
|
3
|
-
"version": "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": {
|
package/src/capture/index.mjs
CHANGED
|
@@ -19,8 +19,9 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
19
19
|
import fs from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import { createCanvas, loadImage } from '@napi-rs/canvas';
|
|
22
|
+
import { rgbPngBuffer } from '../png.mjs';
|
|
22
23
|
|
|
23
|
-
/** Flatten
|
|
24
|
+
/** Flatten over black and rewrite as a NO-ALPHA RGB PNG (App Store & Play reject alpha on screenshots). */
|
|
24
25
|
async function stripAlpha(file) {
|
|
25
26
|
const img = await loadImage(file);
|
|
26
27
|
const c = createCanvas(img.width, img.height);
|
|
@@ -28,7 +29,7 @@ async function stripAlpha(file) {
|
|
|
28
29
|
ctx.fillStyle = '#000';
|
|
29
30
|
ctx.fillRect(0, 0, img.width, img.height);
|
|
30
31
|
ctx.drawImage(img, 0, 0);
|
|
31
|
-
fs.writeFileSync(file, c
|
|
32
|
+
fs.writeFileSync(file, rgbPngBuffer(c)); // colour-type-2 PNG, no alpha channel
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
function sh(cmd, args) {
|
|
@@ -68,9 +69,16 @@ function bootIosSim(flags) {
|
|
|
68
69
|
return udid;
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
const simctlOk = () => spawnSync('xcrun', ['simctl', 'help'], { stdio: 'ignore' }).status === 0;
|
|
73
|
+
|
|
71
74
|
async function captureIos(flags) {
|
|
72
|
-
if (
|
|
73
|
-
|
|
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
|
+
}
|
|
74
82
|
}
|
|
75
83
|
const outDir = path.resolve(flags.out || 'shots');
|
|
76
84
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -93,7 +101,7 @@ async function captureIos(flags) {
|
|
|
93
101
|
}
|
|
94
102
|
const states = flags.states.split(',').map((s) => s.trim()).filter(Boolean);
|
|
95
103
|
const suffix = flags.suffix || '';
|
|
96
|
-
const settle = Number(flags.settle ||
|
|
104
|
+
const settle = Number(flags.settle || 4);
|
|
97
105
|
const udid = bootIosSim(flags);
|
|
98
106
|
|
|
99
107
|
if (flags.build !== undefined) {
|
|
@@ -108,9 +116,10 @@ async function captureIos(flags) {
|
|
|
108
116
|
sh('xcrun', ['simctl', 'install', udid, app]);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
// Pin
|
|
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).
|
|
112
121
|
spawnSync('xcrun', ['simctl', 'status_bar', udid, 'override', '--time', '9:41',
|
|
113
|
-
'--batteryState', '
|
|
122
|
+
'--batteryState', 'discharging', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'], { stdio: 'ignore' });
|
|
114
123
|
|
|
115
124
|
console.log(`▶︎ Driving ${states.length} screens via "${flags.arg} <id>" on ${flags.bundle}…`);
|
|
116
125
|
for (const st of states) {
|
|
@@ -133,6 +142,20 @@ async function captureIos(flags) {
|
|
|
133
142
|
console.log(`✓ ${out} (alpha stripped, store-ready)`);
|
|
134
143
|
}
|
|
135
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
|
+
|
|
136
159
|
async function captureAndroid(flags) {
|
|
137
160
|
if (spawnSync('adb', ['version'], { stdio: 'ignore' }).status !== 0) {
|
|
138
161
|
throw new Error('adb not found — install Android platform-tools and connect a device/emulator.');
|
|
@@ -151,18 +174,69 @@ async function captureAndroid(flags) {
|
|
|
151
174
|
return;
|
|
152
175
|
}
|
|
153
176
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
+
}
|
|
161
211
|
}
|
|
162
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
|
+
*/
|
|
163
234
|
export async function runCapture(flags) {
|
|
164
235
|
const platform = flags.platform;
|
|
165
236
|
if (platform === 'ios') return captureIos(flags);
|
|
166
237
|
if (platform === 'android') return captureAndroid(flags);
|
|
167
|
-
|
|
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).');
|
|
168
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('--'))
|
|
27
|
-
|
|
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
|
}
|