@joewinke/jatui 0.10.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/CLAUDE.md +10 -0
- package/dist/components/VideoPlayer.svelte +504 -8
- package/dist/components/VideoPlayer.svelte.d.ts +42 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/video/README.md +18 -4
- package/dist/video/advisors/README.md +20 -0
- package/dist/video/advisors/qr.d.ts +95 -0
- package/dist/video/advisors/qr.js +546 -0
- package/dist/video/advisors/quiet-listening.d.ts +73 -0
- package/dist/video/advisors/quiet-listening.js +292 -0
- package/dist/video/advisors/resume-point.d.ts +71 -0
- package/dist/video/advisors/resume-point.js +302 -0
- package/dist/video/advisors/smart-speed.d.ts +82 -0
- package/dist/video/advisors/smart-speed.js +314 -0
- package/dist/video/advisors/speaker-rate.d.ts +74 -0
- package/dist/video/advisors/speaker-rate.js +322 -0
- 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 +3 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-speaker playback rate, measured rather than guessed. Ported verbatim
|
|
3
|
+
* from extension-library `lib/speaker-rate.js` (mos-5lzns.27).
|
|
4
|
+
*
|
|
5
|
+
* The manual version of this feature is a speed slider, and a speed slider is
|
|
6
|
+
* a chore: you nudge it up for the slow host, forget, and then the guest who
|
|
7
|
+
* talks at 200 wpm arrives sounding auctioned. The premise here is the one
|
|
8
|
+
* behind loudness matching, one sense over — the app can MEASURE the thing you
|
|
9
|
+
* were about to compensate for. Words per minute falls straight out of a
|
|
10
|
+
* diarised transcript (words in a segment, seconds of that segment, attributed
|
|
11
|
+
* to whoever was talking), so it can propose the rates that put everyone at one
|
|
12
|
+
* pace and let you keep or discard them. Nobody has to guess "is 1.2 too much
|
|
13
|
+
* for this one".
|
|
14
|
+
*
|
|
15
|
+
* Pure advisor, deliberately (src/lib/video/README.md doctrine): no DOM, no
|
|
16
|
+
* <video>, no settings reads, no motion (so nothing here for
|
|
17
|
+
* prefers-reduced-motion to reduce). It answers "how fast is each person
|
|
18
|
+
* talking, and what would even them out" and stops there — the component
|
|
19
|
+
* decides whether to apply any of it, and every function returns a safe
|
|
20
|
+
* neutral value rather than throwing when the data is missing or malformed,
|
|
21
|
+
* because half this library's videos have no diarisation at all.
|
|
22
|
+
*
|
|
23
|
+
* speakerPace(transcript, speakers)
|
|
24
|
+
* -> { bySpeaker: { name: { wpm, words, seconds } }, medianWpm }
|
|
25
|
+
* suggestRates(pace, { targetWpm, min, max, step }) -> { name: rate }
|
|
26
|
+
* rateAt(t, turns, rates, fallback) -> multiplier at time t
|
|
27
|
+
* describeRates(pace, rates) -> ["Darren 168 wpm → 0.9x", …]
|
|
28
|
+
*
|
|
29
|
+
* Input shapes: segments/turns are the same duration-bearing objects the
|
|
30
|
+
* component already holds (VideoTranscriptSegment / VideoSpeakerTurn read
|
|
31
|
+
* structurally — `start`/`end` seconds, `text`, `speaker`/`name`, `ad`), so
|
|
32
|
+
* meeting and scribe data flows straight through. Field names are read with
|
|
33
|
+
* the extension's spellings (`start`/`end`/`text`/`speaker`/`name`/`ad`);
|
|
34
|
+
* mos-5lzns.32's wiring maps the component's `Sec`-suffixed props at the call
|
|
35
|
+
* site if they ever differ.
|
|
36
|
+
*/
|
|
37
|
+
// Under this many words a WPM is an artefact of segment boundaries rather than
|
|
38
|
+
// a speaking style: "Right." alone on a four-second segment reads as 15 wpm,
|
|
39
|
+
// and a three-word interjection inside someone else's sentence reads as 400.
|
|
40
|
+
// Twenty words is roughly eight seconds of continuous speech — the shortest
|
|
41
|
+
// sample that has ever looked like a pace instead of a coincidence.
|
|
42
|
+
const MIN_WORDS = 20;
|
|
43
|
+
// Within ±7% of target, do nothing at all. 1.05 is audible on music and
|
|
44
|
+
// essentially inaudible on speech, so a rate that close buys nothing — and
|
|
45
|
+
// since turns hand off every few seconds, a hair-trigger would have the pitch
|
|
46
|
+
// stepping on every handoff. A rate that flickers 1.0 → 1.05 → 1.0 is worse
|
|
47
|
+
// than no feature; leaving someone alone is a real answer.
|
|
48
|
+
const DEADZONE = 0.07;
|
|
49
|
+
const DEFAULTS = { min: 0.75, max: 1.5, step: 0.05 };
|
|
50
|
+
// finite-or-null: the single gate every external number passes through, so
|
|
51
|
+
// "0.9", undefined, NaN and {} all collapse to one thing callers can test
|
|
52
|
+
function num(v) {
|
|
53
|
+
const n = typeof v === "number" ? v : parseFloat(v);
|
|
54
|
+
return Number.isFinite(n) ? n : null;
|
|
55
|
+
}
|
|
56
|
+
function labelOf(v) {
|
|
57
|
+
if (typeof v !== "string")
|
|
58
|
+
return null;
|
|
59
|
+
const s = v.trim();
|
|
60
|
+
return s || null;
|
|
61
|
+
}
|
|
62
|
+
// Whitespace tokens, and nothing cleverer. Stripping punctuation or filtering
|
|
63
|
+
// "um" would make the number prettier and less true — WPM is only useful here
|
|
64
|
+
// as a RATIO between two speakers counted the same way, and both of them say um.
|
|
65
|
+
function countWords(text) {
|
|
66
|
+
if (typeof text !== "string")
|
|
67
|
+
return 0;
|
|
68
|
+
const s = text.trim();
|
|
69
|
+
return s ? s.split(/\s+/).length : 0;
|
|
70
|
+
}
|
|
71
|
+
function flattenTurns(turns) {
|
|
72
|
+
const list = [];
|
|
73
|
+
for (const t of turns) {
|
|
74
|
+
if (!t || typeof t !== "object")
|
|
75
|
+
continue;
|
|
76
|
+
const start = num(t.start);
|
|
77
|
+
const end = num(t.end);
|
|
78
|
+
if (start == null || end == null || end <= start)
|
|
79
|
+
continue;
|
|
80
|
+
list.push({
|
|
81
|
+
start,
|
|
82
|
+
end,
|
|
83
|
+
name: t.ad ? null : labelOf(t.name),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!list.length)
|
|
87
|
+
return [];
|
|
88
|
+
// hosts promise sorted input; sorting anyway costs O(n log n) once per array
|
|
89
|
+
// and takes the promise out of the contract, which is cheaper than a support
|
|
90
|
+
// thread about rates being off by one speaker
|
|
91
|
+
list.sort((a, b) => a.start - b.start);
|
|
92
|
+
const cuts = [];
|
|
93
|
+
for (const t of list)
|
|
94
|
+
cuts.push(t.start, t.end);
|
|
95
|
+
cuts.sort((a, b) => a - b);
|
|
96
|
+
const flat = [];
|
|
97
|
+
let ti = 0;
|
|
98
|
+
let open = [];
|
|
99
|
+
for (let i = 0; i < cuts.length - 1; i++) {
|
|
100
|
+
const a = cuts[i];
|
|
101
|
+
const b = cuts[i + 1];
|
|
102
|
+
if (b <= a)
|
|
103
|
+
continue; // duplicate boundary — zero-width slice, nothing to own
|
|
104
|
+
while (ti < list.length && list[ti].start <= a)
|
|
105
|
+
open.push(list[ti++]);
|
|
106
|
+
open = open.filter((t) => t.end > a); // bounded by overlap depth, not by n
|
|
107
|
+
if (!open.length)
|
|
108
|
+
continue;
|
|
109
|
+
let win = open[0];
|
|
110
|
+
for (const t of open)
|
|
111
|
+
if (t.start >= win.start)
|
|
112
|
+
win = t;
|
|
113
|
+
const prev = flat[flat.length - 1];
|
|
114
|
+
// merge across cut points: the interesting boundaries are handoffs, and a
|
|
115
|
+
// speaker interrupted-then-resumed should read as one stretch, not three
|
|
116
|
+
if (prev && prev.name === win.name && prev.end === a)
|
|
117
|
+
prev.end = b;
|
|
118
|
+
else
|
|
119
|
+
flat.push({ start: a, end: b, name: win.name });
|
|
120
|
+
}
|
|
121
|
+
return flat;
|
|
122
|
+
}
|
|
123
|
+
// rateAt runs on every timeupdate, so the flatten is memoised against the array
|
|
124
|
+
// identity the host handed us. Fingerprinting length + endpoints catches the
|
|
125
|
+
// realistic staleness (a fresh array from setSpeakers, a re-diarise); an
|
|
126
|
+
// in-place edit that preserves both would go unseen, which is why the host
|
|
127
|
+
// replaces this array rather than mutating it.
|
|
128
|
+
const flatCache = new WeakMap();
|
|
129
|
+
function prepared(turns) {
|
|
130
|
+
if (!Array.isArray(turns) || !turns.length)
|
|
131
|
+
return [];
|
|
132
|
+
const mark = turns.length +
|
|
133
|
+
":" +
|
|
134
|
+
turns[0]?.start +
|
|
135
|
+
":" +
|
|
136
|
+
turns[turns.length - 1]?.end;
|
|
137
|
+
const hit = flatCache.get(turns);
|
|
138
|
+
if (hit && hit.mark === mark)
|
|
139
|
+
return hit.flat;
|
|
140
|
+
const flat = flattenTurns(turns);
|
|
141
|
+
flatCache.set(turns, { mark, flat });
|
|
142
|
+
return flat;
|
|
143
|
+
}
|
|
144
|
+
// last slice starting at or before sec, then a coverage check — [start, end),
|
|
145
|
+
// so a time landing exactly on a handoff belongs to the person taking over
|
|
146
|
+
function segAt(flat, sec) {
|
|
147
|
+
let lo = 0;
|
|
148
|
+
let hi = flat.length - 1;
|
|
149
|
+
let found = -1;
|
|
150
|
+
while (lo <= hi) {
|
|
151
|
+
const mid = (lo + hi) >> 1;
|
|
152
|
+
if (flat[mid].start <= sec) {
|
|
153
|
+
found = mid;
|
|
154
|
+
lo = mid + 1;
|
|
155
|
+
}
|
|
156
|
+
else
|
|
157
|
+
hi = mid - 1;
|
|
158
|
+
}
|
|
159
|
+
if (found < 0)
|
|
160
|
+
return null;
|
|
161
|
+
const seg = flat[found];
|
|
162
|
+
return sec < seg.end ? seg : null;
|
|
163
|
+
}
|
|
164
|
+
// Weighted by talk time, not a plain median of the speakers. Two speakers with
|
|
165
|
+
// a plain median land on the midpoint and BOTH get resampled — the worst of the
|
|
166
|
+
// three options, since the voice you hear most is the one you're least likely
|
|
167
|
+
// to want altered. Weighted, the dominant speaker sits at the target and stays
|
|
168
|
+
// at 1x, and the occasional guest is the one who moves. An exact tie splits the
|
|
169
|
+
// difference so a two-hander doesn't arbitrarily crown whoever is slower.
|
|
170
|
+
function weightedMedian(rows) {
|
|
171
|
+
const list = rows.slice().sort((a, b) => a.wpm - b.wpm);
|
|
172
|
+
const total = list.reduce((s, r) => s + r.seconds, 0);
|
|
173
|
+
if (!(total > 0))
|
|
174
|
+
return 0;
|
|
175
|
+
const half = total / 2;
|
|
176
|
+
let acc = 0;
|
|
177
|
+
for (let i = 0; i < list.length; i++) {
|
|
178
|
+
acc += list[i].seconds;
|
|
179
|
+
if (acc > half)
|
|
180
|
+
return list[i].wpm;
|
|
181
|
+
if (acc === half) {
|
|
182
|
+
const next = list[i + 1];
|
|
183
|
+
return next ? (list[i].wpm + next.wpm) / 2 : list[i].wpm;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return list[list.length - 1].wpm;
|
|
187
|
+
}
|
|
188
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
189
|
+
export function speakerPace(transcript, speakers) {
|
|
190
|
+
const empty = { bySpeaker: Object.create(null), medianWpm: 0 };
|
|
191
|
+
if (!Array.isArray(transcript) || !transcript.length)
|
|
192
|
+
return empty;
|
|
193
|
+
const flat = prepared(speakers);
|
|
194
|
+
const acc = new Map();
|
|
195
|
+
for (const seg of transcript) {
|
|
196
|
+
if (!seg || typeof seg !== "object")
|
|
197
|
+
continue;
|
|
198
|
+
const start = num(seg.start);
|
|
199
|
+
const end = num(seg.end);
|
|
200
|
+
if (start == null || end == null || end <= start)
|
|
201
|
+
continue;
|
|
202
|
+
const words = countWords(seg.text);
|
|
203
|
+
if (!words)
|
|
204
|
+
continue;
|
|
205
|
+
// Midpoint, not start: a segment straddling a handoff belongs to whoever
|
|
206
|
+
// did most of the talking in it, and with no per-word timings the midpoint
|
|
207
|
+
// is the cheapest honest proxy. Turns win over segment.speaker when both
|
|
208
|
+
// exist — the diarisation pass that produced the turns is the one that
|
|
209
|
+
// resolved LABEL → display name and marked the ads.
|
|
210
|
+
const covering = flat.length ? segAt(flat, (start + end) / 2) : null;
|
|
211
|
+
// covering with a null name is an ad read: known, and deliberately not a
|
|
212
|
+
// person. Falling through to seg.speaker here would file the sponsor's
|
|
213
|
+
// words under the host.
|
|
214
|
+
const name = covering
|
|
215
|
+
? covering.name
|
|
216
|
+
: labelOf(seg.speaker);
|
|
217
|
+
if (!name)
|
|
218
|
+
continue;
|
|
219
|
+
const row = acc.get(name) || { words: 0, seconds: 0 };
|
|
220
|
+
row.words += words;
|
|
221
|
+
row.seconds += end - start;
|
|
222
|
+
acc.set(name, row);
|
|
223
|
+
}
|
|
224
|
+
// null-prototype: a speaker literally named "__proto__" (names are
|
|
225
|
+
// user-editable — the rename menu accepts anything) would otherwise assign
|
|
226
|
+
// to the object's prototype slot instead of creating a key, leaving
|
|
227
|
+
// bySpeaker self-inconsistent: readable by name, invisible to Object.keys
|
|
228
|
+
// (the empty return above is null-prototype for the same reason — both
|
|
229
|
+
// Object.create(null) sites are deliberate, not style)
|
|
230
|
+
const bySpeaker = Object.create(null);
|
|
231
|
+
const rows = [];
|
|
232
|
+
for (const [name, row] of acc) {
|
|
233
|
+
if (row.words < MIN_WORDS || !(row.seconds > 0))
|
|
234
|
+
continue;
|
|
235
|
+
const wpm = round1((row.words / row.seconds) * 60);
|
|
236
|
+
bySpeaker[name] = {
|
|
237
|
+
wpm,
|
|
238
|
+
words: row.words,
|
|
239
|
+
seconds: Math.round(row.seconds * 100) / 100,
|
|
240
|
+
};
|
|
241
|
+
rows.push({ wpm, seconds: row.seconds });
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
bySpeaker,
|
|
245
|
+
medianWpm: rows.length ? round1(weightedMedian(rows)) : 0,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export function suggestRates(pace, opts) {
|
|
249
|
+
const out = {};
|
|
250
|
+
const by = pace && typeof pace === "object" ? pace.bySpeaker : null;
|
|
251
|
+
if (!by || typeof by !== "object")
|
|
252
|
+
return out;
|
|
253
|
+
const o = opts && typeof opts === "object" ? opts : {};
|
|
254
|
+
const target = num(o.targetWpm) ?? num(pace.medianWpm);
|
|
255
|
+
if (!(target !== null && target > 0))
|
|
256
|
+
return out;
|
|
257
|
+
const a = num(o.min) ?? DEFAULTS.min;
|
|
258
|
+
const b = num(o.max) ?? DEFAULTS.max;
|
|
259
|
+
const lo = Math.min(a, b);
|
|
260
|
+
const hi = Math.max(a, b); // an inverted range is a typo, not an instruction to return nothing
|
|
261
|
+
const step = num(o.step) ?? DEFAULTS.step;
|
|
262
|
+
for (const [name, p] of Object.entries(by)) {
|
|
263
|
+
const wpm = num(p && p.wpm);
|
|
264
|
+
if (wpm == null || wpm <= 0)
|
|
265
|
+
continue;
|
|
266
|
+
const raw = target / wpm;
|
|
267
|
+
if (Math.abs(raw - 1) <= DEADZONE && raw >= lo && raw <= hi) {
|
|
268
|
+
// exactly 1, not 0.9999 — the host compares against 1. Gated on the
|
|
269
|
+
// range: an unmodified 1 is only the right answer if 1 is a rate the
|
|
270
|
+
// caller actually allows. With min 1.2 it isn't, and returning it anyway
|
|
271
|
+
// silently broke the one promise this function makes about its output.
|
|
272
|
+
out[name] = 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// snap first, then clamp: snapping a clamped value can walk it back over
|
|
276
|
+
// the limit, and the limit is the promise the user actually set
|
|
277
|
+
const snapped = step > 0 ? Math.round(raw / step) * step : raw;
|
|
278
|
+
const rate = Math.round(Math.min(hi, Math.max(lo, snapped)) * 1000) / 1000;
|
|
279
|
+
out[name] = Math.abs(rate - 1) < 1e-9 ? 1 : rate;
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
export function rateAt(t, turns, rates, fallback = 1) {
|
|
284
|
+
const fb = num(fallback) ?? 1;
|
|
285
|
+
const sec = num(t);
|
|
286
|
+
if (sec == null)
|
|
287
|
+
return fb;
|
|
288
|
+
const flat = prepared(turns);
|
|
289
|
+
if (!flat.length)
|
|
290
|
+
return fb;
|
|
291
|
+
const seg = segAt(flat, sec);
|
|
292
|
+
// no turn covering t, or an ad read (name === null): nobody to match, so
|
|
293
|
+
// normal speed. Silence between turns is the one place a rate change would
|
|
294
|
+
// be inaudible anyway, and holding the last speaker's rate through it makes
|
|
295
|
+
// the next handoff land late.
|
|
296
|
+
if (!seg || !seg.name)
|
|
297
|
+
return fb;
|
|
298
|
+
const r = rates && typeof rates === "object"
|
|
299
|
+
? num(rates[seg.name])
|
|
300
|
+
: null;
|
|
301
|
+
return r != null && r > 0 ? r : fb;
|
|
302
|
+
}
|
|
303
|
+
// Sorted by talk time because that's the order you think about the people in:
|
|
304
|
+
// the host first, the guest who said four sentences last. Someone already on
|
|
305
|
+
// pace still gets a line — seeing that they were measured and left alone is
|
|
306
|
+
// the point, and a list that only shows the changed speakers reads like the
|
|
307
|
+
// others were missed.
|
|
308
|
+
export function describeRates(pace, rates) {
|
|
309
|
+
const by = pace && typeof pace === "object" ? pace.bySpeaker : null;
|
|
310
|
+
if (!by || typeof by !== "object")
|
|
311
|
+
return [];
|
|
312
|
+
const rows = Object.entries(by).filter(([, p]) => p && num(p.wpm) != null);
|
|
313
|
+
rows.sort((x, y) => (num(y[1].seconds) ?? 0) - (num(x[1].seconds) ?? 0) ||
|
|
314
|
+
x[0].localeCompare(y[0]));
|
|
315
|
+
return rows.map(([name, p]) => {
|
|
316
|
+
const wpm = Math.round(num(p.wpm));
|
|
317
|
+
const r = rates && typeof rates === "object" ? num(rates[name]) : null;
|
|
318
|
+
if (r == null || Math.abs(r - 1) < 1e-9)
|
|
319
|
+
return `${name} ${wpm} wpm · on pace`;
|
|
320
|
+
return `${name} ${wpm} wpm → ${+r.toFixed(2)}x`;
|
|
321
|
+
});
|
|
322
|
+
}
|
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
|
+
}
|