@glissade/core 0.8.1 → 0.9.0-pre.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.
@@ -0,0 +1,126 @@
1
+ import { H as Key, N as Timeline, S as isEditableNodeId, St as ValueTypeId, a as SidecarOrphan, lt as EaseSpec, r as SidecarDoc, w as targetNodeId } from "./sidecar.js";
2
+
3
+ //#region src/timelinePatch.d.ts
4
+
5
+ /** A new key's data (no id — one is assigned on apply). */
6
+ interface NewKey {
7
+ t: number;
8
+ value: unknown;
9
+ ease?: EaseSpec;
10
+ interp?: 'default' | 'hold';
11
+ }
12
+ type TimelinePatch = {
13
+ op: 'setTrackKeys';
14
+ timelineId: string;
15
+ target: string;
16
+ type: ValueTypeId;
17
+ keys: Key[];
18
+ baseHash?: string | null;
19
+ verbatim?: boolean;
20
+ } | {
21
+ op: 'removeTrack';
22
+ timelineId: string;
23
+ target: string;
24
+ prune?: boolean;
25
+ } | {
26
+ op: 'addKey';
27
+ timelineId: string;
28
+ target: string;
29
+ key: NewKey;
30
+ } | {
31
+ op: 'removeKey';
32
+ timelineId: string;
33
+ target: string;
34
+ id: string;
35
+ } | {
36
+ op: 'moveKey';
37
+ timelineId: string;
38
+ target: string;
39
+ id: string;
40
+ t: number;
41
+ } | {
42
+ op: 'setKeyValue';
43
+ timelineId: string;
44
+ target: string;
45
+ id: string;
46
+ value: unknown;
47
+ } | {
48
+ op: 'setKeyEase';
49
+ timelineId: string;
50
+ target: string;
51
+ id: string;
52
+ ease?: EaseSpec;
53
+ interp?: 'default' | 'hold';
54
+ } | {
55
+ op: 'setLabel';
56
+ timelineId: string;
57
+ name: string;
58
+ t: number;
59
+ } | {
60
+ op: 'removeLabel';
61
+ timelineId: string;
62
+ name: string;
63
+ };
64
+ interface PatchOk {
65
+ ok: true;
66
+ doc: SidecarDoc;
67
+ /** inverse transaction — apply to undo (restores each touched track/label). */
68
+ inverse: TimelinePatch[];
69
+ }
70
+ interface PatchErr {
71
+ ok: false;
72
+ error: string;
73
+ }
74
+ type PatchResult = PatchOk | PatchErr;
75
+ /** The code-baseline track for a target, so a first edit on a code-only track seeds + records its baseHash. */
76
+ interface TrackBaseline {
77
+ type: ValueTypeId;
78
+ keys: readonly Key[];
79
+ }
80
+ type BaselineLookup = (timelineId: string, target: string) => TrackBaseline | null;
81
+ /**
82
+ * Apply a batch of patches **atomically**: validate-and-build against a working
83
+ * copy; on the FIRST invalid patch, return `{ ok: false }` with the original
84
+ * doc untouched (immutable updates never mutate the input). On success, return
85
+ * the new doc plus the inverse transaction (one snapshot-restore per touched
86
+ * track/label). `baseline` seeds a first edit on a code-only track.
87
+ */
88
+ declare function applyPatches(doc: SidecarDoc, patches: readonly TimelinePatch[], baseline?: BaselineLookup): PatchResult;
89
+ /** Apply a single patch (convenience over `applyPatches`). */
90
+ declare function applyPatch(doc: SidecarDoc, patch: TimelinePatch, baseline?: BaselineLookup): PatchResult;
91
+ //#endregion
92
+ //#region src/studioHost.d.ts
93
+ /** The merged code+sidecar view plus the tracks parked off the merge (§6.2 rule 3). */
94
+ type MergedTimeline = Timeline & {
95
+ orphans: Record<string, SidecarOrphan>;
96
+ };
97
+ /** `${nodeId}/${propPath}` — the §2.2 canonical track grammar. */
98
+ type SignalPath = string;
99
+ type Unsubscribe = () => void;
100
+ interface PropDescriptor {
101
+ name: string;
102
+ type: ValueTypeId;
103
+ /** editable IFF the node has an explicit id AND a merged/editor-created track exists (§6.4 sub-decision). */
104
+ editable: boolean;
105
+ }
106
+ interface NodeDescriptor {
107
+ id: string;
108
+ /** the node kind (Group/Rect/Text/…). */
109
+ type: string;
110
+ props: PropDescriptor[];
111
+ }
112
+ type StudioEvent = 'tree-changed' | 'doc-patched' | 'playhead-moved';
113
+ interface StudioHost {
114
+ /** ids, types, prop schemas + editability — deep plain data, clone-safe. */
115
+ getSceneTree(): NodeDescriptor[];
116
+ /** merged code+sidecar timeline + orphans (§6.2). */
117
+ getTimeline(): MergedTimeline;
118
+ /** bridge a node/prop signal to a callback; returns an unsubscribe. */
119
+ subscribeSignal(path: SignalPath, cb: (v: unknown) => void): Unsubscribe;
120
+ /** apply an edit transaction — validated + atomic; returns the inverse for undo. */
121
+ applyPatch(patches: TimelinePatch[]): PatchResult;
122
+ setPlayhead(t: number): void;
123
+ on(ev: StudioEvent, cb: (...args: unknown[]) => void): Unsubscribe;
124
+ }
125
+ //#endregion
126
+ export { type BaselineLookup, MergedTimeline, type NewKey, NodeDescriptor, type PatchErr, type PatchOk, type PatchResult, PropDescriptor, SignalPath, StudioEvent, StudioHost, type TimelinePatch, type TrackBaseline, Unsubscribe, applyPatch, applyPatches, isEditableNodeId, targetNodeId };
@@ -0,0 +1,262 @@
1
+ import { a as hashKeys, h as targetNodeId, l as normalizeEditedKeys, n as assignKeyIds, p as isEditableNodeId } from "./sidecar.js";
2
+ //#region src/timelinePatch.ts
3
+ /** Unambiguous composite map key (no control-char separators). */
4
+ function refKey(timelineId, name) {
5
+ return JSON.stringify([timelineId, name]);
6
+ }
7
+ function parseRefKey(k) {
8
+ return JSON.parse(k);
9
+ }
10
+ function getTimeline(doc, id) {
11
+ return doc.timelines[id] ?? { tracks: {} };
12
+ }
13
+ /** The current sidecar entry for a target, seeding from the code baseline (with ids) if absent. */
14
+ function resolveEntry(doc, p, baseline) {
15
+ const existing = doc.timelines[p.timelineId]?.tracks[p.target];
16
+ if (existing) return existing;
17
+ const base = baseline?.(p.timelineId, p.target) ?? null;
18
+ if (!base) return null;
19
+ return {
20
+ type: base.type,
21
+ baseHash: hashKeys(base.keys),
22
+ keys: assignKeyIds(base.keys.map((k) => ({ ...k })))
23
+ };
24
+ }
25
+ function putEntry(doc, timelineId, target, entry) {
26
+ const tl = getTimeline(doc, timelineId);
27
+ return {
28
+ ...doc,
29
+ timelines: {
30
+ ...doc.timelines,
31
+ [timelineId]: {
32
+ ...tl,
33
+ tracks: {
34
+ ...tl.tracks,
35
+ [target]: entry
36
+ }
37
+ }
38
+ }
39
+ };
40
+ }
41
+ function removeEntry(doc, timelineId, target, prune = false) {
42
+ const tl = doc.timelines[timelineId];
43
+ if (!tl?.tracks[target]) return doc;
44
+ const tracks = { ...tl.tracks };
45
+ delete tracks[target];
46
+ const labels = tl.labels;
47
+ if (prune && Object.keys(tracks).length === 0 && (!labels || Object.keys(labels).length === 0)) {
48
+ const timelines = { ...doc.timelines };
49
+ delete timelines[timelineId];
50
+ return {
51
+ ...doc,
52
+ timelines
53
+ };
54
+ }
55
+ return {
56
+ ...doc,
57
+ timelines: {
58
+ ...doc.timelines,
59
+ [timelineId]: {
60
+ ...tl,
61
+ tracks
62
+ }
63
+ }
64
+ };
65
+ }
66
+ /**
67
+ * Apply a key-list edit. `verbatim` restores keys EXACTLY as given (the inverse
68
+ * path — it is replaying a prior valid state, so re-normalizing would re-pin
69
+ * springs / re-nudge collisions and break byte-exact undo). The forward editor
70
+ * path normalizes (§2.7 spring re-pin + collision nudge) and (re)assigns ids.
71
+ */
72
+ function withKeys(entry, keys, verbatim = false) {
73
+ return {
74
+ ...entry,
75
+ keys: verbatim ? keys.map((k) => ({ ...k })) : assignKeyIds(normalizeEditedKeys(keys))
76
+ };
77
+ }
78
+ /**
79
+ * Apply a batch of patches **atomically**: validate-and-build against a working
80
+ * copy; on the FIRST invalid patch, return `{ ok: false }` with the original
81
+ * doc untouched (immutable updates never mutate the input). On success, return
82
+ * the new doc plus the inverse transaction (one snapshot-restore per touched
83
+ * track/label). `baseline` seeds a first edit on a code-only track.
84
+ */
85
+ function applyPatches(doc, patches, baseline) {
86
+ let work = doc;
87
+ const trackSnap = /* @__PURE__ */ new Map();
88
+ const labelSnap = /* @__PURE__ */ new Map();
89
+ const tlExisted = /* @__PURE__ */ new Map();
90
+ const snapTrack = (timelineId, target) => {
91
+ if (!tlExisted.has(timelineId)) tlExisted.set(timelineId, work.timelines[timelineId] !== void 0);
92
+ const k = refKey(timelineId, target);
93
+ if (!trackSnap.has(k)) trackSnap.set(k, work.timelines[timelineId]?.tracks[target] ?? null);
94
+ };
95
+ const snapLabel = (timelineId, name) => {
96
+ const k = refKey(timelineId, name);
97
+ if (!labelSnap.has(k)) labelSnap.set(k, work.timelines[timelineId]?.labels?.[name]);
98
+ };
99
+ for (const p of patches) switch (p.op) {
100
+ case "setTrackKeys": {
101
+ if (!p.verbatim && !isEditableNodeId(targetNodeId(p.target))) return {
102
+ ok: false,
103
+ error: `setTrackKeys: '${p.target}' is not an editable host (structural/un-id'd nodes cannot host tracks, §6.5)`
104
+ };
105
+ snapTrack(p.timelineId, p.target);
106
+ const prev = work.timelines[p.timelineId]?.tracks[p.target];
107
+ const entry = withKeys({
108
+ type: p.type,
109
+ baseHash: p.baseHash !== void 0 ? p.baseHash : prev?.baseHash ?? null,
110
+ keys: []
111
+ }, p.keys.map((k) => ({ ...k })), p.verbatim);
112
+ work = putEntry(work, p.timelineId, p.target, entry);
113
+ break;
114
+ }
115
+ case "removeTrack":
116
+ if (!work.timelines[p.timelineId]?.tracks[p.target]) return {
117
+ ok: false,
118
+ error: `removeTrack: no sidecar track '${p.target}'`
119
+ };
120
+ snapTrack(p.timelineId, p.target);
121
+ work = removeEntry(work, p.timelineId, p.target, p.prune);
122
+ break;
123
+ case "addKey": {
124
+ if (!isEditableNodeId(targetNodeId(p.target))) return {
125
+ ok: false,
126
+ error: `addKey: '${p.target}' is not an editable host (structural/un-id'd nodes cannot host tracks, §6.5)`
127
+ };
128
+ const entry = resolveEntry(work, p, baseline);
129
+ if (!entry) return {
130
+ ok: false,
131
+ error: `addKey: unknown track '${p.target}' (no sidecar entry and no code baseline)`
132
+ };
133
+ snapTrack(p.timelineId, p.target);
134
+ const k = {
135
+ t: p.key.t,
136
+ value: p.key.value,
137
+ ...p.key.ease !== void 0 ? { ease: p.key.ease } : {},
138
+ ...p.key.interp !== void 0 ? { interp: p.key.interp } : {}
139
+ };
140
+ work = putEntry(work, p.timelineId, p.target, withKeys(entry, [...entry.keys, k]));
141
+ break;
142
+ }
143
+ case "removeKey":
144
+ case "moveKey":
145
+ case "setKeyValue":
146
+ case "setKeyEase": {
147
+ const entry = resolveEntry(work, p, baseline);
148
+ if (!entry) return {
149
+ ok: false,
150
+ error: `${p.op}: unknown track '${p.target}'`
151
+ };
152
+ if (!entry.keys.some((k) => k.id === p.id)) return {
153
+ ok: false,
154
+ error: `${p.op}: no key '${p.id}' in '${p.target}'`
155
+ };
156
+ snapTrack(p.timelineId, p.target);
157
+ const next = p.op === "removeKey" ? entry.keys.filter((k) => k.id !== p.id) : entry.keys.map((k) => {
158
+ if (k.id !== p.id) return k;
159
+ if (p.op === "moveKey") return {
160
+ ...k,
161
+ t: Math.max(0, p.t)
162
+ };
163
+ if (p.op === "setKeyValue") return {
164
+ ...k,
165
+ value: p.value
166
+ };
167
+ const u = { ...k };
168
+ if (p.ease !== void 0) u.ease = p.ease;
169
+ else delete u.ease;
170
+ if (p.interp !== void 0) u.interp = p.interp;
171
+ else delete u.interp;
172
+ return u;
173
+ });
174
+ work = putEntry(work, p.timelineId, p.target, withKeys(entry, next));
175
+ break;
176
+ }
177
+ case "setLabel": {
178
+ snapLabel(p.timelineId, p.name);
179
+ const tl = getTimeline(work, p.timelineId);
180
+ work = {
181
+ ...work,
182
+ timelines: {
183
+ ...work.timelines,
184
+ [p.timelineId]: {
185
+ ...tl,
186
+ labels: {
187
+ ...tl.labels ?? {},
188
+ [p.name]: p.t
189
+ }
190
+ }
191
+ }
192
+ };
193
+ break;
194
+ }
195
+ case "removeLabel": {
196
+ snapLabel(p.timelineId, p.name);
197
+ const tl = work.timelines[p.timelineId];
198
+ if (tl?.labels && p.name in tl.labels) {
199
+ const labels = { ...tl.labels };
200
+ delete labels[p.name];
201
+ work = {
202
+ ...work,
203
+ timelines: {
204
+ ...work.timelines,
205
+ [p.timelineId]: {
206
+ ...tl,
207
+ labels
208
+ }
209
+ }
210
+ };
211
+ }
212
+ break;
213
+ }
214
+ }
215
+ const inverse = [];
216
+ for (const [k, snap] of trackSnap) {
217
+ const [timelineId, target] = parseRefKey(k);
218
+ if (snap === null) inverse.push(tlExisted.get(timelineId) === false ? {
219
+ op: "removeTrack",
220
+ timelineId,
221
+ target,
222
+ prune: true
223
+ } : {
224
+ op: "removeTrack",
225
+ timelineId,
226
+ target
227
+ });
228
+ else inverse.push({
229
+ op: "setTrackKeys",
230
+ timelineId,
231
+ target,
232
+ type: snap.type,
233
+ keys: snap.keys.map((x) => ({ ...x })),
234
+ baseHash: snap.baseHash,
235
+ verbatim: true
236
+ });
237
+ }
238
+ for (const [k, value] of labelSnap) {
239
+ const [timelineId, name] = parseRefKey(k);
240
+ inverse.push(value === void 0 ? {
241
+ op: "removeLabel",
242
+ timelineId,
243
+ name
244
+ } : {
245
+ op: "setLabel",
246
+ timelineId,
247
+ name,
248
+ t: value
249
+ });
250
+ }
251
+ return {
252
+ ok: true,
253
+ doc: work,
254
+ inverse
255
+ };
256
+ }
257
+ /** Apply a single patch (convenience over `applyPatches`). */
258
+ function applyPatch(doc, patch, baseline) {
259
+ return applyPatches(doc, [patch], baseline);
260
+ }
261
+ //#endregion
262
+ export { applyPatch, applyPatches, isEditableNodeId, targetNodeId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/core",
3
- "version": "0.8.1",
3
+ "version": "0.9.0-pre.1",
4
4
  "description": "glissade core: signals, tracks, timeline document, evaluation, easing, springs, seeded RNG. Zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
+ },
13
+ "./studio-host": {
14
+ "types": "./dist/studioHost.d.ts",
15
+ "default": "./dist/studioHost.js"
12
16
  }
13
17
  },
14
18
  "files": [