@clypra/ui 1.0.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,420 @@
1
+ /**
2
+ * PresetManager Component
3
+ *
4
+ * Create, load, save, and manage effect presets.
5
+ * Supports preset library, export/import JSON.
6
+ */
7
+
8
+ import React, { useState } from "react";
9
+
10
+ export interface Preset {
11
+ id: string;
12
+ name: string;
13
+ description?: string;
14
+ effectId: string;
15
+ parameters: Record<string, any>;
16
+ version: string;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ tags?: string[];
20
+ }
21
+
22
+ export interface PresetManagerProps {
23
+ /** Current effect being edited */
24
+ effect: {
25
+ id: string;
26
+ name: string;
27
+ version: string;
28
+ };
29
+ /** Current parameter values */
30
+ parameters: Record<string, any>;
31
+ /** Available presets for this effect */
32
+ presets?: Preset[];
33
+ /** Callback when preset is loaded */
34
+ onLoadPreset?: (preset: Preset) => void;
35
+ /** Callback when preset is saved */
36
+ onSavePreset?: (preset: Preset) => void;
37
+ /** Callback when preset is deleted */
38
+ onDeletePreset?: (presetId: string) => void;
39
+ /** Callback when preset is exported */
40
+ onExportPreset?: (preset: Preset) => void;
41
+ /** Callback when preset is imported */
42
+ onImportPreset?: (preset: Preset) => void;
43
+ }
44
+
45
+ export function PresetManager({ effect, parameters, presets = [], onLoadPreset, onSavePreset, onDeletePreset, onExportPreset, onImportPreset }: PresetManagerProps) {
46
+ const [selectedPreset, setSelectedPreset] = useState<Preset | null>(null);
47
+ const [isCreating, setIsCreating] = useState(false);
48
+ const [newPresetName, setNewPresetName] = useState("");
49
+ const [newPresetDescription, setNewPresetDescription] = useState("");
50
+ const [searchQuery, setSearchQuery] = useState("");
51
+ const [filterTag, setFilterTag] = useState<string | null>(null);
52
+
53
+ // Get all unique tags from presets
54
+ const allTags = Array.from(new Set(presets.flatMap((p) => p.tags || []))).sort();
55
+
56
+ // Filter presets by search query and tag
57
+ const filteredPresets = presets.filter((preset) => {
58
+ const matchesSearch = searchQuery === "" || preset.name.toLowerCase().includes(searchQuery.toLowerCase()) || preset.description?.toLowerCase().includes(searchQuery.toLowerCase());
59
+
60
+ const matchesTag = filterTag === null || (preset.tags && preset.tags.includes(filterTag));
61
+
62
+ return matchesSearch && matchesTag;
63
+ });
64
+
65
+ const handleCreatePreset = () => {
66
+ if (!newPresetName.trim()) return;
67
+
68
+ const newPreset: Preset = {
69
+ id: `preset-${Date.now()}`,
70
+ name: newPresetName,
71
+ description: newPresetDescription,
72
+ effectId: effect.id,
73
+ parameters: { ...parameters },
74
+ version: effect.version,
75
+ createdAt: new Date().toISOString(),
76
+ updatedAt: new Date().toISOString(),
77
+ tags: [],
78
+ };
79
+
80
+ onSavePreset?.(newPreset);
81
+ setIsCreating(false);
82
+ setNewPresetName("");
83
+ setNewPresetDescription("");
84
+ };
85
+
86
+ const handleLoadPreset = (preset: Preset) => {
87
+ setSelectedPreset(preset);
88
+ onLoadPreset?.(preset);
89
+ };
90
+
91
+ const handleExportJSON = (preset: Preset) => {
92
+ const json = JSON.stringify(preset, null, 2);
93
+ const blob = new Blob([json], { type: "application/json" });
94
+ const url = URL.createObjectURL(blob);
95
+ const a = document.createElement("a");
96
+ a.href = url;
97
+ a.download = `${preset.name.replace(/\s+/g, "-").toLowerCase()}.json`;
98
+ a.click();
99
+ URL.revokeObjectURL(url);
100
+ onExportPreset?.(preset);
101
+ };
102
+
103
+ const handleImportJSON = () => {
104
+ const input = document.createElement("input");
105
+ input.type = "file";
106
+ input.accept = ".json";
107
+ input.onchange = async (e) => {
108
+ const file = (e.target as HTMLInputElement).files?.[0];
109
+ if (!file) return;
110
+
111
+ const text = await file.text();
112
+ try {
113
+ const preset = JSON.parse(text) as Preset;
114
+ onImportPreset?.(preset);
115
+ } catch (error) {
116
+ console.error("Failed to import preset:", error);
117
+ alert("Invalid preset file");
118
+ }
119
+ };
120
+ input.click();
121
+ };
122
+
123
+ return (
124
+ <div
125
+ style={{
126
+ padding: "16px",
127
+ background: "#0f172a",
128
+ borderRadius: "8px",
129
+ border: "1px solid #334155",
130
+ }}
131
+ >
132
+ {/* Header */}
133
+ <div
134
+ style={{
135
+ display: "flex",
136
+ justifyContent: "space-between",
137
+ alignItems: "center",
138
+ marginBottom: "16px",
139
+ }}
140
+ >
141
+ <h3 style={{ margin: 0, color: "#f1f5f9", fontSize: "16px" }}>Preset Manager</h3>
142
+ <div style={{ display: "flex", gap: "8px" }}>
143
+ <button
144
+ onClick={() => setIsCreating(true)}
145
+ style={{
146
+ padding: "6px 12px",
147
+ background: "#3b82f6",
148
+ color: "white",
149
+ border: "none",
150
+ borderRadius: "6px",
151
+ cursor: "pointer",
152
+ fontSize: "14px",
153
+ }}
154
+ >
155
+ + New Preset
156
+ </button>
157
+ <button
158
+ onClick={handleImportJSON}
159
+ style={{
160
+ padding: "6px 12px",
161
+ background: "#475569",
162
+ color: "white",
163
+ border: "none",
164
+ borderRadius: "6px",
165
+ cursor: "pointer",
166
+ fontSize: "14px",
167
+ }}
168
+ >
169
+ Import
170
+ </button>
171
+ </div>
172
+ </div>
173
+
174
+ {/* Effect info */}
175
+ <div
176
+ style={{
177
+ marginBottom: "16px",
178
+ padding: "12px",
179
+ background: "#1e293b",
180
+ borderRadius: "6px",
181
+ fontSize: "14px",
182
+ color: "#94a3b8",
183
+ }}
184
+ >
185
+ <div>
186
+ <strong style={{ color: "#f1f5f9" }}>Effect:</strong> {effect.name}
187
+ </div>
188
+ <div>
189
+ <strong style={{ color: "#f1f5f9" }}>Version:</strong> {effect.version}
190
+ </div>
191
+ </div>
192
+
193
+ {/* Create preset form */}
194
+ {isCreating && (
195
+ <div
196
+ style={{
197
+ marginBottom: "16px",
198
+ padding: "12px",
199
+ background: "#1e293b",
200
+ borderRadius: "6px",
201
+ border: "2px solid #3b82f6",
202
+ }}
203
+ >
204
+ <input
205
+ type="text"
206
+ placeholder="Preset name"
207
+ value={newPresetName}
208
+ onChange={(e) => setNewPresetName(e.target.value)}
209
+ style={{
210
+ width: "100%",
211
+ padding: "8px",
212
+ marginBottom: "8px",
213
+ background: "#0f172a",
214
+ border: "1px solid #334155",
215
+ borderRadius: "4px",
216
+ color: "#f1f5f9",
217
+ fontSize: "14px",
218
+ }}
219
+ />
220
+ <textarea
221
+ placeholder="Description (optional)"
222
+ value={newPresetDescription}
223
+ onChange={(e) => setNewPresetDescription(e.target.value)}
224
+ rows={3}
225
+ style={{
226
+ width: "100%",
227
+ padding: "8px",
228
+ marginBottom: "8px",
229
+ background: "#0f172a",
230
+ border: "1px solid #334155",
231
+ borderRadius: "4px",
232
+ color: "#f1f5f9",
233
+ fontSize: "14px",
234
+ resize: "vertical",
235
+ }}
236
+ />
237
+ <div style={{ display: "flex", gap: "8px" }}>
238
+ <button
239
+ onClick={handleCreatePreset}
240
+ disabled={!newPresetName.trim()}
241
+ style={{
242
+ flex: 1,
243
+ padding: "8px",
244
+ background: newPresetName.trim() ? "#10b981" : "#334155",
245
+ color: "white",
246
+ border: "none",
247
+ borderRadius: "6px",
248
+ cursor: newPresetName.trim() ? "pointer" : "not-allowed",
249
+ fontSize: "14px",
250
+ }}
251
+ >
252
+ Save Preset
253
+ </button>
254
+ <button
255
+ onClick={() => {
256
+ setIsCreating(false);
257
+ setNewPresetName("");
258
+ setNewPresetDescription("");
259
+ }}
260
+ style={{
261
+ flex: 1,
262
+ padding: "8px",
263
+ background: "#475569",
264
+ color: "white",
265
+ border: "none",
266
+ borderRadius: "6px",
267
+ cursor: "pointer",
268
+ fontSize: "14px",
269
+ }}
270
+ >
271
+ Cancel
272
+ </button>
273
+ </div>
274
+ </div>
275
+ )}
276
+
277
+ {/* Search and filter */}
278
+ <div style={{ marginBottom: "16px" }}>
279
+ <input
280
+ type="text"
281
+ placeholder="Search presets..."
282
+ value={searchQuery}
283
+ onChange={(e) => setSearchQuery(e.target.value)}
284
+ style={{
285
+ width: "100%",
286
+ padding: "8px",
287
+ marginBottom: "8px",
288
+ background: "#1e293b",
289
+ border: "1px solid #334155",
290
+ borderRadius: "6px",
291
+ color: "#f1f5f9",
292
+ fontSize: "14px",
293
+ }}
294
+ />
295
+ {allTags.length > 0 && (
296
+ <div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
297
+ <button
298
+ onClick={() => setFilterTag(null)}
299
+ style={{
300
+ padding: "4px 10px",
301
+ background: filterTag === null ? "#3b82f6" : "#334155",
302
+ color: "white",
303
+ border: "none",
304
+ borderRadius: "4px",
305
+ cursor: "pointer",
306
+ fontSize: "12px",
307
+ }}
308
+ >
309
+ All
310
+ </button>
311
+ {allTags.map((tag) => (
312
+ <button
313
+ key={tag}
314
+ onClick={() => setFilterTag(tag)}
315
+ style={{
316
+ padding: "4px 10px",
317
+ background: filterTag === tag ? "#3b82f6" : "#334155",
318
+ color: "white",
319
+ border: "none",
320
+ borderRadius: "4px",
321
+ cursor: "pointer",
322
+ fontSize: "12px",
323
+ }}
324
+ >
325
+ {tag}
326
+ </button>
327
+ ))}
328
+ </div>
329
+ )}
330
+ </div>
331
+
332
+ {/* Preset library */}
333
+ <div style={{ maxHeight: "400px", overflowY: "auto" }}>
334
+ {filteredPresets.length === 0 ? (
335
+ <div
336
+ style={{
337
+ padding: "32px",
338
+ textAlign: "center",
339
+ color: "#64748b",
340
+ fontSize: "14px",
341
+ }}
342
+ >
343
+ {presets.length === 0 ? "No presets yet. Create your first preset!" : "No presets match your search"}
344
+ </div>
345
+ ) : (
346
+ <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
347
+ {filteredPresets.map((preset) => (
348
+ <div
349
+ key={preset.id}
350
+ style={{
351
+ padding: "12px",
352
+ background: selectedPreset?.id === preset.id ? "#1e40af" : "#1e293b",
353
+ border: selectedPreset?.id === preset.id ? "2px solid #3b82f6" : "1px solid #334155",
354
+ borderRadius: "6px",
355
+ cursor: "pointer",
356
+ }}
357
+ onClick={() => handleLoadPreset(preset)}
358
+ >
359
+ <div
360
+ style={{
361
+ display: "flex",
362
+ justifyContent: "space-between",
363
+ alignItems: "start",
364
+ marginBottom: "6px",
365
+ }}
366
+ >
367
+ <div style={{ flex: 1 }}>
368
+ <div
369
+ style={{
370
+ fontWeight: 600,
371
+ color: "#f1f5f9",
372
+ marginBottom: "4px",
373
+ }}
374
+ >
375
+ {preset.name}
376
+ </div>
377
+ {preset.description && <div style={{ fontSize: "13px", color: "#94a3b8" }}>{preset.description}</div>}
378
+ </div>
379
+ <div style={{ display: "flex", gap: "6px" }} onClick={(e) => e.stopPropagation()}>
380
+ <button
381
+ onClick={() => handleExportJSON(preset)}
382
+ title="Export as JSON"
383
+ style={{
384
+ padding: "4px 8px",
385
+ background: "#475569",
386
+ color: "white",
387
+ border: "none",
388
+ borderRadius: "4px",
389
+ cursor: "pointer",
390
+ fontSize: "12px",
391
+ }}
392
+ >
393
+
394
+ </button>
395
+ <button
396
+ onClick={() => onDeletePreset?.(preset.id)}
397
+ title="Delete preset"
398
+ style={{
399
+ padding: "4px 8px",
400
+ background: "#ef4444",
401
+ color: "white",
402
+ border: "none",
403
+ borderRadius: "4px",
404
+ cursor: "pointer",
405
+ fontSize: "12px",
406
+ }}
407
+ >
408
+ ×
409
+ </button>
410
+ </div>
411
+ </div>
412
+ <div style={{ fontSize: "12px", color: "#64748b" }}>Updated: {new Date(preset.updatedAt).toLocaleDateString()}</div>
413
+ </div>
414
+ ))}
415
+ </div>
416
+ )}
417
+ </div>
418
+ </div>
419
+ );
420
+ }
@@ -0,0 +1,2 @@
1
+ export { PresetManager } from "./PresetManager";
2
+ export type { PresetManagerProps, Preset } from "./PresetManager";
@@ -0,0 +1,168 @@
1
+ /**
2
+ * PreviewCanvas Component
3
+ *
4
+ * Renders effect with current parameters.
5
+ * Supports pause/play/scrub and before/after comparison.
6
+ */
7
+
8
+ import React, { useEffect, useRef, useState } from "react";
9
+
10
+ export interface PreviewCanvasProps {
11
+ /** Effect definition to render */
12
+ effect: any; // TODO: Type from @clypra/engine
13
+ /** Media inputs for the effect */
14
+ inputs: Record<string, any>;
15
+ /** Current playback time in seconds */
16
+ currentTime: number;
17
+ /** Canvas width */
18
+ width?: number;
19
+ /** Canvas height */
20
+ height?: number;
21
+ /** Show before/after comparison */
22
+ showComparison?: boolean;
23
+ /** Playback state */
24
+ playing?: boolean;
25
+ /** Callback when playback state changes */
26
+ onPlayingChange?: (playing: boolean) => void;
27
+ /** Callback when time changes */
28
+ onTimeChange?: (time: number) => void;
29
+ }
30
+
31
+ export function PreviewCanvas({ effect, inputs, currentTime, width = 1920, height = 1080, showComparison = false, playing = false, onPlayingChange, onTimeChange }: PreviewCanvasProps) {
32
+ const canvasRef = useRef<HTMLCanvasElement>(null);
33
+ const animationFrameRef = useRef<number>();
34
+ const lastTimeRef = useRef<number>(0);
35
+
36
+ // Handle playback animation
37
+ useEffect(() => {
38
+ if (!playing) {
39
+ if (animationFrameRef.current) {
40
+ cancelAnimationFrame(animationFrameRef.current);
41
+ }
42
+ return;
43
+ }
44
+
45
+ const animate = (timestamp: number) => {
46
+ const deltaTime = (timestamp - lastTimeRef.current) / 1000;
47
+ lastTimeRef.current = timestamp;
48
+
49
+ if (onTimeChange && deltaTime > 0) {
50
+ onTimeChange(currentTime + deltaTime);
51
+ }
52
+
53
+ animationFrameRef.current = requestAnimationFrame(animate);
54
+ };
55
+
56
+ lastTimeRef.current = performance.now();
57
+ animationFrameRef.current = requestAnimationFrame(animate);
58
+
59
+ return () => {
60
+ if (animationFrameRef.current) {
61
+ cancelAnimationFrame(animationFrameRef.current);
62
+ }
63
+ };
64
+ }, [playing, currentTime, onTimeChange]);
65
+
66
+ // Render effect frame
67
+ useEffect(() => {
68
+ const canvas = canvasRef.current;
69
+ if (!canvas) return;
70
+
71
+ const ctx = canvas.getContext("2d");
72
+ if (!ctx) return;
73
+
74
+ // TODO: Integrate with PixiRenderer from @clypra/runtime
75
+ // For now, show placeholder
76
+ ctx.fillStyle = "#1a1a1a";
77
+ ctx.fillRect(0, 0, width, height);
78
+
79
+ ctx.fillStyle = "#3b82f6";
80
+ ctx.font = "24px sans-serif";
81
+ ctx.textAlign = "center";
82
+ ctx.textBaseline = "middle";
83
+ ctx.fillText(`Preview Canvas - ${effect?.name || "No Effect"}`, width / 2, height / 2 - 30);
84
+
85
+ ctx.fillStyle = "#94a3b8";
86
+ ctx.font = "16px monospace";
87
+ ctx.fillText(`Time: ${currentTime.toFixed(2)}s`, width / 2, height / 2 + 10);
88
+ ctx.fillText(`Playing: ${playing}`, width / 2, height / 2 + 40);
89
+
90
+ if (showComparison) {
91
+ // Draw comparison split
92
+ ctx.strokeStyle = "#f59e0b";
93
+ ctx.lineWidth = 3;
94
+ ctx.beginPath();
95
+ ctx.moveTo(width / 2, 0);
96
+ ctx.lineTo(width / 2, height);
97
+ ctx.stroke();
98
+
99
+ ctx.fillStyle = "#f59e0b";
100
+ ctx.font = "14px sans-serif";
101
+ ctx.fillText("Before", width / 4, 30);
102
+ ctx.fillText("After", (width * 3) / 4, 30);
103
+ }
104
+ }, [effect, inputs, currentTime, width, height, showComparison, playing]);
105
+
106
+ const handleCanvasClick = () => {
107
+ if (onPlayingChange) {
108
+ onPlayingChange(!playing);
109
+ }
110
+ };
111
+
112
+ return (
113
+ <div className="preview-canvas-container">
114
+ <canvas
115
+ ref={canvasRef}
116
+ width={width}
117
+ height={height}
118
+ onClick={handleCanvasClick}
119
+ style={{
120
+ width: "100%",
121
+ height: "auto",
122
+ cursor: "pointer",
123
+ border: "1px solid #334155",
124
+ borderRadius: "8px",
125
+ }}
126
+ />
127
+ <div
128
+ style={{
129
+ marginTop: "8px",
130
+ display: "flex",
131
+ gap: "12px",
132
+ alignItems: "center",
133
+ fontSize: "14px",
134
+ color: "#94a3b8",
135
+ }}
136
+ >
137
+ <button
138
+ onClick={() => onPlayingChange?.(!playing)}
139
+ style={{
140
+ padding: "6px 16px",
141
+ background: playing ? "#ef4444" : "#3b82f6",
142
+ color: "white",
143
+ border: "none",
144
+ borderRadius: "6px",
145
+ cursor: "pointer",
146
+ fontWeight: 500,
147
+ }}
148
+ >
149
+ {playing ? "⏸ Pause" : "▶ Play"}
150
+ </button>
151
+ <label>
152
+ <input
153
+ type="checkbox"
154
+ checked={showComparison}
155
+ onChange={(e) => {
156
+ // Handle comparison toggle - prop would need to be passed
157
+ }}
158
+ style={{ marginRight: "6px" }}
159
+ />
160
+ Before/After
161
+ </label>
162
+ <span>
163
+ Resolution: {width}×{height}
164
+ </span>
165
+ </div>
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,2 @@
1
+ export { PreviewCanvas } from "./PreviewCanvas";
2
+ export type { PreviewCanvasProps } from "./PreviewCanvas";