zdymak 0.4.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zdymak",
3
- "version": "0.4.0",
3
+ "version": "0.7.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": {
@@ -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 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). */
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.toBuffer('image/png'));
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 (spawnSync('xcrun', ['simctl', 'help'], { stdio: 'ignore' }).status !== 0) {
73
- 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
+ }
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 || 3);
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 the canonical 9:41 marketing status bar (best-effort).
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', 'charging', '--batteryLevel', '100', '--cellularBars', '4', '--wifiBars', '3'], { stdio: 'ignore' });
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 name = flags.name || 'shot';
155
- const out = path.join(outDir, `${name}.png`);
156
- const buf = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
157
- if (buf.status !== 0) throw new Error(`adb screencap failed: ${buf.stderr}`);
158
- fs.writeFileSync(out, buf.stdout);
159
- await stripAlpha(out);
160
- 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
+ }
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
- 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).');
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('--')) 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
  }
@@ -72,7 +79,7 @@ async function cmdScreenshots(flags) {
72
79
  }
73
80
  console.log(`zdymak screenshots • ${cfg.devices.length} device group(s)`);
74
81
  for (const device of cfg.devices) {
75
- const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
82
+ const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
76
83
  console.log(` • ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}×${written[0].H}…)` : ' — no captures found, skipped'}`);
77
84
  }
78
85
  console.log('Done.');
@@ -96,7 +103,7 @@ async function cmdBuild(flags) {
96
103
  }
97
104
  }
98
105
  if (device.screenshots.length) {
99
- const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
106
+ const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
100
107
  console.log(` • ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
101
108
  }
102
109
  }
package/src/config.mjs CHANGED
@@ -88,7 +88,8 @@ export async function loadConfig(configPath) {
88
88
  sceneDur: raw.sceneDur ?? 3.1,
89
89
  xfade: raw.xfade ?? 0.32,
90
90
  timing: raw.timing, // reel-mode timeline override { coldOpen, scene, endCard, xfade }
91
- theme: raw.theme, // premium-technique styling override (matte, vignette, label, cuts) — defaults apply
91
+ theme: raw.theme, // premium-technique styling override for VIDEOS (matte, vignette, label, cuts)
92
+ stillTheme: raw.stillTheme, // screenshot-only matte override; falls back to `theme` when unset
92
93
  out: path.resolve(baseDir, raw.out || 'store-assets'),
93
94
  baseDir,
94
95
  };
package/src/premium.mjs CHANGED
@@ -45,6 +45,8 @@ const DEFAULT_THEME = {
45
45
  cutHard: 0.05, // dissolve seconds when adjacent screens share a palette (≈ hard cut)
46
46
  cutSoft: 0.24, // dissolve seconds at a palette shift
47
47
  cutThreshold: 42, // RGB-distance above which a boundary counts as a palette shift
48
+ captionAnchor: 'bottom', // 'top' places the caption above the device (the bright store-shot layout)
49
+ fit: 'cover', // screenLayer image fit: 'cover' (fill + crop) | 'contain' (whole capture, matte margins)
48
50
  };
49
51
 
50
52
  /** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
@@ -110,8 +112,10 @@ function drawLabel(ctx, W, H, caption, th, alpha) {
110
112
  const titleSize = Math.round(Math.min(W, H * 0.9) * 0.05);
111
113
  const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
112
114
  const cx = W / 2;
113
- // Anchor higher on wide (landscape) aspects so the pill + subtitle clear the app's own bottom UI.
114
- let y = (W > H ? 0.7 : 0.82) * H;
115
+ // Bottom by default (anchored higher on wide/landscape so the pill clears the app's own bottom UI);
116
+ // captionAnchor:'top' puts it above the device the bright store-shot layout.
117
+ const topCaption = th.captionAnchor === 'top';
118
+ let y = topCaption ? H * 0.085 : (W > H ? 0.7 : 0.82) * H;
115
119
 
116
120
  if (caption.title && th.label) {
117
121
  ctx.font = font(titleSize, 'bold');
@@ -167,12 +171,29 @@ export function resolvePremiumTheme(brand, theme) {
167
171
  return th;
168
172
  }
169
173
 
170
- /** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas. */
174
+ /** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
175
+ * fit 'cover' fills the inset box (crops overflow); fit 'contain' shows the WHOLE capture — the card
176
+ * shrinks to the capture's aspect and floats with matte margins (correct for a Mac window on the matte,
177
+ * where a cover-crop would slice off the title bar / traffic lights). */
171
178
  function screenLayer(W, H, img, th) {
172
- const insetW = Math.round(W * th.inset);
173
- const insetH = Math.round(H * th.inset);
174
- const insetX = Math.round((W - insetW) / 2);
175
- const insetY = Math.round((H - insetH) / 2);
179
+ // Reserve headroom for a top caption so the window floats BELOW it (else it covers the headline).
180
+ const topCaption = th.captionAnchor === 'top';
181
+ const bandTop = topCaption ? H * 0.18 : 0;
182
+ const bandH = (topCaption ? 0.8 : 1) * H;
183
+ const boxW = Math.round(W * th.inset);
184
+ const boxH = Math.round(bandH * th.inset);
185
+ const ia = img.height / img.width;
186
+ let dw = boxW;
187
+ let dh = boxH;
188
+ if (th.fit === 'contain') {
189
+ dh = boxW * ia; // fit width, then clamp height so the whole capture fits the box
190
+ if (dh > boxH) {
191
+ dh = boxH;
192
+ dw = boxH / ia;
193
+ }
194
+ }
195
+ const dx = Math.round((W - dw) / 2);
196
+ const dy = Math.round(bandTop + (bandH - dh) / 2);
176
197
  const radius = Math.round(W * th.radius);
177
198
  const layer = createCanvas(W, H);
178
199
  const lctx = layer.getContext('2d');
@@ -180,14 +201,15 @@ function screenLayer(W, H, img, th) {
180
201
  lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
181
202
  lctx.shadowBlur = Math.round(W * 0.05);
182
203
  lctx.shadowOffsetY = Math.round(W * 0.012);
183
- roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
204
+ roundRectPath(lctx, dx, dy, dw, dh, radius);
184
205
  lctx.fillStyle = '#000';
185
206
  lctx.fill();
186
207
  lctx.restore();
187
208
  lctx.save();
188
- roundRectPath(lctx, insetX, insetY, insetW, insetH, radius);
209
+ roundRectPath(lctx, dx, dy, dw, dh, radius);
189
210
  lctx.clip();
190
- lctx.drawImage(coverCanvas(img, insetW, insetH), insetX, insetY);
211
+ // The card now matches the draw aspect, so cover here fills it exactly (no crop for 'contain').
212
+ lctx.drawImage(coverCanvas(img, dw, dh), dx, dy);
191
213
  lctx.restore();
192
214
  return layer;
193
215
  }
@@ -196,17 +218,22 @@ function screenLayer(W, H, img, th) {
196
218
  * drawn inside a device bezel floating on the matte; otherwise it's the plain rounded inset. */
197
219
  export function premiumStill({ W, H, img, caption, brand, theme, frame }) {
198
220
  const th = resolvePremiumTheme(brand, theme);
221
+ // Store-shot smart defaults (each overridable via `theme`): headline sits ON TOP; a frameless window
222
+ // (e.g. Mac) shows the WHOLE capture instead of cropping its title bar.
223
+ if (theme?.captionAnchor === undefined) th.captionAnchor = 'top';
224
+ if (!frame && theme?.fit === undefined) th.fit = 'contain';
199
225
  const c = createCanvas(W, H);
200
226
  const ctx = c.getContext('2d');
201
227
  paintMatte(ctx, W, H, th);
202
228
 
229
+ const topCaption = th.captionAnchor === 'top';
203
230
  const drawFrame = frame ? frameFor(frame) : null;
204
231
  if (drawFrame) {
205
- const cy = H * 0.42; // sit high so the framed body clears the bottom label
232
+ const cy = H * (topCaption ? 0.57 : 0.42); // drop the device when the caption sits on top
206
233
  if (frame === 'watch') {
207
234
  drawFrame(ctx, img, W / 2, cy, Math.min(W, H) * 0.6);
208
235
  } else {
209
- const budgetH = H * 0.64; // vertical room for the device body
236
+ const budgetH = H * (topCaption ? 0.66 : 0.64); // vertical room for the device body
210
237
  const screenW = Math.min(budgetH / (img.height / img.width), W * 0.8);
211
238
  drawFrame(ctx, img, W / 2, cy, screenW);
212
239
  }
@@ -27,8 +27,10 @@ export async function buildDeviceScreenshots({ device, brand, theme, outDir }) {
27
27
  const spec = IMAGE_TARGETS[shot.target];
28
28
  if (!spec) throw new Error(`Unknown image target "${shot.target}" (device: ${device.name})`);
29
29
  const [W, H] = targetSize(spec, shot.size);
30
- const style = shot.style || 'premium';
31
30
  const frame = shot.frame || inferFrame(shot.target); // device bezel for the `framed` style
31
+ // Infer the style from the target: a framed device (phone/tablet/watch) → 'framed'; a frameless
32
+ // target (Mac/desktop) → 'premium' (window on the matte). Overridable per shot (e.g. Watch → 'bleed').
33
+ const style = shot.style || (frame ? 'framed' : 'premium');
32
34
 
33
35
  // Feature graphic (Play banner) is a single branded image, not a per-scene shot.
34
36
  if (spec.graphic) {