@joewinke/jatui 0.2.30 → 0.2.32
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/actions/railNav.js +21 -4
- package/dist/components/Flythrough.svelte +173 -0
- package/dist/components/Flythrough.svelte.d.ts +22 -0
- package/dist/components/LessonEmbed.svelte +227 -0
- package/dist/components/LessonEmbed.svelte.d.ts +47 -0
- package/dist/components/MediaWorkbench.svelte +5 -3
- package/dist/components/MediaWorkbench.svelte.d.ts +4 -3
- package/dist/components/VideoPlayer.svelte +140 -3
- package/dist/components/VideoPlayer.svelte.d.ts +33 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
package/dist/actions/railNav.js
CHANGED
|
@@ -84,6 +84,16 @@ export function cycle(list, current, dir) {
|
|
|
84
84
|
const BUMP_DURATION_MS = 280;
|
|
85
85
|
const RAIL_FOCUS_CLASS = 'rail-focus';
|
|
86
86
|
const RAIL_CYCLED_CLASS = 'rail-cycled';
|
|
87
|
+
/** `<input>` types whose vertical arrows natively step the value (spinner / picker). */
|
|
88
|
+
const STEPPER_INPUT_TYPES = new Set([
|
|
89
|
+
'number',
|
|
90
|
+
'range',
|
|
91
|
+
'date',
|
|
92
|
+
'time',
|
|
93
|
+
'month',
|
|
94
|
+
'week',
|
|
95
|
+
'datetime-local',
|
|
96
|
+
]);
|
|
87
97
|
function resolveOrder(opts) {
|
|
88
98
|
return typeof opts.order === 'function' ? opts.order() : opts.order;
|
|
89
99
|
}
|
|
@@ -213,6 +223,13 @@ function makeController(optsRef) {
|
|
|
213
223
|
const isTextField = !!el &&
|
|
214
224
|
(tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
215
225
|
const isMultiline = !!el && (tag === 'TEXTAREA' || el.isContentEditable === true);
|
|
226
|
+
const inputType = tag === 'INPUT' ? (el.type || '').toLowerCase() : '';
|
|
227
|
+
// A <select> cycles its value with arrows on BOTH axes, and a radio moves
|
|
228
|
+
// within its `name` group on both axes — the rail must yield entirely or it
|
|
229
|
+
// silently drives an unrelated rung while the user thinks they're picking a value.
|
|
230
|
+
const ownsBothArrowAxes = tag === 'SELECT' || inputType === 'radio';
|
|
231
|
+
const blocksVerticalArrows = isMultiline || ownsBothArrowAxes || STEPPER_INPUT_TYPES.has(inputType);
|
|
232
|
+
const blocksHorizontalArrows = isTextField || ownsBothArrowAxes;
|
|
216
233
|
const hasMod = event.metaKey || event.ctrlKey || event.altKey;
|
|
217
234
|
// Modal trap — stop non-text keystrokes from bubbling to window-level page
|
|
218
235
|
// listeners (e.g., a listNav attached to `window`). Text-field keystrokes
|
|
@@ -245,13 +262,13 @@ function makeController(optsRef) {
|
|
|
245
262
|
const popoverOpen = opts.isPopoverOpen?.() ?? false;
|
|
246
263
|
if (!hasMod && !popoverOpen) {
|
|
247
264
|
// Vertical: ArrowDown / j step down; ArrowUp / k step up.
|
|
248
|
-
// j / k are blocked in ANY text field; arrows are
|
|
249
|
-
//
|
|
265
|
+
// j / k are blocked in ANY text field (they'd type a letter); arrows are
|
|
266
|
+
// blocked wherever the control owns them natively.
|
|
250
267
|
const goDown = event.key === 'ArrowDown' || event.key === 'j';
|
|
251
268
|
const goUp = event.key === 'ArrowUp' || event.key === 'k';
|
|
252
269
|
if (goDown || goUp) {
|
|
253
270
|
const isVimKey = event.key === 'j' || event.key === 'k';
|
|
254
|
-
const blocked = isVimKey ? isTextField :
|
|
271
|
+
const blocked = isVimKey ? isTextField : blocksVerticalArrows;
|
|
255
272
|
if (!blocked) {
|
|
256
273
|
event.preventDefault();
|
|
257
274
|
moveRung(goDown ? 1 : -1);
|
|
@@ -261,7 +278,7 @@ function makeController(optsRef) {
|
|
|
261
278
|
// Horizontal: ArrowLeft / ArrowRight dispatch to `onAction`.
|
|
262
279
|
// Skipped inside text fields so caret-left / caret-right keep working.
|
|
263
280
|
if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') &&
|
|
264
|
-
!
|
|
281
|
+
!blocksHorizontalArrows) {
|
|
265
282
|
event.preventDefault();
|
|
266
283
|
const attr = resolveAttr(opts);
|
|
267
284
|
// Prefer the element that actually has DOM focus, then fall back to
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Flythrough — a decorative, aspect-locked motion embed for seedance/kenburns
|
|
4
|
+
* flythrough clips (the "camera drifts over the scene" B-roll that heads a
|
|
5
|
+
* lesson or a section). Presentation primitive per the unified-video-pipeline
|
|
6
|
+
* PRD, sibling to <VideoPlayer> (the full player) and <LessonEmbed>.
|
|
7
|
+
*
|
|
8
|
+
* It is NOT a player: no controls, no scrubber, no audio. A flythrough is
|
|
9
|
+
* ambient — it plays muted, loops, and asks for no interaction. That framing
|
|
10
|
+
* decides every behaviour here:
|
|
11
|
+
*
|
|
12
|
+
* · It autoplays muted + loops by default (that's what "flythrough" means).
|
|
13
|
+
* · Under `prefers-reduced-motion: reduce` it does NOT play — it shows a still
|
|
14
|
+
* poster instead. Ambient motion is exactly the motion that preference asks
|
|
15
|
+
* to suppress, and a flythrough always has a poster to fall back TO
|
|
16
|
+
* (`reducedMotionPoster` overrides `poster` for that still if the host wants
|
|
17
|
+
* a hand-picked frame rather than the video's cover art).
|
|
18
|
+
* · The box is aspect-locked from the first frame (poster convention) so the
|
|
19
|
+
* poster → decoded-frame swap is a cross-fade in place, never a layout shove.
|
|
20
|
+
*
|
|
21
|
+
* Contract (shared asset conventions): takes only a decode-checked `src` and a
|
|
22
|
+
* `poster.jpg` beside it. No ffmpeg in the browser; the clip is already CFR,
|
|
23
|
+
* decode-clean and web-encoded upstream (`jat-video`).
|
|
24
|
+
*/
|
|
25
|
+
import { onMount, untrack } from 'svelte';
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
src,
|
|
29
|
+
poster = null,
|
|
30
|
+
aspect = '16:9',
|
|
31
|
+
reducedMotionPoster = null,
|
|
32
|
+
autoplay = true,
|
|
33
|
+
loop = true,
|
|
34
|
+
alt = '',
|
|
35
|
+
class: className = ''
|
|
36
|
+
}: {
|
|
37
|
+
/** Decode-checked flythrough clip. */
|
|
38
|
+
src: string;
|
|
39
|
+
/** Cover art beside the clip — shown until the first frame decodes, and the
|
|
40
|
+
* still shown outright under reduced-motion (unless `reducedMotionPoster`
|
|
41
|
+
* supplies a different frame). */
|
|
42
|
+
poster?: string | null;
|
|
43
|
+
/** `"W:H"` | ratio number | null/"auto". Reserved from the first frame. */
|
|
44
|
+
aspect?: string | null;
|
|
45
|
+
/** A hand-picked still for the reduced-motion fallback. Falls back to `poster`. */
|
|
46
|
+
reducedMotionPoster?: string | null;
|
|
47
|
+
/** Play on mount (muted). Vetoed by `prefers-reduced-motion`. Default true. */
|
|
48
|
+
autoplay?: boolean;
|
|
49
|
+
/** Loop the clip. Default true — a flythrough is ambient. */
|
|
50
|
+
loop?: boolean;
|
|
51
|
+
/** Alt text for the reduced-motion still (a11y — the still is the content then). */
|
|
52
|
+
alt?: string;
|
|
53
|
+
class?: string;
|
|
54
|
+
} = $props();
|
|
55
|
+
|
|
56
|
+
const aspectRatioCss = $derived.by(() => {
|
|
57
|
+
const a = aspect;
|
|
58
|
+
if (a == null) return null;
|
|
59
|
+
const trimmed = String(a).trim();
|
|
60
|
+
if (trimmed === '' || trimmed === 'auto') return null;
|
|
61
|
+
const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
|
|
62
|
+
if (colon) {
|
|
63
|
+
const w = parseFloat(colon[1]);
|
|
64
|
+
const h = parseFloat(colon[2]);
|
|
65
|
+
if (w > 0 && h > 0) return `${w} / ${h}`;
|
|
66
|
+
}
|
|
67
|
+
const num = Number(trimmed);
|
|
68
|
+
if (Number.isFinite(num) && num > 0) return String(num);
|
|
69
|
+
return null;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let videoEl: HTMLVideoElement | undefined = $state();
|
|
73
|
+
let reduceMotion = $state(false);
|
|
74
|
+
|
|
75
|
+
/** Under reduce we render a poster still and never mount playback. */
|
|
76
|
+
const showStill = $derived(reduceMotion);
|
|
77
|
+
const stillSrc = $derived(reducedMotionPoster ?? poster);
|
|
78
|
+
|
|
79
|
+
onMount(() => {
|
|
80
|
+
let stop: (() => void) | undefined;
|
|
81
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
82
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
83
|
+
reduceMotion = mq.matches;
|
|
84
|
+
const onChange = () => (reduceMotion = mq.matches);
|
|
85
|
+
mq.addEventListener('change', onChange);
|
|
86
|
+
stop = () => mq.removeEventListener('change', onChange);
|
|
87
|
+
}
|
|
88
|
+
return () => stop?.();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Start playback once the element exists and motion is allowed. Imperative (not
|
|
92
|
+
// the native attribute) so a mid-session flip to "reduce" can pause it and a
|
|
93
|
+
// flip away can resume — the poster-still swap is handled by `showStill`.
|
|
94
|
+
let started = false;
|
|
95
|
+
$effect(() => {
|
|
96
|
+
const v = videoEl;
|
|
97
|
+
const play = autoplay && !reduceMotion && !showStill;
|
|
98
|
+
untrack(() => {
|
|
99
|
+
if (!v) return;
|
|
100
|
+
if (play && !started) {
|
|
101
|
+
started = true;
|
|
102
|
+
v.muted = true;
|
|
103
|
+
void v.play().catch(() => {});
|
|
104
|
+
} else if (!play && !v.paused) {
|
|
105
|
+
v.pause();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<div
|
|
112
|
+
class="ft-root {className}"
|
|
113
|
+
class:ft-locked={aspectRatioCss != null}
|
|
114
|
+
style={aspectRatioCss != null ? `--ft-aspect: ${aspectRatioCss};` : undefined}
|
|
115
|
+
>
|
|
116
|
+
{#if showStill}
|
|
117
|
+
<!-- Reduced-motion fallback: the flythrough is content-free decoration, so the
|
|
118
|
+
still IS the presentation. alt is host-supplied (usually empty for pure
|
|
119
|
+
decoration). -->
|
|
120
|
+
{#if stillSrc}
|
|
121
|
+
<img class="ft-still" src={stillSrc} {alt} />
|
|
122
|
+
{:else}
|
|
123
|
+
<div class="ft-still ft-still-empty" aria-hidden="true"></div>
|
|
124
|
+
{/if}
|
|
125
|
+
{:else}
|
|
126
|
+
<video
|
|
127
|
+
bind:this={videoEl}
|
|
128
|
+
{src}
|
|
129
|
+
{loop}
|
|
130
|
+
muted
|
|
131
|
+
playsinline
|
|
132
|
+
poster={poster ?? undefined}
|
|
133
|
+
class="ft-video"
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
></video>
|
|
136
|
+
{/if}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<style>
|
|
140
|
+
/* Aspect-locked from the first frame (poster convention): the box owns its
|
|
141
|
+
ratio before the video decodes, so poster → frame is an in-place swap. */
|
|
142
|
+
.ft-root {
|
|
143
|
+
position: relative;
|
|
144
|
+
width: 100%;
|
|
145
|
+
overflow: hidden;
|
|
146
|
+
background: var(--color-neutral);
|
|
147
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.ft-locked {
|
|
151
|
+
aspect-ratio: var(--ft-aspect);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.ft-video,
|
|
155
|
+
.ft-still {
|
|
156
|
+
display: block;
|
|
157
|
+
width: 100%;
|
|
158
|
+
height: 100%;
|
|
159
|
+
object-fit: cover;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* When the box is aspect-locked the media fills it absolutely; unlocked, the
|
|
163
|
+
media's intrinsic size drives the box height. */
|
|
164
|
+
.ft-locked .ft-video,
|
|
165
|
+
.ft-locked .ft-still {
|
|
166
|
+
position: absolute;
|
|
167
|
+
inset: 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.ft-still-empty {
|
|
171
|
+
background: var(--color-base-300, var(--color-neutral));
|
|
172
|
+
}
|
|
173
|
+
</style>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** Decode-checked flythrough clip. */
|
|
3
|
+
src: string;
|
|
4
|
+
/** Cover art beside the clip — shown until the first frame decodes, and the
|
|
5
|
+
* still shown outright under reduced-motion (unless `reducedMotionPoster`
|
|
6
|
+
* supplies a different frame). */
|
|
7
|
+
poster?: string | null;
|
|
8
|
+
/** `"W:H"` | ratio number | null/"auto". Reserved from the first frame. */
|
|
9
|
+
aspect?: string | null;
|
|
10
|
+
/** A hand-picked still for the reduced-motion fallback. Falls back to `poster`. */
|
|
11
|
+
reducedMotionPoster?: string | null;
|
|
12
|
+
/** Play on mount (muted). Vetoed by `prefers-reduced-motion`. Default true. */
|
|
13
|
+
autoplay?: boolean;
|
|
14
|
+
/** Loop the clip. Default true — a flythrough is ambient. */
|
|
15
|
+
loop?: boolean;
|
|
16
|
+
/** Alt text for the reduced-motion still (a11y — the still is the content then). */
|
|
17
|
+
alt?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
};
|
|
20
|
+
declare const Flythrough: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
21
|
+
type Flythrough = ReturnType<typeof Flythrough>;
|
|
22
|
+
export default Flythrough;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* The demo-assemble `collection.json` shape (pinned from mardukos
|
|
4
|
+
* `scripts/demo-assemble.mjs` writer + `welcome/[token]` reader, per
|
|
5
|
+
* docs/edl-contract.md). Fields the writer emits with `_url` suffixes are
|
|
6
|
+
* accepted alongside the reader's shorter aliases, so a manifest from either
|
|
7
|
+
* side of the contract loads without a translation step.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One beat's timing within a cut (for a future chapter rail; carried through). */
|
|
11
|
+
export interface CollectionBeat {
|
|
12
|
+
id: string | number;
|
|
13
|
+
name?: string;
|
|
14
|
+
start_sec?: number;
|
|
15
|
+
end_sec?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A named output variant — one playable video. */
|
|
19
|
+
export interface CollectionCut {
|
|
20
|
+
id: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Assemble writes `blurb`; audience copy for the picker. */
|
|
23
|
+
blurb?: string;
|
|
24
|
+
duration_sec?: number;
|
|
25
|
+
/** The mp4. Writer/reader both use `url` (minio or repo-relative path). */
|
|
26
|
+
url: string;
|
|
27
|
+
/** Cover art. Writer emits `poster_url`; `poster` accepted as an alias. */
|
|
28
|
+
poster_url?: string;
|
|
29
|
+
poster?: string;
|
|
30
|
+
beats?: CollectionBeat[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `collection.json` manifest. */
|
|
34
|
+
export interface Collection {
|
|
35
|
+
template?: string;
|
|
36
|
+
prospect?: string | null;
|
|
37
|
+
brand_name?: string;
|
|
38
|
+
primary_color?: string;
|
|
39
|
+
cuts: CollectionCut[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cutPoster(c: CollectionCut): string | null {
|
|
43
|
+
return c.poster_url ?? c.poster ?? null;
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<script lang="ts">
|
|
48
|
+
/**
|
|
49
|
+
* LessonEmbed — renders a demo-assemble `collection.json`: a picker across the
|
|
50
|
+
* named cuts with a <VideoPlayer> for the selected one. Presentation primitive
|
|
51
|
+
* per the unified-video-pipeline PRD; the third of the trio beside <VideoPlayer>
|
|
52
|
+
* and <Flythrough>.
|
|
53
|
+
*
|
|
54
|
+
* A `collection.json` is several cuts of the SAME demo (a 30s teaser, a 2min
|
|
55
|
+
* walkthrough, an audience-specific edit). The host hands the parsed manifest;
|
|
56
|
+
* this component owns the "which cut" selection and delegates playback to
|
|
57
|
+
* <VideoPlayer> — so a cut gets the full player (aspect-lock, poster, captions,
|
|
58
|
+
* keyboard) for free, and this file only adds the picker.
|
|
59
|
+
*
|
|
60
|
+
* Contract: takes only decode-checked assets already referenced by the manifest
|
|
61
|
+
* (each cut's `url` + `poster`). No fetching, no ffmpeg — the manifest is the
|
|
62
|
+
* input, exactly as `demo-assemble` emitted it.
|
|
63
|
+
*/
|
|
64
|
+
import VideoPlayer from './VideoPlayer.svelte';
|
|
65
|
+
// Collection / CollectionCut are declared in the `module` script above and are
|
|
66
|
+
// already in scope here — no import (that would conflict with the local decl).
|
|
67
|
+
|
|
68
|
+
let {
|
|
69
|
+
collection,
|
|
70
|
+
cut = null,
|
|
71
|
+
aspect = '16:9',
|
|
72
|
+
class: className = ''
|
|
73
|
+
}: {
|
|
74
|
+
collection: Collection;
|
|
75
|
+
/** Preselect a cut by `id`. Falls back to the first cut. */
|
|
76
|
+
cut?: string | null;
|
|
77
|
+
/** Aspect passed through to the inner <VideoPlayer>. */
|
|
78
|
+
aspect?: string | null;
|
|
79
|
+
class?: string;
|
|
80
|
+
} = $props();
|
|
81
|
+
|
|
82
|
+
const cuts = $derived(
|
|
83
|
+
Array.isArray(collection?.cuts)
|
|
84
|
+
? collection.cuts.filter((c): c is CollectionCut => !!c && typeof c.url === 'string')
|
|
85
|
+
: []
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Selected cut id. Seeded from `cut` when it names a real cut, else the first.
|
|
89
|
+
// `$state` initialiser reads props once; a later `cut` change is reconciled by
|
|
90
|
+
// the effect below rather than re-running the initialiser.
|
|
91
|
+
let selectedId = $state<string | null>(null);
|
|
92
|
+
$effect(() => {
|
|
93
|
+
const ids = cuts.map((c) => c.id);
|
|
94
|
+
const wanted = cut && ids.includes(cut) ? cut : null;
|
|
95
|
+
// Only (re)seed when the current selection is gone or a valid `cut` arrives.
|
|
96
|
+
if (selectedId == null || !ids.includes(selectedId)) {
|
|
97
|
+
selectedId = wanted ?? ids[0] ?? null;
|
|
98
|
+
} else if (wanted && wanted !== selectedId && cut === wanted) {
|
|
99
|
+
// Host explicitly asked for a different cut via the prop.
|
|
100
|
+
selectedId = wanted;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const active = $derived(cuts.find((c) => c.id === selectedId) ?? cuts[0] ?? null);
|
|
105
|
+
|
|
106
|
+
function fmtDuration(s: number | undefined): string {
|
|
107
|
+
if (!Number.isFinite(s) || (s ?? 0) <= 0) return '';
|
|
108
|
+
const total = Math.round(s as number);
|
|
109
|
+
const m = Math.floor(total / 60);
|
|
110
|
+
const sec = total % 60;
|
|
111
|
+
return m > 0 ? `${m}:${String(sec).padStart(2, '0')}` : `${sec}s`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function poster(c: CollectionCut): string | undefined {
|
|
115
|
+
return c.poster_url ?? c.poster ?? undefined;
|
|
116
|
+
}
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<div class="le-root {className}">
|
|
120
|
+
{#if cuts.length === 0}
|
|
121
|
+
<div class="le-empty">No cuts in this collection.</div>
|
|
122
|
+
{:else}
|
|
123
|
+
{#if cuts.length > 1}
|
|
124
|
+
<!-- Cut picker: a tab row. Each cut is one edit of the same demo, so the
|
|
125
|
+
picker reads as "which version", not "which video". -->
|
|
126
|
+
<div class="le-picker" role="tablist" aria-label="Demo cuts">
|
|
127
|
+
{#each cuts as c (c.id)}
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
role="tab"
|
|
131
|
+
class="le-tab"
|
|
132
|
+
class:active={c.id === selectedId}
|
|
133
|
+
aria-selected={c.id === selectedId}
|
|
134
|
+
onclick={() => (selectedId = c.id)}
|
|
135
|
+
>
|
|
136
|
+
<span class="le-tab-label">{c.label ?? c.id}</span>
|
|
137
|
+
{#if fmtDuration(c.duration_sec)}
|
|
138
|
+
<span class="le-tab-dur">{fmtDuration(c.duration_sec)}</span>
|
|
139
|
+
{/if}
|
|
140
|
+
{#if c.blurb}<span class="le-tab-blurb">{c.blurb}</span>{/if}
|
|
141
|
+
</button>
|
|
142
|
+
{/each}
|
|
143
|
+
</div>
|
|
144
|
+
{/if}
|
|
145
|
+
|
|
146
|
+
{#if active}
|
|
147
|
+
<!-- keyed so switching cuts remounts the player (a fresh source + poster,
|
|
148
|
+
not a src swap mid-playback). -->
|
|
149
|
+
{#key active.id}
|
|
150
|
+
<VideoPlayer src={active.url} poster={poster(active)} {aspect} />
|
|
151
|
+
{/key}
|
|
152
|
+
{/if}
|
|
153
|
+
{/if}
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<style>
|
|
157
|
+
.le-root {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
gap: var(--jatui-space-lg, 12px);
|
|
161
|
+
min-width: 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.le-empty {
|
|
165
|
+
padding: var(--jatui-space-xl, 16px);
|
|
166
|
+
text-align: center;
|
|
167
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
168
|
+
background: var(--color-base-200);
|
|
169
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
170
|
+
font-size: 0.85rem;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.le-picker {
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-wrap: wrap;
|
|
176
|
+
gap: var(--jatui-space-sm, 4px);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.le-tab {
|
|
180
|
+
display: flex;
|
|
181
|
+
flex-direction: column;
|
|
182
|
+
align-items: flex-start;
|
|
183
|
+
gap: var(--jatui-space-xs, 2px);
|
|
184
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-lg, 12px);
|
|
185
|
+
background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
|
|
186
|
+
border: var(--jatui-border-w, 1px) solid transparent;
|
|
187
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
188
|
+
color: var(--color-base-content);
|
|
189
|
+
text-align: left;
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
transition:
|
|
192
|
+
background var(--motion-fast, 150ms) var(--motion-glide, ease-out),
|
|
193
|
+
border-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.le-tab:hover {
|
|
197
|
+
background: color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.le-tab.active {
|
|
201
|
+
background: color-mix(in oklch, var(--color-primary) 12%, transparent);
|
|
202
|
+
border-color: var(--color-primary);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.le-tab-label {
|
|
206
|
+
font-size: 0.8rem;
|
|
207
|
+
font-weight: 600;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.le-tab-dur {
|
|
211
|
+
font-size: 0.66rem;
|
|
212
|
+
font-variant-numeric: tabular-nums;
|
|
213
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.le-tab-blurb {
|
|
217
|
+
font-size: 0.7rem;
|
|
218
|
+
color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
|
|
219
|
+
max-width: 16rem;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@media (prefers-reduced-motion: reduce) {
|
|
223
|
+
.le-tab {
|
|
224
|
+
transition: none;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The demo-assemble `collection.json` shape (pinned from mardukos
|
|
3
|
+
* `scripts/demo-assemble.mjs` writer + `welcome/[token]` reader, per
|
|
4
|
+
* docs/edl-contract.md). Fields the writer emits with `_url` suffixes are
|
|
5
|
+
* accepted alongside the reader's shorter aliases, so a manifest from either
|
|
6
|
+
* side of the contract loads without a translation step.
|
|
7
|
+
*/
|
|
8
|
+
/** One beat's timing within a cut (for a future chapter rail; carried through). */
|
|
9
|
+
export interface CollectionBeat {
|
|
10
|
+
id: string | number;
|
|
11
|
+
name?: string;
|
|
12
|
+
start_sec?: number;
|
|
13
|
+
end_sec?: number;
|
|
14
|
+
}
|
|
15
|
+
/** A named output variant — one playable video. */
|
|
16
|
+
export interface CollectionCut {
|
|
17
|
+
id: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
/** Assemble writes `blurb`; audience copy for the picker. */
|
|
20
|
+
blurb?: string;
|
|
21
|
+
duration_sec?: number;
|
|
22
|
+
/** The mp4. Writer/reader both use `url` (minio or repo-relative path). */
|
|
23
|
+
url: string;
|
|
24
|
+
/** Cover art. Writer emits `poster_url`; `poster` accepted as an alias. */
|
|
25
|
+
poster_url?: string;
|
|
26
|
+
poster?: string;
|
|
27
|
+
beats?: CollectionBeat[];
|
|
28
|
+
}
|
|
29
|
+
/** The `collection.json` manifest. */
|
|
30
|
+
export interface Collection {
|
|
31
|
+
template?: string;
|
|
32
|
+
prospect?: string | null;
|
|
33
|
+
brand_name?: string;
|
|
34
|
+
primary_color?: string;
|
|
35
|
+
cuts: CollectionCut[];
|
|
36
|
+
}
|
|
37
|
+
type $$ComponentProps = {
|
|
38
|
+
collection: Collection;
|
|
39
|
+
/** Preselect a cut by `id`. Falls back to the first cut. */
|
|
40
|
+
cut?: string | null;
|
|
41
|
+
/** Aspect passed through to the inner <VideoPlayer>. */
|
|
42
|
+
aspect?: string | null;
|
|
43
|
+
class?: string;
|
|
44
|
+
};
|
|
45
|
+
declare const LessonEmbed: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
46
|
+
type LessonEmbed = ReturnType<typeof LessonEmbed>;
|
|
47
|
+
export default LessonEmbed;
|
|
@@ -95,9 +95,10 @@
|
|
|
95
95
|
* Identifies the existing asset-store row being edited, if any. Forwarded
|
|
96
96
|
* to /api/media/edit and /api/media/remove-bg so the host app's
|
|
97
97
|
* kind='record' safety check (assets that must never be AI pixel-edited)
|
|
98
|
-
* can run server-side.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* can run server-side. Safe to omit for a genuinely untracked image
|
|
99
|
+
* (freshly generated, not yet saved) — a host that fail-closes this
|
|
100
|
+
* check (as mardukos does) derives the id from the source URL instead
|
|
101
|
+
* when it's absent, so this is a convenience, not the only signal.
|
|
101
102
|
*/
|
|
102
103
|
assetId?: string
|
|
103
104
|
}>()
|
|
@@ -390,6 +391,7 @@
|
|
|
390
391
|
const fd = new FormData()
|
|
391
392
|
fd.append("image", blob, "source.png")
|
|
392
393
|
if (assetId) fd.append("assetId", assetId)
|
|
394
|
+
fd.append("sourceUrl", sourceUrl)
|
|
393
395
|
const res = await fetch("/api/media/remove-bg", { method: "POST", body: fd })
|
|
394
396
|
if (!res.ok) {
|
|
395
397
|
const msg = await res.text().catch(() => res.statusText)
|
|
@@ -7,9 +7,10 @@ type $$ComponentProps = {
|
|
|
7
7
|
* Identifies the existing asset-store row being edited, if any. Forwarded
|
|
8
8
|
* to /api/media/edit and /api/media/remove-bg so the host app's
|
|
9
9
|
* kind='record' safety check (assets that must never be AI pixel-edited)
|
|
10
|
-
* can run server-side.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* can run server-side. Safe to omit for a genuinely untracked image
|
|
11
|
+
* (freshly generated, not yet saved) — a host that fail-closes this
|
|
12
|
+
* check (as mardukos does) derives the id from the source URL instead
|
|
13
|
+
* when it's absent, so this is a convenience, not the only signal.
|
|
13
14
|
*/
|
|
14
15
|
assetId?: string;
|
|
15
16
|
};
|
|
@@ -116,9 +116,13 @@
|
|
|
116
116
|
let {
|
|
117
117
|
src,
|
|
118
118
|
poster = null,
|
|
119
|
+
aspect = '16:9',
|
|
120
|
+
autoplay = false,
|
|
121
|
+
loop = false,
|
|
119
122
|
captionsSrc = null,
|
|
120
123
|
captionsLabel = 'Captions',
|
|
121
124
|
captionsLang = 'en',
|
|
125
|
+
captionsDefault = true,
|
|
122
126
|
chapters = [],
|
|
123
127
|
speakers = [],
|
|
124
128
|
transcript = [],
|
|
@@ -135,9 +139,42 @@
|
|
|
135
139
|
}: {
|
|
136
140
|
src: string;
|
|
137
141
|
poster?: string | null;
|
|
142
|
+
/**
|
|
143
|
+
* Aspect ratio for the video stage — presentation-primitive prop (PRD).
|
|
144
|
+
* `"16:9"` | `"4:3"` | `"1:1"` | `"9:16"` | `"21:9"`, a bare number ratio
|
|
145
|
+
* (`"1.85"`), or `null`/`"auto"` to let the video's own intrinsic ratio drive
|
|
146
|
+
* the box. The stage reserves this ratio from the first frame so poster →
|
|
147
|
+
* video swap never shoves layout (the poster convention: cover art fills the
|
|
148
|
+
* locked box until the first decoded frame paints over it).
|
|
149
|
+
*/
|
|
150
|
+
aspect?: string | null;
|
|
151
|
+
/**
|
|
152
|
+
* Autoplay muted on mount — presentation-primitive prop (PRD). Honoured only
|
|
153
|
+
* when the user has NOT asked to reduce motion; under `prefers-reduced-motion`
|
|
154
|
+
* the video stays paused on its poster (autoplaying video is motion). Autoplay
|
|
155
|
+
* forces `muted` regardless (browsers block unmuted autoplay).
|
|
156
|
+
*/
|
|
157
|
+
autoplay?: boolean;
|
|
158
|
+
/** Loop playback — presentation-primitive prop (PRD). */
|
|
159
|
+
loop?: boolean;
|
|
138
160
|
captionsSrc?: string | null;
|
|
139
161
|
captionsLabel?: string;
|
|
140
162
|
captionsLang?: string;
|
|
163
|
+
/**
|
|
164
|
+
* Should the captions <track> start ENABLED (`default` attribute)?
|
|
165
|
+
*
|
|
166
|
+
* `true` (default) preserves the historical behaviour: a track with a src
|
|
167
|
+
* shows on load. Pass `false` when the video already has captions burned
|
|
168
|
+
* into its frames (e.g. an academy episode render) — the track is still
|
|
169
|
+
* present and toggleable from the native menu, but not painted on top of
|
|
170
|
+
* the burned-in text, which would double every caption.
|
|
171
|
+
*
|
|
172
|
+
* Long-term direction (see the double-caption bug, jat-abwim): renders
|
|
173
|
+
* should STOP burning captions into the frames and rely on this
|
|
174
|
+
* toggleable VTT track alone. Until episodes are re-rendered without
|
|
175
|
+
* burned-in text, a burned-in episode passes `captionsDefault={false}`.
|
|
176
|
+
*/
|
|
177
|
+
captionsDefault?: boolean;
|
|
141
178
|
chapters?: VideoChapter[];
|
|
142
179
|
speakers?: VideoSpeakerTurn[];
|
|
143
180
|
transcript?: VideoTranscriptSegment[];
|
|
@@ -161,6 +198,37 @@
|
|
|
161
198
|
class?: string;
|
|
162
199
|
} = $props();
|
|
163
200
|
|
|
201
|
+
/**
|
|
202
|
+
* `aspect` → a CSS `aspect-ratio` value. Accepts `"W:H"`, a bare ratio number,
|
|
203
|
+
* or null/"auto" (⇒ no lock; the video's intrinsic ratio drives the box). Shared
|
|
204
|
+
* with <Flythrough>/<LessonEmbed> conceptually, kept inline so the primitive has
|
|
205
|
+
* no cross-file coupling for one small pure function.
|
|
206
|
+
*/
|
|
207
|
+
const aspectRatioCss = $derived.by(() => {
|
|
208
|
+
const a = aspect;
|
|
209
|
+
if (a == null) return null;
|
|
210
|
+
const trimmed = String(a).trim();
|
|
211
|
+
if (trimmed === '' || trimmed === 'auto') return null;
|
|
212
|
+
const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
|
|
213
|
+
if (colon) {
|
|
214
|
+
const w = parseFloat(colon[1]);
|
|
215
|
+
const h = parseFloat(colon[2]);
|
|
216
|
+
if (w > 0 && h > 0) return `${w} / ${h}`;
|
|
217
|
+
}
|
|
218
|
+
const num = Number(trimmed);
|
|
219
|
+
if (Number.isFinite(num) && num > 0) return String(num);
|
|
220
|
+
return null;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
|
|
224
|
+
// under the reduce preference the video holds on its poster instead. Tracked
|
|
225
|
+
// live (media query listener) so a mid-session preference flip is honoured, and
|
|
226
|
+
// SSR-safe (matchMedia only exists in the browser; defaults to "not reduced").
|
|
227
|
+
let reduceMotion = $state(false);
|
|
228
|
+
// Autoplay forces muted — browsers refuse to autoplay audible video, and a
|
|
229
|
+
// silent presentation clip is the point of the primitive anyway.
|
|
230
|
+
const effectiveAutoplay = $derived(autoplay && !reduceMotion);
|
|
231
|
+
|
|
164
232
|
// ── Element refs + media state ──────────────────────────────────────────
|
|
165
233
|
let videoEl: HTMLVideoElement | undefined = $state();
|
|
166
234
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
|
@@ -675,13 +743,49 @@
|
|
|
675
743
|
clearColorCache();
|
|
676
744
|
themeTick++;
|
|
677
745
|
});
|
|
746
|
+
|
|
747
|
+
// Live reduced-motion tracking — drives the autoplay gate. A flip toward
|
|
748
|
+
// "reduce" mid-playback pauses the clip on its current frame; a flip away
|
|
749
|
+
// lets a queued autoplay fire (the effect below owns actually starting it).
|
|
750
|
+
let stopReduce: (() => void) | undefined;
|
|
751
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
752
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
753
|
+
reduceMotion = mq.matches;
|
|
754
|
+
const onChange = () => (reduceMotion = mq.matches);
|
|
755
|
+
mq.addEventListener('change', onChange);
|
|
756
|
+
stopReduce = () => mq.removeEventListener('change', onChange);
|
|
757
|
+
}
|
|
758
|
+
|
|
678
759
|
return () => {
|
|
679
760
|
stopTheme();
|
|
761
|
+
stopReduce?.();
|
|
680
762
|
if (zoomTimer) clearTimeout(zoomTimer);
|
|
681
763
|
if (osdTimer) clearTimeout(osdTimer);
|
|
682
764
|
};
|
|
683
765
|
});
|
|
684
766
|
|
|
767
|
+
// Autoplay is applied imperatively (a queued `.play()` that the reduced-motion
|
|
768
|
+
// gate can veto) rather than via the native `autoplay` attribute, so a
|
|
769
|
+
// mid-session preference flip is respected instead of being a mount-only
|
|
770
|
+
// decision. The `.play()` promise can reject (no user gesture, tab backgrounded)
|
|
771
|
+
// — that's fine, the poster stays and the controls remain.
|
|
772
|
+
let didAutoplay = false;
|
|
773
|
+
$effect(() => {
|
|
774
|
+
const v = videoEl;
|
|
775
|
+
const go = effectiveAutoplay;
|
|
776
|
+
untrack(() => {
|
|
777
|
+
if (!v) return;
|
|
778
|
+
if (go && !didAutoplay) {
|
|
779
|
+
didAutoplay = true;
|
|
780
|
+
v.muted = true;
|
|
781
|
+
void v.play().catch(() => {});
|
|
782
|
+
} else if (!go && reduceMotion && didAutoplay && !v.paused) {
|
|
783
|
+
// Preference flipped to reduce after we'd started — settle on a frame.
|
|
784
|
+
v.pause();
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
685
789
|
// ── Band pointer handlers ───────────────────────────────────────────────
|
|
686
790
|
function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
|
|
687
791
|
const canvas = canvasEl;
|
|
@@ -943,7 +1047,12 @@
|
|
|
943
1047
|
>
|
|
944
1048
|
<div class="vp-main">
|
|
945
1049
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
946
|
-
<div
|
|
1050
|
+
<div
|
|
1051
|
+
class="vp-stage"
|
|
1052
|
+
class:vp-stage-locked={aspectRatioCss != null}
|
|
1053
|
+
style={aspectRatioCss != null ? `--vp-aspect: ${aspectRatioCss};` : undefined}
|
|
1054
|
+
onwheel={onStageWheel}
|
|
1055
|
+
>
|
|
947
1056
|
<video
|
|
948
1057
|
bind:this={videoEl}
|
|
949
1058
|
bind:currentTime
|
|
@@ -951,13 +1060,20 @@
|
|
|
951
1060
|
ontimeupdate={handleTimeUpdate}
|
|
952
1061
|
onloadedmetadata={onLoadedMetadata}
|
|
953
1062
|
{src}
|
|
1063
|
+
{loop}
|
|
1064
|
+
muted={effectiveAutoplay || undefined}
|
|
954
1065
|
poster={poster ?? undefined}
|
|
955
1066
|
controls
|
|
1067
|
+
playsinline
|
|
956
1068
|
class="vp-video"
|
|
957
1069
|
>
|
|
958
1070
|
<!-- A captions track is always present for a11y; without a src it is
|
|
959
|
-
an empty, inert track rather than a missing affordance.
|
|
960
|
-
|
|
1071
|
+
an empty, inert track rather than a missing affordance. It starts
|
|
1072
|
+
ENABLED only when there's a src AND the host opts in via
|
|
1073
|
+
captionsDefault (true by default). A burned-in-captions video
|
|
1074
|
+
passes captionsDefault={false} so the track stays available in the
|
|
1075
|
+
menu but isn't painted on top of the frames' own text (jat-abwim). -->
|
|
1076
|
+
<track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
|
|
961
1077
|
</video>
|
|
962
1078
|
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
963
1079
|
</div>
|
|
@@ -1202,6 +1318,27 @@
|
|
|
1202
1318
|
position: relative;
|
|
1203
1319
|
}
|
|
1204
1320
|
|
|
1321
|
+
/* Aspect-lock: the stage reserves --vp-aspect from the first frame, so the
|
|
1322
|
+
poster → decoded-video swap never resizes the box (the poster convention).
|
|
1323
|
+
The video is absolutely filled into the locked box with object-fit: contain,
|
|
1324
|
+
so a source whose intrinsic ratio differs letterboxes instead of stretching. */
|
|
1325
|
+
.vp-stage-locked {
|
|
1326
|
+
aspect-ratio: var(--vp-aspect);
|
|
1327
|
+
width: 100%;
|
|
1328
|
+
background: var(--color-neutral);
|
|
1329
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1330
|
+
overflow: hidden;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
.vp-stage-locked .vp-video {
|
|
1334
|
+
position: absolute;
|
|
1335
|
+
inset: 0;
|
|
1336
|
+
width: 100%;
|
|
1337
|
+
height: 100%;
|
|
1338
|
+
object-fit: contain;
|
|
1339
|
+
border-radius: 0;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1205
1342
|
.vp-video {
|
|
1206
1343
|
display: block;
|
|
1207
1344
|
width: 100%;
|
|
@@ -47,9 +47,42 @@ export interface VideoSpeakerTalkTime {
|
|
|
47
47
|
type $$ComponentProps = {
|
|
48
48
|
src: string;
|
|
49
49
|
poster?: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* Aspect ratio for the video stage — presentation-primitive prop (PRD).
|
|
52
|
+
* `"16:9"` | `"4:3"` | `"1:1"` | `"9:16"` | `"21:9"`, a bare number ratio
|
|
53
|
+
* (`"1.85"`), or `null`/`"auto"` to let the video's own intrinsic ratio drive
|
|
54
|
+
* the box. The stage reserves this ratio from the first frame so poster →
|
|
55
|
+
* video swap never shoves layout (the poster convention: cover art fills the
|
|
56
|
+
* locked box until the first decoded frame paints over it).
|
|
57
|
+
*/
|
|
58
|
+
aspect?: string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Autoplay muted on mount — presentation-primitive prop (PRD). Honoured only
|
|
61
|
+
* when the user has NOT asked to reduce motion; under `prefers-reduced-motion`
|
|
62
|
+
* the video stays paused on its poster (autoplaying video is motion). Autoplay
|
|
63
|
+
* forces `muted` regardless (browsers block unmuted autoplay).
|
|
64
|
+
*/
|
|
65
|
+
autoplay?: boolean;
|
|
66
|
+
/** Loop playback — presentation-primitive prop (PRD). */
|
|
67
|
+
loop?: boolean;
|
|
50
68
|
captionsSrc?: string | null;
|
|
51
69
|
captionsLabel?: string;
|
|
52
70
|
captionsLang?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Should the captions <track> start ENABLED (`default` attribute)?
|
|
73
|
+
*
|
|
74
|
+
* `true` (default) preserves the historical behaviour: a track with a src
|
|
75
|
+
* shows on load. Pass `false` when the video already has captions burned
|
|
76
|
+
* into its frames (e.g. an academy episode render) — the track is still
|
|
77
|
+
* present and toggleable from the native menu, but not painted on top of
|
|
78
|
+
* the burned-in text, which would double every caption.
|
|
79
|
+
*
|
|
80
|
+
* Long-term direction (see the double-caption bug, jat-abwim): renders
|
|
81
|
+
* should STOP burning captions into the frames and rely on this
|
|
82
|
+
* toggleable VTT track alone. Until episodes are re-rendered without
|
|
83
|
+
* burned-in text, a burned-in episode passes `captionsDefault={false}`.
|
|
84
|
+
*/
|
|
85
|
+
captionsDefault?: boolean;
|
|
53
86
|
chapters?: VideoChapter[];
|
|
54
87
|
speakers?: VideoSpeakerTurn[];
|
|
55
88
|
transcript?: VideoTranscriptSegment[];
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,9 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
|
|
|
13
13
|
export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
|
|
14
14
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
15
15
|
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, } from './components/VideoPlayer.svelte';
|
|
16
|
+
export { default as Flythrough } from './components/Flythrough.svelte';
|
|
17
|
+
export { default as LessonEmbed } from './components/LessonEmbed.svelte';
|
|
18
|
+
export type { Collection, CollectionCut, CollectionBeat, } from './components/LessonEmbed.svelte';
|
|
16
19
|
export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
|
|
17
20
|
export type { ItemKind, TranscriptItem, FileTouchedEntry, SignalNode, LinkedSession, WorkBlockClient, WorkTranscriptItemClient, } from './components/session-nav/transcriptModel';
|
|
18
21
|
export { default as UserAvatar } from './components/UserAvatar.svelte';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,13 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
|
|
|
13
13
|
// optional so a bare mp4 still gets the full playback UX. TranscriptPlayer
|
|
14
14
|
// stays exported unchanged — meetings already depends on it.
|
|
15
15
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
16
|
+
// Presentation-primitive video trio (unified-video-pipeline PRD): the frozen
|
|
17
|
+
// surface academy + mardukos consume. VideoPlayer (above) doubles as the full
|
|
18
|
+
// player AND the presentation player — extended with aspect/autoplay/loop +
|
|
19
|
+
// aspect-lock + reduced-motion; Flythrough is the ambient motion embed; and
|
|
20
|
+
// LessonEmbed renders a demo-assemble collection.json (cut picker + player).
|
|
21
|
+
export { default as Flythrough } from './components/Flythrough.svelte';
|
|
22
|
+
export { default as LessonEmbed } from './components/LessonEmbed.svelte';
|
|
16
23
|
// Data models — Session transcript (from JAT IDE, jat-jj79f.16 G1)
|
|
17
24
|
export { buildLinkedSession, buildLinkedSessionFromWork, rawTypeToState, sampleLinkedSession, SAMPLE_TRANSCRIPT_LINES, } from './components/session-nav/transcriptModel';
|
|
18
25
|
// Components — Universal
|