@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 +32 -1
- package/dist/README.md +32 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.js +41 -1
- package/dist/styles/image-highlights.css +5 -0
- package/dist/styles/instrumental.css +6 -0
- package/dist/styles/lyrics.css +11 -0
- package/dist/util.d.ts +5 -0
- package/dist/util.js +7 -0
- package/dist/view.js +3 -0
- package/package.json +1 -1
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 */
|
package/dist/styles/lyrics.css
CHANGED
|
@@ -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);
|