@joewinke/jatui 0.2.29 → 0.2.31
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/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 +14 -1
- package/dist/components/MediaWorkbench.svelte.d.ts +10 -0
- package/dist/components/VideoPlayer.svelte +118 -1
- package/dist/components/VideoPlayer.svelte.d.ts +18 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -85,11 +85,22 @@
|
|
|
85
85
|
uploadEndpoint = "/api/media",
|
|
86
86
|
onComplete = undefined,
|
|
87
87
|
accept = "image/png,image/jpeg,image/webp,image/gif",
|
|
88
|
+
assetId = undefined,
|
|
88
89
|
} = $props<{
|
|
89
90
|
value?: string | null
|
|
90
91
|
uploadEndpoint?: string
|
|
91
92
|
onComplete?: (url: string) => void
|
|
92
93
|
accept?: string
|
|
94
|
+
/**
|
|
95
|
+
* Identifies the existing asset-store row being edited, if any. Forwarded
|
|
96
|
+
* to /api/media/edit and /api/media/remove-bg so the host app's
|
|
97
|
+
* kind='record' safety check (assets that must never be AI pixel-edited)
|
|
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.
|
|
102
|
+
*/
|
|
103
|
+
assetId?: string
|
|
93
104
|
}>()
|
|
94
105
|
|
|
95
106
|
let open = $state(false)
|
|
@@ -355,7 +366,7 @@
|
|
|
355
366
|
const res = await fetch("/api/media/edit", {
|
|
356
367
|
method: "POST",
|
|
357
368
|
headers: { "Content-Type": "application/json" },
|
|
358
|
-
body: JSON.stringify({ imageUrl: sourceUrl, instruction: editInstruction }),
|
|
369
|
+
body: JSON.stringify({ imageUrl: sourceUrl, instruction: editInstruction, assetId }),
|
|
359
370
|
})
|
|
360
371
|
const body = await res.json().catch(() => ({})) as { error?: string; url?: string }
|
|
361
372
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
|
|
@@ -379,6 +390,8 @@
|
|
|
379
390
|
const blob = await imgRes.blob()
|
|
380
391
|
const fd = new FormData()
|
|
381
392
|
fd.append("image", blob, "source.png")
|
|
393
|
+
if (assetId) fd.append("assetId", assetId)
|
|
394
|
+
fd.append("sourceUrl", sourceUrl)
|
|
382
395
|
const res = await fetch("/api/media/remove-bg", { method: "POST", body: fd })
|
|
383
396
|
if (!res.ok) {
|
|
384
397
|
const msg = await res.text().catch(() => res.statusText)
|
|
@@ -3,6 +3,16 @@ type $$ComponentProps = {
|
|
|
3
3
|
uploadEndpoint?: string;
|
|
4
4
|
onComplete?: (url: string) => void;
|
|
5
5
|
accept?: string;
|
|
6
|
+
/**
|
|
7
|
+
* Identifies the existing asset-store row being edited, if any. Forwarded
|
|
8
|
+
* to /api/media/edit and /api/media/remove-bg so the host app's
|
|
9
|
+
* kind='record' safety check (assets that must never be AI pixel-edited)
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
assetId?: string;
|
|
6
16
|
};
|
|
7
17
|
declare const MediaWorkbench: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
8
18
|
type MediaWorkbench = ReturnType<typeof MediaWorkbench>;
|
|
@@ -116,6 +116,9 @@
|
|
|
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',
|
|
@@ -135,6 +138,24 @@
|
|
|
135
138
|
}: {
|
|
136
139
|
src: string;
|
|
137
140
|
poster?: string | null;
|
|
141
|
+
/**
|
|
142
|
+
* Aspect ratio for the video stage — presentation-primitive prop (PRD).
|
|
143
|
+
* `"16:9"` | `"4:3"` | `"1:1"` | `"9:16"` | `"21:9"`, a bare number ratio
|
|
144
|
+
* (`"1.85"`), or `null`/`"auto"` to let the video's own intrinsic ratio drive
|
|
145
|
+
* the box. The stage reserves this ratio from the first frame so poster →
|
|
146
|
+
* video swap never shoves layout (the poster convention: cover art fills the
|
|
147
|
+
* locked box until the first decoded frame paints over it).
|
|
148
|
+
*/
|
|
149
|
+
aspect?: string | null;
|
|
150
|
+
/**
|
|
151
|
+
* Autoplay muted on mount — presentation-primitive prop (PRD). Honoured only
|
|
152
|
+
* when the user has NOT asked to reduce motion; under `prefers-reduced-motion`
|
|
153
|
+
* the video stays paused on its poster (autoplaying video is motion). Autoplay
|
|
154
|
+
* forces `muted` regardless (browsers block unmuted autoplay).
|
|
155
|
+
*/
|
|
156
|
+
autoplay?: boolean;
|
|
157
|
+
/** Loop playback — presentation-primitive prop (PRD). */
|
|
158
|
+
loop?: boolean;
|
|
138
159
|
captionsSrc?: string | null;
|
|
139
160
|
captionsLabel?: string;
|
|
140
161
|
captionsLang?: string;
|
|
@@ -161,6 +182,37 @@
|
|
|
161
182
|
class?: string;
|
|
162
183
|
} = $props();
|
|
163
184
|
|
|
185
|
+
/**
|
|
186
|
+
* `aspect` → a CSS `aspect-ratio` value. Accepts `"W:H"`, a bare ratio number,
|
|
187
|
+
* or null/"auto" (⇒ no lock; the video's intrinsic ratio drives the box). Shared
|
|
188
|
+
* with <Flythrough>/<LessonEmbed> conceptually, kept inline so the primitive has
|
|
189
|
+
* no cross-file coupling for one small pure function.
|
|
190
|
+
*/
|
|
191
|
+
const aspectRatioCss = $derived.by(() => {
|
|
192
|
+
const a = aspect;
|
|
193
|
+
if (a == null) return null;
|
|
194
|
+
const trimmed = String(a).trim();
|
|
195
|
+
if (trimmed === '' || trimmed === 'auto') return null;
|
|
196
|
+
const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
|
|
197
|
+
if (colon) {
|
|
198
|
+
const w = parseFloat(colon[1]);
|
|
199
|
+
const h = parseFloat(colon[2]);
|
|
200
|
+
if (w > 0 && h > 0) return `${w} / ${h}`;
|
|
201
|
+
}
|
|
202
|
+
const num = Number(trimmed);
|
|
203
|
+
if (Number.isFinite(num) && num > 0) return String(num);
|
|
204
|
+
return null;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
|
|
208
|
+
// under the reduce preference the video holds on its poster instead. Tracked
|
|
209
|
+
// live (media query listener) so a mid-session preference flip is honoured, and
|
|
210
|
+
// SSR-safe (matchMedia only exists in the browser; defaults to "not reduced").
|
|
211
|
+
let reduceMotion = $state(false);
|
|
212
|
+
// Autoplay forces muted — browsers refuse to autoplay audible video, and a
|
|
213
|
+
// silent presentation clip is the point of the primitive anyway.
|
|
214
|
+
const effectiveAutoplay = $derived(autoplay && !reduceMotion);
|
|
215
|
+
|
|
164
216
|
// ── Element refs + media state ──────────────────────────────────────────
|
|
165
217
|
let videoEl: HTMLVideoElement | undefined = $state();
|
|
166
218
|
let canvasEl: HTMLCanvasElement | undefined = $state();
|
|
@@ -675,13 +727,49 @@
|
|
|
675
727
|
clearColorCache();
|
|
676
728
|
themeTick++;
|
|
677
729
|
});
|
|
730
|
+
|
|
731
|
+
// Live reduced-motion tracking — drives the autoplay gate. A flip toward
|
|
732
|
+
// "reduce" mid-playback pauses the clip on its current frame; a flip away
|
|
733
|
+
// lets a queued autoplay fire (the effect below owns actually starting it).
|
|
734
|
+
let stopReduce: (() => void) | undefined;
|
|
735
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
736
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
737
|
+
reduceMotion = mq.matches;
|
|
738
|
+
const onChange = () => (reduceMotion = mq.matches);
|
|
739
|
+
mq.addEventListener('change', onChange);
|
|
740
|
+
stopReduce = () => mq.removeEventListener('change', onChange);
|
|
741
|
+
}
|
|
742
|
+
|
|
678
743
|
return () => {
|
|
679
744
|
stopTheme();
|
|
745
|
+
stopReduce?.();
|
|
680
746
|
if (zoomTimer) clearTimeout(zoomTimer);
|
|
681
747
|
if (osdTimer) clearTimeout(osdTimer);
|
|
682
748
|
};
|
|
683
749
|
});
|
|
684
750
|
|
|
751
|
+
// Autoplay is applied imperatively (a queued `.play()` that the reduced-motion
|
|
752
|
+
// gate can veto) rather than via the native `autoplay` attribute, so a
|
|
753
|
+
// mid-session preference flip is respected instead of being a mount-only
|
|
754
|
+
// decision. The `.play()` promise can reject (no user gesture, tab backgrounded)
|
|
755
|
+
// — that's fine, the poster stays and the controls remain.
|
|
756
|
+
let didAutoplay = false;
|
|
757
|
+
$effect(() => {
|
|
758
|
+
const v = videoEl;
|
|
759
|
+
const go = effectiveAutoplay;
|
|
760
|
+
untrack(() => {
|
|
761
|
+
if (!v) return;
|
|
762
|
+
if (go && !didAutoplay) {
|
|
763
|
+
didAutoplay = true;
|
|
764
|
+
v.muted = true;
|
|
765
|
+
void v.play().catch(() => {});
|
|
766
|
+
} else if (!go && reduceMotion && didAutoplay && !v.paused) {
|
|
767
|
+
// Preference flipped to reduce after we'd started — settle on a frame.
|
|
768
|
+
v.pause();
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
685
773
|
// ── Band pointer handlers ───────────────────────────────────────────────
|
|
686
774
|
function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
|
|
687
775
|
const canvas = canvasEl;
|
|
@@ -943,7 +1031,12 @@
|
|
|
943
1031
|
>
|
|
944
1032
|
<div class="vp-main">
|
|
945
1033
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
946
|
-
<div
|
|
1034
|
+
<div
|
|
1035
|
+
class="vp-stage"
|
|
1036
|
+
class:vp-stage-locked={aspectRatioCss != null}
|
|
1037
|
+
style={aspectRatioCss != null ? `--vp-aspect: ${aspectRatioCss};` : undefined}
|
|
1038
|
+
onwheel={onStageWheel}
|
|
1039
|
+
>
|
|
947
1040
|
<video
|
|
948
1041
|
bind:this={videoEl}
|
|
949
1042
|
bind:currentTime
|
|
@@ -951,8 +1044,11 @@
|
|
|
951
1044
|
ontimeupdate={handleTimeUpdate}
|
|
952
1045
|
onloadedmetadata={onLoadedMetadata}
|
|
953
1046
|
{src}
|
|
1047
|
+
{loop}
|
|
1048
|
+
muted={effectiveAutoplay || undefined}
|
|
954
1049
|
poster={poster ?? undefined}
|
|
955
1050
|
controls
|
|
1051
|
+
playsinline
|
|
956
1052
|
class="vp-video"
|
|
957
1053
|
>
|
|
958
1054
|
<!-- A captions track is always present for a11y; without a src it is
|
|
@@ -1202,6 +1298,27 @@
|
|
|
1202
1298
|
position: relative;
|
|
1203
1299
|
}
|
|
1204
1300
|
|
|
1301
|
+
/* Aspect-lock: the stage reserves --vp-aspect from the first frame, so the
|
|
1302
|
+
poster → decoded-video swap never resizes the box (the poster convention).
|
|
1303
|
+
The video is absolutely filled into the locked box with object-fit: contain,
|
|
1304
|
+
so a source whose intrinsic ratio differs letterboxes instead of stretching. */
|
|
1305
|
+
.vp-stage-locked {
|
|
1306
|
+
aspect-ratio: var(--vp-aspect);
|
|
1307
|
+
width: 100%;
|
|
1308
|
+
background: var(--color-neutral);
|
|
1309
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1310
|
+
overflow: hidden;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
.vp-stage-locked .vp-video {
|
|
1314
|
+
position: absolute;
|
|
1315
|
+
inset: 0;
|
|
1316
|
+
width: 100%;
|
|
1317
|
+
height: 100%;
|
|
1318
|
+
object-fit: contain;
|
|
1319
|
+
border-radius: 0;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1205
1322
|
.vp-video {
|
|
1206
1323
|
display: block;
|
|
1207
1324
|
width: 100%;
|
|
@@ -47,6 +47,24 @@ 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;
|
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
|