zdymak 0.10.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/src/cli.mjs CHANGED
@@ -1,215 +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 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 } from './screenshots.mjs';
20
- import { VIDEO_TARGETS, IMAGE_TARGETS, videoTarget } from './specs.mjs';
21
- import { runCapture } from './capture/index.mjs';
22
-
23
- function parseFlags(argv) {
24
- const flags = {};
25
- const rest = [];
26
- for (let i = 0; i < argv.length; i++) {
27
- const a = argv[i];
28
- if (a.startsWith('--')) {
29
- const next = argv[i + 1];
30
- // Boolean flag when followed by nothing or another --flag (e.g. `--build --project …`); else it
31
- // takes the next token as its value. Single-dash values like `-marketingScreen` ARE values.
32
- if (next === undefined || next.startsWith('--')) flags[a.slice(2)] = true;
33
- else flags[a.slice(2)] = argv[++i];
34
- } else {
35
- rest.push(a);
36
- }
37
- }
38
- return { flags, rest };
39
- }
40
-
41
- const DEFAULT_CONFIG = 'zdymak.config.mjs';
42
-
43
- async function open(flags) {
44
- const cfg = await loadConfig(flags.config || DEFAULT_CONFIG);
45
- registerFonts(cfg.brand.fontPaths);
46
- const outDir = flags.out ? path.resolve(flags.out) : cfg.out;
47
- // --clean wipes the output folder first, so a run can't leave stale assets from a removed target/scene
48
- // behind (every screenshot/video in the folder is then freshly produced by THIS run).
49
- if (flags.clean && fs.existsSync(outDir)) {
50
- fs.rmSync(outDir, { recursive: true, force: true });
51
- console.log(`🧹 cleaned ${path.relative(process.cwd(), outDir) || outDir}`);
52
- }
53
- return { cfg, outDir };
54
- }
55
-
56
- /** Warn when the audio expectation and the config disagree: a video/reel that should carry a music score
57
- * has none (Apple recommends a single score for continuity), or `music` is set where nothing consumes it. */
58
- function warnAudio({ videoCount, hasMusic, label }) {
59
- if (videoCount > 0 && !hasMusic) {
60
- console.warn(`āš ļøŽ audio: ${label} ${videoCount === 1 ? 'plays' : 'play'} SILENT — no music. Apple recommends a single music score for continuity; set \`music.path\`.`);
61
- }
62
- if (videoCount === 0 && hasMusic) {
63
- console.warn('āš ļøŽ audio: `music.path` is set but this run builds no video/reel — the audio is ignored (screenshots have no sound).');
64
- }
65
- }
66
-
67
- /** Build one video target (optionally at an overridden device size / scene set / theme). */
68
- async function buildVideoTarget({ id, scenes, brand, cfg, outFile, size, theme }) {
69
- const base = videoTarget(id);
70
- const spec = size ? { ...base, w: size[0], h: size[1] } : base;
71
- const build = spec.style === 'reel' ? buildReel : spec.style === 'premium' ? buildPremium : buildVideo;
72
- const tag = spec.style ? `, ${spec.style}` : '';
73
- process.stdout.write(` • ${id} (${spec.w}Ɨ${spec.h}${tag}${cfg.music ? ', ♪' : ''}) … `);
74
- const { totalDur, warnings } = await build({
75
- scenes, spec, brand, outFile,
76
- sceneDur: cfg.sceneDur, xfade: cfg.xfade, timing: cfg.timing,
77
- theme: theme ?? cfg.theme, music: cfg.music,
78
- });
79
- console.log(`${totalDur.toFixed(1)}s → ${path.relative(process.cwd(), outFile)}`);
80
- for (const w of warnings) console.warn(` āš ļøŽ ${w}`);
81
- }
82
-
83
- async function cmdVideo(flags) {
84
- const { cfg, outDir } = await open(flags);
85
- const targets = flags.target ? flags.target.split(',').map((s) => s.trim()) : cfg.targets;
86
- console.log(`zdymak video • ${cfg.scenes.length} scenes → ${targets.join(', ')}`);
87
- for (const id of targets) {
88
- await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
89
- }
90
- warnAudio({ videoCount: targets.length, hasMusic: !!cfg.music, label: 'videos' });
91
- console.log('Done.');
92
- }
93
-
94
- async function cmdReel(flags) {
95
- const { cfg, outDir } = await open(flags);
96
- const reel = cfg.reel;
97
- if (!reel?.segments?.length) {
98
- console.warn('No `reel.segments` in the config — nothing to build. (See README: live-footage reel.)');
99
- return;
100
- }
101
- const [w, h] = reel.size || [1080, 1920];
102
- const spec = { w, h, fps: reel.fps || 30, profile: reel.profile || 'high', level: reel.level || '4.1' };
103
- const outFile = path.join(outDir, `${flags.name || 'reel'}.mp4`);
104
- fs.mkdirSync(outDir, { recursive: true });
105
- console.log(`zdymak reel • ${reel.segments.length} segment(s) → ${w}Ɨ${h}${reel.music ? ', ♪' : ''}`);
106
- const { totalDur } = await buildMontage({
107
- segments: reel.segments, brand: cfg.brand, theme: reel.theme, spec, // reel theme (light default), NOT cfg.theme
108
- music: reel.music, sceneDur: reel.sceneDur, bpm: reel.bpm, beatsPerCut: reel.beatsPerCut,
109
- transition: reel.transition, xfadeDur: reel.xfadeDur, outFile,
110
- });
111
- console.log(` āœ“ ${totalDur.toFixed(1)}s → ${path.relative(process.cwd(), outFile)}`);
112
- warnAudio({ videoCount: 1, hasMusic: !!reel.music, label: 'the reel' });
113
- console.log('Done.');
114
- }
115
-
116
- async function cmdScreenshots(flags) {
117
- const { cfg, outDir } = await open(flags);
118
- if (!cfg.devices.length) {
119
- console.warn('No `devices` in the config — nothing to screenshot. (See README: devices map.)');
120
- return;
121
- }
122
- console.log(`zdymak screenshots • ${cfg.devices.length} device group(s)`);
123
- for (const device of cfg.devices) {
124
- const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
125
- console.log(` • ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}Ɨ${written[0].H}…)` : ' — no captures found, skipped'}`);
126
- }
127
- warnAudio({ videoCount: 0, hasMusic: !!cfg.music, label: 'screenshots' });
128
- console.log('Done.');
129
- }
130
-
131
- async function cmdBuild(flags) {
132
- const { cfg, outDir } = await open(flags);
133
- // 1) shared (phone) video targets from the top-level scenes
134
- if (cfg.targets.length && cfg.scenes.length) {
135
- console.log('zdymak build • videos');
136
- for (const id of cfg.targets) {
137
- await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
138
- }
139
- }
140
- // 2) per-device videos + screenshots
141
- for (const device of cfg.devices) {
142
- if (device.videos.length) {
143
- console.log(`zdymak build • ${device.name} videos`);
144
- for (const v of device.videos) {
145
- 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`) });
146
- }
147
- }
148
- if (device.screenshots.length) {
149
- const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
150
- console.log(` • ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
151
- }
152
- }
153
- const videoCount = (cfg.targets.length && cfg.scenes.length ? cfg.targets.length : 0)
154
- + cfg.devices.reduce((a, d) => a + d.videos.length, 0);
155
- warnAudio({ videoCount, hasMusic: !!cfg.music, label: 'videos' });
156
- console.log('Done.');
157
- }
158
-
159
- function cmdSpecs() {
160
- console.log('\nVIDEO targets (produce an .mp4):');
161
- for (const [id, s] of Object.entries(VIDEO_TARGETS)) {
162
- const dur = s.minSec ? `${s.minSec}–${s.maxSec}s` : 'any length';
163
- console.log(` ${id.padEnd(20)} ${s.w}Ɨ${s.h} @${s.fps} H.264 ${s.profile}@${s.level} ${dur} — ${s.store}`);
164
- }
165
- console.log('\nIMAGE targets (store screenshots — `zdymak screenshots` / `build`):');
166
- for (const [id, s] of Object.entries(IMAGE_TARGETS)) {
167
- const dim = s.accepts ? s.accepts.map((a) => a.join('Ɨ')).join(' | ') : `${s.w}Ɨ${s.h}`;
168
- console.log(` ${id.padEnd(24)} ${dim}${s.alpha === false ? ' (no alpha)' : ''} — ${s.store}`);
169
- }
170
- console.log('');
171
- }
172
-
173
- function cmdHelp() {
174
- console.log(`zdymak — premium App Store, Google Play & social videos + screenshots, from your captures
175
-
176
- Usage:
177
- zdymak build [--config <path>] [--out <dir>] [--clean] # everything: videos + per-device screenshots
178
- zdymak video [--config <path>] [--target <ids>] [--out <dir>] [--clean]
179
- zdymak reel [--config <path>] [--out <dir>] [--clean] # LIVE-FOOTAGE montage from clips/images
180
- zdymak screenshots [--config <path>] [--out <dir>] [--clean]
181
- zdymak specs
182
- zdymak capture --platform ios --bundle <id> --arg <handle> --states <a,b,c> [--suffix -light]
183
- [--build --project <.xcodeproj> --scheme <name>] [--device <sim>] [--out <dir>] [--clean]
184
- # full workflow: start the app, drive each screen by a launch handle, snap store-ready PNGs
185
- zdymak capture --platform ios|android --name <screen> # single snapshot of the booted device
186
- zdymak help
187
-
188
- Defaults: --config ${DEFAULT_CONFIG}. Needs ffmpeg on PATH (or $FFMPEG).
189
- --clean: wipe the output folder first (capture clears stale PNGs but keeps the .dd build cache) — so the
190
- folder ends up holding ONLY this run's assets, never a stale screenshot from a removed target/scene.
191
- README.md documents the config (brand, scenes, targets, theme, music, devices); SKILL.md is for agents.`);
192
- }
193
-
194
- export async function run(argv = process.argv.slice(2)) {
195
- const [cmd, ...rest0] = argv;
196
- const { flags, rest } = parseFlags(rest0);
197
- try {
198
- switch (cmd) {
199
- case 'build': await cmdBuild(flags); break;
200
- case 'video': await cmdVideo(flags); break;
201
- case 'reel': await cmdReel(flags); break;
202
- case 'screenshots': await cmdScreenshots(flags); break;
203
- case 'specs': cmdSpecs(); break;
204
- case 'capture': await runCapture(flags, rest); break;
205
- case 'help': case undefined: case '--help': case '-h': cmdHelp(); break;
206
- default:
207
- console.error(`Unknown command "${cmd}".\n`);
208
- cmdHelp();
209
- process.exitCode = 1;
210
- }
211
- } catch (e) {
212
- console.error(`\nāœ— ${e.message}`);
213
- process.exitCode = 1;
214
- }
215
- }
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
+ }