@vosjs/shared 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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The semantic differ: pure functions over two
3
+ * immutable version states — no I/O, no recorded gesture log ("derive,
4
+ * don't record"). Runs identically in Workers, the CLI, and the studio;
5
+ * same two inputs ⇒ byte-identical ops and summary.
6
+ */
7
+ interface Op {
8
+ op: 'add' | 'remove' | 'modify';
9
+ /**
10
+ * Doc tracks: zoom | tilt | speed | overlay | object | audio, plus scoped
11
+ * singletons (segments | frame | cursor | cam | export | camera).
12
+ * Config tracks: knob (param key space) | look | data | code | config.
13
+ */
14
+ track: string;
15
+ /** Span/clip id, param key, function name, or field name. */
16
+ id: string;
17
+ /** Changed properties: key → [from, to]. Absent on code ops. */
18
+ props?: Record<string, [unknown, unknown]>;
19
+ /** Code-function modifies only: magnitude of the line-level change. */
20
+ lines?: number;
21
+ /**
22
+ * True when the change is a cascaded consequence of another gesture
23
+ * (e.g. a trim shifting downstream spans), not a direct edit.
24
+ */
25
+ cascade?: boolean;
26
+ }
27
+ /** One intervening version's worth of changes, as the pull contract ships it. */
28
+ interface VersionChanges {
29
+ versionId: string;
30
+ versionNumber: number;
31
+ origin: string;
32
+ label: string | null;
33
+ note: string | null;
34
+ ops: Op[];
35
+ summary: string;
36
+ }
37
+ /** Stable composite key for net-effect folding. */
38
+ declare function opKey(op: Op): string;
39
+
40
+ type AnyRecord = Record<string, unknown>;
41
+
42
+ declare function diffConfig(a: AnyRecord, b: AnyRecord): Op[];
43
+
44
+ declare function diffDoc(a: AnyRecord, b: AnyRecord): Op[];
45
+
46
+ /**
47
+ * Net effect per node across a sequence of op sets (the coalescing rules):
48
+ * initial→final only, no intermediate drags. add→modify folds into the add;
49
+ * add→remove vanishes; modify chains keep the first `from` and last `to`
50
+ * (dropping props that net to no change); remove→add of the same id is one
51
+ * modify (the node was replaced in place). Output is deterministically
52
+ * ordered: track declaration order, then output-time anchor, then id.
53
+ */
54
+ declare function coalesce(opsInOrder: readonly Op[]): Op[];
55
+
56
+ /**
57
+ * Deterministic prose over an op list — the line that lands in the agent's
58
+ * context ("zoom z3: end 4.2s→4.6s, level 2.2→1.8; tilt t1 removed").
59
+ * Same ops ⇒ byte-identical string; unit-pinned like the geometry mirrors.
60
+ */
61
+ declare function summarize(ops: readonly Op[], options?: {
62
+ maxItems?: number;
63
+ }): string;
64
+
65
+ export { type Op, type VersionChanges, coalesce, diffConfig, diffDoc, opKey, summarize };
@@ -0,0 +1,567 @@
1
+ // src/diff/internal.ts
2
+ function isRecord(v) {
3
+ return !!v && typeof v === "object" && !Array.isArray(v);
4
+ }
5
+ function deepEqual(a, b) {
6
+ if (Object.is(a, b)) return true;
7
+ if (typeof a !== typeof b) return false;
8
+ if (Array.isArray(a) && Array.isArray(b)) {
9
+ return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
10
+ }
11
+ if (isRecord(a) && isRecord(b)) {
12
+ const ka = Object.keys(a);
13
+ const kb = Object.keys(b);
14
+ if (ka.length !== kb.length) return false;
15
+ return ka.every((k) => deepEqual(a[k], b[k]));
16
+ }
17
+ return false;
18
+ }
19
+ function shallowDiff(a, b, ignore = /* @__PURE__ */ new Set()) {
20
+ const props = {};
21
+ const keys = [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].sort();
22
+ for (const k of keys) {
23
+ if (ignore.has(k)) continue;
24
+ if (!deepEqual(a[k], b[k])) props[k] = [a[k], b[k]];
25
+ }
26
+ return Object.keys(props).length ? props : void 0;
27
+ }
28
+ function diffIdTrack(track, a, b) {
29
+ const ops = [];
30
+ const aById = new Map((a ?? []).map((n) => [String(n.id), n]));
31
+ const bById = new Map((b ?? []).map((n) => [String(n.id), n]));
32
+ for (const [id, node] of aById) {
33
+ if (!bById.has(id)) {
34
+ ops.push({ op: "remove", track, id, props: pickAnchor(node) });
35
+ }
36
+ }
37
+ for (const [id, node] of bById) {
38
+ const prev = aById.get(id);
39
+ if (!prev) {
40
+ ops.push({ op: "add", track, id, props: pickAnchor(node) });
41
+ continue;
42
+ }
43
+ const props = shallowDiff(prev, node, ID_KEYS);
44
+ if (props) ops.push({ op: "modify", track, id, props });
45
+ }
46
+ return ops;
47
+ }
48
+ var ID_KEYS = /* @__PURE__ */ new Set(["id"]);
49
+ function pickAnchor(node) {
50
+ const out = {};
51
+ for (const k of ["in", "out", "at", "start", "kind", "key"]) {
52
+ if (node[k] !== void 0) out[k] = [void 0, node[k]];
53
+ }
54
+ return Object.keys(out).length ? out : void 0;
55
+ }
56
+ function lineDelta(a, b) {
57
+ const count = (s) => {
58
+ const m = /* @__PURE__ */ new Map();
59
+ for (const line of s.split("\n")) m.set(line, (m.get(line) ?? 0) + 1);
60
+ return m;
61
+ };
62
+ const ca = count(a);
63
+ const cb = count(b);
64
+ let removed = 0;
65
+ let added = 0;
66
+ for (const [line, n] of ca) removed += Math.max(0, n - (cb.get(line) ?? 0));
67
+ for (const [line, n] of cb) added += Math.max(0, n - (ca.get(line) ?? 0));
68
+ return Math.max(added, removed);
69
+ }
70
+
71
+ // src/diff/diffConfig.ts
72
+ var FUNCTION_FIELDS = ["setup", "createContent", "createTimeline", "onFrame"];
73
+ var CONFIG_FIELDS = ["duration", "camera", "postprocessing", "elements"];
74
+ function diffConfig(a, b) {
75
+ const ops = [];
76
+ const aParams = paramsByKey(a);
77
+ const bParams = paramsByKey(b);
78
+ const aData = isRecord(a.data) ? a.data : {};
79
+ const bData = isRecord(b.data) ? b.data : {};
80
+ const removedKeys = [];
81
+ const addedKeys = [];
82
+ for (const key of aParams.keys()) if (!bParams.has(key)) removedKeys.push(key);
83
+ for (const key of bParams.keys()) if (!aParams.has(key)) addedKeys.push(key);
84
+ const renamed = /* @__PURE__ */ new Map();
85
+ for (const oldKey of [...removedKeys]) {
86
+ const oldSpec = aParams.get(oldKey);
87
+ const match = addedKeys.find(
88
+ (newKey) => specEqualIgnoringIdentity(oldSpec, bParams.get(newKey))
89
+ );
90
+ if (match) {
91
+ renamed.set(oldKey, match);
92
+ removedKeys.splice(removedKeys.indexOf(oldKey), 1);
93
+ addedKeys.splice(addedKeys.indexOf(match), 1);
94
+ }
95
+ }
96
+ for (const [oldKey, newKey] of renamed) {
97
+ const props = {
98
+ key: [oldKey, newKey]
99
+ };
100
+ const fromVal = valueOf(aData, aParams.get(oldKey), oldKey);
101
+ const toVal = valueOf(bData, bParams.get(newKey), newKey);
102
+ if (!deepEqual(fromVal, toVal)) props.value = [fromVal, toVal];
103
+ ops.push({ op: "modify", track: "knob", id: newKey, props });
104
+ }
105
+ for (const key of removedKeys) {
106
+ ops.push({ op: "remove", track: "knob", id: key });
107
+ }
108
+ for (const key of addedKeys) {
109
+ const spec = bParams.get(key);
110
+ ops.push({
111
+ op: "add",
112
+ track: "knob",
113
+ id: key,
114
+ props: {
115
+ kind: [void 0, spec.kind],
116
+ value: [void 0, valueOf(bData, spec, key)]
117
+ }
118
+ });
119
+ }
120
+ for (const [key, aSpec] of aParams) {
121
+ const bSpec = bParams.get(key);
122
+ if (!bSpec) continue;
123
+ const props = {};
124
+ const aVal = valueOf(aData, aSpec, key);
125
+ const bVal = valueOf(bData, bSpec, key);
126
+ if (!deepEqual(aVal, bVal)) props.value = [aVal, bVal];
127
+ const specDiff = shallowDiff(aSpec, bSpec, KNOB_IDENTITY);
128
+ if (specDiff) {
129
+ if ("default" in specDiff && "value" in props) delete specDiff.default;
130
+ Object.assign(props, specDiff);
131
+ }
132
+ if (Object.keys(props).length) {
133
+ ops.push({ op: "modify", track: "knob", id: key, props });
134
+ }
135
+ }
136
+ const declared = /* @__PURE__ */ new Set([...aParams.keys(), ...bParams.keys()]);
137
+ const dataKeys = [
138
+ .../* @__PURE__ */ new Set([...Object.keys(aData), ...Object.keys(bData)])
139
+ ].sort();
140
+ for (const key of dataKeys) {
141
+ if (declared.has(key)) continue;
142
+ const from = aData[key];
143
+ const to = bData[key];
144
+ if (deepEqual(from, to)) continue;
145
+ const op = from === void 0 ? "add" : to === void 0 ? "remove" : "modify";
146
+ ops.push({ op, track: "data", id: key, props: { value: [from, to] } });
147
+ }
148
+ ops.push(
149
+ ...diffIdTrack("look", looksAsNodes(a.presets), looksAsNodes(b.presets))
150
+ );
151
+ for (const fn of FUNCTION_FIELDS) {
152
+ const va = a[fn];
153
+ const vb = b[fn];
154
+ const fa = typeof va === "string" ? va : "";
155
+ const fb = typeof vb === "string" ? vb : "";
156
+ if (fa === fb) continue;
157
+ ops.push({
158
+ op: "modify",
159
+ track: "code",
160
+ id: fn,
161
+ lines: lineDelta(fa, fb)
162
+ });
163
+ }
164
+ for (const field of CONFIG_FIELDS) {
165
+ if (deepEqual(a[field], b[field])) continue;
166
+ ops.push({
167
+ op: "modify",
168
+ track: "config",
169
+ id: field,
170
+ props: { value: [a[field], b[field]] }
171
+ });
172
+ }
173
+ return ops;
174
+ }
175
+ var KNOB_IDENTITY = /* @__PURE__ */ new Set(["key", "label", "order"]);
176
+ function paramsByKey(config) {
177
+ const out = /* @__PURE__ */ new Map();
178
+ if (!Array.isArray(config.params)) return out;
179
+ for (const entry of config.params) {
180
+ if (isRecord(entry) && typeof entry.key === "string" && entry.key) {
181
+ out.set(entry.key, entry);
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+ function valueOf(data, spec, key) {
187
+ return data[key] !== void 0 ? data[key] : spec.default;
188
+ }
189
+ function specEqualIgnoringIdentity(a, b) {
190
+ return !shallowDiff(a, b, KNOB_IDENTITY);
191
+ }
192
+ function looksAsNodes(presets) {
193
+ if (!Array.isArray(presets)) return [];
194
+ return presets.filter((p) => isRecord(p) && typeof p.name === "string").map((p) => ({ ...p, id: p.name }));
195
+ }
196
+
197
+ // src/diff/diffDoc.ts
198
+ var ID_TRACKS = [
199
+ ["zoom", "zoom"],
200
+ ["tilt", "tilt"],
201
+ ["camMove", "camMotion"],
202
+ ["speed", "speed"],
203
+ ["overlay", "overlays"],
204
+ ["object", "objects"],
205
+ ["audio", "audio"]
206
+ ];
207
+ var SCOPED_FIELDS = ["frame", "cursor", "cam", "export"];
208
+ function diffDoc(a, b) {
209
+ const ops = [];
210
+ const pa = isRecord(a.program) ? a.program : void 0;
211
+ const pb = isRecord(b.program) ? b.program : void 0;
212
+ if (pa || pb) {
213
+ ops.push(
214
+ ...diffConfig(
215
+ pa && isRecord(pa.config) ? pa.config : {},
216
+ pb && isRecord(pb.config) ? pb.config : {}
217
+ )
218
+ );
219
+ const ea = pa && isRecord(pa.tweenEdits) ? pa.tweenEdits : {};
220
+ const eb = pb && isRecord(pb.tweenEdits) ? pb.tweenEdits : {};
221
+ for (const key of /* @__PURE__ */ new Set([...Object.keys(ea), ...Object.keys(eb)])) {
222
+ const va = isRecord(ea[key]) ? ea[key] : void 0;
223
+ const vb = isRecord(eb[key]) ? eb[key] : void 0;
224
+ if (va && !vb) ops.push({ op: "remove", track: "tween", id: `#${key}` });
225
+ else if (!va && vb) ops.push({ op: "add", track: "tween", id: `#${key}` });
226
+ else if (va && vb) {
227
+ const props = shallowDiff(va, vb);
228
+ if (props)
229
+ ops.push({ op: "modify", track: "tween", id: `#${key}`, props });
230
+ }
231
+ }
232
+ const da = programLength(pa);
233
+ const db = programLength(pb);
234
+ if (da !== db && (pa?.duration !== void 0 || pb?.duration !== void 0)) {
235
+ ops.push({
236
+ op: "modify",
237
+ track: "config",
238
+ id: "duration",
239
+ props: { value: [da, db] }
240
+ });
241
+ }
242
+ }
243
+ for (const [track, field] of ID_TRACKS) {
244
+ ops.push(...diffIdTrack(track, asNodeList(a[field]), asNodeList(b[field])));
245
+ }
246
+ const speedOps = ops.filter((o) => o.track === "speed");
247
+ if (speedOps.length) {
248
+ const [oa, ob] = [outputDuration(a), outputDuration(b)];
249
+ if (oa !== null && ob !== null && Math.abs(oa - ob) >= 0.05) {
250
+ speedOps[0].props = { ...speedOps[0].props ?? {}, output: [oa, ob] };
251
+ }
252
+ }
253
+ const aSeg = asNodeList(a.segments) ?? [];
254
+ const bSeg = asNodeList(b.segments) ?? [];
255
+ if (!deepEqual(a.segments, b.segments)) {
256
+ const props = {};
257
+ if (aSeg.length !== bSeg.length) props.count = [aSeg.length, bSeg.length];
258
+ const [da, db] = [keptDuration(aSeg), keptDuration(bSeg)];
259
+ if (da !== db) props.duration = [da, db];
260
+ ops.push({
261
+ op: "modify",
262
+ track: "segments",
263
+ id: "segments",
264
+ props: Object.keys(props).length ? props : { value: ["edited", "edited"] }
265
+ });
266
+ }
267
+ for (const field of SCOPED_FIELDS) {
268
+ const va = a[field];
269
+ const vb = b[field];
270
+ const fa = isRecord(va) ? va : {};
271
+ const fb = isRecord(vb) ? vb : {};
272
+ const props = shallowDiff(fa, fb);
273
+ if (props) ops.push({ op: "modify", track: field, id: field, props });
274
+ }
275
+ const cameraProps = {};
276
+ for (const k of ["zoomStyle", "tiltStyle", "micGain"]) {
277
+ if (!deepEqual(a[k], b[k])) cameraProps[k] = [a[k], b[k]];
278
+ }
279
+ if (!deepEqual(a.zoomParams, b.zoomParams)) {
280
+ cameraProps.zoomParams = [a.zoomParams, b.zoomParams];
281
+ }
282
+ if (Object.keys(cameraProps).length) {
283
+ ops.push({
284
+ op: "modify",
285
+ track: "camera",
286
+ id: "camera",
287
+ props: cameraProps
288
+ });
289
+ }
290
+ return ops;
291
+ }
292
+ function asNodeList(v) {
293
+ if (!Array.isArray(v)) return void 0;
294
+ return v.filter(isRecord);
295
+ }
296
+ function programLength(program) {
297
+ if (!program) return null;
298
+ if (typeof program.duration === "number") return program.duration;
299
+ const cfg = isRecord(program.config) ? program.config : void 0;
300
+ return cfg && typeof cfg.duration === "number" ? cfg.duration : null;
301
+ }
302
+ function outputDuration(doc) {
303
+ const source = isRecord(doc.source) ? doc.source : void 0;
304
+ const meta = source && isRecord(source.meta) ? source.meta : void 0;
305
+ const durS = isRecord(doc.program) ? programLength(doc.program) : meta && typeof meta.durationMs === "number" ? meta.durationMs / 1e3 : null;
306
+ const explicit = (asNodeList(doc.segments) ?? []).map((s) => ({
307
+ in: typeof s.in === "number" ? s.in : 0,
308
+ out: typeof s.out === "number" ? s.out : 0
309
+ })).filter((s) => s.out > s.in);
310
+ const segments = explicit.length ? explicit : durS !== null ? [{ in: 0, out: durS }] : null;
311
+ if (!segments) return null;
312
+ const spans = (asNodeList(doc.speed) ?? []).map((s) => ({
313
+ in: typeof s.in === "number" ? s.in : 0,
314
+ out: typeof s.out === "number" ? s.out : 0,
315
+ rate: typeof s.rate === "number" && s.rate > 0 ? s.rate : 1
316
+ })).filter((s) => s.out > s.in);
317
+ let total = 0;
318
+ for (const seg of segments) {
319
+ total += seg.out - seg.in;
320
+ for (const sp of spans) {
321
+ const ov = Math.min(seg.out, sp.out) - Math.max(seg.in, sp.in);
322
+ if (ov > 0) total -= ov - ov / sp.rate;
323
+ }
324
+ }
325
+ return Math.round(total * 1e3) / 1e3;
326
+ }
327
+ function keptDuration(segments) {
328
+ let total = 0;
329
+ for (const s of segments) {
330
+ const start = typeof s.in === "number" ? s.in : 0;
331
+ const end = typeof s.out === "number" ? s.out : start;
332
+ total += Math.max(0, end - start);
333
+ }
334
+ return Math.round(total * 1e3) / 1e3;
335
+ }
336
+
337
+ // src/diff/types.ts
338
+ function opKey(op) {
339
+ return `${op.track}\0${op.id}`;
340
+ }
341
+
342
+ // src/diff/coalesce.ts
343
+ function coalesce(opsInOrder) {
344
+ const byNode = /* @__PURE__ */ new Map();
345
+ for (const next of opsInOrder) {
346
+ const key = opKey(next);
347
+ const prev = byNode.get(key);
348
+ if (!prev) {
349
+ byNode.set(key, cloneOp(next));
350
+ continue;
351
+ }
352
+ const folded = foldPair(prev, next);
353
+ if (folded) byNode.set(key, folded);
354
+ else byNode.delete(key);
355
+ }
356
+ return [...byNode.values()].sort(compareOps);
357
+ }
358
+ function foldPair(prev, next) {
359
+ if (prev.op === "add" && next.op === "remove") return null;
360
+ if (prev.op === "add" && next.op === "modify") {
361
+ return { ...prev, props: mergeProps(prev.props, next.props) };
362
+ }
363
+ if (next.op === "remove") return { ...next };
364
+ if (prev.op === "remove" && next.op === "add") {
365
+ return { ...next, op: "modify", props: mergeProps(prev.props, next.props) };
366
+ }
367
+ if (prev.op === "modify" && next.op === "modify") {
368
+ const merged = mergeProps(prev.props, next.props, true);
369
+ const lines = prev.lines !== void 0 || next.lines !== void 0 ? (prev.lines ?? 0) + (next.lines ?? 0) : void 0;
370
+ if (!merged && lines === void 0) return null;
371
+ return { ...prev, props: merged, lines };
372
+ }
373
+ return { ...next };
374
+ }
375
+ function mergeProps(a, b, dropNoNet = false) {
376
+ if (!a) return b ? { ...b } : void 0;
377
+ if (!b) return { ...a };
378
+ const out = { ...a };
379
+ for (const [k, [from, to]] of Object.entries(b)) {
380
+ out[k] = k in out ? [out[k][0], to] : [from, to];
381
+ }
382
+ if (dropNoNet) {
383
+ for (const [k, [from, to]] of Object.entries(out)) {
384
+ if (JSON.stringify(from) === JSON.stringify(to)) delete out[k];
385
+ }
386
+ }
387
+ return Object.keys(out).length ? out : void 0;
388
+ }
389
+ function cloneOp(op) {
390
+ return { ...op, props: op.props ? { ...op.props } : void 0 };
391
+ }
392
+ var TRACK_ORDER = [
393
+ "segments",
394
+ "frame",
395
+ "camera",
396
+ "cursor",
397
+ "cam",
398
+ "zoom",
399
+ "tilt",
400
+ "speed",
401
+ "overlay",
402
+ "object",
403
+ "audio",
404
+ "export",
405
+ "knob",
406
+ "look",
407
+ "data",
408
+ "code",
409
+ "config"
410
+ ];
411
+ function compareOps(a, b) {
412
+ const ta = TRACK_ORDER.indexOf(a.track);
413
+ const tb = TRACK_ORDER.indexOf(b.track);
414
+ if (ta !== tb) return (ta === -1 ? 99 : ta) - (tb === -1 ? 99 : tb);
415
+ const anchorA = timeAnchor(a);
416
+ const anchorB = timeAnchor(b);
417
+ if (anchorA !== anchorB) return anchorA - anchorB;
418
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
419
+ }
420
+ function timeAnchor(op) {
421
+ for (const k of ["in", "at", "start"]) {
422
+ const pair = op.props?.[k];
423
+ if (pair) {
424
+ const v = pair[1] ?? pair[0];
425
+ if (typeof v === "number") return v;
426
+ }
427
+ }
428
+ return Number.MAX_SAFE_INTEGER;
429
+ }
430
+
431
+ // src/diff/summarize.ts
432
+ function summarize(ops, options = {}) {
433
+ const max = options.maxItems ?? 12;
434
+ if (ops.length === 0) return "no changes";
435
+ const shown = ops.slice(0, max);
436
+ const parts = shown.map(phrase);
437
+ const hidden = ops.length - shown.length;
438
+ if (hidden > 0)
439
+ parts.push(`and ${hidden} smaller change${hidden === 1 ? "" : "s"}`);
440
+ return parts.join("; ");
441
+ }
442
+ function phrase(op) {
443
+ if (op.track === "code") {
444
+ return `code ${op.id} changed (${op.lines ?? 0} line${op.lines === 1 ? "" : "s"})`;
445
+ }
446
+ if (op.track === "knob") return knobPhrase(op);
447
+ if (op.track === "config") {
448
+ if (op.id === "duration" && op.props?.value) {
449
+ const [from, to] = op.props.value;
450
+ return `duration ${fmt(from, true)}\u2192${fmt(to, true)}`;
451
+ }
452
+ return `${op.id} changed`;
453
+ }
454
+ if (op.track === "data") {
455
+ const pair = op.props?.value;
456
+ if (op.op === "add") return `data ${op.id} set to ${fmt(pair?.[1])}`;
457
+ if (op.op === "remove") return `data ${op.id} removed`;
458
+ return `data ${op.id}: ${fmt(pair?.[0])}\u2192${fmt(pair?.[1])}`;
459
+ }
460
+ if (op.track === "look") {
461
+ if (op.op === "add") return `look "${op.id}" added`;
462
+ if (op.op === "remove") return `look "${op.id}" removed`;
463
+ return `look "${op.id}" changed`;
464
+ }
465
+ if (op.track === "segments") {
466
+ const d = op.props?.duration;
467
+ if (d) return `trim: kept footage ${fmt(d[0], true)}\u2192${fmt(d[1], true)}`;
468
+ return "segments edited";
469
+ }
470
+ if (SCOPED_TRACKS.has(op.track)) {
471
+ return `${op.track} ${propList(op.props)}`;
472
+ }
473
+ const name = `${op.track} ${op.id}`;
474
+ const output = op.props?.output;
475
+ const outputNote = output ? `output ${fmt(output[0], true)}\u2192${fmt(output[1], true)}` : null;
476
+ if (op.op === "add") {
477
+ const range = rangeOf(op);
478
+ const bits = [range, outputNote].filter(Boolean).join(", ");
479
+ return bits ? `${name} added (${bits})` : `${name} added`;
480
+ }
481
+ if (op.op === "remove")
482
+ return outputNote ? `${name} removed (${outputNote})` : `${name} removed`;
483
+ return `${name}: ${propList(op.props)}`;
484
+ }
485
+ function knobPhrase(op) {
486
+ const rename = op.props?.key;
487
+ if (rename) return `replaced knob ${fmt(rename[0])} with ${fmt(rename[1])}`;
488
+ if (op.op === "add") {
489
+ const kind = (op.props?.kind ?? [])[1];
490
+ return typeof kind === "string" ? `knob ${op.id} added (${kind})` : `knob ${op.id} added`;
491
+ }
492
+ if (op.op === "remove") return `knob ${op.id} removed`;
493
+ const value = op.props?.value;
494
+ const parts = [];
495
+ if (value) parts.push(`${fmt(value[0])}\u2192${fmt(value[1])}`);
496
+ for (const [k, [from, to]] of Object.entries(op.props ?? {})) {
497
+ if (k === "value") continue;
498
+ parts.push(`${k} ${fmt(from)}\u2192${fmt(to)}`);
499
+ }
500
+ return `knob ${op.id}: ${parts.join(", ")}`;
501
+ }
502
+ var SCOPED_TRACKS = /* @__PURE__ */ new Set(["frame", "cursor", "cam", "export", "camera"]);
503
+ var PROP_LABELS = {
504
+ in: "start",
505
+ out: "end",
506
+ cx: "focus x",
507
+ cy: "focus y"
508
+ };
509
+ var TIME_PROPS = /* @__PURE__ */ new Set([
510
+ "in",
511
+ "out",
512
+ "at",
513
+ "start",
514
+ "end",
515
+ "duration",
516
+ "output"
517
+ ]);
518
+ function propList(props) {
519
+ if (!props) return "changed";
520
+ return Object.entries(props).map(([k, [from, to]]) => {
521
+ const time = TIME_PROPS.has(k);
522
+ return `${PROP_LABELS[k] ?? k} ${fmt(from, time)}\u2192${fmt(to, time)}`;
523
+ }).join(", ");
524
+ }
525
+ function rangeOf(op) {
526
+ const startPair = anchorPair(op, ["in", "at", "start"]);
527
+ const start = startPair ? startPair[1] : void 0;
528
+ if (typeof start !== "number") return null;
529
+ const endPair = anchorPair(op, ["out"]);
530
+ const end = endPair ? endPair[1] : void 0;
531
+ return typeof end === "number" ? `${fmt(start, true)}\u2013${fmt(end, true)}` : `at ${fmt(start, true)}`;
532
+ }
533
+ function anchorPair(op, keys) {
534
+ for (const k of keys) {
535
+ const pair = op.props?.[k];
536
+ if (pair) return pair;
537
+ }
538
+ return void 0;
539
+ }
540
+ var FMT_STRING_MAX = 40;
541
+ function fmt(v, time = false) {
542
+ if (v === void 0) return "\u2205";
543
+ if (v === null) return "null";
544
+ if (typeof v === "number") {
545
+ const rounded = Math.round(v * 100) / 100;
546
+ return time ? `${rounded}s` : String(rounded);
547
+ }
548
+ if (typeof v === "boolean") return v ? "on" : "off";
549
+ if (typeof v === "string") {
550
+ if (v === "") return '""';
551
+ const flat = v.replace(/\s*\n\s*/g, " ");
552
+ const clipped = flat.length > FMT_STRING_MAX ? `${flat.slice(0, FMT_STRING_MAX - 1)}\u2026` : flat;
553
+ return clipped === v ? quoteIfNeeded(clipped) : `"${clipped}"`;
554
+ }
555
+ return "changed";
556
+ }
557
+ function quoteIfNeeded(s) {
558
+ return /^[\w.#-]+$/.test(s) ? s : `"${s}"`;
559
+ }
560
+ export {
561
+ coalesce,
562
+ diffConfig,
563
+ diffDoc,
564
+ opKey,
565
+ summarize
566
+ };
567
+ //# sourceMappingURL=index.js.map