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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Web capture driver (Playwright) — the browser sibling of the iOS/Android drivers.
3
+ *
4
+ * A web app's "handle" for reaching a seeded screen is simply its URL, so `--states` here is a list of
5
+ * paths (or absolute URLs) rather than launch-arg ids:
6
+ *
7
+ * zdymak capture --platform web --url http://localhost:3000 \
8
+ * --states /,/today,/study --suffix -light --out marketing/web/captures
9
+ *
10
+ * Playwright is an OPTIONAL dependency: it pulls a browser binary (hundreds of MB), which nobody
11
+ * capturing only iOS/Android should pay for. It's imported lazily, with an install hint when absent.
12
+ *
13
+ * Marketing shots must be reproducible — a re-run should differ only where the UI did. So every page is
14
+ * loaded with animations disabled, `prefers-reduced-motion: reduce`, a fixed viewport + device scale
15
+ * factor, and the shot waits for fonts to finish loading (a webfont swapping in one frame late is the
16
+ * classic source of "why is this screenshot different").
17
+ */
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ /** Lazily load Playwright, turning the module-not-found case into an actionable message. */
22
+ async function loadPlaywright() {
23
+ try {
24
+ return await import('playwright');
25
+ } catch {
26
+ throw new Error(
27
+ 'web capture needs Playwright (optional dependency — it downloads a browser).\n' +
28
+ ' npm i -D playwright && npx playwright install chromium',
29
+ );
30
+ }
31
+ }
32
+
33
+ /**
34
+ * `/` → `home`, `/today` → `today`, `/decks/new` → `decks-new`, `?a=1` dropped. Absolute URLs use their
35
+ * pathname. Keeps capture filenames stable and scene-id-shaped, so they line up with a `scenes` list.
36
+ */
37
+ export function stateToName(state) {
38
+ const p = (state.includes('://') ? new URL(state).pathname : state.split('?')[0].split('#')[0])
39
+ .replace(/^\/+|\/+$/g, '')
40
+ .replace(/\.html?$/i, ''); // static exports (`/today.html`) get the same id as a routed `/today`
41
+ const name = p ? p.replace(/[/\\]+/g, '-').replace(/[^\w.-]+/g, '-') : 'home';
42
+ return name === 'index' ? 'home' : name; // `/index.html` is the landing page, same as `/`
43
+ }
44
+
45
+ /** Join a base URL with a state that may be a path or an absolute URL. */
46
+ export function resolveUrl(base, state) {
47
+ if (state.includes('://')) return state;
48
+ if (!base) throw new Error(`web capture: --url is required to resolve the relative state "${state}".`);
49
+ return new URL(state, base).href;
50
+ }
51
+
52
+ const parseViewport = (v) => {
53
+ const m = /^(\d+)\s*[x×]\s*(\d+)$/i.exec(String(v || '').trim());
54
+ if (!m) throw new Error(`--viewport must look like 1280x800 (got "${v}").`);
55
+ return { width: Number(m[1]), height: Number(m[2]) };
56
+ };
57
+
58
+ export async function captureWeb(flags, { stripAlpha, sleep }) {
59
+ const { chromium, devices } = await loadPlaywright();
60
+
61
+ const outDir = path.resolve(flags.out || 'shots');
62
+ fs.mkdirSync(outDir, { recursive: true });
63
+ if (flags.clean) {
64
+ let cleared = 0;
65
+ for (const f of fs.readdirSync(outDir)) {
66
+ if (/\.png$/i.test(f)) { fs.rmSync(path.join(outDir, f), { force: true }); cleared++; }
67
+ }
68
+ console.log(`🧹 cleaned ${cleared} stale capture(s) in ${path.relative(process.cwd(), outDir) || outDir}`);
69
+ }
70
+
71
+ // A Playwright device descriptor (`--device "iPhone 15 Pro"`) brings its own viewport, scale factor and
72
+ // user agent — the honest way to shoot a mobile-web screen. Otherwise: an explicit desktop viewport.
73
+ const descriptor = flags.device ? devices[flags.device] : undefined;
74
+ if (flags.device && !descriptor) {
75
+ throw new Error(`--device "${flags.device}" is not a Playwright device. Try e.g. "iPhone 15 Pro", "Pixel 7", "iPad Pro 11".`);
76
+ }
77
+ const contextOptions = {
78
+ ...(descriptor || {
79
+ viewport: parseViewport(flags.viewport || '1280x800'),
80
+ deviceScaleFactor: Number(flags.dsf || 2), // 2 = retina-sharp; store shots get downscaled, never upscaled
81
+ }),
82
+ colorScheme: flags.theme === 'dark' ? 'dark' : 'light',
83
+ reducedMotion: 'reduce',
84
+ locale: flags.locale || undefined,
85
+ };
86
+
87
+ const browser = await chromium.launch();
88
+ const context = await browser.newContext(contextOptions);
89
+ // Kill CSS/SMIL animation and caret blink outright — `reducedMotion` is only a hint the app may ignore.
90
+ await context.addInitScript(() => {
91
+ const css = `*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;
92
+ transition-duration:0s!important;transition-delay:0s!important;caret-color:transparent!important}`;
93
+ document.addEventListener('DOMContentLoaded', () => {
94
+ const s = document.createElement('style');
95
+ s.textContent = css;
96
+ document.head.appendChild(s);
97
+ });
98
+ });
99
+ const page = await context.newPage();
100
+
101
+ const settle = Number(flags.settle ?? 1);
102
+ const shoot = async (url, file) => {
103
+ await page.goto(url, { waitUntil: 'networkidle' });
104
+ if (flags.wait) await page.waitForSelector(String(flags.wait), { state: 'visible' });
105
+ // Webfonts + lazy images are the two things that land after `networkidle` and silently change a shot.
106
+ await page.evaluate(() => document.fonts?.ready);
107
+ await page.evaluate(() => Promise.all(
108
+ Array.from(document.images).filter((i) => !i.complete).map((i) => i.decode().catch(() => {})),
109
+ ));
110
+ if (settle) await sleep(settle * 1000);
111
+ await page.screenshot({ path: file, fullPage: !!flags['full-page'] });
112
+ await stripAlpha(file); // stores reject transparency
113
+ };
114
+
115
+ try {
116
+ const suffix = flags.suffix || '';
117
+ if (flags.states) {
118
+ const states = String(flags.states).split(',').map((s) => s.trim()).filter(Boolean);
119
+ const { width, height } = page.viewportSize() || {};
120
+ console.log(`▶︎ Capturing ${states.length} page(s) at ${width}×${height}${flags.device ? ` (${flags.device})` : ''}…`);
121
+ for (const state of states) {
122
+ const file = path.join(outDir, `${stateToName(state)}${suffix}.png`);
123
+ await shoot(resolveUrl(flags.url, state), file);
124
+ console.log(` ✓ ${path.basename(file)}`);
125
+ }
126
+ console.log(`Done → ${outDir}`);
127
+ } else {
128
+ if (!flags.url) throw new Error('web capture needs --url <page> (plus --states for the full workflow).');
129
+ const file = path.join(outDir, `${flags.name || stateToName(flags.url)}${suffix}.png`);
130
+ await shoot(flags.url, file);
131
+ console.log(`✓ ${file} (alpha stripped, store-ready)`);
132
+ }
133
+ } finally {
134
+ await context.close();
135
+ await browser.close();
136
+ }
137
+ }
package/src/cli.mjs CHANGED
@@ -1,165 +1,298 @@
1
- /**
2
- * zdymak CLI.
3
- *
4
- * zdymak build [--config <path>] [--out <dir>] # EVERYTHING in the config: videos + screenshots
5
- * zdymak video [--config <path>] [--target <ids>] [--out <dir>]
6
- * zdymak screenshots [--config <path>] [--out <dir>] # per-device store screenshots
7
- * zdymak specs # print the store spec matrix
8
- * zdymak capture --platform ios|android --name <screen> [--record] [--out <dir>]
9
- * zdymak help
10
- */
11
- import path from 'node:path';
12
- import { registerFonts } from './fonts.mjs';
13
- import { loadConfig } from './config.mjs';
14
- import { buildVideo } from './video.mjs';
15
- import { buildReel } from './reel.mjs';
16
- import { buildPremium } from './premium.mjs';
17
- import { buildDeviceScreenshots } from './screenshots.mjs';
18
- import { VIDEO_TARGETS, IMAGE_TARGETS, videoTarget } from './specs.mjs';
19
- import { runCapture } from './capture/index.mjs';
20
-
21
- function parseFlags(argv) {
22
- const flags = {};
23
- const rest = [];
24
- for (let i = 0; i < argv.length; i++) {
25
- const a = argv[i];
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
- }
35
- }
36
- return { flags, rest };
37
- }
38
-
39
- const DEFAULT_CONFIG = 'zdymak.config.mjs';
40
-
41
- async function open(flags) {
42
- const cfg = await loadConfig(flags.config || DEFAULT_CONFIG);
43
- registerFonts(cfg.brand.fontPaths);
44
- const outDir = flags.out ? path.resolve(flags.out) : cfg.out;
45
- return { cfg, outDir };
46
- }
47
-
48
- /** Build one video target (optionally at an overridden device size / scene set / theme). */
49
- async function buildVideoTarget({ id, scenes, brand, cfg, outFile, size, theme }) {
50
- const base = videoTarget(id);
51
- const spec = size ? { ...base, w: size[0], h: size[1] } : base;
52
- const build = spec.style === 'reel' ? buildReel : spec.style === 'premium' ? buildPremium : buildVideo;
53
- const tag = spec.style ? `, ${spec.style}` : '';
54
- process.stdout.write(` • ${id} (${spec.w}×${spec.h}${tag}${cfg.music ? ', ♪' : ''}) … `);
55
- const { totalDur, warnings } = await build({
56
- scenes, spec, brand, outFile,
57
- sceneDur: cfg.sceneDur, xfade: cfg.xfade, timing: cfg.timing,
58
- theme: theme ?? cfg.theme, music: cfg.music,
59
- });
60
- console.log(`${totalDur.toFixed(1)}s ${path.relative(process.cwd(), outFile)}`);
61
- for (const w of warnings) console.warn(` ⚠︎ ${w}`);
62
- }
63
-
64
- async function cmdVideo(flags) {
65
- const { cfg, outDir } = await open(flags);
66
- const targets = flags.target ? flags.target.split(',').map((s) => s.trim()) : cfg.targets;
67
- console.log(`zdymak video ${cfg.scenes.length} scenes → ${targets.join(', ')}`);
68
- for (const id of targets) {
69
- await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
70
- }
71
- console.log('Done.');
72
- }
73
-
74
- async function cmdScreenshots(flags) {
75
- const { cfg, outDir } = await open(flags);
76
- if (!cfg.devices.length) {
77
- console.warn('No `devices` in the config — nothing to screenshot. (See README: devices map.)');
78
- return;
79
- }
80
- console.log(`zdymak screenshots ${cfg.devices.length} device group(s)`);
81
- for (const device of cfg.devices) {
82
- const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
83
- console.log(` ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}×${written[0].H}…)` : ' no captures found, skipped'}`);
84
- }
85
- console.log('Done.');
86
- }
87
-
88
- async function cmdBuild(flags) {
89
- const { cfg, outDir } = await open(flags);
90
- // 1) shared (phone) video targets from the top-level scenes
91
- if (cfg.targets.length && cfg.scenes.length) {
92
- console.log('zdymak build • videos');
93
- for (const id of cfg.targets) {
94
- await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
95
- }
96
- }
97
- // 2) per-device videos + screenshots
98
- for (const device of cfg.devices) {
99
- if (device.videos.length) {
100
- console.log(`zdymak build ${device.name} videos`);
101
- for (const v of device.videos) {
102
- await buildVideoTarget({ id: v.target, scenes: device.scenes, brand: cfg.brand, cfg, size: v.size, theme: device.theme, outFile: path.join(outDir, `${device.name}-${v.target}.mp4`) });
103
- }
104
- }
105
- if (device.screenshots.length) {
106
- const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
107
- console.log(` ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
108
- }
109
- }
110
- console.log('Done.');
111
- }
112
-
113
- function cmdSpecs() {
114
- console.log('\nVIDEO targets (produce an .mp4):');
115
- for (const [id, s] of Object.entries(VIDEO_TARGETS)) {
116
- const dur = s.minSec ? `${s.minSec}–${s.maxSec}s` : 'any length';
117
- console.log(` ${id.padEnd(20)} ${s.w}×${s.h} @${s.fps} H.264 ${s.profile}@${s.level} ${dur} — ${s.store}`);
118
- }
119
- console.log('\nIMAGE targets (store screenshots — `zdymak screenshots` / `build`):');
120
- for (const [id, s] of Object.entries(IMAGE_TARGETS)) {
121
- const dim = s.accepts ? s.accepts.map((a) => a.join('×')).join(' | ') : `${s.w}×${s.h}`;
122
- console.log(` ${id.padEnd(24)} ${dim}${s.alpha === false ? ' (no alpha)' : ''} — ${s.store}`);
123
- }
124
- console.log('');
125
- }
126
-
127
- function cmdHelp() {
128
- console.log(`zdymak premium App Store, Google Play & social videos + screenshots, from your captures
129
-
130
- Usage:
131
- zdymak build [--config <path>] [--out <dir>] # everything: videos + per-device screenshots
132
- zdymak video [--config <path>] [--target <ids>] [--out <dir>]
133
- zdymak screenshots [--config <path>] [--out <dir>]
134
- zdymak specs
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
139
- zdymak help
140
-
141
- Defaults: --config ${DEFAULT_CONFIG}. Needs ffmpeg on PATH (or $FFMPEG).
142
- README.md documents the config (brand, scenes, targets, theme, music, devices); SKILL.md is for agents.`);
143
- }
144
-
145
- export async function run(argv = process.argv.slice(2)) {
146
- const [cmd, ...rest0] = argv;
147
- const { flags, rest } = parseFlags(rest0);
148
- try {
149
- switch (cmd) {
150
- case 'build': await cmdBuild(flags); break;
151
- case 'video': await cmdVideo(flags); break;
152
- case 'screenshots': await cmdScreenshots(flags); break;
153
- case 'specs': cmdSpecs(); break;
154
- case 'capture': await runCapture(flags, rest); break;
155
- case 'help': case undefined: case '--help': case '-h': cmdHelp(); break;
156
- default:
157
- console.error(`Unknown command "${cmd}".\n`);
158
- cmdHelp();
159
- process.exitCode = 1;
160
- }
161
- } catch (e) {
162
- console.error(`\n✗ ${e.message}`);
163
- process.exitCode = 1;
164
- }
165
- }
1
+ /**
2
+ * zdymak CLI.
3
+ *
4
+ * zdymak build [--config <path>] [--out <dir>] [--locale <ids>] # EVERYTHING: videos + screenshots
5
+ * zdymak video [--config <path>] [--target <ids>] [--out <dir>]
6
+ * zdymak screenshots [--config <path>] [--out <dir>] [--locale <ids>] # per-device store screenshots
7
+ * zdymak specs # print the store spec matrix
8
+ * zdymak capture --platform ios|android --name <screen> [--record] [--out <dir>]
9
+ * zdymak help
10
+ */
11
+ import path from 'node:path';
12
+ import fs from 'node:fs';
13
+ import { registerFonts } from './fonts.mjs';
14
+ import { loadConfig } from './config.mjs';
15
+ import { buildVideo } from './video.mjs';
16
+ import { buildReel } from './reel.mjs';
17
+ import { buildPremium } from './premium.mjs';
18
+ import { buildMontage } from './montage.mjs';
19
+ import { buildDeviceScreenshots, localizeScenes, localizeBrand, untranslatedScenes } from './screenshots.mjs';
20
+ import { VIDEO_TARGETS, IMAGE_TARGETS, videoTarget } from './specs.mjs';
21
+ import { runCapture } from './capture/index.mjs';
22
+ import { resolveVideo, paletteAt } from './destinations.mjs';
23
+ import { validateVideo, validateImage } from './validate.mjs';
24
+
25
+ function parseFlags(argv) {
26
+ const flags = {};
27
+ const rest = [];
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a.startsWith('--')) {
31
+ const next = argv[i + 1];
32
+ // Boolean flag when followed by nothing or another --flag (e.g. `--build --project …`); else it
33
+ // takes the next token as its value. Single-dash values like `-marketingScreen` ARE values.
34
+ if (next === undefined || next.startsWith('--')) flags[a.slice(2)] = true;
35
+ else flags[a.slice(2)] = argv[++i];
36
+ } else {
37
+ rest.push(a);
38
+ }
39
+ }
40
+ return { flags, rest };
41
+ }
42
+
43
+ const DEFAULT_CONFIG = 'zdymak.config.mjs';
44
+
45
+ async function open(flags) {
46
+ const cfg = await loadConfig(flags.config || DEFAULT_CONFIG);
47
+ registerFonts(cfg.brand.fontPaths);
48
+ const outDir = flags.out ? path.resolve(flags.out) : cfg.out;
49
+ // --clean wipes the output folder first, so a run can't leave stale assets from a removed target/scene
50
+ // behind (every screenshot/video in the folder is then freshly produced by THIS run).
51
+ if (flags.clean && fs.existsSync(outDir)) {
52
+ fs.rmSync(outDir, { recursive: true, force: true });
53
+ console.log(`🧹 cleaned ${path.relative(process.cwd(), outDir) || outDir}`);
54
+ }
55
+ return { cfg, outDir };
56
+ }
57
+
58
+ /** Warn when the audio expectation and the config disagree: a video/reel that should carry a music score
59
+ * has none (Apple recommends a single score for continuity), or `music` is set where nothing consumes it. */
60
+ function warnAudio({ videoCount, hasMusic, label, silentByDesign }) {
61
+ // Never nag about a Play promo: our own guidance is to keep it silent unless the track is cleared,
62
+ // because a ContentID claim can force ads onto a listing video, which Play forbids.
63
+ if (silentByDesign) return;
64
+ if (videoCount > 0 && !hasMusic) {
65
+ console.warn(`⚠︎ audio: ${label} ${videoCount === 1 ? 'plays' : 'play'} SILENT no music. Apple recommends a single music score for continuity; set \`music.path\`.`);
66
+ }
67
+ if (videoCount === 0 && hasMusic) {
68
+ console.warn('⚠︎ audio: `music.path` is set but this run builds no video/reel — the audio is ignored (screenshots have no sound).');
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Build one video. `entry` is either the `{ target }` shorthand or the split
74
+ * `{ destination, preset, transitions, effects }` form — DESTINATION decides what's accepted, PRESET
75
+ * decides how it looks, and a `transitions`/`effects` list lets the caller supply its own vocabulary
76
+ * instead of the preset's defaults.
77
+ */
78
+ async function buildVideoTarget({ entry, scenes, brand, cfg, outFile, size, theme, flags }) {
79
+ const r = resolveVideo(entry);
80
+ const base = r.destination;
81
+ const useSize = size || r.size;
82
+ const spec = useSize ? { ...base, w: useSize[0], h: useSize[1] } : base;
83
+ const build = r.engine === 'reel' ? buildReel : r.engine === 'premium' ? buildPremium : buildVideo;
84
+ const id = r.id;
85
+ // A caller-supplied palette overrides the per-scene default for scenes that don't name their own.
86
+ if (r.transitions || r.effects) {
87
+ scenes = scenes.map((sc, i) => ({
88
+ ...sc,
89
+ cut: sc.cut ?? paletteAt(r.transitions, i, undefined),
90
+ effect: sc.effect ?? paletteAt(r.effects, i, undefined),
91
+ }));
92
+ }
93
+ const tag = r.preset !== 'full-bleed' ? `, ${r.preset}` : '';
94
+ process.stdout.write(` • ${id} (${spec.w}×${spec.h}${tag}${cfg.music ? ', ♪' : ''}) … `);
95
+ const { totalDur, warnings } = await build({
96
+ scenes, spec, brand, outFile,
97
+ sceneDur: cfg.sceneDur, xfade: cfg.xfade, timing: cfg.timing,
98
+ theme: theme ?? cfg.theme, music: cfg.music,
99
+ });
100
+ console.log(`${totalDur.toFixed(1)}s ${path.relative(process.cwd(), outFile)}`);
101
+ for (const w of warnings) console.warn(` ⚠︎ ${w}`);
102
+ // Measure the artefact, don't trust the intent: a renderer or encoder bug is caught here.
103
+ validateVideo({ file: outFile, destination: base, size: useSize, force: !!flags?.force });
104
+ }
105
+
106
+ async function cmdVideo(flags) {
107
+ const { cfg, outDir } = await open(flags);
108
+ const targets = flags.target ? flags.target.split(',').map((s) => s.trim()) : cfg.targets;
109
+ console.log(`zdymak video • ${cfg.scenes.length} scenes → ${targets.join(', ')}`);
110
+ for (const id of targets) {
111
+ await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
112
+ }
113
+ warnAudio({ videoCount: targets.length, hasMusic: !!cfg.music, label: 'videos' });
114
+ console.log('Done.');
115
+ }
116
+
117
+ async function cmdReel(flags) {
118
+ const { cfg, outDir } = await open(flags);
119
+ const reel = cfg.reel;
120
+ if (!reel?.segments?.length) {
121
+ console.warn('No `reel.segments` in the config — nothing to build. (See README: live-footage reel.)');
122
+ return;
123
+ }
124
+ const [w, h] = reel.size || [1080, 1920];
125
+ const spec = { w, h, fps: reel.fps || 30, profile: reel.profile || 'high', level: reel.level || '4.1' };
126
+ const outFile = path.join(outDir, `${flags.name || 'reel'}.mp4`);
127
+ fs.mkdirSync(outDir, { recursive: true });
128
+ console.log(`zdymak reel ${reel.segments.length} segment(s) ${w}×${h}${reel.music ? ', ♪' : ''}`);
129
+ const { totalDur } = await buildMontage({
130
+ segments: reel.segments, brand: cfg.brand, theme: reel.theme, spec, // reel theme (light default), NOT cfg.theme
131
+ music: reel.music, sceneDur: reel.sceneDur, bpm: reel.bpm, beatsPerCut: reel.beatsPerCut,
132
+ transition: reel.transition, xfadeDur: reel.xfadeDur, outFile,
133
+ });
134
+ console.log(` ✓ ${totalDur.toFixed(1)}s → ${path.relative(process.cwd(), outFile)}`);
135
+ warnAudio({ videoCount: 1, hasMusic: !!reel.music, label: 'the reel' });
136
+ console.log('Done.');
137
+ }
138
+
139
+ /**
140
+ * Which locales to render, from `captions` + an optional `--locale de,fr` filter. An unknown locale is an
141
+ * error, not a silent no-op a typo'd locale would otherwise look like a successful run that shipped
142
+ * nothing.
143
+ */
144
+ function localesFor(cfg, flags) {
145
+ const configured = Object.keys(cfg.captions || {});
146
+ if (!flags.locale) return configured;
147
+ const wanted = String(flags.locale).split(',').map((s) => s.trim()).filter(Boolean);
148
+ const unknown = wanted.filter((l) => !configured.includes(l));
149
+ if (unknown.length) {
150
+ throw new Error(
151
+ `--locale: no captions configured for ${unknown.join(', ')}. ` +
152
+ (configured.length ? `Configured: ${configured.join(', ')}.` : 'The config has no `captions` block.'),
153
+ );
154
+ }
155
+ return wanted;
156
+ }
157
+
158
+ /**
159
+ * Per-locale screenshot sets → `<out>/<locale>/<target>/…`, leaving the base (source-language) set where
160
+ * it already is. Screenshots only: stores take localized stills far more often than localized previews,
161
+ * and re-encoding every video per locale costs minutes each.
162
+ */
163
+ async function buildLocalizedScreenshots({ cfg, outDir, flags }) {
164
+ for (const locale of localesFor(cfg, flags)) {
165
+ const table = cfg.captions[locale];
166
+ console.log(`zdymak • ${locale} screenshots`);
167
+ const fellBack = new Set();
168
+ for (const device of cfg.devices) {
169
+ if (!device.screenshots.length) continue;
170
+ untranslatedScenes(device.scenes, table).forEach((id) => fellBack.add(id));
171
+ const written = await buildDeviceScreenshots({
172
+ device: { ...device, scenes: localizeScenes(device.scenes, table) },
173
+ brand: localizeBrand(cfg.brand, table),
174
+ theme: device.theme ?? cfg.stillTheme ?? cfg.theme,
175
+ outDir: path.join(outDir, locale),
176
+ force: !!flags.force,
177
+ });
178
+ console.log(` • ${device.name}: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
179
+ }
180
+ if (fellBack.size) {
181
+ console.log(` ↳ ${fellBack.size} scene(s) kept the base caption (no ${locale} translation): ${[...fellBack].join(', ')}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ async function cmdScreenshots(flags) {
187
+ const { cfg, outDir } = await open(flags);
188
+ if (!cfg.devices.length) {
189
+ console.warn('No `devices` in the config — nothing to screenshot. (See README: devices map.)');
190
+ return;
191
+ }
192
+ console.log(`zdymak screenshots • ${cfg.devices.length} device group(s)`);
193
+ for (const device of cfg.devices) {
194
+ const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir, force: !!flags.force });
195
+ console.log(` • ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}×${written[0].H}…)` : ' — no captures found, skipped'}`);
196
+ }
197
+ await buildLocalizedScreenshots({ cfg, outDir, flags });
198
+ warnAudio({ videoCount: 0, hasMusic: !!cfg.music, label: 'screenshots' });
199
+ console.log('Done.');
200
+ }
201
+
202
+ async function cmdBuild(flags) {
203
+ const { cfg, outDir } = await open(flags);
204
+ // 1) shared (phone) video targets from the top-level scenes
205
+ if (cfg.targets.length && cfg.scenes.length) {
206
+ console.log('zdymak build • videos');
207
+ for (const id of cfg.targets) {
208
+ await buildVideoTarget({ entry: id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`), flags });
209
+ }
210
+ }
211
+ // 2) per-device videos + screenshots
212
+ let deviceVideos = 0;
213
+ for (const device of cfg.devices) {
214
+ // A device the app doesn't ship (or hasn't captured yet) skips cleanly — same contract as its
215
+ // screenshots. Without this, one uncaptured device aborts the whole build for every other one.
216
+ const captured = device.scenes.filter((s) => fs.existsSync(s.image));
217
+ if (device.videos.length) {
218
+ if (!captured.length) {
219
+ console.log(` • ${device.name} videos: skipped — no captures found`);
220
+ } else {
221
+ console.log(`zdymak build • ${device.name} videos`);
222
+ for (const v of device.videos) {
223
+ const vid = resolveVideo(v);
224
+ await buildVideoTarget({ entry: v, scenes: captured, brand: cfg.brand, cfg, size: v.size, theme: v.theme ?? device.theme, outFile: path.join(outDir, `${device.name}-${vid.id}.mp4`), flags });
225
+ deviceVideos++;
226
+ }
227
+ }
228
+ }
229
+ if (device.screenshots.length) {
230
+ const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir, force: !!flags.force });
231
+ console.log(` • ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
232
+ }
233
+ }
234
+ // 3) localized screenshot sets (base set already written above)
235
+ await buildLocalizedScreenshots({ cfg, outDir, flags });
236
+
237
+ const videoCount = (cfg.targets.length && cfg.scenes.length ? cfg.targets.length : 0) + deviceVideos;
238
+ warnAudio({ videoCount, hasMusic: !!cfg.music, label: 'videos' });
239
+ console.log('Done.');
240
+ }
241
+
242
+ function cmdSpecs() {
243
+ console.log('\nVIDEO targets (produce an .mp4):');
244
+ for (const [id, s] of Object.entries(VIDEO_TARGETS)) {
245
+ const dur = s.minSec ? `${s.minSec}–${s.maxSec}s` : 'any length';
246
+ console.log(` ${id.padEnd(20)} ${s.w}×${s.h} @${s.fps} H.264 ${s.profile}@${s.level} ${dur} — ${s.store}`);
247
+ }
248
+ console.log('\nIMAGE targets (store screenshots — `zdymak screenshots` / `build`):');
249
+ for (const [id, s] of Object.entries(IMAGE_TARGETS)) {
250
+ const dim = s.accepts ? s.accepts.map((a) => a.join('×')).join(' | ') : `${s.w}×${s.h}`;
251
+ console.log(` ${id.padEnd(24)} ${dim}${s.alpha === false ? ' (no alpha)' : ''} — ${s.store}`);
252
+ }
253
+ console.log('');
254
+ }
255
+
256
+ function cmdHelp() {
257
+ console.log(`zdymak — premium App Store, Google Play & social videos + screenshots, from your captures
258
+
259
+ Usage:
260
+ zdymak build [--config <path>] [--out <dir>] [--clean] [--locale <ids>] [--force] # everything
261
+ zdymak video [--config <path>] [--target <ids>] [--out <dir>] [--clean]
262
+ zdymak reel [--config <path>] [--out <dir>] [--clean] # LIVE-FOOTAGE montage from clips/images
263
+ zdymak screenshots [--config <path>] [--out <dir>] [--clean] [--locale <ids>]
264
+ zdymak specs
265
+ zdymak capture --platform ios --bundle <id> --arg <handle> --states <a,b,c> [--suffix -light]
266
+ [--build --project <.xcodeproj> --scheme <name>] [--device <sim>] [--out <dir>] [--clean] [--keep]
267
+ # full workflow: start the app, drive each screen by a launch handle, snap store-ready PNGs
268
+ zdymak capture --platform ios|android --name <screen> # single snapshot of the booted device
269
+ zdymak help
270
+
271
+ Defaults: --config ${DEFAULT_CONFIG}. Needs ffmpeg on PATH (or $FFMPEG).
272
+ --clean: wipe the output folder first (capture clears stale PNGs but keeps the .dd build cache) — so the
273
+ folder ends up holding ONLY this run's assets, never a stale screenshot from a removed target/scene.
274
+ README.md documents the config (brand, scenes, targets, theme, music, devices); SKILL.md is for agents.`);
275
+ }
276
+
277
+ export async function run(argv = process.argv.slice(2)) {
278
+ const [cmd, ...rest0] = argv;
279
+ const { flags, rest } = parseFlags(rest0);
280
+ try {
281
+ switch (cmd) {
282
+ case 'build': await cmdBuild(flags); break;
283
+ case 'video': await cmdVideo(flags); break;
284
+ case 'reel': await cmdReel(flags); break;
285
+ case 'screenshots': await cmdScreenshots(flags); break;
286
+ case 'specs': cmdSpecs(); break;
287
+ case 'capture': await runCapture(flags, rest); break;
288
+ case 'help': case undefined: case '--help': case '-h': cmdHelp(); break;
289
+ default:
290
+ console.error(`Unknown command "${cmd}".\n`);
291
+ cmdHelp();
292
+ process.exitCode = 1;
293
+ }
294
+ } catch (e) {
295
+ console.error(`\n✗ ${e.message}`);
296
+ process.exitCode = 1;
297
+ }
298
+ }