@braccato/core 1.13.1 → 1.14.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.
package/README.md CHANGED
@@ -479,3 +479,42 @@ loading regional CJK web fonts should set `--noto-sans-universal` on these eleme
479
479
  `--blyrics-font-family` and `--blyrics-translated-font-family` remain explicit theme overrides.
480
480
  They are unset by default; custom CSS that reads them directly should use
481
481
  `var(--blyrics-font-family, var(--blyrics-default-font-family))` for the default stack.
482
+
483
+ ### Theme-provided image fills
484
+
485
+ Image highlights are optional. Enable them in a theme comment:
486
+
487
+ ```css
488
+ /* blyrics-image-highlights = true; */
489
+ .blyrics-container {
490
+ --blyrics-highlight-image: url("my-image.jpg");
491
+ --blyrics-image-glow-opacity-from: 0.5;
492
+ --blyrics-image-glow-opacity-to: 0;
493
+ }
494
+ ```
495
+
496
+ Changing this setting requests a lyric rebuild through the existing `setTheme` return value.
497
+ Without it, the renderer adds no image or glow layers and keeps its shadow animation. With it,
498
+ the sharp fill and a separate blurred copy share the word reveal, letter motion, opacity, pause,
499
+ seek and playback-rate handling. Existing glow radius, duration and easing properties still apply;
500
+ the two image-glow opacity properties replace the solid shadow color's alpha.
501
+
502
+ The image may be an SDR texture or an HDR gain-map asset. The theme owns HDR media queries
503
+ and `dynamic-range-limit`; the core never opts into extra display brightness. Ordinary active
504
+ text color backs up the image fill, while the note keeps its original color path until its
505
+ SVG image loads. The note image reuses the original animated wave clip and fade group.
506
+
507
+ The glow layer is outside the paint's mask, avoiding clipped halos at word edges. Bidi-sensitive
508
+ runs and fragmented long words use a parallel inline text run for the glow, so the browser
509
+ reproduces the same ordering and wrapped fragments. Its reveal and visibility use the same
510
+ animation clock as the sharp text. No generated text or per-frame geometry reads are needed.
511
+ Joining-script word groups retain contextual joining by using whole-word sweeps instead of
512
+ inline-block letter waves, including when image highlights are disabled.
513
+ Forced colors suppress the image and halo in favor of system text colors.
514
+
515
+ For real-browser regression checks, build the package, serve the repository root and open
516
+ `tooling/browser/image-highlights.html`. Import `image-highlights-checks.js` from that page and
517
+ call `runImageHighlightChecks(testView)`. The default fixture uses an SDR image; a `theme` query
518
+ parameter can point at a consumer's HDR stylesheet for physical-display testing.
519
+
520
+ Words that mix RTL characters with LTR letters or numbers retain native text shaping instead of per-letter motion. Image glow, letter masks and letter motion retain their current paint during the line exit fade.
package/dist/README.md CHANGED
@@ -479,3 +479,42 @@ loading regional CJK web fonts should set `--noto-sans-universal` on these eleme
479
479
  `--blyrics-font-family` and `--blyrics-translated-font-family` remain explicit theme overrides.
480
480
  They are unset by default; custom CSS that reads them directly should use
481
481
  `var(--blyrics-font-family, var(--blyrics-default-font-family))` for the default stack.
482
+
483
+ ### Theme-provided image fills
484
+
485
+ Image highlights are optional. Enable them in a theme comment:
486
+
487
+ ```css
488
+ /* blyrics-image-highlights = true; */
489
+ .blyrics-container {
490
+ --blyrics-highlight-image: url("my-image.jpg");
491
+ --blyrics-image-glow-opacity-from: 0.5;
492
+ --blyrics-image-glow-opacity-to: 0;
493
+ }
494
+ ```
495
+
496
+ Changing this setting requests a lyric rebuild through the existing `setTheme` return value.
497
+ Without it, the renderer adds no image or glow layers and keeps its shadow animation. With it,
498
+ the sharp fill and a separate blurred copy share the word reveal, letter motion, opacity, pause,
499
+ seek and playback-rate handling. Existing glow radius, duration and easing properties still apply;
500
+ the two image-glow opacity properties replace the solid shadow color's alpha.
501
+
502
+ The image may be an SDR texture or an HDR gain-map asset. The theme owns HDR media queries
503
+ and `dynamic-range-limit`; the core never opts into extra display brightness. Ordinary active
504
+ text color backs up the image fill, while the note keeps its original color path until its
505
+ SVG image loads. The note image reuses the original animated wave clip and fade group.
506
+
507
+ The glow layer is outside the paint's mask, avoiding clipped halos at word edges. Bidi-sensitive
508
+ runs and fragmented long words use a parallel inline text run for the glow, so the browser
509
+ reproduces the same ordering and wrapped fragments. Its reveal and visibility use the same
510
+ animation clock as the sharp text. No generated text or per-frame geometry reads are needed.
511
+ Joining-script word groups retain contextual joining by using whole-word sweeps instead of
512
+ inline-block letter waves, including when image highlights are disabled.
513
+ Forced colors suppress the image and halo in favor of system text colors.
514
+
515
+ For real-browser regression checks, build the package, serve the repository root and open
516
+ `tooling/browser/image-highlights.html`. Import `image-highlights-checks.js` from that page and
517
+ call `runImageHighlightChecks(testView)`. The default fixture uses an SDR image; a `theme` query
518
+ parameter can point at a consumer's HDR stylesheet for physical-display testing.
519
+
520
+ Words that mix RTL characters with LTR letters or numbers retain native text shaping instead of per-letter motion. Image glow, letter masks and letter motion retain their current paint during the line exit fade.
package/dist/engine.d.ts CHANGED
@@ -171,6 +171,10 @@ interface AnimationConfig {
171
171
  swipeEndFrom: string;
172
172
  swipeStartTo: string;
173
173
  swipeEndTo: string;
174
+ imageGlowFrom: string;
175
+ imageGlowTo: string;
176
+ imageGlowOpacityFrom: string;
177
+ imageGlowOpacityTo: string;
174
178
  glowFrom: string;
175
179
  glowTo: string;
176
180
  glowDurationRatio: number;
package/dist/engine.js CHANGED
@@ -319,6 +319,7 @@ function togglePartClass(part, className, force) {
319
319
  part.lyricElement.classList.toggle(className, force);
320
320
  if ("highlightElement" in part) {
321
321
  part.highlightElement.classList.toggle(className, force);
322
+ part.imageLayers?.highlight?.classList.toggle(className, force);
322
323
  }
323
324
  }
324
325
  function setAnimationsPlayState(lineData, isPlaying) {
@@ -359,6 +360,7 @@ export function updateWordStates(lineData, currentTime) {
359
360
  part.wordState = state;
360
361
  part.lyricElement.setAttribute(WORD_STATE_ATTR, state);
361
362
  part.highlightElement.setAttribute(WORD_STATE_ATTR, state);
363
+ part.imageLayers?.highlight?.setAttribute(WORD_STATE_ATTR, state);
362
364
  }
363
365
  }
364
366
  const LINE_SYNCED_WORD_CLASS = "blyrics-line-synced-word";
@@ -386,6 +388,25 @@ const LINE_SCROLL_INLINE_PROPERTIES = [
386
388
  ...LINE_SCROLL_STYLE_SETTINGS.map(([property]) => property),
387
389
  ];
388
390
  const animationTimingTracks = new WeakMap();
391
+ function mirrorImageHighlightAnimations(part, animations) {
392
+ const target = part.imageLayers?.highlight;
393
+ if (!target)
394
+ return [];
395
+ const mirrors = [];
396
+ for (const animation of animations) {
397
+ const effect = animation.effect;
398
+ if (!effect || effect.target !== part.highlightElement)
399
+ continue;
400
+ const mirror = target.animate(effect.getKeyframes(), effect.getTiming());
401
+ mirror.playbackRate = animation.playbackRate;
402
+ mirror.currentTime = animation.currentTime;
403
+ const timing = animationTimingTracks.get(animation);
404
+ if (timing)
405
+ animationTimingTracks.set(mirror, timing);
406
+ mirrors.push(mirror);
407
+ }
408
+ return mirrors;
409
+ }
389
410
  function trackLyricAnimationTiming(engine, animation, timing) {
390
411
  animationTimingTracks.set(animation, {
391
412
  ...timing,
@@ -652,7 +673,12 @@ function activeTextGradientKeyframes(config) {
652
673
  },
653
674
  ];
654
675
  }
655
- function activeTextGlowKeyframes(config) {
676
+ function activeTextGlowKeyframes(config, part) {
677
+ if (part.imageLayers)
678
+ return [
679
+ { filter: `blur(${config.highlight.imageGlowFrom})`, opacity: config.highlight.imageGlowOpacityFrom },
680
+ { filter: `blur(${config.highlight.imageGlowTo})`, opacity: config.highlight.imageGlowOpacityTo },
681
+ ];
656
682
  return [{ filter: config.highlight.glowFrom }, { filter: config.highlight.glowTo }];
657
683
  }
658
684
  function activeTextVisibleKeyframes() {
@@ -686,17 +712,17 @@ function lineSyncedTextKeyframes(config) {
686
712
  },
687
713
  ];
688
714
  }
689
- function fadeOutTextKeyframes(config) {
715
+ function fadeOutTextKeyframes(config, part) {
690
716
  return [
691
717
  {
692
718
  opacity: 1,
693
- filter: config.highlight.glowTo,
719
+ filter: part.imageLayers ? "none" : config.highlight.glowTo,
694
720
  "--lyric-transition-amount-start": config.highlight.swipeStartTo,
695
721
  "--lyric-transition-amount-end": config.highlight.swipeEndTo,
696
722
  },
697
723
  {
698
724
  opacity: 0,
699
- filter: config.highlight.glowTo,
725
+ filter: part.imageLayers ? "none" : config.highlight.glowTo,
700
726
  "--lyric-transition-amount-start": config.highlight.swipeStartTo,
701
727
  "--lyric-transition-amount-end": config.highlight.swipeEndTo,
702
728
  },
@@ -790,15 +816,18 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
790
816
  endTo: config.highlight.swipeEndTo,
791
817
  }, highlightLetters.length, swipeDurationMs, part.highlightElement.classList.contains(RTL_CLASS));
792
818
  sweeps.forEach((sweep, index) => {
793
- const animation = trackLyricAnimationTiming(engine, highlightLetters[index].animate(sweep.keyframes.map(frame => ({
794
- offset: frame.offset,
795
- maskPosition: frame.maskPosition,
796
- WebkitMaskPosition: frame.maskPosition,
797
- })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
798
- animation.currentTime = swipeCurrentTimeMs;
799
- if (index === 0)
800
- swipeAnimation = animation;
801
- animations.push(animation);
819
+ const targets = [highlightLetters[index], part.imageLayers?.glowLetters[index]].filter((target) => target !== undefined);
820
+ for (const target of targets) {
821
+ const animation = trackLyricAnimationTiming(engine, target.animate(sweep.keyframes.map(frame => ({
822
+ offset: frame.offset,
823
+ maskPosition: frame.maskPosition,
824
+ WebkitMaskPosition: frame.maskPosition,
825
+ })), { duration: sweep.durationMs, delay: sweep.delayMs, easing: sweep.easing, fill: "both" }), swipeTiming);
826
+ animation.currentTime = swipeCurrentTimeMs;
827
+ if (index === 0)
828
+ swipeAnimation = animation;
829
+ animations.push(animation);
830
+ }
802
831
  });
803
832
  }
804
833
  else {
@@ -820,10 +849,10 @@ function startRichSyncedHighlightAnimations(engine, part, config, swipeTimeMs, w
820
849
  animations.push(opacityAnimation);
821
850
  let glowAnimation;
822
851
  if (config.enabled.highlightGlow && !part.glowSuppressed) {
823
- glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
852
+ glowAnimation = trackLyricAnimationTiming(engine, (part.imageLayers?.glow ?? highlight).animate(activeTextGlowKeyframes(config, part), {
824
853
  duration: glowDurationMs,
825
854
  easing: config.highlight.glowEasing,
826
- fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
855
+ fill: part.imageLayers ? "forwards" : config.highlight.glowRestingInvisible ? "none" : "forwards",
827
856
  }), { appliedTimingOffsetMs, offsetMs: 0 });
828
857
  glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
829
858
  animations.push(glowAnimation);
@@ -843,10 +872,10 @@ function startLineSyncedHighlightAnimations(engine, part, config, wordTimeMs, gl
843
872
  animations.push(opacityAnimation);
844
873
  let glowAnimation;
845
874
  if (config.enabled.highlightGlow && !part.glowSuppressed) {
846
- glowAnimation = trackLyricAnimationTiming(engine, highlight.animate(activeTextGlowKeyframes(config), {
875
+ glowAnimation = trackLyricAnimationTiming(engine, (part.imageLayers?.glow ?? highlight).animate(activeTextGlowKeyframes(config, part), {
847
876
  duration: glowDurationMs,
848
877
  easing: config.highlight.glowEasing,
849
- fill: config.highlight.glowRestingInvisible ? "none" : "forwards",
878
+ fill: part.imageLayers ? "forwards" : config.highlight.glowRestingInvisible ? "none" : "forwards",
850
879
  }), { appliedTimingOffsetMs, offsetMs: 0 });
851
880
  glowAnimation.currentTime = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, glowDurationMs);
852
881
  animations.push(glowAnimation);
@@ -947,7 +976,7 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
947
976
  const cascadeDurationMs = config.letterWave.durationMs + (letterCount - 1) * staggerMs;
948
977
  const floatStartMs = correctedAnimationTimeMs(wordTimeMs, appliedTimingOffsetMs, cascadeDurationMs);
949
978
  const floatKeyframeSignature = JSON.stringify(floatKeyframes);
950
- for (const set of [part.letterElements, part.highlightLetterElements]) {
979
+ for (const set of [part.letterElements, part.highlightLetterElements, part.imageLayers?.glowLetters]) {
951
980
  set?.forEach((letterElement, index) => {
952
981
  const animation = trackLyricAnimationTiming(engine, acquireWaveAnimation(engine, letterElement, floatKeyframes, floatKeyframeSignature, {
953
982
  duration: config.letterWave.durationMs,
@@ -960,7 +989,11 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
960
989
  }
961
990
  }
962
991
  }
963
- part.animations = [...highlightAnimations.animations, ...wobbleAnimations];
992
+ part.animations = [
993
+ ...highlightAnimations.animations,
994
+ ...mirrorImageHighlightAnimations(part, highlightAnimations.animations),
995
+ ...wobbleAnimations,
996
+ ];
964
997
  }
965
998
  function startLineAnimations(engine, lineData, config, currentTime) {
966
999
  const appliedTimingOffsetMs = engine.learnedAnimationTimingOffsetMs;
@@ -974,20 +1007,43 @@ function startLineAnimations(engine, lineData, config, currentTime) {
974
1007
  startWordAnimations(engine, part, config, currentTime, appliedTimingOffsetMs);
975
1008
  }
976
1009
  }
977
- function startWordExitAnimation(part, config) {
1010
+ function startWordExitAnimation(part, config, imagePaint = []) {
978
1011
  resetPartAnimations(part);
979
1012
  const fadeDuration = config.enabled.highlightFade ? config.highlight.fadeOutDurationMs : 1;
980
- const animation = part.highlightElement.animate(fadeOutTextKeyframes(config), {
1013
+ const animation = part.highlightElement.animate(fadeOutTextKeyframes(config, part), {
981
1014
  duration: fadeDuration,
982
1015
  easing: config.enabled.highlightFade ? config.highlight.fadeOutEasing : "linear",
983
1016
  fill: "none",
984
1017
  });
985
- part.animations = [animation];
1018
+ part.animations = [animation, ...mirrorImageHighlightAnimations(part, [animation])];
1019
+ // Keep the current blur, letter masks and motion visible until the parent fade ends.
1020
+ for (const { target, frame } of imagePaint) {
1021
+ part.animations.push(target.animate([frame, frame], { duration: fadeDuration, fill: "none" }));
1022
+ }
986
1023
  animation.addEventListener("finish", () => {
987
1024
  resetPartAnimations(part);
988
1025
  }, { once: true });
989
1026
  }
990
1027
  function startLineExitAnimations(engine, lineData, config, currentTime) {
1028
+ // Read the entire line before cancelling any effects, so this does not alternate
1029
+ // per-word style reads with writes. There are no extra reads during normal ticks.
1030
+ const imagePaint = new Map();
1031
+ for (const part of lineData.parts) {
1032
+ if (!part.imageLayers || currentTime < part.time)
1033
+ continue;
1034
+ const style = engine.window.getComputedStyle(part.imageLayers.glow);
1035
+ const paint = [
1036
+ { target: part.imageLayers.glow, frame: { opacity: style.opacity, filter: style.filter } },
1037
+ ];
1038
+ for (const target of part.letterElements ?? []) {
1039
+ paint.push({ target, frame: { transform: engine.window.getComputedStyle(target).transform } });
1040
+ }
1041
+ for (const target of [...(part.highlightLetterElements ?? []), ...part.imageLayers.glowLetters]) {
1042
+ const letterStyle = engine.window.getComputedStyle(target);
1043
+ paint.push({ target, frame: { maskPosition: letterStyle.maskPosition, transform: letterStyle.transform } });
1044
+ }
1045
+ imagePaint.set(part, paint);
1046
+ }
991
1047
  startLineExitAnimation(lineData, config);
992
1048
  if (lineData.lyricElement.dataset.instrumental === "true") {
993
1049
  startInstrumentalExitAnimations(engine, lineData, config, currentTime);
@@ -995,7 +1051,7 @@ function startLineExitAnimations(engine, lineData, config, currentTime) {
995
1051
  }
996
1052
  for (const part of lineData.parts) {
997
1053
  if (currentTime >= part.time) {
998
- startWordExitAnimation(part, config);
1054
+ startWordExitAnimation(part, config, imagePaint.get(part));
999
1055
  }
1000
1056
  else {
1001
1057
  resetPartAnimations(part);
@@ -1171,7 +1227,7 @@ export function parseColorAlpha(value) {
1171
1227
  // Per-word glow is resolved only when the container glow already renders nothing, so an empty word
1172
1228
  // drops its blur while a word a theme lit up keeps it.
1173
1229
  function resolveLineGlowSuppression(engine, lineData, config) {
1174
- if (config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
1230
+ if (lineData.parts.some(part => part.imageLayers) || config.highlight.glowContainerAlpha >= GLOW_INVISIBLE_ALPHA) {
1175
1231
  for (const part of lineData.parts)
1176
1232
  part.glowSuppressed = false;
1177
1233
  return;
@@ -1229,6 +1285,10 @@ function readAnimationConfig(engine, lyricsElement) {
1229
1285
  swipeEndFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-from", "-0.1"),
1230
1286
  swipeStartTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-start-to", "1.4"),
1231
1287
  swipeEndTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-swipe-end-to", "1.5"),
1288
+ imageGlowFrom: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-radius-from", "0.8rem"),
1289
+ imageGlowTo: getCSSValue(engine, lyricsElement, "--blyrics-highlight-glow-radius-to", "0px"),
1290
+ imageGlowOpacityFrom: getCSSValue(engine, lyricsElement, "--blyrics-image-glow-opacity-from", "0.5"),
1291
+ imageGlowOpacityTo: getCSSValue(engine, lyricsElement, "--blyrics-image-glow-opacity-to", "0"),
1232
1292
  glowFrom: resolveGlowFilter(engine, lyricsElement, "from", "0.8rem"),
1233
1293
  glowTo: resolveGlowFilter(engine, lyricsElement, "to", "0"),
1234
1294
  glowDurationRatio: getCSSNumber(engine, lyricsElement, "--blyrics-highlight-glow-duration-ratio", 1.2),
@@ -0,0 +1,15 @@
1
+ import type { PartData } from "./inject.js";
2
+ export declare const imageHighlights: import("./themeSettings.js").Setting;
3
+ export interface ImageHighlightLayers {
4
+ glow: HTMLElement;
5
+ glowLetters: HTMLElement[];
6
+ /** A separate inline layout host, only for runs that can fragment. */
7
+ highlight?: HTMLElement;
8
+ }
9
+ export declare function wrapImageHighlight(highlight: HTMLElement): ImageHighlightLayers | undefined;
10
+ /** Keep bidi ordering and inline fragments in the browser's normal text layout. An
11
+ * absolutely positioned copy inside an inline word cannot reproduce those fragments. */
12
+ export declare function alignImageGlowRun(run: HTMLElement, parts: PartData[]): void;
13
+ /** A single CSS url(), not gradients or an arbitrary list of background layers. */
14
+ export declare function imageURL(value: string): string | null;
15
+ export declare function refreshInstrumentalImages(container: HTMLElement | null, view: Window): void;
@@ -0,0 +1,80 @@
1
+ // Optional image paint, independent of HDR: a theme supplies the image and its dynamic range.
2
+ // The extra layer exists only when requested. Blur surrounds the already-masked paint, so it
3
+ // cannot be cut off by the karaoke mask. Both copies are driven by the engine's own animations.
4
+ import { registerThemeSetting } from "./themeSettings.js";
5
+ export const imageHighlights = registerThemeSetting("blyrics-image-highlights", false, true);
6
+ export function wrapImageHighlight(highlight) {
7
+ if (!imageHighlights.getBooleanValue())
8
+ return;
9
+ const doc = highlight.ownerDocument;
10
+ const paint = doc.createElement("span");
11
+ paint.classList.add("blyrics-image-fill");
12
+ while (highlight.firstChild)
13
+ paint.appendChild(highlight.firstChild);
14
+ const glow = doc.createElement("span");
15
+ glow.classList.add("blyrics-image-glow");
16
+ glow.setAttribute("aria-hidden", "true");
17
+ glow.appendChild(paint.cloneNode(true));
18
+ highlight.classList.add("blyrics-image-highlight");
19
+ highlight.appendChild(glow);
20
+ highlight.appendChild(paint);
21
+ return { glow, glowLetters: Array.from(glow.querySelectorAll(".blyrics--letter")) };
22
+ }
23
+ /** Keep bidi ordering and inline fragments in the browser's normal text layout. An
24
+ * absolutely positioned copy inside an inline word cannot reproduce those fragments. */
25
+ export function alignImageGlowRun(run, parts) {
26
+ if (!imageHighlights.getBooleanValue())
27
+ return;
28
+ if (!run.parentElement?.classList.contains("blyrics-bidi-sensitive") &&
29
+ !run.querySelector(".blyrics-word-group-long"))
30
+ return;
31
+ const originals = Array.from(run.querySelectorAll(".blyrics-word-highlight"));
32
+ const byHighlight = new Map(parts.map(part => [part.highlightElement, part]));
33
+ const glowRun = run.cloneNode(true);
34
+ glowRun.classList.add("blyrics-image-glow-run");
35
+ const copies = glowRun.querySelectorAll(".blyrics-word-highlight");
36
+ originals.forEach((original, index) => {
37
+ const part = byHighlight.get(original);
38
+ if (!part?.imageLayers)
39
+ return;
40
+ const copy = copies[index];
41
+ copy.replaceChildren(part.imageLayers.glow);
42
+ part.imageLayers.highlight = copy;
43
+ // Non-fragmented groups in a mixed run can still wobble. Give their glow the
44
+ // identical group transform, rather than letting it drift from the sharp copy.
45
+ const group = original.parentElement;
46
+ if (group && part.wobbleElements.includes(group) && copy.parentElement)
47
+ part.wobbleElements.push(copy.parentElement);
48
+ });
49
+ run.parentElement.insertBefore(glowRun, run);
50
+ }
51
+ /** A single CSS url(), not gradients or an arbitrary list of background layers. */
52
+ export function imageURL(value) {
53
+ const match = /^url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s'"()]+))\s*\)$/i.exec(value.trim());
54
+ return match ? (match[1] ?? match[2] ?? match[3]) : null;
55
+ }
56
+ // SVG <image> follows the browser's normal image drawing path, including gain maps. feImage
57
+ // and SVG patterns can rasterize through an SDR intermediate. Keep the original color path
58
+ // available until loading succeeds, and whenever a theme's media query supplies no image.
59
+ export function refreshInstrumentalImages(container, view) {
60
+ if (!container || !imageHighlights.getBooleanValue())
61
+ return;
62
+ for (const svg of container.querySelectorAll(".blyrics--instrumental-icon")) {
63
+ const image = svg.querySelector(".blyrics-instrumental-image");
64
+ if (!image)
65
+ continue;
66
+ const source = imageURL(view.getComputedStyle(svg).getPropertyValue("--blyrics-highlight-image"));
67
+ if (source === image.getAttribute("href"))
68
+ continue;
69
+ svg.removeAttribute("data-image-ready");
70
+ image.onload = () => {
71
+ if (image.getAttribute("href") === source)
72
+ svg.setAttribute("data-image-ready", "");
73
+ };
74
+ image.onerror = () => svg.removeAttribute("data-image-ready");
75
+ if (source)
76
+ image.setAttribute("href", source);
77
+ else
78
+ image.removeAttribute("href");
79
+ }
80
+ }
package/dist/inject.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ImageHighlightLayers } from "./imageHighlights.js";
1
2
  import { type WordState } from "./constants.js";
2
3
  import type { Lyric, LyricPart, LyricSyncType } from "./types.js";
3
4
  export declare let disableRichsync: import("./themeSettings.js").Setting;
@@ -19,6 +20,7 @@ export interface AnimationData {
19
20
  }
20
21
  export interface PartData extends AnimationData {
21
22
  highlightElement: HTMLElement;
23
+ imageLayers?: ImageHighlightLayers;
22
24
  letterElements?: HTMLElement[];
23
25
  highlightLetterElements?: HTMLElement[];
24
26
  wobbleElements: HTMLElement[];
package/dist/inject.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // animates is one word, so every part is split on whitespace with its timing pro-rated across the
12
12
  // split by character count. A line that arrives with no timed parts at all is rebuilt the same way
13
13
  // into zero duration words, so line synced lyrics reach the DOM the sweep already knows.
14
+ import { alignImageGlowRun, wrapImageHighlight } from "./imageHighlights.js";
14
15
  import { normalizeLanguage } from "./language.js";
15
16
  import { BACKGROUND_LINE_CLASS, BACKGROUND_LYRIC_CLASS, BIDI_RUN_CLASS, BIDI_SENSITIVE_CLASS, CONTENT_LINE_CLASS, EXPLICIT_WORD_CLASS, HIGHLIGHT_RUN_CLASS, LETTER_CLASS, LINE_MAIN_CLASS, LINE_SYNCED_WORD_CLASS, LONG_WORD_GROUP_CLASS, ROMANIZED_LYRICS_CLASS, RTL_CLASS, TRANSLATED_LYRICS_CLASS, WORD_CLASS, WORD_GROUP_CLASS, WORD_HIGHLIGHT_CLASS, WORD_HIGHLIGHT_LETTERED_CLASS, WORD_STATE_ATTR, WORD_STATE_UPCOMING, ZERO_DURATION_ANIMATION_CLASS, } from "./constants.js";
16
17
  import { getSeekTimeFromClick } from "./seek.js";
@@ -23,7 +24,6 @@ let longWordWrapThreshold = registerThemeSetting("blyrics-long-word-wrap-thresho
23
24
  let letterWave = registerThemeSetting("blyrics-letter-wave", true, true);
24
25
  export const hideCredits = registerThemeSetting("blyrics-hide-credits", false, true);
25
26
  const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
26
- const RTL_SCRIPT_REGEX = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u;
27
27
  const LTR_SCRIPT_REGEX = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Han}\p{Script=Hangul}\p{Script=Hiragana}\p{Script=Katakana}]/u;
28
28
  const SPACE_REGEX = /^\s+$/u;
29
29
  export function findNearestAgent(lyrics, fromIndex) {
@@ -70,6 +70,7 @@ function newPartData(part, span, highlight, wobbleElements, letterElements, high
70
70
  duration: part.durationMs / 1000,
71
71
  lyricElement: span,
72
72
  highlightElement: highlight,
73
+ imageLayers: wrapImageHighlight(highlight),
73
74
  letterElements,
74
75
  highlightLetterElements,
75
76
  wobbleElements,
@@ -97,7 +98,7 @@ export function newLineData(lyricElement, startTimeMs, durationMs) {
97
98
  }
98
99
  function detectDirection(text) {
99
100
  for (const char of text) {
100
- if (RTL_SCRIPT_REGEX.test(char))
101
+ if (testRtl(char))
101
102
  return "rtl";
102
103
  if (LTR_SCRIPT_REGEX.test(char))
103
104
  return "ltr";
@@ -217,7 +218,7 @@ function appendLetters(doc, wordElement, text) {
217
218
  return letter;
218
219
  });
219
220
  }
220
- function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
221
+ function createTimedWordSpan(doc, part, wrapThreshold, perLetter, preserveJoining) {
221
222
  const span = doc.createElement("span");
222
223
  const highlight = doc.createElement("span");
223
224
  highlight.classList.add(WORD_HIGHLIGHT_CLASS);
@@ -238,7 +239,8 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
238
239
  wordElement.classList.add(BACKGROUND_LYRIC_CLASS);
239
240
  if (part.explicit)
240
241
  wordElement.classList.add(EXPLICIT_WORD_CLASS);
241
- if (perLetter && !testJoiningScript(part.words)) {
242
+ if (perLetter) {
243
+ wordElement.classList.add("blyrics-word--lettered");
242
244
  const collected = appendLetters(doc, wordElement, part.words);
243
245
  if (wordElement === span) {
244
246
  letters = collected;
@@ -248,6 +250,9 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
248
250
  wordElement.classList.add(WORD_HIGHLIGHT_LETTERED_CLASS);
249
251
  }
250
252
  }
253
+ else if (preserveJoining) {
254
+ wordElement.textContent = part.words;
255
+ }
251
256
  else {
252
257
  appendLongWordBreaks(doc, wordElement, part.words, wrapThreshold);
253
258
  }
@@ -261,7 +266,10 @@ function createTimedWordSpan(doc, part, wrapThreshold, perLetter) {
261
266
  }
262
267
  function createWordGroup(doc, group, lineData) {
263
268
  const wrapThreshold = Math.max(1, longWordWrapThreshold.getNumberValue());
264
- const perLetter = letterWave.getBooleanValue();
269
+ // Atomic grapheme spans disrupt the browser's bidi ordering inside a mixed word.
270
+ const mixedDirection = testRtl(group.text) && [...group.text].some(char => /[\p{Letter}\p{Number}]/u.test(char) && !testRtl(char));
271
+ const preserveJoining = testJoiningScript(group.text);
272
+ const perLetter = letterWave.getBooleanValue() && !preserveJoining && !mixedDirection;
265
273
  const lyricGroup = doc.createElement("span");
266
274
  const highlightGroup = doc.createElement("span");
267
275
  for (const groupElement of [lyricGroup, highlightGroup]) {
@@ -278,7 +286,7 @@ function createWordGroup(doc, group, lineData) {
278
286
  for (const token of group.tokens) {
279
287
  if (token.kind === "space")
280
288
  continue;
281
- const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter);
289
+ const { span, highlight, letters, highlightLetters } = createTimedWordSpan(doc, token.part, wrapThreshold, perLetter, preserveJoining);
282
290
  const wobbleElements = lyricGroup.childNodes.length === 0 ? [lyricGroup, highlightGroup] : [];
283
291
  lineData.parts.push(newPartData(token.part, span, highlight, wobbleElements, letters, highlightLetters));
284
292
  lyricGroup.appendChild(span);
@@ -367,6 +375,9 @@ export function createLyricsLine(doc, parts, line, lyricElement, options = { spl
367
375
  }
368
376
  }
369
377
  }
378
+ alignImageGlowRun(mainHighlightRun, line.parts);
379
+ if (hasBackground)
380
+ alignImageGlowRun(backgroundHighlightRun, line.parts);
370
381
  lyricElement.appendChild(main);
371
382
  if (hasBackground) {
372
383
  lyricElement.appendChild(backgroundLine);
@@ -1,3 +1,4 @@
1
+ import { imageHighlights } from "./imageHighlights.js";
1
2
  export const INSTRUMENTAL_WAVE_PATH_HIGH = "M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z";
2
3
  export const INSTRUMENTAL_WAVE_PATH_LOW = "M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z";
3
4
  /**
@@ -72,7 +73,45 @@ export function createInstrumentalElement(doc, container, durationMs, lineIndex)
72
73
  fillPath.classList.add("blyrics--instrumental-fill");
73
74
  fillPath.setAttribute("clip-path", `url(#${clipId})`);
74
75
  fillPath.setAttribute("d", "M10 21q-1.65 0-2.825-1.175T6 17t1.175-2.825T10 13q.575 0 1.063.138t.937.412V4q0-.425.288-.712T13 3h4q.425 0 .713.288T18 4v2q0 .425-.288.713T17 7h-3v10q0 1.65-1.175 2.825T10 21");
75
- g.appendChild(fillPath);
76
+ if (imageHighlights.getBooleanValue()) {
77
+ // Fade the common group so both the image and the SDR fallback follow the original
78
+ // instrumental animation. The wave and silhouette clips are inside the glow filter.
79
+ fillPath.classList.remove("blyrics--instrumental-fill");
80
+ fillPath.classList.add("blyrics-instrumental-color");
81
+ const fill = doc.createElementNS(svgNS, "g");
82
+ fill.classList.add("blyrics--instrumental-fill");
83
+ fill.appendChild(fillPath);
84
+ const shapeId = `${clipId}-image-shape`;
85
+ const shape = doc.createElementNS(svgNS, "clipPath");
86
+ shape.setAttribute("id", shapeId);
87
+ const shapePath = doc.createElementNS(svgNS, "path");
88
+ shapePath.setAttribute("d", fillPath.getAttribute("d"));
89
+ shape.appendChild(shapePath);
90
+ defs.appendChild(shape);
91
+ const silhouette = doc.createElementNS(svgNS, "g");
92
+ silhouette.setAttribute("clip-path", `url(#${shapeId})`);
93
+ const wave = doc.createElementNS(svgNS, "g");
94
+ wave.setAttribute("clip-path", `url(#${clipId})`);
95
+ const image = doc.createElementNS(svgNS, "image");
96
+ image.classList.add("blyrics-instrumental-image");
97
+ image.setAttribute("x", "0");
98
+ image.setAttribute("y", "0");
99
+ image.setAttribute("width", "24");
100
+ image.setAttribute("height", "24");
101
+ image.setAttribute("preserveAspectRatio", "none");
102
+ wave.appendChild(image);
103
+ silhouette.appendChild(wave);
104
+ fill.appendChild(silhouette);
105
+ g.appendChild(fill);
106
+ // Blur RGB directly instead of recoloring the halo with an SDR flood/shadow.
107
+ filter.setAttribute("color-interpolation-filters", "sRGB");
108
+ feGaussianBlur.setAttribute("stdDeviation", "1");
109
+ filter.removeChild(feColorMatrix);
110
+ feMergeNode1.setAttribute("in", "blur");
111
+ }
112
+ else {
113
+ g.appendChild(fillPath);
114
+ }
76
115
  svg.appendChild(g);
77
116
  container.appendChild(svg);
78
117
  return container;
package/dist/renderer.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // this view on screen, is a loader up) and performs the actions it must not own (seek the player).
10
10
  // `debug` is the exception because there is nothing to default it to. A consumer that wants the
11
11
  // diagnostic overlay supplies the sink, and one that says nothing draws nothing.
12
+ import { imageHighlights, refreshInstrumentalImages } from "./imageHighlights.js";
12
13
  import { CUSTOM_THEME_STYLE_ID } from "./constants.js";
13
14
  import { clearLyrics, clearOnScreenLyrics as clearEngineOnScreenLyrics, clearStyleCaches as clearEngineStyleCaches, createAnimationEngineInstance, getRenderedLines, getRenderedSyncType, hasUnsyncedLyrics, noteContainerResize, noteUserScroll as noteEngineUserScroll, noteVisibilityChange as noteEngineVisibilityChange, relayout, resetScrollResume, resolveTickOptions, retickFromPlaybackClock as retickEngineFromPlaybackClock, scheduleLyricPositionUpdate as scheduleEngineLyricPositionUpdate, tickView, } from "./engine.js";
14
15
  import { parseThemeConfig, setThemeSettings } from "./themeSettings.js";
@@ -139,6 +140,21 @@ export function createLyricsRenderer(rendererOptions) {
139
140
  let currentLanguage = "";
140
141
  const { host, forgetScrollElement } = withHostDefaults(rendererOptions.host, rendererWindow, () => mount);
141
142
  const engine = createAnimationEngineInstance(rendererDocument, rendererWindow, host);
143
+ let imageMediaQueries = [];
144
+ const refreshImagePaint = () => refreshInstrumentalImages(engine.lyricsContainer, rendererWindow);
145
+ function syncImagePaint() {
146
+ if (imageHighlights.getBooleanValue() && imageMediaQueries.length === 0) {
147
+ imageMediaQueries = ["(dynamic-range: high)", "(forced-colors: active)"].map(query => rendererWindow.matchMedia(query));
148
+ for (const query of imageMediaQueries)
149
+ query.addEventListener("change", refreshImagePaint);
150
+ }
151
+ else if (!imageHighlights.getBooleanValue()) {
152
+ for (const query of imageMediaQueries)
153
+ query.removeEventListener("change", refreshImagePaint);
154
+ imageMediaQueries = [];
155
+ }
156
+ refreshImagePaint();
157
+ }
142
158
  /**
143
159
  * Every re-measurement runs through here, which makes it the one place that knows the layout may
144
160
  * have moved under the view. The default scroll element is walked once and remembered, so this is
@@ -262,6 +278,7 @@ export function createLyricsRenderer(rendererOptions) {
262
278
  noLyrics: options?.noLyrics ?? false,
263
279
  songwriters: options?.songwriters,
264
280
  });
281
+ syncImagePaint();
265
282
  currentLyrics = lyrics;
266
283
  currentLanguage = normalizeLanguage(options?.language);
267
284
  measure();
@@ -284,6 +301,7 @@ export function createLyricsRenderer(rendererOptions) {
284
301
  // the theme it is answering about.
285
302
  const needsLyricRebuild = setThemeSettings(parseThemeConfig(css));
286
303
  adoptThemeStyleSheet(css);
304
+ syncImagePaint();
287
305
  // Everything the engine resolved off the document was resolved against the theme that just
288
306
  // went away.
289
307
  clearEngineStyleCaches(engine);
@@ -320,6 +338,9 @@ export function createLyricsRenderer(rendererOptions) {
320
338
  createdThemeStyleElement?.remove();
321
339
  createdThemeStyleElement = null;
322
340
  rendererWindow.removeEventListener("resize", remeasureForViewport);
341
+ for (const query of imageMediaQueries)
342
+ query.removeEventListener("change", refreshImagePaint);
343
+ imageMediaQueries = [];
323
344
  engine.destroy();
324
345
  },
325
346
  noteUserScroll() {
@@ -0,0 +1,62 @@
1
+ /* The renderer adds these classes only for blyrics-image-highlights = true.
2
+ An SDR image is equally valid. HDR assets and dynamic-range-limit belong to the theme. */
3
+ .blyrics--word.blyrics-word-highlight.blyrics-image-highlight {
4
+ background: none;
5
+ mask: none;
6
+ -webkit-mask: none;
7
+ color: transparent;
8
+ }
9
+ .blyrics-image-fill,
10
+ .blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
11
+ background-image: var(--blyrics-highlight-image, none);
12
+ background-color: var(--blyrics-lyric-active-color, white);
13
+ background-size: 100% 100%;
14
+ background-repeat: no-repeat;
15
+ background-clip: text;
16
+ -webkit-background-clip: text;
17
+ color: transparent;
18
+ -webkit-text-fill-color: transparent;
19
+ }
20
+ .blyrics-image-highlight:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
21
+ --blyrics-image-sweep-angle: 90deg;
22
+ mask-image: linear-gradient(var(--blyrics-image-sweep-angle),
23
+ #000 calc(100% * var(--lyric-transition-amount-start)),
24
+ transparent calc(100% * var(--lyric-transition-amount-end) + 1px));
25
+ mask-repeat: no-repeat;
26
+ }
27
+ .blyrics-image-highlight.blyrics-rtl:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
28
+ --blyrics-image-sweep-angle: 270deg;
29
+ }
30
+ .blyrics-image-highlight.blyrics-word-highlight--lettered .blyrics-image-fill {
31
+ background: none;
32
+ }
33
+ .blyrics-image-glow {
34
+ position: absolute;
35
+ inset: 0;
36
+ pointer-events: none;
37
+ user-select: none;
38
+ opacity: 0;
39
+ overflow: visible;
40
+ }
41
+ /* A parallel run reproduces the full inline formatting context, including bidi
42
+ reordering and every wrapped fragment. No generated text or per-frame measuring. */
43
+ .blyrics-image-glow-run .blyrics-image-glow {
44
+ position: static;
45
+ inset: auto;
46
+ display: inline;
47
+ }
48
+ .blyrics-instrumental-image { display: none; }
49
+ .blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-image { display: inline; }
50
+ .blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-color { display: none; }
51
+ @media (forced-colors: active) {
52
+ .blyrics-image-glow,
53
+ .blyrics-image-glow-run .blyrics-image-glow { display: none; }
54
+ .blyrics-image-fill,
55
+ .blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
56
+ background: none;
57
+ color: CanvasText;
58
+ -webkit-text-fill-color: CanvasText;
59
+ }
60
+ .blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-image { display: none; }
61
+ .blyrics--instrumental-icon[data-image-ready] .blyrics-instrumental-color { display: inline; }
62
+ }
@@ -1,3 +1,5 @@
1
+ @import url("image-highlights.css");
2
+
1
3
  /* Resolve the default stack at each language boundary. A root-level var() would freeze
2
4
  the host's language-specific fallback before the lyric/translation language is known.
3
5
  Theme overrides remain inherited through --blyrics-font-family. */
@@ -122,7 +124,6 @@
122
124
  .blyrics-bidi-sensitive .blyrics--word,
123
125
  .blyrics-bidi-sensitive .blyrics-word-group:not(.blyrics-word-group-long) .blyrics--word {
124
126
  display: inline;
125
- unicode-bidi: normal;
126
127
  white-space: inherit;
127
128
  }
128
129
 
@@ -170,6 +171,12 @@
170
171
  display: inline-block;
171
172
  }
172
173
 
174
+ /* Atomic letter boxes otherwise take their visual ordering from the surrounding
175
+ paragraph. Isolate each word's own dir=auto while keeping it inline/wrappable. */
176
+ .blyrics--word.blyrics-word--lettered {
177
+ unicode-bidi: isolate;
178
+ }
179
+
173
180
  .blyrics-word-highlight.blyrics-word-highlight--lettered {
174
181
  background-image: none;
175
182
  }
@@ -1,4 +1,9 @@
1
1
  :root {
2
+ /* Optional image paint; themes own the asset and any HDR brightness policy. */
3
+ --blyrics-highlight-image: none;
4
+ --blyrics-image-glow-opacity-from: 0.5;
5
+ --blyrics-image-glow-opacity-to: 0;
6
+
2
7
  /* Colors */
3
8
  --blyrics-ui-text-color: var(
4
9
  --blyrics-text-color,
package/dist/text.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Script detection for the text the module renders. Pure string work, no DOM and no host state.
2
- export const testRtl = (text) => /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}]/u.test(text);
2
+ export const testRtl = (text) => /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Mandaic}\p{Script=Hanifi_Rohingya}\p{Script=Samaritan}\p{Script=Yezidi}\p{Script=Mende_Kikakui}\p{Script=Old_Hungarian}]/u.test(text);
3
3
  export const testJoiningScript = (text) => /[\p{Script_Extensions=Arabic}\p{Script_Extensions=Syriac}\p{Script_Extensions=Nko}\p{Script_Extensions=Mongolian}\p{Script_Extensions=Adlam}\p{Script_Extensions=Mandaic}\p{Script_Extensions=Hanifi_Rohingya}]/u.test(text);
4
4
  /**
5
5
  * This regex is designed to detect any characters that are outside of the
package/dist/view.js CHANGED
@@ -12,6 +12,7 @@
12
12
  import { CREDITS_CLASS, CREDITS_NAMES_CLASS, CREDITS_TEXT_CLASS, LINE_CLASS, LYRICS_CLASS, RTL_CLASS, } from "./constants.js";
13
13
  import { setupLineCullObserver } from "./engine.js";
14
14
  import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine, deriveSyncType, disableRichsync, findNearestAgent, hideCredits, isNearestLyricRtl, newLineData, } from "./inject.js";
15
+ import { refreshInstrumentalImages } from "./imageHighlights.js";
15
16
  import { createInstrumentalElement } from "./instrumental.js";
16
17
  import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
17
18
  const INITIAL_SKIP_SCROLLS = 2;
@@ -112,6 +113,7 @@ export function setLyrics(engine, mount, lyrics, options) {
112
113
  engine.lines = lines;
113
114
  engine.lyricsContainer = container;
114
115
  engine.syncType = syncType;
116
+ refreshInstrumentalImages(container, engine.window);
115
117
  // Measured last: the container is in the document, filled, and carrying the attributes CSS
116
118
  // sizes it by.
117
119
  engine.lyricWidth = container.clientWidth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",