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.
- package/LICENSE +21 -21
- package/README.md +733 -211
- package/SKILL.md +183 -84
- package/bin/zdymak.mjs +3 -3
- package/package.json +66 -40
- package/src/canvas.mjs +116 -116
- package/src/capture/index.mjs +318 -242
- package/src/capture/web.mjs +137 -0
- package/src/cli.mjs +298 -165
- package/src/config.mjs +159 -96
- package/src/destinations.mjs +103 -0
- package/src/easings.mjs +74 -0
- package/src/effects.mjs +335 -0
- package/src/encode.mjs +70 -44
- package/src/fonts.mjs +42 -42
- package/src/frames.mjs +186 -168
- package/src/graphic.mjs +107 -77
- package/src/index.mjs +27 -12
- package/src/montage.mjs +428 -0
- package/src/png.mjs +50 -50
- package/src/premium.mjs +362 -351
- package/src/reel.mjs +366 -245
- package/src/screenshots.mjs +118 -66
- package/src/specs.mjs +123 -88
- package/src/statusbar.mjs +163 -0
- package/src/still.mjs +72 -62
- package/src/transitions.mjs +653 -0
- package/src/validate.mjs +98 -0
- package/src/video.mjs +243 -240
- package/types/index.d.ts +559 -0
|
@@ -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>]
|
|
5
|
-
* zdymak video [--config <path>] [--target <ids>] [--out <dir>]
|
|
6
|
-
* zdymak screenshots [--config <path>] [--out <dir>]
|
|
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
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
console.log('
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
console.log(`zdymak
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
}
|