@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
|
@@ -1,30 +1,88 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import EditorShell from '../EditorShell/EditorShell.svelte';
|
|
3
3
|
import MediaAssetBrowser from '../../molecules/MediaAssetBrowser/MediaAssetBrowser.svelte';
|
|
4
|
+
import VideoComponentsPanel, {
|
|
5
|
+
VIDEO_COMPONENT_MIME,
|
|
6
|
+
type VideoComponentDef
|
|
7
|
+
} from '../../molecules/VideoComponentsPanel/VideoComponentsPanel.svelte';
|
|
8
|
+
import VideoAnimationsPanel, {
|
|
9
|
+
VIDEO_ANIMATION_MIME,
|
|
10
|
+
type VideoAnimationDef
|
|
11
|
+
} from '../../molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte';
|
|
12
|
+
import VideoTransitionsPanel, {
|
|
13
|
+
VIDEO_TRANSITION_MIME,
|
|
14
|
+
type VideoTransitionDef
|
|
15
|
+
} from '../../molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte';
|
|
4
16
|
import ProgramMonitor from '../../molecules/ProgramMonitor/ProgramMonitor.svelte';
|
|
5
17
|
import TransportControls from '../../molecules/TransportControls/TransportControls.svelte';
|
|
6
18
|
import MediaTimeline from '../../molecules/MediaTimeline/MediaTimeline.svelte';
|
|
19
|
+
import ContextMenu from '../../molecules/ContextMenu/ContextMenu.svelte';
|
|
20
|
+
import type { ContextMenuItem } from '../../molecules/ContextMenu/ContextMenu.svelte';
|
|
21
|
+
import BulkActionBar from '../../molecules/BulkActionBar/BulkActionBar.svelte';
|
|
22
|
+
import type { BulkAction } from '../../molecules/BulkActionBar/BulkActionBar.svelte';
|
|
23
|
+
import FileUploader from '../FileUploader/FileUploader.svelte';
|
|
7
24
|
import PropertyGroup from '../../molecules/PropertyGroup/PropertyGroup.svelte';
|
|
8
25
|
import PropertyField from '../../molecules/PropertyField/PropertyField.svelte';
|
|
9
26
|
import Input from '../../atoms/Input/Input.svelte';
|
|
27
|
+
import NumberInput from '../../molecules/NumberInput/NumberInput.svelte';
|
|
28
|
+
import ColorPicker from '../../molecules/ColorPicker/ColorPicker.svelte';
|
|
10
29
|
import Button from '../../atoms/Button/Button.svelte';
|
|
11
30
|
import IconButton from '../../atoms/IconButton/IconButton.svelte';
|
|
12
31
|
import {
|
|
13
32
|
createMediaClip,
|
|
33
|
+
createMediaTrack,
|
|
14
34
|
updateClipInTracks,
|
|
15
|
-
|
|
35
|
+
removeClipsFromTracks,
|
|
36
|
+
splitClipsAt,
|
|
37
|
+
mergeClipsInTracks,
|
|
38
|
+
canMergeMediaClips,
|
|
39
|
+
duplicateClipInTracks,
|
|
40
|
+
findClipById,
|
|
41
|
+
nextTrackName,
|
|
42
|
+
reorderTracks,
|
|
43
|
+
removeTrack,
|
|
44
|
+
defaultOverlayRect,
|
|
45
|
+
alignClipRect,
|
|
46
|
+
resolveClipRect,
|
|
47
|
+
moveClipsByDelta,
|
|
48
|
+
isMediaClipFieldModified,
|
|
49
|
+
resetMediaClipField,
|
|
50
|
+
kindFromFile,
|
|
51
|
+
probeMediaSrc,
|
|
52
|
+
createMediaAsset,
|
|
16
53
|
type MediaAsset,
|
|
54
|
+
type MediaAssetKind,
|
|
17
55
|
type MediaClip,
|
|
18
|
-
type
|
|
56
|
+
type MediaClipAlign,
|
|
57
|
+
type MediaClipResettableField,
|
|
58
|
+
type MediaTrack,
|
|
59
|
+
type MediaTrackKind
|
|
19
60
|
} from '../../../utils/mediaTracks.js';
|
|
20
|
-
import {
|
|
21
|
-
emptyVideoSequence,
|
|
22
|
-
type VideoSequence
|
|
23
|
-
} from '../../../utils/videoSequence.js';
|
|
61
|
+
import { emptyVideoSequence, type VideoSequence } from '../../../utils/videoSequence.js';
|
|
24
62
|
import { clampMs } from '../../../utils/mediaTime.js';
|
|
63
|
+
import Tooltip from '../../atoms/Tooltip/Tooltip.svelte';
|
|
64
|
+
import Slider from '../../atoms/Slider/Slider.svelte';
|
|
25
65
|
import Download from '@lucide/svelte/icons/download';
|
|
26
66
|
import PanelLeft from '@lucide/svelte/icons/panel-left';
|
|
27
67
|
import PanelRight from '@lucide/svelte/icons/panel-right';
|
|
68
|
+
import Scissors from '@lucide/svelte/icons/scissors';
|
|
69
|
+
import Copy from '@lucide/svelte/icons/copy';
|
|
70
|
+
import Combine from '@lucide/svelte/icons/combine';
|
|
71
|
+
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
72
|
+
import AlignStartVertical from '@lucide/svelte/icons/align-start-vertical';
|
|
73
|
+
import AlignCenterVertical from '@lucide/svelte/icons/align-center-vertical';
|
|
74
|
+
import AlignEndVertical from '@lucide/svelte/icons/align-end-vertical';
|
|
75
|
+
import AlignStartHorizontal from '@lucide/svelte/icons/align-start-horizontal';
|
|
76
|
+
import AlignCenterHorizontal from '@lucide/svelte/icons/align-center-horizontal';
|
|
77
|
+
import AlignEndHorizontal from '@lucide/svelte/icons/align-end-horizontal';
|
|
78
|
+
import AlignCenter from '@lucide/svelte/icons/align-center';
|
|
79
|
+
import AlignLeft from '@lucide/svelte/icons/align-left';
|
|
80
|
+
import AlignRight from '@lucide/svelte/icons/align-right';
|
|
81
|
+
import Maximize2 from '@lucide/svelte/icons/maximize-2';
|
|
82
|
+
import type { Component } from 'svelte';
|
|
83
|
+
import { onDestroy } from 'svelte';
|
|
84
|
+
|
|
85
|
+
type IconComp = Component<{ class?: string }>;
|
|
28
86
|
|
|
29
87
|
interface VideoEditorProps {
|
|
30
88
|
value?: VideoSequence;
|
|
@@ -40,23 +98,150 @@
|
|
|
40
98
|
onexport
|
|
41
99
|
}: VideoEditorProps = $props();
|
|
42
100
|
|
|
43
|
-
let
|
|
101
|
+
let selectedClipIds = $state<string[]>([]);
|
|
44
102
|
let currentTimeMs = $state(0);
|
|
45
103
|
let playing = $state(false);
|
|
104
|
+
let playbackRate = $state(1);
|
|
105
|
+
let muted = $state(false);
|
|
106
|
+
let volume = $state(1);
|
|
46
107
|
let zoom = $state(1);
|
|
47
108
|
let showSidebar = $state(true);
|
|
48
109
|
let showInspector = $state(true);
|
|
110
|
+
let sidebarTab = $state<'components' | 'animations' | 'transitions' | 'assets'>('components');
|
|
111
|
+
let ctxOpen = $state(false);
|
|
112
|
+
let ctxAnchor = $state<{ x: number; y: number } | null>(null);
|
|
113
|
+
let ctxTrackId = $state<string | null>(null);
|
|
49
114
|
let raf = 0;
|
|
50
115
|
let lastTs = 0;
|
|
116
|
+
let transitionPreviewTimer = 0;
|
|
117
|
+
const SKIP_MS = 5000;
|
|
118
|
+
|
|
119
|
+
onDestroy(() => {
|
|
120
|
+
window.clearTimeout(transitionPreviewTimer);
|
|
121
|
+
});
|
|
51
122
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
123
|
+
const primaryClipId = $derived(selectedClipIds[selectedClipIds.length - 1] ?? null);
|
|
124
|
+
const selectedClip = $derived(primaryClipId ? findClipById(value.tracks, primaryClipId) : null);
|
|
125
|
+
const canMerge = $derived(canMergeMediaClips(value.tracks, selectedClipIds));
|
|
126
|
+
const canSplit = $derived(
|
|
127
|
+
!!selectedClip && (selectedClip.kind === 'video' || selectedClip.kind === 'audio')
|
|
128
|
+
);
|
|
129
|
+
const canBulkSplit = $derived(
|
|
130
|
+
selectedClipIds.some((id) => {
|
|
131
|
+
const c = findClipById(value.tracks, id);
|
|
132
|
+
return !!c && (c.kind === 'video' || c.kind === 'audio');
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const bulkActions = $derived.by((): BulkAction[] => [
|
|
137
|
+
{
|
|
138
|
+
id: 'split',
|
|
139
|
+
label: 'Split at playhead',
|
|
140
|
+
icon: Scissors,
|
|
141
|
+
variant: 'ghost',
|
|
142
|
+
disabled: !canBulkSplit
|
|
143
|
+
},
|
|
144
|
+
{ id: 'merge', label: 'Merge', icon: Combine, variant: 'ghost', disabled: !canMerge },
|
|
145
|
+
{ id: 'duplicate', label: 'Duplicate', icon: Copy, variant: 'ghost' },
|
|
146
|
+
{
|
|
147
|
+
id: 'delete',
|
|
148
|
+
label: 'Delete',
|
|
149
|
+
icon: Trash2,
|
|
150
|
+
variant: 'destructive',
|
|
151
|
+
confirm: true,
|
|
152
|
+
confirmLabel: `Delete ${selectedClipIds.length}`
|
|
56
153
|
}
|
|
57
|
-
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
function handleBulk(id: string) {
|
|
157
|
+
if (id === 'split') splitSelected();
|
|
158
|
+
else if (id === 'merge') mergeSelected();
|
|
159
|
+
else if (id === 'duplicate') duplicateSelected();
|
|
160
|
+
else if (id === 'delete') deleteSelected();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ctxItems = $derived.by((): ContextMenuItem[] => {
|
|
164
|
+
const hasSel = selectedClipIds.length > 0;
|
|
165
|
+
const canDeleteTrack = !!ctxTrackId && value.tracks.length > 1;
|
|
166
|
+
return [
|
|
167
|
+
{
|
|
168
|
+
id: 'split',
|
|
169
|
+
label: 'Split at playhead',
|
|
170
|
+
shortcut: 'S',
|
|
171
|
+
disabled: !canSplit,
|
|
172
|
+
category: 'Edit'
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'merge',
|
|
176
|
+
label: 'Merge selected',
|
|
177
|
+
disabled: !canMerge,
|
|
178
|
+
category: 'Edit'
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
id: 'duplicate',
|
|
182
|
+
label: 'Duplicate',
|
|
183
|
+
shortcut: '⌘D',
|
|
184
|
+
disabled: !hasSel,
|
|
185
|
+
category: 'Edit'
|
|
186
|
+
},
|
|
187
|
+
{ id: 'sep-1', label: '', separator: true },
|
|
188
|
+
{
|
|
189
|
+
id: 'add-clip',
|
|
190
|
+
label: 'Add empty clip',
|
|
191
|
+
category: 'Insert',
|
|
192
|
+
disabled: !ctxTrackId && !hasSel
|
|
193
|
+
},
|
|
194
|
+
{ id: 'add-video-track', label: 'Add video track', category: 'Tracks' },
|
|
195
|
+
{ id: 'add-audio-track', label: 'Add audio track', category: 'Tracks' },
|
|
196
|
+
{
|
|
197
|
+
id: 'delete-track',
|
|
198
|
+
label: 'Delete track',
|
|
199
|
+
destructive: true,
|
|
200
|
+
disabled: !canDeleteTrack,
|
|
201
|
+
category: 'Tracks'
|
|
202
|
+
},
|
|
203
|
+
{ id: 'sep-2', label: '', separator: true },
|
|
204
|
+
{
|
|
205
|
+
id: 'delete',
|
|
206
|
+
label: selectedClipIds.length > 1 ? `Delete ${selectedClipIds.length} clips` : 'Delete',
|
|
207
|
+
destructive: true,
|
|
208
|
+
shortcut: '⌫',
|
|
209
|
+
disabled: !hasSel,
|
|
210
|
+
category: 'Edit'
|
|
211
|
+
}
|
|
212
|
+
];
|
|
58
213
|
});
|
|
59
214
|
|
|
215
|
+
const alignDock: { id: MediaClipAlign; label: string; icon: IconComp }[] = [
|
|
216
|
+
{ id: 'left', label: 'Align left', icon: AlignStartVertical },
|
|
217
|
+
{ id: 'center-h', label: 'Align center', icon: AlignCenterVertical },
|
|
218
|
+
{ id: 'right', label: 'Align right', icon: AlignEndVertical },
|
|
219
|
+
{ id: 'top', label: 'Align top', icon: AlignStartHorizontal },
|
|
220
|
+
{ id: 'center-v', label: 'Align middle', icon: AlignCenterHorizontal },
|
|
221
|
+
{ id: 'bottom', label: 'Align bottom', icon: AlignEndHorizontal },
|
|
222
|
+
{ id: 'center', label: 'Center on frame', icon: AlignCenter },
|
|
223
|
+
{ id: 'full', label: 'Fill frame', icon: Maximize2 }
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const textAlignDock: { id: 'left' | 'center' | 'right'; label: string; icon: IconComp }[] = [
|
|
227
|
+
{ id: 'left', label: 'Align left', icon: AlignLeft },
|
|
228
|
+
{ id: 'center', label: 'Align center', icon: AlignCenter },
|
|
229
|
+
{ id: 'right', label: 'Align right', icon: AlignRight }
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const selectedRect = $derived(
|
|
233
|
+
selectedClip
|
|
234
|
+
? resolveClipRect(selectedClip, { width: value.width, height: value.height })
|
|
235
|
+
: null
|
|
236
|
+
);
|
|
237
|
+
const showTransform = $derived(
|
|
238
|
+
!!selectedClip &&
|
|
239
|
+
selectedClip.kind !== 'audio' &&
|
|
240
|
+
(selectedClip.kind === 'text' ||
|
|
241
|
+
!!selectedClip.rect ||
|
|
242
|
+
(selectedClip.kind === 'image' && !selectedClip.src))
|
|
243
|
+
);
|
|
244
|
+
|
|
60
245
|
function emit(next: VideoSequence) {
|
|
61
246
|
value = next;
|
|
62
247
|
onchange?.(next);
|
|
@@ -66,8 +251,52 @@
|
|
|
66
251
|
emit({ ...value, tracks });
|
|
67
252
|
}
|
|
68
253
|
|
|
254
|
+
function patchSequence(partial: Partial<VideoSequence>) {
|
|
255
|
+
emit({ ...value, ...partial });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function selectClip(id: string, e?: MouseEvent) {
|
|
259
|
+
const multi = e && (e.metaKey || e.ctrlKey || e.shiftKey);
|
|
260
|
+
if (multi) {
|
|
261
|
+
if (selectedClipIds.includes(id)) {
|
|
262
|
+
selectedClipIds = selectedClipIds.filter((x) => x !== id);
|
|
263
|
+
} else {
|
|
264
|
+
selectedClipIds = [...selectedClipIds, id];
|
|
265
|
+
}
|
|
266
|
+
} else if (selectedClipIds.includes(id)) {
|
|
267
|
+
// Keep multi-selection when dragging / clicking an already-selected clip
|
|
268
|
+
return;
|
|
269
|
+
} else {
|
|
270
|
+
selectedClipIds = [id];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function setSelection(ids: string[]) {
|
|
275
|
+
selectedClipIds = ids;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function openClipMenu(clipId: string, e: MouseEvent) {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
e.stopPropagation();
|
|
281
|
+
if (!selectedClipIds.includes(clipId)) {
|
|
282
|
+
selectedClipIds = [clipId];
|
|
283
|
+
}
|
|
284
|
+
ctxTrackId = findClipById(value.tracks, clipId)?.trackId ?? null;
|
|
285
|
+
// Update anchor in place so an already-open menu retargets without flicker
|
|
286
|
+
ctxAnchor = { x: e.clientX, y: e.clientY };
|
|
287
|
+
ctxOpen = true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function openTrackMenu(trackId: string, e: MouseEvent) {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
ctxTrackId = trackId;
|
|
294
|
+
ctxAnchor = { x: e.clientX, y: e.clientY };
|
|
295
|
+
ctxOpen = true;
|
|
296
|
+
}
|
|
297
|
+
|
|
69
298
|
function addAssetAsClip(asset: MediaAsset) {
|
|
70
|
-
const trackKind = asset.kind === 'audio' ? 'audio' : 'video';
|
|
299
|
+
const trackKind: MediaTrackKind = asset.kind === 'audio' ? 'audio' : 'video';
|
|
71
300
|
const track =
|
|
72
301
|
value.tracks.find((t) => t.kind === trackKind) ??
|
|
73
302
|
value.tracks.find((t) => t.kind === 'video') ??
|
|
@@ -83,7 +312,8 @@
|
|
|
83
312
|
src: asset.src,
|
|
84
313
|
startMs,
|
|
85
314
|
endMs: startMs + dur,
|
|
86
|
-
color: track.color ?? (trackKind === 'audio' ? '#10b981' : '#3b82f6')
|
|
315
|
+
color: track.color ?? (trackKind === 'audio' ? '#10b981' : '#3b82f6'),
|
|
316
|
+
text: asset.kind === 'text' ? asset.name : undefined
|
|
87
317
|
});
|
|
88
318
|
const assets = value.assets.some((a) => a.id === asset.id)
|
|
89
319
|
? value.assets
|
|
@@ -95,21 +325,467 @@
|
|
|
95
325
|
t.id === track.id ? { ...t, clips: [...t.clips, clip] } : t
|
|
96
326
|
)
|
|
97
327
|
});
|
|
98
|
-
|
|
328
|
+
selectedClipIds = [clip.id];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function addComponent(def: VideoComponentDef, at?: { trackId?: string; startMs?: number }) {
|
|
332
|
+
const kind: MediaAssetKind =
|
|
333
|
+
def.kind === 'solid' ? 'image' : def.kind === 'image' ? 'image' : 'text';
|
|
334
|
+
const startMs = at?.startMs ?? currentTimeMs;
|
|
335
|
+
const frame = { width: value.width, height: value.height };
|
|
336
|
+
const rect = defaultOverlayRect(frame, def.rectPreset ?? (kind === 'text' ? 'center' : 'full'));
|
|
337
|
+
|
|
338
|
+
if (at?.trackId) {
|
|
339
|
+
const track = value.tracks.find((t) => t.id === at.trackId);
|
|
340
|
+
if (!track || track.kind === 'audio') {
|
|
341
|
+
// Fall through to new track
|
|
342
|
+
} else {
|
|
343
|
+
const clip = createMediaClip({
|
|
344
|
+
trackId: track.id,
|
|
345
|
+
name: def.label,
|
|
346
|
+
kind,
|
|
347
|
+
text: def.kind === 'text' ? def.text : undefined,
|
|
348
|
+
src: def.src,
|
|
349
|
+
startMs,
|
|
350
|
+
endMs: startMs + def.durationMs,
|
|
351
|
+
color: def.color ?? '#6366f1',
|
|
352
|
+
textColor: def.textColor,
|
|
353
|
+
fontSize: def.fontSize,
|
|
354
|
+
fontWeight: def.fontWeight,
|
|
355
|
+
fontFamily: def.fontFamily,
|
|
356
|
+
textAlign: def.textAlign,
|
|
357
|
+
opacity: 1,
|
|
358
|
+
previewOpacity: def.previewOpacity ?? def.opacity,
|
|
359
|
+
rect
|
|
360
|
+
});
|
|
361
|
+
setTracks(
|
|
362
|
+
value.tracks.map((t) =>
|
|
363
|
+
t.id === track.id ? { ...t, clips: [...t.clips, clip] } : t
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
selectedClipIds = [clip.id];
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const trackKind: MediaTrackKind = 'video';
|
|
372
|
+
const track = createMediaTrack({
|
|
373
|
+
name: nextTrackName(value.tracks, trackKind),
|
|
374
|
+
kind: trackKind,
|
|
375
|
+
color: def.color ?? '#6366f1'
|
|
376
|
+
});
|
|
377
|
+
const clip = createMediaClip({
|
|
378
|
+
trackId: track.id,
|
|
379
|
+
name: def.label,
|
|
380
|
+
kind,
|
|
381
|
+
text: def.kind === 'text' ? def.text : undefined,
|
|
382
|
+
src: def.src,
|
|
383
|
+
startMs,
|
|
384
|
+
endMs: startMs + def.durationMs,
|
|
385
|
+
color: def.color ?? '#6366f1',
|
|
386
|
+
textColor: def.textColor,
|
|
387
|
+
fontSize: def.fontSize,
|
|
388
|
+
fontWeight: def.fontWeight,
|
|
389
|
+
fontFamily: def.fontFamily,
|
|
390
|
+
textAlign: def.textAlign,
|
|
391
|
+
opacity: 1,
|
|
392
|
+
previewOpacity: def.previewOpacity ?? def.opacity,
|
|
393
|
+
rect
|
|
394
|
+
});
|
|
395
|
+
const audioIdx = value.tracks.findIndex((t) => t.kind === 'audio');
|
|
396
|
+
const next = [...value.tracks];
|
|
397
|
+
const insertAt = audioIdx >= 0 ? audioIdx : next.length;
|
|
398
|
+
next.splice(insertAt, 0, { ...track, clips: [clip] });
|
|
399
|
+
setTracks(next);
|
|
400
|
+
selectedClipIds = [clip.id];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function addEmptyClip(trackId?: string | null) {
|
|
404
|
+
const track =
|
|
405
|
+
(trackId && value.tracks.find((t) => t.id === trackId)) ||
|
|
406
|
+
(selectedClip && value.tracks.find((t) => t.id === selectedClip.trackId)) ||
|
|
407
|
+
value.tracks.find((t) => t.kind === 'video') ||
|
|
408
|
+
value.tracks[0];
|
|
409
|
+
if (!track) return;
|
|
410
|
+
const startMs = currentTimeMs;
|
|
411
|
+
const kind: MediaAssetKind = track.kind === 'audio' ? 'audio' : 'video';
|
|
412
|
+
const clip = createMediaClip({
|
|
413
|
+
trackId: track.id,
|
|
414
|
+
name: 'New clip',
|
|
415
|
+
kind,
|
|
416
|
+
startMs,
|
|
417
|
+
endMs: startMs + 3000,
|
|
418
|
+
color: track.color ?? (kind === 'audio' ? '#10b981' : '#3b82f6')
|
|
419
|
+
});
|
|
420
|
+
setTracks(
|
|
421
|
+
value.tracks.map((t) =>
|
|
422
|
+
t.id === track.id ? { ...t, clips: [...t.clips, clip] } : t
|
|
423
|
+
)
|
|
424
|
+
);
|
|
425
|
+
selectedClipIds = [clip.id];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function addTrack(kind: MediaTrackKind) {
|
|
429
|
+
const name = nextTrackName(value.tracks, kind);
|
|
430
|
+
const track = createMediaTrack({
|
|
431
|
+
name,
|
|
432
|
+
kind,
|
|
433
|
+
color: kind === 'audio' ? '#10b981' : '#3b82f6'
|
|
434
|
+
});
|
|
435
|
+
setTracks([...value.tracks, track]);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function deleteTrack(trackId: string) {
|
|
439
|
+
if (value.tracks.length <= 1) return;
|
|
440
|
+
const track = value.tracks.find((t) => t.id === trackId);
|
|
441
|
+
if (!track) return;
|
|
442
|
+
const removedClipIds = new Set(track.clips.map((c) => c.id));
|
|
443
|
+
setTracks(removeTrack(value.tracks, trackId));
|
|
444
|
+
selectedClipIds = selectedClipIds.filter((id) => !removedClipIds.has(id));
|
|
445
|
+
if (ctxTrackId === trackId) ctxTrackId = null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function reorderTrackIds(orderedIds: string[]) {
|
|
449
|
+
setTracks(reorderTracks(value.tracks, orderedIds));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function applyAlign(align: MediaClipAlign) {
|
|
453
|
+
if (!selectedClipIds.length) return;
|
|
454
|
+
const frame = { width: value.width, height: value.height };
|
|
455
|
+
let tracks = value.tracks;
|
|
456
|
+
for (const id of selectedClipIds) {
|
|
457
|
+
tracks = updateClipInTracks(tracks, id, (c) => {
|
|
458
|
+
if (c.kind === 'audio' || c.kind === 'video') {
|
|
459
|
+
// Allow transform on overlays / text / solids; skip pure A/V full-frame
|
|
460
|
+
if (!c.rect && c.kind === 'video') return c;
|
|
461
|
+
if (!c.rect && c.kind === 'audio') return c;
|
|
462
|
+
}
|
|
463
|
+
return alignClipRect(c, frame, align);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
setTracks(tracks);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function patchSelectedRect(partial: Partial<{ x: number; y: number; w: number; h: number }>) {
|
|
470
|
+
if (!selectedClip || !selectedRect) return;
|
|
471
|
+
patchClip({
|
|
472
|
+
...selectedClip,
|
|
473
|
+
rect: {
|
|
474
|
+
x: partial.x ?? selectedRect.x,
|
|
475
|
+
y: partial.y ?? selectedRect.y,
|
|
476
|
+
w: Math.max(1, partial.w ?? selectedRect.w),
|
|
477
|
+
h: Math.max(1, partial.h ?? selectedRect.h)
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function clipFieldModified(field: MediaClipResettableField): boolean {
|
|
483
|
+
return (
|
|
484
|
+
!!selectedClip &&
|
|
485
|
+
isMediaClipFieldModified(selectedClip, field, {
|
|
486
|
+
width: value.width,
|
|
487
|
+
height: value.height
|
|
488
|
+
})
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function resetClipField(field: MediaClipResettableField) {
|
|
493
|
+
if (!selectedClip) return;
|
|
494
|
+
patchClip(
|
|
495
|
+
resetMediaClipField(selectedClip, field, {
|
|
496
|
+
width: value.width,
|
|
497
|
+
height: value.height
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function acceptForClipKind(kind: MediaAssetKind): string {
|
|
503
|
+
if (kind === 'audio') return 'audio/*';
|
|
504
|
+
if (kind === 'image') return 'image/*';
|
|
505
|
+
if (kind === 'video') return 'video/*';
|
|
506
|
+
return 'image/*,video/*,audio/*';
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function replaceSelectedMedia(files: File[]) {
|
|
510
|
+
const file = files[0];
|
|
511
|
+
if (!file || !selectedClip) return;
|
|
512
|
+
const kind = kindFromFile(file);
|
|
513
|
+
if (kind === 'text') return;
|
|
514
|
+
// Keep clip kind aligned with the track role when possible
|
|
515
|
+
const nextKind: MediaAssetKind =
|
|
516
|
+
selectedClip.kind === 'audio'
|
|
517
|
+
? 'audio'
|
|
518
|
+
: kind === 'audio'
|
|
519
|
+
? selectedClip.kind
|
|
520
|
+
: kind;
|
|
521
|
+
if (selectedClip.kind === 'audio' && kind !== 'audio') return;
|
|
522
|
+
if (selectedClip.kind !== 'audio' && kind === 'audio') return;
|
|
523
|
+
|
|
524
|
+
const src = URL.createObjectURL(file);
|
|
525
|
+
const meta = await probeMediaSrc(src, nextKind);
|
|
526
|
+
const asset = createMediaAsset({
|
|
527
|
+
kind: nextKind,
|
|
528
|
+
name: file.name,
|
|
529
|
+
src,
|
|
530
|
+
...meta
|
|
531
|
+
});
|
|
532
|
+
const dur = meta.durationMs;
|
|
533
|
+
const startMs = selectedClip.startMs;
|
|
534
|
+
const endMs =
|
|
535
|
+
dur != null
|
|
536
|
+
? startMs + dur
|
|
537
|
+
: selectedClip.endMs > startMs
|
|
538
|
+
? selectedClip.endMs
|
|
539
|
+
: startMs + 5000;
|
|
540
|
+
|
|
541
|
+
emit({
|
|
542
|
+
...value,
|
|
543
|
+
assets: value.assets.some((a) => a.id === asset.id)
|
|
544
|
+
? value.assets
|
|
545
|
+
: [...value.assets, asset],
|
|
546
|
+
tracks: updateClipInTracks(value.tracks, selectedClip.id, (c) => ({
|
|
547
|
+
...c,
|
|
548
|
+
assetId: asset.id,
|
|
549
|
+
name: file.name,
|
|
550
|
+
kind: nextKind,
|
|
551
|
+
src,
|
|
552
|
+
endMs
|
|
553
|
+
}))
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function clearSelectedMedia() {
|
|
558
|
+
if (!selectedClip) return;
|
|
559
|
+
patchClip({
|
|
560
|
+
...selectedClip,
|
|
561
|
+
src: undefined,
|
|
562
|
+
assetId: undefined
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function applyAnimation(def: VideoAnimationDef, clipIds = selectedClipIds) {
|
|
567
|
+
if (!clipIds.length) return;
|
|
568
|
+
let tracks = value.tracks;
|
|
569
|
+
for (const id of clipIds) {
|
|
570
|
+
tracks = updateClipInTracks(tracks, id, (c) => {
|
|
571
|
+
if (def.slot === 'in') {
|
|
572
|
+
return { ...c, animationIn: { type: def.type, durationMs: def.durationMs } };
|
|
573
|
+
}
|
|
574
|
+
if (def.slot === 'out') {
|
|
575
|
+
return { ...c, animationOut: { type: def.type, durationMs: def.durationMs } };
|
|
576
|
+
}
|
|
577
|
+
// combo
|
|
578
|
+
return {
|
|
579
|
+
...c,
|
|
580
|
+
animationIn: { type: def.type, durationMs: def.durationMs },
|
|
581
|
+
animationOut: {
|
|
582
|
+
type: def.outType ?? def.type,
|
|
583
|
+
durationMs: def.durationMs
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
setTracks(tracks);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function applyTransition(def: VideoTransitionDef, clipIds = selectedClipIds) {
|
|
592
|
+
if (!clipIds.length) return;
|
|
593
|
+
let tracks = value.tracks;
|
|
594
|
+
for (const id of clipIds) {
|
|
595
|
+
tracks = updateClipInTracks(tracks, id, (c) => ({
|
|
596
|
+
...c,
|
|
597
|
+
transitionOut: { type: def.type, durationMs: def.durationMs }
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
setTracks(tracks);
|
|
601
|
+
// Preview: play through the transition window so the effect is obvious
|
|
602
|
+
const primary = findClipById(tracks, clipIds[clipIds.length - 1] ?? '');
|
|
603
|
+
if (primary?.transitionOut) {
|
|
604
|
+
const dur = Math.min(
|
|
605
|
+
primary.transitionOut.durationMs,
|
|
606
|
+
Math.max(120, primary.endMs - primary.startMs)
|
|
607
|
+
);
|
|
608
|
+
const start = clampMs(primary.endMs - dur, 0, value.durationMs);
|
|
609
|
+
currentTimeMs = start;
|
|
610
|
+
playing = true;
|
|
611
|
+
window.clearTimeout(transitionPreviewTimer);
|
|
612
|
+
transitionPreviewTimer = window.setTimeout(
|
|
613
|
+
() => {
|
|
614
|
+
playing = false;
|
|
615
|
+
currentTimeMs = clampMs(primary.endMs - 1, 0, value.durationMs);
|
|
616
|
+
},
|
|
617
|
+
Math.round(dur / Math.max(0.25, playbackRate)) + 120
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function handleClipDrop(clipId: string, e: DragEvent) {
|
|
623
|
+
const animRaw = e.dataTransfer?.getData(VIDEO_ANIMATION_MIME);
|
|
624
|
+
if (animRaw) {
|
|
625
|
+
try {
|
|
626
|
+
applyAnimation(JSON.parse(animRaw) as VideoAnimationDef, [clipId]);
|
|
627
|
+
selectedClipIds = [clipId];
|
|
628
|
+
} catch {
|
|
629
|
+
/* ignore */
|
|
630
|
+
}
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const transRaw = e.dataTransfer?.getData(VIDEO_TRANSITION_MIME);
|
|
634
|
+
if (transRaw) {
|
|
635
|
+
try {
|
|
636
|
+
applyTransition(JSON.parse(transRaw) as VideoTransitionDef, [clipId]);
|
|
637
|
+
selectedClipIds = [clipId];
|
|
638
|
+
} catch {
|
|
639
|
+
/* ignore */
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const compRaw = e.dataTransfer?.getData(VIDEO_COMPONENT_MIME);
|
|
644
|
+
if (compRaw) {
|
|
645
|
+
try {
|
|
646
|
+
const def = JSON.parse(compRaw) as VideoComponentDef;
|
|
647
|
+
const clip = findClipById(value.tracks, clipId);
|
|
648
|
+
addComponent(def, { trackId: clip?.trackId, startMs: clip?.endMs ?? currentTimeMs });
|
|
649
|
+
} catch {
|
|
650
|
+
/* ignore */
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function handleTrackDrop(trackId: string, e: DragEvent, timeMs: number) {
|
|
656
|
+
const animRaw = e.dataTransfer?.getData(VIDEO_ANIMATION_MIME);
|
|
657
|
+
if (animRaw) {
|
|
658
|
+
const track = value.tracks.find((t) => t.id === trackId);
|
|
659
|
+
const hit = track?.clips.find((c) => timeMs >= c.startMs && timeMs < c.endMs);
|
|
660
|
+
if (hit) {
|
|
661
|
+
try {
|
|
662
|
+
applyAnimation(JSON.parse(animRaw) as VideoAnimationDef, [hit.id]);
|
|
663
|
+
selectedClipIds = [hit.id];
|
|
664
|
+
} catch {
|
|
665
|
+
/* ignore */
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const transRaw = e.dataTransfer?.getData(VIDEO_TRANSITION_MIME);
|
|
671
|
+
if (transRaw) {
|
|
672
|
+
const track = value.tracks.find((t) => t.id === trackId);
|
|
673
|
+
const hit = track?.clips.find((c) => timeMs >= c.startMs && timeMs < c.endMs);
|
|
674
|
+
if (hit) {
|
|
675
|
+
try {
|
|
676
|
+
applyTransition(JSON.parse(transRaw) as VideoTransitionDef, [hit.id]);
|
|
677
|
+
selectedClipIds = [hit.id];
|
|
678
|
+
} catch {
|
|
679
|
+
/* ignore */
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const compRaw = e.dataTransfer?.getData(VIDEO_COMPONENT_MIME);
|
|
685
|
+
if (!compRaw) return;
|
|
686
|
+
try {
|
|
687
|
+
const def = JSON.parse(compRaw) as VideoComponentDef;
|
|
688
|
+
addComponent(def, { trackId, startMs: timeMs });
|
|
689
|
+
} catch {
|
|
690
|
+
/* ignore */
|
|
691
|
+
}
|
|
99
692
|
}
|
|
100
693
|
|
|
101
694
|
function patchClip(clip: MediaClip) {
|
|
102
695
|
setTracks(updateClipInTracks(value.tracks, clip.id, () => clip));
|
|
103
696
|
}
|
|
104
697
|
|
|
698
|
+
function patchClips(clips: MediaClip[]) {
|
|
699
|
+
if (!clips.length) return;
|
|
700
|
+
let tracks = value.tracks;
|
|
701
|
+
for (const clip of clips) {
|
|
702
|
+
tracks = updateClipInTracks(tracks, clip.id, () => clip);
|
|
703
|
+
}
|
|
704
|
+
setTracks(tracks);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function moveSelectedClips(clipIds: string[], deltaMs: number) {
|
|
708
|
+
setTracks(moveClipsByDelta(value.tracks, clipIds, deltaMs));
|
|
709
|
+
}
|
|
710
|
+
|
|
105
711
|
function patchTrack(track: MediaTrack) {
|
|
106
712
|
setTracks(value.tracks.map((t) => (t.id === track.id ? track : t)));
|
|
107
713
|
}
|
|
108
714
|
|
|
715
|
+
function splitSelected() {
|
|
716
|
+
if (!selectedClipIds.length) return;
|
|
717
|
+
setTracks(splitClipsAt(value.tracks, selectedClipIds, currentTimeMs));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function mergeSelected() {
|
|
721
|
+
const idsBefore = new Set(value.tracks.flatMap((t) => t.clips.map((c) => c.id)));
|
|
722
|
+
const next = mergeClipsInTracks(value.tracks, selectedClipIds);
|
|
723
|
+
if (!next) return;
|
|
724
|
+
setTracks(next);
|
|
725
|
+
const created = next.flatMap((t) => t.clips).find((c) => !idsBefore.has(c.id));
|
|
726
|
+
selectedClipIds = created ? [created.id] : [];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function duplicateSelected() {
|
|
730
|
+
let tracks = value.tracks;
|
|
731
|
+
const created: string[] = [];
|
|
732
|
+
for (const id of selectedClipIds) {
|
|
733
|
+
const before = new Set(tracks.flatMap((t) => t.clips.map((c) => c.id)));
|
|
734
|
+
tracks = duplicateClipInTracks(tracks, id);
|
|
735
|
+
const neu = tracks.flatMap((t) => t.clips).find((c) => !before.has(c.id));
|
|
736
|
+
if (neu) created.push(neu.id);
|
|
737
|
+
}
|
|
738
|
+
setTracks(tracks);
|
|
739
|
+
if (created.length) selectedClipIds = created;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function deleteSelected() {
|
|
743
|
+
if (!selectedClipIds.length) return;
|
|
744
|
+
setTracks(removeClipsFromTracks(value.tracks, selectedClipIds));
|
|
745
|
+
selectedClipIds = [];
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function handleCtx(id: string) {
|
|
749
|
+
if (id === 'split') splitSelected();
|
|
750
|
+
else if (id === 'merge') mergeSelected();
|
|
751
|
+
else if (id === 'duplicate') duplicateSelected();
|
|
752
|
+
else if (id === 'delete') deleteSelected();
|
|
753
|
+
else if (id === 'add-clip') addEmptyClip(ctxTrackId);
|
|
754
|
+
else if (id === 'add-video-track') addTrack('video');
|
|
755
|
+
else if (id === 'add-audio-track') addTrack('audio');
|
|
756
|
+
else if (id === 'delete-track' && ctxTrackId) deleteTrack(ctxTrackId);
|
|
757
|
+
ctxOpen = false;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function secValue(ms: number) {
|
|
761
|
+
const s = Math.round(ms / 100) / 10; // 1 decimal second
|
|
762
|
+
return Number.isInteger(s) ? String(s) : s.toFixed(1);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function msToSec(ms: number) {
|
|
766
|
+
return Math.round(ms / 100) / 10;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function secToMs(s: number) {
|
|
770
|
+
return Math.max(0, s) * 1000;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function clearSelection() {
|
|
774
|
+
selectedClipIds = [];
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function seekBy(deltaMs: number) {
|
|
778
|
+
currentTimeMs = clampMs(currentTimeMs + deltaMs, 0, value.durationMs);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function seekTo(ms: number) {
|
|
782
|
+
currentTimeMs = clampMs(ms, 0, value.durationMs);
|
|
783
|
+
}
|
|
784
|
+
|
|
109
785
|
function tick(ts: number) {
|
|
110
786
|
if (!playing) return;
|
|
111
787
|
if (lastTs) {
|
|
112
|
-
const delta = ts - lastTs;
|
|
788
|
+
const delta = (ts - lastTs) * playbackRate;
|
|
113
789
|
currentTimeMs = clampMs(currentTimeMs + delta, 0, value.durationMs);
|
|
114
790
|
if (currentTimeMs >= value.durationMs) {
|
|
115
791
|
playing = false;
|
|
@@ -137,10 +813,49 @@
|
|
|
137
813
|
playing = !playing;
|
|
138
814
|
return;
|
|
139
815
|
}
|
|
140
|
-
if (
|
|
816
|
+
if (e.key === 'Escape') {
|
|
141
817
|
e.preventDefault();
|
|
142
|
-
|
|
143
|
-
|
|
818
|
+
clearSelection();
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (e.key === 'ArrowLeft') {
|
|
822
|
+
e.preventDefault();
|
|
823
|
+
seekBy(e.shiftKey ? -SKIP_MS : -(1000 / Math.max(1, value.fps)));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (e.key === 'ArrowRight') {
|
|
827
|
+
e.preventDefault();
|
|
828
|
+
seekBy(e.shiftKey ? SKIP_MS : 1000 / Math.max(1, value.fps));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (e.key === 'Home') {
|
|
832
|
+
e.preventDefault();
|
|
833
|
+
seekTo(0);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (e.key === 'End') {
|
|
837
|
+
e.preventDefault();
|
|
838
|
+
seekTo(value.durationMs);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (e.key === 'm' || e.key === 'M') {
|
|
842
|
+
e.preventDefault();
|
|
843
|
+
muted = !muted;
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if ((e.key === 's' || e.key === 'S') && canSplit) {
|
|
847
|
+
e.preventDefault();
|
|
848
|
+
splitSelected();
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if ((e.key === 'd' || e.key === 'D') && (e.metaKey || e.ctrlKey) && selectedClipIds.length) {
|
|
852
|
+
e.preventDefault();
|
|
853
|
+
duplicateSelected();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedClipIds.length) {
|
|
857
|
+
e.preventDefault();
|
|
858
|
+
deleteSelected();
|
|
144
859
|
}
|
|
145
860
|
}
|
|
146
861
|
|
|
@@ -154,12 +869,18 @@
|
|
|
154
869
|
a.click();
|
|
155
870
|
URL.revokeObjectURL(url);
|
|
156
871
|
}
|
|
872
|
+
|
|
873
|
+
const tabBtn = (active: boolean) =>
|
|
874
|
+
[
|
|
875
|
+
'flex-1 rounded-md px-1.5 py-1.5 text-[10px] font-medium transition-colors',
|
|
876
|
+
active ? 'bg-brand-500 text-white' : 'text-secondary hover:bg-surface-overlay'
|
|
877
|
+
].join(' ');
|
|
157
878
|
</script>
|
|
158
879
|
|
|
159
880
|
<svelte:window onkeydown={handleKeydown} />
|
|
160
881
|
|
|
161
|
-
<div class={['flex h-full min-h-
|
|
162
|
-
<div class="flex items-center gap-2 border-b border-border bg-surface-elevated px-3 py-2">
|
|
882
|
+
<div class={['relative flex h-full min-h-0 flex-col', className]}>
|
|
883
|
+
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-surface-elevated px-3 py-2">
|
|
163
884
|
<span class="text-sm font-semibold text-primary">Video Editor</span>
|
|
164
885
|
<div class="ml-auto flex items-center gap-1">
|
|
165
886
|
<IconButton label="Toggle sidebar" size="sm" onclick={() => (showSidebar = !showSidebar)}>
|
|
@@ -181,10 +902,14 @@
|
|
|
181
902
|
</div>
|
|
182
903
|
</div>
|
|
183
904
|
|
|
184
|
-
<div class="min-h-0 flex-1">
|
|
905
|
+
<div class="min-h-0 flex-1 overflow-hidden">
|
|
185
906
|
<EditorShell
|
|
186
|
-
sidebarTitle="
|
|
187
|
-
inspectorTitle=
|
|
907
|
+
sidebarTitle="Library"
|
|
908
|
+
inspectorTitle={selectedClip
|
|
909
|
+
? selectedClipIds.length > 1
|
|
910
|
+
? `${selectedClipIds.length} clips`
|
|
911
|
+
: 'Clip'
|
|
912
|
+
: 'Sequence'}
|
|
188
913
|
workspaceTitle="Program"
|
|
189
914
|
bottomTitle="Timeline"
|
|
190
915
|
showBottom={true}
|
|
@@ -195,47 +920,103 @@
|
|
|
195
920
|
wrapWorkspace={false}
|
|
196
921
|
wrapBottom={false}
|
|
197
922
|
sidebarSize={280}
|
|
198
|
-
inspectorSize={
|
|
199
|
-
bottomSize={
|
|
923
|
+
inspectorSize={280}
|
|
924
|
+
bottomSize={300}
|
|
925
|
+
sidebarMin={180}
|
|
926
|
+
sidebarMax={420}
|
|
927
|
+
inspectorMin={200}
|
|
928
|
+
inspectorMax={420}
|
|
929
|
+
bottomMin={180}
|
|
930
|
+
bottomMax={560}
|
|
200
931
|
sizeUnit="px"
|
|
201
932
|
fullHeight
|
|
933
|
+
class="h-full"
|
|
202
934
|
>
|
|
203
935
|
{#snippet sidebar()}
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
936
|
+
<div class="flex h-full min-h-0 flex-col">
|
|
937
|
+
<div class="flex shrink-0 gap-1 border-b border-border p-2">
|
|
938
|
+
<button
|
|
939
|
+
type="button"
|
|
940
|
+
class={tabBtn(sidebarTab === 'components')}
|
|
941
|
+
onclick={() => (sidebarTab = 'components')}
|
|
942
|
+
>
|
|
943
|
+
Comp
|
|
944
|
+
</button>
|
|
945
|
+
<button
|
|
946
|
+
type="button"
|
|
947
|
+
class={tabBtn(sidebarTab === 'animations')}
|
|
948
|
+
onclick={() => (sidebarTab = 'animations')}
|
|
949
|
+
>
|
|
950
|
+
Anim
|
|
951
|
+
</button>
|
|
952
|
+
<button
|
|
953
|
+
type="button"
|
|
954
|
+
class={tabBtn(sidebarTab === 'transitions')}
|
|
955
|
+
onclick={() => (sidebarTab = 'transitions')}
|
|
956
|
+
>
|
|
957
|
+
Trans
|
|
958
|
+
</button>
|
|
959
|
+
<button
|
|
960
|
+
type="button"
|
|
961
|
+
class={tabBtn(sidebarTab === 'assets')}
|
|
962
|
+
onclick={() => (sidebarTab = 'assets')}
|
|
963
|
+
>
|
|
964
|
+
Assets
|
|
965
|
+
</button>
|
|
966
|
+
</div>
|
|
967
|
+
<div class="min-h-0 flex-1 overflow-auto">
|
|
968
|
+
{#if sidebarTab === 'components'}
|
|
969
|
+
<VideoComponentsPanel onadd={addComponent} />
|
|
970
|
+
{:else if sidebarTab === 'animations'}
|
|
971
|
+
<VideoAnimationsPanel
|
|
972
|
+
disabled={!selectedClip}
|
|
973
|
+
onapply={applyAnimation}
|
|
974
|
+
/>
|
|
975
|
+
{:else if sidebarTab === 'transitions'}
|
|
976
|
+
<VideoTransitionsPanel
|
|
977
|
+
disabled={!selectedClip}
|
|
978
|
+
onapply={applyTransition}
|
|
979
|
+
/>
|
|
980
|
+
{:else}
|
|
981
|
+
<MediaAssetBrowser
|
|
982
|
+
assets={value.assets}
|
|
983
|
+
accept="image/*,video/*,audio/*"
|
|
984
|
+
helperText="Image, video or audio"
|
|
985
|
+
uploadLayout="vertical"
|
|
986
|
+
onassetschange={(assets) => emit({ ...value, assets })}
|
|
987
|
+
onselect={(id) => {
|
|
988
|
+
const asset = value.assets.find((a) => a.id === id);
|
|
989
|
+
if (asset) addAssetAsClip(asset);
|
|
990
|
+
}}
|
|
991
|
+
onadd={(asset) => {
|
|
992
|
+
emit({ ...value, assets: [...value.assets, asset] });
|
|
993
|
+
}}
|
|
994
|
+
/>
|
|
995
|
+
{/if}
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
217
998
|
{/snippet}
|
|
218
999
|
|
|
219
1000
|
{#snippet workspace()}
|
|
220
|
-
<div class="flex h-full flex-col
|
|
221
|
-
<TransportControls
|
|
222
|
-
{playing}
|
|
223
|
-
{currentTimeMs}
|
|
224
|
-
durationMs={value.durationMs}
|
|
225
|
-
onplay={() => (playing = true)}
|
|
226
|
-
onpause={() => (playing = false)}
|
|
227
|
-
onstop={() => {
|
|
228
|
-
playing = false;
|
|
229
|
-
currentTimeMs = 0;
|
|
230
|
-
}}
|
|
231
|
-
/>
|
|
1001
|
+
<div class="flex h-full min-h-0 flex-col bg-surface p-3">
|
|
232
1002
|
<div class="min-h-0 flex-1">
|
|
233
1003
|
<ProgramMonitor
|
|
234
1004
|
width={value.width}
|
|
235
1005
|
height={value.height}
|
|
236
1006
|
background={value.background}
|
|
237
1007
|
{currentTimeMs}
|
|
1008
|
+
{playing}
|
|
1009
|
+
{playbackRate}
|
|
1010
|
+
{muted}
|
|
1011
|
+
{volume}
|
|
238
1012
|
tracks={value.tracks}
|
|
1013
|
+
{selectedClipIds}
|
|
1014
|
+
editable={!playing}
|
|
1015
|
+
onselectclip={selectClip}
|
|
1016
|
+
onchangeclip={patchClip}
|
|
1017
|
+
onchangeclips={patchClips}
|
|
1018
|
+
onselectionchange={setSelection}
|
|
1019
|
+
onemptyclick={clearSelection}
|
|
239
1020
|
class="mx-auto max-h-full"
|
|
240
1021
|
/>
|
|
241
1022
|
</div>
|
|
@@ -244,7 +1025,51 @@
|
|
|
244
1025
|
|
|
245
1026
|
{#snippet inspector()}
|
|
246
1027
|
{#if !selectedClip}
|
|
247
|
-
<
|
|
1028
|
+
<PropertyGroup title="Sequence">
|
|
1029
|
+
<PropertyField label="Width">
|
|
1030
|
+
<NumberInput
|
|
1031
|
+
size="sm"
|
|
1032
|
+
min={1}
|
|
1033
|
+
value={value.width}
|
|
1034
|
+
onchange={(v) => patchSequence({ width: v || value.width })}
|
|
1035
|
+
/>
|
|
1036
|
+
</PropertyField>
|
|
1037
|
+
<PropertyField label="Height">
|
|
1038
|
+
<NumberInput
|
|
1039
|
+
size="sm"
|
|
1040
|
+
min={1}
|
|
1041
|
+
value={value.height}
|
|
1042
|
+
onchange={(v) => patchSequence({ height: v || value.height })}
|
|
1043
|
+
/>
|
|
1044
|
+
</PropertyField>
|
|
1045
|
+
<PropertyField label="FPS">
|
|
1046
|
+
<NumberInput
|
|
1047
|
+
size="sm"
|
|
1048
|
+
min={1}
|
|
1049
|
+
value={value.fps}
|
|
1050
|
+
onchange={(v) => patchSequence({ fps: v || value.fps })}
|
|
1051
|
+
/>
|
|
1052
|
+
</PropertyField>
|
|
1053
|
+
<PropertyField label="Duration (s)">
|
|
1054
|
+
<NumberInput
|
|
1055
|
+
size="sm"
|
|
1056
|
+
step={0.1}
|
|
1057
|
+
min={0.1}
|
|
1058
|
+
value={msToSec(value.durationMs)}
|
|
1059
|
+
onchange={(v) =>
|
|
1060
|
+
patchSequence({ durationMs: secToMs(v) || value.durationMs })}
|
|
1061
|
+
/>
|
|
1062
|
+
</PropertyField>
|
|
1063
|
+
<PropertyField label="Background">
|
|
1064
|
+
<ColorPicker
|
|
1065
|
+
size="sm"
|
|
1066
|
+
value={value.background}
|
|
1067
|
+
showSwatches={false}
|
|
1068
|
+
onchange={(v) => patchSequence({ background: v })}
|
|
1069
|
+
/>
|
|
1070
|
+
</PropertyField>
|
|
1071
|
+
</PropertyGroup>
|
|
1072
|
+
<p class="px-3 py-4 text-center text-xs text-muted">Select a clip to edit</p>
|
|
248
1073
|
{:else}
|
|
249
1074
|
<PropertyGroup title="Clip">
|
|
250
1075
|
<PropertyField label="Name">
|
|
@@ -258,51 +1083,605 @@
|
|
|
258
1083
|
})}
|
|
259
1084
|
/>
|
|
260
1085
|
</PropertyField>
|
|
261
|
-
<PropertyField label="
|
|
262
|
-
<
|
|
263
|
-
|
|
1086
|
+
<PropertyField label="Kind">
|
|
1087
|
+
<span class="text-xs capitalize text-secondary">{selectedClip.kind}</span>
|
|
1088
|
+
</PropertyField>
|
|
1089
|
+
<PropertyField label="Start (s)">
|
|
1090
|
+
<NumberInput
|
|
264
1091
|
size="sm"
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
1092
|
+
step={0.1}
|
|
1093
|
+
min={0}
|
|
1094
|
+
value={msToSec(selectedClip.startMs)}
|
|
1095
|
+
onchange={(v) => {
|
|
1096
|
+
const startMs = secToMs(v);
|
|
268
1097
|
const dur = selectedClip.endMs - selectedClip.startMs;
|
|
269
1098
|
patchClip({ ...selectedClip, startMs, endMs: startMs + dur });
|
|
270
1099
|
}}
|
|
271
1100
|
/>
|
|
272
1101
|
</PropertyField>
|
|
273
|
-
<PropertyField label="End">
|
|
274
|
-
<
|
|
275
|
-
type="number"
|
|
1102
|
+
<PropertyField label="End (s)">
|
|
1103
|
+
<NumberInput
|
|
276
1104
|
size="sm"
|
|
277
|
-
|
|
278
|
-
|
|
1105
|
+
step={0.1}
|
|
1106
|
+
min={0}
|
|
1107
|
+
value={msToSec(selectedClip.endMs)}
|
|
1108
|
+
onchange={(v) => patchClip({ ...selectedClip, endMs: secToMs(v) })}
|
|
1109
|
+
/>
|
|
1110
|
+
</PropertyField>
|
|
1111
|
+
<PropertyField label="Duration (s)">
|
|
1112
|
+
<NumberInput
|
|
1113
|
+
size="sm"
|
|
1114
|
+
step={0.1}
|
|
1115
|
+
min={0.1}
|
|
1116
|
+
value={msToSec(selectedClip.endMs - selectedClip.startMs)}
|
|
1117
|
+
onchange={(v) =>
|
|
279
1118
|
patchClip({
|
|
280
1119
|
...selectedClip,
|
|
281
|
-
endMs:
|
|
1120
|
+
endMs: selectedClip.startMs + Math.max(50, secToMs(v))
|
|
282
1121
|
})}
|
|
283
1122
|
/>
|
|
284
1123
|
</PropertyField>
|
|
1124
|
+
<PropertyField
|
|
1125
|
+
label="Clip opacity"
|
|
1126
|
+
modified={clipFieldModified('opacity')}
|
|
1127
|
+
onreset={() => resetClipField('opacity')}
|
|
1128
|
+
>
|
|
1129
|
+
<Slider
|
|
1130
|
+
size="sm"
|
|
1131
|
+
min={0}
|
|
1132
|
+
max={1}
|
|
1133
|
+
step={0.01}
|
|
1134
|
+
value={selectedClip.opacity ?? 1}
|
|
1135
|
+
showValue
|
|
1136
|
+
valuePosition="header"
|
|
1137
|
+
oninput={(v) => patchClip({ ...selectedClip, opacity: v })}
|
|
1138
|
+
/>
|
|
1139
|
+
</PropertyField>
|
|
1140
|
+
{#if selectedClip.kind !== 'audio'}
|
|
1141
|
+
<PropertyField
|
|
1142
|
+
label="Preview opacity"
|
|
1143
|
+
modified={clipFieldModified('previewOpacity')}
|
|
1144
|
+
onreset={() => resetClipField('previewOpacity')}
|
|
1145
|
+
>
|
|
1146
|
+
<Slider
|
|
1147
|
+
size="sm"
|
|
1148
|
+
min={0}
|
|
1149
|
+
max={1}
|
|
1150
|
+
step={0.01}
|
|
1151
|
+
value={selectedClip.previewOpacity ?? 1}
|
|
1152
|
+
showValue
|
|
1153
|
+
valuePosition="header"
|
|
1154
|
+
oninput={(v) => patchClip({ ...selectedClip, previewOpacity: v })}
|
|
1155
|
+
/>
|
|
1156
|
+
</PropertyField>
|
|
1157
|
+
{/if}
|
|
1158
|
+
<PropertyField
|
|
1159
|
+
label="Color"
|
|
1160
|
+
modified={clipFieldModified('color')}
|
|
1161
|
+
onreset={() => resetClipField('color')}
|
|
1162
|
+
>
|
|
1163
|
+
<ColorPicker
|
|
1164
|
+
size="sm"
|
|
1165
|
+
value={selectedClip.color ?? '#3b82f6'}
|
|
1166
|
+
showSwatches={false}
|
|
1167
|
+
onchange={(v) => patchClip({ ...selectedClip, color: v })}
|
|
1168
|
+
/>
|
|
1169
|
+
</PropertyField>
|
|
285
1170
|
</PropertyGroup>
|
|
1171
|
+
|
|
1172
|
+
{#if showTransform && selectedRect}
|
|
1173
|
+
<PropertyGroup title="Transform">
|
|
1174
|
+
<div class="mx-1 mb-1.5 flex w-full min-w-0 flex-col gap-1.5">
|
|
1175
|
+
<div
|
|
1176
|
+
class="flex w-full flex-nowrap items-center justify-between gap-0 overflow-x-auto rounded-xl border border-border bg-surface-elevated p-0.5 shadow-sm"
|
|
1177
|
+
role="toolbar"
|
|
1178
|
+
aria-label="Position"
|
|
1179
|
+
>
|
|
1180
|
+
{#each alignDock as action (action.id)}
|
|
1181
|
+
{@const Icon = action.icon}
|
|
1182
|
+
<Tooltip content={action.label} side="top" class="shrink-0">
|
|
1183
|
+
<IconButton
|
|
1184
|
+
label={action.label}
|
|
1185
|
+
size="xs"
|
|
1186
|
+
variant="ghost"
|
|
1187
|
+
onclick={() => applyAlign(action.id)}
|
|
1188
|
+
>
|
|
1189
|
+
<Icon class="h-3.5 w-3.5" />
|
|
1190
|
+
</IconButton>
|
|
1191
|
+
</Tooltip>
|
|
1192
|
+
{/each}
|
|
1193
|
+
</div>
|
|
1194
|
+
</div>
|
|
1195
|
+
<PropertyField
|
|
1196
|
+
label="X"
|
|
1197
|
+
modified={clipFieldModified('rect.x')}
|
|
1198
|
+
onreset={() => resetClipField('rect.x')}
|
|
1199
|
+
>
|
|
1200
|
+
<NumberInput
|
|
1201
|
+
size="sm"
|
|
1202
|
+
value={Math.round(selectedRect.x)}
|
|
1203
|
+
onchange={(v) => patchSelectedRect({ x: v })}
|
|
1204
|
+
/>
|
|
1205
|
+
</PropertyField>
|
|
1206
|
+
<PropertyField
|
|
1207
|
+
label="Y"
|
|
1208
|
+
modified={clipFieldModified('rect.y')}
|
|
1209
|
+
onreset={() => resetClipField('rect.y')}
|
|
1210
|
+
>
|
|
1211
|
+
<NumberInput
|
|
1212
|
+
size="sm"
|
|
1213
|
+
value={Math.round(selectedRect.y)}
|
|
1214
|
+
onchange={(v) => patchSelectedRect({ y: v })}
|
|
1215
|
+
/>
|
|
1216
|
+
</PropertyField>
|
|
1217
|
+
<PropertyField
|
|
1218
|
+
label="W"
|
|
1219
|
+
modified={clipFieldModified('rect.w')}
|
|
1220
|
+
onreset={() => resetClipField('rect.w')}
|
|
1221
|
+
>
|
|
1222
|
+
<NumberInput
|
|
1223
|
+
size="sm"
|
|
1224
|
+
min={1}
|
|
1225
|
+
value={Math.round(selectedRect.w)}
|
|
1226
|
+
onchange={(v) => patchSelectedRect({ w: v })}
|
|
1227
|
+
/>
|
|
1228
|
+
</PropertyField>
|
|
1229
|
+
<PropertyField
|
|
1230
|
+
label="H"
|
|
1231
|
+
modified={clipFieldModified('rect.h')}
|
|
1232
|
+
onreset={() => resetClipField('rect.h')}
|
|
1233
|
+
>
|
|
1234
|
+
<NumberInput
|
|
1235
|
+
size="sm"
|
|
1236
|
+
min={1}
|
|
1237
|
+
value={Math.round(selectedRect.h)}
|
|
1238
|
+
onchange={(v) => patchSelectedRect({ h: v })}
|
|
1239
|
+
/>
|
|
1240
|
+
</PropertyField>
|
|
1241
|
+
</PropertyGroup>
|
|
1242
|
+
{/if}
|
|
1243
|
+
|
|
1244
|
+
{#if selectedClip.kind === 'text'}
|
|
1245
|
+
<PropertyGroup title="Text">
|
|
1246
|
+
<PropertyField label="Content">
|
|
1247
|
+
<Input
|
|
1248
|
+
size="sm"
|
|
1249
|
+
value={selectedClip.text ?? ''}
|
|
1250
|
+
oninput={(e) =>
|
|
1251
|
+
patchClip({
|
|
1252
|
+
...selectedClip,
|
|
1253
|
+
text: (e.currentTarget as HTMLInputElement).value
|
|
1254
|
+
})}
|
|
1255
|
+
/>
|
|
1256
|
+
</PropertyField>
|
|
1257
|
+
<PropertyField
|
|
1258
|
+
label="Color"
|
|
1259
|
+
modified={clipFieldModified('textColor')}
|
|
1260
|
+
onreset={() => resetClipField('textColor')}
|
|
1261
|
+
>
|
|
1262
|
+
<ColorPicker
|
|
1263
|
+
size="sm"
|
|
1264
|
+
value={selectedClip.textColor ?? '#ffffff'}
|
|
1265
|
+
showSwatches={false}
|
|
1266
|
+
onchange={(v) => patchClip({ ...selectedClip, textColor: v })}
|
|
1267
|
+
/>
|
|
1268
|
+
</PropertyField>
|
|
1269
|
+
<PropertyField
|
|
1270
|
+
label="Size"
|
|
1271
|
+
modified={clipFieldModified('fontSize')}
|
|
1272
|
+
onreset={() => resetClipField('fontSize')}
|
|
1273
|
+
>
|
|
1274
|
+
<Slider
|
|
1275
|
+
size="sm"
|
|
1276
|
+
min={8}
|
|
1277
|
+
max={120}
|
|
1278
|
+
step={1}
|
|
1279
|
+
value={selectedClip.fontSize ?? 18}
|
|
1280
|
+
showValue
|
|
1281
|
+
valuePosition="header"
|
|
1282
|
+
oninput={(v) => patchClip({ ...selectedClip, fontSize: v })}
|
|
1283
|
+
/>
|
|
1284
|
+
</PropertyField>
|
|
1285
|
+
<PropertyField
|
|
1286
|
+
label="Weight"
|
|
1287
|
+
modified={clipFieldModified('fontWeight')}
|
|
1288
|
+
onreset={() => resetClipField('fontWeight')}
|
|
1289
|
+
>
|
|
1290
|
+
<Slider
|
|
1291
|
+
size="sm"
|
|
1292
|
+
min={100}
|
|
1293
|
+
max={900}
|
|
1294
|
+
step={100}
|
|
1295
|
+
value={Number(selectedClip.fontWeight ?? 600)}
|
|
1296
|
+
showValue
|
|
1297
|
+
valuePosition="header"
|
|
1298
|
+
oninput={(v) => patchClip({ ...selectedClip, fontWeight: v })}
|
|
1299
|
+
/>
|
|
1300
|
+
</PropertyField>
|
|
1301
|
+
<PropertyField
|
|
1302
|
+
label="Align"
|
|
1303
|
+
modified={clipFieldModified('textAlign')}
|
|
1304
|
+
onreset={() => resetClipField('textAlign')}
|
|
1305
|
+
>
|
|
1306
|
+
<div
|
|
1307
|
+
class="flex w-full items-center justify-between gap-0 rounded-xl border border-border bg-surface-elevated p-0.5 shadow-sm"
|
|
1308
|
+
role="toolbar"
|
|
1309
|
+
aria-label="Text align"
|
|
1310
|
+
>
|
|
1311
|
+
{#each textAlignDock as action (action.id)}
|
|
1312
|
+
{@const Icon = action.icon}
|
|
1313
|
+
{@const active = (selectedClip.textAlign ?? 'center') === action.id}
|
|
1314
|
+
<Tooltip content={action.label} side="top" class="min-w-0 flex-1">
|
|
1315
|
+
<IconButton
|
|
1316
|
+
label={action.label}
|
|
1317
|
+
size="xs"
|
|
1318
|
+
variant={active ? 'secondary' : 'ghost'}
|
|
1319
|
+
class="w-full"
|
|
1320
|
+
onclick={() =>
|
|
1321
|
+
patchClip({ ...selectedClip, textAlign: action.id })}
|
|
1322
|
+
>
|
|
1323
|
+
<Icon class="h-3.5 w-3.5" />
|
|
1324
|
+
</IconButton>
|
|
1325
|
+
</Tooltip>
|
|
1326
|
+
{/each}
|
|
1327
|
+
</div>
|
|
1328
|
+
</PropertyField>
|
|
1329
|
+
</PropertyGroup>
|
|
1330
|
+
{/if}
|
|
1331
|
+
|
|
1332
|
+
{#if selectedClip.kind === 'video' || selectedClip.kind === 'image'}
|
|
1333
|
+
<PropertyGroup title={selectedClip.kind === 'video' ? 'Video' : 'Image'}>
|
|
1334
|
+
<div class="px-1 pb-1">
|
|
1335
|
+
{#key selectedClip.id}
|
|
1336
|
+
<FileUploader
|
|
1337
|
+
label="Source"
|
|
1338
|
+
variant="single"
|
|
1339
|
+
layout="vertical"
|
|
1340
|
+
accept={acceptForClipKind(selectedClip.kind)}
|
|
1341
|
+
helperText={selectedClip.kind === 'video' ? 'MP4, WebM…' : 'PNG, JPG, WebP…'}
|
|
1342
|
+
src={selectedClip.src ?? ''}
|
|
1343
|
+
srcName={selectedClip.name}
|
|
1344
|
+
maxSizeMb={200}
|
|
1345
|
+
showFileList={false}
|
|
1346
|
+
onchange={(files) => void replaceSelectedMedia(files)}
|
|
1347
|
+
onclear={clearSelectedMedia}
|
|
1348
|
+
/>
|
|
1349
|
+
{/key}
|
|
1350
|
+
</div>
|
|
1351
|
+
{#if selectedClip.kind === 'video'}
|
|
1352
|
+
<PropertyField
|
|
1353
|
+
label="Volume"
|
|
1354
|
+
modified={clipFieldModified('volume')}
|
|
1355
|
+
onreset={() => resetClipField('volume')}
|
|
1356
|
+
>
|
|
1357
|
+
<Slider
|
|
1358
|
+
size="sm"
|
|
1359
|
+
min={0}
|
|
1360
|
+
max={1}
|
|
1361
|
+
step={0.01}
|
|
1362
|
+
value={selectedClip.volume ?? 1}
|
|
1363
|
+
showValue
|
|
1364
|
+
valuePosition="header"
|
|
1365
|
+
oninput={(v) => patchClip({ ...selectedClip, volume: v })}
|
|
1366
|
+
/>
|
|
1367
|
+
</PropertyField>
|
|
1368
|
+
{/if}
|
|
1369
|
+
<PropertyField
|
|
1370
|
+
label="Trim in (s)"
|
|
1371
|
+
modified={clipFieldModified('trimInMs')}
|
|
1372
|
+
onreset={() => resetClipField('trimInMs')}
|
|
1373
|
+
>
|
|
1374
|
+
<NumberInput
|
|
1375
|
+
size="sm"
|
|
1376
|
+
step={0.1}
|
|
1377
|
+
min={0}
|
|
1378
|
+
value={msToSec(selectedClip.trimInMs ?? 0)}
|
|
1379
|
+
onchange={(v) => patchClip({ ...selectedClip, trimInMs: secToMs(v) })}
|
|
1380
|
+
/>
|
|
1381
|
+
</PropertyField>
|
|
1382
|
+
<PropertyField
|
|
1383
|
+
label="Trim out (s)"
|
|
1384
|
+
modified={clipFieldModified('trimOutMs')}
|
|
1385
|
+
onreset={() => resetClipField('trimOutMs')}
|
|
1386
|
+
>
|
|
1387
|
+
<NumberInput
|
|
1388
|
+
size="sm"
|
|
1389
|
+
step={0.1}
|
|
1390
|
+
min={0}
|
|
1391
|
+
value={msToSec(selectedClip.trimOutMs ?? 0)}
|
|
1392
|
+
onchange={(v) =>
|
|
1393
|
+
patchClip({
|
|
1394
|
+
...selectedClip,
|
|
1395
|
+
trimOutMs: secToMs(v) || undefined
|
|
1396
|
+
})}
|
|
1397
|
+
/>
|
|
1398
|
+
</PropertyField>
|
|
1399
|
+
</PropertyGroup>
|
|
1400
|
+
{/if}
|
|
1401
|
+
|
|
1402
|
+
{#if selectedClip.kind === 'audio'}
|
|
1403
|
+
<PropertyGroup title="Audio">
|
|
1404
|
+
<div class="px-1 pb-1">
|
|
1405
|
+
{#key selectedClip.id}
|
|
1406
|
+
<FileUploader
|
|
1407
|
+
label="Source"
|
|
1408
|
+
variant="single"
|
|
1409
|
+
layout="vertical"
|
|
1410
|
+
accept={acceptForClipKind('audio')}
|
|
1411
|
+
helperText="MP3, WAV, OGG…"
|
|
1412
|
+
src={selectedClip.src ?? ''}
|
|
1413
|
+
srcName={selectedClip.name}
|
|
1414
|
+
maxSizeMb={200}
|
|
1415
|
+
showFileList={false}
|
|
1416
|
+
onchange={(files) => void replaceSelectedMedia(files)}
|
|
1417
|
+
onclear={clearSelectedMedia}
|
|
1418
|
+
/>
|
|
1419
|
+
{/key}
|
|
1420
|
+
</div>
|
|
1421
|
+
<PropertyField
|
|
1422
|
+
label="Volume"
|
|
1423
|
+
modified={clipFieldModified('volume')}
|
|
1424
|
+
onreset={() => resetClipField('volume')}
|
|
1425
|
+
>
|
|
1426
|
+
<Slider
|
|
1427
|
+
size="sm"
|
|
1428
|
+
min={0}
|
|
1429
|
+
max={1}
|
|
1430
|
+
step={0.01}
|
|
1431
|
+
value={selectedClip.volume ?? 1}
|
|
1432
|
+
showValue
|
|
1433
|
+
valuePosition="header"
|
|
1434
|
+
oninput={(v) => patchClip({ ...selectedClip, volume: v })}
|
|
1435
|
+
/>
|
|
1436
|
+
</PropertyField>
|
|
1437
|
+
<PropertyField
|
|
1438
|
+
label="Trim in (s)"
|
|
1439
|
+
modified={clipFieldModified('trimInMs')}
|
|
1440
|
+
onreset={() => resetClipField('trimInMs')}
|
|
1441
|
+
>
|
|
1442
|
+
<NumberInput
|
|
1443
|
+
size="sm"
|
|
1444
|
+
step={0.1}
|
|
1445
|
+
min={0}
|
|
1446
|
+
value={msToSec(selectedClip.trimInMs ?? 0)}
|
|
1447
|
+
onchange={(v) => patchClip({ ...selectedClip, trimInMs: secToMs(v) })}
|
|
1448
|
+
/>
|
|
1449
|
+
</PropertyField>
|
|
1450
|
+
<PropertyField
|
|
1451
|
+
label="Trim out (s)"
|
|
1452
|
+
modified={clipFieldModified('trimOutMs')}
|
|
1453
|
+
onreset={() => resetClipField('trimOutMs')}
|
|
1454
|
+
>
|
|
1455
|
+
<NumberInput
|
|
1456
|
+
size="sm"
|
|
1457
|
+
step={0.1}
|
|
1458
|
+
min={0}
|
|
1459
|
+
value={msToSec(selectedClip.trimOutMs ?? 0)}
|
|
1460
|
+
onchange={(v) =>
|
|
1461
|
+
patchClip({
|
|
1462
|
+
...selectedClip,
|
|
1463
|
+
trimOutMs: secToMs(v) || undefined
|
|
1464
|
+
})}
|
|
1465
|
+
/>
|
|
1466
|
+
</PropertyField>
|
|
1467
|
+
</PropertyGroup>
|
|
1468
|
+
{/if}
|
|
1469
|
+
|
|
1470
|
+
{#if selectedClip.animationIn || selectedClip.animationOut || selectedClip.transitionOut}
|
|
1471
|
+
<PropertyGroup title="Effects">
|
|
1472
|
+
{#if selectedClip.animationIn}
|
|
1473
|
+
<PropertyField label="Anim in">
|
|
1474
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
1475
|
+
<span class="min-w-0 flex-1 truncate text-xs capitalize text-secondary">
|
|
1476
|
+
{selectedClip.animationIn.type}
|
|
1477
|
+
</span>
|
|
1478
|
+
<IconButton
|
|
1479
|
+
label="Remove animation in"
|
|
1480
|
+
size="xs"
|
|
1481
|
+
onclick={() => {
|
|
1482
|
+
const { animationIn: _a, ...rest } = selectedClip;
|
|
1483
|
+
patchClip(rest);
|
|
1484
|
+
}}
|
|
1485
|
+
>
|
|
1486
|
+
<Trash2 class="h-3 w-3" />
|
|
1487
|
+
</IconButton>
|
|
1488
|
+
</div>
|
|
1489
|
+
</PropertyField>
|
|
1490
|
+
<PropertyField label="In duration (s)">
|
|
1491
|
+
<NumberInput
|
|
1492
|
+
size="sm"
|
|
1493
|
+
step={0.05}
|
|
1494
|
+
min={0.05}
|
|
1495
|
+
value={msToSec(selectedClip.animationIn.durationMs)}
|
|
1496
|
+
onchange={(v) =>
|
|
1497
|
+
patchClip({
|
|
1498
|
+
...selectedClip,
|
|
1499
|
+
animationIn: {
|
|
1500
|
+
...selectedClip.animationIn!,
|
|
1501
|
+
durationMs: Math.max(50, secToMs(v))
|
|
1502
|
+
}
|
|
1503
|
+
})}
|
|
1504
|
+
/>
|
|
1505
|
+
</PropertyField>
|
|
1506
|
+
{/if}
|
|
1507
|
+
{#if selectedClip.animationOut}
|
|
1508
|
+
<PropertyField label="Anim out">
|
|
1509
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
1510
|
+
<span class="min-w-0 flex-1 truncate text-xs capitalize text-secondary">
|
|
1511
|
+
{selectedClip.animationOut.type}
|
|
1512
|
+
</span>
|
|
1513
|
+
<IconButton
|
|
1514
|
+
label="Remove animation out"
|
|
1515
|
+
size="xs"
|
|
1516
|
+
onclick={() => {
|
|
1517
|
+
const { animationOut: _a, ...rest } = selectedClip;
|
|
1518
|
+
patchClip(rest);
|
|
1519
|
+
}}
|
|
1520
|
+
>
|
|
1521
|
+
<Trash2 class="h-3 w-3" />
|
|
1522
|
+
</IconButton>
|
|
1523
|
+
</div>
|
|
1524
|
+
</PropertyField>
|
|
1525
|
+
<PropertyField label="Out duration (s)">
|
|
1526
|
+
<NumberInput
|
|
1527
|
+
size="sm"
|
|
1528
|
+
step={0.05}
|
|
1529
|
+
min={0.05}
|
|
1530
|
+
value={msToSec(selectedClip.animationOut.durationMs)}
|
|
1531
|
+
onchange={(v) =>
|
|
1532
|
+
patchClip({
|
|
1533
|
+
...selectedClip,
|
|
1534
|
+
animationOut: {
|
|
1535
|
+
...selectedClip.animationOut!,
|
|
1536
|
+
durationMs: Math.max(50, secToMs(v))
|
|
1537
|
+
}
|
|
1538
|
+
})}
|
|
1539
|
+
/>
|
|
1540
|
+
</PropertyField>
|
|
1541
|
+
{/if}
|
|
1542
|
+
{#if selectedClip.transitionOut}
|
|
1543
|
+
<PropertyField label="Transition">
|
|
1544
|
+
<div class="flex min-w-0 items-center gap-1">
|
|
1545
|
+
<span class="min-w-0 flex-1 truncate text-xs capitalize text-secondary">
|
|
1546
|
+
{selectedClip.transitionOut.type}
|
|
1547
|
+
</span>
|
|
1548
|
+
<IconButton
|
|
1549
|
+
label="Remove transition"
|
|
1550
|
+
size="xs"
|
|
1551
|
+
onclick={() => {
|
|
1552
|
+
const { transitionOut: _t, ...rest } = selectedClip;
|
|
1553
|
+
patchClip(rest);
|
|
1554
|
+
}}
|
|
1555
|
+
>
|
|
1556
|
+
<Trash2 class="h-3 w-3" />
|
|
1557
|
+
</IconButton>
|
|
1558
|
+
</div>
|
|
1559
|
+
</PropertyField>
|
|
1560
|
+
<PropertyField label="Trans duration (s)">
|
|
1561
|
+
<NumberInput
|
|
1562
|
+
size="sm"
|
|
1563
|
+
step={0.05}
|
|
1564
|
+
min={0.05}
|
|
1565
|
+
value={msToSec(selectedClip.transitionOut.durationMs)}
|
|
1566
|
+
onchange={(v) =>
|
|
1567
|
+
patchClip({
|
|
1568
|
+
...selectedClip,
|
|
1569
|
+
transitionOut: {
|
|
1570
|
+
...selectedClip.transitionOut!,
|
|
1571
|
+
durationMs: Math.max(50, secToMs(v))
|
|
1572
|
+
}
|
|
1573
|
+
})}
|
|
1574
|
+
/>
|
|
1575
|
+
</PropertyField>
|
|
1576
|
+
{/if}
|
|
1577
|
+
</PropertyGroup>
|
|
1578
|
+
{:else}
|
|
1579
|
+
<PropertyGroup title="Effects">
|
|
1580
|
+
<p class="px-2 py-2 text-[10px] leading-relaxed text-muted">
|
|
1581
|
+
Select Animations / Transitions in the library, then click or drag onto this clip.
|
|
1582
|
+
CapCut-style: one <span class="text-secondary">In</span> + one
|
|
1583
|
+
<span class="text-secondary"> Out</span> + optional transition at the end.
|
|
1584
|
+
</p>
|
|
1585
|
+
</PropertyGroup>
|
|
1586
|
+
{/if}
|
|
1587
|
+
|
|
1588
|
+
{#if canSplit || canMerge}
|
|
1589
|
+
<div class="mt-3 flex flex-col gap-2 px-3 pb-3">
|
|
1590
|
+
{#if canSplit}
|
|
1591
|
+
<Button size="sm" variant="secondary" onclick={splitSelected}>
|
|
1592
|
+
<span class="inline-flex items-center gap-1.5">
|
|
1593
|
+
<Scissors class="h-3.5 w-3.5" />
|
|
1594
|
+
Split at playhead
|
|
1595
|
+
</span>
|
|
1596
|
+
</Button>
|
|
1597
|
+
{/if}
|
|
1598
|
+
{#if canMerge}
|
|
1599
|
+
<Button size="sm" variant="secondary" onclick={mergeSelected}>Merge selected</Button>
|
|
1600
|
+
{/if}
|
|
1601
|
+
</div>
|
|
1602
|
+
{/if}
|
|
286
1603
|
{/if}
|
|
287
1604
|
{/snippet}
|
|
288
1605
|
|
|
289
1606
|
{#snippet bottom()}
|
|
290
|
-
<div class="h-full p-1">
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
1607
|
+
<div class="flex h-full min-h-0 flex-col gap-1 p-1">
|
|
1608
|
+
<div class="shrink-0 rounded-md border border-border bg-surface-elevated px-2 py-1">
|
|
1609
|
+
<TransportControls
|
|
1610
|
+
{playing}
|
|
1611
|
+
{currentTimeMs}
|
|
1612
|
+
durationMs={value.durationMs}
|
|
1613
|
+
bind:playbackRate
|
|
1614
|
+
bind:muted
|
|
1615
|
+
bind:volume
|
|
1616
|
+
skipMs={SKIP_MS}
|
|
1617
|
+
onplay={() => (playing = true)}
|
|
1618
|
+
onpause={() => (playing = false)}
|
|
1619
|
+
onstop={() => {
|
|
1620
|
+
playing = false;
|
|
1621
|
+
seekTo(0);
|
|
1622
|
+
}}
|
|
1623
|
+
onseekstart={() => seekTo(0)}
|
|
1624
|
+
onseekend={() => seekTo(value.durationMs)}
|
|
1625
|
+
onseekback={() => seekBy(-SKIP_MS)}
|
|
1626
|
+
onseekforward={() => seekBy(SKIP_MS)}
|
|
1627
|
+
/>
|
|
1628
|
+
</div>
|
|
1629
|
+
<div class="min-h-0 flex-1">
|
|
1630
|
+
<MediaTimeline
|
|
1631
|
+
tracks={value.tracks}
|
|
1632
|
+
durationMs={value.durationMs}
|
|
1633
|
+
bind:currentTimeMs
|
|
1634
|
+
bind:zoom
|
|
1635
|
+
{selectedClipIds}
|
|
1636
|
+
showTrackActions
|
|
1637
|
+
reorderTracks
|
|
1638
|
+
allowRemoveTracks
|
|
1639
|
+
onselectclip={selectClip}
|
|
1640
|
+
oncontextmenuclip={openClipMenu}
|
|
1641
|
+
oncontextmenutrack={openTrackMenu}
|
|
1642
|
+
onchangeclip={patchClip}
|
|
1643
|
+
onmoveclips={moveSelectedClips}
|
|
1644
|
+
ondropclip={handleClipDrop}
|
|
1645
|
+
ondroptrack={handleTrackDrop}
|
|
1646
|
+
onselectionchange={setSelection}
|
|
1647
|
+
onchangetrack={patchTrack}
|
|
1648
|
+
onaddtrack={addTrack}
|
|
1649
|
+
onremovetrack={deleteTrack}
|
|
1650
|
+
onreordertracks={reorderTrackIds}
|
|
1651
|
+
onemptyclick={() => clearSelection()}
|
|
1652
|
+
headerVariant="video"
|
|
1653
|
+
onseek={(ms) => (currentTimeMs = ms)}
|
|
1654
|
+
class="h-full min-h-0"
|
|
1655
|
+
/>
|
|
1656
|
+
</div>
|
|
304
1657
|
</div>
|
|
305
1658
|
{/snippet}
|
|
306
1659
|
</EditorShell>
|
|
307
1660
|
</div>
|
|
1661
|
+
|
|
1662
|
+
{#if selectedClipIds.length > 1}
|
|
1663
|
+
<div class="pointer-events-none absolute inset-x-0 bottom-4 z-40 flex justify-center px-4">
|
|
1664
|
+
<div class="pointer-events-auto">
|
|
1665
|
+
<BulkActionBar
|
|
1666
|
+
count={selectedClipIds.length}
|
|
1667
|
+
itemLabel="clip"
|
|
1668
|
+
itemLabelPlural="clips"
|
|
1669
|
+
placement="dock"
|
|
1670
|
+
compact
|
|
1671
|
+
showSelectAll={false}
|
|
1672
|
+
maxPrimary={4}
|
|
1673
|
+
actions={bulkActions}
|
|
1674
|
+
onaction={handleBulk}
|
|
1675
|
+
onclear={clearSelection}
|
|
1676
|
+
/>
|
|
1677
|
+
</div>
|
|
1678
|
+
</div>
|
|
1679
|
+
{/if}
|
|
308
1680
|
</div>
|
|
1681
|
+
|
|
1682
|
+
<ContextMenu
|
|
1683
|
+
items={ctxItems}
|
|
1684
|
+
bind:open={ctxOpen}
|
|
1685
|
+
bind:anchor={ctxAnchor}
|
|
1686
|
+
onselect={handleCtx}
|
|
1687
|
+
/>
|