@subvo/renderer 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/renderer.d.ts +4 -0
- package/dist/renderer.esm.js +239 -67
- package/dist/renderer.iife.js +239 -67
- 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;
|
|
@@ -36,6 +39,7 @@ type RenderMetadata = {
|
|
|
36
39
|
};
|
|
37
40
|
type RenderPosition = "bottom_safe" | "center";
|
|
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;
|
package/dist/renderer.esm.js
CHANGED
|
@@ -1,64 +1,185 @@
|
|
|
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
|
|
49
180
|
function clamp(value, min = 0, max = 1) {
|
|
50
181
|
return Math.min(max, Math.max(min, value));
|
|
51
182
|
}
|
|
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
183
|
function computeWordProgress(word, timeMs) {
|
|
63
184
|
const duration = word.endMs - word.startMs;
|
|
64
185
|
if (duration <= 0) return 0;
|
|
@@ -69,24 +190,29 @@ function computePulseScale(style, progress) {
|
|
|
69
190
|
}
|
|
70
191
|
|
|
71
192
|
// src/drawText.ts
|
|
72
|
-
function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
|
|
73
|
-
const
|
|
193
|
+
function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor, emphasis = 0) {
|
|
194
|
+
const emphasisBoost = emphasis > 0.6 ? Math.min(1, (emphasis - 0.6) / 0.4) : 0;
|
|
195
|
+
const effectiveScale = scaleFactor * (1 + emphasisBoost * 0.06);
|
|
196
|
+
const invScale = 1 / effectiveScale;
|
|
197
|
+
const strokeWidth = style.outlineWidth + emphasisBoost * 1.2;
|
|
198
|
+
const baseBlur = style.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
|
|
199
|
+
const emphasisBlur = style.fontSize * 0.12 * emphasisBoost * invScale;
|
|
74
200
|
ctx.save();
|
|
75
201
|
ctx.translate(x, y);
|
|
76
202
|
ctx.translate(0, -ascent);
|
|
77
|
-
ctx.scale(
|
|
203
|
+
ctx.scale(effectiveScale, effectiveScale);
|
|
78
204
|
ctx.translate(0, ascent);
|
|
79
205
|
ctx.fillStyle = fillColor;
|
|
80
|
-
if (
|
|
81
|
-
ctx.lineWidth =
|
|
206
|
+
if (strokeWidth > 0) {
|
|
207
|
+
ctx.lineWidth = strokeWidth * invScale;
|
|
82
208
|
ctx.strokeStyle = style.outlineColor;
|
|
83
209
|
ctx.strokeText(text, 0, 0);
|
|
84
210
|
}
|
|
85
|
-
if (style.shadow || style.glowIntensity > 0) {
|
|
86
|
-
ctx.shadowColor = style.outlineColor;
|
|
211
|
+
if (style.shadow || style.glowIntensity > 0 || emphasisBoost > 0) {
|
|
212
|
+
ctx.shadowColor = emphasisBoost > 0 ? fillColor : style.outlineColor;
|
|
87
213
|
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
88
214
|
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
89
|
-
ctx.shadowBlur =
|
|
215
|
+
ctx.shadowBlur = baseBlur + emphasisBlur;
|
|
90
216
|
} else {
|
|
91
217
|
ctx.shadowColor = "transparent";
|
|
92
218
|
ctx.shadowBlur = 0;
|
|
@@ -98,47 +224,93 @@ function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
|
|
|
98
224
|
}
|
|
99
225
|
|
|
100
226
|
// src/renderFrame.ts
|
|
227
|
+
var DEFAULT_TRANSITION_MS = 150;
|
|
228
|
+
var MIN_TRANSITION_MS = 120;
|
|
229
|
+
var MAX_TRANSITION_MS = 180;
|
|
101
230
|
function renderFrame(ctx, renderJob, timeMs) {
|
|
102
231
|
const { playRes, style } = renderJob;
|
|
103
232
|
const layout = computeLayout(ctx, renderJob);
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
233
|
+
const transitionMs = resolveTransitionMs(style);
|
|
234
|
+
const visibleLines = layout.map((entry) => ({
|
|
235
|
+
entry,
|
|
236
|
+
alpha: computeLineAlpha(entry.line, timeMs, transitionMs)
|
|
237
|
+
})).filter((entry) => entry.alpha > 0);
|
|
238
|
+
if (!visibleLines.length) return;
|
|
108
239
|
const transform = ctx.getTransform();
|
|
109
240
|
const canvasWidth = ctx.canvas.width / (transform.a || 1);
|
|
110
241
|
const canvasHeight = ctx.canvas.height / (transform.d || 1);
|
|
111
242
|
const scale = Math.max(canvasWidth / playRes.width, canvasHeight / playRes.height);
|
|
112
243
|
const offsetX = (canvasWidth - playRes.width * scale) / 2;
|
|
113
244
|
const offsetY = (canvasHeight - playRes.height * scale) / 2;
|
|
114
|
-
const alpha = computeFadeAlpha(style, active.line, timeMs);
|
|
115
245
|
ctx.save();
|
|
116
246
|
ctx.translate(offsetX, offsetY);
|
|
117
247
|
ctx.scale(scale, scale);
|
|
118
|
-
ctx.globalAlpha = alpha;
|
|
119
248
|
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
120
249
|
ctx.textBaseline = "alphabetic";
|
|
121
250
|
const metrics = ctx.measureText("Mg");
|
|
122
251
|
const ascent = metrics.actualBoundingBoxAscent;
|
|
123
252
|
const centerX = playRes.width / 2;
|
|
124
253
|
let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
|
|
125
|
-
for (const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
254
|
+
for (const { entry, alpha } of visibleLines) {
|
|
255
|
+
ctx.save();
|
|
256
|
+
ctx.globalAlpha = alpha;
|
|
257
|
+
let lineY = y;
|
|
258
|
+
for (const row of entry.rows) {
|
|
259
|
+
let x = centerX - row.width / 2;
|
|
260
|
+
for (const { word, text, width } of row.words) {
|
|
261
|
+
const visibleWord = isWordVisible(entry.line, word, timeMs);
|
|
262
|
+
if (visibleWord) {
|
|
263
|
+
const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
|
|
264
|
+
const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
|
|
265
|
+
const scaleFactor = computePulseScale(style, progress);
|
|
266
|
+
const fillColor = activeWord ? style.highlightColor : style.baseColor;
|
|
267
|
+
drawWord(
|
|
268
|
+
ctx,
|
|
269
|
+
text,
|
|
270
|
+
x,
|
|
271
|
+
lineY,
|
|
272
|
+
style,
|
|
273
|
+
scaleFactor,
|
|
274
|
+
ascent,
|
|
275
|
+
fillColor,
|
|
276
|
+
word.emphasis
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
x += width;
|
|
280
|
+
}
|
|
281
|
+
lineY += style.fontSize * style.lineSpacing;
|
|
134
282
|
}
|
|
135
|
-
|
|
283
|
+
ctx.restore();
|
|
136
284
|
}
|
|
137
285
|
ctx.restore();
|
|
138
286
|
}
|
|
287
|
+
function resolveTransitionMs(style) {
|
|
288
|
+
return clamp(
|
|
289
|
+
Math.max(DEFAULT_TRANSITION_MS, style.fadeInMs, style.fadeOutMs),
|
|
290
|
+
MIN_TRANSITION_MS,
|
|
291
|
+
MAX_TRANSITION_MS
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
function computeLineAlpha(line, timeMs, transitionMs) {
|
|
295
|
+
const padding = transitionMs / 2;
|
|
296
|
+
const visibleStart = line.startMs - padding;
|
|
297
|
+
const visibleEnd = line.endMs + padding;
|
|
298
|
+
if (timeMs < visibleStart || timeMs > visibleEnd) {
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
const fadeIn = clamp((timeMs - visibleStart) / transitionMs);
|
|
302
|
+
const fadeOut = clamp((visibleEnd - timeMs) / transitionMs);
|
|
303
|
+
return Math.min(fadeIn, fadeOut);
|
|
304
|
+
}
|
|
305
|
+
function isWordVisible(line, word, timeMs) {
|
|
306
|
+
if (line.mode !== "progressive_build") {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
return timeMs >= word.startMs;
|
|
310
|
+
}
|
|
139
311
|
|
|
140
312
|
// src/index.ts
|
|
141
|
-
var injectedVersion = "1.
|
|
313
|
+
var injectedVersion = "1.2.0".length > 0 ? "1.2.0" : "dev";
|
|
142
314
|
var VERSION = injectedVersion;
|
|
143
315
|
export {
|
|
144
316
|
VERSION,
|
package/dist/renderer.iife.js
CHANGED
|
@@ -26,66 +26,187 @@ 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
|
|
76
207
|
function clamp(value, min = 0, max = 1) {
|
|
77
208
|
return Math.min(max, Math.max(min, value));
|
|
78
209
|
}
|
|
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
210
|
function computeWordProgress(word, timeMs) {
|
|
90
211
|
const duration = word.endMs - word.startMs;
|
|
91
212
|
if (duration <= 0) return 0;
|
|
@@ -96,24 +217,29 @@ var SubvoRenderer = (() => {
|
|
|
96
217
|
}
|
|
97
218
|
|
|
98
219
|
// src/drawText.ts
|
|
99
|
-
function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
|
|
100
|
-
const
|
|
220
|
+
function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor, emphasis = 0) {
|
|
221
|
+
const emphasisBoost = emphasis > 0.6 ? Math.min(1, (emphasis - 0.6) / 0.4) : 0;
|
|
222
|
+
const effectiveScale = scaleFactor * (1 + emphasisBoost * 0.06);
|
|
223
|
+
const invScale = 1 / effectiveScale;
|
|
224
|
+
const strokeWidth = style.outlineWidth + emphasisBoost * 1.2;
|
|
225
|
+
const baseBlur = style.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
|
|
226
|
+
const emphasisBlur = style.fontSize * 0.12 * emphasisBoost * invScale;
|
|
101
227
|
ctx.save();
|
|
102
228
|
ctx.translate(x, y);
|
|
103
229
|
ctx.translate(0, -ascent);
|
|
104
|
-
ctx.scale(
|
|
230
|
+
ctx.scale(effectiveScale, effectiveScale);
|
|
105
231
|
ctx.translate(0, ascent);
|
|
106
232
|
ctx.fillStyle = fillColor;
|
|
107
|
-
if (
|
|
108
|
-
ctx.lineWidth =
|
|
233
|
+
if (strokeWidth > 0) {
|
|
234
|
+
ctx.lineWidth = strokeWidth * invScale;
|
|
109
235
|
ctx.strokeStyle = style.outlineColor;
|
|
110
236
|
ctx.strokeText(text, 0, 0);
|
|
111
237
|
}
|
|
112
|
-
if (style.shadow || style.glowIntensity > 0) {
|
|
113
|
-
ctx.shadowColor = style.outlineColor;
|
|
238
|
+
if (style.shadow || style.glowIntensity > 0 || emphasisBoost > 0) {
|
|
239
|
+
ctx.shadowColor = emphasisBoost > 0 ? fillColor : style.outlineColor;
|
|
114
240
|
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
115
241
|
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
116
|
-
ctx.shadowBlur =
|
|
242
|
+
ctx.shadowBlur = baseBlur + emphasisBlur;
|
|
117
243
|
} else {
|
|
118
244
|
ctx.shadowColor = "transparent";
|
|
119
245
|
ctx.shadowBlur = 0;
|
|
@@ -125,47 +251,93 @@ var SubvoRenderer = (() => {
|
|
|
125
251
|
}
|
|
126
252
|
|
|
127
253
|
// src/renderFrame.ts
|
|
254
|
+
var DEFAULT_TRANSITION_MS = 150;
|
|
255
|
+
var MIN_TRANSITION_MS = 120;
|
|
256
|
+
var MAX_TRANSITION_MS = 180;
|
|
128
257
|
function renderFrame(ctx, renderJob, timeMs) {
|
|
129
258
|
const { playRes, style } = renderJob;
|
|
130
259
|
const layout = computeLayout(ctx, renderJob);
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
260
|
+
const transitionMs = resolveTransitionMs(style);
|
|
261
|
+
const visibleLines = layout.map((entry) => ({
|
|
262
|
+
entry,
|
|
263
|
+
alpha: computeLineAlpha(entry.line, timeMs, transitionMs)
|
|
264
|
+
})).filter((entry) => entry.alpha > 0);
|
|
265
|
+
if (!visibleLines.length) return;
|
|
135
266
|
const transform = ctx.getTransform();
|
|
136
267
|
const canvasWidth = ctx.canvas.width / (transform.a || 1);
|
|
137
268
|
const canvasHeight = ctx.canvas.height / (transform.d || 1);
|
|
138
269
|
const scale = Math.max(canvasWidth / playRes.width, canvasHeight / playRes.height);
|
|
139
270
|
const offsetX = (canvasWidth - playRes.width * scale) / 2;
|
|
140
271
|
const offsetY = (canvasHeight - playRes.height * scale) / 2;
|
|
141
|
-
const alpha = computeFadeAlpha(style, active.line, timeMs);
|
|
142
272
|
ctx.save();
|
|
143
273
|
ctx.translate(offsetX, offsetY);
|
|
144
274
|
ctx.scale(scale, scale);
|
|
145
|
-
ctx.globalAlpha = alpha;
|
|
146
275
|
ctx.font = `${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
|
147
276
|
ctx.textBaseline = "alphabetic";
|
|
148
277
|
const metrics = ctx.measureText("Mg");
|
|
149
278
|
const ascent = metrics.actualBoundingBoxAscent;
|
|
150
279
|
const centerX = playRes.width / 2;
|
|
151
280
|
let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
|
|
152
|
-
for (const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
281
|
+
for (const { entry, alpha } of visibleLines) {
|
|
282
|
+
ctx.save();
|
|
283
|
+
ctx.globalAlpha = alpha;
|
|
284
|
+
let lineY = y;
|
|
285
|
+
for (const row of entry.rows) {
|
|
286
|
+
let x = centerX - row.width / 2;
|
|
287
|
+
for (const { word, text, width } of row.words) {
|
|
288
|
+
const visibleWord = isWordVisible(entry.line, word, timeMs);
|
|
289
|
+
if (visibleWord) {
|
|
290
|
+
const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
|
|
291
|
+
const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
|
|
292
|
+
const scaleFactor = computePulseScale(style, progress);
|
|
293
|
+
const fillColor = activeWord ? style.highlightColor : style.baseColor;
|
|
294
|
+
drawWord(
|
|
295
|
+
ctx,
|
|
296
|
+
text,
|
|
297
|
+
x,
|
|
298
|
+
lineY,
|
|
299
|
+
style,
|
|
300
|
+
scaleFactor,
|
|
301
|
+
ascent,
|
|
302
|
+
fillColor,
|
|
303
|
+
word.emphasis
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
x += width;
|
|
307
|
+
}
|
|
308
|
+
lineY += style.fontSize * style.lineSpacing;
|
|
161
309
|
}
|
|
162
|
-
|
|
310
|
+
ctx.restore();
|
|
163
311
|
}
|
|
164
312
|
ctx.restore();
|
|
165
313
|
}
|
|
314
|
+
function resolveTransitionMs(style) {
|
|
315
|
+
return clamp(
|
|
316
|
+
Math.max(DEFAULT_TRANSITION_MS, style.fadeInMs, style.fadeOutMs),
|
|
317
|
+
MIN_TRANSITION_MS,
|
|
318
|
+
MAX_TRANSITION_MS
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
function computeLineAlpha(line, timeMs, transitionMs) {
|
|
322
|
+
const padding = transitionMs / 2;
|
|
323
|
+
const visibleStart = line.startMs - padding;
|
|
324
|
+
const visibleEnd = line.endMs + padding;
|
|
325
|
+
if (timeMs < visibleStart || timeMs > visibleEnd) {
|
|
326
|
+
return 0;
|
|
327
|
+
}
|
|
328
|
+
const fadeIn = clamp((timeMs - visibleStart) / transitionMs);
|
|
329
|
+
const fadeOut = clamp((visibleEnd - timeMs) / transitionMs);
|
|
330
|
+
return Math.min(fadeIn, fadeOut);
|
|
331
|
+
}
|
|
332
|
+
function isWordVisible(line, word, timeMs) {
|
|
333
|
+
if (line.mode !== "progressive_build") {
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
return timeMs >= word.startMs;
|
|
337
|
+
}
|
|
166
338
|
|
|
167
339
|
// src/index.ts
|
|
168
|
-
var injectedVersion = "1.
|
|
340
|
+
var injectedVersion = "1.2.0".length > 0 ? "1.2.0" : "dev";
|
|
169
341
|
var VERSION = injectedVersion;
|
|
170
342
|
return __toCommonJS(src_exports);
|
|
171
343
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@subvo/renderer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
+
}
|