@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.
@@ -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;
@@ -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 = 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
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 invScale = 1 / scaleFactor;
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(scaleFactor, scaleFactor);
203
+ ctx.scale(effectiveScale, effectiveScale);
78
204
  ctx.translate(0, ascent);
79
205
  ctx.fillStyle = fillColor;
80
- if (style.outlineWidth > 0) {
81
- ctx.lineWidth = style.outlineWidth * invScale;
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 = style.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
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 active = layout.find(
105
- (line) => timeMs >= line.line.startMs && timeMs <= line.line.endMs
106
- );
107
- if (!active) return;
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 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;
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
- y += style.fontSize * style.lineSpacing;
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.0.0".length > 0 ? "1.0.0" : "dev";
313
+ var injectedVersion = "1.2.0".length > 0 ? "1.2.0" : "dev";
142
314
  var VERSION = injectedVersion;
143
315
  export {
144
316
  VERSION,
@@ -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 = 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
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 invScale = 1 / scaleFactor;
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(scaleFactor, scaleFactor);
230
+ ctx.scale(effectiveScale, effectiveScale);
105
231
  ctx.translate(0, ascent);
106
232
  ctx.fillStyle = fillColor;
107
- if (style.outlineWidth > 0) {
108
- ctx.lineWidth = style.outlineWidth * invScale;
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 = style.glowIntensity > 0 ? style.fontSize * 0.3 * invScale : 0;
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 active = layout.find(
132
- (line) => timeMs >= line.line.startMs && timeMs <= line.line.endMs
133
- );
134
- if (!active) return;
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 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;
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
- y += style.fontSize * style.lineSpacing;
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.0.0".length > 0 ? "1.0.0" : "dev";
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.0.0",
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
+ }