@braccato/core 1.16.6 → 1.16.8

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
@@ -328,6 +328,35 @@ It is written only when a word's state changes rather than every frame, so selec
328
328
  theme nothing per tick. A line synced word, which has no duration of its own, is `active` from its
329
329
  start until the next word begins.
330
330
 
331
+ ### Browser engine
332
+
333
+ On Firefox the container carries `data-browser-engine="gecko"`. The renderer's Gecko-only CSS keys on
334
+ it, and a theme can do the same to target Firefox without sniffing the user agent. It is detected from
335
+ a feature only Gecko ships, so forks of Firefox carry it too. Script reads the same answer from
336
+ `isGecko()` in `@braccato/core/util`, which the renderer's Gecko-only script checks once on load.
337
+
338
+ ```css
339
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--line {
340
+ /* Firefox only */
341
+ }
342
+ ```
343
+
344
+ The letter wave uses both. WebRender rounds text moved by a 2D translation to whole device
345
+ pixels, and it only redraws a cached tile once something on it has moved a quarter of a pixel, so the
346
+ wave stepped on Firefox. Lettered words on animating lines get a distant `perspective` there, and each
347
+ base letter in the wave runs an invisible additive opacity ripple that changes every frame and forces the
348
+ redraw. Chromium gets neither.
349
+
350
+ The playback clock checks `isGecko()`. Firefox advances a media element's `currentTime` about every 40ms, so
351
+ each read lags the true time by a different amount and never leads it. The renderer took each new lag
352
+ for drift and reseeked the line, so the highlight swipe jumped back mid-word. On Firefox the tick keeps
353
+ its own clock instead: a read ahead of it is taken, a read behind it pulls it back by a tenth per
354
+ second, and a read more than 60ms off, about one Firefox step plus margin, counts as a seek.
355
+
356
+ The instrumental fill uses the attribute. Gecko resolves a `fill-box` transform origin wrongly for a shape
357
+ inside a `clipPath`, so the flattening wave crest came loose from the rising fill. On Firefox the wave
358
+ scales from its floor in user units instead, which Chromium already renders identically.
359
+
331
360
  ### Class names
332
361
 
333
362
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -428,7 +457,7 @@ Four leaves import nothing at all, so taking one does not pull the engine into y
428
457
  - `@braccato/core/constants` for the class names and element ids above
429
458
  - `@braccato/core/text` for script detection: `testRtl`, `containsNonLatin`, `detectNonLatinLanguage`
430
459
  - `@braccato/core/themeSettings` for `parseThemeConfig`
431
- - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`
460
+ - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`, and `isGecko` for engine-specific fixes
432
461
 
433
462
  Two notes on the element entry point. A browser extension's isolated world has no custom element
434
463
  registry, so `window.customElements` is null there and importing this file throws where it registers.
@@ -557,5 +586,7 @@ For real-browser regression checks, build the package, serve the repository root
557
586
  `tooling/browser/image-highlights.html`. Import `image-highlights-checks.js` from that page and
558
587
  call `runImageHighlightChecks(testView)`. The default fixture uses an SDR image; a `theme` query
559
588
  parameter can point at a consumer's HDR stylesheet for physical-display testing.
589
+ `runLetterInkChecks(testView)` in the same module checks that letter masks cover glyph overhang,
590
+ such as a `j`'s hook or an italic `f`, without moving any letter.
560
591
 
561
592
  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
@@ -328,6 +328,35 @@ It is written only when a word's state changes rather than every frame, so selec
328
328
  theme nothing per tick. A line synced word, which has no duration of its own, is `active` from its
329
329
  start until the next word begins.
330
330
 
331
+ ### Browser engine
332
+
333
+ On Firefox the container carries `data-browser-engine="gecko"`. The renderer's Gecko-only CSS keys on
334
+ it, and a theme can do the same to target Firefox without sniffing the user agent. It is detected from
335
+ a feature only Gecko ships, so forks of Firefox carry it too. Script reads the same answer from
336
+ `isGecko()` in `@braccato/core/util`, which the renderer's Gecko-only script checks once on load.
337
+
338
+ ```css
339
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--line {
340
+ /* Firefox only */
341
+ }
342
+ ```
343
+
344
+ The letter wave uses both. WebRender rounds text moved by a 2D translation to whole device
345
+ pixels, and it only redraws a cached tile once something on it has moved a quarter of a pixel, so the
346
+ wave stepped on Firefox. Lettered words on animating lines get a distant `perspective` there, and each
347
+ base letter in the wave runs an invisible additive opacity ripple that changes every frame and forces the
348
+ redraw. Chromium gets neither.
349
+
350
+ The playback clock checks `isGecko()`. Firefox advances a media element's `currentTime` about every 40ms, so
351
+ each read lags the true time by a different amount and never leads it. The renderer took each new lag
352
+ for drift and reseeked the line, so the highlight swipe jumped back mid-word. On Firefox the tick keeps
353
+ its own clock instead: a read ahead of it is taken, a read behind it pulls it back by a tenth per
354
+ second, and a read more than 60ms off, about one Firefox step plus margin, counts as a seek.
355
+
356
+ The instrumental fill uses the attribute. Gecko resolves a `fill-box` transform origin wrongly for a shape
357
+ inside a `clipPath`, so the flattening wave crest came loose from the rising fill. On Firefox the wave
358
+ scales from its floor in user units instead, which Chromium already renders identically.
359
+
331
360
  ### Class names
332
361
 
333
362
  These are published API rather than implementation. Renaming one costs a migration rather than a
@@ -428,7 +457,7 @@ Four leaves import nothing at all, so taking one does not pull the engine into y
428
457
  - `@braccato/core/constants` for the class names and element ids above
429
458
  - `@braccato/core/text` for script detection: `testRtl`, `containsNonLatin`, `detectNonLatinLanguage`
430
459
  - `@braccato/core/themeSettings` for `parseThemeConfig`
431
- - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`
460
+ - `@braccato/core/util` for pure helpers such as `clamp` and `toMs`, and `isGecko` for engine-specific fixes
432
461
 
433
462
  Two notes on the element entry point. A browser extension's isolated world has no custom element
434
463
  registry, so `window.customElements` is null there and importing this file throws where it registers.
@@ -557,5 +586,7 @@ For real-browser regression checks, build the package, serve the repository root
557
586
  `tooling/browser/image-highlights.html`. Import `image-highlights-checks.js` from that page and
558
587
  call `runImageHighlightChecks(testView)`. The default fixture uses an SDR image; a `theme` query
559
588
  parameter can point at a consumer's HDR stylesheet for physical-display testing.
589
+ `runLetterInkChecks(testView)` in the same module checks that letter masks cover glyph overhang,
590
+ such as a `j`'s hook or an italic `f`, without moving any letter.
560
591
 
561
592
  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
@@ -98,6 +98,7 @@ export declare function createAnimationEngineInstance(engineDocument: Document,
98
98
  * rather than as a jump away from the previous one.
99
99
  */
100
100
  export declare function resetPlaybackClock(): void;
101
+ export declare function smoothedPlaybackTime(previousTime: number, previousAt: number, rawTime: number, now: number, playbackRate: number): number;
101
102
  /**
102
103
  * The user asked for autoscroll back, now.
103
104
  */
package/dist/engine.js CHANGED
@@ -20,7 +20,7 @@ import { ANIMATING_CLASS, CREDITS_CLASS, CURRENT_LYRICS_CLASS, FOOTER_CLASS, LIN
20
20
  import { INSTRUMENTAL_WAVE_PATH_HIGH, INSTRUMENTAL_WAVE_PATH_LOW } from "./instrumental.js";
21
21
  import { layoutStage, planStage, queuedStageY, stageTextSpan, } from "./stage.js";
22
22
  import { registerThemeSetting } from "./themeSettings.js";
23
- import { clamp, getRelativeLayoutBounds, positiveModulo, roundedMs, toMs } from "./util.js";
23
+ import { clamp, getRelativeLayoutBounds, isGecko, positiveModulo, roundedMs, toMs } from "./util.js";
24
24
  const NO_LYRICS_ELEMENT_LOG = "No lyrics element found on the page, skipping lyrics injection";
25
25
  const LYRICS_CHECK_INTERVAL_ERROR = "Error in lyrics check interval:";
26
26
  const PAUSING_LYRICS_SCROLL_LOG = "Pausing Lyrics Autoscroll Due to User Scroll";
@@ -29,6 +29,11 @@ const PASSIVE_USER_SCROLL_RESUME_DELAY_MS = 5000;
29
29
  const LYRIC_ENDING_THRESHOLD_S = registerThemeSetting("blyrics-lyric-ending-threshold-s", 0.5);
30
30
  const EARLY_SCROLL_CONSIDER = registerThemeSetting("blyrics-early-scroll-consider-s", 0.54);
31
31
  const TIME_JUMP_THRESHOLD = 0.5;
32
+ // Gecko steps media time every ~40ms, so reads lag by a random amount (never lead) that reseeks lines as drift.
33
+ const SMOOTH_PLAYBACK_CLOCK = isGecko();
34
+ // One Gecko step (~45ms) plus margin: a read further off is a seek or a timer spike, not media lag.
35
+ const CLOCK_RESYNC_THRESHOLD_S = 0.06;
36
+ const CLOCK_SLEW_PER_S = 0.1;
32
37
  const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
33
38
  const SCROLL_PREPARE_LEAD_MS = 120;
34
39
  const LINE_SCROLL_FALLBACK_DURATION_MS = 750;
@@ -185,6 +190,8 @@ const playbackClock = {
185
190
  lastTime: 0,
186
191
  lastPlayState: false,
187
192
  lastEventCreationTime: NO_PLAYER_SNAPSHOT,
193
+ smoothedTime: 0,
194
+ smoothedAt: NO_PLAYER_SNAPSHOT,
188
195
  };
189
196
  /**
190
197
  * Forgets the last player snapshot, so the next tick is treated as the first one of a new song
@@ -194,6 +201,17 @@ export function resetPlaybackClock() {
194
201
  playbackClock.lastTime = 0;
195
202
  playbackClock.lastPlayState = false;
196
203
  playbackClock.lastEventCreationTime = NO_PLAYER_SNAPSHOT;
204
+ playbackClock.smoothedAt = NO_PLAYER_SNAPSHOT;
205
+ }
206
+ export function smoothedPlaybackTime(previousTime, previousAt, rawTime, now, playbackRate) {
207
+ if (previousAt === NO_PLAYER_SNAPSHOT)
208
+ return rawTime;
209
+ const elapsedS = Math.max(0, now - previousAt) / 1000;
210
+ const predictedTime = previousTime + elapsedS * playbackRate;
211
+ const errorS = rawTime - predictedTime;
212
+ if (errorS >= 0 || Math.abs(errorS) >= CLOCK_RESYNC_THRESHOLD_S)
213
+ return rawTime;
214
+ return predictedTime + errorS * Math.min(1, elapsedS * CLOCK_SLEW_PER_S);
197
215
  }
198
216
  // -- View operations --------------------------
199
217
  /**
@@ -847,6 +865,11 @@ function swipeRamp(config) {
847
865
  }
848
866
  // How far through its float a letter crests, as a keyframe offset.
849
867
  const LETTER_WAVE_CREST_OFFSET = 0.4;
868
+ // WebRender redraws a cached tile only after a quarter device pixel of movement, so Gecko letters step.
869
+ // An invisible additive opacity ripple changes every frame and forces the redraw.
870
+ const GECKO_REDRAW = isGecko();
871
+ const GECKO_REDRAW_KEYFRAMES = [{ opacity: 0 }, { opacity: -0.002 }, { opacity: 0 }];
872
+ const GECKO_REDRAW_PERIOD_MS = 200;
850
873
  // When each letter's wave starts, counted from the start of the word, and how long it lasts. A letter
851
874
  // crests just as the swipe finishes lighting it. If the swipe lights it faster than the float rises,
852
875
  // it starts early enough to make that, which on a short word means before the word itself does. If the
@@ -1078,6 +1101,16 @@ function startWordAnimations(engine, part, config, currentTime, appliedTimingOff
1078
1101
  });
1079
1102
  animation.currentTime = floatStartMs;
1080
1103
  wobbleAnimations.push(animation);
1104
+ if (!GECKO_REDRAW || !sampled)
1105
+ return;
1106
+ const redraw = trackLyricAnimationTiming(engine, letterElement.animate(GECKO_REDRAW_KEYFRAMES, {
1107
+ duration: GECKO_REDRAW_PERIOD_MS,
1108
+ iterations: waves[index].durationMs / GECKO_REDRAW_PERIOD_MS,
1109
+ delay: delayMs,
1110
+ composite: "add",
1111
+ }), { appliedTimingOffsetMs, offsetMs: waveLeadMs, skipDriftSample: true });
1112
+ redraw.currentTime = floatStartMs;
1113
+ wobbleAnimations.push(redraw);
1081
1114
  });
1082
1115
  };
1083
1116
  animateLetters(part.letterElements, Infinity, true);
@@ -2286,6 +2319,13 @@ export function tickView(engine, currentTime, options) {
2286
2319
  timeOffset = 0;
2287
2320
  }
2288
2321
  currentTime += timeOffset / 1000;
2322
+ if (SMOOTH_PLAYBACK_CLOCK) {
2323
+ if (isPlaying && !timeJumped) {
2324
+ currentTime = smoothedPlaybackTime(playbackClock.smoothedTime, playbackClock.smoothedAt, currentTime, now, engine.playbackRate);
2325
+ }
2326
+ playbackClock.smoothedTime = currentTime;
2327
+ playbackClock.smoothedAt = isPlaying ? now : NO_PLAYER_SNAPSHOT;
2328
+ }
2289
2329
  if (!engine.host.isViewVisible()) {
2290
2330
  clearVisibleLyricWillChange(engine);
2291
2331
  return "ok";
@@ -17,6 +17,11 @@
17
17
  color: transparent;
18
18
  -webkit-text-fill-color: transparent;
19
19
  }
20
+ /* The content-box copy keeps each letter's image; the padding-box copy carries it under glyph overhang. */
21
+ .blyrics-word-highlight.blyrics-image-highlight .blyrics--letter {
22
+ background-image: var(--blyrics-highlight-image, none), var(--blyrics-highlight-image, none);
23
+ background-origin: content-box, padding-box;
24
+ }
20
25
  .blyrics-image-highlight:not(.blyrics-word-highlight--lettered) .blyrics-image-fill {
21
26
  --blyrics-image-sweep-angle: 90deg;
22
27
  mask-image: linear-gradient(var(--blyrics-image-sweep-angle),
@@ -68,6 +68,12 @@
68
68
  d: var(--blyrics-instrumental-wave-path-high);
69
69
  }
70
70
 
71
+ /* Gecko misplaces a fill-box origin inside a clipPath, so pin the wave's floor (y=4) in user units. */
72
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--wave-path {
73
+ transform-box: view-box;
74
+ transform-origin: 0 4px;
75
+ }
76
+
71
77
  /* Trigger the Flattening */
72
78
  .blyrics--instrumental.blyrics--animating:not(.blyrics--paused) .blyrics--wave-path {
73
79
  /* Flatten the wave to 0 height */
@@ -177,11 +177,22 @@
177
177
  unicode-bidi: isolate;
178
178
  }
179
179
 
180
+ /* WebRender snaps 2D-translated glyphs to whole pixels; perspective rasterizes them in local space. */
181
+ .blyrics-container[data-browser-engine="gecko"] .blyrics--animating .blyrics-word--lettered {
182
+ perspective: 100000px;
183
+ }
184
+
180
185
  .blyrics-word-highlight.blyrics-word-highlight--lettered {
181
186
  background-image: none;
182
187
  }
183
188
 
189
+ /* A mask clips to the border box, cutting off glyph overhang (a j's hook, italics). The padding
190
+ widens that clip without moving layout, and the content-box origin keeps the sweep geometry. */
184
191
  .blyrics-word-highlight .blyrics--letter {
192
+ padding-inline: 0.25em;
193
+ margin-inline: -0.25em;
194
+ -webkit-mask-origin: content-box;
195
+ mask-origin: content-box;
185
196
  color: var(--blyrics-lyric-active-color);
186
197
  -webkit-mask-image: linear-gradient(90deg, #000 0, #000 50%, #0000 calc(50% + var(--mask-fade, 3%)));
187
198
  mask-image: linear-gradient(90deg, #000 0, #000 50%, #0000 calc(50% + var(--mask-fade, 3%)));
package/dist/util.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export declare function clamp(value: number, min: number, max: number): number;
2
2
  export declare function positiveModulo(value: number, divisor: number): number;
3
+ /**
4
+ * Whether the page renders with Gecko (Firefox). Detects the engine by a feature only it ships, so
5
+ * spoofed user agents and Firefox forks resolve the same way.
6
+ */
7
+ export declare function isGecko(): boolean;
3
8
  export declare function roundedMs(value: number): number;
4
9
  /**
5
10
  * Converts CSS duration value to milliseconds.
package/dist/util.js CHANGED
@@ -4,6 +4,13 @@ export function clamp(value, min, max) {
4
4
  export function positiveModulo(value, divisor) {
5
5
  return ((value % divisor) + divisor) % divisor;
6
6
  }
7
+ /**
8
+ * Whether the page renders with Gecko (Firefox). Detects the engine by a feature only it ships, so
9
+ * spoofed user agents and Firefox forks resolve the same way.
10
+ */
11
+ export function isGecko() {
12
+ return typeof CSS !== "undefined" && CSS.supports("-moz-appearance", "none");
13
+ }
7
14
  export function roundedMs(value) {
8
15
  return Math.round(value * 10) / 10;
9
16
  }
package/dist/view.js CHANGED
@@ -15,6 +15,7 @@ import { addSeekHandler, applyDirection, buildLineSyncedParts, createLyricsLine,
15
15
  import { refreshInstrumentalImages } from "./imageHighlights.js";
16
16
  import { createInstrumentalElement } from "./instrumental.js";
17
17
  import { applyLyricLanguage, resolveLyricLanguages } from "./language.js";
18
+ import { isGecko } from "./util.js";
18
19
  const INITIAL_SKIP_SCROLLS = 2;
19
20
  const SKIP_SCROLL_DECAY_MS = 2000;
20
21
  function buildInstrumentalLine(doc, lyricElement, lyrics, lineIndex) {
@@ -73,6 +74,8 @@ export function setLyrics(engine, mount, lyrics, options) {
73
74
  const doc = engine.document;
74
75
  const container = doc.createElement("div");
75
76
  container.className = LYRICS_CLASS;
77
+ if (isGecko())
78
+ container.dataset.browserEngine = "gecko";
76
79
  mount.replaceChildren(container);
77
80
  const allZero = lyrics.every(item => item.startTimeMs === 0);
78
81
  const seek = (timeS) => engine.host.seek(timeS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@braccato/core",
3
- "version": "1.16.6",
3
+ "version": "1.16.8",
4
4
  "description": "Synchronized lyrics renderer with word-by-word animations",
5
5
  "type": "module",
6
6
  "license": "MIT",