@subvo/renderer 1.0.0 → 1.2.1
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/renderer.d.ts +8 -1
- package/dist/renderer.esm.js +419 -76
- package/dist/renderer.iife.js +419 -76
- package/package.json +5 -5
package/dist/renderer.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ type RenderLine = {
|
|
|
17
17
|
id: string;
|
|
18
18
|
startMs: number;
|
|
19
19
|
endMs: number;
|
|
20
|
+
mode?: RenderMode;
|
|
21
|
+
breakHints?: string[];
|
|
20
22
|
words: RenderWord[];
|
|
21
23
|
};
|
|
22
24
|
type RenderWord = {
|
|
@@ -24,6 +26,7 @@ type RenderWord = {
|
|
|
24
26
|
text: string;
|
|
25
27
|
startMs: number;
|
|
26
28
|
endMs: number;
|
|
29
|
+
emphasis?: number;
|
|
27
30
|
};
|
|
28
31
|
type RenderLayoutHints = {
|
|
29
32
|
maxWordsPerLine?: number;
|
|
@@ -34,8 +37,9 @@ type RenderMetadata = {
|
|
|
34
37
|
createdAt?: string;
|
|
35
38
|
tags?: string[];
|
|
36
39
|
};
|
|
37
|
-
type RenderPosition = "
|
|
40
|
+
type RenderPosition = "top_safe" | "center" | "bottom_safe";
|
|
38
41
|
type RenderPreset = "fire" | "clean" | "luxury" | "pop" | "ghost";
|
|
42
|
+
type RenderMode = "phrase_highlight" | "progressive_build";
|
|
39
43
|
type RenderStyle = {
|
|
40
44
|
fontFamily: string;
|
|
41
45
|
fontSize: number;
|
|
@@ -56,6 +60,9 @@ type RenderStyle = {
|
|
|
56
60
|
offsetY: number;
|
|
57
61
|
fadeInMs: number;
|
|
58
62
|
fadeOutMs: number;
|
|
63
|
+
highlightBox?: boolean;
|
|
64
|
+
karaokeFill?: boolean;
|
|
65
|
+
bounce?: boolean;
|
|
59
66
|
preset: RenderPreset;
|
|
60
67
|
};
|
|
61
68
|
|
package/dist/renderer.esm.js
CHANGED
|
@@ -1,64 +1,212 @@
|
|
|
1
1
|
// src/layout.ts
|
|
2
|
+
var MAX_ROW_WIDTH_RATIO = 0.72;
|
|
3
|
+
var ONE_ROW_COMFORT_RATIO = 0.66;
|
|
4
|
+
var CLOSING_PUNCTUATION_RE = /["')\]}”’]+$/;
|
|
5
|
+
var STRONG_BOUNDARY_RE = /[.?!:;]$/;
|
|
6
|
+
var SOFT_BOUNDARY_RE = /[,]$/;
|
|
7
|
+
var SOFT_BREAK_WORDS = /* @__PURE__ */ new Set([
|
|
8
|
+
"and",
|
|
9
|
+
"because",
|
|
10
|
+
"but",
|
|
11
|
+
"now",
|
|
12
|
+
"ok",
|
|
13
|
+
"okay",
|
|
14
|
+
"so",
|
|
15
|
+
"then",
|
|
16
|
+
"well"
|
|
17
|
+
]);
|
|
2
18
|
function computeLayout(ctx, renderJob) {
|
|
3
|
-
const { style } = renderJob;
|
|
19
|
+
const { style, playRes } = renderJob;
|
|
4
20
|
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
5
21
|
ctx.textBaseline = "alphabetic";
|
|
6
22
|
return renderJob.lines.map((line) => {
|
|
7
|
-
const rows =
|
|
23
|
+
const rows = resolveRows(
|
|
24
|
+
ctx,
|
|
25
|
+
line.words,
|
|
26
|
+
line.breakHints,
|
|
27
|
+
style.maxWordsPerLine,
|
|
28
|
+
playRes.width
|
|
29
|
+
);
|
|
8
30
|
return {
|
|
9
31
|
line,
|
|
10
|
-
rows: rows.map((words) =>
|
|
11
|
-
let rowWidth = 0;
|
|
12
|
-
const measuredWords = words.map((word, index) => {
|
|
13
|
-
const text = index < words.length - 1 ? `${word.text} ` : word.text;
|
|
14
|
-
const metrics = ctx.measureText(text);
|
|
15
|
-
const width = metrics.width;
|
|
16
|
-
rowWidth += width;
|
|
17
|
-
return {
|
|
18
|
-
word,
|
|
19
|
-
text,
|
|
20
|
-
width
|
|
21
|
-
};
|
|
22
|
-
});
|
|
23
|
-
return {
|
|
24
|
-
words: measuredWords,
|
|
25
|
-
width: rowWidth
|
|
26
|
-
};
|
|
27
|
-
})
|
|
32
|
+
rows: rows.map((words) => buildLayoutRow(ctx, words))
|
|
28
33
|
};
|
|
29
34
|
});
|
|
30
35
|
}
|
|
31
|
-
function
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
function resolveRows(ctx, words, breakHints, maxWordsPerLine, playWidth) {
|
|
37
|
+
if (words.length <= 1) {
|
|
38
|
+
return [words];
|
|
39
|
+
}
|
|
40
|
+
const measuredWords = words.map((word) => ({
|
|
41
|
+
word,
|
|
42
|
+
width: ctx.measureText(word.text).width
|
|
43
|
+
}));
|
|
44
|
+
const spaceWidth = ctx.measureText(" ").width;
|
|
45
|
+
const hintSet = new Set((breakHints != null ? breakHints : []).map((hint) => String(hint)));
|
|
46
|
+
const maxRowWidth = playWidth * MAX_ROW_WIDTH_RATIO;
|
|
47
|
+
const totalWidth = measureRowWidth(measuredWords, spaceWidth);
|
|
48
|
+
const candidates = [];
|
|
49
|
+
candidates.push({
|
|
50
|
+
rows: [measuredWords],
|
|
51
|
+
score: scoreSingleRowCandidate(
|
|
52
|
+
[measuredWords],
|
|
53
|
+
totalWidth,
|
|
54
|
+
maxRowWidth,
|
|
55
|
+
hintSet,
|
|
56
|
+
maxWordsPerLine
|
|
57
|
+
)
|
|
58
|
+
});
|
|
59
|
+
for (let index = 1; index < measuredWords.length; index += 1) {
|
|
60
|
+
const first = measuredWords.slice(0, index);
|
|
61
|
+
const second = measuredWords.slice(index);
|
|
62
|
+
candidates.push({
|
|
63
|
+
rows: [first, second],
|
|
64
|
+
score: scoreTwoRowCandidate(
|
|
65
|
+
[first, second],
|
|
66
|
+
hintSet,
|
|
67
|
+
maxRowWidth,
|
|
68
|
+
maxWordsPerLine,
|
|
69
|
+
spaceWidth
|
|
70
|
+
)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
candidates.sort((left, right) => left.score - right.score);
|
|
74
|
+
return candidates[0].rows.map((row) => row.map((entry) => entry.word));
|
|
75
|
+
}
|
|
76
|
+
function scoreSingleRowCandidate(rows, totalWidth, maxRowWidth, hintSet, maxWordsPerLine) {
|
|
77
|
+
var _a, _b;
|
|
78
|
+
let score = 0;
|
|
79
|
+
score += overflowPenalty(totalWidth, maxRowWidth) * 6;
|
|
80
|
+
if (totalWidth > maxRowWidth * ONE_ROW_COMFORT_RATIO) {
|
|
81
|
+
score += (totalWidth - maxRowWidth * ONE_ROW_COMFORT_RATIO) * 0.12;
|
|
82
|
+
}
|
|
83
|
+
const wordCount = (_b = (_a = rows[0]) == null ? void 0 : _a.length) != null ? _b : 0;
|
|
84
|
+
if (wordCount > Math.max(4, maxWordsPerLine + 1)) {
|
|
85
|
+
score += (wordCount - Math.max(4, maxWordsPerLine + 1)) * 40;
|
|
86
|
+
}
|
|
87
|
+
if (hintSet.size > 0) {
|
|
88
|
+
score += 60;
|
|
89
|
+
}
|
|
90
|
+
return score;
|
|
91
|
+
}
|
|
92
|
+
function scoreTwoRowCandidate(rows, hintSet, maxRowWidth, maxWordsPerLine, spaceWidth) {
|
|
93
|
+
var _a, _b;
|
|
94
|
+
const [first, second] = rows;
|
|
95
|
+
const firstWidth = measureRowWidth(first, spaceWidth);
|
|
96
|
+
const secondWidth = measureRowWidth(second, spaceWidth);
|
|
97
|
+
let score = 0;
|
|
98
|
+
score += overflowPenalty(firstWidth, maxRowWidth) * 8;
|
|
99
|
+
score += overflowPenalty(secondWidth, maxRowWidth) * 8;
|
|
100
|
+
score += Math.abs(firstWidth - secondWidth) * 0.18;
|
|
101
|
+
score += Math.abs(first.length - second.length) * 16;
|
|
102
|
+
if (first.length === 1 || second.length === 1) {
|
|
103
|
+
score += 320;
|
|
104
|
+
} else {
|
|
105
|
+
if (first.length === 2) score += 45;
|
|
106
|
+
if (second.length === 2) score += 45;
|
|
107
|
+
}
|
|
108
|
+
const preferredWordsPerRow = Math.max(3, maxWordsPerLine);
|
|
109
|
+
if (first.length > preferredWordsPerRow + 2) {
|
|
110
|
+
score += (first.length - (preferredWordsPerRow + 2)) * 25;
|
|
111
|
+
}
|
|
112
|
+
if (second.length > preferredWordsPerRow + 2) {
|
|
113
|
+
score += (second.length - (preferredWordsPerRow + 2)) * 25;
|
|
114
|
+
}
|
|
115
|
+
const nextRowFirstWord = (_a = second[0]) == null ? void 0 : _a.word;
|
|
116
|
+
if (nextRowFirstWord) {
|
|
117
|
+
if (hintSet.has(String(nextRowFirstWord.id))) {
|
|
118
|
+
score -= 70;
|
|
119
|
+
} else if (hintSet.size > 0) {
|
|
120
|
+
score += 20;
|
|
40
121
|
}
|
|
41
122
|
}
|
|
42
|
-
|
|
43
|
-
|
|
123
|
+
const boundaryWord = (_b = first[first.length - 1]) == null ? void 0 : _b.word;
|
|
124
|
+
if (boundaryWord) {
|
|
125
|
+
score -= boundaryBonus(boundaryWord.text);
|
|
126
|
+
}
|
|
127
|
+
if (nextRowFirstWord) {
|
|
128
|
+
score -= softBreakWordBonus(nextRowFirstWord.text);
|
|
129
|
+
}
|
|
130
|
+
return score;
|
|
131
|
+
}
|
|
132
|
+
function overflowPenalty(width, maxWidth) {
|
|
133
|
+
return Math.max(0, width - maxWidth);
|
|
134
|
+
}
|
|
135
|
+
function boundaryBonus(text) {
|
|
136
|
+
const normalized = normalizeBoundaryToken(text);
|
|
137
|
+
if (!normalized) {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
if (STRONG_BOUNDARY_RE.test(normalized)) {
|
|
141
|
+
return 55;
|
|
142
|
+
}
|
|
143
|
+
if (SOFT_BOUNDARY_RE.test(normalized)) {
|
|
144
|
+
return 35;
|
|
44
145
|
}
|
|
45
|
-
return
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
function softBreakWordBonus(text) {
|
|
149
|
+
return SOFT_BREAK_WORDS.has(normalizeWord(text)) ? 18 : 0;
|
|
150
|
+
}
|
|
151
|
+
function normalizeBoundaryToken(text) {
|
|
152
|
+
return text.replace(CLOSING_PUNCTUATION_RE, "");
|
|
153
|
+
}
|
|
154
|
+
function normalizeWord(text) {
|
|
155
|
+
return normalizeBoundaryToken(text).replace(/^[^\w]+|[^\w]+$/g, "").toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
function measureRowWidth(words, spaceWidth) {
|
|
158
|
+
const wordWidth = words.reduce((total, entry) => total + entry.width, 0);
|
|
159
|
+
return wordWidth + Math.max(0, words.length - 1) * spaceWidth;
|
|
160
|
+
}
|
|
161
|
+
function buildLayoutRow(ctx, words) {
|
|
162
|
+
let rowWidth = 0;
|
|
163
|
+
const measuredWords = words.map((word, index) => {
|
|
164
|
+
const text = index < words.length - 1 ? `${word.text} ` : word.text;
|
|
165
|
+
const width = ctx.measureText(text).width;
|
|
166
|
+
rowWidth += width;
|
|
167
|
+
return {
|
|
168
|
+
word,
|
|
169
|
+
text,
|
|
170
|
+
width
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
words: measuredWords,
|
|
175
|
+
width: rowWidth
|
|
176
|
+
};
|
|
46
177
|
}
|
|
47
178
|
|
|
48
179
|
// src/effects.ts
|
|
180
|
+
var PRESET_FEATURE_DEFAULTS = {
|
|
181
|
+
fire: {
|
|
182
|
+
highlightBox: true,
|
|
183
|
+
karaokeFill: true,
|
|
184
|
+
bounce: true
|
|
185
|
+
},
|
|
186
|
+
clean: {
|
|
187
|
+
highlightBox: false,
|
|
188
|
+
karaokeFill: true,
|
|
189
|
+
bounce: false
|
|
190
|
+
},
|
|
191
|
+
luxury: {
|
|
192
|
+
highlightBox: true,
|
|
193
|
+
karaokeFill: false,
|
|
194
|
+
bounce: false
|
|
195
|
+
},
|
|
196
|
+
pop: {
|
|
197
|
+
highlightBox: false,
|
|
198
|
+
karaokeFill: true,
|
|
199
|
+
bounce: true
|
|
200
|
+
},
|
|
201
|
+
ghost: {
|
|
202
|
+
highlightBox: false,
|
|
203
|
+
karaokeFill: false,
|
|
204
|
+
bounce: false
|
|
205
|
+
}
|
|
206
|
+
};
|
|
49
207
|
function clamp(value, min = 0, max = 1) {
|
|
50
208
|
return Math.min(max, Math.max(min, value));
|
|
51
209
|
}
|
|
52
|
-
function computeFadeAlpha(style, line, timeMs) {
|
|
53
|
-
let alpha = 1;
|
|
54
|
-
if (style.fadeInMs > 0) {
|
|
55
|
-
alpha = Math.min(alpha, (timeMs - line.startMs) / style.fadeInMs);
|
|
56
|
-
}
|
|
57
|
-
if (style.fadeOutMs > 0) {
|
|
58
|
-
alpha = Math.min(alpha, (line.endMs - timeMs) / style.fadeOutMs);
|
|
59
|
-
}
|
|
60
|
-
return clamp(alpha);
|
|
61
|
-
}
|
|
62
210
|
function computeWordProgress(word, timeMs) {
|
|
63
211
|
const duration = word.endMs - word.startMs;
|
|
64
212
|
if (duration <= 0) return 0;
|
|
@@ -67,78 +215,273 @@ function computeWordProgress(word, timeMs) {
|
|
|
67
215
|
function computePulseScale(style, progress) {
|
|
68
216
|
return 1 + (style.pulse - 1) * progress * style.highlightStrength;
|
|
69
217
|
}
|
|
218
|
+
function computeImpactProgress(word, timeMs, durationMs = 150) {
|
|
219
|
+
if (durationMs <= 0) return 0;
|
|
220
|
+
return clamp((timeMs - word.startMs) / durationMs);
|
|
221
|
+
}
|
|
222
|
+
function computeImpactScale(progress) {
|
|
223
|
+
const clampedProgress = clamp(progress);
|
|
224
|
+
if (clampedProgress <= 0 || clampedProgress >= 1) {
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
if (clampedProgress < 0.3) {
|
|
228
|
+
return 1 + clampedProgress * 0.8;
|
|
229
|
+
}
|
|
230
|
+
return Math.max(1, 1.25 - (clampedProgress - 0.3) * 0.35);
|
|
231
|
+
}
|
|
232
|
+
function resolveStyleFlag(style, key) {
|
|
233
|
+
const value = style[key];
|
|
234
|
+
if (typeof value === "boolean") {
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
return PRESET_FEATURE_DEFAULTS[style.preset][key];
|
|
238
|
+
}
|
|
70
239
|
|
|
71
240
|
// src/drawText.ts
|
|
72
|
-
|
|
73
|
-
|
|
241
|
+
var BOX_PADDING_X = 12;
|
|
242
|
+
var BOX_PADDING_Y = 6;
|
|
243
|
+
var BOX_RADIUS = 8;
|
|
244
|
+
function drawWord(ctx, rawText, text, x, y, style, scaleFactor, ascent, progress = 0, emphasis = 0) {
|
|
245
|
+
const activeProgress = Math.max(0, Math.min(1, progress));
|
|
246
|
+
const emphasisBoost = emphasis > 0.6 ? Math.min(1, (emphasis - 0.6) / 0.4) : 0;
|
|
247
|
+
const effectiveScale = scaleFactor * (1 + emphasisBoost * 0.06);
|
|
248
|
+
const invScale = 1 / effectiveScale;
|
|
249
|
+
const outlineMultiplier = emphasis >= 0.6 ? 1.4 : 1;
|
|
250
|
+
const strokeWidth = style.outlineWidth * outlineMultiplier + emphasisBoost * 1.2;
|
|
251
|
+
const glowIntensity = style.glowIntensity + (emphasis >= 0.75 ? 0.4 : 0);
|
|
252
|
+
const isActive = activeProgress > 0;
|
|
253
|
+
const highlightBoxActive = resolveStyleFlag(style, "highlightBox") && isActive && emphasis >= 0.7;
|
|
254
|
+
const karaokeFillActive = resolveStyleFlag(style, "karaokeFill") && isActive && activeProgress < 1;
|
|
255
|
+
const inactiveFillColor = !resolveStyleFlag(style, "karaokeFill") && style.preset !== "ghost" && emphasis >= 0.75 ? style.highlightColor : style.baseColor;
|
|
256
|
+
const baseFillColor = highlightBoxActive ? style.baseColor : isActive && karaokeFillActive ? style.baseColor : inactiveFillColor;
|
|
257
|
+
const activeFillColor = highlightBoxActive ? style.baseColor : style.highlightColor;
|
|
258
|
+
const textBounds = measureTextBounds(ctx, rawText, ascent);
|
|
74
259
|
ctx.save();
|
|
75
260
|
ctx.translate(x, y);
|
|
76
261
|
ctx.translate(0, -ascent);
|
|
77
|
-
ctx.scale(
|
|
262
|
+
ctx.scale(effectiveScale, effectiveScale);
|
|
78
263
|
ctx.translate(0, ascent);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
ctx
|
|
264
|
+
if (highlightBoxActive) {
|
|
265
|
+
resetShadow(ctx);
|
|
266
|
+
drawHighlightBox(ctx, textBounds, style.highlightColor);
|
|
267
|
+
}
|
|
268
|
+
resetShadow(ctx);
|
|
269
|
+
if (strokeWidth > 0) {
|
|
270
|
+
ctx.lineWidth = strokeWidth * invScale;
|
|
82
271
|
ctx.strokeStyle = style.outlineColor;
|
|
83
272
|
ctx.strokeText(text, 0, 0);
|
|
84
273
|
}
|
|
85
|
-
|
|
86
|
-
ctx
|
|
274
|
+
applyTextEffects(
|
|
275
|
+
ctx,
|
|
276
|
+
style,
|
|
277
|
+
invScale,
|
|
278
|
+
glowIntensity,
|
|
279
|
+
emphasis >= 0.75 ? style.highlightColor : style.outlineColor
|
|
280
|
+
);
|
|
281
|
+
ctx.fillStyle = isActive && !karaokeFillActive ? activeFillColor : baseFillColor;
|
|
282
|
+
ctx.fillText(text, 0, 0);
|
|
283
|
+
if (karaokeFillActive) {
|
|
284
|
+
ctx.save();
|
|
285
|
+
applyTextEffects(ctx, style, invScale, glowIntensity, style.highlightColor);
|
|
286
|
+
ctx.beginPath();
|
|
287
|
+
ctx.rect(
|
|
288
|
+
textBounds.left - 1 * invScale,
|
|
289
|
+
textBounds.top - 2 * invScale,
|
|
290
|
+
(textBounds.width + 2 * invScale) * activeProgress,
|
|
291
|
+
textBounds.height + 4 * invScale
|
|
292
|
+
);
|
|
293
|
+
ctx.clip();
|
|
294
|
+
ctx.fillStyle = style.highlightColor;
|
|
295
|
+
ctx.fillText(text, 0, 0);
|
|
296
|
+
ctx.restore();
|
|
297
|
+
}
|
|
298
|
+
ctx.restore();
|
|
299
|
+
}
|
|
300
|
+
function measureTextBounds(ctx, rawText, fallbackAscent) {
|
|
301
|
+
const metrics = ctx.measureText(rawText);
|
|
302
|
+
const left = -metrics.actualBoundingBoxLeft;
|
|
303
|
+
const width = Math.max(
|
|
304
|
+
metrics.width,
|
|
305
|
+
metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
|
306
|
+
);
|
|
307
|
+
const ascent = metrics.actualBoundingBoxAscent || fallbackAscent;
|
|
308
|
+
const descent = metrics.actualBoundingBoxDescent || Math.max(4, fallbackAscent * 0.28);
|
|
309
|
+
return {
|
|
310
|
+
left,
|
|
311
|
+
top: -ascent,
|
|
312
|
+
width,
|
|
313
|
+
height: ascent + descent
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function drawHighlightBox(ctx, textBounds, color) {
|
|
317
|
+
const boxX = textBounds.left - BOX_PADDING_X;
|
|
318
|
+
const boxY = textBounds.top - BOX_PADDING_Y;
|
|
319
|
+
const boxWidth = textBounds.width + BOX_PADDING_X * 2;
|
|
320
|
+
const boxHeight = textBounds.height + BOX_PADDING_Y * 2;
|
|
321
|
+
ctx.beginPath();
|
|
322
|
+
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, BOX_RADIUS);
|
|
323
|
+
ctx.fillStyle = color;
|
|
324
|
+
ctx.fill();
|
|
325
|
+
}
|
|
326
|
+
function applyTextEffects(ctx, style, invScale, glowIntensity, shadowColor) {
|
|
327
|
+
if (style.shadow || glowIntensity > 0) {
|
|
328
|
+
ctx.shadowColor = shadowColor;
|
|
87
329
|
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
88
330
|
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
89
|
-
ctx.shadowBlur = style.
|
|
90
|
-
|
|
91
|
-
ctx.shadowColor = "transparent";
|
|
92
|
-
ctx.shadowBlur = 0;
|
|
93
|
-
ctx.shadowOffsetX = 0;
|
|
94
|
-
ctx.shadowOffsetY = 0;
|
|
331
|
+
ctx.shadowBlur = style.fontSize * glowIntensity * invScale;
|
|
332
|
+
return;
|
|
95
333
|
}
|
|
96
|
-
ctx
|
|
97
|
-
|
|
334
|
+
resetShadow(ctx);
|
|
335
|
+
}
|
|
336
|
+
function resetShadow(ctx) {
|
|
337
|
+
ctx.shadowColor = "transparent";
|
|
338
|
+
ctx.shadowBlur = 0;
|
|
339
|
+
ctx.shadowOffsetX = 0;
|
|
340
|
+
ctx.shadowOffsetY = 0;
|
|
98
341
|
}
|
|
99
342
|
|
|
100
343
|
// src/renderFrame.ts
|
|
344
|
+
var DEFAULT_TRANSITION_MS = 150;
|
|
345
|
+
var MIN_TRANSITION_MS = 120;
|
|
346
|
+
var MAX_TRANSITION_MS = 180;
|
|
347
|
+
var TOP_UNSAFE_RATIO = 0.14;
|
|
348
|
+
var BOTTOM_UNSAFE_RATIO = 0.22;
|
|
349
|
+
var TOP_SAFE_ANCHOR_RATIO = 0.22;
|
|
350
|
+
var CENTER_ANCHOR_RATIO = 0.5;
|
|
351
|
+
var BOTTOM_SAFE_ANCHOR_RATIO = 0.7;
|
|
101
352
|
function renderFrame(ctx, renderJob, timeMs) {
|
|
353
|
+
var _a;
|
|
102
354
|
const { playRes, style } = renderJob;
|
|
103
355
|
const layout = computeLayout(ctx, renderJob);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
356
|
+
const transitionMs = resolveTransitionMs(style);
|
|
357
|
+
const visibleLines = layout.map((entry) => ({
|
|
358
|
+
entry,
|
|
359
|
+
alpha: computeLineAlpha(entry.line, timeMs, transitionMs)
|
|
360
|
+
})).filter((entry) => entry.alpha > 0);
|
|
361
|
+
if (!visibleLines.length) return;
|
|
108
362
|
const transform = ctx.getTransform();
|
|
109
363
|
const canvasWidth = ctx.canvas.width / (transform.a || 1);
|
|
110
364
|
const canvasHeight = ctx.canvas.height / (transform.d || 1);
|
|
111
365
|
const scale = Math.max(canvasWidth / playRes.width, canvasHeight / playRes.height);
|
|
112
366
|
const offsetX = (canvasWidth - playRes.width * scale) / 2;
|
|
113
367
|
const offsetY = (canvasHeight - playRes.height * scale) / 2;
|
|
114
|
-
const alpha = computeFadeAlpha(style, active.line, timeMs);
|
|
115
368
|
ctx.save();
|
|
116
369
|
ctx.translate(offsetX, offsetY);
|
|
117
370
|
ctx.scale(scale, scale);
|
|
118
|
-
ctx.globalAlpha = alpha;
|
|
119
371
|
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
120
372
|
ctx.textBaseline = "alphabetic";
|
|
121
373
|
const metrics = ctx.measureText("Mg");
|
|
122
374
|
const ascent = metrics.actualBoundingBoxAscent;
|
|
375
|
+
const descent = metrics.actualBoundingBoxDescent || Math.max(4, style.fontSize * 0.28);
|
|
123
376
|
const centerX = playRes.width / 2;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
377
|
+
const lineStep = style.fontSize * style.lineSpacing;
|
|
378
|
+
const safeTop = playRes.height * TOP_UNSAFE_RATIO;
|
|
379
|
+
const safeBottom = playRes.height * (1 - BOTTOM_UNSAFE_RATIO);
|
|
380
|
+
const anchorY = resolveAnchorY(playRes.height, style.position) + style.offsetY;
|
|
381
|
+
for (const { entry, alpha } of visibleLines) {
|
|
382
|
+
ctx.save();
|
|
383
|
+
ctx.globalAlpha = alpha;
|
|
384
|
+
const baselineY = clampBaselineY(
|
|
385
|
+
anchorY,
|
|
386
|
+
entry.rows.length,
|
|
387
|
+
ascent,
|
|
388
|
+
descent,
|
|
389
|
+
lineStep,
|
|
390
|
+
safeTop,
|
|
391
|
+
safeBottom
|
|
392
|
+
);
|
|
393
|
+
for (const [rowIndex, row] of entry.rows.entries()) {
|
|
394
|
+
let x = centerX - row.width / 2;
|
|
395
|
+
const rowY = baselineY + rowIndex * lineStep;
|
|
396
|
+
for (const { word, text, width } of row.words) {
|
|
397
|
+
const visibleWord = isWordVisible(entry.line, word, timeMs);
|
|
398
|
+
if (visibleWord) {
|
|
399
|
+
const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
|
|
400
|
+
const emphasis = (_a = word.emphasis) != null ? _a : 0;
|
|
401
|
+
const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
|
|
402
|
+
const impactProgress = activeWord ? computeImpactProgress(word, timeMs) : 0;
|
|
403
|
+
const pulseScale = computePulseScale(style, progress);
|
|
404
|
+
const bounceScale = shouldBounceWord(style, emphasis) ? computeImpactScale(impactProgress) : 1;
|
|
405
|
+
const scaleFactor = pulseScale * bounceScale;
|
|
406
|
+
drawWord(
|
|
407
|
+
ctx,
|
|
408
|
+
word.text,
|
|
409
|
+
text,
|
|
410
|
+
x,
|
|
411
|
+
rowY,
|
|
412
|
+
style,
|
|
413
|
+
scaleFactor,
|
|
414
|
+
ascent,
|
|
415
|
+
progress,
|
|
416
|
+
emphasis
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
x += width;
|
|
420
|
+
}
|
|
134
421
|
}
|
|
135
|
-
|
|
422
|
+
ctx.restore();
|
|
136
423
|
}
|
|
137
424
|
ctx.restore();
|
|
138
425
|
}
|
|
426
|
+
function resolveTransitionMs(style) {
|
|
427
|
+
return clamp(
|
|
428
|
+
Math.max(DEFAULT_TRANSITION_MS, style.fadeInMs, style.fadeOutMs),
|
|
429
|
+
MIN_TRANSITION_MS,
|
|
430
|
+
MAX_TRANSITION_MS
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
function computeLineAlpha(line, timeMs, transitionMs) {
|
|
434
|
+
const padding = transitionMs / 2;
|
|
435
|
+
const visibleStart = line.startMs - padding;
|
|
436
|
+
const visibleEnd = line.endMs + padding;
|
|
437
|
+
if (timeMs < visibleStart || timeMs > visibleEnd) {
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
const fadeIn = clamp((timeMs - visibleStart) / transitionMs);
|
|
441
|
+
const fadeOut = clamp((visibleEnd - timeMs) / transitionMs);
|
|
442
|
+
return Math.min(fadeIn, fadeOut);
|
|
443
|
+
}
|
|
444
|
+
function isWordVisible(line, word, timeMs) {
|
|
445
|
+
if (line.mode !== "progressive_build") {
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
return timeMs >= word.startMs;
|
|
449
|
+
}
|
|
450
|
+
function shouldBounceWord(style, emphasis) {
|
|
451
|
+
if (style.preset === "ghost") {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
if (resolveStyleFlag(style, "bounce")) {
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
return emphasis >= 0.85;
|
|
458
|
+
}
|
|
459
|
+
function resolveAnchorY(playHeight, position) {
|
|
460
|
+
if (position === "top_safe") {
|
|
461
|
+
return playHeight * TOP_SAFE_ANCHOR_RATIO;
|
|
462
|
+
}
|
|
463
|
+
if (position === "center") {
|
|
464
|
+
return playHeight * CENTER_ANCHOR_RATIO;
|
|
465
|
+
}
|
|
466
|
+
return playHeight * BOTTOM_SAFE_ANCHOR_RATIO;
|
|
467
|
+
}
|
|
468
|
+
function clampBaselineY(baselineY, rowCount, ascent, descent, lineStep, safeTop, safeBottom) {
|
|
469
|
+
const rows = Math.max(1, rowCount);
|
|
470
|
+
const blockTop = baselineY - ascent;
|
|
471
|
+
const blockBottom = baselineY + (rows - 1) * lineStep + descent;
|
|
472
|
+
if (blockTop < safeTop) {
|
|
473
|
+
baselineY += safeTop - blockTop;
|
|
474
|
+
}
|
|
475
|
+
if (blockBottom > safeBottom) {
|
|
476
|
+
baselineY -= blockBottom - safeBottom;
|
|
477
|
+
}
|
|
478
|
+
const minBaselineY = safeTop + ascent;
|
|
479
|
+
const maxBaselineY = safeBottom - descent - (rows - 1) * lineStep;
|
|
480
|
+
return clamp(baselineY, minBaselineY, Math.max(minBaselineY, maxBaselineY));
|
|
481
|
+
}
|
|
139
482
|
|
|
140
483
|
// src/index.ts
|
|
141
|
-
var injectedVersion = "1.
|
|
484
|
+
var injectedVersion = "1.2.1".length > 0 ? "1.2.1" : "dev";
|
|
142
485
|
var VERSION = injectedVersion;
|
|
143
486
|
export {
|
|
144
487
|
VERSION,
|
package/dist/renderer.iife.js
CHANGED
|
@@ -26,66 +26,214 @@ var SubvoRenderer = (() => {
|
|
|
26
26
|
});
|
|
27
27
|
|
|
28
28
|
// src/layout.ts
|
|
29
|
+
var MAX_ROW_WIDTH_RATIO = 0.72;
|
|
30
|
+
var ONE_ROW_COMFORT_RATIO = 0.66;
|
|
31
|
+
var CLOSING_PUNCTUATION_RE = /["')\]}”’]+$/;
|
|
32
|
+
var STRONG_BOUNDARY_RE = /[.?!:;]$/;
|
|
33
|
+
var SOFT_BOUNDARY_RE = /[,]$/;
|
|
34
|
+
var SOFT_BREAK_WORDS = /* @__PURE__ */ new Set([
|
|
35
|
+
"and",
|
|
36
|
+
"because",
|
|
37
|
+
"but",
|
|
38
|
+
"now",
|
|
39
|
+
"ok",
|
|
40
|
+
"okay",
|
|
41
|
+
"so",
|
|
42
|
+
"then",
|
|
43
|
+
"well"
|
|
44
|
+
]);
|
|
29
45
|
function computeLayout(ctx, renderJob) {
|
|
30
|
-
const { style } = renderJob;
|
|
46
|
+
const { style, playRes } = renderJob;
|
|
31
47
|
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
32
48
|
ctx.textBaseline = "alphabetic";
|
|
33
49
|
return renderJob.lines.map((line) => {
|
|
34
|
-
const rows =
|
|
50
|
+
const rows = resolveRows(
|
|
51
|
+
ctx,
|
|
52
|
+
line.words,
|
|
53
|
+
line.breakHints,
|
|
54
|
+
style.maxWordsPerLine,
|
|
55
|
+
playRes.width
|
|
56
|
+
);
|
|
35
57
|
return {
|
|
36
58
|
line,
|
|
37
|
-
rows: rows.map((words) =>
|
|
38
|
-
let rowWidth = 0;
|
|
39
|
-
const measuredWords = words.map((word, index) => {
|
|
40
|
-
const text = index < words.length - 1 ? `${word.text} ` : word.text;
|
|
41
|
-
const metrics = ctx.measureText(text);
|
|
42
|
-
const width = metrics.width;
|
|
43
|
-
rowWidth += width;
|
|
44
|
-
return {
|
|
45
|
-
word,
|
|
46
|
-
text,
|
|
47
|
-
width
|
|
48
|
-
};
|
|
49
|
-
});
|
|
50
|
-
return {
|
|
51
|
-
words: measuredWords,
|
|
52
|
-
width: rowWidth
|
|
53
|
-
};
|
|
54
|
-
})
|
|
59
|
+
rows: rows.map((words) => buildLayoutRow(ctx, words))
|
|
55
60
|
};
|
|
56
61
|
});
|
|
57
62
|
}
|
|
58
|
-
function
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
function resolveRows(ctx, words, breakHints, maxWordsPerLine, playWidth) {
|
|
64
|
+
if (words.length <= 1) {
|
|
65
|
+
return [words];
|
|
66
|
+
}
|
|
67
|
+
const measuredWords = words.map((word) => ({
|
|
68
|
+
word,
|
|
69
|
+
width: ctx.measureText(word.text).width
|
|
70
|
+
}));
|
|
71
|
+
const spaceWidth = ctx.measureText(" ").width;
|
|
72
|
+
const hintSet = new Set((breakHints != null ? breakHints : []).map((hint) => String(hint)));
|
|
73
|
+
const maxRowWidth = playWidth * MAX_ROW_WIDTH_RATIO;
|
|
74
|
+
const totalWidth = measureRowWidth(measuredWords, spaceWidth);
|
|
75
|
+
const candidates = [];
|
|
76
|
+
candidates.push({
|
|
77
|
+
rows: [measuredWords],
|
|
78
|
+
score: scoreSingleRowCandidate(
|
|
79
|
+
[measuredWords],
|
|
80
|
+
totalWidth,
|
|
81
|
+
maxRowWidth,
|
|
82
|
+
hintSet,
|
|
83
|
+
maxWordsPerLine
|
|
84
|
+
)
|
|
85
|
+
});
|
|
86
|
+
for (let index = 1; index < measuredWords.length; index += 1) {
|
|
87
|
+
const first = measuredWords.slice(0, index);
|
|
88
|
+
const second = measuredWords.slice(index);
|
|
89
|
+
candidates.push({
|
|
90
|
+
rows: [first, second],
|
|
91
|
+
score: scoreTwoRowCandidate(
|
|
92
|
+
[first, second],
|
|
93
|
+
hintSet,
|
|
94
|
+
maxRowWidth,
|
|
95
|
+
maxWordsPerLine,
|
|
96
|
+
spaceWidth
|
|
97
|
+
)
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
candidates.sort((left, right) => left.score - right.score);
|
|
101
|
+
return candidates[0].rows.map((row) => row.map((entry) => entry.word));
|
|
102
|
+
}
|
|
103
|
+
function scoreSingleRowCandidate(rows, totalWidth, maxRowWidth, hintSet, maxWordsPerLine) {
|
|
104
|
+
var _a, _b;
|
|
105
|
+
let score = 0;
|
|
106
|
+
score += overflowPenalty(totalWidth, maxRowWidth) * 6;
|
|
107
|
+
if (totalWidth > maxRowWidth * ONE_ROW_COMFORT_RATIO) {
|
|
108
|
+
score += (totalWidth - maxRowWidth * ONE_ROW_COMFORT_RATIO) * 0.12;
|
|
109
|
+
}
|
|
110
|
+
const wordCount = (_b = (_a = rows[0]) == null ? void 0 : _a.length) != null ? _b : 0;
|
|
111
|
+
if (wordCount > Math.max(4, maxWordsPerLine + 1)) {
|
|
112
|
+
score += (wordCount - Math.max(4, maxWordsPerLine + 1)) * 40;
|
|
113
|
+
}
|
|
114
|
+
if (hintSet.size > 0) {
|
|
115
|
+
score += 60;
|
|
116
|
+
}
|
|
117
|
+
return score;
|
|
118
|
+
}
|
|
119
|
+
function scoreTwoRowCandidate(rows, hintSet, maxRowWidth, maxWordsPerLine, spaceWidth) {
|
|
120
|
+
var _a, _b;
|
|
121
|
+
const [first, second] = rows;
|
|
122
|
+
const firstWidth = measureRowWidth(first, spaceWidth);
|
|
123
|
+
const secondWidth = measureRowWidth(second, spaceWidth);
|
|
124
|
+
let score = 0;
|
|
125
|
+
score += overflowPenalty(firstWidth, maxRowWidth) * 8;
|
|
126
|
+
score += overflowPenalty(secondWidth, maxRowWidth) * 8;
|
|
127
|
+
score += Math.abs(firstWidth - secondWidth) * 0.18;
|
|
128
|
+
score += Math.abs(first.length - second.length) * 16;
|
|
129
|
+
if (first.length === 1 || second.length === 1) {
|
|
130
|
+
score += 320;
|
|
131
|
+
} else {
|
|
132
|
+
if (first.length === 2) score += 45;
|
|
133
|
+
if (second.length === 2) score += 45;
|
|
134
|
+
}
|
|
135
|
+
const preferredWordsPerRow = Math.max(3, maxWordsPerLine);
|
|
136
|
+
if (first.length > preferredWordsPerRow + 2) {
|
|
137
|
+
score += (first.length - (preferredWordsPerRow + 2)) * 25;
|
|
138
|
+
}
|
|
139
|
+
if (second.length > preferredWordsPerRow + 2) {
|
|
140
|
+
score += (second.length - (preferredWordsPerRow + 2)) * 25;
|
|
141
|
+
}
|
|
142
|
+
const nextRowFirstWord = (_a = second[0]) == null ? void 0 : _a.word;
|
|
143
|
+
if (nextRowFirstWord) {
|
|
144
|
+
if (hintSet.has(String(nextRowFirstWord.id))) {
|
|
145
|
+
score -= 70;
|
|
146
|
+
} else if (hintSet.size > 0) {
|
|
147
|
+
score += 20;
|
|
67
148
|
}
|
|
68
149
|
}
|
|
69
|
-
|
|
70
|
-
|
|
150
|
+
const boundaryWord = (_b = first[first.length - 1]) == null ? void 0 : _b.word;
|
|
151
|
+
if (boundaryWord) {
|
|
152
|
+
score -= boundaryBonus(boundaryWord.text);
|
|
153
|
+
}
|
|
154
|
+
if (nextRowFirstWord) {
|
|
155
|
+
score -= softBreakWordBonus(nextRowFirstWord.text);
|
|
156
|
+
}
|
|
157
|
+
return score;
|
|
158
|
+
}
|
|
159
|
+
function overflowPenalty(width, maxWidth) {
|
|
160
|
+
return Math.max(0, width - maxWidth);
|
|
161
|
+
}
|
|
162
|
+
function boundaryBonus(text) {
|
|
163
|
+
const normalized = normalizeBoundaryToken(text);
|
|
164
|
+
if (!normalized) {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
if (STRONG_BOUNDARY_RE.test(normalized)) {
|
|
168
|
+
return 55;
|
|
169
|
+
}
|
|
170
|
+
if (SOFT_BOUNDARY_RE.test(normalized)) {
|
|
171
|
+
return 35;
|
|
71
172
|
}
|
|
72
|
-
return
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
function softBreakWordBonus(text) {
|
|
176
|
+
return SOFT_BREAK_WORDS.has(normalizeWord(text)) ? 18 : 0;
|
|
177
|
+
}
|
|
178
|
+
function normalizeBoundaryToken(text) {
|
|
179
|
+
return text.replace(CLOSING_PUNCTUATION_RE, "");
|
|
180
|
+
}
|
|
181
|
+
function normalizeWord(text) {
|
|
182
|
+
return normalizeBoundaryToken(text).replace(/^[^\w]+|[^\w]+$/g, "").toLowerCase();
|
|
183
|
+
}
|
|
184
|
+
function measureRowWidth(words, spaceWidth) {
|
|
185
|
+
const wordWidth = words.reduce((total, entry) => total + entry.width, 0);
|
|
186
|
+
return wordWidth + Math.max(0, words.length - 1) * spaceWidth;
|
|
187
|
+
}
|
|
188
|
+
function buildLayoutRow(ctx, words) {
|
|
189
|
+
let rowWidth = 0;
|
|
190
|
+
const measuredWords = words.map((word, index) => {
|
|
191
|
+
const text = index < words.length - 1 ? `${word.text} ` : word.text;
|
|
192
|
+
const width = ctx.measureText(text).width;
|
|
193
|
+
rowWidth += width;
|
|
194
|
+
return {
|
|
195
|
+
word,
|
|
196
|
+
text,
|
|
197
|
+
width
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
words: measuredWords,
|
|
202
|
+
width: rowWidth
|
|
203
|
+
};
|
|
73
204
|
}
|
|
74
205
|
|
|
75
206
|
// src/effects.ts
|
|
207
|
+
var PRESET_FEATURE_DEFAULTS = {
|
|
208
|
+
fire: {
|
|
209
|
+
highlightBox: true,
|
|
210
|
+
karaokeFill: true,
|
|
211
|
+
bounce: true
|
|
212
|
+
},
|
|
213
|
+
clean: {
|
|
214
|
+
highlightBox: false,
|
|
215
|
+
karaokeFill: true,
|
|
216
|
+
bounce: false
|
|
217
|
+
},
|
|
218
|
+
luxury: {
|
|
219
|
+
highlightBox: true,
|
|
220
|
+
karaokeFill: false,
|
|
221
|
+
bounce: false
|
|
222
|
+
},
|
|
223
|
+
pop: {
|
|
224
|
+
highlightBox: false,
|
|
225
|
+
karaokeFill: true,
|
|
226
|
+
bounce: true
|
|
227
|
+
},
|
|
228
|
+
ghost: {
|
|
229
|
+
highlightBox: false,
|
|
230
|
+
karaokeFill: false,
|
|
231
|
+
bounce: false
|
|
232
|
+
}
|
|
233
|
+
};
|
|
76
234
|
function clamp(value, min = 0, max = 1) {
|
|
77
235
|
return Math.min(max, Math.max(min, value));
|
|
78
236
|
}
|
|
79
|
-
function computeFadeAlpha(style, line, timeMs) {
|
|
80
|
-
let alpha = 1;
|
|
81
|
-
if (style.fadeInMs > 0) {
|
|
82
|
-
alpha = Math.min(alpha, (timeMs - line.startMs) / style.fadeInMs);
|
|
83
|
-
}
|
|
84
|
-
if (style.fadeOutMs > 0) {
|
|
85
|
-
alpha = Math.min(alpha, (line.endMs - timeMs) / style.fadeOutMs);
|
|
86
|
-
}
|
|
87
|
-
return clamp(alpha);
|
|
88
|
-
}
|
|
89
237
|
function computeWordProgress(word, timeMs) {
|
|
90
238
|
const duration = word.endMs - word.startMs;
|
|
91
239
|
if (duration <= 0) return 0;
|
|
@@ -94,78 +242,273 @@ var SubvoRenderer = (() => {
|
|
|
94
242
|
function computePulseScale(style, progress) {
|
|
95
243
|
return 1 + (style.pulse - 1) * progress * style.highlightStrength;
|
|
96
244
|
}
|
|
245
|
+
function computeImpactProgress(word, timeMs, durationMs = 150) {
|
|
246
|
+
if (durationMs <= 0) return 0;
|
|
247
|
+
return clamp((timeMs - word.startMs) / durationMs);
|
|
248
|
+
}
|
|
249
|
+
function computeImpactScale(progress) {
|
|
250
|
+
const clampedProgress = clamp(progress);
|
|
251
|
+
if (clampedProgress <= 0 || clampedProgress >= 1) {
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
if (clampedProgress < 0.3) {
|
|
255
|
+
return 1 + clampedProgress * 0.8;
|
|
256
|
+
}
|
|
257
|
+
return Math.max(1, 1.25 - (clampedProgress - 0.3) * 0.35);
|
|
258
|
+
}
|
|
259
|
+
function resolveStyleFlag(style, key) {
|
|
260
|
+
const value = style[key];
|
|
261
|
+
if (typeof value === "boolean") {
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
return PRESET_FEATURE_DEFAULTS[style.preset][key];
|
|
265
|
+
}
|
|
97
266
|
|
|
98
267
|
// src/drawText.ts
|
|
99
|
-
|
|
100
|
-
|
|
268
|
+
var BOX_PADDING_X = 12;
|
|
269
|
+
var BOX_PADDING_Y = 6;
|
|
270
|
+
var BOX_RADIUS = 8;
|
|
271
|
+
function drawWord(ctx, rawText, text, x, y, style, scaleFactor, ascent, progress = 0, emphasis = 0) {
|
|
272
|
+
const activeProgress = Math.max(0, Math.min(1, progress));
|
|
273
|
+
const emphasisBoost = emphasis > 0.6 ? Math.min(1, (emphasis - 0.6) / 0.4) : 0;
|
|
274
|
+
const effectiveScale = scaleFactor * (1 + emphasisBoost * 0.06);
|
|
275
|
+
const invScale = 1 / effectiveScale;
|
|
276
|
+
const outlineMultiplier = emphasis >= 0.6 ? 1.4 : 1;
|
|
277
|
+
const strokeWidth = style.outlineWidth * outlineMultiplier + emphasisBoost * 1.2;
|
|
278
|
+
const glowIntensity = style.glowIntensity + (emphasis >= 0.75 ? 0.4 : 0);
|
|
279
|
+
const isActive = activeProgress > 0;
|
|
280
|
+
const highlightBoxActive = resolveStyleFlag(style, "highlightBox") && isActive && emphasis >= 0.7;
|
|
281
|
+
const karaokeFillActive = resolveStyleFlag(style, "karaokeFill") && isActive && activeProgress < 1;
|
|
282
|
+
const inactiveFillColor = !resolveStyleFlag(style, "karaokeFill") && style.preset !== "ghost" && emphasis >= 0.75 ? style.highlightColor : style.baseColor;
|
|
283
|
+
const baseFillColor = highlightBoxActive ? style.baseColor : isActive && karaokeFillActive ? style.baseColor : inactiveFillColor;
|
|
284
|
+
const activeFillColor = highlightBoxActive ? style.baseColor : style.highlightColor;
|
|
285
|
+
const textBounds = measureTextBounds(ctx, rawText, ascent);
|
|
101
286
|
ctx.save();
|
|
102
287
|
ctx.translate(x, y);
|
|
103
288
|
ctx.translate(0, -ascent);
|
|
104
|
-
ctx.scale(
|
|
289
|
+
ctx.scale(effectiveScale, effectiveScale);
|
|
105
290
|
ctx.translate(0, ascent);
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
ctx
|
|
291
|
+
if (highlightBoxActive) {
|
|
292
|
+
resetShadow(ctx);
|
|
293
|
+
drawHighlightBox(ctx, textBounds, style.highlightColor);
|
|
294
|
+
}
|
|
295
|
+
resetShadow(ctx);
|
|
296
|
+
if (strokeWidth > 0) {
|
|
297
|
+
ctx.lineWidth = strokeWidth * invScale;
|
|
109
298
|
ctx.strokeStyle = style.outlineColor;
|
|
110
299
|
ctx.strokeText(text, 0, 0);
|
|
111
300
|
}
|
|
112
|
-
|
|
113
|
-
ctx
|
|
301
|
+
applyTextEffects(
|
|
302
|
+
ctx,
|
|
303
|
+
style,
|
|
304
|
+
invScale,
|
|
305
|
+
glowIntensity,
|
|
306
|
+
emphasis >= 0.75 ? style.highlightColor : style.outlineColor
|
|
307
|
+
);
|
|
308
|
+
ctx.fillStyle = isActive && !karaokeFillActive ? activeFillColor : baseFillColor;
|
|
309
|
+
ctx.fillText(text, 0, 0);
|
|
310
|
+
if (karaokeFillActive) {
|
|
311
|
+
ctx.save();
|
|
312
|
+
applyTextEffects(ctx, style, invScale, glowIntensity, style.highlightColor);
|
|
313
|
+
ctx.beginPath();
|
|
314
|
+
ctx.rect(
|
|
315
|
+
textBounds.left - 1 * invScale,
|
|
316
|
+
textBounds.top - 2 * invScale,
|
|
317
|
+
(textBounds.width + 2 * invScale) * activeProgress,
|
|
318
|
+
textBounds.height + 4 * invScale
|
|
319
|
+
);
|
|
320
|
+
ctx.clip();
|
|
321
|
+
ctx.fillStyle = style.highlightColor;
|
|
322
|
+
ctx.fillText(text, 0, 0);
|
|
323
|
+
ctx.restore();
|
|
324
|
+
}
|
|
325
|
+
ctx.restore();
|
|
326
|
+
}
|
|
327
|
+
function measureTextBounds(ctx, rawText, fallbackAscent) {
|
|
328
|
+
const metrics = ctx.measureText(rawText);
|
|
329
|
+
const left = -metrics.actualBoundingBoxLeft;
|
|
330
|
+
const width = Math.max(
|
|
331
|
+
metrics.width,
|
|
332
|
+
metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
|
333
|
+
);
|
|
334
|
+
const ascent = metrics.actualBoundingBoxAscent || fallbackAscent;
|
|
335
|
+
const descent = metrics.actualBoundingBoxDescent || Math.max(4, fallbackAscent * 0.28);
|
|
336
|
+
return {
|
|
337
|
+
left,
|
|
338
|
+
top: -ascent,
|
|
339
|
+
width,
|
|
340
|
+
height: ascent + descent
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function drawHighlightBox(ctx, textBounds, color) {
|
|
344
|
+
const boxX = textBounds.left - BOX_PADDING_X;
|
|
345
|
+
const boxY = textBounds.top - BOX_PADDING_Y;
|
|
346
|
+
const boxWidth = textBounds.width + BOX_PADDING_X * 2;
|
|
347
|
+
const boxHeight = textBounds.height + BOX_PADDING_Y * 2;
|
|
348
|
+
ctx.beginPath();
|
|
349
|
+
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, BOX_RADIUS);
|
|
350
|
+
ctx.fillStyle = color;
|
|
351
|
+
ctx.fill();
|
|
352
|
+
}
|
|
353
|
+
function applyTextEffects(ctx, style, invScale, glowIntensity, shadowColor) {
|
|
354
|
+
if (style.shadow || glowIntensity > 0) {
|
|
355
|
+
ctx.shadowColor = shadowColor;
|
|
114
356
|
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
115
357
|
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
116
|
-
ctx.shadowBlur = style.
|
|
117
|
-
|
|
118
|
-
ctx.shadowColor = "transparent";
|
|
119
|
-
ctx.shadowBlur = 0;
|
|
120
|
-
ctx.shadowOffsetX = 0;
|
|
121
|
-
ctx.shadowOffsetY = 0;
|
|
358
|
+
ctx.shadowBlur = style.fontSize * glowIntensity * invScale;
|
|
359
|
+
return;
|
|
122
360
|
}
|
|
123
|
-
ctx
|
|
124
|
-
|
|
361
|
+
resetShadow(ctx);
|
|
362
|
+
}
|
|
363
|
+
function resetShadow(ctx) {
|
|
364
|
+
ctx.shadowColor = "transparent";
|
|
365
|
+
ctx.shadowBlur = 0;
|
|
366
|
+
ctx.shadowOffsetX = 0;
|
|
367
|
+
ctx.shadowOffsetY = 0;
|
|
125
368
|
}
|
|
126
369
|
|
|
127
370
|
// src/renderFrame.ts
|
|
371
|
+
var DEFAULT_TRANSITION_MS = 150;
|
|
372
|
+
var MIN_TRANSITION_MS = 120;
|
|
373
|
+
var MAX_TRANSITION_MS = 180;
|
|
374
|
+
var TOP_UNSAFE_RATIO = 0.14;
|
|
375
|
+
var BOTTOM_UNSAFE_RATIO = 0.22;
|
|
376
|
+
var TOP_SAFE_ANCHOR_RATIO = 0.22;
|
|
377
|
+
var CENTER_ANCHOR_RATIO = 0.5;
|
|
378
|
+
var BOTTOM_SAFE_ANCHOR_RATIO = 0.7;
|
|
128
379
|
function renderFrame(ctx, renderJob, timeMs) {
|
|
380
|
+
var _a;
|
|
129
381
|
const { playRes, style } = renderJob;
|
|
130
382
|
const layout = computeLayout(ctx, renderJob);
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
383
|
+
const transitionMs = resolveTransitionMs(style);
|
|
384
|
+
const visibleLines = layout.map((entry) => ({
|
|
385
|
+
entry,
|
|
386
|
+
alpha: computeLineAlpha(entry.line, timeMs, transitionMs)
|
|
387
|
+
})).filter((entry) => entry.alpha > 0);
|
|
388
|
+
if (!visibleLines.length) return;
|
|
135
389
|
const transform = ctx.getTransform();
|
|
136
390
|
const canvasWidth = ctx.canvas.width / (transform.a || 1);
|
|
137
391
|
const canvasHeight = ctx.canvas.height / (transform.d || 1);
|
|
138
392
|
const scale = Math.max(canvasWidth / playRes.width, canvasHeight / playRes.height);
|
|
139
393
|
const offsetX = (canvasWidth - playRes.width * scale) / 2;
|
|
140
394
|
const offsetY = (canvasHeight - playRes.height * scale) / 2;
|
|
141
|
-
const alpha = computeFadeAlpha(style, active.line, timeMs);
|
|
142
395
|
ctx.save();
|
|
143
396
|
ctx.translate(offsetX, offsetY);
|
|
144
397
|
ctx.scale(scale, scale);
|
|
145
|
-
ctx.globalAlpha = alpha;
|
|
146
398
|
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
147
399
|
ctx.textBaseline = "alphabetic";
|
|
148
400
|
const metrics = ctx.measureText("Mg");
|
|
149
401
|
const ascent = metrics.actualBoundingBoxAscent;
|
|
402
|
+
const descent = metrics.actualBoundingBoxDescent || Math.max(4, style.fontSize * 0.28);
|
|
150
403
|
const centerX = playRes.width / 2;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
404
|
+
const lineStep = style.fontSize * style.lineSpacing;
|
|
405
|
+
const safeTop = playRes.height * TOP_UNSAFE_RATIO;
|
|
406
|
+
const safeBottom = playRes.height * (1 - BOTTOM_UNSAFE_RATIO);
|
|
407
|
+
const anchorY = resolveAnchorY(playRes.height, style.position) + style.offsetY;
|
|
408
|
+
for (const { entry, alpha } of visibleLines) {
|
|
409
|
+
ctx.save();
|
|
410
|
+
ctx.globalAlpha = alpha;
|
|
411
|
+
const baselineY = clampBaselineY(
|
|
412
|
+
anchorY,
|
|
413
|
+
entry.rows.length,
|
|
414
|
+
ascent,
|
|
415
|
+
descent,
|
|
416
|
+
lineStep,
|
|
417
|
+
safeTop,
|
|
418
|
+
safeBottom
|
|
419
|
+
);
|
|
420
|
+
for (const [rowIndex, row] of entry.rows.entries()) {
|
|
421
|
+
let x = centerX - row.width / 2;
|
|
422
|
+
const rowY = baselineY + rowIndex * lineStep;
|
|
423
|
+
for (const { word, text, width } of row.words) {
|
|
424
|
+
const visibleWord = isWordVisible(entry.line, word, timeMs);
|
|
425
|
+
if (visibleWord) {
|
|
426
|
+
const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
|
|
427
|
+
const emphasis = (_a = word.emphasis) != null ? _a : 0;
|
|
428
|
+
const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
|
|
429
|
+
const impactProgress = activeWord ? computeImpactProgress(word, timeMs) : 0;
|
|
430
|
+
const pulseScale = computePulseScale(style, progress);
|
|
431
|
+
const bounceScale = shouldBounceWord(style, emphasis) ? computeImpactScale(impactProgress) : 1;
|
|
432
|
+
const scaleFactor = pulseScale * bounceScale;
|
|
433
|
+
drawWord(
|
|
434
|
+
ctx,
|
|
435
|
+
word.text,
|
|
436
|
+
text,
|
|
437
|
+
x,
|
|
438
|
+
rowY,
|
|
439
|
+
style,
|
|
440
|
+
scaleFactor,
|
|
441
|
+
ascent,
|
|
442
|
+
progress,
|
|
443
|
+
emphasis
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
x += width;
|
|
447
|
+
}
|
|
161
448
|
}
|
|
162
|
-
|
|
449
|
+
ctx.restore();
|
|
163
450
|
}
|
|
164
451
|
ctx.restore();
|
|
165
452
|
}
|
|
453
|
+
function resolveTransitionMs(style) {
|
|
454
|
+
return clamp(
|
|
455
|
+
Math.max(DEFAULT_TRANSITION_MS, style.fadeInMs, style.fadeOutMs),
|
|
456
|
+
MIN_TRANSITION_MS,
|
|
457
|
+
MAX_TRANSITION_MS
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
function computeLineAlpha(line, timeMs, transitionMs) {
|
|
461
|
+
const padding = transitionMs / 2;
|
|
462
|
+
const visibleStart = line.startMs - padding;
|
|
463
|
+
const visibleEnd = line.endMs + padding;
|
|
464
|
+
if (timeMs < visibleStart || timeMs > visibleEnd) {
|
|
465
|
+
return 0;
|
|
466
|
+
}
|
|
467
|
+
const fadeIn = clamp((timeMs - visibleStart) / transitionMs);
|
|
468
|
+
const fadeOut = clamp((visibleEnd - timeMs) / transitionMs);
|
|
469
|
+
return Math.min(fadeIn, fadeOut);
|
|
470
|
+
}
|
|
471
|
+
function isWordVisible(line, word, timeMs) {
|
|
472
|
+
if (line.mode !== "progressive_build") {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
return timeMs >= word.startMs;
|
|
476
|
+
}
|
|
477
|
+
function shouldBounceWord(style, emphasis) {
|
|
478
|
+
if (style.preset === "ghost") {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
if (resolveStyleFlag(style, "bounce")) {
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
return emphasis >= 0.85;
|
|
485
|
+
}
|
|
486
|
+
function resolveAnchorY(playHeight, position) {
|
|
487
|
+
if (position === "top_safe") {
|
|
488
|
+
return playHeight * TOP_SAFE_ANCHOR_RATIO;
|
|
489
|
+
}
|
|
490
|
+
if (position === "center") {
|
|
491
|
+
return playHeight * CENTER_ANCHOR_RATIO;
|
|
492
|
+
}
|
|
493
|
+
return playHeight * BOTTOM_SAFE_ANCHOR_RATIO;
|
|
494
|
+
}
|
|
495
|
+
function clampBaselineY(baselineY, rowCount, ascent, descent, lineStep, safeTop, safeBottom) {
|
|
496
|
+
const rows = Math.max(1, rowCount);
|
|
497
|
+
const blockTop = baselineY - ascent;
|
|
498
|
+
const blockBottom = baselineY + (rows - 1) * lineStep + descent;
|
|
499
|
+
if (blockTop < safeTop) {
|
|
500
|
+
baselineY += safeTop - blockTop;
|
|
501
|
+
}
|
|
502
|
+
if (blockBottom > safeBottom) {
|
|
503
|
+
baselineY -= blockBottom - safeBottom;
|
|
504
|
+
}
|
|
505
|
+
const minBaselineY = safeTop + ascent;
|
|
506
|
+
const maxBaselineY = safeBottom - descent - (rows - 1) * lineStep;
|
|
507
|
+
return clamp(baselineY, minBaselineY, Math.max(minBaselineY, maxBaselineY));
|
|
508
|
+
}
|
|
166
509
|
|
|
167
510
|
// src/index.ts
|
|
168
|
-
var injectedVersion = "1.
|
|
511
|
+
var injectedVersion = "1.2.1".length > 0 ? "1.2.1" : "dev";
|
|
169
512
|
var VERSION = injectedVersion;
|
|
170
513
|
return __toCommonJS(src_exports);
|
|
171
514
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subvo/renderer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/renderer.esm.js",
|
|
6
6
|
"module": "./dist/renderer.esm.js",
|
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"dist"
|
|
17
17
|
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup"
|
|
20
|
+
},
|
|
18
21
|
"devDependencies": {
|
|
19
22
|
"tsup": "^8.0.0",
|
|
20
23
|
"typescript": "^5.7.0"
|
|
21
|
-
},
|
|
22
|
-
"scripts": {
|
|
23
|
-
"build": "tsup"
|
|
24
24
|
}
|
|
25
|
-
}
|
|
25
|
+
}
|