@braccato/core 1.16.4 → 1.16.6

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/engine.d.ts CHANGED
@@ -272,6 +272,11 @@ export interface LetterMaskSweep {
272
272
  easing: string;
273
273
  }
274
274
  export declare function planLetterMaskSweep(swipe: SwipeRamp, letterCount: number, swipeDurationMs: number, rtl: boolean): LetterMaskSweep[];
275
+ export interface LetterWaveTiming {
276
+ delayMs: number;
277
+ durationMs: number;
278
+ }
279
+ export declare function planLetterWave(swipe: SwipeRamp, letterCount: number, wordDurationMs: number, swipeDurationMs: number, swipeLeadMs: number, floatDurationMs: number): LetterWaveTiming[];
275
280
  export declare function clearStyleCaches(engine: AnimationEngineInstance): void;
276
281
  export declare function parseColorAlpha(value: string): number | null;
277
282
  interface PreparedLineScroll {
package/dist/engine.js CHANGED
@@ -436,7 +436,7 @@ function mirrorImageHighlightAnimations(part, animations) {
436
436
  mirror.currentTime = animation.currentTime;
437
437
  const timing = animationTimingTracks.get(animation);
438
438
  if (timing)
439
- animationTimingTracks.set(mirror, timing);
439
+ animationTimingTracks.set(mirror, { ...timing, skipDriftSample: true });
440
440
  mirrors.push(mirror);
441
441
  }
442
442
  return mirrors;
@@ -529,8 +529,11 @@ function timingValueToMs(value) {
529
529
  function animationCurrentTimeMs(animation) {
530
530
  return timingValueToMs(animation.currentTime);
531
531
  }
532
- function animationActiveDurationMs(animation) {
533
- return timingValueToMs(animation.effect?.getComputedTiming().activeDuration);
532
+ // A finished animation's currentTime stops at its end time, which counts the delay, so that is where
533
+ // the expected time has to stop too. Clamping to the active duration alone saturates a delayed
534
+ // animation as soon as it is created and hides any drift in it.
535
+ function animationEndTimeMs(animation) {
536
+ return timingValueToMs(animation.effect?.getComputedTiming().endTime);
534
537
  }
535
538
  function wrappedTimingOffsetMs(actualTimeMs, expectedTimeMs, wrapDurationMs) {
536
539
  return positiveModulo(actualTimeMs - expectedTimeMs + wrapDurationMs / 2, wrapDurationMs) - wrapDurationMs / 2;
@@ -542,18 +545,18 @@ function normalizeAnimationTimeMs(animation, timeMs, timing) {
542
545
  if (timing.wrapDurationMs && timing.wrapDurationMs > 0) {
543
546
  return positiveModulo(timeMs, timing.wrapDurationMs);
544
547
  }
545
- const activeDurationMs = animationActiveDurationMs(animation);
546
- if (activeDurationMs !== null && Number.isFinite(activeDurationMs)) {
547
- return Math.min(timeMs, activeDurationMs);
548
+ const endTimeMs = animationEndTimeMs(animation);
549
+ if (endTimeMs !== null && Number.isFinite(endTimeMs)) {
550
+ return Math.min(timeMs, endTimeMs);
548
551
  }
549
552
  return timeMs;
550
553
  }
551
554
  function animationTimingSample(part, animation, currentTime) {
552
- if (animation.playState === "idle") {
555
+ const timing = animationTimingTracks.get(animation);
556
+ if (!timing || timing.skipDriftSample) {
553
557
  return null;
554
558
  }
555
- const timing = animationTimingTracks.get(animation);
556
- if (!timing) {
559
+ if (animation.playState === "idle") {
557
560
  return null;
558
561
  }
559
562
  const actualTimeMs = animationCurrentTimeMs(animation);
@@ -833,6 +836,43 @@ export function planLetterMaskSweep(swipe, letterCount, swipeDurationMs, rtl) {
833
836
  return { keyframes, delayMs: 0, durationMs, easing: swipe.easing };
834
837
  });
835
838
  }
839
+ function swipeRamp(config) {
840
+ return {
841
+ easing: config.highlight.swipeEasing,
842
+ startFrom: config.highlight.swipeStartFrom,
843
+ startTo: config.highlight.swipeStartTo,
844
+ endFrom: config.highlight.swipeEndFrom,
845
+ endTo: config.highlight.swipeEndTo,
846
+ };
847
+ }
848
+ // How far through its float a letter crests, as a keyframe offset.
849
+ const LETTER_WAVE_CREST_OFFSET = 0.4;
850
+ // When each letter's wave starts, counted from the start of the word, and how long it lasts. A letter
851
+ // crests just as the swipe finishes lighting it. If the swipe lights it faster than the float rises,
852
+ // it starts early enough to make that, which on a short word means before the word itself does. If the
853
+ // swipe lights it slower, it starts as the swipe touches it and its float stretches to crest on time.
854
+ export function planLetterWave(swipe, letterCount, wordDurationMs, swipeDurationMs, swipeLeadMs, floatDurationMs) {
855
+ const riseMs = floatDurationMs * LETTER_WAVE_CREST_OFFSET;
856
+ const windows = computeLetterSwipeWindows({ ...swipe, easing: "linear" }, letterCount, swipeDurationMs);
857
+ const timings = [];
858
+ for (let index = 0; index < letterCount; index++) {
859
+ if (!windows) {
860
+ // No windows for this ramp, so spread the letters evenly over the word.
861
+ timings.push({ delayMs: (wordDurationMs * index) / letterCount, durationMs: floatDurationMs });
862
+ continue;
863
+ }
864
+ // The swipe runs swipeLeadMs ahead of the word, so take the lead back off.
865
+ const touchedMs = windows[index].delayMs - swipeLeadMs;
866
+ const litMs = touchedMs + windows[index].durationMs;
867
+ if (litMs - riseMs > touchedMs) {
868
+ timings.push({ delayMs: touchedMs, durationMs: (litMs - touchedMs) / LETTER_WAVE_CREST_OFFSET });
869
+ }
870
+ else {
871
+ timings.push({ delayMs: litMs - riseMs, durationMs: floatDurationMs });
872
+ }
873
+ }
874
+ return timings;
875
+ }
836
876
  function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, wordTimeMs, swipeDurationMs, glowDurationMs, appliedTimingOffsetMs) {
837
877
  const animations = [];
838
878
  const highlight = part.highlightElement;
@@ -842,13 +882,7 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
842
882
  const swipeCurrentTimeMs = correctedAnimationTimeMs(swipeTimeMs, appliedTimingOffsetMs, swipeDurationMs);
843
883
  const highlightLetters = part.highlightLetterElements;
844
884
  if (highlightLetters && highlightLetters.length > 0) {
845
- const sweeps = planLetterMaskSweep({
846
- easing: config.highlight.swipeEasing,
847
- startFrom: config.highlight.swipeStartFrom,
848
- startTo: config.highlight.swipeStartTo,
849
- endFrom: config.highlight.swipeEndFrom,
850
- endTo: config.highlight.swipeEndTo,
851
- }, highlightLetters.length, swipeDurationMs, part.highlightElement.classList.contains(RTL_CLASS));
885
+ const sweeps = planLetterMaskSweep(swipeRamp(config), highlightLetters.length, swipeDurationMs, part.highlightElement.classList.contains(RTL_CLASS));
852
886
  sweeps.forEach((sweep, index) => {
853
887
  const targets = [highlightLetters[index], part.imageLayers?.glowLetters[index]].filter((target) => target !== undefined);
854
888
  for (const target of targets) {
@@ -856,7 +890,11 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
856
890
  offset: frame.offset,
857
891
  maskPosition: frame.maskPosition,
858
892
  WebkitMaskPosition: frame.maskPosition,
859
- })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
893
+ })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }),
894
+ // The last letter's sweep ends last, so it alone covers the whole word.
895
+ index === sweeps.length - 1 && target === highlightLetters[index]
896
+ ? swipeTiming
897
+ : { ...swipeTiming, skipDriftSample: true });
860
898
  animation.currentTime = swipeCurrentTimeMs;
861
899
  if (index === 0)
862
900
  swipeAnimation = animation;
@@ -1000,27 +1038,51 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
1000
1038
  { transform: `translateY(0)${emphasisRest}`, easing: config.letterWave.riseEasing },
1001
1039
  {
1002
1040
  transform: `${config.letterWave.transform}${emphasisPeak}`,
1003
- offset: 0.4,
1041
+ offset: LETTER_WAVE_CREST_OFFSET,
1004
1042
  easing: config.letterWave.fallEasing,
1005
1043
  },
1006
1044
  { transform: `${config.letterWave.settle}${emphasisRest}` },
1007
1045
  ];
1008
1046
  const letterCount = letters.length;
1009
- const staggerMs = timedDurationMs > 0 ? timedDurationMs / 2.5 / letterCount : 0;
1010
- const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
1011
- const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
1047
+ const waves = planLetterWave(swipeRamp(config), letterCount, timedDurationMs, swipeDurationMs, swipeLeadMs, config.letterWave.durationMs);
1048
+ // A letter that starts before the word would have a negative delay, which the drift check reads
1049
+ // as the wave running behind once it finishes. Run the wave's clock ahead by the head start
1050
+ // instead, the way the swipe carries its lead, so every delay stays at zero or above.
1051
+ const waveLeadMs = Math.max(0, -Math.min(...waves.map(wave => wave.delayMs)));
1052
+ const waveEndsMs = waves.map(wave => wave.delayMs + wave.durationMs);
1053
+ const lastWaveEndMs = Math.max(...waveEndsMs);
1054
+ const lastEndingWaveIndex = waveEndsMs.lastIndexOf(lastWaveEndMs);
1055
+ const cascadeDurationMs = waveLeadMs + lastWaveEndMs;
1056
+ const floatStartMs = correctedAnimationTimeMs(wordTimeMs + waveLeadMs, appliedTimingOffsetMs, cascadeDurationMs);
1012
1057
  const floatKeyframeSignature = JSON.stringify(floatKeyframes);
1013
- for (const set of [part.letterElements, part.highlightLetterElements, part.imageLayers?.glowLetters]) {
1014
- set?.forEach((letterElement, index) => {
1058
+ // Glow letters only show while the glow does: never when it is off or renders nothing for this
1059
+ // word, and not once it has faded out. Their waves stop there instead of running to the end.
1060
+ const glowLetters = config.enabled.highlightGlow && !part.glowSuppressed ? part.imageLayers?.glowLetters : undefined;
1061
+ const glowEndMs = Number.parseFloat(config.highlight.imageGlowOpacityTo) === 0 ? waveLeadMs + glowDurationMs : Infinity;
1062
+ const animateLetters = (letters, endMs, sampled) => {
1063
+ letters?.forEach((letterElement, index) => {
1064
+ const delayMs = waves[index].delayMs + waveLeadMs;
1065
+ if (delayMs >= endMs)
1066
+ return;
1067
+ const endDelayMs = Math.min(0, endMs - delayMs - waves[index].durationMs);
1015
1068
  const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
1016
- duration: config.letterWave.durationMs,
1017
- delay: index * staggerMs,
1069
+ duration: waves[index].durationMs,
1070
+ delay: delayMs,
1071
+ endDelay: endDelayMs,
1018
1072
  fill: "forwards",
1019
- }), { appliedTimingOffsetMs, offsetMs: 0 });
1073
+ }), {
1074
+ appliedTimingOffsetMs,
1075
+ offsetMs: waveLeadMs,
1076
+ // The wave that ends last covers the whole word. A theme's swipe ramp can make that an early letter.
1077
+ skipDriftSample: !sampled || index !== lastEndingWaveIndex,
1078
+ });
1020
1079
  animation.currentTime = floatStartMs;
1021
1080
  wobbleAnimations.push(animation);
1022
1081
  });
1023
- }
1082
+ };
1083
+ animateLetters(part.letterElements, Infinity, true);
1084
+ animateLetters(part.highlightLetterElements, Infinity, false);
1085
+ animateLetters(glowLetters, glowEndMs, false);
1024
1086
  }
1025
1087
  }
1026
1088
  part.animations = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.16.4",
3
+ "version": "1.16.6",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",