zdymak 0.3.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 ADDED
@@ -0,0 +1,155 @@
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('--')) flags[a.slice(2)] = argv[++i];
27
+ else rest.push(a);
28
+ }
29
+ return { flags, rest };
30
+ }
31
+
32
+ const DEFAULT_CONFIG = 'zdymak.config.mjs';
33
+
34
+ async function open(flags) {
35
+ const cfg = await loadConfig(flags.config || DEFAULT_CONFIG);
36
+ registerFonts(cfg.brand.fontPaths);
37
+ const outDir = flags.out ? path.resolve(flags.out) : cfg.out;
38
+ return { cfg, outDir };
39
+ }
40
+
41
+ /** Build one video target (optionally at an overridden device size / scene set / theme). */
42
+ async function buildVideoTarget({ id, scenes, brand, cfg, outFile, size, theme }) {
43
+ const base = videoTarget(id);
44
+ const spec = size ? { ...base, w: size[0], h: size[1] } : base;
45
+ const build = spec.style === 'reel' ? buildReel : spec.style === 'premium' ? buildPremium : buildVideo;
46
+ const tag = spec.style ? `, ${spec.style}` : '';
47
+ process.stdout.write(` • ${id} (${spec.w}×${spec.h}${tag}${cfg.music ? ', ♪' : ''}) … `);
48
+ const { totalDur, warnings } = await build({
49
+ scenes, spec, brand, outFile,
50
+ sceneDur: cfg.sceneDur, xfade: cfg.xfade, timing: cfg.timing,
51
+ theme: theme ?? cfg.theme, music: cfg.music,
52
+ });
53
+ console.log(`${totalDur.toFixed(1)}s → ${path.relative(process.cwd(), outFile)}`);
54
+ for (const w of warnings) console.warn(` ⚠︎ ${w}`);
55
+ }
56
+
57
+ async function cmdVideo(flags) {
58
+ const { cfg, outDir } = await open(flags);
59
+ const targets = flags.target ? flags.target.split(',').map((s) => s.trim()) : cfg.targets;
60
+ console.log(`zdymak video • ${cfg.scenes.length} scenes → ${targets.join(', ')}`);
61
+ for (const id of targets) {
62
+ await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
63
+ }
64
+ console.log('Done.');
65
+ }
66
+
67
+ async function cmdScreenshots(flags) {
68
+ const { cfg, outDir } = await open(flags);
69
+ if (!cfg.devices.length) {
70
+ console.warn('No `devices` in the config — nothing to screenshot. (See README: devices map.)');
71
+ return;
72
+ }
73
+ console.log(`zdymak screenshots • ${cfg.devices.length} device group(s)`);
74
+ for (const device of cfg.devices) {
75
+ const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
76
+ console.log(` • ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}×${written[0].H}…)` : ' — no captures found, skipped'}`);
77
+ }
78
+ console.log('Done.');
79
+ }
80
+
81
+ async function cmdBuild(flags) {
82
+ const { cfg, outDir } = await open(flags);
83
+ // 1) shared (phone) video targets from the top-level scenes
84
+ if (cfg.targets.length && cfg.scenes.length) {
85
+ console.log('zdymak build • videos');
86
+ for (const id of cfg.targets) {
87
+ await buildVideoTarget({ id, scenes: cfg.scenes, brand: cfg.brand, cfg, outFile: path.join(outDir, `${id}.mp4`) });
88
+ }
89
+ }
90
+ // 2) per-device videos + screenshots
91
+ for (const device of cfg.devices) {
92
+ if (device.videos.length) {
93
+ console.log(`zdymak build • ${device.name} videos`);
94
+ for (const v of device.videos) {
95
+ 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`) });
96
+ }
97
+ }
98
+ if (device.screenshots.length) {
99
+ const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
100
+ console.log(` • ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
101
+ }
102
+ }
103
+ console.log('Done.');
104
+ }
105
+
106
+ function cmdSpecs() {
107
+ console.log('\nVIDEO targets (produce an .mp4):');
108
+ for (const [id, s] of Object.entries(VIDEO_TARGETS)) {
109
+ const dur = s.minSec ? `${s.minSec}–${s.maxSec}s` : 'any length';
110
+ console.log(` ${id.padEnd(20)} ${s.w}×${s.h} @${s.fps} H.264 ${s.profile}@${s.level} ${dur} — ${s.store}`);
111
+ }
112
+ console.log('\nIMAGE targets (store screenshots — `zdymak screenshots` / `build`):');
113
+ for (const [id, s] of Object.entries(IMAGE_TARGETS)) {
114
+ const dim = s.accepts ? s.accepts.map((a) => a.join('×')).join(' | ') : `${s.w}×${s.h}`;
115
+ console.log(` ${id.padEnd(24)} ${dim}${s.alpha === false ? ' (no alpha)' : ''} — ${s.store}`);
116
+ }
117
+ console.log('');
118
+ }
119
+
120
+ function cmdHelp() {
121
+ console.log(`zdymak — premium App Store, Google Play & social videos + screenshots, from your captures
122
+
123
+ Usage:
124
+ zdymak build [--config <path>] [--out <dir>] # everything: videos + per-device screenshots
125
+ zdymak video [--config <path>] [--target <ids>] [--out <dir>]
126
+ zdymak screenshots [--config <path>] [--out <dir>]
127
+ zdymak specs
128
+ zdymak capture --platform ios|android --name <screen> [--record] [--out <dir>]
129
+ zdymak help
130
+
131
+ Defaults: --config ${DEFAULT_CONFIG}. Needs ffmpeg on PATH (or $FFMPEG).
132
+ README.md documents the config (brand, scenes, targets, theme, music, devices); SKILL.md is for agents.`);
133
+ }
134
+
135
+ export async function run(argv = process.argv.slice(2)) {
136
+ const [cmd, ...rest0] = argv;
137
+ const { flags, rest } = parseFlags(rest0);
138
+ try {
139
+ switch (cmd) {
140
+ case 'build': await cmdBuild(flags); break;
141
+ case 'video': await cmdVideo(flags); break;
142
+ case 'screenshots': await cmdScreenshots(flags); break;
143
+ case 'specs': cmdSpecs(); break;
144
+ case 'capture': await runCapture(flags, rest); break;
145
+ case 'help': case undefined: case '--help': case '-h': cmdHelp(); break;
146
+ default:
147
+ console.error(`Unknown command "${cmd}".\n`);
148
+ cmdHelp();
149
+ process.exitCode = 1;
150
+ }
151
+ } catch (e) {
152
+ console.error(`\n✗ ${e.message}`);
153
+ process.exitCode = 1;
154
+ }
155
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Config loading + normalization. A project's `zdymak.config.mjs` (or .json) is the ONLY
3
+ * project-specific input; everything else lives in this reusable package.
4
+ *
5
+ * Shape (all paths are relative to the config file):
6
+ * export default {
7
+ * brand: { ink, title, sub, fontPaths? }, // hex colours; fontPaths optional custom TTFs
8
+ * screenshotsDir: 'marketing/ios/captures', // where the PNGs live
9
+ * suffix: '-light', // appended to scene.id → `${id}${suffix}.png`
10
+ * scenes: [{ id | image, title, sub, move }],// move: pushIn|pushInSlow|pullBack|driftUp|driftLeft|…
11
+ * targets: ['appstore-preview', 'play-promo'],
12
+ * sceneDur: 3.1, xfade: 0.32,
13
+ * out: 'store-assets',
14
+ * }
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+
20
+ const DEFAULT_BRAND = {
21
+ ink: '#0b0b0a', title: '#F5F5F4', sub: '#BBF7D0', fontPaths: [],
22
+ // Reel-mode fields (device-framed marketing reel) — optional; only used by the `social-reel` target.
23
+ name: 'App', tagline: '', endline: '', endsub: '', logo: null, reel: {},
24
+ };
25
+
26
+ /** Map raw scenes → resolved scenes with absolute image paths (`${dir}/${id}${suffix}.png` or explicit). */
27
+ function resolveScenes(rawScenes, baseDir, dir, suffix) {
28
+ return (rawScenes || []).map((s, i) => {
29
+ if (!s.image && !s.id) throw new Error(`Config error: scene[${i}] needs an "id" or an "image".`);
30
+ const image = s.image ? path.resolve(baseDir, s.image) : path.join(dir, `${s.id}${suffix}.png`);
31
+ return { id: s.id || String(i + 1), image, title: s.title || '', sub: s.sub || '', move: s.move };
32
+ });
33
+ }
34
+
35
+ const asList = (arr) => (arr || []).map((x) => (typeof x === 'string' ? { target: x } : x));
36
+
37
+ export async function loadConfig(configPath) {
38
+ const abs = path.resolve(configPath);
39
+ if (!fs.existsSync(abs)) {
40
+ throw new Error(`Config not found: ${abs}\nCreate one (see the README) or pass --config <path>.`);
41
+ }
42
+ const baseDir = path.dirname(abs);
43
+
44
+ let raw;
45
+ if (abs.endsWith('.json')) {
46
+ raw = JSON.parse(fs.readFileSync(abs, 'utf8'));
47
+ } else {
48
+ const mod = await import(pathToFileURL(abs).href);
49
+ raw = mod.default ?? mod.config ?? mod;
50
+ }
51
+
52
+ const brand = { ...DEFAULT_BRAND, ...(raw.brand || {}) };
53
+ brand.fontPaths = (brand.fontPaths || []).map((p) => path.resolve(baseDir, p));
54
+ if (brand.logo) brand.logo = path.resolve(baseDir, brand.logo); // reel-mode logo (cold-open / end-card)
55
+
56
+ const screenshotsDir = raw.screenshotsDir ? path.resolve(baseDir, raw.screenshotsDir) : baseDir;
57
+ const suffix = raw.suffix ?? '';
58
+
59
+ // Top-level scenes are required UNLESS a `devices` map supplies its own per-device scenes.
60
+ if (!raw.devices && (!Array.isArray(raw.scenes) || raw.scenes.length === 0)) {
61
+ throw new Error('Config error: `scenes` must be a non-empty array (or use a `devices` map).');
62
+ }
63
+ const scenes = resolveScenes(raw.scenes, baseDir, screenshotsDir, suffix);
64
+
65
+ // Optional music bed shared by every video target — path is relative to the config file.
66
+ const music = raw.music?.path ? { ...raw.music, path: path.resolve(baseDir, raw.music.path) } : undefined;
67
+
68
+ // Modular per-device config. An app lists ONLY the devices it ships; each has its own captures dir,
69
+ // optional scene overrides, screenshot targets (+ style) and video targets. Missing captures skip cleanly.
70
+ const devices = Object.entries(raw.devices || {}).map(([name, d]) => {
71
+ const dir = d.capturesDir ? path.resolve(baseDir, d.capturesDir) : screenshotsDir;
72
+ const suf = d.suffix ?? suffix;
73
+ return {
74
+ name,
75
+ scenes: resolveScenes(d.scenes || raw.scenes, baseDir, dir, suf),
76
+ screenshots: asList(d.screenshots),
77
+ videos: asList(d.videos),
78
+ theme: d.theme,
79
+ };
80
+ });
81
+
82
+ return {
83
+ brand,
84
+ scenes,
85
+ devices,
86
+ music,
87
+ targets: raw.targets?.length ? raw.targets : ['appstore-preview'],
88
+ sceneDur: raw.sceneDur ?? 3.1,
89
+ xfade: raw.xfade ?? 0.32,
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
92
+ out: path.resolve(baseDir, raw.out || 'store-assets'),
93
+ baseDir,
94
+ };
95
+ }
package/src/encode.mjs ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared H.264 encoder — one place every video builder (bleed / reel / premium) pipes raw RGBA frames to.
3
+ * Handles the store-safe encode (High profile at the target level, yuv420p, faststart) and the optional
4
+ * MUSIC bed with the same knobs across all styles: offset into the track, fade-in / fade-out, volume.
5
+ *
6
+ * music = { path, offset=0, fadeIn=0.6, fadeOut=0.8, volume=1 } (all optional except path)
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+
11
+ /** Start the ffmpeg process reading rawvideo from stdin. Returns { proc, done, hasAudio }. */
12
+ export function spawnEncoder({ W, H, fps, spec, outFile, music, totalDur }) {
13
+ const hasAudio = !!(music && music.path && fs.existsSync(music.path));
14
+ const args = [
15
+ '-y', '-loglevel', 'error',
16
+ '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', '-',
17
+ ];
18
+
19
+ if (hasAudio) {
20
+ const offset = Number(music.offset) || 0;
21
+ if (offset > 0) args.push('-ss', String(offset)); // seek INTO the track before it's used as input
22
+ args.push('-i', music.path);
23
+ const vol = music.volume ?? 1;
24
+ const fi = music.fadeIn ?? 0.6;
25
+ const fo = music.fadeOut ?? 0.8;
26
+ const outStart = Math.max(0, totalDur - fo).toFixed(2);
27
+ args.push('-af', `volume=${vol},afade=t=in:st=0:d=${fi},afade=t=out:st=${outStart}:d=${fo}`, '-shortest');
28
+ }
29
+
30
+ args.push(
31
+ '-c:v', 'libx264', '-profile:v', spec.profile, '-level:v', spec.level,
32
+ '-pix_fmt', 'yuv420p', '-crf', '17', '-maxrate', '12M', '-bufsize', '12M',
33
+ '-preset', 'slow', '-r', String(fps), '-movflags', '+faststart',
34
+ ...(hasAudio ? ['-c:a', 'aac', '-b:a', '192k'] : ['-an']),
35
+ outFile,
36
+ );
37
+
38
+ const proc = spawn(process.env.FFMPEG || 'ffmpeg', args, { stdio: ['pipe', 'inherit', 'inherit'] });
39
+ const done = new Promise((res, rej) => {
40
+ proc.on('close', (code) => (code === 0 ? res() : rej(new Error(`ffmpeg exited ${code}`))));
41
+ proc.on('error', (e) => rej(new Error(`ffmpeg failed to start (${e.message}). Is ffmpeg on PATH or $FFMPEG set?`)));
42
+ });
43
+ return { proc, done, hasAudio };
44
+ }
package/src/fonts.mjs ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Font registration for the canvas renderer. Everything renders under one family name — "Brand" — so the
3
+ * engine never branches on which file won. Resolution order:
4
+ * 1. explicit paths the project passes in (`brand.fontPaths` — e.g. your own Inter/brand TTFs)
5
+ * 2. the host system font: San Francisco on macOS (the Apple system face — ideal for store assets),
6
+ * Segoe UI on Windows, DejaVu Sans on Linux.
7
+ * No font is bundled, so there is nothing to license or ship; macOS (where you build App Store assets)
8
+ * always resolves to SF.
9
+ */
10
+ import { GlobalFonts } from '@napi-rs/canvas';
11
+ import fs from 'node:fs';
12
+
13
+ const SYSTEM_CANDIDATES = [
14
+ '/System/Library/Fonts/SFNS.ttf', // macOS — San Francisco (variable, all weights)
15
+ '/System/Library/Fonts/SFNSDisplay.ttf',
16
+ 'C:/Windows/Fonts/segoeui.ttf', // Windows — Segoe UI
17
+ 'C:/Windows/Fonts/segoeuib.ttf',
18
+ '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', // Linux
19
+ '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf',
20
+ ];
21
+
22
+ /**
23
+ * Register the "Brand" family. `fontPaths` (optional) are tried first, in order.
24
+ * Returns true if at least one face registered (else the canvas default is used, with a warning).
25
+ */
26
+ export function registerFonts(fontPaths = []) {
27
+ let any = false;
28
+ for (const p of [...fontPaths, ...SYSTEM_CANDIDATES]) {
29
+ if (p && fs.existsSync(p)) {
30
+ try {
31
+ GlobalFonts.registerFromPath(p, 'Brand');
32
+ any = true;
33
+ } catch {
34
+ /* skip unreadable */
35
+ }
36
+ }
37
+ }
38
+ if (!any) {
39
+ console.warn('[zdymak] No brand/system font found — captions use the canvas default. Pass brand.fontPaths to fix.');
40
+ }
41
+ return any;
42
+ }
package/src/frames.mjs ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Device frames for the `framed` screenshot style — an iPhone bezel (Dynamic Island), an iPad bezel, or a
3
+ * round Apple-Watch ring, with the capture drawn inside. The screen rect follows the capture's OWN aspect,
4
+ * so nothing is cropped. Ported from a production store-asset pipeline; pure rendering.
5
+ *
6
+ * (Mac captures are already windowed — traffic-light title bar — so Mac uses the plain premium still; no
7
+ * extra window frame is drawn here.)
8
+ */
9
+ import { roundRectPath } from './canvas.mjs';
10
+
11
+ /** Cover-fit an image into a rect (preserve aspect, crop overflow). */
12
+ function drawInto(ctx, img, x, y, w, h) {
13
+ const sAspect = img.height / img.width;
14
+ let dw = w;
15
+ let dh = w * sAspect;
16
+ if (dh < h) {
17
+ dh = h;
18
+ dw = h / sAspect;
19
+ }
20
+ ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
21
+ }
22
+
23
+ /** iPhone: dark unibody, thin uniform bezel, centred Dynamic Island. */
24
+ export function drawPhoneFrame(ctx, img, cx, cy, screenW) {
25
+ const screenH = screenW * (img.height / img.width);
26
+ const bezel = Math.round(screenW * 0.032);
27
+ const bodyW = screenW + bezel * 2;
28
+ const bodyH = screenH + bezel * 2;
29
+ const bodyX = Math.round(cx - bodyW / 2);
30
+ const bodyY = Math.round(cy - bodyH / 2);
31
+ const sx = bodyX + bezel;
32
+ const sy = bodyY + bezel;
33
+
34
+ ctx.save();
35
+ ctx.shadowColor = 'rgba(0,0,0,0.4)';
36
+ ctx.shadowBlur = Math.round(screenW * 0.09);
37
+ ctx.shadowOffsetY = Math.round(screenW * 0.035);
38
+ roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, Math.round(screenW * 0.155));
39
+ ctx.fillStyle = '#0b0b0a';
40
+ ctx.fill();
41
+ ctx.restore();
42
+
43
+ ctx.save();
44
+ roundRectPath(ctx, sx, sy, screenW, screenH, Math.round(screenW * 0.135));
45
+ ctx.clip();
46
+ drawInto(ctx, img, sx, sy, screenW, screenH);
47
+ ctx.restore();
48
+
49
+ const iw = Math.round(screenW * 0.3);
50
+ const ih = Math.round(screenW * 0.085);
51
+ roundRectPath(ctx, Math.round(sx + (screenW - iw) / 2), Math.round(sy + bezel * 1.1), iw, ih, ih / 2);
52
+ ctx.fillStyle = '#050505';
53
+ ctx.fill();
54
+ return { bodyH };
55
+ }
56
+
57
+ /** Android (Pixel-neutral): dark unibody, uniform bezel, centred punch-hole camera. */
58
+ export function drawAndroidPhoneFrame(ctx, img, cx, cy, screenW) {
59
+ const screenH = screenW * (img.height / img.width);
60
+ const bezel = Math.round(screenW * 0.035);
61
+ const bodyW = screenW + bezel * 2;
62
+ const bodyH = screenH + bezel * 2;
63
+ const bodyX = Math.round(cx - bodyW / 2);
64
+ const bodyY = Math.round(cy - bodyH / 2);
65
+ const sx = bodyX + bezel;
66
+ const sy = bodyY + bezel;
67
+
68
+ ctx.save();
69
+ ctx.shadowColor = 'rgba(0,0,0,0.4)';
70
+ ctx.shadowBlur = Math.round(screenW * 0.09);
71
+ ctx.shadowOffsetY = Math.round(screenW * 0.035);
72
+ roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, Math.round(screenW * 0.13));
73
+ ctx.fillStyle = '#0b0b0a';
74
+ ctx.fill();
75
+ ctx.restore();
76
+
77
+ ctx.save();
78
+ roundRectPath(ctx, sx, sy, screenW, screenH, Math.round(screenW * 0.105));
79
+ ctx.clip();
80
+ drawInto(ctx, img, sx, sy, screenW, screenH);
81
+ ctx.restore();
82
+
83
+ // Centred punch-hole camera near the top edge.
84
+ ctx.beginPath();
85
+ ctx.arc(sx + screenW / 2, sy + bezel * 1.6, Math.round(screenW * 0.018), 0, Math.PI * 2);
86
+ ctx.fillStyle = '#050505';
87
+ ctx.fill();
88
+ return { bodyH };
89
+ }
90
+
91
+ /** iPad: dark unibody, tight even bezel, gentler corners, no island. */
92
+ export function drawIpadFrame(ctx, img, cx, cy, screenW) {
93
+ const screenH = screenW * (img.height / img.width);
94
+ const bezel = Math.round(screenW * 0.024);
95
+ const bodyW = screenW + bezel * 2;
96
+ const bodyH = screenH + bezel * 2;
97
+ const bodyX = Math.round(cx - bodyW / 2);
98
+ const bodyY = Math.round(cy - bodyH / 2);
99
+ const sx = bodyX + bezel;
100
+ const sy = bodyY + bezel;
101
+
102
+ ctx.save();
103
+ ctx.shadowColor = 'rgba(0,0,0,0.36)';
104
+ ctx.shadowBlur = Math.round(screenW * 0.06);
105
+ ctx.shadowOffsetY = Math.round(screenW * 0.024);
106
+ roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, Math.round(screenW * 0.05));
107
+ ctx.fillStyle = '#0b0b0a';
108
+ ctx.fill();
109
+ ctx.restore();
110
+
111
+ ctx.save();
112
+ roundRectPath(ctx, sx, sy, screenW, screenH, Math.round(screenW * 0.035));
113
+ ctx.clip();
114
+ drawInto(ctx, img, sx, sy, screenW, screenH);
115
+ ctx.restore();
116
+ return { bodyH };
117
+ }
118
+
119
+ /** Apple Watch: round dark unibody, thin ring, a crown nub; the square capture is clipped to the circle. */
120
+ export function drawWatchFrame(ctx, img, cx, cy, diameter) {
121
+ const screenR = diameter / 2;
122
+ const bezel = Math.round(diameter * 0.05);
123
+ const bodyR = screenR + bezel;
124
+
125
+ ctx.save();
126
+ ctx.shadowColor = 'rgba(0,0,0,0.4)';
127
+ ctx.shadowBlur = Math.round(diameter * 0.08);
128
+ ctx.shadowOffsetY = Math.round(diameter * 0.03);
129
+ ctx.beginPath();
130
+ ctx.arc(cx, cy, bodyR, 0, Math.PI * 2);
131
+ ctx.fillStyle = '#0b0b0a';
132
+ ctx.fill();
133
+ ctx.restore();
134
+
135
+ // Crown nub on the right edge.
136
+ const crownW = Math.round(diameter * 0.03);
137
+ const crownH = Math.round(diameter * 0.12);
138
+ roundRectPath(ctx, cx + bodyR - Math.round(crownW * 0.35), cy - crownH / 2, crownW, crownH, Math.round(crownW * 0.5));
139
+ ctx.fillStyle = '#1a1a18';
140
+ ctx.fill();
141
+
142
+ ctx.save();
143
+ ctx.beginPath();
144
+ ctx.arc(cx, cy, screenR, 0, Math.PI * 2);
145
+ ctx.clip();
146
+ drawInto(ctx, img, cx - screenR, cy - screenR, diameter, diameter);
147
+ ctx.restore();
148
+ return { bodyH: bodyR * 2 };
149
+ }
150
+
151
+ /** Dispatch by frame id → the draw fn (null for unsupported → caller falls back to a plain still). */
152
+ export function frameFor(id) {
153
+ return {
154
+ phone: drawPhoneFrame, iphone: drawPhoneFrame,
155
+ android: drawAndroidPhoneFrame,
156
+ ipad: drawIpadFrame, tablet: drawIpadFrame,
157
+ watch: drawWatchFrame,
158
+ }[id] || null; // 'mac' etc. → no added frame
159
+ }
160
+
161
+ /** Infer a frame id from a screenshot target id (order matters: play-phone → android before → phone). */
162
+ export function inferFrame(target) {
163
+ if (/watch|wear/.test(target)) return 'watch';
164
+ if (/ipad|tablet/.test(target)) return 'ipad';
165
+ if (/play.*phone|android/.test(target)) return 'android';
166
+ if (/iphone|phone/.test(target)) return 'phone';
167
+ return null;
168
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Feature graphic — Google Play's 1024×500 listing banner (no alpha). A brand matte with the logo +
3
+ * wordmark + tagline on the left and a tilted device (a hero capture in a frame) bleeding off the right.
4
+ * Ported/generalized from a production store-asset pipeline; brand-driven.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
9
+ import { font, hexA, roundRectPath, fillVerticalGradient, radialGlow } from './canvas.mjs';
10
+ import { frameFor, drawAndroidPhoneFrame } from './frames.mjs';
11
+ import { rgbPngBuffer } from './png.mjs';
12
+
13
+ const DEFAULT = { matteTop: '#052E16', matteBottom: '#0b0b0a', glow: null, glowAlpha: 0.22 };
14
+
15
+ /** Split a tagline into ≤2 lines (on the first sentence break, else the whole thing). */
16
+ function splitTagline(t) {
17
+ if (!t) return ['', ''];
18
+ const m = t.match(/^(.*?[.!?])\s+(.*)$/);
19
+ return m ? [m[1], m[2]] : [t, ''];
20
+ }
21
+
22
+ /** Build the 1024×500 feature graphic → writes a no-alpha PNG. `frame`: which device frame for the hero. */
23
+ export async function buildFeatureGraphic({ W = 1024, H = 500, brand, theme, heroPath, outFile, frame = 'android' }) {
24
+ const th = { ...DEFAULT, ...(theme || {}) };
25
+ const glow = th.glow || brand.sub;
26
+ const c = createCanvas(W, H);
27
+ const ctx = c.getContext('2d');
28
+ fillVerticalGradient(ctx, W, H, th.matteTop, th.matteBottom);
29
+ radialGlow(ctx, W * 0.3, H * 0.5, W * 0.5, glow, th.glowAlpha);
30
+
31
+ const leftX = 72;
32
+ const iconSize = 92;
33
+ const logo = brand.logo && fs.existsSync(brand.logo) ? await loadImage(brand.logo) : null;
34
+ if (logo) {
35
+ ctx.save();
36
+ roundRectPath(ctx, leftX, 70, iconSize, iconSize, iconSize * 0.22);
37
+ ctx.clip();
38
+ ctx.drawImage(logo, leftX, 70, iconSize, iconSize);
39
+ ctx.restore();
40
+ }
41
+ ctx.textAlign = 'left';
42
+ ctx.textBaseline = 'middle';
43
+ ctx.font = font(76, 'bold');
44
+ ctx.fillStyle = brand.title;
45
+ ctx.fillText(brand.name || 'App', leftX + (logo ? iconSize + 26 : 0), 70 + iconSize / 2 + 4);
46
+
47
+ ctx.textBaseline = 'top';
48
+ const [t1, t2] = splitTagline(brand.tagline);
49
+ ctx.font = font(48, 'bold');
50
+ ctx.fillStyle = brand.title;
51
+ if (t1) ctx.fillText(t1, leftX, 220);
52
+ if (t2) {
53
+ ctx.fillStyle = brand.sub;
54
+ ctx.fillText(t2, leftX, 278);
55
+ }
56
+ if (brand.endsub) {
57
+ ctx.font = font(28, 'regular');
58
+ ctx.fillStyle = hexA(brand.sub, 0.85);
59
+ ctx.fillText(brand.endsub, leftX, 352);
60
+ }
61
+
62
+ if (heroPath && fs.existsSync(heroPath)) {
63
+ const hero = await loadImage(heroPath);
64
+ const pc = createCanvas(640, 960);
65
+ const pctx = pc.getContext('2d');
66
+ (frameFor(frame) || drawAndroidPhoneFrame)(pctx, hero, pc.width / 2, pc.height / 2, 250);
67
+ ctx.save();
68
+ ctx.translate(W - 150, H / 2 + 40);
69
+ ctx.rotate(-0.12);
70
+ ctx.drawImage(pc, -pc.width / 2, -pc.height / 2);
71
+ ctx.restore();
72
+ }
73
+
74
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
75
+ fs.writeFileSync(outFile, rgbPngBuffer(c));
76
+ return { outFile, W, H };
77
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,12 @@
1
+ /** Programmatic API — use the engine directly without the CLI. */
2
+ export { buildVideo } from './video.mjs';
3
+ export { buildReel } from './reel.mjs';
4
+ export { buildPremium } from './premium.mjs';
5
+ export { buildDeviceScreenshots } from './screenshots.mjs';
6
+ export { buildFeatureGraphic } from './graphic.mjs';
7
+ export { renderStill } from './still.mjs';
8
+ export { rgbPngBuffer } from './png.mjs';
9
+ export { loadConfig } from './config.mjs';
10
+ export { registerFonts } from './fonts.mjs';
11
+ export { VIDEO_TARGETS, IMAGE_TARGETS, videoTarget } from './specs.mjs';
12
+ export { run } from './cli.mjs';
package/src/png.mjs ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Encode a canvas as a **lossless RGB PNG with no alpha channel** (PNG colour type 2).
3
+ *
4
+ * App Store Connect rejects any screenshot carrying an alpha channel ("Images can't contain alpha channels
5
+ * or transparencies"), and Google Play wants "24-bit PNG (no alpha)" too. @napi-rs/canvas always emits
6
+ * RGBA, so we re-encode from the raw pixels here. Dependency-free (Node `zlib`), lossless (deflate level 9
7
+ * over the exact RGB samples). Every still fills an opaque background, so dropping the (fully-opaque) alpha
8
+ * is a pure flatten with zero visual change.
9
+ */
10
+ import zlib from 'node:zlib';
11
+
12
+ export function rgbPngBuffer(canvas) {
13
+ const w = canvas.width;
14
+ const h = canvas.height;
15
+ const { data } = canvas.getContext('2d').getImageData(0, 0, w, h); // RGBA, row-major, 8-bit
16
+
17
+ // PNG scanlines with filter type 0 (none): [0, R,G,B, R,G,B, …] per row.
18
+ const raw = Buffer.allocUnsafe(h * (1 + w * 3));
19
+ let p = 0;
20
+ for (let y = 0; y < h; y++) {
21
+ raw[p++] = 0;
22
+ let src = y * w * 4;
23
+ for (let x = 0; x < w; x++) {
24
+ raw[p++] = data[src];
25
+ raw[p++] = data[src + 1];
26
+ raw[p++] = data[src + 2];
27
+ src += 4;
28
+ }
29
+ }
30
+ const idat = zlib.deflateSync(raw, { level: 9 });
31
+
32
+ const chunk = (type, body) => {
33
+ const len = Buffer.alloc(4);
34
+ len.writeUInt32BE(body.length, 0);
35
+ const typeBuf = Buffer.from(type, 'ascii');
36
+ const crc = Buffer.alloc(4);
37
+ crc.writeUInt32BE(zlib.crc32(Buffer.concat([typeBuf, body])) >>> 0, 0);
38
+ return Buffer.concat([len, typeBuf, body, crc]);
39
+ };
40
+
41
+ const ihdr = Buffer.alloc(13);
42
+ ihdr.writeUInt32BE(w, 0);
43
+ ihdr.writeUInt32BE(h, 4);
44
+ ihdr[8] = 8; // bit depth
45
+ ihdr[9] = 2; // colour type 2 = truecolour (RGB), no alpha
46
+ // bytes 10–12 (compression, filter, interlace) stay 0
47
+
48
+ const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
49
+ return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
50
+ }