@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.
@@ -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 = "bottom_safe" | "center";
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
 
@@ -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 = chunkWords(line.words, style.maxWordsPerLine);
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 chunkWords(words, maxPerLine) {
32
- if (maxPerLine <= 0) return [words];
33
- const rows = [];
34
- let current = [];
35
- for (const word of words) {
36
- current.push(word);
37
- if (current.length >= maxPerLine) {
38
- rows.push(current);
39
- current = [];
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
- if (current.length > 0) {
43
- rows.push(current);
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 rows;
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
- function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
73
- const invScale = 1 / scaleFactor;
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(scaleFactor, scaleFactor);
262
+ ctx.scale(effectiveScale, effectiveScale);
78
263
  ctx.translate(0, ascent);
79
- ctx.fillStyle = fillColor;
80
- if (style.outlineWidth > 0) {
81
- ctx.lineWidth = style.outlineWidth * invScale;
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
- if (style.shadow || style.glowIntensity > 0) {
86
- ctx.shadowColor = style.outlineColor;
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.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
90
- } else {
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.fillText(text, 0, 0);
97
- ctx.restore();
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 active = layout.find(
105
- (line) => timeMs >= line.line.startMs && timeMs <= line.line.endMs
106
- );
107
- if (!active) return;
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
- let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
125
- for (const row of active.rows) {
126
- let x = centerX - row.width / 2;
127
- for (const { word, text, width } of row.words) {
128
- const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
129
- const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
130
- const scaleFactor = computePulseScale(style, progress);
131
- const fillColor = activeWord ? style.highlightColor : style.baseColor;
132
- drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor);
133
- x += width;
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
- y += style.fontSize * style.lineSpacing;
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.0.0".length > 0 ? "1.0.0" : "dev";
484
+ var injectedVersion = "1.2.1".length > 0 ? "1.2.1" : "dev";
142
485
  var VERSION = injectedVersion;
143
486
  export {
144
487
  VERSION,
@@ -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 = chunkWords(line.words, style.maxWordsPerLine);
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 chunkWords(words, maxPerLine) {
59
- if (maxPerLine <= 0) return [words];
60
- const rows = [];
61
- let current = [];
62
- for (const word of words) {
63
- current.push(word);
64
- if (current.length >= maxPerLine) {
65
- rows.push(current);
66
- current = [];
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
- if (current.length > 0) {
70
- rows.push(current);
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 rows;
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
- function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor) {
100
- const invScale = 1 / scaleFactor;
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(scaleFactor, scaleFactor);
289
+ ctx.scale(effectiveScale, effectiveScale);
105
290
  ctx.translate(0, ascent);
106
- ctx.fillStyle = fillColor;
107
- if (style.outlineWidth > 0) {
108
- ctx.lineWidth = style.outlineWidth * invScale;
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
- if (style.shadow || style.glowIntensity > 0) {
113
- ctx.shadowColor = style.outlineColor;
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.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
117
- } else {
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.fillText(text, 0, 0);
124
- ctx.restore();
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 active = layout.find(
132
- (line) => timeMs >= line.line.startMs && timeMs <= line.line.endMs
133
- );
134
- if (!active) return;
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
- let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
152
- for (const row of active.rows) {
153
- let x = centerX - row.width / 2;
154
- for (const { word, text, width } of row.words) {
155
- const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
156
- const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
157
- const scaleFactor = computePulseScale(style, progress);
158
- const fillColor = activeWord ? style.highlightColor : style.baseColor;
159
- drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor);
160
- x += width;
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
- y += style.fontSize * style.lineSpacing;
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.0.0".length > 0 ? "1.0.0" : "dev";
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.0.0",
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
+ }