@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.
- package/dist/components/atoms/Playhead/Playhead.svelte +44 -5
- package/dist/components/atoms/Playhead/Playhead.svelte.d.ts +8 -0
- package/dist/components/atoms/TimeRuler/TimeRuler.svelte +75 -16
- package/dist/components/atoms/TimeRuler/TimeRuler.svelte.d.ts +7 -0
- package/dist/components/atoms/TimelineGrid/TimelineGrid.stories.js +8 -0
- package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte +39 -0
- package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte.d.ts +11 -0
- package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte +7 -0
- package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte.d.ts +18 -0
- package/dist/components/molecules/ColorPicker/ColorPicker.svelte +45 -4
- package/dist/components/molecules/ColorPicker/ColorPicker.svelte.d.ts +2 -0
- package/dist/components/molecules/ContextMenu/ContextMenu.svelte +38 -11
- package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte +17 -2
- package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte.d.ts +5 -0
- package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte +362 -52
- package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte.d.ts +22 -2
- package/dist/components/molecules/PdfViewer/PdfViewer.svelte.d.ts +1 -1
- package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte +751 -29
- package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte.d.ts +14 -1
- package/dist/components/molecules/SplitPane/SplitPane.svelte +2 -2
- package/dist/components/molecules/TimelineClip/TimelineClip.svelte +95 -18
- package/dist/components/molecules/TimelineClip/TimelineClip.svelte.d.ts +3 -0
- package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte +82 -12
- package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte.d.ts +13 -2
- package/dist/components/molecules/TransportControls/TransportControls.svelte +84 -2
- package/dist/components/molecules/TransportControls/TransportControls.svelte.d.ts +13 -1
- package/dist/components/molecules/TransportControls/TransportControlsStory.svelte +33 -11
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.stories.js +8 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte +213 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte.d.ts +22 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte +7 -0
- package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte.d.ts +18 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.stories.js +8 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte +524 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte.d.ts +32 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte +7 -0
- package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte.d.ts +18 -0
- package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte +85 -32
- package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte.d.ts +6 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.stories.js +8 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte +113 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte.d.ts +18 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte +7 -0
- package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte.d.ts +18 -0
- package/dist/components/organisms/AudioEditor/AudioEditor.svelte +52 -1
- package/dist/components/organisms/EditorShell/EditorShell.svelte +102 -92
- package/dist/components/organisms/FileUploader/FileUploader.stories.d.ts +10 -0
- package/dist/components/organisms/FileUploader/FileUploader.stories.js +21 -0
- package/dist/components/organisms/FileUploader/FileUploader.svelte +61 -21
- package/dist/components/organisms/FileUploader/FileUploader.svelte.d.ts +9 -0
- package/dist/components/organisms/FileUploader/FileUploaderStory.svelte +7 -1
- package/dist/components/organisms/FileUploader/FileUploaderStory.svelte.d.ts +3 -0
- package/dist/components/organisms/VideoEditor/VideoEditor.svelte +1457 -78
- package/dist/components/organisms/VideoEditor/VideoEditor.svelte.d.ts +2 -1
- package/dist/components/organisms/VideoEditor/VideoEditorStory.svelte +3 -2
- package/dist/index.d.ts +11 -3
- package/dist/index.js +6 -2
- package/dist/utils/mediaTime.d.ts +14 -4
- package/dist/utils/mediaTime.js +27 -8
- package/dist/utils/mediaTracks.d.ts +154 -0
- package/dist/utils/mediaTracks.js +697 -1
- package/dist/utils/videoSequence.js +35 -4
- 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
|
+
}
|