@joewinke/jatui 0.11.0 → 0.13.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.
@@ -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;
@@ -0,0 +1,138 @@
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 { lumaPeak } from '../band/luma';
31
+ import { deeperLevels, guardLevels, isNeutral, nightLevels, NIGHT_BLIND_LUMA, NIGHT_NEUTRAL_LEVELS } from './levels';
32
+ /** Seconds of look-ahead: long enough to ease, short enough to still be about the moment you are in. */
33
+ export const LUMA_LOOKAHEAD = 0.9;
34
+ /** The guard is already down this long before the first flash… */
35
+ export const FLASH_LEAD = 0.4;
36
+ /** …and stays down this long after the last. */
37
+ export const FLASH_TAIL = 0.3;
38
+ /** The level the guard pins to across a flash interval. */
39
+ export const FLASH_LUMA = 0.92;
40
+ /** Fraction of the gap closed per tick when the level is coming back UP. */
41
+ export 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 function lumaAhead(series, t, horizonSec = 0) {
48
+ if (!series || !Number.isFinite(t))
49
+ return null;
50
+ const h = Number.isFinite(horizonSec) && horizonSec > 0 ? horizonSec : 0;
51
+ if (t > series.durationSec)
52
+ return null;
53
+ return lumaPeak(series, Math.max(0, t), Math.max(0, t) + LUMA_LOOKAHEAD + h);
54
+ }
55
+ /**
56
+ * Validate the sidecar's flash intervals: finite, ordered pairs, sorted by
57
+ * start. Garbage entries are dropped (never guessed at) — a missing guard is
58
+ * degraded, a guard in the wrong place is wrong.
59
+ */
60
+ export function normalizeFlashes(track) {
61
+ const raw = track?.flashes;
62
+ if (!Array.isArray(raw))
63
+ return [];
64
+ const out = [];
65
+ for (const f of raw) {
66
+ if (!Array.isArray(f) || f.length < 2)
67
+ continue;
68
+ const s = f[0], e = f[1];
69
+ if (typeof s !== 'number' || typeof e !== 'number' || !Number.isFinite(s) || !Number.isFinite(e))
70
+ continue;
71
+ out.push(s <= e ? [s, e] : [e, s]);
72
+ }
73
+ return out.sort((a, b) => a[0] - b[0]);
74
+ }
75
+ /**
76
+ * Is the guard down at `t`? True inside `[start − lead, end + tail]` of any
77
+ * interval, where the lead is widened by `horizonSec` (the gap until the next
78
+ * evaluation) so a coarse tick can't let the first flash through.
79
+ */
80
+ export function inFlash(flashes, t, horizonSec = 0) {
81
+ if (!flashes.length || !Number.isFinite(t))
82
+ return false;
83
+ const h = Number.isFinite(horizonSec) && horizonSec > 0 ? horizonSec : 0;
84
+ for (const [s, e] of flashes) {
85
+ if (s - FLASH_LEAD > t + h)
86
+ return false; // sorted: nothing later can match
87
+ if (t <= e + FLASH_TAIL)
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+ export const NIGHT_IDLE = Object.freeze({ luma: 0, guardLuma: 0 });
93
+ /**
94
+ * One tick of the composer. The component owns the state (feed the frame
95
+ * back as `prev`) and the application; this decides. `mode: 'none'` ⇒ remove
96
+ * every trace.
97
+ *
98
+ * Night: target = max(meter or blind level, prediction) at the user's depth.
99
+ * Guard: target = FLASH_LUMA at GUARD_DEPTH (1) while down, whatever night is
100
+ * doing — armed, disarmed or off. The two stack per channel, the deeper wins.
101
+ * Each level lands darker at once and releases brighter by OUTPUT_RELEASE per
102
+ * tick.
103
+ */
104
+ export function composeNight(prev, input) {
105
+ const pNight = finite01(prev?.luma ?? 0);
106
+ const pGuard = finite01(prev?.guardLuma ?? 0);
107
+ const flash = !!input.flash;
108
+ let nightTarget = 0;
109
+ if (input.armed) {
110
+ const base = input.blind ? NIGHT_BLIND_LUMA : finite01(input.meter);
111
+ nightTarget = Math.max(base, input.ahead == null ? 0 : finite01(input.ahead));
112
+ }
113
+ // Disarmed (the auto window just ended) ⇒ night's level releases per tick
114
+ // like any other brightening rather than dropping to 0 in one step. An
115
+ // explicit `off` is the component's to clear at once — that is a statement.
116
+ const luma = step(pNight, nightTarget);
117
+ const guardLuma = step(pGuard, flash ? FLASH_LUMA : 0);
118
+ const guardL = guardLevels(guardLuma);
119
+ const nightL = nightLevels(luma, input.depth);
120
+ // While releasing after disarm only the level drop carries over; the
121
+ // warm/vignette veils fade out on their own CSS ramp.
122
+ const levels = input.armed
123
+ ? deeperLevels(nightL, guardL)
124
+ : deeperLevels({ bright: nightL.bright, sat: nightL.sat, warm: 0, vig: 0 }, guardL);
125
+ if (!input.armed && isNeutral(levels)) {
126
+ return { luma: 0, guardLuma: 0, levels: { ...NIGHT_NEUTRAL_LEVELS }, mode: 'none', guard: false };
127
+ }
128
+ // Settle a released guard to exactly 0 once it no longer changes a pixel.
129
+ const g = isNeutral(guardL) && !flash ? 0 : guardLuma;
130
+ return { luma, guardLuma: g, levels, mode: input.armed ? 'night' : 'guard', guard: flash };
131
+ }
132
+ /** Darker lands at once; brighter is released a fraction per tick. */
133
+ function step(prev, target) {
134
+ return target >= prev ? target : prev + (target - prev) * OUTPUT_RELEASE;
135
+ }
136
+ function finite01(v) {
137
+ return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0;
138
+ }
@@ -1,26 +1,31 @@
1
1
  /**
2
- * Night module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.33
3
- * ("night dimming, bias light, predictive dimming, strobe guard").
2
+ * Night module (mos-5lzns.33) — night dimming, bias light, predictive
3
+ * dimming (the luma track), strobe guard. Ported from extension-library
4
+ * FEATURES.md §Night dimming, §Bias light, §The light track.
4
5
  *
5
- * What lands here (extension-library FEATURES.md §Night dimming, §Bias light,
6
- * §The light track), as ADVISORS + paint helpers:
6
+ * Layout (every file pure and node-testable — `npm run test:unit`):
7
7
  *
8
- * · night dimming — every ~900ms while playing, sample the frame's actual
9
- * brightness (downscaled canvas read), drive filter: brightness() toward
10
- * `1 − 0.5·depth·excess`. Attack fast, release slow, so a dark cut inside a
11
- * bright video doesn't flash. `N` cycles auto/on/off — add its row to
12
- * keys.ts PLAYER_KEYS when the reader lands, not before.
13
- * · bias light — the frame's own colours, blurred, behind the player
14
- * (radial-gradient cells at z-index −1).
15
- * · predictive dimming — the pre-scanned luma track read ~1s ahead so
16
- * dimming starts BEFORE a bright cut; the light ribbon rides the same
17
- * track on whichever axis owns scrubbing (mos-5lzns.30's one-axis).
18
- * · strobe guard — hold the level down across detected flash intervals.
8
+ * · night/meter.ts — 32×18 frame bytes → Rec.709 luma (+ blown-out share),
9
+ * asymmetric smoothing (attack 0.5, release 0.12), and the 3×3 bias-light
10
+ * cells read from the SAME bytes.
11
+ * · night/levels.ts — luma → brightness / saturation / warmth / vignette off
12
+ * NIGHT_FLOOR 0.34; the blind fixed level; the surround level that re-tunes
13
+ * night for LIGHT themes.
14
+ * · night/clock.ts — what arms it: local clock (prefers-color-scheme dark only
15
+ * widens the window) + the manual auto/on/off cycle (`N`). No hyprsunset.
16
+ * · night/track.ts — prediction (can only ADD dimming), flash intervals, and
17
+ * `composeNight`, the one-tick decision: darker lands at once, brighter is
18
+ * released per tick, so nothing ever brightens in a jump.
19
19
  *
20
- * The measuring code is an advisor: it reads no settings, touches no
21
- * <video>, and returns safe neutral values on garbage input (CORS-tainted
22
- * canvas ⇒ escalate to the caller, never silently guess brightness). The
23
- * component decides and applies. Respect prefers-reduced-motion where it
24
- * applies: the correction is the feature, the ramp is only how it arrives.
20
+ * The advisor rule (README): nothing here reads a setting, touches the
21
+ * <video>, or draws. The component samples the frame (crossorigin="anonymous"
22
+ * is the host's call), escalates a tainted canvas via `onNightBlind`, and
23
+ * applies the numbers as CSS variables that exist only while night acts — off
24
+ * leaves no trace. prefers-reduced-motion drops the ramps, never the
25
+ * correction: the dimming is the feature, the ramp is only how it arrives.
25
26
  */
26
27
  export declare const NIGHT_NEUTRAL = 1;
28
+ export * from './night/meter';
29
+ export * from './night/levels';
30
+ export * from './night/clock';
31
+ export * from './night/track';