@pixerate/editor-svelte 0.56.1 → 0.57.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.
@@ -0,0 +1,134 @@
1
+ <script lang="ts">
2
+ import { findVideoClip, formatVideoTimecode, VIDEO_TRANSITION_TYPES, type VideoEdit, type VideoEditorController, type VideoTransitionType } from '@pixerate/editor/video-editor';
3
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
4
+
5
+ interface Props {
6
+ controller: VideoEditorController;
7
+ class?: string;
8
+ }
9
+
10
+ let { controller, class: className = '' }: Props = $props();
11
+ const view = videoEditorStateOf(() => controller);
12
+ const editor = $derived(view.current);
13
+ const fps = $derived(editor.project.fps);
14
+ const at = $derived(editor.selection.length === 1 ? findVideoClip(editor.project, editor.selection[0]) : null);
15
+ const clip = $derived(at?.clip);
16
+ const disabled = $derived(editor.readOnly || !!at?.track.locked);
17
+ let localError = $state<{ text: string; revision: number } | null>(null);
18
+ // A local error shows until the editor changes; then the editor's own error (if any) shows.
19
+ const message = $derived((localError && localError.revision === editor.revision ? localError.text : null) ?? editor.error);
20
+ const input = 'w-full min-w-0 rounded border border-border bg-transparent px-1 py-0.5 text-xs disabled:opacity-50';
21
+
22
+ function apply(edit: VideoEdit, label: string) {
23
+ localError = null;
24
+ return controller.apply(edit, label);
25
+ }
26
+ /** One field: a value sets it, `undefined` removes it (back to its default). */
27
+ function set(key: string, value: unknown, label: string) {
28
+ if (!clip) return;
29
+ apply(value === undefined ? { op: 'update_clip', clip: clip.id, remove: [key] } : { op: 'update_clip', clip: clip.id, set: { [key]: value } }, label);
30
+ }
31
+ /** A number field; empty removes it. */
32
+ function number(key: string, raw: string, label: string, check: { min?: number; max?: number; above?: number; what: string }) {
33
+ if (raw.trim() === '') return set(key, undefined, label);
34
+ const n = Number(raw);
35
+ if (!Number.isFinite(n) || (check.min !== undefined && n < check.min) || (check.max !== undefined && n > check.max) || (check.above !== undefined && n <= check.above)) {
36
+ localError = { text: check.what, revision: editor.revision };
37
+ return;
38
+ }
39
+ set(key, n, label);
40
+ }
41
+ /** A text field; empty removes it unless it is required. */
42
+ function text(key: string, raw: string, label: string, required = false) {
43
+ if (raw.trim() === '' && !required) return set(key, undefined, label);
44
+ if (raw.trim() === '') {
45
+ localError = { text: `${label.replace(/^Change /, '')} cannot be empty.`, revision: editor.revision };
46
+ return;
47
+ }
48
+ set(key, key === 'text' ? raw : raw.trim(), label);
49
+ }
50
+ function setTransition(type: string) {
51
+ if (!clip) return;
52
+ if (!type) apply({ op: 'set_transition', clip: clip.id, transition: null }, 'Remove transition');
53
+ else apply({ op: 'set_transition', clip: clip.id, transition: { ...clip.transition, type: type as VideoTransitionType, duration: clip.transition?.duration ?? 500 } }, 'Set transition');
54
+ }
55
+ function transitionDuration(raw: string) {
56
+ if (!clip?.transition) return;
57
+ const n = Number(raw);
58
+ if (!Number.isFinite(n) || n <= 0) {
59
+ localError = { text: 'Transition duration must be a number of ms above 0.', revision: editor.revision };
60
+ return;
61
+ }
62
+ apply({ op: 'set_transition', clip: clip.id, transition: { ...clip.transition, duration: n } }, 'Change transition');
63
+ }
64
+ </script>
65
+
66
+ <!--
67
+ The selected clip: its name, timing, opacity and fades; volume, speed and mute for media; text,
68
+ colours and size for titles; the colour of a colour clip; and the transition into it. Every
69
+ committed change is one undo step.
70
+ -->
71
+ <div class="flex flex-col gap-1.5 text-xs {className}" data-clip-inspector="" data-clip={clip?.id}>
72
+ {#if !clip}
73
+ <p class="text-muted-foreground" data-inspector-hint="">
74
+ {editor.selection.length > 1 ? `${editor.selection.length} clips selected; select one to edit it.` : 'Select a clip on the timeline to edit it.'}
75
+ </p>
76
+ {:else}
77
+ <div class="flex items-center gap-1">
78
+ <span class="font-medium">Clip</span>
79
+ <code class="text-[10px] text-muted-foreground">{clip.id}</code>
80
+ <span class="text-[10px] text-muted-foreground">{clip.type}</span>
81
+ {#if at?.track.locked}<span class="ml-auto text-[10px] text-amber-500">Track {at.track.name ?? at.track.id} is locked</span>{/if}
82
+ </div>
83
+ <div class="grid grid-cols-[5.5rem_1fr] items-center gap-x-2 gap-y-1">
84
+ <span class="text-muted-foreground">Name</span>
85
+ <input class={input} aria-label="Clip name" {disabled} value={clip.name ?? ''} onchange={(e) => text('name', e.currentTarget.value, 'Rename clip')} data-field="name" />
86
+ <span class="text-muted-foreground">Start</span>
87
+ <span class="tabular-nums" data-field="start">{formatVideoTimecode(clip.start, fps)}</span>
88
+ <span class="text-muted-foreground">Duration</span>
89
+ <span class="tabular-nums" data-field="duration">{formatVideoTimecode(clip.duration, fps)} ({(clip.duration / 1000).toFixed(2)} s)</span>
90
+ <span class="text-muted-foreground">Opacity</span>
91
+ <input class={input} type="number" min="0" max="1" step="0.05" placeholder="1" aria-label="Opacity" {disabled} value={clip.opacity ?? ''} onchange={(e) => number('opacity', e.currentTarget.value, 'Change opacity', { min: 0, max: 1, what: 'Opacity must be between 0 and 1.' })} data-field="opacity" />
92
+ <span class="text-muted-foreground">Fade in</span>
93
+ <input class={input} type="number" min="0" step="100" placeholder="0 ms" aria-label="Fade in" {disabled} value={clip.fadeIn ?? ''} onchange={(e) => number('fadeIn', e.currentTarget.value, 'Change fade in', { min: 0, what: 'Fade in must be a number of ms, 0 or more.' })} data-field="fadeIn" />
94
+ <span class="text-muted-foreground">Fade out</span>
95
+ <input class={input} type="number" min="0" step="100" placeholder="0 ms" aria-label="Fade out" {disabled} value={clip.fadeOut ?? ''} onchange={(e) => number('fadeOut', e.currentTarget.value, 'Change fade out', { min: 0, what: 'Fade out must be a number of ms, 0 or more.' })} data-field="fadeOut" />
96
+ {#if clip.type === 'media'}
97
+ <span class="text-muted-foreground">Volume</span>
98
+ <input class={input} type="number" min="0" step="0.05" placeholder="1" aria-label="Volume" {disabled} value={clip.volume ?? ''} onchange={(e) => number('volume', e.currentTarget.value, 'Change volume', { min: 0, what: 'Volume must be 0 or more (1 = unchanged).' })} data-field="volume" />
99
+ <span class="text-muted-foreground">Speed</span>
100
+ <input class={input} type="number" min="0.05" step="0.05" placeholder="1" aria-label="Speed" {disabled} value={clip.speed ?? ''} onchange={(e) => number('speed', e.currentTarget.value, 'Change speed', { above: 0, what: 'Speed must be a number above 0 (1 = normal speed).' })} data-field="speed" />
101
+ <span class="text-muted-foreground">Muted</span>
102
+ <input type="checkbox" class="justify-self-start" aria-label="Muted" {disabled} checked={!!clip.muted} onchange={(e) => set('muted', e.currentTarget.checked || undefined, e.currentTarget.checked ? 'Mute clip' : 'Unmute clip')} data-field="muted" />
103
+ {:else if clip.type === 'text'}
104
+ <span class="text-muted-foreground">Text</span>
105
+ <textarea class={input} rows="2" aria-label="Text" {disabled} value={clip.text} onchange={(e) => text('text', e.currentTarget.value, 'Change text', true)} data-field="text"></textarea>
106
+ <span class="text-muted-foreground">Colour</span>
107
+ <input class={input} placeholder="white" aria-label="Text colour" {disabled} value={clip.color ?? ''} onchange={(e) => text('color', e.currentTarget.value, 'Change text colour')} data-field="color" />
108
+ <span class="text-muted-foreground">Font size</span>
109
+ <input class={input} type="number" min="1" step="1" placeholder="64" aria-label="Font size" {disabled} value={clip.fontSize ?? ''} onchange={(e) => number('fontSize', e.currentTarget.value, 'Change font size', { above: 0, what: 'Font size must be a number of pixels above 0.' })} data-field="fontSize" />
110
+ <span class="text-muted-foreground">Background</span>
111
+ <input class={input} placeholder="none" aria-label="Text background" {disabled} value={clip.background ?? ''} onchange={(e) => text('background', e.currentTarget.value, 'Change text background')} data-field="background" />
112
+ {:else}
113
+ <span class="text-muted-foreground">Colour</span>
114
+ <input class={input} aria-label="Clip colour" {disabled} value={clip.color} onchange={(e) => text('color', e.currentTarget.value, 'Change colour', true)} data-field="color" />
115
+ {/if}
116
+ <span class="text-muted-foreground">Transition</span>
117
+ <div class="flex items-center gap-1">
118
+ <select class={input} aria-label="Transition" {disabled} value={clip.transition?.type ?? ''} onchange={(e) => setTransition(e.currentTarget.value)} data-field="transition">
119
+ <option value="">none</option>
120
+ {#each VIDEO_TRANSITION_TYPES as t (t)}
121
+ <option value={t}>{t}</option>
122
+ {/each}
123
+ </select>
124
+ {#if clip.transition}
125
+ <input class="{input} w-20" type="number" min="1" step="50" aria-label="Transition duration" {disabled} value={clip.transition.duration} onchange={(e) => transitionDuration(e.currentTarget.value)} data-field="transitionDuration" />
126
+ <span class="text-[10px] text-muted-foreground">ms</span>
127
+ {/if}
128
+ </div>
129
+ </div>
130
+ {/if}
131
+ {#if message}
132
+ <p class="text-red-500" role="alert" data-edit-error="">{message}</p>
133
+ {/if}
134
+ </div>
@@ -0,0 +1,9 @@
1
+ import { type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ class?: string;
5
+ }
6
+ declare const ClipInspector: import("svelte").Component<Props, {}, "">;
7
+ type ClipInspector = ReturnType<typeof ClipInspector>;
8
+ export default ClipInspector;
9
+ //# sourceMappingURL=ClipInspector.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ClipInspector.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/ClipInspector.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAA8E,KAAK,qBAAqB,EAA4B,MAAM,+BAA+B,CAAC;AAI/K,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAkIH,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,160 @@
1
+ <script lang="ts">
2
+ import {
3
+ formatVideoSeconds,
4
+ probeVideoMedia,
5
+ videoAssetDropEdit,
6
+ videoAssetKindOf,
7
+ type VideoAsset,
8
+ type VideoAssetKind,
9
+ type VideoEditorController,
10
+ } from '@pixerate/editor/video-editor';
11
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
12
+
13
+ interface Props {
14
+ controller: VideoEditorController;
15
+ class?: string;
16
+ }
17
+
18
+ let { controller, class: className = '' }: Props = $props();
19
+ const view = videoEditorStateOf(() => controller);
20
+ const editor = $derived(view.current);
21
+ const assets = $derived(editor.project.assets);
22
+ let url = $state('');
23
+ let urlKind = $state<VideoAssetKind | ''>('');
24
+ let busy = $state(0);
25
+ let dragOver = $state(false);
26
+ let localError = $state<{ text: string; revision: number } | null>(null);
27
+ // A local error shows until the editor changes; then the editor's own error (if any) shows.
28
+ const message = $derived((localError && localError.revision === editor.revision ? localError.text : null) ?? editor.error);
29
+ const btn = 'rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40';
30
+
31
+ function fail(text: string) {
32
+ localError = { text, revision: editor.revision };
33
+ }
34
+
35
+ function addToTimeline(asset: VideoAsset) {
36
+ localError = null;
37
+ const edit = videoAssetDropEdit(editor.project, asset.id);
38
+ if (!edit) return fail(`There is no unlocked ${asset.kind === 'audio' ? 'audio' : 'video'} track to add "${asset.name ?? asset.id}" to.`);
39
+ const result = controller.apply(edit, 'Add clip');
40
+ const clip = result.ok ? result.outcomes[0]?.clip : undefined;
41
+ if (clip) controller.select([clip]);
42
+ }
43
+
44
+ /** Reads the media's length and size, then adds it as an asset (without them if it cannot be read). */
45
+ async function addAsset(src: string, kind: VideoAssetKind, name: string) {
46
+ busy++;
47
+ let probe: { duration?: number; width?: number; height?: number } = {};
48
+ let problem: string | null = null;
49
+ try {
50
+ const { kind: _kind, ...found } = await probeVideoMedia(src, kind);
51
+ probe = found;
52
+ } catch (error) {
53
+ problem = (error as Error).message;
54
+ } finally {
55
+ busy--;
56
+ }
57
+ localError = null;
58
+ const result = controller.apply({ op: 'add_asset', asset: { kind, src, name, ...probe } }, 'Add media');
59
+ if (result.ok && problem) fail(`${problem} It was added without its length; set one before using it.`);
60
+ }
61
+
62
+ function addFiles(files: FileList | readonly File[] | null | undefined) {
63
+ if (!files || editor.readOnly) return;
64
+ for (const file of Array.from(files)) {
65
+ const kind = videoAssetKindOf(file.type || file.name);
66
+ if (!kind) {
67
+ fail(`"${file.name}" is not a video, audio or image file.`);
68
+ continue;
69
+ }
70
+ // The object URL lives as long as the page: the project keeps referring to it.
71
+ void addAsset(URL.createObjectURL(file), kind, file.name);
72
+ }
73
+ }
74
+
75
+ function addUrl() {
76
+ const src = url.trim();
77
+ if (!src) return;
78
+ const kind = urlKind || videoAssetKindOf(src.split(/[?#]/)[0]);
79
+ if (!kind) return fail('Choose whether the URL is a video, audio or image: its extension does not say.');
80
+ url = '';
81
+ urlKind = '';
82
+ void addAsset(src, kind, src.split(/[?#]/)[0].replace(/^.*[\\/]/, '') || src);
83
+ }
84
+ </script>
85
+
86
+ <!--
87
+ The project's media: add files (pick or drop them) or a URL, then add each to the timeline at the
88
+ end of the first track of its kind. Every addition is one undo step.
89
+ -->
90
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
91
+ <div
92
+ class="flex flex-col gap-1.5 rounded text-xs {dragOver ? 'outline-dashed outline-2 outline-rose-500' : ''} {className}"
93
+ data-media-bin=""
94
+ ondragover={(e) => {
95
+ if (editor.readOnly || !e.dataTransfer?.types.includes('Files')) return;
96
+ e.preventDefault();
97
+ dragOver = true;
98
+ }}
99
+ ondragleave={() => (dragOver = false)}
100
+ ondrop={(e) => {
101
+ dragOver = false;
102
+ if (editor.readOnly || !e.dataTransfer?.files.length) return;
103
+ e.preventDefault();
104
+ addFiles(e.dataTransfer.files);
105
+ }}
106
+ >
107
+ <div class="flex items-center gap-1">
108
+ <span class="font-medium">Media</span>
109
+ {#if busy}<span class="text-muted-foreground" data-busy="">Reading…</span>{/if}
110
+ <label class="{btn} ml-auto cursor-pointer {editor.readOnly ? 'pointer-events-none opacity-40' : ''}">
111
+ Add files
112
+ <input
113
+ type="file"
114
+ class="sr-only"
115
+ multiple
116
+ accept="video/*,audio/*,image/*"
117
+ disabled={editor.readOnly}
118
+ aria-label="Add media files"
119
+ onchange={(e) => {
120
+ addFiles(e.currentTarget.files);
121
+ e.currentTarget.value = '';
122
+ }}
123
+ data-file-input=""
124
+ />
125
+ </label>
126
+ </div>
127
+ {#if assets.length}
128
+ <ul class="flex flex-col gap-0.5" aria-label="Media">
129
+ {#each assets as asset (asset.id)}
130
+ <li class="flex items-center gap-1 rounded border border-border px-1 py-0.5" data-asset={asset.id}>
131
+ <span class="rounded bg-muted px-1 text-[10px] text-muted-foreground">{asset.kind}</span>
132
+ <span class="min-w-0 flex-1 truncate" title={asset.src}>{asset.name ?? asset.id}</span>
133
+ {#if asset.duration !== undefined}<span class="tabular-nums text-muted-foreground">{formatVideoSeconds(asset.duration)}</span>{/if}
134
+ <button type="button" class={btn} disabled={editor.readOnly} onclick={() => addToTimeline(asset)} aria-label="Add {asset.name ?? asset.id} to timeline" data-add-to-timeline="">Add to timeline</button>
135
+ </li>
136
+ {/each}
137
+ </ul>
138
+ {:else}
139
+ <p class="text-muted-foreground">Drop video, audio or images here, or add them from a file or URL.</p>
140
+ {/if}
141
+ <form
142
+ class="flex items-center gap-1"
143
+ onsubmit={(e) => {
144
+ e.preventDefault();
145
+ addUrl();
146
+ }}
147
+ >
148
+ <input class="min-w-0 flex-1 rounded border border-border bg-transparent px-1 py-0.5" type="url" placeholder="https://…/clip.mp4" aria-label="Media URL" disabled={editor.readOnly} bind:value={url} data-url-input="" />
149
+ <select class="rounded border border-border bg-transparent px-1 py-0.5" aria-label="Media kind" disabled={editor.readOnly} bind:value={urlKind}>
150
+ <option value="">auto</option>
151
+ <option value="video">video</option>
152
+ <option value="audio">audio</option>
153
+ <option value="image">image</option>
154
+ </select>
155
+ <button type="submit" class={btn} disabled={editor.readOnly || !url.trim()} data-add-url="">Add URL</button>
156
+ </form>
157
+ {#if message}
158
+ <p class="text-red-500" role="alert" data-edit-error="">{message}</p>
159
+ {/if}
160
+ </div>
@@ -0,0 +1,9 @@
1
+ import { type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ class?: string;
5
+ }
6
+ declare const MediaBin: import("svelte").Component<Props, {}, "">;
7
+ type MediaBin = ReturnType<typeof MediaBin>;
8
+ export default MediaBin;
9
+ //# sourceMappingURL=MediaBin.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MediaBin.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/MediaBin.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAOH,KAAK,qBAAqB,EAC3B,MAAM,+BAA+B,CAAC;AAIvC,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAmIH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { VideoEditorController, type VideoEditorOptions } from '@pixerate/editor/video-editor';
4
+ import ClipInspector from './ClipInspector.svelte';
5
+ import MediaBin from './MediaBin.svelte';
6
+ import VideoPreview from './VideoPreview.svelte';
7
+ import VideoTimeline from './VideoTimeline.svelte';
8
+ import VideoTransport from './VideoTransport.svelte';
9
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
10
+
11
+ interface Props {
12
+ /** The editor to show (default: one made from `options`, destroyed with the component). */
13
+ controller?: VideoEditorController;
14
+ /** Options for the editor made when no `controller` is passed (read once). */
15
+ options?: VideoEditorOptions;
16
+ /**
17
+ * Show the editing panels: the media bin, clip inspector, edit buttons, dragging and edit keys
18
+ * (default true). False shows the preview, transport, timeline (scrub and select) and issues.
19
+ */
20
+ editable?: boolean;
21
+ /** Timeline zoom in pixels per second (default 100). Bindable. */
22
+ pxPerSecond?: number;
23
+ /** List the project's issues under the timeline (default true). */
24
+ showIssues?: boolean;
25
+ class?: string;
26
+ }
27
+
28
+ let { controller, options, editable = true, pxPerSecond = $bindable(100), showIssues = true, class: className = '' }: Props = $props();
29
+ let owned: VideoEditorController | null = null;
30
+ // A controller is made only when none is passed, and kept for the component's lifetime.
31
+ const ctl = $derived(controller ?? (owned ??= new VideoEditorController(options)));
32
+ onDestroy(() => owned?.destroy());
33
+ const view = videoEditorStateOf(() => ctl);
34
+ const editor = $derived(view.current);
35
+ </script>
36
+
37
+ {#snippet issueList()}
38
+ {#if showIssues && editor.issues.length > 0}
39
+ <ul class="space-y-0.5 text-xs" aria-label="Video issues" data-video-issues="">
40
+ {#each editor.issues as issue, i (i)}
41
+ <li class={issue.severity === 'error' ? 'text-red-500' : issue.severity === 'warning' ? 'text-amber-500' : 'text-muted-foreground'} data-issue={issue.code}>{issue.message}</li>
42
+ {/each}
43
+ </ul>
44
+ {/if}
45
+ {/snippet}
46
+
47
+ <!--
48
+ A whole video editor: the media bin, the preview, transport, the timeline (drag to move and trim,
49
+ keys to split and delete), the selected clip's inspector and the project's issues. Every edit is
50
+ one undo step.
51
+ -->
52
+ {#if !editable}
53
+ <div class="flex flex-col gap-2 {className}" data-video-editor="">
54
+ <VideoPreview controller={ctl} class="rounded-md" />
55
+ <VideoTransport controller={ctl} editable={false} />
56
+ <VideoTimeline controller={ctl} bind:pxPerSecond editable={false} />
57
+ {@render issueList()}
58
+ </div>
59
+ {:else}
60
+ <div class="grid gap-3 lg:grid-cols-[14rem_minmax(0,1fr)_18rem] {className}" data-video-editor="">
61
+ <MediaBin controller={ctl} class="min-w-0" />
62
+ <div class="flex min-w-0 flex-col gap-2">
63
+ <VideoPreview controller={ctl} class="rounded-md" />
64
+ <VideoTransport controller={ctl} />
65
+ </div>
66
+ <ClipInspector controller={ctl} class="min-w-0" />
67
+ <div class="flex min-w-0 flex-col gap-2 lg:col-span-3">
68
+ <VideoTimeline controller={ctl} bind:pxPerSecond />
69
+ {@render issueList()}
70
+ </div>
71
+ </div>
72
+ {/if}
@@ -0,0 +1,21 @@
1
+ import { VideoEditorController, type VideoEditorOptions } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ /** The editor to show (default: one made from `options`, destroyed with the component). */
4
+ controller?: VideoEditorController;
5
+ /** Options for the editor made when no `controller` is passed (read once). */
6
+ options?: VideoEditorOptions;
7
+ /**
8
+ * Show the editing panels: the media bin, clip inspector, edit buttons, dragging and edit keys
9
+ * (default true). False shows the preview, transport, timeline (scrub and select) and issues.
10
+ */
11
+ editable?: boolean;
12
+ /** Timeline zoom in pixels per second (default 100). Bindable. */
13
+ pxPerSecond?: number;
14
+ /** List the project's issues under the timeline (default true). */
15
+ showIssues?: boolean;
16
+ class?: string;
17
+ }
18
+ declare const VideoEditor: import("svelte").Component<Props, {}, "pxPerSecond">;
19
+ type VideoEditor = ReturnType<typeof VideoEditor>;
20
+ export default VideoEditor;
21
+ //# sourceMappingURL=VideoEditor.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/VideoEditor.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,qBAAqB,EAAE,KAAK,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AAS7F,UAAU,KAAK;IACb,2FAA2F;IAC3F,UAAU,CAAC,EAAE,qBAAqB,CAAC;IACnC,8EAA8E;IAC9E,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mEAAmE;IACnE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAwDH,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import { untrack } from 'svelte';
3
+ import { formatVideoTimecode, VideoPreviewEngine, type VideoEditorController } from '@pixerate/editor/video-editor';
4
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
5
+
6
+ interface Props {
7
+ controller: VideoEditorController;
8
+ /** Canvas pixels per frame pixel (default: the displayed width at the device pixel ratio, at most 1). */
9
+ resolution?: number;
10
+ class?: string;
11
+ style?: string;
12
+ }
13
+
14
+ let { controller, resolution, class: className = '', style = '' }: Props = $props();
15
+ const view = videoEditorStateOf(() => controller);
16
+ const project = $derived(view.current.project);
17
+ let canvas: HTMLCanvasElement | undefined = $state();
18
+ // One engine per canvas, controller and resolution: it follows the controller by itself.
19
+ $effect(() => {
20
+ const el = canvas;
21
+ const source = controller;
22
+ const options = { resolution };
23
+ if (!el) return;
24
+ const engine = untrack(() => new VideoPreviewEngine(source, el, options));
25
+ return () => engine.destroy();
26
+ });
27
+ </script>
28
+
29
+ <!-- The frame at the playhead, drawn on a canvas with the project's aspect ratio; media plays in step with the timeline. -->
30
+ <canvas
31
+ bind:this={canvas}
32
+ class="block w-full bg-black {className}"
33
+ style="aspect-ratio:{project.width} / {project.height};{style}"
34
+ data-video-preview=""
35
+ aria-label="{project.name ?? 'Video'} at {formatVideoTimecode(view.current.time, project.fps)}"
36
+ ></canvas>
@@ -0,0 +1,12 @@
1
+ import { type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ /** Canvas pixels per frame pixel (default: the displayed width at the device pixel ratio, at most 1). */
5
+ resolution?: number;
6
+ class?: string;
7
+ style?: string;
8
+ }
9
+ declare const VideoPreview: import("svelte").Component<Props, {}, "">;
10
+ type VideoPreview = ReturnType<typeof VideoPreview>;
11
+ export default VideoPreview;
12
+ //# sourceMappingURL=VideoPreview.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoPreview.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/VideoPreview.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,EAA2C,KAAK,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AAIlH,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,yGAAyG;IACzG,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA2BH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,339 @@
1
+ <script lang="ts">
2
+ import {
3
+ findVideoClip,
4
+ formatVideoTimecode,
5
+ planVideoClipDrag,
6
+ videoAssetMap,
7
+ videoClipLabel,
8
+ videoClipTint,
9
+ videoDropTracks,
10
+ videoRulerTicks,
11
+ type VideoClip,
12
+ type VideoClipDragMode,
13
+ type VideoClipDragPlan,
14
+ type VideoEditorController,
15
+ type VideoTrack,
16
+ } from '@pixerate/editor/video-editor';
17
+ import { onDestroy } from 'svelte';
18
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
19
+
20
+ interface Props {
21
+ controller: VideoEditorController;
22
+ /** Zoom: pixels per second of timeline (default 100). Bindable. */
23
+ pxPerSecond?: number;
24
+ /** Called when the zoom control changes `pxPerSecond`. */
25
+ onpxpersecondchange?: (pxPerSecond: number) => void;
26
+ /** Allow moving and trimming clips, the track toggles and the edit keys (default true). False still scrubs, selects and plays. */
27
+ editable?: boolean;
28
+ class?: string;
29
+ }
30
+
31
+ let { controller, pxPerSecond = $bindable(100), onpxpersecondchange, editable = true, class: className = '' }: Props = $props();
32
+ const view = videoEditorStateOf(() => controller);
33
+ const editor = $derived(view.current);
34
+ const project = $derived(editor.project);
35
+ const assets = $derived(videoAssetMap(project));
36
+ const px = $derived(Math.max(1, pxPerSecond));
37
+ const x = (ms: number) => (ms * px) / 1000;
38
+ /** Room after the last clip to drop and drag into. */
39
+ const span = $derived(Math.max(editor.duration + 5000, 10000));
40
+ const width = $derived(x(span));
41
+ const ticks = $derived(videoRulerTicks(span, px, project.fps));
42
+ const canEdit = $derived(editable && !editor.readOnly);
43
+ const HEADER = 144;
44
+ const ZOOM_MIN = 10;
45
+ const ZOOM_MAX = 2000;
46
+ const btn = 'rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40';
47
+
48
+ let container: HTMLDivElement | undefined = $state();
49
+ let ruler: HTMLDivElement | undefined = $state();
50
+
51
+ interface Drag {
52
+ clip: string;
53
+ mode: VideoClipDragMode;
54
+ pointerX: number;
55
+ moved: boolean;
56
+ /** Whether the drag may edit (false: it only selects). */
57
+ edits: boolean;
58
+ additive: boolean;
59
+ plan: VideoClipDragPlan | null;
60
+ }
61
+ let drag = $state.raw<Drag | null>(null);
62
+
63
+ interface Item {
64
+ clip: VideoClip;
65
+ start: number;
66
+ duration: number;
67
+ dragging: boolean;
68
+ }
69
+ // While dragging, the clip is drawn where the plan puts it (possibly on another track).
70
+ const rows = $derived.by(() => {
71
+ const plan = drag?.plan ?? null;
72
+ const moving = plan && drag ? findVideoClip(project, drag.clip) : null;
73
+ return project.tracks.map((track) => {
74
+ const items: Item[] = [];
75
+ for (const clip of track.clips) {
76
+ if (moving && plan && clip.id === moving.clip.id) {
77
+ if (plan.track === track.id) items.push({ clip, start: plan.start, duration: plan.duration, dragging: true });
78
+ } else items.push({ clip, start: clip.start, duration: clip.duration, dragging: false });
79
+ }
80
+ if (moving && plan && plan.track === track.id && moving.track.id !== track.id) items.push({ clip: moving.clip, start: plan.start, duration: plan.duration, dragging: true });
81
+ return { track, items };
82
+ });
83
+ });
84
+
85
+ function setZoom(value: number) {
86
+ const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value)));
87
+ if (next === pxPerSecond) return;
88
+ pxPerSecond = next;
89
+ onpxpersecondchange?.(next);
90
+ }
91
+
92
+ function seekAt(clientX: number) {
93
+ const box = ruler!.getBoundingClientRect();
94
+ controller.seek(Math.max(0, ((clientX - box.left) * 1000) / px));
95
+ }
96
+
97
+ /** The track row under the pointer, when the clip may be dropped there. */
98
+ function trackAt(clientY: number, clip: string): string | undefined {
99
+ const allowed = videoDropTracks(project, clip);
100
+ for (const lane of container?.querySelectorAll<HTMLElement>('[data-lane]') ?? []) {
101
+ const box = lane.getBoundingClientRect();
102
+ if (box.height > 0 && clientY >= box.top && clientY < box.bottom) {
103
+ const id = lane.dataset.lane!;
104
+ return allowed.includes(id) ? id : undefined;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ function onwindowmove(e: PointerEvent) {
111
+ const d = drag;
112
+ if (!d || !d.edits) return;
113
+ const dx = e.clientX - d.pointerX;
114
+ if (!d.moved && Math.abs(dx) < 3) return;
115
+ const clip = d.clip;
116
+ const plan = planVideoClipDrag(project, {
117
+ clip,
118
+ mode: d.mode,
119
+ delta: (dx * 1000) / px,
120
+ track: d.mode === 'move' ? trackAt(e.clientY, clip) : undefined,
121
+ ripple: e.altKey,
122
+ snap: (t) => controller.snap(t, { exclude: [clip] }).time,
123
+ });
124
+ drag = { ...d, moved: true, plan };
125
+ }
126
+
127
+ function onwindowup() {
128
+ const d = drag;
129
+ stopListening();
130
+ drag = null;
131
+ if (!d) return;
132
+ if (d.moved) {
133
+ const edit = d.plan?.edit;
134
+ if (edit) controller.apply(edit, edit.op === 'move_clip' ? 'Move clip' : edit.op === 'trim_clip' && edit.ripple ? 'Ripple trim' : 'Trim clip');
135
+ } else if (!d.additive) controller.select([d.clip]);
136
+ }
137
+
138
+ function oncancel() {
139
+ stopListening();
140
+ drag = null;
141
+ }
142
+
143
+ function stopListening() {
144
+ if (typeof window === 'undefined') return;
145
+ window.removeEventListener('pointermove', onwindowmove);
146
+ window.removeEventListener('pointerup', onwindowup);
147
+ window.removeEventListener('pointercancel', oncancel);
148
+ }
149
+ onDestroy(stopListening);
150
+
151
+ function onclipdown(e: PointerEvent, clip: VideoClip, track: VideoTrack) {
152
+ if (e.button !== 0) return;
153
+ e.stopPropagation();
154
+ e.preventDefault();
155
+ container?.focus();
156
+ const additive = e.shiftKey || e.metaKey || e.ctrlKey;
157
+ if (additive) controller.select([clip.id], { add: true });
158
+ else if (!editor.selection.includes(clip.id)) controller.select([clip.id]);
159
+ const edge = (e.target as HTMLElement).closest('[data-edge]')?.getAttribute('data-edge');
160
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
161
+ drag = {
162
+ clip: clip.id,
163
+ mode: edge === 'start' ? 'trim-start' : edge === 'end' ? 'trim-end' : 'move',
164
+ pointerX: e.clientX,
165
+ moved: false,
166
+ edits: canEdit && !track.locked && !additive,
167
+ additive,
168
+ plan: null,
169
+ };
170
+ // On the window, not the block: a block dragged onto another track is drawn by another row.
171
+ stopListening();
172
+ window.addEventListener('pointermove', onwindowmove);
173
+ window.addEventListener('pointerup', onwindowup);
174
+ window.addEventListener('pointercancel', oncancel);
175
+ }
176
+
177
+ function toggleTrack(track: VideoTrack, key: 'muted' | 'hidden' | 'locked') {
178
+ const on = !track[key];
179
+ const verb = { muted: ['Mute', 'Unmute'], hidden: ['Hide', 'Show'], locked: ['Lock', 'Unlock'] }[key][on ? 0 : 1];
180
+ controller.apply({ op: 'update_track', track: track.id, set: { [key]: on } }, `${verb} track`);
181
+ }
182
+
183
+ function onkeydown(e: KeyboardEvent) {
184
+ const el = e.target as HTMLElement;
185
+ if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName) || el.isContentEditable) return;
186
+ if (el.tagName === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
187
+ const mod = e.metaKey || e.ctrlKey;
188
+ if (e.key === ' ') controller.toggle();
189
+ else if (e.key === 'ArrowLeft') controller.step(-1);
190
+ else if (e.key === 'ArrowRight') controller.step(1);
191
+ else if (e.key === 'Escape') controller.select(null);
192
+ else if (!editable) return;
193
+ else if (mod && e.key.toLowerCase() === 'z') {
194
+ if (e.shiftKey) controller.redo();
195
+ else controller.undo();
196
+ } else if (!mod && e.key.toLowerCase() === 's') controller.splitAtPlayhead();
197
+ else if (e.key === 'Delete' || e.key === 'Backspace') controller.deleteSelection({ ripple: e.shiftKey });
198
+ else return;
199
+ e.preventDefault();
200
+ }
201
+
202
+ const toggles = (track: VideoTrack) =>
203
+ [
204
+ { key: 'muted' as const, short: 'M', label: track.muted ? 'Unmute' : 'Mute' },
205
+ ...(track.kind === 'video' ? [{ key: 'hidden' as const, short: 'H', label: track.hidden ? 'Show' : 'Hide' }] : []),
206
+ { key: 'locked' as const, short: 'L', label: track.locked ? 'Unlock' : 'Lock' },
207
+ ];
208
+ </script>
209
+
210
+ <!--
211
+ The timeline: a ruler to scrub (with markers), the playhead, and a row per track with its
212
+ toggles and clips. Click a clip to select it (shift / ⌘ to add), drag it to move it (onto
213
+ another track of its kind), drag an edge to trim (alt: ripple). Keys: Space plays, ←/→ step a
214
+ frame, S splits, Delete deletes (shift: ripple), ⌘Z / ⇧⌘Z undo and redo. Every edit is one undo step.
215
+ -->
216
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
217
+ <div
218
+ bind:this={container}
219
+ class="flex flex-col gap-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-rose-500 {className}"
220
+ tabindex="0"
221
+ role="group"
222
+ aria-label="Timeline"
223
+ data-video-timeline=""
224
+ {onkeydown}
225
+ >
226
+ <div class="flex items-center gap-1 text-muted-foreground">
227
+ <span class="tabular-nums" data-timecode="">{formatVideoTimecode(editor.time, project.fps)}</span>
228
+ <span class="ml-auto flex items-center gap-1" data-zoom="">
229
+ <button type="button" class={btn} aria-label="Zoom out" onclick={() => setZoom(px / 1.25)}>−</button>
230
+ <input type="range" min={ZOOM_MIN} max={ZOOM_MAX} step="1" value={px} aria-label="Zoom" class="w-24" oninput={(e) => setZoom(Number(e.currentTarget.value))} />
231
+ <button type="button" class={btn} aria-label="Zoom in" onclick={() => setZoom(px * 1.25)}>+</button>
232
+ </span>
233
+ </div>
234
+ <div class="relative overflow-x-auto rounded border border-border">
235
+ <div class="relative" style="width:{HEADER + width}px">
236
+ <div class="flex h-6 border-b border-border">
237
+ <div class="sticky left-0 z-20 shrink-0 bg-background" style="width:{HEADER}px"></div>
238
+ <div
239
+ bind:this={ruler}
240
+ class="relative cursor-ew-resize select-none touch-none text-[10px] text-muted-foreground"
241
+ style="width:{width}px"
242
+ role="slider"
243
+ tabindex="-1"
244
+ aria-label="Playhead"
245
+ aria-valuemin={0}
246
+ aria-valuemax={editor.duration}
247
+ aria-valuenow={Math.round(editor.time)}
248
+ data-ruler=""
249
+ onpointerdown={(e) => {
250
+ e.currentTarget.setPointerCapture?.(e.pointerId);
251
+ seekAt(e.clientX);
252
+ }}
253
+ onpointermove={(e) => {
254
+ if (e.buttons & 1) seekAt(e.clientX);
255
+ }}
256
+ >
257
+ {#each ticks as t (t.time)}
258
+ <div class="absolute bottom-0 w-px {t.major ? 'h-2.5 bg-muted-foreground/70' : 'h-1 bg-muted-foreground/40'}" style="left:{t.x}px">
259
+ {#if t.label}<span class="absolute -top-0.5 left-1 whitespace-nowrap tabular-nums">{t.label}</span>{/if}
260
+ </div>
261
+ {/each}
262
+ {#each project.markers ?? [] as m (m.id)}
263
+ <div
264
+ class="absolute top-0 h-2 w-2 -translate-x-1/2 rotate-45"
265
+ style="left:{x(m.time)}px;background:{m.color ?? '#f59e0b'}"
266
+ title="{m.label ?? m.id} · {formatVideoTimecode(m.time, project.fps)}"
267
+ data-marker={m.id}
268
+ ></div>
269
+ {/each}
270
+ </div>
271
+ </div>
272
+ {#each rows as row (row.track.id)}
273
+ {@const track = row.track}
274
+ <div class="flex h-12 border-b border-border last:border-b-0" data-track={track.id}>
275
+ <div class="sticky left-0 z-20 flex shrink-0 flex-col justify-center gap-0.5 border-r border-border bg-background px-1.5" style="width:{HEADER}px">
276
+ <div class="flex items-center gap-1">
277
+ <span class="truncate font-medium">{track.name ?? track.id}</span>
278
+ <span class="rounded bg-muted px-1 text-[10px] text-muted-foreground" data-track-kind="">{track.kind}</span>
279
+ </div>
280
+ <div class="flex gap-0.5">
281
+ {#each toggles(track) as t (t.key)}
282
+ <button
283
+ type="button"
284
+ class="{btn} {track[t.key] ? 'bg-muted text-foreground' : 'text-muted-foreground'}"
285
+ aria-label="{t.label} {track.name ?? track.id}"
286
+ aria-pressed={!!track[t.key]}
287
+ title="{t.label} track"
288
+ disabled={!canEdit}
289
+ data-toggle={t.key}
290
+ onclick={() => toggleTrack(track, t.key)}>{t.short}</button
291
+ >
292
+ {/each}
293
+ </div>
294
+ </div>
295
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
296
+ <div
297
+ class="relative shrink-0 {track.locked ? 'bg-muted/60' : ''} {track.hidden || track.muted ? 'opacity-60' : ''}"
298
+ style="width:{width}px"
299
+ data-lane={track.id}
300
+ onpointerdown={(e) => {
301
+ if (e.button === 0 && !(e.shiftKey || e.metaKey || e.ctrlKey)) controller.select(null);
302
+ }}
303
+ >
304
+ {#each row.items as item (item.clip.id)}
305
+ {@const clip = item.clip}
306
+ {@const asset = clip.type === 'media' ? assets.get(clip.asset) : undefined}
307
+ {@const selected = editor.selection.includes(clip.id)}
308
+ <div
309
+ class="absolute inset-y-1 flex select-none touch-none items-center gap-1 overflow-hidden rounded px-1.5 text-[11px] text-white {selected ? 'outline outline-2 outline-offset-1 outline-rose-500' : ''} {item.dragging ? 'opacity-80 shadow-lg' : ''} {canEdit && !track.locked ? 'cursor-grab' : 'cursor-pointer'}"
310
+ style="left:{x(item.start)}px;width:{Math.max(2, x(item.duration))}px;background:{videoClipTint(clip, asset)}"
311
+ role="button"
312
+ tabindex="-1"
313
+ aria-pressed={selected}
314
+ title="{videoClipLabel(clip, asset)} · {formatVideoTimecode(item.start, project.fps)}–{formatVideoTimecode(item.start + item.duration, project.fps)}"
315
+ data-clip={clip.id}
316
+ data-selected={selected ? '' : undefined}
317
+ data-dragging={item.dragging ? '' : undefined}
318
+ onpointerdown={(e) => onclipdown(e, clip, track)}
319
+ >
320
+ {#if clip.transition}
321
+ <span class="shrink-0 rounded bg-black/40 px-1 text-[9px]" title="{clip.transition.type} {clip.transition.duration} ms" data-transition={clip.transition.type}>⋈</span>
322
+ {/if}
323
+ <span class="truncate drop-shadow">{videoClipLabel(clip, asset)}</span>
324
+ {#if canEdit && !track.locked}
325
+ <div class="absolute inset-y-0 left-0 w-1.5 cursor-ew-resize bg-white/0 hover:bg-white/40" data-edge="start" aria-hidden="true"></div>
326
+ <div class="absolute inset-y-0 right-0 w-1.5 cursor-ew-resize bg-white/0 hover:bg-white/40" data-edge="end" aria-hidden="true"></div>
327
+ {/if}
328
+ </div>
329
+ {/each}
330
+ </div>
331
+ </div>
332
+ {/each}
333
+ <div class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-rose-500" style="left:{HEADER + x(editor.time)}px" data-playhead=""></div>
334
+ </div>
335
+ </div>
336
+ {#if editor.error}
337
+ <p class="text-red-500" role="alert" data-edit-error="">{editor.error}</p>
338
+ {/if}
339
+ </div>
@@ -0,0 +1,15 @@
1
+ import { type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ /** Zoom: pixels per second of timeline (default 100). Bindable. */
5
+ pxPerSecond?: number;
6
+ /** Called when the zoom control changes `pxPerSecond`. */
7
+ onpxpersecondchange?: (pxPerSecond: number) => void;
8
+ /** Allow moving and trimming clips, the track toggles and the edit keys (default true). False still scrubs, selects and plays. */
9
+ editable?: boolean;
10
+ class?: string;
11
+ }
12
+ declare const VideoTimeline: import("svelte").Component<Props, {}, "pxPerSecond">;
13
+ type VideoTimeline = ReturnType<typeof VideoTimeline>;
14
+ export default VideoTimeline;
15
+ //# sourceMappingURL=VideoTimeline.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoTimeline.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/VideoTimeline.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAYH,KAAK,qBAAqB,EAE3B,MAAM,+BAA+B,CAAC;AAKvC,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,kIAAkI;IAClI,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyQH,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -0,0 +1,37 @@
1
+ <script lang="ts">
2
+ import { formatVideoTimecode, type VideoEditorController } from '@pixerate/editor/video-editor';
3
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
4
+
5
+ interface Props {
6
+ controller: VideoEditorController;
7
+ /** Show the editing buttons: split, delete, ripple delete, undo and redo (default true). */
8
+ editable?: boolean;
9
+ class?: string;
10
+ }
11
+
12
+ let { controller, editable = true, class: className = '' }: Props = $props();
13
+ const view = videoEditorStateOf(() => controller);
14
+ const editor = $derived(view.current);
15
+ const fps = $derived(editor.project.fps);
16
+ const btn = 'rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40';
17
+ const locked = $derived(editor.readOnly);
18
+ </script>
19
+
20
+ <!-- Play / pause, frame steps, the playhead's timecode, and split, delete, undo and redo. -->
21
+ <div class="flex flex-wrap items-center gap-1.5 text-xs {className}" data-video-transport="">
22
+ <button type="button" class={btn} aria-label="Step back one frame" onclick={() => controller.step(-1)} data-step-back="">◀︎|</button>
23
+ <button type="button" class={btn} aria-label={editor.playing ? 'Pause' : 'Play'} onclick={() => controller.toggle()} disabled={editor.duration <= 0} data-play="">
24
+ {editor.playing ? 'Pause' : 'Play'}
25
+ </button>
26
+ <button type="button" class={btn} aria-label="Step forward one frame" onclick={() => controller.step(1)} data-step-forward="">|▶︎</button>
27
+ <span class="tabular-nums text-muted-foreground" data-timecode="">{formatVideoTimecode(editor.time, fps)} / {formatVideoTimecode(editor.duration, fps)}</span>
28
+ {#if editable}
29
+ <span class="ml-auto flex flex-wrap gap-1">
30
+ <button type="button" class={btn} disabled={locked} onclick={() => controller.splitAtPlayhead()} title="Split at the playhead (S)" data-split="">Split</button>
31
+ <button type="button" class={btn} disabled={locked || !editor.selection.length} onclick={() => controller.deleteSelection()} title="Delete the selected clips (Delete)" data-delete="">Delete</button>
32
+ <button type="button" class={btn} disabled={locked || !editor.selection.length} onclick={() => controller.deleteSelection({ ripple: true })} title="Delete and close the gaps (Shift+Delete)" data-ripple-delete="">Ripple delete</button>
33
+ <button type="button" class={btn} disabled={locked || !editor.canUndo} onclick={() => controller.undo()} title={editor.canUndo ? `Undo ${editor.undoLabel}` : 'Nothing to undo'} data-undo="">Undo</button>
34
+ <button type="button" class={btn} disabled={locked || !editor.canRedo} onclick={() => controller.redo()} title={editor.canRedo ? `Redo ${editor.redoLabel}` : 'Nothing to redo'} data-redo="">Redo</button>
35
+ </span>
36
+ {/if}
37
+ </div>
@@ -0,0 +1,11 @@
1
+ import { type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ /** Show the editing buttons: split, delete, ripple delete, undo and redo (default true). */
5
+ editable?: boolean;
6
+ class?: string;
7
+ }
8
+ declare const VideoTransport: import("svelte").Component<Props, {}, "">;
9
+ type VideoTransport = ReturnType<typeof VideoTransport>;
10
+ export default VideoTransport;
11
+ //# sourceMappingURL=VideoTransport.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VideoTransport.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/VideoTransport.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAAuB,KAAK,qBAAqB,EAAE,MAAM,+BAA+B,CAAC;AAI9F,UAAU,KAAK;IACb,UAAU,EAAE,qBAAqB,CAAC;IAClC,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAmCH,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -0,0 +1,40 @@
1
+ import { VideoEditorController, type VideoEditorOptions, type VideoEditorState, type VideoEditorToolProviderOptions, type VideoEditResult } from "@pixerate/editor/video-editor";
2
+ import type { ToolRegistry } from "@pixerate/editor/tools";
3
+ export interface SvelteVideoEditor {
4
+ readonly controller: VideoEditorController;
5
+ /** The controller's state (reactive, including the playhead while playing). */
6
+ readonly state: VideoEditorState;
7
+ play(): void;
8
+ pause(): void;
9
+ toggle(): void;
10
+ /** Moves the playhead (ms). */
11
+ seek(time: number): void;
12
+ /** Moves the playhead by whole frames (negative steps back), pausing. */
13
+ step(frames: number): void;
14
+ undo(): void;
15
+ redo(): void;
16
+ /** Splits the selected clips (or every clip) under the playhead, as one undo step. */
17
+ splitAtPlayhead(): VideoEditResult;
18
+ /** Deletes the selected clips; `ripple` closes the gaps they leave. */
19
+ deleteSelection(options?: {
20
+ ripple?: boolean;
21
+ }): VideoEditResult;
22
+ /** Stops listening to the controller (and destroys it when this rune created it). */
23
+ destroy(): void;
24
+ }
25
+ /**
26
+ * Svelte 5 rune: subscribes to a `VideoEditorController` (or creates one from options) and
27
+ * exposes its state reactively with bound actions.
28
+ */
29
+ export declare function createVideoEditor(controllerOrOptions?: VideoEditorController | VideoEditorOptions): SvelteVideoEditor;
30
+ /** Reactive state of a (possibly replaced) controller prop, for use inside components. */
31
+ export declare function videoEditorStateOf(getController: () => VideoEditorController): {
32
+ readonly current: VideoEditorState;
33
+ };
34
+ /**
35
+ * Registers the video editor's agent tools (`get_video_project`, `add_video_clip`,
36
+ * `render_video_frame`, …) for the lifetime of the calling component (call it during
37
+ * initialisation). Pass a getter to follow a changing registry.
38
+ */
39
+ export declare function useVideoEditorTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoEditorToolProviderOptions): void;
40
+ //# sourceMappingURL=createVideoEditor.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createVideoEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/video-editor/createVideoEditor.svelte.ts"],"names":[],"mappings":"AACA,OAAO,EACL,qBAAqB,EAErB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,8BAA8B,EACnC,KAAK,eAAe,EACrB,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE3D,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,UAAU,EAAE,qBAAqB,CAAC;IAC3C,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;IACjC,IAAI,IAAI,IAAI,CAAC;IACb,KAAK,IAAI,IAAI,CAAC;IACd,MAAM,IAAI,IAAI,CAAC;IACf,+BAA+B;IAC/B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,yEAAyE;IACzE,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,IAAI,IAAI,IAAI,CAAC;IACb,IAAI,IAAI,IAAI,CAAC;IACb,sFAAsF;IACtF,eAAe,IAAI,eAAe,CAAC;IACnC,uEAAuE;IACvE,eAAe,CAAC,OAAO,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,eAAe,CAAC;IACjE,qFAAqF;IACrF,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,mBAAmB,GAAE,qBAAqB,GAAG,kBAAuB,GAAG,iBAAiB,CA0BzH;AAED,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,qBAAqB,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAA;CAAE,CAgBrH;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GAAG,CAAC,MAAM,YAAY,GAAG,IAAI,GAAG,SAAS,CAAC,EACnF,UAAU,EAAE,qBAAqB,EACjC,OAAO,GAAE,8BAAmC,GAC3C,IAAI,CAMN"}
@@ -0,0 +1,63 @@
1
+ import { untrack } from "svelte";
2
+ import { VideoEditorController, createVideoEditorToolProvider, } from "@pixerate/editor/video-editor";
3
+ /**
4
+ * Svelte 5 rune: subscribes to a `VideoEditorController` (or creates one from options) and
5
+ * exposes its state reactively with bound actions.
6
+ */
7
+ export function createVideoEditor(controllerOrOptions = {}) {
8
+ const owned = !(controllerOrOptions instanceof VideoEditorController);
9
+ const controller = controllerOrOptions instanceof VideoEditorController ? controllerOrOptions : new VideoEditorController(controllerOrOptions);
10
+ let state = $state.raw(controller.getState());
11
+ const unsubscribe = controller.subscribe((next) => {
12
+ state = next;
13
+ });
14
+ return {
15
+ controller,
16
+ get state() {
17
+ return state;
18
+ },
19
+ play: () => controller.play(),
20
+ pause: () => controller.pause(),
21
+ toggle: () => controller.toggle(),
22
+ seek: (time) => controller.seek(time),
23
+ step: (frames) => controller.step(frames),
24
+ undo: () => void controller.undo(),
25
+ redo: () => void controller.redo(),
26
+ splitAtPlayhead: () => controller.splitAtPlayhead(),
27
+ deleteSelection: (options) => controller.deleteSelection(options),
28
+ destroy: () => {
29
+ unsubscribe();
30
+ if (owned)
31
+ controller.destroy();
32
+ },
33
+ };
34
+ }
35
+ /** Reactive state of a (possibly replaced) controller prop, for use inside components. */
36
+ export function videoEditorStateOf(getController) {
37
+ let view = $state.raw(untrack(() => getController().getState()));
38
+ $effect(() => {
39
+ const controller = getController();
40
+ view = controller.getState();
41
+ return untrack(() => controller.subscribe((next) => {
42
+ view = next;
43
+ }));
44
+ });
45
+ return {
46
+ get current() {
47
+ return view;
48
+ },
49
+ };
50
+ }
51
+ /**
52
+ * Registers the video editor's agent tools (`get_video_project`, `add_video_clip`,
53
+ * `render_video_frame`, …) for the lifetime of the calling component (call it during
54
+ * initialisation). Pass a getter to follow a changing registry.
55
+ */
56
+ export function useVideoEditorTools(registry, controller, options = {}) {
57
+ $effect(() => {
58
+ const target = typeof registry === "function" ? registry() : registry;
59
+ if (!target)
60
+ return;
61
+ return target.register(createVideoEditorToolProvider(controller, options));
62
+ });
63
+ }
@@ -0,0 +1,21 @@
1
+ import type { ComponentProps } from "svelte";
2
+ import type VideoEditorComponent from "./VideoEditor.svelte";
3
+ import type VideoPreviewComponent from "./VideoPreview.svelte";
4
+ import type VideoTransportComponent from "./VideoTransport.svelte";
5
+ import type VideoTimelineComponent from "./VideoTimeline.svelte";
6
+ import type ClipInspectorComponent from "./ClipInspector.svelte";
7
+ import type MediaBinComponent from "./MediaBin.svelte";
8
+ export { default as VideoEditor } from "./VideoEditor.svelte";
9
+ export { default as VideoPreview } from "./VideoPreview.svelte";
10
+ export { default as VideoTransport } from "./VideoTransport.svelte";
11
+ export { default as VideoTimeline } from "./VideoTimeline.svelte";
12
+ export { default as ClipInspector } from "./ClipInspector.svelte";
13
+ export { default as MediaBin } from "./MediaBin.svelte";
14
+ export * from "./createVideoEditor.svelte.js";
15
+ export type VideoEditorProps = ComponentProps<typeof VideoEditorComponent>;
16
+ export type VideoPreviewProps = ComponentProps<typeof VideoPreviewComponent>;
17
+ export type VideoTransportProps = ComponentProps<typeof VideoTransportComponent>;
18
+ export type VideoTimelineProps = ComponentProps<typeof VideoTimelineComponent>;
19
+ export type ClipInspectorProps = ComponentProps<typeof ClipInspectorComponent>;
20
+ export type MediaBinProps = ComponentProps<typeof MediaBinComponent>;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/video-editor/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,KAAK,oBAAoB,MAAM,sBAAsB,CAAC;AAC7D,OAAO,KAAK,qBAAqB,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,uBAAuB,MAAM,yBAAyB,CAAC;AACnE,OAAO,KAAK,sBAAsB,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,sBAAsB,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,iBAAiB,MAAM,mBAAmB,CAAC;AAEvD,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,cAAc,+BAA+B,CAAC;AAE9C,MAAM,MAAM,gBAAgB,GAAG,cAAc,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAC3E,MAAM,MAAM,iBAAiB,GAAG,cAAc,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAC7E,MAAM,MAAM,mBAAmB,GAAG,cAAc,CAAC,OAAO,uBAAuB,CAAC,CAAC;AACjF,MAAM,MAAM,kBAAkB,GAAG,cAAc,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAC/E,MAAM,MAAM,kBAAkB,GAAG,cAAc,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAC/E,MAAM,MAAM,aAAa,GAAG,cAAc,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
@@ -0,0 +1,7 @@
1
+ export { default as VideoEditor } from "./VideoEditor.svelte";
2
+ export { default as VideoPreview } from "./VideoPreview.svelte";
3
+ export { default as VideoTransport } from "./VideoTransport.svelte";
4
+ export { default as VideoTimeline } from "./VideoTimeline.svelte";
5
+ export { default as ClipInspector } from "./ClipInspector.svelte";
6
+ export { default as MediaBin } from "./MediaBin.svelte";
7
+ export * from "./createVideoEditor.svelte.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.56.1",
3
+ "version": "0.57.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -73,6 +73,11 @@
73
73
  "svelte": "./dist/chart/index.js",
74
74
  "default": "./dist/chart/index.js"
75
75
  },
76
+ "./video-editor": {
77
+ "types": "./dist/video-editor/index.d.ts",
78
+ "svelte": "./dist/video-editor/index.js",
79
+ "default": "./dist/video-editor/index.js"
80
+ },
76
81
  "./package.json": "./package.json"
77
82
  },
78
83
  "typesVersions": {
@@ -109,6 +114,9 @@
109
114
  ],
110
115
  "chart": [
111
116
  "./dist/chart/index.d.ts"
117
+ ],
118
+ "video-editor": [
119
+ "./dist/video-editor/index.d.ts"
112
120
  ]
113
121
  }
114
122
  },
@@ -123,7 +131,7 @@
123
131
  "@tiptap/extension-bubble-menu": "^2.11.5",
124
132
  "@tiptap/extension-placeholder": "^2.11.5",
125
133
  "@tiptap/starter-kit": "^2.11.5",
126
- "@pixerate/editor": "0.74.0"
134
+ "@pixerate/editor": "0.76.0"
127
135
  },
128
136
  "peerDependencies": {
129
137
  "@dagrejs/dagre": "^1.1.4",