@pixerate/flair 0.0.0-stage → 0.1.1
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +254 -2
- package/dist/catalog/index.d.ts +74 -0
- package/dist/catalog/index.js +47 -0
- package/dist/catalog/index.js.map +1 -0
- package/dist/chunk-36DTNSHU.js +160 -0
- package/dist/chunk-36DTNSHU.js.map +1 -0
- package/dist/chunk-3FAUZOGL.js +331 -0
- package/dist/chunk-3FAUZOGL.js.map +1 -0
- package/dist/chunk-CFCLT2XL.js +1 -0
- package/dist/chunk-CFCLT2XL.js.map +1 -0
- package/dist/chunk-EHDJ3CPJ.js +327 -0
- package/dist/chunk-EHDJ3CPJ.js.map +1 -0
- package/dist/chunk-G5FNZDTA.js +536 -0
- package/dist/chunk-G5FNZDTA.js.map +1 -0
- package/dist/chunk-HKXOHRCD.js +763 -0
- package/dist/chunk-HKXOHRCD.js.map +1 -0
- package/dist/chunk-J55UBNAT.js +164 -0
- package/dist/chunk-J55UBNAT.js.map +1 -0
- package/dist/chunk-JWZCPMFG.js +2074 -0
- package/dist/chunk-JWZCPMFG.js.map +1 -0
- package/dist/chunk-MWVTQFV5.js +1894 -0
- package/dist/chunk-MWVTQFV5.js.map +1 -0
- package/dist/chunk-UQQNZSYL.js +594 -0
- package/dist/chunk-UQQNZSYL.js.map +1 -0
- package/dist/chunk-WMZQCUFJ.js +73 -0
- package/dist/chunk-WMZQCUFJ.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +331 -0
- package/dist/cli.js.map +1 -0
- package/dist/clock-BhdXWH-n.d.ts +47 -0
- package/dist/controls-DdXMbovi.d.ts +97 -0
- package/dist/editor/index.d.ts +413 -0
- package/dist/editor/index.js +1009 -0
- package/dist/editor/index.js.map +1 -0
- package/dist/export-FEBPEEO7.js +22 -0
- package/dist/export-FEBPEEO7.js.map +1 -0
- package/dist/index.d.ts +1431 -0
- package/dist/index.js +1263 -0
- package/dist/index.js.map +1 -0
- package/dist/motion-D0TVv2CF.d.ts +148 -0
- package/dist/node.d.ts +24 -0
- package/dist/node.js +10 -0
- package/dist/node.js.map +1 -0
- package/dist/recorder-WJTWMEQN.js +10 -0
- package/dist/recorder-WJTWMEQN.js.map +1 -0
- package/dist/renderer-Oyynz-fs.d.ts +293 -0
- package/dist/types-CkKkavvB.d.ts +71 -0
- package/dist/vector/index.d.ts +83 -0
- package/dist/vector/index.js +39 -0
- package/dist/vector/index.js.map +1 -0
- package/package.json +93 -3
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EFFECT_CATEGORIES,
|
|
3
|
+
findEffects,
|
|
4
|
+
getEffect,
|
|
5
|
+
renderVectorEffect,
|
|
6
|
+
vectorControlDef
|
|
7
|
+
} from "../chunk-MWVTQFV5.js";
|
|
8
|
+
import {
|
|
9
|
+
svgMotionIssues,
|
|
10
|
+
svgView
|
|
11
|
+
} from "../chunk-G5FNZDTA.js";
|
|
12
|
+
import {
|
|
13
|
+
BUILTIN_UNIFORMS,
|
|
14
|
+
interpolateOklab
|
|
15
|
+
} from "../chunk-HKXOHRCD.js";
|
|
16
|
+
import "../chunk-EHDJ3CPJ.js";
|
|
17
|
+
import {
|
|
18
|
+
realClock
|
|
19
|
+
} from "../chunk-WMZQCUFJ.js";
|
|
20
|
+
import {
|
|
21
|
+
checkControlValue,
|
|
22
|
+
controlValue,
|
|
23
|
+
parseControls
|
|
24
|
+
} from "../chunk-J55UBNAT.js";
|
|
25
|
+
|
|
26
|
+
// src/editor/timeline.ts
|
|
27
|
+
var EASING_PRESETS = [
|
|
28
|
+
"linear",
|
|
29
|
+
"ease",
|
|
30
|
+
"ease-in",
|
|
31
|
+
"ease-out",
|
|
32
|
+
"ease-in-out",
|
|
33
|
+
"ease-in-back",
|
|
34
|
+
"ease-out-back",
|
|
35
|
+
"spring",
|
|
36
|
+
"hold"
|
|
37
|
+
];
|
|
38
|
+
var BEZIERS = {
|
|
39
|
+
ease: [0.25, 0.1, 0.25, 1],
|
|
40
|
+
"ease-in": [0.42, 0, 1, 1],
|
|
41
|
+
"ease-out": [0, 0, 0.58, 1],
|
|
42
|
+
"ease-in-out": [0.42, 0, 0.58, 1],
|
|
43
|
+
"ease-in-back": [0.36, 0, 0.66, -0.56],
|
|
44
|
+
"ease-out-back": [0.34, 1.56, 0.64, 1]
|
|
45
|
+
};
|
|
46
|
+
function isEasing(value) {
|
|
47
|
+
if (typeof value === "string") return EASING_PRESETS.includes(value);
|
|
48
|
+
if (!value || typeof value !== "object") return false;
|
|
49
|
+
const e = value;
|
|
50
|
+
if (e.type === "cubic-bezier") return ["x1", "y1", "x2", "y2"].every((k) => Number.isFinite(e[k]));
|
|
51
|
+
if (e.type === "spring")
|
|
52
|
+
return ["stiffness", "damping", "mass"].every((k) => e[k] === void 0 || typeof e[k] === "number" && e[k] > 0);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
function cubicBezierAt(x1, y1, x2, y2, x) {
|
|
56
|
+
if (x <= 0) return 0;
|
|
57
|
+
if (x >= 1) return 1;
|
|
58
|
+
x1 = Math.min(1, Math.max(0, x1));
|
|
59
|
+
x2 = Math.min(1, Math.max(0, x2));
|
|
60
|
+
const bx = (t2) => 3 * x1 * t2 * (1 - t2) ** 2 + 3 * x2 * t2 * t2 * (1 - t2) + t2 ** 3;
|
|
61
|
+
const by = (t2) => 3 * y1 * t2 * (1 - t2) ** 2 + 3 * y2 * t2 * t2 * (1 - t2) + t2 ** 3;
|
|
62
|
+
const dx = (t2) => 3 * x1 * (1 - t2) ** 2 + 6 * (x2 - x1) * t2 * (1 - t2) + 3 * (1 - x2) * t2 * t2;
|
|
63
|
+
let t = x;
|
|
64
|
+
for (let i = 0; i < 8; i++) {
|
|
65
|
+
const err = bx(t) - x;
|
|
66
|
+
if (Math.abs(err) < 1e-6) return by(t);
|
|
67
|
+
const d = dx(t);
|
|
68
|
+
if (Math.abs(d) < 1e-6) break;
|
|
69
|
+
t -= err / d;
|
|
70
|
+
}
|
|
71
|
+
let lo = 0;
|
|
72
|
+
let hi = 1;
|
|
73
|
+
t = x;
|
|
74
|
+
for (let i = 0; i < 40; i++) {
|
|
75
|
+
if (bx(t) < x) lo = t;
|
|
76
|
+
else hi = t;
|
|
77
|
+
t = (lo + hi) / 2;
|
|
78
|
+
}
|
|
79
|
+
return by(t);
|
|
80
|
+
}
|
|
81
|
+
function springAt(easing, u, durationMs) {
|
|
82
|
+
const k = easing.stiffness ?? 170;
|
|
83
|
+
const c = easing.damping ?? 26;
|
|
84
|
+
const m = easing.mass ?? 1;
|
|
85
|
+
const w0 = Math.sqrt(k / m);
|
|
86
|
+
const zeta = c / (2 * Math.sqrt(k * m));
|
|
87
|
+
const t = u * Math.max(durationMs / 1e3, 0.6);
|
|
88
|
+
if (zeta < 1) {
|
|
89
|
+
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
90
|
+
return 1 - Math.exp(-zeta * w0 * t) * (Math.cos(wd * t) + zeta * w0 / wd * Math.sin(wd * t));
|
|
91
|
+
}
|
|
92
|
+
return 1 - Math.exp(-w0 * t) * (1 + w0 * t);
|
|
93
|
+
}
|
|
94
|
+
function evaluateEasing(easing, u, durationMs = 1e3) {
|
|
95
|
+
if (u <= 0) return 0;
|
|
96
|
+
if (u >= 1) return 1;
|
|
97
|
+
if (!easing || easing === "linear") return u;
|
|
98
|
+
if (easing === "hold") return 0;
|
|
99
|
+
if (easing === "spring") return springAt({ type: "spring" }, u, durationMs);
|
|
100
|
+
if (typeof easing === "string") {
|
|
101
|
+
const b = BEZIERS[easing];
|
|
102
|
+
return b ? cubicBezierAt(b[0], b[1], b[2], b[3], u) : u;
|
|
103
|
+
}
|
|
104
|
+
if (easing.type === "spring") return springAt(easing, u, durationMs);
|
|
105
|
+
return cubicBezierAt(easing.x1, easing.y1, easing.x2, easing.y2, u);
|
|
106
|
+
}
|
|
107
|
+
function interpolateControlValue(control, a, b, t) {
|
|
108
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
109
|
+
const v = a + (b - a) * t;
|
|
110
|
+
return control?.type === "int" ? Math.round(v) : v;
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
|
|
113
|
+
if (control?.color && a.length >= 3) {
|
|
114
|
+
const rgb = interpolateOklab(a.slice(0, 3), b.slice(0, 3), t);
|
|
115
|
+
const out = rgb.map((c) => Math.min(1, Math.max(0, c)));
|
|
116
|
+
return a.length === 4 ? [...out, a[3] + (b[3] - a[3]) * t] : out;
|
|
117
|
+
}
|
|
118
|
+
return a.map((v, i) => v + (b[i] - v) * t);
|
|
119
|
+
}
|
|
120
|
+
return t < 0.5 ? a : b;
|
|
121
|
+
}
|
|
122
|
+
function evaluateTrack(track, time, control) {
|
|
123
|
+
if (!track || track.enabled === false || track.keyframes.length === 0) return void 0;
|
|
124
|
+
const keys = track.keyframes;
|
|
125
|
+
if (time <= keys[0].time) return keys[0].value;
|
|
126
|
+
const last = keys[keys.length - 1];
|
|
127
|
+
if (time >= last.time) return last.value;
|
|
128
|
+
let i = 0;
|
|
129
|
+
while (i < keys.length - 2 && keys[i + 1].time <= time) i++;
|
|
130
|
+
const from = keys[i];
|
|
131
|
+
const to = keys[i + 1];
|
|
132
|
+
const span = to.time - from.time;
|
|
133
|
+
const u = span > 0 ? (time - from.time) / span : 1;
|
|
134
|
+
return interpolateControlValue(control, from.value, to.value, evaluateEasing(from.easing, u, span));
|
|
135
|
+
}
|
|
136
|
+
function normalizeKeyframes(keyframes) {
|
|
137
|
+
const byTime = /* @__PURE__ */ new Map();
|
|
138
|
+
for (const k of keyframes) byTime.set(Math.round(k.time * 1e3) / 1e3, { ...k, time: Math.round(k.time * 1e3) / 1e3 });
|
|
139
|
+
return [...byTime.values()].sort((a, b) => a.time - b.time);
|
|
140
|
+
}
|
|
141
|
+
function keyframeTimes(tracks) {
|
|
142
|
+
return [...new Set(Object.values(tracks).flatMap((t) => t.keyframes.map((k) => k.time)))].sort((a, b) => a - b);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/editor/document.ts
|
|
146
|
+
var DEFAULT_SHADER = `// A new effect: edit me.
|
|
147
|
+
uniform vec3 uColorA; // @control color default=#6366F1 label="Colour A"
|
|
148
|
+
uniform vec3 uColorB; // @control color default=#EC4899 label="Colour B"
|
|
149
|
+
uniform float uSpeed; // @control min=0 max=4 default=1 label="Speed"
|
|
150
|
+
|
|
151
|
+
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|
152
|
+
vec2 uv = fragCoord / iResolution.xy;
|
|
153
|
+
float t = 0.5 + 0.5 * sin(uv.x * 3.0 + iTime * uSpeed);
|
|
154
|
+
fragColor = vec4(mix(uColorA, uColorB, t), 1.0);
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
157
|
+
var DEFAULT_DURATION = 5e3;
|
|
158
|
+
function createEffectDocument(from) {
|
|
159
|
+
if (from === void 0) return blankDocument("Untitled effect", DEFAULT_SHADER);
|
|
160
|
+
if (typeof from === "object" && !("kind" in from)) return blankDocument(from.name ?? "Untitled effect", from.source);
|
|
161
|
+
const effect = typeof from === "string" ? getEffect(from) : from;
|
|
162
|
+
if (!effect) throw new Error(`There is no effect "${String(from)}" in the catalog.`);
|
|
163
|
+
return {
|
|
164
|
+
version: 1,
|
|
165
|
+
name: effect.name,
|
|
166
|
+
basedOn: effect.id,
|
|
167
|
+
effect: effect.kind === "shader" ? { kind: "shader", source: effect.source } : { kind: "vector", motion: structuredClone(effect.motion), controls: effect.controls.map((c) => ({ ...c })), hold: effect.hold },
|
|
168
|
+
values: {},
|
|
169
|
+
tracks: {},
|
|
170
|
+
duration: effect.kind === "shader" && effect.loop ? Math.round(effect.loop * 1e3) : DEFAULT_DURATION,
|
|
171
|
+
loop: true,
|
|
172
|
+
presets: (effect.presets ?? []).map((p) => ({ name: p.name, values: { ...p.values } }))
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function blankDocument(name, source) {
|
|
176
|
+
return { version: 1, name, effect: { kind: "shader", source }, values: {}, tracks: {}, duration: DEFAULT_DURATION, loop: true, presets: [] };
|
|
177
|
+
}
|
|
178
|
+
function documentControls(doc) {
|
|
179
|
+
if (doc.effect.kind === "shader") return parseControls(doc.effect.source);
|
|
180
|
+
return doc.effect.controls.map((c, i) => vectorControlDef(c, i + 1));
|
|
181
|
+
}
|
|
182
|
+
function valuesAt(doc, time, controls = documentControls(doc)) {
|
|
183
|
+
const out = {};
|
|
184
|
+
for (const control of controls) {
|
|
185
|
+
const tracked = evaluateTrack(doc.tracks[control.name], time, control);
|
|
186
|
+
out[control.name] = tracked ?? controlValue(control, doc.values);
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
function motionAt(doc, time) {
|
|
191
|
+
if (doc.effect.kind !== "vector") return void 0;
|
|
192
|
+
const base = doc.basedOn ? getEffect(doc.basedOn) : void 0;
|
|
193
|
+
const derived = base?.kind === "vector" ? base.derived : void 0;
|
|
194
|
+
return renderVectorEffect({ motion: doc.effect.motion, controls: doc.effect.controls, derived }, valuesAt(doc, time));
|
|
195
|
+
}
|
|
196
|
+
function documentIssues(doc) {
|
|
197
|
+
const issues = [];
|
|
198
|
+
if (!(doc.duration > 0)) issues.push("`duration` must be above 0 ms");
|
|
199
|
+
if (doc.effect.kind === "shader" && !/void\s+mainImage\s*\(/.test(doc.effect.source))
|
|
200
|
+
issues.push("the shader needs `void mainImage(out vec4 fragColor, in vec2 fragCoord)`");
|
|
201
|
+
const controls = new Map(documentControls(doc).map((c) => [c.name, c]));
|
|
202
|
+
const check = (where, name, value) => {
|
|
203
|
+
const control = controls.get(name);
|
|
204
|
+
if (!control) return void issues.push(`${where}: there is no control "${name}"`);
|
|
205
|
+
const result = checkControlValue(control, value);
|
|
206
|
+
if (!result.ok) issues.push(`${where}: ${result.message}`);
|
|
207
|
+
};
|
|
208
|
+
for (const [name, value] of Object.entries(doc.values)) check("values", name, value);
|
|
209
|
+
for (const [name, track] of Object.entries(doc.tracks)) {
|
|
210
|
+
track.keyframes.forEach((k, i) => {
|
|
211
|
+
const where = `tracks.${name}[${i}]`;
|
|
212
|
+
if (!(k.time >= 0)) issues.push(`${where}: \`time\` must be 0 or more`);
|
|
213
|
+
if (k.easing !== void 0 && !isEasing(k.easing)) issues.push(`${where}: unknown easing`);
|
|
214
|
+
check(where, name, k.value);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
for (const preset of doc.presets) for (const [name, value] of Object.entries(preset.values)) check(`preset "${preset.name}"`, name, value);
|
|
218
|
+
if (doc.effect.kind === "vector") {
|
|
219
|
+
const motion = motionAt(doc, 0);
|
|
220
|
+
issues.push(...svgMotionIssues(motion));
|
|
221
|
+
if (/\{\{[^}]+\}\}/.test(JSON.stringify(motion))) issues.push("the picture has placeholders no control fills");
|
|
222
|
+
}
|
|
223
|
+
return issues;
|
|
224
|
+
}
|
|
225
|
+
function parseEffectDocument(input) {
|
|
226
|
+
let raw = input;
|
|
227
|
+
if (typeof input === "string") {
|
|
228
|
+
try {
|
|
229
|
+
raw = JSON.parse(input);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return { ok: false, errors: [`not JSON: ${e.message}`] };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const errors = [];
|
|
235
|
+
const o = raw ?? {};
|
|
236
|
+
if (o.version !== 1) errors.push("`version` must be 1");
|
|
237
|
+
if (typeof o.name !== "string") errors.push("`name` must be a string");
|
|
238
|
+
const effect = o.effect;
|
|
239
|
+
if (!effect || effect.kind !== "shader" && effect.kind !== "vector") errors.push('`effect.kind` must be "shader" or "vector"');
|
|
240
|
+
else if (effect.kind === "shader" && typeof effect.source !== "string") errors.push("`effect.source` must be a string");
|
|
241
|
+
else if (effect.kind === "vector") {
|
|
242
|
+
const v = effect;
|
|
243
|
+
if (!v.motion || typeof v.motion.svg !== "string") errors.push("`effect.motion.svg` must be a string");
|
|
244
|
+
if (!Array.isArray(v.controls)) errors.push("`effect.controls` must be a list");
|
|
245
|
+
}
|
|
246
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
247
|
+
if (o.values !== void 0 && !isRecord(o.values)) errors.push("`values` must be an object");
|
|
248
|
+
if (o.tracks !== void 0 && !isRecord(o.tracks)) errors.push("`tracks` must be an object");
|
|
249
|
+
for (const [name, track] of Object.entries(isRecord(o.tracks) ? o.tracks : {}))
|
|
250
|
+
if (!track || !Array.isArray(track.keyframes)) errors.push(`\`tracks.${name}.keyframes\` must be a list`);
|
|
251
|
+
if (o.presets !== void 0 && !Array.isArray(o.presets)) errors.push("`presets` must be a list");
|
|
252
|
+
if (errors.length) return { ok: false, errors };
|
|
253
|
+
const tracks = Object.fromEntries(
|
|
254
|
+
Object.entries(o.tracks ?? {}).map(([k, t]) => [k, { ...t, keyframes: normalizeKeyframes(t.keyframes) }])
|
|
255
|
+
);
|
|
256
|
+
return {
|
|
257
|
+
ok: true,
|
|
258
|
+
document: {
|
|
259
|
+
version: 1,
|
|
260
|
+
name: o.name,
|
|
261
|
+
basedOn: typeof o.basedOn === "string" ? o.basedOn : void 0,
|
|
262
|
+
effect: o.effect,
|
|
263
|
+
values: o.values ?? {},
|
|
264
|
+
tracks,
|
|
265
|
+
duration: typeof o.duration === "number" && o.duration > 0 ? o.duration : DEFAULT_DURATION,
|
|
266
|
+
loop: o.loop !== false,
|
|
267
|
+
presets: o.presets ?? []
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/editor/editor.ts
|
|
273
|
+
import { createCommandRegistry, createHistoryCommands } from "@pixerate/editor/commands";
|
|
274
|
+
import { createHistoryManager, HistoryManager } from "@pixerate/editor/history";
|
|
275
|
+
|
|
276
|
+
// src/editor/commands.ts
|
|
277
|
+
function done(result) {
|
|
278
|
+
if (!result.ok) throw new Error(result.message);
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
var easingSchema = {
|
|
282
|
+
description: `An easing preset (${EASING_PRESETS.join(", ")}) or {"type":"cubic-bezier","x1","y1","x2","y2"} or {"type":"spring","stiffness","damping","mass"}.`
|
|
283
|
+
};
|
|
284
|
+
var valueSchema = {
|
|
285
|
+
description: 'A control value: a number (float, int), true/false (bool), an array of numbers (vec2\u2013vec4; colours 0\u20131), a "#RRGGBB" colour, or an image URL (sampler2D).'
|
|
286
|
+
};
|
|
287
|
+
function describeEditor(editor) {
|
|
288
|
+
const state = editor.getState();
|
|
289
|
+
const doc = state.document;
|
|
290
|
+
return {
|
|
291
|
+
name: doc.name,
|
|
292
|
+
basedOn: doc.basedOn,
|
|
293
|
+
kind: doc.effect.kind,
|
|
294
|
+
...doc.effect.kind === "shader" ? { source: doc.effect.source } : { motion: doc.effect.motion, vectorControls: doc.effect.controls },
|
|
295
|
+
controls: state.controls.map((c) => ({
|
|
296
|
+
name: c.name,
|
|
297
|
+
type: c.type,
|
|
298
|
+
label: c.label,
|
|
299
|
+
...c.color && { color: true },
|
|
300
|
+
...c.type !== "bool" && c.type !== "sampler2D" && { min: c.min, max: c.max, step: c.step },
|
|
301
|
+
default: c.default
|
|
302
|
+
})),
|
|
303
|
+
values: state.values,
|
|
304
|
+
tracks: doc.tracks,
|
|
305
|
+
presets: doc.presets.map((p) => p.name),
|
|
306
|
+
timeline: { duration: doc.duration, loop: doc.loop, time: state.time, playing: state.playing, recording: state.recording },
|
|
307
|
+
issues: state.issues,
|
|
308
|
+
compile: state.compile,
|
|
309
|
+
history: { canUndo: state.history.canUndo, canRedo: state.history.canRedo }
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function effectCommands() {
|
|
313
|
+
return [
|
|
314
|
+
{
|
|
315
|
+
id: "effect.describe",
|
|
316
|
+
label: "Describe effect",
|
|
317
|
+
group: "Effect",
|
|
318
|
+
description: "The effect being edited: its source (or picture), controls, values at the playhead, keyframes, presets, timeline, issues and compile errors.",
|
|
319
|
+
readOnly: true,
|
|
320
|
+
run: (editor) => describeEditor(editor)
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
id: "catalog.search",
|
|
324
|
+
label: "Search effects",
|
|
325
|
+
group: "Catalog",
|
|
326
|
+
description: "Searches the built-in effect catalog. Load one with effect.load to start from it.",
|
|
327
|
+
readOnly: true,
|
|
328
|
+
params: {
|
|
329
|
+
type: "object",
|
|
330
|
+
properties: {
|
|
331
|
+
text: { type: "string", description: "Words to look for (all must match)." },
|
|
332
|
+
category: { type: "string", enum: EFFECT_CATEGORIES },
|
|
333
|
+
kind: { type: "string", enum: ["shader", "vector"] }
|
|
334
|
+
},
|
|
335
|
+
additionalProperties: false
|
|
336
|
+
},
|
|
337
|
+
run: (_, params) => findEffects(params ?? {}).map((e) => ({ id: e.id, name: e.name, kind: e.kind, category: e.category, description: e.description, presets: e.presets?.map((p) => p.name) ?? [] }))
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: "effect.load",
|
|
341
|
+
label: "Load effect",
|
|
342
|
+
group: "Effect",
|
|
343
|
+
description: "Starts over from a catalog effect (by id, optionally with one of its presets), or from a shader's source.",
|
|
344
|
+
params: {
|
|
345
|
+
type: "object",
|
|
346
|
+
properties: {
|
|
347
|
+
id: { type: "string", description: "A catalog effect id (see catalog.search)." },
|
|
348
|
+
preset: { type: "string" },
|
|
349
|
+
source: { type: "string", description: "A shader source to start from instead of a catalog effect." },
|
|
350
|
+
name: { type: "string" }
|
|
351
|
+
},
|
|
352
|
+
additionalProperties: false
|
|
353
|
+
},
|
|
354
|
+
run: (editor, p) => done(p?.source !== void 0 ? editor.load({ source: p.source, name: p.name }) : editor.load(p?.id, p?.preset))
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: "effect.setSource",
|
|
358
|
+
label: "Set shader source",
|
|
359
|
+
group: "Effect",
|
|
360
|
+
description: "Replaces the shader's GLSL ES 3.00 source (Shadertoy-style mainImage; controls are uniforms annotated `// @control`). Compile errors show up in effect.describe once the preview has compiled it.",
|
|
361
|
+
params: { type: "object", properties: { source: { type: "string" } }, required: ["source"], additionalProperties: false },
|
|
362
|
+
run: (editor, p) => done(editor.setSource(p.source))
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: "effect.rename",
|
|
366
|
+
label: "Rename effect",
|
|
367
|
+
group: "Effect",
|
|
368
|
+
params: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
369
|
+
run: (editor, p) => done(editor.rename(p.name))
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
id: "values.set",
|
|
373
|
+
label: "Set values",
|
|
374
|
+
group: "Values",
|
|
375
|
+
description: "Sets control values by name, all or nothing. A keyframed control (or any, while recording) gets a keyframe at the playhead instead.",
|
|
376
|
+
params: {
|
|
377
|
+
type: "object",
|
|
378
|
+
properties: { values: { type: "object", additionalProperties: valueSchema, description: "Control name \u2192 value." } },
|
|
379
|
+
required: ["values"],
|
|
380
|
+
additionalProperties: false
|
|
381
|
+
},
|
|
382
|
+
run: (editor, p) => done(editor.setValues(p.values))
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
id: "values.reset",
|
|
386
|
+
label: "Reset values",
|
|
387
|
+
group: "Values",
|
|
388
|
+
description: "Puts controls back to their defaults: the named ones, or all.",
|
|
389
|
+
params: { type: "object", properties: { names: { type: "array", items: { type: "string" } } }, additionalProperties: false },
|
|
390
|
+
run: (editor, p) => done(editor.resetValues(p?.names))
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "values.randomize",
|
|
394
|
+
label: "Randomize values",
|
|
395
|
+
group: "Values",
|
|
396
|
+
keywords: ["shuffle", "surprise", "explore"],
|
|
397
|
+
params: { type: "object", properties: { names: { type: "array", items: { type: "string" } } }, additionalProperties: false },
|
|
398
|
+
run: (editor, p) => done(editor.randomize(p?.names))
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
id: "preset.apply",
|
|
402
|
+
label: "Apply preset",
|
|
403
|
+
group: "Presets",
|
|
404
|
+
params: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
405
|
+
run: (editor, p) => done(editor.applyPreset(p.name))
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: "preset.save",
|
|
409
|
+
label: "Save preset",
|
|
410
|
+
group: "Presets",
|
|
411
|
+
description: "Saves the values at the playhead (all, or the named controls) as a preset.",
|
|
412
|
+
params: {
|
|
413
|
+
type: "object",
|
|
414
|
+
properties: { name: { type: "string" }, names: { type: "array", items: { type: "string" } } },
|
|
415
|
+
required: ["name"],
|
|
416
|
+
additionalProperties: false
|
|
417
|
+
},
|
|
418
|
+
run: (editor, p) => done(editor.savePreset(p.name, p.names))
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
id: "preset.delete",
|
|
422
|
+
label: "Delete preset",
|
|
423
|
+
group: "Presets",
|
|
424
|
+
params: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
425
|
+
run: (editor, p) => done(editor.deletePreset(p.name))
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: "keyframe.set",
|
|
429
|
+
label: "Set keyframe",
|
|
430
|
+
group: "Keyframes",
|
|
431
|
+
description: "Keyframes a control at a time (ms). Without a value, keyframes its current value there. The easing shapes the segment leaving this keyframe.",
|
|
432
|
+
params: {
|
|
433
|
+
type: "object",
|
|
434
|
+
properties: { name: { type: "string" }, time: { type: "number", minimum: 0 }, value: valueSchema, easing: easingSchema },
|
|
435
|
+
required: ["name", "time"],
|
|
436
|
+
additionalProperties: false
|
|
437
|
+
},
|
|
438
|
+
run: (editor, p) => done(editor.setKeyframe(p.name, p.time, p.value, p.easing))
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
id: "keyframe.remove",
|
|
442
|
+
label: "Delete keyframe",
|
|
443
|
+
group: "Keyframes",
|
|
444
|
+
params: { type: "object", properties: { name: { type: "string" }, time: { type: "number" } }, required: ["name", "time"], additionalProperties: false },
|
|
445
|
+
run: (editor, p) => done(editor.removeKeyframe(p.name, p.time))
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
id: "keyframe.setEasing",
|
|
449
|
+
label: "Set keyframe easing",
|
|
450
|
+
group: "Keyframes",
|
|
451
|
+
params: {
|
|
452
|
+
type: "object",
|
|
453
|
+
properties: { name: { type: "string" }, time: { type: "number" }, easing: easingSchema },
|
|
454
|
+
required: ["name", "time", "easing"],
|
|
455
|
+
additionalProperties: false
|
|
456
|
+
},
|
|
457
|
+
run: (editor, p) => done(editor.setKeyframeEasing(p.name, p.time, p.easing))
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
id: "keyframe.clear",
|
|
461
|
+
label: "Clear keyframes",
|
|
462
|
+
group: "Keyframes",
|
|
463
|
+
description: "Removes a control's keyframes; it keeps the value it has at the playhead.",
|
|
464
|
+
params: { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false },
|
|
465
|
+
run: (editor, p) => done(editor.clearTrack(p.name))
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
id: "timeline.setTiming",
|
|
469
|
+
label: "Set timing",
|
|
470
|
+
group: "Timeline",
|
|
471
|
+
params: {
|
|
472
|
+
type: "object",
|
|
473
|
+
properties: { duration: { type: "number", exclusiveMinimum: 0, description: "Ms." }, loop: { type: "boolean" } },
|
|
474
|
+
additionalProperties: false
|
|
475
|
+
},
|
|
476
|
+
run: (editor, p) => done(editor.setTiming(p ?? {}))
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
id: "timeline.play",
|
|
480
|
+
label: "Play",
|
|
481
|
+
group: "Timeline",
|
|
482
|
+
run: (editor) => editor.play()
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: "timeline.pause",
|
|
486
|
+
label: "Pause",
|
|
487
|
+
group: "Timeline",
|
|
488
|
+
run: (editor) => editor.pause()
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: "timeline.toggle",
|
|
492
|
+
label: "Play / pause",
|
|
493
|
+
group: "Timeline",
|
|
494
|
+
shortcut: "Space",
|
|
495
|
+
run: (editor) => editor.toggle()
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
id: "timeline.seek",
|
|
499
|
+
label: "Go to time",
|
|
500
|
+
group: "Timeline",
|
|
501
|
+
params: { type: "object", properties: { time: { type: "number", minimum: 0, description: "Ms." } }, required: ["time"], additionalProperties: false },
|
|
502
|
+
run: (editor, p) => editor.seek(p.time)
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
id: "timeline.stop",
|
|
506
|
+
label: "Stop",
|
|
507
|
+
group: "Timeline",
|
|
508
|
+
run: (editor) => editor.stop()
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
id: "timeline.toggleRecording",
|
|
512
|
+
label: "Record keyframes",
|
|
513
|
+
group: "Timeline",
|
|
514
|
+
description: "While recording, changing a value keyframes it at the playhead.",
|
|
515
|
+
run: (editor) => editor.setRecording(!editor.getState().recording)
|
|
516
|
+
}
|
|
517
|
+
];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/editor/editor.ts
|
|
521
|
+
var ok = { ok: true };
|
|
522
|
+
var fail = (message) => ({ ok: false, message });
|
|
523
|
+
var EffectEditor = class {
|
|
524
|
+
history;
|
|
525
|
+
/** Every editing action, by id: for menus, shortcuts, a command palette and agents. */
|
|
526
|
+
commands;
|
|
527
|
+
doc;
|
|
528
|
+
controlsCache = null;
|
|
529
|
+
compileReport = null;
|
|
530
|
+
time = 0;
|
|
531
|
+
playing = false;
|
|
532
|
+
recording = false;
|
|
533
|
+
clock;
|
|
534
|
+
mergeWindowMs;
|
|
535
|
+
listeners = /* @__PURE__ */ new Set();
|
|
536
|
+
state = null;
|
|
537
|
+
unsubscribeHistory;
|
|
538
|
+
disposed = false;
|
|
539
|
+
constructor(options = {}) {
|
|
540
|
+
this.doc = options.document ?? createEffectDocument(options.from);
|
|
541
|
+
this.history = options.history ?? createHistoryManager({ maxDepth: 100 });
|
|
542
|
+
this.clock = options.clock ?? realClock;
|
|
543
|
+
this.mergeWindowMs = options.mergeWindowMs ?? 400;
|
|
544
|
+
this.commands = createCommandRegistry({ context: this });
|
|
545
|
+
this.commands.register([...createHistoryCommands(this.history), ...effectCommands()]);
|
|
546
|
+
this.unsubscribeHistory = this.history.subscribe(() => this.changed());
|
|
547
|
+
}
|
|
548
|
+
// ── Reading ────────────────────────────────────────────────────────
|
|
549
|
+
get document() {
|
|
550
|
+
return this.doc;
|
|
551
|
+
}
|
|
552
|
+
/** The current state; the same object until something changes (safe for `useSyncExternalStore`). */
|
|
553
|
+
getState() {
|
|
554
|
+
if (!this.state) {
|
|
555
|
+
const { controls, issues } = this.analysis();
|
|
556
|
+
this.state = {
|
|
557
|
+
document: this.doc,
|
|
558
|
+
controls,
|
|
559
|
+
values: valuesAt(this.doc, this.time, controls),
|
|
560
|
+
issues,
|
|
561
|
+
compile: this.doc.effect.kind === "shader" ? this.compileReport : null,
|
|
562
|
+
time: this.time,
|
|
563
|
+
playing: this.playing,
|
|
564
|
+
recording: this.recording,
|
|
565
|
+
history: this.history.getState()
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
return this.state;
|
|
569
|
+
}
|
|
570
|
+
/** Calls `listener` with the new state after every change; returns an unsubscribe function. */
|
|
571
|
+
subscribe(listener) {
|
|
572
|
+
this.listeners.add(listener);
|
|
573
|
+
return () => this.listeners.delete(listener);
|
|
574
|
+
}
|
|
575
|
+
/** The document's controls. */
|
|
576
|
+
get controls() {
|
|
577
|
+
return this.analysis().controls;
|
|
578
|
+
}
|
|
579
|
+
/** The document as JSON. */
|
|
580
|
+
toJSON() {
|
|
581
|
+
return this.doc;
|
|
582
|
+
}
|
|
583
|
+
// ── Whole documents ─────────────────────────────────────────────────
|
|
584
|
+
/** Replaces the document (one undo step). */
|
|
585
|
+
replaceDocument(next, label = "Replace effect") {
|
|
586
|
+
const parsed = parseEffectDocument(next);
|
|
587
|
+
if (!parsed.ok) return fail(parsed.errors.join("; "));
|
|
588
|
+
this.commit(label, parsed.document);
|
|
589
|
+
return ok;
|
|
590
|
+
}
|
|
591
|
+
/** Starts over from a catalog effect, a shader source, or blank (one undo step). */
|
|
592
|
+
load(from, preset) {
|
|
593
|
+
let next;
|
|
594
|
+
try {
|
|
595
|
+
next = createEffectDocument(from);
|
|
596
|
+
} catch (e) {
|
|
597
|
+
return fail(e.message);
|
|
598
|
+
}
|
|
599
|
+
if (preset) {
|
|
600
|
+
const values = next.presets.find((p) => p.name === preset)?.values;
|
|
601
|
+
if (!values) return fail(`"${next.name}" has no preset "${preset}"; it has ${next.presets.map((p) => `"${p.name}"`).join(", ") || "none"}.`);
|
|
602
|
+
next = { ...next, values: { ...values } };
|
|
603
|
+
}
|
|
604
|
+
this.compileReport = null;
|
|
605
|
+
this.commit(`Load ${next.name}`, next);
|
|
606
|
+
return ok;
|
|
607
|
+
}
|
|
608
|
+
rename(name) {
|
|
609
|
+
if (!name.trim()) return fail("A name cannot be empty.");
|
|
610
|
+
this.commit("Rename", { ...this.doc, name: name.trim() });
|
|
611
|
+
return ok;
|
|
612
|
+
}
|
|
613
|
+
// ── The effect itself ───────────────────────────────────────────────
|
|
614
|
+
/** Replaces a shader's source. Compile errors arrive later, from the renderer (see `reportCompile`). */
|
|
615
|
+
setSource(source) {
|
|
616
|
+
if (this.doc.effect.kind !== "shader") return fail("This is a vector effect; use setMotion.");
|
|
617
|
+
if (source === this.doc.effect.source) return ok;
|
|
618
|
+
this.commit("Edit source", { ...this.doc, effect: { kind: "shader", source } }, "source");
|
|
619
|
+
return ok;
|
|
620
|
+
}
|
|
621
|
+
/** Replaces a vector effect's picture (and optionally its controls). */
|
|
622
|
+
setMotion(motion, controls) {
|
|
623
|
+
if (this.doc.effect.kind !== "vector") return fail("This is a shader effect; use setSource.");
|
|
624
|
+
this.commit("Edit picture", { ...this.doc, effect: { ...this.doc.effect, motion, controls: controls ?? this.doc.effect.controls } });
|
|
625
|
+
return ok;
|
|
626
|
+
}
|
|
627
|
+
/** Called by a renderer binding with the result of compiling the current source. */
|
|
628
|
+
reportCompile(report) {
|
|
629
|
+
this.compileReport = { ok: report.ok, errors: [...report.errors] };
|
|
630
|
+
this.changed();
|
|
631
|
+
}
|
|
632
|
+
// ── Values ──────────────────────────────────────────────────────────
|
|
633
|
+
/**
|
|
634
|
+
* Sets a control's value. With a track on that control (or while
|
|
635
|
+
* recording) it sets a keyframe at the playhead instead. Quick successive
|
|
636
|
+
* changes to one control merge into one undo step.
|
|
637
|
+
*/
|
|
638
|
+
setValue(name, value) {
|
|
639
|
+
const control = this.control(name);
|
|
640
|
+
if (!control) return this.unknownControl(name);
|
|
641
|
+
const check = checkControlValue(control, value);
|
|
642
|
+
if (!check.ok) return fail(check.message);
|
|
643
|
+
if (this.recording || this.doc.tracks[name]) return this.setKeyframe(name, this.time, check.value, void 0, `value:${name}`);
|
|
644
|
+
this.commit(`Set ${control.label}`, { ...this.doc, values: { ...this.doc.values, [name]: check.value } }, `value:${name}`);
|
|
645
|
+
return ok;
|
|
646
|
+
}
|
|
647
|
+
/** Sets several values in one undo step; refuses them all if any is wrong. */
|
|
648
|
+
setValues(values) {
|
|
649
|
+
const next = { ...this.doc.values };
|
|
650
|
+
const tracks = { ...this.doc.tracks };
|
|
651
|
+
for (const [name, value] of Object.entries(values)) {
|
|
652
|
+
const control = this.control(name);
|
|
653
|
+
if (!control) return this.unknownControl(name);
|
|
654
|
+
const check = checkControlValue(control, value);
|
|
655
|
+
if (!check.ok) return fail(check.message);
|
|
656
|
+
if (this.recording || tracks[name]) {
|
|
657
|
+
const keyframes = [...tracks[name]?.keyframes ?? [], { time: this.time, value: check.value }];
|
|
658
|
+
tracks[name] = { ...tracks[name], keyframes: normalizeKeyframes(keyframes) };
|
|
659
|
+
} else next[name] = check.value;
|
|
660
|
+
}
|
|
661
|
+
this.commit("Set values", { ...this.doc, values: next, tracks });
|
|
662
|
+
return ok;
|
|
663
|
+
}
|
|
664
|
+
/** Back to defaults: the named controls, or all of them (values only; tracks stay). */
|
|
665
|
+
resetValues(names) {
|
|
666
|
+
const unknown = names?.find((n) => !this.control(n));
|
|
667
|
+
if (unknown) return this.unknownControl(unknown);
|
|
668
|
+
const values = names ? Object.fromEntries(Object.entries(this.doc.values).filter(([k]) => !names.includes(k))) : {};
|
|
669
|
+
this.commit("Reset values", { ...this.doc, values });
|
|
670
|
+
return ok;
|
|
671
|
+
}
|
|
672
|
+
/** Random values in each control's range (colours anywhere), for exploring. */
|
|
673
|
+
randomize(names, random = Math.random) {
|
|
674
|
+
const values = {};
|
|
675
|
+
for (const control of this.controls) {
|
|
676
|
+
if (names ? !names.includes(control.name) : control.type === "sampler2D") continue;
|
|
677
|
+
const pick = () => {
|
|
678
|
+
const v = control.min + random() * (control.max - control.min);
|
|
679
|
+
return control.type === "int" ? Math.round(v) : +v.toFixed(3);
|
|
680
|
+
};
|
|
681
|
+
if (control.type === "bool") values[control.name] = random() < 0.5;
|
|
682
|
+
else if (control.type === "sampler2D") continue;
|
|
683
|
+
else if (control.color) values[control.name] = Array.from({ length: control.type === "vec4" ? 4 : 3 }, () => +random().toFixed(3));
|
|
684
|
+
else if (control.type === "float" || control.type === "int") values[control.name] = pick();
|
|
685
|
+
else values[control.name] = Array.from({ length: Number(control.type.slice(3)) }, pick);
|
|
686
|
+
}
|
|
687
|
+
return this.setValues(values);
|
|
688
|
+
}
|
|
689
|
+
// ── Presets ─────────────────────────────────────────────────────────
|
|
690
|
+
applyPreset(name) {
|
|
691
|
+
const preset = this.doc.presets.find((p) => p.name === name);
|
|
692
|
+
if (!preset) return fail(`There is no preset "${name}"; there are ${this.doc.presets.map((p) => `"${p.name}"`).join(", ") || "none"}.`);
|
|
693
|
+
this.commit(`Apply ${name}`, { ...this.doc, values: { ...this.doc.values, ...preset.values } });
|
|
694
|
+
return ok;
|
|
695
|
+
}
|
|
696
|
+
/** Saves the current values (all, or the named ones) as a preset, replacing one of the same name. */
|
|
697
|
+
savePreset(name, names) {
|
|
698
|
+
if (!name.trim()) return fail("A preset needs a name.");
|
|
699
|
+
const all = valuesAt(this.doc, this.time, this.controls);
|
|
700
|
+
const values = Object.fromEntries(Object.entries(all).filter(([k]) => names ? names.includes(k) : true));
|
|
701
|
+
const presets = [...this.doc.presets.filter((p) => p.name !== name), { name: name.trim(), values }];
|
|
702
|
+
this.commit(`Save preset ${name}`, { ...this.doc, presets });
|
|
703
|
+
return ok;
|
|
704
|
+
}
|
|
705
|
+
deletePreset(name) {
|
|
706
|
+
if (!this.doc.presets.some((p) => p.name === name)) return fail(`There is no preset "${name}".`);
|
|
707
|
+
this.commit(`Delete preset ${name}`, { ...this.doc, presets: this.doc.presets.filter((p) => p.name !== name) });
|
|
708
|
+
return ok;
|
|
709
|
+
}
|
|
710
|
+
// ── Keyframes ───────────────────────────────────────────────────────
|
|
711
|
+
/** Sets (or replaces) a keyframe; `value` defaults to the control's value at that time. */
|
|
712
|
+
setKeyframe(name, time, value, easing, mergeKey) {
|
|
713
|
+
const control = this.control(name);
|
|
714
|
+
if (!control) return this.unknownControl(name);
|
|
715
|
+
if (!(time >= 0)) return fail("A keyframe's time is 0 ms or more.");
|
|
716
|
+
if (easing !== void 0 && !isEasing(easing)) return fail(`Unknown easing ${JSON.stringify(easing)}.`);
|
|
717
|
+
let v;
|
|
718
|
+
if (value === void 0) v = valuesAt(this.doc, time, [control])[name];
|
|
719
|
+
else {
|
|
720
|
+
const check = checkControlValue(control, value);
|
|
721
|
+
if (!check.ok) return fail(check.message);
|
|
722
|
+
v = check.value;
|
|
723
|
+
}
|
|
724
|
+
const track = this.doc.tracks[name];
|
|
725
|
+
const existing = track?.keyframes.find((k) => Math.abs(k.time - time) < 1e-3);
|
|
726
|
+
const keyframe = { time, value: v, ...(easing ?? existing?.easing) !== void 0 && { easing: easing ?? existing?.easing } };
|
|
727
|
+
const keyframes = normalizeKeyframes([...track?.keyframes ?? [], keyframe]);
|
|
728
|
+
this.commit(`Keyframe ${control.label}`, { ...this.doc, tracks: { ...this.doc.tracks, [name]: { ...track, keyframes } } }, mergeKey);
|
|
729
|
+
return ok;
|
|
730
|
+
}
|
|
731
|
+
removeKeyframe(name, time) {
|
|
732
|
+
const track = this.doc.tracks[name];
|
|
733
|
+
const keyframes = track?.keyframes.filter((k) => Math.abs(k.time - time) >= 1e-3);
|
|
734
|
+
if (!track || keyframes.length === track.keyframes.length) return fail(`"${name}" has no keyframe at ${time} ms.`);
|
|
735
|
+
const tracks = { ...this.doc.tracks };
|
|
736
|
+
if (keyframes.length) tracks[name] = { ...track, keyframes };
|
|
737
|
+
else delete tracks[name];
|
|
738
|
+
this.commit("Delete keyframe", { ...this.doc, tracks });
|
|
739
|
+
return ok;
|
|
740
|
+
}
|
|
741
|
+
setKeyframeEasing(name, time, easing) {
|
|
742
|
+
if (!isEasing(easing)) return fail(`Unknown easing ${JSON.stringify(easing)}.`);
|
|
743
|
+
const track = this.doc.tracks[name];
|
|
744
|
+
if (!track?.keyframes.some((k) => Math.abs(k.time - time) < 1e-3)) return fail(`"${name}" has no keyframe at ${time} ms.`);
|
|
745
|
+
const keyframes = track.keyframes.map((k) => Math.abs(k.time - time) < 1e-3 ? { ...k, easing } : k);
|
|
746
|
+
this.commit("Set easing", { ...this.doc, tracks: { ...this.doc.tracks, [name]: { ...track, keyframes } } });
|
|
747
|
+
return ok;
|
|
748
|
+
}
|
|
749
|
+
/** Removes a control's track; it keeps the value it had at the playhead. */
|
|
750
|
+
clearTrack(name) {
|
|
751
|
+
if (!this.doc.tracks[name]) return fail(`"${name}" has no keyframes.`);
|
|
752
|
+
const control = this.control(name);
|
|
753
|
+
const value = control ? evaluateTrack(this.doc.tracks[name], this.time, control) : void 0;
|
|
754
|
+
const tracks = { ...this.doc.tracks };
|
|
755
|
+
delete tracks[name];
|
|
756
|
+
const values = value === void 0 ? this.doc.values : { ...this.doc.values, [name]: value };
|
|
757
|
+
this.commit("Clear keyframes", { ...this.doc, tracks, values });
|
|
758
|
+
return ok;
|
|
759
|
+
}
|
|
760
|
+
setTiming(timing) {
|
|
761
|
+
if (timing.duration !== void 0 && !(timing.duration > 0)) return fail("The duration must be above 0 ms.");
|
|
762
|
+
this.commit("Set timing", { ...this.doc, duration: timing.duration ?? this.doc.duration, loop: timing.loop ?? this.doc.loop });
|
|
763
|
+
return ok;
|
|
764
|
+
}
|
|
765
|
+
// ── Playhead ────────────────────────────────────────────────────────
|
|
766
|
+
play() {
|
|
767
|
+
if (this.playing || this.disposed) return;
|
|
768
|
+
if (!this.doc.loop && this.time >= this.doc.duration) this.time = 0;
|
|
769
|
+
this.playing = true;
|
|
770
|
+
this.changed();
|
|
771
|
+
void this.run();
|
|
772
|
+
}
|
|
773
|
+
pause() {
|
|
774
|
+
if (!this.playing) return;
|
|
775
|
+
this.playing = false;
|
|
776
|
+
this.changed();
|
|
777
|
+
}
|
|
778
|
+
toggle() {
|
|
779
|
+
if (this.playing) this.pause();
|
|
780
|
+
else this.play();
|
|
781
|
+
}
|
|
782
|
+
/** Moves the playhead (clamped to the timeline). */
|
|
783
|
+
seek(time) {
|
|
784
|
+
const t = Math.min(Math.max(0, time), this.doc.duration);
|
|
785
|
+
if (t === this.time) return;
|
|
786
|
+
this.time = t;
|
|
787
|
+
this.changed();
|
|
788
|
+
}
|
|
789
|
+
stop() {
|
|
790
|
+
this.playing = false;
|
|
791
|
+
this.time = 0;
|
|
792
|
+
this.changed();
|
|
793
|
+
}
|
|
794
|
+
/** While recording, changing a value keyframes it at the playhead. */
|
|
795
|
+
setRecording(recording) {
|
|
796
|
+
if (recording === this.recording) return;
|
|
797
|
+
this.recording = recording;
|
|
798
|
+
this.changed();
|
|
799
|
+
}
|
|
800
|
+
undo() {
|
|
801
|
+
return this.history.undo();
|
|
802
|
+
}
|
|
803
|
+
redo() {
|
|
804
|
+
return this.history.redo();
|
|
805
|
+
}
|
|
806
|
+
dispose() {
|
|
807
|
+
this.disposed = true;
|
|
808
|
+
this.playing = false;
|
|
809
|
+
this.unsubscribeHistory();
|
|
810
|
+
this.listeners.clear();
|
|
811
|
+
}
|
|
812
|
+
// ── Internals ───────────────────────────────────────────────────────
|
|
813
|
+
async run() {
|
|
814
|
+
let last = this.clock.now();
|
|
815
|
+
while (this.playing && !this.disposed) {
|
|
816
|
+
await this.clock.frame();
|
|
817
|
+
if (!this.playing || this.disposed) return;
|
|
818
|
+
const now = this.clock.now();
|
|
819
|
+
let next = this.time + (now - last);
|
|
820
|
+
last = now;
|
|
821
|
+
if (next >= this.doc.duration) {
|
|
822
|
+
if (this.doc.loop) next %= this.doc.duration;
|
|
823
|
+
else {
|
|
824
|
+
this.time = this.doc.duration;
|
|
825
|
+
this.playing = false;
|
|
826
|
+
this.changed();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
this.time = next;
|
|
831
|
+
this.changed();
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
analysis() {
|
|
835
|
+
if (this.controlsCache?.doc !== this.doc) {
|
|
836
|
+
this.controlsCache = { doc: this.doc, controls: documentControls(this.doc), issues: documentIssues(this.doc) };
|
|
837
|
+
}
|
|
838
|
+
return this.controlsCache;
|
|
839
|
+
}
|
|
840
|
+
control(name) {
|
|
841
|
+
return this.controls.find((c) => c.name === name);
|
|
842
|
+
}
|
|
843
|
+
unknownControl(name) {
|
|
844
|
+
return fail(`There is no control "${name}"; the controls are ${this.controls.map((c) => c.name).join(", ") || "none"}.`);
|
|
845
|
+
}
|
|
846
|
+
commit(label, next, mergeKey) {
|
|
847
|
+
const before = this.doc;
|
|
848
|
+
const command = HistoryManager.createSnapshotCommand({
|
|
849
|
+
name: label,
|
|
850
|
+
before,
|
|
851
|
+
after: next,
|
|
852
|
+
apply: (doc) => {
|
|
853
|
+
this.doc = doc;
|
|
854
|
+
if (this.time > doc.duration) this.time = doc.duration;
|
|
855
|
+
this.changed();
|
|
856
|
+
},
|
|
857
|
+
tag: mergeKey
|
|
858
|
+
});
|
|
859
|
+
if (mergeKey) this.history.executeMerged(command, { key: mergeKey, windowMs: this.mergeWindowMs });
|
|
860
|
+
else this.history.execute(command);
|
|
861
|
+
}
|
|
862
|
+
changed() {
|
|
863
|
+
this.state = null;
|
|
864
|
+
if (this.listeners.size === 0) return;
|
|
865
|
+
const state = this.getState();
|
|
866
|
+
for (const listener of [...this.listeners]) listener(state);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
function createEffectEditor(options = {}) {
|
|
870
|
+
return new EffectEditor(options);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// src/editor/agent.ts
|
|
874
|
+
import { createCommandToolProvider } from "@pixerate/editor/commands";
|
|
875
|
+
function createEffectToolProvider(editor, options = {}) {
|
|
876
|
+
const base = createCommandToolProvider(editor.commands, {
|
|
877
|
+
id: options.id ?? "flair-effect",
|
|
878
|
+
namespace: options.namespace,
|
|
879
|
+
exclude: options.exclude
|
|
880
|
+
});
|
|
881
|
+
const extra = [];
|
|
882
|
+
if (options.capture) {
|
|
883
|
+
const capture = options.capture;
|
|
884
|
+
extra.push({
|
|
885
|
+
name: options.namespace ? `${options.namespace}__view_effect` : "view_effect",
|
|
886
|
+
title: "View effect",
|
|
887
|
+
description: "An image of the effect's preview as it is drawn now, to check what your changes look like.",
|
|
888
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
889
|
+
annotations: { readOnlyHint: true },
|
|
890
|
+
handler: async () => {
|
|
891
|
+
const url = await capture();
|
|
892
|
+
const match = url ? /^data:([^;,]+);base64,(.*)$/.exec(url) : null;
|
|
893
|
+
if (!match) return { content: [{ type: "text", text: "Nothing to show yet: the preview has not drawn a frame." }], isError: true };
|
|
894
|
+
return { content: [{ type: "image", data: match[2], mimeType: match[1] }] };
|
|
895
|
+
}
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
const baseTools = () => typeof base.tools === "function" ? base.tools() : base.tools;
|
|
899
|
+
return { id: base.id, namespace: base.namespace, subscribe: base.subscribe, tools: () => [...baseTools(), ...extra] };
|
|
900
|
+
}
|
|
901
|
+
function toolDefinitions(provider) {
|
|
902
|
+
const tools = typeof provider.tools === "function" ? provider.tools() : provider.tools;
|
|
903
|
+
return tools.map(({ handler: _handler, ...definition }) => definition);
|
|
904
|
+
}
|
|
905
|
+
async function callTool(provider, name, args = {}) {
|
|
906
|
+
const tools = typeof provider.tools === "function" ? provider.tools() : provider.tools;
|
|
907
|
+
const tool = tools.find((t) => t.name === name);
|
|
908
|
+
if (!tool) return { content: [{ type: "text", text: `There is no tool "${name}".` }], isError: true };
|
|
909
|
+
try {
|
|
910
|
+
const result = await tool.handler(args, { name, providerId: provider.id });
|
|
911
|
+
if (result && typeof result === "object" && Array.isArray(result.content)) return result;
|
|
912
|
+
if (result === void 0) return { content: [] };
|
|
913
|
+
if (typeof result === "string") return { content: [{ type: "text", text: result }] };
|
|
914
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result };
|
|
915
|
+
} catch (e) {
|
|
916
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
var EFFECT_AGENT_INSTRUCTIONS = `You design web visual effects in an effect editor. Work through its tools: \`list_commands\` shows every command with its parameters; \`run_command\` runs one; \`run_commands\` runs several in order.
|
|
920
|
+
|
|
921
|
+
Start with \`effect.describe\` to see what is being edited. To start from something close, use \`catalog.search\` then \`effect.load\` (with a preset if one fits). Every change is one undo step; \`history.undo\` reverts a step.
|
|
922
|
+
|
|
923
|
+
Shader effects are GLSL ES 3.00 fragment shaders written Shadertoy-style:
|
|
924
|
+
- Define \`void mainImage(out vec4 fragColor, in vec2 fragCoord)\`; the output is opaque (alpha is ignored).
|
|
925
|
+
- Built-in uniforms (do not declare them): ${BUILTIN_UNIFORMS.map((u) => `\`${u.type} ${u.name}\` (${u.doc})`).join("; ")}.
|
|
926
|
+
- Declare controls as uniforms with an annotation on the same line, e.g.
|
|
927
|
+
uniform float uSpeed; // @control min=0 max=4 default=1 label="Speed"
|
|
928
|
+
uniform vec3 uTint; // @control color default=#77C16A label="Tint"
|
|
929
|
+
uniform bool uGrain; // @control default=true label="Film grain"
|
|
930
|
+
uniform sampler2D uPhoto; // @control filter=linear wrap=clamp label="Photo"
|
|
931
|
+
Types: float, int, bool, vec2, vec3, vec4, sampler2D. Every uniform you declare should be a control.
|
|
932
|
+
- Write float literals with a decimal point (1.0, not 1); GLSL does not convert int to float implicitly. Use \`texture()\`, not \`texture2D()\`.
|
|
933
|
+
- After \`effect.setSource\`, call \`effect.describe\` and read \`compile\`: fix any errors (their line numbers match your source) before going on.
|
|
934
|
+
|
|
935
|
+
Vector effects are animated SVG pictures: change their controls' values; their picture is edited with care, keeping element ids that animations refer to.
|
|
936
|
+
|
|
937
|
+
Make effects that loop smoothly and read well behind content: keep motion calm unless asked otherwise, keep contrast where text may sit, and expose the few controls a designer would actually want (colours, speed, scale, intensity) with sensible ranges. Use keyframes (\`keyframe.set\`, in ms) for changes over the timeline. When you can, look at the result with \`view_effect\` and refine it.`;
|
|
938
|
+
|
|
939
|
+
// src/editor/bind.ts
|
|
940
|
+
function bindShaderRenderer(editor, renderer) {
|
|
941
|
+
let source;
|
|
942
|
+
renderer.pause();
|
|
943
|
+
const sync = (state) => {
|
|
944
|
+
const effect = state.document.effect;
|
|
945
|
+
if (effect.kind !== "shader") return;
|
|
946
|
+
if (effect.source !== source) {
|
|
947
|
+
source = effect.source;
|
|
948
|
+
editor.reportCompile(renderer.setSource(source));
|
|
949
|
+
}
|
|
950
|
+
renderer.setValues(state.values);
|
|
951
|
+
renderer.seek(state.time / 1e3);
|
|
952
|
+
};
|
|
953
|
+
sync(editor.getState());
|
|
954
|
+
return editor.subscribe(sync);
|
|
955
|
+
}
|
|
956
|
+
function bindSvgView(editor, container) {
|
|
957
|
+
let view;
|
|
958
|
+
let key;
|
|
959
|
+
const sync = (state) => {
|
|
960
|
+
const motion = motionAt(state.document, state.time);
|
|
961
|
+
if (!motion) {
|
|
962
|
+
view?.element.remove();
|
|
963
|
+
view = void 0;
|
|
964
|
+
key = void 0;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const next = JSON.stringify(motion);
|
|
968
|
+
if (next !== key) {
|
|
969
|
+
key = next;
|
|
970
|
+
view?.element.remove();
|
|
971
|
+
view = svgView(motion);
|
|
972
|
+
container.appendChild(view.element);
|
|
973
|
+
}
|
|
974
|
+
view.draw(state.time);
|
|
975
|
+
};
|
|
976
|
+
sync(editor.getState());
|
|
977
|
+
const unsubscribe = editor.subscribe(sync);
|
|
978
|
+
return () => {
|
|
979
|
+
unsubscribe();
|
|
980
|
+
view?.element.remove();
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
export {
|
|
984
|
+
EASING_PRESETS,
|
|
985
|
+
EFFECT_AGENT_INSTRUCTIONS,
|
|
986
|
+
EffectEditor,
|
|
987
|
+
bindShaderRenderer,
|
|
988
|
+
bindSvgView,
|
|
989
|
+
callTool,
|
|
990
|
+
createEffectDocument,
|
|
991
|
+
createEffectEditor,
|
|
992
|
+
createEffectToolProvider,
|
|
993
|
+
cubicBezierAt,
|
|
994
|
+
describeEditor,
|
|
995
|
+
documentControls,
|
|
996
|
+
documentIssues,
|
|
997
|
+
effectCommands,
|
|
998
|
+
evaluateEasing,
|
|
999
|
+
evaluateTrack,
|
|
1000
|
+
interpolateControlValue,
|
|
1001
|
+
isEasing,
|
|
1002
|
+
keyframeTimes,
|
|
1003
|
+
motionAt,
|
|
1004
|
+
normalizeKeyframes,
|
|
1005
|
+
parseEffectDocument,
|
|
1006
|
+
toolDefinitions,
|
|
1007
|
+
valuesAt
|
|
1008
|
+
};
|
|
1009
|
+
//# sourceMappingURL=index.js.map
|