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.
package/dist/store.js ADDED
@@ -0,0 +1,866 @@
1
+ /**
2
+ * Editor store — the single source of truth for timeline / canvas / text
3
+ * state and every editing operation (add, split, trim, transitions,
4
+ * canvas presets, undo…). Runs anywhere: the browser wraps it in a React
5
+ * hook (vivid-app `src/store/editor.ts`), vivid-mcp drives it headless in
6
+ * Node. Everything that touches the browser (audio engine, asset manager,
7
+ * localStorage, rAF, vision analysis) is injected through `EditorStoreDeps`
8
+ * and defaults to a no-op.
9
+ */
10
+ import { createStore } from 'zustand/vanilla';
11
+ import { findFirstGap, findNearestValidPosition } from './timeline-utils.js';
12
+ // Color palettes for dynamic track assignment
13
+ const VISUAL_COLORS = [
14
+ 'rgba(245, 101, 69, 0.6)', // vivid orange
15
+ 'rgba(168, 85, 247, 0.6)', // purple
16
+ 'rgba(59, 130, 246, 0.6)', // blue
17
+ 'rgba(236, 72, 153, 0.6)', // pink
18
+ ];
19
+ const AUDIO_COLORS = [
20
+ 'rgba(13, 148, 136, 0.5)', // teal
21
+ 'rgba(34, 197, 94, 0.5)', // green
22
+ 'rgba(234, 179, 8, 0.5)', // yellow
23
+ 'rgba(59, 130, 246, 0.5)', // blue
24
+ ];
25
+ /** @deprecated Use store `tracks` state instead. Kept for backward compatibility. */
26
+ export const TIMELINE_TRACKS = [
27
+ { id: 'V1', label: 'V1', type: 'visual', color: VISUAL_COLORS[0] },
28
+ ];
29
+ const INITIAL_TRACKS = [
30
+ { id: 'V1', label: 'V1', type: 'visual', color: VISUAL_COLORS[0] },
31
+ ];
32
+ export const CANVAS_PRESETS = [
33
+ { id: 'landscape', label: '16:9 Landscape', width: 1280, height: 720 },
34
+ { id: 'landscape-fhd', label: '16:9 Full HD', width: 1920, height: 1080 },
35
+ { id: 'portrait', label: '9:16 Portrait', width: 720, height: 1280 },
36
+ { id: 'portrait-fhd', label: '9:16 Vertical HD', width: 1080, height: 1920 },
37
+ { id: 'square', label: '1:1 Square', width: 1080, height: 1080 },
38
+ { id: 'social', label: '4:5 Social Feed', width: 864, height: 1080 },
39
+ ];
40
+ export const CINEMATIC_LOOKS = [
41
+ { value: 'none', label: 'None' },
42
+ { value: 'kodak-portra', label: 'Kodak Portra' },
43
+ { value: 'noir', label: 'Noir' },
44
+ { value: 'cyberpunk', label: 'Cyberpunk' },
45
+ { value: 'kodak-portra-400', label: 'Portra 400' },
46
+ { value: 'fuji-provia', label: 'Fuji Provia' },
47
+ { value: 'ilford-hp5', label: 'Ilford HP5' },
48
+ { value: 'cinestill-800t', label: 'Cinestill 800T' },
49
+ { value: 'bleach-bypass', label: 'Bleach Bypass' },
50
+ { value: 'polaroid-vintage', label: 'Polaroid' },
51
+ { value: 'technicolor', label: 'Technicolor' },
52
+ { value: 'cross-process', label: 'Cross Process' },
53
+ { value: 'muted-cinematic', label: 'Muted Cinema' },
54
+ { value: 'cyberpunk-neon', label: 'Neon Punk' },
55
+ ];
56
+ /** CSS filter string for live DOM preview */
57
+ export function getCinematicFilter(look) {
58
+ switch (look) {
59
+ case 'kodak-portra': return 'contrast(1.05) sepia(0.15) saturate(1.1)';
60
+ case 'noir': return 'grayscale(1) contrast(1.2)';
61
+ case 'cyberpunk': return 'contrast(1.15) saturate(1.4) hue-rotate(-10deg)';
62
+ case 'kodak-portra-400': return 'contrast(1.05) saturate(1.2) sepia(0.1)';
63
+ case 'fuji-provia': return 'contrast(1.1) saturate(1.3) hue-rotate(-5deg)';
64
+ case 'ilford-hp5': return 'grayscale(1) contrast(1.2) brightness(0.9)';
65
+ case 'cinestill-800t': return 'contrast(1.1) saturate(1.1) hue-rotate(-10deg) brightness(1.05)';
66
+ case 'bleach-bypass': return 'contrast(1.4) saturate(0.5)';
67
+ case 'polaroid-vintage': return 'contrast(0.9) saturate(0.8) sepia(0.3) brightness(1.1)';
68
+ case 'technicolor': return 'contrast(1.2) saturate(1.5)';
69
+ case 'cross-process': return 'contrast(1.1) saturate(1.2) sepia(0.2) hue-rotate(15deg)';
70
+ case 'muted-cinematic': return 'contrast(0.95) saturate(0.6)';
71
+ case 'cyberpunk-neon': return 'contrast(1.2) saturate(1.4) hue-rotate(30deg)';
72
+ default: return 'none';
73
+ }
74
+ }
75
+ export const MAX_TIMELINE_MS = 300_000; // 5 minutes
76
+ export const MAX_CROSSFADE_MS = 3000; // max overlap for auto-crossfade (3 s)
77
+ const MIN_CLIP_MS = 100;
78
+ // ─── Zoom Constants ─────────────────────────────────────────
79
+ export const ZOOM_MIN_PX_PER_SEC = 10;
80
+ export const ZOOM_MAX_PX_PER_SEC = 3000;
81
+ export const ZOOM_DEFAULT_PX_PER_SEC = 100;
82
+ // Keep the rendered timeline width below a level where CSS transforms /
83
+ // scroll math start to misbehave. At long durations this caps the max zoom
84
+ // so a 5-minute project can't blow the DOM up to ~900k px at full zoom.
85
+ const SAFE_TIMELINE_WIDTH_PX = 400_000;
86
+ // Effective max zoom for the current timeline length: never exceeds the
87
+ // hard ZOOM_MAX, but shrinks for long timelines so total width stays safe.
88
+ export function maxPxPerSecForDuration(durationMs) {
89
+ const durationSec = Math.max(1, durationMs / 1000);
90
+ const widthCapped = Math.floor(SAFE_TIMELINE_WIDTH_PX / durationSec);
91
+ return Math.max(ZOOM_MIN_PX_PER_SEC, Math.min(ZOOM_MAX_PX_PER_SEC, widthCapped));
92
+ }
93
+ // Total occupied timeline length (end of the last clip), used to derive
94
+ // the duration-aware zoom ceiling.
95
+ function timelineContentMs(clips) {
96
+ return clips.reduce((max, c) => Math.max(max, c.startMs + c.durationMs), 0);
97
+ }
98
+ function clamp(v, min, max) {
99
+ return Math.max(min, Math.min(max, v));
100
+ }
101
+ export function createEditorStore(deps = {}) {
102
+ return createStore()((set, get) => ({
103
+ // ── Initial state ──
104
+ projectName: '',
105
+ playheadMs: 0,
106
+ isPlaying: false,
107
+ loopPlayback: (() => {
108
+ try {
109
+ return deps.storage?.get('vivid.editor.loopPlayback') === '1';
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ })(),
115
+ assetLibrary: [],
116
+ selectedAssetIds: new Set(),
117
+ timelineClips: [],
118
+ selectedClipId: null,
119
+ tracks: [...INITIAL_TRACKS],
120
+ nextVisualNum: 2,
121
+ nextAudioNum: 1,
122
+ canvasPreset: CANVAS_PRESETS[0],
123
+ canvasObjects: [],
124
+ selectedObjectId: null,
125
+ snapGuides: [],
126
+ textOverlays: [],
127
+ selectedTextId: null,
128
+ cinematicLook: 'none',
129
+ pendingReframe: null,
130
+ maskEditorClipId: null,
131
+ pxPerSec: ZOOM_DEFAULT_PX_PER_SEC,
132
+ audioBeatsByAssetId: {},
133
+ undoStack: [],
134
+ redoStack: [],
135
+ // ── Project ──
136
+ setProjectName: (name) => set({ projectName: name }),
137
+ // ── Audio beat analysis ──
138
+ setAudioBeatsStatus: (assetId, value) => set((s) => ({ audioBeatsByAssetId: { ...s.audioBeatsByAssetId, [assetId]: value } })),
139
+ // ── Bulk timeline replacement ──
140
+ // Used by AI beat-sync to drop in a freshly computed clip array. Caller
141
+ // is responsible for pushing undo before invoking.
142
+ //
143
+ // If `canvasObjects` is provided, the canvas object array is fully
144
+ // replaced for visual clips (caller assembles them with the right
145
+ // transforms). Otherwise existing canvas objects whose clipId no longer
146
+ // exists are pruned, and surviving ones are kept untouched.
147
+ replaceTimelineClips: (clips, canvasObjectsArg) => {
148
+ set((s) => {
149
+ if (canvasObjectsArg) {
150
+ return { timelineClips: clips, canvasObjects: canvasObjectsArg, redoStack: [] };
151
+ }
152
+ const remainingClipIds = new Set(clips.map((c) => c.id));
153
+ const prunedCanvasObjects = s.canvasObjects.filter((obj) => remainingClipIds.has(obj.clipId));
154
+ return { timelineClips: clips, canvasObjects: prunedCanvasObjects, redoStack: [] };
155
+ });
156
+ },
157
+ // ── Playback ──
158
+ setPlayheadMs: (ms) => {
159
+ const clamped = clamp(ms, 0, MAX_TIMELINE_MS);
160
+ deps.onSeek?.(clamped); // browser: stop audio nodes, sync + re-anchor the playhead engine
161
+ set({ playheadMs: clamped });
162
+ },
163
+ setIsPlaying: (playing) => {
164
+ deps.onPlayingChange?.(playing);
165
+ set({ isPlaying: playing });
166
+ },
167
+ toggleLoopPlayback: () => set((s) => {
168
+ const next = !s.loopPlayback;
169
+ try {
170
+ deps.storage?.set('vivid.editor.loopPlayback', next ? '1' : '0');
171
+ }
172
+ catch { /* ignore quota */ }
173
+ return { loopPlayback: next };
174
+ }),
175
+ // ── Assets ──
176
+ addAsset: (clip) => {
177
+ deps.onAssetAdded?.(clip); // browser: AssetManager (triggers background audio analysis if audio)
178
+ set((s) => ({ assetLibrary: [...s.assetLibrary, clip] }));
179
+ // Fire-and-forget vision analysis for visual assets without a description
180
+ if (deps.analyzeAsset && clip.mediaType !== 'audio' && !clip.aiDescription && clip.url) {
181
+ deps.analyzeAsset(clip).then((result) => {
182
+ if (result?.description) {
183
+ get().updateAsset(clip.id, { aiDescription: result.description });
184
+ }
185
+ }).catch(() => { });
186
+ }
187
+ },
188
+ removeAsset: (id) => {
189
+ deps.onAssetRemoved?.(id);
190
+ set((s) => ({
191
+ assetLibrary: s.assetLibrary.filter((a) => a.id !== id),
192
+ selectedAssetIds: (() => { const next = new Set(s.selectedAssetIds); next.delete(id); return next; })(),
193
+ }));
194
+ },
195
+ updateAsset: (id, patch) => {
196
+ deps.onAssetUpdated?.(id, patch);
197
+ set((s) => ({
198
+ assetLibrary: s.assetLibrary.map((a) => a.id === id ? { ...a, ...patch } : a),
199
+ }));
200
+ },
201
+ toggleAssetSelected: (id) => set((s) => {
202
+ const next = new Set(s.selectedAssetIds);
203
+ if (next.has(id))
204
+ next.delete(id);
205
+ else
206
+ next.add(id);
207
+ return { selectedAssetIds: next };
208
+ }),
209
+ clearAssetSelection: () => set({ selectedAssetIds: new Set() }),
210
+ // ── Timeline ──
211
+ addClipToTimeline: (asset) => {
212
+ const state = get();
213
+ state.pushUndo();
214
+ const isAudio = asset.mediaType === 'audio';
215
+ const trackType = isAudio ? 'audio' : 'visual';
216
+ const clipDuration = Math.min(asset.durationMs, MAX_TIMELINE_MS);
217
+ // Find a track with space, or create one
218
+ let targetTrackId = null;
219
+ let startMs = null;
220
+ // Try existing tracks of the right type in order
221
+ const candidateTracks = state.tracks.filter((t) => t.type === trackType);
222
+ // For audio, ensure at least A1 exists
223
+ if (isAudio && candidateTracks.length === 0) {
224
+ const newTrack = get().addTrack('audio');
225
+ targetTrackId = newTrack.id;
226
+ startMs = findFirstGap(get().timelineClips, targetTrackId, clipDuration, MAX_TIMELINE_MS);
227
+ }
228
+ else {
229
+ for (const track of candidateTracks) {
230
+ const gap = findFirstGap(state.timelineClips, track.id, clipDuration, MAX_TIMELINE_MS);
231
+ if (gap !== null) {
232
+ targetTrackId = track.id;
233
+ startMs = gap;
234
+ break;
235
+ }
236
+ }
237
+ // No space on existing tracks → create a new one
238
+ if (targetTrackId === null) {
239
+ const newTrack = get().addTrack(trackType);
240
+ targetTrackId = newTrack.id;
241
+ startMs = findFirstGap(get().timelineClips, targetTrackId, clipDuration, MAX_TIMELINE_MS);
242
+ }
243
+ }
244
+ if (startMs === null || targetTrackId === null)
245
+ return;
246
+ const availableMs = MAX_TIMELINE_MS - startMs;
247
+ if (availableMs <= 0)
248
+ return;
249
+ const clipId = `clip-${Date.now()}`;
250
+ const clip = {
251
+ id: clipId,
252
+ assetId: asset.id,
253
+ name: asset.name,
254
+ url: asset.url,
255
+ startMs,
256
+ durationMs: Math.min(asset.durationMs, availableMs),
257
+ originalDurationMs: asset.durationMs,
258
+ sourceOffsetMs: 0,
259
+ mediaType: asset.mediaType,
260
+ trackId: targetTrackId,
261
+ };
262
+ // Create canvas object for visual clips
263
+ // Size to fit the full canvas while preserving aspect ratio (contain, not cover)
264
+ let objW = 1, objH = 1, objX = 0, objY = 0;
265
+ if (!isAudio && asset.intrinsicWidth && asset.intrinsicHeight) {
266
+ const canvasAR = state.canvasPreset.width / state.canvasPreset.height;
267
+ const mediaAR = asset.intrinsicWidth / asset.intrinsicHeight;
268
+ if (mediaAR > canvasAR) {
269
+ // Media is wider than canvas → fit to width, shrink height
270
+ objW = 1;
271
+ objH = canvasAR / mediaAR;
272
+ objX = 0;
273
+ objY = (1 - objH) / 2; // center vertically
274
+ }
275
+ else {
276
+ // Media is taller than canvas → fit to height, shrink width
277
+ objH = 1;
278
+ objW = mediaAR / canvasAR;
279
+ objX = (1 - objW) / 2; // center horizontally
280
+ objY = 0;
281
+ }
282
+ }
283
+ const newCanvasObjects = isAudio ? [] : [{
284
+ id: `obj-${clipId}`,
285
+ clipId,
286
+ x: objX, y: objY, w: objW, h: objH,
287
+ rotation: 0,
288
+ }];
289
+ set((s) => ({
290
+ timelineClips: [...s.timelineClips, clip],
291
+ canvasObjects: [...s.canvasObjects, ...newCanvasObjects],
292
+ redoStack: [],
293
+ }));
294
+ // Audio mirror track is no longer auto-created — only shown when audio is detached
295
+ },
296
+ updateClipBounds: (id, updates) => {
297
+ const state = get();
298
+ state.pushUndo();
299
+ set((s) => ({
300
+ timelineClips: s.timelineClips.map((c) => c.id === id ? { ...c, ...updates } : c),
301
+ redoStack: [],
302
+ }));
303
+ },
304
+ moveClip: (id, newStartMs, newTrackId, allowOverlap) => {
305
+ const state = get();
306
+ const clip = state.timelineClips.find((c) => c.id === id);
307
+ if (!clip)
308
+ return;
309
+ let trackId = newTrackId ?? clip.trackId;
310
+ // Auto-create track if it doesn't exist yet (drag-to-new-track)
311
+ if (!state.tracks.some((t) => t.id === trackId)) {
312
+ const type = trackId.startsWith('V') ? 'visual' : 'audio';
313
+ const num = parseInt(trackId.replace(/^[VA]/, ''), 10);
314
+ const colors = type === 'visual' ? VISUAL_COLORS : AUDIO_COLORS;
315
+ const newTrack = {
316
+ id: trackId,
317
+ label: trackId,
318
+ type,
319
+ color: colors[(num - 1) % colors.length],
320
+ };
321
+ const nextKey = type === 'visual' ? 'nextVisualNum' : 'nextAudioNum';
322
+ const currentNext = type === 'visual' ? state.nextVisualNum : state.nextAudioNum;
323
+ set({ tracks: [...state.tracks, newTrack], [nextKey]: Math.max(currentNext, num + 1) });
324
+ }
325
+ // When allowOverlap is set (crossfade), skip collision prevention — place at exact position
326
+ const validStart = allowOverlap
327
+ ? clamp(newStartMs, 0, MAX_TIMELINE_MS - clip.durationMs)
328
+ : findNearestValidPosition(get().timelineClips, id, trackId, newStartMs, clip.durationMs, MAX_TIMELINE_MS);
329
+ set((s) => ({
330
+ timelineClips: s.timelineClips.map((c) => c.id === id
331
+ ? { ...c, startMs: validStart, trackId }
332
+ : c),
333
+ }));
334
+ // Clean up empty tracks after move
335
+ get().removeEmptyTracks();
336
+ },
337
+ trimClipStart: (id, deltaMs) => {
338
+ set((s) => ({
339
+ timelineClips: s.timelineClips.map((c) => {
340
+ if (c.id !== id)
341
+ return c;
342
+ // Find the previous clip on the same track (left neighbor)
343
+ const prevClipEnd = s.timelineClips
344
+ .filter((other) => other.id !== id && other.trackId === c.trackId && other.startMs + other.durationMs <= c.startMs)
345
+ .reduce((max, other) => Math.max(max, other.startMs + other.durationMs), 0);
346
+ if (c.mediaType === 'image' || c.mediaType === 'color') {
347
+ const maxShrink = c.durationMs - MIN_CLIP_MS;
348
+ const actualDelta = clamp(deltaMs, -(c.startMs - prevClipEnd), maxShrink);
349
+ return {
350
+ ...c,
351
+ startMs: c.startMs + actualDelta,
352
+ durationMs: c.durationMs - actualDelta,
353
+ sourceOffsetMs: 0,
354
+ };
355
+ }
356
+ const rate = c.playbackRate ?? 1;
357
+ const maxTrim = c.durationMs - MIN_CLIP_MS;
358
+ // sourceOffsetMs is in source-time, so convert from timeline-delta
359
+ const minDelta = Math.max(-c.sourceOffsetMs / rate, -(c.startMs - prevClipEnd));
360
+ const actualDelta = clamp(deltaMs, minDelta, maxTrim);
361
+ return {
362
+ ...c,
363
+ startMs: c.startMs + actualDelta,
364
+ durationMs: c.durationMs - actualDelta,
365
+ sourceOffsetMs: c.sourceOffsetMs + actualDelta * rate,
366
+ };
367
+ }),
368
+ }));
369
+ },
370
+ trimClipEnd: (id, deltaMs) => {
371
+ set((s) => ({
372
+ timelineClips: s.timelineClips.map((c) => {
373
+ if (c.id !== id)
374
+ return c;
375
+ // Find the next clip on the same track (right neighbor)
376
+ const nextClipStart = s.timelineClips
377
+ .filter((other) => other.id !== id && other.trackId === c.trackId && other.startMs >= c.startMs + c.durationMs)
378
+ .reduce((min, other) => Math.min(min, other.startMs), MAX_TIMELINE_MS);
379
+ const maxDurationByTrack = nextClipStart - c.startMs;
380
+ if (c.mediaType === 'image' || c.mediaType === 'color') {
381
+ const maxDuration = Math.min(MAX_TIMELINE_MS - c.startMs, maxDurationByTrack);
382
+ const newDuration = clamp(c.durationMs + deltaMs, MIN_CLIP_MS, maxDuration);
383
+ return { ...c, durationMs: newDuration };
384
+ }
385
+ const maxExtend = c.originalDurationMs - c.sourceOffsetMs - c.durationMs;
386
+ const maxByNeighbor = maxDurationByTrack - c.durationMs;
387
+ const newDuration = clamp(c.durationMs + deltaMs, MIN_CLIP_MS, c.durationMs + Math.min(maxExtend, maxByNeighbor));
388
+ return { ...c, durationMs: newDuration };
389
+ }),
390
+ }));
391
+ },
392
+ splitClip: (splitAtMs) => {
393
+ const state = get();
394
+ const clips = state.timelineClips;
395
+ let clipIndex;
396
+ if (state.selectedClipId) {
397
+ // Prioritize the selected clip — only split it
398
+ clipIndex = clips.findIndex((c) => c.id === state.selectedClipId && splitAtMs > c.startMs && splitAtMs < c.startMs + c.durationMs);
399
+ }
400
+ else {
401
+ // No selection — split the first clip that intersects the playhead
402
+ clipIndex = clips.findIndex((c) => splitAtMs > c.startMs && splitAtMs < c.startMs + c.durationMs);
403
+ }
404
+ if (clipIndex === -1)
405
+ return;
406
+ state.pushUndo();
407
+ const clip = clips[clipIndex];
408
+ const offsetInClip = splitAtMs - clip.startMs;
409
+ const leftId = `${clip.id}-L`;
410
+ const rightId = `${clip.id}-R`;
411
+ const leftClip = {
412
+ ...clip,
413
+ id: leftId,
414
+ durationMs: offsetInClip,
415
+ isAudioDetached: clip.isAudioDetached,
416
+ transitionIn: clip.transitionIn,
417
+ transitionOut: undefined, // cut point — no transition at the split
418
+ };
419
+ const rightClip = {
420
+ ...clip,
421
+ id: rightId,
422
+ startMs: splitAtMs,
423
+ durationMs: clip.durationMs - offsetInClip,
424
+ sourceOffsetMs: clip.mediaType === 'image' ? 0 : clip.sourceOffsetMs + offsetInClip * (clip.playbackRate ?? 1),
425
+ isAudioDetached: clip.isAudioDetached,
426
+ transitionIn: undefined, // cut point — no transition at the split
427
+ transitionOut: clip.transitionOut,
428
+ };
429
+ // Find the original canvas object and duplicate it for both halves
430
+ const origObj = state.canvasObjects.find((o) => o.clipId === clip.id);
431
+ const baseObj = origObj ?? { x: 0, y: 0, w: 1, h: 1, rotation: 0 };
432
+ const leftObj = { ...baseObj, id: `obj-${leftId}`, clipId: leftId };
433
+ const rightObj = { ...baseObj, id: `obj-${rightId}`, clipId: rightId };
434
+ set((s) => ({
435
+ timelineClips: [
436
+ ...s.timelineClips.slice(0, clipIndex),
437
+ leftClip,
438
+ rightClip,
439
+ ...s.timelineClips.slice(clipIndex + 1),
440
+ ],
441
+ // Replace original canvas object with left + right copies
442
+ canvasObjects: [
443
+ ...s.canvasObjects.filter((o) => o.clipId !== clip.id),
444
+ leftObj,
445
+ rightObj,
446
+ ],
447
+ selectedClipId: leftId,
448
+ redoStack: [],
449
+ }));
450
+ },
451
+ duplicateClip: (id) => {
452
+ const state = get();
453
+ const clip = state.timelineClips.find((c) => c.id === id);
454
+ if (!clip)
455
+ return;
456
+ state.pushUndo();
457
+ const newId = `clip-${Date.now()}`;
458
+ const newStart = clip.startMs + clip.durationMs; // place right after original
459
+ const newClip = {
460
+ ...clip,
461
+ id: newId,
462
+ startMs: newStart,
463
+ };
464
+ // Also duplicate canvas object if it's a visual clip
465
+ const canvasObj = state.canvasObjects.find((o) => o.clipId === id);
466
+ const newObjs = canvasObj
467
+ ? [...state.canvasObjects, { ...canvasObj, id: `obj-${Date.now()}`, clipId: newId }]
468
+ : state.canvasObjects;
469
+ set({
470
+ timelineClips: [...state.timelineClips, newClip],
471
+ canvasObjects: newObjs,
472
+ selectedClipId: newId,
473
+ });
474
+ },
475
+ deleteSelectedClip: () => {
476
+ const { selectedClipId, pushUndo } = get();
477
+ if (!selectedClipId)
478
+ return;
479
+ pushUndo();
480
+ set((s) => {
481
+ // If we're deleting a detached audio clip, keep the parent video muted
482
+ // (isAudioDetached stays true so the video doesn't re-play its audio)
483
+ const updatedClips = s.timelineClips.filter((c) => c.id !== selectedClipId);
484
+ return {
485
+ timelineClips: updatedClips,
486
+ canvasObjects: s.canvasObjects.filter((o) => o.clipId !== selectedClipId),
487
+ selectedClipId: null,
488
+ selectedObjectId: null,
489
+ redoStack: [],
490
+ };
491
+ });
492
+ // Clean up empty tracks after deletion
493
+ get().removeEmptyTracks();
494
+ },
495
+ deleteClipById: (id) => {
496
+ set((s) => ({
497
+ timelineClips: s.timelineClips.filter((c) => c.id !== id),
498
+ canvasObjects: s.canvasObjects.filter((o) => o.clipId !== id),
499
+ selectedClipId: s.selectedClipId === id ? null : s.selectedClipId,
500
+ selectedObjectId: s.selectedObjectId && s.canvasObjects.find((o) => o.id === s.selectedObjectId)?.clipId === id ? null : s.selectedObjectId,
501
+ redoStack: [],
502
+ }));
503
+ get().removeEmptyTracks();
504
+ },
505
+ setSelectedClipId: (id) => {
506
+ const objId = id ? get().canvasObjects.find((o) => o.clipId === id)?.id ?? null : null;
507
+ set({ selectedClipId: id, selectedObjectId: objId, selectedTextId: null });
508
+ },
509
+ // ── Color Clips ──
510
+ addColorClip: (color, durationMs) => {
511
+ const state = get();
512
+ state.pushUndo();
513
+ const clipDuration = Math.min(durationMs, MAX_TIMELINE_MS);
514
+ // Find a visual track with space
515
+ let targetTrackId = null;
516
+ let startMs = null;
517
+ const visualTracks = state.tracks.filter((t) => t.type === 'visual');
518
+ for (const track of visualTracks) {
519
+ const gap = findFirstGap(state.timelineClips, track.id, clipDuration, MAX_TIMELINE_MS);
520
+ if (gap !== null) {
521
+ targetTrackId = track.id;
522
+ startMs = gap;
523
+ break;
524
+ }
525
+ }
526
+ if (targetTrackId === null) {
527
+ const newTrack = get().addTrack('visual');
528
+ targetTrackId = newTrack.id;
529
+ startMs = findFirstGap(get().timelineClips, targetTrackId, clipDuration, MAX_TIMELINE_MS);
530
+ }
531
+ if (startMs === null || targetTrackId === null)
532
+ return;
533
+ const clipId = `clip-color-${Date.now()}`;
534
+ const clip = {
535
+ id: clipId,
536
+ assetId: `color-${color.replace('#', '')}`,
537
+ name: `Color ${color}`,
538
+ url: '',
539
+ startMs,
540
+ durationMs: clipDuration,
541
+ originalDurationMs: clipDuration,
542
+ sourceOffsetMs: 0,
543
+ mediaType: 'color',
544
+ trackId: targetTrackId,
545
+ color,
546
+ };
547
+ // Color clips fill the entire canvas
548
+ const obj = {
549
+ id: `obj-${clipId}`,
550
+ clipId,
551
+ x: 0, y: 0, w: 1, h: 1,
552
+ rotation: 0,
553
+ };
554
+ set((s) => ({
555
+ timelineClips: [...s.timelineClips, clip],
556
+ canvasObjects: [...s.canvasObjects, obj],
557
+ selectedClipId: clipId,
558
+ redoStack: [],
559
+ }));
560
+ },
561
+ // ── Transitions ──
562
+ setClipTransition: (clipId, edge, config) => {
563
+ const state = get();
564
+ state.pushUndo();
565
+ set((s) => ({
566
+ timelineClips: s.timelineClips.map((c) => c.id === clipId
567
+ ? { ...c, [edge === 'in' ? 'transitionIn' : 'transitionOut']: config }
568
+ : c),
569
+ redoStack: [],
570
+ }));
571
+ },
572
+ // ── Audio ──
573
+ setClipVolume: (clipId, volume) => {
574
+ const state = get();
575
+ state.pushUndo();
576
+ set((s) => ({
577
+ timelineClips: s.timelineClips.map((c) => c.id === clipId ? { ...c, volume: Math.max(0, Math.min(1, volume)) } : c),
578
+ redoStack: [],
579
+ }));
580
+ },
581
+ detachAudio: (clipId) => {
582
+ const state = get();
583
+ const clip = state.timelineClips.find((c) => c.id === clipId);
584
+ if (!clip || clip.mediaType !== 'video' || clip.isAudioDetached)
585
+ return;
586
+ state.pushUndo();
587
+ // Ensure an audio track exists for this video's track
588
+ const audioTrackId = clip.trackId.replace('V', 'A');
589
+ state.ensureAudioTrack(clip.trackId);
590
+ const audioClipId = `audio-detach-${Date.now()}`;
591
+ const audioClip = {
592
+ id: audioClipId,
593
+ assetId: clip.assetId,
594
+ name: `${clip.name} (Audio)`,
595
+ url: clip.url,
596
+ startMs: clip.startMs,
597
+ durationMs: clip.durationMs,
598
+ originalDurationMs: clip.originalDurationMs,
599
+ sourceOffsetMs: clip.sourceOffsetMs,
600
+ mediaType: 'audio',
601
+ trackId: audioTrackId,
602
+ };
603
+ set((s) => ({
604
+ timelineClips: [
605
+ ...s.timelineClips.map((c) => c.id === clipId ? { ...c, isAudioDetached: true } : c),
606
+ audioClip,
607
+ ],
608
+ redoStack: [],
609
+ }));
610
+ },
611
+ // ── Clip Properties ──
612
+ setClipOpacity: (clipId, opacity) => {
613
+ const state = get();
614
+ state.pushUndo();
615
+ set((s) => ({
616
+ timelineClips: s.timelineClips.map((c) => c.id === clipId ? { ...c, opacity: Math.max(0, Math.min(1, opacity)) } : c),
617
+ redoStack: [],
618
+ }));
619
+ },
620
+ setClipSpeed: (clipId, rate) => {
621
+ const state = get();
622
+ const clip = state.timelineClips.find((c) => c.id === clipId);
623
+ if (!clip)
624
+ return;
625
+ const clampedRate = Math.max(0.25, Math.min(4, rate));
626
+ const prevRate = clip.playbackRate ?? 1;
627
+ // Adjust visual duration inversely with speed: a 10s clip at 2x shows as 5s
628
+ const sourceDuration = clip.durationMs * prevRate; // recover source duration
629
+ const newDuration = sourceDuration / clampedRate;
630
+ state.pushUndo();
631
+ set((s) => ({
632
+ timelineClips: s.timelineClips.map((c) => c.id === clipId
633
+ ? { ...c, playbackRate: clampedRate, durationMs: newDuration }
634
+ : c),
635
+ redoStack: [],
636
+ }));
637
+ },
638
+ setClipAnimation: (clipId, phase, config) => {
639
+ set((s) => ({
640
+ timelineClips: s.timelineClips.map((c) => c.id === clipId
641
+ ? { ...c, [phase === 'in' ? 'animationIn' : 'animationOut']: config }
642
+ : c),
643
+ }));
644
+ },
645
+ // ── Tracks ──
646
+ addTrack: (type) => {
647
+ const state = get();
648
+ let track;
649
+ if (type === 'visual') {
650
+ const num = state.nextVisualNum;
651
+ track = { id: `V${num}`, label: `V${num}`, type: 'visual', color: VISUAL_COLORS[(num - 1) % VISUAL_COLORS.length] };
652
+ set({ tracks: [...state.tracks, track], nextVisualNum: num + 1 });
653
+ }
654
+ else {
655
+ const num = state.nextAudioNum;
656
+ track = { id: `A${num}`, label: `A${num}`, type: 'audio', color: AUDIO_COLORS[(num - 1) % AUDIO_COLORS.length] };
657
+ set({ tracks: [...state.tracks, track], nextAudioNum: num + 1 });
658
+ }
659
+ return track;
660
+ },
661
+ removeEmptyTracks: () => {
662
+ const state = get();
663
+ const visualTracks = state.tracks.filter((t) => t.type === 'visual');
664
+ const audioTracks = state.tracks.filter((t) => t.type === 'audio');
665
+ const kept = [];
666
+ // Keep visual tracks that have clips, or the last one (always keep at least 1 visual)
667
+ const visualWithClips = visualTracks.filter((t) => state.timelineClips.some((c) => c.trackId === t.id));
668
+ if (visualWithClips.length > 0) {
669
+ kept.push(...visualWithClips);
670
+ }
671
+ else {
672
+ // Keep first visual track even if empty
673
+ kept.push(visualTracks[0]);
674
+ }
675
+ // Keep audio tracks that have direct audio clips OR have detached audio from a kept visual track
676
+ for (const at of audioTracks) {
677
+ const hasDirectClips = state.timelineClips.some((c) => c.trackId === at.id);
678
+ // Check if a corresponding visual track has video clips with detached audio
679
+ const vId = at.id.replace('A', 'V');
680
+ const hasDetachedAudio = kept.some((t) => t.id === vId) &&
681
+ state.timelineClips.some((c) => c.trackId === vId && c.mediaType === 'video' && c.isAudioDetached);
682
+ if (hasDirectClips || hasDetachedAudio) {
683
+ kept.push(at);
684
+ }
685
+ }
686
+ if (kept.length !== state.tracks.length) {
687
+ set({ tracks: kept });
688
+ }
689
+ },
690
+ ensureAudioTrack: (visualTrackId) => {
691
+ const state = get();
692
+ const audioId = visualTrackId.replace('V', 'A');
693
+ const exists = state.tracks.some((t) => t.id === audioId);
694
+ if (!exists) {
695
+ const num = parseInt(visualTrackId.replace('V', ''), 10);
696
+ const track = {
697
+ id: audioId,
698
+ label: audioId,
699
+ type: 'audio',
700
+ color: AUDIO_COLORS[(num - 1) % AUDIO_COLORS.length],
701
+ };
702
+ // Ensure nextAudioNum stays ahead
703
+ const nextNum = Math.max(state.nextAudioNum, num + 1);
704
+ set({ tracks: [...state.tracks, track], nextAudioNum: nextNum });
705
+ }
706
+ },
707
+ // ── Text Overlays ──
708
+ addTextOverlay: (overlay) => {
709
+ get().pushUndo();
710
+ set((s) => ({ textOverlays: [...s.textOverlays, overlay], selectedTextId: overlay.id, redoStack: [] }));
711
+ },
712
+ updateTextOverlay: (id, patch) => {
713
+ set((s) => ({
714
+ textOverlays: s.textOverlays.map((t) => t.id === id ? { ...t, ...patch } : t),
715
+ }));
716
+ },
717
+ removeTextOverlay: (id) => {
718
+ get().pushUndo();
719
+ set((s) => ({
720
+ textOverlays: s.textOverlays.filter((t) => t.id !== id),
721
+ selectedTextId: s.selectedTextId === id ? null : s.selectedTextId,
722
+ redoStack: [],
723
+ }));
724
+ // Nudge playhead by 0ms to force canvas redraw (clears ghost of deleted overlay)
725
+ const ms = get().playheadMs;
726
+ set({ playheadMs: ms + 0.001 });
727
+ (deps.nextFrame ?? ((cb) => cb()))(() => set({ playheadMs: ms }));
728
+ },
729
+ setSelectedTextId: (id) => set({ selectedTextId: id, selectedObjectId: null, selectedClipId: null }),
730
+ // ── Canvas ──
731
+ setCanvasPreset: (preset) => {
732
+ const state = get();
733
+ state.pushUndo();
734
+ // Reset all canvas objects to full-canvas bounding box.
735
+ // Since we use drawCover (object-fit: cover), the media will fill
736
+ // the box and crop overflow — no letterboxing.
737
+ const updatedObjects = state.canvasObjects.map((obj) => ({
738
+ ...obj, x: 0, y: 0, w: 1, h: 1,
739
+ }));
740
+ // Text overlays use normalized x/y (0-1) which auto-adapt.
741
+ // fontSize is in preview pixels — stays the same since preview container doesn't change.
742
+ // If the timeline has clips, offer AI reframe
743
+ const shouldOfferReframe = state.timelineClips.length > 0
744
+ && (preset.width !== state.canvasPreset.width || preset.height !== state.canvasPreset.height);
745
+ set({
746
+ canvasPreset: preset,
747
+ canvasObjects: updatedObjects,
748
+ redoStack: [],
749
+ pendingReframe: shouldOfferReframe ? preset : null,
750
+ });
751
+ },
752
+ updateCanvasObject: (id, patch) => {
753
+ set((s) => ({
754
+ canvasObjects: s.canvasObjects.map((o) => o.id === id ? { ...o, ...patch } : o),
755
+ }));
756
+ },
757
+ setSelectedObjectId: (id) => {
758
+ const clipId = id ? get().canvasObjects.find((o) => o.id === id)?.clipId ?? null : null;
759
+ set({ selectedObjectId: id, selectedClipId: clipId, selectedTextId: null });
760
+ },
761
+ setSnapGuides: (guides) => set({ snapGuides: guides }),
762
+ // ── Cinematic Look ──
763
+ setCinematicLook: (look) => set({ cinematicLook: look }),
764
+ // ── AI Reframe ──
765
+ dismissReframe: () => set({ pendingReframe: null }),
766
+ // ── Mask Editor ──
767
+ openMaskEditor: (clipId) => set({ maskEditorClipId: clipId }),
768
+ closeMaskEditor: () => set({ maskEditorClipId: null }),
769
+ // ── Zoom ──
770
+ setPxPerSec: (pxPerSec) => set((s) => ({
771
+ pxPerSec: clamp(pxPerSec, ZOOM_MIN_PX_PER_SEC, maxPxPerSecForDuration(timelineContentMs(s.timelineClips))),
772
+ })),
773
+ zoomIn: () => set((s) => ({
774
+ pxPerSec: clamp(Math.round(s.pxPerSec * 1.5), ZOOM_MIN_PX_PER_SEC, maxPxPerSecForDuration(timelineContentMs(s.timelineClips))),
775
+ })),
776
+ zoomOut: () => set((s) => ({
777
+ pxPerSec: clamp(Math.round(s.pxPerSec / 1.5), ZOOM_MIN_PX_PER_SEC, maxPxPerSecForDuration(timelineContentMs(s.timelineClips))),
778
+ })),
779
+ // ── Undo/Redo ──
780
+ pushUndo: () => {
781
+ const { timelineClips, canvasObjects, tracks, textOverlays } = get();
782
+ set((s) => ({
783
+ undoStack: [...s.undoStack.slice(-49), { timelineClips, canvasObjects, tracks, textOverlays }],
784
+ }));
785
+ },
786
+ undo: () => {
787
+ const { undoStack, timelineClips, canvasObjects, tracks, textOverlays } = get();
788
+ if (undoStack.length === 0)
789
+ return;
790
+ const prev = undoStack[undoStack.length - 1];
791
+ set({
792
+ undoStack: undoStack.slice(0, -1),
793
+ redoStack: [...get().redoStack, { timelineClips, canvasObjects, tracks, textOverlays }],
794
+ timelineClips: prev.timelineClips,
795
+ canvasObjects: prev.canvasObjects,
796
+ tracks: prev.tracks,
797
+ textOverlays: prev.textOverlays,
798
+ });
799
+ },
800
+ redo: () => {
801
+ const { redoStack, timelineClips, canvasObjects, tracks, textOverlays } = get();
802
+ if (redoStack.length === 0)
803
+ return;
804
+ const next = redoStack[redoStack.length - 1];
805
+ set({
806
+ redoStack: redoStack.slice(0, -1),
807
+ undoStack: [...get().undoStack, { timelineClips, canvasObjects, tracks, textOverlays }],
808
+ timelineClips: next.timelineClips,
809
+ canvasObjects: next.canvasObjects,
810
+ tracks: next.tracks,
811
+ textOverlays: next.textOverlays,
812
+ });
813
+ },
814
+ // ── Hydrate (AI Video Generator) ──
815
+ hydrateTimeline: (data) => {
816
+ // Push undo so the entire AI generation can be undone with Ctrl+Z
817
+ const { timelineClips, canvasObjects, tracks, textOverlays } = get();
818
+ set((s) => ({
819
+ undoStack: [...s.undoStack.slice(-49), { timelineClips, canvasObjects, tracks, textOverlays }],
820
+ }));
821
+ // Apply cinematic look if provided by the AI
822
+ if (data.cinematicLook) {
823
+ set({ cinematicLook: data.cinematicLook });
824
+ }
825
+ // Compute next track numbering from the incoming tracks
826
+ const vNums = data.tracks.filter((t) => t.type === 'visual').map((t) => parseInt(t.id.replace('V', ''), 10)).filter((n) => !isNaN(n));
827
+ const aNums = data.tracks.filter((t) => t.type === 'audio').map((t) => parseInt(t.id.replace('A', ''), 10)).filter((n) => !isNaN(n));
828
+ set({
829
+ tracks: data.tracks,
830
+ timelineClips: data.timelineClips,
831
+ canvasObjects: data.canvasObjects,
832
+ textOverlays: data.textOverlays,
833
+ selectedClipId: null,
834
+ selectedObjectId: null,
835
+ selectedTextId: null,
836
+ redoStack: [],
837
+ nextVisualNum: (vNums.length > 0 ? Math.max(...vNums) : 0) + 1,
838
+ nextAudioNum: (aNums.length > 0 ? Math.max(...aNums) : 0) + 1,
839
+ });
840
+ },
841
+ // ── Reset ──
842
+ reset: () => set({
843
+ projectName: '',
844
+ playheadMs: 0,
845
+ isPlaying: false,
846
+ assetLibrary: [],
847
+ selectedAssetIds: new Set(),
848
+ timelineClips: [],
849
+ selectedClipId: null,
850
+ tracks: [...INITIAL_TRACKS],
851
+ nextVisualNum: 2,
852
+ nextAudioNum: 1,
853
+ canvasObjects: [],
854
+ selectedObjectId: null,
855
+ snapGuides: [],
856
+ textOverlays: [],
857
+ selectedTextId: null,
858
+ cinematicLook: 'none',
859
+ pendingReframe: null,
860
+ maskEditorClipId: null,
861
+ pxPerSec: ZOOM_DEFAULT_PX_PER_SEC,
862
+ undoStack: [],
863
+ redoStack: [],
864
+ }),
865
+ }));
866
+ }