@joewinke/jatui 0.18.1 → 0.19.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 +347 -24
- package/dist/video/model.d.ts +35 -3
- package/dist/video/model.js +40 -5
- package/dist/video/speakerTheme.d.ts +43 -0
- package/dist/video/speakerTheme.js +156 -0
- package/dist/video/transcriptWindow.d.ts +52 -0
- package/dist/video/transcriptWindow.js +102 -0
- package/package.json +1 -1
|
@@ -101,9 +101,20 @@
|
|
|
101
101
|
sortSegments,
|
|
102
102
|
sortTurns,
|
|
103
103
|
speakerKeyOf,
|
|
104
|
-
|
|
104
|
+
DEFAULT_SPEAKER_SOURCE,
|
|
105
105
|
UNKNOWN_KEY
|
|
106
106
|
} from '../video/model';
|
|
107
|
+
import { adjustSpeakerColors, buildSpeakerPaletteSource } from '../video/speakerTheme';
|
|
108
|
+
import {
|
|
109
|
+
buildOffsets,
|
|
110
|
+
EST_ROW_H,
|
|
111
|
+
findAnchor,
|
|
112
|
+
OVERSCAN,
|
|
113
|
+
TRANS_GAP,
|
|
114
|
+
transcriptResetKind,
|
|
115
|
+
VIRTUAL_THRESHOLD,
|
|
116
|
+
windowFor
|
|
117
|
+
} from '../video/transcriptWindow';
|
|
107
118
|
import {
|
|
108
119
|
bandClusters,
|
|
109
120
|
clampCardX,
|
|
@@ -724,6 +735,224 @@
|
|
|
724
735
|
// mount and only read from inside an effect, never used as a reactive source.
|
|
725
736
|
let rowRefs: Record<string, HTMLButtonElement> = {};
|
|
726
737
|
|
|
738
|
+
// ── Transcript windowing (mos-5lzns.95) ─────────────────────────────────
|
|
739
|
+
// Long transcripts (a 2h talk ≈ 2–3k rows) render only the rows near the
|
|
740
|
+
// viewport; the math lives in video/transcriptWindow.ts. Heights vary
|
|
741
|
+
// (text wraps), so rendered rows are measured and the estimates corrected.
|
|
742
|
+
const transVirtual = $derived(sortedSegments.length > VIRTUAL_THRESHOLD);
|
|
743
|
+
const segIndexById = $derived.by(() => {
|
|
744
|
+
const m = new Map<string, number>();
|
|
745
|
+
sortedSegments.forEach((s, i) => m.set(s.id, i));
|
|
746
|
+
return m;
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
let transcriptEl: HTMLDivElement | null = null;
|
|
750
|
+
let vpScrollTop = $state(0);
|
|
751
|
+
let vpHeight = $state(0);
|
|
752
|
+
let heightsVersion = $state(0);
|
|
753
|
+
// Non-reactive window caches — reset wholesale when the transcript changes.
|
|
754
|
+
let measuredHeights: number[] = [];
|
|
755
|
+
let offsetsCache: number[] = [];
|
|
756
|
+
let offsetsValid = false;
|
|
757
|
+
let transPadTop = 0; // pane padding-top (rows start below it)
|
|
758
|
+
let pendingAutoScrollId: string | null = $state(null);
|
|
759
|
+
let measureScheduled = false;
|
|
760
|
+
const pendingMeasures = new Map<number, HTMLButtonElement>();
|
|
761
|
+
|
|
762
|
+
function ensureOffsets(): number[] {
|
|
763
|
+
if (offsetsValid) return offsetsCache;
|
|
764
|
+
offsetsCache = buildOffsets(measuredHeights, sortedSegments.length, EST_ROW_H, TRANS_GAP);
|
|
765
|
+
offsetsValid = true;
|
|
766
|
+
return offsetsCache;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function resetTranscriptWindow() {
|
|
770
|
+
measuredHeights = [];
|
|
771
|
+
offsetsValid = false;
|
|
772
|
+
offsetsCache = [];
|
|
773
|
+
transPadTop = 0;
|
|
774
|
+
pendingAutoScrollId = null;
|
|
775
|
+
vpScrollTop = 0;
|
|
776
|
+
if (transcriptEl) transcriptEl.scrollTop = 0;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Append-only growth: heights measured for the prefix stay valid, so only
|
|
780
|
+
* the offsets rebuild (lazily, for the new length); scroll position holds. */
|
|
781
|
+
function extendTranscriptWindow() {
|
|
782
|
+
offsetsValid = false;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Re-index + re-queue measurement for every mounted row. A transcript
|
|
786
|
+
* swap can keep keyed rows mounted (same seg.id) while their indices and
|
|
787
|
+
* the height cache change — without this, dataset.segIndex goes stale and
|
|
788
|
+
* the spacers drift. */
|
|
789
|
+
function requeueMountedRows() {
|
|
790
|
+
for (const [id, node] of Object.entries(rowRefs)) {
|
|
791
|
+
const idx = segIndexById.get(id);
|
|
792
|
+
if (idx == null) continue; // row unmounts; its action destroy cleans up
|
|
793
|
+
node.dataset.segIndex = String(idx);
|
|
794
|
+
if (transVirtual) pendingMeasures.set(idx, node);
|
|
795
|
+
}
|
|
796
|
+
scheduleRowMeasure();
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
let prevSegmentIds: string[] | null = null;
|
|
800
|
+
|
|
801
|
+
$effect(() => {
|
|
802
|
+
// New transcript data ⇒ classify against the ids already rendered:
|
|
803
|
+
// unchanged ⇒ nothing (rows are keyed and stay mounted); append-only ⇒
|
|
804
|
+
// keep scrollTop and the measured prefix; anything else ⇒ full reset.
|
|
805
|
+
void transVirtual;
|
|
806
|
+
const ids = sortedSegments.map((s) => s.id);
|
|
807
|
+
untrack(() => {
|
|
808
|
+
const prev = prevSegmentIds;
|
|
809
|
+
prevSegmentIds = ids;
|
|
810
|
+
const kind = prev === null ? 'reset' : transcriptResetKind(prev, ids);
|
|
811
|
+
if (kind === 'reset') {
|
|
812
|
+
resetTranscriptWindow();
|
|
813
|
+
requeueMountedRows();
|
|
814
|
+
} else if (kind === 'extend') {
|
|
815
|
+
extendTranscriptWindow();
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const transWindow = $derived.by(() => {
|
|
821
|
+
if (!transVirtual) return null;
|
|
822
|
+
void vpScrollTop;
|
|
823
|
+
void vpHeight;
|
|
824
|
+
void heightsVersion;
|
|
825
|
+
const offsets = ensureOffsets();
|
|
826
|
+
const w = windowFor(offsets, vpScrollTop - transPadTop, Math.max(0, vpHeight), OVERSCAN);
|
|
827
|
+
// Spacer heights account for the flex gap that separates them from the
|
|
828
|
+
// rows: the top spacer is followed by a gap, and the pane's last row has
|
|
829
|
+
// no trailing gap, so the total matches the unwindowed layout exactly.
|
|
830
|
+
const total = offsets[sortedSegments.length] ?? 0;
|
|
831
|
+
return {
|
|
832
|
+
...w,
|
|
833
|
+
padTop: Math.max(0, (offsets[w.first] ?? 0) - TRANS_GAP),
|
|
834
|
+
padBottom: Math.max(0, total - TRANS_GAP - (offsets[w.last] ?? total))
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
const renderedSegments = $derived(
|
|
839
|
+
transWindow ? sortedSegments.slice(transWindow.first, transWindow.last) : sortedSegments
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
function onTranscriptScroll() {
|
|
843
|
+
if (transcriptEl) vpScrollTop = transcriptEl.scrollTop;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function bindTranscriptRow(seg: VideoTranscriptSegment, node: HTMLButtonElement) {
|
|
847
|
+
rowRefs[seg.id] = node;
|
|
848
|
+
if (!transVirtual) return;
|
|
849
|
+
const idx = segIndexById.get(seg.id);
|
|
850
|
+
if (idx == null) return;
|
|
851
|
+
node.dataset.segIndex = String(idx);
|
|
852
|
+
pendingMeasures.set(idx, node);
|
|
853
|
+
scheduleRowMeasure();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Action (node, param): bind the row, measure it, keep it correct across
|
|
857
|
+
* transcript updates, and clean the ref up on unmount. */
|
|
858
|
+
function transcriptRowAction(node: HTMLButtonElement, seg: VideoTranscriptSegment) {
|
|
859
|
+
bindTranscriptRow(seg, node);
|
|
860
|
+
return {
|
|
861
|
+
update(next: VideoTranscriptSegment) {
|
|
862
|
+
if (next.id !== seg.id) delete rowRefs[seg.id];
|
|
863
|
+
seg = next;
|
|
864
|
+
// Keyed rows survive transcript rebuilds: re-index the dataset and
|
|
865
|
+
// re-queue measurement so the height cache tracks the new array.
|
|
866
|
+
bindTranscriptRow(seg, node);
|
|
867
|
+
},
|
|
868
|
+
destroy() {
|
|
869
|
+
delete rowRefs[seg.id];
|
|
870
|
+
// Windowing unmount of the FOCUSED row: move focus to the pane
|
|
871
|
+
// container instead of dropping it to <body> (keyboard users keep
|
|
872
|
+
// their place; scrolling brings the rows back into range).
|
|
873
|
+
const idx = segIndexById.get(seg.id);
|
|
874
|
+
const outOfWindow =
|
|
875
|
+
idx == null ||
|
|
876
|
+
transWindow === null ||
|
|
877
|
+
idx < transWindow.first ||
|
|
878
|
+
idx >= transWindow.last;
|
|
879
|
+
if (
|
|
880
|
+
transVirtual &&
|
|
881
|
+
transWindow !== null &&
|
|
882
|
+
outOfWindow &&
|
|
883
|
+
transcriptEl &&
|
|
884
|
+
document.activeElement === node
|
|
885
|
+
) {
|
|
886
|
+
transcriptEl.focus();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function scheduleRowMeasure() {
|
|
893
|
+
if (measureScheduled) return;
|
|
894
|
+
measureScheduled = true;
|
|
895
|
+
requestAnimationFrame(applyRowMeasures);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function applyRowMeasures() {
|
|
899
|
+
measureScheduled = false;
|
|
900
|
+
if (pendingMeasures.size === 0) return;
|
|
901
|
+
const el = transcriptEl;
|
|
902
|
+
const before = ensureOffsets();
|
|
903
|
+
const st = el ? el.scrollTop : vpScrollTop;
|
|
904
|
+
const anchor = findAnchor(before, st);
|
|
905
|
+
const beforeTop = before[anchor] ?? 0;
|
|
906
|
+
let changed = false;
|
|
907
|
+
for (const [idx, node] of pendingMeasures) {
|
|
908
|
+
const h = node.offsetHeight;
|
|
909
|
+
if (h > 0 && measuredHeights[idx] !== h) {
|
|
910
|
+
measuredHeights[idx] = h;
|
|
911
|
+
changed = true;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
pendingMeasures.clear();
|
|
915
|
+
if (!changed) return;
|
|
916
|
+
offsetsValid = false;
|
|
917
|
+
heightsVersion++;
|
|
918
|
+
// Keep the viewport content stable: if rows ABOVE the scroll position
|
|
919
|
+
// changed height, shift scrollTop by the same delta so nothing jumps.
|
|
920
|
+
const after = ensureOffsets();
|
|
921
|
+
const delta = (after[anchor] ?? beforeTop) - beforeTop;
|
|
922
|
+
if (el && delta !== 0 && st >= beforeTop) {
|
|
923
|
+
el.scrollTop = st + delta; // fires onscroll → vpScrollTop follows
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function measureTranscriptPane(el: HTMLDivElement) {
|
|
928
|
+
transcriptEl = el;
|
|
929
|
+
el.tabIndex = -1; // focus sink when the focused row unmounts from windowing
|
|
930
|
+
const cs = getComputedStyle(el);
|
|
931
|
+
transPadTop = parseFloat(cs.paddingTop) || 0;
|
|
932
|
+
vpHeight = el.clientHeight;
|
|
933
|
+
const ro = new ResizeObserver(() => {
|
|
934
|
+
vpHeight = el.clientHeight;
|
|
935
|
+
// Width changes re-wrap text: re-measure what's rendered.
|
|
936
|
+
for (const node of Object.values(rowRefs)) {
|
|
937
|
+
const idx = Number(node.dataset.segIndex);
|
|
938
|
+
if (Number.isInteger(idx) && idx >= 0) pendingMeasures.set(idx, node);
|
|
939
|
+
}
|
|
940
|
+
scheduleRowMeasure();
|
|
941
|
+
});
|
|
942
|
+
ro.observe(el);
|
|
943
|
+
return {
|
|
944
|
+
destroy() {
|
|
945
|
+
ro.disconnect();
|
|
946
|
+
transcriptEl = null;
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Action wrapper so the template can attach the pane. */
|
|
952
|
+
function transcriptPaneAction(el: HTMLDivElement) {
|
|
953
|
+
return measureTranscriptPane(el);
|
|
954
|
+
}
|
|
955
|
+
|
|
727
956
|
// ── Derived data ────────────────────────────────────────────────────────
|
|
728
957
|
const effectiveDuration = $derived(
|
|
729
958
|
Number.isFinite(duration) && duration > 0 ? duration : (waveform?.durationSec ?? 0)
|
|
@@ -760,8 +989,34 @@
|
|
|
760
989
|
|
|
761
990
|
const hasSpeakers = $derived(speakerIndex.size > 0);
|
|
762
991
|
|
|
763
|
-
/**
|
|
764
|
-
|
|
992
|
+
/**
|
|
993
|
+
* Theme-derived speaker identity: colours + the fallback cycle in one read
|
|
994
|
+
* (mos-5lzns.95). Colours derive from the live theme's tokens: the cycle
|
|
995
|
+
* rotates the semantic role colours and hue-bearing speakers take the
|
|
996
|
+
* theme's own lightness/chroma, then every colour is lifted to AA against
|
|
997
|
+
* the transcript surface by mixing toward base-content. SSR keeps the
|
|
998
|
+
* legacy literal palette (deterministic snapshot output); themeTick
|
|
999
|
+
* recomputes everything on a theme swap.
|
|
1000
|
+
*/
|
|
1001
|
+
const speakerTheme = $derived.by(() => {
|
|
1002
|
+
void themeTick;
|
|
1003
|
+
if (typeof document === 'undefined') {
|
|
1004
|
+
return {
|
|
1005
|
+
colors: buildSpeakerColors(speakerIndex, DEFAULT_SPEAKER_SOURCE),
|
|
1006
|
+
cycle: DEFAULT_SPEAKER_SOURCE.cycle
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const ctx = buildSpeakerPaletteSource();
|
|
1010
|
+
return {
|
|
1011
|
+
colors: adjustSpeakerColors(
|
|
1012
|
+
buildSpeakerColors(speakerIndex, ctx.source),
|
|
1013
|
+
ctx.surface,
|
|
1014
|
+
ctx.ink
|
|
1015
|
+
),
|
|
1016
|
+
cycle: ctx.source.cycle
|
|
1017
|
+
};
|
|
1018
|
+
});
|
|
1019
|
+
const speakerColors = $derived(speakerTheme.colors);
|
|
765
1020
|
|
|
766
1021
|
/** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
|
|
767
1022
|
const laneCarriesSpeakers = $derived(!hasWave && sortedTurns.length > 0);
|
|
@@ -863,7 +1118,7 @@
|
|
|
863
1118
|
|
|
864
1119
|
/** Explicit hue wins (host-supplied identity colour); otherwise the cycled palette. */
|
|
865
1120
|
function speakerColor(key: string): string {
|
|
866
|
-
return speakerColors.get(key) ??
|
|
1121
|
+
return speakerColors.get(key) ?? speakerTheme.cycle[0] ?? DEFAULT_SPEAKER_SOURCE.cycle[0];
|
|
867
1122
|
}
|
|
868
1123
|
|
|
869
1124
|
// ── Zoom window: one mapping every position computation goes through ────
|
|
@@ -2471,10 +2726,39 @@
|
|
|
2471
2726
|
});
|
|
2472
2727
|
|
|
2473
2728
|
// Auto-scroll the active transcript row into view as playback advances.
|
|
2729
|
+
// Windowed panes may not have the row rendered: jump to its (estimated)
|
|
2730
|
+
// offset first, then a follow-up effect lands exactly on the real row once
|
|
2731
|
+
// the window renders it.
|
|
2474
2732
|
$effect(() => {
|
|
2475
2733
|
const seg = activeSegment;
|
|
2476
2734
|
if (!seg) return;
|
|
2477
|
-
untrack(() =>
|
|
2735
|
+
untrack(() => {
|
|
2736
|
+
const node = rowRefs[seg.id];
|
|
2737
|
+
if (node) {
|
|
2738
|
+
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2739
|
+
return;
|
|
2740
|
+
}
|
|
2741
|
+
if (!transVirtual || !transcriptEl) return;
|
|
2742
|
+
const idx = segIndexById.get(seg.id);
|
|
2743
|
+
if (idx == null) return;
|
|
2744
|
+
const top = ensureOffsets()[idx] ?? 0;
|
|
2745
|
+
// Row top (in content coords) = pane padding + offset; land it ⅓ down.
|
|
2746
|
+
transcriptEl.scrollTop = Math.max(0, top + transPadTop - Math.max(0, vpHeight) / 3);
|
|
2747
|
+
pendingAutoScrollId = seg.id;
|
|
2748
|
+
});
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
// Exact landing after a windowed jump: the row is now rendered — snap to it.
|
|
2752
|
+
$effect(() => {
|
|
2753
|
+
void renderedSegments;
|
|
2754
|
+
const id = pendingAutoScrollId;
|
|
2755
|
+
if (!id) return;
|
|
2756
|
+
untrack(() => {
|
|
2757
|
+
const node = rowRefs[id];
|
|
2758
|
+
if (!node) return;
|
|
2759
|
+
pendingAutoScrollId = null;
|
|
2760
|
+
node.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
2761
|
+
});
|
|
2478
2762
|
});
|
|
2479
2763
|
|
|
2480
2764
|
// ── Legend interactions ─────────────────────────────────────────────────
|
|
@@ -2861,25 +3145,56 @@
|
|
|
2861
3145
|
</div>
|
|
2862
3146
|
|
|
2863
3147
|
{#if sortedSegments.length > 0 && transcriptVisible}
|
|
2864
|
-
<div
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
<
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
3148
|
+
<div
|
|
3149
|
+
class="vp-transcript"
|
|
3150
|
+
style="max-height: {transcriptMaxHeight}"
|
|
3151
|
+
use:transcriptPaneAction
|
|
3152
|
+
onscroll={onTranscriptScroll}
|
|
3153
|
+
>
|
|
3154
|
+
{#if transWindow}
|
|
3155
|
+
<!-- Windowed: only the rows near the viewport exist in the DOM.
|
|
3156
|
+
Spacers stand in for the rows above/below; heights are
|
|
3157
|
+
estimates corrected by measuring rendered rows. -->
|
|
3158
|
+
<div class="vp-spacer" style="height: {transWindow.padTop}px" aria-hidden="true"></div>
|
|
3159
|
+
{#each renderedSegments as seg (seg.id)}
|
|
3160
|
+
<button
|
|
3161
|
+
type="button"
|
|
3162
|
+
class="vp-row"
|
|
3163
|
+
class:active={activeSegment?.id === seg.id}
|
|
3164
|
+
class:vp-dim={highlightKey != null
|
|
3165
|
+
? highlightKey !== speakerKeyOf(seg)
|
|
3166
|
+
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
3167
|
+
use:transcriptRowAction={seg}
|
|
3168
|
+
onclick={() => seekTo(seg.startSec)}
|
|
3169
|
+
>
|
|
3170
|
+
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3171
|
+
<span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
|
|
3172
|
+
{seg.speakerName ?? 'Unknown speaker'}
|
|
3173
|
+
</span>
|
|
3174
|
+
<span class="vp-row-text">{seg.text}</span>
|
|
3175
|
+
</button>
|
|
3176
|
+
{/each}
|
|
3177
|
+
<div class="vp-spacer" style="height: {transWindow.padBottom}px" aria-hidden="true"></div>
|
|
3178
|
+
{:else}
|
|
3179
|
+
{#each sortedSegments as seg (seg.id)}
|
|
3180
|
+
<button
|
|
3181
|
+
type="button"
|
|
3182
|
+
class="vp-row"
|
|
3183
|
+
class:active={activeSegment?.id === seg.id}
|
|
3184
|
+
class:vp-dim={highlightKey != null
|
|
3185
|
+
? highlightKey !== speakerKeyOf(seg)
|
|
3186
|
+
: filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
|
|
3187
|
+
use:transcriptRowAction={seg}
|
|
3188
|
+
onclick={() => seekTo(seg.startSec)}
|
|
3189
|
+
>
|
|
3190
|
+
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3191
|
+
<span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
|
|
3192
|
+
{seg.speakerName ?? 'Unknown speaker'}
|
|
3193
|
+
</span>
|
|
3194
|
+
<span class="vp-row-text">{seg.text}</span>
|
|
3195
|
+
</button>
|
|
3196
|
+
{/each}
|
|
3197
|
+
{/if}
|
|
2883
3198
|
</div>
|
|
2884
3199
|
{/if}
|
|
2885
3200
|
|
|
@@ -3391,6 +3706,14 @@
|
|
|
3391
3706
|
min-width: 0;
|
|
3392
3707
|
}
|
|
3393
3708
|
|
|
3709
|
+
/* Windowing stand-ins (mos-5lzns.95): fixed-height blocks sized by the
|
|
3710
|
+
prefix-sum offsets so the scrollbar reflects the whole transcript. */
|
|
3711
|
+
.vp-spacer {
|
|
3712
|
+
flex: none;
|
|
3713
|
+
width: 100%;
|
|
3714
|
+
pointer-events: none;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3394
3717
|
.vp-row {
|
|
3395
3718
|
display: flex;
|
|
3396
3719
|
align-items: baseline;
|
package/dist/video/model.d.ts
CHANGED
|
@@ -10,10 +10,42 @@ import type { VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment } f
|
|
|
10
10
|
export declare const ALL_KEY = "__all__";
|
|
11
11
|
export declare const UNKNOWN_KEY = "__unknown__";
|
|
12
12
|
/**
|
|
13
|
-
* Speaker identity palette — the
|
|
14
|
-
*
|
|
13
|
+
* Speaker identity palette — the FALLBACK cycle used only where no theme is
|
|
14
|
+
* reachable (SSR render, unit tests, un-styled hosts). Live components derive
|
|
15
|
+
* the cycle from DaisyUI theme tokens via buildSpeakerPaletteSource()
|
|
16
|
+
* (video/speakerTheme.ts) so white-label tenants reskin speaker colours with
|
|
17
|
+
* the rest of the theme (mos-5lzns.95).
|
|
15
18
|
*/
|
|
16
19
|
export declare const SPEAKER_PALETTE: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Where identity colours come from. `cycle` is the rotation for speakers
|
|
22
|
+
* without an explicit diarization hue; `hueBase` (lightness + chroma from a
|
|
23
|
+
* THEME token, usually --color-primary) is rebuilt into `oklch(L C hue)` so
|
|
24
|
+
* hue-bearing speakers follow the theme's own tone instead of a literal.
|
|
25
|
+
* `hueBase: null` falls those speakers back onto the cycle.
|
|
26
|
+
*/
|
|
27
|
+
export interface SpeakerColorSource {
|
|
28
|
+
cycle: string[];
|
|
29
|
+
hueBase: {
|
|
30
|
+
l: number;
|
|
31
|
+
c: number;
|
|
32
|
+
} | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The legacy derivation, kept as the default so SSR and pure consumers render
|
|
36
|
+
* exactly as before (stable snapshot output, no DOM needed).
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_SPEAKER_SOURCE: SpeakerColorSource;
|
|
39
|
+
/**
|
|
40
|
+
* Parse `oklch(L C H)` / `oklch(L% C H)` / with alpha into just the lightness
|
|
41
|
+
* and chroma channels (hue comes from the speaker, not the theme). Returns
|
|
42
|
+
* null for anything that isn't an oklch() literal — hex themes just fall back
|
|
43
|
+
* to the cycle.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseOklch(color: string): {
|
|
46
|
+
l: number;
|
|
47
|
+
c: number;
|
|
48
|
+
} | null;
|
|
17
49
|
export type SpeakerLike = {
|
|
18
50
|
speakerIdentity?: string | null;
|
|
19
51
|
speakerName?: string | null;
|
|
@@ -37,7 +69,7 @@ export declare function buildSpeakerColors(speakerIndex: Map<string, {
|
|
|
37
69
|
key: string;
|
|
38
70
|
name: string;
|
|
39
71
|
hue: number | null;
|
|
40
|
-
}
|
|
72
|
+
}>, src?: SpeakerColorSource): Map<string, string>;
|
|
41
73
|
/** Ascending sort of the optional data arrays — the only normalization every consumer agrees on. */
|
|
42
74
|
export declare function sortChapters<T extends {
|
|
43
75
|
startSec: number;
|
package/dist/video/model.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
export const ALL_KEY = '__all__';
|
|
2
2
|
export const UNKNOWN_KEY = '__unknown__';
|
|
3
3
|
/**
|
|
4
|
-
* Speaker identity palette — the
|
|
5
|
-
*
|
|
4
|
+
* Speaker identity palette — the FALLBACK cycle used only where no theme is
|
|
5
|
+
* reachable (SSR render, unit tests, un-styled hosts). Live components derive
|
|
6
|
+
* the cycle from DaisyUI theme tokens via buildSpeakerPaletteSource()
|
|
7
|
+
* (video/speakerTheme.ts) so white-label tenants reskin speaker colours with
|
|
8
|
+
* the rest of the theme (mos-5lzns.95).
|
|
6
9
|
*/
|
|
7
10
|
export const SPEAKER_PALETTE = [
|
|
8
11
|
'oklch(0.70 0.16 250)', // blue
|
|
@@ -12,6 +15,30 @@ export const SPEAKER_PALETTE = [
|
|
|
12
15
|
'oklch(0.68 0.16 300)', // violet
|
|
13
16
|
'oklch(0.72 0.14 200)' // cyan
|
|
14
17
|
];
|
|
18
|
+
/**
|
|
19
|
+
* The legacy derivation, kept as the default so SSR and pure consumers render
|
|
20
|
+
* exactly as before (stable snapshot output, no DOM needed).
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_SPEAKER_SOURCE = {
|
|
23
|
+
cycle: SPEAKER_PALETTE,
|
|
24
|
+
hueBase: { l: 0.72, c: 0.14 }
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Parse `oklch(L C H)` / `oklch(L% C H)` / with alpha into just the lightness
|
|
28
|
+
* and chroma channels (hue comes from the speaker, not the theme). Returns
|
|
29
|
+
* null for anything that isn't an oklch() literal — hex themes just fall back
|
|
30
|
+
* to the cycle.
|
|
31
|
+
*/
|
|
32
|
+
export function parseOklch(color) {
|
|
33
|
+
const m = /^oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+[\d.]+(?:\s*\/\s*[\d.]+%?\s*)?\)$/i.exec(color.trim());
|
|
34
|
+
if (!m)
|
|
35
|
+
return null;
|
|
36
|
+
const l = parseFloat(m[1]);
|
|
37
|
+
const c = parseFloat(m[2]);
|
|
38
|
+
if (!Number.isFinite(l) || !Number.isFinite(c))
|
|
39
|
+
return null;
|
|
40
|
+
return { l: /%/.test(m[1]) ? l / 100 : l, c };
|
|
41
|
+
}
|
|
15
42
|
export function speakerKeyOf(s) {
|
|
16
43
|
return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
|
|
17
44
|
}
|
|
@@ -45,17 +72,25 @@ export function buildSpeakerIndex(turns, segments) {
|
|
|
45
72
|
* colour per rendered column and reruns on every playhead tick, so this must
|
|
46
73
|
* never be an indexOf scan at call time.
|
|
47
74
|
*/
|
|
48
|
-
export function buildSpeakerColors(speakerIndex) {
|
|
75
|
+
export function buildSpeakerColors(speakerIndex, src = DEFAULT_SPEAKER_SOURCE) {
|
|
49
76
|
const map = new Map();
|
|
77
|
+
const cycle = src.cycle.length > 0 ? src.cycle : DEFAULT_SPEAKER_SOURCE.cycle;
|
|
50
78
|
let i = 0;
|
|
51
79
|
for (const [key, entry] of speakerIndex) {
|
|
52
80
|
map.set(key, entry.hue != null
|
|
53
|
-
?
|
|
54
|
-
:
|
|
81
|
+
? hueColor(entry.hue, src.hueBase) ?? cycle[i % cycle.length]
|
|
82
|
+
: cycle[i % cycle.length]);
|
|
55
83
|
i++;
|
|
56
84
|
}
|
|
57
85
|
return map;
|
|
58
86
|
}
|
|
87
|
+
/** Theme-toned identity colour for a diarization hue, wrapped to 0–360. */
|
|
88
|
+
function hueColor(hue, base) {
|
|
89
|
+
if (!base)
|
|
90
|
+
return null;
|
|
91
|
+
const h = ((hue % 360) + 360) % 360;
|
|
92
|
+
return `oklch(${base.l} ${base.c} ${h})`;
|
|
93
|
+
}
|
|
59
94
|
/** Ascending sort of the optional data arrays — the only normalization every consumer agrees on. */
|
|
60
95
|
export function sortChapters(chapters) {
|
|
61
96
|
return [...chapters]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type SpeakerColorSource } from './model';
|
|
2
|
+
/**
|
|
3
|
+
* hueBase from a theme token colour. Keeps the token's lightness, floors its
|
|
4
|
+
* chroma, and falls back to the legacy 0.72/0.14 tone when the token is
|
|
5
|
+
* achromatic (or not an oklch literal — parseOklch returns null for hex
|
|
6
|
+
* themes, which then ride the cycle only).
|
|
7
|
+
*/
|
|
8
|
+
export declare function hueBaseFromToken(color: string | null | undefined): {
|
|
9
|
+
l: number;
|
|
10
|
+
c: number;
|
|
11
|
+
} | null;
|
|
12
|
+
export interface SpeakerThemeContext {
|
|
13
|
+
source: SpeakerColorSource;
|
|
14
|
+
/** Resolved --color-base-100 (the transcript surface). */
|
|
15
|
+
surface: string;
|
|
16
|
+
/** Resolved --color-base-content (the ink the ladder mixes toward). */
|
|
17
|
+
ink: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the palette source from the live theme. De-dupes the token colours
|
|
21
|
+
* (a custom theme may alias roles) and tops the cycle up with token mixes if
|
|
22
|
+
* fewer than three distinct hues survive, so speakers stay distinguishable.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildSpeakerPaletteSource(): SpeakerThemeContext;
|
|
25
|
+
/** `color-mix(in oklch, color (100−pct)%, ink pct%)` — pct is the INK weight. */
|
|
26
|
+
export declare function mixToward(color: string, ink: string, pct: number): string;
|
|
27
|
+
/** WCAG contrast ratio between two rgb triples (≥1, ≤21). */
|
|
28
|
+
export declare function contrastRatio(a: readonly number[], b: readonly number[]): number;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve any CSS colour string to sRGB via a 1×1 canvas. Returns null when
|
|
31
|
+
* the string cannot be parsed (SSR, unsupported colour function) — callers
|
|
32
|
+
* keep the original colour rather than guessing.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveColorRgb(color: string): readonly [number, number, number] | null;
|
|
35
|
+
/**
|
|
36
|
+
* Lift `color` to AA contrast against `surface` by ladder-mixing toward ink.
|
|
37
|
+
* Colour-parsing environments that cannot resolve a string keep it unchanged
|
|
38
|
+
* (the raw token value is still theme-following, just unverified).
|
|
39
|
+
* `resolve` is injectable for DOM-free tests.
|
|
40
|
+
*/
|
|
41
|
+
export declare function adjustForContrast(color: string, surface: string, ink: string, minRatio?: number, resolve?: (c: string) => readonly [number, number, number] | null): string;
|
|
42
|
+
/** Map-of-speakers convenience over adjustForContrast. */
|
|
43
|
+
export declare function adjustSpeakerColors(colors: Map<string, string>, surface: string, ink: string): Map<string, string>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-derived speaker identity colours for VideoPlayer (mos-5lzns.95).
|
|
3
|
+
*
|
|
4
|
+
* The old SPEAKER_PALETTE was hard-coded oklch literals, so white-label
|
|
5
|
+
* tenants could not reskin speaker colours. This module derives the cycle
|
|
6
|
+
* from the ACTIVE DaisyUI theme's semantic tokens (the same six roles most
|
|
7
|
+
* themes give distinct hues), rebuilds hue-bearing speaker colours on the
|
|
8
|
+
* theme's own lightness/chroma, and lifts every identity colour to WCAG AA
|
|
9
|
+
* against the surface by mixing toward `--color-base-content` when needed —
|
|
10
|
+
* one colour per speaker that works as band fill, chip dot AND transcript
|
|
11
|
+
* text.
|
|
12
|
+
*
|
|
13
|
+
* DOM-aware (computed styles + a 1×1 canvas), client-only by contract — the
|
|
14
|
+
* component calls it under `typeof document !== 'undefined'` and falls back
|
|
15
|
+
* to DEFAULT_SPEAKER_SOURCE for SSR.
|
|
16
|
+
*/
|
|
17
|
+
import { getDaisyUIColor } from '../utils/daisyuiColors';
|
|
18
|
+
import { DEFAULT_SPEAKER_SOURCE, parseOklch } from './model';
|
|
19
|
+
import { relativeLuminance } from './night/levels';
|
|
20
|
+
/** Semantic tokens the cycle rotates, in rotation order. */
|
|
21
|
+
const CYCLE_TOKENS = ['primary', 'secondary', 'accent', 'info', 'success', 'warning'];
|
|
22
|
+
/** Mixing steps toward base-content tried (in order) until AA is met. */
|
|
23
|
+
const MIX_LADDER = [25, 45, 65, 85];
|
|
24
|
+
/** Chroma below which a token is effectively achromatic (lofi/black themes
|
|
25
|
+
* resolve primary at ≈0 chroma, which would render every hue speaker the same
|
|
26
|
+
* grey) — those fall back to the legacy speaker tone instead. */
|
|
27
|
+
const ACHROMATIC_CHROMA = 0.03;
|
|
28
|
+
/** Floor for the hue-bearing base chroma: near-neutral tokens are lifted so
|
|
29
|
+
* hue speakers stay distinguishable from each other. */
|
|
30
|
+
const HUE_CHROMA_FLOOR = 0.1;
|
|
31
|
+
/**
|
|
32
|
+
* hueBase from a theme token colour. Keeps the token's lightness, floors its
|
|
33
|
+
* chroma, and falls back to the legacy 0.72/0.14 tone when the token is
|
|
34
|
+
* achromatic (or not an oklch literal — parseOklch returns null for hex
|
|
35
|
+
* themes, which then ride the cycle only).
|
|
36
|
+
*/
|
|
37
|
+
export function hueBaseFromToken(color) {
|
|
38
|
+
const parsed = color ? parseOklch(color) : null;
|
|
39
|
+
if (!parsed)
|
|
40
|
+
return null;
|
|
41
|
+
if (parsed.c < ACHROMATIC_CHROMA)
|
|
42
|
+
return DEFAULT_SPEAKER_SOURCE.hueBase;
|
|
43
|
+
return { l: parsed.l, c: Math.max(parsed.c, HUE_CHROMA_FLOOR) };
|
|
44
|
+
}
|
|
45
|
+
/** WCAG AA for body-size text. */
|
|
46
|
+
const AA_CONTRAST = 4.5;
|
|
47
|
+
/**
|
|
48
|
+
* Build the palette source from the live theme. De-dupes the token colours
|
|
49
|
+
* (a custom theme may alias roles) and tops the cycle up with token mixes if
|
|
50
|
+
* fewer than three distinct hues survive, so speakers stay distinguishable.
|
|
51
|
+
*/
|
|
52
|
+
export function buildSpeakerPaletteSource() {
|
|
53
|
+
const surface = getDaisyUIColor('base-100');
|
|
54
|
+
const ink = getDaisyUIColor('base-content');
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
const cycle = [];
|
|
57
|
+
for (const token of CYCLE_TOKENS) {
|
|
58
|
+
const c = getDaisyUIColor(token);
|
|
59
|
+
if (c && !seen.has(c)) {
|
|
60
|
+
seen.add(c);
|
|
61
|
+
cycle.push(c);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (cycle.length > 0 && cycle.length < 3) {
|
|
65
|
+
const base = cycle[0];
|
|
66
|
+
for (const pct of [30, 55, 80]) {
|
|
67
|
+
const m = mixToward(base, ink, pct);
|
|
68
|
+
if (!seen.has(m)) {
|
|
69
|
+
seen.add(m);
|
|
70
|
+
cycle.push(m);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
source: {
|
|
76
|
+
cycle: cycle.length > 0 ? cycle : ['oklch(0.5 0 0)'],
|
|
77
|
+
hueBase: hueBaseFromToken(getDaisyUIColor('primary'))
|
|
78
|
+
},
|
|
79
|
+
surface,
|
|
80
|
+
ink
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** `color-mix(in oklch, color (100−pct)%, ink pct%)` — pct is the INK weight. */
|
|
84
|
+
export function mixToward(color, ink, pct) {
|
|
85
|
+
return `color-mix(in oklch, ${color} ${100 - pct}%, ${ink} ${pct}%)`;
|
|
86
|
+
}
|
|
87
|
+
/** WCAG contrast ratio between two rgb triples (≥1, ≤21). */
|
|
88
|
+
export function contrastRatio(a, b) {
|
|
89
|
+
const la = relativeLuminance(a[0], a[1], a[2]);
|
|
90
|
+
const lb = relativeLuminance(b[0], b[1], b[2]);
|
|
91
|
+
const [hi, lo] = la >= lb ? [la, lb] : [lb, la];
|
|
92
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
93
|
+
}
|
|
94
|
+
const rgbCache = new Map();
|
|
95
|
+
/**
|
|
96
|
+
* Resolve any CSS colour string to sRGB via a 1×1 canvas. Returns null when
|
|
97
|
+
* the string cannot be parsed (SSR, unsupported colour function) — callers
|
|
98
|
+
* keep the original colour rather than guessing.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveColorRgb(color) {
|
|
101
|
+
if (typeof document === 'undefined')
|
|
102
|
+
return null;
|
|
103
|
+
const cached = rgbCache.get(color);
|
|
104
|
+
if (cached !== undefined)
|
|
105
|
+
return cached;
|
|
106
|
+
let out = null;
|
|
107
|
+
try {
|
|
108
|
+
const canvas = document.createElement('canvas');
|
|
109
|
+
canvas.width = canvas.height = 1;
|
|
110
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
111
|
+
if (ctx) {
|
|
112
|
+
ctx.fillStyle = '#123456'; // known-good sentinel
|
|
113
|
+
const sentinel = ctx.fillStyle;
|
|
114
|
+
ctx.fillStyle = color;
|
|
115
|
+
if (ctx.fillStyle !== sentinel) {
|
|
116
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
117
|
+
const d = ctx.getImageData(0, 0, 1, 1).data;
|
|
118
|
+
out = [d[0], d[1], d[2]];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
out = null;
|
|
124
|
+
}
|
|
125
|
+
rgbCache.set(color, out);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Lift `color` to AA contrast against `surface` by ladder-mixing toward ink.
|
|
130
|
+
* Colour-parsing environments that cannot resolve a string keep it unchanged
|
|
131
|
+
* (the raw token value is still theme-following, just unverified).
|
|
132
|
+
* `resolve` is injectable for DOM-free tests.
|
|
133
|
+
*/
|
|
134
|
+
export function adjustForContrast(color, surface, ink, minRatio = AA_CONTRAST, resolve = resolveColorRgb) {
|
|
135
|
+
const s = resolve(surface);
|
|
136
|
+
const c = resolve(color);
|
|
137
|
+
if (!s || !c || contrastRatio(c, s) >= minRatio)
|
|
138
|
+
return color;
|
|
139
|
+
const i = resolve(ink);
|
|
140
|
+
if (!i)
|
|
141
|
+
return color;
|
|
142
|
+
for (const pct of MIX_LADDER) {
|
|
143
|
+
const mixed = mixToward(color, ink, pct);
|
|
144
|
+
const m = resolve(mixed);
|
|
145
|
+
if (m && contrastRatio(m, s) >= minRatio)
|
|
146
|
+
return mixed;
|
|
147
|
+
}
|
|
148
|
+
return mixToward(color, ink, 95);
|
|
149
|
+
}
|
|
150
|
+
/** Map-of-speakers convenience over adjustForContrast. */
|
|
151
|
+
export function adjustSpeakerColors(colors, surface, ink) {
|
|
152
|
+
const out = new Map();
|
|
153
|
+
for (const [key, color] of colors)
|
|
154
|
+
out.set(key, adjustForContrast(color, surface, ink));
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript windowing for VideoPlayer (mos-5lzns.95).
|
|
3
|
+
*
|
|
4
|
+
* Pure, DOM-free math: a transcript pane renders only the rows near the
|
|
5
|
+
* viewport instead of every segment (a 2h talk is 2–3k rows). The component
|
|
6
|
+
* keeps the measured row heights (index → px, sparse) and a scroll offset;
|
|
7
|
+
* this module owns the prefix-sum offsets, the binary-search window and the
|
|
8
|
+
* constants. Row heights vary (text wraps), so estimates are corrected by
|
|
9
|
+
* measurement — the offsets are always exact for measured rows.
|
|
10
|
+
*/
|
|
11
|
+
/** Below this many segments the pane renders everything (no windowing). */
|
|
12
|
+
export declare const VIRTUAL_THRESHOLD = 120;
|
|
13
|
+
/** Rows rendered above and below the visible window. */
|
|
14
|
+
export declare const OVERSCAN = 8;
|
|
15
|
+
/** Height assumed for a row until it has been measured (px). */
|
|
16
|
+
export declare const EST_ROW_H = 30;
|
|
17
|
+
/** Flex gap between transcript rows — MUST match .vp-transcript's CSS gap. */
|
|
18
|
+
export declare const TRANS_GAP = 1;
|
|
19
|
+
/**
|
|
20
|
+
* Prefix sums over row heights: offsets[0] = 0, offsets[i] = top edge of row i
|
|
21
|
+
* relative to the pane's content start (its padding box), including `gap` px
|
|
22
|
+
* of flex gap before each row. Unmeasured rows use `fallback`. `heights` may
|
|
23
|
+
* be sparse.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildOffsets(heights: ArrayLike<number>, count: number, fallback: number, gap?: number): number[];
|
|
26
|
+
/** Largest row index whose TOP edge is at or above y (0 when y ≤ 0). */
|
|
27
|
+
export declare function findAnchor(offsets: ArrayLike<number>, y: number): number;
|
|
28
|
+
/** How a new segment-id list relates to the one currently rendered. */
|
|
29
|
+
export type TranscriptResetKind = 'none' | 'extend' | 'reset';
|
|
30
|
+
/**
|
|
31
|
+
* Classify a transcript update against the id list already rendered (both in
|
|
32
|
+
* render order):
|
|
33
|
+
*
|
|
34
|
+
* - `'none'` — identical ids in order: keyed rows stay mounted and everything
|
|
35
|
+
* holds (scroll position, measured heights, offsets). No reset.
|
|
36
|
+
* - `'extend'` — append-only growth: the prefix keeps every measured height
|
|
37
|
+
* and the scroll position holds; offsets rebuild lazily for the new length.
|
|
38
|
+
* - `'reset'` — anything else (replacement, reorder, removal): fresh height
|
|
39
|
+
* cache and top-of-pane scroll.
|
|
40
|
+
*/
|
|
41
|
+
export declare function transcriptResetKind(prev: readonly string[], next: readonly string[]): TranscriptResetKind;
|
|
42
|
+
/**
|
|
43
|
+
* The row range to render for a scroll position. `first`/`last` are inclusive
|
|
44
|
+
* row indexes padded by `overscan`; every row overlapping the viewport is in
|
|
45
|
+
* range. Returns null-shaped output when the pane can't be measured yet — the
|
|
46
|
+
* component treats a non-positive viewport as "render the overscan around the
|
|
47
|
+
* top" rather than nothing.
|
|
48
|
+
*/
|
|
49
|
+
export declare function windowFor(offsets: ArrayLike<number>, scrollTop: number, viewportH: number, overscan: number): {
|
|
50
|
+
first: number;
|
|
51
|
+
last: number;
|
|
52
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript windowing for VideoPlayer (mos-5lzns.95).
|
|
3
|
+
*
|
|
4
|
+
* Pure, DOM-free math: a transcript pane renders only the rows near the
|
|
5
|
+
* viewport instead of every segment (a 2h talk is 2–3k rows). The component
|
|
6
|
+
* keeps the measured row heights (index → px, sparse) and a scroll offset;
|
|
7
|
+
* this module owns the prefix-sum offsets, the binary-search window and the
|
|
8
|
+
* constants. Row heights vary (text wraps), so estimates are corrected by
|
|
9
|
+
* measurement — the offsets are always exact for measured rows.
|
|
10
|
+
*/
|
|
11
|
+
/** Below this many segments the pane renders everything (no windowing). */
|
|
12
|
+
export const VIRTUAL_THRESHOLD = 120;
|
|
13
|
+
/** Rows rendered above and below the visible window. */
|
|
14
|
+
export const OVERSCAN = 8;
|
|
15
|
+
/** Height assumed for a row until it has been measured (px). */
|
|
16
|
+
export const EST_ROW_H = 30;
|
|
17
|
+
/** Flex gap between transcript rows — MUST match .vp-transcript's CSS gap. */
|
|
18
|
+
export const TRANS_GAP = 1;
|
|
19
|
+
/**
|
|
20
|
+
* Prefix sums over row heights: offsets[0] = 0, offsets[i] = top edge of row i
|
|
21
|
+
* relative to the pane's content start (its padding box), including `gap` px
|
|
22
|
+
* of flex gap before each row. Unmeasured rows use `fallback`. `heights` may
|
|
23
|
+
* be sparse.
|
|
24
|
+
*/
|
|
25
|
+
export function buildOffsets(heights, count, fallback, gap = 0) {
|
|
26
|
+
const offsets = new Array(count + 1);
|
|
27
|
+
offsets[0] = 0;
|
|
28
|
+
let prev = 0;
|
|
29
|
+
for (let i = 0; i < count; i++) {
|
|
30
|
+
const h = heights[i];
|
|
31
|
+
prev += (Number.isFinite(h) && h > 0 ? h : fallback) + gap;
|
|
32
|
+
offsets[i + 1] = prev;
|
|
33
|
+
}
|
|
34
|
+
return offsets;
|
|
35
|
+
}
|
|
36
|
+
/** Largest row index whose TOP edge is at or above y (0 when y ≤ 0). */
|
|
37
|
+
export function findAnchor(offsets, y) {
|
|
38
|
+
let lo = 0;
|
|
39
|
+
let hi = offsets.length - 1;
|
|
40
|
+
if (y <= 0)
|
|
41
|
+
return 0;
|
|
42
|
+
while (lo < hi) {
|
|
43
|
+
const mid = (lo + hi + 1) >> 1;
|
|
44
|
+
if (offsets[mid] <= y)
|
|
45
|
+
lo = mid;
|
|
46
|
+
else
|
|
47
|
+
hi = mid - 1;
|
|
48
|
+
}
|
|
49
|
+
return lo;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Classify a transcript update against the id list already rendered (both in
|
|
53
|
+
* render order):
|
|
54
|
+
*
|
|
55
|
+
* - `'none'` — identical ids in order: keyed rows stay mounted and everything
|
|
56
|
+
* holds (scroll position, measured heights, offsets). No reset.
|
|
57
|
+
* - `'extend'` — append-only growth: the prefix keeps every measured height
|
|
58
|
+
* and the scroll position holds; offsets rebuild lazily for the new length.
|
|
59
|
+
* - `'reset'` — anything else (replacement, reorder, removal): fresh height
|
|
60
|
+
* cache and top-of-pane scroll.
|
|
61
|
+
*/
|
|
62
|
+
export function transcriptResetKind(prev, next) {
|
|
63
|
+
if (next.length >= prev.length) {
|
|
64
|
+
for (let i = 0; i < prev.length; i++) {
|
|
65
|
+
if (prev[i] !== next[i])
|
|
66
|
+
return 'reset';
|
|
67
|
+
}
|
|
68
|
+
return next.length === prev.length ? 'none' : 'extend';
|
|
69
|
+
}
|
|
70
|
+
return 'reset';
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The row range to render for a scroll position. `first`/`last` are inclusive
|
|
74
|
+
* row indexes padded by `overscan`; every row overlapping the viewport is in
|
|
75
|
+
* range. Returns null-shaped output when the pane can't be measured yet — the
|
|
76
|
+
* component treats a non-positive viewport as "render the overscan around the
|
|
77
|
+
* top" rather than nothing.
|
|
78
|
+
*/
|
|
79
|
+
export function windowFor(offsets, scrollTop, viewportH, overscan) {
|
|
80
|
+
const n = offsets.length - 1;
|
|
81
|
+
if (n <= 0)
|
|
82
|
+
return { first: 0, last: 0 };
|
|
83
|
+
const top = Math.max(0, scrollTop);
|
|
84
|
+
const firstOverlap = findAnchor(offsets, top);
|
|
85
|
+
// Row `firstOverlap` may START above `top` (its top edge ≤ y): the row
|
|
86
|
+
// overlapping the viewport top is that one or the next — cover both.
|
|
87
|
+
const first = Math.max(0, firstOverlap - overscan);
|
|
88
|
+
const bottom = top + Math.max(0, viewportH);
|
|
89
|
+
// First row whose TOP edge is at/below the viewport bottom, then one more
|
|
90
|
+
// so a row straddling the bottom edge is not dropped mid-scroll.
|
|
91
|
+
let lo = 0;
|
|
92
|
+
let hi = n;
|
|
93
|
+
while (lo < hi) {
|
|
94
|
+
const mid = (lo + hi) >> 1;
|
|
95
|
+
if (offsets[mid] < bottom)
|
|
96
|
+
lo = mid + 1;
|
|
97
|
+
else
|
|
98
|
+
hi = mid;
|
|
99
|
+
}
|
|
100
|
+
const last = Math.min(n, lo + overscan);
|
|
101
|
+
return { first, last };
|
|
102
|
+
}
|