@joewinke/jatui 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/VideoPlayer.svelte +371 -1
- package/dist/components/VideoPlayer.svelte.d.ts +44 -1
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +1 -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 +37 -31
- package/dist/video/band.js +78 -57
- package/dist/video/keys.d.ts +1 -1
- package/dist/video/keys.js +3 -0
- package/dist/video/model.d.ts +1 -1
- package/dist/video/settings.js +6 -5
- package/package.json +2 -2
|
@@ -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[];
|
|
@@ -0,0 +1,97 @@
|
|
|
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 { fracToSec, spanSec } from './window';
|
|
20
|
+
export const LUMA_SCALE = 99;
|
|
21
|
+
export const LUMA_DEFAULT_RATE = 4;
|
|
22
|
+
/** Fewer parsed samples than this and the host writes nothing — so a track this short is not a track. */
|
|
23
|
+
export const LUMA_MIN_SAMPLES = 4;
|
|
24
|
+
/** Ribbon alpha ceiling: `v² × RIBBON_ALPHA` (FEATURES.md, the amber wash). */
|
|
25
|
+
export const RIBBON_ALPHA = 0.55;
|
|
26
|
+
/** Gradient stops for a whole-video ribbon (FEATURES.md: 120). */
|
|
27
|
+
export const RIBBON_STOPS = 120;
|
|
28
|
+
/**
|
|
29
|
+
* Validate + normalise a track. Never throws; garbage in ⇒ null out (a
|
|
30
|
+
* missing ribbon, never a wrong one). A non-finite sample inherits the
|
|
31
|
+
* previous one — the host's own rule for empty buckets, so a gap never reads
|
|
32
|
+
* as pitch black.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeLuma(track) {
|
|
35
|
+
const raw = track?.luma;
|
|
36
|
+
if (!Array.isArray(raw) || raw.length < LUMA_MIN_SAMPLES)
|
|
37
|
+
return null;
|
|
38
|
+
const rate = typeof track?.rate === 'number' && Number.isFinite(track.rate) && track.rate > 0
|
|
39
|
+
? track.rate
|
|
40
|
+
: LUMA_DEFAULT_RATE;
|
|
41
|
+
const values = new Array(raw.length);
|
|
42
|
+
let prev = 0;
|
|
43
|
+
let anyFinite = false;
|
|
44
|
+
for (let i = 0; i < raw.length; i++) {
|
|
45
|
+
const v = raw[i];
|
|
46
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
47
|
+
prev = Math.max(0, Math.min(1, v / LUMA_SCALE));
|
|
48
|
+
anyFinite = true;
|
|
49
|
+
}
|
|
50
|
+
values[i] = prev;
|
|
51
|
+
}
|
|
52
|
+
if (!anyFinite)
|
|
53
|
+
return null;
|
|
54
|
+
const d = track?.durationSec;
|
|
55
|
+
const durationSec = typeof d === 'number' && Number.isFinite(d) && d > 0 ? d : raw.length / rate;
|
|
56
|
+
return { values, rate, durationSec };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Peak brightness over `[t0, t1]` — the max of every bucket the interval
|
|
60
|
+
* touches (the same max-not-mean reasoning as the scan). 0 outside the track.
|
|
61
|
+
*/
|
|
62
|
+
export function lumaPeak(s, t0, t1) {
|
|
63
|
+
if (t1 < t0)
|
|
64
|
+
[t0, t1] = [t1, t0];
|
|
65
|
+
if (t1 < 0 || t0 > s.durationSec)
|
|
66
|
+
return 0;
|
|
67
|
+
const n = s.values.length;
|
|
68
|
+
const i0 = Math.max(0, Math.min(n - 1, Math.floor(t0 * s.rate)));
|
|
69
|
+
// End-exclusive: an interval ending exactly on a bucket edge does not
|
|
70
|
+
// reach into the next bucket (adjacent columns must not both claim it).
|
|
71
|
+
const i1 = Math.max(i0, Math.min(n - 1, Math.ceil(t1 * s.rate) - 1));
|
|
72
|
+
let m = 0;
|
|
73
|
+
for (let i = i0; i <= i1; i++)
|
|
74
|
+
if (s.values[i] > m)
|
|
75
|
+
m = s.values[i];
|
|
76
|
+
return m;
|
|
77
|
+
}
|
|
78
|
+
export function ribbonAlpha(v) {
|
|
79
|
+
const c = Math.max(0, Math.min(1, v));
|
|
80
|
+
return c * c * RIBBON_ALPHA;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Ribbon alphas for `cols` equal columns across the band's CURRENT window —
|
|
84
|
+
* column i covers `[fracToSec(i/cols), fracToSec((i+1)/cols)]`, so the ribbon
|
|
85
|
+
* maps through the same window as the peaks and zooms with them.
|
|
86
|
+
*/
|
|
87
|
+
export function ribbonColumns(s, w, duration, cols) {
|
|
88
|
+
const out = [];
|
|
89
|
+
if (!(duration > 0) || cols <= 0 || !(spanSec(w, duration) > 0))
|
|
90
|
+
return out;
|
|
91
|
+
for (let i = 0; i < cols; i++) {
|
|
92
|
+
const t0 = fracToSec(w, duration, i / cols);
|
|
93
|
+
const t1 = fracToSec(w, duration, (i + 1) / cols);
|
|
94
|
+
out.push(ribbonAlpha(lumaPeak(s, t0, t1)));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social markers on the band — timestamped comments and a like heatmap
|
|
3
|
+
* (mos-5lzns.30's own brief; not an extension capability).
|
|
4
|
+
*
|
|
5
|
+
* The band already carries two marker families, told apart by EDGE and
|
|
6
|
+
* SHAPE: chapter flags (the model's) are triangles on the top edge, bookmark
|
|
7
|
+
* diamonds (yours) on the bottom edge. Social markers are other people's, so
|
|
8
|
+
* they get their own grammar rather than borrowing either:
|
|
9
|
+
*
|
|
10
|
+
* · comments — small round dots riding just under the chapter flags; hover
|
|
11
|
+
* shows the comment, click seeks to its exact second (no transcript
|
|
12
|
+
* bias — a comment is ABOUT that instant).
|
|
13
|
+
* · likes — never individual glyphs (a hundred likes would be a hundred
|
|
14
|
+
* dots). They fold into a heat strip along the bottom edge, under the
|
|
15
|
+
* bookmark diamonds, normalised to the window's own peak so "where did
|
|
16
|
+
* people react" reads at any zoom.
|
|
17
|
+
*
|
|
18
|
+
* DOM-free; node-testable.
|
|
19
|
+
*/
|
|
20
|
+
import { type BandWindow } from './window';
|
|
21
|
+
export interface VideoMarker {
|
|
22
|
+
atSec: number;
|
|
23
|
+
kind: 'comment' | 'like';
|
|
24
|
+
id?: string | null;
|
|
25
|
+
/** Comment body (comments only). */
|
|
26
|
+
text?: string | null;
|
|
27
|
+
author?: string | null;
|
|
28
|
+
/** Likes only: how many reactions this entry stands for (pre-aggregated hosts). Default 1. */
|
|
29
|
+
weight?: number | null;
|
|
30
|
+
}
|
|
31
|
+
export declare const MARKER_HIT_PX = 6;
|
|
32
|
+
/** Under `(pointer: coarse)` a fingertip needs a target, not a 6px sliver. */
|
|
33
|
+
export declare const MARKER_HIT_PX_COARSE = 14;
|
|
34
|
+
/** Comment dots closer than this collapse to one (the chapter-flag merge rule). */
|
|
35
|
+
export declare const COMMENT_MERGE_PX = 6;
|
|
36
|
+
/** Heat bins are this many CSS px wide. */
|
|
37
|
+
export declare const HEAT_BIN_PX = 3;
|
|
38
|
+
/** Drop junk (non-finite / negative times, unknown kinds) and sort ascending. */
|
|
39
|
+
export declare function sortMarkers(markers: VideoMarker[] | null | undefined): VideoMarker[];
|
|
40
|
+
export declare function splitMarkers(sorted: VideoMarker[]): {
|
|
41
|
+
comments: VideoMarker[];
|
|
42
|
+
likes: VideoMarker[];
|
|
43
|
+
};
|
|
44
|
+
export interface CommentCluster {
|
|
45
|
+
x: number;
|
|
46
|
+
markers: VideoMarker[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Comment dots in the visible window, x in CSS px, with neighbours closer
|
|
50
|
+
* than COMMENT_MERGE_PX collapsed (a long video at full zoom-out must not
|
|
51
|
+
* smear them into a bar). Expects ascending input.
|
|
52
|
+
*/
|
|
53
|
+
export declare function commentClusters(comments: VideoMarker[], w: BandWindow, duration: number, width: number): CommentCluster[];
|
|
54
|
+
/** The comment cluster under the pointer, or null. */
|
|
55
|
+
export declare function commentAt(xCss: number, clusters: CommentCluster[], hitPx?: number): CommentCluster | null;
|
|
56
|
+
/**
|
|
57
|
+
* Like density across the visible window in `bins` equal bins, smoothed
|
|
58
|
+
* with a [1,2,1] kernel (a reaction lands a beat after the moment) and
|
|
59
|
+
* normalised to 0–1 against the window's own peak. All zeros when there is
|
|
60
|
+
* nothing to show.
|
|
61
|
+
*/
|
|
62
|
+
export declare function likeHeat(likes: VideoMarker[], w: BandWindow, duration: number, bins: number): number[];
|
|
63
|
+
/**
|
|
64
|
+
* Previous / next comment relative to the playhead — the keyboard path to
|
|
65
|
+
* markers that are otherwise pointer-only. Same graces as the chapter jump
|
|
66
|
+
* (keys.ts): `next` skips 0.25s ahead so a press on a comment's own second
|
|
67
|
+
* doesn't stick; `previous` looks 0.75s back so it reaches the one BEFORE
|
|
68
|
+
* the comment you are sitting on. Comments sharing an instant come back
|
|
69
|
+
* together (a cluster is announced as one). Expects ascending input.
|
|
70
|
+
*/
|
|
71
|
+
export declare function commentJump(current: number, comments: VideoMarker[], direction: 1 | -1): VideoMarker[] | null;
|
|
72
|
+
/** One line for a screen reader: who, when, what — and how many more. */
|
|
73
|
+
export declare function describeComments(group: VideoMarker[], formatAt: (sec: number) => string): string;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social markers on the band — timestamped comments and a like heatmap
|
|
3
|
+
* (mos-5lzns.30's own brief; not an extension capability).
|
|
4
|
+
*
|
|
5
|
+
* The band already carries two marker families, told apart by EDGE and
|
|
6
|
+
* SHAPE: chapter flags (the model's) are triangles on the top edge, bookmark
|
|
7
|
+
* diamonds (yours) on the bottom edge. Social markers are other people's, so
|
|
8
|
+
* they get their own grammar rather than borrowing either:
|
|
9
|
+
*
|
|
10
|
+
* · comments — small round dots riding just under the chapter flags; hover
|
|
11
|
+
* shows the comment, click seeks to its exact second (no transcript
|
|
12
|
+
* bias — a comment is ABOUT that instant).
|
|
13
|
+
* · likes — never individual glyphs (a hundred likes would be a hundred
|
|
14
|
+
* dots). They fold into a heat strip along the bottom edge, under the
|
|
15
|
+
* bookmark diamonds, normalised to the window's own peak so "where did
|
|
16
|
+
* people react" reads at any zoom.
|
|
17
|
+
*
|
|
18
|
+
* DOM-free; node-testable.
|
|
19
|
+
*/
|
|
20
|
+
import { secToFrac, spanSec } from './window';
|
|
21
|
+
export const MARKER_HIT_PX = 6;
|
|
22
|
+
/** Under `(pointer: coarse)` a fingertip needs a target, not a 6px sliver. */
|
|
23
|
+
export const MARKER_HIT_PX_COARSE = 14;
|
|
24
|
+
/** Comment dots closer than this collapse to one (the chapter-flag merge rule). */
|
|
25
|
+
export const COMMENT_MERGE_PX = 6;
|
|
26
|
+
/** Heat bins are this many CSS px wide. */
|
|
27
|
+
export const HEAT_BIN_PX = 3;
|
|
28
|
+
/** Drop junk (non-finite / negative times, unknown kinds) and sort ascending. */
|
|
29
|
+
export function sortMarkers(markers) {
|
|
30
|
+
if (!Array.isArray(markers))
|
|
31
|
+
return [];
|
|
32
|
+
return markers
|
|
33
|
+
.filter((m) => m != null &&
|
|
34
|
+
typeof m.atSec === 'number' &&
|
|
35
|
+
Number.isFinite(m.atSec) &&
|
|
36
|
+
m.atSec >= 0 &&
|
|
37
|
+
(m.kind === 'comment' || m.kind === 'like'))
|
|
38
|
+
.slice()
|
|
39
|
+
.sort((a, b) => a.atSec - b.atSec);
|
|
40
|
+
}
|
|
41
|
+
export function splitMarkers(sorted) {
|
|
42
|
+
const comments = [];
|
|
43
|
+
const likes = [];
|
|
44
|
+
for (const m of sorted)
|
|
45
|
+
(m.kind === 'comment' ? comments : likes).push(m);
|
|
46
|
+
return { comments, likes };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Comment dots in the visible window, x in CSS px, with neighbours closer
|
|
50
|
+
* than COMMENT_MERGE_PX collapsed (a long video at full zoom-out must not
|
|
51
|
+
* smear them into a bar). Expects ascending input.
|
|
52
|
+
*/
|
|
53
|
+
export function commentClusters(comments, w, duration, width) {
|
|
54
|
+
const out = [];
|
|
55
|
+
if (!(duration > 0) || !(width > 0))
|
|
56
|
+
return out;
|
|
57
|
+
for (const m of comments) {
|
|
58
|
+
const x = secToFrac(w, duration, m.atSec) * width;
|
|
59
|
+
if (x < -4 || x > width + 4)
|
|
60
|
+
continue;
|
|
61
|
+
const last = out[out.length - 1];
|
|
62
|
+
if (last && x - last.x < COMMENT_MERGE_PX)
|
|
63
|
+
last.markers.push(m);
|
|
64
|
+
else
|
|
65
|
+
out.push({ x, markers: [m] });
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** The comment cluster under the pointer, or null. */
|
|
70
|
+
export function commentAt(xCss, clusters, hitPx = MARKER_HIT_PX) {
|
|
71
|
+
let best = null;
|
|
72
|
+
let bestD = hitPx;
|
|
73
|
+
for (const c of clusters) {
|
|
74
|
+
const d = Math.abs(c.x - xCss);
|
|
75
|
+
if (d < bestD) {
|
|
76
|
+
bestD = d;
|
|
77
|
+
best = c;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return best;
|
|
81
|
+
}
|
|
82
|
+
function likeWeight(m) {
|
|
83
|
+
const w = m.weight;
|
|
84
|
+
return typeof w === 'number' && Number.isFinite(w) && w > 0 ? w : 1;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Like density across the visible window in `bins` equal bins, smoothed
|
|
88
|
+
* with a [1,2,1] kernel (a reaction lands a beat after the moment) and
|
|
89
|
+
* normalised to 0–1 against the window's own peak. All zeros when there is
|
|
90
|
+
* nothing to show.
|
|
91
|
+
*/
|
|
92
|
+
export function likeHeat(likes, w, duration, bins) {
|
|
93
|
+
const n = Math.max(0, Math.floor(bins));
|
|
94
|
+
const raw = new Array(n).fill(0);
|
|
95
|
+
if (!n || !(duration > 0) || !(spanSec(w, duration) > 0))
|
|
96
|
+
return raw;
|
|
97
|
+
for (const m of likes) {
|
|
98
|
+
const f = secToFrac(w, duration, m.atSec);
|
|
99
|
+
if (f < 0 || f > 1)
|
|
100
|
+
continue;
|
|
101
|
+
raw[Math.min(n - 1, Math.floor(f * n))] += likeWeight(m);
|
|
102
|
+
}
|
|
103
|
+
const out = new Array(n).fill(0);
|
|
104
|
+
let max = 0;
|
|
105
|
+
for (let i = 0; i < n; i++) {
|
|
106
|
+
const v = 2 * raw[i] + (raw[i - 1] ?? 0) + (raw[i + 1] ?? 0);
|
|
107
|
+
out[i] = v;
|
|
108
|
+
if (v > max)
|
|
109
|
+
max = v;
|
|
110
|
+
}
|
|
111
|
+
return max > 0 ? out.map((v) => v / max) : out;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Previous / next comment relative to the playhead — the keyboard path to
|
|
115
|
+
* markers that are otherwise pointer-only. Same graces as the chapter jump
|
|
116
|
+
* (keys.ts): `next` skips 0.25s ahead so a press on a comment's own second
|
|
117
|
+
* doesn't stick; `previous` looks 0.75s back so it reaches the one BEFORE
|
|
118
|
+
* the comment you are sitting on. Comments sharing an instant come back
|
|
119
|
+
* together (a cluster is announced as one). Expects ascending input.
|
|
120
|
+
*/
|
|
121
|
+
export function commentJump(current, comments, direction) {
|
|
122
|
+
let hit;
|
|
123
|
+
if (direction === 1)
|
|
124
|
+
hit = comments.find((c) => c.atSec > current + 0.25);
|
|
125
|
+
else
|
|
126
|
+
for (const c of comments) {
|
|
127
|
+
if (c.atSec < current - 0.75)
|
|
128
|
+
hit = c;
|
|
129
|
+
else
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (!hit)
|
|
133
|
+
return null;
|
|
134
|
+
const at = hit.atSec;
|
|
135
|
+
return comments.filter((c) => Math.abs(c.atSec - at) < 0.5);
|
|
136
|
+
}
|
|
137
|
+
/** One line for a screen reader: who, when, what — and how many more. */
|
|
138
|
+
export function describeComments(group, formatAt) {
|
|
139
|
+
if (group.length === 0)
|
|
140
|
+
return '';
|
|
141
|
+
const first = group[0];
|
|
142
|
+
const who = first.author ? `Comment by ${first.author}` : 'Comment';
|
|
143
|
+
const body = first.text ? `: ${first.text}` : '';
|
|
144
|
+
const more = group.length > 1 ? ` (+${group.length - 1} more)` : '';
|
|
145
|
+
return `${who} at ${formatAt(first.atSec)}${body}${more}`;
|
|
146
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance memo slots for the band's derived layers (mos-5lzns.30
|
|
3
|
+
* review). The band repaints on every timeupdate, but the ribbon columns,
|
|
4
|
+
* like heat and comment clusters depend only on (window, width, data) — so
|
|
5
|
+
* they are recomputed when one of THOSE changes, never per playhead tick.
|
|
6
|
+
* Deps compare by identity (`Object.is`), so data props must be replaced,
|
|
7
|
+
* not mutated in place — the component's own convention already.
|
|
8
|
+
*/
|
|
9
|
+
export interface MemoSlot<T> {
|
|
10
|
+
deps: unknown[] | null;
|
|
11
|
+
value: T | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare function memoSlot<T>(): MemoSlot<T>;
|
|
14
|
+
export declare function memo<T>(slot: MemoSlot<T>, deps: unknown[], compute: () => T): T;
|
|
15
|
+
/** The band's three derived layers, one cache per player instance. */
|
|
16
|
+
export interface BandLayerCache {
|
|
17
|
+
ribbon: MemoSlot<number[]>;
|
|
18
|
+
heat: MemoSlot<number[]>;
|
|
19
|
+
clusters: MemoSlot<import('./markers').CommentCluster[]>;
|
|
20
|
+
}
|
|
21
|
+
export declare function createBandLayerCache(): BandLayerCache;
|