@shotstack/shotstack-canvas 1.0.2 → 1.0.4
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/dist/entry.node.cjs +1548 -0
- package/dist/entry.node.cjs.map +1 -0
- package/dist/entry.node.d.cts +229 -0
- package/dist/entry.node.d.ts +229 -0
- package/dist/entry.node.js +1511 -0
- package/dist/entry.node.js.map +1 -0
- package/dist/entry.web.d.ts +207 -0
- package/dist/entry.web.js +1285 -0
- package/dist/entry.web.js.map +1 -0
- package/package.json +20 -7
- package/.eslintrc.cjs +0 -7
- package/.prettierrc +0 -1
- package/pnpm-workspace.yaml +0 -3
- package/scripts/vendor-harfbuzz.js +0 -64
- package/src/config/canvas-constants.ts +0 -30
- package/src/core/animations.ts +0 -570
- package/src/core/colors.ts +0 -11
- package/src/core/decoration.ts +0 -9
- package/src/core/drawops.ts +0 -206
- package/src/core/font-registry.ts +0 -77
- package/src/core/gradients.ts +0 -12
- package/src/core/layout.ts +0 -184
- package/src/core/utils.ts +0 -3
- package/src/core/video-generator.ts +0 -157
- package/src/env/entry.node.ts +0 -167
- package/src/env/entry.web.ts +0 -146
- package/src/index.ts +0 -1
- package/src/io/node.ts +0 -45
- package/src/io/web.ts +0 -5
- package/src/painters/node.ts +0 -290
- package/src/painters/web.ts +0 -224
- package/src/schema/asset-schema.ts +0 -166
- package/src/types.ts +0 -36
- package/src/wasm/hb-loader.ts +0 -31
- package/tsconfig.base.json +0 -22
- package/tsconfig.json +0 -16
- package/tsup.config.ts +0 -52
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
// src/schema/asset-schema.ts
|
|
2
|
+
import Joi from "joi";
|
|
3
|
+
|
|
4
|
+
// src/config/canvas-constants.ts
|
|
5
|
+
var CANVAS_CONFIG = {
|
|
6
|
+
DEFAULTS: {
|
|
7
|
+
width: 800,
|
|
8
|
+
height: 400,
|
|
9
|
+
pixelRatio: 2,
|
|
10
|
+
fontFamily: "Roboto",
|
|
11
|
+
fontSize: 48,
|
|
12
|
+
color: "#000000",
|
|
13
|
+
textAlign: "left"
|
|
14
|
+
},
|
|
15
|
+
LIMITS: {
|
|
16
|
+
minWidth: 1,
|
|
17
|
+
maxWidth: 4096,
|
|
18
|
+
minHeight: 1,
|
|
19
|
+
maxHeight: 4096,
|
|
20
|
+
minFontSize: 1,
|
|
21
|
+
maxFontSize: 512,
|
|
22
|
+
minDuration: 0.1,
|
|
23
|
+
maxDuration: 120,
|
|
24
|
+
maxTextLength: 1e4
|
|
25
|
+
},
|
|
26
|
+
ANIMATION_TYPES: [
|
|
27
|
+
"typewriter",
|
|
28
|
+
"fadeIn",
|
|
29
|
+
"slideIn",
|
|
30
|
+
"shift",
|
|
31
|
+
"ascend",
|
|
32
|
+
"movingLetters"
|
|
33
|
+
]
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// src/schema/asset-schema.ts
|
|
37
|
+
var HEX6 = /^#[A-Fa-f0-9]{6}$/;
|
|
38
|
+
var gradientSchema = Joi.object({
|
|
39
|
+
type: Joi.string().valid("linear", "radial").default("linear"),
|
|
40
|
+
angle: Joi.number().min(0).max(360).default(0),
|
|
41
|
+
stops: Joi.array().items(
|
|
42
|
+
Joi.object({
|
|
43
|
+
offset: Joi.number().min(0).max(1).required(),
|
|
44
|
+
color: Joi.string().pattern(HEX6).required()
|
|
45
|
+
}).unknown(false)
|
|
46
|
+
).min(2).required()
|
|
47
|
+
}).unknown(false);
|
|
48
|
+
var shadowSchema = Joi.object({
|
|
49
|
+
offsetX: Joi.number().default(0),
|
|
50
|
+
offsetY: Joi.number().default(0),
|
|
51
|
+
blur: Joi.number().min(0).default(0),
|
|
52
|
+
color: Joi.string().pattern(HEX6).default("#000000"),
|
|
53
|
+
opacity: Joi.number().min(0).max(1).default(0.5)
|
|
54
|
+
}).unknown(false);
|
|
55
|
+
var strokeSchema = Joi.object({
|
|
56
|
+
width: Joi.number().min(0).default(0),
|
|
57
|
+
color: Joi.string().pattern(HEX6).default("#000000"),
|
|
58
|
+
opacity: Joi.number().min(0).max(1).default(1)
|
|
59
|
+
}).unknown(false);
|
|
60
|
+
var fontSchema = Joi.object({
|
|
61
|
+
family: Joi.string().default(CANVAS_CONFIG.DEFAULTS.fontFamily),
|
|
62
|
+
size: Joi.number().min(CANVAS_CONFIG.LIMITS.minFontSize).max(CANVAS_CONFIG.LIMITS.maxFontSize).default(CANVAS_CONFIG.DEFAULTS.fontSize),
|
|
63
|
+
weight: Joi.alternatives().try(Joi.string(), Joi.number()).default("400"),
|
|
64
|
+
style: Joi.string().valid("normal", "italic", "oblique").default("normal"),
|
|
65
|
+
color: Joi.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
|
|
66
|
+
opacity: Joi.number().min(0).max(1).default(1)
|
|
67
|
+
}).unknown(false);
|
|
68
|
+
var styleSchema = Joi.object({
|
|
69
|
+
letterSpacing: Joi.number().default(0),
|
|
70
|
+
lineHeight: Joi.number().min(0).max(10).default(1.2),
|
|
71
|
+
textTransform: Joi.string().valid("none", "uppercase", "lowercase", "capitalize").default("none"),
|
|
72
|
+
textDecoration: Joi.string().valid("none", "underline", "line-through").default("none"),
|
|
73
|
+
gradient: gradientSchema.optional()
|
|
74
|
+
}).unknown(false);
|
|
75
|
+
var alignmentSchema = Joi.object({
|
|
76
|
+
horizontal: Joi.string().valid("left", "center", "right").default(CANVAS_CONFIG.DEFAULTS.textAlign),
|
|
77
|
+
vertical: Joi.string().valid("top", "middle", "bottom").default("middle")
|
|
78
|
+
}).unknown(false);
|
|
79
|
+
var animationSchema = Joi.object({
|
|
80
|
+
preset: Joi.string().valid(...CANVAS_CONFIG.ANIMATION_TYPES),
|
|
81
|
+
speed: Joi.number().min(0.1).max(10).default(1),
|
|
82
|
+
duration: Joi.number().min(CANVAS_CONFIG.LIMITS.minDuration).max(CANVAS_CONFIG.LIMITS.maxDuration).optional(),
|
|
83
|
+
style: Joi.string().valid("character", "word").optional().when("preset", {
|
|
84
|
+
is: Joi.valid("typewriter", "shift"),
|
|
85
|
+
then: Joi.optional(),
|
|
86
|
+
otherwise: Joi.forbidden()
|
|
87
|
+
}),
|
|
88
|
+
direction: Joi.string().optional().when("preset", {
|
|
89
|
+
switch: [
|
|
90
|
+
{ is: "ascend", then: Joi.valid("up", "down") },
|
|
91
|
+
{ is: "shift", then: Joi.valid("left", "right", "up", "down") },
|
|
92
|
+
{ is: "slideIn", then: Joi.valid("left", "right", "up", "down") },
|
|
93
|
+
{ is: "movingLetters", then: Joi.valid("left", "right", "up", "down") }
|
|
94
|
+
],
|
|
95
|
+
otherwise: Joi.forbidden()
|
|
96
|
+
})
|
|
97
|
+
}).unknown(false);
|
|
98
|
+
var backgroundSchema = Joi.object({
|
|
99
|
+
color: Joi.string().pattern(HEX6).optional(),
|
|
100
|
+
opacity: Joi.number().min(0).max(1).default(1),
|
|
101
|
+
borderRadius: Joi.number().min(0).default(0)
|
|
102
|
+
}).unknown(false);
|
|
103
|
+
var customFontSchema = Joi.object({
|
|
104
|
+
src: Joi.string().uri().required(),
|
|
105
|
+
family: Joi.string().required(),
|
|
106
|
+
weight: Joi.alternatives().try(Joi.string(), Joi.number()).optional(),
|
|
107
|
+
style: Joi.string().optional(),
|
|
108
|
+
originalFamily: Joi.string().optional()
|
|
109
|
+
}).unknown(false);
|
|
110
|
+
var RichTextAssetSchema = Joi.object({
|
|
111
|
+
type: Joi.string().valid("rich-text").required(),
|
|
112
|
+
text: Joi.string().allow("").max(CANVAS_CONFIG.LIMITS.maxTextLength).default(""),
|
|
113
|
+
width: Joi.number().min(CANVAS_CONFIG.LIMITS.minWidth).max(CANVAS_CONFIG.LIMITS.maxWidth).default(CANVAS_CONFIG.DEFAULTS.width).optional(),
|
|
114
|
+
height: Joi.number().min(CANVAS_CONFIG.LIMITS.minHeight).max(CANVAS_CONFIG.LIMITS.maxHeight).default(CANVAS_CONFIG.DEFAULTS.height).optional(),
|
|
115
|
+
font: fontSchema.optional(),
|
|
116
|
+
style: styleSchema.optional(),
|
|
117
|
+
stroke: strokeSchema.optional(),
|
|
118
|
+
shadow: shadowSchema.optional(),
|
|
119
|
+
background: backgroundSchema.optional(),
|
|
120
|
+
align: alignmentSchema.optional(),
|
|
121
|
+
animation: animationSchema.optional(),
|
|
122
|
+
customFonts: Joi.array().items(customFontSchema).optional(),
|
|
123
|
+
cacheEnabled: Joi.boolean().default(true),
|
|
124
|
+
pixelRatio: Joi.number().min(1).max(3).default(CANVAS_CONFIG.DEFAULTS.pixelRatio)
|
|
125
|
+
}).unknown(false);
|
|
126
|
+
|
|
127
|
+
// src/wasm/hb-loader.ts
|
|
128
|
+
var hbSingleton = null;
|
|
129
|
+
async function initHB(wasmBaseURL) {
|
|
130
|
+
if (hbSingleton) return hbSingleton;
|
|
131
|
+
const harfbuzzjs = await import("harfbuzzjs");
|
|
132
|
+
const hbPromise = harfbuzzjs.default;
|
|
133
|
+
if (typeof hbPromise === "function") {
|
|
134
|
+
hbSingleton = await hbPromise();
|
|
135
|
+
} else if (hbPromise && typeof hbPromise.then === "function") {
|
|
136
|
+
hbSingleton = await hbPromise;
|
|
137
|
+
} else {
|
|
138
|
+
hbSingleton = hbPromise;
|
|
139
|
+
}
|
|
140
|
+
if (!hbSingleton || typeof hbSingleton.createBuffer !== "function") {
|
|
141
|
+
throw new Error("Failed to initialize HarfBuzz: invalid API");
|
|
142
|
+
}
|
|
143
|
+
return hbSingleton;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/core/font-registry.ts
|
|
147
|
+
var FontRegistry = class {
|
|
148
|
+
hb;
|
|
149
|
+
faces = /* @__PURE__ */ new Map();
|
|
150
|
+
fonts = /* @__PURE__ */ new Map();
|
|
151
|
+
blobs = /* @__PURE__ */ new Map();
|
|
152
|
+
wasmBaseURL;
|
|
153
|
+
constructor(wasmBaseURL) {
|
|
154
|
+
this.wasmBaseURL = wasmBaseURL;
|
|
155
|
+
}
|
|
156
|
+
async init() {
|
|
157
|
+
if (!this.hb) this.hb = await initHB(this.wasmBaseURL);
|
|
158
|
+
}
|
|
159
|
+
getHB() {
|
|
160
|
+
if (!this.hb) throw new Error("FontRegistry not initialized - call init() first");
|
|
161
|
+
return this.hb;
|
|
162
|
+
}
|
|
163
|
+
key(desc) {
|
|
164
|
+
return `${desc.family}__${desc.weight ?? "400"}__${desc.style ?? "normal"}`;
|
|
165
|
+
}
|
|
166
|
+
async registerFromBytes(bytes, desc) {
|
|
167
|
+
if (!this.hb) await this.init();
|
|
168
|
+
const k = this.key(desc);
|
|
169
|
+
if (this.fonts.has(k)) return;
|
|
170
|
+
const blob = this.hb.createBlob(bytes);
|
|
171
|
+
const face = this.hb.createFace(blob, 0);
|
|
172
|
+
const font = this.hb.createFont(face);
|
|
173
|
+
const upem = face.upem || 1e3;
|
|
174
|
+
font.setScale(upem, upem);
|
|
175
|
+
this.blobs.set(k, blob);
|
|
176
|
+
this.faces.set(k, face);
|
|
177
|
+
this.fonts.set(k, font);
|
|
178
|
+
}
|
|
179
|
+
getFont(desc) {
|
|
180
|
+
const k = this.key(desc);
|
|
181
|
+
const f = this.fonts.get(k);
|
|
182
|
+
if (!f) throw new Error(`Font not registered for ${k}`);
|
|
183
|
+
return f;
|
|
184
|
+
}
|
|
185
|
+
getFace(desc) {
|
|
186
|
+
const k = this.key(desc);
|
|
187
|
+
return this.faces.get(k);
|
|
188
|
+
}
|
|
189
|
+
/** NEW: expose units-per-em for scaling glyph paths to px */
|
|
190
|
+
getUnitsPerEm(desc) {
|
|
191
|
+
const face = this.getFace(desc);
|
|
192
|
+
return face?.upem || 1e3;
|
|
193
|
+
}
|
|
194
|
+
glyphPath(desc, glyphId) {
|
|
195
|
+
const font = this.getFont(desc);
|
|
196
|
+
const path = font.glyphToPath(glyphId);
|
|
197
|
+
return path && path !== "" ? path : "M 0 0";
|
|
198
|
+
}
|
|
199
|
+
destroy() {
|
|
200
|
+
for (const [, f] of this.fonts) f.destroy?.();
|
|
201
|
+
for (const [, f] of this.faces) f.destroy?.();
|
|
202
|
+
for (const [, b] of this.blobs) b.destroy?.();
|
|
203
|
+
this.fonts.clear();
|
|
204
|
+
this.faces.clear();
|
|
205
|
+
this.blobs.clear();
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/core/layout.ts
|
|
210
|
+
var LayoutEngine = class {
|
|
211
|
+
constructor(fonts) {
|
|
212
|
+
this.fonts = fonts;
|
|
213
|
+
}
|
|
214
|
+
transformText(t, tr) {
|
|
215
|
+
switch (tr) {
|
|
216
|
+
case "uppercase":
|
|
217
|
+
return t.toUpperCase();
|
|
218
|
+
case "lowercase":
|
|
219
|
+
return t.toLowerCase();
|
|
220
|
+
case "capitalize":
|
|
221
|
+
return t.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
222
|
+
default:
|
|
223
|
+
return t;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
shapeFull(text, desc) {
|
|
227
|
+
const hb = this.fonts.getHB();
|
|
228
|
+
const buffer = hb.createBuffer();
|
|
229
|
+
buffer.addText(text);
|
|
230
|
+
buffer.guessSegmentProperties();
|
|
231
|
+
const font = this.fonts.getFont(desc);
|
|
232
|
+
const face = this.fonts.getFace(desc);
|
|
233
|
+
const upem = face?.upem || 1e3;
|
|
234
|
+
font.setScale(upem, upem);
|
|
235
|
+
hb.shape(font, buffer);
|
|
236
|
+
const result = buffer.json();
|
|
237
|
+
buffer.destroy();
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
layout(params) {
|
|
241
|
+
const { textTransform, desc, fontSize, letterSpacing, width } = params;
|
|
242
|
+
const input = this.transformText(params.text, textTransform);
|
|
243
|
+
if (!input || input.length === 0) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
const shaped = this.shapeFull(input, desc);
|
|
247
|
+
const face = this.fonts.getFace(desc);
|
|
248
|
+
const upem = face?.upem || 1e3;
|
|
249
|
+
const scale = fontSize / upem;
|
|
250
|
+
const glyphs = shaped.map((g, i) => {
|
|
251
|
+
const charIndex = g.cl;
|
|
252
|
+
let char;
|
|
253
|
+
if (charIndex >= 0 && charIndex < input.length) {
|
|
254
|
+
char = input[charIndex];
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
id: g.g,
|
|
258
|
+
xAdvance: g.ax * scale + letterSpacing,
|
|
259
|
+
xOffset: g.dx * scale,
|
|
260
|
+
yOffset: -g.dy * scale,
|
|
261
|
+
cluster: g.cl,
|
|
262
|
+
char
|
|
263
|
+
// This now correctly maps to the original character
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
const lines = [];
|
|
267
|
+
let currentLine = [];
|
|
268
|
+
let currentWidth = 0;
|
|
269
|
+
const spaceIndices = /* @__PURE__ */ new Set();
|
|
270
|
+
for (let i = 0; i < input.length; i++) {
|
|
271
|
+
if (input[i] === " ") {
|
|
272
|
+
spaceIndices.add(i);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
let lastBreakIndex = -1;
|
|
276
|
+
for (let i = 0; i < glyphs.length; i++) {
|
|
277
|
+
const glyph = glyphs[i];
|
|
278
|
+
const glyphWidth = glyph.xAdvance;
|
|
279
|
+
if (glyph.char === "\n") {
|
|
280
|
+
if (currentLine.length > 0) {
|
|
281
|
+
lines.push({
|
|
282
|
+
glyphs: currentLine,
|
|
283
|
+
width: currentWidth,
|
|
284
|
+
y: 0
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
currentLine = [];
|
|
288
|
+
currentWidth = 0;
|
|
289
|
+
lastBreakIndex = i;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (currentWidth + glyphWidth > width && currentLine.length > 0) {
|
|
293
|
+
if (lastBreakIndex > -1) {
|
|
294
|
+
const breakPoint = lastBreakIndex - (i - currentLine.length) + 1;
|
|
295
|
+
const nextLine = currentLine.splice(breakPoint);
|
|
296
|
+
const lineWidth = currentLine.reduce((sum, g) => sum + g.xAdvance, 0);
|
|
297
|
+
lines.push({
|
|
298
|
+
glyphs: currentLine,
|
|
299
|
+
width: lineWidth,
|
|
300
|
+
y: 0
|
|
301
|
+
});
|
|
302
|
+
currentLine = nextLine;
|
|
303
|
+
currentWidth = nextLine.reduce((sum, g) => sum + g.xAdvance, 0);
|
|
304
|
+
} else {
|
|
305
|
+
lines.push({
|
|
306
|
+
glyphs: currentLine,
|
|
307
|
+
width: currentWidth,
|
|
308
|
+
y: 0
|
|
309
|
+
});
|
|
310
|
+
currentLine = [];
|
|
311
|
+
currentWidth = 0;
|
|
312
|
+
}
|
|
313
|
+
lastBreakIndex = -1;
|
|
314
|
+
}
|
|
315
|
+
currentLine.push(glyph);
|
|
316
|
+
currentWidth += glyphWidth;
|
|
317
|
+
if (spaceIndices.has(glyph.cluster)) {
|
|
318
|
+
lastBreakIndex = i;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (currentLine.length > 0) {
|
|
322
|
+
lines.push({
|
|
323
|
+
glyphs: currentLine,
|
|
324
|
+
width: currentWidth,
|
|
325
|
+
y: 0
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const lineHeight = params.lineHeight * fontSize;
|
|
329
|
+
for (let i = 0; i < lines.length; i++) {
|
|
330
|
+
lines[i].y = (i + 1) * lineHeight;
|
|
331
|
+
}
|
|
332
|
+
return lines;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/core/gradients.ts
|
|
337
|
+
function gradientSpecFrom(g, opacity) {
|
|
338
|
+
if (g.type === "linear") {
|
|
339
|
+
return { kind: "linear", angle: g.angle, stops: g.stops, opacity };
|
|
340
|
+
} else {
|
|
341
|
+
return { kind: "radial", stops: g.stops, opacity };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/core/decoration.ts
|
|
346
|
+
function decorationGeometry(kind, p) {
|
|
347
|
+
const thickness = Math.max(1, Math.round(p.fontSize * 0.05));
|
|
348
|
+
let y = p.baselineY + Math.round(p.fontSize * 0.1);
|
|
349
|
+
if (kind === "line-through") y = p.baselineY - Math.round(p.fontSize * 0.3);
|
|
350
|
+
return { x1: p.xStart, x2: p.xStart + p.lineWidth, y, width: thickness };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/core/drawops.ts
|
|
354
|
+
function buildDrawOps(p) {
|
|
355
|
+
const ops = [];
|
|
356
|
+
ops.push({
|
|
357
|
+
op: "BeginFrame",
|
|
358
|
+
width: p.canvas.width,
|
|
359
|
+
height: p.canvas.height,
|
|
360
|
+
pixelRatio: p.canvas.pixelRatio,
|
|
361
|
+
clear: true,
|
|
362
|
+
bg: p.background ? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius } : void 0
|
|
363
|
+
});
|
|
364
|
+
if (p.lines.length === 0) return ops;
|
|
365
|
+
const upem = Math.max(1, p.getUnitsPerEm?.() ?? 1e3);
|
|
366
|
+
const scale = p.font.size / upem;
|
|
367
|
+
const blockHeight = p.lines[p.lines.length - 1].y;
|
|
368
|
+
let blockY;
|
|
369
|
+
switch (p.align.vertical) {
|
|
370
|
+
case "top":
|
|
371
|
+
blockY = p.font.size;
|
|
372
|
+
break;
|
|
373
|
+
case "bottom":
|
|
374
|
+
blockY = p.textRect.height - blockHeight + p.font.size;
|
|
375
|
+
break;
|
|
376
|
+
case "middle":
|
|
377
|
+
default:
|
|
378
|
+
blockY = (p.textRect.height - blockHeight) / 2 + p.font.size;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
const fill = p.style.gradient ? gradientSpecFrom(p.style.gradient, 1) : { kind: "solid", color: p.font.color, opacity: p.font.opacity };
|
|
382
|
+
const decoColor = p.style.gradient ? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color : p.font.color;
|
|
383
|
+
let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
|
|
384
|
+
for (const line of p.lines) {
|
|
385
|
+
let lineX;
|
|
386
|
+
switch (p.align.horizontal) {
|
|
387
|
+
case "left":
|
|
388
|
+
lineX = 0;
|
|
389
|
+
break;
|
|
390
|
+
case "right":
|
|
391
|
+
lineX = p.textRect.width - line.width;
|
|
392
|
+
break;
|
|
393
|
+
case "center":
|
|
394
|
+
default:
|
|
395
|
+
lineX = (p.textRect.width - line.width) / 2;
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
let xCursor = lineX;
|
|
399
|
+
const baselineY = blockY + line.y - p.font.size;
|
|
400
|
+
for (const glyph of line.glyphs) {
|
|
401
|
+
const path = p.glyphPathProvider(glyph.id);
|
|
402
|
+
if (!path || path === "M 0 0") {
|
|
403
|
+
xCursor += glyph.xAdvance;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
const glyphX = xCursor + glyph.xOffset;
|
|
407
|
+
const glyphY = baselineY + glyph.yOffset;
|
|
408
|
+
const pb = computePathBounds(path);
|
|
409
|
+
const x1 = glyphX + scale * pb.x;
|
|
410
|
+
const x2 = glyphX + scale * (pb.x + pb.w);
|
|
411
|
+
const y1 = glyphY - scale * (pb.y + pb.h);
|
|
412
|
+
const y2 = glyphY - scale * pb.y;
|
|
413
|
+
if (x1 < gMinX) gMinX = x1;
|
|
414
|
+
if (y1 < gMinY) gMinY = y1;
|
|
415
|
+
if (x2 > gMaxX) gMaxX = x2;
|
|
416
|
+
if (y2 > gMaxY) gMaxY = y2;
|
|
417
|
+
if (p.shadow && p.shadow.blur > 0) {
|
|
418
|
+
ops.push({
|
|
419
|
+
isShadow: true,
|
|
420
|
+
op: "FillPath",
|
|
421
|
+
path,
|
|
422
|
+
x: glyphX + p.shadow.offsetX,
|
|
423
|
+
y: glyphY + p.shadow.offsetY,
|
|
424
|
+
// @ts-ignore scale propagated to painters
|
|
425
|
+
scale,
|
|
426
|
+
fill: { kind: "solid", color: p.shadow.color, opacity: p.shadow.opacity }
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (p.stroke && p.stroke.width > 0) {
|
|
430
|
+
ops.push({
|
|
431
|
+
op: "StrokePath",
|
|
432
|
+
path,
|
|
433
|
+
x: glyphX,
|
|
434
|
+
y: glyphY,
|
|
435
|
+
// @ts-ignore scale propagated to painters
|
|
436
|
+
scale,
|
|
437
|
+
width: p.stroke.width,
|
|
438
|
+
color: p.stroke.color,
|
|
439
|
+
opacity: p.stroke.opacity
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
ops.push({
|
|
443
|
+
op: "FillPath",
|
|
444
|
+
path,
|
|
445
|
+
x: glyphX,
|
|
446
|
+
y: glyphY,
|
|
447
|
+
// @ts-ignore scale propagated to painters
|
|
448
|
+
scale,
|
|
449
|
+
fill
|
|
450
|
+
});
|
|
451
|
+
xCursor += glyph.xAdvance;
|
|
452
|
+
}
|
|
453
|
+
if (p.style.textDecoration !== "none") {
|
|
454
|
+
const deco = decorationGeometry(p.style.textDecoration, {
|
|
455
|
+
baselineY,
|
|
456
|
+
fontSize: p.font.size,
|
|
457
|
+
lineWidth: line.width,
|
|
458
|
+
xStart: lineX
|
|
459
|
+
});
|
|
460
|
+
ops.push({
|
|
461
|
+
op: "DecorationLine",
|
|
462
|
+
from: { x: deco.x1, y: deco.y },
|
|
463
|
+
to: { x: deco.x2, y: deco.y },
|
|
464
|
+
width: deco.width,
|
|
465
|
+
color: decoColor,
|
|
466
|
+
opacity: p.font.opacity
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (gMinX !== Infinity) {
|
|
471
|
+
const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
|
|
472
|
+
for (const op of ops) {
|
|
473
|
+
if (op.op === "FillPath" && !op.isShadow) {
|
|
474
|
+
op.gradientBBox = gbox;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return ops;
|
|
479
|
+
}
|
|
480
|
+
function tokenizePath(d) {
|
|
481
|
+
return d.match(/[MLCQZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
|
|
482
|
+
}
|
|
483
|
+
function computePathBounds(d) {
|
|
484
|
+
const t = tokenizePath(d);
|
|
485
|
+
let i = 0;
|
|
486
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
487
|
+
const touch = (x, y) => {
|
|
488
|
+
if (x < minX) minX = x;
|
|
489
|
+
if (y < minY) minY = y;
|
|
490
|
+
if (x > maxX) maxX = x;
|
|
491
|
+
if (y > maxY) maxY = y;
|
|
492
|
+
};
|
|
493
|
+
while (i < t.length) {
|
|
494
|
+
const cmd = t[i++];
|
|
495
|
+
switch (cmd) {
|
|
496
|
+
case "M":
|
|
497
|
+
case "L": {
|
|
498
|
+
const x = parseFloat(t[i++]);
|
|
499
|
+
const y = parseFloat(t[i++]);
|
|
500
|
+
touch(x, y);
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
case "C": {
|
|
504
|
+
const c1x = parseFloat(t[i++]);
|
|
505
|
+
const c1y = parseFloat(t[i++]);
|
|
506
|
+
const c2x = parseFloat(t[i++]);
|
|
507
|
+
const c2y = parseFloat(t[i++]);
|
|
508
|
+
const x = parseFloat(t[i++]);
|
|
509
|
+
const y = parseFloat(t[i++]);
|
|
510
|
+
touch(c1x, c1y);
|
|
511
|
+
touch(c2x, c2y);
|
|
512
|
+
touch(x, y);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case "Q": {
|
|
516
|
+
const cx = parseFloat(t[i++]);
|
|
517
|
+
const cy = parseFloat(t[i++]);
|
|
518
|
+
const x = parseFloat(t[i++]);
|
|
519
|
+
const y = parseFloat(t[i++]);
|
|
520
|
+
touch(cx, cy);
|
|
521
|
+
touch(x, y);
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
case "Z":
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (minX === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
|
|
529
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// src/core/animations.ts
|
|
533
|
+
var DECORATION_DONE_THRESHOLD = 0.999;
|
|
534
|
+
function applyAnimation(ops, lines, p) {
|
|
535
|
+
if (!p.anim || !p.anim.preset) return ops;
|
|
536
|
+
const { preset } = p.anim;
|
|
537
|
+
const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
|
|
538
|
+
const duration = p.anim.duration ?? Math.max(0.3, totalGlyphs / 30 / p.anim.speed);
|
|
539
|
+
const progress = Math.max(0, Math.min(1, p.t / duration));
|
|
540
|
+
switch (preset) {
|
|
541
|
+
case "typewriter":
|
|
542
|
+
return applyTypewriterAnimation(
|
|
543
|
+
ops,
|
|
544
|
+
lines,
|
|
545
|
+
progress,
|
|
546
|
+
p.anim.style,
|
|
547
|
+
p.fontSize,
|
|
548
|
+
p.t,
|
|
549
|
+
duration
|
|
550
|
+
);
|
|
551
|
+
case "fadeIn":
|
|
552
|
+
return applyFadeInAnimation(ops, progress);
|
|
553
|
+
case "slideIn":
|
|
554
|
+
return applySlideInAnimation(ops, progress, p.anim.direction ?? "left", p.fontSize);
|
|
555
|
+
case "shift":
|
|
556
|
+
return applyShiftAnimation(
|
|
557
|
+
ops,
|
|
558
|
+
lines,
|
|
559
|
+
progress,
|
|
560
|
+
p.anim.direction ?? "left",
|
|
561
|
+
p.fontSize,
|
|
562
|
+
p.anim.style,
|
|
563
|
+
duration
|
|
564
|
+
);
|
|
565
|
+
case "ascend":
|
|
566
|
+
return applyAscendAnimation(
|
|
567
|
+
ops,
|
|
568
|
+
lines,
|
|
569
|
+
progress,
|
|
570
|
+
p.anim.direction ?? "up",
|
|
571
|
+
p.fontSize,
|
|
572
|
+
duration
|
|
573
|
+
);
|
|
574
|
+
case "movingLetters":
|
|
575
|
+
return applyMovingLettersAnimation(ops, progress, p.anim.direction ?? "up", p.fontSize);
|
|
576
|
+
default:
|
|
577
|
+
return ops;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
var isShadowFill = (op) => op.op === "FillPath" && op.isShadow === true;
|
|
581
|
+
var isGlyphFill = (op) => op.op === "FillPath" && !op.isShadow === true;
|
|
582
|
+
function getTextColorFromOps(ops) {
|
|
583
|
+
for (const op of ops) {
|
|
584
|
+
if (op.op === "FillPath") {
|
|
585
|
+
const fill = op.fill;
|
|
586
|
+
if (fill?.kind === "solid") return fill.color;
|
|
587
|
+
if ((fill?.kind === "linear" || fill?.kind === "radial") && Array.isArray(fill.stops) && fill.stops.length) {
|
|
588
|
+
return fill.stops[fill.stops.length - 1].color || "#000000";
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return "#000000";
|
|
593
|
+
}
|
|
594
|
+
function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, duration) {
|
|
595
|
+
const byWord = style === "word";
|
|
596
|
+
if (byWord) {
|
|
597
|
+
const wordSegments = getWordSegments(lines);
|
|
598
|
+
const totalWords = wordSegments.length;
|
|
599
|
+
const visibleWords = Math.floor(progress * totalWords);
|
|
600
|
+
if (visibleWords === 0) return ops.filter((x) => x.op === "BeginFrame");
|
|
601
|
+
let totalVisibleGlyphs = 0;
|
|
602
|
+
for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
|
|
603
|
+
totalVisibleGlyphs += wordSegments[i].glyphCount;
|
|
604
|
+
}
|
|
605
|
+
const visibleOpsRaw = sliceGlyphOps(ops, totalVisibleGlyphs);
|
|
606
|
+
const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
|
|
607
|
+
if (progress < 1 && totalVisibleGlyphs > 0) {
|
|
608
|
+
return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time);
|
|
609
|
+
}
|
|
610
|
+
return visibleOps;
|
|
611
|
+
} else {
|
|
612
|
+
const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
|
|
613
|
+
const visibleGlyphs = Math.floor(progress * totalGlyphs);
|
|
614
|
+
if (visibleGlyphs === 0) return ops.filter((x) => x.op === "BeginFrame");
|
|
615
|
+
const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
|
|
616
|
+
const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
|
|
617
|
+
if (progress < 1 && visibleGlyphs > 0) {
|
|
618
|
+
return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time);
|
|
619
|
+
}
|
|
620
|
+
return visibleOps;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function applyAscendAnimation(ops, lines, progress, direction, fontSize, duration) {
|
|
624
|
+
const wordSegments = getWordSegments(lines);
|
|
625
|
+
const totalWords = wordSegments.length;
|
|
626
|
+
if (totalWords === 0) return ops;
|
|
627
|
+
const result = [];
|
|
628
|
+
let glyphIndex = 0;
|
|
629
|
+
for (const op of ops) {
|
|
630
|
+
if (op.op === "BeginFrame") {
|
|
631
|
+
result.push(op);
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
for (const op of ops) {
|
|
636
|
+
if (op.op === "FillPath" || op.op === "StrokePath") {
|
|
637
|
+
let wordIndex = -1, acc = 0;
|
|
638
|
+
for (let i = 0; i < wordSegments.length; i++) {
|
|
639
|
+
const gcount = wordSegments[i].glyphCount;
|
|
640
|
+
if (glyphIndex >= acc && glyphIndex < acc + gcount) {
|
|
641
|
+
wordIndex = i;
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
acc += gcount;
|
|
645
|
+
}
|
|
646
|
+
if (wordIndex >= 0) {
|
|
647
|
+
const startF = wordIndex / Math.max(1, totalWords) * (duration / duration);
|
|
648
|
+
const endF = Math.min(1, startF + 0.3);
|
|
649
|
+
if (progress >= endF) {
|
|
650
|
+
result.push(op);
|
|
651
|
+
} else if (progress > startF) {
|
|
652
|
+
const local = (progress - startF) / Math.max(1e-6, endF - startF);
|
|
653
|
+
const ease = easeOutCubic(Math.min(1, local));
|
|
654
|
+
const startOffset = direction === "up" ? fontSize * 0.4 : -fontSize * 0.4;
|
|
655
|
+
const animated = { ...op, y: op.y + startOffset * (1 - ease) };
|
|
656
|
+
if (op.op === "FillPath") {
|
|
657
|
+
if (animated.fill.kind === "solid")
|
|
658
|
+
animated.fill = { ...animated.fill, opacity: animated.fill.opacity * ease };
|
|
659
|
+
else animated.fill = { ...animated.fill, opacity: (animated.fill.opacity ?? 1) * ease };
|
|
660
|
+
} else {
|
|
661
|
+
animated.opacity = animated.opacity * ease;
|
|
662
|
+
}
|
|
663
|
+
result.push(animated);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (isGlyphFill(op)) glyphIndex++;
|
|
667
|
+
} else if (op.op === "DecorationLine") {
|
|
668
|
+
if (progress >= DECORATION_DONE_THRESHOLD) {
|
|
669
|
+
result.push(op);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return result;
|
|
674
|
+
}
|
|
675
|
+
function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, duration) {
|
|
676
|
+
const byWord = style === "word";
|
|
677
|
+
const startOffsets = {
|
|
678
|
+
left: { x: fontSize * 0.6, y: 0 },
|
|
679
|
+
right: { x: -fontSize * 0.6, y: 0 },
|
|
680
|
+
up: { x: 0, y: fontSize * 0.6 },
|
|
681
|
+
down: { x: 0, y: -fontSize * 0.6 }
|
|
682
|
+
};
|
|
683
|
+
const offset = startOffsets[direction];
|
|
684
|
+
const wordSegments = byWord ? getWordSegments(lines) : [];
|
|
685
|
+
const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
|
|
686
|
+
const totalUnits = byWord ? wordSegments.length : totalGlyphs;
|
|
687
|
+
if (totalUnits === 0) return ops;
|
|
688
|
+
const result = [];
|
|
689
|
+
for (const op of ops) {
|
|
690
|
+
if (op.op === "BeginFrame") {
|
|
691
|
+
result.push(op);
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const windowDuration = 0.3;
|
|
696
|
+
const overlapFactor = 0.7;
|
|
697
|
+
const staggerDelay = duration * overlapFactor / Math.max(1, totalUnits - 1);
|
|
698
|
+
const windowFor = (unitIdx) => {
|
|
699
|
+
const startTime = unitIdx * staggerDelay;
|
|
700
|
+
const startF = startTime / duration;
|
|
701
|
+
const endF = Math.min(1, (startTime + windowDuration) / duration);
|
|
702
|
+
return { startF, endF };
|
|
703
|
+
};
|
|
704
|
+
let glyphIndex = 0;
|
|
705
|
+
for (const op of ops) {
|
|
706
|
+
if (op.op !== "FillPath" && op.op !== "StrokePath") {
|
|
707
|
+
if (op.op === "DecorationLine" && progress > 0.8) result.push(op);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
let unitIndex;
|
|
711
|
+
if (!byWord) {
|
|
712
|
+
unitIndex = glyphIndex;
|
|
713
|
+
} else {
|
|
714
|
+
let wordIndex = -1, acc = 0;
|
|
715
|
+
for (let i = 0; i < wordSegments.length; i++) {
|
|
716
|
+
const gcount = wordSegments[i].glyphCount;
|
|
717
|
+
if (glyphIndex >= acc && glyphIndex < acc + gcount) {
|
|
718
|
+
wordIndex = i;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
acc += gcount;
|
|
722
|
+
}
|
|
723
|
+
unitIndex = Math.max(0, wordIndex);
|
|
724
|
+
}
|
|
725
|
+
const { startF, endF } = windowFor(unitIndex);
|
|
726
|
+
if (progress <= startF) {
|
|
727
|
+
const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
|
|
728
|
+
if (op.op === "FillPath") {
|
|
729
|
+
if (animated.fill.kind === "solid") animated.fill = { ...animated.fill, opacity: 0 };
|
|
730
|
+
else animated.fill = { ...animated.fill, opacity: 0 };
|
|
731
|
+
} else {
|
|
732
|
+
animated.opacity = 0;
|
|
733
|
+
}
|
|
734
|
+
result.push(animated);
|
|
735
|
+
} else if (progress >= endF) {
|
|
736
|
+
result.push(op);
|
|
737
|
+
} else {
|
|
738
|
+
const local = (progress - startF) / Math.max(1e-6, endF - startF);
|
|
739
|
+
const ease = easeOutCubic(Math.min(1, local));
|
|
740
|
+
const dx = offset.x * (1 - ease);
|
|
741
|
+
const dy = offset.y * (1 - ease);
|
|
742
|
+
const animated = { ...op, x: op.x + dx, y: op.y + dy };
|
|
743
|
+
if (op.op === "FillPath") {
|
|
744
|
+
const targetOpacity = animated.fill.kind === "solid" ? animated.fill.opacity : animated.fill.opacity ?? 1;
|
|
745
|
+
if (animated.fill.kind === "solid")
|
|
746
|
+
animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
|
|
747
|
+
else animated.fill = { ...animated.fill, opacity: targetOpacity * ease };
|
|
748
|
+
} else {
|
|
749
|
+
animated.opacity = animated.opacity * ease;
|
|
750
|
+
}
|
|
751
|
+
result.push(animated);
|
|
752
|
+
}
|
|
753
|
+
if (isGlyphFill(op)) glyphIndex++;
|
|
754
|
+
}
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
function applyFadeInAnimation(ops, progress) {
|
|
758
|
+
const alpha = easeOutQuad(progress);
|
|
759
|
+
const scale = 0.95 + 0.05 * alpha;
|
|
760
|
+
return scaleAndFade(ops, alpha, scale);
|
|
761
|
+
}
|
|
762
|
+
function applySlideInAnimation(ops, progress, direction, fontSize) {
|
|
763
|
+
const easeProgress = easeOutCubic(progress);
|
|
764
|
+
const shift = shiftFor(1 - easeProgress, direction, fontSize * 2);
|
|
765
|
+
const alpha = easeOutQuad(progress);
|
|
766
|
+
return translateGlyphOps(ops, shift.dx, shift.dy, alpha);
|
|
767
|
+
}
|
|
768
|
+
function applyMovingLettersAnimation(ops, progress, direction, fontSize) {
|
|
769
|
+
const amp = fontSize * 0.3;
|
|
770
|
+
return waveTransform(ops, direction, amp, progress);
|
|
771
|
+
}
|
|
772
|
+
function getWordSegments(lines) {
|
|
773
|
+
const segments = [];
|
|
774
|
+
let totalGlyphIndex = 0;
|
|
775
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
776
|
+
const line = lines[lineIndex];
|
|
777
|
+
const words = segmentLineBySpaces(line);
|
|
778
|
+
for (const word of words) {
|
|
779
|
+
if (word.length > 0)
|
|
780
|
+
segments.push({ startGlyph: totalGlyphIndex, glyphCount: word.length, lineIndex });
|
|
781
|
+
totalGlyphIndex += word.length;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return segments;
|
|
785
|
+
}
|
|
786
|
+
function segmentLineBySpaces(line) {
|
|
787
|
+
const words = [];
|
|
788
|
+
let current = [];
|
|
789
|
+
for (const g of line.glyphs) {
|
|
790
|
+
const isSpace = g.char === " " || g.char === " " || g.char === "\n";
|
|
791
|
+
if (isSpace) {
|
|
792
|
+
if (current.length) {
|
|
793
|
+
words.push([...current]);
|
|
794
|
+
current = [];
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
current.push(g);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (current.length) words.push(current);
|
|
801
|
+
return words;
|
|
802
|
+
}
|
|
803
|
+
function sliceGlyphOps(ops, maxGlyphs) {
|
|
804
|
+
const result = [];
|
|
805
|
+
let glyphCount = 0;
|
|
806
|
+
let foundGlyphs = false;
|
|
807
|
+
for (const op of ops) {
|
|
808
|
+
if (op.op === "BeginFrame") {
|
|
809
|
+
result.push(op);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
if (op.op === "FillPath" && !isShadowFill(op)) {
|
|
813
|
+
if (glyphCount < maxGlyphs) {
|
|
814
|
+
result.push(op);
|
|
815
|
+
foundGlyphs = true;
|
|
816
|
+
}
|
|
817
|
+
glyphCount++;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (op.op === "StrokePath") {
|
|
821
|
+
if (glyphCount < maxGlyphs) result.push(op);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (op.op === "FillPath" && isShadowFill(op)) {
|
|
825
|
+
if (glyphCount < maxGlyphs) result.push(op);
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
if (op.op === "DecorationLine" && foundGlyphs) {
|
|
829
|
+
result.push(op);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return result;
|
|
834
|
+
}
|
|
835
|
+
function addTypewriterCursor(ops, glyphCount, fontSize, time) {
|
|
836
|
+
const blinkRate = 2;
|
|
837
|
+
const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
|
|
838
|
+
if (!cursorVisible || glyphCount === 0) return ops;
|
|
839
|
+
let last = null;
|
|
840
|
+
let count = 0;
|
|
841
|
+
for (const op of ops) {
|
|
842
|
+
if (op.op === "FillPath" && !isShadowFill(op)) {
|
|
843
|
+
count++;
|
|
844
|
+
if (count === glyphCount) {
|
|
845
|
+
last = op;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (last && last.op === "FillPath") {
|
|
851
|
+
const color = getTextColorFromOps(ops);
|
|
852
|
+
const cursorX = last.x + fontSize * 0.5;
|
|
853
|
+
const cursorY = last.y;
|
|
854
|
+
const cursorOp = {
|
|
855
|
+
op: "DecorationLine",
|
|
856
|
+
from: { x: cursorX, y: cursorY - fontSize * 0.7 },
|
|
857
|
+
to: { x: cursorX, y: cursorY + fontSize * 0.1 },
|
|
858
|
+
width: Math.max(2, fontSize / 25),
|
|
859
|
+
color,
|
|
860
|
+
opacity: 1
|
|
861
|
+
};
|
|
862
|
+
return [...ops, cursorOp];
|
|
863
|
+
}
|
|
864
|
+
return ops;
|
|
865
|
+
}
|
|
866
|
+
function scaleAndFade(ops, alpha, scale) {
|
|
867
|
+
let cx = 0, cy = 0, n = 0;
|
|
868
|
+
ops.forEach((op) => {
|
|
869
|
+
if (op.op === "FillPath") {
|
|
870
|
+
cx += op.x;
|
|
871
|
+
cy += op.y;
|
|
872
|
+
n++;
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
if (n > 0) {
|
|
876
|
+
cx /= n;
|
|
877
|
+
cy /= n;
|
|
878
|
+
}
|
|
879
|
+
return ops.map((op) => {
|
|
880
|
+
if (op.op === "FillPath") {
|
|
881
|
+
const out = { ...op };
|
|
882
|
+
if (out.fill.kind === "solid") out.fill = { ...out.fill, opacity: out.fill.opacity * alpha };
|
|
883
|
+
else out.fill = { ...out.fill, opacity: (out.fill.opacity ?? 1) * alpha };
|
|
884
|
+
if (scale !== 1 && n > 0) {
|
|
885
|
+
const dx = op.x - cx, dy = op.y - cy;
|
|
886
|
+
out.x = cx + dx * scale;
|
|
887
|
+
out.y = cy + dy * scale;
|
|
888
|
+
}
|
|
889
|
+
return out;
|
|
890
|
+
}
|
|
891
|
+
if (op.op === "StrokePath") {
|
|
892
|
+
const out = { ...op, opacity: op.opacity * alpha };
|
|
893
|
+
if (scale !== 1 && n > 0) {
|
|
894
|
+
const dx = op.x - cx, dy = op.y - cy;
|
|
895
|
+
out.x = cx + dx * scale;
|
|
896
|
+
out.y = cy + dy * scale;
|
|
897
|
+
}
|
|
898
|
+
return out;
|
|
899
|
+
}
|
|
900
|
+
if (op.op === "DecorationLine") return { ...op, opacity: op.opacity * alpha };
|
|
901
|
+
return op;
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
function translateGlyphOps(ops, dx, dy, alpha = 1) {
|
|
905
|
+
return ops.map((op) => {
|
|
906
|
+
if (op.op === "FillPath") {
|
|
907
|
+
const out = { ...op, x: op.x + dx, y: op.y + dy };
|
|
908
|
+
if (alpha < 1) {
|
|
909
|
+
if (out.fill.kind === "solid")
|
|
910
|
+
out.fill = { ...out.fill, opacity: out.fill.opacity * alpha };
|
|
911
|
+
else out.fill = { ...out.fill, opacity: (out.fill.opacity ?? 1) * alpha };
|
|
912
|
+
}
|
|
913
|
+
return out;
|
|
914
|
+
}
|
|
915
|
+
if (op.op === "StrokePath")
|
|
916
|
+
return { ...op, x: op.x + dx, y: op.y + dy, opacity: op.opacity * alpha };
|
|
917
|
+
if (op.op === "DecorationLine") {
|
|
918
|
+
return {
|
|
919
|
+
...op,
|
|
920
|
+
from: { x: op.from.x + dx, y: op.from.y + dy },
|
|
921
|
+
to: { x: op.to.x + dx, y: op.to.y + dy },
|
|
922
|
+
opacity: op.opacity * alpha
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
return op;
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
function waveTransform(ops, dir, amp, p) {
|
|
929
|
+
let glyphIndex = 0;
|
|
930
|
+
return ops.map((op) => {
|
|
931
|
+
if (op.op === "FillPath" || op.op === "StrokePath") {
|
|
932
|
+
const phase = Math.sin(glyphIndex / 5 * Math.PI + p * Math.PI * 4);
|
|
933
|
+
const dx = dir === "left" || dir === "right" ? phase * amp * (dir === "left" ? -1 : 1) : 0;
|
|
934
|
+
const dy = dir === "up" || dir === "down" ? phase * amp * (dir === "up" ? -1 : 1) : 0;
|
|
935
|
+
const waveAlpha = Math.min(1, p * 2);
|
|
936
|
+
if (op.op === "FillPath") {
|
|
937
|
+
if (!isShadowFill(op)) glyphIndex++;
|
|
938
|
+
const out = { ...op, x: op.x + dx, y: op.y + dy };
|
|
939
|
+
if (out.fill.kind === "solid")
|
|
940
|
+
out.fill = { ...out.fill, opacity: out.fill.opacity * waveAlpha };
|
|
941
|
+
else out.fill = { ...out.fill, opacity: (out.fill.opacity ?? 1) * waveAlpha };
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
return { ...op, x: op.x + dx, y: op.y + dy, opacity: op.opacity * waveAlpha };
|
|
945
|
+
}
|
|
946
|
+
return op;
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
function shiftFor(progress, dir, dist) {
|
|
950
|
+
const d = progress * dist;
|
|
951
|
+
switch (dir) {
|
|
952
|
+
case "left":
|
|
953
|
+
return { dx: -d, dy: 0 };
|
|
954
|
+
case "right":
|
|
955
|
+
return { dx: d, dy: 0 };
|
|
956
|
+
case "up":
|
|
957
|
+
return { dx: 0, dy: -d };
|
|
958
|
+
case "down":
|
|
959
|
+
return { dx: 0, dy: d };
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function easeOutQuad(t) {
|
|
963
|
+
return t * (2 - t);
|
|
964
|
+
}
|
|
965
|
+
function easeOutCubic(t) {
|
|
966
|
+
return 1 - Math.pow(1 - t, 3);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/core/colors.ts
|
|
970
|
+
function parseHex6(hex, alpha = 1) {
|
|
971
|
+
const m = /^#?([a-f0-9]{6})$/i.exec(hex);
|
|
972
|
+
if (!m) throw new Error(`Invalid color ${hex}`);
|
|
973
|
+
const n = parseInt(m[1], 16);
|
|
974
|
+
const r = n >> 16 & 255;
|
|
975
|
+
const g = n >> 8 & 255;
|
|
976
|
+
const b = n & 255;
|
|
977
|
+
return { r, g, b, a: alpha };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/painters/node.ts
|
|
981
|
+
async function createNodePainter(opts) {
|
|
982
|
+
const canvasMod = await import("canvas");
|
|
983
|
+
const { createCanvas } = canvasMod;
|
|
984
|
+
const canvas = createCanvas(
|
|
985
|
+
Math.floor(opts.width * opts.pixelRatio),
|
|
986
|
+
Math.floor(opts.height * opts.pixelRatio)
|
|
987
|
+
);
|
|
988
|
+
const ctx = canvas.getContext("2d");
|
|
989
|
+
if (!ctx) throw new Error("2D context unavailable in Node (canvas).");
|
|
990
|
+
const api = {
|
|
991
|
+
async render(ops) {
|
|
992
|
+
const globalBox = computeGlobalTextBounds(ops);
|
|
993
|
+
for (const op of ops) {
|
|
994
|
+
if (op.op === "BeginFrame") {
|
|
995
|
+
const dpr = op.pixelRatio ?? opts.pixelRatio;
|
|
996
|
+
const wantW = Math.floor(op.width * dpr);
|
|
997
|
+
const wantH = Math.floor(op.height * dpr);
|
|
998
|
+
if (canvas.width !== wantW || canvas.height !== wantH) {
|
|
999
|
+
canvas.width = wantW;
|
|
1000
|
+
canvas.height = wantH;
|
|
1001
|
+
}
|
|
1002
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1003
|
+
if (op.clear) ctx.clearRect(0, 0, op.width, op.height);
|
|
1004
|
+
if (op.bg) {
|
|
1005
|
+
const { color, opacity, radius } = op.bg;
|
|
1006
|
+
if (color) {
|
|
1007
|
+
const c = parseHex6(color, opacity);
|
|
1008
|
+
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
|
|
1009
|
+
if (radius && radius > 0) {
|
|
1010
|
+
ctx.save();
|
|
1011
|
+
ctx.beginPath();
|
|
1012
|
+
roundRectPath(ctx, 0, 0, op.width, op.height, radius);
|
|
1013
|
+
ctx.fill();
|
|
1014
|
+
ctx.restore();
|
|
1015
|
+
} else {
|
|
1016
|
+
ctx.fillRect(0, 0, op.width, op.height);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
if (op.op === "FillPath") {
|
|
1023
|
+
ctx.save();
|
|
1024
|
+
ctx.translate(op.x, op.y);
|
|
1025
|
+
const s = op.scale ?? 1;
|
|
1026
|
+
ctx.scale(s, -s);
|
|
1027
|
+
ctx.beginPath();
|
|
1028
|
+
drawSvgPathOnCtx(ctx, op.path);
|
|
1029
|
+
const bbox = op.gradientBBox ?? globalBox;
|
|
1030
|
+
const fill = makeGradientFromBBox(ctx, op.fill, bbox);
|
|
1031
|
+
ctx.fillStyle = fill;
|
|
1032
|
+
ctx.fill();
|
|
1033
|
+
ctx.restore();
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (op.op === "StrokePath") {
|
|
1037
|
+
ctx.save();
|
|
1038
|
+
ctx.translate(op.x, op.y);
|
|
1039
|
+
const s = op.scale ?? 1;
|
|
1040
|
+
ctx.scale(s, -s);
|
|
1041
|
+
const invAbs = 1 / Math.abs(s);
|
|
1042
|
+
ctx.beginPath();
|
|
1043
|
+
drawSvgPathOnCtx(ctx, op.path);
|
|
1044
|
+
const c = parseHex6(op.color, op.opacity);
|
|
1045
|
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
|
|
1046
|
+
ctx.lineWidth = op.width * invAbs;
|
|
1047
|
+
ctx.lineJoin = "round";
|
|
1048
|
+
ctx.lineCap = "round";
|
|
1049
|
+
ctx.stroke();
|
|
1050
|
+
ctx.restore();
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (op.op === "DecorationLine") {
|
|
1054
|
+
ctx.save();
|
|
1055
|
+
const c = parseHex6(op.color, op.opacity);
|
|
1056
|
+
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
|
|
1057
|
+
ctx.lineWidth = op.width;
|
|
1058
|
+
ctx.beginPath();
|
|
1059
|
+
ctx.moveTo(op.from.x, op.from.y);
|
|
1060
|
+
ctx.lineTo(op.to.x, op.to.y);
|
|
1061
|
+
ctx.stroke();
|
|
1062
|
+
ctx.restore();
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
},
|
|
1067
|
+
async toPNG() {
|
|
1068
|
+
return canvas.toBuffer("image/png");
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
return api;
|
|
1072
|
+
}
|
|
1073
|
+
function makeGradientFromBBox(ctx, spec, box) {
|
|
1074
|
+
if (spec.kind === "solid") {
|
|
1075
|
+
const c = parseHex6(spec.color, spec.opacity);
|
|
1076
|
+
return `rgba(${c.r},${c.g},${c.b},${c.a})`;
|
|
1077
|
+
}
|
|
1078
|
+
const cx = box.x + box.w / 2, cy = box.y + box.h / 2, r = Math.max(box.w, box.h) / 2;
|
|
1079
|
+
const addStops = (g) => {
|
|
1080
|
+
const op = spec.opacity ?? 1;
|
|
1081
|
+
for (const s of spec.stops) {
|
|
1082
|
+
const c = parseHex6(s.color, op);
|
|
1083
|
+
g.addColorStop(s.offset, `rgba(${c.r},${c.g},${c.b},${c.a})`);
|
|
1084
|
+
}
|
|
1085
|
+
return g;
|
|
1086
|
+
};
|
|
1087
|
+
if (spec.kind === "linear") {
|
|
1088
|
+
const rad = (spec.angle || 0) * Math.PI / 180;
|
|
1089
|
+
const x1 = cx + Math.cos(rad + Math.PI) * r;
|
|
1090
|
+
const y1 = cy + Math.sin(rad + Math.PI) * r;
|
|
1091
|
+
const x2 = cx + Math.cos(rad) * r;
|
|
1092
|
+
const y2 = cy + Math.sin(rad) * r;
|
|
1093
|
+
return addStops(ctx.createLinearGradient(x1, y1, x2, y2));
|
|
1094
|
+
} else {
|
|
1095
|
+
return addStops(ctx.createRadialGradient(cx, cy, 0, cx, cy, r));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
function computeGlobalTextBounds(ops) {
|
|
1099
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1100
|
+
for (const op of ops) {
|
|
1101
|
+
if (op.op !== "FillPath" || op.isShadow) continue;
|
|
1102
|
+
const b = computePathBounds2(op.path);
|
|
1103
|
+
const s = op.scale ?? 1;
|
|
1104
|
+
const x1 = op.x + s * b.x;
|
|
1105
|
+
const x2 = op.x + s * (b.x + b.w);
|
|
1106
|
+
const y1 = op.y - s * (b.y + b.h);
|
|
1107
|
+
const y2 = op.y - s * b.y;
|
|
1108
|
+
if (x1 < minX) minX = x1;
|
|
1109
|
+
if (y1 < minY) minY = y1;
|
|
1110
|
+
if (x2 > maxX) maxX = x2;
|
|
1111
|
+
if (y2 > maxY) maxY = y2;
|
|
1112
|
+
}
|
|
1113
|
+
if (minX === Infinity) return { x: 0, y: 0, w: 1, h: 1 };
|
|
1114
|
+
return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
|
|
1115
|
+
}
|
|
1116
|
+
function drawSvgPathOnCtx(ctx, d) {
|
|
1117
|
+
const t = tokenizePath2(d);
|
|
1118
|
+
let i = 0;
|
|
1119
|
+
while (i < t.length) {
|
|
1120
|
+
const cmd = t[i++];
|
|
1121
|
+
switch (cmd) {
|
|
1122
|
+
case "M": {
|
|
1123
|
+
const x = parseFloat(t[i++]);
|
|
1124
|
+
const y = parseFloat(t[i++]);
|
|
1125
|
+
ctx.moveTo(x, y);
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
case "L": {
|
|
1129
|
+
const x = parseFloat(t[i++]);
|
|
1130
|
+
const y = parseFloat(t[i++]);
|
|
1131
|
+
ctx.lineTo(x, y);
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
case "C": {
|
|
1135
|
+
const c1x = parseFloat(t[i++]);
|
|
1136
|
+
const c1y = parseFloat(t[i++]);
|
|
1137
|
+
const c2x = parseFloat(t[i++]);
|
|
1138
|
+
const c2y = parseFloat(t[i++]);
|
|
1139
|
+
const x = parseFloat(t[i++]);
|
|
1140
|
+
const y = parseFloat(t[i++]);
|
|
1141
|
+
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x, y);
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
case "Q": {
|
|
1145
|
+
const cx = parseFloat(t[i++]);
|
|
1146
|
+
const cy = parseFloat(t[i++]);
|
|
1147
|
+
const x = parseFloat(t[i++]);
|
|
1148
|
+
const y = parseFloat(t[i++]);
|
|
1149
|
+
ctx.quadraticCurveTo(cx, cy, x, y);
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
case "Z": {
|
|
1153
|
+
ctx.closePath();
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
function computePathBounds2(d) {
|
|
1160
|
+
const t = tokenizePath2(d);
|
|
1161
|
+
let i = 0;
|
|
1162
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1163
|
+
const touch = (x, y) => {
|
|
1164
|
+
if (x < minX) minX = x;
|
|
1165
|
+
if (y < minY) minY = y;
|
|
1166
|
+
if (x > maxX) maxX = x;
|
|
1167
|
+
if (y > maxY) maxY = y;
|
|
1168
|
+
};
|
|
1169
|
+
while (i < t.length) {
|
|
1170
|
+
const cmd = t[i++];
|
|
1171
|
+
switch (cmd) {
|
|
1172
|
+
case "M":
|
|
1173
|
+
case "L": {
|
|
1174
|
+
const x = parseFloat(t[i++]);
|
|
1175
|
+
const y = parseFloat(t[i++]);
|
|
1176
|
+
touch(x, y);
|
|
1177
|
+
break;
|
|
1178
|
+
}
|
|
1179
|
+
case "C": {
|
|
1180
|
+
const c1x = parseFloat(t[i++]);
|
|
1181
|
+
const c1y = parseFloat(t[i++]);
|
|
1182
|
+
const c2x = parseFloat(t[i++]);
|
|
1183
|
+
const c2y = parseFloat(t[i++]);
|
|
1184
|
+
const x = parseFloat(t[i++]);
|
|
1185
|
+
const y = parseFloat(t[i++]);
|
|
1186
|
+
touch(c1x, c1y);
|
|
1187
|
+
touch(c2x, c2y);
|
|
1188
|
+
touch(x, y);
|
|
1189
|
+
break;
|
|
1190
|
+
}
|
|
1191
|
+
case "Q": {
|
|
1192
|
+
const cx = parseFloat(t[i++]);
|
|
1193
|
+
const cy = parseFloat(t[i++]);
|
|
1194
|
+
const x = parseFloat(t[i++]);
|
|
1195
|
+
const y = parseFloat(t[i++]);
|
|
1196
|
+
touch(cx, cy);
|
|
1197
|
+
touch(x, y);
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
case "Z":
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (minX === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
|
|
1205
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
1206
|
+
}
|
|
1207
|
+
function roundRectPath(ctx, x, y, w, h, r) {
|
|
1208
|
+
ctx.moveTo(x + r, y);
|
|
1209
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
1210
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
1211
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
1212
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
1213
|
+
ctx.closePath();
|
|
1214
|
+
}
|
|
1215
|
+
function tokenizePath2(d) {
|
|
1216
|
+
return d.match(/[MLCQZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// src/io/node.ts
|
|
1220
|
+
import { readFile } from "fs/promises";
|
|
1221
|
+
import * as http from "http";
|
|
1222
|
+
import * as https from "https";
|
|
1223
|
+
function bufferToArrayBuffer(buf) {
|
|
1224
|
+
const { buffer, byteOffset, byteLength } = buf;
|
|
1225
|
+
if (typeof SharedArrayBuffer !== "undefined" && buffer instanceof SharedArrayBuffer) {
|
|
1226
|
+
const ab2 = new ArrayBuffer(byteLength);
|
|
1227
|
+
new Uint8Array(ab2).set(buf);
|
|
1228
|
+
return ab2;
|
|
1229
|
+
}
|
|
1230
|
+
const ab = buffer;
|
|
1231
|
+
return ab.slice(byteOffset, byteOffset + byteLength);
|
|
1232
|
+
}
|
|
1233
|
+
async function loadFileOrHttpToArrayBuffer(pathOrUrl) {
|
|
1234
|
+
if (/^https?:\/\//.test(pathOrUrl)) {
|
|
1235
|
+
const client = pathOrUrl.startsWith("https:") ? https : http;
|
|
1236
|
+
const buf2 = await new Promise((resolve, reject) => {
|
|
1237
|
+
client.get(pathOrUrl, (res) => {
|
|
1238
|
+
const chunks = [];
|
|
1239
|
+
res.on("data", (d) => chunks.push(d));
|
|
1240
|
+
res.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1241
|
+
res.on("error", reject);
|
|
1242
|
+
}).on("error", reject);
|
|
1243
|
+
});
|
|
1244
|
+
return bufferToArrayBuffer(buf2);
|
|
1245
|
+
}
|
|
1246
|
+
const buf = await readFile(pathOrUrl);
|
|
1247
|
+
return bufferToArrayBuffer(buf);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// src/core/video-generator.ts
|
|
1251
|
+
import { spawn } from "child_process";
|
|
1252
|
+
import fs from "fs";
|
|
1253
|
+
var VideoGenerator = class {
|
|
1254
|
+
ffmpegPath = null;
|
|
1255
|
+
trySetPath(p) {
|
|
1256
|
+
if (p && fs.existsSync(p)) {
|
|
1257
|
+
this.ffmpegPath = p;
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
return false;
|
|
1261
|
+
}
|
|
1262
|
+
async init(opts) {
|
|
1263
|
+
if (typeof window !== "undefined") {
|
|
1264
|
+
throw new Error("VideoGenerator is only available in Node.js environment");
|
|
1265
|
+
}
|
|
1266
|
+
if (this.trySetPath(opts?.ffmpegPath)) return;
|
|
1267
|
+
if (this.trySetPath(process.env.FFMPEG_PATH)) return;
|
|
1268
|
+
if (this.trySetPath(process.env.FFMPEG_BIN)) return;
|
|
1269
|
+
if (this.trySetPath("/opt/bin/ffmpeg")) return;
|
|
1270
|
+
try {
|
|
1271
|
+
const ffmpegStatic = await import("ffmpeg-static");
|
|
1272
|
+
const p = ffmpegStatic.default;
|
|
1273
|
+
if (this.trySetPath(p)) return;
|
|
1274
|
+
} catch {
|
|
1275
|
+
}
|
|
1276
|
+
throw new Error("FFmpeg not available. Please install ffmpeg-static or provide FFMPEG_PATH.");
|
|
1277
|
+
}
|
|
1278
|
+
async generateVideo(frameGenerator, options) {
|
|
1279
|
+
if (!this.ffmpegPath) await this.init({ ffmpegPath: options.ffmpegPath });
|
|
1280
|
+
const {
|
|
1281
|
+
width,
|
|
1282
|
+
height,
|
|
1283
|
+
fps,
|
|
1284
|
+
duration,
|
|
1285
|
+
outputPath,
|
|
1286
|
+
pixelRatio = 1,
|
|
1287
|
+
crf = 17,
|
|
1288
|
+
preset = "slow",
|
|
1289
|
+
tune = "animation",
|
|
1290
|
+
profile = "high",
|
|
1291
|
+
level = "4.2",
|
|
1292
|
+
pixFmt = "yuv420p"
|
|
1293
|
+
} = options;
|
|
1294
|
+
const totalFrames = Math.max(2, Math.round(duration * fps) + 1);
|
|
1295
|
+
console.log(
|
|
1296
|
+
`\u{1F3AC} Generating video: ${width}x${height} @ ${fps}fps, ${duration}s (${totalFrames} frames)
|
|
1297
|
+
CRF=${crf}, preset=${preset}, tune=${tune}, profile=${profile}, level=${level}, pix_fmt=${pixFmt}`
|
|
1298
|
+
);
|
|
1299
|
+
return new Promise(async (resolve, reject) => {
|
|
1300
|
+
const args = [
|
|
1301
|
+
"-y",
|
|
1302
|
+
"-f",
|
|
1303
|
+
"image2pipe",
|
|
1304
|
+
"-vcodec",
|
|
1305
|
+
"png",
|
|
1306
|
+
"-framerate",
|
|
1307
|
+
String(fps),
|
|
1308
|
+
"-i",
|
|
1309
|
+
"-",
|
|
1310
|
+
"-c:v",
|
|
1311
|
+
"libx264",
|
|
1312
|
+
"-preset",
|
|
1313
|
+
preset,
|
|
1314
|
+
"-crf",
|
|
1315
|
+
String(crf),
|
|
1316
|
+
"-tune",
|
|
1317
|
+
tune,
|
|
1318
|
+
"-profile:v",
|
|
1319
|
+
profile,
|
|
1320
|
+
"-level",
|
|
1321
|
+
level,
|
|
1322
|
+
"-pix_fmt",
|
|
1323
|
+
pixFmt,
|
|
1324
|
+
"-r",
|
|
1325
|
+
String(fps),
|
|
1326
|
+
"-movflags",
|
|
1327
|
+
"+faststart",
|
|
1328
|
+
outputPath
|
|
1329
|
+
];
|
|
1330
|
+
const ffmpeg = spawn(this.ffmpegPath, args, { stdio: ["pipe", "inherit", "pipe"] });
|
|
1331
|
+
let ffmpegError = "";
|
|
1332
|
+
ffmpeg.stderr.on("data", (data) => {
|
|
1333
|
+
const message = data.toString();
|
|
1334
|
+
const m = message.match(/time=(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/);
|
|
1335
|
+
if (m) {
|
|
1336
|
+
const hh = parseInt(m[1], 10);
|
|
1337
|
+
const mm = parseInt(m[2], 10);
|
|
1338
|
+
const ss = parseInt(m[3], 10);
|
|
1339
|
+
const ff = m[4] ? parseFloat(`0.${m[4]}`) : 0;
|
|
1340
|
+
const currentTime = hh * 3600 + mm * 60 + ss + ff;
|
|
1341
|
+
const pct = Math.min(100, Math.round(currentTime / duration * 100));
|
|
1342
|
+
if (pct % 10 === 0) console.log(`\u{1F4F9} Encoding: ${pct}%`);
|
|
1343
|
+
}
|
|
1344
|
+
ffmpegError += message;
|
|
1345
|
+
});
|
|
1346
|
+
ffmpeg.on("close", (code) => {
|
|
1347
|
+
if (code === 0) {
|
|
1348
|
+
console.log("\u2705 Video generation complete!");
|
|
1349
|
+
resolve();
|
|
1350
|
+
} else {
|
|
1351
|
+
console.error("FFmpeg stderr:", ffmpegError);
|
|
1352
|
+
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
ffmpeg.on("error", (err) => {
|
|
1356
|
+
console.error("\u274C FFmpeg spawn error:", err);
|
|
1357
|
+
reject(err);
|
|
1358
|
+
});
|
|
1359
|
+
try {
|
|
1360
|
+
const painter = await createNodePainter({ width, height, pixelRatio });
|
|
1361
|
+
for (let frame = 0; frame < totalFrames; frame++) {
|
|
1362
|
+
const t = frame / (totalFrames - 1) * duration;
|
|
1363
|
+
const ops = await frameGenerator(t);
|
|
1364
|
+
await painter.render(ops);
|
|
1365
|
+
const png = await painter.toPNG();
|
|
1366
|
+
const ok = ffmpeg.stdin.write(png);
|
|
1367
|
+
if (!ok) await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
1368
|
+
if (frame % Math.max(1, Math.floor(fps / 2)) === 0) {
|
|
1369
|
+
const pct = Math.round((frame + 1) / totalFrames * 100);
|
|
1370
|
+
console.log(`\u{1F39E}\uFE0F Rendering frames: ${pct}%`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
ffmpeg.stdin.end();
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
console.error("Frame generation error:", err);
|
|
1376
|
+
ffmpeg.kill("SIGKILL");
|
|
1377
|
+
reject(err);
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
// src/env/entry.node.ts
|
|
1384
|
+
async function createTextEngine(opts = {}) {
|
|
1385
|
+
const width = opts.width ?? CANVAS_CONFIG.DEFAULTS.width;
|
|
1386
|
+
const height = opts.height ?? CANVAS_CONFIG.DEFAULTS.height;
|
|
1387
|
+
const pixelRatio = opts.pixelRatio ?? CANVAS_CONFIG.DEFAULTS.pixelRatio;
|
|
1388
|
+
const fps = opts.fps ?? 30;
|
|
1389
|
+
const wasmBaseURL = opts.wasmBaseURL;
|
|
1390
|
+
const fonts = new FontRegistry(wasmBaseURL);
|
|
1391
|
+
const layout = new LayoutEngine(fonts);
|
|
1392
|
+
const videoGenerator = new VideoGenerator();
|
|
1393
|
+
async function ensureFonts(asset) {
|
|
1394
|
+
if (asset.customFonts) {
|
|
1395
|
+
for (const cf of asset.customFonts) {
|
|
1396
|
+
const bytes = await loadFileOrHttpToArrayBuffer(cf.src);
|
|
1397
|
+
await fonts.registerFromBytes(bytes, {
|
|
1398
|
+
family: cf.family,
|
|
1399
|
+
weight: cf.weight ?? "400",
|
|
1400
|
+
style: cf.style ?? "normal"
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
const main = asset.font ?? {
|
|
1405
|
+
family: "Roboto",
|
|
1406
|
+
weight: "400",
|
|
1407
|
+
style: "normal",
|
|
1408
|
+
size: 48,
|
|
1409
|
+
color: "#000000",
|
|
1410
|
+
opacity: 1
|
|
1411
|
+
};
|
|
1412
|
+
return main;
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
validate(input) {
|
|
1416
|
+
const { value, error } = RichTextAssetSchema.validate(input, {
|
|
1417
|
+
abortEarly: false,
|
|
1418
|
+
convert: true
|
|
1419
|
+
});
|
|
1420
|
+
if (error) throw error;
|
|
1421
|
+
return { value };
|
|
1422
|
+
},
|
|
1423
|
+
async registerFontFromFile(path, desc) {
|
|
1424
|
+
const bytes = await loadFileOrHttpToArrayBuffer(path);
|
|
1425
|
+
await fonts.registerFromBytes(bytes, desc);
|
|
1426
|
+
},
|
|
1427
|
+
async registerFontFromUrl(url, desc) {
|
|
1428
|
+
const bytes = await loadFileOrHttpToArrayBuffer(url);
|
|
1429
|
+
await fonts.registerFromBytes(bytes, desc);
|
|
1430
|
+
},
|
|
1431
|
+
async renderFrame(asset, tSeconds) {
|
|
1432
|
+
const main = await ensureFonts(asset);
|
|
1433
|
+
const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
|
|
1434
|
+
const lines = layout.layout({
|
|
1435
|
+
text: asset.text,
|
|
1436
|
+
width: asset.width ?? width,
|
|
1437
|
+
letterSpacing: asset.style?.letterSpacing ?? 0,
|
|
1438
|
+
fontSize: main.size,
|
|
1439
|
+
lineHeight: asset.style?.lineHeight ?? 1.2,
|
|
1440
|
+
desc,
|
|
1441
|
+
textTransform: asset.style?.textTransform ?? "none"
|
|
1442
|
+
});
|
|
1443
|
+
const textRect = { x: 0, y: 0, width: asset.width ?? width, height: asset.height ?? height };
|
|
1444
|
+
const canvasW = asset.width ?? width;
|
|
1445
|
+
const canvasH = asset.height ?? height;
|
|
1446
|
+
const canvasPR = asset.pixelRatio ?? pixelRatio;
|
|
1447
|
+
const ops0 = buildDrawOps({
|
|
1448
|
+
canvas: { width: canvasW, height: canvasH, pixelRatio: canvasPR },
|
|
1449
|
+
textRect,
|
|
1450
|
+
lines,
|
|
1451
|
+
font: {
|
|
1452
|
+
family: main.family,
|
|
1453
|
+
size: main.size,
|
|
1454
|
+
weight: `${main.weight}`,
|
|
1455
|
+
style: main.style,
|
|
1456
|
+
color: main.color,
|
|
1457
|
+
opacity: main.opacity
|
|
1458
|
+
},
|
|
1459
|
+
style: {
|
|
1460
|
+
lineHeight: asset.style?.lineHeight ?? 1.2,
|
|
1461
|
+
textDecoration: asset.style?.textDecoration ?? "none",
|
|
1462
|
+
gradient: asset.style?.gradient
|
|
1463
|
+
},
|
|
1464
|
+
stroke: asset.stroke,
|
|
1465
|
+
shadow: asset.shadow,
|
|
1466
|
+
align: asset.align ?? { horizontal: "left", vertical: "middle" },
|
|
1467
|
+
background: asset.background,
|
|
1468
|
+
glyphPathProvider: (gid) => fonts.glyphPath(desc, gid),
|
|
1469
|
+
/** NEW: provide UPEM so drawops can compute scale */
|
|
1470
|
+
getUnitsPerEm: () => fonts.getUnitsPerEm(desc)
|
|
1471
|
+
});
|
|
1472
|
+
const ops = applyAnimation(ops0, lines, {
|
|
1473
|
+
t: tSeconds,
|
|
1474
|
+
fontSize: main.size,
|
|
1475
|
+
anim: asset.animation ? {
|
|
1476
|
+
preset: asset.animation.preset,
|
|
1477
|
+
speed: asset.animation.speed,
|
|
1478
|
+
duration: asset.animation.duration,
|
|
1479
|
+
style: asset.animation.style,
|
|
1480
|
+
direction: asset.animation.direction
|
|
1481
|
+
} : void 0
|
|
1482
|
+
});
|
|
1483
|
+
return ops;
|
|
1484
|
+
},
|
|
1485
|
+
async createRenderer(p) {
|
|
1486
|
+
return createNodePainter({
|
|
1487
|
+
width: p.width ?? width,
|
|
1488
|
+
height: p.height ?? height,
|
|
1489
|
+
pixelRatio: p.pixelRatio ?? pixelRatio
|
|
1490
|
+
});
|
|
1491
|
+
},
|
|
1492
|
+
async generateVideo(asset, options) {
|
|
1493
|
+
const finalOptions = {
|
|
1494
|
+
width: asset.width ?? width,
|
|
1495
|
+
height: asset.height ?? height,
|
|
1496
|
+
fps,
|
|
1497
|
+
duration: asset.animation?.duration ?? 3,
|
|
1498
|
+
outputPath: options.outputPath ?? "output.mp4",
|
|
1499
|
+
pixelRatio: asset.pixelRatio ?? pixelRatio
|
|
1500
|
+
};
|
|
1501
|
+
const frameGenerator = async (time) => {
|
|
1502
|
+
return this.renderFrame(asset, time);
|
|
1503
|
+
};
|
|
1504
|
+
await videoGenerator.generateVideo(frameGenerator, finalOptions);
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
export {
|
|
1509
|
+
createTextEngine
|
|
1510
|
+
};
|
|
1511
|
+
//# sourceMappingURL=entry.node.js.map
|