@r2digisolutions/components 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/components/atoms/Playhead/Playhead.svelte +44 -5
  2. package/dist/components/atoms/Playhead/Playhead.svelte.d.ts +8 -0
  3. package/dist/components/atoms/TimeRuler/TimeRuler.svelte +75 -16
  4. package/dist/components/atoms/TimeRuler/TimeRuler.svelte.d.ts +7 -0
  5. package/dist/components/atoms/TimelineGrid/TimelineGrid.stories.js +8 -0
  6. package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte +39 -0
  7. package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte.d.ts +11 -0
  8. package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte +7 -0
  9. package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte.d.ts +18 -0
  10. package/dist/components/molecules/ColorPicker/ColorPicker.svelte +45 -4
  11. package/dist/components/molecules/ColorPicker/ColorPicker.svelte.d.ts +2 -0
  12. package/dist/components/molecules/ContextMenu/ContextMenu.svelte +38 -11
  13. package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte +17 -2
  14. package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte.d.ts +5 -0
  15. package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte +362 -52
  16. package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte.d.ts +22 -2
  17. package/dist/components/molecules/PdfViewer/PdfViewer.svelte.d.ts +1 -1
  18. package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte +751 -29
  19. package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte.d.ts +14 -1
  20. package/dist/components/molecules/SplitPane/SplitPane.svelte +2 -2
  21. package/dist/components/molecules/TimelineClip/TimelineClip.svelte +95 -18
  22. package/dist/components/molecules/TimelineClip/TimelineClip.svelte.d.ts +3 -0
  23. package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte +82 -12
  24. package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte.d.ts +13 -2
  25. package/dist/components/molecules/TransportControls/TransportControls.svelte +84 -2
  26. package/dist/components/molecules/TransportControls/TransportControls.svelte.d.ts +13 -1
  27. package/dist/components/molecules/TransportControls/TransportControlsStory.svelte +33 -11
  28. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.stories.js +8 -0
  29. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte +213 -0
  30. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte.d.ts +22 -0
  31. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte +7 -0
  32. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte.d.ts +18 -0
  33. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.stories.js +8 -0
  34. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte +524 -0
  35. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte.d.ts +32 -0
  36. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte +7 -0
  37. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte.d.ts +18 -0
  38. package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte +85 -32
  39. package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte.d.ts +6 -0
  40. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.stories.js +8 -0
  41. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte +113 -0
  42. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte.d.ts +18 -0
  43. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte +7 -0
  44. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte.d.ts +18 -0
  45. package/dist/components/organisms/AudioEditor/AudioEditor.svelte +52 -1
  46. package/dist/components/organisms/EditorShell/EditorShell.svelte +102 -92
  47. package/dist/components/organisms/FileUploader/FileUploader.stories.d.ts +10 -0
  48. package/dist/components/organisms/FileUploader/FileUploader.stories.js +21 -0
  49. package/dist/components/organisms/FileUploader/FileUploader.svelte +61 -21
  50. package/dist/components/organisms/FileUploader/FileUploader.svelte.d.ts +9 -0
  51. package/dist/components/organisms/FileUploader/FileUploaderStory.svelte +7 -1
  52. package/dist/components/organisms/FileUploader/FileUploaderStory.svelte.d.ts +3 -0
  53. package/dist/components/organisms/VideoEditor/VideoEditor.svelte +1457 -78
  54. package/dist/components/organisms/VideoEditor/VideoEditor.svelte.d.ts +2 -1
  55. package/dist/components/organisms/VideoEditor/VideoEditorStory.svelte +3 -2
  56. package/dist/index.d.ts +11 -3
  57. package/dist/index.js +6 -2
  58. package/dist/utils/mediaTime.d.ts +14 -4
  59. package/dist/utils/mediaTime.js +27 -8
  60. package/dist/utils/mediaTracks.d.ts +154 -0
  61. package/dist/utils/mediaTracks.js +697 -1
  62. package/dist/utils/videoSequence.js +35 -4
  63. package/package.json +1 -1
@@ -1,14 +1,48 @@
1
1
  <script lang="ts">
2
- import type { MediaClip, MediaTrack } from '../../../utils/mediaTracks.js';
3
- import { findClipAt } from '../../../utils/mediaTracks.js';
2
+ import type { MediaClip, MediaClipRect, MediaTrack } from '../../../utils/mediaTracks.js';
3
+ import {
4
+ findClipAt,
5
+ findTransitionAt,
6
+ isClipActiveAt,
7
+ resolveClipRect,
8
+ resolvePreviewOpacity,
9
+ transitionDipOverlay,
10
+ transitionLayerStyle,
11
+ type TransitionLayerStyle
12
+ } from '../../../utils/mediaTracks.js';
13
+ import { formatTimecode } from '../../../utils/mediaTime.js';
14
+ import MarqueeRect from '../../atoms/MarqueeRect/MarqueeRect.svelte';
15
+ import {
16
+ attachMarqueeSelect,
17
+ collectMarqueeItems,
18
+ defaultMarqueeIgnore,
19
+ resolveMarqueeSelection,
20
+ type MarqueeRect as MarqueeBox
21
+ } from '../../../utils/marqueeSelect.js';
22
+ import { onDestroy } from 'svelte';
23
+
24
+ type ResizeEdge = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
4
25
 
5
26
  interface ProgramMonitorProps {
6
27
  width?: number;
7
28
  height?: number;
8
29
  background?: string;
9
30
  currentTimeMs?: number;
31
+ playing?: boolean;
32
+ playbackRate?: number;
33
+ muted?: boolean;
34
+ volume?: number;
10
35
  tracks?: MediaTrack[];
36
+ selectedClipIds?: string[];
37
+ /** When true, selected overlays can be dragged / resized on the frame */
38
+ editable?: boolean;
11
39
  class?: string;
40
+ onselectclip?: (clipId: string, e: MouseEvent) => void;
41
+ onchangeclip?: (clip: MediaClip) => void;
42
+ /** Batch update (multi-drag). Falls back to repeated onchangeclip. */
43
+ onchangeclips?: (clips: MediaClip[]) => void;
44
+ onemptyclick?: () => void;
45
+ onselectionchange?: (clipIds: string[]) => void;
12
46
  }
13
47
 
14
48
  let {
@@ -16,57 +50,745 @@
16
50
  height = 720,
17
51
  background = '#0a0a0a',
18
52
  currentTimeMs = 0,
53
+ playing = false,
54
+ playbackRate = 1,
55
+ muted = false,
56
+ volume = 1,
19
57
  tracks = [],
20
- class: className = ''
58
+ selectedClipIds = [],
59
+ editable = true,
60
+ class: className = '',
61
+ onselectclip,
62
+ onchangeclip,
63
+ onchangeclips,
64
+ onemptyclick,
65
+ onselectionchange
21
66
  }: ProgramMonitorProps = $props();
22
67
 
23
- const activeVideo = $derived(
24
- findClipAt(tracks, currentTimeMs, 'video') ?? findClipAt(tracks, currentTimeMs, 'image')
25
- );
68
+ const frame = $derived({ width, height });
69
+ const selectedSet = $derived(new Set(selectedClipIds));
70
+
71
+ function isOverlayClip(clip: MediaClip): boolean {
72
+ if (clip.kind === 'text') return true;
73
+ if (clip.kind === 'image' && !clip.src) return true;
74
+ if (clip.rect && clip.kind !== 'video' && clip.kind !== 'audio') return true;
75
+ return false;
76
+ }
77
+
78
+ const activeVideo = $derived.by(() => {
79
+ const clip =
80
+ findClipAt(tracks, currentTimeMs, 'video') ?? findClipAt(tracks, currentTimeMs, 'image');
81
+ if (!clip || clip.kind === 'text' || isOverlayClip(clip)) return null;
82
+ return clip;
83
+ });
84
+
26
85
  const activeAudio = $derived(findClipAt(tracks, currentTimeMs, 'audio'));
27
86
 
87
+ const transition = $derived(findTransitionAt(tracks, currentTimeMs));
88
+
89
+ function isProgramMedia(clip: MediaClip | null | undefined): clip is MediaClip {
90
+ return !!clip && clip.kind !== 'text' && clip.kind !== 'audio' && !isOverlayClip(clip);
91
+ }
92
+
93
+ /** Outgoing full-frame layer during a transition (kept even past the cut). */
94
+ const programOutgoing = $derived.by(() => {
95
+ if (transition) {
96
+ if (isProgramMedia(transition.from)) return transition.from;
97
+ // From is an overlay — don't also treat the incoming program clip as "outgoing"
98
+ if (activeVideo && activeVideo.id !== transition.to?.id) return activeVideo;
99
+ return null;
100
+ }
101
+ return activeVideo;
102
+ });
103
+
104
+ const programIncoming = $derived.by(() => {
105
+ if (!transition?.to || !isProgramMedia(transition.to)) return null;
106
+ return transition.to;
107
+ });
108
+
109
+ const overlays = $derived.by(() => {
110
+ const list: MediaClip[] = [];
111
+ const seen = new Set<string>();
112
+ for (const t of tracks) {
113
+ if (t.muted && !t.solo) continue;
114
+ for (const c of t.clips) {
115
+ if (!isOverlayClip(c)) continue;
116
+ const active = isClipActiveAt(c, currentTimeMs);
117
+ const inTransition =
118
+ !!transition &&
119
+ (c.id === transition.from.id || (!!transition.to && c.id === transition.to.id));
120
+ if (!active && !inTransition) continue;
121
+ if (seen.has(c.id)) continue;
122
+ seen.add(c.id);
123
+ list.push(c);
124
+ }
125
+ }
126
+ return list;
127
+ });
128
+
129
+ const dip = $derived(
130
+ transition ? transitionDipOverlay(transition.type, transition.progress) : null
131
+ );
132
+
133
+ let stageEl = $state<HTMLDivElement | null>(null);
134
+ let videoEl = $state<HTMLVideoElement | null>(null);
135
+ let incomingVideoEl = $state<HTMLVideoElement | null>(null);
136
+ let audioEl = $state<HTMLAudioElement | null>(null);
137
+ let marquee = $state<MarqueeBox | null>(null);
138
+ let disposeMarquee: (() => void) | null = null;
139
+
140
+ const SCRUB_DRIFT = 0.04;
141
+ const PLAY_DRIFT = 0.45;
142
+
143
+ let lastVideoClipId: string | null = null;
144
+ let lastIncomingClipId: string | null = null;
145
+ let lastAudioClipId: string | null = null;
146
+ let wasPlayingVideo = false;
147
+ let wasPlayingIncoming = false;
148
+ let wasPlayingAudio = false;
149
+
150
+ type DragMode = 'move' | 'resize';
151
+ let drag = $state<{
152
+ clipId: string;
153
+ mode: DragMode;
154
+ edge?: ResizeEdge;
155
+ startX: number;
156
+ startY: number;
157
+ origins: Record<string, MediaClipRect>;
158
+ } | null>(null);
159
+
160
+ $effect(() => {
161
+ disposeMarquee?.();
162
+ disposeMarquee = null;
163
+ const el = stageEl;
164
+ if (!el || !editable || playing || !onselectionchange) return;
165
+ disposeMarquee = attachMarqueeSelect(el, {
166
+ getItems: () => collectMarqueeItems(el),
167
+ onRect: (r) => (marquee = r),
168
+ shouldIgnore: (target) =>
169
+ defaultMarqueeIgnore(target) ||
170
+ (target instanceof Element && Boolean(target.closest('[data-marquee-id]'))),
171
+ onSelect: ({ ids, modifier }) => {
172
+ onselectionchange(resolveMarqueeSelection(ids, selectedClipIds, modifier));
173
+ }
174
+ });
175
+ });
176
+
177
+ onDestroy(() => {
178
+ disposeMarquee?.();
179
+ });
180
+
181
+ const canvaHandles: { edge: ResizeEdge; class: string }[] = [
182
+ { edge: 'n', class: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-n-resize' },
183
+ { edge: 's', class: 'left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-s-resize' },
184
+ { edge: 'e', class: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2 cursor-e-resize' },
185
+ { edge: 'w', class: 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2 cursor-w-resize' },
186
+ { edge: 'nw', class: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize' },
187
+ { edge: 'ne', class: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize' },
188
+ { edge: 'sw', class: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize' },
189
+ { edge: 'se', class: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize' }
190
+ ];
191
+
28
192
  function sourceTime(clip: MediaClip): number {
29
193
  const trim = clip.trimInMs ?? 0;
194
+ if (currentTimeMs < clip.startMs) return trim / 1000;
30
195
  return Math.max(0, currentTimeMs - clip.startMs + trim) / 1000;
31
196
  }
197
+
198
+ function clipOpacity(clip: MediaClip): number {
199
+ const t = currentTimeMs;
200
+ const inAnim = clip.animationIn;
201
+ const outAnim = clip.animationOut;
202
+ let opacity = resolvePreviewOpacity(clip);
203
+
204
+ const fadeTypes = new Set([
205
+ 'fade',
206
+ 'pop',
207
+ 'bounce',
208
+ 'blur',
209
+ 'glow',
210
+ 'drop',
211
+ 'swing',
212
+ 'spin',
213
+ 'flip-x',
214
+ 'flip-y'
215
+ ]);
216
+ if (inAnim && fadeTypes.has(inAnim.type) && inAnim.durationMs > 0) {
217
+ const local = t - clip.startMs;
218
+ if (local < inAnim.durationMs) {
219
+ const p = local / inAnim.durationMs;
220
+ const eased = inAnim.type === 'bounce' ? bounceEase(p) : p;
221
+ opacity = Math.min(opacity, eased);
222
+ }
223
+ }
224
+ if (outAnim && fadeTypes.has(outAnim.type) && outAnim.durationMs > 0) {
225
+ const remaining = clip.endMs - t;
226
+ if (remaining < outAnim.durationMs) {
227
+ const p = remaining / outAnim.durationMs;
228
+ opacity = Math.min(opacity, p);
229
+ }
230
+ }
231
+ return Math.max(0, Math.min(1, opacity));
232
+ }
233
+
234
+ function bounceEase(p: number) {
235
+ return Math.min(1, 1 - Math.abs(Math.sin((1 - p) * Math.PI * 1.2)) * (1 - p));
236
+ }
237
+
238
+ function clipFilter(clip: MediaClip): string | undefined {
239
+ const t = currentTimeMs;
240
+ const parts: string[] = [];
241
+
242
+ function apply(type: string, durationMs: number, entering: boolean) {
243
+ const local = entering ? t - clip.startMs : clip.endMs - t;
244
+ if (local < 0 || local >= durationMs) return;
245
+ const p = entering ? 1 - local / durationMs : 1 - local / durationMs;
246
+ if (type === 'blur') parts.push(`blur(${p * 10}px)`);
247
+ if (type === 'glow') parts.push(`brightness(${1 + p * 0.8})`);
248
+ }
249
+
250
+ if (clip.animationIn?.durationMs) apply(clip.animationIn.type, clip.animationIn.durationMs, true);
251
+ if (clip.animationOut?.durationMs) apply(clip.animationOut.type, clip.animationOut.durationMs, false);
252
+ return parts.length ? parts.join(' ') : undefined;
253
+ }
254
+
255
+ function clipTransform(clip: MediaClip): string {
256
+ const t = currentTimeMs;
257
+ const inAnim = clip.animationIn;
258
+ const outAnim = clip.animationOut;
259
+ const parts: string[] = [];
260
+
261
+ function applyIn(type: string, durationMs: number) {
262
+ const local = t - clip.startMs;
263
+ if (local < 0 || local >= durationMs) return;
264
+ const p = 1 - local / durationMs; // 1 → 0 as it settles
265
+ const reveal = 1 - p; // 0 → 1
266
+ if (type === 'slide' || type === 'slide-up') parts.push(`translateY(${p * 48}px)`);
267
+ else if (type === 'slide-down') parts.push(`translateY(${-p * 48}px)`);
268
+ else if (type === 'slide-left') parts.push(`translateX(${p * 64}px)`);
269
+ else if (type === 'slide-right') parts.push(`translateX(${-p * 64}px)`);
270
+ else if (type === 'scale' || type === 'pop' || type === 'grow')
271
+ parts.push(`scale(${0.55 + 0.45 * reveal})`);
272
+ else if (type === 'shrink') parts.push(`scale(${1.35 - 0.35 * reveal})`);
273
+ else if (type === 'rotate' || type === 'spin')
274
+ parts.push(`rotate(${p * -28}deg) scale(${0.85 + 0.15 * reveal})`);
275
+ else if (type === 'bounce') parts.push(`translateY(${Math.sin(p * Math.PI) * 22}px)`);
276
+ else if (type === 'drop') parts.push(`translateY(${-p * 80}px)`);
277
+ else if (type === 'swing') parts.push(`rotate(${Math.sin(p * Math.PI) * -18}deg)`);
278
+ else if (type === 'flip-x') parts.push(`perspective(700px) rotateY(${p * 90}deg)`);
279
+ else if (type === 'flip-y') parts.push(`perspective(700px) rotateX(${p * 90}deg)`);
280
+ else if (type === 'skew') parts.push(`skewX(${p * 18}deg)`);
281
+ else if (type === 'expand-h') parts.push(`scaleX(${0.15 + 0.85 * reveal})`);
282
+ else if (type === 'expand-v') parts.push(`scaleY(${0.15 + 0.85 * reveal})`);
283
+ }
284
+
285
+ function applyOut(type: string, durationMs: number) {
286
+ const remaining = clip.endMs - t;
287
+ if (remaining < 0 || remaining >= durationMs) return;
288
+ const p = 1 - remaining / durationMs; // 0 → 1 as it exits
289
+ if (type === 'slide' || type === 'slide-up') parts.push(`translateY(${-p * 48}px)`);
290
+ else if (type === 'slide-down') parts.push(`translateY(${p * 48}px)`);
291
+ else if (type === 'slide-left') parts.push(`translateX(${-p * 64}px)`);
292
+ else if (type === 'slide-right') parts.push(`translateX(${p * 64}px)`);
293
+ else if (type === 'scale' || type === 'pop' || type === 'shrink')
294
+ parts.push(`scale(${1 - 0.45 * p})`);
295
+ else if (type === 'grow') parts.push(`scale(${1 + 0.35 * p})`);
296
+ else if (type === 'rotate' || type === 'spin')
297
+ parts.push(`rotate(${p * 28}deg) scale(${1 - 0.15 * p})`);
298
+ else if (type === 'bounce') parts.push(`translateY(${-Math.sin(p * Math.PI) * 22}px)`);
299
+ else if (type === 'drop') parts.push(`translateY(${p * 80}px)`);
300
+ else if (type === 'swing') parts.push(`rotate(${Math.sin(p * Math.PI) * 18}deg)`);
301
+ else if (type === 'flip-x') parts.push(`perspective(700px) rotateY(${p * -90}deg)`);
302
+ else if (type === 'flip-y') parts.push(`perspective(700px) rotateX(${p * -90}deg)`);
303
+ else if (type === 'skew') parts.push(`skewX(${-p * 18}deg)`);
304
+ else if (type === 'expand-h') parts.push(`scaleX(${1 - 0.85 * p})`);
305
+ else if (type === 'expand-v') parts.push(`scaleY(${1 - 0.85 * p})`);
306
+ }
307
+
308
+ if (inAnim?.durationMs) applyIn(inAnim.type, inAnim.durationMs);
309
+ if (outAnim?.durationMs) applyOut(outAnim.type, outAnim.durationMs);
310
+
311
+ return parts.length ? parts.join(' ') : 'none';
312
+ }
313
+
314
+ function ensureSrc(el: HTMLMediaElement, src: string) {
315
+ if (el.dataset.mediaSrc === src) return;
316
+ el.dataset.mediaSrc = src;
317
+ el.src = src;
318
+ el.load();
319
+ }
320
+
321
+ function syncMedia(
322
+ el: HTMLMediaElement | null,
323
+ clip: MediaClip | null,
324
+ shouldPlay: boolean,
325
+ opts: {
326
+ lastClipId: string | null;
327
+ wasPlaying: boolean;
328
+ applyAudio: boolean;
329
+ }
330
+ ): { clipId: string | null; wasPlaying: boolean } {
331
+ if (!el || !clip?.src) {
332
+ if (el && !el.paused) el.pause();
333
+ return { clipId: null, wasPlaying: false };
334
+ }
335
+
336
+ ensureSrc(el, clip.src);
337
+ el.playbackRate = playbackRate;
338
+
339
+ if (opts.applyAudio) {
340
+ const clipVol = clip.volume ?? 1;
341
+ const nextVol = muted ? 0 : Math.max(0, Math.min(1, clipVol * volume));
342
+ el.muted = muted || nextVol === 0;
343
+ el.volume = nextVol;
344
+ } else {
345
+ el.muted = true;
346
+ }
347
+
348
+ const target = sourceTime(clip);
349
+ const clipChanged = clip.id !== opts.lastClipId;
350
+ const playStarted = shouldPlay && !opts.wasPlaying;
351
+ const drift = Math.abs(el.currentTime - target);
352
+ const threshold = shouldPlay ? PLAY_DRIFT * Math.max(1, playbackRate) : SCRUB_DRIFT;
353
+
354
+ if (clipChanged || playStarted || drift > threshold) {
355
+ try {
356
+ el.currentTime = target;
357
+ } catch {
358
+ /* ignore seek before loaded */
359
+ }
360
+ }
361
+
362
+ if (shouldPlay) {
363
+ if (el.paused) void el.play().catch(() => {});
364
+ } else if (!el.paused) {
365
+ el.pause();
366
+ }
367
+
368
+ return { clipId: clip.id, wasPlaying: shouldPlay };
369
+ }
370
+
371
+ $effect(() => {
372
+ const videoClip = programOutgoing?.kind === 'video' ? programOutgoing : null;
373
+ const next = syncMedia(videoEl, videoClip, playing, {
374
+ lastClipId: lastVideoClipId,
375
+ wasPlaying: wasPlayingVideo,
376
+ applyAudio: false
377
+ });
378
+ lastVideoClipId = next.clipId;
379
+ wasPlayingVideo = next.wasPlaying;
380
+ });
381
+
382
+ $effect(() => {
383
+ const videoClip = programIncoming?.kind === 'video' ? programIncoming : null;
384
+ const next = syncMedia(incomingVideoEl, videoClip, playing, {
385
+ lastClipId: lastIncomingClipId,
386
+ wasPlaying: wasPlayingIncoming,
387
+ applyAudio: false
388
+ });
389
+ lastIncomingClipId = next.clipId;
390
+ wasPlayingIncoming = next.wasPlaying;
391
+ });
392
+
393
+ $effect(() => {
394
+ const next = syncMedia(audioEl, activeAudio, playing, {
395
+ lastClipId: lastAudioClipId,
396
+ wasPlaying: wasPlayingAudio,
397
+ applyAudio: true
398
+ });
399
+ lastAudioClipId = next.clipId;
400
+ wasPlayingAudio = next.wasPlaying;
401
+ });
402
+
403
+ function overlayVisual(clip: MediaClip): TransitionLayerStyle {
404
+ const baseOp = clipOpacity(clip);
405
+ const baseTf = clipTransform(clip);
406
+ const baseFilter = clipFilter(clip);
407
+ if (
408
+ !transition ||
409
+ (clip.id !== transition.from.id && clip.id !== transition.to?.id)
410
+ ) {
411
+ return {
412
+ opacity: baseOp,
413
+ transform: baseTf === 'none' ? 'none' : baseTf,
414
+ filter: baseFilter
415
+ };
416
+ }
417
+ const role = clip.id === transition.from.id ? 'from' : 'to';
418
+ const layer = transitionLayerStyle(role, transition.type, transition.progress);
419
+ const parts = [baseTf !== 'none' ? baseTf : '', layer.transform !== 'none' ? layer.transform : '']
420
+ .filter(Boolean)
421
+ .join(' ');
422
+ const filters = [baseFilter, layer.filter].filter(Boolean).join(' ');
423
+ return {
424
+ opacity: baseOp * layer.opacity,
425
+ transform: parts || 'none',
426
+ filter: filters || undefined,
427
+ transformOrigin: layer.transformOrigin,
428
+ maskImage: layer.maskImage,
429
+ wipe: layer.wipe,
430
+ clipPath: layer.clipPath
431
+ };
432
+ }
433
+
434
+ function programLayerStyle(role: 'from' | 'to', clip: MediaClip): TransitionLayerStyle {
435
+ if (transition && (clip.id === transition.from.id || clip.id === transition.to?.id)) {
436
+ const r = clip.id === transition.from.id ? 'from' : 'to';
437
+ return transitionLayerStyle(r, transition.type, transition.progress);
438
+ }
439
+ if (role === 'from') {
440
+ return {
441
+ opacity: clipOpacity(clip),
442
+ transform: clipTransform(clip),
443
+ filter: clipFilter(clip)
444
+ };
445
+ }
446
+ return { opacity: 1, transform: 'none' };
447
+ }
448
+
449
+ function layerInlineStyle(s: TransitionLayerStyle): string {
450
+ const parts = [
451
+ `opacity:${s.opacity}`,
452
+ `transform:${s.transform || 'none'}`,
453
+ `transform-origin:${s.transformOrigin ?? 'center'}`
454
+ ];
455
+ if (s.filter) parts.push(`filter:${s.filter}`);
456
+ if (s.clipPath) parts.push(`clip-path:${s.clipPath}`, `-webkit-clip-path:${s.clipPath}`);
457
+ if (s.maskImage) {
458
+ parts.push(
459
+ `mask-image:${s.maskImage}`,
460
+ `-webkit-mask-image:${s.maskImage}`,
461
+ 'mask-size:100% 100%',
462
+ '-webkit-mask-size:100% 100%',
463
+ 'mask-repeat:no-repeat',
464
+ '-webkit-mask-repeat:no-repeat'
465
+ );
466
+ }
467
+ return parts.join(';');
468
+ }
469
+
470
+ function wipeBoxStyle(box: { left: string; top: string; width: string; height: string }): string {
471
+ return `left:${box.left};top:${box.top};width:${box.width};height:${box.height};`;
472
+ }
473
+
474
+ function beginInteract(clip: MediaClip, mode: DragMode, e: PointerEvent, edge?: ResizeEdge) {
475
+ if (!editable || playing) return;
476
+ e.preventDefault();
477
+ e.stopPropagation();
478
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
479
+ onselectclip?.(clip.id, e as unknown as MouseEvent);
480
+ const moveIds =
481
+ mode === 'move' && selectedSet.has(clip.id) && selectedClipIds.length > 1
482
+ ? selectedClipIds
483
+ : [clip.id];
484
+ const origins: Record<string, MediaClipRect> = {};
485
+ for (const id of moveIds) {
486
+ const c = overlays.find((o) => o.id === id) ?? (id === clip.id ? clip : null);
487
+ if (c) origins[id] = { ...resolveClipRect(c, frame) };
488
+ }
489
+ if (!origins[clip.id]) origins[clip.id] = { ...resolveClipRect(clip, frame) };
490
+ drag = {
491
+ clipId: clip.id,
492
+ mode,
493
+ edge,
494
+ startX: e.clientX,
495
+ startY: e.clientY,
496
+ origins
497
+ };
498
+ }
499
+
500
+ function applyResize(o: MediaClipRect, edge: ResizeEdge, dx: number, dy: number): MediaClipRect {
501
+ const minW = 40;
502
+ const minH = 24;
503
+ let { x, y, w, h } = o;
504
+
505
+ if (edge.includes('e')) w = Math.max(minW, o.w + dx);
506
+ if (edge.includes('s')) h = Math.max(minH, o.h + dy);
507
+ if (edge.includes('w')) {
508
+ const nextW = Math.max(minW, o.w - dx);
509
+ x = o.x + (o.w - nextW);
510
+ w = nextW;
511
+ }
512
+ if (edge.includes('n')) {
513
+ const nextH = Math.max(minH, o.h - dy);
514
+ y = o.y + (o.h - nextH);
515
+ h = nextH;
516
+ }
517
+
518
+ x = Math.min(Math.max(0, x), width - w);
519
+ y = Math.min(Math.max(0, y), height - h);
520
+ w = Math.min(w, width - x);
521
+ h = Math.min(h, height - y);
522
+
523
+ return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
524
+ }
525
+
526
+ function onPointerMove(e: PointerEvent) {
527
+ if (!drag || !stageEl) return;
528
+ const r = stageEl.getBoundingClientRect();
529
+ const sx = r.width > 0 ? width / r.width : 1;
530
+ const sy = r.height > 0 ? height / r.height : 1;
531
+ const dx = (e.clientX - drag.startX) * sx;
532
+ const dy = (e.clientY - drag.startY) * sy;
533
+
534
+ if (drag.mode === 'move') {
535
+ const updates: MediaClip[] = [];
536
+ for (const [id, o] of Object.entries(drag.origins)) {
537
+ const next = {
538
+ x: Math.round(Math.min(Math.max(0, o.x + dx), width - o.w)),
539
+ y: Math.round(Math.min(Math.max(0, o.y + dy), height - o.h)),
540
+ w: o.w,
541
+ h: o.h
542
+ };
543
+ const clip = overlays.find((c) => c.id === id);
544
+ if (clip) updates.push({ ...clip, rect: next });
545
+ }
546
+ if (updates.length) {
547
+ if (onchangeclips) onchangeclips(updates);
548
+ else updates.forEach((c) => onchangeclip?.(c));
549
+ }
550
+ return;
551
+ }
552
+
553
+ const o = drag.origins[drag.clipId];
554
+ if (!o) return;
555
+ const next = applyResize(o, drag.edge ?? 'se', dx, dy);
556
+ const clip = overlays.find((c) => c.id === drag!.clipId);
557
+ if (clip) onchangeclip?.({ ...clip, rect: next });
558
+ }
559
+
560
+ function endInteract() {
561
+ drag = null;
562
+ }
563
+
564
+ function styleForRect(rect: MediaClipRect): string {
565
+ return `left:${(rect.x / width) * 100}%;top:${(rect.y / height) * 100}%;width:${(rect.w / width) * 100}%;height:${(rect.h / height) * 100}%;`;
566
+ }
32
567
  </script>
33
568
 
569
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
34
570
  <div
571
+ bind:this={stageEl}
35
572
  class={[
36
573
  'relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg border border-border',
37
574
  className
38
575
  ]}
39
576
  style:background
40
577
  style:aspect-ratio={`${width} / ${height}`}
578
+ onpointermove={onPointerMove}
579
+ onpointerup={endInteract}
580
+ onpointercancel={endInteract}
581
+ onclick={(e) => {
582
+ if (e.target === e.currentTarget) onemptyclick?.();
583
+ }}
41
584
  >
42
- {#if activeVideo?.src && activeVideo.kind === 'video'}
43
- <video
44
- src={activeVideo.src}
45
- class="h-full w-full object-contain"
46
- muted
47
- playsinline
48
- currentTime={sourceTime(activeVideo)}
49
- ></video>
50
- {:else if activeVideo?.src && activeVideo.kind === 'image'}
51
- <img src={activeVideo.src} alt={activeVideo.name} class="h-full w-full object-contain" />
52
- {:else if activeVideo}
53
- <div class="flex flex-col items-center gap-2 text-secondary">
54
- <span class="rounded-md bg-surface-overlay px-3 py-1.5 text-sm font-medium text-primary">
55
- {activeVideo.name}
56
- </span>
57
- <span class="text-xs text-muted">No media source</span>
585
+ <!-- Program / full-frame media (+ transition pair) -->
586
+ <div class="absolute inset-0 overflow-hidden">
587
+ {#if programOutgoing?.src && (programOutgoing.kind === 'video' || programOutgoing.kind === 'image')}
588
+ {@const fromStyle = programLayerStyle('from', programOutgoing)}
589
+ {#if fromStyle.wipe}
590
+ <div class="absolute overflow-hidden" style={wipeBoxStyle(fromStyle.wipe.box)}>
591
+ <div
592
+ class="absolute flex items-center justify-center"
593
+ style={`${wipeBoxStyle(fromStyle.wipe.content)}${layerInlineStyle(fromStyle)}`}
594
+ >
595
+ {#if programOutgoing.kind === 'video'}
596
+ <video
597
+ bind:this={videoEl}
598
+ class="h-full w-full object-contain"
599
+ muted
600
+ playsinline
601
+ preload="auto"
602
+ ></video>
603
+ {:else}
604
+ <img
605
+ src={programOutgoing.src}
606
+ alt={programOutgoing.name}
607
+ class="h-full w-full object-contain"
608
+ />
609
+ {/if}
610
+ </div>
611
+ </div>
612
+ {:else}
613
+ <div class="absolute inset-0 flex items-center justify-center" style={layerInlineStyle(fromStyle)}>
614
+ {#if programOutgoing.kind === 'video'}
615
+ <video
616
+ bind:this={videoEl}
617
+ class="h-full w-full object-contain"
618
+ muted
619
+ playsinline
620
+ preload="auto"
621
+ ></video>
622
+ {:else}
623
+ <img
624
+ src={programOutgoing.src}
625
+ alt={programOutgoing.name}
626
+ class="h-full w-full object-contain"
627
+ />
628
+ {/if}
629
+ </div>
630
+ {/if}
631
+ {/if}
632
+
633
+ {#if programIncoming?.src}
634
+ {@const toStyle = programLayerStyle('to', programIncoming)}
635
+ {#if toStyle.wipe}
636
+ <div class="absolute overflow-hidden" style={wipeBoxStyle(toStyle.wipe.box)}>
637
+ <div
638
+ class="absolute flex items-center justify-center"
639
+ style={`${wipeBoxStyle(toStyle.wipe.content)}${layerInlineStyle(toStyle)}`}
640
+ >
641
+ {#if programIncoming.kind === 'video'}
642
+ <video
643
+ bind:this={incomingVideoEl}
644
+ class="h-full w-full object-contain"
645
+ muted
646
+ playsinline
647
+ preload="auto"
648
+ ></video>
649
+ {:else}
650
+ <img
651
+ src={programIncoming.src}
652
+ alt={programIncoming.name}
653
+ class="h-full w-full object-contain"
654
+ />
655
+ {/if}
656
+ </div>
657
+ </div>
658
+ {:else}
659
+ <div class="absolute inset-0 flex items-center justify-center" style={layerInlineStyle(toStyle)}>
660
+ {#if programIncoming.kind === 'video'}
661
+ <video
662
+ bind:this={incomingVideoEl}
663
+ class="h-full w-full object-contain"
664
+ muted
665
+ playsinline
666
+ preload="auto"
667
+ ></video>
668
+ {:else}
669
+ <img
670
+ src={programIncoming.src}
671
+ alt={programIncoming.name}
672
+ class="h-full w-full object-contain"
673
+ />
674
+ {/if}
675
+ </div>
676
+ {/if}
677
+ {/if}
678
+
679
+ {#if dip}
680
+ <div
681
+ class="pointer-events-none absolute inset-0"
682
+ style:background={dip.color}
683
+ style:opacity={dip.opacity}
684
+ ></div>
685
+ {/if}
686
+
687
+ {#if !programOutgoing?.src && overlays.length === 0 && !programIncoming}
688
+ <div class="flex h-full items-center justify-center">
689
+ <span class="text-sm text-muted">No clip at playhead</span>
690
+ </div>
691
+ {/if}
692
+ </div>
693
+
694
+ {#each overlays as clip (clip.id)}
695
+ {@const rect = resolveClipRect(clip, frame)}
696
+ {@const selected = selectedSet.has(clip.id)}
697
+ {@const showChrome = selected && editable && !playing}
698
+ {@const visual = overlayVisual(clip)}
699
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
700
+ <div
701
+ class={[
702
+ 'group/overlay absolute box-border overflow-hidden',
703
+ editable && !playing ? 'cursor-move' : 'pointer-events-none',
704
+ showChrome && 'outline outline-2 outline-[#3b82f6]'
705
+ ]}
706
+ style={styleForRect(rect)}
707
+ data-marquee-id={clip.id}
708
+ onpointerdown={(e) => {
709
+ const t = e.target as HTMLElement;
710
+ if (t?.closest('[data-resize-handle]')) return;
711
+ beginInteract(clip, 'move', e);
712
+ }}
713
+ onclick={(e) => {
714
+ e.stopPropagation();
715
+ onselectclip?.(clip.id, e);
716
+ }}
717
+ >
718
+ {#if visual.wipe}
719
+ <div class="absolute overflow-hidden" style={wipeBoxStyle(visual.wipe.box)}>
720
+ <div
721
+ class="absolute flex items-center justify-center"
722
+ style={`${wipeBoxStyle(visual.wipe.content)}${layerInlineStyle(visual)};background:${clip.kind === 'image' && !clip.src ? (clip.color ?? '#000') : 'transparent'}`}
723
+ >
724
+ {#if clip.kind === 'text'}
725
+ <span
726
+ class="max-h-full max-w-full overflow-hidden whitespace-pre-line px-2 py-1 text-center leading-tight"
727
+ style:color={clip.textColor ?? '#ffffff'}
728
+ style:font-size={`${Math.max(10, (clip.fontSize ?? 24) * (rect.h / 120))}px`}
729
+ style:font-weight={clip.fontWeight ?? 600}
730
+ style:font-family={clip.fontFamily}
731
+ style:text-align={clip.textAlign ?? 'center'}
732
+ style:text-shadow="0 1px 3px rgba(0,0,0,0.55)"
733
+ >
734
+ {clip.text ?? clip.name}
735
+ </span>
736
+ {:else if clip.src}
737
+ <img src={clip.src} alt={clip.name} class="h-full w-full object-cover" />
738
+ {/if}
739
+ </div>
740
+ </div>
741
+ {:else}
742
+ <div
743
+ class="flex h-full w-full items-center justify-center overflow-hidden"
744
+ style={`${layerInlineStyle(visual)};background:${clip.kind === 'image' && !clip.src ? (clip.color ?? '#000') : 'transparent'}`}
745
+ >
746
+ {#if clip.kind === 'text'}
747
+ <span
748
+ class="max-h-full max-w-full overflow-hidden whitespace-pre-line px-2 py-1 text-center leading-tight"
749
+ style:color={clip.textColor ?? '#ffffff'}
750
+ style:font-size={`${Math.max(10, (clip.fontSize ?? 24) * (rect.h / 120))}px`}
751
+ style:font-weight={clip.fontWeight ?? 600}
752
+ style:font-family={clip.fontFamily}
753
+ style:text-align={clip.textAlign ?? 'center'}
754
+ style:text-shadow="0 1px 3px rgba(0,0,0,0.55)"
755
+ >
756
+ {clip.text ?? clip.name}
757
+ </span>
758
+ {:else if clip.src}
759
+ <img src={clip.src} alt={clip.name} class="h-full w-full object-cover" />
760
+ {/if}
761
+ </div>
762
+ {/if}
763
+
764
+ {#if showChrome}
765
+ {#each canvaHandles as h (h.edge)}
766
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
767
+ <div
768
+ data-resize-handle
769
+ role="separator"
770
+ aria-label={`Resize ${h.edge}`}
771
+ class={[
772
+ 'absolute z-20 h-3 w-3 touch-none rounded-full border-2 border-[#3b82f6] bg-white shadow-sm',
773
+ h.class
774
+ ]}
775
+ onpointerdown={(e) => beginInteract(clip, 'resize', e, h.edge)}
776
+ ></div>
777
+ {/each}
778
+ {/if}
58
779
  </div>
59
- {:else}
60
- <span class="text-sm text-muted">No clip at playhead</span>
61
- {/if}
780
+ {/each}
781
+
782
+ <audio bind:this={audioEl} class="hidden" preload="auto"></audio>
62
783
 
63
- {#if activeAudio?.src}
64
- <audio src={activeAudio.src} currentTime={sourceTime(activeAudio)} class="hidden"></audio>
65
- {/if}
784
+ <MarqueeRect rect={marquee} />
66
785
 
67
786
  <div
68
787
  class="pointer-events-none absolute bottom-2 left-2 rounded bg-black/50 px-2 py-0.5 font-mono text-[10px] text-white"
69
788
  >
70
- {Math.round(currentTimeMs)}ms
789
+ {formatTimecode(currentTimeMs)}
790
+ {#if transition}
791
+ <span class="ml-2 text-fuchsia-300">· {transition.type}</span>
792
+ {/if}
71
793
  </div>
72
794
  </div>