@joewinke/jatui 0.11.0 → 0.12.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/dist/components/VideoPlayer.svelte +504 -8
- package/dist/components/VideoPlayer.svelte.d.ts +42 -1
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +1 -1
- package/dist/video/keys.d.ts +4 -4
- package/dist/video/keys.js +4 -3
- package/dist/video/night/clock.d.ts +31 -0
- package/dist/video/night/clock.js +58 -0
- package/dist/video/night/levels.d.ts +78 -0
- package/dist/video/night/levels.js +117 -0
- package/dist/video/night/meter.d.ts +53 -0
- package/dist/video/night/meter.js +115 -0
- package/dist/video/night/track.d.ts +105 -0
- package/dist/video/night/track.js +138 -0
- package/dist/video/night.d.ts +25 -20
- package/dist/video/night.js +25 -20
- package/dist/video/settings.js +10 -1
- package/package.json +1 -1
package/dist/video/README.md
CHANGED
|
@@ -21,7 +21,7 @@ props + callback surfaces.
|
|
|
21
21
|
| `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
|
|
22
22
|
| `settings.ts` | shared — knobs added by .30–.34 | `coerce` (never throws, never junk) + `VIDEO_SETTINGS` registry. A knob lands WITH its reader. |
|
|
23
23
|
| `audio.ts` | **.31** | Loudness matching, quiet listening, limiter — audio-graph contract is in the file header. |
|
|
24
|
-
| `night.ts` | **.33** |
|
|
24
|
+
| `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
|
|
25
25
|
| `presence.ts` | **.34** | Wind-down (Idle Detection), resume-at-sentence, FLIP open. |
|
|
26
26
|
| `advisors/` | **.27 (done)** | The five extension-library pure advisors, ported verbatim: `smart-speed` (silence map + boost), `speaker-rate` (measured wpm + suggested rates), `resume-point` (sentence-snapping + the label), `quiet-listening` (tone-only chain + `amountFor`), `qr` (ISO 18004 encoder v1–10). Each has its own README row in `advisors/README.md`; the `--self-test` suites live on as vitest in `tests/unit/advisors/` (`npm run test:advisors`). |
|
|
27
27
|
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
* until mos-5lzns.33; add it THERE, with its own row here), `,` (settings —
|
|
10
|
+
* library-app verbs, not player verbs), `,` (settings —
|
|
12
11
|
* jatui is prop-driven; a settings module arrives with the features that need
|
|
13
|
-
* one), `/` (transcript search — the pane has none yet).
|
|
12
|
+
* one), `/` (transcript search — the pane has none yet). N (night cycle)
|
|
13
|
+
* landed with the night module (mos-5lzns.33), gated on the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
export interface PlayerKeyRow {
|
|
16
16
|
/** Keycap label as shown in the help overlay — `/`-separated alternates. */
|
|
@@ -20,7 +20,7 @@ export interface PlayerKeyRow {
|
|
|
20
20
|
* Availability gate: the row is only advertised (and the key only bound)
|
|
21
21
|
* when the feature's data exists. Undefined ⇒ always active.
|
|
22
22
|
*/
|
|
23
|
-
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments';
|
|
23
|
+
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments' | 'night';
|
|
24
24
|
}
|
|
25
25
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
26
26
|
export declare const PLAYER_KEYS: PlayerKeyRow[];
|
package/dist/video/keys.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
* until mos-5lzns.33; add it THERE, with its own row here), `,` (settings —
|
|
10
|
+
* library-app verbs, not player verbs), `,` (settings —
|
|
12
11
|
* jatui is prop-driven; a settings module arrives with the features that need
|
|
13
|
-
* one), `/` (transcript search — the pane has none yet).
|
|
12
|
+
* one), `/` (transcript search — the pane has none yet). N (night cycle)
|
|
13
|
+
* landed with the night module (mos-5lzns.33), gated on the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
16
16
|
export const PLAYER_KEYS = [
|
|
@@ -28,6 +28,7 @@ export const PLAYER_KEYS = [
|
|
|
28
28
|
{ keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
|
|
29
29
|
{ keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
|
|
30
30
|
{ keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
31
|
+
{ keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
|
|
31
32
|
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
32
33
|
{ keys: '?', description: 'Toggle this help' }
|
|
33
34
|
];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What arms night dimming (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* The extension asked the desktop (hyprsunset's colour temperature) and fell
|
|
5
|
+
* back to the clock. A component in a web app has no desktop to ask, so the
|
|
6
|
+
* signal is re-based (docs/videos-module-viability.md §6): the LOCAL clock,
|
|
7
|
+
* plus a manual toggle. `prefers-color-scheme: dark` can only WIDEN the
|
|
8
|
+
* window (18:00–08:00 instead of 20:00–07:00), never be the trigger — a lot
|
|
9
|
+
* of desktops report dark around the clock.
|
|
10
|
+
*
|
|
11
|
+
* `N` cycles auto → on → off. `auto` follows the clock; `on` ignores it;
|
|
12
|
+
* `off` leaves no trace.
|
|
13
|
+
*/
|
|
14
|
+
export type NightMode = 'auto' | 'on' | 'off';
|
|
15
|
+
export declare const NIGHT_MODES: readonly NightMode[];
|
|
16
|
+
export interface NightWindow {
|
|
17
|
+
/** Hour (0–24, fractional ok) the night starts. */
|
|
18
|
+
from: number;
|
|
19
|
+
/** Hour the night ends (next morning when < from). */
|
|
20
|
+
to: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const NIGHT_WINDOW: Readonly<NightWindow>;
|
|
23
|
+
export declare const NIGHT_WINDOW_DARK: Readonly<NightWindow>;
|
|
24
|
+
/** Any unknown mode is treated as off — a bad value must never switch dimming ON. */
|
|
25
|
+
export declare function coerceNightMode(mode: unknown): NightMode;
|
|
26
|
+
export declare function nextNightMode(mode: unknown): NightMode;
|
|
27
|
+
/** Is `date` inside the night window? Handles windows that wrap midnight (and ones that don't). */
|
|
28
|
+
export declare function inNightWindow(date: Date, prefersDark?: boolean, win?: NightWindow | null): boolean;
|
|
29
|
+
export declare function nightArmed(mode: unknown, date: Date, prefersDark?: boolean, win?: NightWindow | null): boolean;
|
|
30
|
+
/** The OSD / aria text for a mode, as the user will read it. */
|
|
31
|
+
export declare function nightModeLabel(mode: unknown, armed: boolean): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What arms night dimming (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* The extension asked the desktop (hyprsunset's colour temperature) and fell
|
|
5
|
+
* back to the clock. A component in a web app has no desktop to ask, so the
|
|
6
|
+
* signal is re-based (docs/videos-module-viability.md §6): the LOCAL clock,
|
|
7
|
+
* plus a manual toggle. `prefers-color-scheme: dark` can only WIDEN the
|
|
8
|
+
* window (18:00–08:00 instead of 20:00–07:00), never be the trigger — a lot
|
|
9
|
+
* of desktops report dark around the clock.
|
|
10
|
+
*
|
|
11
|
+
* `N` cycles auto → on → off. `auto` follows the clock; `on` ignores it;
|
|
12
|
+
* `off` leaves no trace.
|
|
13
|
+
*/
|
|
14
|
+
export const NIGHT_MODES = ['auto', 'on', 'off'];
|
|
15
|
+
export const NIGHT_WINDOW = Object.freeze({ from: 20, to: 7 });
|
|
16
|
+
export const NIGHT_WINDOW_DARK = Object.freeze({ from: 18, to: 8 });
|
|
17
|
+
/** Any unknown mode is treated as off — a bad value must never switch dimming ON. */
|
|
18
|
+
export function coerceNightMode(mode) {
|
|
19
|
+
return mode === 'auto' || mode === 'on' || mode === 'off' ? mode : 'off';
|
|
20
|
+
}
|
|
21
|
+
export function nextNightMode(mode) {
|
|
22
|
+
const m = coerceNightMode(mode);
|
|
23
|
+
return NIGHT_MODES[(NIGHT_MODES.indexOf(m) + 1) % NIGHT_MODES.length];
|
|
24
|
+
}
|
|
25
|
+
/** Is `date` inside the night window? Handles windows that wrap midnight (and ones that don't). */
|
|
26
|
+
export function inNightWindow(date, prefersDark = false, win) {
|
|
27
|
+
const t = date instanceof Date ? date.getTime() : NaN;
|
|
28
|
+
if (!Number.isFinite(t))
|
|
29
|
+
return false;
|
|
30
|
+
const w = validWindow(win) ?? (prefersDark ? NIGHT_WINDOW_DARK : NIGHT_WINDOW);
|
|
31
|
+
const h = date.getHours() + date.getMinutes() / 60;
|
|
32
|
+
if (w.from === w.to)
|
|
33
|
+
return false;
|
|
34
|
+
return w.from > w.to ? h >= w.from || h < w.to : h >= w.from && h < w.to;
|
|
35
|
+
}
|
|
36
|
+
export function nightArmed(mode, date, prefersDark = false, win) {
|
|
37
|
+
const m = coerceNightMode(mode);
|
|
38
|
+
if (m === 'on')
|
|
39
|
+
return true;
|
|
40
|
+
if (m === 'off')
|
|
41
|
+
return false;
|
|
42
|
+
return inNightWindow(date, prefersDark, win);
|
|
43
|
+
}
|
|
44
|
+
/** The OSD / aria text for a mode, as the user will read it. */
|
|
45
|
+
export function nightModeLabel(mode, armed) {
|
|
46
|
+
const m = coerceNightMode(mode);
|
|
47
|
+
if (m === 'on')
|
|
48
|
+
return 'Night · always on';
|
|
49
|
+
if (m === 'off')
|
|
50
|
+
return 'Night · off';
|
|
51
|
+
return armed ? 'Night · auto, dimming now' : 'Night · auto, until dark';
|
|
52
|
+
}
|
|
53
|
+
function validWindow(w) {
|
|
54
|
+
if (!w)
|
|
55
|
+
return null;
|
|
56
|
+
const ok = (h) => typeof h === 'number' && Number.isFinite(h) && h >= 0 && h <= 24;
|
|
57
|
+
return ok(w.from) && ok(w.to) ? { from: w.from, to: w.to } : null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene luma → the night layers (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §Night dimming, `nightLevels()`: everything
|
|
5
|
+
* scales off `over` — how far past NIGHT_FLOOR the frame is — eased `over^0.75`
|
|
6
|
+
* so the first bit of brightness barely registers and the last bit lands hard.
|
|
7
|
+
*
|
|
8
|
+
* | level | formula | layer |
|
|
9
|
+
* |--------|------------------|---------------------------------------------------|
|
|
10
|
+
* | bright | 1 − 0.5·d·e | `filter: brightness()` on the video itself |
|
|
11
|
+
* | sat | 1 − 0.18·d·e | `saturate()` — bright AND loud is what stings |
|
|
12
|
+
* | warm | d·(0.1 + 0.24·e) | warm pane, `mix-blend-mode: multiply` |
|
|
13
|
+
* | vig | d·(0.2 + 0.34·e) | soft vignette, multiply |
|
|
14
|
+
*
|
|
15
|
+
* Every layer MULTIPLIES. A grey scrim on top raises the black point (blacks
|
|
16
|
+
* go grey, the picture fogs); multiplying by a warm colour leaves red intact
|
|
17
|
+
* and takes most of the blue. Blacks stay black.
|
|
18
|
+
*
|
|
19
|
+
* The strobe guard uses the `bright`/`sat` half only (`guardLevels`), at a
|
|
20
|
+
* FIXED depth of 1 whatever the night dial says: it is a photosensitivity
|
|
21
|
+
* guard, not night — it has no business warming the picture, and a comfort
|
|
22
|
+
* setting must never weaken it.
|
|
23
|
+
*/
|
|
24
|
+
/** Scenes dimmer than this are already easy on the eye; touching them is correction for its own sake. */
|
|
25
|
+
export declare const NIGHT_FLOOR = 0.34;
|
|
26
|
+
/** Depth 1 takes a full-white frame to about half brightness — lamp-in-a-dark-room. */
|
|
27
|
+
export declare const NIGHT_DEPTH_MIN = 0.2;
|
|
28
|
+
export declare const NIGHT_DEPTH_MAX = 1;
|
|
29
|
+
export declare const NIGHT_DEPTH_DEFAULT = 0.7;
|
|
30
|
+
export declare const NIGHT_DEPTH_STEP = 0.1;
|
|
31
|
+
/** Frame readback refused (tainted canvas) ⇒ run on this fixed level: dimming without a meter beats a white wall at 1am. */
|
|
32
|
+
export declare const NIGHT_BLIND_LUMA = 0.62;
|
|
33
|
+
export interface NightLevels {
|
|
34
|
+
bright: number;
|
|
35
|
+
sat: number;
|
|
36
|
+
warm: number;
|
|
37
|
+
vig: number;
|
|
38
|
+
}
|
|
39
|
+
export declare const NIGHT_NEUTRAL_LEVELS: Readonly<NightLevels>;
|
|
40
|
+
/** Clamp a depth knob; garbage ⇒ the default (never a junk level). */
|
|
41
|
+
export declare function clampDepth(depth: unknown): number;
|
|
42
|
+
/** One wheel/keyboard notch on the depth dial, rounded so repeated nudges don't drift. */
|
|
43
|
+
export declare function nudgeDepth(depth: unknown, dir: number): number;
|
|
44
|
+
/** 0 at/below the floor, eased toward 1 at full white. */
|
|
45
|
+
export declare function nightExcess(luma: number): number;
|
|
46
|
+
export declare function nightLevels(luma: number, depth: number): NightLevels;
|
|
47
|
+
/**
|
|
48
|
+
* The strobe guard's depth. FIXED at the dial's maximum and deliberately NOT
|
|
49
|
+
* the user's night depth: the dial is a comfort setting, the guard is a
|
|
50
|
+
* photosensitivity protection, and turning comfort down must never weaken it.
|
|
51
|
+
*/
|
|
52
|
+
export declare const GUARD_DEPTH = 1;
|
|
53
|
+
/** The strobe guard's half: the level drop only, always at GUARD_DEPTH. No warmth, no vignette. */
|
|
54
|
+
export declare function guardLevels(luma: number): NightLevels;
|
|
55
|
+
/** Per-channel strongest of two level sets — the guard and night stack, the deeper one wins. */
|
|
56
|
+
export declare function deeperLevels(a: NightLevels, b: NightLevels): NightLevels;
|
|
57
|
+
/** True when a set of levels would change no pixel — the caller then removes every trace. */
|
|
58
|
+
export declare function isNeutral(L: NightLevels, eps?: number): boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The surround — the player's own chrome around the picture.
|
|
61
|
+
*
|
|
62
|
+
* The extension's page layer assumed a dark app: dim the video and the room
|
|
63
|
+
* gets easier. On a LIGHT theme the player sits on a near-white surface, so
|
|
64
|
+
* dimming only the picture makes the screen-to-surround ratio WORSE — a
|
|
65
|
+
* dimmed video in a white frame. So on a light surface the surround is pulled
|
|
66
|
+
* toward the theme's neutral in proportion to how light it is and how deep
|
|
67
|
+
* the dial is; on a dark surface it is left alone (it is already the room).
|
|
68
|
+
*
|
|
69
|
+
* `surfaceLuma` is the surround colour's relative luminance, 0–1 (the
|
|
70
|
+
* component resolves it from the theme token). Returns the mix fraction
|
|
71
|
+
* (0 … SURROUND_MAX) toward neutral. Capped well short of dark: text sits on
|
|
72
|
+
* this surface, and after dark contrast is the one thing you still have.
|
|
73
|
+
*/
|
|
74
|
+
export declare const SURROUND_FROM = 0.35;
|
|
75
|
+
export declare const SURROUND_MAX = 0.3;
|
|
76
|
+
export declare function surroundLevel(surfaceLuma: number, depth: number): number;
|
|
77
|
+
/** Relative luminance (sRGB → linear, Rec.709 weights) of 0–255 channels. */
|
|
78
|
+
export declare function relativeLuminance(r: number, g: number, b: number): number;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene luma → the night layers (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §Night dimming, `nightLevels()`: everything
|
|
5
|
+
* scales off `over` — how far past NIGHT_FLOOR the frame is — eased `over^0.75`
|
|
6
|
+
* so the first bit of brightness barely registers and the last bit lands hard.
|
|
7
|
+
*
|
|
8
|
+
* | level | formula | layer |
|
|
9
|
+
* |--------|------------------|---------------------------------------------------|
|
|
10
|
+
* | bright | 1 − 0.5·d·e | `filter: brightness()` on the video itself |
|
|
11
|
+
* | sat | 1 − 0.18·d·e | `saturate()` — bright AND loud is what stings |
|
|
12
|
+
* | warm | d·(0.1 + 0.24·e) | warm pane, `mix-blend-mode: multiply` |
|
|
13
|
+
* | vig | d·(0.2 + 0.34·e) | soft vignette, multiply |
|
|
14
|
+
*
|
|
15
|
+
* Every layer MULTIPLIES. A grey scrim on top raises the black point (blacks
|
|
16
|
+
* go grey, the picture fogs); multiplying by a warm colour leaves red intact
|
|
17
|
+
* and takes most of the blue. Blacks stay black.
|
|
18
|
+
*
|
|
19
|
+
* The strobe guard uses the `bright`/`sat` half only (`guardLevels`), at a
|
|
20
|
+
* FIXED depth of 1 whatever the night dial says: it is a photosensitivity
|
|
21
|
+
* guard, not night — it has no business warming the picture, and a comfort
|
|
22
|
+
* setting must never weaken it.
|
|
23
|
+
*/
|
|
24
|
+
/** Scenes dimmer than this are already easy on the eye; touching them is correction for its own sake. */
|
|
25
|
+
export const NIGHT_FLOOR = 0.34;
|
|
26
|
+
/** Depth 1 takes a full-white frame to about half brightness — lamp-in-a-dark-room. */
|
|
27
|
+
export const NIGHT_DEPTH_MIN = 0.2;
|
|
28
|
+
export const NIGHT_DEPTH_MAX = 1;
|
|
29
|
+
export const NIGHT_DEPTH_DEFAULT = 0.7;
|
|
30
|
+
export const NIGHT_DEPTH_STEP = 0.1;
|
|
31
|
+
/** Frame readback refused (tainted canvas) ⇒ run on this fixed level: dimming without a meter beats a white wall at 1am. */
|
|
32
|
+
export const NIGHT_BLIND_LUMA = 0.62;
|
|
33
|
+
export const NIGHT_NEUTRAL_LEVELS = Object.freeze({ bright: 1, sat: 1, warm: 0, vig: 0 });
|
|
34
|
+
/** Clamp a depth knob; garbage ⇒ the default (never a junk level). */
|
|
35
|
+
export function clampDepth(depth) {
|
|
36
|
+
const d = typeof depth === 'number' ? depth : Number(depth);
|
|
37
|
+
if (!Number.isFinite(d))
|
|
38
|
+
return NIGHT_DEPTH_DEFAULT;
|
|
39
|
+
return Math.min(NIGHT_DEPTH_MAX, Math.max(NIGHT_DEPTH_MIN, d));
|
|
40
|
+
}
|
|
41
|
+
/** One wheel/keyboard notch on the depth dial, rounded so repeated nudges don't drift. */
|
|
42
|
+
export function nudgeDepth(depth, dir) {
|
|
43
|
+
const step = dir > 0 ? NIGHT_DEPTH_STEP : dir < 0 ? -NIGHT_DEPTH_STEP : 0;
|
|
44
|
+
return clampDepth(+(clampDepth(depth) + step).toFixed(2));
|
|
45
|
+
}
|
|
46
|
+
/** 0 at/below the floor, eased toward 1 at full white. */
|
|
47
|
+
export function nightExcess(luma) {
|
|
48
|
+
if (!Number.isFinite(luma))
|
|
49
|
+
return 0;
|
|
50
|
+
const over = Math.max(0, Math.min(1, luma) - NIGHT_FLOOR) / (1 - NIGHT_FLOOR);
|
|
51
|
+
return Math.pow(over, 0.75);
|
|
52
|
+
}
|
|
53
|
+
export function nightLevels(luma, depth) {
|
|
54
|
+
const e = nightExcess(luma);
|
|
55
|
+
const d = clampDepth(depth);
|
|
56
|
+
return {
|
|
57
|
+
bright: 1 - 0.5 * d * e,
|
|
58
|
+
sat: 1 - 0.18 * d * e,
|
|
59
|
+
warm: d * (0.1 + 0.24 * e),
|
|
60
|
+
vig: d * (0.2 + 0.34 * e)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The strobe guard's depth. FIXED at the dial's maximum and deliberately NOT
|
|
65
|
+
* the user's night depth: the dial is a comfort setting, the guard is a
|
|
66
|
+
* photosensitivity protection, and turning comfort down must never weaken it.
|
|
67
|
+
*/
|
|
68
|
+
export const GUARD_DEPTH = 1;
|
|
69
|
+
/** The strobe guard's half: the level drop only, always at GUARD_DEPTH. No warmth, no vignette. */
|
|
70
|
+
export function guardLevels(luma) {
|
|
71
|
+
const L = nightLevels(luma, GUARD_DEPTH);
|
|
72
|
+
return { bright: L.bright, sat: L.sat, warm: 0, vig: 0 };
|
|
73
|
+
}
|
|
74
|
+
/** Per-channel strongest of two level sets — the guard and night stack, the deeper one wins. */
|
|
75
|
+
export function deeperLevels(a, b) {
|
|
76
|
+
return {
|
|
77
|
+
bright: Math.min(a.bright, b.bright),
|
|
78
|
+
sat: Math.min(a.sat, b.sat),
|
|
79
|
+
warm: Math.max(a.warm, b.warm),
|
|
80
|
+
vig: Math.max(a.vig, b.vig)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** True when a set of levels would change no pixel — the caller then removes every trace. */
|
|
84
|
+
export function isNeutral(L, eps = 0.001) {
|
|
85
|
+
return (Math.abs(L.bright - 1) < eps && Math.abs(L.sat - 1) < eps && L.warm < eps && L.vig < eps);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The surround — the player's own chrome around the picture.
|
|
89
|
+
*
|
|
90
|
+
* The extension's page layer assumed a dark app: dim the video and the room
|
|
91
|
+
* gets easier. On a LIGHT theme the player sits on a near-white surface, so
|
|
92
|
+
* dimming only the picture makes the screen-to-surround ratio WORSE — a
|
|
93
|
+
* dimmed video in a white frame. So on a light surface the surround is pulled
|
|
94
|
+
* toward the theme's neutral in proportion to how light it is and how deep
|
|
95
|
+
* the dial is; on a dark surface it is left alone (it is already the room).
|
|
96
|
+
*
|
|
97
|
+
* `surfaceLuma` is the surround colour's relative luminance, 0–1 (the
|
|
98
|
+
* component resolves it from the theme token). Returns the mix fraction
|
|
99
|
+
* (0 … SURROUND_MAX) toward neutral. Capped well short of dark: text sits on
|
|
100
|
+
* this surface, and after dark contrast is the one thing you still have.
|
|
101
|
+
*/
|
|
102
|
+
export const SURROUND_FROM = 0.35;
|
|
103
|
+
export const SURROUND_MAX = 0.3;
|
|
104
|
+
export function surroundLevel(surfaceLuma, depth) {
|
|
105
|
+
if (!Number.isFinite(surfaceLuma) || surfaceLuma <= SURROUND_FROM)
|
|
106
|
+
return 0;
|
|
107
|
+
const light = Math.min(1, (surfaceLuma - SURROUND_FROM) / (1 - SURROUND_FROM));
|
|
108
|
+
return SURROUND_MAX * clampDepth(depth) * light;
|
|
109
|
+
}
|
|
110
|
+
/** Relative luminance (sRGB → linear, Rec.709 weights) of 0–255 channels. */
|
|
111
|
+
export function relativeLuminance(r, g, b) {
|
|
112
|
+
const lin = (c) => {
|
|
113
|
+
const s = Math.max(0, Math.min(255, Number.isFinite(c) ? c : 0)) / 255;
|
|
114
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
115
|
+
};
|
|
116
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
117
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The night meter + bias-light sampler (mos-5lzns.33) — pixel math only.
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §Night dimming / §Bias light. The component
|
|
5
|
+
* draws the current frame into a NIGHT_W × NIGHT_H canvas (576 px — an
|
|
6
|
+
* average does not get truer with more pixels, it just gets hotter) and hands
|
|
7
|
+
* the RGBA bytes here. This module never sees the <video> or the canvas: it
|
|
8
|
+
* takes an array of bytes and returns numbers, so it is node-testable and a
|
|
9
|
+
* tainted canvas is the COMPONENT's problem to escalate (getImageData throws
|
|
10
|
+
* there, before anything reaches this file).
|
|
11
|
+
*
|
|
12
|
+
* Bias light reads the SAME bytes the meter just read. That is the
|
|
13
|
+
* extension's hard-won rule: the sampler's canvas is only meaningful right
|
|
14
|
+
* after a fresh draw — read it any other time and it is empty, and an empty
|
|
15
|
+
* canvas reads as pitch black, which kills the glow.
|
|
16
|
+
*/
|
|
17
|
+
export declare const NIGHT_W = 32;
|
|
18
|
+
export declare const NIGHT_H = 18;
|
|
19
|
+
/** A pixel brighter than this counts as blown out. */
|
|
20
|
+
export declare const BLOWN_AT = 0.82;
|
|
21
|
+
/** How much say the blown-out share gets on top of the mean. */
|
|
22
|
+
export declare const BLOWN_WEIGHT = 0.25;
|
|
23
|
+
/** Smoothing: bright arrives fast (it is the thing that hurts)… */
|
|
24
|
+
export declare const METER_ATTACK = 0.5;
|
|
25
|
+
/** …and releases slowly, so a dark cut inside a bright video does not flash the level back up. */
|
|
26
|
+
export declare const METER_RELEASE = 0.12;
|
|
27
|
+
/** The meter's resting value before the first reading (extension parity). */
|
|
28
|
+
export declare const METER_START = 0.55;
|
|
29
|
+
/** Colour push for bias cells: an average is always muddier than the scene it came from. */
|
|
30
|
+
export declare const BIAS_SATURATE = 1.6;
|
|
31
|
+
type Bytes = ArrayLike<number>;
|
|
32
|
+
/**
|
|
33
|
+
* Rec.709 mean luma of an RGBA byte array, plus a term for the blown-out
|
|
34
|
+
* share: `min(1, mean + blownShare × 0.25)`. The mean alone under-reads the
|
|
35
|
+
* case that actually hurts — a dark page with one white pane in it.
|
|
36
|
+
* Returns null for anything that is not a whole number of RGBA pixels.
|
|
37
|
+
*/
|
|
38
|
+
export declare function frameLuma(px: Bytes | null | undefined): number | null;
|
|
39
|
+
/** One asymmetric smoothing step. A null/garbage reading leaves the value where it was. */
|
|
40
|
+
export declare function smoothMeter(prev: number, reading: number | null | undefined): number;
|
|
41
|
+
/**
|
|
42
|
+
* Nine cells (3×3, row-major) of the frame's own colours, each pushed toward
|
|
43
|
+
* saturation: `mean + (channel − mean) × 1.6`. A grey halo behind a video
|
|
44
|
+
* reads as fog, not light. Returns null on a buffer that does not match w×h.
|
|
45
|
+
*/
|
|
46
|
+
export declare function biasCells(px: Bytes | null | undefined, w?: number, h?: number): Array<[number, number, number]> | null;
|
|
47
|
+
/**
|
|
48
|
+
* The glow as one CSS background-image: eight radial gradients (every cell
|
|
49
|
+
* but the centre, which the video itself covers). These rgb() values are the
|
|
50
|
+
* FRAME's colours — data, like a speaker hue — not theme colours.
|
|
51
|
+
*/
|
|
52
|
+
export declare function biasGradient(cells: Array<[number, number, number]> | null | undefined): string;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The night meter + bias-light sampler (mos-5lzns.33) — pixel math only.
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §Night dimming / §Bias light. The component
|
|
5
|
+
* draws the current frame into a NIGHT_W × NIGHT_H canvas (576 px — an
|
|
6
|
+
* average does not get truer with more pixels, it just gets hotter) and hands
|
|
7
|
+
* the RGBA bytes here. This module never sees the <video> or the canvas: it
|
|
8
|
+
* takes an array of bytes and returns numbers, so it is node-testable and a
|
|
9
|
+
* tainted canvas is the COMPONENT's problem to escalate (getImageData throws
|
|
10
|
+
* there, before anything reaches this file).
|
|
11
|
+
*
|
|
12
|
+
* Bias light reads the SAME bytes the meter just read. That is the
|
|
13
|
+
* extension's hard-won rule: the sampler's canvas is only meaningful right
|
|
14
|
+
* after a fresh draw — read it any other time and it is empty, and an empty
|
|
15
|
+
* canvas reads as pitch black, which kills the glow.
|
|
16
|
+
*/
|
|
17
|
+
export const NIGHT_W = 32;
|
|
18
|
+
export const NIGHT_H = 18;
|
|
19
|
+
/** A pixel brighter than this counts as blown out. */
|
|
20
|
+
export const BLOWN_AT = 0.82;
|
|
21
|
+
/** How much say the blown-out share gets on top of the mean. */
|
|
22
|
+
export const BLOWN_WEIGHT = 0.25;
|
|
23
|
+
/** Smoothing: bright arrives fast (it is the thing that hurts)… */
|
|
24
|
+
export const METER_ATTACK = 0.5;
|
|
25
|
+
/** …and releases slowly, so a dark cut inside a bright video does not flash the level back up. */
|
|
26
|
+
export const METER_RELEASE = 0.12;
|
|
27
|
+
/** The meter's resting value before the first reading (extension parity). */
|
|
28
|
+
export const METER_START = 0.55;
|
|
29
|
+
/** Colour push for bias cells: an average is always muddier than the scene it came from. */
|
|
30
|
+
export const BIAS_SATURATE = 1.6;
|
|
31
|
+
/**
|
|
32
|
+
* Rec.709 mean luma of an RGBA byte array, plus a term for the blown-out
|
|
33
|
+
* share: `min(1, mean + blownShare × 0.25)`. The mean alone under-reads the
|
|
34
|
+
* case that actually hurts — a dark page with one white pane in it.
|
|
35
|
+
* Returns null for anything that is not a whole number of RGBA pixels.
|
|
36
|
+
*/
|
|
37
|
+
export function frameLuma(px) {
|
|
38
|
+
if (!px || typeof px.length !== 'number' || px.length < 4 || px.length % 4 !== 0)
|
|
39
|
+
return null;
|
|
40
|
+
let sum = 0;
|
|
41
|
+
let blown = 0;
|
|
42
|
+
const n = px.length / 4;
|
|
43
|
+
for (let i = 0; i < px.length; i += 4) {
|
|
44
|
+
const r = px[i], g = px[i + 1], b = px[i + 2];
|
|
45
|
+
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b))
|
|
46
|
+
return null;
|
|
47
|
+
const l = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
48
|
+
sum += l;
|
|
49
|
+
if (l > BLOWN_AT)
|
|
50
|
+
blown++;
|
|
51
|
+
}
|
|
52
|
+
return clamp01(sum / n + (blown / n) * BLOWN_WEIGHT);
|
|
53
|
+
}
|
|
54
|
+
/** One asymmetric smoothing step. A null/garbage reading leaves the value where it was. */
|
|
55
|
+
export function smoothMeter(prev, reading) {
|
|
56
|
+
const p = Number.isFinite(prev) ? clamp01(prev) : METER_START;
|
|
57
|
+
if (typeof reading !== 'number' || !Number.isFinite(reading))
|
|
58
|
+
return p;
|
|
59
|
+
const l = clamp01(reading);
|
|
60
|
+
return p + (l - p) * (l > p ? METER_ATTACK : METER_RELEASE);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Nine cells (3×3, row-major) of the frame's own colours, each pushed toward
|
|
64
|
+
* saturation: `mean + (channel − mean) × 1.6`. A grey halo behind a video
|
|
65
|
+
* reads as fog, not light. Returns null on a buffer that does not match w×h.
|
|
66
|
+
*/
|
|
67
|
+
export function biasCells(px, w = NIGHT_W, h = NIGHT_H) {
|
|
68
|
+
if (!px || !(w >= 3) || !(h >= 3) || px.length !== w * h * 4)
|
|
69
|
+
return null;
|
|
70
|
+
const cells = [];
|
|
71
|
+
for (let cy = 0; cy < 3; cy++) {
|
|
72
|
+
for (let cx = 0; cx < 3; cx++) {
|
|
73
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
74
|
+
const x0 = Math.floor((cx * w) / 3), x1 = Math.floor(((cx + 1) * w) / 3);
|
|
75
|
+
const y0 = Math.floor((cy * h) / 3), y1 = Math.floor(((cy + 1) * h) / 3);
|
|
76
|
+
for (let y = y0; y < y1; y++) {
|
|
77
|
+
for (let x = x0; x < x1; x++) {
|
|
78
|
+
const i = (y * w + x) * 4;
|
|
79
|
+
r += px[i];
|
|
80
|
+
g += px[i + 1];
|
|
81
|
+
b += px[i + 2];
|
|
82
|
+
n++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!n)
|
|
86
|
+
return null;
|
|
87
|
+
const avg = [r / n, g / n, b / n];
|
|
88
|
+
const mean = (avg[0] + avg[1] + avg[2]) / 3;
|
|
89
|
+
const push = avg.map((c) => Math.round(Math.max(0, Math.min(255, mean + (c - mean) * BIAS_SATURATE))));
|
|
90
|
+
cells.push([push[0], push[1], push[2]]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return cells;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The glow as one CSS background-image: eight radial gradients (every cell
|
|
97
|
+
* but the centre, which the video itself covers). These rgb() values are the
|
|
98
|
+
* FRAME's colours — data, like a speaker hue — not theme colours.
|
|
99
|
+
*/
|
|
100
|
+
export function biasGradient(cells) {
|
|
101
|
+
if (!cells || cells.length !== 9)
|
|
102
|
+
return '';
|
|
103
|
+
const at = ['0% 0%', '50% 0%', '100% 0%', '0% 50%', '', '100% 50%', '0% 100%', '50% 100%', '100% 100%'];
|
|
104
|
+
const parts = [];
|
|
105
|
+
for (let i = 0; i < 9; i++) {
|
|
106
|
+
if (i === 4)
|
|
107
|
+
continue;
|
|
108
|
+
const [r, g, b] = cells[i];
|
|
109
|
+
parts.push(`radial-gradient(60% 60% at ${at[i]}, rgb(${r} ${g} ${b}) 0%, transparent 70%)`);
|
|
110
|
+
}
|
|
111
|
+
return parts.join(', ');
|
|
112
|
+
}
|
|
113
|
+
function clamp01(v) {
|
|
114
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
115
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predictive dimming + the strobe guard + the composer (mos-5lzns.33).
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §The light track. The live meter is honest but
|
|
5
|
+
* always a beat late: the bright frame reaches your eye, THEN the level comes
|
|
6
|
+
* down. With the host's pre-scanned light track (the same `luma` prop the
|
|
7
|
+
* ribbon paints — band/luma.ts) the player reads ahead and starts easing
|
|
8
|
+
* BEFORE the cut lands.
|
|
9
|
+
*
|
|
10
|
+
* Two rules carry the whole design:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Prediction can only ever ADD dimming.** `composeNight` takes the max of
|
|
13
|
+
* the meter and the prediction, so a stale or wrong track degrades to the
|
|
14
|
+
* live meter alone — never to a brighter picture than the meter asked for.
|
|
15
|
+
* 2. **Nothing here ever brightens in a jump.** Darkening lands at once (a
|
|
16
|
+
* sudden drop in light is never the photosensitivity hazard; the CSS ramp
|
|
17
|
+
* eases it anyway). Brightening is released a fraction per tick, so the end
|
|
18
|
+
* of a predicted cut or a flash interval can't snap the level back up —
|
|
19
|
+
* even under `prefers-reduced-motion`, where the CSS ramp shrinks to a
|
|
20
|
+
* short luminance fade.
|
|
21
|
+
*
|
|
22
|
+
* The strobe guard widens each detected flash interval by a lead and a tail
|
|
23
|
+
* and pins its own level to FLASH_LUMA at a FIXED depth of 1 across it —
|
|
24
|
+
* independent of the night dial and of whether night is on at all. The lead
|
|
25
|
+
* also covers one tick of look-ahead (`horizonSec`), because a guard that
|
|
26
|
+
* arrives after the strobe is commentary rather than protection. It REDUCES a
|
|
27
|
+
* flash; it does not make content WCAG 2.3.1-safe, and detecting the flashes
|
|
28
|
+
* is the host's job (the sidecar's `flashes` intervals).
|
|
29
|
+
*/
|
|
30
|
+
import { type LumaSeries, type VideoLumaTrack } from '../band/luma';
|
|
31
|
+
import { type NightLevels } from './levels';
|
|
32
|
+
/** Seconds of look-ahead: long enough to ease, short enough to still be about the moment you are in. */
|
|
33
|
+
export declare const LUMA_LOOKAHEAD = 0.9;
|
|
34
|
+
/** The guard is already down this long before the first flash… */
|
|
35
|
+
export declare const FLASH_LEAD = 0.4;
|
|
36
|
+
/** …and stays down this long after the last. */
|
|
37
|
+
export declare const FLASH_TAIL = 0.3;
|
|
38
|
+
/** The level the guard pins to across a flash interval. */
|
|
39
|
+
export declare const FLASH_LUMA = 0.92;
|
|
40
|
+
/** Fraction of the gap closed per tick when the level is coming back UP. */
|
|
41
|
+
export declare const OUTPUT_RELEASE = 0.25;
|
|
42
|
+
/**
|
|
43
|
+
* Peak brightness due over the next LUMA_LOOKAHEAD (+ one tick's horizon),
|
|
44
|
+
* 0–1, or null without a track. Reuses the ribbon's own max-per-interval
|
|
45
|
+
* reader so the ribbon and the prediction can never disagree.
|
|
46
|
+
*/
|
|
47
|
+
export declare function lumaAhead(series: LumaSeries | null | undefined, t: number, horizonSec?: number): number | null;
|
|
48
|
+
/**
|
|
49
|
+
* Validate the sidecar's flash intervals: finite, ordered pairs, sorted by
|
|
50
|
+
* start. Garbage entries are dropped (never guessed at) — a missing guard is
|
|
51
|
+
* degraded, a guard in the wrong place is wrong.
|
|
52
|
+
*/
|
|
53
|
+
export declare function normalizeFlashes(track: VideoLumaTrack | null | undefined): Array<[number, number]>;
|
|
54
|
+
/**
|
|
55
|
+
* Is the guard down at `t`? True inside `[start − lead, end + tail]` of any
|
|
56
|
+
* interval, where the lead is widened by `horizonSec` (the gap until the next
|
|
57
|
+
* evaluation) so a coarse tick can't let the first flash through.
|
|
58
|
+
*/
|
|
59
|
+
export declare function inFlash(flashes: ReadonlyArray<[number, number]>, t: number, horizonSec?: number): boolean;
|
|
60
|
+
export interface NightInput {
|
|
61
|
+
/** Night dimming is armed right now (mode on, or auto inside the window). */
|
|
62
|
+
armed: boolean;
|
|
63
|
+
/** Frame readback was refused — run on NIGHT_BLIND_LUMA instead of the meter. */
|
|
64
|
+
blind: boolean;
|
|
65
|
+
/** The smoothed live meter, 0–1. */
|
|
66
|
+
meter: number;
|
|
67
|
+
/** Predicted peak ahead (null = no track / prediction off). */
|
|
68
|
+
ahead: number | null;
|
|
69
|
+
/** The strobe guard is down (host-detected flash interval, strobeGuard on). */
|
|
70
|
+
flash: boolean;
|
|
71
|
+
/** The user's night depth — night only; the guard never reads it. */
|
|
72
|
+
depth: number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The composer's running state — two independent levels. Night's and the
|
|
76
|
+
* guard's are kept apart so each releases on its own terms: a guard ending
|
|
77
|
+
* inside a lighter night must not hand the picture straight back to night's
|
|
78
|
+
* (shallower) level in one step.
|
|
79
|
+
*/
|
|
80
|
+
export interface NightState {
|
|
81
|
+
/** Night's applied luma. */
|
|
82
|
+
luma: number;
|
|
83
|
+
/** The guard's applied luma (FLASH_LUMA while down, released after). */
|
|
84
|
+
guardLuma: number;
|
|
85
|
+
}
|
|
86
|
+
export declare const NIGHT_IDLE: Readonly<NightState>;
|
|
87
|
+
export interface NightFrame extends NightState {
|
|
88
|
+
levels: NightLevels;
|
|
89
|
+
/** Which surface is acting: night (all four layers), the guard alone (level drop only), or nothing. */
|
|
90
|
+
mode: 'night' | 'guard' | 'none';
|
|
91
|
+
/** The guard is down right now — the component switches to the fast ramp and freezes the bias light. */
|
|
92
|
+
guard: boolean;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* One tick of the composer. The component owns the state (feed the frame
|
|
96
|
+
* back as `prev`) and the application; this decides. `mode: 'none'` ⇒ remove
|
|
97
|
+
* every trace.
|
|
98
|
+
*
|
|
99
|
+
* Night: target = max(meter or blind level, prediction) at the user's depth.
|
|
100
|
+
* Guard: target = FLASH_LUMA at GUARD_DEPTH (1) while down, whatever night is
|
|
101
|
+
* doing — armed, disarmed or off. The two stack per channel, the deeper wins.
|
|
102
|
+
* Each level lands darker at once and releases brighter by OUTPUT_RELEASE per
|
|
103
|
+
* tick.
|
|
104
|
+
*/
|
|
105
|
+
export declare function composeNight(prev: Partial<NightState> | null | undefined, input: NightInput): NightFrame;
|