@pixerate/editor-svelte 0.117.1 → 0.118.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.
@@ -25,6 +25,16 @@
25
25
  videoTransitionAudioCurve,
26
26
  videoKeyframeToggleEdit,
27
27
  videoKeyframeValueEdit,
28
+ videoClipHeading,
29
+ videoMaskEdit,
30
+ videoSkinProtectionEdits,
31
+ VIDEO_ADJUSTMENT_SCOPES,
32
+ VIDEO_ADJUSTMENT_SCOPE_LABELS,
33
+ VIDEO_MASK_SHAPES,
34
+ VIDEO_MASK_SHAPE_LABELS,
35
+ type VideoAdjustmentScope,
36
+ type VideoClipMask,
37
+ type VideoMaskShape,
28
38
  type VideoEdit,
29
39
  type VideoEditorController,
30
40
  type VideoFadeCurve,
@@ -166,6 +176,46 @@
166
176
  // A strength of 0 changes nothing, so it takes the LUT off.
167
177
  setLut(n > 0 ? { ...lut, strength: Math.min(1, n) } : null, n > 0 ? 'Change LUT strength' : 'Remove LUT');
168
178
  }
179
+ function skinProtection(raw: string) {
180
+ const n = Number(raw);
181
+ if (!clip || !raw.trim() || !Number.isFinite(n)) return;
182
+ const r = controller.apply(videoSkinProtectionEdits(editor.project, [clip.id], n), n > 0 ? 'Protect skin tones' : 'Stop protecting skin tones');
183
+ if (!r.ok) localError = { text: r.error, revision: editor.revision };
184
+ }
185
+ function setScope(scope: VideoAdjustmentScope) {
186
+ if (!clip || !at) return;
187
+ const target = editor.project.tracks.find((t) => t.kind === 'video' && t.id !== at.track.id)?.id;
188
+ if (scope === 'track') apply({ op: 'update_clip', clip: clip.id, set: { scope, targetTrack: (clip.type === 'adjustment' ? clip.targetTrack : undefined) ?? target ?? at.track.id } }, 'Change adjustment scope');
189
+ else apply({ op: 'update_clip', clip: clip.id, ...(scope === 'underneath' ? { remove: ['scope', 'targetTrack'] } : { set: { scope }, remove: ['targetTrack'] }) }, 'Change adjustment scope');
190
+ }
191
+ function setMaskShape(shape: VideoMaskShape | '') {
192
+ if (!clip) return;
193
+ const { points: _p, cornerRadius: _c, ...rest } = clip.mask ?? ({ shape: 'ellipse' } as VideoClipMask);
194
+ const points = [
195
+ { x: 0.5, y: 0.2 },
196
+ { x: 0.8, y: 0.8 },
197
+ { x: 0.2, y: 0.8 }
198
+ ];
199
+ apply(videoMaskEdit(clip.id, shape ? { ...rest, shape, ...(shape === 'polygon' ? { points } : {}) } : null), shape ? 'Mask clip' : 'Remove mask');
200
+ }
201
+ function setMaskValue(key: 'width' | 'height' | 'feather' | 'rotation', raw: string) {
202
+ if (!clip?.mask) return;
203
+ const n = Number(raw);
204
+ if (raw.trim() && !Number.isFinite(n)) return;
205
+ const { [key]: _old, ...rest } = clip.mask;
206
+ apply(videoMaskEdit(clip.id, raw.trim() ? { ...rest, [key]: n } : rest), 'Change mask');
207
+ }
208
+ function invertMask(on: boolean) {
209
+ if (!clip?.mask) return;
210
+ const { inverted: _i, ...rest } = clip.mask;
211
+ apply(videoMaskEdit(clip.id, on ? { ...rest, inverted: true } : rest), 'Invert mask');
212
+ }
213
+ const MASK_FIELDS = [
214
+ { key: 'width', label: 'Mask width', step: 0.05, placeholder: '0.5' },
215
+ { key: 'height', label: 'Mask height', step: 0.05, placeholder: '0.5' },
216
+ { key: 'feather', label: 'Feather (px)', step: 5, placeholder: '0' },
217
+ { key: 'rotation', label: 'Mask angle', step: 5, placeholder: '0' }
218
+ ] as const;
169
219
  function transitionAudioCurve(curve: string) {
170
220
  if (!clip?.transition) return;
171
221
  const { audioCurve: _, ...rest } = clip.transition;
@@ -198,9 +248,8 @@
198
248
  </p>
199
249
  {:else}
200
250
  <div class="flex items-center gap-1">
201
- <span class="font-medium">Clip</span>
251
+ <span class="font-medium">{videoClipHeading(clip)}</span>
202
252
  <code class="text-[10px] text-muted-foreground">{clip.id}</code>
203
- <span class="text-[10px] text-muted-foreground">{clip.type}</span>
204
253
  {#if at?.track.locked}<span class="ml-auto text-[10px] text-amber-500">Track {at.track.name ?? at.track.id} is locked</span>{/if}
205
254
  </div>
206
255
  <div class="grid grid-cols-[5.5rem_1fr] items-center gap-x-2 gap-y-1">
@@ -244,6 +293,41 @@
244
293
  {#if clip.grade?.lut}
245
294
  <span class="text-muted-foreground">LUT strength</span>
246
295
  <input class={input} type="number" min="0" max="1" step="0.05" aria-label="LUT strength" {disabled} value={clip.grade.lut.strength ?? 1} onchange={(e) => lutStrength(e.currentTarget.value)} data-field="grade.lut.strength" />
296
+ <span class="text-muted-foreground">Protect skin</span>
297
+ <input class={input} type="number" min="0" max="1" step="0.1" aria-label="Protect skin tones" title="How much of the LUT to hold back on skin tones (0 to 1)" {disabled} value={clip.grade.lut.preserveSkinTones ?? 0} onchange={(e) => skinProtection(e.currentTarget.value)} data-field="grade.lut.preserveSkinTones" />
298
+ {/if}
299
+ {/if}
300
+ {#if clip.type === 'adjustment'}
301
+ <span class="text-muted-foreground">Changes</span>
302
+ <select class={input} aria-label="Adjustment scope" {disabled} value={clip.scope ?? 'underneath'} onchange={(e) => setScope(e.currentTarget.value as VideoAdjustmentScope)} data-field="scope">
303
+ {#each VIDEO_ADJUSTMENT_SCOPES as s (s)}
304
+ <option value={s}>{VIDEO_ADJUSTMENT_SCOPE_LABELS[s]}</option>
305
+ {/each}
306
+ </select>
307
+ {#if clip.scope === 'track'}
308
+ <span class="text-muted-foreground">Track</span>
309
+ <select class={input} aria-label="Adjusted track" {disabled} value={clip.targetTrack ?? ''} onchange={(e) => set('targetTrack', e.currentTarget.value, 'Change adjusted track')} data-field="targetTrack">
310
+ {#each editor.project.tracks.filter((t) => t.kind === 'video') as t (t.id)}
311
+ <option value={t.id}>{t.name ?? t.id}</option>
312
+ {/each}
313
+ </select>
314
+ {/if}
315
+ {/if}
316
+ {#if at?.track.kind === 'video'}
317
+ <span class="text-muted-foreground">Mask</span>
318
+ <select class={input} aria-label="Mask shape" {disabled} value={clip.mask?.shape ?? ''} onchange={(e) => setMaskShape(e.currentTarget.value as VideoMaskShape | '')} data-field="mask">
319
+ <option value="">none</option>
320
+ {#each VIDEO_MASK_SHAPES as s (s)}
321
+ <option value={s}>{VIDEO_MASK_SHAPE_LABELS[s]}</option>
322
+ {/each}
323
+ </select>
324
+ {#if clip.mask}
325
+ {#each MASK_FIELDS as f (f.key)}
326
+ <span class="text-muted-foreground">{f.label}</span>
327
+ <input class={input} type="number" step={f.step} min={f.key === 'rotation' ? undefined : 0} placeholder={f.placeholder} aria-label={f.label} {disabled} value={clip.mask[f.key] ?? ''} onchange={(e) => setMaskValue(f.key, e.currentTarget.value)} data-field="mask.{f.key}" />
328
+ {/each}
329
+ <span class="text-muted-foreground">Invert mask</span>
330
+ <input type="checkbox" class="justify-self-start" aria-label="Invert mask" {disabled} checked={!!clip.mask.inverted} onchange={(e) => invertMask(e.currentTarget.checked)} data-field="mask.inverted" />
247
331
  {/if}
248
332
  {/if}
249
333
  {#if at?.track.kind === 'video'}
@@ -325,7 +409,7 @@
325
409
  {:else if clip.type === 'effect'}
326
410
  <span class="text-muted-foreground">Effect</span>
327
411
  <span class="truncate" title="Change its controls in the Effects panel" data-field="effect">{clip.name ?? clip.effect}</span>
328
- {:else}
412
+ {:else if clip.type === 'color'}
329
413
  <span class="text-muted-foreground">Colour</span>
330
414
  <input class={input} aria-label="Clip colour" {disabled} value={clip.color} onchange={(e) => text('color', e.currentTarget.value, 'Change colour', true)} data-field="color" />
331
415
  {/if}
@@ -0,0 +1,72 @@
1
+ <script lang="ts">
2
+ import { describeVideoEditPlan, formatVideoSeconds, type VideoEditorController } from '@pixerate/editor/video-editor';
3
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
4
+
5
+ interface Props {
6
+ controller: VideoEditorController;
7
+ /** Allow accepting plans (default true; discarding is always allowed). */
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 total = $derived(Math.max(1, editor.duration));
16
+ let message = $state<string | null>(null);
17
+ const btn = 'rounded border border-border px-2 py-0.5 text-[11px] disabled:opacity-40';
18
+
19
+ function accept(id: string) {
20
+ const r = controller.applyPlan(id);
21
+ message = r.ok ? null : r.error;
22
+ }
23
+ function discard(id: string) {
24
+ controller.discardPlan(id);
25
+ message = null;
26
+ }
27
+ </script>
28
+
29
+ <!--
30
+ Proposed edit plans waiting for review: title, what each would do (length change, clips added,
31
+ changed and removed, new problems), a strip of the stretches it cuts (click to go there) and
32
+ Accept / Discard. Accepting applies the plan as one undo step. Renders nothing without plans.
33
+ -->
34
+ {#if editor.plans.length}
35
+ <section class="flex flex-col gap-2 rounded-md border border-amber-500/50 bg-amber-500/5 p-2 text-xs {className}" aria-label="Proposed edits" data-edit-plans="">
36
+ {#each editor.plans as plan (plan.id)}
37
+ {@const s = plan.summary}
38
+ {@const stale = editor.revision >= 0 && controller.isPlanStale(plan.id)}
39
+ <article class="flex flex-col gap-1" data-edit-plan={plan.id}>
40
+ <div class="flex items-center gap-2">
41
+ <h3 class="flex-1 font-semibold">{plan.title}</h3>
42
+ <span class="tabular-nums {s.durationDelta < 0 ? 'text-emerald-600' : 'text-muted-foreground'}" data-plan-delta="">
43
+ {s.durationDelta < 0 ? '−' : s.durationDelta > 0 ? '+' : '±'}{formatVideoSeconds(Math.abs(s.durationDelta))}
44
+ </span>
45
+ </div>
46
+ {#if plan.description}<p class="text-muted-foreground">{plan.description}</p>{/if}
47
+ <p class="text-muted-foreground" data-plan-summary="">{describeVideoEditPlan(plan)} · {formatVideoSeconds(s.durationBefore)} → {formatVideoSeconds(s.durationAfter)}</p>
48
+ {#if plan.ranges?.length}
49
+ <div class="relative h-3 w-full overflow-hidden rounded bg-muted" aria-label="Stretches the plan cuts" data-plan-strip="">
50
+ {#each plan.ranges as r, i (i)}
51
+ <button
52
+ type="button"
53
+ class="absolute top-0 h-full bg-rose-500/70 hover:bg-rose-600"
54
+ style="left: {(r.from / total) * 100}%; width: {Math.max(0.4, ((r.to - r.from) / total) * 100)}%"
55
+ title={`${r.reason ? `${r.reason}: ` : ''}${r.text ?? ''} (${formatVideoSeconds(r.from)})`}
56
+ aria-label={`Go to ${r.reason ?? 'cut'} at ${formatVideoSeconds(r.from)}`}
57
+ onclick={() => controller.seek(r.from)}
58
+ ></button>
59
+ {/each}
60
+ </div>
61
+ {/if}
62
+ {#if s.newIssues.length}<p class="text-amber-600">It would add {s.newIssues.length} problem{s.newIssues.length > 1 ? 's' : ''}: {s.newIssues[0].message}</p>{/if}
63
+ {#if stale}<p class="text-amber-600" data-plan-stale="">The project changed since this plan was made; it is checked again when accepted.</p>{/if}
64
+ <div class="flex gap-1">
65
+ <button type="button" class={btn} disabled={!editable || editor.readOnly} onclick={() => accept(plan.id)} data-accept-plan="">Accept plan</button>
66
+ <button type="button" class={btn} onclick={() => discard(plan.id)} data-discard-plan="">Discard</button>
67
+ </div>
68
+ </article>
69
+ {/each}
70
+ {#if message}<p class="text-red-500" role="alert" data-plan-error="">{message}</p>{/if}
71
+ </section>
72
+ {/if}
@@ -0,0 +1,10 @@
1
+ import { type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ /** Allow accepting plans (default true; discarding is always allowed). */
5
+ editable?: boolean;
6
+ class?: string;
7
+ }
8
+ declare const EditPlanPreview: import("svelte").Component<Props, {}, "">;
9
+ type EditPlanPreview = ReturnType<typeof EditPlanPreview>;
10
+ export default EditPlanPreview;
@@ -0,0 +1,105 @@
1
+ <script lang="ts">
2
+ import type { VideoDisfluencyKind, VideoEditorController } from '@pixerate/editor/video-editor';
3
+ import { transcriptVideoOf } from './createVideoEditor.svelte.js';
4
+
5
+ interface Props {
6
+ controller: VideoEditorController;
7
+ /** Track whose words to show (default: the first audio track with a transcribed clip, else the first video track with one). */
8
+ track?: string;
9
+ /** Deleting words proposes a plan for review instead of cutting at once (default false). */
10
+ planFirst?: boolean;
11
+ /** Allow cutting by text (default true). */
12
+ editable?: boolean;
13
+ class?: string;
14
+ }
15
+
16
+ let { controller, track, planFirst = false, editable = true, class: className = '' }: Props = $props();
17
+ const view = transcriptVideoOf(() => controller, () => ({ track, planFirst }));
18
+ const ts = $derived(view.current);
19
+ const canEdit = $derived(editable && ts.editable);
20
+ let anchor = $state<number | null>(null);
21
+ const btn = 'rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40';
22
+ const CHIP: Record<VideoDisfluencyKind, string> = {
23
+ filler: 'bg-amber-400/30 text-amber-700 dark:text-amber-300',
24
+ pause: 'bg-sky-400/25 text-sky-700 dark:text-sky-300',
25
+ repetition: 'bg-violet-400/25 text-violet-700 dark:text-violet-300'
26
+ };
27
+ const KIND_LABEL: Record<VideoDisfluencyKind, string> = { filler: 'filler', pause: 'pause', repetition: 'repeat' };
28
+
29
+ function pick(e: MouseEvent, i: number) {
30
+ const t = view.transcript;
31
+ if (e.shiftKey && anchor !== null) t.select(anchor, i);
32
+ else {
33
+ anchor = i;
34
+ t.select(i);
35
+ t.seekToWord(i);
36
+ }
37
+ }
38
+ function onKey(e: KeyboardEvent) {
39
+ if (canEdit && ts.selection && (e.key === 'Delete' || e.key === 'Backspace')) {
40
+ e.preventDefault();
41
+ view.transcript.deleteSelection();
42
+ }
43
+ }
44
+ </script>
45
+
46
+ <!--
47
+ Edit by text, with disfluencies marked: the word under the playhead highlighted as it plays,
48
+ fillers, long pauses and stutters marked and listed as chips that cut each one out with a click.
49
+ Click a word to go to it, Shift-click to select a run, Delete to cut it (or propose the cut with
50
+ planFirst). "Plan cleanup" proposes every disfluency as one plan for review (see EditPlanPreview).
51
+ -->
52
+ {#if !ts.words.length}
53
+ <p class="text-xs text-muted-foreground {className}" data-transcript-editor="" data-transcript-empty="">
54
+ No transcribed media on the timeline. Attach a transcript (Whisper JSON, .srt or .vtt) to edit by text.
55
+ </p>
56
+ {:else}
57
+ <div class="flex flex-col gap-2 text-xs {className}" data-transcript-editor="">
58
+ <div class="max-h-56 overflow-y-auto leading-6" role="listbox" aria-label="Transcript" aria-multiselectable="true" tabindex="0" onkeydown={onKey}>
59
+ {#each ts.words as w, i (`${w.clip}:${w.index}`)}
60
+ {@const selected = !!ts.selection && i >= ts.selection[0] && i <= ts.selection[1]}
61
+ {@const kinds = ts.flags[i]}
62
+ <span
63
+ role="option"
64
+ aria-selected={selected}
65
+ tabindex="-1"
66
+ class="cursor-pointer rounded px-0.5 {selected ? 'bg-rose-500/30' : i === ts.current ? 'bg-amber-400/50' : kinds?.length ? CHIP[kinds[0]] : 'hover:bg-muted'}"
67
+ data-word={i}
68
+ data-current={i === ts.current ? '' : undefined}
69
+ data-selected={selected ? '' : undefined}
70
+ data-disfluency={kinds?.join(' ') || undefined}
71
+ title={kinds?.length ? kinds.map((k) => KIND_LABEL[k]).join(', ') : undefined}
72
+ onclick={(e) => pick(e, i)}
73
+ onkeydown={() => {}}>{w.word}</span
74
+ >{#if kinds?.includes('pause')}<span class="mx-0.5 text-sky-500" aria-hidden="true">⏸</span>{:else}{' '}{/if}
75
+ {/each}
76
+ </div>
77
+ {#if canEdit}
78
+ <div class="flex flex-wrap items-center gap-1">
79
+ <button type="button" class={btn} disabled={!ts.selection} onclick={() => view.transcript.deleteSelection()} data-delete-words="">
80
+ {planFirst ? 'Propose deleting words' : 'Delete words'}
81
+ </button>
82
+ <button type="button" class={btn} disabled={!ts.disfluencies.length} onclick={() => view.transcript.planCleanup()} data-plan-cleanup="">
83
+ Plan cleanup ({ts.disfluencies.length})
84
+ </button>
85
+ </div>
86
+ {/if}
87
+ {#if ts.disfluencies.length}
88
+ <ul class="flex flex-wrap gap-1" aria-label="Disfluencies" data-disfluencies="">
89
+ {#each ts.disfluencies as d, i (`${d.kind}:${d.from}`)}
90
+ <li class="flex items-center gap-1 rounded px-1 {CHIP[d.kind]}" data-disfluency-chip={d.kind}>
91
+ <button type="button" class="tabular-nums" title="Go to it" onclick={() => controller.seek(d.from)}>
92
+ {KIND_LABEL[d.kind]}: {d.kind === 'pause' ? `${((d.to - d.from) / 1000).toFixed(1)} s` : d.text}
93
+ </button>
94
+ {#if canEdit}
95
+ <button type="button" aria-label={`Cut ${KIND_LABEL[d.kind]} ${d.text}`} onclick={() => view.transcript.removeDisfluency(i)} data-cut-disfluency={i}>✕</button>
96
+ {/if}
97
+ </li>
98
+ {/each}
99
+ </ul>
100
+ {/if}
101
+ {#if ts.error}
102
+ <p class="text-red-500" role="alert" data-transcript-error="">{ts.error}</p>
103
+ {/if}
104
+ </div>
105
+ {/if}
@@ -0,0 +1,14 @@
1
+ import type { VideoEditorController } from '@pixerate/editor/video-editor';
2
+ interface Props {
3
+ controller: VideoEditorController;
4
+ /** Track whose words to show (default: the first audio track with a transcribed clip, else the first video track with one). */
5
+ track?: string;
6
+ /** Deleting words proposes a plan for review instead of cutting at once (default false). */
7
+ planFirst?: boolean;
8
+ /** Allow cutting by text (default true). */
9
+ editable?: boolean;
10
+ class?: string;
11
+ }
12
+ declare const TranscriptEditor: import("svelte").Component<Props, {}, "">;
13
+ type TranscriptEditor = ReturnType<typeof TranscriptEditor>;
14
+ export default TranscriptEditor;
@@ -5,6 +5,8 @@
5
5
  import ClipInspector from './ClipInspector.svelte';
6
6
  import MediaBin from './MediaBin.svelte';
7
7
  import TranscriptPanel from './TranscriptPanel.svelte';
8
+ import TranscriptEditor from './TranscriptEditor.svelte';
9
+ import EditPlanPreview from './EditPlanPreview.svelte';
8
10
  import VideoPreview from './VideoPreview.svelte';
9
11
  import VideoScopes from './VideoScopes.svelte';
10
12
  import VideoTimeline from './VideoTimeline.svelte';
@@ -30,6 +32,12 @@
30
32
  showIssues?: boolean;
31
33
  /** Show the transcript panel for editing by text (default true). */
32
34
  showTranscript?: boolean;
35
+ /**
36
+ * How the transcript is shown: `panel` (default) is `TranscriptPanel`, which also loads
37
+ * transcript files; `editor` marks fillers, pauses and stutters and plans cleanups for review
38
+ * (`TranscriptEditor`).
39
+ */
40
+ transcriptMode?: 'editor' | 'panel';
33
41
  /** Show colour scopes (histogram, luma waveform, vectorscope) of the preview under it (default false). */
34
42
  showScopes?: boolean;
35
43
  /**
@@ -47,7 +55,7 @@
47
55
  onplaybackreveal?: () => void | Promise<void>;
48
56
  }
49
57
 
50
- let { controller, options, editable = true, pxPerSecond = $bindable(100), showIssues = true, showTranscript = true, showScopes = false, effects, showEffects, class: className = '', playback, onplaybackreveal }: Props = $props();
58
+ let { controller, options, editable = true, pxPerSecond = $bindable(100), showIssues = true, showTranscript = true, transcriptMode = 'panel', showScopes = false, effects, showEffects, class: className = '', playback, onplaybackreveal }: Props = $props();
51
59
  let owned: VideoEditorController | null = null;
52
60
  // A controller is made only when none is passed, and kept for the component's lifetime.
53
61
  const ctl = $derived(controller ?? (owned ??= new VideoEditorController(options)));
@@ -55,7 +63,7 @@
55
63
  const view = videoEditorStateOf(() => ctl);
56
64
  const editor = $derived(view.current);
57
65
  useEffects(() => ctl, () => effects);
58
- const effectsShown = $derived(showEffects ?? !!(editor.effectsProvider || editor.project.tracks.some((t) => t.clips.some((c) => c.type === 'effect' || c.effects?.length || c.transition?.type === 'effect'))));
66
+ const effectsShown = $derived(showEffects ?? !!(editor.effectsProvider || editor.project.tracks.some((t) => t.clips.some((c) => c.type === 'effect' || c.type === 'adjustment' || c.effects?.length || c.transition?.type === 'effect'))));
59
67
  </script>
60
68
 
61
69
  {#snippet issueList()}
@@ -79,7 +87,8 @@
79
87
  <VideoTransport controller={ctl} editable={false} />
80
88
  {#if showScopes}<VideoScopes controller={ctl} />{/if}
81
89
  <VideoTimeline controller={ctl} bind:pxPerSecond editable={false} {playback} {onplaybackreveal} />
82
- {#if showTranscript}<TranscriptPanel controller={ctl} editable={false} />{/if}
90
+ <EditPlanPreview controller={ctl} editable={false} />
91
+ {#if showTranscript}{#if transcriptMode === 'editor'}<TranscriptEditor controller={ctl} editable={false} />{:else}<TranscriptPanel controller={ctl} editable={false} />{/if}{/if}
83
92
  {@render issueList()}
84
93
  </div>
85
94
  {:else}
@@ -96,7 +105,8 @@
96
105
  </div>
97
106
  <div class="flex min-w-0 flex-col gap-2 lg:col-span-3">
98
107
  <VideoTimeline controller={ctl} bind:pxPerSecond {playback} {onplaybackreveal} />
99
- {#if showTranscript}<TranscriptPanel controller={ctl} />{/if}
108
+ <EditPlanPreview controller={ctl} />
109
+ {#if showTranscript}{#if transcriptMode === 'editor'}<TranscriptEditor controller={ctl} />{:else}<TranscriptPanel controller={ctl} />{/if}{/if}
100
110
  {@render issueList()}
101
111
  </div>
102
112
  </div>
@@ -17,6 +17,12 @@ interface Props {
17
17
  showIssues?: boolean;
18
18
  /** Show the transcript panel for editing by text (default true). */
19
19
  showTranscript?: boolean;
20
+ /**
21
+ * How the transcript is shown: `panel` (default) is `TranscriptPanel`, which also loads
22
+ * transcript files; `editor` marks fillers, pauses and stutters and plans cleanups for review
23
+ * (`TranscriptEditor`).
24
+ */
25
+ transcriptMode?: 'editor' | 'panel';
20
26
  /** Show colour scopes (histogram, luma waveform, vectorscope) of the preview under it (default false). */
21
27
  showScopes?: boolean;
22
28
  /**
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
- import { findVideoClip, isVideoKeyframes, type VideoEditorController, type VideoEffectEditResult, type VideoEffectValues } from '@pixerate/editor/video-editor';
2
+ import { findVideoClip, isVideoKeyframes, openVideoClipEffectAuthoring, type VideoClipEffectAuthoring, type VideoEditorController, type VideoEffectEditResult, type VideoEffectValues } from '@pixerate/editor/video-editor';
3
+ import { onDestroy } from 'svelte';
3
4
  import EffectsBrowser from '../effects/EffectsBrowser.svelte';
5
+ import EffectAuthoringEditor from '../effects/EffectAuthoringEditor.svelte';
4
6
  import EffectControls from './EffectControls.svelte';
5
7
  import { videoEditorStateOf } from './createVideoEditor.svelte.js';
6
8
 
@@ -35,6 +37,25 @@
35
37
  );
36
38
  }
37
39
  const merge = (gesture: string | null) => ({ merge: gesture ?? undefined });
40
+ let authoring = $state.raw<VideoClipEffectAuthoring | null>(null);
41
+ function closeAuthoring() {
42
+ authoring?.dispose();
43
+ authoring = null;
44
+ }
45
+ function author(clipId: string, instance: string) {
46
+ closeAuthoring();
47
+ openVideoClipEffectAuthoring(controller, clipId, instance).then(
48
+ (a) => (authoring = a),
49
+ (err: unknown) => (message = err instanceof Error ? err.message : String(err))
50
+ );
51
+ }
52
+ async function commitAuthoring() {
53
+ if (!authoring) return;
54
+ const r = await authoring.commit();
55
+ message = r.ok ? (r.notes?.join(' ') ?? null) : r.error;
56
+ if (r.ok) closeAuthoring();
57
+ }
58
+ onDestroy(closeAuthoring);
38
59
  </script>
39
60
 
40
61
  <!--
@@ -115,6 +136,7 @@
115
136
  <button type="button" class="min-w-0 flex-1 truncate text-left" aria-expanded={open === e.id} onclick={() => (open = open === e.id ? null : e.id)}>{label}</button>
116
137
  <button type="button" class={btn} disabled={disabled || i === 0} aria-label="Move up" onclick={() => report(controller.updateClipEffect(clip!.id, e.id, { index: i - 1 }))}>↑</button>
117
138
  <button type="button" class={btn} disabled={disabled || i === count - 1} aria-label="Move down" onclick={() => report(controller.updateClipEffect(clip!.id, e.id, { index: i + 1 }))}>↓</button>
139
+ <button type="button" class={btn} disabled={disabled || !provider} aria-label="Author {label}" title="Tune and keyframe it in the effect authoring editor" onclick={() => author(clip!.id, e.id)} data-author-effect={e.id}>✎</button>
118
140
  <button type="button" class={btn} {disabled} aria-label="Remove {label}" onclick={() => report(controller.removeClipEffect(clip!.id, e.id))}>✕</button>
119
141
  </div>
120
142
  {#if open === e.id}
@@ -150,5 +172,14 @@
150
172
  {#if !clip.effects?.length}<p class="text-[11px] text-muted-foreground">No effects on this clip.</p>{/if}
151
173
  </div>
152
174
  {/if}
175
+ {#if authoring}
176
+ <div class="space-y-1 rounded border border-border p-1" data-effect-authoring={authoring.instance}>
177
+ <EffectAuthoringEditor session={authoring.session} showSource={false} previewSize={{ width: 240, height: 135 }} />
178
+ <div class="flex gap-1">
179
+ <button type="button" class={btn} {disabled} onclick={commitAuthoring} data-apply-authoring="">Apply to clip</button>
180
+ <button type="button" class={btn} onclick={closeAuthoring}>Close</button>
181
+ </div>
182
+ </div>
183
+ {/if}
153
184
  {#if message}<p role="alert" class="text-[11px] text-destructive">{message}</p>{/if}
154
185
  </section>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { formatVideoTimecode, parseVideoPreviewScale, VIDEO_PREVIEW_SCALE_OPTIONS, videoProjectHasProxies, type VideoEditorController } from '@pixerate/editor/video-editor';
2
+ import { formatVideoTimecode, parseVideoPreviewScale, VIDEO_PREVIEW_SCALE_OPTIONS, videoAddAdjustmentEdits, videoProjectHasProxies, type VideoEditorController } from '@pixerate/editor/video-editor';
3
3
  import { videoEditorStateOf } from './createVideoEditor.svelte.js';
4
4
 
5
5
  interface Props {
@@ -15,9 +15,16 @@
15
15
  const fps = $derived(editor.project.fps);
16
16
  const btn = 'rounded border border-border px-2 py-0.5 text-xs disabled:opacity-40';
17
17
  const locked = $derived(editor.readOnly);
18
+ function addAdjustment() {
19
+ const r = controller.apply(videoAddAdjustmentEdits(editor.project, editor.time), 'Add adjustment clip');
20
+ if (r.ok) {
21
+ const added = r.outcomes[r.outcomes.length - 1].clip;
22
+ if (added) controller.select([added]);
23
+ }
24
+ }
18
25
  </script>
19
26
 
20
- <!-- Play / pause, frame steps, the playhead's timecode, a preview size when there are proxies, and split, delete, undo and redo. -->
27
+ <!-- Play / pause, frame steps, the playhead's timecode, a preview size when there are proxies, and split, delete, add an adjustment clip, undo and redo. -->
21
28
  <div class="flex flex-wrap items-center gap-1.5 text-xs {className}" data-video-transport="">
22
29
  <button type="button" class={btn} aria-label="Step back one frame" onclick={() => controller.step(-1)} data-step-back="">◀︎|</button>
23
30
  <button type="button" class={btn} aria-label={editor.playing ? 'Pause' : 'Play'} onclick={() => controller.toggle()} disabled={editor.duration <= 0} data-play="">
@@ -37,6 +44,7 @@
37
44
  <button type="button" class={btn} disabled={locked} onclick={() => controller.splitAtPlayhead()} title="Split at the playhead (S)" data-split="">Split</button>
38
45
  <button type="button" class={btn} disabled={locked || !editor.selection.length} onclick={() => controller.deleteSelection()} title="Delete the selected clips (Delete)" data-delete="">Delete</button>
39
46
  <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>
47
+ <button type="button" class={btn} disabled={locked} onclick={addAdjustment} title="Add an adjustment clip at the playhead over every track: its grade, effects and mask change the pictures underneath" data-add-adjustment="">Adjustment</button>
40
48
  <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>
41
49
  <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>
42
50
  </span>
@@ -1,4 +1,4 @@
1
- import { VideoEditorController, type VideoEffectsToolProviderOptions, type VideoEditorOptions, type VideoEditorState, type VideoEditorToolProviderOptions, type VideoEditResult, type VideoProject } from "@pixerate/editor/video-editor";
1
+ import { VideoEditorController, TranscriptVideoController, type TranscriptVideoOptions, type TranscriptVideoState, type VideoDialogueToolProviderOptions, type VideoMusicToolProviderOptions, type VideoEffectsToolProviderOptions, type VideoEditorOptions, type VideoEditorState, type VideoEditorToolProviderOptions, type VideoEditResult, type VideoProject } from "@pixerate/editor/video-editor";
2
2
  import type { EditorDocument } from "@pixerate/editor/persistence";
3
3
  import type { ToolRegistry } from "@pixerate/editor/tools";
4
4
  export interface SvelteVideoEditor {
@@ -47,3 +47,22 @@ export declare function useVideoEditorTools(registry: ToolRegistry | null | unde
47
47
  * read tools say so and the write tools refuse.
48
48
  */
49
49
  export declare function useVideoEffectsTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoEffectsToolProviderOptions): void;
50
+ /**
51
+ * Registers the talking-head cleanup tools (`find_video_disfluencies`, `plan_video_dialogue_cleanup`)
52
+ * for the lifetime of the calling component (skill `pixerate-talking-head-edit`).
53
+ */
54
+ export declare function useVideoDialogueTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoDialogueToolProviderOptions): void;
55
+ /**
56
+ * Registers the music and mix tools (`find_video_beats`, `plan_video_beat_snap`, `duck_video_music`,
57
+ * `measure_video_loudness`, `normalize_video_loudness`) for the lifetime of the calling component
58
+ * (skill `pixerate-bgm-alignment`).
59
+ */
60
+ export declare function useVideoMusicTools(registry: ToolRegistry | null | undefined | (() => ToolRegistry | null | undefined), controller: VideoEditorController, options?: VideoMusicToolProviderOptions): void;
61
+ /**
62
+ * Svelte 5 rune: a `TranscriptVideoController` over a video editor and its reactive state. It is
63
+ * rebuilt when the editor, track or `planFirst` change, and destroyed with the calling component.
64
+ */
65
+ export declare function transcriptVideoOf(getController: () => VideoEditorController, getOptions?: () => TranscriptVideoOptions): {
66
+ readonly current: TranscriptVideoState;
67
+ readonly transcript: TranscriptVideoController;
68
+ };
@@ -1,5 +1,5 @@
1
1
  import { untrack } from "svelte";
2
- import { VideoEditorController, createVideoEditorToolProvider, createVideoEffectsToolProvider, createVideoEditorDocument, } from "@pixerate/editor/video-editor";
2
+ import { VideoEditorController, createVideoEditorToolProvider, createVideoEffectsToolProvider, createVideoDialogueToolProvider, createVideoMusicToolProvider, TranscriptVideoController, createVideoEditorDocument, } from "@pixerate/editor/video-editor";
3
3
  /**
4
4
  * Svelte 5 rune: subscribes to a `VideoEditorController` (or creates one from options) and
5
5
  * exposes its state reactively with bound actions.
@@ -78,3 +78,62 @@ export function useVideoEffectsTools(registry, controller, options = {}) {
78
78
  return target.register(createVideoEffectsToolProvider(controller, options));
79
79
  });
80
80
  }
81
+ /**
82
+ * Registers the talking-head cleanup tools (`find_video_disfluencies`, `plan_video_dialogue_cleanup`)
83
+ * for the lifetime of the calling component (skill `pixerate-talking-head-edit`).
84
+ */
85
+ export function useVideoDialogueTools(registry, controller, options = {}) {
86
+ $effect(() => {
87
+ const target = typeof registry === "function" ? registry() : registry;
88
+ if (!target)
89
+ return;
90
+ return target.register(createVideoDialogueToolProvider(controller, options));
91
+ });
92
+ }
93
+ /**
94
+ * Registers the music and mix tools (`find_video_beats`, `plan_video_beat_snap`, `duck_video_music`,
95
+ * `measure_video_loudness`, `normalize_video_loudness`) for the lifetime of the calling component
96
+ * (skill `pixerate-bgm-alignment`).
97
+ */
98
+ export function useVideoMusicTools(registry, controller, options = {}) {
99
+ $effect(() => {
100
+ const target = typeof registry === "function" ? registry() : registry;
101
+ if (!target)
102
+ return;
103
+ return target.register(createVideoMusicToolProvider(controller, options));
104
+ });
105
+ }
106
+ /**
107
+ * Svelte 5 rune: a `TranscriptVideoController` over a video editor and its reactive state. It is
108
+ * rebuilt when the editor, track or `planFirst` change, and destroyed with the calling component.
109
+ */
110
+ export function transcriptVideoOf(getController, getOptions = () => ({})) {
111
+ const initial = untrack(() => ({ controller: getController(), ...getOptions() }));
112
+ let built = initial;
113
+ let transcript = $state.raw(new TranscriptVideoController(initial.controller, initial));
114
+ let view = $state.raw(untrack(() => transcript.getState()));
115
+ $effect(() => {
116
+ const next = { controller: getController(), ...getOptions() };
117
+ untrack(() => {
118
+ if (next.controller !== built.controller || next.track !== built.track || !!next.planFirst !== !!built.planFirst) {
119
+ transcript.destroy();
120
+ transcript = new TranscriptVideoController(next.controller, next);
121
+ built = next;
122
+ }
123
+ });
124
+ const current = untrack(() => transcript);
125
+ view = current.getState();
126
+ return current.subscribe((state) => {
127
+ view = state;
128
+ });
129
+ });
130
+ $effect(() => () => untrack(() => transcript).destroy());
131
+ return {
132
+ get current() {
133
+ return view;
134
+ },
135
+ get transcript() {
136
+ return transcript;
137
+ },
138
+ };
139
+ }
@@ -7,6 +7,8 @@ import type VideoTimelineComponent from "./VideoTimeline.svelte";
7
7
  import type ClipInspectorComponent from "./ClipInspector.svelte";
8
8
  import type MediaBinComponent from "./MediaBin.svelte";
9
9
  import type TranscriptPanelComponent from "./TranscriptPanel.svelte";
10
+ import type TranscriptEditorComponent from "./TranscriptEditor.svelte";
11
+ import type EditPlanPreviewComponent from "./EditPlanPreview.svelte";
10
12
  import type VideoEffectsPanelComponent from "./VideoEffectsPanel.svelte";
11
13
  export { default as VideoEditor } from "./VideoEditor.svelte";
12
14
  export { default as VideoPreview } from "./VideoPreview.svelte";
@@ -16,6 +18,8 @@ export { default as VideoTimeline } from "./VideoTimeline.svelte";
16
18
  export { default as ClipInspector } from "./ClipInspector.svelte";
17
19
  export { default as MediaBin } from "./MediaBin.svelte";
18
20
  export { default as TranscriptPanel } from "./TranscriptPanel.svelte";
21
+ export { default as TranscriptEditor } from "./TranscriptEditor.svelte";
22
+ export { default as EditPlanPreview } from "./EditPlanPreview.svelte";
19
23
  export { default as VideoEffectsPanel } from "./VideoEffectsPanel.svelte";
20
24
  export * from "./createVideoEditor.svelte.js";
21
25
  export type VideoEditorProps = ComponentProps<typeof VideoEditorComponent>;
@@ -26,4 +30,6 @@ export type VideoTimelineProps = ComponentProps<typeof VideoTimelineComponent>;
26
30
  export type ClipInspectorProps = ComponentProps<typeof ClipInspectorComponent>;
27
31
  export type MediaBinProps = ComponentProps<typeof MediaBinComponent>;
28
32
  export type TranscriptPanelProps = ComponentProps<typeof TranscriptPanelComponent>;
33
+ export type TranscriptEditorProps = ComponentProps<typeof TranscriptEditorComponent>;
34
+ export type EditPlanPreviewProps = ComponentProps<typeof EditPlanPreviewComponent>;
29
35
  export type VideoEffectsPanelProps = ComponentProps<typeof VideoEffectsPanelComponent>;
@@ -6,5 +6,7 @@ export { default as VideoTimeline } from "./VideoTimeline.svelte";
6
6
  export { default as ClipInspector } from "./ClipInspector.svelte";
7
7
  export { default as MediaBin } from "./MediaBin.svelte";
8
8
  export { default as TranscriptPanel } from "./TranscriptPanel.svelte";
9
+ export { default as TranscriptEditor } from "./TranscriptEditor.svelte";
10
+ export { default as EditPlanPreview } from "./EditPlanPreview.svelte";
9
11
  export { default as VideoEffectsPanel } from "./VideoEffectsPanel.svelte";
10
12
  export * from "./createVideoEditor.svelte.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.117.1",
3
+ "version": "0.118.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -160,7 +160,7 @@
160
160
  "@tiptap/extension-bubble-menu": "^2.27.3",
161
161
  "@tiptap/extension-placeholder": "^2.27.3",
162
162
  "@tiptap/starter-kit": "^2.27.3",
163
- "@pixerate/editor": "0.160.0"
163
+ "@pixerate/editor": "0.161.0"
164
164
  },
165
165
  "peerDependencies": {
166
166
  "@dagrejs/dagre": "^1.1.4",