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
package/src/frames.mjs
CHANGED
|
@@ -1,168 +1,186 @@
|
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
ctx.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
ctx.
|
|
53
|
-
ctx.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
ctx.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
ctx.
|
|
85
|
-
ctx.
|
|
86
|
-
ctx.
|
|
87
|
-
ctx.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
ctx.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
ctx.
|
|
127
|
-
ctx
|
|
128
|
-
ctx.
|
|
129
|
-
ctx
|
|
130
|
-
ctx.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
ctx.
|
|
141
|
-
|
|
142
|
-
ctx.
|
|
143
|
-
ctx.
|
|
144
|
-
ctx.
|
|
145
|
-
ctx.
|
|
146
|
-
|
|
147
|
-
ctx.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
/**
|
|
12
|
+
* Paint the screen area. `screen` is either an image (cover-fit, as before) or a callback
|
|
13
|
+
* `(ctx, x, y, w, h) => void` that paints it — the hook that lets the reel scroll content inside a device
|
|
14
|
+
* that itself never moves. With a callback the caller must say how tall the screen is via `opts.aspect`,
|
|
15
|
+
* since there's no image to measure.
|
|
16
|
+
*/
|
|
17
|
+
function paintScreen(ctx, screen, x, y, w, h) {
|
|
18
|
+
if (typeof screen === 'function') return screen(ctx, x, y, w, h);
|
|
19
|
+
return drawInto(ctx, screen, x, y, w, h);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Screen height for a frame: the image's own aspect, or an explicit one when painting via callback. */
|
|
23
|
+
const screenHeight = (screen, screenW, opts) =>
|
|
24
|
+
screenW * (typeof screen === 'function' ? (opts.aspect ?? 2.165) : screen.height / screen.width);
|
|
25
|
+
|
|
26
|
+
/** Cover-fit an image into a rect (preserve aspect, crop overflow). */
|
|
27
|
+
function drawInto(ctx, img, x, y, w, h) {
|
|
28
|
+
const sAspect = img.height / img.width;
|
|
29
|
+
let dw = w;
|
|
30
|
+
let dh = w * sAspect;
|
|
31
|
+
if (dh < h) {
|
|
32
|
+
dh = h;
|
|
33
|
+
dw = h / sAspect;
|
|
34
|
+
}
|
|
35
|
+
ctx.drawImage(img, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** iPhone: dark unibody, thin uniform bezel, centred Dynamic Island. */
|
|
39
|
+
export function drawPhoneFrame(ctx, img, cx, cy, screenW, opts = {}) {
|
|
40
|
+
const screenH = screenHeight(img, screenW, opts);
|
|
41
|
+
const bezel = Math.round(screenW * 0.032);
|
|
42
|
+
const bodyW = screenW + bezel * 2;
|
|
43
|
+
const bodyH = screenH + bezel * 2;
|
|
44
|
+
const bodyX = Math.round(cx - bodyW / 2);
|
|
45
|
+
const bodyY = Math.round(cy - bodyH / 2);
|
|
46
|
+
const sx = bodyX + bezel;
|
|
47
|
+
const sy = bodyY + bezel;
|
|
48
|
+
|
|
49
|
+
ctx.save();
|
|
50
|
+
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
|
51
|
+
ctx.shadowBlur = Math.round(screenW * 0.09);
|
|
52
|
+
ctx.shadowOffsetY = Math.round(screenW * 0.035);
|
|
53
|
+
roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, Math.round(screenW * 0.155));
|
|
54
|
+
ctx.fillStyle = '#0b0b0a';
|
|
55
|
+
ctx.fill();
|
|
56
|
+
ctx.restore();
|
|
57
|
+
|
|
58
|
+
ctx.save();
|
|
59
|
+
roundRectPath(ctx, sx, sy, screenW, screenH, Math.round(screenW * 0.135));
|
|
60
|
+
ctx.clip();
|
|
61
|
+
paintScreen(ctx, img, sx, sy, screenW, screenH);
|
|
62
|
+
ctx.restore();
|
|
63
|
+
|
|
64
|
+
const iw = Math.round(screenW * 0.3);
|
|
65
|
+
const ih = Math.round(screenW * 0.085);
|
|
66
|
+
roundRectPath(ctx, Math.round(sx + (screenW - iw) / 2), Math.round(sy + bezel * 1.1), iw, ih, ih / 2);
|
|
67
|
+
ctx.fillStyle = '#050505';
|
|
68
|
+
ctx.fill();
|
|
69
|
+
return { bodyH, bodyY, screen: { x: sx, y: sy, w: screenW, h: screenH, r: Math.round(screenW * 0.135) } };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Android (Pixel-neutral): dark unibody, uniform bezel, centred punch-hole camera. */
|
|
73
|
+
export function drawAndroidPhoneFrame(ctx, img, cx, cy, screenW, opts = {}) {
|
|
74
|
+
const screenH = screenHeight(img, screenW, opts);
|
|
75
|
+
const bezel = Math.round(screenW * 0.035);
|
|
76
|
+
const bodyW = screenW + bezel * 2;
|
|
77
|
+
const bodyH = screenH + bezel * 2;
|
|
78
|
+
const bodyX = Math.round(cx - bodyW / 2);
|
|
79
|
+
const bodyY = Math.round(cy - bodyH / 2);
|
|
80
|
+
const sx = bodyX + bezel;
|
|
81
|
+
const sy = bodyY + bezel;
|
|
82
|
+
|
|
83
|
+
ctx.save();
|
|
84
|
+
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
|
85
|
+
ctx.shadowBlur = Math.round(screenW * 0.09);
|
|
86
|
+
ctx.shadowOffsetY = Math.round(screenW * 0.035);
|
|
87
|
+
roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, Math.round(screenW * 0.13));
|
|
88
|
+
ctx.fillStyle = '#0b0b0a';
|
|
89
|
+
ctx.fill();
|
|
90
|
+
ctx.restore();
|
|
91
|
+
|
|
92
|
+
ctx.save();
|
|
93
|
+
roundRectPath(ctx, sx, sy, screenW, screenH, Math.round(screenW * 0.105));
|
|
94
|
+
ctx.clip();
|
|
95
|
+
paintScreen(ctx, img, sx, sy, screenW, screenH);
|
|
96
|
+
ctx.restore();
|
|
97
|
+
|
|
98
|
+
// Centred punch-hole camera near the top edge.
|
|
99
|
+
ctx.beginPath();
|
|
100
|
+
ctx.arc(sx + screenW / 2, sy + bezel * 1.6, Math.round(screenW * 0.018), 0, Math.PI * 2);
|
|
101
|
+
ctx.fillStyle = '#050505';
|
|
102
|
+
ctx.fill();
|
|
103
|
+
return { bodyH, bodyY, screen: { x: sx, y: sy, w: screenW, h: screenH, r: Math.round(screenW * 0.105) } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** iPad: dark unibody, tight even bezel, gentler corners, no island. */
|
|
107
|
+
export function drawIpadFrame(ctx, img, cx, cy, screenW, opts = {}) {
|
|
108
|
+
const screenH = screenHeight(img, screenW, opts);
|
|
109
|
+
const bezel = Math.round(screenW * 0.024);
|
|
110
|
+
const bodyW = screenW + bezel * 2;
|
|
111
|
+
const bodyH = screenH + bezel * 2;
|
|
112
|
+
const bodyX = Math.round(cx - bodyW / 2);
|
|
113
|
+
const bodyY = Math.round(cy - bodyH / 2);
|
|
114
|
+
const sx = bodyX + bezel;
|
|
115
|
+
const sy = bodyY + bezel;
|
|
116
|
+
|
|
117
|
+
ctx.save();
|
|
118
|
+
ctx.shadowColor = 'rgba(0,0,0,0.36)';
|
|
119
|
+
ctx.shadowBlur = Math.round(screenW * 0.06);
|
|
120
|
+
ctx.shadowOffsetY = Math.round(screenW * 0.024);
|
|
121
|
+
roundRectPath(ctx, bodyX, bodyY, bodyW, bodyH, Math.round(screenW * 0.05));
|
|
122
|
+
ctx.fillStyle = '#0b0b0a';
|
|
123
|
+
ctx.fill();
|
|
124
|
+
ctx.restore();
|
|
125
|
+
|
|
126
|
+
ctx.save();
|
|
127
|
+
roundRectPath(ctx, sx, sy, screenW, screenH, Math.round(screenW * 0.035));
|
|
128
|
+
ctx.clip();
|
|
129
|
+
paintScreen(ctx, img, sx, sy, screenW, screenH);
|
|
130
|
+
ctx.restore();
|
|
131
|
+
return { bodyH, bodyY, screen: { x: sx, y: sy, w: screenW, h: screenH, r: Math.round(screenW * 0.035) } };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Apple Watch: round dark unibody, thin ring, a crown nub; the square capture is clipped to the circle. */
|
|
135
|
+
export function drawWatchFrame(ctx, img, cx, cy, diameter) {
|
|
136
|
+
const screenR = diameter / 2;
|
|
137
|
+
const bezel = Math.round(diameter * 0.05);
|
|
138
|
+
const bodyR = screenR + bezel;
|
|
139
|
+
|
|
140
|
+
ctx.save();
|
|
141
|
+
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
|
142
|
+
ctx.shadowBlur = Math.round(diameter * 0.08);
|
|
143
|
+
ctx.shadowOffsetY = Math.round(diameter * 0.03);
|
|
144
|
+
ctx.beginPath();
|
|
145
|
+
ctx.arc(cx, cy, bodyR, 0, Math.PI * 2);
|
|
146
|
+
ctx.fillStyle = '#0b0b0a';
|
|
147
|
+
ctx.fill();
|
|
148
|
+
ctx.restore();
|
|
149
|
+
|
|
150
|
+
// Crown nub on the right edge.
|
|
151
|
+
const crownW = Math.round(diameter * 0.03);
|
|
152
|
+
const crownH = Math.round(diameter * 0.12);
|
|
153
|
+
roundRectPath(ctx, cx + bodyR - Math.round(crownW * 0.35), cy - crownH / 2, crownW, crownH, Math.round(crownW * 0.5));
|
|
154
|
+
ctx.fillStyle = '#1a1a18';
|
|
155
|
+
ctx.fill();
|
|
156
|
+
|
|
157
|
+
ctx.save();
|
|
158
|
+
ctx.beginPath();
|
|
159
|
+
ctx.arc(cx, cy, screenR, 0, Math.PI * 2);
|
|
160
|
+
ctx.clip();
|
|
161
|
+
drawInto(ctx, img, cx - screenR, cy - screenR, diameter, diameter);
|
|
162
|
+
ctx.restore();
|
|
163
|
+
return { bodyH: bodyR * 2 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Dispatch by frame id → the draw fn (null for unsupported → caller falls back to a plain still). */
|
|
167
|
+
export function frameFor(id) {
|
|
168
|
+
return {
|
|
169
|
+
phone: drawPhoneFrame, iphone: drawPhoneFrame,
|
|
170
|
+
android: drawAndroidPhoneFrame,
|
|
171
|
+
ipad: drawIpadFrame, tablet: drawIpadFrame,
|
|
172
|
+
watch: drawWatchFrame,
|
|
173
|
+
}[id] || null; // 'mac' etc. → no added frame
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Infer a frame id from a screenshot target id (order matters: play-phone → android before → phone). */
|
|
177
|
+
export function inferFrame(target) {
|
|
178
|
+
if (/watch|wear/.test(target)) return 'watch';
|
|
179
|
+
// Play targets are matched FIRST: `play-tablet` contains "tablet", so an ipad/tablet test placed above
|
|
180
|
+
// this would frame Android tablet captures in an iPad body — the same misrepresentation we refuse to
|
|
181
|
+
// make on phones.
|
|
182
|
+
if (/^play/.test(target) || /android/.test(target)) return 'android';
|
|
183
|
+
if (/ipad|tablet/.test(target)) return 'ipad';
|
|
184
|
+
if (/iphone|phone/.test(target)) return 'phone';
|
|
185
|
+
return null;
|
|
186
|
+
}
|
package/src/graphic.mjs
CHANGED
|
@@ -1,77 +1,107 @@
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The Play app icon — 512×512, 32-bit PNG, alpha allowed (the ONE store asset where it is).
|
|
82
|
+
*
|
|
83
|
+
* This is a branded graphic, not a per-scene screenshot: it renders `brand.logo` on the brand ground,
|
|
84
|
+
* which is why the target carries `graphic: true`. If no logo is configured we refuse rather than emit a
|
|
85
|
+
* blank square — a silently-empty icon is worse than a clear error at build time.
|
|
86
|
+
*/
|
|
87
|
+
export async function buildAppIcon({ W = 512, H = 512, brand, theme, outFile }) {
|
|
88
|
+
const th = { ...DEFAULT, ...(theme || {}) };
|
|
89
|
+
if (!brand.logo || !fs.existsSync(brand.logo)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"play-icon needs `brand.logo` — a square PNG of your app icon. Set it, or drop the 'play-icon' target " +
|
|
92
|
+
'(Play also accepts the icon uploaded straight to the Console, so this target is a convenience).',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const c = createCanvas(W, H);
|
|
96
|
+
const ctx = c.getContext('2d');
|
|
97
|
+
const logo = await loadImage(brand.logo);
|
|
98
|
+
// Fill the square: Play shows the icon masked to its own shape, so bleed to the edges and let the
|
|
99
|
+
// store apply the mask. Any transparency in the source logo is preserved (alpha is legal here).
|
|
100
|
+
const a = logo.height / logo.width;
|
|
101
|
+
let dw = W;
|
|
102
|
+
let dh = W * a;
|
|
103
|
+
if (dh < H) { dh = H; dw = H / a; }
|
|
104
|
+
ctx.drawImage(logo, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
|
105
|
+
fs.writeFileSync(outFile, c.toBuffer('image/png')); // RGBA — do NOT route through rgbPngBuffer
|
|
106
|
+
return { outFile, W, H };
|
|
107
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
|
-
/** Programmatic API — use the engine directly without the CLI. */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
/** Programmatic API — use the engine directly without the CLI. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identity helper for `zdymak.config.mjs` — returns the config unchanged, but lets an editor type-check
|
|
5
|
+
* and autocomplete it from `types/index.d.ts` without the project needing TypeScript:
|
|
6
|
+
*
|
|
7
|
+
* import { defineConfig } from 'zdymak';
|
|
8
|
+
* export default defineConfig({ … });
|
|
9
|
+
*
|
|
10
|
+
* @param {import('../types/index.js').Config} config
|
|
11
|
+
* @returns {import('../types/index.js').Config}
|
|
12
|
+
*/
|
|
13
|
+
export const defineConfig = (config) => config;
|
|
14
|
+
|
|
15
|
+
export { buildVideo } from './video.mjs';
|
|
16
|
+
export { buildReel } from './reel.mjs';
|
|
17
|
+
export { buildPremium } from './premium.mjs';
|
|
18
|
+
export { buildDeviceScreenshots, localizeScenes, untranslatedScenes, localizeBrand } from './screenshots.mjs';
|
|
19
|
+
export { buildFeatureGraphic } from './graphic.mjs';
|
|
20
|
+
export { renderStill } from './still.mjs';
|
|
21
|
+
export { rgbPngBuffer } from './png.mjs';
|
|
22
|
+
export { loadConfig } from './config.mjs';
|
|
23
|
+
export { registerFonts } from './fonts.mjs';
|
|
24
|
+
export { VIDEO_TARGETS, IMAGE_TARGETS, videoTarget } from './specs.mjs';
|
|
25
|
+
export { TRANSITIONS, TRANSITION_IDS, transitionFor } from './transitions.mjs';
|
|
26
|
+
export { EFFECTS, EFFECT_IDS, effectFor } from './effects.mjs';
|
|
27
|
+
export { run } from './cli.mjs';
|