@webpacked-timeline/core 1.0.0-beta.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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/chunk-27XCNVPR.js +5969 -0
- package/dist/chunk-6PDBJDHM.js +2263 -0
- package/dist/chunk-BWPS6NQT.js +7465 -0
- package/dist/chunk-FBOYSUYV.js +1280 -0
- package/dist/chunk-FR632TZX.js +1870 -0
- package/dist/chunk-HW4Z7YLJ.js +1242 -0
- package/dist/chunk-HWW62IFH.js +5424 -0
- package/dist/chunk-I2GZXRH4.js +4790 -0
- package/dist/chunk-JQZE3OK4.js +1255 -0
- package/dist/chunk-KF7JNK2F.js +1864 -0
- package/dist/chunk-KR3P2DYK.js +5655 -0
- package/dist/chunk-MO5DSFSW.js +2214 -0
- package/dist/chunk-MQAW33RJ.js +5530 -0
- package/dist/chunk-N4WUWZZX.js +2833 -0
- package/dist/chunk-NRJV7I4C.js +1331 -0
- package/dist/chunk-NXG52532.js +2230 -0
- package/dist/chunk-PVXF67CN.js +1278 -0
- package/dist/chunk-QSB6DHIF.js +5429 -0
- package/dist/chunk-QYWJT7HR.js +5837 -0
- package/dist/chunk-SWBRCMW7.js +7466 -0
- package/dist/chunk-TAT3ULSV.js +2214 -0
- package/dist/chunk-TTDP5JUM.js +2228 -0
- package/dist/chunk-UAGP4VPG.js +1739 -0
- package/dist/chunk-WIG6SY7A.js +1183 -0
- package/dist/chunk-YJ2K5N2R.js +6187 -0
- package/dist/index-3Lr_vKBd.d.cts +2810 -0
- package/dist/index-3Lr_vKBd.d.ts +2810 -0
- package/dist/index-7IPJn1yM.d.cts +1146 -0
- package/dist/index-7IPJn1yM.d.ts +1146 -0
- package/dist/index-B0xOv0V0.d.cts +3259 -0
- package/dist/index-B0xOv0V0.d.ts +3259 -0
- package/dist/index-B2m3zwg7.d.cts +1381 -0
- package/dist/index-B2m3zwg7.d.ts +1381 -0
- package/dist/index-B3sUrU_X.d.cts +1249 -0
- package/dist/index-B3sUrU_X.d.ts +1249 -0
- package/dist/index-B6wla7ZJ.d.cts +2751 -0
- package/dist/index-B6wla7ZJ.d.ts +2751 -0
- package/dist/index-BIv8RWWT.d.cts +1574 -0
- package/dist/index-BIv8RWWT.d.ts +1574 -0
- package/dist/index-BJv6hDHL.d.cts +3255 -0
- package/dist/index-BJv6hDHL.d.ts +3255 -0
- package/dist/index-BUCimS2e.d.cts +1393 -0
- package/dist/index-BUCimS2e.d.ts +1393 -0
- package/dist/index-Bw_nvNcG.d.cts +1275 -0
- package/dist/index-Bw_nvNcG.d.ts +1275 -0
- package/dist/index-ByG0gOtd.d.cts +1167 -0
- package/dist/index-ByG0gOtd.d.ts +1167 -0
- package/dist/index-CDGd2XXv.d.cts +2492 -0
- package/dist/index-CDGd2XXv.d.ts +2492 -0
- package/dist/index-CznAVeJ6.d.cts +1145 -0
- package/dist/index-CznAVeJ6.d.ts +1145 -0
- package/dist/index-DQD9IMh7.d.cts +2534 -0
- package/dist/index-DQD9IMh7.d.ts +2534 -0
- package/dist/index-Dl3qtJEI.d.cts +2178 -0
- package/dist/index-Dl3qtJEI.d.ts +2178 -0
- package/dist/index-DnE2A-Nz.d.cts +2603 -0
- package/dist/index-DnE2A-Nz.d.ts +2603 -0
- package/dist/index-DrOA6QmW.d.cts +2492 -0
- package/dist/index-DrOA6QmW.d.ts +2492 -0
- package/dist/index-Vpa3rPEM.d.cts +1402 -0
- package/dist/index-Vpa3rPEM.d.ts +1402 -0
- package/dist/index-jP6BomSd.d.cts +2640 -0
- package/dist/index-jP6BomSd.d.ts +2640 -0
- package/dist/index-wiGRwVyY.d.cts +3259 -0
- package/dist/index-wiGRwVyY.d.ts +3259 -0
- package/dist/index.cjs +7386 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +263 -0
- package/dist/internal.cjs +7721 -0
- package/dist/internal.d.cts +704 -0
- package/dist/internal.d.ts +704 -0
- package/dist/internal.js +405 -0
- package/package.json +58 -0
|
@@ -0,0 +1,4790 @@
|
|
|
1
|
+
// src/types/timeline.ts
|
|
2
|
+
var DEFAULT_SEQUENCE_SETTINGS = {
|
|
3
|
+
pixelAspectRatio: 1,
|
|
4
|
+
fieldOrder: "progressive",
|
|
5
|
+
colorSpace: "sRGB",
|
|
6
|
+
audioSampleRate: 48e3,
|
|
7
|
+
audioChannelCount: 2
|
|
8
|
+
};
|
|
9
|
+
function createTimeline(params) {
|
|
10
|
+
return {
|
|
11
|
+
id: params.id,
|
|
12
|
+
name: params.name,
|
|
13
|
+
fps: params.fps,
|
|
14
|
+
duration: params.duration,
|
|
15
|
+
startTimecode: params.startTimecode ?? "00:00:00:00",
|
|
16
|
+
tracks: params.tracks ?? [],
|
|
17
|
+
sequenceSettings: { ...DEFAULT_SEQUENCE_SETTINGS, ...params.sequenceSettings },
|
|
18
|
+
version: 0,
|
|
19
|
+
markers: params.markers ?? [],
|
|
20
|
+
beatGrid: params.beatGrid ?? null,
|
|
21
|
+
inPoint: params.inPoint ?? null,
|
|
22
|
+
outPoint: params.outPoint ?? null,
|
|
23
|
+
...params.trackGroups !== void 0 && { trackGroups: params.trackGroups },
|
|
24
|
+
...params.linkGroups !== void 0 && { linkGroups: params.linkGroups }
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/types/track.ts
|
|
29
|
+
var toTrackId = (s) => s;
|
|
30
|
+
function createTrack(params) {
|
|
31
|
+
return {
|
|
32
|
+
id: params.id,
|
|
33
|
+
name: params.name,
|
|
34
|
+
type: params.type,
|
|
35
|
+
clips: params.clips ?? [],
|
|
36
|
+
captions: params.captions ?? [],
|
|
37
|
+
locked: params.locked ?? false,
|
|
38
|
+
muted: params.muted ?? false,
|
|
39
|
+
solo: params.solo ?? false,
|
|
40
|
+
height: params.height ?? 56,
|
|
41
|
+
...params.blendMode !== void 0 && { blendMode: params.blendMode },
|
|
42
|
+
...params.opacity !== void 0 && { opacity: params.opacity },
|
|
43
|
+
...params.groupId !== void 0 && { groupId: params.groupId }
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function sortTrackClips(track) {
|
|
47
|
+
return {
|
|
48
|
+
...track,
|
|
49
|
+
clips: [...track.clips].sort((a, b) => a.timelineStart - b.timelineStart)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/types/clip.ts
|
|
54
|
+
var toClipId = (s) => s;
|
|
55
|
+
function createClip(params) {
|
|
56
|
+
return {
|
|
57
|
+
id: params.id,
|
|
58
|
+
assetId: params.assetId,
|
|
59
|
+
trackId: params.trackId,
|
|
60
|
+
timelineStart: params.timelineStart,
|
|
61
|
+
timelineEnd: params.timelineEnd,
|
|
62
|
+
mediaIn: params.mediaIn,
|
|
63
|
+
mediaOut: params.mediaOut,
|
|
64
|
+
speed: params.speed ?? 1,
|
|
65
|
+
enabled: params.enabled ?? true,
|
|
66
|
+
reversed: params.reversed ?? false,
|
|
67
|
+
name: params.name ?? null,
|
|
68
|
+
color: params.color ?? null,
|
|
69
|
+
metadata: params.metadata ?? {},
|
|
70
|
+
...params.effects !== void 0 && { effects: params.effects },
|
|
71
|
+
...params.transform !== void 0 && { transform: params.transform },
|
|
72
|
+
...params.audio !== void 0 && { audio: params.audio },
|
|
73
|
+
...params.transition !== void 0 && { transition: params.transition }
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function getClipDuration(clip) {
|
|
77
|
+
return clip.timelineEnd - clip.timelineStart;
|
|
78
|
+
}
|
|
79
|
+
function getClipMediaDuration(clip) {
|
|
80
|
+
return clip.mediaOut - clip.mediaIn;
|
|
81
|
+
}
|
|
82
|
+
function clipContainsFrame(clip, f) {
|
|
83
|
+
return f >= clip.timelineStart && f < clip.timelineEnd;
|
|
84
|
+
}
|
|
85
|
+
function clipsOverlap(a, b) {
|
|
86
|
+
return a.timelineStart < b.timelineEnd && b.timelineStart < a.timelineEnd;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/types/asset.ts
|
|
90
|
+
var toAssetId = (s) => s;
|
|
91
|
+
function createAsset(params) {
|
|
92
|
+
return {
|
|
93
|
+
kind: "file",
|
|
94
|
+
id: params.id,
|
|
95
|
+
name: params.name,
|
|
96
|
+
mediaType: params.mediaType,
|
|
97
|
+
filePath: params.filePath,
|
|
98
|
+
intrinsicDuration: params.intrinsicDuration,
|
|
99
|
+
nativeFps: params.nativeFps,
|
|
100
|
+
sourceTimecodeOffset: params.sourceTimecodeOffset,
|
|
101
|
+
status: params.status ?? "online"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function createGeneratorAsset(params) {
|
|
105
|
+
return {
|
|
106
|
+
kind: "generator",
|
|
107
|
+
id: params.id,
|
|
108
|
+
name: params.name,
|
|
109
|
+
mediaType: params.mediaType,
|
|
110
|
+
intrinsicDuration: params.generatorDef.duration,
|
|
111
|
+
nativeFps: params.nativeFps,
|
|
112
|
+
sourceTimecodeOffset: 0,
|
|
113
|
+
status: params.status ?? "online",
|
|
114
|
+
generatorDef: params.generatorDef
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/types/state.ts
|
|
119
|
+
var CURRENT_SCHEMA_VERSION = 2;
|
|
120
|
+
function createTimelineState(params) {
|
|
121
|
+
const registry = params.assetRegistry ?? (params.assets ? params.assets : /* @__PURE__ */ new Map());
|
|
122
|
+
return {
|
|
123
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
124
|
+
timeline: params.timeline,
|
|
125
|
+
assetRegistry: registry
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/types/frame.ts
|
|
130
|
+
var toFrame = (n) => n;
|
|
131
|
+
function frame(value) {
|
|
132
|
+
const rounded = Math.round(value);
|
|
133
|
+
if (rounded < 0) {
|
|
134
|
+
throw new Error(`TimelineFrame must be non-negative, got: ${value}`);
|
|
135
|
+
}
|
|
136
|
+
return rounded;
|
|
137
|
+
}
|
|
138
|
+
var FrameRates = {
|
|
139
|
+
CINEMA: 24,
|
|
140
|
+
PAL: 25,
|
|
141
|
+
NTSC_DF: 29.97,
|
|
142
|
+
NTSC: 30,
|
|
143
|
+
PAL_HFR: 50,
|
|
144
|
+
NTSC_HFR: 59.94,
|
|
145
|
+
HFR: 60
|
|
146
|
+
};
|
|
147
|
+
function frameRate(value) {
|
|
148
|
+
const valid = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
|
|
149
|
+
if (!valid.includes(value)) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`FrameRate must be one of ${valid.join(", ")}, got: ${value}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
var toTimecode = (s) => s;
|
|
157
|
+
function isValidFrame(value) {
|
|
158
|
+
return Number.isInteger(value) && value >= 0;
|
|
159
|
+
}
|
|
160
|
+
function isDropFrame(fps) {
|
|
161
|
+
return fps === 29.97 || fps === 59.94;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/utils/frame.ts
|
|
165
|
+
function framesToSeconds(frames, fps) {
|
|
166
|
+
return frames / fps;
|
|
167
|
+
}
|
|
168
|
+
function secondsToFrames(seconds, fps) {
|
|
169
|
+
return toFrame(seconds * fps);
|
|
170
|
+
}
|
|
171
|
+
function framesToTimecode(frames, fps) {
|
|
172
|
+
const totalFrames = frames;
|
|
173
|
+
const framesPart = totalFrames % fps;
|
|
174
|
+
const totalSeconds = Math.floor(totalFrames / fps);
|
|
175
|
+
const secondsPart = totalSeconds % 60;
|
|
176
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
177
|
+
const minutesPart = totalMinutes % 60;
|
|
178
|
+
const hoursPart = Math.floor(totalMinutes / 60);
|
|
179
|
+
return `${pad(hoursPart)}:${pad(minutesPart)}:${pad(secondsPart)}:${pad(framesPart)}`;
|
|
180
|
+
}
|
|
181
|
+
function framesToMinutesSeconds(frames, fps) {
|
|
182
|
+
const totalSeconds = Math.floor(frames / fps);
|
|
183
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
184
|
+
const seconds = totalSeconds % 60;
|
|
185
|
+
return `${minutes}:${pad(seconds)}`;
|
|
186
|
+
}
|
|
187
|
+
function clampFrame(value, min, max) {
|
|
188
|
+
return toFrame(Math.max(min, Math.min(max, value)));
|
|
189
|
+
}
|
|
190
|
+
function addFrames(a, b) {
|
|
191
|
+
return toFrame(a + b);
|
|
192
|
+
}
|
|
193
|
+
function subtractFrames(a, b) {
|
|
194
|
+
return toFrame(Math.max(0, a - b));
|
|
195
|
+
}
|
|
196
|
+
function frameDuration(start, end) {
|
|
197
|
+
return toFrame(end - start);
|
|
198
|
+
}
|
|
199
|
+
function pad(num, width = 2) {
|
|
200
|
+
return num.toString().padStart(width, "0");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/engine/history.ts
|
|
204
|
+
function createHistory(initialState, limit = 50) {
|
|
205
|
+
return {
|
|
206
|
+
past: [],
|
|
207
|
+
present: initialState,
|
|
208
|
+
future: [],
|
|
209
|
+
limit
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
function pushHistory(history, newState) {
|
|
213
|
+
const newPast = [...history.past, history.present];
|
|
214
|
+
if (newPast.length > history.limit) {
|
|
215
|
+
newPast.shift();
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
...history,
|
|
219
|
+
past: newPast,
|
|
220
|
+
present: newState,
|
|
221
|
+
future: []
|
|
222
|
+
// Clear future on new action
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function undo(history) {
|
|
226
|
+
if (history.past.length === 0) {
|
|
227
|
+
return history;
|
|
228
|
+
}
|
|
229
|
+
const newPast = [...history.past];
|
|
230
|
+
const previous = newPast.pop();
|
|
231
|
+
return {
|
|
232
|
+
...history,
|
|
233
|
+
past: newPast,
|
|
234
|
+
present: previous,
|
|
235
|
+
future: [history.present, ...history.future]
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function redo(history) {
|
|
239
|
+
if (history.future.length === 0) {
|
|
240
|
+
return history;
|
|
241
|
+
}
|
|
242
|
+
const newFuture = [...history.future];
|
|
243
|
+
const next = newFuture.shift();
|
|
244
|
+
return {
|
|
245
|
+
...history,
|
|
246
|
+
past: [...history.past, history.present],
|
|
247
|
+
present: next,
|
|
248
|
+
future: newFuture
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function canUndo(history) {
|
|
252
|
+
return history.past.length > 0;
|
|
253
|
+
}
|
|
254
|
+
function canRedo(history) {
|
|
255
|
+
return history.future.length > 0;
|
|
256
|
+
}
|
|
257
|
+
function getCurrentState(history) {
|
|
258
|
+
return history.present;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/systems/queries.ts
|
|
262
|
+
function findClipById(state, clipId) {
|
|
263
|
+
for (const track of state.timeline.tracks) {
|
|
264
|
+
const clip = track.clips.find((c) => c.id === clipId);
|
|
265
|
+
if (clip) {
|
|
266
|
+
return clip;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return void 0;
|
|
270
|
+
}
|
|
271
|
+
function findTrackById(state, trackId) {
|
|
272
|
+
return state.timeline.tracks.find((t) => t.id === trackId);
|
|
273
|
+
}
|
|
274
|
+
function getClipsOnTrack(state, trackId) {
|
|
275
|
+
const track = findTrackById(state, trackId);
|
|
276
|
+
return track ? Array.from(track.clips) : [];
|
|
277
|
+
}
|
|
278
|
+
function getClipsAtFrame(state, frame2) {
|
|
279
|
+
const clips = [];
|
|
280
|
+
for (const track of state.timeline.tracks) {
|
|
281
|
+
for (const clip of track.clips) {
|
|
282
|
+
if (clipContainsFrame(clip, frame2)) {
|
|
283
|
+
clips.push(clip);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return clips;
|
|
288
|
+
}
|
|
289
|
+
function getClipsInRange(state, start, end) {
|
|
290
|
+
const clips = [];
|
|
291
|
+
for (const track of state.timeline.tracks) {
|
|
292
|
+
for (const clip of track.clips) {
|
|
293
|
+
if (clip.timelineStart < end && clip.timelineEnd > start) {
|
|
294
|
+
clips.push(clip);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return clips;
|
|
299
|
+
}
|
|
300
|
+
function getAllClips(state) {
|
|
301
|
+
const clips = [];
|
|
302
|
+
for (const track of state.timeline.tracks) {
|
|
303
|
+
clips.push(...track.clips);
|
|
304
|
+
}
|
|
305
|
+
return clips;
|
|
306
|
+
}
|
|
307
|
+
function getAllTracks(state) {
|
|
308
|
+
return state.timeline.tracks;
|
|
309
|
+
}
|
|
310
|
+
function findTrackIndex(state, trackId) {
|
|
311
|
+
return state.timeline.tracks.findIndex((t) => t.id === trackId);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/types/validation.ts
|
|
315
|
+
function validResult() {
|
|
316
|
+
return {
|
|
317
|
+
valid: true,
|
|
318
|
+
errors: []
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function invalidResult(code, message, context) {
|
|
322
|
+
const error = { code, message };
|
|
323
|
+
if (context !== void 0) {
|
|
324
|
+
error.context = context;
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
valid: false,
|
|
328
|
+
errors: [error]
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
function invalidResults(errors) {
|
|
332
|
+
return {
|
|
333
|
+
valid: false,
|
|
334
|
+
errors
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function combineResults(...results) {
|
|
338
|
+
const allErrors = [];
|
|
339
|
+
for (const result of results) {
|
|
340
|
+
if (!result.valid) {
|
|
341
|
+
allErrors.push(...result.errors);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (allErrors.length > 0) {
|
|
345
|
+
return invalidResults(allErrors);
|
|
346
|
+
}
|
|
347
|
+
return validResult();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/systems/asset-registry.ts
|
|
351
|
+
function registerAsset(state, asset) {
|
|
352
|
+
const next = new Map(state.assetRegistry);
|
|
353
|
+
next.set(asset.id, asset);
|
|
354
|
+
return { ...state, assetRegistry: next };
|
|
355
|
+
}
|
|
356
|
+
function getAsset(state, assetId) {
|
|
357
|
+
return state.assetRegistry.get(toAssetId(assetId));
|
|
358
|
+
}
|
|
359
|
+
function hasAsset(state, assetId) {
|
|
360
|
+
return state.assetRegistry.has(toAssetId(assetId));
|
|
361
|
+
}
|
|
362
|
+
function getAllAssets(state) {
|
|
363
|
+
return Array.from(state.assetRegistry.values());
|
|
364
|
+
}
|
|
365
|
+
function unregisterAsset(state, assetId) {
|
|
366
|
+
const next = new Map(state.assetRegistry);
|
|
367
|
+
next.delete(toAssetId(assetId));
|
|
368
|
+
return { ...state, assetRegistry: next };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/systems/validation.ts
|
|
372
|
+
function validateClip(state, clip) {
|
|
373
|
+
const errors = [];
|
|
374
|
+
const asset = getAsset(state, clip.assetId);
|
|
375
|
+
if (!asset) {
|
|
376
|
+
errors.push(invalidResult(
|
|
377
|
+
"ASSET_NOT_FOUND",
|
|
378
|
+
`Asset '${clip.assetId}' not found in registry`,
|
|
379
|
+
{ clipId: clip.id, assetId: clip.assetId }
|
|
380
|
+
));
|
|
381
|
+
return combineResults(...errors);
|
|
382
|
+
}
|
|
383
|
+
if (clip.timelineEnd <= clip.timelineStart) {
|
|
384
|
+
errors.push(invalidResult(
|
|
385
|
+
"INVALID_TIMELINE_BOUNDS",
|
|
386
|
+
`Clip timeline end (${clip.timelineEnd}) must be greater than start (${clip.timelineStart})`,
|
|
387
|
+
{ clipId: clip.id, timelineStart: clip.timelineStart, timelineEnd: clip.timelineEnd }
|
|
388
|
+
));
|
|
389
|
+
}
|
|
390
|
+
if (clip.mediaIn < 0) {
|
|
391
|
+
errors.push(invalidResult(
|
|
392
|
+
"INVALID_MEDIA_IN",
|
|
393
|
+
`Clip media in (${clip.mediaIn}) must be >= 0`,
|
|
394
|
+
{ clipId: clip.id, mediaIn: clip.mediaIn }
|
|
395
|
+
));
|
|
396
|
+
}
|
|
397
|
+
if (clip.mediaOut <= clip.mediaIn) {
|
|
398
|
+
errors.push(invalidResult(
|
|
399
|
+
"INVALID_MEDIA_BOUNDS",
|
|
400
|
+
`Clip media out (${clip.mediaOut}) must be greater than media in (${clip.mediaIn})`,
|
|
401
|
+
{ clipId: clip.id, mediaIn: clip.mediaIn, mediaOut: clip.mediaOut }
|
|
402
|
+
));
|
|
403
|
+
}
|
|
404
|
+
if (clip.mediaOut > asset.intrinsicDuration) {
|
|
405
|
+
errors.push(invalidResult(
|
|
406
|
+
"MEDIA_EXCEEDS_ASSET",
|
|
407
|
+
`Clip media out (${clip.mediaOut}) exceeds asset duration (${asset.intrinsicDuration})`,
|
|
408
|
+
{ clipId: clip.id, mediaOut: clip.mediaOut, assetDuration: asset.intrinsicDuration }
|
|
409
|
+
));
|
|
410
|
+
}
|
|
411
|
+
const timelineDuration = getClipDuration(clip);
|
|
412
|
+
const mediaDuration = getClipMediaDuration(clip);
|
|
413
|
+
if (timelineDuration !== mediaDuration) {
|
|
414
|
+
errors.push(invalidResult(
|
|
415
|
+
"DURATION_MISMATCH",
|
|
416
|
+
`Clip timeline duration (${timelineDuration}) must match media duration (${mediaDuration}) in Phase 1`,
|
|
417
|
+
{ clipId: clip.id, timelineDuration, mediaDuration }
|
|
418
|
+
));
|
|
419
|
+
}
|
|
420
|
+
return combineResults(...errors);
|
|
421
|
+
}
|
|
422
|
+
function validateTrack(state, track) {
|
|
423
|
+
const errors = [];
|
|
424
|
+
for (const clip of track.clips) {
|
|
425
|
+
const clipResult = validateClip(state, clip);
|
|
426
|
+
if (!clipResult.valid) {
|
|
427
|
+
errors.push(clipResult);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (let i = 0; i < track.clips.length; i++) {
|
|
431
|
+
for (let j = i + 1; j < track.clips.length; j++) {
|
|
432
|
+
const clip1 = track.clips[i];
|
|
433
|
+
const clip2 = track.clips[j];
|
|
434
|
+
if (!clip1 || !clip2) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (clipsOverlap(clip1, clip2)) {
|
|
438
|
+
errors.push(invalidResult(
|
|
439
|
+
"CLIPS_OVERLAP",
|
|
440
|
+
`Clips '${clip1.id}' and '${clip2.id}' overlap on track '${track.id}'`,
|
|
441
|
+
{
|
|
442
|
+
trackId: track.id,
|
|
443
|
+
clip1Id: clip1.id,
|
|
444
|
+
clip2Id: clip2.id,
|
|
445
|
+
clip1Start: clip1.timelineStart,
|
|
446
|
+
clip1End: clip1.timelineEnd,
|
|
447
|
+
clip2Start: clip2.timelineStart,
|
|
448
|
+
clip2End: clip2.timelineEnd
|
|
449
|
+
}
|
|
450
|
+
));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return combineResults(...errors);
|
|
455
|
+
}
|
|
456
|
+
function validateTimeline(state) {
|
|
457
|
+
const errors = [];
|
|
458
|
+
if (state.timeline.fps <= 0) {
|
|
459
|
+
errors.push(invalidResult(
|
|
460
|
+
"INVALID_FPS",
|
|
461
|
+
`Timeline FPS must be positive, got ${state.timeline.fps}`,
|
|
462
|
+
{ fps: state.timeline.fps }
|
|
463
|
+
));
|
|
464
|
+
}
|
|
465
|
+
if (state.timeline.duration <= 0) {
|
|
466
|
+
errors.push(invalidResult(
|
|
467
|
+
"INVALID_DURATION",
|
|
468
|
+
`Timeline duration must be positive, got ${state.timeline.duration}`,
|
|
469
|
+
{ duration: state.timeline.duration }
|
|
470
|
+
));
|
|
471
|
+
}
|
|
472
|
+
for (const track of state.timeline.tracks) {
|
|
473
|
+
const trackResult = validateTrack(state, track);
|
|
474
|
+
if (!trackResult.valid) {
|
|
475
|
+
errors.push(trackResult);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return combineResults(...errors);
|
|
479
|
+
}
|
|
480
|
+
function validateNoOverlap(track, clip) {
|
|
481
|
+
for (const existingClip of track.clips) {
|
|
482
|
+
if (existingClip.id === clip.id) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (clipsOverlap(existingClip, clip)) {
|
|
486
|
+
return invalidResult(
|
|
487
|
+
"CLIPS_OVERLAP",
|
|
488
|
+
`Clip '${clip.id}' would overlap with existing clip '${existingClip.id}' on track '${track.id}'`,
|
|
489
|
+
{
|
|
490
|
+
trackId: track.id,
|
|
491
|
+
newClipId: clip.id,
|
|
492
|
+
existingClipId: existingClip.id,
|
|
493
|
+
newClipStart: clip.timelineStart,
|
|
494
|
+
newClipEnd: clip.timelineEnd,
|
|
495
|
+
existingClipStart: existingClip.timelineStart,
|
|
496
|
+
existingClipEnd: existingClip.timelineEnd
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return validResult();
|
|
502
|
+
}
|
|
503
|
+
function validateTrackTypeMatch(state, clip, targetTrack) {
|
|
504
|
+
const asset = getAsset(state, clip.assetId);
|
|
505
|
+
if (!asset) {
|
|
506
|
+
return invalidResult(
|
|
507
|
+
"ASSET_NOT_FOUND",
|
|
508
|
+
`Asset '${clip.assetId}' not found in registry`,
|
|
509
|
+
{ clipId: clip.id, assetId: clip.assetId }
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (asset.mediaType === "video" && targetTrack.type !== "video") {
|
|
513
|
+
return invalidResult(
|
|
514
|
+
"TRACK_TYPE_MISMATCH",
|
|
515
|
+
`Cannot place video clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`,
|
|
516
|
+
{
|
|
517
|
+
clipId: clip.id,
|
|
518
|
+
assetType: asset.mediaType,
|
|
519
|
+
trackType: targetTrack.type,
|
|
520
|
+
trackId: targetTrack.id
|
|
521
|
+
}
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
if (asset.mediaType === "audio" && targetTrack.type !== "audio") {
|
|
525
|
+
return invalidResult(
|
|
526
|
+
"TRACK_TYPE_MISMATCH",
|
|
527
|
+
`Cannot place audio clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`,
|
|
528
|
+
{
|
|
529
|
+
clipId: clip.id,
|
|
530
|
+
assetType: asset.mediaType,
|
|
531
|
+
trackType: targetTrack.type,
|
|
532
|
+
trackId: targetTrack.id
|
|
533
|
+
}
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
return validResult();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/operations/clip-operations.ts
|
|
540
|
+
function addClip(state, trackId, clip) {
|
|
541
|
+
const trackIndex = state.timeline.tracks.findIndex((t) => t.id === trackId);
|
|
542
|
+
if (trackIndex === -1) {
|
|
543
|
+
return state;
|
|
544
|
+
}
|
|
545
|
+
const track = state.timeline.tracks[trackIndex];
|
|
546
|
+
if (!track) {
|
|
547
|
+
return state;
|
|
548
|
+
}
|
|
549
|
+
const newTrack = sortTrackClips({
|
|
550
|
+
...track,
|
|
551
|
+
clips: [...track.clips, clip]
|
|
552
|
+
});
|
|
553
|
+
const newTracks = [...state.timeline.tracks];
|
|
554
|
+
newTracks[trackIndex] = newTrack;
|
|
555
|
+
return {
|
|
556
|
+
...state,
|
|
557
|
+
timeline: {
|
|
558
|
+
...state.timeline,
|
|
559
|
+
tracks: newTracks
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
function removeClip(state, clipId) {
|
|
564
|
+
const newTracks = state.timeline.tracks.map((track) => ({
|
|
565
|
+
...track,
|
|
566
|
+
clips: track.clips.filter((c) => c.id !== clipId)
|
|
567
|
+
}));
|
|
568
|
+
return {
|
|
569
|
+
...state,
|
|
570
|
+
timeline: {
|
|
571
|
+
...state.timeline,
|
|
572
|
+
tracks: newTracks
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
function moveClip(state, clipId, newStart) {
|
|
577
|
+
const clip = findClipById(state, clipId);
|
|
578
|
+
if (!clip) {
|
|
579
|
+
return state;
|
|
580
|
+
}
|
|
581
|
+
const duration = clip.timelineEnd - clip.timelineStart;
|
|
582
|
+
const newEnd = newStart + duration;
|
|
583
|
+
return updateClip(state, clipId, {
|
|
584
|
+
timelineStart: newStart,
|
|
585
|
+
timelineEnd: newEnd
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function resizeClip(state, clipId, newStart, newEnd) {
|
|
589
|
+
const clip = findClipById(state, clipId);
|
|
590
|
+
if (!clip) {
|
|
591
|
+
return state;
|
|
592
|
+
}
|
|
593
|
+
const startDelta = newStart - clip.timelineStart;
|
|
594
|
+
const endDelta = newEnd - clip.timelineEnd;
|
|
595
|
+
let newMediaIn = clip.mediaIn;
|
|
596
|
+
let newMediaOut = clip.mediaOut;
|
|
597
|
+
if (startDelta !== 0) {
|
|
598
|
+
newMediaIn = clip.mediaIn + startDelta;
|
|
599
|
+
}
|
|
600
|
+
if (endDelta !== 0) {
|
|
601
|
+
newMediaOut = clip.mediaOut + endDelta;
|
|
602
|
+
}
|
|
603
|
+
return updateClip(state, clipId, {
|
|
604
|
+
timelineStart: newStart,
|
|
605
|
+
timelineEnd: newEnd,
|
|
606
|
+
mediaIn: newMediaIn,
|
|
607
|
+
mediaOut: newMediaOut
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
function trimClip(state, clipId, newMediaIn, newMediaOut) {
|
|
611
|
+
return updateClip(state, clipId, {
|
|
612
|
+
mediaIn: newMediaIn,
|
|
613
|
+
mediaOut: newMediaOut
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function updateClip(state, clipId, updates) {
|
|
617
|
+
const newTracks = state.timeline.tracks.map((track) => {
|
|
618
|
+
const clipIndex = track.clips.findIndex((c) => c.id === clipId);
|
|
619
|
+
if (clipIndex === -1) {
|
|
620
|
+
return track;
|
|
621
|
+
}
|
|
622
|
+
const newClips = [...track.clips];
|
|
623
|
+
const existingClip = newClips[clipIndex];
|
|
624
|
+
if (!existingClip) {
|
|
625
|
+
return track;
|
|
626
|
+
}
|
|
627
|
+
newClips[clipIndex] = {
|
|
628
|
+
...existingClip,
|
|
629
|
+
...updates
|
|
630
|
+
};
|
|
631
|
+
return sortTrackClips({
|
|
632
|
+
...track,
|
|
633
|
+
clips: newClips
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
return {
|
|
637
|
+
...state,
|
|
638
|
+
timeline: {
|
|
639
|
+
...state.timeline,
|
|
640
|
+
tracks: newTracks
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function moveClipToTrack(state, clipId, targetTrackId) {
|
|
645
|
+
const clip = findClipById(state, clipId);
|
|
646
|
+
if (!clip) {
|
|
647
|
+
return state;
|
|
648
|
+
}
|
|
649
|
+
const targetTrack = findTrackById(state, targetTrackId);
|
|
650
|
+
if (!targetTrack) {
|
|
651
|
+
return state;
|
|
652
|
+
}
|
|
653
|
+
const validationResult = validateTrackTypeMatch(state, clip, targetTrack);
|
|
654
|
+
if (!validationResult.valid) {
|
|
655
|
+
return state;
|
|
656
|
+
}
|
|
657
|
+
if (clip.trackId === targetTrackId) {
|
|
658
|
+
return state;
|
|
659
|
+
}
|
|
660
|
+
let newState = removeClip(state, clipId);
|
|
661
|
+
const updatedClip = { ...clip, trackId: targetTrackId };
|
|
662
|
+
newState = addClip(newState, targetTrackId, updatedClip);
|
|
663
|
+
return newState;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// src/operations/track-operations.ts
|
|
667
|
+
function addTrack(state, track) {
|
|
668
|
+
return {
|
|
669
|
+
...state,
|
|
670
|
+
timeline: {
|
|
671
|
+
...state.timeline,
|
|
672
|
+
tracks: [...state.timeline.tracks, track]
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function removeTrack(state, trackId) {
|
|
677
|
+
return {
|
|
678
|
+
...state,
|
|
679
|
+
timeline: {
|
|
680
|
+
...state.timeline,
|
|
681
|
+
tracks: state.timeline.tracks.filter((t) => t.id !== trackId)
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
function moveTrack(state, trackId, newIndex) {
|
|
686
|
+
const currentIndex = findTrackIndex(state, trackId);
|
|
687
|
+
if (currentIndex === -1) {
|
|
688
|
+
return state;
|
|
689
|
+
}
|
|
690
|
+
const newTracks = [...state.timeline.tracks];
|
|
691
|
+
const [track] = newTracks.splice(currentIndex, 1);
|
|
692
|
+
if (!track) {
|
|
693
|
+
return state;
|
|
694
|
+
}
|
|
695
|
+
newTracks.splice(newIndex, 0, track);
|
|
696
|
+
return {
|
|
697
|
+
...state,
|
|
698
|
+
timeline: {
|
|
699
|
+
...state.timeline,
|
|
700
|
+
tracks: newTracks
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function updateTrack(state, trackId, updates) {
|
|
705
|
+
const trackIndex = findTrackIndex(state, trackId);
|
|
706
|
+
if (trackIndex === -1) {
|
|
707
|
+
return state;
|
|
708
|
+
}
|
|
709
|
+
const newTracks = [...state.timeline.tracks];
|
|
710
|
+
const existingTrack = newTracks[trackIndex];
|
|
711
|
+
if (!existingTrack) {
|
|
712
|
+
return state;
|
|
713
|
+
}
|
|
714
|
+
newTracks[trackIndex] = {
|
|
715
|
+
...existingTrack,
|
|
716
|
+
...updates
|
|
717
|
+
};
|
|
718
|
+
return {
|
|
719
|
+
...state,
|
|
720
|
+
timeline: {
|
|
721
|
+
...state.timeline,
|
|
722
|
+
tracks: newTracks
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function toggleTrackMute(state, trackId) {
|
|
727
|
+
const trackIndex = findTrackIndex(state, trackId);
|
|
728
|
+
if (trackIndex === -1) {
|
|
729
|
+
return state;
|
|
730
|
+
}
|
|
731
|
+
const track = state.timeline.tracks[trackIndex];
|
|
732
|
+
if (!track) {
|
|
733
|
+
return state;
|
|
734
|
+
}
|
|
735
|
+
return updateTrack(state, trackId, { muted: !track.muted });
|
|
736
|
+
}
|
|
737
|
+
function toggleTrackLock(state, trackId) {
|
|
738
|
+
const trackIndex = findTrackIndex(state, trackId);
|
|
739
|
+
if (trackIndex === -1) {
|
|
740
|
+
return state;
|
|
741
|
+
}
|
|
742
|
+
const track = state.timeline.tracks[trackIndex];
|
|
743
|
+
if (!track) {
|
|
744
|
+
return state;
|
|
745
|
+
}
|
|
746
|
+
return updateTrack(state, trackId, { locked: !track.locked });
|
|
747
|
+
}
|
|
748
|
+
function toggleTrackSolo(state, trackId) {
|
|
749
|
+
const trackIndex = findTrackIndex(state, trackId);
|
|
750
|
+
if (trackIndex === -1) {
|
|
751
|
+
return state;
|
|
752
|
+
}
|
|
753
|
+
const track = state.timeline.tracks[trackIndex];
|
|
754
|
+
if (!track) {
|
|
755
|
+
return state;
|
|
756
|
+
}
|
|
757
|
+
return updateTrack(state, trackId, { solo: !track.solo });
|
|
758
|
+
}
|
|
759
|
+
function setTrackHeight(state, trackId, height) {
|
|
760
|
+
return updateTrack(state, trackId, { height: Math.max(40, Math.min(200, height)) });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/operations/timeline-operations.ts
|
|
764
|
+
function setTimelineDuration(state, duration) {
|
|
765
|
+
return {
|
|
766
|
+
...state,
|
|
767
|
+
timeline: {
|
|
768
|
+
...state.timeline,
|
|
769
|
+
duration
|
|
770
|
+
}
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
function setTimelineName(state, name) {
|
|
774
|
+
return {
|
|
775
|
+
...state,
|
|
776
|
+
timeline: {
|
|
777
|
+
...state.timeline,
|
|
778
|
+
name
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/engine/transactions.ts
|
|
784
|
+
function beginTransaction(state) {
|
|
785
|
+
return {
|
|
786
|
+
initialState: state,
|
|
787
|
+
currentState: state,
|
|
788
|
+
operations: [],
|
|
789
|
+
finalized: false
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function applyOperation(tx, operation) {
|
|
793
|
+
if (tx.finalized) {
|
|
794
|
+
throw new Error("Cannot apply operation to finalized transaction");
|
|
795
|
+
}
|
|
796
|
+
const newState = operation(tx.currentState);
|
|
797
|
+
return {
|
|
798
|
+
...tx,
|
|
799
|
+
currentState: newState,
|
|
800
|
+
operations: [...tx.operations, operation]
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function commitTransaction(tx) {
|
|
804
|
+
if (tx.finalized) {
|
|
805
|
+
throw new Error("Transaction already finalized");
|
|
806
|
+
}
|
|
807
|
+
tx.finalized = true;
|
|
808
|
+
return tx.currentState;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/operations/ripple.ts
|
|
812
|
+
function rippleDelete(state, clipId) {
|
|
813
|
+
const clip = findClipById(state, clipId);
|
|
814
|
+
if (!clip) {
|
|
815
|
+
throw new Error(`Clip not found: ${clipId}`);
|
|
816
|
+
}
|
|
817
|
+
const track = findTrackById(state, clip.trackId);
|
|
818
|
+
if (!track) {
|
|
819
|
+
throw new Error(`Track not found: ${clip.trackId}`);
|
|
820
|
+
}
|
|
821
|
+
const clipDuration = getClipDuration(clip);
|
|
822
|
+
const deleteEnd = clip.timelineEnd;
|
|
823
|
+
const clipsToShift = track.clips.filter(
|
|
824
|
+
(c) => c.id !== clipId && c.timelineStart >= deleteEnd
|
|
825
|
+
);
|
|
826
|
+
let tx = beginTransaction(state);
|
|
827
|
+
tx = applyOperation(tx, (s) => removeClip(s, clipId));
|
|
828
|
+
for (const clipToShift of clipsToShift) {
|
|
829
|
+
const newStart = clipToShift.timelineStart - clipDuration;
|
|
830
|
+
tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, newStart));
|
|
831
|
+
}
|
|
832
|
+
return commitTransaction(tx);
|
|
833
|
+
}
|
|
834
|
+
function rippleTrim(state, clipId, newEnd) {
|
|
835
|
+
const clip = findClipById(state, clipId);
|
|
836
|
+
if (!clip) {
|
|
837
|
+
throw new Error(`Clip not found: ${clipId}`);
|
|
838
|
+
}
|
|
839
|
+
const track = findTrackById(state, clip.trackId);
|
|
840
|
+
if (!track) {
|
|
841
|
+
throw new Error(`Track not found: ${clip.trackId}`);
|
|
842
|
+
}
|
|
843
|
+
if (newEnd <= clip.timelineStart) {
|
|
844
|
+
throw new Error("New end must be after clip start");
|
|
845
|
+
}
|
|
846
|
+
const delta = newEnd - clip.timelineEnd;
|
|
847
|
+
const clipsToShift = track.clips.filter(
|
|
848
|
+
(c) => c.id !== clipId && c.timelineStart >= clip.timelineEnd
|
|
849
|
+
);
|
|
850
|
+
let tx = beginTransaction(state);
|
|
851
|
+
tx = applyOperation(tx, (s) => resizeClip(s, clipId, clip.timelineStart, newEnd));
|
|
852
|
+
for (const clipToShift of clipsToShift) {
|
|
853
|
+
const newStart = clipToShift.timelineStart + delta;
|
|
854
|
+
tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, newStart));
|
|
855
|
+
}
|
|
856
|
+
return commitTransaction(tx);
|
|
857
|
+
}
|
|
858
|
+
function insertEdit(state, trackId, clip, atFrame) {
|
|
859
|
+
const track = findTrackById(state, trackId);
|
|
860
|
+
if (!track) {
|
|
861
|
+
throw new Error(`Track not found: ${trackId}`);
|
|
862
|
+
}
|
|
863
|
+
const clipDuration = getClipDuration(clip);
|
|
864
|
+
const clipsToShift = track.clips.filter((c) => c.timelineStart >= atFrame);
|
|
865
|
+
const adjustedClip = {
|
|
866
|
+
...clip,
|
|
867
|
+
timelineStart: atFrame,
|
|
868
|
+
timelineEnd: atFrame + clipDuration
|
|
869
|
+
};
|
|
870
|
+
let tx = beginTransaction(state);
|
|
871
|
+
for (const clipToShift of clipsToShift) {
|
|
872
|
+
const newStart = clipToShift.timelineStart + clipDuration;
|
|
873
|
+
tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, newStart));
|
|
874
|
+
}
|
|
875
|
+
tx = applyOperation(tx, (s) => addClip(s, trackId, adjustedClip));
|
|
876
|
+
return commitTransaction(tx);
|
|
877
|
+
}
|
|
878
|
+
function rippleMove(state, clipId, newStart) {
|
|
879
|
+
const clip = findClipById(state, clipId);
|
|
880
|
+
if (!clip) {
|
|
881
|
+
throw new Error(`Clip not found: ${clipId}`);
|
|
882
|
+
}
|
|
883
|
+
const track = findTrackById(state, clip.trackId);
|
|
884
|
+
if (!track) {
|
|
885
|
+
throw new Error(`Track not found: ${clip.trackId}`);
|
|
886
|
+
}
|
|
887
|
+
const clipDuration = getClipDuration(clip);
|
|
888
|
+
const newEnd = newStart + clipDuration;
|
|
889
|
+
if (newStart < 0) {
|
|
890
|
+
throw new Error("Cannot move clip before timeline start (frame 0)");
|
|
891
|
+
}
|
|
892
|
+
if (newEnd > state.timeline.duration) {
|
|
893
|
+
throw new Error(`Cannot move clip beyond timeline duration (${state.timeline.duration} frames)`);
|
|
894
|
+
}
|
|
895
|
+
const originalStart = clip.timelineStart;
|
|
896
|
+
const originalEnd = clip.timelineEnd;
|
|
897
|
+
if (newStart === originalStart) {
|
|
898
|
+
return state;
|
|
899
|
+
}
|
|
900
|
+
let tx = beginTransaction(state);
|
|
901
|
+
if (newStart > originalStart) {
|
|
902
|
+
const afterSource = track.clips.filter((c) => c.id !== clipId && c.timelineStart >= originalEnd).sort((a, b) => a.timelineStart - b.timelineStart);
|
|
903
|
+
for (const other of afterSource) {
|
|
904
|
+
const s = other.timelineStart - clipDuration;
|
|
905
|
+
tx = applyOperation(tx, (st) => moveClip(st, other.id, s));
|
|
906
|
+
}
|
|
907
|
+
const anyClipBetween = afterSource.some((c) => c.timelineStart < newStart);
|
|
908
|
+
const collapsedDest = anyClipBetween ? newStart - clipDuration : newStart;
|
|
909
|
+
const currentTrack = tx.currentState.timeline.tracks.find((t) => t.id === track.id);
|
|
910
|
+
const atDest = currentTrack.clips.filter((c) => c.id !== clipId && c.timelineStart >= collapsedDest).sort((a, b) => b.timelineStart - a.timelineStart);
|
|
911
|
+
for (const other of atDest) {
|
|
912
|
+
const s = other.timelineStart + clipDuration;
|
|
913
|
+
tx = applyOperation(tx, (st) => moveClip(st, other.id, s));
|
|
914
|
+
}
|
|
915
|
+
tx = applyOperation(tx, (st) => moveClip(st, clipId, collapsedDest));
|
|
916
|
+
} else {
|
|
917
|
+
const afterSource = track.clips.filter((c) => c.id !== clipId && c.timelineStart >= originalEnd).sort((a, b) => a.timelineStart - b.timelineStart);
|
|
918
|
+
for (const other of afterSource) {
|
|
919
|
+
const s = other.timelineStart - clipDuration;
|
|
920
|
+
tx = applyOperation(tx, (st) => moveClip(st, other.id, s));
|
|
921
|
+
}
|
|
922
|
+
tx = applyOperation(tx, (st) => moveClip(st, clipId, newStart));
|
|
923
|
+
}
|
|
924
|
+
return commitTransaction(tx);
|
|
925
|
+
}
|
|
926
|
+
function insertMove(state, clipId, newStart) {
|
|
927
|
+
const clip = findClipById(state, clipId);
|
|
928
|
+
if (!clip) {
|
|
929
|
+
throw new Error(`Clip not found: ${clipId}`);
|
|
930
|
+
}
|
|
931
|
+
const track = findTrackById(state, clip.trackId);
|
|
932
|
+
if (!track) {
|
|
933
|
+
throw new Error(`Track not found: ${clip.trackId}`);
|
|
934
|
+
}
|
|
935
|
+
const clipDuration = getClipDuration(clip);
|
|
936
|
+
const newEnd = newStart + clipDuration;
|
|
937
|
+
if (newStart < 0) {
|
|
938
|
+
throw new Error("Cannot move clip before timeline start (frame 0)");
|
|
939
|
+
}
|
|
940
|
+
if (newEnd > state.timeline.duration) {
|
|
941
|
+
throw new Error(`Cannot move clip beyond timeline duration (${state.timeline.duration} frames)`);
|
|
942
|
+
}
|
|
943
|
+
if (newStart === clip.timelineStart) {
|
|
944
|
+
return state;
|
|
945
|
+
}
|
|
946
|
+
let tx = beginTransaction(state);
|
|
947
|
+
const clipsToShift = track.clips.filter(
|
|
948
|
+
(c) => c.id !== clipId && c.timelineStart >= newStart
|
|
949
|
+
);
|
|
950
|
+
for (const clipToShift of clipsToShift) {
|
|
951
|
+
const shiftedStart = clipToShift.timelineStart + clipDuration;
|
|
952
|
+
tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, shiftedStart));
|
|
953
|
+
}
|
|
954
|
+
tx = applyOperation(tx, (s) => moveClip(s, clipId, newStart));
|
|
955
|
+
return commitTransaction(tx);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// src/engine/timeline-engine.ts
|
|
959
|
+
function legacyDispatch(history, operation) {
|
|
960
|
+
const currentState = getCurrentState(history);
|
|
961
|
+
let newState;
|
|
962
|
+
try {
|
|
963
|
+
newState = operation(currentState);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
return { accepted: false, errors: [{ code: "OPERATION_ERROR", message: String(err) }] };
|
|
966
|
+
}
|
|
967
|
+
const newHistory = pushHistory(history, newState);
|
|
968
|
+
return { accepted: true, history: newHistory };
|
|
969
|
+
}
|
|
970
|
+
var TimelineEngine = class {
|
|
971
|
+
history;
|
|
972
|
+
listeners = /* @__PURE__ */ new Set();
|
|
973
|
+
/**
|
|
974
|
+
* Create a new timeline engine
|
|
975
|
+
*
|
|
976
|
+
* @param initialState - Initial timeline state
|
|
977
|
+
* @param historyLimit - Maximum number of undo steps (default: 50)
|
|
978
|
+
*/
|
|
979
|
+
constructor(initialState, historyLimit = 50) {
|
|
980
|
+
this.history = createHistory(initialState, historyLimit);
|
|
981
|
+
}
|
|
982
|
+
// ===== SUBSCRIPTION =====
|
|
983
|
+
/**
|
|
984
|
+
* Subscribe to state changes
|
|
985
|
+
*
|
|
986
|
+
* The listener will be called whenever the timeline state changes,
|
|
987
|
+
* with the new state passed as an argument.
|
|
988
|
+
* This is used by framework adapters (e.g., React) to trigger re-renders.
|
|
989
|
+
*
|
|
990
|
+
* @param listener - Function to call on state changes, receives new state
|
|
991
|
+
* @returns Unsubscribe function
|
|
992
|
+
*
|
|
993
|
+
* @example
|
|
994
|
+
* ```typescript
|
|
995
|
+
* const unsubscribe = engine.subscribe((state) => {
|
|
996
|
+
* console.log('State changed:', state);
|
|
997
|
+
* });
|
|
998
|
+
*
|
|
999
|
+
* // Later...
|
|
1000
|
+
* unsubscribe();
|
|
1001
|
+
* ```
|
|
1002
|
+
*/
|
|
1003
|
+
subscribe(listener) {
|
|
1004
|
+
this.listeners.add(listener);
|
|
1005
|
+
return () => {
|
|
1006
|
+
this.listeners.delete(listener);
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Notify all subscribers of a state change
|
|
1011
|
+
*
|
|
1012
|
+
* This is called internally after any operation that modifies state.
|
|
1013
|
+
* Framework adapters use this to trigger re-renders.
|
|
1014
|
+
*/
|
|
1015
|
+
notify() {
|
|
1016
|
+
const state = this.getState();
|
|
1017
|
+
this.listeners.forEach((listener) => listener(state));
|
|
1018
|
+
}
|
|
1019
|
+
// ===== STATE ACCESS =====
|
|
1020
|
+
/**
|
|
1021
|
+
* Get the current timeline state
|
|
1022
|
+
*
|
|
1023
|
+
* @returns Current timeline state
|
|
1024
|
+
*/
|
|
1025
|
+
getState() {
|
|
1026
|
+
return getCurrentState(this.history);
|
|
1027
|
+
}
|
|
1028
|
+
// ===== ASSET OPERATIONS =====
|
|
1029
|
+
/**
|
|
1030
|
+
* Register an asset
|
|
1031
|
+
*
|
|
1032
|
+
* @param asset - Asset to register
|
|
1033
|
+
* @returns Dispatch result
|
|
1034
|
+
*/
|
|
1035
|
+
registerAsset(asset) {
|
|
1036
|
+
const result = legacyDispatch(
|
|
1037
|
+
this.history,
|
|
1038
|
+
(state) => registerAsset(state, asset)
|
|
1039
|
+
);
|
|
1040
|
+
if (result.accepted && result.history) {
|
|
1041
|
+
this.history = result.history;
|
|
1042
|
+
this.notify();
|
|
1043
|
+
}
|
|
1044
|
+
return result;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Get an asset by ID
|
|
1048
|
+
*
|
|
1049
|
+
* @param assetId - Asset ID
|
|
1050
|
+
* @returns The asset, or undefined if not found
|
|
1051
|
+
*/
|
|
1052
|
+
getAsset(assetId) {
|
|
1053
|
+
return getAsset(this.getState(), assetId);
|
|
1054
|
+
}
|
|
1055
|
+
// ===== CLIP OPERATIONS =====
|
|
1056
|
+
/**
|
|
1057
|
+
* Add a clip to a track
|
|
1058
|
+
*
|
|
1059
|
+
* @param trackId - ID of the track to add to
|
|
1060
|
+
* @param clip - Clip to add
|
|
1061
|
+
* @returns Dispatch result
|
|
1062
|
+
*/
|
|
1063
|
+
addClip(trackId, clip) {
|
|
1064
|
+
const result = legacyDispatch(
|
|
1065
|
+
this.history,
|
|
1066
|
+
(state) => addClip(state, trackId, clip)
|
|
1067
|
+
);
|
|
1068
|
+
if (result.accepted && result.history) {
|
|
1069
|
+
this.history = result.history;
|
|
1070
|
+
this.notify();
|
|
1071
|
+
}
|
|
1072
|
+
return result;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Remove a clip
|
|
1076
|
+
*
|
|
1077
|
+
* @param clipId - ID of the clip to remove
|
|
1078
|
+
* @returns Dispatch result
|
|
1079
|
+
*/
|
|
1080
|
+
removeClip(clipId) {
|
|
1081
|
+
const result = legacyDispatch(
|
|
1082
|
+
this.history,
|
|
1083
|
+
(state) => removeClip(state, clipId)
|
|
1084
|
+
);
|
|
1085
|
+
if (result.accepted && result.history) {
|
|
1086
|
+
this.history = result.history;
|
|
1087
|
+
this.notify();
|
|
1088
|
+
}
|
|
1089
|
+
return result;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Move a clip to a new timeline position
|
|
1093
|
+
*
|
|
1094
|
+
* @param clipId - ID of the clip to move
|
|
1095
|
+
* @param newStart - New timeline start frame
|
|
1096
|
+
* @returns Dispatch result
|
|
1097
|
+
*/
|
|
1098
|
+
moveClip(clipId, newStart) {
|
|
1099
|
+
const result = legacyDispatch(
|
|
1100
|
+
this.history,
|
|
1101
|
+
(state) => moveClip(state, clipId, newStart)
|
|
1102
|
+
);
|
|
1103
|
+
if (result.accepted && result.history) {
|
|
1104
|
+
this.history = result.history;
|
|
1105
|
+
this.notify();
|
|
1106
|
+
}
|
|
1107
|
+
return result;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Resize a clip
|
|
1111
|
+
*
|
|
1112
|
+
* @param clipId - ID of the clip to resize
|
|
1113
|
+
* @param newStart - New timeline start frame
|
|
1114
|
+
* @param newEnd - New timeline end frame
|
|
1115
|
+
* @returns Dispatch result
|
|
1116
|
+
*/
|
|
1117
|
+
resizeClip(clipId, newStart, newEnd) {
|
|
1118
|
+
const result = legacyDispatch(
|
|
1119
|
+
this.history,
|
|
1120
|
+
(state) => resizeClip(state, clipId, newStart, newEnd)
|
|
1121
|
+
);
|
|
1122
|
+
if (result.accepted && result.history) {
|
|
1123
|
+
this.history = result.history;
|
|
1124
|
+
this.notify();
|
|
1125
|
+
}
|
|
1126
|
+
return result;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Trim a clip (change media bounds)
|
|
1130
|
+
*
|
|
1131
|
+
* @param clipId - ID of the clip to trim
|
|
1132
|
+
* @param newMediaIn - New media in frame
|
|
1133
|
+
* @param newMediaOut - New media out frame
|
|
1134
|
+
* @returns Dispatch result
|
|
1135
|
+
*/
|
|
1136
|
+
trimClip(clipId, newMediaIn, newMediaOut) {
|
|
1137
|
+
const result = legacyDispatch(
|
|
1138
|
+
this.history,
|
|
1139
|
+
(state) => trimClip(state, clipId, newMediaIn, newMediaOut)
|
|
1140
|
+
);
|
|
1141
|
+
if (result.accepted && result.history) {
|
|
1142
|
+
this.history = result.history;
|
|
1143
|
+
this.notify();
|
|
1144
|
+
}
|
|
1145
|
+
return result;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Move a clip to a different track
|
|
1149
|
+
*
|
|
1150
|
+
* @param clipId - ID of the clip to move
|
|
1151
|
+
* @param targetTrackId - ID of the target track
|
|
1152
|
+
* @returns Dispatch result
|
|
1153
|
+
*/
|
|
1154
|
+
moveClipToTrack(clipId, targetTrackId) {
|
|
1155
|
+
const result = legacyDispatch(
|
|
1156
|
+
this.history,
|
|
1157
|
+
(state) => moveClipToTrack(state, clipId, targetTrackId)
|
|
1158
|
+
);
|
|
1159
|
+
if (result.accepted && result.history) {
|
|
1160
|
+
this.history = result.history;
|
|
1161
|
+
this.notify();
|
|
1162
|
+
}
|
|
1163
|
+
return result;
|
|
1164
|
+
}
|
|
1165
|
+
// ===== TRACK OPERATIONS =====
|
|
1166
|
+
/**
|
|
1167
|
+
* Add a track
|
|
1168
|
+
*
|
|
1169
|
+
* @param track - Track to add
|
|
1170
|
+
* @returns Dispatch result
|
|
1171
|
+
*/
|
|
1172
|
+
addTrack(track) {
|
|
1173
|
+
const result = legacyDispatch(
|
|
1174
|
+
this.history,
|
|
1175
|
+
(state) => addTrack(state, track)
|
|
1176
|
+
);
|
|
1177
|
+
if (result.accepted && result.history) {
|
|
1178
|
+
this.history = result.history;
|
|
1179
|
+
this.notify();
|
|
1180
|
+
}
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Remove a track
|
|
1185
|
+
*
|
|
1186
|
+
* @param trackId - ID of the track to remove
|
|
1187
|
+
* @returns Dispatch result
|
|
1188
|
+
*/
|
|
1189
|
+
removeTrack(trackId) {
|
|
1190
|
+
const result = legacyDispatch(
|
|
1191
|
+
this.history,
|
|
1192
|
+
(state) => removeTrack(state, trackId)
|
|
1193
|
+
);
|
|
1194
|
+
if (result.accepted && result.history) {
|
|
1195
|
+
this.history = result.history;
|
|
1196
|
+
this.notify();
|
|
1197
|
+
}
|
|
1198
|
+
return result;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Move a track to a new position
|
|
1202
|
+
*
|
|
1203
|
+
* @param trackId - ID of the track to move
|
|
1204
|
+
* @param newIndex - New index position
|
|
1205
|
+
* @returns Dispatch result
|
|
1206
|
+
*/
|
|
1207
|
+
moveTrack(trackId, newIndex) {
|
|
1208
|
+
const result = legacyDispatch(
|
|
1209
|
+
this.history,
|
|
1210
|
+
(state) => moveTrack(state, trackId, newIndex)
|
|
1211
|
+
);
|
|
1212
|
+
if (result.accepted && result.history) {
|
|
1213
|
+
this.history = result.history;
|
|
1214
|
+
this.notify();
|
|
1215
|
+
}
|
|
1216
|
+
return result;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Toggle track mute
|
|
1220
|
+
*
|
|
1221
|
+
* @param trackId - ID of the track
|
|
1222
|
+
* @returns Dispatch result
|
|
1223
|
+
*/
|
|
1224
|
+
toggleTrackMute(trackId) {
|
|
1225
|
+
const result = legacyDispatch(
|
|
1226
|
+
this.history,
|
|
1227
|
+
(state) => toggleTrackMute(state, trackId)
|
|
1228
|
+
);
|
|
1229
|
+
if (result.accepted && result.history) {
|
|
1230
|
+
this.history = result.history;
|
|
1231
|
+
this.notify();
|
|
1232
|
+
}
|
|
1233
|
+
return result;
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Toggle track lock
|
|
1237
|
+
*
|
|
1238
|
+
* @param trackId - ID of the track
|
|
1239
|
+
* @returns Dispatch result
|
|
1240
|
+
*/
|
|
1241
|
+
toggleTrackLock(trackId) {
|
|
1242
|
+
const result = legacyDispatch(
|
|
1243
|
+
this.history,
|
|
1244
|
+
(state) => toggleTrackLock(state, trackId)
|
|
1245
|
+
);
|
|
1246
|
+
if (result.accepted && result.history) {
|
|
1247
|
+
this.history = result.history;
|
|
1248
|
+
this.notify();
|
|
1249
|
+
}
|
|
1250
|
+
return result;
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Toggle track solo
|
|
1254
|
+
*
|
|
1255
|
+
* @param trackId - ID of the track
|
|
1256
|
+
* @returns Dispatch result
|
|
1257
|
+
*/
|
|
1258
|
+
toggleTrackSolo(trackId) {
|
|
1259
|
+
const result = legacyDispatch(
|
|
1260
|
+
this.history,
|
|
1261
|
+
(state) => toggleTrackSolo(state, trackId)
|
|
1262
|
+
);
|
|
1263
|
+
if (result.accepted && result.history) {
|
|
1264
|
+
this.history = result.history;
|
|
1265
|
+
this.notify();
|
|
1266
|
+
}
|
|
1267
|
+
return result;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Set track height
|
|
1271
|
+
*
|
|
1272
|
+
* @param trackId - ID of the track
|
|
1273
|
+
* @param height - New height in pixels
|
|
1274
|
+
* @returns Dispatch result
|
|
1275
|
+
*/
|
|
1276
|
+
setTrackHeight(trackId, height) {
|
|
1277
|
+
const result = legacyDispatch(
|
|
1278
|
+
this.history,
|
|
1279
|
+
(state) => setTrackHeight(state, trackId, height)
|
|
1280
|
+
);
|
|
1281
|
+
if (result.accepted && result.history) {
|
|
1282
|
+
this.history = result.history;
|
|
1283
|
+
this.notify();
|
|
1284
|
+
}
|
|
1285
|
+
return result;
|
|
1286
|
+
}
|
|
1287
|
+
// ===== TIMELINE OPERATIONS =====
|
|
1288
|
+
/**
|
|
1289
|
+
* Set timeline duration
|
|
1290
|
+
*
|
|
1291
|
+
* @param duration - New duration in frames
|
|
1292
|
+
* @returns Dispatch result
|
|
1293
|
+
*/
|
|
1294
|
+
setTimelineDuration(duration) {
|
|
1295
|
+
const result = legacyDispatch(
|
|
1296
|
+
this.history,
|
|
1297
|
+
(state) => setTimelineDuration(state, duration)
|
|
1298
|
+
);
|
|
1299
|
+
if (result.accepted && result.history) {
|
|
1300
|
+
this.history = result.history;
|
|
1301
|
+
this.notify();
|
|
1302
|
+
}
|
|
1303
|
+
return result;
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Set timeline name
|
|
1307
|
+
*
|
|
1308
|
+
* @param name - New timeline name
|
|
1309
|
+
* @returns Dispatch result
|
|
1310
|
+
*/
|
|
1311
|
+
setTimelineName(name) {
|
|
1312
|
+
const result = legacyDispatch(
|
|
1313
|
+
this.history,
|
|
1314
|
+
(state) => setTimelineName(state, name)
|
|
1315
|
+
);
|
|
1316
|
+
if (result.accepted && result.history) {
|
|
1317
|
+
this.history = result.history;
|
|
1318
|
+
this.notify();
|
|
1319
|
+
}
|
|
1320
|
+
return result;
|
|
1321
|
+
}
|
|
1322
|
+
// ===== HISTORY OPERATIONS =====
|
|
1323
|
+
/**
|
|
1324
|
+
* Undo the last action
|
|
1325
|
+
*
|
|
1326
|
+
* @returns true if undo was performed
|
|
1327
|
+
*/
|
|
1328
|
+
undo() {
|
|
1329
|
+
if (!this.canUndo()) {
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
this.history = undo(this.history);
|
|
1333
|
+
this.notify();
|
|
1334
|
+
return true;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Redo the last undone action
|
|
1338
|
+
*
|
|
1339
|
+
* @returns true if redo was performed
|
|
1340
|
+
*/
|
|
1341
|
+
redo() {
|
|
1342
|
+
if (!this.canRedo()) {
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
this.history = redo(this.history);
|
|
1346
|
+
this.notify();
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Check if undo is available
|
|
1351
|
+
*
|
|
1352
|
+
* @returns true if undo is available
|
|
1353
|
+
*/
|
|
1354
|
+
canUndo() {
|
|
1355
|
+
return canUndo(this.history);
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Check if redo is available
|
|
1359
|
+
*
|
|
1360
|
+
* @returns true if redo is available
|
|
1361
|
+
*/
|
|
1362
|
+
canRedo() {
|
|
1363
|
+
return canRedo(this.history);
|
|
1364
|
+
}
|
|
1365
|
+
// ===== QUERY OPERATIONS =====
|
|
1366
|
+
/**
|
|
1367
|
+
* Find a clip by ID
|
|
1368
|
+
*
|
|
1369
|
+
* @param clipId - Clip ID
|
|
1370
|
+
* @returns The clip, or undefined if not found
|
|
1371
|
+
*/
|
|
1372
|
+
findClipById(clipId) {
|
|
1373
|
+
return findClipById(this.getState(), clipId);
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Find a track by ID
|
|
1377
|
+
*
|
|
1378
|
+
* @param trackId - Track ID
|
|
1379
|
+
* @returns The track, or undefined if not found
|
|
1380
|
+
*/
|
|
1381
|
+
findTrackById(trackId) {
|
|
1382
|
+
return findTrackById(this.getState(), trackId);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Get all clips on a track
|
|
1386
|
+
*
|
|
1387
|
+
* @param trackId - Track ID
|
|
1388
|
+
* @returns Array of clips on the track
|
|
1389
|
+
*/
|
|
1390
|
+
getClipsOnTrack(trackId) {
|
|
1391
|
+
return getClipsOnTrack(this.getState(), trackId);
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Get all clips at a specific frame
|
|
1395
|
+
*
|
|
1396
|
+
* @param frame - Frame to check
|
|
1397
|
+
* @returns Array of clips at that frame
|
|
1398
|
+
*/
|
|
1399
|
+
getClipsAtFrame(f) {
|
|
1400
|
+
return getClipsAtFrame(this.getState(), f);
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Get all clips in a frame range
|
|
1404
|
+
*
|
|
1405
|
+
* @param start - Start frame
|
|
1406
|
+
* @param end - End frame
|
|
1407
|
+
* @returns Array of clips in the range
|
|
1408
|
+
*/
|
|
1409
|
+
getClipsInRange(start, end) {
|
|
1410
|
+
return getClipsInRange(this.getState(), start, end);
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Get all clips in the timeline
|
|
1414
|
+
*
|
|
1415
|
+
* @returns Array of all clips
|
|
1416
|
+
*/
|
|
1417
|
+
getAllClips() {
|
|
1418
|
+
return getAllClips(this.getState());
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Get all tracks in the timeline
|
|
1422
|
+
*
|
|
1423
|
+
* @returns Array of all tracks
|
|
1424
|
+
*/
|
|
1425
|
+
getAllTracks() {
|
|
1426
|
+
return getAllTracks(this.getState());
|
|
1427
|
+
}
|
|
1428
|
+
// ===== RIPPLE OPERATIONS =====
|
|
1429
|
+
/**
|
|
1430
|
+
* Ripple delete - delete clip and shift subsequent clips left
|
|
1431
|
+
*
|
|
1432
|
+
* @param clipId - ID of the clip to delete
|
|
1433
|
+
* @returns Dispatch result
|
|
1434
|
+
*/
|
|
1435
|
+
rippleDelete(clipId) {
|
|
1436
|
+
const result = legacyDispatch(
|
|
1437
|
+
this.history,
|
|
1438
|
+
(state) => rippleDelete(state, clipId)
|
|
1439
|
+
);
|
|
1440
|
+
if (result.accepted && result.history) {
|
|
1441
|
+
this.history = result.history;
|
|
1442
|
+
this.notify();
|
|
1443
|
+
}
|
|
1444
|
+
return result;
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Ripple trim - trim clip end and shift subsequent clips
|
|
1448
|
+
*
|
|
1449
|
+
* @param clipId - ID of the clip to trim
|
|
1450
|
+
* @param newEnd - New end frame for the clip
|
|
1451
|
+
* @returns Dispatch result
|
|
1452
|
+
*/
|
|
1453
|
+
rippleTrim(clipId, newEnd) {
|
|
1454
|
+
const result = legacyDispatch(
|
|
1455
|
+
this.history,
|
|
1456
|
+
(state) => rippleTrim(state, clipId, newEnd)
|
|
1457
|
+
);
|
|
1458
|
+
if (result.accepted && result.history) {
|
|
1459
|
+
this.history = result.history;
|
|
1460
|
+
this.notify();
|
|
1461
|
+
}
|
|
1462
|
+
return result;
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Insert edit - insert clip and shift subsequent clips right
|
|
1466
|
+
*
|
|
1467
|
+
* @param trackId - ID of the track to insert into
|
|
1468
|
+
* @param clip - Clip to insert
|
|
1469
|
+
* @param atFrame - Frame to insert at
|
|
1470
|
+
* @returns Dispatch result
|
|
1471
|
+
*/
|
|
1472
|
+
insertEdit(trackId, clip, atFrame) {
|
|
1473
|
+
const result = legacyDispatch(
|
|
1474
|
+
this.history,
|
|
1475
|
+
(state) => insertEdit(state, trackId, clip, atFrame)
|
|
1476
|
+
);
|
|
1477
|
+
if (result.accepted && result.history) {
|
|
1478
|
+
this.history = result.history;
|
|
1479
|
+
this.notify();
|
|
1480
|
+
}
|
|
1481
|
+
return result;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Ripple move - move clip and shift surrounding clips to accommodate
|
|
1485
|
+
*
|
|
1486
|
+
* This moves a clip to a new position while maintaining timeline continuity:
|
|
1487
|
+
* - Closes the gap at the source position
|
|
1488
|
+
* - Makes space at the destination position
|
|
1489
|
+
* - All operations are atomic (single undo entry)
|
|
1490
|
+
*
|
|
1491
|
+
* @param clipId - ID of the clip to move
|
|
1492
|
+
* @param newStart - New start frame for the clip
|
|
1493
|
+
* @returns Dispatch result
|
|
1494
|
+
*/
|
|
1495
|
+
rippleMove(clipId, newStart) {
|
|
1496
|
+
const result = legacyDispatch(
|
|
1497
|
+
this.history,
|
|
1498
|
+
(state) => rippleMove(state, clipId, newStart)
|
|
1499
|
+
);
|
|
1500
|
+
if (result.accepted && result.history) {
|
|
1501
|
+
this.history = result.history;
|
|
1502
|
+
this.notify();
|
|
1503
|
+
} else if (!result.accepted && result.errors?.[0]?.code === "OPERATION_ERROR") {
|
|
1504
|
+
throw new Error(result.errors[0].message);
|
|
1505
|
+
}
|
|
1506
|
+
return result;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Insert move - move clip and shift destination clips right
|
|
1510
|
+
*
|
|
1511
|
+
* This moves a clip to a new position without closing the gap at source:
|
|
1512
|
+
* - Leaves gap at the source position
|
|
1513
|
+
* - Pushes all clips at destination right to make space
|
|
1514
|
+
* - All operations are atomic (single undo entry)
|
|
1515
|
+
*
|
|
1516
|
+
* @param clipId - ID of the clip to move
|
|
1517
|
+
* @param newStart - New start frame for the clip
|
|
1518
|
+
* @returns Dispatch result
|
|
1519
|
+
*/
|
|
1520
|
+
insertMove(clipId, newStart) {
|
|
1521
|
+
const result = legacyDispatch(
|
|
1522
|
+
this.history,
|
|
1523
|
+
(state) => insertMove(state, clipId, newStart)
|
|
1524
|
+
);
|
|
1525
|
+
if (result.accepted && result.history) {
|
|
1526
|
+
this.history = result.history;
|
|
1527
|
+
this.notify();
|
|
1528
|
+
} else if (!result.accepted && result.errors?.[0]?.code === "OPERATION_ERROR") {
|
|
1529
|
+
throw new Error(result.errors[0].message);
|
|
1530
|
+
}
|
|
1531
|
+
return result;
|
|
1532
|
+
}
|
|
1533
|
+
// Phase 2: Marker and WorkArea operations are gated to Phase 2.
|
|
1534
|
+
// They are intentionally omitted here to keep Phase 0 clean.
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
// src/types/clip-transform.ts
|
|
1538
|
+
function createAnimatableProperty(value) {
|
|
1539
|
+
return { value, keyframes: [] };
|
|
1540
|
+
}
|
|
1541
|
+
var DEFAULT_CLIP_TRANSFORM = {
|
|
1542
|
+
positionX: createAnimatableProperty(0),
|
|
1543
|
+
positionY: createAnimatableProperty(0),
|
|
1544
|
+
scaleX: createAnimatableProperty(1),
|
|
1545
|
+
scaleY: createAnimatableProperty(1),
|
|
1546
|
+
rotation: createAnimatableProperty(0),
|
|
1547
|
+
opacity: createAnimatableProperty(1),
|
|
1548
|
+
anchorX: createAnimatableProperty(0),
|
|
1549
|
+
anchorY: createAnimatableProperty(0)
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
// src/types/audio-properties.ts
|
|
1553
|
+
var DEFAULT_AUDIO_PROPERTIES = {
|
|
1554
|
+
gain: createAnimatableProperty(0),
|
|
1555
|
+
pan: createAnimatableProperty(0),
|
|
1556
|
+
mute: false,
|
|
1557
|
+
channelRouting: "stereo",
|
|
1558
|
+
normalizationGain: 0
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
// src/types/caption.ts
|
|
1562
|
+
var toCaptionId = (s) => s;
|
|
1563
|
+
|
|
1564
|
+
// src/engine/subtitle-import.ts
|
|
1565
|
+
var defaultCaptionStyle = {
|
|
1566
|
+
fontFamily: "sans-serif",
|
|
1567
|
+
fontSize: 16,
|
|
1568
|
+
color: "#ffffff",
|
|
1569
|
+
backgroundColor: "rgba(0,0,0,0.75)",
|
|
1570
|
+
hAlign: "center",
|
|
1571
|
+
vAlign: "bottom"
|
|
1572
|
+
};
|
|
1573
|
+
function timecodeToFrame(tc, fps) {
|
|
1574
|
+
const normalized = tc.trim().replace(",", ".");
|
|
1575
|
+
const parts = normalized.split(":");
|
|
1576
|
+
let h = 0;
|
|
1577
|
+
let m;
|
|
1578
|
+
let s;
|
|
1579
|
+
let ms;
|
|
1580
|
+
if (parts.length === 3) {
|
|
1581
|
+
h = parseInt(parts[0], 10) || 0;
|
|
1582
|
+
m = parseInt(parts[1], 10) || 0;
|
|
1583
|
+
const sMs = parts[2].split(".");
|
|
1584
|
+
s = parseInt(sMs[0], 10) || 0;
|
|
1585
|
+
ms = parseInt(sMs[1] ?? "0", 10) || 0;
|
|
1586
|
+
} else if (parts.length === 2) {
|
|
1587
|
+
m = parseInt(parts[0], 10) || 0;
|
|
1588
|
+
const sMs = parts[1].split(".");
|
|
1589
|
+
s = parseInt(sMs[0], 10) || 0;
|
|
1590
|
+
ms = parseInt(sMs[1] ?? "0", 10) || 0;
|
|
1591
|
+
} else {
|
|
1592
|
+
return toFrame(0);
|
|
1593
|
+
}
|
|
1594
|
+
const totalSeconds = h * 3600 + m * 60 + s + ms / 1e3;
|
|
1595
|
+
return toFrame(Math.round(totalSeconds * fps));
|
|
1596
|
+
}
|
|
1597
|
+
function stripTags(text) {
|
|
1598
|
+
return text.replace(/<b>\s*<\/b>/gi, "").replace(/<i>\s*<\/i>/gi, "").replace(/<u>\s*<\/u>/gi, "").replace(/<b>/gi, "").replace(/<\/b>/gi, "").replace(/<i>/gi, "").replace(/<\/i>/gi, "").replace(/<u>/gi, "").replace(/<\/u>/gi, "").replace(/<font[^>]*>/gi, "").replace(/<\/font>/gi, "").replace(/<ruby>\s*<\/ruby>/gi, "").replace(/<rt>\s*<\/rt>/gi, "").replace(/<ruby>/gi, "").replace(/<\/ruby>/gi, "").replace(/<rt>/gi, "").replace(/<\/rt>/gi, "").replace(/<lang[^>]*>/gi, "").replace(/<\/lang>/gi, "").replace(/<[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}>/g, "");
|
|
1599
|
+
}
|
|
1600
|
+
var FULL_TIMECODE_RE = /^(\d{1,2}:\d{1,2}:\d{1,2}[,.]\d{3})\s*-->\s*(\d{1,2}:\d{1,2}:\d{1,2}[,.]\d{3})/;
|
|
1601
|
+
var SHORT_TIMECODE_RE = /^(\d{1,2}:\d{1,2}[,.]\d{3})\s*-->\s*(\d{1,2}:\d{1,2}[,.]\d{3})/;
|
|
1602
|
+
function matchTimecodeLine(line) {
|
|
1603
|
+
const full = line.match(FULL_TIMECODE_RE);
|
|
1604
|
+
if (full) return { start: full[1], end: full[2] };
|
|
1605
|
+
const short = line.match(SHORT_TIMECODE_RE);
|
|
1606
|
+
if (short) return { start: short[1], end: short[2] };
|
|
1607
|
+
return null;
|
|
1608
|
+
}
|
|
1609
|
+
function parseSRT(raw, fps, options) {
|
|
1610
|
+
const language = options?.language ?? "en-US";
|
|
1611
|
+
const burnIn = options?.burnIn ?? false;
|
|
1612
|
+
const style = { ...defaultCaptionStyle, ...options?.defaultStyle };
|
|
1613
|
+
const blocks = raw.split(/\r?\n\r?\n/).filter((b) => b.trim().length > 0);
|
|
1614
|
+
const captions = [];
|
|
1615
|
+
for (const block of blocks) {
|
|
1616
|
+
const lines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1617
|
+
if (lines.length < 2) continue;
|
|
1618
|
+
const indexStr = lines[0];
|
|
1619
|
+
const timecodeLine = lines[1];
|
|
1620
|
+
const matched = matchTimecodeLine(timecodeLine);
|
|
1621
|
+
if (!matched) continue;
|
|
1622
|
+
const startTc = matched.start.replace(",", ".");
|
|
1623
|
+
const endTc = matched.end.replace(",", ".");
|
|
1624
|
+
const textLines = lines.slice(2);
|
|
1625
|
+
const text = stripTags(textLines.join("\n"));
|
|
1626
|
+
const index = indexStr.replace(/\D/g, "") || indexStr;
|
|
1627
|
+
captions.push({
|
|
1628
|
+
id: toCaptionId(`srt-${index}`),
|
|
1629
|
+
text,
|
|
1630
|
+
startFrame: timecodeToFrame(startTc, fps),
|
|
1631
|
+
endFrame: timecodeToFrame(endTc, fps),
|
|
1632
|
+
language,
|
|
1633
|
+
style,
|
|
1634
|
+
burnIn
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
return captions;
|
|
1638
|
+
}
|
|
1639
|
+
function parseVTT(raw, fps, options) {
|
|
1640
|
+
const lines = raw.split(/\r?\n/);
|
|
1641
|
+
if (lines.length === 0 || !lines[0].trim().startsWith("WEBVTT")) {
|
|
1642
|
+
return [];
|
|
1643
|
+
}
|
|
1644
|
+
const language = options?.language ?? "en-US";
|
|
1645
|
+
const burnIn = options?.burnIn ?? false;
|
|
1646
|
+
const style = { ...defaultCaptionStyle, ...options?.defaultStyle };
|
|
1647
|
+
const captions = [];
|
|
1648
|
+
let cueIndex = 0;
|
|
1649
|
+
const blocks = raw.split(/\r?\n\r?\n/);
|
|
1650
|
+
for (const block of blocks) {
|
|
1651
|
+
const blockLines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
1652
|
+
if (blockLines.length === 0) continue;
|
|
1653
|
+
const first = blockLines[0];
|
|
1654
|
+
if (first.startsWith("NOTE") || first.startsWith("STYLE") || first.startsWith("REGION")) continue;
|
|
1655
|
+
let timecodeLine;
|
|
1656
|
+
let textLines;
|
|
1657
|
+
if (first.includes("-->")) {
|
|
1658
|
+
timecodeLine = first;
|
|
1659
|
+
textLines = blockLines.slice(1);
|
|
1660
|
+
} else if (blockLines.length >= 2 && blockLines[1].includes("-->")) {
|
|
1661
|
+
timecodeLine = blockLines[1];
|
|
1662
|
+
textLines = blockLines.slice(2);
|
|
1663
|
+
} else {
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
const matched = matchTimecodeLine(timecodeLine);
|
|
1667
|
+
if (!matched) continue;
|
|
1668
|
+
cueIndex++;
|
|
1669
|
+
const startTc = matched.start.replace(",", ".");
|
|
1670
|
+
const endTc = matched.end.replace(",", ".");
|
|
1671
|
+
const text = stripTags(textLines.join("\n"));
|
|
1672
|
+
captions.push({
|
|
1673
|
+
id: toCaptionId(`vtt-${cueIndex}`),
|
|
1674
|
+
text,
|
|
1675
|
+
startFrame: timecodeToFrame(startTc, fps),
|
|
1676
|
+
endFrame: timecodeToFrame(endTc, fps),
|
|
1677
|
+
language,
|
|
1678
|
+
style,
|
|
1679
|
+
burnIn
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
return captions;
|
|
1683
|
+
}
|
|
1684
|
+
function subtitleImportToOps(captions, trackId) {
|
|
1685
|
+
return captions.map((caption) => ({
|
|
1686
|
+
type: "ADD_CAPTION",
|
|
1687
|
+
caption,
|
|
1688
|
+
trackId
|
|
1689
|
+
}));
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// src/validation/invariants.ts
|
|
1693
|
+
function checkInvariants(state) {
|
|
1694
|
+
const violations = [];
|
|
1695
|
+
if (state.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1696
|
+
violations.push({
|
|
1697
|
+
type: "SCHEMA_VERSION_MISMATCH",
|
|
1698
|
+
entityId: "timeline",
|
|
1699
|
+
message: `Expected schema v${CURRENT_SCHEMA_VERSION}, got v${state.schemaVersion}`
|
|
1700
|
+
});
|
|
1701
|
+
return violations;
|
|
1702
|
+
}
|
|
1703
|
+
for (const track of state.timeline.tracks) {
|
|
1704
|
+
checkTrack(state, track, violations);
|
|
1705
|
+
}
|
|
1706
|
+
checkMarkerBounds(state, violations);
|
|
1707
|
+
checkInOutPoints(state, violations);
|
|
1708
|
+
checkBeatGrid(state, violations);
|
|
1709
|
+
checkLinkGroups(state, violations);
|
|
1710
|
+
checkTrackGroups(state, violations);
|
|
1711
|
+
return violations;
|
|
1712
|
+
}
|
|
1713
|
+
function checkTrack(state, track, violations) {
|
|
1714
|
+
const clips = track.clips;
|
|
1715
|
+
for (let i = 1; i < clips.length; i++) {
|
|
1716
|
+
const prev = clips[i - 1];
|
|
1717
|
+
const curr = clips[i];
|
|
1718
|
+
if (prev.timelineStart > curr.timelineStart) {
|
|
1719
|
+
violations.push({
|
|
1720
|
+
type: "TRACK_NOT_SORTED",
|
|
1721
|
+
entityId: track.id,
|
|
1722
|
+
message: `Track '${track.id}': clip '${curr.id}' (start=${curr.timelineStart}) appears after clip '${prev.id}' (start=${prev.timelineStart}) \u2014 not sorted.`
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
for (let i = 0; i < clips.length; i++) {
|
|
1727
|
+
for (let j = i + 1; j < clips.length; j++) {
|
|
1728
|
+
const a = clips[i];
|
|
1729
|
+
const b = clips[j];
|
|
1730
|
+
if (clipsOverlap(a, b)) {
|
|
1731
|
+
violations.push({
|
|
1732
|
+
type: "OVERLAP",
|
|
1733
|
+
entityId: a.id,
|
|
1734
|
+
message: `Track '${track.id}': clips '${a.id}' [${a.timelineStart}-${a.timelineEnd}) and '${b.id}' [${b.timelineStart}-${b.timelineEnd}) overlap.`
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
for (const clip of clips) {
|
|
1740
|
+
checkClip(state, track, clip, violations);
|
|
1741
|
+
}
|
|
1742
|
+
checkCaptionBounds(state, track, violations);
|
|
1743
|
+
}
|
|
1744
|
+
function checkClip(state, track, clip, violations) {
|
|
1745
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
1746
|
+
if (!asset) {
|
|
1747
|
+
violations.push({
|
|
1748
|
+
type: "ASSET_MISSING",
|
|
1749
|
+
entityId: clip.id,
|
|
1750
|
+
message: `Clip '${clip.id}' references asset '${clip.assetId}' which is not in the registry.`
|
|
1751
|
+
});
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
if (asset.mediaType !== track.type) {
|
|
1755
|
+
violations.push({
|
|
1756
|
+
type: "TRACK_TYPE_MISMATCH",
|
|
1757
|
+
entityId: clip.id,
|
|
1758
|
+
message: `Clip '${clip.id}' has asset mediaType '${asset.mediaType}' but is on a '${track.type}' track '${track.id}'.`
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
if (clip.mediaIn < 0) {
|
|
1762
|
+
violations.push({
|
|
1763
|
+
type: "MEDIA_BOUNDS_INVALID",
|
|
1764
|
+
entityId: clip.id,
|
|
1765
|
+
message: `Clip '${clip.id}': mediaIn (${clip.mediaIn}) must be >= 0.`
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
if (clip.mediaOut > asset.intrinsicDuration) {
|
|
1769
|
+
violations.push({
|
|
1770
|
+
type: "MEDIA_BOUNDS_INVALID",
|
|
1771
|
+
entityId: clip.id,
|
|
1772
|
+
message: `Clip '${clip.id}': mediaOut (${clip.mediaOut}) exceeds asset intrinsicDuration (${asset.intrinsicDuration}).`
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
const mediaDuration = clip.mediaOut - clip.mediaIn;
|
|
1776
|
+
const timelineDuration = clip.timelineEnd - clip.timelineStart;
|
|
1777
|
+
const expectedMediaDuration = timelineDuration / clip.speed;
|
|
1778
|
+
if (Math.abs(mediaDuration - expectedMediaDuration) > 0.5) {
|
|
1779
|
+
violations.push({
|
|
1780
|
+
type: "DURATION_MISMATCH",
|
|
1781
|
+
entityId: clip.id,
|
|
1782
|
+
message: `Clip '${clip.id}': mediaDuration (${mediaDuration}) \u2260 timelineDuration/speed (${expectedMediaDuration.toFixed(2)}).`
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
if (clip.timelineEnd > state.timeline.duration) {
|
|
1786
|
+
violations.push({
|
|
1787
|
+
type: "CLIP_BEYOND_TIMELINE",
|
|
1788
|
+
entityId: clip.id,
|
|
1789
|
+
message: `Clip '${clip.id}': timelineEnd (${clip.timelineEnd}) exceeds timeline duration (${state.timeline.duration}).`
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
if (clip.speed <= 0) {
|
|
1793
|
+
violations.push({
|
|
1794
|
+
type: "SPEED_INVALID",
|
|
1795
|
+
entityId: clip.id,
|
|
1796
|
+
message: `Clip '${clip.id}': speed (${clip.speed}) must be > 0.`
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
checkEffects(clip, violations);
|
|
1800
|
+
checkTransitions(clip, state, violations);
|
|
1801
|
+
}
|
|
1802
|
+
function checkEffects(clip, violations) {
|
|
1803
|
+
const effects = clip.effects ?? [];
|
|
1804
|
+
const validStages = ["preComposite", "postComposite", "output"];
|
|
1805
|
+
for (const effect of effects) {
|
|
1806
|
+
if (!validStages.includes(effect.renderStage)) {
|
|
1807
|
+
violations.push({
|
|
1808
|
+
type: "INVALID_RENDER_STAGE",
|
|
1809
|
+
entityId: effect.id,
|
|
1810
|
+
message: `Effect '${effect.id}': renderStage '${effect.renderStage}' is invalid.`
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
const kfs = effect.keyframes;
|
|
1814
|
+
for (let i = 1; i < kfs.length; i++) {
|
|
1815
|
+
const prev = kfs[i - 1];
|
|
1816
|
+
const curr = kfs[i];
|
|
1817
|
+
if (prev.frame > curr.frame || prev.frame === curr.frame) {
|
|
1818
|
+
violations.push({
|
|
1819
|
+
type: "KEYFRAME_ORDER_VIOLATION",
|
|
1820
|
+
entityId: effect.id,
|
|
1821
|
+
message: `Effect '${effect.id}': keyframes must be sorted ascending by frame with no duplicates.`
|
|
1822
|
+
});
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
function checkMarkerBounds(state, violations) {
|
|
1829
|
+
const dur = state.timeline.duration;
|
|
1830
|
+
for (const m of state.timeline.markers) {
|
|
1831
|
+
if (m.type === "point") {
|
|
1832
|
+
if (m.frame < 0) {
|
|
1833
|
+
violations.push({
|
|
1834
|
+
type: "MARKER_OUT_OF_BOUNDS",
|
|
1835
|
+
entityId: m.id,
|
|
1836
|
+
message: `Point marker '${m.id}' frame (${m.frame}) must be >= 0.`
|
|
1837
|
+
});
|
|
1838
|
+
}
|
|
1839
|
+
if (m.frame >= dur) {
|
|
1840
|
+
violations.push({
|
|
1841
|
+
type: "MARKER_OUT_OF_BOUNDS",
|
|
1842
|
+
entityId: m.id,
|
|
1843
|
+
message: `Point marker '${m.id}' frame (${m.frame}) must be < timeline duration (${dur}).`
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
} else {
|
|
1847
|
+
if (m.frameStart < 0) {
|
|
1848
|
+
violations.push({
|
|
1849
|
+
type: "MARKER_OUT_OF_BOUNDS",
|
|
1850
|
+
entityId: m.id,
|
|
1851
|
+
message: `Range marker '${m.id}' frameStart (${m.frameStart}) must be >= 0.`
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
if (m.frameStart >= dur) {
|
|
1855
|
+
violations.push({
|
|
1856
|
+
type: "MARKER_OUT_OF_BOUNDS",
|
|
1857
|
+
entityId: m.id,
|
|
1858
|
+
message: `Range marker '${m.id}' frameStart (${m.frameStart}) must be < timeline duration (${dur}).`
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
if (m.frameEnd > dur) {
|
|
1862
|
+
violations.push({
|
|
1863
|
+
type: "MARKER_OUT_OF_BOUNDS",
|
|
1864
|
+
entityId: m.id,
|
|
1865
|
+
message: `Range marker '${m.id}' frameEnd (${m.frameEnd}) exceeds timeline duration (${dur}).`
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
if (m.frameEnd <= m.frameStart) {
|
|
1869
|
+
violations.push({
|
|
1870
|
+
type: "MARKER_OUT_OF_BOUNDS",
|
|
1871
|
+
entityId: m.id,
|
|
1872
|
+
message: `Range marker '${m.id}' frameEnd must be > frameStart.`
|
|
1873
|
+
});
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
function checkInOutPoints(state, violations) {
|
|
1879
|
+
const dur = state.timeline.duration;
|
|
1880
|
+
const inPt = state.timeline.inPoint;
|
|
1881
|
+
const outPt = state.timeline.outPoint;
|
|
1882
|
+
if (inPt !== null && inPt < 0) {
|
|
1883
|
+
violations.push({
|
|
1884
|
+
type: "IN_OUT_INVALID",
|
|
1885
|
+
entityId: "timeline",
|
|
1886
|
+
message: `In point (${inPt}) must be >= 0.`
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
if (outPt !== null && outPt > dur) {
|
|
1890
|
+
violations.push({
|
|
1891
|
+
type: "IN_OUT_INVALID",
|
|
1892
|
+
entityId: "timeline",
|
|
1893
|
+
message: `Out point (${outPt}) must be <= timeline duration (${dur}).`
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
if (inPt !== null && outPt !== null && inPt >= outPt) {
|
|
1897
|
+
violations.push({
|
|
1898
|
+
type: "IN_OUT_INVALID",
|
|
1899
|
+
entityId: "timeline",
|
|
1900
|
+
message: `In point (${inPt}) must be < out point (${outPt}).`
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
function checkBeatGrid(state, violations) {
|
|
1905
|
+
const bg = state.timeline.beatGrid;
|
|
1906
|
+
if (bg === null) return;
|
|
1907
|
+
if (bg.bpm <= 0) {
|
|
1908
|
+
violations.push({
|
|
1909
|
+
type: "BEAT_GRID_INVALID",
|
|
1910
|
+
entityId: "timeline",
|
|
1911
|
+
message: `Beat grid bpm (${bg.bpm}) must be > 0.`
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
if (bg.timeSignature[0] <= 0 || bg.timeSignature[1] <= 0) {
|
|
1915
|
+
violations.push({
|
|
1916
|
+
type: "BEAT_GRID_INVALID",
|
|
1917
|
+
entityId: "timeline",
|
|
1918
|
+
message: `Beat grid timeSignature must be positive.`
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
function checkCaptionBounds(state, track, violations) {
|
|
1923
|
+
const dur = state.timeline.duration;
|
|
1924
|
+
for (const cap of track.captions) {
|
|
1925
|
+
if (cap.endFrame > dur) {
|
|
1926
|
+
violations.push({
|
|
1927
|
+
type: "CAPTION_OUT_OF_BOUNDS",
|
|
1928
|
+
entityId: cap.id,
|
|
1929
|
+
message: `Caption '${cap.id}' endFrame (${cap.endFrame}) exceeds timeline duration (${dur}).`
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
if (cap.endFrame <= cap.startFrame) {
|
|
1933
|
+
violations.push({
|
|
1934
|
+
type: "CAPTION_OUT_OF_BOUNDS",
|
|
1935
|
+
entityId: cap.id,
|
|
1936
|
+
message: `Caption '${cap.id}' endFrame must be > startFrame.`
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
const byStart = [...track.captions].sort((a, b) => a.startFrame - b.startFrame);
|
|
1941
|
+
for (let i = 0; i < byStart.length - 1; i++) {
|
|
1942
|
+
const a = byStart[i];
|
|
1943
|
+
const b = byStart[i + 1];
|
|
1944
|
+
if (a.endFrame > b.startFrame) {
|
|
1945
|
+
violations.push({
|
|
1946
|
+
type: "CAPTION_OVERLAP",
|
|
1947
|
+
entityId: track.id,
|
|
1948
|
+
message: `Captions '${a.id}' and '${b.id}' overlap on track '${track.id}'.`
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
var VALID_TRANSITION_ALIGNMENTS = ["centerOnCut", "endAtCut", "startAtCut"];
|
|
1954
|
+
function checkTransitions(clip, state, violations) {
|
|
1955
|
+
const trans = clip.transition;
|
|
1956
|
+
if (!trans) return;
|
|
1957
|
+
if (trans.durationFrames <= 0) {
|
|
1958
|
+
violations.push({
|
|
1959
|
+
type: "INVALID_RANGE",
|
|
1960
|
+
entityId: clip.id,
|
|
1961
|
+
message: `Clip '${clip.id}' transition durationFrames (${trans.durationFrames}) must be > 0.`
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
if (!VALID_TRANSITION_ALIGNMENTS.includes(trans.alignment)) {
|
|
1965
|
+
violations.push({
|
|
1966
|
+
type: "INVALID_RANGE",
|
|
1967
|
+
entityId: clip.id,
|
|
1968
|
+
message: `Clip '${clip.id}' transition alignment '${trans.alignment}' is invalid.`
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
function checkLinkGroups(state, violations) {
|
|
1973
|
+
const groups = state.timeline.linkGroups ?? [];
|
|
1974
|
+
const clipIdsInGroups = /* @__PURE__ */ new Map();
|
|
1975
|
+
for (const g of groups) {
|
|
1976
|
+
if (g.clipIds.length < 2) {
|
|
1977
|
+
violations.push({
|
|
1978
|
+
type: "INVALID_RANGE",
|
|
1979
|
+
entityId: g.id,
|
|
1980
|
+
message: `Link group '${g.id}' must have at least 2 clipIds.`
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
for (const cid of g.clipIds) {
|
|
1984
|
+
const clip = findClipInState(state, cid);
|
|
1985
|
+
if (!clip) {
|
|
1986
|
+
violations.push({
|
|
1987
|
+
type: "LINK_GROUP_NOT_FOUND",
|
|
1988
|
+
entityId: g.id,
|
|
1989
|
+
message: `Link group '${g.id}' references non-existent clip '${cid}'.`
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
const count = (clipIdsInGroups.get(cid) ?? 0) + 1;
|
|
1993
|
+
clipIdsInGroups.set(cid, count);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
for (const [cid, count] of clipIdsInGroups) {
|
|
1997
|
+
if (count > 1) {
|
|
1998
|
+
violations.push({
|
|
1999
|
+
type: "INVALID_RANGE",
|
|
2000
|
+
entityId: cid,
|
|
2001
|
+
message: `Clip '${cid}' appears in more than one link group.`
|
|
2002
|
+
});
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
function checkTrackGroups(state, violations) {
|
|
2007
|
+
const groups = state.timeline.trackGroups ?? [];
|
|
2008
|
+
const groupIds = new Set(groups.map((g) => g.id));
|
|
2009
|
+
for (const g of groups) {
|
|
2010
|
+
for (const tid of g.trackIds) {
|
|
2011
|
+
if (!state.timeline.tracks.some((t) => t.id === tid)) {
|
|
2012
|
+
violations.push({
|
|
2013
|
+
type: "TRACK_GROUP_NOT_FOUND",
|
|
2014
|
+
entityId: g.id,
|
|
2015
|
+
message: `Track group '${g.id}' references non-existent track '${tid}'.`
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
for (const track of state.timeline.tracks) {
|
|
2021
|
+
const gid = track.groupId;
|
|
2022
|
+
if (gid !== void 0 && !groupIds.has(gid)) {
|
|
2023
|
+
violations.push({
|
|
2024
|
+
type: "TRACK_GROUP_NOT_FOUND",
|
|
2025
|
+
entityId: track.id,
|
|
2026
|
+
message: `Track '${track.id}' has groupId '${gid}' which does not exist.`
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
function findClipInState(state, clipId) {
|
|
2032
|
+
for (const track of state.timeline.tracks) {
|
|
2033
|
+
const clip = track.clips.find((c) => c.id === clipId);
|
|
2034
|
+
if (clip) return clip;
|
|
2035
|
+
}
|
|
2036
|
+
return void 0;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/engine/apply.ts
|
|
2040
|
+
function applyOperation2(state, op) {
|
|
2041
|
+
switch (op.type) {
|
|
2042
|
+
// — Clip operations ——————————————————————————————————————————————————
|
|
2043
|
+
case "INSERT_CLIP": {
|
|
2044
|
+
return updateTrack2(
|
|
2045
|
+
state,
|
|
2046
|
+
op.trackId,
|
|
2047
|
+
(track) => sortTrackClips({ ...track, clips: [...track.clips, op.clip] })
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
case "DELETE_CLIP": {
|
|
2051
|
+
return updateTrackOfClip(state, op.clipId, (track) => ({
|
|
2052
|
+
...track,
|
|
2053
|
+
clips: track.clips.filter((c) => c.id !== op.clipId)
|
|
2054
|
+
}));
|
|
2055
|
+
}
|
|
2056
|
+
case "MOVE_CLIP": {
|
|
2057
|
+
const targetTrackId = op.targetTrackId;
|
|
2058
|
+
let foundClip;
|
|
2059
|
+
for (const track of state.timeline.tracks) {
|
|
2060
|
+
const c = track.clips.find((c2) => c2.id === op.clipId);
|
|
2061
|
+
if (c) {
|
|
2062
|
+
foundClip = c;
|
|
2063
|
+
break;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
if (!foundClip) return state;
|
|
2067
|
+
const delta = op.newTimelineStart - foundClip.timelineStart;
|
|
2068
|
+
const movedClip = {
|
|
2069
|
+
...foundClip,
|
|
2070
|
+
trackId: targetTrackId ?? foundClip.trackId,
|
|
2071
|
+
timelineStart: op.newTimelineStart,
|
|
2072
|
+
timelineEnd: foundClip.timelineEnd + delta
|
|
2073
|
+
};
|
|
2074
|
+
const effectiveTargetTrackId = targetTrackId ?? foundClip.trackId;
|
|
2075
|
+
const isCrossTrack = effectiveTargetTrackId !== foundClip.trackId;
|
|
2076
|
+
let stateWithMovedClip;
|
|
2077
|
+
if (!isCrossTrack) {
|
|
2078
|
+
stateWithMovedClip = updateClip2(state, op.clipId, () => movedClip);
|
|
2079
|
+
} else {
|
|
2080
|
+
const newTracks = state.timeline.tracks.map((track) => {
|
|
2081
|
+
if (track.id === foundClip.trackId) {
|
|
2082
|
+
return { ...track, clips: track.clips.filter((c) => c.id !== op.clipId) };
|
|
2083
|
+
}
|
|
2084
|
+
if (track.id === effectiveTargetTrackId) {
|
|
2085
|
+
return sortTrackClips({ ...track, clips: [...track.clips, movedClip] });
|
|
2086
|
+
}
|
|
2087
|
+
return track;
|
|
2088
|
+
});
|
|
2089
|
+
stateWithMovedClip = { ...state, timeline: { ...state.timeline, tracks: newTracks } };
|
|
2090
|
+
}
|
|
2091
|
+
const shiftedMarkers = shiftLinkedMarkers(
|
|
2092
|
+
stateWithMovedClip.timeline.markers,
|
|
2093
|
+
op.clipId,
|
|
2094
|
+
delta
|
|
2095
|
+
);
|
|
2096
|
+
return {
|
|
2097
|
+
...stateWithMovedClip,
|
|
2098
|
+
timeline: { ...stateWithMovedClip.timeline, markers: shiftedMarkers }
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
case "RESIZE_CLIP": {
|
|
2102
|
+
return updateClip2(state, op.clipId, (clip) => {
|
|
2103
|
+
if (op.edge === "start") {
|
|
2104
|
+
const delta = op.newFrame - clip.timelineStart;
|
|
2105
|
+
return {
|
|
2106
|
+
...clip,
|
|
2107
|
+
timelineStart: op.newFrame,
|
|
2108
|
+
mediaIn: clip.mediaIn + delta
|
|
2109
|
+
};
|
|
2110
|
+
} else {
|
|
2111
|
+
const delta = op.newFrame - clip.timelineEnd;
|
|
2112
|
+
return {
|
|
2113
|
+
...clip,
|
|
2114
|
+
timelineEnd: op.newFrame,
|
|
2115
|
+
mediaOut: clip.mediaOut + delta
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
case "SLICE_CLIP": {
|
|
2121
|
+
return state;
|
|
2122
|
+
}
|
|
2123
|
+
case "SET_MEDIA_BOUNDS": {
|
|
2124
|
+
return updateClip2(state, op.clipId, (clip) => ({
|
|
2125
|
+
...clip,
|
|
2126
|
+
mediaIn: op.mediaIn,
|
|
2127
|
+
mediaOut: op.mediaOut
|
|
2128
|
+
}));
|
|
2129
|
+
}
|
|
2130
|
+
case "SET_CLIP_ENABLED": {
|
|
2131
|
+
return updateClip2(state, op.clipId, (clip) => ({ ...clip, enabled: op.enabled }));
|
|
2132
|
+
}
|
|
2133
|
+
case "SET_CLIP_REVERSED": {
|
|
2134
|
+
return updateClip2(state, op.clipId, (clip) => ({ ...clip, reversed: op.reversed }));
|
|
2135
|
+
}
|
|
2136
|
+
case "SET_CLIP_SPEED": {
|
|
2137
|
+
return updateClip2(state, op.clipId, (clip) => ({ ...clip, speed: op.speed }));
|
|
2138
|
+
}
|
|
2139
|
+
case "SET_CLIP_COLOR": {
|
|
2140
|
+
return updateClip2(state, op.clipId, (clip) => ({ ...clip, color: op.color }));
|
|
2141
|
+
}
|
|
2142
|
+
case "SET_CLIP_NAME": {
|
|
2143
|
+
return updateClip2(state, op.clipId, (clip) => ({ ...clip, name: op.name }));
|
|
2144
|
+
}
|
|
2145
|
+
// — Track operations ——————————————————————————————————————————————————
|
|
2146
|
+
case "ADD_TRACK": {
|
|
2147
|
+
return {
|
|
2148
|
+
...state,
|
|
2149
|
+
timeline: {
|
|
2150
|
+
...state.timeline,
|
|
2151
|
+
tracks: [...state.timeline.tracks, op.track]
|
|
2152
|
+
}
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
case "DELETE_TRACK": {
|
|
2156
|
+
return {
|
|
2157
|
+
...state,
|
|
2158
|
+
timeline: {
|
|
2159
|
+
...state.timeline,
|
|
2160
|
+
tracks: state.timeline.tracks.filter((t) => t.id !== op.trackId)
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
case "REORDER_TRACK": {
|
|
2165
|
+
const tracks = [...state.timeline.tracks];
|
|
2166
|
+
const idx = tracks.findIndex((t) => t.id === op.trackId);
|
|
2167
|
+
if (idx === -1) return state;
|
|
2168
|
+
const [track] = tracks.splice(idx, 1);
|
|
2169
|
+
if (!track) return state;
|
|
2170
|
+
tracks.splice(op.newIndex, 0, track);
|
|
2171
|
+
return { ...state, timeline: { ...state.timeline, tracks } };
|
|
2172
|
+
}
|
|
2173
|
+
case "SET_TRACK_HEIGHT": {
|
|
2174
|
+
return updateTrack2(state, op.trackId, (t) => ({ ...t, height: op.height }));
|
|
2175
|
+
}
|
|
2176
|
+
case "SET_TRACK_NAME": {
|
|
2177
|
+
return updateTrack2(state, op.trackId, (t) => ({ ...t, name: op.name }));
|
|
2178
|
+
}
|
|
2179
|
+
// — Asset operations ——————————————————————————————————————————————————
|
|
2180
|
+
case "REGISTER_ASSET": {
|
|
2181
|
+
const next = new Map(state.assetRegistry);
|
|
2182
|
+
next.set(op.asset.id, op.asset);
|
|
2183
|
+
return { ...state, assetRegistry: next };
|
|
2184
|
+
}
|
|
2185
|
+
case "UNREGISTER_ASSET": {
|
|
2186
|
+
const next = new Map(state.assetRegistry);
|
|
2187
|
+
next.delete(op.assetId);
|
|
2188
|
+
return { ...state, assetRegistry: next };
|
|
2189
|
+
}
|
|
2190
|
+
case "SET_ASSET_STATUS": {
|
|
2191
|
+
const asset = state.assetRegistry.get(op.assetId);
|
|
2192
|
+
if (!asset) return state;
|
|
2193
|
+
const next = new Map(state.assetRegistry);
|
|
2194
|
+
next.set(op.assetId, { ...asset, status: op.status });
|
|
2195
|
+
return { ...state, assetRegistry: next };
|
|
2196
|
+
}
|
|
2197
|
+
// — Timeline operations ———————————————————————————————————————————————
|
|
2198
|
+
case "RENAME_TIMELINE": {
|
|
2199
|
+
return { ...state, timeline: { ...state.timeline, name: op.name } };
|
|
2200
|
+
}
|
|
2201
|
+
case "SET_TIMELINE_DURATION": {
|
|
2202
|
+
return { ...state, timeline: { ...state.timeline, duration: op.duration } };
|
|
2203
|
+
}
|
|
2204
|
+
case "SET_TIMELINE_START_TC": {
|
|
2205
|
+
return { ...state, timeline: { ...state.timeline, startTimecode: op.startTimecode } };
|
|
2206
|
+
}
|
|
2207
|
+
case "SET_SEQUENCE_SETTINGS": {
|
|
2208
|
+
return {
|
|
2209
|
+
...state,
|
|
2210
|
+
timeline: {
|
|
2211
|
+
...state.timeline,
|
|
2212
|
+
sequenceSettings: { ...state.timeline.sequenceSettings, ...op.settings }
|
|
2213
|
+
}
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
// — Phase 3: Marker operations ————————————————————————————————————————
|
|
2217
|
+
case "ADD_MARKER": {
|
|
2218
|
+
const markers = [...state.timeline.markers, op.marker].sort(sortMarkersByAnchor);
|
|
2219
|
+
return { ...state, timeline: { ...state.timeline, markers } };
|
|
2220
|
+
}
|
|
2221
|
+
case "MOVE_MARKER": {
|
|
2222
|
+
const marker = state.timeline.markers.find((m) => m.id === op.markerId);
|
|
2223
|
+
if (!marker) return state;
|
|
2224
|
+
const updated = marker.type === "point" ? { ...marker, frame: op.newFrame } : {
|
|
2225
|
+
...marker,
|
|
2226
|
+
frameStart: op.newFrame,
|
|
2227
|
+
frameEnd: op.newFrame + (marker.frameEnd - marker.frameStart)
|
|
2228
|
+
};
|
|
2229
|
+
const markers = state.timeline.markers.map((m) => m.id === op.markerId ? updated : m).sort(sortMarkersByAnchor);
|
|
2230
|
+
return { ...state, timeline: { ...state.timeline, markers } };
|
|
2231
|
+
}
|
|
2232
|
+
case "DELETE_MARKER": {
|
|
2233
|
+
const markers = state.timeline.markers.filter((m) => m.id !== op.markerId);
|
|
2234
|
+
return { ...state, timeline: { ...state.timeline, markers } };
|
|
2235
|
+
}
|
|
2236
|
+
case "SET_IN_POINT": {
|
|
2237
|
+
return { ...state, timeline: { ...state.timeline, inPoint: op.frame } };
|
|
2238
|
+
}
|
|
2239
|
+
case "SET_OUT_POINT": {
|
|
2240
|
+
return { ...state, timeline: { ...state.timeline, outPoint: op.frame } };
|
|
2241
|
+
}
|
|
2242
|
+
case "ADD_BEAT_GRID": {
|
|
2243
|
+
return { ...state, timeline: { ...state.timeline, beatGrid: op.beatGrid } };
|
|
2244
|
+
}
|
|
2245
|
+
case "REMOVE_BEAT_GRID": {
|
|
2246
|
+
return { ...state, timeline: { ...state.timeline, beatGrid: null } };
|
|
2247
|
+
}
|
|
2248
|
+
case "INSERT_GENERATOR": {
|
|
2249
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2250
|
+
if (!track) return state;
|
|
2251
|
+
const genAsset = createGeneratorAsset({
|
|
2252
|
+
id: op.generator.id,
|
|
2253
|
+
name: op.generator.name,
|
|
2254
|
+
mediaType: track.type,
|
|
2255
|
+
generatorDef: op.generator,
|
|
2256
|
+
nativeFps: state.timeline.fps
|
|
2257
|
+
});
|
|
2258
|
+
const clip = createClip({
|
|
2259
|
+
id: `gen-clip-${op.generator.id}`,
|
|
2260
|
+
assetId: genAsset.id,
|
|
2261
|
+
trackId: op.trackId,
|
|
2262
|
+
timelineStart: op.atFrame,
|
|
2263
|
+
timelineEnd: op.atFrame + op.generator.duration,
|
|
2264
|
+
mediaIn: 0,
|
|
2265
|
+
mediaOut: op.generator.duration
|
|
2266
|
+
});
|
|
2267
|
+
const nextRegistry = new Map(state.assetRegistry);
|
|
2268
|
+
nextRegistry.set(genAsset.id, genAsset);
|
|
2269
|
+
return updateTrack2(
|
|
2270
|
+
{ ...state, assetRegistry: nextRegistry },
|
|
2271
|
+
op.trackId,
|
|
2272
|
+
(t) => sortTrackClips({ ...t, clips: [...t.clips, clip] })
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
case "ADD_CAPTION": {
|
|
2276
|
+
const captionToAdd = {
|
|
2277
|
+
...op.caption,
|
|
2278
|
+
style: op.caption.style ?? defaultCaptionStyle
|
|
2279
|
+
};
|
|
2280
|
+
return updateTrack2(state, op.trackId, (track) => {
|
|
2281
|
+
const captions = [...track.captions, captionToAdd].sort(
|
|
2282
|
+
(a, b) => a.startFrame - b.startFrame
|
|
2283
|
+
);
|
|
2284
|
+
return { ...track, captions };
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
case "EDIT_CAPTION": {
|
|
2288
|
+
return updateTrack2(state, op.trackId, (track) => {
|
|
2289
|
+
const cap = track.captions.find((c) => c.id === op.captionId);
|
|
2290
|
+
if (!cap) return track;
|
|
2291
|
+
const updated = {
|
|
2292
|
+
...cap,
|
|
2293
|
+
...op.text !== void 0 && { text: op.text },
|
|
2294
|
+
...op.language !== void 0 && { language: op.language },
|
|
2295
|
+
...op.style !== void 0 && { style: { ...cap.style, ...op.style } },
|
|
2296
|
+
...op.burnIn !== void 0 && { burnIn: op.burnIn },
|
|
2297
|
+
...op.startFrame !== void 0 && { startFrame: op.startFrame },
|
|
2298
|
+
...op.endFrame !== void 0 && { endFrame: op.endFrame }
|
|
2299
|
+
};
|
|
2300
|
+
const captions = track.captions.map((c) => c.id === op.captionId ? updated : c).sort((a, b) => a.startFrame - b.startFrame);
|
|
2301
|
+
return { ...track, captions };
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
case "DELETE_CAPTION": {
|
|
2305
|
+
return updateTrack2(state, op.trackId, (track) => ({
|
|
2306
|
+
...track,
|
|
2307
|
+
captions: track.captions.filter((c) => c.id !== op.captionId)
|
|
2308
|
+
}));
|
|
2309
|
+
}
|
|
2310
|
+
// — Phase 4: Effect & Keyframe ————————————————————————————————————————
|
|
2311
|
+
case "ADD_EFFECT": {
|
|
2312
|
+
return updateClipEffects(state, op.clipId, (effects) => [...effects, op.effect]);
|
|
2313
|
+
}
|
|
2314
|
+
case "REMOVE_EFFECT": {
|
|
2315
|
+
return updateClipEffects(
|
|
2316
|
+
state,
|
|
2317
|
+
op.clipId,
|
|
2318
|
+
(effects) => effects.filter((e) => e.id !== op.effectId)
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
case "REORDER_EFFECT": {
|
|
2322
|
+
return updateClipEffects(state, op.clipId, (effects) => {
|
|
2323
|
+
const idx = effects.findIndex((e) => e.id === op.effectId);
|
|
2324
|
+
if (idx < 0) return effects;
|
|
2325
|
+
const arr = [...effects];
|
|
2326
|
+
const [removed] = arr.splice(idx, 1);
|
|
2327
|
+
if (!removed) return effects;
|
|
2328
|
+
arr.splice(op.newIndex, 0, removed);
|
|
2329
|
+
return arr;
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
case "SET_EFFECT_ENABLED": {
|
|
2333
|
+
return updateClipEffects(
|
|
2334
|
+
state,
|
|
2335
|
+
op.clipId,
|
|
2336
|
+
(effects) => effects.map((e) => e.id === op.effectId ? { ...e, enabled: op.enabled } : e)
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
case "SET_EFFECT_PARAM": {
|
|
2340
|
+
return updateClipEffects(
|
|
2341
|
+
state,
|
|
2342
|
+
op.clipId,
|
|
2343
|
+
(effects) => effects.map((e) => {
|
|
2344
|
+
if (e.id !== op.effectId) return e;
|
|
2345
|
+
const idx = e.params.findIndex((p) => p.key === op.key);
|
|
2346
|
+
const newParams = idx >= 0 ? e.params.map((p, i) => i === idx ? { key: op.key, value: op.value } : p) : [...e.params, { key: op.key, value: op.value }];
|
|
2347
|
+
return { ...e, params: newParams };
|
|
2348
|
+
})
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
case "ADD_KEYFRAME": {
|
|
2352
|
+
return updateClipEffects(
|
|
2353
|
+
state,
|
|
2354
|
+
op.clipId,
|
|
2355
|
+
(effects) => effects.map((e) => {
|
|
2356
|
+
if (e.id !== op.effectId) return e;
|
|
2357
|
+
const keyframes = [...e.keyframes, op.keyframe].sort((a, b) => a.frame - b.frame);
|
|
2358
|
+
return { ...e, keyframes };
|
|
2359
|
+
})
|
|
2360
|
+
);
|
|
2361
|
+
}
|
|
2362
|
+
case "MOVE_KEYFRAME": {
|
|
2363
|
+
return updateClipEffects(
|
|
2364
|
+
state,
|
|
2365
|
+
op.clipId,
|
|
2366
|
+
(effects) => effects.map((e) => {
|
|
2367
|
+
if (e.id !== op.effectId) return e;
|
|
2368
|
+
const keyframes = e.keyframes.map((k) => k.id === op.keyframeId ? { ...k, frame: op.newFrame } : k).sort((a, b) => a.frame - b.frame);
|
|
2369
|
+
return { ...e, keyframes };
|
|
2370
|
+
})
|
|
2371
|
+
);
|
|
2372
|
+
}
|
|
2373
|
+
case "DELETE_KEYFRAME": {
|
|
2374
|
+
return updateClipEffects(
|
|
2375
|
+
state,
|
|
2376
|
+
op.clipId,
|
|
2377
|
+
(effects) => effects.map(
|
|
2378
|
+
(e) => e.id === op.effectId ? { ...e, keyframes: e.keyframes.filter((k) => k.id !== op.keyframeId) } : e
|
|
2379
|
+
)
|
|
2380
|
+
);
|
|
2381
|
+
}
|
|
2382
|
+
case "SET_KEYFRAME_EASING": {
|
|
2383
|
+
return updateClipEffects(
|
|
2384
|
+
state,
|
|
2385
|
+
op.clipId,
|
|
2386
|
+
(effects) => effects.map((e) => ({
|
|
2387
|
+
...e,
|
|
2388
|
+
keyframes: e.keyframes.map(
|
|
2389
|
+
(k) => k.id === op.keyframeId ? { ...k, easing: op.easing } : k
|
|
2390
|
+
)
|
|
2391
|
+
}))
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
// — Phase 4 Step 3: Transform, Audio, Transitions, Groups ———————————————
|
|
2395
|
+
case "SET_CLIP_TRANSFORM": {
|
|
2396
|
+
return updateClip2(state, op.clipId, (clip) => {
|
|
2397
|
+
const base = clip.transform ?? DEFAULT_CLIP_TRANSFORM;
|
|
2398
|
+
const p = op.transform;
|
|
2399
|
+
const merged = {
|
|
2400
|
+
positionX: p.positionX ?? base.positionX,
|
|
2401
|
+
positionY: p.positionY ?? base.positionY,
|
|
2402
|
+
scaleX: p.scaleX ?? base.scaleX,
|
|
2403
|
+
scaleY: p.scaleY ?? base.scaleY,
|
|
2404
|
+
rotation: p.rotation ?? base.rotation,
|
|
2405
|
+
opacity: p.opacity ?? base.opacity,
|
|
2406
|
+
anchorX: p.anchorX ?? base.anchorX,
|
|
2407
|
+
anchorY: p.anchorY ?? base.anchorY
|
|
2408
|
+
};
|
|
2409
|
+
return { ...clip, transform: merged };
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
case "SET_AUDIO_PROPERTIES": {
|
|
2413
|
+
return updateClip2(state, op.clipId, (clip) => {
|
|
2414
|
+
const base = clip.audio ?? DEFAULT_AUDIO_PROPERTIES;
|
|
2415
|
+
const merged = { ...base, ...op.properties };
|
|
2416
|
+
return { ...clip, audio: merged };
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
case "ADD_TRANSITION": {
|
|
2420
|
+
return updateClip2(state, op.clipId, (clip) => ({ ...clip, transition: op.transition }));
|
|
2421
|
+
}
|
|
2422
|
+
case "DELETE_TRANSITION": {
|
|
2423
|
+
return updateClip2(state, op.clipId, (clip) => {
|
|
2424
|
+
const { transition: _, ...rest } = clip;
|
|
2425
|
+
return rest;
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
case "SET_TRANSITION_DURATION": {
|
|
2429
|
+
return updateClip2(state, op.clipId, (clip) => {
|
|
2430
|
+
if (!clip.transition) return clip;
|
|
2431
|
+
return { ...clip, transition: { ...clip.transition, durationFrames: op.durationFrames } };
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
case "SET_TRANSITION_ALIGNMENT": {
|
|
2435
|
+
return updateClip2(state, op.clipId, (clip) => {
|
|
2436
|
+
if (!clip.transition) return clip;
|
|
2437
|
+
return { ...clip, transition: { ...clip.transition, alignment: op.alignment } };
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
case "LINK_CLIPS": {
|
|
2441
|
+
const linkGroups = [...state.timeline.linkGroups ?? [], op.linkGroup];
|
|
2442
|
+
return { ...state, timeline: { ...state.timeline, linkGroups } };
|
|
2443
|
+
}
|
|
2444
|
+
case "UNLINK_CLIPS": {
|
|
2445
|
+
const linkGroups = (state.timeline.linkGroups ?? []).filter((g) => g.id !== op.linkGroupId);
|
|
2446
|
+
return { ...state, timeline: { ...state.timeline, linkGroups } };
|
|
2447
|
+
}
|
|
2448
|
+
case "ADD_TRACK_GROUP": {
|
|
2449
|
+
const trackGroups = [...state.timeline.trackGroups ?? [], op.trackGroup];
|
|
2450
|
+
const tracks = state.timeline.tracks.map(
|
|
2451
|
+
(t) => op.trackGroup.trackIds.some((id) => id === t.id) ? { ...t, groupId: op.trackGroup.id } : t
|
|
2452
|
+
);
|
|
2453
|
+
return {
|
|
2454
|
+
...state,
|
|
2455
|
+
timeline: { ...state.timeline, trackGroups, tracks }
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
case "DELETE_TRACK_GROUP": {
|
|
2459
|
+
const trackGroups = (state.timeline.trackGroups ?? []).filter((g) => g.id !== op.trackGroupId);
|
|
2460
|
+
const tracks = state.timeline.tracks.map((t) => {
|
|
2461
|
+
if (t.groupId !== op.trackGroupId) return t;
|
|
2462
|
+
const { groupId: _, ...rest } = t;
|
|
2463
|
+
return rest;
|
|
2464
|
+
});
|
|
2465
|
+
return {
|
|
2466
|
+
...state,
|
|
2467
|
+
timeline: { ...state.timeline, trackGroups, tracks }
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
case "SET_TRACK_BLEND_MODE": {
|
|
2471
|
+
return updateTrack2(state, op.trackId, (t) => ({ ...t, blendMode: op.blendMode }));
|
|
2472
|
+
}
|
|
2473
|
+
case "SET_TRACK_OPACITY": {
|
|
2474
|
+
return updateTrack2(state, op.trackId, (t) => ({ ...t, opacity: op.opacity }));
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
function shiftLinkedMarkers(markers, clipId, delta) {
|
|
2479
|
+
const shifted = markers.map((m) => {
|
|
2480
|
+
if (m.clipId !== clipId) return m;
|
|
2481
|
+
if (m.type === "point") {
|
|
2482
|
+
return { ...m, frame: m.frame + delta };
|
|
2483
|
+
}
|
|
2484
|
+
return {
|
|
2485
|
+
...m,
|
|
2486
|
+
frameStart: m.frameStart + delta,
|
|
2487
|
+
frameEnd: m.frameEnd + delta
|
|
2488
|
+
};
|
|
2489
|
+
});
|
|
2490
|
+
return [...shifted].sort(sortMarkersByAnchor);
|
|
2491
|
+
}
|
|
2492
|
+
function sortMarkersByAnchor(a, b) {
|
|
2493
|
+
const anchorA = a.type === "point" ? a.frame : a.frameStart;
|
|
2494
|
+
const anchorB = b.type === "point" ? b.frame : b.frameStart;
|
|
2495
|
+
return anchorA - anchorB;
|
|
2496
|
+
}
|
|
2497
|
+
function updateClipEffects(state, clipId, fn) {
|
|
2498
|
+
return updateClip2(state, clipId, (clip) => ({
|
|
2499
|
+
...clip,
|
|
2500
|
+
effects: fn(clip.effects ?? [])
|
|
2501
|
+
}));
|
|
2502
|
+
}
|
|
2503
|
+
function updateTrack2(state, trackId, fn) {
|
|
2504
|
+
return {
|
|
2505
|
+
...state,
|
|
2506
|
+
timeline: {
|
|
2507
|
+
...state.timeline,
|
|
2508
|
+
tracks: state.timeline.tracks.map((t) => t.id === trackId ? fn(t) : t)
|
|
2509
|
+
}
|
|
2510
|
+
};
|
|
2511
|
+
}
|
|
2512
|
+
function updateTrackOfClip(state, clipId, fn) {
|
|
2513
|
+
return {
|
|
2514
|
+
...state,
|
|
2515
|
+
timeline: {
|
|
2516
|
+
...state.timeline,
|
|
2517
|
+
tracks: state.timeline.tracks.map(
|
|
2518
|
+
(t) => t.clips.some((c) => c.id === clipId) ? fn(t) : t
|
|
2519
|
+
)
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
function updateClip2(state, clipId, fn) {
|
|
2524
|
+
return {
|
|
2525
|
+
...state,
|
|
2526
|
+
timeline: {
|
|
2527
|
+
...state.timeline,
|
|
2528
|
+
tracks: state.timeline.tracks.map((track) => {
|
|
2529
|
+
if (!track.clips.some((c) => c.id === clipId)) return track;
|
|
2530
|
+
const updatedTrack = {
|
|
2531
|
+
...track,
|
|
2532
|
+
clips: track.clips.map((c) => c.id === clipId ? fn(c) : c)
|
|
2533
|
+
};
|
|
2534
|
+
return sortTrackClips(updatedTrack);
|
|
2535
|
+
})
|
|
2536
|
+
}
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/validation/validators.ts
|
|
2541
|
+
function validateOperation(state, op) {
|
|
2542
|
+
switch (op.type) {
|
|
2543
|
+
case "MOVE_CLIP":
|
|
2544
|
+
return validateMoveClip(state, op);
|
|
2545
|
+
case "RESIZE_CLIP":
|
|
2546
|
+
return validateResizeClip(state, op);
|
|
2547
|
+
case "SLICE_CLIP":
|
|
2548
|
+
return validateSliceClip(state, op);
|
|
2549
|
+
case "DELETE_CLIP":
|
|
2550
|
+
return validateDeleteClip(state, op);
|
|
2551
|
+
case "INSERT_CLIP":
|
|
2552
|
+
return validateInsertClip(state, op);
|
|
2553
|
+
case "SET_MEDIA_BOUNDS":
|
|
2554
|
+
return validateSetMediaBounds(state, op);
|
|
2555
|
+
case "SET_CLIP_SPEED":
|
|
2556
|
+
return validateSetClipSpeed(state, op);
|
|
2557
|
+
case "ADD_TRACK":
|
|
2558
|
+
return validateAddTrack(state, op);
|
|
2559
|
+
case "DELETE_TRACK":
|
|
2560
|
+
return validateDeleteTrack(state, op);
|
|
2561
|
+
case "UNREGISTER_ASSET":
|
|
2562
|
+
return validateUnregisterAsset(state, op);
|
|
2563
|
+
case "ADD_MARKER":
|
|
2564
|
+
return validateAddMarker(state, op);
|
|
2565
|
+
case "MOVE_MARKER":
|
|
2566
|
+
return validateMoveMarker(state, op);
|
|
2567
|
+
case "DELETE_MARKER":
|
|
2568
|
+
return validateDeleteMarker(state, op);
|
|
2569
|
+
case "SET_IN_POINT":
|
|
2570
|
+
return validateSetInPoint(state, op);
|
|
2571
|
+
case "SET_OUT_POINT":
|
|
2572
|
+
return validateSetOutPoint(state, op);
|
|
2573
|
+
case "ADD_BEAT_GRID":
|
|
2574
|
+
return validateAddBeatGrid(state, op);
|
|
2575
|
+
case "REMOVE_BEAT_GRID":
|
|
2576
|
+
return validateRemoveBeatGrid(state, op);
|
|
2577
|
+
case "INSERT_GENERATOR":
|
|
2578
|
+
return validateInsertGenerator(state, op);
|
|
2579
|
+
case "ADD_CAPTION":
|
|
2580
|
+
return validateAddCaption(state, op);
|
|
2581
|
+
case "EDIT_CAPTION":
|
|
2582
|
+
return validateEditCaption(state, op);
|
|
2583
|
+
case "DELETE_CAPTION":
|
|
2584
|
+
return validateDeleteCaption(state, op);
|
|
2585
|
+
case "ADD_EFFECT":
|
|
2586
|
+
return validateAddEffect(state, op);
|
|
2587
|
+
case "REMOVE_EFFECT":
|
|
2588
|
+
return validateRemoveEffect(state, op);
|
|
2589
|
+
case "REORDER_EFFECT":
|
|
2590
|
+
return validateReorderEffect(state, op);
|
|
2591
|
+
case "SET_EFFECT_ENABLED":
|
|
2592
|
+
return validateSetEffectEnabled(state, op);
|
|
2593
|
+
case "SET_EFFECT_PARAM":
|
|
2594
|
+
return validateSetEffectParam(state, op);
|
|
2595
|
+
case "ADD_KEYFRAME":
|
|
2596
|
+
return validateAddKeyframe(state, op);
|
|
2597
|
+
case "MOVE_KEYFRAME":
|
|
2598
|
+
return validateMoveKeyframe(state, op);
|
|
2599
|
+
case "DELETE_KEYFRAME":
|
|
2600
|
+
return validateDeleteKeyframe(state, op);
|
|
2601
|
+
case "SET_KEYFRAME_EASING":
|
|
2602
|
+
return validateSetKeyframeEasing(state, op);
|
|
2603
|
+
case "SET_CLIP_TRANSFORM":
|
|
2604
|
+
return validateSetClipTransform(state, op);
|
|
2605
|
+
case "SET_AUDIO_PROPERTIES":
|
|
2606
|
+
return validateSetAudioProperties(state, op);
|
|
2607
|
+
case "ADD_TRANSITION":
|
|
2608
|
+
return validateAddTransition(state, op);
|
|
2609
|
+
case "DELETE_TRANSITION":
|
|
2610
|
+
return validateDeleteTransition(state, op);
|
|
2611
|
+
case "SET_TRANSITION_DURATION":
|
|
2612
|
+
return validateSetTransitionDuration(state, op);
|
|
2613
|
+
case "SET_TRANSITION_ALIGNMENT":
|
|
2614
|
+
return validateSetTransitionAlignment(state, op);
|
|
2615
|
+
case "LINK_CLIPS":
|
|
2616
|
+
return validateLinkClips(state, op);
|
|
2617
|
+
case "UNLINK_CLIPS":
|
|
2618
|
+
return validateUnlinkClips(state, op);
|
|
2619
|
+
case "ADD_TRACK_GROUP":
|
|
2620
|
+
return validateAddTrackGroup(state, op);
|
|
2621
|
+
case "DELETE_TRACK_GROUP":
|
|
2622
|
+
return validateDeleteTrackGroup(state, op);
|
|
2623
|
+
case "SET_TRACK_BLEND_MODE":
|
|
2624
|
+
return validateSetTrackBlendMode(state, op);
|
|
2625
|
+
case "SET_TRACK_OPACITY":
|
|
2626
|
+
return validateSetTrackOpacity(state, op);
|
|
2627
|
+
default:
|
|
2628
|
+
return null;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
function validateMoveClip(state, op) {
|
|
2632
|
+
const clip = findClip(state, op.clipId);
|
|
2633
|
+
if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
|
|
2634
|
+
const targetTrackId = op.targetTrackId ?? clip.trackId;
|
|
2635
|
+
const track = state.timeline.tracks.find((t) => t.id === targetTrackId);
|
|
2636
|
+
if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${targetTrackId}' not found.` };
|
|
2637
|
+
if (track.locked) return { reason: "LOCKED_TRACK", message: `Track '${targetTrackId}' is locked.` };
|
|
2638
|
+
const duration = clip.timelineEnd - clip.timelineStart;
|
|
2639
|
+
const newEnd = op.newTimelineStart + duration;
|
|
2640
|
+
if (op.newTimelineStart < 0 || newEnd > state.timeline.duration) {
|
|
2641
|
+
return { reason: "OUT_OF_BOUNDS", message: `MOVE_CLIP would place clip '${op.clipId}' outside timeline bounds.` };
|
|
2642
|
+
}
|
|
2643
|
+
for (const existing of track.clips) {
|
|
2644
|
+
if (existing.id === op.clipId) continue;
|
|
2645
|
+
const overlaps = op.newTimelineStart < existing.timelineEnd && newEnd > existing.timelineStart;
|
|
2646
|
+
if (overlaps) {
|
|
2647
|
+
return { reason: "OVERLAP", message: `Clip '${op.clipId}' would overlap '${existing.id}' on track '${targetTrackId}'.` };
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
return null;
|
|
2651
|
+
}
|
|
2652
|
+
function validateResizeClip(state, op) {
|
|
2653
|
+
const clip = findClip(state, op.clipId);
|
|
2654
|
+
if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
|
|
2655
|
+
if (op.edge === "start" && op.newFrame >= clip.timelineEnd) {
|
|
2656
|
+
return { reason: "OUT_OF_BOUNDS", message: `RESIZE_CLIP start edge must be < timelineEnd.` };
|
|
2657
|
+
}
|
|
2658
|
+
if (op.edge === "end" && op.newFrame <= clip.timelineStart) {
|
|
2659
|
+
return { reason: "OUT_OF_BOUNDS", message: `RESIZE_CLIP end edge must be > timelineStart.` };
|
|
2660
|
+
}
|
|
2661
|
+
return null;
|
|
2662
|
+
}
|
|
2663
|
+
function validateSliceClip(state, op) {
|
|
2664
|
+
const clip = findClip(state, op.clipId);
|
|
2665
|
+
if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
|
|
2666
|
+
if (op.atFrame <= clip.timelineStart || op.atFrame >= clip.timelineEnd) {
|
|
2667
|
+
return { reason: "OUT_OF_BOUNDS", message: `SLICE_CLIP atFrame must be strictly inside the clip bounds.` };
|
|
2668
|
+
}
|
|
2669
|
+
return null;
|
|
2670
|
+
}
|
|
2671
|
+
function validateDeleteClip(state, op) {
|
|
2672
|
+
const clip = findClip(state, op.clipId);
|
|
2673
|
+
if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
|
|
2674
|
+
const track = state.timeline.tracks.find((t) => t.id === clip.trackId);
|
|
2675
|
+
if (track?.locked) return { reason: "LOCKED_TRACK", message: `Track '${clip.trackId}' is locked.` };
|
|
2676
|
+
return null;
|
|
2677
|
+
}
|
|
2678
|
+
function validateInsertClip(state, op) {
|
|
2679
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2680
|
+
if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${op.trackId}' not found.` };
|
|
2681
|
+
if (track.locked) return { reason: "LOCKED_TRACK", message: `Track '${op.trackId}' is locked.` };
|
|
2682
|
+
const asset = state.assetRegistry.get(op.clip.assetId);
|
|
2683
|
+
if (!asset) return { reason: "ASSET_MISSING", message: `Asset '${op.clip.assetId}' not in registry.` };
|
|
2684
|
+
if (asset.mediaType !== track.type) return { reason: "TYPE_MISMATCH", message: `Asset mediaType '${asset.mediaType}' \u2260 track type '${track.type}'.` };
|
|
2685
|
+
for (const existing of track.clips) {
|
|
2686
|
+
const overlaps = op.clip.timelineStart < existing.timelineEnd && op.clip.timelineEnd > existing.timelineStart;
|
|
2687
|
+
if (overlaps) {
|
|
2688
|
+
return { reason: "OVERLAP", message: `INSERT_CLIP would overlap '${existing.id}'.` };
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
return null;
|
|
2692
|
+
}
|
|
2693
|
+
function validateSetMediaBounds(state, op) {
|
|
2694
|
+
const clip = findClip(state, op.clipId);
|
|
2695
|
+
if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
|
|
2696
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
2697
|
+
if (!asset) return { reason: "ASSET_MISSING", message: `Asset '${clip.assetId}' not found.` };
|
|
2698
|
+
if (op.mediaIn < 0) return { reason: "MEDIA_BOUNDS_INVALID", message: `mediaIn must be >= 0.` };
|
|
2699
|
+
if (op.mediaOut > asset.intrinsicDuration) {
|
|
2700
|
+
return { reason: "MEDIA_BOUNDS_INVALID", message: `mediaOut (${op.mediaOut}) exceeds asset intrinsicDuration (${asset.intrinsicDuration}).` };
|
|
2701
|
+
}
|
|
2702
|
+
return null;
|
|
2703
|
+
}
|
|
2704
|
+
function validateSetClipSpeed(_state, op) {
|
|
2705
|
+
if (op.speed <= 0) return { reason: "SPEED_INVALID", message: `speed must be > 0, got ${op.speed}.` };
|
|
2706
|
+
return null;
|
|
2707
|
+
}
|
|
2708
|
+
function validateAddTrack(state, op) {
|
|
2709
|
+
if (state.timeline.tracks.some((t) => t.id === op.track.id)) {
|
|
2710
|
+
return { reason: "OVERLAP", message: `Track '${op.track.id}' already exists.` };
|
|
2711
|
+
}
|
|
2712
|
+
return null;
|
|
2713
|
+
}
|
|
2714
|
+
function validateDeleteTrack(state, op) {
|
|
2715
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2716
|
+
if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${op.trackId}' not found.` };
|
|
2717
|
+
if (track.clips.length > 0) return { reason: "TRACK_NOT_EMPTY", message: `Cannot delete track '${op.trackId}': it has ${track.clips.length} clips. Delete all clips first.` };
|
|
2718
|
+
return null;
|
|
2719
|
+
}
|
|
2720
|
+
function validateUnregisterAsset(state, op) {
|
|
2721
|
+
for (const track of state.timeline.tracks) {
|
|
2722
|
+
for (const clip of track.clips) {
|
|
2723
|
+
if (clip.assetId === op.assetId) {
|
|
2724
|
+
return { reason: "ASSET_IN_USE", message: `Asset '${op.assetId}' is referenced by clip '${clip.id}'.` };
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
return null;
|
|
2729
|
+
}
|
|
2730
|
+
function validateAddMarker(state, op) {
|
|
2731
|
+
const { marker } = op;
|
|
2732
|
+
if (state.timeline.markers.some((m) => m.id === marker.id)) {
|
|
2733
|
+
return { reason: "OUT_OF_BOUNDS", message: `Marker '${marker.id}' already exists.` };
|
|
2734
|
+
}
|
|
2735
|
+
if (marker.clipId != null) {
|
|
2736
|
+
const clip = findClip(state, marker.clipId);
|
|
2737
|
+
if (!clip) {
|
|
2738
|
+
return { reason: "NOT_FOUND", message: `Clip '${marker.clipId}' not found.` };
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
const dur = state.timeline.duration;
|
|
2742
|
+
if (marker.type === "point") {
|
|
2743
|
+
if (marker.frame < 0 || marker.frame > dur) {
|
|
2744
|
+
return { reason: "OUT_OF_BOUNDS", message: `Point marker frame (${marker.frame}) must be in [0, ${dur}].` };
|
|
2745
|
+
}
|
|
2746
|
+
} else {
|
|
2747
|
+
if (marker.frameStart >= marker.frameEnd) {
|
|
2748
|
+
return { reason: "OUT_OF_BOUNDS", message: `Range marker frameStart must be < frameEnd.` };
|
|
2749
|
+
}
|
|
2750
|
+
if (marker.frameEnd > dur) {
|
|
2751
|
+
return { reason: "OUT_OF_BOUNDS", message: `Range marker frameEnd (${marker.frameEnd}) exceeds timeline duration (${dur}).` };
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
return null;
|
|
2755
|
+
}
|
|
2756
|
+
function validateMoveMarker(state, op) {
|
|
2757
|
+
const marker = findMarker(state, op.markerId);
|
|
2758
|
+
if (!marker) return { reason: "NOT_FOUND", message: `Marker '${op.markerId}' not found.` };
|
|
2759
|
+
const dur = state.timeline.duration;
|
|
2760
|
+
if (marker.type === "point") {
|
|
2761
|
+
if (op.newFrame < 0 || op.newFrame > dur) {
|
|
2762
|
+
return { reason: "OUT_OF_BOUNDS", message: `newFrame (${op.newFrame}) must be in [0, ${dur}].` };
|
|
2763
|
+
}
|
|
2764
|
+
} else {
|
|
2765
|
+
const duration = marker.frameEnd - marker.frameStart;
|
|
2766
|
+
const newEnd = op.newFrame + duration;
|
|
2767
|
+
if (op.newFrame < 0 || newEnd > dur) {
|
|
2768
|
+
return { reason: "OUT_OF_BOUNDS", message: `MOVE_MARKER would place range marker outside timeline.` };
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
function validateDeleteMarker(state, op) {
|
|
2774
|
+
if (!findMarker(state, op.markerId)) {
|
|
2775
|
+
return { reason: "NOT_FOUND", message: `Marker '${op.markerId}' not found.` };
|
|
2776
|
+
}
|
|
2777
|
+
return null;
|
|
2778
|
+
}
|
|
2779
|
+
function validateSetInPoint(state, op) {
|
|
2780
|
+
if (op.frame === null) return null;
|
|
2781
|
+
if (op.frame < 0) return { reason: "OUT_OF_BOUNDS", message: `In point frame must be >= 0.` };
|
|
2782
|
+
const out = state.timeline.outPoint;
|
|
2783
|
+
if (out !== null && op.frame >= out) {
|
|
2784
|
+
return { reason: "OUT_OF_BOUNDS", message: `In point must be < out point (${out}).` };
|
|
2785
|
+
}
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
function validateSetOutPoint(state, op) {
|
|
2789
|
+
if (op.frame === null) return null;
|
|
2790
|
+
if (op.frame < 0) return { reason: "OUT_OF_BOUNDS", message: `Out point frame must be >= 0.` };
|
|
2791
|
+
const inPt = state.timeline.inPoint;
|
|
2792
|
+
if (inPt !== null && op.frame <= inPt) {
|
|
2793
|
+
return { reason: "OUT_OF_BOUNDS", message: `Out point must be > in point (${inPt}).` };
|
|
2794
|
+
}
|
|
2795
|
+
return null;
|
|
2796
|
+
}
|
|
2797
|
+
function validateAddBeatGrid(state, op) {
|
|
2798
|
+
if (state.timeline.beatGrid !== null) {
|
|
2799
|
+
return { reason: "BEAT_GRID_EXISTS", message: `Timeline already has a beat grid.` };
|
|
2800
|
+
}
|
|
2801
|
+
const { beatGrid } = op;
|
|
2802
|
+
if (beatGrid.bpm <= 0) return { reason: "OUT_OF_BOUNDS", message: `Beat grid bpm must be > 0.` };
|
|
2803
|
+
if (beatGrid.timeSignature[0] <= 0 || beatGrid.timeSignature[1] <= 0) {
|
|
2804
|
+
return { reason: "OUT_OF_BOUNDS", message: `Beat grid timeSignature must be positive.` };
|
|
2805
|
+
}
|
|
2806
|
+
return null;
|
|
2807
|
+
}
|
|
2808
|
+
function validateRemoveBeatGrid(_state, _op) {
|
|
2809
|
+
return null;
|
|
2810
|
+
}
|
|
2811
|
+
function validateInsertGenerator(state, op) {
|
|
2812
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2813
|
+
if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${op.trackId}' not found.` };
|
|
2814
|
+
if (track.locked) return { reason: "LOCKED_TRACK", message: `Track '${op.trackId}' is locked.` };
|
|
2815
|
+
if (track.type !== "video" && track.type !== "audio") {
|
|
2816
|
+
return { reason: "TYPE_MISMATCH", message: `INSERT_GENERATOR requires video or audio track.` };
|
|
2817
|
+
}
|
|
2818
|
+
const dur = state.timeline.duration;
|
|
2819
|
+
if (op.atFrame < 0 || op.atFrame + op.generator.duration > dur) {
|
|
2820
|
+
return { reason: "OUT_OF_BOUNDS", message: `INSERT_GENERATOR would place clip outside timeline.` };
|
|
2821
|
+
}
|
|
2822
|
+
for (const c of track.clips) {
|
|
2823
|
+
const overlaps = op.atFrame < c.timelineEnd && op.atFrame + op.generator.duration > c.timelineStart;
|
|
2824
|
+
if (overlaps) return { reason: "OVERLAP", message: `INSERT_GENERATOR would overlap clip '${c.id}'.` };
|
|
2825
|
+
}
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
function validateAddCaption(state, op) {
|
|
2829
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2830
|
+
if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${op.trackId}' not found.` };
|
|
2831
|
+
if (track.locked) return { reason: "LOCKED_TRACK", message: `Track '${op.trackId}' is locked.` };
|
|
2832
|
+
const { caption } = op;
|
|
2833
|
+
if (caption.startFrame >= caption.endFrame) {
|
|
2834
|
+
return { reason: "OUT_OF_BOUNDS", message: `Caption startFrame must be < endFrame.` };
|
|
2835
|
+
}
|
|
2836
|
+
if (caption.endFrame > state.timeline.duration) {
|
|
2837
|
+
return { reason: "OUT_OF_BOUNDS", message: `Caption endFrame exceeds timeline duration.` };
|
|
2838
|
+
}
|
|
2839
|
+
if (track.captions.some((c) => c.id === caption.id)) {
|
|
2840
|
+
return { reason: "OUT_OF_BOUNDS", message: `Caption '${caption.id}' already on track.` };
|
|
2841
|
+
}
|
|
2842
|
+
const overlaps = track.captions.some(
|
|
2843
|
+
(c) => caption.startFrame < c.endFrame && caption.endFrame > c.startFrame
|
|
2844
|
+
);
|
|
2845
|
+
if (overlaps) {
|
|
2846
|
+
return { reason: "OVERLAP", message: `Caption overlaps an existing caption on track '${op.trackId}'.` };
|
|
2847
|
+
}
|
|
2848
|
+
return null;
|
|
2849
|
+
}
|
|
2850
|
+
function validateEditCaption(state, op) {
|
|
2851
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2852
|
+
if (!track) return { reason: "NOT_FOUND", message: `Track '${op.trackId}' not found.` };
|
|
2853
|
+
const caption = track.captions.find((c) => c.id === op.captionId);
|
|
2854
|
+
if (!caption) return { reason: "NOT_FOUND", message: `Caption '${op.captionId}' not found on track.` };
|
|
2855
|
+
if (op.startFrame !== void 0 && op.endFrame !== void 0) {
|
|
2856
|
+
if (op.startFrame >= op.endFrame) return { reason: "OUT_OF_BOUNDS", message: `startFrame must be < endFrame.` };
|
|
2857
|
+
if (op.endFrame > state.timeline.duration) return { reason: "OUT_OF_BOUNDS", message: `endFrame exceeds timeline duration.` };
|
|
2858
|
+
} else if (op.startFrame !== void 0) {
|
|
2859
|
+
if (op.startFrame >= caption.endFrame) return { reason: "OUT_OF_BOUNDS", message: `startFrame must be < endFrame.` };
|
|
2860
|
+
} else if (op.endFrame !== void 0) {
|
|
2861
|
+
if (caption.startFrame >= op.endFrame) return { reason: "OUT_OF_BOUNDS", message: `endFrame must be > startFrame.` };
|
|
2862
|
+
if (op.endFrame > state.timeline.duration) return { reason: "OUT_OF_BOUNDS", message: `endFrame exceeds timeline duration.` };
|
|
2863
|
+
}
|
|
2864
|
+
return null;
|
|
2865
|
+
}
|
|
2866
|
+
function validateDeleteCaption(state, op) {
|
|
2867
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
2868
|
+
if (!track) return { reason: "NOT_FOUND", message: `Track '${op.trackId}' not found.` };
|
|
2869
|
+
if (!track.captions.some((c) => c.id === op.captionId)) {
|
|
2870
|
+
return { reason: "NOT_FOUND", message: `Caption '${op.captionId}' not found on track.` };
|
|
2871
|
+
}
|
|
2872
|
+
return null;
|
|
2873
|
+
}
|
|
2874
|
+
function validateAddEffect(state, op) {
|
|
2875
|
+
const clip = findClip(state, op.clipId);
|
|
2876
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2877
|
+
const effects = clip.effects ?? [];
|
|
2878
|
+
if (effects.some((e) => e.id === op.effect.id)) {
|
|
2879
|
+
return { reason: "DUPLICATE_EFFECT_ID", message: `Effect '${op.effect.id}' already exists on clip '${op.clipId}'.` };
|
|
2880
|
+
}
|
|
2881
|
+
return null;
|
|
2882
|
+
}
|
|
2883
|
+
function validateRemoveEffect(state, op) {
|
|
2884
|
+
const clip = findClip(state, op.clipId);
|
|
2885
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2886
|
+
const effect = findEffect(clip, op.effectId);
|
|
2887
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2888
|
+
return null;
|
|
2889
|
+
}
|
|
2890
|
+
function validateReorderEffect(state, op) {
|
|
2891
|
+
const clip = findClip(state, op.clipId);
|
|
2892
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2893
|
+
const effect = findEffect(clip, op.effectId);
|
|
2894
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2895
|
+
const effects = clip.effects ?? [];
|
|
2896
|
+
if (op.newIndex < 0 || op.newIndex >= effects.length) {
|
|
2897
|
+
return { reason: "EFFECT_INDEX_OUT_OF_RANGE", message: `newIndex ${op.newIndex} out of range [0, ${effects.length - 1}].` };
|
|
2898
|
+
}
|
|
2899
|
+
return null;
|
|
2900
|
+
}
|
|
2901
|
+
function validateSetEffectEnabled(state, op) {
|
|
2902
|
+
const clip = findClip(state, op.clipId);
|
|
2903
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2904
|
+
const effect = findEffect(clip, op.effectId);
|
|
2905
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2906
|
+
return null;
|
|
2907
|
+
}
|
|
2908
|
+
function validateSetEffectParam(state, op) {
|
|
2909
|
+
const clip = findClip(state, op.clipId);
|
|
2910
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2911
|
+
const effect = findEffect(clip, op.effectId);
|
|
2912
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2913
|
+
return null;
|
|
2914
|
+
}
|
|
2915
|
+
function validateAddKeyframe(state, op) {
|
|
2916
|
+
const clip = findClip(state, op.clipId);
|
|
2917
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2918
|
+
const effect = findEffect(clip, op.effectId);
|
|
2919
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2920
|
+
if (effect.keyframes.some((k) => k.id === op.keyframe.id)) {
|
|
2921
|
+
return { reason: "DUPLICATE_KEYFRAME_ID", message: `Keyframe '${op.keyframe.id}' already exists on effect '${op.effectId}'.` };
|
|
2922
|
+
}
|
|
2923
|
+
if (op.keyframe.frame < 0) {
|
|
2924
|
+
return { reason: "INVALID_RANGE", message: `Keyframe frame (${op.keyframe.frame}) must be >= 0.` };
|
|
2925
|
+
}
|
|
2926
|
+
return null;
|
|
2927
|
+
}
|
|
2928
|
+
function validateMoveKeyframe(state, op) {
|
|
2929
|
+
const clip = findClip(state, op.clipId);
|
|
2930
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2931
|
+
const effect = findEffect(clip, op.effectId);
|
|
2932
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2933
|
+
const kf = effect.keyframes.find((k) => k.id === op.keyframeId);
|
|
2934
|
+
if (!kf) return { reason: "KEYFRAME_NOT_FOUND", message: `Keyframe '${op.keyframeId}' not found on effect '${op.effectId}'.` };
|
|
2935
|
+
if (op.newFrame < 0) {
|
|
2936
|
+
return { reason: "INVALID_RANGE", message: `newFrame (${op.newFrame}) must be >= 0.` };
|
|
2937
|
+
}
|
|
2938
|
+
return null;
|
|
2939
|
+
}
|
|
2940
|
+
function validateDeleteKeyframe(state, op) {
|
|
2941
|
+
const clip = findClip(state, op.clipId);
|
|
2942
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2943
|
+
const effect = findEffect(clip, op.effectId);
|
|
2944
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2945
|
+
const kf = effect.keyframes.find((k) => k.id === op.keyframeId);
|
|
2946
|
+
if (!kf) return { reason: "KEYFRAME_NOT_FOUND", message: `Keyframe '${op.keyframeId}' not found on effect '${op.effectId}'.` };
|
|
2947
|
+
return null;
|
|
2948
|
+
}
|
|
2949
|
+
function validateSetKeyframeEasing(state, op) {
|
|
2950
|
+
const clip = findClip(state, op.clipId);
|
|
2951
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2952
|
+
const effect = findEffect(clip, op.effectId);
|
|
2953
|
+
if (!effect) return { reason: "EFFECT_NOT_FOUND", message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` };
|
|
2954
|
+
const kf = effect.keyframes.find((k) => k.id === op.keyframeId);
|
|
2955
|
+
if (!kf) return { reason: "KEYFRAME_NOT_FOUND", message: `Keyframe '${op.keyframeId}' not found on effect '${op.effectId}'.` };
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
function validateSetClipTransform(state, op) {
|
|
2959
|
+
const clip = findClip(state, op.clipId);
|
|
2960
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2961
|
+
return null;
|
|
2962
|
+
}
|
|
2963
|
+
function validateSetAudioProperties(state, op) {
|
|
2964
|
+
const clip = findClip(state, op.clipId);
|
|
2965
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2966
|
+
const p = op.properties;
|
|
2967
|
+
if (p.pan !== void 0 && typeof p.pan === "object" && p.pan !== null && "value" in p.pan) {
|
|
2968
|
+
const v = p.pan.value;
|
|
2969
|
+
if (v < -1 || v > 1) {
|
|
2970
|
+
return { reason: "INVALID_RANGE", message: `pan must be in [-1, 1].` };
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
if (p.normalizationGain !== void 0 && p.normalizationGain < 0) {
|
|
2974
|
+
return { reason: "INVALID_RANGE", message: `normalizationGain must be >= 0.` };
|
|
2975
|
+
}
|
|
2976
|
+
return null;
|
|
2977
|
+
}
|
|
2978
|
+
function validateAddTransition(state, op) {
|
|
2979
|
+
const clip = findClip(state, op.clipId);
|
|
2980
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2981
|
+
if (op.transition.durationFrames <= 0) {
|
|
2982
|
+
return { reason: "INVALID_RANGE", message: `transition.durationFrames must be > 0.` };
|
|
2983
|
+
}
|
|
2984
|
+
return null;
|
|
2985
|
+
}
|
|
2986
|
+
function validateDeleteTransition(state, op) {
|
|
2987
|
+
const clip = findClip(state, op.clipId);
|
|
2988
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2989
|
+
if (!clip.transition) {
|
|
2990
|
+
return { reason: "TRANSITION_NOT_FOUND", message: `Clip '${op.clipId}' has no transition to delete.` };
|
|
2991
|
+
}
|
|
2992
|
+
return null;
|
|
2993
|
+
}
|
|
2994
|
+
function validateSetTransitionDuration(state, op) {
|
|
2995
|
+
const clip = findClip(state, op.clipId);
|
|
2996
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
2997
|
+
if (!clip.transition) {
|
|
2998
|
+
return { reason: "TRANSITION_NOT_FOUND", message: `Clip '${op.clipId}' has no transition.` };
|
|
2999
|
+
}
|
|
3000
|
+
if (op.durationFrames <= 0) {
|
|
3001
|
+
return { reason: "INVALID_RANGE", message: `durationFrames must be > 0.` };
|
|
3002
|
+
}
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
function validateSetTransitionAlignment(state, op) {
|
|
3006
|
+
const clip = findClip(state, op.clipId);
|
|
3007
|
+
if (!clip) return { reason: "CLIP_NOT_FOUND", message: `Clip '${op.clipId}' not found.` };
|
|
3008
|
+
if (!clip.transition) {
|
|
3009
|
+
return { reason: "TRANSITION_NOT_FOUND", message: `Clip '${op.clipId}' has no transition.` };
|
|
3010
|
+
}
|
|
3011
|
+
return null;
|
|
3012
|
+
}
|
|
3013
|
+
function validateLinkClips(state, op) {
|
|
3014
|
+
const linkGroup = op.linkGroup;
|
|
3015
|
+
if (!linkGroup.clipIds.length || linkGroup.clipIds.length < 2) {
|
|
3016
|
+
return { reason: "INVALID_RANGE", message: `linkGroup.clipIds must have length >= 2.` };
|
|
3017
|
+
}
|
|
3018
|
+
for (const cid of linkGroup.clipIds) {
|
|
3019
|
+
if (!findClip(state, cid)) {
|
|
3020
|
+
return { reason: "CLIP_NOT_FOUND", message: `Clip '${cid}' not found.` };
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
const existing = state.timeline.linkGroups ?? [];
|
|
3024
|
+
if (existing.some((g) => g.id === linkGroup.id)) {
|
|
3025
|
+
return { reason: "DUPLICATE_LINK_GROUP_ID", message: `Link group '${linkGroup.id}' already exists.` };
|
|
3026
|
+
}
|
|
3027
|
+
return null;
|
|
3028
|
+
}
|
|
3029
|
+
function validateUnlinkClips(state, op) {
|
|
3030
|
+
const groups = state.timeline.linkGroups ?? [];
|
|
3031
|
+
if (!groups.some((g) => g.id === op.linkGroupId)) {
|
|
3032
|
+
return { reason: "LINK_GROUP_NOT_FOUND", message: `Link group '${op.linkGroupId}' not found.` };
|
|
3033
|
+
}
|
|
3034
|
+
return null;
|
|
3035
|
+
}
|
|
3036
|
+
function validateAddTrackGroup(state, op) {
|
|
3037
|
+
const groups = state.timeline.trackGroups ?? [];
|
|
3038
|
+
if (groups.some((g) => g.id === op.trackGroup.id)) {
|
|
3039
|
+
return { reason: "DUPLICATE_TRACK_GROUP_ID", message: `Track group '${op.trackGroup.id}' already exists.` };
|
|
3040
|
+
}
|
|
3041
|
+
for (const tid of op.trackGroup.trackIds) {
|
|
3042
|
+
if (!state.timeline.tracks.some((t) => t.id === tid)) {
|
|
3043
|
+
return { reason: "TRACK_NOT_FOUND", message: `Track '${tid}' not found.` };
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
return null;
|
|
3047
|
+
}
|
|
3048
|
+
function validateDeleteTrackGroup(state, op) {
|
|
3049
|
+
const groups = state.timeline.trackGroups ?? [];
|
|
3050
|
+
if (!groups.some((g) => g.id === op.trackGroupId)) {
|
|
3051
|
+
return { reason: "TRACK_GROUP_NOT_FOUND", message: `Track group '${op.trackGroupId}' not found.` };
|
|
3052
|
+
}
|
|
3053
|
+
return null;
|
|
3054
|
+
}
|
|
3055
|
+
function validateSetTrackBlendMode(state, op) {
|
|
3056
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
3057
|
+
if (!track) return { reason: "TRACK_NOT_FOUND", message: `Track '${op.trackId}' not found.` };
|
|
3058
|
+
return null;
|
|
3059
|
+
}
|
|
3060
|
+
function validateSetTrackOpacity(state, op) {
|
|
3061
|
+
const track = state.timeline.tracks.find((t) => t.id === op.trackId);
|
|
3062
|
+
if (!track) return { reason: "TRACK_NOT_FOUND", message: `Track '${op.trackId}' not found.` };
|
|
3063
|
+
if (op.opacity < 0 || op.opacity > 1) {
|
|
3064
|
+
return { reason: "INVALID_OPACITY", message: `opacity must be in [0, 1].` };
|
|
3065
|
+
}
|
|
3066
|
+
return null;
|
|
3067
|
+
}
|
|
3068
|
+
function findEffect(clip, effectId) {
|
|
3069
|
+
const effects = clip.effects ?? [];
|
|
3070
|
+
return effects.find((e) => e.id === effectId);
|
|
3071
|
+
}
|
|
3072
|
+
function findClip(state, clipId) {
|
|
3073
|
+
for (const track of state.timeline.tracks) {
|
|
3074
|
+
const clip = track.clips.find((c) => c.id === clipId);
|
|
3075
|
+
if (clip) return clip;
|
|
3076
|
+
}
|
|
3077
|
+
return void 0;
|
|
3078
|
+
}
|
|
3079
|
+
function findMarker(state, markerId) {
|
|
3080
|
+
return state.timeline.markers.find((m) => m.id === markerId);
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// src/engine/dispatcher.ts
|
|
3084
|
+
function dispatch(state, transaction) {
|
|
3085
|
+
let proposedState = state;
|
|
3086
|
+
for (const op of transaction.operations) {
|
|
3087
|
+
const rejection = validateOperation(proposedState, op);
|
|
3088
|
+
if (rejection) {
|
|
3089
|
+
return {
|
|
3090
|
+
accepted: false,
|
|
3091
|
+
reason: rejection.reason,
|
|
3092
|
+
message: rejection.message
|
|
3093
|
+
};
|
|
3094
|
+
}
|
|
3095
|
+
proposedState = applyOperation2(proposedState, op);
|
|
3096
|
+
}
|
|
3097
|
+
const violations = checkInvariants(proposedState);
|
|
3098
|
+
if (violations.length > 0) {
|
|
3099
|
+
return {
|
|
3100
|
+
accepted: false,
|
|
3101
|
+
reason: "INVARIANT_VIOLATED",
|
|
3102
|
+
message: violations.map((v) => v.message).join("; ")
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
const nextState = {
|
|
3106
|
+
...proposedState,
|
|
3107
|
+
timeline: {
|
|
3108
|
+
...proposedState.timeline,
|
|
3109
|
+
version: state.timeline.version + 1
|
|
3110
|
+
}
|
|
3111
|
+
};
|
|
3112
|
+
return { accepted: true, nextState };
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
// src/snap-index.ts
|
|
3116
|
+
var PRIORITIES = {
|
|
3117
|
+
Marker: 100,
|
|
3118
|
+
InPoint: 90,
|
|
3119
|
+
OutPoint: 90,
|
|
3120
|
+
ClipStart: 80,
|
|
3121
|
+
ClipEnd: 80,
|
|
3122
|
+
Playhead: 70,
|
|
3123
|
+
BeatGrid: 50
|
|
3124
|
+
};
|
|
3125
|
+
function buildSnapIndex(state, playheadFrame, enabled = true) {
|
|
3126
|
+
const points = [];
|
|
3127
|
+
for (const track of state.timeline.tracks) {
|
|
3128
|
+
for (const clip of track.clips) {
|
|
3129
|
+
points.push({
|
|
3130
|
+
frame: clip.timelineStart,
|
|
3131
|
+
type: "ClipStart",
|
|
3132
|
+
priority: PRIORITIES.ClipStart,
|
|
3133
|
+
trackId: track.id,
|
|
3134
|
+
sourceId: clip.id
|
|
3135
|
+
});
|
|
3136
|
+
points.push({
|
|
3137
|
+
frame: clip.timelineEnd,
|
|
3138
|
+
type: "ClipEnd",
|
|
3139
|
+
priority: PRIORITIES.ClipEnd,
|
|
3140
|
+
trackId: track.id,
|
|
3141
|
+
sourceId: clip.id
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
points.push({
|
|
3146
|
+
frame: playheadFrame,
|
|
3147
|
+
type: "Playhead",
|
|
3148
|
+
priority: PRIORITIES.Playhead,
|
|
3149
|
+
trackId: null,
|
|
3150
|
+
sourceId: "__playhead__"
|
|
3151
|
+
});
|
|
3152
|
+
const beatGrid = state.timeline.beatGrid;
|
|
3153
|
+
const dur = state.timeline.duration;
|
|
3154
|
+
if (beatGrid !== null) {
|
|
3155
|
+
const fps = state.timeline.fps;
|
|
3156
|
+
const beatDurationFrames = Math.round(60 / beatGrid.bpm * fps);
|
|
3157
|
+
let f = beatGrid.offset;
|
|
3158
|
+
while (f < dur) {
|
|
3159
|
+
points.push({
|
|
3160
|
+
frame: f,
|
|
3161
|
+
type: "BeatGrid",
|
|
3162
|
+
priority: PRIORITIES.BeatGrid,
|
|
3163
|
+
trackId: null,
|
|
3164
|
+
sourceId: `__beat_${f}__`
|
|
3165
|
+
});
|
|
3166
|
+
f = f + beatDurationFrames;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
points.sort((a, b) => a.frame - b.frame);
|
|
3170
|
+
return { points, builtAt: Date.now(), enabled };
|
|
3171
|
+
}
|
|
3172
|
+
function nearest(index, frame2, radiusFrames, exclude, allowedTypes) {
|
|
3173
|
+
if (!index.enabled) return null;
|
|
3174
|
+
const excludeSet = exclude ? new Set(exclude) : null;
|
|
3175
|
+
let best = null;
|
|
3176
|
+
let bestDist = Infinity;
|
|
3177
|
+
for (const point of index.points) {
|
|
3178
|
+
if (excludeSet && excludeSet.has(point.sourceId)) continue;
|
|
3179
|
+
if (allowedTypes && !allowedTypes.includes(point.type)) continue;
|
|
3180
|
+
const dist = Math.abs(point.frame - frame2);
|
|
3181
|
+
if (dist > radiusFrames) continue;
|
|
3182
|
+
if (dist < bestDist || dist === bestDist && best !== null && point.priority > best.priority) {
|
|
3183
|
+
best = point;
|
|
3184
|
+
bestDist = dist;
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
return best;
|
|
3188
|
+
}
|
|
3189
|
+
function toggleSnap(index, enabled) {
|
|
3190
|
+
return { ...index, enabled };
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// src/tools/types.ts
|
|
3194
|
+
function toToolId(s) {
|
|
3195
|
+
return s;
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
// src/tools/registry.ts
|
|
3199
|
+
function createRegistry(tools, defaultId) {
|
|
3200
|
+
const map = /* @__PURE__ */ new Map();
|
|
3201
|
+
for (const tool of tools) {
|
|
3202
|
+
map.set(tool.id, tool);
|
|
3203
|
+
}
|
|
3204
|
+
if (!map.has(defaultId)) {
|
|
3205
|
+
throw new Error(
|
|
3206
|
+
`createRegistry: defaultId "${defaultId}" not found in tools. Available: [${[...map.keys()].join(", ")}]`
|
|
3207
|
+
);
|
|
3208
|
+
}
|
|
3209
|
+
return { tools: map, activeToolId: defaultId };
|
|
3210
|
+
}
|
|
3211
|
+
function activateTool(registry, id) {
|
|
3212
|
+
getActiveTool(registry).onCancel();
|
|
3213
|
+
if (!registry.tools.has(id)) {
|
|
3214
|
+
throw new Error(
|
|
3215
|
+
`activateTool: unknown toolId "${id}". Registered: [${[...registry.tools.keys()].join(", ")}]`
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
return { ...registry, activeToolId: id };
|
|
3219
|
+
}
|
|
3220
|
+
function getActiveTool(registry) {
|
|
3221
|
+
const tool = registry.tools.get(registry.activeToolId);
|
|
3222
|
+
if (!tool) {
|
|
3223
|
+
throw new Error(
|
|
3224
|
+
`getActiveTool: activeToolId "${registry.activeToolId}" is not registered. Registry is corrupt.`
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
return tool;
|
|
3228
|
+
}
|
|
3229
|
+
function registerTool(registry, tool) {
|
|
3230
|
+
const next = new Map(registry.tools);
|
|
3231
|
+
next.set(tool.id, tool);
|
|
3232
|
+
return { ...registry, tools: next };
|
|
3233
|
+
}
|
|
3234
|
+
var NoOpTool = {
|
|
3235
|
+
id: toToolId("noop"),
|
|
3236
|
+
shortcutKey: "",
|
|
3237
|
+
getCursor(_ctx) {
|
|
3238
|
+
return "default";
|
|
3239
|
+
},
|
|
3240
|
+
getSnapCandidateTypes() {
|
|
3241
|
+
return [];
|
|
3242
|
+
},
|
|
3243
|
+
onPointerDown(_evt, _ctx) {
|
|
3244
|
+
},
|
|
3245
|
+
onPointerMove(_evt, _ctx) {
|
|
3246
|
+
return null;
|
|
3247
|
+
},
|
|
3248
|
+
onPointerUp(_evt, _ctx) {
|
|
3249
|
+
return null;
|
|
3250
|
+
},
|
|
3251
|
+
onKeyDown(_evt, _ctx) {
|
|
3252
|
+
return null;
|
|
3253
|
+
},
|
|
3254
|
+
onKeyUp(_evt, _ctx) {
|
|
3255
|
+
},
|
|
3256
|
+
onCancel() {
|
|
3257
|
+
}
|
|
3258
|
+
};
|
|
3259
|
+
|
|
3260
|
+
// src/tools/provisional.ts
|
|
3261
|
+
function createProvisionalManager() {
|
|
3262
|
+
return { current: null };
|
|
3263
|
+
}
|
|
3264
|
+
function setProvisional(_manager, state) {
|
|
3265
|
+
return { current: state };
|
|
3266
|
+
}
|
|
3267
|
+
function clearProvisional(_manager) {
|
|
3268
|
+
return { current: null };
|
|
3269
|
+
}
|
|
3270
|
+
function resolveClip(clipId, state, manager) {
|
|
3271
|
+
if (manager.current !== null) {
|
|
3272
|
+
const ghost = manager.current.clips.find((c) => c.id === clipId);
|
|
3273
|
+
if (ghost) return ghost;
|
|
3274
|
+
}
|
|
3275
|
+
for (const track of state.timeline.tracks) {
|
|
3276
|
+
const clip = track.clips.find((c) => c.id === clipId);
|
|
3277
|
+
if (clip) return clip;
|
|
3278
|
+
}
|
|
3279
|
+
return void 0;
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
// src/engine/marker-search.ts
|
|
3283
|
+
function findMarkersByColor(state, color) {
|
|
3284
|
+
return state.timeline.markers.filter((m) => m.color === color);
|
|
3285
|
+
}
|
|
3286
|
+
function findMarkersByLabel(state, label) {
|
|
3287
|
+
const lower = label.toLowerCase();
|
|
3288
|
+
return state.timeline.markers.filter(
|
|
3289
|
+
(m) => m.label.toLowerCase().includes(lower)
|
|
3290
|
+
);
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/types/easing.ts
|
|
3294
|
+
var LINEAR_EASING = { kind: "Linear" };
|
|
3295
|
+
var HOLD_EASING = { kind: "Hold" };
|
|
3296
|
+
|
|
3297
|
+
// src/types/keyframe.ts
|
|
3298
|
+
function toKeyframeId(s) {
|
|
3299
|
+
return s;
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
// src/types/effect.ts
|
|
3303
|
+
function toEffectId(s) {
|
|
3304
|
+
return s;
|
|
3305
|
+
}
|
|
3306
|
+
function createEffect(id, effectType, renderStage = "preComposite", params = []) {
|
|
3307
|
+
return { id, effectType, enabled: true, renderStage, params, keyframes: [] };
|
|
3308
|
+
}
|
|
3309
|
+
|
|
3310
|
+
// src/types/transition.ts
|
|
3311
|
+
function toTransitionId(s) {
|
|
3312
|
+
return s;
|
|
3313
|
+
}
|
|
3314
|
+
function createTransition(id, type, durationFrames, alignment = "centerOnCut", easing = LINEAR_EASING, params = []) {
|
|
3315
|
+
return { id, type, durationFrames, alignment, easing, params };
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
// src/types/track-group.ts
|
|
3319
|
+
function toTrackGroupId(s) {
|
|
3320
|
+
return s;
|
|
3321
|
+
}
|
|
3322
|
+
function createTrackGroup(id, label, trackIds = []) {
|
|
3323
|
+
return { id, label, trackIds, collapsed: false };
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
// src/types/link-group.ts
|
|
3327
|
+
function toLinkGroupId(s) {
|
|
3328
|
+
return s;
|
|
3329
|
+
}
|
|
3330
|
+
function createLinkGroup(id, clipIds) {
|
|
3331
|
+
return { id, clipIds };
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// src/tools/transition-tool.ts
|
|
3335
|
+
var TRANSITION_EDGE_THRESHOLD_PX = 8;
|
|
3336
|
+
function findClip2(state, clipId) {
|
|
3337
|
+
for (const track of state.timeline.tracks) {
|
|
3338
|
+
const c = track.clips.find((c2) => c2.id === clipId);
|
|
3339
|
+
if (c) return c;
|
|
3340
|
+
}
|
|
3341
|
+
return void 0;
|
|
3342
|
+
}
|
|
3343
|
+
function findClipAtRightEdge(state, x, pixelsPerFrame) {
|
|
3344
|
+
for (let ti = 0; ti < state.timeline.tracks.length; ti++) {
|
|
3345
|
+
const track = state.timeline.tracks[ti];
|
|
3346
|
+
for (const clip of track.clips) {
|
|
3347
|
+
const rightEdgePx = clip.timelineEnd * pixelsPerFrame;
|
|
3348
|
+
if (Math.abs(x - rightEdgePx) <= TRANSITION_EDGE_THRESHOLD_PX) {
|
|
3349
|
+
return { clip, trackIndex: ti };
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
return null;
|
|
3354
|
+
}
|
|
3355
|
+
function findClipInTransitionZone(state, x, pixelsPerFrame) {
|
|
3356
|
+
for (const track of state.timeline.tracks) {
|
|
3357
|
+
for (const clip of track.clips) {
|
|
3358
|
+
if (!clip.transition) continue;
|
|
3359
|
+
const rightEdgePx = clip.timelineEnd * pixelsPerFrame;
|
|
3360
|
+
const leftOfTransitionPx = rightEdgePx - clip.transition.durationFrames * pixelsPerFrame;
|
|
3361
|
+
if (x >= leftOfTransitionPx && x <= rightEdgePx) return clip;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
return null;
|
|
3365
|
+
}
|
|
3366
|
+
var _txSeq = 0;
|
|
3367
|
+
function txId() {
|
|
3368
|
+
return `transition-tx-${++_txSeq}`;
|
|
3369
|
+
}
|
|
3370
|
+
var TransitionTool = class {
|
|
3371
|
+
id = toToolId("transition");
|
|
3372
|
+
shortcutKey = "T";
|
|
3373
|
+
pendingClipId = null;
|
|
3374
|
+
dragStartX = 0;
|
|
3375
|
+
pendingDeleteTransitionClipId = null;
|
|
3376
|
+
getCursor(_ctx) {
|
|
3377
|
+
return "ew-resize";
|
|
3378
|
+
}
|
|
3379
|
+
getSnapCandidateTypes() {
|
|
3380
|
+
return ["ClipStart", "ClipEnd", "Marker", "BeatGrid"];
|
|
3381
|
+
}
|
|
3382
|
+
onPointerDown(event, ctx) {
|
|
3383
|
+
const { state, pixelsPerFrame } = ctx;
|
|
3384
|
+
const x = event.x;
|
|
3385
|
+
const atEdge = findClipAtRightEdge(state, x, pixelsPerFrame);
|
|
3386
|
+
if (atEdge) {
|
|
3387
|
+
this.pendingClipId = atEdge.clip.id;
|
|
3388
|
+
this.dragStartX = x;
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
const inTransition = findClipInTransitionZone(state, x, pixelsPerFrame);
|
|
3392
|
+
if (inTransition) {
|
|
3393
|
+
this.pendingDeleteTransitionClipId = inTransition.id;
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
onPointerMove(event, ctx) {
|
|
3397
|
+
if (this.pendingClipId === null) return null;
|
|
3398
|
+
const clip = findClip2(ctx.state, this.pendingClipId);
|
|
3399
|
+
if (!clip) return null;
|
|
3400
|
+
const dragDeltaX = event.x - this.dragStartX;
|
|
3401
|
+
const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame);
|
|
3402
|
+
const durationFrames = Math.max(1, deltaFrames);
|
|
3403
|
+
const transition = createTransition(
|
|
3404
|
+
toTransitionId(`tr-${clip.id}-preview`),
|
|
3405
|
+
"dissolve",
|
|
3406
|
+
durationFrames,
|
|
3407
|
+
"centerOnCut",
|
|
3408
|
+
LINEAR_EASING
|
|
3409
|
+
);
|
|
3410
|
+
let nextState;
|
|
3411
|
+
if (clip.transition) {
|
|
3412
|
+
nextState = applyOperation2(ctx.state, {
|
|
3413
|
+
type: "SET_TRANSITION_DURATION",
|
|
3414
|
+
clipId: clip.id,
|
|
3415
|
+
durationFrames
|
|
3416
|
+
});
|
|
3417
|
+
} else {
|
|
3418
|
+
nextState = applyOperation2(ctx.state, {
|
|
3419
|
+
type: "ADD_TRANSITION",
|
|
3420
|
+
clipId: clip.id,
|
|
3421
|
+
transition
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
const updatedClip = nextState.timeline.tracks.flatMap((t) => t.clips).find((c) => c.id === this.pendingClipId);
|
|
3425
|
+
if (!updatedClip) return null;
|
|
3426
|
+
return {
|
|
3427
|
+
clips: [updatedClip],
|
|
3428
|
+
isProvisional: true
|
|
3429
|
+
};
|
|
3430
|
+
}
|
|
3431
|
+
onPointerUp(event, ctx) {
|
|
3432
|
+
const pendingClipId = this.pendingClipId;
|
|
3433
|
+
const dragStartX = this.dragStartX;
|
|
3434
|
+
const pendingDelete = this.pendingDeleteTransitionClipId;
|
|
3435
|
+
this.pendingClipId = null;
|
|
3436
|
+
this.dragStartX = 0;
|
|
3437
|
+
this.pendingDeleteTransitionClipId = null;
|
|
3438
|
+
if (pendingDelete !== null) {
|
|
3439
|
+
return {
|
|
3440
|
+
id: txId(),
|
|
3441
|
+
label: "Delete transition",
|
|
3442
|
+
timestamp: Date.now(),
|
|
3443
|
+
operations: [{ type: "DELETE_TRANSITION", clipId: pendingDelete }]
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
if (pendingClipId === null) return null;
|
|
3447
|
+
const clip = findClip2(ctx.state, pendingClipId);
|
|
3448
|
+
if (!clip) return null;
|
|
3449
|
+
const dragDeltaX = event.x - dragStartX;
|
|
3450
|
+
const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame);
|
|
3451
|
+
const durationFrames = Math.max(1, deltaFrames);
|
|
3452
|
+
if (deltaFrames < 1) return null;
|
|
3453
|
+
if (clip.transition) {
|
|
3454
|
+
return {
|
|
3455
|
+
id: txId(),
|
|
3456
|
+
label: "Set transition duration",
|
|
3457
|
+
timestamp: Date.now(),
|
|
3458
|
+
operations: [
|
|
3459
|
+
{
|
|
3460
|
+
type: "SET_TRANSITION_DURATION",
|
|
3461
|
+
clipId: pendingClipId,
|
|
3462
|
+
durationFrames
|
|
3463
|
+
}
|
|
3464
|
+
]
|
|
3465
|
+
};
|
|
3466
|
+
}
|
|
3467
|
+
const transition = createTransition(
|
|
3468
|
+
toTransitionId(`tr-${clip.id}-${Date.now()}`),
|
|
3469
|
+
"dissolve",
|
|
3470
|
+
durationFrames,
|
|
3471
|
+
"centerOnCut",
|
|
3472
|
+
LINEAR_EASING
|
|
3473
|
+
);
|
|
3474
|
+
return {
|
|
3475
|
+
id: txId(),
|
|
3476
|
+
label: "Add transition",
|
|
3477
|
+
timestamp: Date.now(),
|
|
3478
|
+
operations: [
|
|
3479
|
+
{
|
|
3480
|
+
type: "ADD_TRANSITION",
|
|
3481
|
+
clipId: pendingClipId,
|
|
3482
|
+
transition
|
|
3483
|
+
}
|
|
3484
|
+
]
|
|
3485
|
+
};
|
|
3486
|
+
}
|
|
3487
|
+
onKeyDown(_event, _ctx) {
|
|
3488
|
+
return null;
|
|
3489
|
+
}
|
|
3490
|
+
onKeyUp(_event, _ctx) {
|
|
3491
|
+
}
|
|
3492
|
+
onCancel() {
|
|
3493
|
+
this.pendingClipId = null;
|
|
3494
|
+
this.dragStartX = 0;
|
|
3495
|
+
this.pendingDeleteTransitionClipId = null;
|
|
3496
|
+
}
|
|
3497
|
+
};
|
|
3498
|
+
|
|
3499
|
+
// src/tools/keyframe-tool.ts
|
|
3500
|
+
var KEYFRAME_HIT_RADIUS_PX = 6;
|
|
3501
|
+
var SNAP_RADIUS_FRAMES = 5;
|
|
3502
|
+
function findClip3(state, clipId) {
|
|
3503
|
+
for (const track of state.timeline.tracks) {
|
|
3504
|
+
const c = track.clips.find((c2) => c2.id === clipId);
|
|
3505
|
+
if (c) return c;
|
|
3506
|
+
}
|
|
3507
|
+
return void 0;
|
|
3508
|
+
}
|
|
3509
|
+
function findKeyframeAt(clip, x, pixelsPerFrame) {
|
|
3510
|
+
const effects = clip.effects ?? [];
|
|
3511
|
+
for (const effect of effects) {
|
|
3512
|
+
for (const kf of effect.keyframes) {
|
|
3513
|
+
const kfPx = kf.frame * pixelsPerFrame;
|
|
3514
|
+
if (Math.abs(x - kfPx) <= KEYFRAME_HIT_RADIUS_PX) {
|
|
3515
|
+
return { effectId: effect.id, keyframe: kf };
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
return null;
|
|
3520
|
+
}
|
|
3521
|
+
var _txSeq2 = 0;
|
|
3522
|
+
function txId2() {
|
|
3523
|
+
return `keyframe-tx-${++_txSeq2}`;
|
|
3524
|
+
}
|
|
3525
|
+
var KeyframeTool = class {
|
|
3526
|
+
id = toToolId("keyframe");
|
|
3527
|
+
shortcutKey = "P";
|
|
3528
|
+
draggingKeyframe = null;
|
|
3529
|
+
activeClipId = null;
|
|
3530
|
+
activeEffectId = null;
|
|
3531
|
+
pendingAddKeyframe = null;
|
|
3532
|
+
getCursor(_ctx) {
|
|
3533
|
+
return "crosshair";
|
|
3534
|
+
}
|
|
3535
|
+
getSnapCandidateTypes() {
|
|
3536
|
+
return ["ClipStart", "ClipEnd", "Marker", "BeatGrid"];
|
|
3537
|
+
}
|
|
3538
|
+
onPointerDown(event, ctx) {
|
|
3539
|
+
if (event.clipId === null) return;
|
|
3540
|
+
const clip = findClip3(ctx.state, event.clipId);
|
|
3541
|
+
if (!clip) return;
|
|
3542
|
+
const effects = clip.effects ?? [];
|
|
3543
|
+
if (effects.length === 0) return;
|
|
3544
|
+
const hitKf = findKeyframeAt(clip, event.x, ctx.pixelsPerFrame);
|
|
3545
|
+
if (hitKf) {
|
|
3546
|
+
this.draggingKeyframe = {
|
|
3547
|
+
clipId: clip.id,
|
|
3548
|
+
effectId: hitKf.effectId,
|
|
3549
|
+
keyframeId: hitKf.keyframe.id,
|
|
3550
|
+
startX: event.x,
|
|
3551
|
+
startFrame: hitKf.keyframe.frame
|
|
3552
|
+
};
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
const firstEffect = effects[0];
|
|
3556
|
+
let targetFrame = ctx.frameAtX(event.x);
|
|
3557
|
+
if (ctx.snapIndex.enabled) {
|
|
3558
|
+
const snapPoint = nearest(
|
|
3559
|
+
ctx.snapIndex,
|
|
3560
|
+
targetFrame,
|
|
3561
|
+
SNAP_RADIUS_FRAMES,
|
|
3562
|
+
void 0,
|
|
3563
|
+
["ClipStart", "ClipEnd", "Marker", "BeatGrid"]
|
|
3564
|
+
);
|
|
3565
|
+
if (snapPoint) targetFrame = snapPoint.frame;
|
|
3566
|
+
}
|
|
3567
|
+
this.activeClipId = clip.id;
|
|
3568
|
+
this.activeEffectId = firstEffect.id;
|
|
3569
|
+
this.pendingAddKeyframe = {
|
|
3570
|
+
clipId: clip.id,
|
|
3571
|
+
effectId: firstEffect.id,
|
|
3572
|
+
targetFrame
|
|
3573
|
+
};
|
|
3574
|
+
}
|
|
3575
|
+
onPointerMove(event, ctx) {
|
|
3576
|
+
if (this.draggingKeyframe === null) return null;
|
|
3577
|
+
const clip = findClip3(ctx.state, this.draggingKeyframe.clipId);
|
|
3578
|
+
if (!clip) return null;
|
|
3579
|
+
const dragDeltaX = event.x - this.draggingKeyframe.startX;
|
|
3580
|
+
const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame);
|
|
3581
|
+
let newFrame = Math.max(0, this.draggingKeyframe.startFrame + deltaFrames);
|
|
3582
|
+
if (ctx.snapIndex.enabled) {
|
|
3583
|
+
const snapPoint = nearest(
|
|
3584
|
+
ctx.snapIndex,
|
|
3585
|
+
newFrame,
|
|
3586
|
+
SNAP_RADIUS_FRAMES,
|
|
3587
|
+
void 0,
|
|
3588
|
+
["ClipStart", "ClipEnd", "Marker", "BeatGrid"]
|
|
3589
|
+
);
|
|
3590
|
+
if (snapPoint) newFrame = snapPoint.frame;
|
|
3591
|
+
}
|
|
3592
|
+
const nextState = applyOperation2(ctx.state, {
|
|
3593
|
+
type: "MOVE_KEYFRAME",
|
|
3594
|
+
clipId: this.draggingKeyframe.clipId,
|
|
3595
|
+
effectId: this.draggingKeyframe.effectId,
|
|
3596
|
+
keyframeId: this.draggingKeyframe.keyframeId,
|
|
3597
|
+
newFrame
|
|
3598
|
+
});
|
|
3599
|
+
const updatedClip = nextState.timeline.tracks.flatMap((t) => t.clips).find((c) => c.id === this.draggingKeyframe.clipId);
|
|
3600
|
+
if (!updatedClip) return null;
|
|
3601
|
+
return {
|
|
3602
|
+
clips: [updatedClip],
|
|
3603
|
+
isProvisional: true
|
|
3604
|
+
};
|
|
3605
|
+
}
|
|
3606
|
+
onPointerUp(event, ctx) {
|
|
3607
|
+
const dragging = this.draggingKeyframe;
|
|
3608
|
+
const pendingAdd = this.pendingAddKeyframe;
|
|
3609
|
+
this.draggingKeyframe = null;
|
|
3610
|
+
this.activeClipId = null;
|
|
3611
|
+
this.activeEffectId = null;
|
|
3612
|
+
this.pendingAddKeyframe = null;
|
|
3613
|
+
if (pendingAdd !== null) {
|
|
3614
|
+
return {
|
|
3615
|
+
id: txId2(),
|
|
3616
|
+
label: "Add keyframe",
|
|
3617
|
+
timestamp: Date.now(),
|
|
3618
|
+
operations: [
|
|
3619
|
+
{
|
|
3620
|
+
type: "ADD_KEYFRAME",
|
|
3621
|
+
clipId: pendingAdd.clipId,
|
|
3622
|
+
effectId: pendingAdd.effectId,
|
|
3623
|
+
keyframe: {
|
|
3624
|
+
id: toKeyframeId(`kf-${Date.now()}`),
|
|
3625
|
+
frame: pendingAdd.targetFrame,
|
|
3626
|
+
value: 1,
|
|
3627
|
+
easing: LINEAR_EASING
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
]
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
if (dragging === null) return null;
|
|
3634
|
+
const dragDeltaX = event.x - dragging.startX;
|
|
3635
|
+
const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame);
|
|
3636
|
+
let newFrame = Math.max(0, dragging.startFrame + deltaFrames);
|
|
3637
|
+
if (ctx.snapIndex.enabled) {
|
|
3638
|
+
const snapPoint = nearest(
|
|
3639
|
+
ctx.snapIndex,
|
|
3640
|
+
newFrame,
|
|
3641
|
+
SNAP_RADIUS_FRAMES,
|
|
3642
|
+
void 0,
|
|
3643
|
+
["ClipStart", "ClipEnd", "Marker", "BeatGrid"]
|
|
3644
|
+
);
|
|
3645
|
+
if (snapPoint) newFrame = snapPoint.frame;
|
|
3646
|
+
}
|
|
3647
|
+
if (newFrame === dragging.startFrame) return null;
|
|
3648
|
+
return {
|
|
3649
|
+
id: txId2(),
|
|
3650
|
+
label: "Move keyframe",
|
|
3651
|
+
timestamp: Date.now(),
|
|
3652
|
+
operations: [
|
|
3653
|
+
{
|
|
3654
|
+
type: "MOVE_KEYFRAME",
|
|
3655
|
+
clipId: dragging.clipId,
|
|
3656
|
+
effectId: dragging.effectId,
|
|
3657
|
+
keyframeId: dragging.keyframeId,
|
|
3658
|
+
newFrame
|
|
3659
|
+
}
|
|
3660
|
+
]
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
onKeyDown(event, ctx) {
|
|
3664
|
+
if (event.key !== "Delete" && event.key !== "Backspace") return null;
|
|
3665
|
+
const dragging = this.draggingKeyframe;
|
|
3666
|
+
this.draggingKeyframe = null;
|
|
3667
|
+
this.activeClipId = null;
|
|
3668
|
+
this.activeEffectId = null;
|
|
3669
|
+
this.pendingAddKeyframe = null;
|
|
3670
|
+
if (dragging === null) return null;
|
|
3671
|
+
return {
|
|
3672
|
+
id: txId2(),
|
|
3673
|
+
label: "Delete keyframe",
|
|
3674
|
+
timestamp: Date.now(),
|
|
3675
|
+
operations: [
|
|
3676
|
+
{
|
|
3677
|
+
type: "DELETE_KEYFRAME",
|
|
3678
|
+
clipId: dragging.clipId,
|
|
3679
|
+
effectId: dragging.effectId,
|
|
3680
|
+
keyframeId: dragging.keyframeId
|
|
3681
|
+
}
|
|
3682
|
+
]
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
onKeyUp(_event, _ctx) {
|
|
3686
|
+
}
|
|
3687
|
+
onCancel() {
|
|
3688
|
+
this.draggingKeyframe = null;
|
|
3689
|
+
this.activeClipId = null;
|
|
3690
|
+
this.activeEffectId = null;
|
|
3691
|
+
this.pendingAddKeyframe = null;
|
|
3692
|
+
}
|
|
3693
|
+
};
|
|
3694
|
+
|
|
3695
|
+
// src/engine/serialization-error.ts
|
|
3696
|
+
var SerializationError = class extends Error {
|
|
3697
|
+
constructor(message, violations) {
|
|
3698
|
+
super(message);
|
|
3699
|
+
this.violations = violations;
|
|
3700
|
+
this.name = "SerializationError";
|
|
3701
|
+
}
|
|
3702
|
+
};
|
|
3703
|
+
|
|
3704
|
+
// src/engine/migrator.ts
|
|
3705
|
+
function migrateV1toV2(raw) {
|
|
3706
|
+
return {
|
|
3707
|
+
...raw,
|
|
3708
|
+
schemaVersion: 2
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
function migrate(raw) {
|
|
3712
|
+
if (typeof raw !== "object" || raw === null) {
|
|
3713
|
+
throw new SerializationError("Invalid JSON structure");
|
|
3714
|
+
}
|
|
3715
|
+
const obj = raw;
|
|
3716
|
+
const version = obj.schemaVersion;
|
|
3717
|
+
if (typeof version !== "number") {
|
|
3718
|
+
throw new SerializationError("Missing schemaVersion");
|
|
3719
|
+
}
|
|
3720
|
+
if (version > CURRENT_SCHEMA_VERSION) {
|
|
3721
|
+
throw new SerializationError(`Unknown schema version: ${version}`);
|
|
3722
|
+
}
|
|
3723
|
+
let current = raw;
|
|
3724
|
+
if (version < 2) current = migrateV1toV2(current);
|
|
3725
|
+
const curr = current;
|
|
3726
|
+
const registry = curr.assetRegistry;
|
|
3727
|
+
const assetRegistry = registry instanceof Map ? registry : new Map(
|
|
3728
|
+
Object.entries(registry).map(([k, v]) => [k, v])
|
|
3729
|
+
);
|
|
3730
|
+
return {
|
|
3731
|
+
schemaVersion: curr.schemaVersion,
|
|
3732
|
+
timeline: curr.timeline,
|
|
3733
|
+
assetRegistry
|
|
3734
|
+
};
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
// src/engine/serializer.ts
|
|
3738
|
+
function serializeTimeline(state) {
|
|
3739
|
+
const plain = {
|
|
3740
|
+
schemaVersion: state.schemaVersion,
|
|
3741
|
+
timeline: state.timeline,
|
|
3742
|
+
assetRegistry: Object.fromEntries(state.assetRegistry)
|
|
3743
|
+
};
|
|
3744
|
+
return JSON.stringify(plain, null, 2);
|
|
3745
|
+
}
|
|
3746
|
+
function deserializeTimeline(raw) {
|
|
3747
|
+
let parsed;
|
|
3748
|
+
try {
|
|
3749
|
+
parsed = JSON.parse(raw);
|
|
3750
|
+
} catch (e) {
|
|
3751
|
+
const msg = e instanceof Error ? e.message : "Invalid JSON";
|
|
3752
|
+
throw new SerializationError(msg);
|
|
3753
|
+
}
|
|
3754
|
+
let state;
|
|
3755
|
+
try {
|
|
3756
|
+
state = migrate(parsed);
|
|
3757
|
+
} catch (e) {
|
|
3758
|
+
if (e instanceof SerializationError) throw e;
|
|
3759
|
+
throw new SerializationError(e instanceof Error ? e.message : "Migration failed");
|
|
3760
|
+
}
|
|
3761
|
+
const violations = checkInvariants(state);
|
|
3762
|
+
if (violations.length > 0) {
|
|
3763
|
+
throw new SerializationError("State failed invariant checks", violations);
|
|
3764
|
+
}
|
|
3765
|
+
return state;
|
|
3766
|
+
}
|
|
3767
|
+
function remapAssetPaths(state, remap) {
|
|
3768
|
+
const next = /* @__PURE__ */ new Map();
|
|
3769
|
+
for (const [id, asset] of state.assetRegistry) {
|
|
3770
|
+
if (asset.kind === "file") {
|
|
3771
|
+
next.set(id, remap(asset));
|
|
3772
|
+
} else {
|
|
3773
|
+
next.set(id, asset);
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
return { ...state, assetRegistry: next };
|
|
3777
|
+
}
|
|
3778
|
+
function findOfflineAssets(state, isOnline) {
|
|
3779
|
+
const result = [];
|
|
3780
|
+
for (const asset of state.assetRegistry.values()) {
|
|
3781
|
+
if (asset.kind !== "file") continue;
|
|
3782
|
+
if (isOnline(asset)) continue;
|
|
3783
|
+
const clipIds = [];
|
|
3784
|
+
for (const track of state.timeline.tracks) {
|
|
3785
|
+
for (const clip of track.clips) {
|
|
3786
|
+
if (clip.assetId === asset.id) clipIds.push(clip.id);
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
result.push({
|
|
3790
|
+
assetId: asset.id,
|
|
3791
|
+
path: asset.filePath,
|
|
3792
|
+
clipIds
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
return result;
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
// src/engine/otio-export.ts
|
|
3799
|
+
function rationalTime(value, rate) {
|
|
3800
|
+
return { value, rate };
|
|
3801
|
+
}
|
|
3802
|
+
function timeRange(start, duration, rate) {
|
|
3803
|
+
return {
|
|
3804
|
+
OTIO_SCHEMA: "TimeRange.1",
|
|
3805
|
+
start_time: rationalTime(start, rate),
|
|
3806
|
+
duration: rationalTime(duration, rate)
|
|
3807
|
+
};
|
|
3808
|
+
}
|
|
3809
|
+
function fpsFromTimeline(state) {
|
|
3810
|
+
return state.timeline.fps;
|
|
3811
|
+
}
|
|
3812
|
+
function clipDurationFrames(clip) {
|
|
3813
|
+
return clip.timelineEnd - clip.timelineStart;
|
|
3814
|
+
}
|
|
3815
|
+
function mediaReferenceForClip(state, clip, fps) {
|
|
3816
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
3817
|
+
if (!asset) {
|
|
3818
|
+
return { OTIO_SCHEMA: "MissingReference.1" };
|
|
3819
|
+
}
|
|
3820
|
+
if (asset.kind === "file") {
|
|
3821
|
+
const fa = asset;
|
|
3822
|
+
const dur = fa.intrinsicDuration;
|
|
3823
|
+
return {
|
|
3824
|
+
OTIO_SCHEMA: "ExternalReference.1",
|
|
3825
|
+
target_url: fa.filePath,
|
|
3826
|
+
available_range: timeRange(0, dur, fps)
|
|
3827
|
+
};
|
|
3828
|
+
}
|
|
3829
|
+
const ga = asset;
|
|
3830
|
+
return {
|
|
3831
|
+
OTIO_SCHEMA: "GeneratorReference.1",
|
|
3832
|
+
generator_kind: ga.generatorDef.type
|
|
3833
|
+
};
|
|
3834
|
+
}
|
|
3835
|
+
function effectToOTIO(e) {
|
|
3836
|
+
return {
|
|
3837
|
+
OTIO_SCHEMA: "Effect.1",
|
|
3838
|
+
name: e.effectType,
|
|
3839
|
+
effect_name: e.effectType,
|
|
3840
|
+
enabled: e.enabled,
|
|
3841
|
+
metadata: { params: e.params ?? [] }
|
|
3842
|
+
};
|
|
3843
|
+
}
|
|
3844
|
+
function clipToOTIO(state, clip, fps) {
|
|
3845
|
+
const durationFrames = clipDurationFrames(clip);
|
|
3846
|
+
const mediaStart = clip.mediaIn;
|
|
3847
|
+
const otioClip = {
|
|
3848
|
+
OTIO_SCHEMA: "Clip.1",
|
|
3849
|
+
name: clip.name ?? clip.id,
|
|
3850
|
+
source_range: {
|
|
3851
|
+
OTIO_SCHEMA: "TimeRange.1",
|
|
3852
|
+
start_time: rationalTime(mediaStart, fps),
|
|
3853
|
+
duration: rationalTime(durationFrames, fps)
|
|
3854
|
+
},
|
|
3855
|
+
media_reference: mediaReferenceForClip(state, clip, fps)
|
|
3856
|
+
};
|
|
3857
|
+
const effects = clip.effects;
|
|
3858
|
+
if (effects && effects.length > 0) {
|
|
3859
|
+
otioClip.effects = effects.map(effectToOTIO);
|
|
3860
|
+
}
|
|
3861
|
+
return otioClip;
|
|
3862
|
+
}
|
|
3863
|
+
function trackToOTIO(state, track, fps) {
|
|
3864
|
+
const children = [];
|
|
3865
|
+
const clips = track.clips;
|
|
3866
|
+
for (let i = 0; i < clips.length; i++) {
|
|
3867
|
+
const clip = clips[i];
|
|
3868
|
+
const clipStart = clip.timelineStart;
|
|
3869
|
+
const prevEnd = i === 0 ? 0 : clips[i - 1].timelineEnd;
|
|
3870
|
+
const gapFrames = clipStart - prevEnd;
|
|
3871
|
+
if (gapFrames > 0) {
|
|
3872
|
+
children.push({
|
|
3873
|
+
OTIO_SCHEMA: "Gap.1",
|
|
3874
|
+
source_range: timeRange(0, gapFrames, fps)
|
|
3875
|
+
});
|
|
3876
|
+
}
|
|
3877
|
+
children.push(clipToOTIO(state, clip, fps));
|
|
3878
|
+
}
|
|
3879
|
+
const kind = track.type === "video" ? "Video" : track.type === "audio" ? "Audio" : "Video";
|
|
3880
|
+
return {
|
|
3881
|
+
OTIO_SCHEMA: "Track.1",
|
|
3882
|
+
kind,
|
|
3883
|
+
children
|
|
3884
|
+
};
|
|
3885
|
+
}
|
|
3886
|
+
function markerToOTIO(marker, fps) {
|
|
3887
|
+
if (marker.type === "point") {
|
|
3888
|
+
const frame2 = marker.frame;
|
|
3889
|
+
return {
|
|
3890
|
+
OTIO_SCHEMA: "Marker.1",
|
|
3891
|
+
name: marker.label ?? "",
|
|
3892
|
+
color: marker.color ?? "RED",
|
|
3893
|
+
marked_range: timeRange(frame2, 0, fps)
|
|
3894
|
+
};
|
|
3895
|
+
}
|
|
3896
|
+
const start = marker.frameStart;
|
|
3897
|
+
const duration = marker.frameEnd - marker.frameStart;
|
|
3898
|
+
return {
|
|
3899
|
+
OTIO_SCHEMA: "Marker.1",
|
|
3900
|
+
name: marker.label ?? "",
|
|
3901
|
+
color: marker.color ?? "RED",
|
|
3902
|
+
marked_range: timeRange(start, duration, fps)
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3905
|
+
function exportToOTIO(state) {
|
|
3906
|
+
const fps = fpsFromTimeline(state);
|
|
3907
|
+
const timeline = state.timeline;
|
|
3908
|
+
const tracks = timeline.tracks.map((t) => trackToOTIO(state, t, fps));
|
|
3909
|
+
const markers = (timeline.markers ?? []).map((m) => markerToOTIO(m, fps));
|
|
3910
|
+
return {
|
|
3911
|
+
OTIO_SCHEMA: "Timeline.1",
|
|
3912
|
+
name: timeline.name ?? "Untitled",
|
|
3913
|
+
global_start_time: rationalTime(0, fps),
|
|
3914
|
+
tracks: {
|
|
3915
|
+
OTIO_SCHEMA: "Stack.1",
|
|
3916
|
+
children: tracks
|
|
3917
|
+
},
|
|
3918
|
+
markers
|
|
3919
|
+
};
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
// src/types/marker.ts
|
|
3923
|
+
var toMarkerId = (s) => s;
|
|
3924
|
+
|
|
3925
|
+
// src/types/generator.ts
|
|
3926
|
+
var toGeneratorId = (s) => s;
|
|
3927
|
+
|
|
3928
|
+
// src/engine/otio-import.ts
|
|
3929
|
+
var clipIdCounter = 0;
|
|
3930
|
+
function generateClipId() {
|
|
3931
|
+
return `clip-${++clipIdCounter}`;
|
|
3932
|
+
}
|
|
3933
|
+
function parseFps(doc, options) {
|
|
3934
|
+
if (options?.fps != null) return options.fps;
|
|
3935
|
+
const global = doc.global_start_time;
|
|
3936
|
+
if (global?.rate != null) return global.rate;
|
|
3937
|
+
const tracks = doc.tracks?.children;
|
|
3938
|
+
if (tracks) {
|
|
3939
|
+
for (const track of tracks) {
|
|
3940
|
+
const children = track?.children ?? [];
|
|
3941
|
+
for (const item of children) {
|
|
3942
|
+
const sr = item?.source_range;
|
|
3943
|
+
if (sr?.duration?.rate != null) return sr.duration.rate;
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
}
|
|
3947
|
+
return 30;
|
|
3948
|
+
}
|
|
3949
|
+
function rationalTimeToFrames(rt, targetFps) {
|
|
3950
|
+
if (!rt) return 0;
|
|
3951
|
+
const value = rt.value ?? 0;
|
|
3952
|
+
const rate = rt.rate ?? targetFps;
|
|
3953
|
+
return Math.round(value * (targetFps / rate));
|
|
3954
|
+
}
|
|
3955
|
+
function ensureFrameRate(fps) {
|
|
3956
|
+
const valid = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
|
|
3957
|
+
if (!valid.includes(fps)) return 30;
|
|
3958
|
+
return frameRate(fps);
|
|
3959
|
+
}
|
|
3960
|
+
function importFromOTIO(doc, options) {
|
|
3961
|
+
if (typeof doc !== "object" || doc === null) {
|
|
3962
|
+
throw new SerializationError("Invalid OTIO document");
|
|
3963
|
+
}
|
|
3964
|
+
const d = doc;
|
|
3965
|
+
const schema = d.OTIO_SCHEMA;
|
|
3966
|
+
if (typeof schema !== "string" || !schema.startsWith("Timeline")) {
|
|
3967
|
+
throw new SerializationError("Invalid OTIO document: OTIO_SCHEMA must be Timeline");
|
|
3968
|
+
}
|
|
3969
|
+
const targetFps = parseFps(d, options);
|
|
3970
|
+
const fps = ensureFrameRate(targetFps);
|
|
3971
|
+
const timelineName = options?.name ?? d.name ?? "Untitled";
|
|
3972
|
+
const assetRegistry = /* @__PURE__ */ new Map();
|
|
3973
|
+
const tracks = [];
|
|
3974
|
+
const trackList = d.tracks?.children ?? [];
|
|
3975
|
+
for (let ti = 0; ti < trackList.length; ti++) {
|
|
3976
|
+
const otioTrack = trackList[ti];
|
|
3977
|
+
const kind = otioTrack.kind ?? "Video";
|
|
3978
|
+
const trackType = kind === "Audio" ? "audio" : "video";
|
|
3979
|
+
const trackId = toTrackId(`track-${ti + 1}`);
|
|
3980
|
+
const clips = [];
|
|
3981
|
+
let cursorFrames = 0;
|
|
3982
|
+
const children = otioTrack.children ?? [];
|
|
3983
|
+
for (const item of children) {
|
|
3984
|
+
const itemSchema = item?.OTIO_SCHEMA ?? "";
|
|
3985
|
+
if (itemSchema === "Gap.1") {
|
|
3986
|
+
const dur = item?.source_range?.duration;
|
|
3987
|
+
const gapFrames = rationalTimeToFrames(dur, targetFps);
|
|
3988
|
+
cursorFrames += gapFrames;
|
|
3989
|
+
continue;
|
|
3990
|
+
}
|
|
3991
|
+
if (itemSchema === "Clip.1") {
|
|
3992
|
+
const sr = item.source_range;
|
|
3993
|
+
const durationFrames = rationalTimeToFrames(sr?.duration, targetFps);
|
|
3994
|
+
const mediaStartFrames = rationalTimeToFrames(sr?.start_time, targetFps);
|
|
3995
|
+
const clipName = item.name ?? generateClipId();
|
|
3996
|
+
const clipId = toClipId(clipName);
|
|
3997
|
+
const mediaRef = item.media_reference;
|
|
3998
|
+
let assetId;
|
|
3999
|
+
if (mediaRef?.OTIO_SCHEMA === "GeneratorReference.1") {
|
|
4000
|
+
const genKind = mediaRef.generator_kind ?? "solid";
|
|
4001
|
+
const assetIdStr = `gen-${ti}-${clips.length}`;
|
|
4002
|
+
assetId = toAssetId(assetIdStr);
|
|
4003
|
+
if (!assetRegistry.has(assetId)) {
|
|
4004
|
+
const genAsset = createGeneratorAsset({
|
|
4005
|
+
id: assetIdStr,
|
|
4006
|
+
name: genKind,
|
|
4007
|
+
mediaType: trackType,
|
|
4008
|
+
generatorDef: {
|
|
4009
|
+
id: toGeneratorId(assetIdStr),
|
|
4010
|
+
type: ["solid", "bars", "countdown", "noise", "text"].includes(genKind) ? genKind : "solid",
|
|
4011
|
+
params: {},
|
|
4012
|
+
duration: toFrame(Math.max(1, durationFrames)),
|
|
4013
|
+
name: genKind
|
|
4014
|
+
},
|
|
4015
|
+
nativeFps: fps
|
|
4016
|
+
});
|
|
4017
|
+
assetRegistry.set(assetId, genAsset);
|
|
4018
|
+
}
|
|
4019
|
+
} else if (mediaRef?.OTIO_SCHEMA === "ExternalReference.1" && mediaRef.target_url != null) {
|
|
4020
|
+
const url = mediaRef.target_url;
|
|
4021
|
+
const avail = mediaRef.available_range;
|
|
4022
|
+
const intrinsicDuration = rationalTimeToFrames(avail?.duration, targetFps) || 1;
|
|
4023
|
+
const aidStr = `asset-${url}-${intrinsicDuration}`;
|
|
4024
|
+
assetId = toAssetId(aidStr);
|
|
4025
|
+
if (!assetRegistry.has(assetId)) {
|
|
4026
|
+
const fileAsset = createAsset({
|
|
4027
|
+
id: aidStr,
|
|
4028
|
+
name: url.split("/").pop() ?? "media",
|
|
4029
|
+
mediaType: trackType,
|
|
4030
|
+
filePath: url,
|
|
4031
|
+
intrinsicDuration: toFrame(Math.max(1, intrinsicDuration)),
|
|
4032
|
+
nativeFps: fps,
|
|
4033
|
+
sourceTimecodeOffset: toFrame(0)
|
|
4034
|
+
});
|
|
4035
|
+
assetRegistry.set(assetId, fileAsset);
|
|
4036
|
+
}
|
|
4037
|
+
} else {
|
|
4038
|
+
const aidStr = `missing-${ti}-${clips.length}`;
|
|
4039
|
+
assetId = toAssetId(aidStr);
|
|
4040
|
+
if (!assetRegistry.has(assetId)) {
|
|
4041
|
+
const fileAsset = createAsset({
|
|
4042
|
+
id: aidStr,
|
|
4043
|
+
name: "Missing",
|
|
4044
|
+
mediaType: trackType,
|
|
4045
|
+
filePath: "",
|
|
4046
|
+
intrinsicDuration: toFrame(Math.max(1, durationFrames)),
|
|
4047
|
+
nativeFps: fps,
|
|
4048
|
+
sourceTimecodeOffset: toFrame(0),
|
|
4049
|
+
status: "missing"
|
|
4050
|
+
});
|
|
4051
|
+
assetRegistry.set(assetId, fileAsset);
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
const timelineStart = toFrame(cursorFrames);
|
|
4055
|
+
const timelineEnd = toFrame(cursorFrames + durationFrames);
|
|
4056
|
+
const mediaIn = toFrame(mediaStartFrames);
|
|
4057
|
+
const mediaOut = toFrame(mediaStartFrames + durationFrames);
|
|
4058
|
+
let effects;
|
|
4059
|
+
const otioEffects = item.effects;
|
|
4060
|
+
if (otioEffects && otioEffects.length > 0) {
|
|
4061
|
+
effects = otioEffects.map((e, idx) => {
|
|
4062
|
+
const effectType = e.effect_name ?? e.name ?? "effect";
|
|
4063
|
+
return createEffect(
|
|
4064
|
+
toEffectId(`eff-${clipId}-${idx}`),
|
|
4065
|
+
effectType,
|
|
4066
|
+
"preComposite",
|
|
4067
|
+
e.metadata?.params ?? []
|
|
4068
|
+
);
|
|
4069
|
+
});
|
|
4070
|
+
}
|
|
4071
|
+
const clipParams = {
|
|
4072
|
+
id: clipId,
|
|
4073
|
+
assetId,
|
|
4074
|
+
trackId,
|
|
4075
|
+
timelineStart,
|
|
4076
|
+
timelineEnd,
|
|
4077
|
+
mediaIn,
|
|
4078
|
+
mediaOut
|
|
4079
|
+
};
|
|
4080
|
+
if (effects?.length) clipParams.effects = effects;
|
|
4081
|
+
const clip = createClip(clipParams);
|
|
4082
|
+
clips.push(clip);
|
|
4083
|
+
cursorFrames += durationFrames;
|
|
4084
|
+
}
|
|
4085
|
+
}
|
|
4086
|
+
tracks.push(
|
|
4087
|
+
createTrack({
|
|
4088
|
+
id: trackId,
|
|
4089
|
+
name: kind === "Audio" ? `Audio ${ti + 1}` : `Video ${ti + 1}`,
|
|
4090
|
+
type: trackType,
|
|
4091
|
+
clips
|
|
4092
|
+
})
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
const markers = [];
|
|
4096
|
+
const otioMarkers = d.markers ?? [];
|
|
4097
|
+
for (let i = 0; i < otioMarkers.length; i++) {
|
|
4098
|
+
const m = otioMarkers[i];
|
|
4099
|
+
const range = m.marked_range;
|
|
4100
|
+
const startFrames = rationalTimeToFrames(range?.start_time, targetFps);
|
|
4101
|
+
const durationFrames = rationalTimeToFrames(range?.duration, targetFps);
|
|
4102
|
+
if (durationFrames <= 0) {
|
|
4103
|
+
markers.push({
|
|
4104
|
+
type: "point",
|
|
4105
|
+
id: toMarkerId(`m${i + 1}`),
|
|
4106
|
+
frame: toFrame(startFrames),
|
|
4107
|
+
label: m.name ?? "",
|
|
4108
|
+
color: m.color ?? "RED",
|
|
4109
|
+
scope: "global",
|
|
4110
|
+
linkedClipId: null
|
|
4111
|
+
});
|
|
4112
|
+
} else {
|
|
4113
|
+
markers.push({
|
|
4114
|
+
type: "range",
|
|
4115
|
+
id: toMarkerId(`m${i + 1}`),
|
|
4116
|
+
frameStart: toFrame(startFrames),
|
|
4117
|
+
frameEnd: toFrame(startFrames + durationFrames),
|
|
4118
|
+
label: m.name ?? "",
|
|
4119
|
+
color: m.color ?? "RED",
|
|
4120
|
+
scope: "global",
|
|
4121
|
+
linkedClipId: null
|
|
4122
|
+
});
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
const timeline = createTimeline({
|
|
4126
|
+
id: "tl",
|
|
4127
|
+
name: timelineName,
|
|
4128
|
+
fps,
|
|
4129
|
+
duration: toFrame(Math.max(1, 86400)),
|
|
4130
|
+
startTimecode: "00:00:00:00",
|
|
4131
|
+
tracks,
|
|
4132
|
+
markers
|
|
4133
|
+
});
|
|
4134
|
+
const state = createTimelineState({
|
|
4135
|
+
timeline,
|
|
4136
|
+
assetRegistry
|
|
4137
|
+
});
|
|
4138
|
+
const violations = checkInvariants(state);
|
|
4139
|
+
if (violations.length > 0) {
|
|
4140
|
+
throw new SerializationError("OTIO import produced invalid state", violations);
|
|
4141
|
+
}
|
|
4142
|
+
return state;
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
// src/engine/edl-export.ts
|
|
4146
|
+
function pad2(n) {
|
|
4147
|
+
return String(n).padStart(2, "0");
|
|
4148
|
+
}
|
|
4149
|
+
function frameToTimecodeNonDrop(frame2, fps) {
|
|
4150
|
+
const totalSeconds = Math.floor(frame2 / fps);
|
|
4151
|
+
const ff = Math.floor(frame2 % fps);
|
|
4152
|
+
const ss = totalSeconds % 60;
|
|
4153
|
+
const mm = Math.floor(totalSeconds / 60) % 60;
|
|
4154
|
+
const hh = Math.floor(totalSeconds / 3600);
|
|
4155
|
+
return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`;
|
|
4156
|
+
}
|
|
4157
|
+
function frameToTimecodeDropFrame29_97(frame2) {
|
|
4158
|
+
const FRAMES_PER_MIN = 1798;
|
|
4159
|
+
const FRAMES_PER_10_MIN = 17982;
|
|
4160
|
+
const d = Math.floor(frame2 / FRAMES_PER_10_MIN);
|
|
4161
|
+
const m = Math.floor(frame2 % FRAMES_PER_10_MIN / FRAMES_PER_MIN);
|
|
4162
|
+
const totalMinutes = 10 * d + m;
|
|
4163
|
+
const r = frame2 % FRAMES_PER_MIN;
|
|
4164
|
+
const ss = Math.floor(r / 30);
|
|
4165
|
+
const ff = r % 30;
|
|
4166
|
+
const hh = Math.floor(totalMinutes / 60);
|
|
4167
|
+
const mm = totalMinutes % 60;
|
|
4168
|
+
return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`;
|
|
4169
|
+
}
|
|
4170
|
+
function frameToTimecode(frame2, fps, dropFrame) {
|
|
4171
|
+
if (!dropFrame || fps !== 29.97) {
|
|
4172
|
+
return frameToTimecodeNonDrop(frame2, fps);
|
|
4173
|
+
}
|
|
4174
|
+
return frameToTimecodeDropFrame29_97(frame2);
|
|
4175
|
+
}
|
|
4176
|
+
function reelName(asset) {
|
|
4177
|
+
let raw;
|
|
4178
|
+
if (!asset || asset.kind === "generator") raw = "AX";
|
|
4179
|
+
else {
|
|
4180
|
+
const fa = asset;
|
|
4181
|
+
const path = fa.filePath;
|
|
4182
|
+
const base = path.split("/").pop() ?? path;
|
|
4183
|
+
const noExt = base.includes(".") ? base.slice(0, base.lastIndexOf(".")) : base;
|
|
4184
|
+
raw = noExt.toUpperCase().replace(/[^A-Z0-9_-]/g, "_").slice(0, 8);
|
|
4185
|
+
}
|
|
4186
|
+
return raw.padEnd(8).slice(0, 8);
|
|
4187
|
+
}
|
|
4188
|
+
function transitionCode(clip) {
|
|
4189
|
+
const t = clip.transition;
|
|
4190
|
+
if (t && t.type === "dissolve") return "D";
|
|
4191
|
+
return "C";
|
|
4192
|
+
}
|
|
4193
|
+
function clipDisplayName(asset, clipName) {
|
|
4194
|
+
if (!asset) return clipName ?? "unknown";
|
|
4195
|
+
if (asset.kind === "file") return asset.filePath.split("/").pop() ?? asset.filePath;
|
|
4196
|
+
return asset.generatorDef?.type ?? asset.name ?? "generator";
|
|
4197
|
+
}
|
|
4198
|
+
function clipDurationFrames2(clip) {
|
|
4199
|
+
return clip.timelineEnd - clip.timelineStart;
|
|
4200
|
+
}
|
|
4201
|
+
function exportToEDL(state, options) {
|
|
4202
|
+
const title = options?.title ?? state.timeline.name ?? "Untitled";
|
|
4203
|
+
const dropFrame = options?.dropFrame ?? false;
|
|
4204
|
+
const trackIndex = options?.trackIndex ?? 0;
|
|
4205
|
+
const videoTracks = state.timeline.tracks.filter((t) => t.type === "video");
|
|
4206
|
+
const track = videoTracks[trackIndex];
|
|
4207
|
+
if (!track) {
|
|
4208
|
+
return `TITLE: ${title}
|
|
4209
|
+
FCM: ${dropFrame ? "DROP FRAME" : "NON-DROP FRAME"}
|
|
4210
|
+
`;
|
|
4211
|
+
}
|
|
4212
|
+
const fps = state.timeline.fps;
|
|
4213
|
+
const useDropFrame = dropFrame && fps === 29.97;
|
|
4214
|
+
let headerComment = "";
|
|
4215
|
+
if (dropFrame && fps !== 29.97) {
|
|
4216
|
+
headerComment = "* DROP FRAME NOT SUPPORTED FOR THIS FRAME RATE\n\n";
|
|
4217
|
+
}
|
|
4218
|
+
const lines = [];
|
|
4219
|
+
lines.push(`TITLE: ${title}`);
|
|
4220
|
+
lines.push(`FCM: ${useDropFrame ? "DROP FRAME" : "NON-DROP FRAME"}`);
|
|
4221
|
+
if (headerComment) lines.push(headerComment.trim());
|
|
4222
|
+
const clips = track.clips;
|
|
4223
|
+
for (let i = 0; i < clips.length; i++) {
|
|
4224
|
+
const clip = clips[i];
|
|
4225
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
4226
|
+
const eventNum = String(i + 1).padStart(3, "0");
|
|
4227
|
+
const reel = reelName(asset);
|
|
4228
|
+
const channel = "V";
|
|
4229
|
+
const trans = transitionCode(clip);
|
|
4230
|
+
const startFrame = clip.timelineStart;
|
|
4231
|
+
const dur = clipDurationFrames2(clip);
|
|
4232
|
+
const mediaStart = clip.mediaIn;
|
|
4233
|
+
const srcIn = frameToTimecode(mediaStart, fps, useDropFrame);
|
|
4234
|
+
const srcOut = frameToTimecode(mediaStart + dur, fps, useDropFrame);
|
|
4235
|
+
const recIn = frameToTimecode(startFrame, fps, useDropFrame);
|
|
4236
|
+
const recOut = frameToTimecode(startFrame + dur, fps, useDropFrame);
|
|
4237
|
+
const eventLine = `${eventNum} ${reel} ${channel} ${trans} ${srcIn} ${srcOut} ${recIn} ${recOut}`;
|
|
4238
|
+
lines.push(eventLine);
|
|
4239
|
+
lines.push(`* FROM CLIP NAME: ${clipDisplayName(asset, clip.name)}`);
|
|
4240
|
+
if (i < clips.length - 1) lines.push("");
|
|
4241
|
+
}
|
|
4242
|
+
return lines.join("\n");
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
// src/engine/aaf-export.ts
|
|
4246
|
+
function xmlEscape(s) {
|
|
4247
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4248
|
+
}
|
|
4249
|
+
function clipDurationFrames3(clip) {
|
|
4250
|
+
return clip.timelineEnd - clip.timelineStart;
|
|
4251
|
+
}
|
|
4252
|
+
function sourceRefForClip(state, clip) {
|
|
4253
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
4254
|
+
if (!asset) return "missing";
|
|
4255
|
+
if (asset.kind === "file") return asset.filePath;
|
|
4256
|
+
return asset.generatorDef.type;
|
|
4257
|
+
}
|
|
4258
|
+
function dataDefinition(track) {
|
|
4259
|
+
return track.type === "video" ? "Picture" : track.type === "audio" ? "Sound" : "Picture";
|
|
4260
|
+
}
|
|
4261
|
+
function exportToAAF(state, options) {
|
|
4262
|
+
const projectName = xmlEscape(options?.projectName ?? state.timeline.name ?? "Untitled");
|
|
4263
|
+
const fps = state.timeline.fps;
|
|
4264
|
+
const editRate = options?.frameRate ?? `${fps}/1`;
|
|
4265
|
+
const timelineName = xmlEscape(state.timeline.name ?? "Timeline");
|
|
4266
|
+
const lines = [];
|
|
4267
|
+
const indent = (n) => " ".repeat(n);
|
|
4268
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
4269
|
+
lines.push(`<AAF version="1.1"`);
|
|
4270
|
+
lines.push(` xmlns="urn:aaf:schema:1.1"`);
|
|
4271
|
+
lines.push(` projectName="${projectName}">`);
|
|
4272
|
+
lines.push(`${indent(1)}<Dictionary/>`);
|
|
4273
|
+
lines.push(`${indent(1)}<ContentStorage>`);
|
|
4274
|
+
const clipTrackPairs = [];
|
|
4275
|
+
for (const track of state.timeline.tracks) {
|
|
4276
|
+
for (const clip of track.clips) clipTrackPairs.push({ clip, track });
|
|
4277
|
+
}
|
|
4278
|
+
for (const { clip, track } of clipTrackPairs) {
|
|
4279
|
+
const clipId = xmlEscape(clip.id);
|
|
4280
|
+
const len = clipDurationFrames3(clip);
|
|
4281
|
+
const ref = xmlEscape(sourceRefForClip(state, clip));
|
|
4282
|
+
const def = dataDefinition(track);
|
|
4283
|
+
lines.push(`${indent(2)}<MasterMob name="${clipId}" mobID="${clipId}">`);
|
|
4284
|
+
lines.push(`${indent(3)}<TimelineMobSlot>`);
|
|
4285
|
+
lines.push(`${indent(4)}<Sequence dataDefinition="${def}">`);
|
|
4286
|
+
lines.push(`${indent(5)}<SourceClip length="${len}" sourceRef="${ref}"/>`);
|
|
4287
|
+
lines.push(`${indent(4)}</Sequence>`);
|
|
4288
|
+
lines.push(`${indent(3)}</TimelineMobSlot>`);
|
|
4289
|
+
lines.push(`${indent(2)}</MasterMob>`);
|
|
4290
|
+
}
|
|
4291
|
+
lines.push(`${indent(2)}<CompositionMob name="${timelineName}">`);
|
|
4292
|
+
state.timeline.tracks.forEach((track, slotIndex) => {
|
|
4293
|
+
const def = dataDefinition(track);
|
|
4294
|
+
lines.push(`${indent(3)}<TimelineMobSlot slotID="${slotIndex}" editRate="${editRate}">`);
|
|
4295
|
+
lines.push(`${indent(4)}<Sequence dataDefinition="${def}">`);
|
|
4296
|
+
let cursor = 0;
|
|
4297
|
+
for (const clip of track.clips) {
|
|
4298
|
+
const start = clip.timelineStart;
|
|
4299
|
+
const gapFrames = start - cursor;
|
|
4300
|
+
if (gapFrames > 0) {
|
|
4301
|
+
lines.push(`${indent(5)}<Filler length="${gapFrames}"/>`);
|
|
4302
|
+
}
|
|
4303
|
+
const len = clipDurationFrames3(clip);
|
|
4304
|
+
const clipId = xmlEscape(clip.id);
|
|
4305
|
+
lines.push(`${indent(5)}<SourceClip length="${len}" sourceRef="${clipId}"/>`);
|
|
4306
|
+
cursor = clip.timelineEnd;
|
|
4307
|
+
}
|
|
4308
|
+
lines.push(`${indent(4)}</Sequence>`);
|
|
4309
|
+
lines.push(`${indent(3)}</TimelineMobSlot>`);
|
|
4310
|
+
});
|
|
4311
|
+
lines.push(`${indent(2)}</CompositionMob>`);
|
|
4312
|
+
lines.push(`${indent(1)}</ContentStorage>`);
|
|
4313
|
+
lines.push("</AAF>");
|
|
4314
|
+
return lines.join("\n");
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
// src/engine/fcpxml-export.ts
|
|
4318
|
+
function xmlEscape2(s) {
|
|
4319
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
4320
|
+
}
|
|
4321
|
+
function toFCPTime(frames, fps) {
|
|
4322
|
+
if (frames === 0) return "0s";
|
|
4323
|
+
return `${frames}/${fps}s`;
|
|
4324
|
+
}
|
|
4325
|
+
function clipDurationFrames4(clip) {
|
|
4326
|
+
return clip.timelineEnd - clip.timelineStart;
|
|
4327
|
+
}
|
|
4328
|
+
var DEFAULT_WIDTH = 1920;
|
|
4329
|
+
var DEFAULT_HEIGHT = 1080;
|
|
4330
|
+
function exportToFCPXML(state, options) {
|
|
4331
|
+
const libraryName = xmlEscape2(options?.libraryName ?? "Library");
|
|
4332
|
+
const eventName = xmlEscape2(options?.eventName ?? state.timeline.name ?? "Event");
|
|
4333
|
+
const timelineName = xmlEscape2(state.timeline.name ?? "Project");
|
|
4334
|
+
const fps = state.timeline.fps;
|
|
4335
|
+
const lines = [];
|
|
4336
|
+
const indent = (n) => " ".repeat(n);
|
|
4337
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
4338
|
+
lines.push("<!DOCTYPE fcpxml>");
|
|
4339
|
+
lines.push('<fcpxml version="1.10">');
|
|
4340
|
+
lines.push(`${indent(1)}<resources>`);
|
|
4341
|
+
const frameDuration2 = fps > 0 ? `1/${fps}s` : "1/30s";
|
|
4342
|
+
lines.push(`${indent(2)}<format id="r1" name="FFVideoFormat${DEFAULT_WIDTH}x${DEFAULT_HEIGHT}p${fps}" frameDuration="${frameDuration2}" width="${DEFAULT_WIDTH}" height="${DEFAULT_HEIGHT}"/>`);
|
|
4343
|
+
const fileAssets = Array.from(state.assetRegistry.values()).filter((a) => a.kind === "file");
|
|
4344
|
+
for (const asset of fileAssets) {
|
|
4345
|
+
const fa = asset;
|
|
4346
|
+
const id = xmlEscape2(fa.id);
|
|
4347
|
+
const name = xmlEscape2(fa.name);
|
|
4348
|
+
const src = "file://" + xmlEscape2(fa.filePath);
|
|
4349
|
+
const duration = toFCPTime(fa.intrinsicDuration, fps);
|
|
4350
|
+
const hasVideo = fa.mediaType === "video" ? "1" : "0";
|
|
4351
|
+
const hasAudio = fa.mediaType === "audio" ? "1" : "0";
|
|
4352
|
+
lines.push(`${indent(2)}<asset id="${id}" name="${name}" src="${src}" duration="${duration}" hasVideo="${hasVideo}" hasAudio="${hasAudio}"/>`);
|
|
4353
|
+
}
|
|
4354
|
+
const genAssets = Array.from(state.assetRegistry.values()).filter((a) => a.kind === "generator");
|
|
4355
|
+
for (const asset of genAssets) {
|
|
4356
|
+
const ga = asset;
|
|
4357
|
+
const id = xmlEscape2(ga.id);
|
|
4358
|
+
const genType = xmlEscape2(ga.generatorDef.type);
|
|
4359
|
+
const uid = `.../Generators.localized/${genType}`;
|
|
4360
|
+
lines.push(`${indent(2)}<effect id="${id}" name="${genType}" uid="${xmlEscape2(uid)}"/>`);
|
|
4361
|
+
}
|
|
4362
|
+
lines.push(`${indent(1)}</resources>`);
|
|
4363
|
+
lines.push(`${indent(1)}<library name="${libraryName}">`);
|
|
4364
|
+
lines.push(`${indent(2)}<event name="${eventName}">`);
|
|
4365
|
+
lines.push(`${indent(3)}<project name="${timelineName}">`);
|
|
4366
|
+
let totalDurationFrames = 0;
|
|
4367
|
+
const firstTrack = state.timeline.tracks[0];
|
|
4368
|
+
if (firstTrack?.clips.length) {
|
|
4369
|
+
const last = firstTrack.clips[firstTrack.clips.length - 1];
|
|
4370
|
+
totalDurationFrames = last.timelineEnd;
|
|
4371
|
+
}
|
|
4372
|
+
const totalDuration = toFCPTime(totalDurationFrames, fps);
|
|
4373
|
+
lines.push(`${indent(4)}<sequence duration="${totalDuration}" format="r1" tcStart="0s" tcFormat="NDF">`);
|
|
4374
|
+
lines.push(`${indent(5)}<spine>`);
|
|
4375
|
+
const videoTrack = state.timeline.tracks.find((t) => t.type === "video") ?? state.timeline.tracks[0];
|
|
4376
|
+
if (videoTrack) {
|
|
4377
|
+
let cursor = 0;
|
|
4378
|
+
for (const clip of videoTrack.clips) {
|
|
4379
|
+
const start = clip.timelineStart;
|
|
4380
|
+
const gapFrames = start - cursor;
|
|
4381
|
+
if (gapFrames > 0) {
|
|
4382
|
+
const gapOffset = toFCPTime(start, fps);
|
|
4383
|
+
const gapDur = toFCPTime(gapFrames, fps);
|
|
4384
|
+
lines.push(`${indent(6)}<gap name="Gap" offset="${gapOffset}" duration="${gapDur}" start="0s"/>`);
|
|
4385
|
+
}
|
|
4386
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
4387
|
+
const dur = clipDurationFrames4(clip);
|
|
4388
|
+
const offset = toFCPTime(start, fps);
|
|
4389
|
+
const durationStr = toFCPTime(dur, fps);
|
|
4390
|
+
const mediaStart = toFCPTime(clip.mediaIn, fps);
|
|
4391
|
+
const clipId = xmlEscape2(clip.id);
|
|
4392
|
+
if (asset?.kind === "generator") {
|
|
4393
|
+
const ga = asset;
|
|
4394
|
+
const ref = xmlEscape2(ga.id);
|
|
4395
|
+
lines.push(`${indent(6)}<clip name="${clipId}" offset="${offset}" duration="${durationStr}" start="${mediaStart}" tcFormat="NDF">`);
|
|
4396
|
+
lines.push(`${indent(7)}<generator ref="${ref}" offset="0s" duration="${durationStr}" start="0s">`);
|
|
4397
|
+
lines.push(`${indent(8)}<param name="Generator" value="${xmlEscape2(ga.generatorDef.type)}"/>`);
|
|
4398
|
+
lines.push(`${indent(7)}</generator>`);
|
|
4399
|
+
lines.push(`${indent(6)}</clip>`);
|
|
4400
|
+
} else {
|
|
4401
|
+
const ref = asset ? xmlEscape2(asset.id) : "missing";
|
|
4402
|
+
lines.push(`${indent(6)}<clip name="${clipId}" offset="${offset}" duration="${durationStr}" start="${mediaStart}" tcFormat="NDF">`);
|
|
4403
|
+
lines.push(`${indent(7)}<video ref="${ref}" offset="0s" duration="${durationStr}" start="0s"/>`);
|
|
4404
|
+
lines.push(`${indent(6)}</clip>`);
|
|
4405
|
+
}
|
|
4406
|
+
cursor = clip.timelineEnd;
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
for (const track of state.timeline.tracks) {
|
|
4410
|
+
if (track.type !== "audio") continue;
|
|
4411
|
+
for (const clip of track.clips) {
|
|
4412
|
+
const dur = clipDurationFrames4(clip);
|
|
4413
|
+
const start = clip.timelineStart;
|
|
4414
|
+
const offset = toFCPTime(start, fps);
|
|
4415
|
+
const durationStr = toFCPTime(dur, fps);
|
|
4416
|
+
const asset = state.assetRegistry.get(clip.assetId);
|
|
4417
|
+
const ref = asset ? xmlEscape2(asset.id) : "missing";
|
|
4418
|
+
const clipId = xmlEscape2(clip.id);
|
|
4419
|
+
lines.push(`${indent(6)}<asset-clip ref="${ref}" name="${clipId}" offset="${offset}" duration="${durationStr}" start="0s" role="dialogue"/>`);
|
|
4420
|
+
}
|
|
4421
|
+
}
|
|
4422
|
+
lines.push(`${indent(5)}</spine>`);
|
|
4423
|
+
lines.push(`${indent(4)}</sequence>`);
|
|
4424
|
+
lines.push(`${indent(3)}</project>`);
|
|
4425
|
+
lines.push(`${indent(2)}</event>`);
|
|
4426
|
+
lines.push(`${indent(1)}</library>`);
|
|
4427
|
+
lines.push("</fcpxml>");
|
|
4428
|
+
return lines.join("\n");
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
// src/types/project.ts
|
|
4432
|
+
function toProjectId(s) {
|
|
4433
|
+
return s;
|
|
4434
|
+
}
|
|
4435
|
+
function toBinId(s) {
|
|
4436
|
+
return s;
|
|
4437
|
+
}
|
|
4438
|
+
function createBin(id, label, parentId = null) {
|
|
4439
|
+
return { id, label, parentId, items: [] };
|
|
4440
|
+
}
|
|
4441
|
+
function createProject(id, name, timelines = []) {
|
|
4442
|
+
const now = Date.now();
|
|
4443
|
+
return {
|
|
4444
|
+
id,
|
|
4445
|
+
name,
|
|
4446
|
+
timelines,
|
|
4447
|
+
bins: [],
|
|
4448
|
+
rootBinIds: [],
|
|
4449
|
+
createdAt: now,
|
|
4450
|
+
updatedAt: now,
|
|
4451
|
+
schemaVersion: CURRENT_SCHEMA_VERSION
|
|
4452
|
+
};
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
// src/engine/project-ops.ts
|
|
4456
|
+
function withUpdatedAt(project, updatedAt = Date.now()) {
|
|
4457
|
+
return { ...project, updatedAt };
|
|
4458
|
+
}
|
|
4459
|
+
function itemsEqual(a, b) {
|
|
4460
|
+
if (a.kind !== b.kind) return false;
|
|
4461
|
+
switch (a.kind) {
|
|
4462
|
+
case "asset":
|
|
4463
|
+
return a.assetId === b.assetId;
|
|
4464
|
+
case "sequence":
|
|
4465
|
+
return a.timelineId === b.timelineId;
|
|
4466
|
+
case "bin":
|
|
4467
|
+
return a.binId === b.binId;
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
function addTimeline(project, state) {
|
|
4471
|
+
return withUpdatedAt({
|
|
4472
|
+
...project,
|
|
4473
|
+
timelines: [...project.timelines, state]
|
|
4474
|
+
});
|
|
4475
|
+
}
|
|
4476
|
+
function removeTimeline(project, timelineId) {
|
|
4477
|
+
const nextTimelines = project.timelines.filter((t) => t.timeline.id !== timelineId);
|
|
4478
|
+
if (nextTimelines.length === project.timelines.length) return project;
|
|
4479
|
+
return withUpdatedAt({ ...project, timelines: nextTimelines });
|
|
4480
|
+
}
|
|
4481
|
+
function addBin(project, bin) {
|
|
4482
|
+
const nextBins = [...project.bins, bin];
|
|
4483
|
+
const nextRoot = bin.parentId === null ? [...project.rootBinIds, bin.id] : project.rootBinIds;
|
|
4484
|
+
return withUpdatedAt({ ...project, bins: nextBins, rootBinIds: nextRoot });
|
|
4485
|
+
}
|
|
4486
|
+
function removeBin(project, binId) {
|
|
4487
|
+
const toRemove = /* @__PURE__ */ new Set();
|
|
4488
|
+
const byParent = /* @__PURE__ */ new Map();
|
|
4489
|
+
for (const b of project.bins) {
|
|
4490
|
+
const key = b.parentId;
|
|
4491
|
+
const arr = byParent.get(key) ?? [];
|
|
4492
|
+
arr.push(b);
|
|
4493
|
+
byParent.set(key, arr);
|
|
4494
|
+
}
|
|
4495
|
+
const stack = [binId];
|
|
4496
|
+
while (stack.length) {
|
|
4497
|
+
const id = stack.pop();
|
|
4498
|
+
if (toRemove.has(id)) continue;
|
|
4499
|
+
toRemove.add(id);
|
|
4500
|
+
const children = byParent.get(id) ?? [];
|
|
4501
|
+
for (const child of children) stack.push(child.id);
|
|
4502
|
+
}
|
|
4503
|
+
if (toRemove.size === 1 && !project.bins.some((b) => b.id === binId)) {
|
|
4504
|
+
return project;
|
|
4505
|
+
}
|
|
4506
|
+
const nextBins = project.bins.filter((b) => !toRemove.has(b.id));
|
|
4507
|
+
const nextRoot = project.rootBinIds.filter((id) => !toRemove.has(id));
|
|
4508
|
+
return withUpdatedAt({ ...project, bins: nextBins, rootBinIds: nextRoot });
|
|
4509
|
+
}
|
|
4510
|
+
function addItemToBin(project, binId, item) {
|
|
4511
|
+
const idx = project.bins.findIndex((b) => b.id === binId);
|
|
4512
|
+
if (idx < 0) throw new Error(`Bin not found: ${binId}`);
|
|
4513
|
+
const target = project.bins[idx];
|
|
4514
|
+
const updated = { ...target, items: [...target.items, item] };
|
|
4515
|
+
const nextBins = [...project.bins];
|
|
4516
|
+
nextBins[idx] = updated;
|
|
4517
|
+
return withUpdatedAt({ ...project, bins: nextBins });
|
|
4518
|
+
}
|
|
4519
|
+
function removeItemFromBin(project, binId, item) {
|
|
4520
|
+
const idx = project.bins.findIndex((b) => b.id === binId);
|
|
4521
|
+
if (idx < 0) throw new Error(`Bin not found: ${binId}`);
|
|
4522
|
+
const target = project.bins[idx];
|
|
4523
|
+
const nextItems = target.items.filter((i) => !itemsEqual(i, item));
|
|
4524
|
+
if (nextItems.length === target.items.length) return project;
|
|
4525
|
+
const updated = { ...target, items: nextItems };
|
|
4526
|
+
const nextBins = [...project.bins];
|
|
4527
|
+
nextBins[idx] = updated;
|
|
4528
|
+
return withUpdatedAt({ ...project, bins: nextBins });
|
|
4529
|
+
}
|
|
4530
|
+
function moveItemBetweenBins(project, fromBinId, toBinId2, item) {
|
|
4531
|
+
const fromIdx = project.bins.findIndex((b) => b.id === fromBinId);
|
|
4532
|
+
const toIdx = project.bins.findIndex((b) => b.id === toBinId2);
|
|
4533
|
+
if (fromIdx < 0) throw new Error(`Bin not found: ${fromBinId}`);
|
|
4534
|
+
if (toIdx < 0) throw new Error(`Bin not found: ${toBinId2}`);
|
|
4535
|
+
const fromBin = project.bins[fromIdx];
|
|
4536
|
+
const toBin = project.bins[toIdx];
|
|
4537
|
+
const nextFromItems = fromBin.items.filter((i) => !itemsEqual(i, item));
|
|
4538
|
+
const nextToItems = [...toBin.items, item];
|
|
4539
|
+
const nextBins = [...project.bins];
|
|
4540
|
+
nextBins[fromIdx] = { ...fromBin, items: nextFromItems };
|
|
4541
|
+
nextBins[toIdx] = { ...toBin, items: nextToItems };
|
|
4542
|
+
return withUpdatedAt({ ...project, bins: nextBins });
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
// src/engine/project-serializer.ts
|
|
4546
|
+
function isObject(v) {
|
|
4547
|
+
return typeof v === "object" && v !== null;
|
|
4548
|
+
}
|
|
4549
|
+
function validateBinItem(item) {
|
|
4550
|
+
if (!isObject(item)) throw new SerializationError("Invalid bin item");
|
|
4551
|
+
const kind = item.kind;
|
|
4552
|
+
if (kind === "asset") {
|
|
4553
|
+
if (typeof item.assetId !== "string") throw new SerializationError("Invalid bin item");
|
|
4554
|
+
return;
|
|
4555
|
+
}
|
|
4556
|
+
if (kind === "sequence") {
|
|
4557
|
+
if (typeof item.timelineId !== "string") throw new SerializationError("Invalid bin item");
|
|
4558
|
+
return;
|
|
4559
|
+
}
|
|
4560
|
+
if (kind === "bin") {
|
|
4561
|
+
if (typeof item.binId !== "string") throw new SerializationError("Invalid bin item");
|
|
4562
|
+
return;
|
|
4563
|
+
}
|
|
4564
|
+
throw new SerializationError("Invalid bin item");
|
|
4565
|
+
}
|
|
4566
|
+
function validateBin(bin) {
|
|
4567
|
+
if (!isObject(bin)) throw new SerializationError("Invalid bin");
|
|
4568
|
+
if (typeof bin.id !== "string") throw new SerializationError("Invalid bin");
|
|
4569
|
+
if (typeof bin.label !== "string") throw new SerializationError("Invalid bin");
|
|
4570
|
+
if (!(typeof bin.parentId === "string" || bin.parentId === null)) throw new SerializationError("Invalid bin");
|
|
4571
|
+
if (!Array.isArray(bin.items)) throw new SerializationError("Invalid bin");
|
|
4572
|
+
for (const item of bin.items) validateBinItem(item);
|
|
4573
|
+
if (bin.color !== void 0 && typeof bin.color !== "string") throw new SerializationError("Invalid bin");
|
|
4574
|
+
}
|
|
4575
|
+
function toPlainTimeline(state) {
|
|
4576
|
+
return {
|
|
4577
|
+
schemaVersion: state.schemaVersion,
|
|
4578
|
+
timeline: state.timeline,
|
|
4579
|
+
assetRegistry: Object.fromEntries(state.assetRegistry)
|
|
4580
|
+
};
|
|
4581
|
+
}
|
|
4582
|
+
function serializeProject(project) {
|
|
4583
|
+
const plain = {
|
|
4584
|
+
id: project.id,
|
|
4585
|
+
name: project.name,
|
|
4586
|
+
timelines: project.timelines.map(toPlainTimeline),
|
|
4587
|
+
bins: project.bins,
|
|
4588
|
+
rootBinIds: project.rootBinIds,
|
|
4589
|
+
createdAt: project.createdAt,
|
|
4590
|
+
updatedAt: project.updatedAt,
|
|
4591
|
+
schemaVersion: project.schemaVersion
|
|
4592
|
+
};
|
|
4593
|
+
return JSON.stringify(plain, null, 2);
|
|
4594
|
+
}
|
|
4595
|
+
function deserializeProject(raw) {
|
|
4596
|
+
let parsed;
|
|
4597
|
+
try {
|
|
4598
|
+
parsed = JSON.parse(raw);
|
|
4599
|
+
} catch (e) {
|
|
4600
|
+
const msg = e instanceof Error ? e.message : "Invalid JSON";
|
|
4601
|
+
throw new SerializationError(msg);
|
|
4602
|
+
}
|
|
4603
|
+
if (!isObject(parsed)) throw new SerializationError("Invalid JSON structure");
|
|
4604
|
+
const obj = parsed;
|
|
4605
|
+
const schemaVersion = obj.schemaVersion;
|
|
4606
|
+
if (typeof schemaVersion !== "number") throw new SerializationError("Missing schemaVersion");
|
|
4607
|
+
if (schemaVersion > CURRENT_SCHEMA_VERSION) {
|
|
4608
|
+
throw new SerializationError(`Unknown project schema version: ${schemaVersion}`);
|
|
4609
|
+
}
|
|
4610
|
+
if (!Array.isArray(obj.timelines)) throw new SerializationError("Missing timelines");
|
|
4611
|
+
const timelines = [];
|
|
4612
|
+
for (const t of obj.timelines) {
|
|
4613
|
+
const state = migrate(t);
|
|
4614
|
+
const violations = checkInvariants(state);
|
|
4615
|
+
if (violations.length > 0) {
|
|
4616
|
+
throw new SerializationError("Timeline failed invariant checks", violations);
|
|
4617
|
+
}
|
|
4618
|
+
timelines.push(state);
|
|
4619
|
+
}
|
|
4620
|
+
const binsRaw = obj.bins;
|
|
4621
|
+
if (binsRaw !== void 0 && !Array.isArray(binsRaw)) throw new SerializationError("Invalid bins");
|
|
4622
|
+
const bins = binsRaw ?? [];
|
|
4623
|
+
for (const b of bins) validateBin(b);
|
|
4624
|
+
const rootBinIdsRaw = obj.rootBinIds;
|
|
4625
|
+
if (rootBinIdsRaw !== void 0 && !Array.isArray(rootBinIdsRaw)) {
|
|
4626
|
+
throw new SerializationError("Invalid rootBinIds");
|
|
4627
|
+
}
|
|
4628
|
+
const rootBinIds = rootBinIdsRaw ?? [];
|
|
4629
|
+
for (const id of rootBinIds) {
|
|
4630
|
+
if (typeof id !== "string") throw new SerializationError("Invalid rootBinIds");
|
|
4631
|
+
}
|
|
4632
|
+
if (typeof obj.id !== "string") throw new SerializationError("Invalid project id");
|
|
4633
|
+
if (typeof obj.name !== "string") throw new SerializationError("Invalid project name");
|
|
4634
|
+
if (typeof obj.createdAt !== "number") throw new SerializationError("Invalid createdAt");
|
|
4635
|
+
if (typeof obj.updatedAt !== "number") throw new SerializationError("Invalid updatedAt");
|
|
4636
|
+
return {
|
|
4637
|
+
id: obj.id,
|
|
4638
|
+
name: obj.name,
|
|
4639
|
+
schemaVersion,
|
|
4640
|
+
createdAt: obj.createdAt,
|
|
4641
|
+
updatedAt: obj.updatedAt,
|
|
4642
|
+
timelines,
|
|
4643
|
+
bins,
|
|
4644
|
+
rootBinIds
|
|
4645
|
+
};
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
export {
|
|
4649
|
+
createTimeline,
|
|
4650
|
+
toTrackId,
|
|
4651
|
+
createTrack,
|
|
4652
|
+
sortTrackClips,
|
|
4653
|
+
toClipId,
|
|
4654
|
+
createClip,
|
|
4655
|
+
getClipDuration,
|
|
4656
|
+
getClipMediaDuration,
|
|
4657
|
+
clipContainsFrame,
|
|
4658
|
+
clipsOverlap,
|
|
4659
|
+
toAssetId,
|
|
4660
|
+
createAsset,
|
|
4661
|
+
CURRENT_SCHEMA_VERSION,
|
|
4662
|
+
createTimelineState,
|
|
4663
|
+
toFrame,
|
|
4664
|
+
frame,
|
|
4665
|
+
FrameRates,
|
|
4666
|
+
frameRate,
|
|
4667
|
+
toTimecode,
|
|
4668
|
+
isValidFrame,
|
|
4669
|
+
isDropFrame,
|
|
4670
|
+
framesToSeconds,
|
|
4671
|
+
secondsToFrames,
|
|
4672
|
+
framesToTimecode,
|
|
4673
|
+
framesToMinutesSeconds,
|
|
4674
|
+
clampFrame,
|
|
4675
|
+
addFrames,
|
|
4676
|
+
subtractFrames,
|
|
4677
|
+
frameDuration,
|
|
4678
|
+
createHistory,
|
|
4679
|
+
pushHistory,
|
|
4680
|
+
undo,
|
|
4681
|
+
redo,
|
|
4682
|
+
canUndo,
|
|
4683
|
+
canRedo,
|
|
4684
|
+
getCurrentState,
|
|
4685
|
+
findClipById,
|
|
4686
|
+
findTrackById,
|
|
4687
|
+
getClipsOnTrack,
|
|
4688
|
+
getClipsAtFrame,
|
|
4689
|
+
getClipsInRange,
|
|
4690
|
+
getAllClips,
|
|
4691
|
+
getAllTracks,
|
|
4692
|
+
findTrackIndex,
|
|
4693
|
+
validResult,
|
|
4694
|
+
invalidResult,
|
|
4695
|
+
invalidResults,
|
|
4696
|
+
combineResults,
|
|
4697
|
+
registerAsset,
|
|
4698
|
+
getAsset,
|
|
4699
|
+
hasAsset,
|
|
4700
|
+
getAllAssets,
|
|
4701
|
+
unregisterAsset,
|
|
4702
|
+
validateClip,
|
|
4703
|
+
validateTrack,
|
|
4704
|
+
validateTimeline,
|
|
4705
|
+
validateNoOverlap,
|
|
4706
|
+
addClip,
|
|
4707
|
+
removeClip,
|
|
4708
|
+
moveClip,
|
|
4709
|
+
resizeClip,
|
|
4710
|
+
trimClip,
|
|
4711
|
+
updateClip,
|
|
4712
|
+
moveClipToTrack,
|
|
4713
|
+
addTrack,
|
|
4714
|
+
removeTrack,
|
|
4715
|
+
moveTrack,
|
|
4716
|
+
updateTrack,
|
|
4717
|
+
toggleTrackMute,
|
|
4718
|
+
toggleTrackLock,
|
|
4719
|
+
setTimelineDuration,
|
|
4720
|
+
setTimelineName,
|
|
4721
|
+
rippleDelete,
|
|
4722
|
+
rippleTrim,
|
|
4723
|
+
insertEdit,
|
|
4724
|
+
rippleMove,
|
|
4725
|
+
insertMove,
|
|
4726
|
+
TimelineEngine,
|
|
4727
|
+
createAnimatableProperty,
|
|
4728
|
+
DEFAULT_CLIP_TRANSFORM,
|
|
4729
|
+
DEFAULT_AUDIO_PROPERTIES,
|
|
4730
|
+
defaultCaptionStyle,
|
|
4731
|
+
parseSRT,
|
|
4732
|
+
parseVTT,
|
|
4733
|
+
subtitleImportToOps,
|
|
4734
|
+
checkInvariants,
|
|
4735
|
+
dispatch,
|
|
4736
|
+
buildSnapIndex,
|
|
4737
|
+
nearest,
|
|
4738
|
+
toggleSnap,
|
|
4739
|
+
toToolId,
|
|
4740
|
+
createRegistry,
|
|
4741
|
+
activateTool,
|
|
4742
|
+
getActiveTool,
|
|
4743
|
+
registerTool,
|
|
4744
|
+
NoOpTool,
|
|
4745
|
+
createProvisionalManager,
|
|
4746
|
+
setProvisional,
|
|
4747
|
+
clearProvisional,
|
|
4748
|
+
resolveClip,
|
|
4749
|
+
findMarkersByColor,
|
|
4750
|
+
findMarkersByLabel,
|
|
4751
|
+
LINEAR_EASING,
|
|
4752
|
+
HOLD_EASING,
|
|
4753
|
+
toKeyframeId,
|
|
4754
|
+
toEffectId,
|
|
4755
|
+
createEffect,
|
|
4756
|
+
toTransitionId,
|
|
4757
|
+
createTransition,
|
|
4758
|
+
toTrackGroupId,
|
|
4759
|
+
createTrackGroup,
|
|
4760
|
+
toLinkGroupId,
|
|
4761
|
+
createLinkGroup,
|
|
4762
|
+
TransitionTool,
|
|
4763
|
+
KeyframeTool,
|
|
4764
|
+
SerializationError,
|
|
4765
|
+
serializeTimeline,
|
|
4766
|
+
deserializeTimeline,
|
|
4767
|
+
remapAssetPaths,
|
|
4768
|
+
findOfflineAssets,
|
|
4769
|
+
exportToOTIO,
|
|
4770
|
+
importFromOTIO,
|
|
4771
|
+
frameToTimecode,
|
|
4772
|
+
reelName,
|
|
4773
|
+
exportToEDL,
|
|
4774
|
+
exportToAAF,
|
|
4775
|
+
toFCPTime,
|
|
4776
|
+
exportToFCPXML,
|
|
4777
|
+
toProjectId,
|
|
4778
|
+
toBinId,
|
|
4779
|
+
createBin,
|
|
4780
|
+
createProject,
|
|
4781
|
+
addTimeline,
|
|
4782
|
+
removeTimeline,
|
|
4783
|
+
addBin,
|
|
4784
|
+
removeBin,
|
|
4785
|
+
addItemToBin,
|
|
4786
|
+
removeItemFromBin,
|
|
4787
|
+
moveItemBetweenBins,
|
|
4788
|
+
serializeProject,
|
|
4789
|
+
deserializeProject
|
|
4790
|
+
};
|