@pixerate/editor-svelte 0.177.0 → 0.179.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.
@@ -102,6 +102,13 @@
102
102
  });
103
103
  });
104
104
 
105
+ // Live effect paint plays while the canvas is mounted (idle when nothing is live or it is paused).
106
+ $effect(() => {
107
+ const c = controller;
108
+ const player = untrack(() => c.playEffects());
109
+ return () => player.stop();
110
+ });
111
+
105
112
  // Wheel needs a non-passive listener to prevent page scroll (see GOTCHAS).
106
113
  $effect(() => {
107
114
  const el = viewport;
@@ -25,8 +25,16 @@
25
25
  effectPaint: editor.current.effectPaint,
26
26
  rendering: editor.current.effectPaintRendering,
27
27
  color: editor.current.color,
28
+ animation: controller.getAnimation(),
29
+ playback: editor.current.effectPlayback,
28
30
  };
29
31
  });
32
+ /** Paints with the effect paint again with some of it changed (the other settings kept). */
33
+ const repaint = (patch: { values?: Record<string, unknown>; map?: unknown; live?: boolean }) => {
34
+ const fx = view.effectPaint;
35
+ if (!fx) return;
36
+ run(() => controller.setEffectPaint({ effect: fx.effect, values: fx.values as never, time: fx.time, map: fx.map, live: fx.live, ...(patch as object) }));
37
+ };
30
38
  const thumbnail = $derived.by(() => {
31
39
  const { effectPaint, rendering } = view;
32
40
  return effectPaint && !rendering ? untrack(() => controller.effectPaintThumbnail(48)) : null;
@@ -129,9 +137,44 @@
129
137
  {provider}
130
138
  effect={fx.effect}
131
139
  values={fx.values}
132
- onchange={(k, v) => run(() => controller.setEffectPaint({ effect: fx.effect, values: { ...fx.values, [k]: v as never }, time: fx.time }))}
133
- onpreset={(values) => run(() => controller.setEffectPaint({ effect: fx.effect, values: { ...fx.values, ...values } as never, time: fx.time }))}
140
+ onchange={(k, v) => repaint({ values: { ...fx.values, [k]: v } })}
141
+ onpreset={(values) => repaint({ values: { ...fx.values, ...values } })}
134
142
  />
143
+ <div class="flex flex-wrap items-center gap-2" data-testid="effect-paint-placement">
144
+ <label class="flex items-center gap-1" title="Lay the effect along each stroke instead of fixing it to the canvas">
145
+ <input type="checkbox" checked={Boolean(fx.map)} onchange={(e) => repaint({ map: e.currentTarget.checked ? { by: 'stroke', length: 300, repeat: 'repeat' } : { by: 'canvas' } })} />
146
+ Along the stroke
147
+ </label>
148
+ <label class="flex items-center gap-1" title="Strokes on a stroke layer keep playing the effect">
149
+ <input type="checkbox" checked={Boolean(fx.live)} onchange={(e) => repaint({ live: e.currentTarget.checked })} />
150
+ Live
151
+ </label>
152
+ </div>
153
+ {#if fx.map}
154
+ {@const map = fx.map}
155
+ <div class="flex flex-wrap items-center gap-2">
156
+ <label class="flex items-center gap-1">
157
+ <span class="text-slate-400">Length</span>
158
+ <input type="number" aria-label="Effect length along the stroke" min="16" max="4096" value={map.length} onchange={(e) => { const length = Number(e.currentTarget.value); if (length !== map.length) repaint({ map: { ...map, length } }); }} class="w-16 rounded bg-slate-800 px-1 py-0.5" />
159
+ </label>
160
+ <select aria-label="Effect repeat" value={map.repeat} onchange={(e) => repaint({ map: { ...map, repeat: e.currentTarget.value } })} class="rounded bg-slate-800 px-1 py-0.5">
161
+ <option value="repeat">Repeat</option>
162
+ <option value="mirror">Mirror</option>
163
+ <option value="clamp">Clamp</option>
164
+ </select>
165
+ </div>
166
+ {/if}
167
+ </div>
168
+ {/if}
169
+ {#if view.animation}
170
+ {@const animation = view.animation}
171
+ <div class="flex items-center gap-2 rounded border border-slate-700 p-1" data-effect-part="playback">
172
+ <span class="flex-1 truncate">Live paint: {animation.strokes} {animation.strokes === 1 ? 'stroke' : 'strokes'}</span>
173
+ <button type="button" class={button} aria-pressed={view.playback.playing} onclick={() => attempt(() => controller.setEffectPlayback({ playing: !view.playback.playing }), (m) => (error = m))}>{view.playback.playing ? 'Pause' : 'Play'}</button>
174
+ <label class="flex items-center gap-1">
175
+ <span class="text-slate-400">fps</span>
176
+ <input type="number" aria-label="Live paint frame rate" min="1" max="60" value={view.playback.fps} onchange={(e) => attempt(() => controller.setEffectPlayback({ fps: Math.round(Number(e.currentTarget.value)) }), (m) => (error = m))} class="w-12 rounded bg-slate-800 px-1 py-0.5" />
177
+ </label>
135
178
  </div>
136
179
  {/if}
137
180
  {#if view.layerEffect && view.activeId}
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
- import type { EffectProvider } from '@pixerate/editor/effects';
3
- import { isVideoKeyframes, type VideoEffectValues } from '@pixerate/editor/video-editor';
2
+ import { pathControlsOf, type EffectProvider } from '@pixerate/editor/effects';
3
+ import { isVideoKeyframes, type VideoClip, type VideoEditorController, type VideoEffectEditResult, type VideoEffectValues } from '@pixerate/editor/video-editor';
4
+ import PathControls from './PathControls.svelte';
4
5
  import EffectParameters from '../effects/EffectParameters.svelte';
5
6
  import { effectDescriptionOf } from '../effects/effects.svelte.js';
6
7
 
@@ -12,9 +13,11 @@
12
13
  disabled: boolean;
13
14
  onchange: (control: string, value: unknown, gesture: string | null) => void;
14
15
  onpreset: (values: Readonly<Record<string, unknown>>) => void;
16
+ /** For an effect on a clip: where its path comes from, when it draws along one. */
17
+ path?: { controller: VideoEditorController; clip: VideoClip; instance: string; report: (r: VideoEffectEditResult | Promise<VideoEffectEditResult>) => void };
15
18
  }
16
19
 
17
- let { provider, effect, name, values, disabled, onchange, onpreset }: Props = $props();
20
+ let { provider, effect, name, values, disabled, onchange, onpreset, path }: Props = $props();
18
21
  const loaded = effectDescriptionOf(() => provider, () => effect);
19
22
  const keyed = $derived(new Set(Object.entries(values ?? {}).filter(([, v]) => isVideoKeyframes(v)).map(([k]) => k)));
20
23
  </script>
@@ -28,5 +31,8 @@
28
31
  <p class="text-[11px] text-muted-foreground">Loading…</p>
29
32
  {:else}
30
33
  {@const description = loaded.description}
34
+ {#if path && pathControlsOf(description).length}
35
+ <PathControls controller={path.controller} clip={path.clip} instance={path.instance} report={path.report} {provider} {disabled} />
36
+ {/if}
31
37
  <EffectParameters {description} values={values ?? {}} keyframed={keyed} {disabled} {onchange} onreset={(k) => onchange(k, description.defaults[k], null)} {onpreset} />
32
38
  {/if}
@@ -1,5 +1,5 @@
1
- import type { EffectProvider } from '@pixerate/editor/effects';
2
- import { type VideoEffectValues } from '@pixerate/editor/video-editor';
1
+ import { type EffectProvider } from '@pixerate/editor/effects';
2
+ import { type VideoClip, type VideoEditorController, type VideoEffectEditResult, type VideoEffectValues } from '@pixerate/editor/video-editor';
3
3
  interface Props {
4
4
  provider: EffectProvider | null;
5
5
  effect: string;
@@ -8,6 +8,13 @@ interface Props {
8
8
  disabled: boolean;
9
9
  onchange: (control: string, value: unknown, gesture: string | null) => void;
10
10
  onpreset: (values: Readonly<Record<string, unknown>>) => void;
11
+ /** For an effect on a clip: where its path comes from, when it draws along one. */
12
+ path?: {
13
+ controller: VideoEditorController;
14
+ clip: VideoClip;
15
+ instance: string;
16
+ report: (r: VideoEffectEditResult | Promise<VideoEffectEditResult>) => void;
17
+ };
11
18
  }
12
19
  declare const EffectControls: import("svelte").Component<Props, {}, "">;
13
20
  type EffectControls = ReturnType<typeof EffectControls>;
@@ -0,0 +1,32 @@
1
+ <script lang="ts">
2
+ import type { EffectProvider } from '@pixerate/editor/effects';
3
+ import type { VideoClip, VideoEditorController, VideoEffectEditResult } from '@pixerate/editor/video-editor';
4
+ import { videoEditorStateOf } from './createVideoEditor.svelte.js';
5
+
6
+ interface Props {
7
+ controller: VideoEditorController;
8
+ clip: VideoClip;
9
+ instance: string;
10
+ provider: EffectProvider;
11
+ disabled: boolean;
12
+ report: (r: VideoEffectEditResult | Promise<VideoEffectEditResult>) => void;
13
+ }
14
+
15
+ let { controller, clip, instance, provider, disabled, report }: Props = $props();
16
+ const view = videoEditorStateOf(() => controller);
17
+ const drawing = $derived(view.current.pathDrawing?.clip === clip.id && view.current.pathDrawing.instance === instance);
18
+ let words = $state('');
19
+ const btn = 'rounded border border-border px-1.5 py-0.5 text-[11px] disabled:opacity-40';
20
+ </script>
21
+
22
+ <!-- Where an effect's path comes from, for an effect that draws along one: drawn on the preview, a mask outline, words, or none. -->
23
+ <div class="flex flex-wrap items-center gap-1" data-effect-part="path">
24
+ <span class="text-muted-foreground">Path</span>
25
+ <button type="button" class={btn} {disabled} aria-pressed={drawing} title="Draw the path on the preview" onclick={() => (drawing ? controller.cancelPathDrawing() : controller.startPathDrawing({ clip: clip.id, instance }))}>{drawing ? 'Drawing… (Esc)' : 'Draw path'}</button>
26
+ <button type="button" class={btn} disabled={disabled || !clip.mask} title={clip.mask ? "Run it along this clip's mask outline" : 'Give the clip a mask first'} onclick={() => report(controller.setEffectPath(clip.id, instance, { mask: { clip: clip.id } }))}>Use mask outline</button>
27
+ {#if provider.pathFromText}
28
+ <input class="w-24 rounded border border-border bg-transparent px-1 py-0.5" aria-label="Words to write along the path" placeholder="Words" bind:value={words} {disabled} />
29
+ <button type="button" class={btn} disabled={disabled || !words.trim()} onclick={() => report(controller.setEffectPath(clip.id, instance, { text: words }))}>Write</button>
30
+ {/if}
31
+ <button type="button" class={btn} {disabled} onclick={() => report(controller.setEffectPath(clip.id, instance, { d: '' }))}>Clear path</button>
32
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { EffectProvider } from '@pixerate/editor/effects';
2
+ import type { VideoClip, VideoEditorController, VideoEffectEditResult } from '@pixerate/editor/video-editor';
3
+ interface Props {
4
+ controller: VideoEditorController;
5
+ clip: VideoClip;
6
+ instance: string;
7
+ provider: EffectProvider;
8
+ disabled: boolean;
9
+ report: (r: VideoEffectEditResult | Promise<VideoEffectEditResult>) => void;
10
+ }
11
+ declare const PathControls: import("svelte").Component<Props, {}, "">;
12
+ type PathControls = ReturnType<typeof PathControls>;
13
+ export default PathControls;
@@ -92,6 +92,7 @@
92
92
  {disabled}
93
93
  onchange={(k, v, g) => report(controller.updateEffectValues(c.id, 'generator', { [k]: v } as VideoEffectValues, merge(g)))}
94
94
  onpreset={(values) => report(controller.updateEffectValues(c.id, 'generator', values as VideoEffectValues))}
95
+ path={{ controller, clip: c, instance: 'generator', report }}
95
96
  />
96
97
  </div>
97
98
  {/if}
@@ -165,6 +166,7 @@
165
166
  {disabled}
166
167
  onchange={(k, v, g) => report(controller.updateClipEffect(clip!.id, e.id, { values: { [k]: v as never } }, merge(g)))}
167
168
  onpreset={(values) => report(controller.updateClipEffect(clip!.id, e.id, { values: values as never }))}
169
+ path={{ controller, clip: clip!, instance: e.id, report }}
168
170
  />
169
171
  </div>
170
172
  {/if}
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
- import { formatVideoTimecode, VideoPreviewEngine, type VideoEditorController } from '@pixerate/editor/video-editor';
3
+ import { formatVideoTimecode, VideoPathGesture, VideoPreviewEngine, type VideoEditorController } from '@pixerate/editor/video-editor';
4
4
  import { videoEditorStateOf } from './createVideoEditor.svelte.js';
5
5
 
6
6
  interface Props {
@@ -24,13 +24,50 @@
24
24
  const engine = untrack(() => new VideoPreviewEngine(source, el, options));
25
25
  return () => engine.destroy();
26
26
  });
27
+ // Drawing a path for an effect (the effects panel's Draw path): the preview takes the pointer.
28
+ const pathDrawing = $derived(view.current.pathDrawing);
29
+ const gesture = $derived(new VideoPathGesture({ width: project.width, height: project.height }));
30
+ let drawn = $state<number[]>([]);
31
+ $effect(() => {
32
+ if (!pathDrawing) return;
33
+ const c = controller;
34
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && c.cancelPathDrawing();
35
+ window.addEventListener('keydown', onKey);
36
+ return () => window.removeEventListener('keydown', onKey);
37
+ });
27
38
  </script>
28
39
 
29
40
  <!-- 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>
41
+ <div style="position: relative">
42
+ <canvas
43
+ bind:this={canvas}
44
+ class="block w-full bg-black {className}"
45
+ style="aspect-ratio:{project.width} / {project.height};{style}"
46
+ data-video-preview=""
47
+ aria-label="{project.name ?? 'Video'} at {formatVideoTimecode(view.current.time, project.fps)}"
48
+ ></canvas>
49
+ {#if pathDrawing}
50
+ <svg
51
+ viewBox="0 0 {project.width} {project.height}"
52
+ style="position: absolute; inset: 0; width: 100%; height: 100%; cursor: crosshair; touch-action: none"
53
+ role="application"
54
+ aria-label="Draw the effect's path"
55
+ data-video-path-drawing=""
56
+ onpointerdown={(e) => {
57
+ e.currentTarget.setPointerCapture?.(e.pointerId);
58
+ gesture.down(e, e.currentTarget.getBoundingClientRect());
59
+ drawn = [...gesture.points];
60
+ }}
61
+ onpointermove={(e) => {
62
+ if (gesture.move(e, e.currentTarget.getBoundingClientRect())) drawn = [...gesture.points];
63
+ }}
64
+ onpointerup={() => {
65
+ const points = gesture.up();
66
+ drawn = [];
67
+ if (points) void controller.finishPathDrawing(points);
68
+ }}
69
+ >
70
+ <polyline points={drawn.join(' ')} fill="none" stroke="#22d3ee" stroke-width={Math.max(2, project.width / 300)} stroke-linecap="round" stroke-linejoin="round" />
71
+ </svg>
72
+ {/if}
73
+ </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor-svelte",
3
- "version": "0.177.0",
3
+ "version": "0.179.0",
4
4
  "description": "Svelte bindings and prompt/rich-text components for @pixerate/editor",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -165,7 +165,7 @@
165
165
  "**/*.css"
166
166
  ],
167
167
  "dependencies": {
168
- "@pixerate/editor": "0.240.0"
168
+ "@pixerate/editor": "0.242.0"
169
169
  },
170
170
  "peerDependencies": {
171
171
  "@dagrejs/dagre": "^1.1.4",