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/src/config.mjs CHANGED
@@ -1,96 +1,159 @@
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 for VIDEOS (matte, vignette, label, cuts)
92
- stillTheme: raw.stillTheme, // screenshot-only matte override; falls back to `theme` when unset
93
- out: path.resolve(baseDir, raw.out || 'store-assets'),
94
- baseDir,
95
- };
96
- }
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
+ /** Every top-level config field an app author can set. The doc-sync guard (`scripts/check-docs.mjs`)
27
+ * asserts each appears in README.md/SKILL.md, so adding a field forces a doc line. Keep in sync with the
28
+ * `raw.*` reads below. */
29
+ export const CONFIG_KEYS = [
30
+ 'brand', 'screenshotsDir', 'suffix', 'scenes', 'targets', 'sceneDur', 'xfade',
31
+ 'timing', 'theme', 'stillTheme', 'music', 'devices', 'captions', 'reel', 'out',
32
+ ];
33
+
34
+ /**
35
+ * Per-locale caption tables: `captions: { de: './captions/de.json' | { sceneId: { title, sub } } }`.
36
+ * A JSON path is read here so a bad path fails at config load, not halfway through a render. The
37
+ * reserved `$brand` key carries the localized wordmark lines used by the feature graphic.
38
+ */
39
+ function loadCaptions(rawCaptions, baseDir) {
40
+ const out = {};
41
+ for (const [locale, value] of Object.entries(rawCaptions)) {
42
+ let table = value;
43
+ if (typeof value === 'string') {
44
+ const file = path.resolve(baseDir, value);
45
+ if (!fs.existsSync(file)) {
46
+ throw new Error(`Config error: captions["${locale}"] → file not found: ${file}`);
47
+ }
48
+ try {
49
+ table = JSON.parse(fs.readFileSync(file, 'utf8'));
50
+ } catch (e) {
51
+ throw new Error(`Config error: captions["${locale}"] → ${file} is not valid JSON (${e.message})`);
52
+ }
53
+ }
54
+ if (!table || typeof table !== 'object' || Array.isArray(table)) {
55
+ throw new Error(`Config error: captions["${locale}"] must be a JSON path or an object of sceneId → { title, sub }.`);
56
+ }
57
+ out[locale] = table;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Map raw scenes resolved scenes with absolute image paths (`${dir}/${id}${suffix}.png` or explicit).
64
+ *
65
+ * SPREAD the source scene rather than picking known keys: this used to whitelist
66
+ * `{id, image, title, sub, move}`, which silently swallowed every per-scene knob added since
67
+ * (`cut`, `effect`, `push`, `scroll`) — the config looked right, no error was raised, and the renderer
68
+ * just never saw them. Anything a scene carries now reaches the engine untouched.
69
+ */
70
+ function resolveScenes(rawScenes, baseDir, dir, suffix) {
71
+ return (rawScenes || []).map((s, i) => {
72
+ if (!s.image && !s.id) throw new Error(`Config error: scene[${i}] needs an "id" or an "image".`);
73
+ const image = s.image ? path.resolve(baseDir, s.image) : path.join(dir, `${s.id}${suffix}.png`);
74
+ return { ...s, id: s.id || String(i + 1), image, title: s.title || '', sub: s.sub || '' };
75
+ });
76
+ }
77
+
78
+ const asList = (arr) => (arr || []).map((x) => (typeof x === 'string' ? { target: x } : x));
79
+
80
+ export async function loadConfig(configPath) {
81
+ const abs = path.resolve(configPath);
82
+ if (!fs.existsSync(abs)) {
83
+ throw new Error(`Config not found: ${abs}\nCreate one (see the README) or pass --config <path>.`);
84
+ }
85
+ const baseDir = path.dirname(abs);
86
+
87
+ let raw;
88
+ if (abs.endsWith('.json')) {
89
+ raw = JSON.parse(fs.readFileSync(abs, 'utf8'));
90
+ } else {
91
+ const mod = await import(pathToFileURL(abs).href);
92
+ raw = mod.default ?? mod.config ?? mod;
93
+ }
94
+
95
+ const brand = { ...DEFAULT_BRAND, ...(raw.brand || {}) };
96
+ brand.fontPaths = (brand.fontPaths || []).map((p) => path.resolve(baseDir, p));
97
+ if (brand.logo) brand.logo = path.resolve(baseDir, brand.logo); // reel-mode logo (cold-open / end-card)
98
+
99
+ const screenshotsDir = raw.screenshotsDir ? path.resolve(baseDir, raw.screenshotsDir) : baseDir;
100
+ const suffix = raw.suffix ?? '';
101
+
102
+ // Top-level scenes are required UNLESS a `devices` map or a live-footage `reel` supplies the content.
103
+ if (!raw.devices && !raw.reel && (!Array.isArray(raw.scenes) || raw.scenes.length === 0)) {
104
+ throw new Error('Config error: provide `scenes`, a `devices` map, or a `reel` block.');
105
+ }
106
+ const scenes = resolveScenes(raw.scenes, baseDir, screenshotsDir, suffix);
107
+
108
+ // Optional music bed shared by every video target — path is relative to the config file.
109
+ const music = raw.music?.path ? { ...raw.music, path: path.resolve(baseDir, raw.music.path) } : undefined;
110
+
111
+ // Modular per-device config. An app lists ONLY the devices it ships; each has its own captures dir,
112
+ // optional scene overrides, screenshot targets (+ style) and video targets. Missing captures skip cleanly.
113
+ const devices = Object.entries(raw.devices || {}).map(([name, d]) => {
114
+ const dir = d.capturesDir ? path.resolve(baseDir, d.capturesDir) : screenshotsDir;
115
+ const suf = d.suffix ?? suffix;
116
+ return {
117
+ name,
118
+ scenes: resolveScenes(d.scenes || raw.scenes, baseDir, dir, suf),
119
+ screenshots: asList(d.screenshots),
120
+ videos: asList(d.videos),
121
+ theme: d.theme,
122
+ };
123
+ });
124
+
125
+ // Live-footage reel: resolve each segment's clip/image(s) + the music bed relative to the config file.
126
+ const reel = raw.reel
127
+ ? {
128
+ ...raw.reel,
129
+ music: raw.reel.music?.path
130
+ ? { ...raw.reel.music, path: path.resolve(baseDir, raw.reel.music.path) }
131
+ : undefined,
132
+ segments: (raw.reel.segments || []).map((s) => ({
133
+ ...s,
134
+ clip: s.clip ? path.resolve(baseDir, s.clip) : undefined,
135
+ image: s.image ? path.resolve(baseDir, s.image) : undefined,
136
+ images: s.images ? s.images.map((p) => path.resolve(baseDir, p)) : undefined,
137
+ })),
138
+ }
139
+ : undefined;
140
+
141
+ return {
142
+ brand,
143
+ reel,
144
+ scenes,
145
+ devices,
146
+ captions: raw.captions ? loadCaptions(raw.captions, baseDir) : undefined,
147
+ music,
148
+ // `?? ` not `?.length ?` — an explicit `targets: []` means "no top-level videos" (a devices-only
149
+ // config renders each device's own), whereas omitting the key entirely still gets the sane default.
150
+ targets: raw.targets ?? ['appstore-preview'],
151
+ sceneDur: raw.sceneDur ?? 3.1,
152
+ xfade: raw.xfade ?? 0.32,
153
+ timing: raw.timing, // reel-mode timeline override { coldOpen, scene, endCard, xfade }
154
+ theme: raw.theme, // premium-technique styling override for VIDEOS (matte, vignette, label, cuts)
155
+ stillTheme: raw.stillTheme, // screenshot-only matte override; falls back to `theme` when unset
156
+ out: path.resolve(baseDir, raw.out || 'store-assets'),
157
+ baseDir,
158
+ };
159
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * DESTINATIONS vs PRESETS — the separation this tool needed.
3
+ *
4
+ * A store *destination* answers "will this be accepted?" — pixel size, codec profile/level, duration
5
+ * bounds, alpha, file size. It has nothing to say about how the asset looks.
6
+ *
7
+ * A *preset* answers "how does it look?" — full-bleed, device-framed, or the premium matte treatment —
8
+ * and carries our opinionated defaults for motion. It has nothing to say about what a store accepts.
9
+ *
10
+ * They were previously welded together in one `target` id, so "the premium look at Play dimensions" or
11
+ * "App Store validation with my own transitions" were unsayable. Now:
12
+ *
13
+ * videos: [{ destination: 'appstore-preview', preset: 'premium', transitions: ['blur-dissolve'] }]
14
+ * videos: [{ target: 'appstore-preview' }] // shorthand — resolves to the pair above's defaults
15
+ *
16
+ * `target` remains a first-class shorthand: every existing config keeps working unchanged.
17
+ */
18
+ import { VIDEO_TARGETS, IMAGE_TARGETS } from './specs.mjs';
19
+
20
+ /** How an asset is rendered. Independent of where it's going. */
21
+ export const PRESETS = {
22
+ 'full-bleed': {
23
+ label: 'Full-bleed — the screen fills the frame, no bezel (required for App Previews)',
24
+ engine: 'video',
25
+ },
26
+ framed: {
27
+ label: 'Device-framed — bezel + brand wash + logo bookends (marketing reels)',
28
+ engine: 'reel',
29
+ },
30
+ premium: {
31
+ label: 'Premium — the screen floats on a brand matte, Apple editing vocabulary',
32
+ engine: 'premium',
33
+ },
34
+ };
35
+
36
+ /** The preset each legacy target implied, so `target:` keeps behaving exactly as before. */
37
+ const TARGET_PRESET = {
38
+ 'appstore-preview': 'full-bleed',
39
+ 'appstore-preview-ipad': 'full-bleed',
40
+ 'appstore-preview-mac': 'full-bleed',
41
+ 'play-promo': 'full-bleed',
42
+ 'social-reel': 'framed',
43
+ 'premium-reel': 'premium',
44
+ };
45
+
46
+ /**
47
+ * Normalize a video entry into `{ destination, preset, size, theme, transitions, effects }`.
48
+ * Accepts the shorthand (`{ target }`) or the split form (`{ destination, preset }`).
49
+ */
50
+ export function resolveVideo(entry) {
51
+ const e = typeof entry === 'string' ? { target: entry } : entry || {};
52
+ const destId = e.destination || e.target;
53
+ if (!destId) {
54
+ throw new Error("A video entry needs `destination` (or the `target` shorthand). See `zdymak specs`.");
55
+ }
56
+ const destination = VIDEO_TARGETS[destId];
57
+ if (!destination) {
58
+ throw new Error(`Unknown video destination "${destId}". Valid: ${Object.keys(VIDEO_TARGETS).join(', ')}`);
59
+ }
60
+ const presetId = e.preset || TARGET_PRESET[destId] || 'full-bleed';
61
+ if (!PRESETS[presetId]) {
62
+ throw new Error(`Unknown preset "${presetId}". Valid: ${Object.keys(PRESETS).join(', ')}`);
63
+ }
64
+ // A destination may forbid a preset: Apple rejects a device bezel in the App Preview slot, so the
65
+ // combination is refused here rather than at review.
66
+ if (destination.noFrame && presetId === 'framed') {
67
+ throw new Error(
68
+ `"${destId}" cannot use the "framed" preset — ${destination.store} rejects a device bezel in that ` +
69
+ 'slot. Use "full-bleed" (or "premium") there, and keep the framed reel for your site and ads.',
70
+ );
71
+ }
72
+ return {
73
+ id: destId,
74
+ destination,
75
+ preset: presetId,
76
+ engine: PRESETS[presetId].engine,
77
+ size: e.size,
78
+ theme: e.theme,
79
+ transitions: e.transitions,
80
+ effects: e.effects,
81
+ };
82
+ }
83
+
84
+ /** Normalize a screenshot entry the same way (images have no preset beyond their render `style`). */
85
+ export function resolveImage(entry) {
86
+ const e = typeof entry === 'string' ? { target: entry } : entry || {};
87
+ const destId = e.destination || e.target;
88
+ const destination = IMAGE_TARGETS[destId];
89
+ if (!destination) {
90
+ throw new Error(`Unknown image destination "${destId}". Valid: ${Object.keys(IMAGE_TARGETS).join(', ')}`);
91
+ }
92
+ return { ...e, target: destId, destination };
93
+ }
94
+
95
+ /**
96
+ * A user-supplied transition/effect palette. When a video carries `transitions: [...]`, scenes that
97
+ * don't name their own `cut` cycle through that list — the "bring your own vocabulary" path, as opposed
98
+ * to a preset's built-in choices. Deterministic by index so re-renders match.
99
+ */
100
+ export function paletteAt(list, index, fallback) {
101
+ if (!Array.isArray(list) || !list.length) return fallback;
102
+ return list[index % list.length];
103
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Easing + numeric helpers.
3
+ *
4
+ * Ported from a production video pipeline (uspamin's collage player), where each of these earned its
5
+ * place by fixing a specific visible artefact rather than by being mathematically tidy:
6
+ *
7
+ * • `smoothstep` on opacity kills the luminance valley in the middle of a cross-dissolve — a linear
8
+ * blend of two images dips visibly darker at 50%.
9
+ * • `qPct` quantises percentage transforms; sub-0.01% jitter makes shadows and hairlines shimmer
10
+ * between frames even when the motion itself is correct.
11
+ * • `softPeak` replaces `sin(x)^4`-style pulses, which land their peak on a single frame and read as
12
+ * a judder rather than a swell.
13
+ */
14
+
15
+ /** Decelerating — the default for anything entering. */
16
+ export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
17
+
18
+ /** Accelerating — for anything leaving. */
19
+ export const easeInCubic = (t) => t * t * t;
20
+
21
+ /** Symmetric ease; the workhorse for camera moves and slides. */
22
+ export const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
23
+
24
+ /** Sharper deceleration than cubic — good for a snap that still lands softly. */
25
+ export const easeOutQuint = (t) => 1 - Math.pow(1 - t, 5);
26
+
27
+ /** Gentle both ends, no overshoot. */
28
+ export const easeInOutSine = (t) => -(Math.cos(Math.PI * t) - 1) / 2;
29
+
30
+ /** Hermite S-curve. Use on OPACITY during a dissolve (see the note above). */
31
+ export const smoothstep = (t) => {
32
+ const x = Math.min(1, Math.max(0, t));
33
+ return x * x * (3 - 2 * x);
34
+ };
35
+
36
+ /** Second-order smoothstep — zero first AND second derivative at both ends. */
37
+ export const smootherstep = (t) => {
38
+ const x = Math.min(1, Math.max(0, t));
39
+ return x * x * x * (x * (x * 6 - 15) + 10);
40
+ };
41
+
42
+ /** Overshoot-and-settle, for something that should feel physical on arrival. */
43
+ export const easeOutBack = (t, s = 1.2) => {
44
+ const c3 = s + 1;
45
+ return 1 + c3 * Math.pow(t - 1, 3) + s * Math.pow(t - 1, 2);
46
+ };
47
+
48
+ /** A swell that HOLDS near its peak instead of spiking on one frame. */
49
+ export const softPeak = (t) => {
50
+ const x = Math.min(1, Math.max(0, t));
51
+ const bell = Math.sin(Math.PI * x);
52
+ return bell * bell * (3 - 2 * bell) * 0.5 + bell * 0.5;
53
+ };
54
+
55
+ /** Quantise a percentage to 0.01% — kills inter-frame shimmer on shadows and hairlines. */
56
+ export const qPct = (v) => Math.round(v * 10000) / 10000;
57
+
58
+ /**
59
+ * Overdamped spring 0→1, integrated per frame (semi-implicit Euler) — the "one earned camera move".
60
+ * Overdamped on purpose: it settles without a bounce, which on a product shot reads as confidence.
61
+ */
62
+ export function springSeries(frames, fps, { stiffness = 26, damping = 26, mass = 1.4 } = {}) {
63
+ const dt = 1 / fps;
64
+ let x = 0;
65
+ let v = 0;
66
+ const out = new Array(frames);
67
+ for (let f = 0; f < frames; f++) {
68
+ const a = (stiffness * (1 - x) - damping * v) / mass;
69
+ v += a * dt;
70
+ x += v * dt;
71
+ out[f] = x;
72
+ }
73
+ return out;
74
+ }