reframe-video 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +77 -0
  3. package/assets/fonts/inter-400.woff2 +0 -0
  4. package/assets/fonts/inter-700.woff2 +0 -0
  5. package/assets/fonts/inter-800.woff2 +0 -0
  6. package/assets/sfx/LICENSE.md +12 -0
  7. package/assets/sfx/click_002.ogg +0 -0
  8. package/assets/sfx/click_003.ogg +0 -0
  9. package/assets/sfx/click_004.ogg +0 -0
  10. package/assets/sfx/confirmation_001.ogg +0 -0
  11. package/assets/sfx/keypress-001.wav +0 -0
  12. package/assets/sfx/keypress-004.wav +0 -0
  13. package/assets/sfx/keypress-007.wav +0 -0
  14. package/assets/sfx/keypress-010.wav +0 -0
  15. package/assets/sfx/keypress-014.wav +0 -0
  16. package/dist/analyze.js +344 -0
  17. package/dist/bin.js +1677 -0
  18. package/dist/browserEntry.js +532 -0
  19. package/dist/cli.js +1205 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +889 -0
  22. package/dist/renderer-canvas.js +89 -0
  23. package/dist/types/audio.d.ts +53 -0
  24. package/dist/types/behaviors.d.ts +7 -0
  25. package/dist/types/compile.d.ts +38 -0
  26. package/dist/types/compose.d.ts +64 -0
  27. package/dist/types/dsl.d.ts +66 -0
  28. package/dist/types/evaluate.d.ts +59 -0
  29. package/dist/types/index.d.ts +9 -0
  30. package/dist/types/interpolate.d.ts +12 -0
  31. package/dist/types/ir.d.ts +213 -0
  32. package/dist/types/validate.d.ts +12 -0
  33. package/guides/edsl-guide.md +202 -0
  34. package/guides/regen-contract.md +18 -0
  35. package/package.json +55 -0
  36. package/preview/index.html +60 -0
  37. package/preview/src/main.ts +162 -0
  38. package/preview/src/panel.ts +347 -0
  39. package/preview/src/store.ts +220 -0
  40. package/preview/src/virtual.d.ts +4 -0
  41. package/preview/vite.config.ts +52 -0
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Inspector panel: scene knobs, node tree, per-prop rows with state-scope
3
+ * expansion, labeled timeline steps, behaviors, compose report, overlay IO.
4
+ * Plain DOM; rebuilt on "structure" changes, report-only refresh on "value".
5
+ */
6
+
7
+ import {
8
+ EASE_NAMES,
9
+ PROPS_BY_TYPE,
10
+ isColor,
11
+ type NodeIR,
12
+ type OverlayDoc,
13
+ type PropValue,
14
+ type TimelineIR,
15
+ } from "@reframe/core";
16
+ import type { EditorStore } from "./store.js";
17
+
18
+ const NUMERIC_DEFAULTS: Record<string, number> = { opacity: 1, scale: 1, rotation: 0 };
19
+ const RANGES: Record<string, [number, number, number]> = {
20
+ opacity: [0, 1, 0.01],
21
+ progress: [0, 1, 0.01],
22
+ scale: [0, 3, 0.01],
23
+ rotation: [-360, 360, 1],
24
+ };
25
+ const ANCHORS = [
26
+ "top-left", "top-center", "top-right",
27
+ "center-left", "center", "center-right",
28
+ "bottom-left", "bottom-center", "bottom-right",
29
+ ];
30
+
31
+ function el<K extends keyof HTMLElementTagNameMap>(
32
+ tag: K,
33
+ attrs: Record<string, string> = {},
34
+ ...children: (HTMLElement | string)[]
35
+ ): HTMLElementTagNameMap[K] {
36
+ const node = document.createElement(tag);
37
+ for (const [k, v] of Object.entries(attrs)) {
38
+ if (k === "class") node.className = v;
39
+ else node.setAttribute(k, v);
40
+ }
41
+ node.append(...children);
42
+ return node;
43
+ }
44
+
45
+ function findNode(nodes: NodeIR[], id: string): NodeIR | null {
46
+ for (const node of nodes) {
47
+ if (node.id === id) return node;
48
+ if (node.type === "group") {
49
+ const hit = findNode(node.children, id);
50
+ if (hit) return hit;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Value editor for one PropValue; numbers get ranges where it makes sense. */
57
+ function makeControl(
58
+ prop: string,
59
+ value: PropValue | undefined,
60
+ edited: boolean,
61
+ onChange: (v: PropValue) => void,
62
+ onRevert: () => void,
63
+ ): HTMLElement {
64
+ let input: HTMLElement;
65
+ if (prop === "anchor") {
66
+ const select = el("select");
67
+ for (const a of ANCHORS) select.append(el("option", { value: a }, a));
68
+ select.value = String(value ?? "top-left");
69
+ select.addEventListener("change", () => onChange(select.value));
70
+ input = select;
71
+ } else if (typeof value === "number" || (value === undefined && prop in NUMERIC_DEFAULTS)) {
72
+ const v = typeof value === "number" ? value : NUMERIC_DEFAULTS[prop]!;
73
+ const range = RANGES[prop];
74
+ const number = el("input", { type: range ? "range" : "number", value: String(v) }) as HTMLInputElement;
75
+ if (range) {
76
+ number.min = String(range[0]);
77
+ number.max = String(range[1]);
78
+ number.step = String(range[2]);
79
+ } else {
80
+ number.step = "1";
81
+ }
82
+ number.addEventListener("input", () => {
83
+ const parsed = Number(number.value);
84
+ if (!Number.isNaN(parsed)) onChange(parsed);
85
+ });
86
+ input = number;
87
+ } else if (typeof value === "string" && isColor(value)) {
88
+ const color = el("input", { type: "color", value: value.slice(0, 7) }) as HTMLInputElement;
89
+ color.addEventListener("input", () => onChange(color.value));
90
+ input = color;
91
+ } else {
92
+ const text = el("input", { type: "text", value: String(value ?? "") }) as HTMLInputElement;
93
+ text.addEventListener("change", () => onChange(text.value));
94
+ input = text;
95
+ }
96
+ const revert = el("button", { class: "revert", title: "revert" }, "×");
97
+ revert.addEventListener("click", onRevert);
98
+ const row = el("div", { class: `prop-row${edited ? " edited" : ""}` }, input);
99
+ if (edited) row.append(revert);
100
+ return row;
101
+ }
102
+
103
+ export function buildPanel(store: EditorStore, root: HTMLElement) {
104
+ let reportBox: HTMLElement | null = null;
105
+
106
+ function rebuild() {
107
+ root.replaceChildren();
108
+ const ir = store.compiled.ir;
109
+
110
+ // --- scene ---
111
+ root.append(el("h3", {}, "Scene"));
112
+ const bg = makeControl(
113
+ "background",
114
+ ir.background ?? "#000000",
115
+ store.draft.scene?.background !== undefined,
116
+ (v) => store.setSceneProp("background", v),
117
+ () => store.unsetSceneProp("background"),
118
+ );
119
+ bg.prepend(el("label", {}, "background"));
120
+ root.append(bg);
121
+ const dur = makeControl(
122
+ "duration",
123
+ ir.duration ?? 0,
124
+ store.draft.scene?.duration !== undefined,
125
+ (v) => store.setSceneProp("duration", Number(v)),
126
+ () => store.unsetSceneProp("duration"),
127
+ );
128
+ dur.prepend(el("label", {}, "duration (s)"));
129
+ root.append(dur);
130
+
131
+ // --- node tree ---
132
+ root.append(el("h3", {}, "Nodes"));
133
+ const renderTree = (nodes: NodeIR[], depth: number) => {
134
+ for (const node of nodes) {
135
+ const edits = store.nodeEditCount(node.id);
136
+ const item = el(
137
+ "div",
138
+ { class: `tree-item${store.selectedId === node.id ? " selected" : ""}` },
139
+ el("span", { style: `padding-left:${depth * 14}px` }, `${node.id} `),
140
+ el("span", { class: "badge" }, edits > 0 ? `●${edits}` : ""),
141
+ );
142
+ item.addEventListener("click", () => store.select(node.id));
143
+ root.append(item);
144
+ if (node.type === "group") renderTree(node.children, depth + 1);
145
+ }
146
+ };
147
+ renderTree(ir.nodes, 0);
148
+
149
+ // --- selected node props with scope expansion ---
150
+ if (store.selectedId) {
151
+ const node = findNode(ir.nodes, store.selectedId);
152
+ if (node) {
153
+ const id = node.id;
154
+ root.append(el("h3", {}, `Props: ${id} (${node.type})`));
155
+ const props = node.props as unknown as Record<string, PropValue | undefined>;
156
+ const states = ir.states ?? {};
157
+ const initial = ir.initial;
158
+
159
+ for (const prop of PROPS_BY_TYPE[node.type]) {
160
+ const baseValue = props[prop] ?? (prop in NUMERIC_DEFAULTS ? NUMERIC_DEFAULTS[prop] : undefined);
161
+ if (baseValue === undefined && !(prop in NUMERIC_DEFAULTS)) continue; // unset optional prop
162
+ const touchingStates = Object.keys(states).filter(
163
+ (s) => states[s]?.[id]?.[prop] !== undefined,
164
+ );
165
+ const deadBase = initial !== undefined && touchingStates.includes(initial);
166
+
167
+ const baseRow = makeControl(
168
+ prop,
169
+ baseValue,
170
+ store.hasNodeEdit(id, prop),
171
+ (v) => store.setNodeProp(id, prop, v),
172
+ () => store.unsetNodeProp(id, prop),
173
+ );
174
+ baseRow.prepend(
175
+ el("label", {}, touchingStates.length > 0 ? `${prop} (base)` : prop),
176
+ );
177
+ if (deadBase) {
178
+ baseRow.classList.add("dead");
179
+ baseRow.querySelectorAll("input,select").forEach((i) => i.setAttribute("disabled", ""));
180
+ }
181
+ root.append(baseRow);
182
+ if (deadBase) {
183
+ root.append(el("div", { class: "hint" }, `overridden by initial "${initial}" — edit the state rows below`));
184
+ }
185
+
186
+ for (const s of touchingStates) {
187
+ const row = makeControl(
188
+ prop,
189
+ states[s]![id]![prop],
190
+ store.hasStateEdit(s, id, prop),
191
+ (v) => store.setStateProp(s, id, prop, v),
192
+ () => store.unsetStateProp(s, id, prop),
193
+ );
194
+ const label = el("label", {}, `${prop} `);
195
+ label.append(el("span", { class: "scope" }, `@${s}`));
196
+ row.prepend(label);
197
+ root.append(row);
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ // --- labeled timeline steps ---
204
+ const steps: Extract<TimelineIR, { label?: string }>[] = [];
205
+ const walkTl = (tl: TimelineIR) => {
206
+ if ("label" in tl && tl.label !== undefined) steps.push(tl);
207
+ if ("children" in tl) tl.children.forEach(walkTl);
208
+ };
209
+ if (ir.timeline) walkTl(ir.timeline);
210
+ if (steps.length > 0) {
211
+ root.append(el("h3", {}, "Timeline"));
212
+ for (const step of steps) {
213
+ const label = step.label!;
214
+ const card = el("div", { class: "step-card" },
215
+ el("div", {}, `${label} `, el("span", { class: "kind" }, `(${step.kind})`)),
216
+ );
217
+ const durRow = makeControl(
218
+ "duration",
219
+ "duration" in step ? (step.duration ?? 0.5) : 0.5,
220
+ store.hasTimelineEdit(label, "duration"),
221
+ (v) => store.setTimelineParam(label, "duration", Number(v)),
222
+ () => store.unsetTimelineParam(label, "duration"),
223
+ );
224
+ durRow.prepend(el("label", {}, "duration"));
225
+ card.append(durRow);
226
+ if (step.kind === "to" || step.kind === "tween") {
227
+ const easeSelect = el("select");
228
+ const current = "ease" in step ? step.ease : undefined;
229
+ for (const name of EASE_NAMES) easeSelect.append(el("option", { value: name }, name));
230
+ if (typeof current === "object") easeSelect.append(el("option", { value: "__custom" }, "custom bezier"));
231
+ easeSelect.value = typeof current === "string" ? current : typeof current === "object" ? "__custom" : "linear";
232
+ easeSelect.addEventListener("change", () => {
233
+ if (easeSelect.value !== "__custom") store.setTimelineParam(label, "ease", easeSelect.value);
234
+ });
235
+ const easeRow = el("div", { class: `prop-row${store.hasTimelineEdit(label, "ease") ? " edited" : ""}` }, el("label", {}, "ease"), easeSelect);
236
+ card.append(easeRow);
237
+ }
238
+ if (step.kind === "to") {
239
+ const stRow = makeControl(
240
+ "stagger",
241
+ step.stagger ?? 0,
242
+ store.hasTimelineEdit(label, "stagger"),
243
+ (v) => store.setTimelineParam(label, "stagger", Number(v)),
244
+ () => store.unsetTimelineParam(label, "stagger"),
245
+ );
246
+ stRow.prepend(el("label", {}, "stagger"));
247
+ card.append(stRow);
248
+ }
249
+ root.append(card);
250
+ }
251
+ }
252
+
253
+ // --- behaviors ---
254
+ if ((ir.behaviors ?? []).length > 0) {
255
+ root.append(el("h3", {}, "Behaviors"));
256
+ for (const b of ir.behaviors!) {
257
+ const card = el("div", { class: "behavior-card" },
258
+ el("div", {}, `${b.target}.${b.prop} `, el("span", { class: "kind" }, b.behavior.name)),
259
+ );
260
+ for (const [param, value] of Object.entries(b.behavior.params)) {
261
+ const row = makeControl(
262
+ param,
263
+ value,
264
+ store.hasBehaviorEdit(b.target, b.prop),
265
+ (v) => store.setBehaviorParam(b.target, b.prop, param, Number(v)),
266
+ () => store.unsetBehavior(b.target, b.prop),
267
+ );
268
+ row.prepend(el("label", {}, param));
269
+ card.append(row);
270
+ }
271
+ root.append(card);
272
+ }
273
+ }
274
+
275
+ // --- report ---
276
+ root.append(el("h3", {}, "Compose"));
277
+ reportBox = el("div", { id: "report" });
278
+ root.append(reportBox);
279
+ refreshReport();
280
+
281
+ // --- io ---
282
+ root.append(el("h3", {}, "Overlay"));
283
+ const name = el("input", { type: "text", id: "overlay-name", value: store.overlayName }) as HTMLInputElement;
284
+ name.addEventListener("change", () => (store.overlayName = name.value));
285
+ const download = el("button", {}, "download");
286
+ download.addEventListener("click", () => {
287
+ const json = JSON.stringify(store.exportDraft(), null, 2);
288
+ const a = el("a", {
289
+ href: URL.createObjectURL(new Blob([json], { type: "application/json" })),
290
+ download: `${store.overlayName}.json`,
291
+ });
292
+ a.click();
293
+ });
294
+ const copy = el("button", {}, "copy");
295
+ copy.addEventListener("click", () => {
296
+ void navigator.clipboard.writeText(JSON.stringify(store.exportDraft(), null, 2));
297
+ copy.textContent = "copied!";
298
+ setTimeout(() => (copy.textContent = "copy"), 1200);
299
+ });
300
+ const load = el("button", {}, "load…");
301
+ const file = el("input", { type: "file", accept: ".json", style: "display:none" }) as HTMLInputElement;
302
+ load.addEventListener("click", () => file.click());
303
+ file.addEventListener("change", async () => {
304
+ const f = file.files?.[0];
305
+ if (!f) return;
306
+ try {
307
+ const doc = JSON.parse(await f.text()) as OverlayDoc;
308
+ if (doc.reframeOverlay !== 1) throw new Error("not a reframe overlay (reframeOverlay: 1 missing)");
309
+ store.importDraft(doc);
310
+ } catch (err) {
311
+ alert(`could not load overlay: ${err instanceof Error ? err.message : err}`);
312
+ }
313
+ file.value = "";
314
+ });
315
+ const reset = el("button", {}, "reset");
316
+ reset.addEventListener("click", () => {
317
+ if (!store.dirty || confirm("Discard all edits?")) store.resetDraft();
318
+ });
319
+ root.append(name, el("div", { id: "io" }, download, copy, load, file, reset));
320
+ }
321
+
322
+ function refreshReport() {
323
+ if (!reportBox) return;
324
+ reportBox.replaceChildren();
325
+ if (store.composeError) {
326
+ reportBox.append(el("div", { class: "error" }, store.composeError));
327
+ }
328
+ const report = store.report;
329
+ if (!report) return;
330
+ reportBox.append(
331
+ el("div", {}, `${report.applied.length} applied, ${report.orphans.length} orphaned, ${report.warnings.length} warnings`),
332
+ );
333
+ for (const o of report.orphans) {
334
+ reportBox.append(el("div", { class: "orphan" }, `✗ ${o.address}: ${o.reason}`));
335
+ }
336
+ for (const w of report.warnings) {
337
+ reportBox.append(el("div", { class: "warning" }, `! ${w}`));
338
+ }
339
+ if (report.applied.length > 0) {
340
+ const details = el("details", {}, el("summary", {}, "applied"));
341
+ for (const a of report.applied) details.append(el("div", {}, `✓ ${a.address}`));
342
+ reportBox.append(details);
343
+ }
344
+ }
345
+
346
+ return { rebuild, refreshReport };
347
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Editor state: one draft OverlayDoc per scene. Every edit writes a key into
3
+ * the draft (keyed merge — re-editing the same control can never duplicate),
4
+ * then the scene is recomposed. Validation failures keep the last good
5
+ * compiled scene so live typing never blanks the canvas.
6
+ */
7
+
8
+ import {
9
+ composeScene,
10
+ compileScene,
11
+ SceneValidationError,
12
+ type BehaviorIR,
13
+ type CompiledScene,
14
+ type ComposeReport,
15
+ type OverlayDoc,
16
+ type PropValue,
17
+ type SceneIR,
18
+ } from "@reframe/core";
19
+
20
+ export type ChangeKind = "value" | "structure";
21
+ type Listener = (kind: ChangeKind) => void;
22
+
23
+ export class EditorStore {
24
+ base: SceneIR;
25
+ draft: OverlayDoc;
26
+ compiled: CompiledScene;
27
+ report: ComposeReport | null = null;
28
+ /** Set when the last recompose threw (overlay defect); compiled stays last-good. */
29
+ composeError: string | null = null;
30
+ selectedId: string | null = null;
31
+ overlayName: string;
32
+
33
+ private listeners = new Set<Listener>();
34
+
35
+ constructor(base: SceneIR) {
36
+ this.base = base;
37
+ this.overlayName = `${base.id}-edits`;
38
+ this.draft = { reframeOverlay: 1 };
39
+ this.compiled = compileScene(base);
40
+ this.recompose("structure");
41
+ }
42
+
43
+ subscribe(fn: Listener): () => void {
44
+ this.listeners.add(fn);
45
+ return () => this.listeners.delete(fn);
46
+ }
47
+
48
+ get dirty(): boolean {
49
+ const d = this.draft;
50
+ return Boolean(
51
+ Object.keys(d.nodes ?? {}).length ||
52
+ Object.keys(d.states ?? {}).length ||
53
+ Object.keys(d.timeline ?? {}).length ||
54
+ d.behaviors?.set?.length ||
55
+ d.scene,
56
+ );
57
+ }
58
+
59
+ // --- setters (all keyed; re-edits overwrite) ---
60
+
61
+ setNodeProp(id: string, prop: string, value: PropValue) {
62
+ ((this.draft.nodes ??= {})[id] ??= {})[prop] = value;
63
+ this.recompose("value");
64
+ }
65
+
66
+ unsetNodeProp(id: string, prop: string) {
67
+ delete this.draft.nodes?.[id]?.[prop];
68
+ this.prune();
69
+ this.recompose("structure");
70
+ }
71
+
72
+ setStateProp(state: string, id: string, prop: string, value: PropValue) {
73
+ (((this.draft.states ??= {})[state] ??= {})[id] ??= {})[prop] = value;
74
+ this.recompose("value");
75
+ }
76
+
77
+ unsetStateProp(state: string, id: string, prop: string) {
78
+ delete this.draft.states?.[state]?.[id]?.[prop];
79
+ this.prune();
80
+ this.recompose("structure");
81
+ }
82
+
83
+ setSceneProp(key: "background" | "duration" | "fps", value: string | number) {
84
+ (this.draft.scene ??= {})[key] = value as never;
85
+ this.recompose("value");
86
+ }
87
+
88
+ unsetSceneProp(key: "background" | "duration" | "fps") {
89
+ delete this.draft.scene?.[key];
90
+ this.prune();
91
+ this.recompose("structure");
92
+ }
93
+
94
+ setTimelineParam(label: string, key: "duration" | "ease" | "stagger", value: number | string) {
95
+ ((this.draft.timeline ??= {})[label] ??= {})[key] = value as never;
96
+ this.recompose("value");
97
+ }
98
+
99
+ unsetTimelineParam(label: string, key: "duration" | "ease" | "stagger") {
100
+ delete this.draft.timeline?.[label]?.[key];
101
+ this.prune();
102
+ this.recompose("structure");
103
+ }
104
+
105
+ /** Clone the composed behavior, patch one param, upsert the whole thing. */
106
+ setBehaviorParam(target: string, prop: string, param: string, value: number) {
107
+ const current = this.compiled.ir.behaviors?.find(
108
+ (b) => b.target === target && b.prop === prop,
109
+ );
110
+ if (!current) return;
111
+ const patched = structuredClone(current) as BehaviorIR;
112
+ (patched.behavior.params as Record<string, number>)[param] = value;
113
+ const set = ((this.draft.behaviors ??= {}).set ??= []);
114
+ const index = set.findIndex((b) => b.target === target && b.prop === prop);
115
+ if (index >= 0) set[index] = patched;
116
+ else set.push(patched);
117
+ this.recompose("value");
118
+ }
119
+
120
+ unsetBehavior(target: string, prop: string) {
121
+ const set = this.draft.behaviors?.set;
122
+ if (set) {
123
+ const index = set.findIndex((b) => b.target === target && b.prop === prop);
124
+ if (index >= 0) set.splice(index, 1);
125
+ }
126
+ this.prune();
127
+ this.recompose("structure");
128
+ }
129
+
130
+ select(id: string | null) {
131
+ this.selectedId = id;
132
+ this.notify("structure");
133
+ }
134
+
135
+ /** Replace the whole draft (import); orphans surface in the report. */
136
+ importDraft(doc: OverlayDoc) {
137
+ this.draft = doc;
138
+ if (doc.name) this.overlayName = doc.name;
139
+ this.recompose("structure");
140
+ }
141
+
142
+ resetDraft() {
143
+ this.draft = { reframeOverlay: 1 };
144
+ this.recompose("structure");
145
+ }
146
+
147
+ /** Pruned, named copy ready for download / the render CLI. */
148
+ exportDraft(): OverlayDoc {
149
+ this.prune();
150
+ return {
151
+ ...structuredClone(this.draft),
152
+ name: this.overlayName,
153
+ target: this.base.id,
154
+ };
155
+ }
156
+
157
+ // --- edit lookups for the panel (badges, revert buttons) ---
158
+
159
+ hasNodeEdit(id: string, prop: string): boolean {
160
+ return this.draft.nodes?.[id]?.[prop] !== undefined;
161
+ }
162
+ hasStateEdit(state: string, id: string, prop: string): boolean {
163
+ return this.draft.states?.[state]?.[id]?.[prop] !== undefined;
164
+ }
165
+ hasTimelineEdit(label: string, key: string): boolean {
166
+ return (this.draft.timeline?.[label] as Record<string, unknown> | undefined)?.[key] !== undefined;
167
+ }
168
+ hasBehaviorEdit(target: string, prop: string): boolean {
169
+ return Boolean(this.draft.behaviors?.set?.some((b) => b.target === target && b.prop === prop));
170
+ }
171
+ nodeEditCount(id: string): number {
172
+ return (
173
+ Object.keys(this.draft.nodes?.[id] ?? {}).length +
174
+ Object.values(this.draft.states ?? {}).reduce(
175
+ (sum, s) => sum + Object.keys(s[id] ?? {}).length,
176
+ 0,
177
+ )
178
+ );
179
+ }
180
+
181
+ private prune() {
182
+ const d = this.draft;
183
+ for (const [id, props] of Object.entries(d.nodes ?? {})) {
184
+ if (Object.keys(props).length === 0) delete d.nodes![id];
185
+ }
186
+ if (d.nodes && Object.keys(d.nodes).length === 0) delete d.nodes;
187
+ for (const [state, nodes] of Object.entries(d.states ?? {})) {
188
+ for (const [id, props] of Object.entries(nodes)) {
189
+ if (Object.keys(props).length === 0) delete nodes[id];
190
+ }
191
+ if (Object.keys(nodes).length === 0) delete d.states![state];
192
+ }
193
+ if (d.states && Object.keys(d.states).length === 0) delete d.states;
194
+ for (const [label, patch] of Object.entries(d.timeline ?? {})) {
195
+ if (Object.keys(patch).length === 0) delete d.timeline![label];
196
+ }
197
+ if (d.timeline && Object.keys(d.timeline).length === 0) delete d.timeline;
198
+ if (d.behaviors?.set?.length === 0) delete d.behaviors.set;
199
+ if (d.behaviors && !d.behaviors.set && !d.behaviors.remove) delete d.behaviors;
200
+ if (d.scene && Object.keys(d.scene).length === 0) delete d.scene;
201
+ }
202
+
203
+ private recompose(kind: ChangeKind) {
204
+ try {
205
+ const { ir, report } = composeScene(this.base, this.draft);
206
+ this.compiled = compileScene(ir);
207
+ this.report = report;
208
+ this.composeError = null;
209
+ } catch (err) {
210
+ // Keep last-good compiled; the user is mid-edit (e.g. typing a duration).
211
+ this.composeError =
212
+ err instanceof SceneValidationError ? err.message : String(err);
213
+ }
214
+ this.notify(kind);
215
+ }
216
+
217
+ private notify(kind: ChangeKind) {
218
+ for (const fn of this.listeners) fn(kind);
219
+ }
220
+ }
@@ -0,0 +1,4 @@
1
+ declare module "virtual:reframe-user-scenes" {
2
+ import type { SceneIR } from "@reframe/core";
3
+ export const userScenes: { name: string; load: () => Promise<{ default: SceneIR }> }[];
4
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { defineConfig, type Plugin } from "vite";
4
+
5
+ const PKG_ROOT = resolve(__dirname, "..");
6
+ const userDir = process.env.REFRAME_SCENE_DIR ? resolve(process.env.REFRAME_SCENE_DIR) : undefined;
7
+ const SCENE_DIR = userDir && existsSync(userDir) ? userDir : undefined;
8
+
9
+ function userScenes(): { name: string; path: string }[] {
10
+ if (!SCENE_DIR) return [];
11
+ return readdirSync(SCENE_DIR)
12
+ .filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"))
13
+ .map((f) => join(SCENE_DIR, f))
14
+ .filter((p) => {
15
+ try {
16
+ const src = readFileSync(p, "utf8");
17
+ return src.includes("@reframe/core") || src.includes("reframe-video");
18
+ } catch {
19
+ return false;
20
+ }
21
+ })
22
+ .map((p) => ({ name: basename(p, ".ts"), path: p }));
23
+ }
24
+
25
+ const userScenesPlugin: Plugin = {
26
+ name: "reframe-user-scenes",
27
+ resolveId(id) {
28
+ return id === "virtual:reframe-user-scenes" ? "\0reframe-user-scenes" : undefined;
29
+ },
30
+ load(id) {
31
+ if (id !== "\0reframe-user-scenes") return undefined;
32
+ const entries = userScenes().map(
33
+ (s) =>
34
+ ` { name: ${JSON.stringify(s.name)}, load: () => import(${JSON.stringify(`/@fs${s.path}`)}) },`,
35
+ );
36
+ return `export const userScenes = [\n${entries.join("\n")}\n];\n`;
37
+ },
38
+ };
39
+
40
+ export default defineConfig({
41
+ plugins: [userScenesPlugin],
42
+ resolve: {
43
+ alias: {
44
+ "@reframe/core": resolve(PKG_ROOT, "dist", "index.js"),
45
+ "reframe-video": resolve(PKG_ROOT, "dist", "index.js"),
46
+ "@reframe/renderer-canvas": resolve(PKG_ROOT, "dist", "renderer-canvas.js"),
47
+ },
48
+ },
49
+ server: {
50
+ fs: { allow: [PKG_ROOT, ...(SCENE_DIR ? [SCENE_DIR] : [])] },
51
+ },
52
+ });