zdymak 0.5.0 → 0.7.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/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/config.mjs +2 -1
- package/src/premium.mjs +39 -12
- package/src/screenshots.mjs +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zdymak",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Generate premium, spec-compliant App Store & Google Play preview videos (and store assets) from screenshots — one config-driven CLI across all your projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -79,7 +79,7 @@ async function cmdScreenshots(flags) {
|
|
|
79
79
|
}
|
|
80
80
|
console.log(`zdymak screenshots • ${cfg.devices.length} device group(s)`);
|
|
81
81
|
for (const device of cfg.devices) {
|
|
82
|
-
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
|
|
82
|
+
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
|
|
83
83
|
console.log(` • ${device.name}: ${written.length} shot(s)${written[0] ? ` (${written[0].W}×${written[0].H}…)` : ' — no captures found, skipped'}`);
|
|
84
84
|
}
|
|
85
85
|
console.log('Done.');
|
|
@@ -103,7 +103,7 @@ async function cmdBuild(flags) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
if (device.screenshots.length) {
|
|
106
|
-
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.theme, outDir });
|
|
106
|
+
const written = await buildDeviceScreenshots({ device, brand: cfg.brand, theme: device.theme ?? cfg.stillTheme ?? cfg.theme, outDir });
|
|
107
107
|
console.log(` • ${device.name} screenshots: ${written.length}${written.length ? '' : ' — no captures found, skipped'}`);
|
|
108
108
|
}
|
|
109
109
|
}
|
package/src/config.mjs
CHANGED
|
@@ -88,7 +88,8 @@ export async function loadConfig(configPath) {
|
|
|
88
88
|
sceneDur: raw.sceneDur ?? 3.1,
|
|
89
89
|
xfade: raw.xfade ?? 0.32,
|
|
90
90
|
timing: raw.timing, // reel-mode timeline override { coldOpen, scene, endCard, xfade }
|
|
91
|
-
theme: raw.theme, // premium-technique styling override (matte, vignette, label, cuts)
|
|
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
|
|
92
93
|
out: path.resolve(baseDir, raw.out || 'store-assets'),
|
|
93
94
|
baseDir,
|
|
94
95
|
};
|
package/src/premium.mjs
CHANGED
|
@@ -45,6 +45,8 @@ const DEFAULT_THEME = {
|
|
|
45
45
|
cutHard: 0.05, // dissolve seconds when adjacent screens share a palette (≈ hard cut)
|
|
46
46
|
cutSoft: 0.24, // dissolve seconds at a palette shift
|
|
47
47
|
cutThreshold: 42, // RGB-distance above which a boundary counts as a palette shift
|
|
48
|
+
captionAnchor: 'bottom', // 'top' places the caption above the device (the bright store-shot layout)
|
|
49
|
+
fit: 'cover', // screenLayer image fit: 'cover' (fill + crop) | 'contain' (whole capture, matte margins)
|
|
48
50
|
};
|
|
49
51
|
|
|
50
52
|
/** Average RGB of a canvas (coarse grid sample) — used to decide hard-cut vs dissolve. */
|
|
@@ -110,8 +112,10 @@ function drawLabel(ctx, W, H, caption, th, alpha) {
|
|
|
110
112
|
const titleSize = Math.round(Math.min(W, H * 0.9) * 0.05);
|
|
111
113
|
const subSize = Math.round(Math.min(W, H * 0.9) * 0.033);
|
|
112
114
|
const cx = W / 2;
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
+
// Bottom by default (anchored higher on wide/landscape so the pill clears the app's own bottom UI);
|
|
116
|
+
// captionAnchor:'top' puts it above the device — the bright store-shot layout.
|
|
117
|
+
const topCaption = th.captionAnchor === 'top';
|
|
118
|
+
let y = topCaption ? H * 0.085 : (W > H ? 0.7 : 0.82) * H;
|
|
115
119
|
|
|
116
120
|
if (caption.title && th.label) {
|
|
117
121
|
ctx.font = font(titleSize, 'bold');
|
|
@@ -167,12 +171,29 @@ export function resolvePremiumTheme(brand, theme) {
|
|
|
167
171
|
return th;
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
/** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
|
|
174
|
+
/** The floating, inset, rounded, shadowed app-screen layer on its own transparent canvas.
|
|
175
|
+
* fit 'cover' fills the inset box (crops overflow); fit 'contain' shows the WHOLE capture — the card
|
|
176
|
+
* shrinks to the capture's aspect and floats with matte margins (correct for a Mac window on the matte,
|
|
177
|
+
* where a cover-crop would slice off the title bar / traffic lights). */
|
|
171
178
|
function screenLayer(W, H, img, th) {
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
const
|
|
179
|
+
// Reserve headroom for a top caption so the window floats BELOW it (else it covers the headline).
|
|
180
|
+
const topCaption = th.captionAnchor === 'top';
|
|
181
|
+
const bandTop = topCaption ? H * 0.18 : 0;
|
|
182
|
+
const bandH = (topCaption ? 0.8 : 1) * H;
|
|
183
|
+
const boxW = Math.round(W * th.inset);
|
|
184
|
+
const boxH = Math.round(bandH * th.inset);
|
|
185
|
+
const ia = img.height / img.width;
|
|
186
|
+
let dw = boxW;
|
|
187
|
+
let dh = boxH;
|
|
188
|
+
if (th.fit === 'contain') {
|
|
189
|
+
dh = boxW * ia; // fit width, then clamp height so the whole capture fits the box
|
|
190
|
+
if (dh > boxH) {
|
|
191
|
+
dh = boxH;
|
|
192
|
+
dw = boxH / ia;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const dx = Math.round((W - dw) / 2);
|
|
196
|
+
const dy = Math.round(bandTop + (bandH - dh) / 2);
|
|
176
197
|
const radius = Math.round(W * th.radius);
|
|
177
198
|
const layer = createCanvas(W, H);
|
|
178
199
|
const lctx = layer.getContext('2d');
|
|
@@ -180,14 +201,15 @@ function screenLayer(W, H, img, th) {
|
|
|
180
201
|
lctx.shadowColor = `rgba(0,0,0,${th.shadow})`;
|
|
181
202
|
lctx.shadowBlur = Math.round(W * 0.05);
|
|
182
203
|
lctx.shadowOffsetY = Math.round(W * 0.012);
|
|
183
|
-
roundRectPath(lctx,
|
|
204
|
+
roundRectPath(lctx, dx, dy, dw, dh, radius);
|
|
184
205
|
lctx.fillStyle = '#000';
|
|
185
206
|
lctx.fill();
|
|
186
207
|
lctx.restore();
|
|
187
208
|
lctx.save();
|
|
188
|
-
roundRectPath(lctx,
|
|
209
|
+
roundRectPath(lctx, dx, dy, dw, dh, radius);
|
|
189
210
|
lctx.clip();
|
|
190
|
-
|
|
211
|
+
// The card now matches the draw aspect, so cover here fills it exactly (no crop for 'contain').
|
|
212
|
+
lctx.drawImage(coverCanvas(img, dw, dh), dx, dy);
|
|
191
213
|
lctx.restore();
|
|
192
214
|
return layer;
|
|
193
215
|
}
|
|
@@ -196,17 +218,22 @@ function screenLayer(W, H, img, th) {
|
|
|
196
218
|
* drawn inside a device bezel floating on the matte; otherwise it's the plain rounded inset. */
|
|
197
219
|
export function premiumStill({ W, H, img, caption, brand, theme, frame }) {
|
|
198
220
|
const th = resolvePremiumTheme(brand, theme);
|
|
221
|
+
// Store-shot smart defaults (each overridable via `theme`): headline sits ON TOP; a frameless window
|
|
222
|
+
// (e.g. Mac) shows the WHOLE capture instead of cropping its title bar.
|
|
223
|
+
if (theme?.captionAnchor === undefined) th.captionAnchor = 'top';
|
|
224
|
+
if (!frame && theme?.fit === undefined) th.fit = 'contain';
|
|
199
225
|
const c = createCanvas(W, H);
|
|
200
226
|
const ctx = c.getContext('2d');
|
|
201
227
|
paintMatte(ctx, W, H, th);
|
|
202
228
|
|
|
229
|
+
const topCaption = th.captionAnchor === 'top';
|
|
203
230
|
const drawFrame = frame ? frameFor(frame) : null;
|
|
204
231
|
if (drawFrame) {
|
|
205
|
-
const cy = H * 0.42; //
|
|
232
|
+
const cy = H * (topCaption ? 0.57 : 0.42); // drop the device when the caption sits on top
|
|
206
233
|
if (frame === 'watch') {
|
|
207
234
|
drawFrame(ctx, img, W / 2, cy, Math.min(W, H) * 0.6);
|
|
208
235
|
} else {
|
|
209
|
-
const budgetH = H * 0.64; // vertical room for the device body
|
|
236
|
+
const budgetH = H * (topCaption ? 0.66 : 0.64); // vertical room for the device body
|
|
210
237
|
const screenW = Math.min(budgetH / (img.height / img.width), W * 0.8);
|
|
211
238
|
drawFrame(ctx, img, W / 2, cy, screenW);
|
|
212
239
|
}
|
package/src/screenshots.mjs
CHANGED
|
@@ -27,8 +27,10 @@ export async function buildDeviceScreenshots({ device, brand, theme, outDir }) {
|
|
|
27
27
|
const spec = IMAGE_TARGETS[shot.target];
|
|
28
28
|
if (!spec) throw new Error(`Unknown image target "${shot.target}" (device: ${device.name})`);
|
|
29
29
|
const [W, H] = targetSize(spec, shot.size);
|
|
30
|
-
const style = shot.style || 'premium';
|
|
31
30
|
const frame = shot.frame || inferFrame(shot.target); // device bezel for the `framed` style
|
|
31
|
+
// Infer the style from the target: a framed device (phone/tablet/watch) → 'framed'; a frameless
|
|
32
|
+
// target (Mac/desktop) → 'premium' (window on the matte). Overridable per shot (e.g. Watch → 'bleed').
|
|
33
|
+
const style = shot.style || (frame ? 'framed' : 'premium');
|
|
32
34
|
|
|
33
35
|
// Feature graphic (Play banner) is a single branded image, not a per-scene shot.
|
|
34
36
|
if (spec.graphic) {
|