@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.
@@ -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';
@@ -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 const NIGHT_NEUTRAL = 1;
28
+ export * from './night/meter';
29
+ export * from './night/levels';
30
+ export * from './night/clock';
31
+ export * from './night/track';
@@ -34,7 +34,16 @@ export const VIDEO_SETTINGS = [
34
34
  { id: 'player.oneAxis', type: 'bool', default: true, owner: 'mos-5lzns.30', wired: true },
35
35
  // { id: 'audio.level', type: 'bool', default: true, owner: 'mos-5lzns.31' },
36
36
  // { id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32' },
37
- // { id: 'night.mode', type: 'enum', default: 'auto', options: ['auto','on','off'], owner: 'mos-5lzns.33' },
37
+ // Read by VideoPlayer's `night` / `nightDepth` / `biasLight` / `predictive` /
38
+ // `strobeGuard` props → video/night/* (clock.nightArmed, levels.nightLevels,
39
+ // meter.biasCells, track.lumaAhead / inFlash / composeNight). Unlike the
40
+ // extension, night.mode's DEFAULT here is the host's opt-in: an absent `night`
41
+ // prop means the feature does not exist on that player.
42
+ { id: 'night.mode', type: 'enum', default: 'auto', options: ['auto', 'on', 'off'], owner: 'mos-5lzns.33', wired: true },
43
+ { id: 'night.depth', type: 'range', default: 0.7, min: 0.2, max: 1, step: 0.1, owner: 'mos-5lzns.33', wired: true },
44
+ { id: 'night.bias', type: 'bool', default: true, owner: 'mos-5lzns.33', wired: true },
45
+ { id: 'player.predictive', type: 'bool', default: true, owner: 'mos-5lzns.33', wired: true },
46
+ { id: 'player.strobeGuard', type: 'bool', default: true, owner: 'mos-5lzns.33', wired: true },
38
47
  // { id: 'sleep.enabled', type: 'bool', default: true, owner: 'mos-5lzns.34' },
39
48
  ];
40
49
  /** Coerce a raw stored value against a spec — never throws, never returns junk. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",