@pixerate/editor-svelte 0.106.0 → 0.107.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/effects/EffectAuthoringEditor.svelte +447 -0
- package/dist/effects/EffectAuthoringEditor.svelte.d.ts +18 -0
- package/dist/effects/EffectParameters.svelte +14 -4
- package/dist/effects/EffectParameters.svelte.d.ts +4 -0
- package/dist/effects/effects.svelte.d.ts +8 -1
- package/dist/effects/effects.svelte.js +26 -0
- package/dist/effects/index.d.ts +3 -0
- package/dist/effects/index.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { effectSampleInputs, type EffectAuthoringSession, type EffectRenderer } from '@pixerate/editor/effects';
|
|
4
|
+
import { effectAuthoringOf } from './effects.svelte.js';
|
|
5
|
+
import EffectParameters from './EffectParameters.svelte';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
/** The effect authoring session to edit. */
|
|
9
|
+
session: EffectAuthoringSession | null;
|
|
10
|
+
/** Whether to show the source code editor pane when supported (default true). */
|
|
11
|
+
showSource?: boolean;
|
|
12
|
+
/** Whether to show the keyframe timeline below parameters (default true). */
|
|
13
|
+
showTimeline?: boolean;
|
|
14
|
+
/** Size of the preview canvas (default 480 × 270). */
|
|
15
|
+
previewSize?: { width: number; height: number };
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
session,
|
|
21
|
+
showSource = true,
|
|
22
|
+
showTimeline = true,
|
|
23
|
+
previewSize = { width: 480, height: 270 },
|
|
24
|
+
class: className = ''
|
|
25
|
+
}: Props = $props();
|
|
26
|
+
|
|
27
|
+
const authoring = effectAuthoringOf(() => session);
|
|
28
|
+
const current = $derived(authoring.state);
|
|
29
|
+
|
|
30
|
+
// Preview renderer lifecycle
|
|
31
|
+
let canvas: HTMLCanvasElement | null = $state(null);
|
|
32
|
+
let renderer: EffectRenderer | null = $state.raw(null);
|
|
33
|
+
let rendering = false;
|
|
34
|
+
let pending = false;
|
|
35
|
+
|
|
36
|
+
// Preset saving state
|
|
37
|
+
let savingPreset = $state(false);
|
|
38
|
+
let presetName = $state('');
|
|
39
|
+
|
|
40
|
+
// Source code pane state
|
|
41
|
+
let textarea: HTMLTextAreaElement | null = $state(null);
|
|
42
|
+
let gutter: HTMLDivElement | null = $state(null);
|
|
43
|
+
let sourceCode = $state('');
|
|
44
|
+
let sourceDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
+
|
|
46
|
+
// Sync preview renderer
|
|
47
|
+
$effect(() => {
|
|
48
|
+
const s = session;
|
|
49
|
+
const w = previewSize.width;
|
|
50
|
+
const h = previewSize.height;
|
|
51
|
+
|
|
52
|
+
if (!s) {
|
|
53
|
+
if (renderer) {
|
|
54
|
+
renderer.dispose();
|
|
55
|
+
renderer = null;
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let live = true;
|
|
61
|
+
let createdRenderer: EffectRenderer | null = null;
|
|
62
|
+
|
|
63
|
+
Promise.resolve(s.createPreview({ width: w, height: h })).then((r) => {
|
|
64
|
+
if (!live) {
|
|
65
|
+
r.dispose();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
createdRenderer = r;
|
|
69
|
+
renderer = r;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
live = false;
|
|
74
|
+
if (createdRenderer) {
|
|
75
|
+
createdRenderer.dispose();
|
|
76
|
+
}
|
|
77
|
+
if (renderer === createdRenderer) {
|
|
78
|
+
renderer = null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Draws frames one at a time. A change while a frame renders asks for one more frame, which
|
|
84
|
+
// then shows the latest playhead and values, so the preview never stops on a stale frame.
|
|
85
|
+
let latestTime = 0;
|
|
86
|
+
/** Sample pictures for the effect's image inputs, so filters and transitions show something. */
|
|
87
|
+
const inputSpecs = $derived(current?.description.inputs);
|
|
88
|
+
const sampleInputs = $derived(inputSpecs ? effectSampleInputs({ inputs: inputSpecs }, previewSize) : {});
|
|
89
|
+
async function pump() {
|
|
90
|
+
if (rendering) {
|
|
91
|
+
pending = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
rendering = true;
|
|
95
|
+
try {
|
|
96
|
+
do {
|
|
97
|
+
pending = false;
|
|
98
|
+
const r = renderer;
|
|
99
|
+
if (!r) break;
|
|
100
|
+
try {
|
|
101
|
+
const pixels = await r.render({ time: latestTime, inputs: sampleInputs });
|
|
102
|
+
const ctx = renderer === r ? canvas?.getContext('2d') : null;
|
|
103
|
+
if (ctx && typeof ImageData !== 'undefined') {
|
|
104
|
+
ctx.putImageData(new ImageData(new Uint8ClampedArray(pixels.data), pixels.width, pixels.height), 0, 0);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// A failed frame (for example a disposed renderer) is skipped; the next change draws again.
|
|
108
|
+
}
|
|
109
|
+
} while (pending);
|
|
110
|
+
} finally {
|
|
111
|
+
rendering = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
$effect(() => {
|
|
116
|
+
const st = current;
|
|
117
|
+
if (!renderer || !st) return;
|
|
118
|
+
latestTime = st.time;
|
|
119
|
+
void pump();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Sync source code when the session's source changes
|
|
123
|
+
$effect(() => {
|
|
124
|
+
if (current?.source !== null && current?.source !== undefined) {
|
|
125
|
+
sourceCode = current.source;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
onDestroy(() => {
|
|
130
|
+
if (sourceDebounceTimer) clearTimeout(sourceDebounceTimer);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
function handleSourceInput(e: Event & { currentTarget: HTMLTextAreaElement }) {
|
|
134
|
+
const val = e.currentTarget.value;
|
|
135
|
+
sourceCode = val;
|
|
136
|
+
if (sourceDebounceTimer) clearTimeout(sourceDebounceTimer);
|
|
137
|
+
sourceDebounceTimer = setTimeout(() => {
|
|
138
|
+
session?.setSource?.(val);
|
|
139
|
+
}, 300);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleSavePreset(e: SubmitEvent) {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
const trimmed = presetName.trim();
|
|
145
|
+
if (trimmed && session) {
|
|
146
|
+
session.savePreset(trimmed);
|
|
147
|
+
savingPreset = false;
|
|
148
|
+
presetName = '';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function jumpToLine(lineNum: number) {
|
|
153
|
+
if (!textarea) return;
|
|
154
|
+
const lines = sourceCode.split('\n');
|
|
155
|
+
let offset = 0;
|
|
156
|
+
for (let i = 0; i < lineNum - 1 && i < lines.length; i++) {
|
|
157
|
+
offset += lines[i].length + 1;
|
|
158
|
+
}
|
|
159
|
+
textarea.focus();
|
|
160
|
+
textarea.setSelectionRange(offset, offset);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const hasSource = $derived(
|
|
164
|
+
showSource && current?.sourceLanguage !== null && typeof session?.setSource === 'function'
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const sourceLines = $derived(sourceCode.split('\n'));
|
|
168
|
+
const issueLines = $derived(
|
|
169
|
+
new Set((current?.issues ?? []).map((i) => i.line).filter((l): l is number => typeof l === 'number'))
|
|
170
|
+
);
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
{#if !session || !current}
|
|
174
|
+
<div class="p-4 text-center text-xs text-muted-foreground {className}">
|
|
175
|
+
No effect authoring session is open.
|
|
176
|
+
</div>
|
|
177
|
+
{:else}
|
|
178
|
+
<div class="space-y-4 rounded border border-border bg-background p-4 text-foreground {className}" data-effect-authoring-editor={current.name}>
|
|
179
|
+
<!-- Toolbar -->
|
|
180
|
+
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
|
|
181
|
+
<div class="flex items-center gap-2">
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
class="rounded border border-border px-2.5 py-1 text-xs font-medium hover:bg-muted"
|
|
185
|
+
aria-label={current.playing ? 'Pause playback' : 'Start playback'}
|
|
186
|
+
onclick={() => (current.playing ? session?.pause() : session?.play())}
|
|
187
|
+
>
|
|
188
|
+
{current.playing ? 'Pause' : 'Play'}
|
|
189
|
+
</button>
|
|
190
|
+
<span class="font-mono text-xs text-muted-foreground" data-time-readout>
|
|
191
|
+
{current.time}ms / {current.duration}ms
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="flex items-center gap-2">
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
class="rounded border border-border px-2 py-1 text-xs disabled:opacity-40 hover:bg-muted"
|
|
199
|
+
aria-label="Undo"
|
|
200
|
+
disabled={!current.canUndo}
|
|
201
|
+
onclick={() => session?.undo()}
|
|
202
|
+
>
|
|
203
|
+
Undo
|
|
204
|
+
</button>
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
class="rounded border border-border px-2 py-1 text-xs disabled:opacity-40 hover:bg-muted"
|
|
208
|
+
aria-label="Redo"
|
|
209
|
+
disabled={!current.canRedo}
|
|
210
|
+
onclick={() => session?.redo()}
|
|
211
|
+
>
|
|
212
|
+
Redo
|
|
213
|
+
</button>
|
|
214
|
+
|
|
215
|
+
{#if savingPreset}
|
|
216
|
+
<form onsubmit={handleSavePreset} class="flex items-center gap-1">
|
|
217
|
+
<input
|
|
218
|
+
type="text"
|
|
219
|
+
class="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
|
|
220
|
+
placeholder="Preset name"
|
|
221
|
+
bind:value={presetName}
|
|
222
|
+
/>
|
|
223
|
+
<button
|
|
224
|
+
type="submit"
|
|
225
|
+
class="rounded bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
|
226
|
+
>
|
|
227
|
+
Save
|
|
228
|
+
</button>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
class="rounded border border-border px-1.5 py-0.5 text-xs hover:bg-muted"
|
|
232
|
+
onclick={() => (savingPreset = false)}
|
|
233
|
+
>
|
|
234
|
+
Cancel
|
|
235
|
+
</button>
|
|
236
|
+
</form>
|
|
237
|
+
{:else}
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
class="rounded border border-border px-2 py-1 text-xs hover:bg-muted"
|
|
241
|
+
onclick={() => (savingPreset = true)}
|
|
242
|
+
>
|
|
243
|
+
Save preset…
|
|
244
|
+
</button>
|
|
245
|
+
{/if}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<!-- Main workspace: Preview + Source -->
|
|
250
|
+
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
251
|
+
<!-- Preview Panel -->
|
|
252
|
+
<div class="space-y-2">
|
|
253
|
+
<div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
254
|
+
Preview
|
|
255
|
+
</div>
|
|
256
|
+
<div class="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded border border-border bg-black">
|
|
257
|
+
<canvas
|
|
258
|
+
bind:this={canvas}
|
|
259
|
+
width={previewSize.width}
|
|
260
|
+
height={previewSize.height}
|
|
261
|
+
class="h-full w-full object-contain"
|
|
262
|
+
aria-label="Effect preview canvas"
|
|
263
|
+
></canvas>
|
|
264
|
+
</div>
|
|
265
|
+
{#if current.issues.length > 0}
|
|
266
|
+
<div class="space-y-1 rounded border border-destructive/20 bg-destructive/10 p-2 text-xs" data-effect-issues>
|
|
267
|
+
{#each current.issues as issue, idx (idx)}
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
class="block w-full text-left {issue.severity === 'error' ? 'text-destructive' : 'text-amber-500'}"
|
|
271
|
+
onclick={() => issue.line !== undefined && jumpToLine(issue.line)}
|
|
272
|
+
>
|
|
273
|
+
{issue.line !== undefined ? `Line ${issue.line}: ` : ''}
|
|
274
|
+
{issue.message}
|
|
275
|
+
</button>
|
|
276
|
+
{/each}
|
|
277
|
+
</div>
|
|
278
|
+
{/if}
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<!-- Source Code Pane -->
|
|
282
|
+
{#if hasSource}
|
|
283
|
+
<div class="space-y-2" data-effect-source-pane>
|
|
284
|
+
<div class="flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
285
|
+
<span>Source ({current.sourceLanguage})</span>
|
|
286
|
+
</div>
|
|
287
|
+
<div class="flex h-64 rounded border border-border bg-muted/20 font-mono text-xs">
|
|
288
|
+
<!-- Line Gutter -->
|
|
289
|
+
<div bind:this={gutter} class="select-none overflow-hidden border-r border-border bg-muted/40 px-2 py-2 text-right text-muted-foreground" aria-hidden="true">
|
|
290
|
+
{#each sourceLines as _, i (i)}
|
|
291
|
+
{@const lineNum = i + 1}
|
|
292
|
+
{@const hasIssue = issueLines.has(lineNum)}
|
|
293
|
+
<div data-gutter-line={lineNum} class={hasIssue ? 'font-bold text-destructive' : ''}>
|
|
294
|
+
{lineNum}
|
|
295
|
+
</div>
|
|
296
|
+
{/each}
|
|
297
|
+
</div>
|
|
298
|
+
<!-- Textarea -->
|
|
299
|
+
<textarea
|
|
300
|
+
bind:this={textarea}
|
|
301
|
+
value={sourceCode}
|
|
302
|
+
oninput={handleSourceInput}
|
|
303
|
+
onscroll={(e) => {
|
|
304
|
+
if (gutter) gutter.scrollTop = e.currentTarget.scrollTop;
|
|
305
|
+
}}
|
|
306
|
+
wrap="off"
|
|
307
|
+
aria-label="Effect source code"
|
|
308
|
+
spellcheck="false"
|
|
309
|
+
class="flex-1 resize-none bg-transparent p-2 font-mono text-xs text-foreground focus:outline-none"
|
|
310
|
+
></textarea>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
{/if}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<!-- Parameters -->
|
|
317
|
+
<div class="space-y-2 border-t border-border pt-4">
|
|
318
|
+
<div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
319
|
+
Parameters
|
|
320
|
+
</div>
|
|
321
|
+
<EffectParameters
|
|
322
|
+
description={current.description}
|
|
323
|
+
values={current.values}
|
|
324
|
+
onchange={(control, val, gesture) => session?.setValue(control, val as any, { gesture })}
|
|
325
|
+
onreset={(control) => session?.resetValues([control])}
|
|
326
|
+
onpresetname={(name) => session?.applyPreset(name)}
|
|
327
|
+
>
|
|
328
|
+
{#snippet addon(control: string)}
|
|
329
|
+
{@const track = current.tracks[control]}
|
|
330
|
+
{@const isAnimated = Boolean(track && track.length > 0)}
|
|
331
|
+
{@const hasKeyframeAtPlayhead = Boolean(isAnimated && track?.some((kf) => kf.time === current.time))}
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
data-keyframe-toggle={control}
|
|
335
|
+
aria-label={
|
|
336
|
+
hasKeyframeAtPlayhead
|
|
337
|
+
? `Remove keyframe for ${control}`
|
|
338
|
+
: `Add keyframe for ${control}`
|
|
339
|
+
}
|
|
340
|
+
onclick={() => {
|
|
341
|
+
if (hasKeyframeAtPlayhead) {
|
|
342
|
+
session?.removeKeyframe(control, current.time);
|
|
343
|
+
} else {
|
|
344
|
+
session?.setKeyframe(control, current.time);
|
|
345
|
+
}
|
|
346
|
+
}}
|
|
347
|
+
class="rounded px-1 text-xs transition-colors {hasKeyframeAtPlayhead ? 'text-primary font-bold' : isAnimated ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}"
|
|
348
|
+
title={hasKeyframeAtPlayhead ? 'Remove keyframe at playhead' : 'Add keyframe at playhead'}
|
|
349
|
+
>
|
|
350
|
+
{hasKeyframeAtPlayhead ? '◆' : '◇'}
|
|
351
|
+
</button>
|
|
352
|
+
{/snippet}
|
|
353
|
+
</EffectParameters>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<!-- Keyframe Timeline -->
|
|
357
|
+
{#if showTimeline}
|
|
358
|
+
<div class="space-y-3 border-t border-border pt-4" data-effect-timeline>
|
|
359
|
+
<div class="flex flex-wrap items-center justify-between gap-4">
|
|
360
|
+
<div class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
361
|
+
Timeline
|
|
362
|
+
</div>
|
|
363
|
+
<div class="flex items-center gap-3 text-xs">
|
|
364
|
+
<label class="flex items-center gap-1.5">
|
|
365
|
+
<span>Duration:</span>
|
|
366
|
+
<input
|
|
367
|
+
type="number"
|
|
368
|
+
aria-label="Duration"
|
|
369
|
+
min={1}
|
|
370
|
+
value={current.duration}
|
|
371
|
+
onchange={(e) =>
|
|
372
|
+
session?.setTiming({ duration: Math.max(1, Number(e.currentTarget.value)) })
|
|
373
|
+
}
|
|
374
|
+
class="w-16 rounded border border-border bg-background px-1.5 py-0.5 text-xs font-mono"
|
|
375
|
+
/>
|
|
376
|
+
<span class="text-muted-foreground">ms</span>
|
|
377
|
+
</label>
|
|
378
|
+
|
|
379
|
+
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
380
|
+
<input
|
|
381
|
+
type="checkbox"
|
|
382
|
+
aria-label="Loop playback"
|
|
383
|
+
checked={current.loop}
|
|
384
|
+
onchange={(e) => session?.setTiming({ loop: e.currentTarget.checked })}
|
|
385
|
+
/>
|
|
386
|
+
<span>Loop</span>
|
|
387
|
+
</label>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
<!-- Scrubber -->
|
|
392
|
+
<div class="space-y-1">
|
|
393
|
+
<input
|
|
394
|
+
type="range"
|
|
395
|
+
aria-label="Timeline scrubber"
|
|
396
|
+
min={0}
|
|
397
|
+
max={current.duration}
|
|
398
|
+
value={current.time}
|
|
399
|
+
oninput={(e) => session?.seek(Number(e.currentTarget.value))}
|
|
400
|
+
class="w-full cursor-pointer accent-primary"
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<!-- Tracks -->
|
|
405
|
+
{#if Object.entries(current.tracks).length > 0}
|
|
406
|
+
<div class="space-y-2 pt-1">
|
|
407
|
+
{#each Object.entries(current.tracks) as [control, keyframes] (control)}
|
|
408
|
+
{#if keyframes && keyframes.length > 0}
|
|
409
|
+
<div class="space-y-1" data-track={control}>
|
|
410
|
+
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
411
|
+
<span class="font-medium">{control}</span>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
class="text-[11px] hover:text-foreground"
|
|
415
|
+
onclick={() => session?.clearTrack(control)}
|
|
416
|
+
aria-label="Clear animation track for {control}"
|
|
417
|
+
>
|
|
418
|
+
Clear track
|
|
419
|
+
</button>
|
|
420
|
+
</div>
|
|
421
|
+
<div class="relative h-4 w-full rounded bg-muted/40">
|
|
422
|
+
{#each keyframes as kf, i (i)}
|
|
423
|
+
{@const leftPct = Math.max(
|
|
424
|
+
0,
|
|
425
|
+
Math.min(100, (kf.time / (current.duration || 1)) * 100)
|
|
426
|
+
)}
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
onclick={() => session?.seek(kf.time)}
|
|
430
|
+
aria-label="Seek to {control} keyframe at {kf.time}ms"
|
|
431
|
+
title="{control} @ {kf.time}ms"
|
|
432
|
+
style="left: {leftPct}%;"
|
|
433
|
+
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 text-[10px] text-primary hover:scale-125"
|
|
434
|
+
>
|
|
435
|
+
◆
|
|
436
|
+
</button>
|
|
437
|
+
{/each}
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
{/if}
|
|
441
|
+
{/each}
|
|
442
|
+
</div>
|
|
443
|
+
{/if}
|
|
444
|
+
</div>
|
|
445
|
+
{/if}
|
|
446
|
+
</div>
|
|
447
|
+
{/if}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type EffectAuthoringSession } from '@pixerate/editor/effects';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** The effect authoring session to edit. */
|
|
4
|
+
session: EffectAuthoringSession | null;
|
|
5
|
+
/** Whether to show the source code editor pane when supported (default true). */
|
|
6
|
+
showSource?: boolean;
|
|
7
|
+
/** Whether to show the keyframe timeline below parameters (default true). */
|
|
8
|
+
showTimeline?: boolean;
|
|
9
|
+
/** Size of the preview canvas (default 480 × 270). */
|
|
10
|
+
previewSize?: {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
class?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const EffectAuthoringEditor: import("svelte").Component<Props, {}, "">;
|
|
17
|
+
type EffectAuthoringEditor = ReturnType<typeof EffectAuthoringEditor>;
|
|
18
|
+
export default EffectAuthoringEditor;
|
|
@@ -19,18 +19,27 @@
|
|
|
19
19
|
keyframed?: ReadonlySet<string>;
|
|
20
20
|
/** Applies a preset's values. */
|
|
21
21
|
onpreset?: (values: Readonly<Record<string, unknown>>) => void;
|
|
22
|
+
/** Applies a preset by name. */
|
|
23
|
+
onpresetname?: (name: string) => void;
|
|
24
|
+
/** Optional addon rendered next to the control label (e.g. keyframe toggle button). */
|
|
25
|
+
addon?: import('svelte').Snippet<[string]>;
|
|
22
26
|
disabled?: boolean;
|
|
23
27
|
class?: string;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
let { description, values, onchange, onreset, keyframed, onpreset, disabled = false, class: className = '' }: Props = $props();
|
|
30
|
+
let { description, values, onchange, onreset, keyframed, onpreset, onpresetname, addon, disabled = false, class: className = '' }: Props = $props();
|
|
27
31
|
const fields = $derived(effectParameterFields(description));
|
|
28
32
|
const idBase = `fx-${Math.random().toString(36).slice(2, 8)}`;
|
|
29
33
|
let gesture: string | null = null;
|
|
30
34
|
let count = 0;
|
|
31
35
|
function preset(name: string) {
|
|
32
|
-
|
|
33
|
-
if (
|
|
36
|
+
if (!name) return;
|
|
37
|
+
if (onpresetname) {
|
|
38
|
+
onpresetname(name);
|
|
39
|
+
} else if (onpreset) {
|
|
40
|
+
const p = description.presets.find((x) => x.name === name);
|
|
41
|
+
if (p) onpreset({ ...description.defaults, ...p.values });
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
44
|
</script>
|
|
36
45
|
|
|
@@ -40,7 +49,7 @@
|
|
|
40
49
|
per-control reset and the effect's presets.
|
|
41
50
|
-->
|
|
42
51
|
<div class="space-y-2 {className}" data-effect-parameters={description.id}>
|
|
43
|
-
{#if onpreset && description.presets.length > 0}
|
|
52
|
+
{#if (onpreset || onpresetname) && description.presets.length > 0}
|
|
44
53
|
<select class="w-full rounded border border-border bg-transparent px-1 py-0.5 text-xs" aria-label="{description.name} presets" value="" {disabled} onchange={(e) => preset(e.currentTarget.value)}>
|
|
45
54
|
<option value="">Presets…</option>
|
|
46
55
|
{#each description.presets as p (p.name)}<option value={p.name}>{p.name}</option>{/each}
|
|
@@ -54,6 +63,7 @@
|
|
|
54
63
|
<div class="flex items-center gap-1.5">
|
|
55
64
|
<label for="{idBase}-{field.id}" class="min-w-0 flex-1 truncate text-xs font-medium" title={field.description}>{label}</label>
|
|
56
65
|
{#if keyed}<span class="text-[10px] text-muted-foreground">keyframed</span>{/if}
|
|
66
|
+
{#if addon}{@render addon(field.id)}{/if}
|
|
57
67
|
{#if onreset && !keyed && !disabled && !parameterValuesEqual(value, field.default)}
|
|
58
68
|
<button type="button" class="rounded px-1 text-[11px] text-muted-foreground hover:text-foreground" aria-label="Reset {label}" onclick={() => onreset(field.id)}>↺</button>
|
|
59
69
|
{/if}
|
|
@@ -16,6 +16,10 @@ interface Props {
|
|
|
16
16
|
keyframed?: ReadonlySet<string>;
|
|
17
17
|
/** Applies a preset's values. */
|
|
18
18
|
onpreset?: (values: Readonly<Record<string, unknown>>) => void;
|
|
19
|
+
/** Applies a preset by name. */
|
|
20
|
+
onpresetname?: (name: string) => void;
|
|
21
|
+
/** Optional addon rendered next to the control label (e.g. keyframe toggle button). */
|
|
22
|
+
addon?: import('svelte').Snippet<[string]>;
|
|
19
23
|
disabled?: boolean;
|
|
20
24
|
class?: string;
|
|
21
25
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EffectDescription, EffectProvider } from "@pixerate/editor/effects";
|
|
1
|
+
import type { EffectAuthoringSession, EffectAuthoringState, EffectDescription, EffectProvider } from "@pixerate/editor/effects";
|
|
2
2
|
/** Anything that takes an effects provider: every editor controller with effects support. */
|
|
3
3
|
export interface EffectsHost {
|
|
4
4
|
useEffects(provider: EffectProvider): () => void;
|
|
@@ -14,3 +14,10 @@ export declare function effectDescriptionOf(provider: () => EffectProvider | nul
|
|
|
14
14
|
readonly description: EffectDescription | null;
|
|
15
15
|
readonly error: string | null;
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* Subscribes a Svelte component to the observable state of an effect authoring session.
|
|
19
|
+
* Updates reactively whenever the session changes. State is `null` when session is null.
|
|
20
|
+
*/
|
|
21
|
+
export declare function effectAuthoringOf(session: EffectAuthoringSession | null | undefined | (() => EffectAuthoringSession | null | undefined)): {
|
|
22
|
+
readonly state: EffectAuthoringState | null;
|
|
23
|
+
};
|
|
@@ -47,3 +47,29 @@ export function effectDescriptionOf(provider, effect) {
|
|
|
47
47
|
},
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Subscribes a Svelte component to the observable state of an effect authoring session.
|
|
52
|
+
* Updates reactively whenever the session changes. State is `null` when session is null.
|
|
53
|
+
*/
|
|
54
|
+
export function effectAuthoringOf(session) {
|
|
55
|
+
let currentState = $state.raw(null);
|
|
56
|
+
$effect(() => {
|
|
57
|
+
const s = typeof session === "function" ? session() : session;
|
|
58
|
+
if (!s) {
|
|
59
|
+
currentState = null;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
currentState = s.getState();
|
|
63
|
+
const unsubscribe = s.subscribe((next) => {
|
|
64
|
+
currentState = next;
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
unsubscribe();
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
get state() {
|
|
72
|
+
return currentState;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/dist/effects/index.d.ts
CHANGED
|
@@ -2,10 +2,13 @@ import type { ComponentProps } from "svelte";
|
|
|
2
2
|
import type EffectsBrowserComponent from "./EffectsBrowser.svelte";
|
|
3
3
|
import type EffectParametersComponent from "./EffectParameters.svelte";
|
|
4
4
|
import type EffectThumbnailComponent from "./EffectThumbnail.svelte";
|
|
5
|
+
import type EffectAuthoringEditorComponent from "./EffectAuthoringEditor.svelte";
|
|
5
6
|
export { default as EffectsBrowser } from "./EffectsBrowser.svelte";
|
|
6
7
|
export { default as EffectParameters } from "./EffectParameters.svelte";
|
|
7
8
|
export { default as EffectThumbnail } from "./EffectThumbnail.svelte";
|
|
9
|
+
export { default as EffectAuthoringEditor } from "./EffectAuthoringEditor.svelte";
|
|
8
10
|
export * from "./effects.svelte.js";
|
|
9
11
|
export type EffectsBrowserProps = ComponentProps<typeof EffectsBrowserComponent>;
|
|
10
12
|
export type EffectParametersProps = ComponentProps<typeof EffectParametersComponent>;
|
|
11
13
|
export type EffectThumbnailProps = ComponentProps<typeof EffectThumbnailComponent>;
|
|
14
|
+
export type EffectAuthoringEditorProps = ComponentProps<typeof EffectAuthoringEditorComponent>;
|
package/dist/effects/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { default as EffectsBrowser } from "./EffectsBrowser.svelte";
|
|
2
2
|
export { default as EffectParameters } from "./EffectParameters.svelte";
|
|
3
3
|
export { default as EffectThumbnail } from "./EffectThumbnail.svelte";
|
|
4
|
+
export { default as EffectAuthoringEditor } from "./EffectAuthoringEditor.svelte";
|
|
4
5
|
export * from "./effects.svelte.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pixerate/editor-svelte",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.107.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.
|
|
163
|
+
"@pixerate/editor": "0.148.0"
|
|
164
164
|
},
|
|
165
165
|
"peerDependencies": {
|
|
166
166
|
"@dagrejs/dagre": "^1.1.4",
|