@joewinke/jatui 0.2.30 → 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.
@@ -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. Omit for generate-only or non-asset-store usage —
99
- * the check is skipped entirely when absent, matching every call site
100
- * that predates this prop.
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. Omit for generate-only or non-asset-store usage —
11
- * the check is skipped entirely when absent, matching every call site
12
- * that predates this prop.
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,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 class="vp-stage" onwheel={onStageWheel}>
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.2.30",
3
+ "version": "0.2.31",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",