@aelionsdk/transaction 0.1.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/affected-ranges.d.ts +5 -0
- package/dist/affected-ranges.d.ts.map +1 -0
- package/dist/affected-ranges.js +144 -0
- package/dist/commands.d.ts +166 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +1181 -0
- package/dist/history.d.ts +45 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +210 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/operations.d.ts +5 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +187 -0
- package/dist/transaction.d.ts +32 -0
- package/dist/transaction.d.ts.map +1 -0
- package/dist/transaction.js +222 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +44 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
import { AelionError } from '@aelionsdk/core';
|
|
2
|
+
function commandError(code, message, entityId) {
|
|
3
|
+
return new AelionError([
|
|
4
|
+
{
|
|
5
|
+
code,
|
|
6
|
+
severity: 'error',
|
|
7
|
+
message,
|
|
8
|
+
...(entityId === undefined ? {} : { entityId }),
|
|
9
|
+
recoverable: true,
|
|
10
|
+
},
|
|
11
|
+
]);
|
|
12
|
+
}
|
|
13
|
+
function assertTime(value, name) {
|
|
14
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
15
|
+
throw commandError('COMMAND_TIME_INVALID', `${name} must be a non-negative safe integer`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function assertTimeDelta(value, name) {
|
|
19
|
+
if (!Number.isSafeInteger(value)) {
|
|
20
|
+
throw commandError('COMMAND_TIME_INVALID', `${name} must be a safe integer`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function itemIn(project, itemId) {
|
|
24
|
+
const item = project.items[itemId];
|
|
25
|
+
if (item === undefined) {
|
|
26
|
+
throw commandError('COMMAND_ITEM_MISSING', `Item ${itemId} does not exist`, itemId);
|
|
27
|
+
}
|
|
28
|
+
return item;
|
|
29
|
+
}
|
|
30
|
+
function trackIn(project, trackId) {
|
|
31
|
+
const track = project.tracks[trackId];
|
|
32
|
+
if (track === undefined) {
|
|
33
|
+
throw commandError('COMMAND_TRACK_MISSING', `Track ${trackId} does not exist`, trackId);
|
|
34
|
+
}
|
|
35
|
+
return track;
|
|
36
|
+
}
|
|
37
|
+
function assertUnlocked(track) {
|
|
38
|
+
if (track.locked) {
|
|
39
|
+
throw commandError('COMMAND_TRACK_LOCKED', `Track ${track.id} is locked`, track.id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function assertItemTrackCompatible(item, track) {
|
|
43
|
+
const requiredKind = item.type === 'audio' ? 'audio' : item.type === 'caption' ? 'caption' : 'visual';
|
|
44
|
+
if (track.kind !== requiredKind) {
|
|
45
|
+
throw commandError('COMMAND_TRACK_KIND_MISMATCH', `Item ${item.id} requires a ${requiredKind} Track, not ${track.kind}`, item.id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function assertAnchor(track, beforeId) {
|
|
49
|
+
if (beforeId !== undefined && !track.itemIds.includes(beforeId)) {
|
|
50
|
+
throw commandError('COMMAND_ITEM_ANCHOR_MISSING', `Item anchor ${beforeId} does not belong to Track ${track.id}`, beforeId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function asObject(value) {
|
|
54
|
+
return value !== null && value !== undefined && !Array.isArray(value) && typeof value === 'object'
|
|
55
|
+
? value
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
function mediaSource(item) {
|
|
59
|
+
const source = asObject(item.source);
|
|
60
|
+
if (source === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
const sourceRange = asObject(source.sourceRange);
|
|
63
|
+
const timeMapping = asObject(source.timeMapping);
|
|
64
|
+
const rate = asObject(timeMapping?.rate);
|
|
65
|
+
if (sourceRange === undefined ||
|
|
66
|
+
timeMapping === undefined ||
|
|
67
|
+
timeMapping.type !== 'linear' ||
|
|
68
|
+
rate === undefined ||
|
|
69
|
+
typeof sourceRange.startUs !== 'number' ||
|
|
70
|
+
typeof sourceRange.durationUs !== 'number' ||
|
|
71
|
+
typeof rate.numerator !== 'number' ||
|
|
72
|
+
typeof rate.denominator !== 'number' ||
|
|
73
|
+
typeof timeMapping.reverse !== 'boolean') {
|
|
74
|
+
throw commandError('COMMAND_TIME_MAPPING_UNSUPPORTED', `Item ${item.id} does not have a supported linear media mapping`, item.id);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
sourceRange: {
|
|
78
|
+
startUs: sourceRange.startUs,
|
|
79
|
+
durationUs: sourceRange.durationUs,
|
|
80
|
+
},
|
|
81
|
+
timeMapping: {
|
|
82
|
+
type: 'linear',
|
|
83
|
+
rate: { numerator: rate.numerator, denominator: rate.denominator },
|
|
84
|
+
reverse: timeMapping.reverse,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function scaledDuration(durationUs, rate) {
|
|
89
|
+
return Math.floor((durationUs * rate.numerator) / rate.denominator);
|
|
90
|
+
}
|
|
91
|
+
function signedScaledDuration(durationUs, rate) {
|
|
92
|
+
return Math.sign(durationUs) * scaledDuration(Math.abs(durationUs), rate);
|
|
93
|
+
}
|
|
94
|
+
function sourceAssetDuration(project, item) {
|
|
95
|
+
const source = asObject(item.source);
|
|
96
|
+
const assetId = source?.assetId;
|
|
97
|
+
if (typeof assetId !== 'string')
|
|
98
|
+
return undefined;
|
|
99
|
+
const asset = project.assets[assetId];
|
|
100
|
+
const probeHint = asObject(asset?.probeHint);
|
|
101
|
+
const durationUs = probeHint?.durationUs;
|
|
102
|
+
return typeof durationUs === 'number' && Number.isSafeInteger(durationUs)
|
|
103
|
+
? durationUs
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
106
|
+
function assertSourceRange(project, item, range) {
|
|
107
|
+
const assetDurationUs = sourceAssetDuration(project, item);
|
|
108
|
+
if (!Number.isSafeInteger(range.startUs) ||
|
|
109
|
+
!Number.isSafeInteger(range.durationUs) ||
|
|
110
|
+
range.startUs < 0 ||
|
|
111
|
+
range.durationUs < 1 ||
|
|
112
|
+
(assetDurationUs !== undefined && range.startUs + range.durationUs > assetDurationUs)) {
|
|
113
|
+
throw commandError('COMMAND_SOURCE_HANDLE_UNAVAILABLE', `Item ${item.id} does not have enough source media for this edit`, item.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function adjustedLinearSourceRange(project, item, edge, deltaUs) {
|
|
117
|
+
const source = mediaSource(item);
|
|
118
|
+
if (source === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
const mappedDeltaUs = signedScaledDuration(deltaUs, source.timeMapping.rate);
|
|
121
|
+
const startUs = edge === 'start'
|
|
122
|
+
? source.sourceRange.startUs + (source.timeMapping.reverse ? 0 : mappedDeltaUs)
|
|
123
|
+
: source.sourceRange.startUs - (source.timeMapping.reverse ? mappedDeltaUs : 0);
|
|
124
|
+
const durationUs = source.sourceRange.durationUs + (edge === 'start' ? -mappedDeltaUs : mappedDeltaUs);
|
|
125
|
+
const range = { startUs, durationUs };
|
|
126
|
+
assertSourceRange(project, item, range);
|
|
127
|
+
return range;
|
|
128
|
+
}
|
|
129
|
+
function assertProfessionalEditItems(project, items) {
|
|
130
|
+
const ids = new Set(items.map(item => item.id));
|
|
131
|
+
for (const item of items) {
|
|
132
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
133
|
+
if (item.linkGroupId !== undefined) {
|
|
134
|
+
throw commandError('COMMAND_LINKED_EDIT_REQUIRES_GROUP', `Item ${item.id} is linked; use a LinkGroup edit or unlink it first`, item.id);
|
|
135
|
+
}
|
|
136
|
+
if (containsAnimation(item)) {
|
|
137
|
+
throw commandError('COMMAND_EDIT_ANIMATION_UNSUPPORTED', `Item ${item.id} contains automation that requires an explicit retiming policy`, item.id);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const transition = Object.values(project.transitions).find(value => ids.has(value.fromItemId) || ids.has(value.toItemId));
|
|
141
|
+
if (transition !== undefined) {
|
|
142
|
+
throw commandError('COMMAND_EDIT_TRANSITION_CONFLICT', `Edit conflicts with Transition ${transition.id}`, transition.id);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function setSourceRange(transaction, item, range) {
|
|
146
|
+
if (range === undefined)
|
|
147
|
+
return;
|
|
148
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'startUs'], range.startUs);
|
|
149
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'durationUs'], range.durationUs);
|
|
150
|
+
}
|
|
151
|
+
function trimmedSourceRange(item, edge, removedDurationUs) {
|
|
152
|
+
const source = mediaSource(item);
|
|
153
|
+
if (source === undefined)
|
|
154
|
+
return undefined;
|
|
155
|
+
const removedSourceUs = scaledDuration(removedDurationUs, source.timeMapping.rate);
|
|
156
|
+
const durationUs = source.sourceRange.durationUs - removedSourceUs;
|
|
157
|
+
if (durationUs < 1) {
|
|
158
|
+
throw commandError('COMMAND_SOURCE_RANGE_EMPTY', `Trim would empty Item ${item.id}'s source range`, item.id);
|
|
159
|
+
}
|
|
160
|
+
const advancesLowEdge = (!source.timeMapping.reverse && edge === 'start') ||
|
|
161
|
+
(source.timeMapping.reverse && edge === 'end');
|
|
162
|
+
return {
|
|
163
|
+
startUs: source.sourceRange.startUs + (advancesLowEdge ? removedSourceUs : 0),
|
|
164
|
+
durationUs,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function rippleTracks(project, sequenceId, requested) {
|
|
168
|
+
const sequence = project.sequences[sequenceId];
|
|
169
|
+
if (sequence === undefined) {
|
|
170
|
+
throw commandError('COMMAND_SEQUENCE_MISSING', `Sequence ${sequenceId} does not exist`);
|
|
171
|
+
}
|
|
172
|
+
const ids = requested === undefined ? [...sequence.trackIds] : [...new Set(requested)];
|
|
173
|
+
if (ids.length === 0 || ids.some(id => !sequence.trackIds.includes(id))) {
|
|
174
|
+
throw commandError('COMMAND_RIPPLE_TRACK_MISMATCH', `Every ripple Track must belong to Sequence ${sequenceId}`, sequenceId);
|
|
175
|
+
}
|
|
176
|
+
return ids.map(id => trackIn(project, id));
|
|
177
|
+
}
|
|
178
|
+
function assertRippleClosure(project, shiftedItemIds, ignoredItemIds = new Set()) {
|
|
179
|
+
for (const id of shiftedItemIds) {
|
|
180
|
+
const groupId = project.items[id]?.linkGroupId;
|
|
181
|
+
if (groupId === undefined)
|
|
182
|
+
continue;
|
|
183
|
+
const group = project.linkGroups[groupId];
|
|
184
|
+
if (group?.itemIds.some(memberId => !shiftedItemIds.has(memberId)) === true) {
|
|
185
|
+
throw commandError('COMMAND_RIPPLE_LINK_GROUP_CONFLICT', `Ripple would move only part of LinkGroup ${group.id}`, group.id);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const shiftedTransitions = [];
|
|
189
|
+
for (const transition of Object.values(project.transitions)) {
|
|
190
|
+
if (ignoredItemIds.has(transition.fromItemId) || ignoredItemIds.has(transition.toItemId)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const fromShifted = shiftedItemIds.has(transition.fromItemId);
|
|
194
|
+
const toShifted = shiftedItemIds.has(transition.toItemId);
|
|
195
|
+
if (fromShifted !== toShifted) {
|
|
196
|
+
throw commandError('COMMAND_RIPPLE_TRANSITION_CONFLICT', `Ripple would move only one side of Transition ${transition.id}`, transition.id);
|
|
197
|
+
}
|
|
198
|
+
if (fromShifted)
|
|
199
|
+
shiftedTransitions.push(transition);
|
|
200
|
+
}
|
|
201
|
+
return shiftedTransitions;
|
|
202
|
+
}
|
|
203
|
+
function appendRippleShift(project, transaction, items, transitions, sequenceId, markerPivotUs, deltaUs) {
|
|
204
|
+
for (const item of items) {
|
|
205
|
+
transaction.setField('items', item.id, ['range', 'startUs'], item.range.startUs + deltaUs);
|
|
206
|
+
}
|
|
207
|
+
for (const transition of transitions) {
|
|
208
|
+
transaction.setField('transitions', transition.id, ['range', 'startUs'], transition.range.startUs + deltaUs);
|
|
209
|
+
}
|
|
210
|
+
for (const marker of Object.values(project.markers)) {
|
|
211
|
+
const owner = asObject(marker.owner);
|
|
212
|
+
if (owner?.type !== 'sequence' ||
|
|
213
|
+
owner.id !== sequenceId ||
|
|
214
|
+
typeof marker.timeUs !== 'number' ||
|
|
215
|
+
marker.timeUs < markerPivotUs) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
transaction.setField('markers', marker.id, ['timeUs'], Math.max(0, marker.timeUs + deltaUs));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function appendRemoveItem(project, item, transaction, skipLinkGroup = false) {
|
|
222
|
+
const track = trackIn(project, item.trackId);
|
|
223
|
+
for (const transition of Object.values(project.transitions)) {
|
|
224
|
+
if (transition.fromItemId !== item.id && transition.toItemId !== item.id)
|
|
225
|
+
continue;
|
|
226
|
+
transaction.listRemove('sequences', transition.sequenceId, ['transitionIds'], transition.id);
|
|
227
|
+
transaction.deleteEntity('transitions', transition.id);
|
|
228
|
+
transaction.deleteEntity('materialInstances', transition.materialInstanceId);
|
|
229
|
+
}
|
|
230
|
+
const ownedMarkerIds = Object.values(project.markers)
|
|
231
|
+
.filter(marker => {
|
|
232
|
+
const owner = asObject(marker.owner);
|
|
233
|
+
return owner?.type === 'item' && owner.id === item.id;
|
|
234
|
+
})
|
|
235
|
+
.map(marker => marker.id);
|
|
236
|
+
const markerIds = new Set([...(item.markerIds ?? []), ...ownedMarkerIds]);
|
|
237
|
+
for (const markerId of markerIds) {
|
|
238
|
+
for (const sequence of Object.values(project.sequences)) {
|
|
239
|
+
if (sequence.markerIds.includes(markerId)) {
|
|
240
|
+
transaction.listRemove('sequences', sequence.id, ['markerIds'], markerId);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (project.markers[markerId] !== undefined)
|
|
244
|
+
transaction.deleteEntity('markers', markerId);
|
|
245
|
+
}
|
|
246
|
+
if (!skipLinkGroup && item.linkGroupId !== undefined) {
|
|
247
|
+
const group = project.linkGroups[item.linkGroupId];
|
|
248
|
+
if (group !== undefined) {
|
|
249
|
+
const remaining = group.itemIds.filter(id => id !== item.id);
|
|
250
|
+
if (remaining.length < 2) {
|
|
251
|
+
for (const remainingId of remaining) {
|
|
252
|
+
if (project.items[remainingId]?.linkGroupId === group.id) {
|
|
253
|
+
transaction.removeField('items', remainingId, ['linkGroupId']);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
transaction.deleteEntity('linkGroups', group.id);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
transaction.listRemove('linkGroups', group.id, ['itemIds'], item.id);
|
|
260
|
+
if (group.syncOffsetsUs !== undefined && Object.hasOwn(group.syncOffsetsUs, item.id)) {
|
|
261
|
+
transaction.removeField('linkGroups', group.id, ['syncOffsetsUs', item.id]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
transaction.listRemove('tracks', track.id, ['itemIds'], item.id);
|
|
267
|
+
transaction.deleteEntity('items', item.id);
|
|
268
|
+
for (const materialId of item.materialInstanceIds) {
|
|
269
|
+
transaction.deleteEntity('materialInstances', materialId);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function splitSourceRanges(item, leftDurationUs) {
|
|
273
|
+
const source = mediaSource(item);
|
|
274
|
+
if (source === undefined)
|
|
275
|
+
return undefined;
|
|
276
|
+
const leftMappedUs = scaledDuration(leftDurationUs, source.timeMapping.rate);
|
|
277
|
+
if (leftMappedUs < 1 || leftMappedUs >= source.sourceRange.durationUs) {
|
|
278
|
+
throw commandError('COMMAND_SOURCE_SPLIT_OUT_OF_RANGE', `Split point maps outside Item ${item.id}'s source range`, item.id);
|
|
279
|
+
}
|
|
280
|
+
const rightMappedUs = source.sourceRange.durationUs - leftMappedUs;
|
|
281
|
+
return source.timeMapping.reverse
|
|
282
|
+
? {
|
|
283
|
+
left: {
|
|
284
|
+
startUs: source.sourceRange.startUs + rightMappedUs,
|
|
285
|
+
durationUs: leftMappedUs,
|
|
286
|
+
},
|
|
287
|
+
right: { startUs: source.sourceRange.startUs, durationUs: rightMappedUs },
|
|
288
|
+
}
|
|
289
|
+
: {
|
|
290
|
+
left: { startUs: source.sourceRange.startUs, durationUs: leftMappedUs },
|
|
291
|
+
right: {
|
|
292
|
+
startUs: source.sourceRange.startUs + leftMappedUs,
|
|
293
|
+
durationUs: rightMappedUs,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function sameIds(left, right) {
|
|
298
|
+
return JSON.stringify(left ?? []) === JSON.stringify(right ?? []);
|
|
299
|
+
}
|
|
300
|
+
function containsAnimation(value) {
|
|
301
|
+
if (Array.isArray(value))
|
|
302
|
+
return value.some(entry => containsAnimation(entry));
|
|
303
|
+
if (value === null || typeof value !== 'object')
|
|
304
|
+
return false;
|
|
305
|
+
if (Object.hasOwn(value, 'animation'))
|
|
306
|
+
return true;
|
|
307
|
+
return Object.values(value).some(entry => containsAnimation(entry));
|
|
308
|
+
}
|
|
309
|
+
function assertDirectTimeEditSupported(item, command) {
|
|
310
|
+
if (item.linkGroupId !== undefined) {
|
|
311
|
+
throw commandError(`COMMAND_${command.toUpperCase()}_LINKED_UNSUPPORTED`, `Item ${item.id} is linked; edit the LinkGroup atomically or unlink it first`, item.id);
|
|
312
|
+
}
|
|
313
|
+
if (command === 'trim' && containsAnimation(item)) {
|
|
314
|
+
throw commandError('COMMAND_TRIM_ANIMATION_UNSUPPORTED', `Item ${item.id} contains animation; an explicit keyframe trim policy is required`, item.id);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function commandEditOptions(options, fallbackLabel) {
|
|
318
|
+
return {
|
|
319
|
+
label: options.label ?? fallbackLabel,
|
|
320
|
+
...(options.baseRevision === undefined ? {} : { baseRevision: options.baseRevision }),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
/** Domain-level editing commands built exclusively from atomic transactions. */
|
|
324
|
+
export class EditingCommands {
|
|
325
|
+
#host;
|
|
326
|
+
constructor(host) {
|
|
327
|
+
this.#host = host;
|
|
328
|
+
}
|
|
329
|
+
insertItem(options) {
|
|
330
|
+
const project = this.#host.getSnapshot();
|
|
331
|
+
const track = trackIn(project, options.item.trackId);
|
|
332
|
+
assertUnlocked(track);
|
|
333
|
+
assertItemTrackCompatible(options.item, track);
|
|
334
|
+
assertAnchor(track, options.beforeItemId);
|
|
335
|
+
if (project.items[options.item.id] !== undefined) {
|
|
336
|
+
throw commandError('COMMAND_ITEM_EXISTS', `Item ${options.item.id} already exists`, options.item.id);
|
|
337
|
+
}
|
|
338
|
+
return this.#host.edit(commandEditOptions(options, 'Insert item'), transaction => {
|
|
339
|
+
transaction.createEntity('items', options.item.id, options.item);
|
|
340
|
+
transaction.listInsert('tracks', track.id, ['itemIds'], options.item.id, options.beforeItemId);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
removeItem(options) {
|
|
344
|
+
const project = this.#host.getSnapshot();
|
|
345
|
+
const item = itemIn(project, options.itemId);
|
|
346
|
+
const track = trackIn(project, item.trackId);
|
|
347
|
+
assertUnlocked(track);
|
|
348
|
+
return this.#host.edit(commandEditOptions(options, 'Remove item'), transaction => {
|
|
349
|
+
appendRemoveItem(project, item, transaction);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
rippleInsertItem(options) {
|
|
353
|
+
const project = this.#host.getSnapshot();
|
|
354
|
+
const targetTrack = trackIn(project, options.item.trackId);
|
|
355
|
+
assertUnlocked(targetTrack);
|
|
356
|
+
assertItemTrackCompatible(options.item, targetTrack);
|
|
357
|
+
assertAnchor(targetTrack, options.beforeItemId);
|
|
358
|
+
if (options.item.linkGroupId !== undefined) {
|
|
359
|
+
throw commandError('COMMAND_RIPPLE_NEW_ITEM_LINKED', 'A ripple-inserted Item cannot reference a LinkGroup that is not created atomically', options.item.id);
|
|
360
|
+
}
|
|
361
|
+
if (project.items[options.item.id] !== undefined) {
|
|
362
|
+
throw commandError('COMMAND_ITEM_EXISTS', `Item ${options.item.id} already exists`);
|
|
363
|
+
}
|
|
364
|
+
const tracks = rippleTracks(project, targetTrack.sequenceId, options.trackIds);
|
|
365
|
+
if (!tracks.some(track => track.id === targetTrack.id)) {
|
|
366
|
+
throw commandError('COMMAND_RIPPLE_TRACK_MISMATCH', 'Ripple Tracks must include the inserted Item Track', targetTrack.id);
|
|
367
|
+
}
|
|
368
|
+
const shiftedItems = tracks.flatMap(track => track.itemIds.flatMap(id => {
|
|
369
|
+
const item = project.items[id];
|
|
370
|
+
return item !== undefined && item.range.startUs >= options.item.range.startUs ? [item] : [];
|
|
371
|
+
}));
|
|
372
|
+
for (const track of tracks) {
|
|
373
|
+
if (shiftedItems.some(item => item.trackId === track.id))
|
|
374
|
+
assertUnlocked(track);
|
|
375
|
+
}
|
|
376
|
+
const shiftedIds = new Set(shiftedItems.map(item => item.id));
|
|
377
|
+
const transitions = assertRippleClosure(project, shiftedIds);
|
|
378
|
+
return this.#host.edit(commandEditOptions(options, 'Ripple insert item'), transaction => {
|
|
379
|
+
appendRippleShift(project, transaction, shiftedItems, transitions, targetTrack.sequenceId, options.item.range.startUs, options.item.range.durationUs);
|
|
380
|
+
transaction.createEntity('items', options.item.id, options.item);
|
|
381
|
+
transaction.listInsert('tracks', targetTrack.id, ['itemIds'], options.item.id, options.beforeItemId);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
rippleRemoveItem(options) {
|
|
385
|
+
const project = this.#host.getSnapshot();
|
|
386
|
+
const item = itemIn(project, options.itemId);
|
|
387
|
+
const sourceTrack = trackIn(project, item.trackId);
|
|
388
|
+
assertUnlocked(sourceTrack);
|
|
389
|
+
if (item.linkGroupId !== undefined) {
|
|
390
|
+
throw commandError('COMMAND_RIPPLE_LINK_GROUP_DELETE_UNSUPPORTED', `Unlink Item ${item.id} before ripple deletion`, item.id);
|
|
391
|
+
}
|
|
392
|
+
const tracks = rippleTracks(project, sourceTrack.sequenceId, options.trackIds);
|
|
393
|
+
if (!tracks.some(track => track.id === sourceTrack.id)) {
|
|
394
|
+
throw commandError('COMMAND_RIPPLE_TRACK_MISMATCH', 'Ripple Tracks must include the removed Item Track', sourceTrack.id);
|
|
395
|
+
}
|
|
396
|
+
const endUs = item.range.startUs + item.range.durationUs;
|
|
397
|
+
const shiftedItems = tracks.flatMap(track => track.itemIds.flatMap(id => {
|
|
398
|
+
const candidate = project.items[id];
|
|
399
|
+
return candidate !== undefined &&
|
|
400
|
+
candidate.id !== item.id &&
|
|
401
|
+
candidate.range.startUs >= endUs
|
|
402
|
+
? [candidate]
|
|
403
|
+
: [];
|
|
404
|
+
}));
|
|
405
|
+
for (const track of tracks) {
|
|
406
|
+
if (shiftedItems.some(candidate => candidate.trackId === track.id))
|
|
407
|
+
assertUnlocked(track);
|
|
408
|
+
}
|
|
409
|
+
const shiftedIds = new Set(shiftedItems.map(candidate => candidate.id));
|
|
410
|
+
const shiftedTransitions = assertRippleClosure(project, shiftedIds, new Set([item.id]));
|
|
411
|
+
return this.#host.edit(commandEditOptions(options, 'Ripple remove item'), transaction => {
|
|
412
|
+
appendRemoveItem(project, item, transaction);
|
|
413
|
+
appendRippleShift(project, transaction, shiftedItems, shiftedTransitions, sourceTrack.sequenceId, endUs, -item.range.durationUs);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
moveItem(options) {
|
|
417
|
+
const project = this.#host.getSnapshot();
|
|
418
|
+
const item = itemIn(project, options.itemId);
|
|
419
|
+
const sourceTrack = trackIn(project, item.trackId);
|
|
420
|
+
const targetTrack = trackIn(project, options.toTrackId ?? item.trackId);
|
|
421
|
+
assertUnlocked(sourceTrack);
|
|
422
|
+
assertUnlocked(targetTrack);
|
|
423
|
+
assertItemTrackCompatible(item, targetTrack);
|
|
424
|
+
assertDirectTimeEditSupported(item, 'move');
|
|
425
|
+
if (options.startUs !== undefined)
|
|
426
|
+
assertTime(options.startUs, 'startUs');
|
|
427
|
+
const anchor = options.beforeItemId ?? undefined;
|
|
428
|
+
assertAnchor(targetTrack, anchor);
|
|
429
|
+
if (options.beforeItemId === item.id) {
|
|
430
|
+
throw commandError('COMMAND_ITEM_ANCHOR_INVALID', 'An Item cannot be moved before itself', item.id);
|
|
431
|
+
}
|
|
432
|
+
if (sourceTrack.id !== targetTrack.id) {
|
|
433
|
+
const transition = Object.values(project.transitions).find(value => value.fromItemId === item.id || value.toItemId === item.id);
|
|
434
|
+
if (transition !== undefined) {
|
|
435
|
+
throw commandError('COMMAND_TRANSITION_TRACK_CONFLICT', `Item ${item.id} participates in Transition ${transition.id}; remove it before moving Tracks`, item.id);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (sourceTrack.id === targetTrack.id &&
|
|
439
|
+
options.startUs === undefined &&
|
|
440
|
+
!Object.hasOwn(options, 'beforeItemId')) {
|
|
441
|
+
throw commandError('COMMAND_NO_CHANGE', `Move does not change Item ${item.id}`, item.id);
|
|
442
|
+
}
|
|
443
|
+
return this.#host.edit(commandEditOptions(options, 'Move item'), transaction => {
|
|
444
|
+
if (sourceTrack.id !== targetTrack.id) {
|
|
445
|
+
transaction.listRemove('tracks', sourceTrack.id, ['itemIds'], item.id);
|
|
446
|
+
transaction.setField('items', item.id, ['trackId'], targetTrack.id);
|
|
447
|
+
transaction.listInsert('tracks', targetTrack.id, ['itemIds'], item.id, anchor);
|
|
448
|
+
}
|
|
449
|
+
else if (Object.hasOwn(options, 'beforeItemId')) {
|
|
450
|
+
transaction.listMove('tracks', sourceTrack.id, ['itemIds'], item.id, anchor);
|
|
451
|
+
}
|
|
452
|
+
if (options.startUs !== undefined && options.startUs !== item.range.startUs) {
|
|
453
|
+
transaction.setField('items', item.id, ['range', 'startUs'], options.startUs);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
trimItem(options) {
|
|
458
|
+
const project = this.#host.getSnapshot();
|
|
459
|
+
const item = itemIn(project, options.itemId);
|
|
460
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
461
|
+
assertDirectTimeEditSupported(item, 'trim');
|
|
462
|
+
assertTime(options.toUs, 'toUs');
|
|
463
|
+
const endUs = item.range.startUs + item.range.durationUs;
|
|
464
|
+
if (options.toUs <= item.range.startUs || options.toUs >= endUs) {
|
|
465
|
+
throw commandError('COMMAND_TRIM_OUT_OF_RANGE', `Trim point must be inside Item ${item.id}`, item.id);
|
|
466
|
+
}
|
|
467
|
+
const removedDurationUs = options.edge === 'start' ? options.toUs - item.range.startUs : endUs - options.toUs;
|
|
468
|
+
const newDurationUs = item.range.durationUs - removedDurationUs;
|
|
469
|
+
const sourceRange = trimmedSourceRange(item, options.edge, removedDurationUs);
|
|
470
|
+
for (const transition of Object.values(project.transitions)) {
|
|
471
|
+
if (transition.fromItemId !== item.id && transition.toItemId !== item.id)
|
|
472
|
+
continue;
|
|
473
|
+
const transitionEnd = transition.range.startUs + transition.range.durationUs;
|
|
474
|
+
const newStart = options.edge === 'start' ? options.toUs : item.range.startUs;
|
|
475
|
+
const newEnd = options.edge === 'end' ? options.toUs : endUs;
|
|
476
|
+
if (transition.range.startUs < newStart || transitionEnd > newEnd) {
|
|
477
|
+
throw commandError('COMMAND_TRIM_TRANSITION_CONFLICT', `Trim would remove part of Transition ${transition.id}`, item.id);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return this.#host.edit(commandEditOptions(options, 'Trim item'), transaction => {
|
|
481
|
+
if (options.edge === 'start') {
|
|
482
|
+
transaction.setField('items', item.id, ['range', 'startUs'], options.toUs);
|
|
483
|
+
}
|
|
484
|
+
transaction.setField('items', item.id, ['range', 'durationUs'], newDurationUs);
|
|
485
|
+
if (sourceRange !== undefined) {
|
|
486
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'startUs'], sourceRange.startUs);
|
|
487
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'durationUs'], sourceRange.durationUs);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
splitItem(options) {
|
|
492
|
+
const project = this.#host.getSnapshot();
|
|
493
|
+
const item = itemIn(project, options.itemId);
|
|
494
|
+
const track = trackIn(project, item.trackId);
|
|
495
|
+
assertUnlocked(track);
|
|
496
|
+
assertTime(options.atUs, 'atUs');
|
|
497
|
+
const endUs = item.range.startUs + item.range.durationUs;
|
|
498
|
+
if (options.atUs <= item.range.startUs || options.atUs >= endUs) {
|
|
499
|
+
throw commandError('COMMAND_SPLIT_OUT_OF_RANGE', `Split point must be inside Item ${item.id}`, item.id);
|
|
500
|
+
}
|
|
501
|
+
if (project.items[options.rightItemId] !== undefined || options.rightItemId === item.id) {
|
|
502
|
+
throw commandError('COMMAND_ITEM_EXISTS', `Right Item ID ${options.rightItemId} already exists`, options.rightItemId);
|
|
503
|
+
}
|
|
504
|
+
if (item.materialInstanceIds.length > 0 || (item.markerIds?.length ?? 0) > 0) {
|
|
505
|
+
throw commandError('COMMAND_SPLIT_OWNED_ENTITY_UNSUPPORTED', `Item ${item.id} owns Material or Marker entities; explicit split policies are required`, item.id);
|
|
506
|
+
}
|
|
507
|
+
if (containsAnimation(item)) {
|
|
508
|
+
throw commandError('COMMAND_SPLIT_ANIMATION_UNSUPPORTED', `Item ${item.id} contains animation; an explicit keyframe split policy is required`, item.id);
|
|
509
|
+
}
|
|
510
|
+
if (item.linkGroupId !== undefined) {
|
|
511
|
+
throw commandError('COMMAND_SPLIT_LINKED_UNSUPPORTED', `Item ${item.id} is linked; split the LinkGroup atomically or unlink it first`, item.id);
|
|
512
|
+
}
|
|
513
|
+
const leftDurationUs = options.atUs - item.range.startUs;
|
|
514
|
+
const rightDurationUs = endUs - options.atUs;
|
|
515
|
+
const sourceRanges = splitSourceRanges(item, leftDurationUs);
|
|
516
|
+
const leftSourceRange = sourceRanges?.left;
|
|
517
|
+
const rightSourceRange = sourceRanges?.right;
|
|
518
|
+
for (const transition of Object.values(project.transitions)) {
|
|
519
|
+
const transitionEnd = transition.range.startUs + transition.range.durationUs;
|
|
520
|
+
if ((transition.fromItemId === item.id || transition.toItemId === item.id) &&
|
|
521
|
+
options.atUs > transition.range.startUs &&
|
|
522
|
+
options.atUs < transitionEnd) {
|
|
523
|
+
throw commandError('COMMAND_SPLIT_TRANSITION_CONFLICT', `Split point intersects Transition ${transition.id}`, item.id);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const right = structuredClone(item);
|
|
527
|
+
right.id = options.rightItemId;
|
|
528
|
+
Reflect.set(right.range, 'startUs', options.atUs);
|
|
529
|
+
Reflect.set(right.range, 'durationUs', rightDurationUs);
|
|
530
|
+
if (rightSourceRange !== undefined) {
|
|
531
|
+
const source = asObject(right.source);
|
|
532
|
+
const range = asObject(source?.sourceRange);
|
|
533
|
+
if (range !== undefined) {
|
|
534
|
+
range.startUs = rightSourceRange.startUs;
|
|
535
|
+
range.durationUs = rightSourceRange.durationUs;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const itemIndex = track.itemIds.indexOf(item.id);
|
|
539
|
+
const beforeId = track.itemIds[itemIndex + 1];
|
|
540
|
+
const commit = this.#host.edit(commandEditOptions(options, 'Split item'), transaction => {
|
|
541
|
+
transaction.setField('items', item.id, ['range', 'durationUs'], leftDurationUs);
|
|
542
|
+
if (leftSourceRange !== undefined) {
|
|
543
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'startUs'], leftSourceRange.startUs);
|
|
544
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'durationUs'], leftSourceRange.durationUs);
|
|
545
|
+
}
|
|
546
|
+
transaction.createEntity('items', right.id, right);
|
|
547
|
+
transaction.listInsert('tracks', track.id, ['itemIds'], right.id, beforeId);
|
|
548
|
+
for (const transition of Object.values(project.transitions)) {
|
|
549
|
+
if (transition.range.startUs < options.atUs)
|
|
550
|
+
continue;
|
|
551
|
+
if (transition.fromItemId === item.id) {
|
|
552
|
+
transaction.setField('transitions', transition.id, ['fromItemId'], right.id);
|
|
553
|
+
}
|
|
554
|
+
if (transition.toItemId === item.id) {
|
|
555
|
+
transaction.setField('transitions', transition.id, ['toItemId'], right.id);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
return { commit, leftItemId: item.id, rightItemId: right.id };
|
|
560
|
+
}
|
|
561
|
+
replaceItem(options) {
|
|
562
|
+
const project = this.#host.getSnapshot();
|
|
563
|
+
const item = itemIn(project, options.itemId);
|
|
564
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
565
|
+
const replacement = options.replacement;
|
|
566
|
+
assertItemTrackCompatible(replacement, trackIn(project, item.trackId));
|
|
567
|
+
if (replacement.id !== item.id || replacement.trackId !== item.trackId) {
|
|
568
|
+
throw commandError('COMMAND_REPLACE_TOPOLOGY_CHANGED', 'Replacement must preserve Item id and trackId; use moveItem for relocation', item.id);
|
|
569
|
+
}
|
|
570
|
+
if (!sameIds(replacement.materialInstanceIds, item.materialInstanceIds) ||
|
|
571
|
+
!sameIds(replacement.markerIds, item.markerIds) ||
|
|
572
|
+
replacement.linkGroupId !== item.linkGroupId) {
|
|
573
|
+
throw commandError('COMMAND_REPLACE_OWNERSHIP_CHANGED', 'Replacement must preserve Material, Marker and LinkGroup ownership references', item.id);
|
|
574
|
+
}
|
|
575
|
+
return this.#host.edit(commandEditOptions(options, 'Replace item'), transaction => {
|
|
576
|
+
transaction.deleteEntity('items', item.id);
|
|
577
|
+
transaction.createEntity('items', item.id, replacement);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
linkItems(options) {
|
|
581
|
+
const project = this.#host.getSnapshot();
|
|
582
|
+
if (project.linkGroups[options.groupId] !== undefined) {
|
|
583
|
+
throw commandError('COMMAND_LINK_GROUP_EXISTS', `LinkGroup ${options.groupId} already exists`, options.groupId);
|
|
584
|
+
}
|
|
585
|
+
const itemIds = [...new Set(options.itemIds)];
|
|
586
|
+
if (itemIds.length !== options.itemIds.length || itemIds.length < 2) {
|
|
587
|
+
throw commandError('COMMAND_LINK_GROUP_INVALID', 'A LinkGroup requires at least two unique Items', options.groupId);
|
|
588
|
+
}
|
|
589
|
+
const items = itemIds.map(id => itemIn(project, id));
|
|
590
|
+
let sequenceId;
|
|
591
|
+
for (const item of items) {
|
|
592
|
+
const track = trackIn(project, item.trackId);
|
|
593
|
+
assertUnlocked(track);
|
|
594
|
+
if (item.linkGroupId !== undefined) {
|
|
595
|
+
throw commandError('COMMAND_ITEM_ALREADY_LINKED', `Item ${item.id} already belongs to LinkGroup ${item.linkGroupId}`, item.id);
|
|
596
|
+
}
|
|
597
|
+
sequenceId ??= track.sequenceId;
|
|
598
|
+
if (track.sequenceId !== sequenceId) {
|
|
599
|
+
throw commandError('COMMAND_LINK_GROUP_SEQUENCE_MISMATCH', 'A LinkGroup cannot span Sequences', options.groupId);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const anchorStartUs = items[0]?.range.startUs ?? 0;
|
|
603
|
+
const group = {
|
|
604
|
+
id: options.groupId,
|
|
605
|
+
kind: options.kind ?? 'edit-group',
|
|
606
|
+
itemIds,
|
|
607
|
+
...((options.kind ?? 'edit-group') === 'av-sync'
|
|
608
|
+
? {
|
|
609
|
+
syncOffsetsUs: Object.fromEntries(items.map(item => [item.id, item.range.startUs - anchorStartUs])),
|
|
610
|
+
}
|
|
611
|
+
: {}),
|
|
612
|
+
};
|
|
613
|
+
return this.#host.edit(commandEditOptions(options, 'Link items'), transaction => {
|
|
614
|
+
transaction.createEntity('linkGroups', options.groupId, group);
|
|
615
|
+
for (const item of items) {
|
|
616
|
+
transaction.setField('items', item.id, ['linkGroupId'], options.groupId);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
unlinkItems(options) {
|
|
621
|
+
const project = this.#host.getSnapshot();
|
|
622
|
+
const group = project.linkGroups[options.groupId];
|
|
623
|
+
if (group === undefined) {
|
|
624
|
+
throw commandError('COMMAND_LINK_GROUP_MISSING', `LinkGroup ${options.groupId} does not exist`, options.groupId);
|
|
625
|
+
}
|
|
626
|
+
const selected = options.itemIds === undefined ? [...group.itemIds] : [...new Set(options.itemIds)];
|
|
627
|
+
if (selected.length === 0 || selected.some(id => !group.itemIds.includes(id))) {
|
|
628
|
+
throw commandError('COMMAND_LINK_GROUP_MEMBER_MISSING', `Every unlinked Item must belong to LinkGroup ${group.id}`, group.id);
|
|
629
|
+
}
|
|
630
|
+
const remaining = group.itemIds.filter(id => !selected.includes(id));
|
|
631
|
+
const removed = remaining.length < 2 ? [...group.itemIds] : selected;
|
|
632
|
+
for (const id of removed) {
|
|
633
|
+
const item = itemIn(project, id);
|
|
634
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
635
|
+
}
|
|
636
|
+
return this.#host.edit(commandEditOptions(options, 'Unlink items'), transaction => {
|
|
637
|
+
for (const id of removed)
|
|
638
|
+
transaction.removeField('items', id, ['linkGroupId']);
|
|
639
|
+
if (remaining.length < 2) {
|
|
640
|
+
transaction.deleteEntity('linkGroups', group.id);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
for (const id of selected) {
|
|
644
|
+
transaction.listRemove('linkGroups', group.id, ['itemIds'], id);
|
|
645
|
+
if (group.syncOffsetsUs !== undefined && Object.hasOwn(group.syncOffsetsUs, id)) {
|
|
646
|
+
transaction.removeField('linkGroups', group.id, ['syncOffsetsUs', id]);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
moveLinkedGroup(options) {
|
|
653
|
+
const project = this.#host.getSnapshot();
|
|
654
|
+
const group = project.linkGroups[options.groupId];
|
|
655
|
+
if (group === undefined) {
|
|
656
|
+
throw commandError('COMMAND_LINK_GROUP_MISSING', `LinkGroup ${options.groupId} does not exist`, options.groupId);
|
|
657
|
+
}
|
|
658
|
+
assertTimeDelta(options.deltaUs, 'deltaUs');
|
|
659
|
+
if (options.deltaUs === 0) {
|
|
660
|
+
throw commandError('COMMAND_NO_CHANGE', 'Linked move deltaUs cannot be zero', group.id);
|
|
661
|
+
}
|
|
662
|
+
const items = group.itemIds.map(id => itemIn(project, id));
|
|
663
|
+
for (const item of items) {
|
|
664
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
665
|
+
if (item.range.startUs + options.deltaUs < 0) {
|
|
666
|
+
throw commandError('COMMAND_MOVE_OUT_OF_RANGE', `Linked move would place Item ${item.id} before Sequence time zero`, item.id);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const ids = new Set(group.itemIds);
|
|
670
|
+
const transitions = Object.values(project.transitions).filter(value => ids.has(value.fromItemId) || ids.has(value.toItemId));
|
|
671
|
+
for (const transition of transitions) {
|
|
672
|
+
if (!ids.has(transition.fromItemId) || !ids.has(transition.toItemId)) {
|
|
673
|
+
throw commandError('COMMAND_TRANSITION_LINK_GROUP_CONFLICT', `Transition ${transition.id} crosses the LinkGroup boundary`, transition.id);
|
|
674
|
+
}
|
|
675
|
+
if (transition.range.startUs + options.deltaUs < 0) {
|
|
676
|
+
throw commandError('COMMAND_MOVE_OUT_OF_RANGE', `Linked move would place Transition ${transition.id} before Sequence time zero`, transition.id);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return this.#host.edit(commandEditOptions(options, 'Move linked group'), transaction => {
|
|
680
|
+
for (const item of items) {
|
|
681
|
+
transaction.setField('items', item.id, ['range', 'startUs'], item.range.startUs + options.deltaUs);
|
|
682
|
+
}
|
|
683
|
+
for (const transition of transitions) {
|
|
684
|
+
transaction.setField('transitions', transition.id, ['range', 'startUs'], transition.range.startUs + options.deltaUs);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
trimLinkedGroup(options) {
|
|
689
|
+
const project = this.#host.getSnapshot();
|
|
690
|
+
const group = project.linkGroups[options.groupId];
|
|
691
|
+
if (group === undefined) {
|
|
692
|
+
throw commandError('COMMAND_LINK_GROUP_MISSING', `LinkGroup ${options.groupId} does not exist`, options.groupId);
|
|
693
|
+
}
|
|
694
|
+
if (!Number.isSafeInteger(options.amountUs) || options.amountUs <= 0) {
|
|
695
|
+
throw commandError('COMMAND_TIME_INVALID', 'amountUs must be a positive safe integer');
|
|
696
|
+
}
|
|
697
|
+
const items = group.itemIds.map(id => itemIn(project, id));
|
|
698
|
+
const updates = items.map(item => {
|
|
699
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
700
|
+
if (containsAnimation(item)) {
|
|
701
|
+
throw commandError('COMMAND_TRIM_ANIMATION_UNSUPPORTED', `Item ${item.id} contains animation; an explicit keyframe trim policy is required`, item.id);
|
|
702
|
+
}
|
|
703
|
+
if (options.amountUs >= item.range.durationUs) {
|
|
704
|
+
throw commandError('COMMAND_TRIM_OUT_OF_RANGE', `Linked trim would empty Item ${item.id}`, item.id);
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
item,
|
|
708
|
+
range: trimmedSourceRange(item, options.edge, options.amountUs),
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
const ids = new Set(group.itemIds);
|
|
712
|
+
for (const transition of Object.values(project.transitions)) {
|
|
713
|
+
if (!ids.has(transition.fromItemId) && !ids.has(transition.toItemId))
|
|
714
|
+
continue;
|
|
715
|
+
if (!ids.has(transition.fromItemId) || !ids.has(transition.toItemId)) {
|
|
716
|
+
throw commandError('COMMAND_TRANSITION_LINK_GROUP_CONFLICT', `Transition ${transition.id} crosses the LinkGroup boundary`, transition.id);
|
|
717
|
+
}
|
|
718
|
+
const transitionEnd = transition.range.startUs + transition.range.durationUs;
|
|
719
|
+
for (const { item } of updates) {
|
|
720
|
+
if (transition.fromItemId !== item.id && transition.toItemId !== item.id)
|
|
721
|
+
continue;
|
|
722
|
+
const itemStart = item.range.startUs + (options.edge === 'start' ? options.amountUs : 0);
|
|
723
|
+
const itemEnd = item.range.startUs +
|
|
724
|
+
item.range.durationUs -
|
|
725
|
+
(options.edge === 'end' ? options.amountUs : 0);
|
|
726
|
+
if (transition.range.startUs < itemStart || transitionEnd > itemEnd) {
|
|
727
|
+
throw commandError('COMMAND_TRIM_TRANSITION_CONFLICT', `Linked trim would remove part of Transition ${transition.id}`, transition.id);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return this.#host.edit(commandEditOptions(options, 'Trim linked group'), transaction => {
|
|
732
|
+
for (const { item, range } of updates) {
|
|
733
|
+
if (options.edge === 'start') {
|
|
734
|
+
transaction.setField('items', item.id, ['range', 'startUs'], item.range.startUs + options.amountUs);
|
|
735
|
+
}
|
|
736
|
+
transaction.setField('items', item.id, ['range', 'durationUs'], item.range.durationUs - options.amountUs);
|
|
737
|
+
setSourceRange(transaction, item, range);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
removeLinkedGroup(options) {
|
|
742
|
+
const project = this.#host.getSnapshot();
|
|
743
|
+
const group = project.linkGroups[options.groupId];
|
|
744
|
+
if (group === undefined) {
|
|
745
|
+
throw commandError('COMMAND_LINK_GROUP_MISSING', `LinkGroup ${options.groupId} does not exist`, options.groupId);
|
|
746
|
+
}
|
|
747
|
+
const items = group.itemIds.map(id => itemIn(project, id));
|
|
748
|
+
for (const item of items)
|
|
749
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
750
|
+
return this.#host.edit(commandEditOptions(options, 'Remove linked group'), transaction => {
|
|
751
|
+
const removedTransitions = new Set();
|
|
752
|
+
for (const item of items) {
|
|
753
|
+
// appendRemoveItem owns all Item resources. Avoid duplicate Transition
|
|
754
|
+
// deletes when both endpoints are members by filtering the immutable snapshot.
|
|
755
|
+
for (const transition of Object.values(project.transitions)) {
|
|
756
|
+
if (removedTransitions.has(transition.id) ||
|
|
757
|
+
(transition.fromItemId !== item.id && transition.toItemId !== item.id)) {
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
removedTransitions.add(transition.id);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
for (const transitionId of removedTransitions) {
|
|
764
|
+
const transition = project.transitions[transitionId];
|
|
765
|
+
if (transition === undefined)
|
|
766
|
+
continue;
|
|
767
|
+
transaction.listRemove('sequences', transition.sequenceId, ['transitionIds'], transition.id);
|
|
768
|
+
transaction.deleteEntity('transitions', transition.id);
|
|
769
|
+
transaction.deleteEntity('materialInstances', transition.materialInstanceId);
|
|
770
|
+
}
|
|
771
|
+
const projectWithoutTransitions = {
|
|
772
|
+
...project,
|
|
773
|
+
transitions: Object.fromEntries(Object.entries(project.transitions).filter(([id]) => !removedTransitions.has(id))),
|
|
774
|
+
};
|
|
775
|
+
for (const item of items)
|
|
776
|
+
appendRemoveItem(projectWithoutTransitions, item, transaction, true);
|
|
777
|
+
transaction.deleteEntity('linkGroups', group.id);
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
splitLinkedGroup(options) {
|
|
781
|
+
const project = this.#host.getSnapshot();
|
|
782
|
+
const group = project.linkGroups[options.groupId];
|
|
783
|
+
if (group === undefined) {
|
|
784
|
+
throw commandError('COMMAND_LINK_GROUP_MISSING', `LinkGroup ${options.groupId} does not exist`, options.groupId);
|
|
785
|
+
}
|
|
786
|
+
if (project.linkGroups[options.rightGroupId] !== undefined ||
|
|
787
|
+
options.rightGroupId === group.id) {
|
|
788
|
+
throw commandError('COMMAND_LINK_GROUP_EXISTS', `LinkGroup ${options.rightGroupId} already exists`, options.rightGroupId);
|
|
789
|
+
}
|
|
790
|
+
assertTime(options.atUs, 'atUs');
|
|
791
|
+
const mappedIds = Object.keys(options.rightItemIds);
|
|
792
|
+
if (mappedIds.length !== group.itemIds.length ||
|
|
793
|
+
group.itemIds.some(id => typeof options.rightItemIds[id] !== 'string')) {
|
|
794
|
+
throw commandError('COMMAND_SPLIT_GROUP_MAPPING_INVALID', 'rightItemIds must map every LinkGroup member exactly once', group.id);
|
|
795
|
+
}
|
|
796
|
+
const rightIds = group.itemIds.map(id => {
|
|
797
|
+
const value = options.rightItemIds[id];
|
|
798
|
+
if (value === undefined) {
|
|
799
|
+
throw commandError('COMMAND_SPLIT_GROUP_MAPPING_INVALID', `Missing right Item ID for ${id}`);
|
|
800
|
+
}
|
|
801
|
+
return value;
|
|
802
|
+
});
|
|
803
|
+
const rightIdAt = (index) => {
|
|
804
|
+
const value = rightIds[index];
|
|
805
|
+
if (value === undefined)
|
|
806
|
+
throw new RangeError('Right Item index is outside the group');
|
|
807
|
+
return value;
|
|
808
|
+
};
|
|
809
|
+
if (new Set(rightIds).size !== rightIds.length ||
|
|
810
|
+
rightIds.some(id => project.items[id] !== undefined || group.itemIds.includes(id))) {
|
|
811
|
+
throw commandError('COMMAND_ITEM_EXISTS', 'Every right-side Item ID must be new and unique', group.id);
|
|
812
|
+
}
|
|
813
|
+
const split = group.itemIds.map((id, index) => {
|
|
814
|
+
const item = itemIn(project, id);
|
|
815
|
+
const track = trackIn(project, item.trackId);
|
|
816
|
+
assertUnlocked(track);
|
|
817
|
+
const endUs = item.range.startUs + item.range.durationUs;
|
|
818
|
+
if (options.atUs <= item.range.startUs || options.atUs >= endUs) {
|
|
819
|
+
throw commandError('COMMAND_SPLIT_OUT_OF_RANGE', `Split point must be inside every linked Item; ${item.id} does not contain it`, item.id);
|
|
820
|
+
}
|
|
821
|
+
if (item.materialInstanceIds.length > 0 ||
|
|
822
|
+
(item.markerIds?.length ?? 0) > 0 ||
|
|
823
|
+
containsAnimation(item)) {
|
|
824
|
+
throw commandError('COMMAND_SPLIT_OWNED_ENTITY_UNSUPPORTED', `Linked Item ${item.id} requires an explicit owned-entity split policy`, item.id);
|
|
825
|
+
}
|
|
826
|
+
const leftDurationUs = options.atUs - item.range.startUs;
|
|
827
|
+
const sourceRanges = splitSourceRanges(item, leftDurationUs);
|
|
828
|
+
const right = structuredClone(item);
|
|
829
|
+
right.id = rightIdAt(index);
|
|
830
|
+
right.linkGroupId = options.rightGroupId;
|
|
831
|
+
right.range = { startUs: options.atUs, durationUs: endUs - options.atUs };
|
|
832
|
+
const rightSource = sourceRanges?.right;
|
|
833
|
+
if (rightSource !== undefined) {
|
|
834
|
+
const range = asObject(asObject(right.source)?.sourceRange);
|
|
835
|
+
if (range !== undefined) {
|
|
836
|
+
range.startUs = rightSource.startUs;
|
|
837
|
+
range.durationUs = rightSource.durationUs;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return { item, track, leftDurationUs, sourceRanges, right };
|
|
841
|
+
});
|
|
842
|
+
for (const transition of Object.values(project.transitions)) {
|
|
843
|
+
const transitionEnd = transition.range.startUs + transition.range.durationUs;
|
|
844
|
+
if ((group.itemIds.includes(transition.fromItemId) ||
|
|
845
|
+
group.itemIds.includes(transition.toItemId)) &&
|
|
846
|
+
options.atUs > transition.range.startUs &&
|
|
847
|
+
options.atUs < transitionEnd) {
|
|
848
|
+
throw commandError('COMMAND_SPLIT_TRANSITION_CONFLICT', `Split point intersects Transition ${transition.id}`, transition.id);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const rightGroup = {
|
|
852
|
+
id: options.rightGroupId,
|
|
853
|
+
kind: group.kind,
|
|
854
|
+
itemIds: rightIds,
|
|
855
|
+
...(group.syncOffsetsUs === undefined
|
|
856
|
+
? {}
|
|
857
|
+
: {
|
|
858
|
+
syncOffsetsUs: Object.fromEntries(group.itemIds.map((_id, index) => [rightIdAt(index), 0])),
|
|
859
|
+
}),
|
|
860
|
+
};
|
|
861
|
+
const commit = this.#host.edit(commandEditOptions(options, 'Split linked group'), transaction => {
|
|
862
|
+
for (const entry of split) {
|
|
863
|
+
transaction.setField('items', entry.item.id, ['range', 'durationUs'], entry.leftDurationUs);
|
|
864
|
+
setSourceRange(transaction, entry.item, entry.sourceRanges?.left);
|
|
865
|
+
transaction.createEntity('items', entry.right.id, entry.right);
|
|
866
|
+
const itemIndex = entry.track.itemIds.indexOf(entry.item.id);
|
|
867
|
+
transaction.listInsert('tracks', entry.track.id, ['itemIds'], entry.right.id, entry.track.itemIds[itemIndex + 1]);
|
|
868
|
+
}
|
|
869
|
+
transaction.createEntity('linkGroups', options.rightGroupId, rightGroup);
|
|
870
|
+
for (const transition of Object.values(project.transitions)) {
|
|
871
|
+
if (transition.range.startUs < options.atUs)
|
|
872
|
+
continue;
|
|
873
|
+
const fromIndex = group.itemIds.indexOf(transition.fromItemId);
|
|
874
|
+
const toIndex = group.itemIds.indexOf(transition.toItemId);
|
|
875
|
+
if (fromIndex >= 0) {
|
|
876
|
+
transaction.setField('transitions', transition.id, ['fromItemId'], rightIdAt(fromIndex));
|
|
877
|
+
}
|
|
878
|
+
if (toIndex >= 0) {
|
|
879
|
+
transaction.setField('transitions', transition.id, ['toItemId'], rightIdAt(toIndex));
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
return {
|
|
884
|
+
commit,
|
|
885
|
+
leftGroupId: group.id,
|
|
886
|
+
rightGroupId: options.rightGroupId,
|
|
887
|
+
rightItemIds: { ...options.rightItemIds },
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
slipItem(options) {
|
|
891
|
+
const project = this.#host.getSnapshot();
|
|
892
|
+
const item = itemIn(project, options.itemId);
|
|
893
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
894
|
+
if (item.linkGroupId !== undefined) {
|
|
895
|
+
throw commandError('COMMAND_LINKED_EDIT_REQUIRES_GROUP', `Item ${item.id} is linked; use a LinkGroup edit or unlink it first`, item.id);
|
|
896
|
+
}
|
|
897
|
+
assertTimeDelta(options.deltaSourceUs, 'deltaSourceUs');
|
|
898
|
+
if (options.deltaSourceUs === 0) {
|
|
899
|
+
throw commandError('COMMAND_NO_CHANGE', 'Slip deltaSourceUs cannot be zero', item.id);
|
|
900
|
+
}
|
|
901
|
+
const source = asObject(item.source);
|
|
902
|
+
const sourceRange = asObject(source?.sourceRange);
|
|
903
|
+
const mapping = asObject(source?.timeMapping);
|
|
904
|
+
if (sourceRange === undefined ||
|
|
905
|
+
mapping === undefined ||
|
|
906
|
+
typeof sourceRange.startUs !== 'number' ||
|
|
907
|
+
typeof sourceRange.durationUs !== 'number') {
|
|
908
|
+
throw commandError('COMMAND_TIME_MAPPING_UNSUPPORTED', `Item ${item.id} does not have a media TimeMap`, item.id);
|
|
909
|
+
}
|
|
910
|
+
const shiftedRange = {
|
|
911
|
+
startUs: sourceRange.startUs + options.deltaSourceUs,
|
|
912
|
+
durationUs: sourceRange.durationUs,
|
|
913
|
+
};
|
|
914
|
+
assertSourceRange(project, item, shiftedRange);
|
|
915
|
+
const curvePoints = mapping.type === 'curve' && Array.isArray(mapping.points)
|
|
916
|
+
? mapping.points.map(value => asObject(value))
|
|
917
|
+
: undefined;
|
|
918
|
+
if (mapping.type !== 'linear' && curvePoints === undefined) {
|
|
919
|
+
throw commandError('COMMAND_TIME_MAPPING_UNSUPPORTED', `Item ${item.id} has an unsupported TimeMap`, item.id);
|
|
920
|
+
}
|
|
921
|
+
if (curvePoints?.some(point => point === undefined || typeof point.sourceTimeUs !== 'number') ===
|
|
922
|
+
true) {
|
|
923
|
+
throw commandError('COMMAND_TIME_MAPPING_UNSUPPORTED', `Item ${item.id} has an invalid curve TimeMap`, item.id);
|
|
924
|
+
}
|
|
925
|
+
return this.#host.edit(commandEditOptions(options, 'Slip item'), transaction => {
|
|
926
|
+
transaction.setField('items', item.id, ['source', 'sourceRange', 'startUs'], shiftedRange.startUs);
|
|
927
|
+
if (curvePoints !== undefined) {
|
|
928
|
+
transaction.setField('items', item.id, ['source', 'timeMapping', 'points'], curvePoints.map(point => ({
|
|
929
|
+
...point,
|
|
930
|
+
sourceTimeUs: point?.sourceTimeUs + options.deltaSourceUs,
|
|
931
|
+
})));
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
rollEdit(options) {
|
|
936
|
+
const project = this.#host.getSnapshot();
|
|
937
|
+
const left = itemIn(project, options.leftItemId);
|
|
938
|
+
const right = itemIn(project, options.rightItemId);
|
|
939
|
+
if (left.trackId !== right.trackId) {
|
|
940
|
+
throw commandError('COMMAND_ROLL_TRACK_MISMATCH', 'Roll Items must share one Track');
|
|
941
|
+
}
|
|
942
|
+
assertProfessionalEditItems(project, [left, right]);
|
|
943
|
+
assertTime(options.toUs, 'toUs');
|
|
944
|
+
const boundaryUs = left.range.startUs + left.range.durationUs;
|
|
945
|
+
if (boundaryUs !== right.range.startUs) {
|
|
946
|
+
throw commandError('COMMAND_ROLL_ITEMS_NOT_ADJACENT', 'Roll Items must share an exact half-open boundary');
|
|
947
|
+
}
|
|
948
|
+
if (options.toUs <= left.range.startUs ||
|
|
949
|
+
options.toUs >= right.range.startUs + right.range.durationUs) {
|
|
950
|
+
throw commandError('COMMAND_ROLL_OUT_OF_RANGE', 'Roll boundary would empty an Item');
|
|
951
|
+
}
|
|
952
|
+
const deltaUs = options.toUs - boundaryUs;
|
|
953
|
+
if (deltaUs === 0)
|
|
954
|
+
throw commandError('COMMAND_NO_CHANGE', 'Roll boundary is unchanged');
|
|
955
|
+
const leftSource = adjustedLinearSourceRange(project, left, 'end', deltaUs);
|
|
956
|
+
const rightSource = adjustedLinearSourceRange(project, right, 'start', deltaUs);
|
|
957
|
+
return this.#host.edit(commandEditOptions(options, 'Roll edit'), transaction => {
|
|
958
|
+
transaction.setField('items', left.id, ['range', 'durationUs'], left.range.durationUs + deltaUs);
|
|
959
|
+
transaction.setField('items', right.id, ['range', 'startUs'], options.toUs);
|
|
960
|
+
transaction.setField('items', right.id, ['range', 'durationUs'], right.range.durationUs - deltaUs);
|
|
961
|
+
setSourceRange(transaction, left, leftSource);
|
|
962
|
+
setSourceRange(transaction, right, rightSource);
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
slideItem(options) {
|
|
966
|
+
const project = this.#host.getSnapshot();
|
|
967
|
+
const item = itemIn(project, options.itemId);
|
|
968
|
+
assertTimeDelta(options.deltaUs, 'deltaUs');
|
|
969
|
+
if (options.deltaUs === 0)
|
|
970
|
+
throw commandError('COMMAND_NO_CHANGE', 'Slide deltaUs is zero');
|
|
971
|
+
const track = trackIn(project, item.trackId);
|
|
972
|
+
const left = track.itemIds
|
|
973
|
+
.map(id => project.items[id])
|
|
974
|
+
.find(value => value !== undefined &&
|
|
975
|
+
value.range.startUs + value.range.durationUs === item.range.startUs);
|
|
976
|
+
const itemEndUs = item.range.startUs + item.range.durationUs;
|
|
977
|
+
const right = track.itemIds
|
|
978
|
+
.map(id => project.items[id])
|
|
979
|
+
.find(value => value !== undefined && value.range.startUs === itemEndUs);
|
|
980
|
+
if (left === undefined || right === undefined) {
|
|
981
|
+
throw commandError('COMMAND_SLIDE_NEIGHBOR_MISSING', `Item ${item.id} requires adjacent left and right Items`, item.id);
|
|
982
|
+
}
|
|
983
|
+
assertProfessionalEditItems(project, [left, item, right]);
|
|
984
|
+
if (left.range.durationUs + options.deltaUs < 1 ||
|
|
985
|
+
right.range.durationUs - options.deltaUs < 1) {
|
|
986
|
+
throw commandError('COMMAND_SLIDE_OUT_OF_RANGE', 'Slide would empty an adjacent Item');
|
|
987
|
+
}
|
|
988
|
+
const leftSource = adjustedLinearSourceRange(project, left, 'end', options.deltaUs);
|
|
989
|
+
const rightSource = adjustedLinearSourceRange(project, right, 'start', options.deltaUs);
|
|
990
|
+
return this.#host.edit(commandEditOptions(options, 'Slide item'), transaction => {
|
|
991
|
+
transaction.setField('items', left.id, ['range', 'durationUs'], left.range.durationUs + options.deltaUs);
|
|
992
|
+
transaction.setField('items', item.id, ['range', 'startUs'], item.range.startUs + options.deltaUs);
|
|
993
|
+
transaction.setField('items', right.id, ['range', 'startUs'], right.range.startUs + options.deltaUs);
|
|
994
|
+
transaction.setField('items', right.id, ['range', 'durationUs'], right.range.durationUs - options.deltaUs);
|
|
995
|
+
setSourceRange(transaction, left, leftSource);
|
|
996
|
+
setSourceRange(transaction, right, rightSource);
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
addMarker(options) {
|
|
1000
|
+
const project = this.#host.getSnapshot();
|
|
1001
|
+
const marker = options.marker;
|
|
1002
|
+
if (project.markers[marker.id] !== undefined) {
|
|
1003
|
+
throw commandError('COMMAND_MARKER_EXISTS', `Marker ${marker.id} already exists`, marker.id);
|
|
1004
|
+
}
|
|
1005
|
+
assertTime(marker.timeUs, 'marker.timeUs');
|
|
1006
|
+
assertTime(marker.durationUs, 'marker.durationUs');
|
|
1007
|
+
let sequenceId;
|
|
1008
|
+
let item;
|
|
1009
|
+
if (marker.owner.type === 'sequence') {
|
|
1010
|
+
if (project.sequences[marker.owner.id] === undefined) {
|
|
1011
|
+
throw commandError('COMMAND_MARKER_OWNER_MISSING', `Sequence ${marker.owner.id} does not exist`, marker.id);
|
|
1012
|
+
}
|
|
1013
|
+
sequenceId = marker.owner.id;
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
item = itemIn(project, marker.owner.id);
|
|
1017
|
+
const track = trackIn(project, item.trackId);
|
|
1018
|
+
assertUnlocked(track);
|
|
1019
|
+
sequenceId = track.sequenceId;
|
|
1020
|
+
if (marker.timeUs + marker.durationUs > item.range.durationUs) {
|
|
1021
|
+
throw commandError('COMMAND_MARKER_OUT_OF_RANGE', `Marker ${marker.id} exceeds Item ${item.id}`, marker.id);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const sequence = project.sequences[sequenceId];
|
|
1025
|
+
if (sequence === undefined)
|
|
1026
|
+
throw commandError('COMMAND_SEQUENCE_MISSING', sequenceId);
|
|
1027
|
+
return this.#host.edit(commandEditOptions(options, 'Add marker'), transaction => {
|
|
1028
|
+
transaction.createEntity('markers', marker.id, marker);
|
|
1029
|
+
transaction.listInsert('sequences', sequence.id, ['markerIds'], marker.id);
|
|
1030
|
+
if (item !== undefined) {
|
|
1031
|
+
if (item.markerIds === undefined) {
|
|
1032
|
+
transaction.setField('items', item.id, ['markerIds'], [marker.id]);
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
transaction.listInsert('items', item.id, ['markerIds'], marker.id);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
updateMarker(options) {
|
|
1041
|
+
const project = this.#host.getSnapshot();
|
|
1042
|
+
const marker = project.markers[options.markerId];
|
|
1043
|
+
if (marker === undefined) {
|
|
1044
|
+
throw commandError('COMMAND_MARKER_MISSING', `Marker ${options.markerId} does not exist`, options.markerId);
|
|
1045
|
+
}
|
|
1046
|
+
const timeUs = options.timeUs ?? marker.timeUs;
|
|
1047
|
+
const durationUs = options.durationUs ?? marker.durationUs;
|
|
1048
|
+
assertTime(timeUs, 'timeUs');
|
|
1049
|
+
assertTime(durationUs, 'durationUs');
|
|
1050
|
+
if (marker.owner.type === 'item') {
|
|
1051
|
+
const item = itemIn(project, marker.owner.id);
|
|
1052
|
+
assertUnlocked(trackIn(project, item.trackId));
|
|
1053
|
+
if (timeUs + durationUs > item.range.durationUs) {
|
|
1054
|
+
throw commandError('COMMAND_MARKER_OUT_OF_RANGE', `Marker ${marker.id} exceeds Item ${item.id}`, marker.id);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return this.#host.edit(commandEditOptions(options, 'Update marker'), transaction => {
|
|
1058
|
+
if (options.timeUs !== undefined) {
|
|
1059
|
+
transaction.setField('markers', marker.id, ['timeUs'], options.timeUs);
|
|
1060
|
+
}
|
|
1061
|
+
if (options.durationUs !== undefined) {
|
|
1062
|
+
transaction.setField('markers', marker.id, ['durationUs'], options.durationUs);
|
|
1063
|
+
}
|
|
1064
|
+
for (const [property, optionProperty] of [
|
|
1065
|
+
['label', 'markerLabel'],
|
|
1066
|
+
['color', 'markerColor'],
|
|
1067
|
+
]) {
|
|
1068
|
+
const value = options[optionProperty];
|
|
1069
|
+
if (value === undefined)
|
|
1070
|
+
continue;
|
|
1071
|
+
if (value === null) {
|
|
1072
|
+
if (marker[property] !== undefined)
|
|
1073
|
+
transaction.removeField('markers', marker.id, [property]);
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
transaction.setField('markers', marker.id, [property], value);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (Object.hasOwn(options, 'payload')) {
|
|
1080
|
+
transaction.setField('markers', marker.id, ['payload'], options.payload ?? null);
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
removeMarker(options) {
|
|
1085
|
+
const project = this.#host.getSnapshot();
|
|
1086
|
+
const marker = project.markers[options.markerId];
|
|
1087
|
+
if (marker === undefined) {
|
|
1088
|
+
throw commandError('COMMAND_MARKER_MISSING', `Marker ${options.markerId} does not exist`, options.markerId);
|
|
1089
|
+
}
|
|
1090
|
+
const sequenceId = marker.owner.type === 'sequence'
|
|
1091
|
+
? marker.owner.id
|
|
1092
|
+
: trackIn(project, itemIn(project, marker.owner.id).trackId).sequenceId;
|
|
1093
|
+
const sequence = project.sequences[sequenceId];
|
|
1094
|
+
return this.#host.edit(commandEditOptions(options, 'Remove marker'), transaction => {
|
|
1095
|
+
if (sequence?.markerIds.includes(marker.id) === true) {
|
|
1096
|
+
transaction.listRemove('sequences', sequence.id, ['markerIds'], marker.id);
|
|
1097
|
+
}
|
|
1098
|
+
if (marker.owner.type === 'item') {
|
|
1099
|
+
const item = itemIn(project, marker.owner.id);
|
|
1100
|
+
if (item.markerIds?.includes(marker.id) === true) {
|
|
1101
|
+
transaction.listRemove('items', item.id, ['markerIds'], marker.id);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
transaction.deleteEntity('markers', marker.id);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
setSelectionMetadata(options) {
|
|
1108
|
+
const project = this.#host.getSnapshot();
|
|
1109
|
+
const sequence = project.sequences[options.sequenceId];
|
|
1110
|
+
if (sequence === undefined) {
|
|
1111
|
+
throw commandError('COMMAND_SEQUENCE_MISSING', `Sequence ${options.sequenceId} does not exist`);
|
|
1112
|
+
}
|
|
1113
|
+
const itemIds = [...new Set(options.itemIds)];
|
|
1114
|
+
if (itemIds.length !== options.itemIds.length) {
|
|
1115
|
+
throw commandError('COMMAND_SELECTION_INVALID', 'Selection Item IDs must be unique');
|
|
1116
|
+
}
|
|
1117
|
+
for (const id of itemIds) {
|
|
1118
|
+
const item = itemIn(project, id);
|
|
1119
|
+
if (trackIn(project, item.trackId).sequenceId !== sequence.id) {
|
|
1120
|
+
throw commandError('COMMAND_SELECTION_SEQUENCE_MISMATCH', `Item ${id} does not belong to Sequence ${sequence.id}`, id);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (options.range !== undefined) {
|
|
1124
|
+
assertTime(options.range.startUs, 'range.startUs');
|
|
1125
|
+
assertTime(options.range.durationUs, 'range.durationUs');
|
|
1126
|
+
}
|
|
1127
|
+
const extensions = asObject(sequence.extensions) ?? {};
|
|
1128
|
+
return this.#host.edit(commandEditOptions(options, 'Set selection metadata'), transaction => {
|
|
1129
|
+
transaction.setField('sequences', sequence.id, ['extensions'], {
|
|
1130
|
+
...extensions,
|
|
1131
|
+
'aelion.selection': {
|
|
1132
|
+
itemIds,
|
|
1133
|
+
...(options.range === undefined ? {} : { range: options.range }),
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
reorderTrack(options) {
|
|
1139
|
+
const project = this.#host.getSnapshot();
|
|
1140
|
+
const sequence = project.sequences[options.sequenceId];
|
|
1141
|
+
if (!sequence?.trackIds.includes(options.trackId)) {
|
|
1142
|
+
throw commandError('COMMAND_TRACK_SEQUENCE_MISMATCH', `Track ${options.trackId} does not belong to Sequence ${options.sequenceId}`, options.trackId);
|
|
1143
|
+
}
|
|
1144
|
+
if (options.beforeTrackId !== undefined && !sequence.trackIds.includes(options.beforeTrackId)) {
|
|
1145
|
+
throw commandError('COMMAND_TRACK_ANCHOR_MISSING', `Track anchor ${options.beforeTrackId} does not belong to Sequence ${sequence.id}`, options.beforeTrackId);
|
|
1146
|
+
}
|
|
1147
|
+
return this.#host.edit(commandEditOptions(options, 'Reorder track'), transaction => {
|
|
1148
|
+
transaction.listMove('sequences', sequence.id, ['trackIds'], options.trackId, options.beforeTrackId);
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
setTrackLocked(options) {
|
|
1152
|
+
trackIn(this.#host.getSnapshot(), options.trackId);
|
|
1153
|
+
return this.#host.edit(commandEditOptions(options, 'Set track lock'), transaction => {
|
|
1154
|
+
transaction.setField('tracks', options.trackId, ['locked'], options.value);
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
setTrackEnabled(options) {
|
|
1158
|
+
trackIn(this.#host.getSnapshot(), options.trackId);
|
|
1159
|
+
return this.#host.edit(commandEditOptions(options, 'Set track enabled'), transaction => {
|
|
1160
|
+
transaction.setField('tracks', options.trackId, ['enabled'], options.value);
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
setTrackMuted(options) {
|
|
1164
|
+
const track = trackIn(this.#host.getSnapshot(), options.trackId);
|
|
1165
|
+
if (track.kind !== 'audio' || asObject(track.audio) === undefined) {
|
|
1166
|
+
throw commandError('COMMAND_TRACK_AUDIO_REQUIRED', `Track ${track.id} is not an audio Track with mixer properties`, track.id);
|
|
1167
|
+
}
|
|
1168
|
+
return this.#host.edit(commandEditOptions(options, 'Set track mute'), transaction => {
|
|
1169
|
+
transaction.setField('tracks', options.trackId, ['audio', 'muted'], options.value);
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
setTrackSolo(options) {
|
|
1173
|
+
const track = trackIn(this.#host.getSnapshot(), options.trackId);
|
|
1174
|
+
if (track.kind !== 'audio' || asObject(track.audio) === undefined) {
|
|
1175
|
+
throw commandError('COMMAND_TRACK_AUDIO_REQUIRED', `Track ${track.id} is not an audio Track with mixer properties`, track.id);
|
|
1176
|
+
}
|
|
1177
|
+
return this.#host.edit(commandEditOptions(options, 'Set track solo'), transaction => {
|
|
1178
|
+
transaction.setField('tracks', options.trackId, ['audio', 'solo'], options.value);
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
}
|