@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
@@ -14,9 +14,95 @@ export function createMediaClip(partial) {
14
14
  endMs: Math.max(partial.startMs + 1, partial.endMs),
15
15
  trimInMs: partial.trimInMs,
16
16
  trimOutMs: partial.trimOutMs,
17
- color: partial.color
17
+ color: partial.color,
18
+ text: partial.text,
19
+ opacity: partial.opacity,
20
+ previewOpacity: partial.previewOpacity,
21
+ volume: partial.volume,
22
+ fontSize: partial.fontSize,
23
+ fontWeight: partial.fontWeight,
24
+ fontFamily: partial.fontFamily,
25
+ textAlign: partial.textAlign,
26
+ textColor: partial.textColor,
27
+ rect: partial.rect,
28
+ animationIn: partial.animationIn,
29
+ animationOut: partial.animationOut,
30
+ transitionOut: partial.transitionOut
18
31
  };
19
32
  }
33
+ /** Default overlay rect for text / plates on a program frame. */
34
+ export function defaultOverlayRect(frame, preset = 'center') {
35
+ const { width: W, height: H } = frame;
36
+ switch (preset) {
37
+ case 'full':
38
+ return { x: 0, y: 0, w: W, h: H };
39
+ case 'lower-third':
40
+ return { x: Math.round(W * 0.06), y: Math.round(H * 0.72), w: Math.round(W * 0.55), h: Math.round(H * 0.16) };
41
+ case 'top':
42
+ return { x: Math.round(W * 0.1), y: Math.round(H * 0.08), w: Math.round(W * 0.8), h: Math.round(H * 0.18) };
43
+ case 'watermark':
44
+ return { x: Math.round(W * 0.78), y: Math.round(H * 0.88), w: Math.round(W * 0.18), h: Math.round(H * 0.08) };
45
+ case 'badge':
46
+ return { x: Math.round(W * 0.04), y: Math.round(H * 0.04), w: Math.round(W * 0.22), h: Math.round(H * 0.1) };
47
+ case 'ticker':
48
+ return { x: 0, y: Math.round(H * 0.88), w: W, h: Math.round(H * 0.12) };
49
+ case 'side':
50
+ return { x: 0, y: 0, w: Math.round(W * 0.28), h: H };
51
+ case 'center':
52
+ default: {
53
+ const w = Math.round(W * 0.7);
54
+ const h = Math.round(H * 0.22);
55
+ return { x: Math.round((W - w) / 2), y: Math.round((H - h) / 2), w, h };
56
+ }
57
+ }
58
+ }
59
+ export function clipHasSpatialRect(clip) {
60
+ return !!clip.rect || clip.kind === 'text' || (!!clip.color && clip.kind === 'image' && !clip.src);
61
+ }
62
+ export function resolveClipRect(clip, frame) {
63
+ if (clip.rect)
64
+ return clip.rect;
65
+ if (clip.kind === 'text')
66
+ return defaultOverlayRect(frame, 'lower-third');
67
+ if (clip.kind === 'image' && !clip.src)
68
+ return defaultOverlayRect(frame, 'full');
69
+ return { x: 0, y: 0, w: frame.width, h: frame.height };
70
+ }
71
+ export function alignClipRect(clip, frame, align) {
72
+ const rect = alignMediaRect(resolveClipRect(clip, frame), frame, align);
73
+ return { ...clip, rect };
74
+ }
75
+ export function alignMediaRect(rect, bounds, align) {
76
+ if (align === 'full') {
77
+ return { x: 0, y: 0, w: bounds.width, h: bounds.height };
78
+ }
79
+ let { x, y, w, h } = rect;
80
+ switch (align) {
81
+ case 'left':
82
+ x = 0;
83
+ break;
84
+ case 'center-h':
85
+ x = Math.max(0, (bounds.width - w) / 2);
86
+ break;
87
+ case 'right':
88
+ x = Math.max(0, bounds.width - w);
89
+ break;
90
+ case 'top':
91
+ y = 0;
92
+ break;
93
+ case 'center-v':
94
+ y = Math.max(0, (bounds.height - h) / 2);
95
+ break;
96
+ case 'bottom':
97
+ y = Math.max(0, bounds.height - h);
98
+ break;
99
+ case 'center':
100
+ x = Math.max(0, (bounds.width - w) / 2);
101
+ y = Math.max(0, (bounds.height - h) / 2);
102
+ break;
103
+ }
104
+ return { x, y, w, h };
105
+ }
20
106
  export function createMediaTrack(partial) {
21
107
  return {
22
108
  id: partial.id ?? uid('track'),
@@ -56,6 +142,320 @@ export function trimClipEnd(clip, newEndMs, minDur = 50) {
56
142
  export function isClipActiveAt(clip, timeMs) {
57
143
  return timeMs >= clip.startMs && timeMs < clip.endMs;
58
144
  }
145
+ function findNextClipAfter(tracks, from, sameTrackFirst) {
146
+ const sortedSame = [...sameTrackFirst.clips]
147
+ .filter((c) => c.id !== from.id)
148
+ .sort((a, b) => a.startMs - b.startMs);
149
+ const onTrack = sortedSame.find((c) => c.startMs >= from.endMs - 80) ??
150
+ sortedSame.find((c) => c.startMs > from.startMs) ??
151
+ null;
152
+ if (onTrack)
153
+ return onTrack;
154
+ // Components often live one-per-track: pick the next clip in time on any video track
155
+ const candidates = [];
156
+ for (const t of tracks) {
157
+ if (t.kind === 'audio' || (t.muted && !t.solo))
158
+ continue;
159
+ for (const c of t.clips) {
160
+ if (c.id === from.id)
161
+ continue;
162
+ if (c.startMs >= from.endMs - 80)
163
+ candidates.push(c);
164
+ }
165
+ }
166
+ candidates.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
167
+ return candidates[0] ?? null;
168
+ }
169
+ export function findTransitionAt(tracks, timeMs) {
170
+ let best = null;
171
+ for (const track of tracks) {
172
+ if (track.muted && !track.solo)
173
+ continue;
174
+ for (const from of track.clips) {
175
+ const tr = from.transitionOut;
176
+ if (!tr?.type || !(tr.durationMs > 0))
177
+ continue;
178
+ const dur = Math.min(tr.durationMs, Math.max(120, from.endMs - from.startMs));
179
+ const to = findNextClipAfter(tracks, from, track);
180
+ // CapCut-like: transition starts before the cut and can bleed slightly into the next clip
181
+ const windowStartMs = from.endMs - dur;
182
+ const windowEndMs = to
183
+ ? Math.min(to.endMs, from.endMs + Math.round(dur * 0.35))
184
+ : from.endMs;
185
+ if (timeMs < windowStartMs || timeMs >= windowEndMs)
186
+ continue;
187
+ const progress = Math.min(1, Math.max(0, (timeMs - windowStartMs) / dur));
188
+ const candidate = {
189
+ from,
190
+ to,
191
+ trackId: track.id,
192
+ type: tr.type,
193
+ progress,
194
+ durationMs: dur,
195
+ windowStartMs,
196
+ windowEndMs
197
+ };
198
+ // Prefer the transition closest to its cut / highest progress still running
199
+ if (!best || candidate.windowStartMs > best.windowStartMs)
200
+ best = candidate;
201
+ }
202
+ }
203
+ return best;
204
+ }
205
+ function pct(n) {
206
+ return `${Math.round(Math.min(1, Math.max(0, n)) * 1000) / 10}%`;
207
+ }
208
+ /** Percent that may exceed 0–100 (wipe content sizing / offsets). */
209
+ function pctAny(n) {
210
+ return `${Math.round(n * 1000) / 10}%`;
211
+ }
212
+ function hardWipe(axis,
213
+ /** from which edge the outgoing shrinks / incoming grows */
214
+ fromStart, role, progress) {
215
+ const p = Math.min(1, Math.max(0, progress));
216
+ const inv = 1 - p;
217
+ const eps = 0.002;
218
+ if (axis === 'x') {
219
+ if (role === 'from') {
220
+ if (fromStart) {
221
+ // wipe-left: keep left portion
222
+ const w = Math.max(inv, eps);
223
+ return {
224
+ box: { left: '0%', top: '0%', width: pct(w), height: '100%' },
225
+ content: { left: '0%', top: '0%', width: pctAny(1 / w), height: '100%' }
226
+ };
227
+ }
228
+ // wipe-right: keep right portion
229
+ const w = Math.max(inv, eps);
230
+ return {
231
+ box: { left: pct(p), top: '0%', width: pct(w), height: '100%' },
232
+ content: { left: pctAny(-p / w), top: '0%', width: pctAny(1 / w), height: '100%' }
233
+ };
234
+ }
235
+ if (fromStart) {
236
+ // wipe-left to: reveal from right
237
+ const w = Math.max(p, eps);
238
+ return {
239
+ box: { left: pct(inv), top: '0%', width: pct(w), height: '100%' },
240
+ content: { left: pctAny(-inv / w), top: '0%', width: pctAny(1 / w), height: '100%' }
241
+ };
242
+ }
243
+ // wipe-right to: reveal from left
244
+ const w = Math.max(p, eps);
245
+ return {
246
+ box: { left: '0%', top: '0%', width: pct(w), height: '100%' },
247
+ content: { left: '0%', top: '0%', width: pctAny(1 / w), height: '100%' }
248
+ };
249
+ }
250
+ // y axis
251
+ if (role === 'from') {
252
+ if (fromStart) {
253
+ // wipe-up: keep top portion
254
+ const h = Math.max(inv, eps);
255
+ return {
256
+ box: { left: '0%', top: '0%', width: '100%', height: pct(h) },
257
+ content: { left: '0%', top: '0%', width: '100%', height: pctAny(1 / h) }
258
+ };
259
+ }
260
+ // wipe-down: keep bottom portion
261
+ const h = Math.max(inv, eps);
262
+ return {
263
+ box: { left: '0%', top: pct(p), width: '100%', height: pct(h) },
264
+ content: { left: '0%', top: pctAny(-p / h), width: '100%', height: pctAny(1 / h) }
265
+ };
266
+ }
267
+ if (fromStart) {
268
+ // wipe-up to: reveal from bottom
269
+ const h = Math.max(p, eps);
270
+ return {
271
+ box: { left: '0%', top: pct(inv), width: '100%', height: pct(h) },
272
+ content: { left: '0%', top: pctAny(-inv / h), width: '100%', height: pctAny(1 / h) }
273
+ };
274
+ }
275
+ // wipe-down to: reveal from top
276
+ const h = Math.max(p, eps);
277
+ return {
278
+ box: { left: '0%', top: '0%', width: '100%', height: pct(h) },
279
+ content: { left: '0%', top: '0%', width: '100%', height: pctAny(1 / h) }
280
+ };
281
+ }
282
+ /** CSS bits for outgoing / incoming layers during a transition. */
283
+ export function transitionLayerStyle(role, type, progress) {
284
+ const p = Math.min(1, Math.max(0, progress));
285
+ const inv = 1 - p;
286
+ const slide = 110;
287
+ if (type === 'crossfade' || type === 'fade-soft') {
288
+ return {
289
+ opacity: role === 'from' ? inv : p,
290
+ transform: role === 'from' ? `scale(${1 - p * 0.04})` : `scale(${0.96 + p * 0.04})`
291
+ };
292
+ }
293
+ if (type === 'blur') {
294
+ return {
295
+ opacity: role === 'from' ? inv : p,
296
+ transform: `scale(${role === 'from' ? 1 + p * 0.12 : 1.12 - p * 0.12})`,
297
+ filter: `blur(${(role === 'from' ? p : inv) * 12}px)`
298
+ };
299
+ }
300
+ if (type === 'dip-black' || type === 'dip-white' || type === 'flash') {
301
+ if (role === 'from')
302
+ return { opacity: p < 0.55 ? 1 - p / 0.55 : 0, transform: 'none' };
303
+ return { opacity: p > 0.45 ? (p - 0.45) / 0.55 : 0, transform: 'none' };
304
+ }
305
+ if (type === 'zoom' || type === 'zoom-in') {
306
+ if (role === 'from')
307
+ return { opacity: inv, transform: `scale(${1 + p * 0.55})` };
308
+ return { opacity: p, transform: `scale(${1.55 - p * 0.55})` };
309
+ }
310
+ if (type === 'zoom-out') {
311
+ if (role === 'from')
312
+ return { opacity: inv, transform: `scale(${1 - p * 0.45})` };
313
+ return { opacity: p, transform: `scale(${0.55 + p * 0.45})` };
314
+ }
315
+ if (type === 'wipe-left') {
316
+ return { opacity: 1, transform: 'none', wipe: hardWipe('x', true, role, p) };
317
+ }
318
+ if (type === 'wipe-right') {
319
+ return { opacity: 1, transform: 'none', wipe: hardWipe('x', false, role, p) };
320
+ }
321
+ if (type === 'wipe-up') {
322
+ return { opacity: 1, transform: 'none', wipe: hardWipe('y', true, role, p) };
323
+ }
324
+ if (type === 'wipe-down') {
325
+ return { opacity: 1, transform: 'none', wipe: hardWipe('y', false, role, p) };
326
+ }
327
+ if (type === 'wipe-diagonal') {
328
+ const edge = Math.min(100, Math.max(0, inv * 100));
329
+ if (role === 'from') {
330
+ return {
331
+ opacity: 1,
332
+ transform: 'none',
333
+ maskImage: `linear-gradient(135deg, #000 ${edge}%, transparent ${edge}%)`
334
+ };
335
+ }
336
+ return {
337
+ opacity: 1,
338
+ transform: 'none',
339
+ maskImage: `linear-gradient(135deg, transparent ${edge}%, #000 ${edge}%)`
340
+ };
341
+ }
342
+ if (type === 'iris' || type === 'circle') {
343
+ const r = role === 'from' ? inv * 70 : p * 70;
344
+ return {
345
+ opacity: 1,
346
+ transform: 'none',
347
+ maskImage: `radial-gradient(circle at 50% 50%, #000 ${r}%, transparent ${r + 0.5}%)`
348
+ };
349
+ }
350
+ if (type === 'barn-doors') {
351
+ const side = inv * 50;
352
+ if (role === 'from') {
353
+ return {
354
+ opacity: 1,
355
+ transform: 'none',
356
+ maskImage: `linear-gradient(to right, #000 ${side}%, transparent ${side}%, transparent ${100 - side}%, #000 ${100 - side}%)`
357
+ };
358
+ }
359
+ return { opacity: p, transform: 'none' };
360
+ }
361
+ if (type === 'push-left' || type === 'slide-left') {
362
+ if (role === 'from')
363
+ return { opacity: 1, transform: `translateX(${-p * slide}%)` };
364
+ return { opacity: 1, transform: `translateX(${inv * slide}%)` };
365
+ }
366
+ if (type === 'push-right' || type === 'slide-right') {
367
+ if (role === 'from')
368
+ return { opacity: 1, transform: `translateX(${p * slide}%)` };
369
+ return { opacity: 1, transform: `translateX(${-inv * slide}%)` };
370
+ }
371
+ if (type === 'push-up' || type === 'slide-up') {
372
+ if (role === 'from')
373
+ return { opacity: 1, transform: `translateY(${-p * slide}%)` };
374
+ return { opacity: 1, transform: `translateY(${inv * slide}%)` };
375
+ }
376
+ if (type === 'push-down' || type === 'slide-down') {
377
+ if (role === 'from')
378
+ return { opacity: 1, transform: `translateY(${p * slide}%)` };
379
+ return { opacity: 1, transform: `translateY(${-inv * slide}%)` };
380
+ }
381
+ if (type === 'rotate' || type === 'spin') {
382
+ if (role === 'from') {
383
+ return { opacity: inv, transform: `rotate(${p * -48}deg) scale(${1 - p * 0.25})` };
384
+ }
385
+ return { opacity: p, transform: `rotate(${inv * 48}deg) scale(${0.75 + p * 0.25})` };
386
+ }
387
+ if (type === 'flip-x') {
388
+ if (role === 'from') {
389
+ return {
390
+ opacity: p < 0.5 ? 1 : 0,
391
+ transform: `perspective(900px) rotateY(${p * 180}deg)`,
392
+ transformOrigin: 'center'
393
+ };
394
+ }
395
+ return {
396
+ opacity: p >= 0.5 ? 1 : 0,
397
+ transform: `perspective(900px) rotateY(${(1 - p) * -180}deg)`,
398
+ transformOrigin: 'center'
399
+ };
400
+ }
401
+ if (type === 'flip-y') {
402
+ if (role === 'from') {
403
+ return {
404
+ opacity: p < 0.5 ? 1 : 0,
405
+ transform: `perspective(900px) rotateX(${p * 180}deg)`,
406
+ transformOrigin: 'center'
407
+ };
408
+ }
409
+ return {
410
+ opacity: p >= 0.5 ? 1 : 0,
411
+ transform: `perspective(900px) rotateX(${(1 - p) * -180}deg)`,
412
+ transformOrigin: 'center'
413
+ };
414
+ }
415
+ if (type === 'squeeze-h') {
416
+ if (role === 'from') {
417
+ return {
418
+ opacity: 1,
419
+ transform: `scaleX(${Math.max(0.02, inv)})`,
420
+ transformOrigin: 'center'
421
+ };
422
+ }
423
+ return {
424
+ opacity: 1,
425
+ transform: `scaleX(${Math.max(0.02, p)})`,
426
+ transformOrigin: 'center'
427
+ };
428
+ }
429
+ if (type === 'squeeze-v') {
430
+ if (role === 'from') {
431
+ return {
432
+ opacity: 1,
433
+ transform: `scaleY(${Math.max(0.02, inv)})`,
434
+ transformOrigin: 'center'
435
+ };
436
+ }
437
+ return {
438
+ opacity: 1,
439
+ transform: `scaleY(${Math.max(0.02, p)})`,
440
+ transformOrigin: 'center'
441
+ };
442
+ }
443
+ return {
444
+ opacity: role === 'from' ? inv : p,
445
+ transform: role === 'from' ? `translateY(${-p * 20}%)` : `translateY(${inv * 20}%)`
446
+ };
447
+ }
448
+ export function transitionDipOverlay(type, progress) {
449
+ const p = Math.min(1, Math.max(0, progress));
450
+ if (type === 'dip-black') {
451
+ return { color: '#000', opacity: p < 0.5 ? p * 2 : (1 - p) * 2 };
452
+ }
453
+ if (type === 'dip-white' || type === 'flash') {
454
+ const peak = type === 'flash' ? 0.95 : 1;
455
+ return { color: '#fff', opacity: (p < 0.5 ? p * 2 : (1 - p) * 2) * peak };
456
+ }
457
+ return null;
458
+ }
59
459
  export function findClipAt(tracks, timeMs, kind) {
60
460
  const list = kind ? tracks.filter((t) => t.kind === kind) : tracks;
61
461
  for (const track of [...list].reverse()) {
@@ -68,6 +468,14 @@ export function findClipAt(tracks, timeMs, kind) {
68
468
  }
69
469
  return null;
70
470
  }
471
+ export function findClipById(tracks, clipId) {
472
+ for (const t of tracks) {
473
+ const c = t.clips.find((x) => x.id === clipId);
474
+ if (c)
475
+ return c;
476
+ }
477
+ return null;
478
+ }
71
479
  export function updateClipInTracks(tracks, clipId, updater) {
72
480
  return tracks.map((t) => ({
73
481
  ...t,
@@ -80,6 +488,262 @@ export function removeClipFromTracks(tracks, clipId) {
80
488
  clips: t.clips.filter((c) => c.id !== clipId)
81
489
  }));
82
490
  }
491
+ export function removeClipsFromTracks(tracks, clipIds) {
492
+ const set = new Set(clipIds);
493
+ return tracks.map((t) => ({
494
+ ...t,
495
+ clips: t.clips.filter((c) => !set.has(c.id))
496
+ }));
497
+ }
498
+ /** Split a clip at timeMs; returns updated tracks (no-op if playhead not inside clip). */
499
+ export function splitClipAt(tracks, clipId, timeMs, minDur = 50) {
500
+ return tracks.map((t) => ({
501
+ ...t,
502
+ clips: t.clips.flatMap((c) => {
503
+ if (c.id !== clipId)
504
+ return [c];
505
+ if (timeMs <= c.startMs + minDur || timeMs >= c.endMs - minDur)
506
+ return [c];
507
+ const leftDur = timeMs - c.startMs;
508
+ const left = {
509
+ ...c,
510
+ endMs: timeMs,
511
+ trimOutMs: c.trimOutMs
512
+ };
513
+ const right = {
514
+ ...c,
515
+ id: uid('clip'),
516
+ startMs: timeMs,
517
+ trimInMs: (c.trimInMs ?? 0) + leftDur
518
+ };
519
+ return [left, right];
520
+ })
521
+ }));
522
+ }
523
+ /** Split every selected clip that contains timeMs. */
524
+ export function splitClipsAt(tracks, clipIds, timeMs, minDur = 50) {
525
+ let next = tracks;
526
+ for (const id of clipIds) {
527
+ next = splitClipAt(next, id, timeMs, minDur);
528
+ }
529
+ return next;
530
+ }
531
+ export function duplicateClipInTracks(tracks, clipId) {
532
+ return tracks.map((t) => {
533
+ const idx = t.clips.findIndex((c) => c.id === clipId);
534
+ if (idx < 0)
535
+ return t;
536
+ const src = t.clips[idx];
537
+ const copy = createMediaClip({
538
+ ...src,
539
+ id: uid('clip'),
540
+ name: `${src.name} copy`,
541
+ startMs: src.endMs,
542
+ endMs: src.endMs + clipDurationMs(src)
543
+ });
544
+ const clips = [...t.clips];
545
+ clips.splice(idx + 1, 0, copy);
546
+ return { ...t, clips };
547
+ });
548
+ }
549
+ /**
550
+ * Merge 2+ clips of the same kind into one spanning earliest start → latest end.
551
+ * Clips may live on different tracks; the result is placed on the earliest clip's track.
552
+ * Returns null if merge is not possible.
553
+ */
554
+ export function canMergeMediaClips(tracks, clipIds) {
555
+ if (clipIds.length < 2)
556
+ return false;
557
+ const idSet = new Set(clipIds);
558
+ const found = [];
559
+ for (const t of tracks) {
560
+ for (const c of t.clips) {
561
+ if (idSet.has(c.id))
562
+ found.push(c);
563
+ }
564
+ }
565
+ if (found.length < 2)
566
+ return false;
567
+ const kind = found[0].kind;
568
+ return found.every((c) => c.kind === kind);
569
+ }
570
+ export function mergeClipsInTracks(tracks, clipIds) {
571
+ if (!canMergeMediaClips(tracks, clipIds))
572
+ return null;
573
+ const idSet = new Set(clipIds);
574
+ const found = [];
575
+ for (const t of tracks) {
576
+ for (const c of t.clips) {
577
+ if (idSet.has(c.id))
578
+ found.push({ clip: c, trackId: t.id });
579
+ }
580
+ }
581
+ const sorted = [...found].sort((a, b) => a.clip.startMs - b.clip.startMs);
582
+ const targetTrackId = sorted[0].trackId;
583
+ const first = sorted[0].clip;
584
+ const last = sorted[sorted.length - 1].clip;
585
+ const merged = createMediaClip({
586
+ ...first,
587
+ id: uid('clip'),
588
+ trackId: targetTrackId,
589
+ name: first.name,
590
+ startMs: first.startMs,
591
+ endMs: Math.max(last.endMs, first.endMs),
592
+ text: first.text,
593
+ src: first.src,
594
+ assetId: first.assetId
595
+ });
596
+ return tracks.map((t) => {
597
+ const rest = t.clips.filter((c) => !idSet.has(c.id));
598
+ if (t.id !== targetTrackId)
599
+ return { ...t, clips: rest };
600
+ return { ...t, clips: [...rest, merged].sort((a, b) => a.startMs - b.startMs) };
601
+ });
602
+ }
603
+ /** Shift one or more clips in time by the same delta (clamped so none go before 0). */
604
+ export function moveClipsByDelta(tracks, clipIds, deltaMs) {
605
+ if (!clipIds.length || deltaMs === 0)
606
+ return tracks;
607
+ const set = new Set(clipIds);
608
+ let minStart = Infinity;
609
+ for (const t of tracks) {
610
+ for (const c of t.clips) {
611
+ if (set.has(c.id))
612
+ minStart = Math.min(minStart, c.startMs);
613
+ }
614
+ }
615
+ if (!Number.isFinite(minStart))
616
+ return tracks;
617
+ const d = minStart + deltaMs < 0 ? -minStart : deltaMs;
618
+ if (d === 0)
619
+ return tracks;
620
+ return tracks.map((t) => ({
621
+ ...t,
622
+ clips: t.clips.map((c) => (set.has(c.id) ? moveClip(c, c.startMs + d) : c))
623
+ }));
624
+ }
625
+ export function nextTrackName(tracks, kind) {
626
+ const prefix = kind === 'audio' ? 'A' : 'V';
627
+ const n = tracks.filter((t) => t.kind === kind || (kind === 'video' && t.kind === 'image')).length + 1;
628
+ return `${prefix}${n}`;
629
+ }
630
+ /** Reorder tracks by id list (ids not listed keep relative order at the end). */
631
+ export function reorderTracks(tracks, orderedIds) {
632
+ const map = new Map(tracks.map((t) => [t.id, t]));
633
+ const next = [];
634
+ for (const id of orderedIds) {
635
+ const t = map.get(id);
636
+ if (t) {
637
+ next.push(t);
638
+ map.delete(id);
639
+ }
640
+ }
641
+ for (const t of map.values())
642
+ next.push(t);
643
+ return next;
644
+ }
645
+ /** Move a track from one index to another. */
646
+ export function moveTrack(tracks, fromIndex, toIndex) {
647
+ if (fromIndex < 0 || fromIndex >= tracks.length)
648
+ return tracks;
649
+ if (toIndex < 0 || toIndex >= tracks.length)
650
+ return tracks;
651
+ if (fromIndex === toIndex)
652
+ return tracks;
653
+ const next = [...tracks];
654
+ const [item] = next.splice(fromIndex, 1);
655
+ next.splice(toIndex, 0, item);
656
+ return next;
657
+ }
658
+ export function removeTrack(tracks, trackId) {
659
+ return tracks.filter((t) => t.id !== trackId);
660
+ }
661
+ export function removeTracks(tracks, trackIds) {
662
+ const set = new Set(trackIds);
663
+ return tracks.filter((t) => !set.has(t.id));
664
+ }
665
+ /** Defaults for inspector reset (Unreal-style). */
666
+ export const MEDIA_CLIP_DEFAULTS = {
667
+ opacity: 1,
668
+ previewOpacity: 1,
669
+ volume: 1,
670
+ fontSize: 18,
671
+ fontWeight: 600,
672
+ textAlign: 'center',
673
+ textColor: '#ffffff',
674
+ color: '#3b82f6',
675
+ trimInMs: 0,
676
+ trimOutMs: undefined
677
+ };
678
+ /** Default rect used by inspector reset (must stay in sync with `resetMediaClipField`). */
679
+ export function defaultMediaClipRect(clip, frame) {
680
+ const preset = clip.kind === 'text' ? 'center' : clip.kind === 'image' && !clip.src ? 'full' : 'full';
681
+ return defaultOverlayRect(frame, preset);
682
+ }
683
+ function rectsEqual(a, b) {
684
+ return (Math.round(a.x) === Math.round(b.x) &&
685
+ Math.round(a.y) === Math.round(b.y) &&
686
+ Math.round(a.w) === Math.round(b.w) &&
687
+ Math.round(a.h) === Math.round(b.h));
688
+ }
689
+ function parseRectAxis(field) {
690
+ if (field === 'rect.x' || field === 'rect.y' || field === 'rect.w' || field === 'rect.h') {
691
+ return field.slice(5);
692
+ }
693
+ return null;
694
+ }
695
+ export function isMediaClipFieldModified(clip, field, frame) {
696
+ const axis = parseRectAxis(field);
697
+ if (field === 'rect' || axis) {
698
+ if (!frame)
699
+ return clip.rect != null;
700
+ const current = resolveClipRect(clip, frame);
701
+ const def = defaultMediaClipRect(clip, frame);
702
+ if (axis)
703
+ return Math.round(current[axis]) !== Math.round(def[axis]);
704
+ return !rectsEqual(current, def);
705
+ }
706
+ const def = MEDIA_CLIP_DEFAULTS[field];
707
+ const cur = clip[field];
708
+ if (cur === undefined || cur === null)
709
+ return false;
710
+ if (field === 'fontWeight')
711
+ return Number(cur) !== Number(def);
712
+ return cur !== def;
713
+ }
714
+ export function resetMediaClipField(clip, field, frame) {
715
+ const axis = parseRectAxis(field);
716
+ if (field === 'rect' || axis) {
717
+ if (!frame) {
718
+ const { rect: _r, ...rest } = clip;
719
+ return rest;
720
+ }
721
+ const def = defaultMediaClipRect(clip, frame);
722
+ if (!axis)
723
+ return { ...clip, rect: def };
724
+ const current = resolveClipRect(clip, frame);
725
+ return {
726
+ ...clip,
727
+ rect: {
728
+ ...current,
729
+ [axis]: axis === 'w' || axis === 'h' ? Math.max(1, def[axis]) : def[axis]
730
+ }
731
+ };
732
+ }
733
+ if (field === 'trimOutMs') {
734
+ const { trimOutMs: _t, ...rest } = clip;
735
+ return rest;
736
+ }
737
+ return { ...clip, [field]: MEDIA_CLIP_DEFAULTS[field] };
738
+ }
739
+ /** Effective program-monitor opacity (ignores timeline clip opacity). */
740
+ export function resolvePreviewOpacity(clip) {
741
+ return clip.previewOpacity ?? 1;
742
+ }
743
+ /** Effective timeline block opacity. */
744
+ export function resolveClipOpacity(clip) {
745
+ return clip.opacity ?? 1;
746
+ }
83
747
  export function createMediaAsset(partial) {
84
748
  return {
85
749
  id: partial.id ?? uid('asset'),
@@ -100,3 +764,35 @@ export function kindFromFile(file) {
100
764
  return 'audio';
101
765
  return 'text';
102
766
  }
767
+ /** Probe width/height/duration from a local media URL. */
768
+ export function probeMediaSrc(src, kind) {
769
+ return new Promise((resolve) => {
770
+ if (kind === 'image') {
771
+ const img = new Image();
772
+ img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
773
+ img.onerror = () => resolve({});
774
+ img.src = src;
775
+ return;
776
+ }
777
+ if (kind !== 'video' && kind !== 'audio') {
778
+ resolve({});
779
+ return;
780
+ }
781
+ const el = document.createElement(kind === 'audio' ? 'audio' : 'video');
782
+ el.preload = 'metadata';
783
+ el.onloadedmetadata = () => {
784
+ const durationMs = Number.isFinite(el.duration)
785
+ ? Math.round(el.duration * 1000)
786
+ : undefined;
787
+ const width = kind === 'video' && 'videoWidth' in el
788
+ ? el.videoWidth || undefined
789
+ : undefined;
790
+ const height = kind === 'video' && 'videoHeight' in el
791
+ ? el.videoHeight || undefined
792
+ : undefined;
793
+ resolve({ durationMs, width, height });
794
+ };
795
+ el.onerror = () => resolve({});
796
+ el.src = src;
797
+ });
798
+ }