@meframe/core 0.0.19 → 0.0.21
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/config/types.d.ts +4 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/model/CompositionModel.d.ts.map +1 -1
- package/dist/model/CompositionModel.js +21 -1
- package/dist/model/CompositionModel.js.map +1 -1
- package/dist/model/types.d.ts +31 -0
- package/dist/model/types.d.ts.map +1 -1
- package/dist/model/types.js.map +1 -1
- package/dist/orchestrator/CompositionPlanner.d.ts +1 -0
- package/dist/orchestrator/CompositionPlanner.d.ts.map +1 -1
- package/dist/orchestrator/CompositionPlanner.js +36 -10
- package/dist/orchestrator/CompositionPlanner.js.map +1 -1
- package/dist/orchestrator/Orchestrator.d.ts.map +1 -1
- package/dist/orchestrator/Orchestrator.js +2 -1
- package/dist/orchestrator/Orchestrator.js.map +1 -1
- package/dist/stages/compose/LayerRenderer.d.ts +4 -7
- package/dist/stages/compose/LayerRenderer.d.ts.map +1 -1
- package/dist/stages/compose/VideoComposer.d.ts +1 -0
- package/dist/stages/compose/VideoComposer.d.ts.map +1 -1
- package/dist/stages/compose/font-system/FontManager.d.ts +11 -0
- package/dist/stages/compose/font-system/FontManager.d.ts.map +1 -0
- package/dist/stages/compose/font-system/FontManager.js +69 -0
- package/dist/stages/compose/font-system/FontManager.js.map +1 -0
- package/dist/stages/compose/font-system/font-templates.d.ts +12 -0
- package/dist/stages/compose/font-system/font-templates.d.ts.map +1 -0
- package/dist/stages/compose/font-system/font-templates.js +384 -0
- package/dist/stages/compose/font-system/font-templates.js.map +1 -0
- package/dist/stages/compose/font-system/index.d.ts +5 -0
- package/dist/stages/compose/font-system/index.d.ts.map +1 -0
- package/dist/stages/compose/font-system/types.d.ts +60 -0
- package/dist/stages/compose/font-system/types.d.ts.map +1 -0
- package/dist/stages/compose/instructions.d.ts +50 -0
- package/dist/stages/compose/instructions.d.ts.map +1 -1
- package/dist/stages/compose/text-renderers/animation-utils.d.ts +16 -0
- package/dist/stages/compose/text-renderers/animation-utils.d.ts.map +1 -0
- package/dist/stages/compose/text-renderers/basic-text-renderer.d.ts +5 -0
- package/dist/stages/compose/text-renderers/basic-text-renderer.d.ts.map +1 -0
- package/dist/stages/compose/text-renderers/character-ktv-renderer.d.ts +4 -0
- package/dist/stages/compose/text-renderers/character-ktv-renderer.d.ts.map +1 -0
- package/dist/stages/compose/text-renderers/index.d.ts +6 -0
- package/dist/stages/compose/text-renderers/index.d.ts.map +1 -0
- package/dist/stages/compose/text-renderers/word-by-word-renderer.d.ts +4 -0
- package/dist/stages/compose/text-renderers/word-by-word-renderer.d.ts.map +1 -0
- package/dist/stages/compose/text-renderers/word-fancy-renderer.d.ts +4 -0
- package/dist/stages/compose/text-renderers/word-fancy-renderer.d.ts.map +1 -0
- package/dist/stages/compose/text-utils/index.d.ts +4 -0
- package/dist/stages/compose/text-utils/index.d.ts.map +1 -0
- package/dist/stages/compose/text-utils/locale-detector.d.ts +5 -0
- package/dist/stages/compose/text-utils/locale-detector.d.ts.map +1 -0
- package/dist/stages/compose/text-utils/text-metrics.d.ts +3 -0
- package/dist/stages/compose/text-utils/text-metrics.d.ts.map +1 -0
- package/dist/stages/compose/text-utils/text-wrapper.d.ts +4 -0
- package/dist/stages/compose/text-utils/text-wrapper.d.ts.map +1 -0
- package/dist/stages/compose/types.d.ts +51 -1
- package/dist/stages/compose/types.d.ts.map +1 -1
- package/dist/workers/stages/compose/video-compose.worker.js +845 -77
- package/dist/workers/stages/compose/video-compose.worker.js.map +1 -1
- package/package.json +1 -1
|
@@ -12,16 +12,794 @@ function frameDurationFromFps(fps) {
|
|
|
12
12
|
const duration = MICROSECONDS_PER_SECOND / normalized;
|
|
13
13
|
return Math.max(Math.round(duration), 1);
|
|
14
14
|
}
|
|
15
|
+
function measureTextWidth(ctx, text, fontSize, fontFamily, fontWeight = 400) {
|
|
16
|
+
ctx.save();
|
|
17
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
18
|
+
const metrics = ctx.measureText(text);
|
|
19
|
+
ctx.restore();
|
|
20
|
+
return metrics.width;
|
|
21
|
+
}
|
|
22
|
+
function getLetterCaseText(text, letterCase) {
|
|
23
|
+
if (letterCase === "upper") {
|
|
24
|
+
return text.toUpperCase();
|
|
25
|
+
}
|
|
26
|
+
if (letterCase === "lower") {
|
|
27
|
+
return text.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
function findAllBreakPoints(text) {
|
|
32
|
+
const breakPoints = [0];
|
|
33
|
+
const chars = Array.from(text);
|
|
34
|
+
for (let i = 1; i < chars.length - 1; i++) {
|
|
35
|
+
if (/[、。!?,,!?;:]/.test(chars[i])) {
|
|
36
|
+
breakPoints.push(i + 1);
|
|
37
|
+
} else if (/[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/.test(chars[i]) && /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]/.test(chars[i + 1])) {
|
|
38
|
+
breakPoints.push(i + 1);
|
|
39
|
+
} else if (/[\s\-–—,.!?;:]/.test(chars[i])) {
|
|
40
|
+
breakPoints.push(i + 1);
|
|
41
|
+
} else if (/\s/.test(chars[i]) && /[a-zA-Z]/.test(chars[i + 1])) {
|
|
42
|
+
breakPoints.push(i + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
breakPoints.push(chars.length);
|
|
46
|
+
return breakPoints;
|
|
47
|
+
}
|
|
48
|
+
function evaluateBalance(lines, ctx, fontSize, fontFamily, fontWeight) {
|
|
49
|
+
if (lines.length <= 1) return 0;
|
|
50
|
+
const lengths = lines.map(
|
|
51
|
+
(line) => measureTextWidth(ctx, line, fontSize, fontFamily, fontWeight)
|
|
52
|
+
);
|
|
53
|
+
const avgLength = lengths.reduce((a, b) => a + b, 0) / lengths.length;
|
|
54
|
+
return lengths.reduce((sum, len) => sum + Math.abs(len - avgLength), 0);
|
|
55
|
+
}
|
|
56
|
+
function tryBreakPointsForMultipleLines(ctx, text, start, remainingLines, currentLines, maxWidth, fontSize, fontFamily, fontWeight, breakPoints) {
|
|
57
|
+
let bestLines = [];
|
|
58
|
+
let bestBalance = Infinity;
|
|
59
|
+
if (remainingLines === 1) {
|
|
60
|
+
const lastLine = text.slice(start).trim();
|
|
61
|
+
const lastLineWidth = measureTextWidth(ctx, lastLine, fontSize, fontFamily, fontWeight);
|
|
62
|
+
if (lastLineWidth <= maxWidth) {
|
|
63
|
+
const allLines = [...currentLines, lastLine];
|
|
64
|
+
const balance = evaluateBalance(allLines, ctx, fontSize, fontFamily, fontWeight);
|
|
65
|
+
if (balance < bestBalance) {
|
|
66
|
+
bestBalance = balance;
|
|
67
|
+
bestLines = allLines;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const words = lastLine.split(/\s+/);
|
|
71
|
+
let currentLine = "";
|
|
72
|
+
let tempLines = [...currentLines];
|
|
73
|
+
for (const word of words) {
|
|
74
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
75
|
+
const lineWidth = measureTextWidth(ctx, testLine, fontSize, fontFamily, fontWeight);
|
|
76
|
+
if (lineWidth <= maxWidth) {
|
|
77
|
+
currentLine = testLine;
|
|
78
|
+
} else {
|
|
79
|
+
if (currentLine) {
|
|
80
|
+
tempLines.push(currentLine);
|
|
81
|
+
currentLine = word;
|
|
82
|
+
} else {
|
|
83
|
+
tempLines.push(word);
|
|
84
|
+
currentLine = "";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (currentLine) {
|
|
89
|
+
tempLines.push(currentLine);
|
|
90
|
+
}
|
|
91
|
+
const balance = evaluateBalance(tempLines, ctx, fontSize, fontFamily, fontWeight);
|
|
92
|
+
if (balance < bestBalance) {
|
|
93
|
+
bestBalance = balance;
|
|
94
|
+
bestLines = tempLines;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { bestLines, bestBalance };
|
|
98
|
+
}
|
|
99
|
+
let foundValidBreak = false;
|
|
100
|
+
for (let i = 0; i < breakPoints.length; i++) {
|
|
101
|
+
const bp = breakPoints[i];
|
|
102
|
+
if (bp <= start || bp >= text.length) continue;
|
|
103
|
+
const line = text.slice(start, bp).trim();
|
|
104
|
+
const lineWidth = measureTextWidth(ctx, line, fontSize, fontFamily, fontWeight);
|
|
105
|
+
if (lineWidth <= maxWidth) {
|
|
106
|
+
foundValidBreak = true;
|
|
107
|
+
const result = tryBreakPointsForMultipleLines(
|
|
108
|
+
ctx,
|
|
109
|
+
text,
|
|
110
|
+
bp,
|
|
111
|
+
remainingLines - 1,
|
|
112
|
+
[...currentLines, line],
|
|
113
|
+
maxWidth,
|
|
114
|
+
fontSize,
|
|
115
|
+
fontFamily,
|
|
116
|
+
fontWeight,
|
|
117
|
+
breakPoints
|
|
118
|
+
);
|
|
119
|
+
if (result.bestBalance < bestBalance) {
|
|
120
|
+
bestBalance = result.bestBalance;
|
|
121
|
+
bestLines = result.bestLines;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!foundValidBreak) {
|
|
126
|
+
const textPortion = text.slice(start);
|
|
127
|
+
const words = textPortion.split(/\s+/);
|
|
128
|
+
let currentLine = "";
|
|
129
|
+
let tempLines = [...currentLines];
|
|
130
|
+
for (const word of words) {
|
|
131
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
132
|
+
const lineWidth = measureTextWidth(ctx, testLine, fontSize, fontFamily, fontWeight);
|
|
133
|
+
if (lineWidth <= maxWidth) {
|
|
134
|
+
currentLine = testLine;
|
|
135
|
+
} else {
|
|
136
|
+
if (currentLine) {
|
|
137
|
+
tempLines.push(currentLine);
|
|
138
|
+
currentLine = word;
|
|
139
|
+
} else {
|
|
140
|
+
tempLines.push(word);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (currentLine) {
|
|
145
|
+
tempLines.push(currentLine);
|
|
146
|
+
}
|
|
147
|
+
bestLines = tempLines;
|
|
148
|
+
}
|
|
149
|
+
return { bestLines, bestBalance };
|
|
150
|
+
}
|
|
151
|
+
function wrapText(ctx, text, maxWidth, fontSize, fontFamily, fontWeight = 400) {
|
|
152
|
+
const textWidth = measureTextWidth(ctx, text, fontSize, fontFamily, fontWeight);
|
|
153
|
+
if (textWidth <= maxWidth) {
|
|
154
|
+
return [text];
|
|
155
|
+
}
|
|
156
|
+
const estimatedLines = Math.ceil(textWidth / maxWidth);
|
|
157
|
+
const breakPoints = findAllBreakPoints(text);
|
|
158
|
+
const { bestLines } = tryBreakPointsForMultipleLines(
|
|
159
|
+
ctx,
|
|
160
|
+
text,
|
|
161
|
+
0,
|
|
162
|
+
estimatedLines,
|
|
163
|
+
[],
|
|
164
|
+
maxWidth,
|
|
165
|
+
fontSize,
|
|
166
|
+
fontFamily,
|
|
167
|
+
fontWeight,
|
|
168
|
+
breakPoints
|
|
169
|
+
);
|
|
170
|
+
return bestLines.length > 0 ? bestLines : [text];
|
|
171
|
+
}
|
|
172
|
+
function formLinesWithWords(ctx, words, maxWidth, fontSize, needsSpace, fontFamily, fontWeight = 400) {
|
|
173
|
+
const result = [];
|
|
174
|
+
let accumulatedWidth = 0;
|
|
175
|
+
const spaceWidth = measureTextWidth(ctx, " ", fontSize, fontFamily, fontWeight);
|
|
176
|
+
let currentLine = "";
|
|
177
|
+
for (const word of words) {
|
|
178
|
+
let wordWidth = measureTextWidth(ctx, word, fontSize, fontFamily, fontWeight);
|
|
179
|
+
if (needsSpace) {
|
|
180
|
+
wordWidth += spaceWidth;
|
|
181
|
+
}
|
|
182
|
+
if (wordWidth + accumulatedWidth <= maxWidth) {
|
|
183
|
+
currentLine += word + (needsSpace ? " " : "");
|
|
184
|
+
accumulatedWidth += wordWidth;
|
|
185
|
+
} else {
|
|
186
|
+
if (currentLine) {
|
|
187
|
+
result.push(currentLine);
|
|
188
|
+
}
|
|
189
|
+
currentLine = word + (needsSpace ? " " : "");
|
|
190
|
+
accumulatedWidth = wordWidth;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (currentLine !== "") {
|
|
194
|
+
result.push(currentLine);
|
|
195
|
+
}
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
function formEvenLinesWithWords(ctx, words, maxWidth, fontSize, needsSpace, fontFamily, fontWeight = 400) {
|
|
199
|
+
let minWidth = maxWidth / 2;
|
|
200
|
+
for (const word of words) {
|
|
201
|
+
const wordWidth = measureTextWidth(ctx, word, fontSize, fontFamily, fontWeight);
|
|
202
|
+
if (wordWidth > minWidth) {
|
|
203
|
+
minWidth = wordWidth;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const leastLineNum = formLinesWithWords(
|
|
207
|
+
ctx,
|
|
208
|
+
words,
|
|
209
|
+
maxWidth,
|
|
210
|
+
fontSize,
|
|
211
|
+
needsSpace,
|
|
212
|
+
fontFamily,
|
|
213
|
+
fontWeight
|
|
214
|
+
).length;
|
|
215
|
+
let bestDelta = maxWidth;
|
|
216
|
+
let bestWidth = minWidth;
|
|
217
|
+
for (let width = maxWidth; width >= minWidth; width -= 1) {
|
|
218
|
+
const lines = formLinesWithWords(
|
|
219
|
+
ctx,
|
|
220
|
+
words,
|
|
221
|
+
width,
|
|
222
|
+
fontSize,
|
|
223
|
+
needsSpace,
|
|
224
|
+
fontFamily,
|
|
225
|
+
fontWeight
|
|
226
|
+
);
|
|
227
|
+
if (lines.length > leastLineNum) {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
let minLineWidth = Infinity;
|
|
231
|
+
let maxLineWidth = 0;
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const lineWidth = measureTextWidth(ctx, line, fontSize, fontFamily, fontWeight);
|
|
234
|
+
if (lineWidth < minLineWidth) {
|
|
235
|
+
minLineWidth = lineWidth;
|
|
236
|
+
}
|
|
237
|
+
if (lineWidth > maxLineWidth) {
|
|
238
|
+
maxLineWidth = lineWidth;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const delta = maxLineWidth - minLineWidth;
|
|
242
|
+
if (delta < bestDelta) {
|
|
243
|
+
bestDelta = delta;
|
|
244
|
+
bestWidth = width;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return formLinesWithWords(ctx, words, bestWidth, fontSize, needsSpace, fontFamily, fontWeight);
|
|
248
|
+
}
|
|
249
|
+
function springEasing(frame, config) {
|
|
250
|
+
const { damping, mass, stiffness, overshootClamping = false } = config;
|
|
251
|
+
if (frame <= 0) return 0;
|
|
252
|
+
const zeta = damping / (2 * Math.sqrt(mass * stiffness));
|
|
253
|
+
const omega = Math.sqrt(stiffness / mass);
|
|
254
|
+
const t = frame / 60;
|
|
255
|
+
let value;
|
|
256
|
+
if (zeta < 1) {
|
|
257
|
+
const omegaD = omega * Math.sqrt(1 - zeta * zeta);
|
|
258
|
+
const A = 1;
|
|
259
|
+
const B = zeta * omega / omegaD;
|
|
260
|
+
value = 1 - Math.exp(-zeta * omega * t) * (A * Math.cos(omegaD * t) + B * Math.sin(omegaD * t));
|
|
261
|
+
} else if (zeta === 1) {
|
|
262
|
+
value = 1 - Math.exp(-omega * t) * (1 + omega * t);
|
|
263
|
+
} else {
|
|
264
|
+
const r1 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));
|
|
265
|
+
const r2 = -omega * (zeta + Math.sqrt(zeta * zeta - 1));
|
|
266
|
+
const A = r2 / (r2 - r1);
|
|
267
|
+
const B = 1 - A;
|
|
268
|
+
value = 1 - A * Math.exp(r1 * t) - B * Math.exp(r2 * t);
|
|
269
|
+
}
|
|
270
|
+
if (overshootClamping && value > 1) {
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
return Math.max(0, value);
|
|
274
|
+
}
|
|
275
|
+
function interpolate(value, inputRange, outputRange, options) {
|
|
276
|
+
const { extrapolateLeft = "extend", extrapolateRight = "extend" } = options || {};
|
|
277
|
+
const [inputMin, inputMax] = inputRange;
|
|
278
|
+
const [outputMin, outputMax] = outputRange;
|
|
279
|
+
if (value < inputMin) {
|
|
280
|
+
if (extrapolateLeft === "clamp") {
|
|
281
|
+
return outputMin;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (value > inputMax) {
|
|
285
|
+
if (extrapolateRight === "clamp") {
|
|
286
|
+
return outputMax;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const inputDelta = inputMax - inputMin;
|
|
290
|
+
if (inputDelta === 0) {
|
|
291
|
+
return outputMin;
|
|
292
|
+
}
|
|
293
|
+
const progress = (value - inputMin) / inputDelta;
|
|
294
|
+
const outputDelta = outputMax - outputMin;
|
|
295
|
+
return outputMin + progress * outputDelta;
|
|
296
|
+
}
|
|
297
|
+
function parseRgb(color) {
|
|
298
|
+
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
299
|
+
if (match) {
|
|
300
|
+
return {
|
|
301
|
+
r: parseInt(match[1], 10),
|
|
302
|
+
g: parseInt(match[2], 10),
|
|
303
|
+
b: parseInt(match[3], 10)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
function interpolateColor(color1, color2, progress) {
|
|
309
|
+
const rgb1 = parseRgb(color1);
|
|
310
|
+
const rgb2 = parseRgb(color2);
|
|
311
|
+
if (!rgb1 || !rgb2) {
|
|
312
|
+
return color1;
|
|
313
|
+
}
|
|
314
|
+
const r = Math.round(interpolate(progress, [0, 1], [rgb1.r, rgb2.r]));
|
|
315
|
+
const g = Math.round(interpolate(progress, [0, 1], [rgb1.g, rgb2.g]));
|
|
316
|
+
const b = Math.round(interpolate(progress, [0, 1], [rgb1.b, rgb2.b]));
|
|
317
|
+
return `rgb(${r}, ${g}, ${b})`;
|
|
318
|
+
}
|
|
319
|
+
const CJK_REGEX = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf\uac00-\ud7af]/g;
|
|
320
|
+
function needsSpaceBetweenWords(locale, text) {
|
|
321
|
+
if (text) {
|
|
322
|
+
const cjkMatches = text.match(CJK_REGEX);
|
|
323
|
+
const cjkCount = cjkMatches ? cjkMatches.length : 0;
|
|
324
|
+
if (cjkCount > 0 && cjkCount / text.length >= 0.6) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return !["zh-CN", "ja-JP", "ko-KR"].includes(locale);
|
|
330
|
+
}
|
|
331
|
+
function calculateYPosition$3(canvasHeight, totalHeight, globalPosition) {
|
|
332
|
+
if (!globalPosition) {
|
|
333
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
334
|
+
}
|
|
335
|
+
if (globalPosition.top) {
|
|
336
|
+
const topPercent = parseFloat(globalPosition.top) / 100;
|
|
337
|
+
return canvasHeight * topPercent;
|
|
338
|
+
}
|
|
339
|
+
if (globalPosition.bottom) {
|
|
340
|
+
const bottomPercent = parseFloat(globalPosition.bottom) / 100;
|
|
341
|
+
return canvasHeight * (1 - bottomPercent) - totalHeight;
|
|
342
|
+
}
|
|
343
|
+
if (globalPosition.justifyContent === "center" || globalPosition.alignItems === "center") {
|
|
344
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
345
|
+
}
|
|
346
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
347
|
+
}
|
|
348
|
+
function renderBasicText(ctx, layer, canvasWidth, canvasHeight, _relativeFrame) {
|
|
349
|
+
const fontConfig = layer.fontConfig?.textStyle;
|
|
350
|
+
if (!fontConfig) return;
|
|
351
|
+
const fontSize = fontConfig.fontSize;
|
|
352
|
+
const fontFamily = fontConfig.fontFamily;
|
|
353
|
+
const fontWeight = fontConfig.fontWeight;
|
|
354
|
+
const fill = fontConfig.fill;
|
|
355
|
+
const stroke = fontConfig.stroke;
|
|
356
|
+
const strokeWidth = fontConfig.strokeWidth || 0;
|
|
357
|
+
const lineHeight = fontConfig.lineHeight || 1.2;
|
|
358
|
+
const maxWidth = canvasWidth * 0.64;
|
|
359
|
+
const text = getLetterCaseText(layer.text, layer.letterCase);
|
|
360
|
+
let lines;
|
|
361
|
+
if (layer.wordTimings && layer.wordTimings.length > 0) {
|
|
362
|
+
const needsSpace = needsSpaceBetweenWords(layer.localeCode || "en-US", text);
|
|
363
|
+
const words = text.split(needsSpace ? /\s+/ : "");
|
|
364
|
+
lines = formEvenLinesWithWords(
|
|
365
|
+
ctx,
|
|
366
|
+
words,
|
|
367
|
+
maxWidth,
|
|
368
|
+
fontSize,
|
|
369
|
+
needsSpace,
|
|
370
|
+
fontFamily,
|
|
371
|
+
fontWeight
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
lines = wrapText(ctx, text, maxWidth, fontSize, fontFamily, fontWeight);
|
|
375
|
+
}
|
|
376
|
+
ctx.save();
|
|
377
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
378
|
+
ctx.textAlign = "center";
|
|
379
|
+
ctx.textBaseline = "middle";
|
|
380
|
+
ctx.lineJoin = "round";
|
|
381
|
+
ctx.lineCap = "round";
|
|
382
|
+
const totalHeight = lines.length * fontSize * lineHeight;
|
|
383
|
+
const startY = calculateYPosition$3(canvasHeight, totalHeight, layer.fontConfig?.globalPosition);
|
|
384
|
+
for (let i = 0; i < lines.length; i++) {
|
|
385
|
+
const line = lines[i];
|
|
386
|
+
const y = startY + i * fontSize * lineHeight + fontSize / 2;
|
|
387
|
+
if (stroke && strokeWidth > 0) {
|
|
388
|
+
ctx.strokeStyle = stroke;
|
|
389
|
+
ctx.lineWidth = strokeWidth;
|
|
390
|
+
ctx.strokeText(line, canvasWidth / 2, y);
|
|
391
|
+
}
|
|
392
|
+
ctx.fillStyle = fill;
|
|
393
|
+
ctx.fillText(line, canvasWidth / 2, y);
|
|
394
|
+
}
|
|
395
|
+
ctx.restore();
|
|
396
|
+
}
|
|
397
|
+
function renderTextWithEntrance(ctx, layer, canvasWidth, canvasHeight, relativeFrame) {
|
|
398
|
+
const fontConfig = layer.fontConfig?.textStyle;
|
|
399
|
+
if (!fontConfig) return;
|
|
400
|
+
const entrance = springEasing(relativeFrame, {
|
|
401
|
+
damping: 200,
|
|
402
|
+
mass: 1,
|
|
403
|
+
stiffness: 300
|
|
404
|
+
});
|
|
405
|
+
const opacity = interpolate(entrance, [0, 1], [0, 1]);
|
|
406
|
+
const scale = interpolate(entrance, [0, 1], [0.9, 1]);
|
|
407
|
+
ctx.save();
|
|
408
|
+
ctx.globalAlpha = opacity;
|
|
409
|
+
ctx.translate(canvasWidth / 2, canvasHeight / 2);
|
|
410
|
+
ctx.scale(scale, scale);
|
|
411
|
+
ctx.translate(-canvasWidth / 2, -canvasHeight / 2);
|
|
412
|
+
renderBasicText(ctx, layer, canvasWidth, canvasHeight);
|
|
413
|
+
ctx.restore();
|
|
414
|
+
}
|
|
415
|
+
function usToFrame$2(us, fps) {
|
|
416
|
+
return Math.floor(us / (1e6 / fps));
|
|
417
|
+
}
|
|
418
|
+
function calculateYPosition$2(canvasHeight, totalHeight, globalPosition) {
|
|
419
|
+
if (!globalPosition) {
|
|
420
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
421
|
+
}
|
|
422
|
+
if (globalPosition.top) {
|
|
423
|
+
const topPercent = parseFloat(globalPosition.top) / 100;
|
|
424
|
+
return canvasHeight * topPercent;
|
|
425
|
+
}
|
|
426
|
+
if (globalPosition.bottom) {
|
|
427
|
+
const bottomPercent = parseFloat(globalPosition.bottom) / 100;
|
|
428
|
+
return canvasHeight * (1 - bottomPercent) - totalHeight;
|
|
429
|
+
}
|
|
430
|
+
if (globalPosition.justifyContent === "center" || globalPosition.alignItems === "center") {
|
|
431
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
432
|
+
}
|
|
433
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
434
|
+
}
|
|
435
|
+
function renderWordByWord(ctx, layer, canvasWidth, canvasHeight, relativeFrame, fps = 30) {
|
|
436
|
+
const fontConfig = layer.fontConfig?.textStyle;
|
|
437
|
+
if (!fontConfig) return;
|
|
438
|
+
const fontSize = fontConfig.fontSize;
|
|
439
|
+
const fontFamily = fontConfig.fontFamily;
|
|
440
|
+
const fontWeight = fontConfig.fontWeight;
|
|
441
|
+
const fill = fontConfig.fill;
|
|
442
|
+
const stroke = fontConfig.stroke;
|
|
443
|
+
const strokeWidth = fontConfig.strokeWidth || 0;
|
|
444
|
+
const lineHeight = fontConfig.lineHeight || 1.2;
|
|
445
|
+
const highlightFill = layer.animation?.highlightTextStyle?.fill || "rgb(255, 215, 0)";
|
|
446
|
+
const highlightStroke = layer.animation?.highlightTextStyle?.stroke || stroke;
|
|
447
|
+
const maxWidth = canvasWidth * 0.64;
|
|
448
|
+
const text = getLetterCaseText(layer.text, layer.letterCase);
|
|
449
|
+
const needsSpace = needsSpaceBetweenWords(layer.localeCode || "en-US", text);
|
|
450
|
+
const words = text.split(needsSpace ? /\s+/ : "");
|
|
451
|
+
const lines = formEvenLinesWithWords(
|
|
452
|
+
ctx,
|
|
453
|
+
words,
|
|
454
|
+
maxWidth,
|
|
455
|
+
fontSize,
|
|
456
|
+
needsSpace,
|
|
457
|
+
fontFamily,
|
|
458
|
+
fontWeight
|
|
459
|
+
);
|
|
460
|
+
const wordPositions = [];
|
|
461
|
+
let wordIndex = 0;
|
|
462
|
+
const totalHeight = lines.length * fontSize * lineHeight;
|
|
463
|
+
const startY = calculateYPosition$2(canvasHeight, totalHeight, layer.fontConfig?.globalPosition);
|
|
464
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
465
|
+
const line = lines[lineIndex];
|
|
466
|
+
const lineWords = line.split(needsSpace ? /\s+/ : "");
|
|
467
|
+
const y = startY + lineIndex * fontSize * lineHeight + fontSize / 2;
|
|
468
|
+
const lineWidth = measureTextWidth(ctx, line, fontSize, fontFamily, fontWeight);
|
|
469
|
+
let currentX = canvasWidth / 2 - lineWidth / 2;
|
|
470
|
+
for (const word of lineWords) {
|
|
471
|
+
const wordWidth = measureTextWidth(ctx, word, fontSize, fontFamily, fontWeight);
|
|
472
|
+
const wordTimingUs = layer.wordTimings?.[wordIndex];
|
|
473
|
+
const wordTiming = wordTimingUs ? {
|
|
474
|
+
startFrame: usToFrame$2(wordTimingUs.startUs, fps),
|
|
475
|
+
endFrame: usToFrame$2(wordTimingUs.endUs, fps)
|
|
476
|
+
} : void 0;
|
|
477
|
+
wordPositions.push({
|
|
478
|
+
text: word,
|
|
479
|
+
x: currentX + wordWidth / 2,
|
|
480
|
+
y,
|
|
481
|
+
lineIndex,
|
|
482
|
+
wordIndex,
|
|
483
|
+
timing: wordTiming
|
|
484
|
+
});
|
|
485
|
+
currentX += wordWidth + (needsSpace ? measureTextWidth(ctx, " ", fontSize, fontFamily, fontWeight) : 0);
|
|
486
|
+
wordIndex++;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
ctx.save();
|
|
490
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
491
|
+
ctx.textAlign = "center";
|
|
492
|
+
ctx.textBaseline = "middle";
|
|
493
|
+
ctx.lineJoin = "round";
|
|
494
|
+
ctx.lineCap = "round";
|
|
495
|
+
for (const wordPos of wordPositions) {
|
|
496
|
+
let currentFill = fill;
|
|
497
|
+
let currentStroke = stroke;
|
|
498
|
+
if (wordPos.timing) {
|
|
499
|
+
const { startFrame, endFrame } = wordPos.timing;
|
|
500
|
+
if (relativeFrame >= startFrame && relativeFrame <= endFrame) {
|
|
501
|
+
const transitionProgressIn = interpolate(
|
|
502
|
+
relativeFrame,
|
|
503
|
+
[startFrame, startFrame + 3],
|
|
504
|
+
[0, 1],
|
|
505
|
+
{
|
|
506
|
+
extrapolateLeft: "clamp",
|
|
507
|
+
extrapolateRight: "clamp"
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
currentFill = interpolateColor(fill, highlightFill, transitionProgressIn);
|
|
511
|
+
if (stroke && highlightStroke) {
|
|
512
|
+
currentStroke = interpolateColor(stroke, highlightStroke, transitionProgressIn);
|
|
513
|
+
}
|
|
514
|
+
} else if (relativeFrame > endFrame) {
|
|
515
|
+
const transitionProgressOut = interpolate(relativeFrame, [endFrame, endFrame + 3], [1, 0], {
|
|
516
|
+
extrapolateLeft: "clamp",
|
|
517
|
+
extrapolateRight: "clamp"
|
|
518
|
+
});
|
|
519
|
+
currentFill = interpolateColor(fill, highlightFill, transitionProgressOut);
|
|
520
|
+
if (stroke && highlightStroke) {
|
|
521
|
+
currentStroke = interpolateColor(stroke, highlightStroke, transitionProgressOut);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (currentStroke && strokeWidth > 0) {
|
|
526
|
+
ctx.strokeStyle = currentStroke;
|
|
527
|
+
ctx.lineWidth = strokeWidth;
|
|
528
|
+
ctx.strokeText(wordPos.text, wordPos.x, wordPos.y);
|
|
529
|
+
}
|
|
530
|
+
ctx.fillStyle = currentFill;
|
|
531
|
+
ctx.fillText(wordPos.text, wordPos.x, wordPos.y);
|
|
532
|
+
}
|
|
533
|
+
ctx.restore();
|
|
534
|
+
}
|
|
535
|
+
function usToFrame$1(us, fps) {
|
|
536
|
+
return Math.floor(us / (1e6 / fps));
|
|
537
|
+
}
|
|
538
|
+
function calculateYPosition$1(canvasHeight, totalHeight, globalPosition) {
|
|
539
|
+
if (!globalPosition) {
|
|
540
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
541
|
+
}
|
|
542
|
+
if (globalPosition.top) {
|
|
543
|
+
const topPercent = parseFloat(globalPosition.top) / 100;
|
|
544
|
+
return canvasHeight * topPercent;
|
|
545
|
+
}
|
|
546
|
+
if (globalPosition.bottom) {
|
|
547
|
+
const bottomPercent = parseFloat(globalPosition.bottom) / 100;
|
|
548
|
+
return canvasHeight * (1 - bottomPercent) - totalHeight;
|
|
549
|
+
}
|
|
550
|
+
if (globalPosition.justifyContent === "center" || globalPosition.alignItems === "center") {
|
|
551
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
552
|
+
}
|
|
553
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
554
|
+
}
|
|
555
|
+
function renderCharacterKTV(ctx, layer, canvasWidth, canvasHeight, relativeFrame, fps = 30) {
|
|
556
|
+
const fontConfig = layer.fontConfig?.textStyle;
|
|
557
|
+
if (!fontConfig) return;
|
|
558
|
+
const fontSize = fontConfig.fontSize;
|
|
559
|
+
const fontFamily = fontConfig.fontFamily;
|
|
560
|
+
const fontWeight = fontConfig.fontWeight;
|
|
561
|
+
const baseFill = fontConfig.fill;
|
|
562
|
+
const stroke = fontConfig.stroke;
|
|
563
|
+
const strokeWidth = fontConfig.strokeWidth || 0;
|
|
564
|
+
const lineHeight = fontConfig.lineHeight || 1.2;
|
|
565
|
+
const highlightFill = layer.animation?.highlightTextStyle?.fill || "rgb(255, 215, 0)";
|
|
566
|
+
const glowColor = layer.animation?.glowColor || "#ffffff";
|
|
567
|
+
const glowIntensity = layer.animation?.glowIntensity || 3;
|
|
568
|
+
const transitionFrames = layer.animation?.transitionFrames || 10;
|
|
569
|
+
const maxWidth = canvasWidth * 0.9;
|
|
570
|
+
const text = getLetterCaseText(layer.text, layer.letterCase);
|
|
571
|
+
const needsSpace = needsSpaceBetweenWords(layer.localeCode || "en-US", text);
|
|
572
|
+
const characterTimings = [];
|
|
573
|
+
if (layer.wordTimings && layer.wordTimings.length > 0) {
|
|
574
|
+
let charIndex = 0;
|
|
575
|
+
for (let wordIndex = 0; wordIndex < layer.wordTimings.length; wordIndex++) {
|
|
576
|
+
const word = layer.wordTimings[wordIndex];
|
|
577
|
+
const wordChars = word.text.split("");
|
|
578
|
+
const wordStartFrame = usToFrame$1(word.startUs, fps);
|
|
579
|
+
const wordEndFrame = usToFrame$1(word.endUs, fps);
|
|
580
|
+
const framesPerChar = wordChars.length > 0 ? (wordEndFrame - wordStartFrame) / wordChars.length : 0;
|
|
581
|
+
for (let i = 0; i < wordChars.length; i++) {
|
|
582
|
+
const charStartFrame = Math.floor(wordStartFrame + i * framesPerChar);
|
|
583
|
+
characterTimings.push({
|
|
584
|
+
char: wordChars[i],
|
|
585
|
+
index: charIndex,
|
|
586
|
+
startFrame: charStartFrame
|
|
587
|
+
});
|
|
588
|
+
charIndex++;
|
|
589
|
+
}
|
|
590
|
+
if (needsSpace && wordIndex < layer.wordTimings.length - 1) {
|
|
591
|
+
const nextWord = layer.wordTimings[wordIndex + 1];
|
|
592
|
+
const nextWordFirstChar = nextWord?.text?.[0] || "";
|
|
593
|
+
const isPunctuation = /[.,!?;:)]/.test(nextWordFirstChar);
|
|
594
|
+
if (!isPunctuation) {
|
|
595
|
+
const spaceStartFrame = characterTimings[characterTimings.length - 1]?.startFrame || 0;
|
|
596
|
+
characterTimings.push({
|
|
597
|
+
char: " ",
|
|
598
|
+
index: charIndex,
|
|
599
|
+
startFrame: spaceStartFrame
|
|
600
|
+
});
|
|
601
|
+
charIndex++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
const totalFrames = 100;
|
|
607
|
+
const chars = text.split("");
|
|
608
|
+
const framesPerChar = chars.length > 0 ? totalFrames / chars.length : 0;
|
|
609
|
+
for (let i = 0; i < chars.length; i++) {
|
|
610
|
+
characterTimings.push({
|
|
611
|
+
char: chars[i],
|
|
612
|
+
index: i,
|
|
613
|
+
startFrame: Math.floor(i * framesPerChar)
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const fullText = characterTimings.map((ct) => ct.char).join("");
|
|
618
|
+
const fullTextLines = wrapText(ctx, fullText, maxWidth, fontSize, fontFamily, fontWeight);
|
|
619
|
+
ctx.save();
|
|
620
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
621
|
+
ctx.textAlign = "left";
|
|
622
|
+
ctx.textBaseline = "middle";
|
|
623
|
+
ctx.lineJoin = "round";
|
|
624
|
+
ctx.lineCap = "round";
|
|
625
|
+
const totalHeight = fullTextLines.length * fontSize * lineHeight;
|
|
626
|
+
const startY = calculateYPosition$1(canvasHeight, totalHeight, layer.fontConfig?.globalPosition);
|
|
627
|
+
let charIndexInText = 0;
|
|
628
|
+
for (let lineIndex = 0; lineIndex < fullTextLines.length; lineIndex++) {
|
|
629
|
+
const line = fullTextLines[lineIndex];
|
|
630
|
+
const y = startY + lineIndex * fontSize * lineHeight + fontSize / 2;
|
|
631
|
+
const lineWidth = measureTextWidth(ctx, line, fontSize, fontFamily, fontWeight);
|
|
632
|
+
let currentX = canvasWidth / 2 - lineWidth / 2;
|
|
633
|
+
for (let i = 0; i < line.length; i++) {
|
|
634
|
+
const char = line[i];
|
|
635
|
+
const timing = characterTimings[charIndexInText];
|
|
636
|
+
if (timing) {
|
|
637
|
+
const hasScanned = relativeFrame >= timing.startFrame;
|
|
638
|
+
const isCurrentlySinging = hasScanned && relativeFrame < timing.startFrame + transitionFrames;
|
|
639
|
+
ctx.fillStyle = hasScanned ? highlightFill : baseFill;
|
|
640
|
+
if (isCurrentlySinging) {
|
|
641
|
+
ctx.shadowColor = glowColor;
|
|
642
|
+
ctx.shadowBlur = glowIntensity * 10;
|
|
643
|
+
} else {
|
|
644
|
+
ctx.shadowBlur = 0;
|
|
645
|
+
}
|
|
646
|
+
if (stroke && strokeWidth > 0) {
|
|
647
|
+
ctx.strokeStyle = stroke;
|
|
648
|
+
ctx.lineWidth = strokeWidth;
|
|
649
|
+
ctx.strokeText(char, currentX, y);
|
|
650
|
+
}
|
|
651
|
+
ctx.fillText(char, currentX, y);
|
|
652
|
+
ctx.shadowBlur = 0;
|
|
653
|
+
}
|
|
654
|
+
currentX += measureTextWidth(ctx, char, fontSize, fontFamily, fontWeight);
|
|
655
|
+
charIndexInText++;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
ctx.restore();
|
|
659
|
+
}
|
|
660
|
+
function usToFrame(us, fps) {
|
|
661
|
+
return Math.floor(us / (1e6 / fps));
|
|
662
|
+
}
|
|
663
|
+
function calculateYPosition(canvasHeight, totalHeight, globalPosition) {
|
|
664
|
+
if (!globalPosition) {
|
|
665
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
666
|
+
}
|
|
667
|
+
if (globalPosition.top) {
|
|
668
|
+
const topPercent = parseFloat(globalPosition.top) / 100;
|
|
669
|
+
return canvasHeight * topPercent;
|
|
670
|
+
}
|
|
671
|
+
if (globalPosition.bottom) {
|
|
672
|
+
const bottomPercent = parseFloat(globalPosition.bottom) / 100;
|
|
673
|
+
return canvasHeight * (1 - bottomPercent) - totalHeight;
|
|
674
|
+
}
|
|
675
|
+
if (globalPosition.justifyContent === "center" || globalPosition.alignItems === "center") {
|
|
676
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
677
|
+
}
|
|
678
|
+
return canvasHeight / 2 - totalHeight / 2;
|
|
679
|
+
}
|
|
680
|
+
function renderWordByWordFancy(ctx, layer, canvasWidth, canvasHeight, relativeFrame, fps = 30) {
|
|
681
|
+
const fontConfig = layer.fontConfig?.textStyle;
|
|
682
|
+
if (!fontConfig) return;
|
|
683
|
+
const fontSize = fontConfig.fontSize;
|
|
684
|
+
const fontFamily = fontConfig.fontFamily;
|
|
685
|
+
const fontWeight = fontConfig.fontWeight;
|
|
686
|
+
const fill = fontConfig.fill;
|
|
687
|
+
const stroke = fontConfig.stroke;
|
|
688
|
+
const strokeWidth = fontConfig.strokeWidth || 0;
|
|
689
|
+
const lineHeight = fontConfig.lineHeight || 1.2;
|
|
690
|
+
const highlightBackgroundColor = layer.animation?.highlightColor || "rgb(255, 215, 0)";
|
|
691
|
+
const maxWidth = canvasWidth * 0.64;
|
|
692
|
+
const text = getLetterCaseText(layer.text, layer.letterCase);
|
|
693
|
+
const needsSpace = needsSpaceBetweenWords(layer.localeCode || "en-US", text);
|
|
694
|
+
const words = text.split(needsSpace ? /\s+/ : "");
|
|
695
|
+
const lines = formEvenLinesWithWords(
|
|
696
|
+
ctx,
|
|
697
|
+
words,
|
|
698
|
+
maxWidth,
|
|
699
|
+
fontSize,
|
|
700
|
+
needsSpace,
|
|
701
|
+
fontFamily,
|
|
702
|
+
fontWeight
|
|
703
|
+
);
|
|
704
|
+
const wordPositions = [];
|
|
705
|
+
let wordIndex = 0;
|
|
706
|
+
const totalHeight = lines.length * fontSize * lineHeight;
|
|
707
|
+
const startY = calculateYPosition(canvasHeight, totalHeight, layer.fontConfig?.globalPosition);
|
|
708
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
709
|
+
const line = lines[lineIndex];
|
|
710
|
+
const lineWords = line.split(needsSpace ? /\s+/ : "");
|
|
711
|
+
const y = startY + lineIndex * fontSize * lineHeight + fontSize / 2;
|
|
712
|
+
const lineWidth = measureTextWidth(ctx, line, fontSize, fontFamily, fontWeight);
|
|
713
|
+
let currentX = canvasWidth / 2 - lineWidth / 2;
|
|
714
|
+
for (const word of lineWords) {
|
|
715
|
+
const wordWidth = measureTextWidth(ctx, word, fontSize, fontFamily, fontWeight);
|
|
716
|
+
const wordTimingUs = layer.wordTimings?.[wordIndex];
|
|
717
|
+
const wordTiming = wordTimingUs ? {
|
|
718
|
+
startFrame: usToFrame(wordTimingUs.startUs, fps),
|
|
719
|
+
endFrame: usToFrame(wordTimingUs.endUs, fps)
|
|
720
|
+
} : void 0;
|
|
721
|
+
wordPositions.push({
|
|
722
|
+
text: word,
|
|
723
|
+
x: currentX + wordWidth / 2,
|
|
724
|
+
y,
|
|
725
|
+
width: wordWidth,
|
|
726
|
+
lineIndex,
|
|
727
|
+
wordIndex,
|
|
728
|
+
timing: wordTiming
|
|
729
|
+
});
|
|
730
|
+
currentX += wordWidth + (needsSpace ? measureTextWidth(ctx, " ", fontSize, fontFamily, fontWeight) : 0);
|
|
731
|
+
wordIndex++;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
ctx.save();
|
|
735
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
736
|
+
ctx.textAlign = "center";
|
|
737
|
+
ctx.textBaseline = "middle";
|
|
738
|
+
ctx.lineJoin = "round";
|
|
739
|
+
ctx.lineCap = "round";
|
|
740
|
+
for (const wordPos of wordPositions) {
|
|
741
|
+
let backgroundOpacity = 0;
|
|
742
|
+
let backgroundScale = 0.8;
|
|
743
|
+
if (wordPos.timing) {
|
|
744
|
+
const { startFrame, endFrame } = wordPos.timing;
|
|
745
|
+
const preStartFrames = 3;
|
|
746
|
+
const isActive = relativeFrame >= startFrame && relativeFrame <= endFrame;
|
|
747
|
+
if (isActive) {
|
|
748
|
+
const scaleSpringIn = springEasing(relativeFrame - (startFrame - preStartFrames), {
|
|
749
|
+
damping: 6,
|
|
750
|
+
mass: 0.35,
|
|
751
|
+
stiffness: 200,
|
|
752
|
+
overshootClamping: false
|
|
753
|
+
});
|
|
754
|
+
const inProgress = interpolate(relativeFrame, [startFrame, startFrame + 1], [0, 1], {
|
|
755
|
+
extrapolateLeft: "clamp",
|
|
756
|
+
extrapolateRight: "clamp"
|
|
757
|
+
});
|
|
758
|
+
backgroundOpacity = 0.9 * inProgress;
|
|
759
|
+
backgroundScale = 0.8 + scaleSpringIn * 0.45;
|
|
760
|
+
} else if (relativeFrame > endFrame) {
|
|
761
|
+
backgroundOpacity = 0;
|
|
762
|
+
backgroundScale = 0.8;
|
|
763
|
+
}
|
|
764
|
+
if (backgroundOpacity > 0) {
|
|
765
|
+
const padding = 8;
|
|
766
|
+
const bgWidth = (wordPos.width + padding * 2) * backgroundScale;
|
|
767
|
+
const bgHeight = (fontSize + padding) * backgroundScale;
|
|
768
|
+
ctx.save();
|
|
769
|
+
ctx.globalAlpha = backgroundOpacity;
|
|
770
|
+
ctx.fillStyle = highlightBackgroundColor;
|
|
771
|
+
ctx.beginPath();
|
|
772
|
+
ctx.roundRect(wordPos.x - bgWidth / 2, wordPos.y - bgHeight / 2, bgWidth, bgHeight, 8);
|
|
773
|
+
ctx.fill();
|
|
774
|
+
ctx.restore();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (stroke && strokeWidth > 0) {
|
|
778
|
+
ctx.strokeStyle = stroke;
|
|
779
|
+
ctx.lineWidth = strokeWidth;
|
|
780
|
+
ctx.strokeText(wordPos.text, wordPos.x, wordPos.y);
|
|
781
|
+
}
|
|
782
|
+
ctx.fillStyle = fill;
|
|
783
|
+
ctx.fillText(wordPos.text, wordPos.x, wordPos.y);
|
|
784
|
+
}
|
|
785
|
+
ctx.restore();
|
|
786
|
+
}
|
|
15
787
|
class LayerRenderer {
|
|
16
788
|
ctx;
|
|
17
789
|
width;
|
|
18
790
|
height;
|
|
19
|
-
|
|
791
|
+
currentFrame = 0;
|
|
792
|
+
fps = 30;
|
|
793
|
+
constructor(ctx, width, height, fps = 30) {
|
|
20
794
|
this.ctx = ctx;
|
|
21
795
|
this.width = width;
|
|
22
796
|
this.height = height;
|
|
797
|
+
this.fps = fps;
|
|
23
798
|
this.ensureHighQualityRendering();
|
|
24
799
|
}
|
|
800
|
+
setCurrentFrame(frame) {
|
|
801
|
+
this.currentFrame = frame;
|
|
802
|
+
}
|
|
25
803
|
ensureHighQualityRendering() {
|
|
26
804
|
this.ctx.imageSmoothingEnabled = true;
|
|
27
805
|
this.ctx.imageSmoothingQuality = "high";
|
|
@@ -211,73 +989,38 @@ class LayerRenderer {
|
|
|
211
989
|
}
|
|
212
990
|
}
|
|
213
991
|
async renderTextLayer(layer) {
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (layer.letterSpacing && typeof this.ctx.letterSpacing !== "undefined") {
|
|
223
|
-
this.ctx.letterSpacing = `${layer.letterSpacing}px`;
|
|
224
|
-
}
|
|
225
|
-
this.ensureHighQualityRendering();
|
|
226
|
-
const baseX = this.calculateTextX(layer.textAlign);
|
|
227
|
-
const baseY = this.calculateTextY(layer.verticalAlign, fontSize);
|
|
228
|
-
const x = Math.round(baseX) + 0.5;
|
|
229
|
-
const y = Math.round(baseY) + 0.5;
|
|
230
|
-
if (layer.shadow) {
|
|
231
|
-
this.ctx.shadowColor = layer.shadow.color;
|
|
232
|
-
this.ctx.shadowOffsetX = layer.shadow.offsetX;
|
|
233
|
-
this.ctx.shadowOffsetY = layer.shadow.offsetY;
|
|
234
|
-
this.ctx.shadowBlur = layer.shadow.blur;
|
|
235
|
-
}
|
|
236
|
-
if (layer.strokeColor && layer.strokeWidth && layer.strokeWidth > 0) {
|
|
237
|
-
this.drawEnhancedStroke(layer.text, x, y, layer.strokeColor, layer.strokeWidth);
|
|
238
|
-
}
|
|
239
|
-
this.ctx.fillText(layer.text, x, y);
|
|
240
|
-
if (layer.shadow) {
|
|
241
|
-
this.ctx.shadowColor = "transparent";
|
|
242
|
-
this.ctx.shadowOffsetX = 0;
|
|
243
|
-
this.ctx.shadowOffsetY = 0;
|
|
244
|
-
this.ctx.shadowBlur = 0;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* Draw enhanced multi-layer stroke for better text visibility
|
|
249
|
-
*/
|
|
250
|
-
drawEnhancedStroke(text, x, y, strokeColor, strokeWidth) {
|
|
251
|
-
this.ctx.save();
|
|
252
|
-
this.ctx.strokeStyle = strokeColor;
|
|
253
|
-
this.ctx.lineJoin = "round";
|
|
254
|
-
this.ctx.lineCap = "round";
|
|
255
|
-
this.ctx.miterLimit = 2;
|
|
256
|
-
const layers = [1.1, 1];
|
|
257
|
-
layers.forEach((multiplier) => {
|
|
258
|
-
this.ctx.lineWidth = strokeWidth * multiplier;
|
|
259
|
-
this.ctx.strokeText(text, x, y);
|
|
260
|
-
});
|
|
261
|
-
this.ctx.restore();
|
|
262
|
-
}
|
|
263
|
-
calculateTextX(align) {
|
|
264
|
-
switch (align) {
|
|
265
|
-
case "center":
|
|
266
|
-
return this.width / 2;
|
|
267
|
-
case "right":
|
|
268
|
-
return this.width;
|
|
269
|
-
default:
|
|
270
|
-
return 0;
|
|
992
|
+
const animationType = layer.animation?.type;
|
|
993
|
+
const hasWordTimings = layer.wordTimings && layer.wordTimings.length > 0;
|
|
994
|
+
const needsWordTimings = ["wordByWord", "characterKTV", "wordByWordFancy"].includes(
|
|
995
|
+
animationType || ""
|
|
996
|
+
);
|
|
997
|
+
if (needsWordTimings && !hasWordTimings) {
|
|
998
|
+
renderBasicText(this.ctx, layer, this.width, this.height, this.currentFrame);
|
|
999
|
+
return;
|
|
271
1000
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
1001
|
+
switch (animationType) {
|
|
1002
|
+
case "wordByWord":
|
|
1003
|
+
renderWordByWord(this.ctx, layer, this.width, this.height, this.currentFrame, this.fps);
|
|
1004
|
+
break;
|
|
1005
|
+
case "characterKTV":
|
|
1006
|
+
renderCharacterKTV(this.ctx, layer, this.width, this.height, this.currentFrame, this.fps);
|
|
1007
|
+
break;
|
|
1008
|
+
case "wordByWordFancy":
|
|
1009
|
+
renderWordByWordFancy(
|
|
1010
|
+
this.ctx,
|
|
1011
|
+
layer,
|
|
1012
|
+
this.width,
|
|
1013
|
+
this.height,
|
|
1014
|
+
this.currentFrame,
|
|
1015
|
+
this.fps
|
|
1016
|
+
);
|
|
1017
|
+
break;
|
|
1018
|
+
case "fade":
|
|
1019
|
+
renderTextWithEntrance(this.ctx, layer, this.width, this.height, this.currentFrame);
|
|
1020
|
+
break;
|
|
279
1021
|
default:
|
|
280
|
-
|
|
1022
|
+
renderBasicText(this.ctx, layer, this.width, this.height, this.currentFrame);
|
|
1023
|
+
break;
|
|
281
1024
|
}
|
|
282
1025
|
}
|
|
283
1026
|
applyMask(mask) {
|
|
@@ -731,8 +1474,14 @@ class VideoComposer {
|
|
|
731
1474
|
}
|
|
732
1475
|
this.ctx = ctx;
|
|
733
1476
|
this.ctx.imageSmoothingEnabled = this.config.enableSmoothing;
|
|
1477
|
+
this.loadFonts();
|
|
734
1478
|
this.ctx.imageSmoothingQuality = "high";
|
|
735
|
-
this.layerRenderer = new LayerRenderer(
|
|
1479
|
+
this.layerRenderer = new LayerRenderer(
|
|
1480
|
+
ctx,
|
|
1481
|
+
this.config.width,
|
|
1482
|
+
this.config.height,
|
|
1483
|
+
this.config.fps
|
|
1484
|
+
);
|
|
736
1485
|
this.transitionProcessor = new TransitionProcessor(this.config.width, this.config.height);
|
|
737
1486
|
this.filterProcessor = new FilterProcessor();
|
|
738
1487
|
this.timelineContext = this.config.timeline;
|
|
@@ -756,7 +1505,8 @@ class VideoComposer {
|
|
|
756
1505
|
clipStartUs: 0,
|
|
757
1506
|
clipDurationUs: Infinity,
|
|
758
1507
|
compositionFps: 30
|
|
759
|
-
}
|
|
1508
|
+
},
|
|
1509
|
+
fonts: config.fonts ?? []
|
|
760
1510
|
};
|
|
761
1511
|
}
|
|
762
1512
|
createStreams(_instruction) {
|
|
@@ -795,6 +1545,9 @@ class VideoComposer {
|
|
|
795
1545
|
throw new Error(`Too many layers: ${request.layers.length} > ${this.config.maxLayers}`);
|
|
796
1546
|
}
|
|
797
1547
|
this.clearCanvas();
|
|
1548
|
+
const frameDurationUs = 1e6 / this.config.fps;
|
|
1549
|
+
const relativeFrame = Math.floor(request.timeUs / frameDurationUs);
|
|
1550
|
+
this.layerRenderer.setCurrentFrame(relativeFrame);
|
|
798
1551
|
if (request.transition) {
|
|
799
1552
|
this.ctx.save();
|
|
800
1553
|
this.transitionProcessor.applyTransition(this.ctx, request.transition);
|
|
@@ -870,6 +1623,22 @@ class VideoComposer {
|
|
|
870
1623
|
});
|
|
871
1624
|
return frame;
|
|
872
1625
|
}
|
|
1626
|
+
async loadFonts() {
|
|
1627
|
+
if (!this.config.fonts || this.config.fonts.length === 0) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
for (const font of this.config.fonts) {
|
|
1631
|
+
try {
|
|
1632
|
+
const fontFace = new FontFace(font.family, `url(${font.url})`);
|
|
1633
|
+
await fontFace.load();
|
|
1634
|
+
if ("fonts" in self) {
|
|
1635
|
+
self.fonts.add(fontFace);
|
|
1636
|
+
}
|
|
1637
|
+
} catch (error) {
|
|
1638
|
+
console.warn(`[VideoComposer] Failed to load font ${font.family}:`, error);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
873
1642
|
updateConfig(config) {
|
|
874
1643
|
Object.assign(this.config, this.applyDefaults({ ...this.config, ...config }));
|
|
875
1644
|
if (config.width || config.height) {
|
|
@@ -884,6 +1653,9 @@ class VideoComposer {
|
|
|
884
1653
|
if (config.timeline) {
|
|
885
1654
|
this.timelineContext = config.timeline;
|
|
886
1655
|
}
|
|
1656
|
+
if (config.fonts) {
|
|
1657
|
+
this.loadFonts();
|
|
1658
|
+
}
|
|
887
1659
|
}
|
|
888
1660
|
dispose() {
|
|
889
1661
|
this.filterProcessor.clearCache();
|
|
@@ -1001,15 +1773,11 @@ function materializeLayer(layer, frame, imageMap, globalTimeUs) {
|
|
|
1001
1773
|
...baseLayer,
|
|
1002
1774
|
type: "text",
|
|
1003
1775
|
text: payload.text,
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
strokeWidth: payload.strokeWidth,
|
|
1010
|
-
lineHeight: payload.lineHeight,
|
|
1011
|
-
textAlign: payload.align,
|
|
1012
|
-
verticalAlign: "bottom"
|
|
1776
|
+
localeCode: payload.localeCode,
|
|
1777
|
+
fontConfig: payload.fontConfig,
|
|
1778
|
+
animation: payload.animation,
|
|
1779
|
+
wordTimings: payload.wordTimings,
|
|
1780
|
+
letterCase: payload.letterCase
|
|
1013
1781
|
};
|
|
1014
1782
|
}
|
|
1015
1783
|
if (layer.type === "image") {
|