@joewinke/jatui 0.9.1 → 0.11.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 +371 -1
- package/dist/components/VideoPlayer.svelte.d.ts +44 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/video/README.md +18 -3
- 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/band/axis.d.ts +81 -0
- package/dist/video/band/axis.js +106 -0
- package/dist/video/band/drag.d.ts +38 -0
- package/dist/video/band/drag.js +45 -0
- package/dist/video/band/luma.d.ts +62 -0
- package/dist/video/band/luma.js +97 -0
- package/dist/video/band/markers.d.ts +73 -0
- package/dist/video/band/markers.js +146 -0
- package/dist/video/band/memo.d.ts +21 -0
- package/dist/video/band/memo.js +15 -0
- package/dist/video/band/window.d.ts +35 -0
- package/dist/video/band/window.js +61 -0
- package/dist/video/band.d.ts +35 -29
- package/dist/video/band.js +77 -56
- package/dist/video/keys.d.ts +1 -1
- package/dist/video/keys.js +3 -0
- package/dist/video/settings.js +6 -5
- 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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One time axis — the four-state arbitration (mos-5lzns.30).
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §"One time axis": two widgets drawing "where
|
|
5
|
+
* you are" is a redundancy whose only product is the chance of them
|
|
6
|
+
* disagreeing, so the band — always on screen, already seeking — takes the
|
|
7
|
+
* job and the seek bar stands down. The band alone knows whether it has an
|
|
8
|
+
* envelope and whether it is zoomed, so it resolves the state and REPORTS it
|
|
9
|
+
* (`bandState` / `onBandChange`); whoever owns a bar reads the report.
|
|
10
|
+
*
|
|
11
|
+
* | hasWave | zoomed | axis | Result |
|
|
12
|
+
* |---------|--------|------------|-----------------------------------------------------------|
|
|
13
|
+
* | yes | no | `band` | Bar hidden. Band owns the axis and the light ribbon. |
|
|
14
|
+
* | yes | yes | `overview` | Bar returns as the OVERVIEW, window drawn as a bracket. |
|
|
15
|
+
* | no | — | `bar` | Bar stays (no envelope ⇒ the bar is the position readout).|
|
|
16
|
+
* | — | — | `both` | `oneAxis` off → both bars, permanently. |
|
|
17
|
+
*
|
|
18
|
+
* jatui divergence, deliberately: the "bar" here is the native `<video
|
|
19
|
+
* controls>` timeline, which a component cannot paint into — so the light
|
|
20
|
+
* ribbon always rides the band, and the zoomed overview is the band's own
|
|
21
|
+
* slim rail (drawn in the band's reserved top padding, so it appearing is
|
|
22
|
+
* never a layout shove). The native timeline can only be stood down where
|
|
23
|
+
* the engine exposes it (Chromium's ::-webkit-media-controls-timeline);
|
|
24
|
+
* elsewhere it stays and the band is simply the more precise of the two.
|
|
25
|
+
*
|
|
26
|
+
* DOM-free: node-testable under `npm run test:unit`.
|
|
27
|
+
*/
|
|
28
|
+
import { type BandWindow } from './window';
|
|
29
|
+
export type BandAxis = 'band' | 'overview' | 'bar' | 'both';
|
|
30
|
+
export interface BandState {
|
|
31
|
+
/** The band has an envelope (waveform peaks) to scrub on. */
|
|
32
|
+
hasWave: boolean;
|
|
33
|
+
/** The band is showing a window narrower than the whole video. */
|
|
34
|
+
zoomed: boolean;
|
|
35
|
+
/** The visible window, seconds. `viewEnd` is 0 until a duration is known. */
|
|
36
|
+
viewStart: number;
|
|
37
|
+
viewEnd: number;
|
|
38
|
+
/** The resolved four-state outcome — see the table above. */
|
|
39
|
+
axis: BandAxis;
|
|
40
|
+
}
|
|
41
|
+
export declare function resolveAxis(hasWave: boolean, zoomed: boolean, oneAxis: boolean): BandAxis;
|
|
42
|
+
export declare function bandStateOf(hasWave: boolean, w: BandWindow, duration: number, oneAxis: boolean): BandState;
|
|
43
|
+
/** The bar (native timeline) stands down only while the band owns the axis. */
|
|
44
|
+
export declare function barHidden(axis: BandAxis): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Report churn guard: the band repaints far more often than its state
|
|
47
|
+
* changes, so the reporter compares `hasWave|zoomed|viewStart|viewEnd|axis`
|
|
48
|
+
* and only calls out on a real change.
|
|
49
|
+
*/
|
|
50
|
+
export declare function bandSignature(s: BandState): string;
|
|
51
|
+
/**
|
|
52
|
+
* Build a reporter that forwards only real changes, deferred through a
|
|
53
|
+
* microtask. The defer is not cosmetic (FEATURES.md failure mode "callback
|
|
54
|
+
* timing"): the player reports from inside its own mount/paint, before the
|
|
55
|
+
* host's reference to the component has finished initialising — a host
|
|
56
|
+
* callback that touches that reference synchronously would read undefined.
|
|
57
|
+
* `schedule` is injectable so tests can run it synchronously.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createBandReporter(getCallback: () => ((s: BandState) => void) | undefined, schedule?: (fn: () => void) => void): (s: BandState) => void;
|
|
60
|
+
/** The playhead line width in CSS px — shared by the band canvas and the overview rail. */
|
|
61
|
+
export declare const PLAYHEAD_W = 1.5;
|
|
62
|
+
/**
|
|
63
|
+
* The playhead's CENTRE x, in CSS px of a box `width` wide. Every widget that
|
|
64
|
+
* draws "where you are" goes through this one function, and both draw the
|
|
65
|
+
* line centred on it (x − PLAYHEAD_W/2), so two widgets sharing a box width
|
|
66
|
+
* agree to sub-pixel rounding — the ≤0.2px FEATURES.md measured, instead of
|
|
67
|
+
* the ~90px two independently-laid-out bars disagreed by.
|
|
68
|
+
*/
|
|
69
|
+
export declare function playheadX(sec: number, w: BandWindow, duration: number, width: number): number;
|
|
70
|
+
/**
|
|
71
|
+
* The zoomed window as an outlined bracket on the whole-video overview, in
|
|
72
|
+
* fractions of the overview's width — an OUTLINE, not a fill, so the played
|
|
73
|
+
* bar underneath stays readable and marking a RANGE of the axis has
|
|
74
|
+
* different grammar from marking a POSITION. Null when not zoomed (nothing
|
|
75
|
+
* to bracket: the band IS the whole video). A 25–50s view of a 100s video is
|
|
76
|
+
* `{ left: 0.25, width: 0.25 }` — the extension harness's own case.
|
|
77
|
+
*/
|
|
78
|
+
export declare function overviewBracket(w: BandWindow, duration: number): {
|
|
79
|
+
left: number;
|
|
80
|
+
width: number;
|
|
81
|
+
} | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One time axis — the four-state arbitration (mos-5lzns.30).
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §"One time axis": two widgets drawing "where
|
|
5
|
+
* you are" is a redundancy whose only product is the chance of them
|
|
6
|
+
* disagreeing, so the band — always on screen, already seeking — takes the
|
|
7
|
+
* job and the seek bar stands down. The band alone knows whether it has an
|
|
8
|
+
* envelope and whether it is zoomed, so it resolves the state and REPORTS it
|
|
9
|
+
* (`bandState` / `onBandChange`); whoever owns a bar reads the report.
|
|
10
|
+
*
|
|
11
|
+
* | hasWave | zoomed | axis | Result |
|
|
12
|
+
* |---------|--------|------------|-----------------------------------------------------------|
|
|
13
|
+
* | yes | no | `band` | Bar hidden. Band owns the axis and the light ribbon. |
|
|
14
|
+
* | yes | yes | `overview` | Bar returns as the OVERVIEW, window drawn as a bracket. |
|
|
15
|
+
* | no | — | `bar` | Bar stays (no envelope ⇒ the bar is the position readout).|
|
|
16
|
+
* | — | — | `both` | `oneAxis` off → both bars, permanently. |
|
|
17
|
+
*
|
|
18
|
+
* jatui divergence, deliberately: the "bar" here is the native `<video
|
|
19
|
+
* controls>` timeline, which a component cannot paint into — so the light
|
|
20
|
+
* ribbon always rides the band, and the zoomed overview is the band's own
|
|
21
|
+
* slim rail (drawn in the band's reserved top padding, so it appearing is
|
|
22
|
+
* never a layout shove). The native timeline can only be stood down where
|
|
23
|
+
* the engine exposes it (Chromium's ::-webkit-media-controls-timeline);
|
|
24
|
+
* elsewhere it stays and the band is simply the more precise of the two.
|
|
25
|
+
*
|
|
26
|
+
* DOM-free: node-testable under `npm run test:unit`.
|
|
27
|
+
*/
|
|
28
|
+
import { isZoomed, secToFrac, spanSec } from './window';
|
|
29
|
+
export function resolveAxis(hasWave, zoomed, oneAxis) {
|
|
30
|
+
if (!oneAxis)
|
|
31
|
+
return 'both';
|
|
32
|
+
if (!hasWave)
|
|
33
|
+
return 'bar';
|
|
34
|
+
return zoomed ? 'overview' : 'band';
|
|
35
|
+
}
|
|
36
|
+
export function bandStateOf(hasWave, w, duration, oneAxis) {
|
|
37
|
+
const zoomed = isZoomed(w, duration);
|
|
38
|
+
return {
|
|
39
|
+
hasWave,
|
|
40
|
+
zoomed,
|
|
41
|
+
viewStart: w.start,
|
|
42
|
+
viewEnd: w.end,
|
|
43
|
+
axis: resolveAxis(hasWave, zoomed, oneAxis)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** The bar (native timeline) stands down only while the band owns the axis. */
|
|
47
|
+
export function barHidden(axis) {
|
|
48
|
+
return axis === 'band' || axis === 'overview';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Report churn guard: the band repaints far more often than its state
|
|
52
|
+
* changes, so the reporter compares `hasWave|zoomed|viewStart|viewEnd|axis`
|
|
53
|
+
* and only calls out on a real change.
|
|
54
|
+
*/
|
|
55
|
+
export function bandSignature(s) {
|
|
56
|
+
return `${s.hasWave}|${s.zoomed}|${s.viewStart}|${s.viewEnd}|${s.axis}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a reporter that forwards only real changes, deferred through a
|
|
60
|
+
* microtask. The defer is not cosmetic (FEATURES.md failure mode "callback
|
|
61
|
+
* timing"): the player reports from inside its own mount/paint, before the
|
|
62
|
+
* host's reference to the component has finished initialising — a host
|
|
63
|
+
* callback that touches that reference synchronously would read undefined.
|
|
64
|
+
* `schedule` is injectable so tests can run it synchronously.
|
|
65
|
+
*/
|
|
66
|
+
export function createBandReporter(getCallback, schedule = queueMicrotask) {
|
|
67
|
+
let last = '';
|
|
68
|
+
return (s) => {
|
|
69
|
+
const sig = bandSignature(s);
|
|
70
|
+
if (sig === last)
|
|
71
|
+
return;
|
|
72
|
+
last = sig;
|
|
73
|
+
const snapshot = { ...s };
|
|
74
|
+
schedule(() => getCallback()?.(snapshot));
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// ── Playhead agreement ──────────────────────────────────────────────────────
|
|
78
|
+
/** The playhead line width in CSS px — shared by the band canvas and the overview rail. */
|
|
79
|
+
export const PLAYHEAD_W = 1.5;
|
|
80
|
+
/**
|
|
81
|
+
* The playhead's CENTRE x, in CSS px of a box `width` wide. Every widget that
|
|
82
|
+
* draws "where you are" goes through this one function, and both draw the
|
|
83
|
+
* line centred on it (x − PLAYHEAD_W/2), so two widgets sharing a box width
|
|
84
|
+
* agree to sub-pixel rounding — the ≤0.2px FEATURES.md measured, instead of
|
|
85
|
+
* the ~90px two independently-laid-out bars disagreed by.
|
|
86
|
+
*/
|
|
87
|
+
export function playheadX(sec, w, duration, width) {
|
|
88
|
+
if (!(duration > 0) || !(width > 0))
|
|
89
|
+
return 0;
|
|
90
|
+
return Math.max(0, Math.min(1, secToFrac(w, duration, sec))) * width;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The zoomed window as an outlined bracket on the whole-video overview, in
|
|
94
|
+
* fractions of the overview's width — an OUTLINE, not a fill, so the played
|
|
95
|
+
* bar underneath stays readable and marking a RANGE of the axis has
|
|
96
|
+
* different grammar from marking a POSITION. Null when not zoomed (nothing
|
|
97
|
+
* to bracket: the band IS the whole video). A 25–50s view of a 100s video is
|
|
98
|
+
* `{ left: 0.25, width: 0.25 }` — the extension harness's own case.
|
|
99
|
+
*/
|
|
100
|
+
export function overviewBracket(w, duration) {
|
|
101
|
+
if (!(duration > 0) || !isZoomed(w, duration))
|
|
102
|
+
return null;
|
|
103
|
+
const left = Math.max(0, Math.min(1, w.start / duration));
|
|
104
|
+
const width = Math.max(0, Math.min(1 - left, spanSec(w, duration) / duration));
|
|
105
|
+
return { left, width };
|
|
106
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-to-scrub on the band (mos-5lzns.30 review). With one-axis on, the
|
|
3
|
+
* native timeline stands down whenever there is an envelope — so the band
|
|
4
|
+
* must scrub like the bar it replaced, not only seek on click.
|
|
5
|
+
*
|
|
6
|
+
* A pure state machine; the component owns pointer capture and the rAF
|
|
7
|
+
* throttle. Rules:
|
|
8
|
+
* · Only the pointer that started the drag moves it (a second finger is
|
|
9
|
+
* ignored, not a teleport).
|
|
10
|
+
* · Within DRAG_SLOP_PX of the press it is still a CLICK — the band's click
|
|
11
|
+
* path (transcript bias, comment hit) keeps working. Past it, it is a
|
|
12
|
+
* scrub: seeks land exactly under the pointer, and the click that follows
|
|
13
|
+
* the release is swallowed.
|
|
14
|
+
* · `pointercancel` (the browser took the gesture — a page scroll on touch)
|
|
15
|
+
* restores the time the drag started from: a scrub you never finished
|
|
16
|
+
* should not have moved you.
|
|
17
|
+
*/
|
|
18
|
+
export declare const DRAG_SLOP_PX = 3;
|
|
19
|
+
export interface DragState {
|
|
20
|
+
pointerId: number;
|
|
21
|
+
startX: number;
|
|
22
|
+
/** Playback position when the press landed — restored on cancel. */
|
|
23
|
+
originSec: number;
|
|
24
|
+
moved: boolean;
|
|
25
|
+
/** Latest time under the pointer, awaiting the next frame's seek. */
|
|
26
|
+
pendingSec: number | null;
|
|
27
|
+
}
|
|
28
|
+
export declare function dragStart(pointerId: number, x: number, originSec: number): DragState;
|
|
29
|
+
/** Returns the next state, or the same object when the event isn't this drag's. */
|
|
30
|
+
export declare function dragMove(s: DragState | null, pointerId: number, x: number, sec: number): DragState | null;
|
|
31
|
+
export interface DragOutcome {
|
|
32
|
+
/** Where to leave the playhead, or null to leave it alone (a plain click). */
|
|
33
|
+
seekSec: number | null;
|
|
34
|
+
/** True ⇒ swallow the click event the browser fires after this release. */
|
|
35
|
+
suppressClick: boolean;
|
|
36
|
+
}
|
|
37
|
+
export declare function dragEnd(s: DragState | null, pointerId: number, sec: number): DragOutcome;
|
|
38
|
+
export declare function dragCancel(s: DragState | null, pointerId: number): DragOutcome;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag-to-scrub on the band (mos-5lzns.30 review). With one-axis on, the
|
|
3
|
+
* native timeline stands down whenever there is an envelope — so the band
|
|
4
|
+
* must scrub like the bar it replaced, not only seek on click.
|
|
5
|
+
*
|
|
6
|
+
* A pure state machine; the component owns pointer capture and the rAF
|
|
7
|
+
* throttle. Rules:
|
|
8
|
+
* · Only the pointer that started the drag moves it (a second finger is
|
|
9
|
+
* ignored, not a teleport).
|
|
10
|
+
* · Within DRAG_SLOP_PX of the press it is still a CLICK — the band's click
|
|
11
|
+
* path (transcript bias, comment hit) keeps working. Past it, it is a
|
|
12
|
+
* scrub: seeks land exactly under the pointer, and the click that follows
|
|
13
|
+
* the release is swallowed.
|
|
14
|
+
* · `pointercancel` (the browser took the gesture — a page scroll on touch)
|
|
15
|
+
* restores the time the drag started from: a scrub you never finished
|
|
16
|
+
* should not have moved you.
|
|
17
|
+
*/
|
|
18
|
+
export const DRAG_SLOP_PX = 3;
|
|
19
|
+
export function dragStart(pointerId, x, originSec) {
|
|
20
|
+
return { pointerId, startX: x, originSec, moved: false, pendingSec: null };
|
|
21
|
+
}
|
|
22
|
+
/** Returns the next state, or the same object when the event isn't this drag's. */
|
|
23
|
+
export function dragMove(s, pointerId, x, sec) {
|
|
24
|
+
if (!s || s.pointerId !== pointerId)
|
|
25
|
+
return s;
|
|
26
|
+
const moved = s.moved || Math.abs(x - s.startX) > DRAG_SLOP_PX;
|
|
27
|
+
if (!moved)
|
|
28
|
+
return s;
|
|
29
|
+
return { ...s, moved, pendingSec: sec };
|
|
30
|
+
}
|
|
31
|
+
const NONE = { seekSec: null, suppressClick: false };
|
|
32
|
+
export function dragEnd(s, pointerId, sec) {
|
|
33
|
+
if (!s || s.pointerId !== pointerId)
|
|
34
|
+
return NONE;
|
|
35
|
+
if (!s.moved)
|
|
36
|
+
return NONE;
|
|
37
|
+
return { seekSec: sec, suppressClick: true };
|
|
38
|
+
}
|
|
39
|
+
export function dragCancel(s, pointerId) {
|
|
40
|
+
if (!s || s.pointerId !== pointerId)
|
|
41
|
+
return NONE;
|
|
42
|
+
if (!s.moved)
|
|
43
|
+
return NONE;
|
|
44
|
+
return { seekSec: s.originSec, suppressClick: true };
|
|
45
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The light track → the light ribbon (mos-5lzns.30).
|
|
3
|
+
*
|
|
4
|
+
* extension-library FEATURES.md §"The light track": the host scans each file
|
|
5
|
+
* once for brightness (ffmpeg signalstats YAVG, limited-range `(v − 16) / 219`)
|
|
6
|
+
* and stores it at 4 samples/second, the MAX per bucket (the worst instant
|
|
7
|
+
* in a quarter second is what hits your eye), as integers 0–99, alongside
|
|
8
|
+
* `duration`, `rate` and `flashes`. That sidecar is exactly `VideoLumaTrack`.
|
|
9
|
+
*
|
|
10
|
+
* The track pays for itself three times: predictive dimming and the strobe
|
|
11
|
+
* guard (night's — mos-5lzns.33 reads the same `luma` prop) and the RIBBON,
|
|
12
|
+
* which is the axis owner's drawing: an amber wash `v² × 0.55`, painted UNDER
|
|
13
|
+
* the envelope through the band's own zoom window, so it reads as a property
|
|
14
|
+
* of the timeline rather than a fourth bar and it zooms with everything else.
|
|
15
|
+
*
|
|
16
|
+
* The squared alpha is the point: mid-grey footage barely tints the band,
|
|
17
|
+
* a white-out glows. DOM-free; node-testable.
|
|
18
|
+
*/
|
|
19
|
+
import { type BandWindow } from './window';
|
|
20
|
+
/** The sidecar, verbatim. `luma` values are integers 0–99 (the sidecar's own scale). */
|
|
21
|
+
export interface VideoLumaTrack {
|
|
22
|
+
luma: number[];
|
|
23
|
+
/** Samples per second. Defaults to the sidecar's 4. */
|
|
24
|
+
rate?: number;
|
|
25
|
+
/** Seconds the track spans. Defaults to `luma.length / rate`. */
|
|
26
|
+
durationSec?: number;
|
|
27
|
+
/** Detected flash intervals `[startSec, endSec]` — read by night's strobe guard (.33). */
|
|
28
|
+
flashes?: Array<[number, number]>;
|
|
29
|
+
}
|
|
30
|
+
export declare const LUMA_SCALE = 99;
|
|
31
|
+
export declare const LUMA_DEFAULT_RATE = 4;
|
|
32
|
+
/** Fewer parsed samples than this and the host writes nothing — so a track this short is not a track. */
|
|
33
|
+
export declare const LUMA_MIN_SAMPLES = 4;
|
|
34
|
+
/** Ribbon alpha ceiling: `v² × RIBBON_ALPHA` (FEATURES.md, the amber wash). */
|
|
35
|
+
export declare const RIBBON_ALPHA = 0.55;
|
|
36
|
+
/** Gradient stops for a whole-video ribbon (FEATURES.md: 120). */
|
|
37
|
+
export declare const RIBBON_STOPS = 120;
|
|
38
|
+
export interface LumaSeries {
|
|
39
|
+
/** 0–1 per sample. */
|
|
40
|
+
values: number[];
|
|
41
|
+
rate: number;
|
|
42
|
+
durationSec: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validate + normalise a track. Never throws; garbage in ⇒ null out (a
|
|
46
|
+
* missing ribbon, never a wrong one). A non-finite sample inherits the
|
|
47
|
+
* previous one — the host's own rule for empty buckets, so a gap never reads
|
|
48
|
+
* as pitch black.
|
|
49
|
+
*/
|
|
50
|
+
export declare function normalizeLuma(track: VideoLumaTrack | null | undefined): LumaSeries | null;
|
|
51
|
+
/**
|
|
52
|
+
* Peak brightness over `[t0, t1]` — the max of every bucket the interval
|
|
53
|
+
* touches (the same max-not-mean reasoning as the scan). 0 outside the track.
|
|
54
|
+
*/
|
|
55
|
+
export declare function lumaPeak(s: LumaSeries, t0: number, t1: number): number;
|
|
56
|
+
export declare function ribbonAlpha(v: number): number;
|
|
57
|
+
/**
|
|
58
|
+
* Ribbon alphas for `cols` equal columns across the band's CURRENT window —
|
|
59
|
+
* column i covers `[fracToSec(i/cols), fracToSec((i+1)/cols)]`, so the ribbon
|
|
60
|
+
* maps through the same window as the peaks and zooms with them.
|
|
61
|
+
*/
|
|
62
|
+
export declare function ribbonColumns(s: LumaSeries, w: BandWindow, duration: number, cols: number): number[];
|