@subvo/renderer 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/renderer.d.ts +4 -1
- package/dist/renderer.esm.js +198 -28
- package/dist/renderer.iife.js +198 -28
- package/package.json +1 -1
package/dist/renderer.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ type RenderMetadata = {
|
|
|
37
37
|
createdAt?: string;
|
|
38
38
|
tags?: string[];
|
|
39
39
|
};
|
|
40
|
-
type RenderPosition = "
|
|
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
|
|
package/dist/renderer.esm.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
198
|
-
const
|
|
199
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
ctx
|
|
274
|
+
applyTextEffects(
|
|
275
|
+
ctx,
|
|
276
|
+
style,
|
|
277
|
+
invScale,
|
|
278
|
+
glowIntensity,
|
|
279
|
+
emphasis >= 0.75 ? style.highlightColor : style.outlineColor
|
|
280
|
+
);
|
|
281
|
+
ctx.fillStyle = isActive && !karaokeFillActive ? activeFillColor : baseFillColor;
|
|
282
|
+
ctx.fillText(text, 0, 0);
|
|
283
|
+
if (karaokeFillActive) {
|
|
284
|
+
ctx.save();
|
|
285
|
+
applyTextEffects(ctx, style, invScale, glowIntensity, style.highlightColor);
|
|
286
|
+
ctx.beginPath();
|
|
287
|
+
ctx.rect(
|
|
288
|
+
textBounds.left - 1 * invScale,
|
|
289
|
+
textBounds.top - 2 * invScale,
|
|
290
|
+
(textBounds.width + 2 * invScale) * activeProgress,
|
|
291
|
+
textBounds.height + 4 * invScale
|
|
292
|
+
);
|
|
293
|
+
ctx.clip();
|
|
294
|
+
ctx.fillStyle = style.highlightColor;
|
|
295
|
+
ctx.fillText(text, 0, 0);
|
|
296
|
+
ctx.restore();
|
|
297
|
+
}
|
|
298
|
+
ctx.restore();
|
|
299
|
+
}
|
|
300
|
+
function measureTextBounds(ctx, rawText, fallbackAscent) {
|
|
301
|
+
const metrics = ctx.measureText(rawText);
|
|
302
|
+
const left = -metrics.actualBoundingBoxLeft;
|
|
303
|
+
const width = Math.max(
|
|
304
|
+
metrics.width,
|
|
305
|
+
metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
|
306
|
+
);
|
|
307
|
+
const ascent = metrics.actualBoundingBoxAscent || fallbackAscent;
|
|
308
|
+
const descent = metrics.actualBoundingBoxDescent || Math.max(4, fallbackAscent * 0.28);
|
|
309
|
+
return {
|
|
310
|
+
left,
|
|
311
|
+
top: -ascent,
|
|
312
|
+
width,
|
|
313
|
+
height: ascent + descent
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function drawHighlightBox(ctx, textBounds, color) {
|
|
317
|
+
const boxX = textBounds.left - BOX_PADDING_X;
|
|
318
|
+
const boxY = textBounds.top - BOX_PADDING_Y;
|
|
319
|
+
const boxWidth = textBounds.width + BOX_PADDING_X * 2;
|
|
320
|
+
const boxHeight = textBounds.height + BOX_PADDING_Y * 2;
|
|
321
|
+
ctx.beginPath();
|
|
322
|
+
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, BOX_RADIUS);
|
|
323
|
+
ctx.fillStyle = color;
|
|
324
|
+
ctx.fill();
|
|
325
|
+
}
|
|
326
|
+
function applyTextEffects(ctx, style, invScale, glowIntensity, shadowColor) {
|
|
327
|
+
if (style.shadow || glowIntensity > 0) {
|
|
328
|
+
ctx.shadowColor = shadowColor;
|
|
213
329
|
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
214
330
|
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
215
|
-
ctx.shadowBlur =
|
|
216
|
-
|
|
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
|
|
223
|
-
|
|
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.24;
|
|
349
|
+
var TOP_SAFE_ANCHOR_RATIO = 0.22;
|
|
350
|
+
var CENTER_ANCHOR_RATIO = 0.5;
|
|
351
|
+
var BOTTOM_SAFE_ANCHOR_RATIO = 0.68;
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
266
|
-
const
|
|
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
|
-
|
|
411
|
+
rowY,
|
|
272
412
|
style,
|
|
273
413
|
scaleFactor,
|
|
274
414
|
ascent,
|
|
275
|
-
|
|
276
|
-
|
|
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
|
}
|
|
@@ -292,9 +431,8 @@ function resolveTransitionMs(style) {
|
|
|
292
431
|
);
|
|
293
432
|
}
|
|
294
433
|
function computeLineAlpha(line, timeMs, transitionMs) {
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
const visibleEnd = line.endMs + padding;
|
|
434
|
+
const visibleStart = line.startMs;
|
|
435
|
+
const visibleEnd = line.endMs;
|
|
298
436
|
if (timeMs < visibleStart || timeMs > visibleEnd) {
|
|
299
437
|
return 0;
|
|
300
438
|
}
|
|
@@ -308,9 +446,41 @@ function isWordVisible(line, word, timeMs) {
|
|
|
308
446
|
}
|
|
309
447
|
return timeMs >= word.startMs;
|
|
310
448
|
}
|
|
449
|
+
function shouldBounceWord(style, emphasis) {
|
|
450
|
+
if (style.preset === "ghost") {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
if (resolveStyleFlag(style, "bounce")) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
return emphasis >= 0.85;
|
|
457
|
+
}
|
|
458
|
+
function resolveAnchorY(playHeight, position) {
|
|
459
|
+
if (position === "top_safe") {
|
|
460
|
+
return playHeight * TOP_SAFE_ANCHOR_RATIO;
|
|
461
|
+
}
|
|
462
|
+
if (position === "center") {
|
|
463
|
+
return playHeight * CENTER_ANCHOR_RATIO;
|
|
464
|
+
}
|
|
465
|
+
return playHeight * BOTTOM_SAFE_ANCHOR_RATIO;
|
|
466
|
+
}
|
|
467
|
+
function clampBaselineY(baselineY, rowCount, ascent, descent, lineStep, safeTop, safeBottom) {
|
|
468
|
+
const rows = Math.max(1, rowCount);
|
|
469
|
+
const blockTop = baselineY - ascent;
|
|
470
|
+
const blockBottom = baselineY + (rows - 1) * lineStep + descent;
|
|
471
|
+
if (blockTop < safeTop) {
|
|
472
|
+
baselineY += safeTop - blockTop;
|
|
473
|
+
}
|
|
474
|
+
if (blockBottom > safeBottom) {
|
|
475
|
+
baselineY -= blockBottom - safeBottom;
|
|
476
|
+
}
|
|
477
|
+
const minBaselineY = safeTop + ascent;
|
|
478
|
+
const maxBaselineY = safeBottom - descent - (rows - 1) * lineStep;
|
|
479
|
+
return clamp(baselineY, minBaselineY, Math.max(minBaselineY, maxBaselineY));
|
|
480
|
+
}
|
|
311
481
|
|
|
312
482
|
// src/index.ts
|
|
313
|
-
var injectedVersion = "1.2.
|
|
483
|
+
var injectedVersion = "1.2.2".length > 0 ? "1.2.2" : "dev";
|
|
314
484
|
var VERSION = injectedVersion;
|
|
315
485
|
export {
|
|
316
486
|
VERSION,
|
package/dist/renderer.iife.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
225
|
-
const
|
|
226
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
ctx
|
|
301
|
+
applyTextEffects(
|
|
302
|
+
ctx,
|
|
303
|
+
style,
|
|
304
|
+
invScale,
|
|
305
|
+
glowIntensity,
|
|
306
|
+
emphasis >= 0.75 ? style.highlightColor : style.outlineColor
|
|
307
|
+
);
|
|
308
|
+
ctx.fillStyle = isActive && !karaokeFillActive ? activeFillColor : baseFillColor;
|
|
309
|
+
ctx.fillText(text, 0, 0);
|
|
310
|
+
if (karaokeFillActive) {
|
|
311
|
+
ctx.save();
|
|
312
|
+
applyTextEffects(ctx, style, invScale, glowIntensity, style.highlightColor);
|
|
313
|
+
ctx.beginPath();
|
|
314
|
+
ctx.rect(
|
|
315
|
+
textBounds.left - 1 * invScale,
|
|
316
|
+
textBounds.top - 2 * invScale,
|
|
317
|
+
(textBounds.width + 2 * invScale) * activeProgress,
|
|
318
|
+
textBounds.height + 4 * invScale
|
|
319
|
+
);
|
|
320
|
+
ctx.clip();
|
|
321
|
+
ctx.fillStyle = style.highlightColor;
|
|
322
|
+
ctx.fillText(text, 0, 0);
|
|
323
|
+
ctx.restore();
|
|
324
|
+
}
|
|
325
|
+
ctx.restore();
|
|
326
|
+
}
|
|
327
|
+
function measureTextBounds(ctx, rawText, fallbackAscent) {
|
|
328
|
+
const metrics = ctx.measureText(rawText);
|
|
329
|
+
const left = -metrics.actualBoundingBoxLeft;
|
|
330
|
+
const width = Math.max(
|
|
331
|
+
metrics.width,
|
|
332
|
+
metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight
|
|
333
|
+
);
|
|
334
|
+
const ascent = metrics.actualBoundingBoxAscent || fallbackAscent;
|
|
335
|
+
const descent = metrics.actualBoundingBoxDescent || Math.max(4, fallbackAscent * 0.28);
|
|
336
|
+
return {
|
|
337
|
+
left,
|
|
338
|
+
top: -ascent,
|
|
339
|
+
width,
|
|
340
|
+
height: ascent + descent
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function drawHighlightBox(ctx, textBounds, color) {
|
|
344
|
+
const boxX = textBounds.left - BOX_PADDING_X;
|
|
345
|
+
const boxY = textBounds.top - BOX_PADDING_Y;
|
|
346
|
+
const boxWidth = textBounds.width + BOX_PADDING_X * 2;
|
|
347
|
+
const boxHeight = textBounds.height + BOX_PADDING_Y * 2;
|
|
348
|
+
ctx.beginPath();
|
|
349
|
+
ctx.roundRect(boxX, boxY, boxWidth, boxHeight, BOX_RADIUS);
|
|
350
|
+
ctx.fillStyle = color;
|
|
351
|
+
ctx.fill();
|
|
352
|
+
}
|
|
353
|
+
function applyTextEffects(ctx, style, invScale, glowIntensity, shadowColor) {
|
|
354
|
+
if (style.shadow || glowIntensity > 0) {
|
|
355
|
+
ctx.shadowColor = shadowColor;
|
|
240
356
|
ctx.shadowOffsetX = style.shadow ? style.shadowDepth * invScale : 0;
|
|
241
357
|
ctx.shadowOffsetY = style.shadow ? style.shadowDepth * invScale : 0;
|
|
242
|
-
ctx.shadowBlur =
|
|
243
|
-
|
|
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
|
|
250
|
-
|
|
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.24;
|
|
376
|
+
var TOP_SAFE_ANCHOR_RATIO = 0.22;
|
|
377
|
+
var CENTER_ANCHOR_RATIO = 0.5;
|
|
378
|
+
var BOTTOM_SAFE_ANCHOR_RATIO = 0.68;
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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
|
|
293
|
-
const
|
|
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
|
-
|
|
438
|
+
rowY,
|
|
299
439
|
style,
|
|
300
440
|
scaleFactor,
|
|
301
441
|
ascent,
|
|
302
|
-
|
|
303
|
-
|
|
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
|
}
|
|
@@ -319,9 +458,8 @@ var SubvoRenderer = (() => {
|
|
|
319
458
|
);
|
|
320
459
|
}
|
|
321
460
|
function computeLineAlpha(line, timeMs, transitionMs) {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
const visibleEnd = line.endMs + padding;
|
|
461
|
+
const visibleStart = line.startMs;
|
|
462
|
+
const visibleEnd = line.endMs;
|
|
325
463
|
if (timeMs < visibleStart || timeMs > visibleEnd) {
|
|
326
464
|
return 0;
|
|
327
465
|
}
|
|
@@ -335,9 +473,41 @@ var SubvoRenderer = (() => {
|
|
|
335
473
|
}
|
|
336
474
|
return timeMs >= word.startMs;
|
|
337
475
|
}
|
|
476
|
+
function shouldBounceWord(style, emphasis) {
|
|
477
|
+
if (style.preset === "ghost") {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
if (resolveStyleFlag(style, "bounce")) {
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
return emphasis >= 0.85;
|
|
484
|
+
}
|
|
485
|
+
function resolveAnchorY(playHeight, position) {
|
|
486
|
+
if (position === "top_safe") {
|
|
487
|
+
return playHeight * TOP_SAFE_ANCHOR_RATIO;
|
|
488
|
+
}
|
|
489
|
+
if (position === "center") {
|
|
490
|
+
return playHeight * CENTER_ANCHOR_RATIO;
|
|
491
|
+
}
|
|
492
|
+
return playHeight * BOTTOM_SAFE_ANCHOR_RATIO;
|
|
493
|
+
}
|
|
494
|
+
function clampBaselineY(baselineY, rowCount, ascent, descent, lineStep, safeTop, safeBottom) {
|
|
495
|
+
const rows = Math.max(1, rowCount);
|
|
496
|
+
const blockTop = baselineY - ascent;
|
|
497
|
+
const blockBottom = baselineY + (rows - 1) * lineStep + descent;
|
|
498
|
+
if (blockTop < safeTop) {
|
|
499
|
+
baselineY += safeTop - blockTop;
|
|
500
|
+
}
|
|
501
|
+
if (blockBottom > safeBottom) {
|
|
502
|
+
baselineY -= blockBottom - safeBottom;
|
|
503
|
+
}
|
|
504
|
+
const minBaselineY = safeTop + ascent;
|
|
505
|
+
const maxBaselineY = safeBottom - descent - (rows - 1) * lineStep;
|
|
506
|
+
return clamp(baselineY, minBaselineY, Math.max(minBaselineY, maxBaselineY));
|
|
507
|
+
}
|
|
338
508
|
|
|
339
509
|
// src/index.ts
|
|
340
|
-
var injectedVersion = "1.2.
|
|
510
|
+
var injectedVersion = "1.2.2".length > 0 ? "1.2.2" : "dev";
|
|
341
511
|
var VERSION = injectedVersion;
|
|
342
512
|
return __toCommonJS(src_exports);
|
|
343
513
|
})();
|