@subvo/renderer 1.2.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.
@@ -37,7 +37,7 @@ type RenderMetadata = {
37
37
  createdAt?: string;
38
38
  tags?: string[];
39
39
  };
40
- type RenderPosition = "bottom_safe" | "center";
40
+ type RenderPosition = "top_safe" | "center" | "bottom_safe";
41
41
  type RenderPreset = "fire" | "clean" | "luxury" | "pop" | "ghost";
42
42
  type RenderMode = "phrase_highlight" | "progressive_build";
43
43
  type RenderStyle = {
@@ -60,6 +60,9 @@ type RenderStyle = {
60
60
  offsetY: number;
61
61
  fadeInMs: number;
62
62
  fadeOutMs: number;
63
+ highlightBox?: boolean;
64
+ karaokeFill?: boolean;
65
+ bounce?: boolean;
63
66
  preset: RenderPreset;
64
67
  };
65
68
 
@@ -177,6 +177,33 @@ function buildLayoutRow(ctx, words) {
177
177
  }
178
178
 
179
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
+ };
180
207
  function clamp(value, min = 0, max = 1) {
181
208
  return Math.min(max, Math.max(min, value));
182
209
  }
@@ -188,46 +215,142 @@ function computeWordProgress(word, timeMs) {
188
215
  function computePulseScale(style, progress) {
189
216
  return 1 + (style.pulse - 1) * progress * style.highlightStrength;
190
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
+ }
191
239
 
192
240
  // src/drawText.ts
193
- function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor, emphasis = 0) {
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));
194
246
  const emphasisBoost = emphasis > 0.6 ? Math.min(1, (emphasis - 0.6) / 0.4) : 0;
195
247
  const effectiveScale = scaleFactor * (1 + emphasisBoost * 0.06);
196
248
  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;
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);
200
259
  ctx.save();
201
260
  ctx.translate(x, y);
202
261
  ctx.translate(0, -ascent);
203
262
  ctx.scale(effectiveScale, effectiveScale);
204
263
  ctx.translate(0, ascent);
205
- ctx.fillStyle = fillColor;
264
+ if (highlightBoxActive) {
265
+ resetShadow(ctx);
266
+ drawHighlightBox(ctx, textBounds, style.highlightColor);
267
+ }
268
+ resetShadow(ctx);
206
269
  if (strokeWidth > 0) {
207
270
  ctx.lineWidth = strokeWidth * invScale;
208
271
  ctx.strokeStyle = style.outlineColor;
209
272
  ctx.strokeText(text, 0, 0);
210
273
  }
211
- if (style.shadow || style.glowIntensity > 0 || emphasisBoost > 0) {
212
- ctx.shadowColor = emphasisBoost > 0 ? fillColor : 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;
213
329
  ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
214
330
  ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
215
- ctx.shadowBlur = baseBlur + emphasisBlur;
216
- } else {
217
- ctx.shadowColor = "transparent";
218
- ctx.shadowBlur = 0;
219
- ctx.shadowOffsetX = 0;
220
- ctx.shadowOffsetY = 0;
331
+ ctx.shadowBlur = style.fontSize * glowIntensity * invScale;
332
+ return;
221
333
  }
222
- ctx.fillText(text, 0, 0);
223
- 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;
224
341
  }
225
342
 
226
343
  // src/renderFrame.ts
227
344
  var DEFAULT_TRANSITION_MS = 150;
228
345
  var MIN_TRANSITION_MS = 120;
229
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;
230
352
  function renderFrame(ctx, renderJob, timeMs) {
353
+ var _a;
231
354
  const { playRes, style } = renderJob;
232
355
  const layout = computeLayout(ctx, renderJob);
233
356
  const transitionMs = resolveTransitionMs(style);
@@ -249,36 +372,52 @@ function renderFrame(ctx, renderJob, timeMs) {
249
372
  ctx.textBaseline = "alphabetic";
250
373
  const metrics = ctx.measureText("Mg");
251
374
  const ascent = metrics.actualBoundingBoxAscent;
375
+ const descent = metrics.actualBoundingBoxDescent || Math.max(4, style.fontSize * 0.28);
252
376
  const centerX = playRes.width / 2;
253
- let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
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;
254
381
  for (const { entry, alpha } of visibleLines) {
255
382
  ctx.save();
256
383
  ctx.globalAlpha = alpha;
257
- let lineY = y;
258
- for (const row of entry.rows) {
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()) {
259
394
  let x = centerX - row.width / 2;
395
+ const rowY = baselineY + rowIndex * lineStep;
260
396
  for (const { word, text, width } of row.words) {
261
397
  const visibleWord = isWordVisible(entry.line, word, timeMs);
262
398
  if (visibleWord) {
263
399
  const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
400
+ const emphasis = (_a = word.emphasis) != null ? _a : 0;
264
401
  const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
265
- const scaleFactor = computePulseScale(style, progress);
266
- const fillColor = activeWord ? style.highlightColor : style.baseColor;
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;
267
406
  drawWord(
268
407
  ctx,
408
+ word.text,
269
409
  text,
270
410
  x,
271
- lineY,
411
+ rowY,
272
412
  style,
273
413
  scaleFactor,
274
414
  ascent,
275
- fillColor,
276
- word.emphasis
415
+ progress,
416
+ emphasis
277
417
  );
278
418
  }
279
419
  x += width;
280
420
  }
281
- lineY += style.fontSize * style.lineSpacing;
282
421
  }
283
422
  ctx.restore();
284
423
  }
@@ -308,9 +447,41 @@ function isWordVisible(line, word, timeMs) {
308
447
  }
309
448
  return timeMs >= word.startMs;
310
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
+ }
311
482
 
312
483
  // src/index.ts
313
- var injectedVersion = "1.2.0".length > 0 ? "1.2.0" : "dev";
484
+ var injectedVersion = "1.2.1".length > 0 ? "1.2.1" : "dev";
314
485
  var VERSION = injectedVersion;
315
486
  export {
316
487
  VERSION,
@@ -204,6 +204,33 @@ var SubvoRenderer = (() => {
204
204
  }
205
205
 
206
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
+ };
207
234
  function clamp(value, min = 0, max = 1) {
208
235
  return Math.min(max, Math.max(min, value));
209
236
  }
@@ -215,46 +242,142 @@ var SubvoRenderer = (() => {
215
242
  function computePulseScale(style, progress) {
216
243
  return 1 + (style.pulse - 1) * progress * style.highlightStrength;
217
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
+ }
218
266
 
219
267
  // src/drawText.ts
220
- function drawWord(ctx, text, x, y, style, scaleFactor, ascent, fillColor, emphasis = 0) {
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));
221
273
  const emphasisBoost = emphasis > 0.6 ? Math.min(1, (emphasis - 0.6) / 0.4) : 0;
222
274
  const effectiveScale = scaleFactor * (1 + emphasisBoost * 0.06);
223
275
  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;
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);
227
286
  ctx.save();
228
287
  ctx.translate(x, y);
229
288
  ctx.translate(0, -ascent);
230
289
  ctx.scale(effectiveScale, effectiveScale);
231
290
  ctx.translate(0, ascent);
232
- ctx.fillStyle = fillColor;
291
+ if (highlightBoxActive) {
292
+ resetShadow(ctx);
293
+ drawHighlightBox(ctx, textBounds, style.highlightColor);
294
+ }
295
+ resetShadow(ctx);
233
296
  if (strokeWidth > 0) {
234
297
  ctx.lineWidth = strokeWidth * invScale;
235
298
  ctx.strokeStyle = style.outlineColor;
236
299
  ctx.strokeText(text, 0, 0);
237
300
  }
238
- if (style.shadow || style.glowIntensity > 0 || emphasisBoost > 0) {
239
- ctx.shadowColor = emphasisBoost > 0 ? fillColor : 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;
240
356
  ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
241
357
  ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
242
- ctx.shadowBlur = baseBlur + emphasisBlur;
243
- } else {
244
- ctx.shadowColor = "transparent";
245
- ctx.shadowBlur = 0;
246
- ctx.shadowOffsetX = 0;
247
- ctx.shadowOffsetY = 0;
358
+ ctx.shadowBlur = style.fontSize * glowIntensity * invScale;
359
+ return;
248
360
  }
249
- ctx.fillText(text, 0, 0);
250
- 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;
251
368
  }
252
369
 
253
370
  // src/renderFrame.ts
254
371
  var DEFAULT_TRANSITION_MS = 150;
255
372
  var MIN_TRANSITION_MS = 120;
256
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;
257
379
  function renderFrame(ctx, renderJob, timeMs) {
380
+ var _a;
258
381
  const { playRes, style } = renderJob;
259
382
  const layout = computeLayout(ctx, renderJob);
260
383
  const transitionMs = resolveTransitionMs(style);
@@ -276,36 +399,52 @@ var SubvoRenderer = (() => {
276
399
  ctx.textBaseline = "alphabetic";
277
400
  const metrics = ctx.measureText("Mg");
278
401
  const ascent = metrics.actualBoundingBoxAscent;
402
+ const descent = metrics.actualBoundingBoxDescent || Math.max(4, style.fontSize * 0.28);
279
403
  const centerX = playRes.width / 2;
280
- let y = style.position === "center" ? playRes.height / 2 + style.offsetY : playRes.height * 0.92 + style.offsetY;
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;
281
408
  for (const { entry, alpha } of visibleLines) {
282
409
  ctx.save();
283
410
  ctx.globalAlpha = alpha;
284
- let lineY = y;
285
- for (const row of entry.rows) {
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()) {
286
421
  let x = centerX - row.width / 2;
422
+ const rowY = baselineY + rowIndex * lineStep;
287
423
  for (const { word, text, width } of row.words) {
288
424
  const visibleWord = isWordVisible(entry.line, word, timeMs);
289
425
  if (visibleWord) {
290
426
  const activeWord = timeMs >= word.startMs && timeMs <= word.endMs;
427
+ const emphasis = (_a = word.emphasis) != null ? _a : 0;
291
428
  const progress = activeWord ? computeWordProgress(word, timeMs) : 0;
292
- const scaleFactor = computePulseScale(style, progress);
293
- const fillColor = activeWord ? style.highlightColor : style.baseColor;
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;
294
433
  drawWord(
295
434
  ctx,
435
+ word.text,
296
436
  text,
297
437
  x,
298
- lineY,
438
+ rowY,
299
439
  style,
300
440
  scaleFactor,
301
441
  ascent,
302
- fillColor,
303
- word.emphasis
442
+ progress,
443
+ emphasis
304
444
  );
305
445
  }
306
446
  x += width;
307
447
  }
308
- lineY += style.fontSize * style.lineSpacing;
309
448
  }
310
449
  ctx.restore();
311
450
  }
@@ -335,9 +474,41 @@ var SubvoRenderer = (() => {
335
474
  }
336
475
  return timeMs >= word.startMs;
337
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
+ }
338
509
 
339
510
  // src/index.ts
340
- var injectedVersion = "1.2.0".length > 0 ? "1.2.0" : "dev";
511
+ var injectedVersion = "1.2.1".length > 0 ? "1.2.1" : "dev";
341
512
  var VERSION = injectedVersion;
342
513
  return __toCommonJS(src_exports);
343
514
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@subvo/renderer",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "main": "./dist/renderer.esm.js",
6
6
  "module": "./dist/renderer.esm.js",