vivid-editor-core 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,617 @@
1
+ /**
2
+ * AI Art Director — State Orchestrator
3
+ *
4
+ * Processes structured AI commands against the editor Zustand store.
5
+ * Each command maps to one or more store mutations (e.g. adding a visual clip
6
+ * always creates both a TimelineClip AND a CanvasObject).
7
+ */
8
+ import { createTextOverlay } from './text-presets.js';
9
+ /**
10
+ * Take whatever the LLM produced for an ADD_TEXT payload and:
11
+ * - clamp x/y to [0, 1] so the overlay can't land off-canvas
12
+ * - clamp opacity / scale / rotation to sensible ranges
13
+ * - guarantee 0 < (endMs - startMs) so the overlay is visible for some time
14
+ * - fill in every required field via createTextOverlay's defaults
15
+ *
16
+ * Returns { overlay, warnings[] } so the orchestrator can log what
17
+ * had to be patched up.
18
+ */
19
+ function normalizeAiTextOverlay(raw, projectDurationMs) {
20
+ const warnings = [];
21
+ const patch = { ...raw };
22
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
23
+ if (typeof patch.x === 'number' && (patch.x < 0 || patch.x > 1)) {
24
+ warnings.push(`x=${patch.x} clamped to [0,1]`);
25
+ patch.x = clamp(patch.x, 0, 1);
26
+ }
27
+ if (typeof patch.y === 'number' && (patch.y < 0 || patch.y > 1)) {
28
+ warnings.push(`y=${patch.y} clamped to [0,1]`);
29
+ patch.y = clamp(patch.y, 0, 1);
30
+ }
31
+ if (typeof patch.opacity === 'number')
32
+ patch.opacity = clamp(patch.opacity, 0, 1);
33
+ if (typeof patch.scale === 'number')
34
+ patch.scale = clamp(patch.scale, 0.1, 10);
35
+ // Timing — guarantee start < end and both inside project range.
36
+ const start = typeof patch.startMs === 'number' && patch.startMs >= 0 ? patch.startMs : 0;
37
+ let end = typeof patch.endMs === 'number' && patch.endMs > 0 ? patch.endMs : start + 3000;
38
+ if (end <= start) {
39
+ warnings.push(`endMs (${end}) <= startMs (${start}); extending by 3 s`);
40
+ end = start + 3000;
41
+ }
42
+ if (projectDurationMs > 0 && start >= projectDurationMs) {
43
+ warnings.push(`startMs (${start}) past project end; clamping`);
44
+ patch.startMs = Math.max(0, projectDurationMs - 3000);
45
+ patch.endMs = projectDurationMs;
46
+ }
47
+ else {
48
+ patch.startMs = start;
49
+ patch.endMs = projectDurationMs > 0 ? Math.min(end, projectDurationMs) : end;
50
+ }
51
+ return { overlay: createTextOverlay(patch), warnings };
52
+ }
53
+ /**
54
+ * Execute an array of AI commands against the editor store.
55
+ *
56
+ * Pushes a single undo checkpoint before processing all commands,
57
+ * so the entire AI action can be undone in one Ctrl+Z.
58
+ */
59
+ export function executeAiCommands(commands, snapshot, actions) {
60
+ const errors = [];
61
+ let applied = 0;
62
+ // Single undo checkpoint for the whole batch
63
+ actions.pushUndo();
64
+ // Whitelist of valid command types — used to detect when the LLM
65
+ // hallucinates a value (e.g. "fade-in") into the `type` field.
66
+ const VALID_TYPES = new Set([
67
+ 'ADD_TEXT', 'UPDATE_TEXT', 'REMOVE_TEXT',
68
+ 'ADD_CLIP', 'UPDATE_CLIP', 'REMOVE_CLIP',
69
+ 'UPDATE_CANVAS_OBJECT', 'SET_CANVAS_PRESET',
70
+ 'ADD_TRACK', 'SPLIT_CLIP', 'DUPLICATE_CLIP',
71
+ 'SET_TRANSITION', 'SET_CLIP_ANIMATION',
72
+ 'SET_CINEMATIC_LOOK', 'DETACH_AUDIO',
73
+ 'CUT_TO_BEAT',
74
+ // V2
75
+ 'SET_OBJECT_ANIMATION', 'SET_ANIMATIONS',
76
+ 'ADD_EFFECT', 'REMOVE_EFFECT',
77
+ 'ADD_AUDIO_REACTIVE', 'SET_MASK',
78
+ ]);
79
+ for (const cmd of commands) {
80
+ // Defensive: every command must be { type, payload }. The LLM
81
+ // sometimes flattens the envelope or puts a value into `type`.
82
+ if (!cmd || typeof cmd !== 'object' || !('type' in cmd)) {
83
+ errors.push(`Malformed command: ${JSON.stringify(cmd)}`);
84
+ continue;
85
+ }
86
+ if (!VALID_TYPES.has(cmd.type)) {
87
+ errors.push(`Unknown command type: ${cmd.type}`);
88
+ continue;
89
+ }
90
+ if (!('payload' in cmd) || cmd.payload == null || typeof cmd.payload !== 'object') {
91
+ errors.push(`${cmd.type}: missing or invalid payload`);
92
+ continue;
93
+ }
94
+ try {
95
+ switch (cmd.type) {
96
+ // ── Text Overlays ──────────────────────────────
97
+ case 'ADD_TEXT': {
98
+ // The LLM commonly emits partial / out-of-bounds payloads
99
+ // (missing fontFamily, x>1, endMs<startMs). Normalize so
100
+ // the overlay is always renderable rather than silently
101
+ // adding an invisible row to the array.
102
+ const raw = cmd.payload;
103
+ if (!raw.text || typeof raw.text !== 'string') {
104
+ errors.push('ADD_TEXT: payload missing required "text" field');
105
+ break;
106
+ }
107
+ const { overlay, warnings } = normalizeAiTextOverlay({ ...raw, text: raw.text }, snapshot.videoDurationMs);
108
+ if (warnings.length > 0) {
109
+ console.warn('[ai-orchestrator] ADD_TEXT normalized:', warnings.join('; '));
110
+ }
111
+ actions.addTextOverlay(overlay);
112
+ applied++;
113
+ break;
114
+ }
115
+ case 'UPDATE_TEXT': {
116
+ const { id, ...patch } = cmd.payload;
117
+ const exists = snapshot.textOverlays.some((t) => t.id === id);
118
+ if (!exists) {
119
+ errors.push(`UPDATE_TEXT: text overlay "${id}" not found`);
120
+ break;
121
+ }
122
+ actions.updateTextOverlay(id, patch);
123
+ applied++;
124
+ break;
125
+ }
126
+ case 'REMOVE_TEXT': {
127
+ actions.removeTextOverlay(cmd.payload.id);
128
+ applied++;
129
+ break;
130
+ }
131
+ // ── Timeline Clips (dual update) ───────────────
132
+ case 'ADD_CLIP': {
133
+ const { assetId, canvasObject } = cmd.payload;
134
+ const asset = snapshot.assetLibrary.find((a) => a.id === assetId);
135
+ if (!asset) {
136
+ errors.push(`ADD_CLIP: asset "${assetId}" not found in library`);
137
+ break;
138
+ }
139
+ // addClipToTimeline handles both TimelineClip + CanvasObject creation
140
+ actions.addClipToTimeline(asset);
141
+ // If the AI specified custom canvas transform, apply it to the newly created object
142
+ if (canvasObject) {
143
+ // The store creates canvas objects with id `obj-clip-{timestamp}`
144
+ // We need to find the newest canvas object for this asset
145
+ // Since addClipToTimeline is sync, we read from the snapshot's length
146
+ // to know the new object was appended. We'll apply after a microtask.
147
+ // For now, we'll use a post-execution patch approach.
148
+ const patchFields = canvasObject;
149
+ cmd._pendingCanvasPatch = patchFields;
150
+ }
151
+ applied++;
152
+ break;
153
+ }
154
+ case 'UPDATE_CLIP': {
155
+ const { id, volume, opacity, playbackRate, ...bounds } = cmd.payload;
156
+ const clip = snapshot.timelineClips.find((c) => c.id === id);
157
+ if (!clip) {
158
+ errors.push(`UPDATE_CLIP: clip "${id}" not found`);
159
+ break;
160
+ }
161
+ if (Object.keys(bounds).length > 0) {
162
+ actions.updateClipBounds(id, bounds);
163
+ }
164
+ if (volume !== undefined)
165
+ actions.setClipVolume(id, volume);
166
+ if (opacity !== undefined)
167
+ actions.setClipOpacity(id, opacity);
168
+ if (playbackRate !== undefined)
169
+ actions.setClipSpeed(id, playbackRate);
170
+ applied++;
171
+ break;
172
+ }
173
+ case 'REMOVE_CLIP': {
174
+ actions.deleteClipById(cmd.payload.id);
175
+ applied++;
176
+ break;
177
+ }
178
+ // ── Canvas Objects ──────────────────────────────
179
+ case 'UPDATE_CANVAS_OBJECT': {
180
+ const { id, ...patch } = cmd.payload;
181
+ const exists = snapshot.canvasObjects.some((o) => o.id === id);
182
+ if (!exists) {
183
+ errors.push(`UPDATE_CANVAS_OBJECT: object "${id}" not found`);
184
+ break;
185
+ }
186
+ actions.updateCanvasObject(id, patch);
187
+ applied++;
188
+ break;
189
+ }
190
+ // ── Canvas Preset ──────────────────────────────
191
+ case 'SET_CANVAS_PRESET': {
192
+ actions.setCanvasPreset(cmd.payload);
193
+ applied++;
194
+ break;
195
+ }
196
+ // ── Tracks ─────────────────────────────────────
197
+ case 'ADD_TRACK': {
198
+ actions.addTrack(cmd.payload.type);
199
+ applied++;
200
+ break;
201
+ }
202
+ // ── Split Clip ─────────────────────────────────
203
+ case 'SPLIT_CLIP': {
204
+ const { clipId, splitAtMs } = cmd.payload;
205
+ const clip = snapshot.timelineClips.find((c) => c.id === clipId);
206
+ if (!clip) {
207
+ errors.push(`SPLIT_CLIP: clip "${clipId}" not found`);
208
+ break;
209
+ }
210
+ // The LLM regularly emits splitAtMs values that are off by
211
+ // a few ms or expressed relative to the clip start instead
212
+ // of absolute. Rather than dropping the command silently,
213
+ // clamp inside [startMs+1, startMs+durationMs-1] and warn.
214
+ const clipEnd = clip.startMs + clip.durationMs;
215
+ let safeSplit = splitAtMs;
216
+ if (safeSplit <= clip.startMs || safeSplit >= clipEnd) {
217
+ // Heuristic: if the value is small (looks like an offset
218
+ // into the clip), interpret as relative; otherwise clamp.
219
+ const relativeGuess = clip.startMs + splitAtMs;
220
+ if (splitAtMs > 0 &&
221
+ splitAtMs < clip.durationMs &&
222
+ relativeGuess > clip.startMs &&
223
+ relativeGuess < clipEnd) {
224
+ safeSplit = relativeGuess;
225
+ errors.push(`SPLIT_CLIP: splitAtMs ${splitAtMs} interpreted as offset into clip → ${safeSplit}`);
226
+ }
227
+ else {
228
+ safeSplit = Math.round((clip.startMs + clipEnd) / 2);
229
+ errors.push(`SPLIT_CLIP: splitAtMs ${splitAtMs} out of clip bounds [${clip.startMs}, ${clipEnd}]; cut at midpoint ${safeSplit} instead`);
230
+ }
231
+ }
232
+ actions.setSelectedClipId(clipId);
233
+ actions.splitClip(safeSplit);
234
+ applied++;
235
+ break;
236
+ }
237
+ // ── Duplicate Clip ─────────────────────────────
238
+ case 'DUPLICATE_CLIP': {
239
+ const clip = snapshot.timelineClips.find((c) => c.id === cmd.payload.id);
240
+ if (!clip) {
241
+ errors.push(`DUPLICATE_CLIP: clip "${cmd.payload.id}" not found`);
242
+ break;
243
+ }
244
+ actions.duplicateClip(cmd.payload.id);
245
+ applied++;
246
+ break;
247
+ }
248
+ // ── Transitions ────────────────────────────────
249
+ case 'SET_TRANSITION': {
250
+ const { clipId, edge, type, durationMs } = cmd.payload;
251
+ const clip = snapshot.timelineClips.find((c) => c.id === clipId);
252
+ if (!clip) {
253
+ errors.push(`SET_TRANSITION: clip "${clipId}" not found`);
254
+ break;
255
+ }
256
+ actions.setClipTransition(clipId, edge, { type, durationMs });
257
+ applied++;
258
+ break;
259
+ }
260
+ // ── Clip Animations ────────────────────────────
261
+ case 'SET_CLIP_ANIMATION': {
262
+ const { clipId, phase, type, durationMs } = cmd.payload;
263
+ const clip = snapshot.timelineClips.find((c) => c.id === clipId);
264
+ if (!clip) {
265
+ errors.push(`SET_CLIP_ANIMATION: clip "${clipId}" not found`);
266
+ break;
267
+ }
268
+ actions.setClipAnimation(clipId, phase, { type, durationMs });
269
+ applied++;
270
+ break;
271
+ }
272
+ // ── Cinematic Look ─────────────────────────────
273
+ case 'SET_CINEMATIC_LOOK': {
274
+ actions.setCinematicLook(cmd.payload.look);
275
+ applied++;
276
+ break;
277
+ }
278
+ // ── Detach Audio ───────────────────────────────
279
+ case 'DETACH_AUDIO': {
280
+ const clip = snapshot.timelineClips.find((c) => c.id === cmd.payload.clipId);
281
+ if (!clip) {
282
+ errors.push(`DETACH_AUDIO: clip "${cmd.payload.clipId}" not found`);
283
+ break;
284
+ }
285
+ if (clip.mediaType !== 'video') {
286
+ errors.push(`DETACH_AUDIO: clip "${cmd.payload.clipId}" is not a video`);
287
+ break;
288
+ }
289
+ actions.detachAudio(cmd.payload.clipId);
290
+ applied++;
291
+ break;
292
+ }
293
+ // ── Cut-to-beat (rhythmic editing) ──────────────
294
+ case 'CUT_TO_BEAT': {
295
+ const { audioClipId, beatInterval } = cmd.payload;
296
+ const mode = cmd.payload.mode ?? 'redistribute';
297
+ const syncClipsArg = cmd.payload.syncClips ?? 'all';
298
+ if (typeof beatInterval !== 'number' || beatInterval < 1 || beatInterval > 32) {
299
+ errors.push(`CUT_TO_BEAT: invalid beatInterval ${beatInterval} (expected 1-32)`);
300
+ break;
301
+ }
302
+ const audioClip = snapshot.timelineClips.find((c) => c.id === audioClipId);
303
+ if (!audioClip || audioClip.mediaType !== 'audio') {
304
+ errors.push(`CUT_TO_BEAT: audio clip "${audioClipId}" not found in timeline`);
305
+ break;
306
+ }
307
+ const analysis = snapshot.audioBeatsByAssetId?.[audioClip.assetId];
308
+ if (!analysis || analysis === 'analyzing' || analysis === 'failed') {
309
+ errors.push(`CUT_TO_BEAT: audio "${audioClip.name}" has no beat analysis (status: ${analysis ?? 'unknown'}). Wait a moment after dropping audio for analysis to complete.`);
310
+ break;
311
+ }
312
+ // Filter visual clips: those participating in the cut.
313
+ const allVisualClips = snapshot.timelineClips.filter((c) => c.mediaType === 'video' || c.mediaType === 'image');
314
+ const targetVisuals = syncClipsArg === 'all'
315
+ ? allVisualClips
316
+ : allVisualClips.filter((c) => syncClipsArg.includes(c.id));
317
+ if (targetVisuals.length === 0) {
318
+ errors.push('CUT_TO_BEAT: no visual clips to sync');
319
+ break;
320
+ }
321
+ // Audio's absolute beat positions on the project timeline.
322
+ const audioStart = audioClip.startMs;
323
+ const audioEnd = audioClip.startMs + audioClip.durationMs;
324
+ // Beats from analysis are offsets from start of source audio.
325
+ // sourceOffsetMs accounts for trims at audio's head.
326
+ const beatsAbsolute = analysis.beats
327
+ .map((bMs) => audioStart + (bMs - audioClip.sourceOffsetMs))
328
+ .filter((t) => t >= audioStart && t <= audioEnd);
329
+ if (beatsAbsolute.length < beatInterval + 1) {
330
+ errors.push(`CUT_TO_BEAT: not enough beats (${beatsAbsolute.length}) for beatInterval ${beatInterval}`);
331
+ break;
332
+ }
333
+ // Cut points: every Nth beat, including audio start (first beat) and end.
334
+ const cutPoints = [];
335
+ for (let i = 0; i < beatsAbsolute.length; i += beatInterval) {
336
+ cutPoints.push(beatsAbsolute[i]);
337
+ }
338
+ // Always end at audio end so the last segment fills the music.
339
+ if (cutPoints[cutPoints.length - 1] < audioEnd - 50)
340
+ cutPoints.push(audioEnd);
341
+ if (mode === 'redistribute') {
342
+ const tape = [];
343
+ let totalSourceMs = 0;
344
+ for (const c of targetVisuals) {
345
+ tape.push({ sourceClip: c, offsetMs: c.sourceOffsetMs, durationMs: c.durationMs });
346
+ totalSourceMs += c.durationMs;
347
+ }
348
+ if (totalSourceMs <= 0) {
349
+ errors.push('CUT_TO_BEAT: total visual duration is zero');
350
+ break;
351
+ }
352
+ const newClips = [];
353
+ const newCanvasObjects = [];
354
+ const trackId = targetVisuals[0].trackId;
355
+ const now = Date.now();
356
+ // Walk the tape with a moving cursor; for each cut interval,
357
+ // peel off a segment of the tape equal to (next - current).
358
+ let tapeIdx = 0;
359
+ let tapeCursorInSegment = 0;
360
+ for (let i = 0; i < cutPoints.length - 1; i++) {
361
+ const segmentMs = cutPoints[i + 1] - cutPoints[i];
362
+ if (segmentMs <= 0)
363
+ continue;
364
+ // The piece we lay down references the current tape segment,
365
+ // possibly clipping its tail if the tape segment is shorter
366
+ // than the beat interval (then we step to next tape segment).
367
+ let remaining = segmentMs;
368
+ let placed = false;
369
+ while (remaining > 0 && tapeIdx < tape.length) {
370
+ const seg = tape[tapeIdx];
371
+ const available = seg.durationMs - tapeCursorInSegment;
372
+ if (available <= 0) {
373
+ tapeIdx++;
374
+ tapeCursorInSegment = 0;
375
+ continue;
376
+ }
377
+ const take = Math.min(available, remaining);
378
+ if (!placed) {
379
+ // First piece of this beat segment — emit a clip starting at cutPoints[i].
380
+ const newId = `clip-cb-${now}-${i}`;
381
+ newClips.push({
382
+ id: newId,
383
+ assetId: seg.sourceClip.assetId,
384
+ name: seg.sourceClip.name,
385
+ url: seg.sourceClip.url,
386
+ startMs: cutPoints[i],
387
+ durationMs: take,
388
+ originalDurationMs: seg.sourceClip.originalDurationMs,
389
+ sourceOffsetMs: seg.offsetMs + tapeCursorInSegment,
390
+ mediaType: seg.sourceClip.mediaType,
391
+ trackId,
392
+ });
393
+ newCanvasObjects.push({
394
+ id: `obj-${newId}`,
395
+ clipId: newId,
396
+ x: 0, y: 0, w: 1, h: 1,
397
+ rotation: 0,
398
+ });
399
+ placed = true;
400
+ }
401
+ else {
402
+ // Subsequent piece within the same beat — extend the
403
+ // previous clip's duration if and only if it's the same
404
+ // source asset; otherwise start a fresh clip nested in
405
+ // the same beat. For simplicity, we only support a
406
+ // single asset per beat segment (truncate trailing tape
407
+ // and snap to next beat).
408
+ break;
409
+ }
410
+ tapeCursorInSegment += take;
411
+ remaining -= take;
412
+ if (tapeCursorInSegment >= seg.durationMs) {
413
+ tapeIdx++;
414
+ tapeCursorInSegment = 0;
415
+ }
416
+ }
417
+ if (tapeIdx >= tape.length)
418
+ break; // tape exhausted, stop emitting
419
+ }
420
+ // Preserve audio + non-target clips intact.
421
+ const preservedClips = snapshot.timelineClips.filter((c) => c.mediaType === 'audio' || c.mediaType === 'color' ||
422
+ (syncClipsArg !== 'all' && !syncClipsArg.includes(c.id) && (c.mediaType === 'video' || c.mediaType === 'image')));
423
+ // Preserve canvas objects whose clip is in preservedClips.
424
+ const preservedClipIds = new Set(preservedClips.map((c) => c.id));
425
+ const preservedCanvasObjects = snapshot.canvasObjects.filter((o) => preservedClipIds.has(o.clipId));
426
+ actions.replaceTimelineClips([...preservedClips, ...newClips], [...preservedCanvasObjects, ...newCanvasObjects]);
427
+ applied++;
428
+ }
429
+ else {
430
+ // mode === 'snap' — leave clips, just nudge edges to nearest beat.
431
+ const snapToNearestBeat = (t) => {
432
+ if (beatsAbsolute.length === 0)
433
+ return t;
434
+ let best = beatsAbsolute[0];
435
+ let bestDist = Math.abs(t - best);
436
+ for (let i = 1; i < beatsAbsolute.length; i++) {
437
+ const d = Math.abs(t - beatsAbsolute[i]);
438
+ if (d < bestDist) {
439
+ best = beatsAbsolute[i];
440
+ bestDist = d;
441
+ }
442
+ else if (beatsAbsolute[i] > t + bestDist)
443
+ break;
444
+ }
445
+ return best;
446
+ };
447
+ const updatedClips = snapshot.timelineClips.map((c) => {
448
+ if (c.mediaType !== 'video' && c.mediaType !== 'image')
449
+ return c;
450
+ if (syncClipsArg !== 'all' && !syncClipsArg.includes(c.id))
451
+ return c;
452
+ const newStart = snapToNearestBeat(c.startMs);
453
+ const newEnd = snapToNearestBeat(c.startMs + c.durationMs);
454
+ const newDur = Math.max(100, newEnd - newStart);
455
+ return { ...c, startMs: newStart, durationMs: newDur };
456
+ });
457
+ actions.replaceTimelineClips(updatedClips);
458
+ applied++;
459
+ }
460
+ break;
461
+ }
462
+ // ── V2: motion design / effects / masks / audio reactivity ──
463
+ // These mutate the optional v2 fields (animations, effects, maskV2,
464
+ // audioReactive) on CanvasObject / TextOverlay. The render path
465
+ // (render.ts) already reads these fields — wiring just needed the
466
+ // orchestrator handlers, which had been left as dead code in M6.
467
+ case 'SET_OBJECT_ANIMATION': {
468
+ const { objectId, objectType, property, animation } = cmd.payload;
469
+ if (objectType === 'canvas') {
470
+ const obj = snapshot.canvasObjects.find((o) => o.id === objectId);
471
+ if (!obj) {
472
+ errors.push(`SET_OBJECT_ANIMATION: canvas object "${objectId}" not found`);
473
+ break;
474
+ }
475
+ // Merge into existing animations rather than replace — lets the
476
+ // AI add multiple per-property curves in successive commands.
477
+ const merged = { ...(obj.animations ?? {}), [property]: animation };
478
+ actions.updateCanvasObject(objectId, { animations: merged });
479
+ applied++;
480
+ }
481
+ else {
482
+ const txt = snapshot.textOverlays.find((t) => t.id === objectId);
483
+ if (!txt) {
484
+ errors.push(`SET_OBJECT_ANIMATION: text overlay "${objectId}" not found`);
485
+ break;
486
+ }
487
+ const merged = { ...(txt.animations ?? {}), [property]: animation };
488
+ actions.updateTextOverlay(objectId, { animations: merged });
489
+ applied++;
490
+ }
491
+ break;
492
+ }
493
+ case 'SET_ANIMATIONS': {
494
+ const { objectId, objectType, animations } = cmd.payload;
495
+ if (objectType === 'canvas') {
496
+ if (!snapshot.canvasObjects.some((o) => o.id === objectId)) {
497
+ errors.push(`SET_ANIMATIONS: canvas object "${objectId}" not found`);
498
+ break;
499
+ }
500
+ actions.updateCanvasObject(objectId, { animations });
501
+ applied++;
502
+ }
503
+ else {
504
+ if (!snapshot.textOverlays.some((t) => t.id === objectId)) {
505
+ errors.push(`SET_ANIMATIONS: text overlay "${objectId}" not found`);
506
+ break;
507
+ }
508
+ actions.updateTextOverlay(objectId, { animations });
509
+ applied++;
510
+ }
511
+ break;
512
+ }
513
+ case 'ADD_EFFECT': {
514
+ const { objectId, objectType, effect, replace } = cmd.payload;
515
+ if (objectType === 'canvas') {
516
+ const obj = snapshot.canvasObjects.find((o) => o.id === objectId);
517
+ if (!obj) {
518
+ errors.push(`ADD_EFFECT: canvas object "${objectId}" not found`);
519
+ break;
520
+ }
521
+ const existing = obj.effects ?? [];
522
+ const next = replace
523
+ ? [...existing.filter((e) => e.type !== effect.type), effect]
524
+ : [...existing, effect];
525
+ actions.updateCanvasObject(objectId, { effects: next });
526
+ applied++;
527
+ }
528
+ else {
529
+ const txt = snapshot.textOverlays.find((t) => t.id === objectId);
530
+ if (!txt) {
531
+ errors.push(`ADD_EFFECT: text overlay "${objectId}" not found`);
532
+ break;
533
+ }
534
+ const existing = txt.effects ?? [];
535
+ const next = replace
536
+ ? [...existing.filter((e) => e.type !== effect.type), effect]
537
+ : [...existing, effect];
538
+ actions.updateTextOverlay(objectId, { effects: next });
539
+ applied++;
540
+ }
541
+ break;
542
+ }
543
+ case 'REMOVE_EFFECT': {
544
+ const { objectId, objectType, effectType } = cmd.payload;
545
+ if (objectType === 'canvas') {
546
+ const obj = snapshot.canvasObjects.find((o) => o.id === objectId);
547
+ if (!obj) {
548
+ errors.push(`REMOVE_EFFECT: canvas object "${objectId}" not found`);
549
+ break;
550
+ }
551
+ const filtered = (obj.effects ?? []).filter((e) => e.type !== effectType);
552
+ actions.updateCanvasObject(objectId, { effects: filtered });
553
+ applied++;
554
+ }
555
+ else {
556
+ const txt = snapshot.textOverlays.find((t) => t.id === objectId);
557
+ if (!txt) {
558
+ errors.push(`REMOVE_EFFECT: text overlay "${objectId}" not found`);
559
+ break;
560
+ }
561
+ const filtered = (txt.effects ?? []).filter((e) => e.type !== effectType);
562
+ actions.updateTextOverlay(objectId, { effects: filtered });
563
+ applied++;
564
+ }
565
+ break;
566
+ }
567
+ case 'ADD_AUDIO_REACTIVE': {
568
+ const { objectId, objectType, modifier } = cmd.payload;
569
+ if (objectType === 'canvas') {
570
+ const obj = snapshot.canvasObjects.find((o) => o.id === objectId);
571
+ if (!obj) {
572
+ errors.push(`ADD_AUDIO_REACTIVE: canvas object "${objectId}" not found`);
573
+ break;
574
+ }
575
+ // Replace any existing modifier on the same property — having
576
+ // two reactive curves on the same property would be undefined
577
+ // behavior at the sampler level.
578
+ const existing = (obj.audioReactive ?? []).filter((m) => m.property !== modifier.property);
579
+ actions.updateCanvasObject(objectId, { audioReactive: [...existing, modifier] });
580
+ applied++;
581
+ }
582
+ else {
583
+ const txt = snapshot.textOverlays.find((t) => t.id === objectId);
584
+ if (!txt) {
585
+ errors.push(`ADD_AUDIO_REACTIVE: text overlay "${objectId}" not found`);
586
+ break;
587
+ }
588
+ const existing = (txt.audioReactive ?? []).filter((m) => m.property !== modifier.property);
589
+ actions.updateTextOverlay(objectId, { audioReactive: [...existing, modifier] });
590
+ applied++;
591
+ }
592
+ break;
593
+ }
594
+ case 'SET_MASK': {
595
+ const { objectId, mask } = cmd.payload;
596
+ const obj = snapshot.canvasObjects.find((o) => o.id === objectId);
597
+ if (!obj) {
598
+ errors.push(`SET_MASK: canvas object "${objectId}" not found`);
599
+ break;
600
+ }
601
+ actions.updateCanvasObject(objectId, { maskV2: mask });
602
+ applied++;
603
+ break;
604
+ }
605
+ default: {
606
+ // Should be unreachable — VALID_TYPES check above filters out
607
+ // unknown types before reaching the switch.
608
+ errors.push(`Unhandled known command type: ${cmd.type}`);
609
+ }
610
+ }
611
+ }
612
+ catch (err) {
613
+ errors.push(`${cmd.type}: ${err instanceof Error ? err.message : String(err)}`);
614
+ }
615
+ }
616
+ return { applied, errors };
617
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Headless editor — open a ProjectFile, apply AiCommands with the SAME
3
+ * store + orchestrator the browser uses, serialize back. This is what
4
+ * vivid-mcp runs in Node; nothing here touches the DOM.
5
+ */
6
+ import { type EditorStore, type EditorState } from './store.js';
7
+ import { type AiCommand, type EditorSnapshot, type OrchestratorActions } from './ai-orchestrator.js';
8
+ import { type ApplyProjectOptions, type ProjectExtras } from './project.js';
9
+ import { type ProjectFile } from './types/project.js';
10
+ export declare function snapshotOf(state: EditorState): EditorSnapshot;
11
+ /** Same wiring as the browser AI panel (ai-chat-panel.tsx). */
12
+ export declare function actionsOf(state: EditorState): OrchestratorActions;
13
+ export interface ApplyResult {
14
+ applied: number;
15
+ errors: string[];
16
+ /** Ids created by this batch, in order (clips + their canvas objects, texts, tracks). */
17
+ created: {
18
+ clips: string[];
19
+ canvasObjects: string[];
20
+ textOverlays: string[];
21
+ tracks: string[];
22
+ };
23
+ }
24
+ /** One command at a time, fresh snapshot each; consumes ADD_CLIP's canvas patch. */
25
+ export declare function applyCommands(store: EditorStore, commands: AiCommand[]): ApplyResult;
26
+ export interface HeadlessEditor {
27
+ store: EditorStore;
28
+ /** Subtitle & template state carried through untouched. */
29
+ extras: ProjectExtras;
30
+ /** Local asset ids whose server asset could not be resolved on open. */
31
+ missingAssets: string[];
32
+ /** True when the file was a v1 project migrated in memory. */
33
+ migrated: boolean;
34
+ snapshot(): EditorSnapshot;
35
+ /**
36
+ * Apply commands through the shared orchestrator. Unlike the browser
37
+ * panel (one snapshot per batch) each command sees the state left by the
38
+ * previous one, so a batch can ADD_CLIP and then UPDATE_CLIP the new clip
39
+ * via the ids reported in `created`.
40
+ */
41
+ apply(commands: AiCommand[]): ApplyResult;
42
+ toProjectFile(): ProjectFile;
43
+ }
44
+ /** Parse + validate (+ migrate v1) a project JSON as loaded from R2. */
45
+ export declare function parseProjectFile(raw: unknown): {
46
+ file: ProjectFile;
47
+ migrated: boolean;
48
+ };
49
+ /** Empty project with sensible defaults (V1 track, first canvas preset). */
50
+ export declare function emptyProjectFile(name: string, canvasPreset?: ProjectFile['canvasPreset']): ProjectFile;
51
+ export declare function openHeadlessProject(raw: unknown, opts: ApplyProjectOptions): HeadlessEditor;