videobook-engine 2.0.1 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -41
- package/dist/artifact-names.d.ts +6 -0
- package/dist/artifact-names.d.ts.map +1 -0
- package/dist/artifact-names.js +56 -0
- package/dist/artifact-names.js.map +1 -0
- package/dist/artifacts.d.ts.map +1 -1
- package/dist/artifacts.js +57 -23
- package/dist/artifacts.js.map +1 -1
- package/dist/books.js +2 -1
- package/dist/books.js.map +1 -1
- package/dist/cas.d.ts +27 -1
- package/dist/cas.d.ts.map +1 -1
- package/dist/cas.js +78 -6
- package/dist/cas.js.map +1 -1
- package/dist/catalog-metadata.d.ts +56 -0
- package/dist/catalog-metadata.d.ts.map +1 -0
- package/dist/catalog-metadata.js +42 -0
- package/dist/catalog-metadata.js.map +1 -0
- package/dist/communications.js +4 -2
- package/dist/communications.js.map +1 -1
- package/dist/context.d.ts +6 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +35 -6
- package/dist/context.js.map +1 -1
- package/dist/domain.d.ts +4 -0
- package/dist/domain.d.ts.map +1 -1
- package/dist/domain.js +870 -58
- package/dist/domain.js.map +1 -1
- package/dist/edit-policy.d.ts +3 -0
- package/dist/edit-policy.d.ts.map +1 -0
- package/dist/edit-policy.js +6 -0
- package/dist/edit-policy.js.map +1 -0
- package/dist/edits.d.ts +10 -0
- package/dist/edits.d.ts.map +1 -0
- package/dist/edits.js +1392 -0
- package/dist/edits.js.map +1 -0
- package/dist/engine-types.d.ts +110 -60
- package/dist/engine-types.d.ts.map +1 -1
- package/dist/engine-types.js +33 -0
- package/dist/engine-types.js.map +1 -1
- package/dist/engine.d.ts +69 -8
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +24 -18
- package/dist/engine.js.map +1 -1
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +31 -27
- package/dist/files.js.map +1 -1
- package/dist/fork.d.ts +147 -0
- package/dist/fork.d.ts.map +1 -0
- package/dist/fork.js +609 -0
- package/dist/fork.js.map +1 -0
- package/dist/history.d.ts +0 -4
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +166 -595
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/job-queue.d.ts +2 -0
- package/dist/job-queue.d.ts.map +1 -1
- package/dist/job-queue.js +29 -2
- package/dist/job-queue.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +1 -0
- package/dist/media.js.map +1 -1
- package/dist/merge-policy.d.ts +99 -0
- package/dist/merge-policy.d.ts.map +1 -0
- package/dist/merge-policy.js +310 -0
- package/dist/merge-policy.js.map +1 -0
- package/dist/metadata.d.ts.map +1 -1
- package/dist/metadata.js +15 -20
- package/dist/metadata.js.map +1 -1
- package/dist/migration.d.ts +9 -0
- package/dist/migration.d.ts.map +1 -0
- package/dist/migration.js +534 -0
- package/dist/migration.js.map +1 -0
- package/dist/mvp-contract-fixtures.d.ts +244 -0
- package/dist/mvp-contract-fixtures.d.ts.map +1 -0
- package/dist/mvp-contract-fixtures.js +556 -0
- package/dist/mvp-contract-fixtures.js.map +1 -0
- package/dist/mvp-contracts.d.ts +761 -0
- package/dist/mvp-contracts.d.ts.map +1 -0
- package/dist/mvp-contracts.js +59 -0
- package/dist/mvp-contracts.js.map +1 -0
- package/dist/mvp-time.d.ts +54 -0
- package/dist/mvp-time.d.ts.map +1 -0
- package/dist/mvp-time.js +148 -0
- package/dist/mvp-time.js.map +1 -0
- package/dist/notebook/types.d.ts +113 -8
- package/dist/notebook/types.d.ts.map +1 -1
- package/dist/order-keys.d.ts +22 -0
- package/dist/order-keys.d.ts.map +1 -0
- package/dist/order-keys.js +262 -0
- package/dist/order-keys.js.map +1 -0
- package/dist/runtime-services.js +4 -2
- package/dist/runtime-services.js.map +1 -1
- package/dist/schema.d.ts +24 -5
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +644 -132
- package/dist/schema.js.map +1 -1
- package/dist/search-benchmark.d.ts +4 -0
- package/dist/search-benchmark.d.ts.map +1 -0
- package/dist/search-benchmark.js +144 -0
- package/dist/search-benchmark.js.map +1 -0
- package/dist/sequences.d.ts +20 -0
- package/dist/sequences.d.ts.map +1 -0
- package/dist/sequences.js +825 -0
- package/dist/sequences.js.map +1 -0
- package/dist/similarity.js +1 -1
- package/dist/storage.d.ts +3 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +388 -7
- package/dist/storage.js.map +1 -1
- package/dist/store.d.ts +63 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +351 -65
- package/dist/store.js.map +1 -1
- package/dist/streams.d.ts +10 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +183 -0
- package/dist/streams.js.map +1 -0
- package/dist/temporal-models.d.ts +48 -0
- package/dist/temporal-models.d.ts.map +1 -0
- package/dist/temporal-models.js +278 -0
- package/dist/temporal-models.js.map +1 -0
- package/dist/temporal-search.d.ts +29 -0
- package/dist/temporal-search.d.ts.map +1 -0
- package/dist/temporal-search.js +1507 -0
- package/dist/temporal-search.js.map +1 -0
- package/dist/transcripts.d.ts +16 -0
- package/dist/transcripts.d.ts.map +1 -0
- package/dist/transcripts.js +519 -0
- package/dist/transcripts.js.map +1 -0
- package/docs/engine-layout.md +346 -106
- package/docs/mvp-prd.md +918 -0
- package/fixtures/media/vancat.mp4 +0 -0
- package/fixtures/media/vancat_profile.jpg +0 -0
- package/fixtures/v5/contract-fixtures.json +1532 -0
- package/package.json +30 -5
- package/dist/history-types.d.ts +0 -74
- package/dist/history-types.d.ts.map +0 -1
- package/dist/history-types.js +0 -2
- package/dist/history-types.js.map +0 -1
- package/dist/timeline.d.ts +0 -9
- package/dist/timeline.d.ts.map +0 -1
- package/dist/timeline.js +0 -236
- package/dist/timeline.js.map +0 -1
package/dist/edits.js
ADDED
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { ok } from "./engine-types.js";
|
|
3
|
+
import { normalizeRational, normalizeSequenceRange, normalizeSourceRange, rationalEquals, sourceRangeEndTick, } from "./mvp-time.js";
|
|
4
|
+
import { resultOf, syncResultOf } from "./context.js";
|
|
5
|
+
import { createSequencesApi } from "./sequences.js";
|
|
6
|
+
import { newUuidV7 } from "./ids.js";
|
|
7
|
+
import { canonicalJson, commitDateMs, EngineFault, parseCommitMessage, } from "./store.js";
|
|
8
|
+
export function createEditsApi(context) {
|
|
9
|
+
return {
|
|
10
|
+
preview: (intent) => syncResultOf(() => project(context, intent).preview),
|
|
11
|
+
commit: (intent, previewHash) => commitEdit(context, intent, previewHash),
|
|
12
|
+
restore: (request) => restoreEdit(context, request),
|
|
13
|
+
get: (actionId) => syncResultOf(() => requiredEditBatch(context, actionId)),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function project(context, intent) {
|
|
17
|
+
validateIntent(intent);
|
|
18
|
+
const current = sequenceValue(createSequencesApi(context).get(intent.sequenceId));
|
|
19
|
+
const normalized = intent.operations.map((operation, ordinal) => normalizeOperation(intent.commandId, ordinal, operation));
|
|
20
|
+
const writeSet = editWriteSet(intent.sequenceId, normalized);
|
|
21
|
+
const conflicts = revisionConflicts(context, intent, writeSet);
|
|
22
|
+
const beforeHash = sequenceHash(current);
|
|
23
|
+
const next = cloneSequence(current);
|
|
24
|
+
const warnings = [];
|
|
25
|
+
for (const operation of normalized) {
|
|
26
|
+
applyOperation(context, next, operation, warnings);
|
|
27
|
+
}
|
|
28
|
+
validateSequenceProjection(context, next);
|
|
29
|
+
const afterHash = sequenceHash(next);
|
|
30
|
+
const affectedRanges = affectedSequenceRanges(intent.sequenceId, normalized);
|
|
31
|
+
const diff = sequenceDiff(current, next);
|
|
32
|
+
const previewHash = hashJson({
|
|
33
|
+
commandId: intent.commandId,
|
|
34
|
+
sequenceId: intent.sequenceId,
|
|
35
|
+
operations: normalized,
|
|
36
|
+
affectedRanges,
|
|
37
|
+
writeSet,
|
|
38
|
+
warnings,
|
|
39
|
+
conflicts,
|
|
40
|
+
diff,
|
|
41
|
+
beforeHash,
|
|
42
|
+
afterHash,
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
preview: {
|
|
46
|
+
commandId: intent.commandId,
|
|
47
|
+
sequenceId: intent.sequenceId,
|
|
48
|
+
baseRevision: intent.baseRevision,
|
|
49
|
+
valid: conflicts.length === 0,
|
|
50
|
+
operations: normalized,
|
|
51
|
+
affectedRanges,
|
|
52
|
+
writeSet,
|
|
53
|
+
warnings,
|
|
54
|
+
conflicts,
|
|
55
|
+
diff,
|
|
56
|
+
beforeHash,
|
|
57
|
+
afterHash,
|
|
58
|
+
previewHash,
|
|
59
|
+
},
|
|
60
|
+
sequence: next,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function commitEdit(context, intent, suppliedPreviewHash) {
|
|
64
|
+
return resultOf(async () => {
|
|
65
|
+
const initial = project(context, intent);
|
|
66
|
+
assertCommittable(initial.preview, suppliedPreviewHash);
|
|
67
|
+
const actionId = newUuidV7();
|
|
68
|
+
const artifactIds = unique(initial.sequence.clips.map((clip) => clip.source.artifactId));
|
|
69
|
+
const mutation = await context.store.semantic({
|
|
70
|
+
operation: "commit_edit",
|
|
71
|
+
tables: [
|
|
72
|
+
"sequence_clips",
|
|
73
|
+
"clip_links",
|
|
74
|
+
"clip_transforms",
|
|
75
|
+
"transitions",
|
|
76
|
+
"caption_cues",
|
|
77
|
+
],
|
|
78
|
+
details: {
|
|
79
|
+
commandId: intent.commandId,
|
|
80
|
+
actionId,
|
|
81
|
+
sequenceId: intent.sequenceId,
|
|
82
|
+
intentVersion: intent.intentVersion,
|
|
83
|
+
sourceSurface: intent.sourceSurface,
|
|
84
|
+
confirmationPolicy: intent.confirmationPolicy,
|
|
85
|
+
actor: intent.actor,
|
|
86
|
+
operations: initial.preview.operations,
|
|
87
|
+
operationCount: initial.preview.operations.length,
|
|
88
|
+
affectedRanges: initial.preview.affectedRanges,
|
|
89
|
+
warnings: initial.preview.warnings,
|
|
90
|
+
previewHash: initial.preview.previewHash,
|
|
91
|
+
beforeHash: initial.preview.beforeHash,
|
|
92
|
+
afterHash: initial.preview.afterHash,
|
|
93
|
+
},
|
|
94
|
+
baseRevision: intent.baseRevision,
|
|
95
|
+
writeSet: initial.preview.writeSet,
|
|
96
|
+
author: intent.actor,
|
|
97
|
+
}, (_operationId, now) => {
|
|
98
|
+
const verified = project(context, intent);
|
|
99
|
+
assertCommittable(verified.preview, suppliedPreviewHash);
|
|
100
|
+
persistSequenceProjection(context, verified.sequence);
|
|
101
|
+
invalidateEditedArtifacts(context, artifactIds, now);
|
|
102
|
+
});
|
|
103
|
+
const sequence = sequenceValue(createSequencesApi(context).getAtRevision(intent.sequenceId, mutation.revision));
|
|
104
|
+
return ok({
|
|
105
|
+
commandId: intent.commandId,
|
|
106
|
+
actionId,
|
|
107
|
+
revision: mutation.revision,
|
|
108
|
+
sequence,
|
|
109
|
+
previewHash: initial.preview.previewHash,
|
|
110
|
+
}, mutation.revision);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async function restoreEdit(context, request) {
|
|
114
|
+
return resultOf(async () => {
|
|
115
|
+
requiredText(request.actor, "Restore actor");
|
|
116
|
+
const target = restoreTarget(context, request);
|
|
117
|
+
const source = sequenceValue(createSequencesApi(context).getAtRevision(target.sequenceId, target.revision));
|
|
118
|
+
const current = sequenceValue(createSequencesApi(context).get(target.sequenceId));
|
|
119
|
+
if (request.baseRevision !== context.store.head) {
|
|
120
|
+
throw new EngineFault({
|
|
121
|
+
code: "STALE_REVISION",
|
|
122
|
+
message: "Restore base revision is stale",
|
|
123
|
+
details: {
|
|
124
|
+
baseRevision: request.baseRevision,
|
|
125
|
+
currentRevision: context.store.head,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
validateSequenceProjection(context, source);
|
|
130
|
+
const actionId = newUuidV7();
|
|
131
|
+
const writeSet = restoreWriteSet(current, source);
|
|
132
|
+
const artifactIds = unique(source.clips.map((clip) => clip.source.artifactId));
|
|
133
|
+
const mutation = await context.store.semantic({
|
|
134
|
+
operation: "restore_edit",
|
|
135
|
+
tables: [
|
|
136
|
+
"sequence_clips",
|
|
137
|
+
"clip_links",
|
|
138
|
+
"clip_transforms",
|
|
139
|
+
"transitions",
|
|
140
|
+
"caption_cues",
|
|
141
|
+
],
|
|
142
|
+
details: {
|
|
143
|
+
actionId,
|
|
144
|
+
sequenceId: target.sequenceId,
|
|
145
|
+
restoredFromRevision: target.revision,
|
|
146
|
+
...(request.targetActionId
|
|
147
|
+
? { restoredFromActionId: request.targetActionId }
|
|
148
|
+
: {}),
|
|
149
|
+
actor: request.actor,
|
|
150
|
+
sourceSurface: request.sourceSurface,
|
|
151
|
+
},
|
|
152
|
+
baseRevision: request.baseRevision,
|
|
153
|
+
writeSet,
|
|
154
|
+
author: request.actor,
|
|
155
|
+
}, (_operationId, now) => {
|
|
156
|
+
if (context.store.head !== request.baseRevision) {
|
|
157
|
+
throw new EngineFault({
|
|
158
|
+
code: "STALE_REVISION",
|
|
159
|
+
message: "Restore base revision changed before commit",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
persistSequenceProjection(context, source);
|
|
163
|
+
invalidateEditedArtifacts(context, artifactIds, now);
|
|
164
|
+
});
|
|
165
|
+
return ok({
|
|
166
|
+
actionId,
|
|
167
|
+
...(request.targetActionId
|
|
168
|
+
? { restoredFromActionId: request.targetActionId }
|
|
169
|
+
: {}),
|
|
170
|
+
restoredFromRevision: target.revision,
|
|
171
|
+
revision: mutation.revision,
|
|
172
|
+
sequence: sequenceValue(createSequencesApi(context).getAtRevision(target.sequenceId, mutation.revision)),
|
|
173
|
+
}, mutation.revision);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function validateIntent(intent) {
|
|
177
|
+
if (intent.intentVersion !== 1) {
|
|
178
|
+
throw new EngineFault({
|
|
179
|
+
code: "SCHEMA_INCOMPATIBLE",
|
|
180
|
+
message: `Unsupported edit intent version: ${intent.intentVersion}`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
requiredText(intent.commandId, "Edit command ID");
|
|
184
|
+
requiredText(intent.sequenceId, "Edit sequence ID");
|
|
185
|
+
requiredText(intent.baseRevision, "Edit base revision");
|
|
186
|
+
requiredText(intent.actor, "Edit actor");
|
|
187
|
+
if (!["ui", "slash", "chat", "system"].includes(intent.sourceSurface)) {
|
|
188
|
+
throw new Error(`Invalid source surface: ${intent.sourceSurface}`);
|
|
189
|
+
}
|
|
190
|
+
if (!["always", "risk-based", "reversible-single-step"].includes(intent.confirmationPolicy)) {
|
|
191
|
+
throw new Error(`Invalid confirmation policy: ${intent.confirmationPolicy}`);
|
|
192
|
+
}
|
|
193
|
+
if (intent.operations.length === 0) {
|
|
194
|
+
throw new Error("Edit intent must contain at least one operation");
|
|
195
|
+
}
|
|
196
|
+
if (intent.operations.length > 1_000) {
|
|
197
|
+
throw new EngineFault({
|
|
198
|
+
code: "RESOURCE_EXHAUSTED",
|
|
199
|
+
message: "Edit intent exceeds the 1,000-operation limit",
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function normalizeOperation(commandId, ordinal, input) {
|
|
204
|
+
const operationId = deterministicUuid(commandId, `operation:${ordinal}`);
|
|
205
|
+
let operation;
|
|
206
|
+
if (input.kind === "insert-clip") {
|
|
207
|
+
operation = {
|
|
208
|
+
...input,
|
|
209
|
+
clipId: input.clipId ?? deterministicUuid(commandId, `clip:${ordinal}`),
|
|
210
|
+
placement: normalizePlacement(input.placement),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
else if (input.kind === "trim-clip") {
|
|
214
|
+
operation = {
|
|
215
|
+
...input,
|
|
216
|
+
...(input.sourceRange
|
|
217
|
+
? { sourceRange: normalizeSourceRange(input.sourceRange) }
|
|
218
|
+
: {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
else if (input.kind === "split-clip") {
|
|
222
|
+
operation = {
|
|
223
|
+
...input,
|
|
224
|
+
leftClipId: input.leftClipId ??
|
|
225
|
+
deterministicUuid(commandId, `split-left:${ordinal}`),
|
|
226
|
+
rightClipId: input.rightClipId ??
|
|
227
|
+
deterministicUuid(commandId, `split-right:${ordinal}`),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
else if (input.kind === "restore-clip") {
|
|
231
|
+
operation = { ...input, placement: normalizePlacement(input.placement) };
|
|
232
|
+
}
|
|
233
|
+
else if (input.kind === "set-clip-speed") {
|
|
234
|
+
operation = { ...input, speed: normalizeRational(input.speed) };
|
|
235
|
+
}
|
|
236
|
+
else if (input.kind === "remove-range") {
|
|
237
|
+
operation = { ...input, range: normalizeSequenceRange(input.range) };
|
|
238
|
+
}
|
|
239
|
+
else if (input.kind === "batch-replace-range") {
|
|
240
|
+
operation = {
|
|
241
|
+
...input,
|
|
242
|
+
range: normalizeSequenceRange(input.range),
|
|
243
|
+
placements: input.placements.map(normalizePlacement),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
operation = structuredClone(input);
|
|
248
|
+
}
|
|
249
|
+
return { operationId, ordinal, operation };
|
|
250
|
+
}
|
|
251
|
+
function normalizePlacement(placement) {
|
|
252
|
+
safeIntegerAtLeast(placement.timelineStartFrame, 0, "Clip timeline start");
|
|
253
|
+
safeIntegerAtLeast(placement.durationFrames, 1, "Clip duration");
|
|
254
|
+
return {
|
|
255
|
+
...structuredClone(placement),
|
|
256
|
+
source: placement.source.kind === "timed"
|
|
257
|
+
? {
|
|
258
|
+
...placement.source,
|
|
259
|
+
range: normalizeSourceRange(placement.source.range),
|
|
260
|
+
}
|
|
261
|
+
: { ...placement.source },
|
|
262
|
+
...(placement.speed ? { speed: normalizeRational(placement.speed) } : {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function applyOperation(context, sequence, normalized, warnings) {
|
|
266
|
+
const operation = normalized.operation;
|
|
267
|
+
switch (operation.kind) {
|
|
268
|
+
case "insert-clip":
|
|
269
|
+
applyInsert(context, sequence, operation, normalized.operationId);
|
|
270
|
+
return;
|
|
271
|
+
case "remove-range":
|
|
272
|
+
applyRemoveRange(sequence, operation.trackIds, operation.range, operation.ripple);
|
|
273
|
+
return;
|
|
274
|
+
case "move-clip":
|
|
275
|
+
applyMove(sequence, operation);
|
|
276
|
+
return;
|
|
277
|
+
case "trim-clip":
|
|
278
|
+
applyTrim(context, sequence, operation);
|
|
279
|
+
return;
|
|
280
|
+
case "split-clip":
|
|
281
|
+
applySplit(sequence, operation, warnings, normalized.operationId);
|
|
282
|
+
return;
|
|
283
|
+
case "restore-clip":
|
|
284
|
+
applyRestore(context, sequence, operation, normalized.operationId);
|
|
285
|
+
return;
|
|
286
|
+
case "set-clip-transform":
|
|
287
|
+
requiredClip(sequence, operation.clipId).transform = validateTransform(operation.transform);
|
|
288
|
+
return;
|
|
289
|
+
case "set-clip-audio":
|
|
290
|
+
applyClipAudio(sequence, operation.clipId, operation.audio);
|
|
291
|
+
return;
|
|
292
|
+
case "set-clip-speed":
|
|
293
|
+
applySpeed(sequence, operation, warnings, normalized.operationId);
|
|
294
|
+
return;
|
|
295
|
+
case "set-transition":
|
|
296
|
+
applyTransition(sequence, operation);
|
|
297
|
+
return;
|
|
298
|
+
case "upsert-caption-cue":
|
|
299
|
+
applyCaption(context, sequence, operation.cue);
|
|
300
|
+
return;
|
|
301
|
+
case "batch-replace-range":
|
|
302
|
+
applyBatchReplace(context, sequence, operation, normalized.operationId);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function applyInsert(context, sequence, operation, operationId) {
|
|
307
|
+
const clipId = operation.clipId ?? deterministicUuid(operationId, "clip");
|
|
308
|
+
if (sequence.clips.some((clip) => clip.clipId === clipId)) {
|
|
309
|
+
throw new EngineFault({
|
|
310
|
+
code: "ALREADY_EXISTS",
|
|
311
|
+
message: `Clip already exists: ${clipId}`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const track = mutableTrack(sequence, operation.placement.trackId);
|
|
315
|
+
validatePlacement(context, sequence, track, operation.placement);
|
|
316
|
+
const start = operation.placement.timelineStartFrame;
|
|
317
|
+
const end = start + operation.placement.durationFrames;
|
|
318
|
+
const overlaps = clipsOnTrack(sequence, track.trackId).filter((clip) => rangesOverlap(start, end, clipStart(clip), clipEnd(clip)));
|
|
319
|
+
if (operation.mode === "overwrite") {
|
|
320
|
+
for (const overlap of overlaps)
|
|
321
|
+
removeClip(sequence, overlap.clipId);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
if (overlaps.some((clip) => clipStart(clip) < start)) {
|
|
325
|
+
throw new EngineFault({
|
|
326
|
+
code: "INVALID_RANGE",
|
|
327
|
+
message: "Insert point intersects an existing clip",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
for (const clip of clipsOnTrack(sequence, track.trackId)) {
|
|
331
|
+
if (clip.timelineStartFrame >= start) {
|
|
332
|
+
clip.timelineStartFrame += operation.placement.durationFrames;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
sequence.clips.push(clipFromPlacement(clipId, operation.placement));
|
|
337
|
+
}
|
|
338
|
+
function applyRemoveRange(sequence, trackIds, range, ripple) {
|
|
339
|
+
assertSequenceRange(sequence, range);
|
|
340
|
+
const uniqueTracks = unique(trackIds);
|
|
341
|
+
for (const trackId of uniqueTracks) {
|
|
342
|
+
mutableTrack(sequence, trackId);
|
|
343
|
+
removeRangeFromTrack(sequence, trackId, range, ripple);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function removeRangeFromTrack(sequence, trackId, range, ripple) {
|
|
347
|
+
const start = range.startFrame;
|
|
348
|
+
const end = start + range.durationFrames;
|
|
349
|
+
const original = clipsOnTrack(sequence, trackId);
|
|
350
|
+
for (const clip of original) {
|
|
351
|
+
const left = clipStart(clip);
|
|
352
|
+
const right = clipEnd(clip);
|
|
353
|
+
if (!rangesOverlap(start, end, left, right))
|
|
354
|
+
continue;
|
|
355
|
+
removeClip(sequence, clip.clipId);
|
|
356
|
+
if (left < start) {
|
|
357
|
+
sequence.clips.push(sliceClip(clip, left, start, `${clip.clipId}:left`));
|
|
358
|
+
}
|
|
359
|
+
if (right > end) {
|
|
360
|
+
const rightClip = sliceClip(clip, end, right, `${clip.clipId}:right`);
|
|
361
|
+
if (ripple)
|
|
362
|
+
rightClip.timelineStartFrame -= range.durationFrames;
|
|
363
|
+
sequence.clips.push(rightClip);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (ripple) {
|
|
367
|
+
for (const clip of clipsOnTrack(sequence, trackId)) {
|
|
368
|
+
if (clip.timelineStartFrame >= end) {
|
|
369
|
+
clip.timelineStartFrame -= range.durationFrames;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const caption of sequence.captions) {
|
|
373
|
+
if (caption.trackId === trackId && caption.timelineStartFrame >= end) {
|
|
374
|
+
caption.timelineStartFrame -= range.durationFrames;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function applyMove(sequence, operation) {
|
|
380
|
+
const clip = requiredClip(sequence, operation.clipId);
|
|
381
|
+
const sourceTrack = mutableTrack(sequence, clip.trackId);
|
|
382
|
+
const targetTrack = mutableTrack(sequence, operation.trackId);
|
|
383
|
+
if (sourceTrack.kind !== targetTrack.kind) {
|
|
384
|
+
throw new EngineFault({
|
|
385
|
+
code: "INVALID_INPUT",
|
|
386
|
+
message: "A clip cannot move between incompatible track kinds",
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
safeIntegerAtLeast(operation.timelineStartFrame, 0, "Clip timeline start");
|
|
390
|
+
clip.trackId = targetTrack.trackId;
|
|
391
|
+
clip.timelineStartFrame = operation.timelineStartFrame;
|
|
392
|
+
assertNoForbiddenOverlap(sequence, clip);
|
|
393
|
+
}
|
|
394
|
+
function applyTrim(context, sequence, operation) {
|
|
395
|
+
const clip = requiredClip(sequence, operation.clipId);
|
|
396
|
+
mutableTrack(sequence, clip.trackId);
|
|
397
|
+
safeIntegerAtLeast(operation.timelineStartFrame, 0, "Trim timeline start");
|
|
398
|
+
safeIntegerAtLeast(operation.durationFrames, 1, "Trim duration");
|
|
399
|
+
clip.timelineStartFrame = operation.timelineStartFrame;
|
|
400
|
+
clip.durationFrames = operation.durationFrames;
|
|
401
|
+
if (operation.sourceRange) {
|
|
402
|
+
if (clip.source.kind !== "timed") {
|
|
403
|
+
throw new EngineFault({
|
|
404
|
+
code: "INVALID_INPUT",
|
|
405
|
+
message: "Still clips cannot accept a timed source range",
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
validateTimedSource(context, clip.source.artifactId, operation.sourceRange);
|
|
409
|
+
clip.source.range = operation.sourceRange;
|
|
410
|
+
}
|
|
411
|
+
else if (clip.source.kind === "timed") {
|
|
412
|
+
clip.source.range = resizeTimedRange(clip.source.range, operation.durationFrames);
|
|
413
|
+
}
|
|
414
|
+
assertNoForbiddenOverlap(sequence, clip);
|
|
415
|
+
}
|
|
416
|
+
function applySplit(sequence, operation, warnings, operationId) {
|
|
417
|
+
const clip = requiredClip(sequence, operation.clipId);
|
|
418
|
+
mutableTrack(sequence, clip.trackId);
|
|
419
|
+
if (operation.splitFrame <= clip.timelineStartFrame ||
|
|
420
|
+
operation.splitFrame >= clipEnd(clip)) {
|
|
421
|
+
throw new EngineFault({
|
|
422
|
+
code: "INVALID_RANGE",
|
|
423
|
+
message: "Split frame must be inside the clip",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const leftId = operation.leftClipId ?? deterministicUuid(operationId, "left");
|
|
427
|
+
const rightId = operation.rightClipId ?? deterministicUuid(operationId, "right");
|
|
428
|
+
if (leftId === rightId ||
|
|
429
|
+
sequence.clips.some((item) => item.clipId !== clip.clipId &&
|
|
430
|
+
(item.clipId === leftId || item.clipId === rightId))) {
|
|
431
|
+
throw new EngineFault({
|
|
432
|
+
code: "ALREADY_EXISTS",
|
|
433
|
+
message: "Split clip IDs must be unique",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const leftFrames = operation.splitFrame - clip.timelineStartFrame;
|
|
437
|
+
const rightFrames = clip.durationFrames - leftFrames;
|
|
438
|
+
const left = {
|
|
439
|
+
...structuredClone(clip),
|
|
440
|
+
clipId: leftId,
|
|
441
|
+
durationFrames: leftFrames,
|
|
442
|
+
};
|
|
443
|
+
const right = {
|
|
444
|
+
...structuredClone(clip),
|
|
445
|
+
clipId: rightId,
|
|
446
|
+
timelineStartFrame: operation.splitFrame,
|
|
447
|
+
durationFrames: rightFrames,
|
|
448
|
+
};
|
|
449
|
+
if (clip.source.kind === "timed") {
|
|
450
|
+
const exactTicks = (clip.source.range.durationTicks * leftFrames) / clip.durationFrames;
|
|
451
|
+
const leftTicks = Math.floor(exactTicks);
|
|
452
|
+
// On a source whose tick resolution is coarser than the sequence frame
|
|
453
|
+
// rate (for example a 1/25 time base in a 30 fps sequence), a split
|
|
454
|
+
// near the clip edge can floor to an empty slice. Refuse with a typed
|
|
455
|
+
// error instead of failing later in projection validation.
|
|
456
|
+
if (leftTicks === 0 || clip.source.range.durationTicks - leftTicks === 0) {
|
|
457
|
+
throw new EngineFault({
|
|
458
|
+
code: "INVALID_RANGE",
|
|
459
|
+
message: "Split frame produces an empty source slice at this clip's tick " +
|
|
460
|
+
"resolution; move the split point further from the clip edge",
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (!Number.isInteger(exactTicks)) {
|
|
464
|
+
warnings.push({
|
|
465
|
+
code: "ROUNDING_APPLIED",
|
|
466
|
+
message: "Split source tick was rounded while preserving total coverage",
|
|
467
|
+
operationId,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
left.source = {
|
|
471
|
+
...clip.source,
|
|
472
|
+
range: { ...clip.source.range, durationTicks: leftTicks },
|
|
473
|
+
};
|
|
474
|
+
right.source = {
|
|
475
|
+
...clip.source,
|
|
476
|
+
range: {
|
|
477
|
+
...clip.source.range,
|
|
478
|
+
startTick: clip.source.range.startTick + leftTicks,
|
|
479
|
+
durationTicks: clip.source.range.durationTicks - leftTicks,
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
removeClip(sequence, clip.clipId);
|
|
484
|
+
sequence.clips.push(left, right);
|
|
485
|
+
}
|
|
486
|
+
function applyRestore(context, sequence, operation, operationId) {
|
|
487
|
+
if (!findEditBatchCommit(context, operation.sourceActionId)) {
|
|
488
|
+
throw new EngineFault({
|
|
489
|
+
code: "NOT_FOUND",
|
|
490
|
+
message: `Source edit action not found: ${operation.sourceActionId}`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
applyInsert(context, sequence, {
|
|
494
|
+
kind: "insert-clip",
|
|
495
|
+
clipId: deterministicUuid(operationId, operation.sourceClipId),
|
|
496
|
+
placement: operation.placement,
|
|
497
|
+
mode: "overwrite",
|
|
498
|
+
}, operationId);
|
|
499
|
+
}
|
|
500
|
+
function applyClipAudio(sequence, clipId, audio) {
|
|
501
|
+
const clip = requiredClip(sequence, clipId);
|
|
502
|
+
const track = mutableTrack(sequence, clip.trackId);
|
|
503
|
+
if (track.kind === "caption") {
|
|
504
|
+
throw new EngineFault({
|
|
505
|
+
code: "INVALID_INPUT",
|
|
506
|
+
message: "Caption clips cannot have audio settings",
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
if (!Number.isFinite(audio.gainDb)) {
|
|
510
|
+
throw new Error("Clip gainDb must be finite");
|
|
511
|
+
}
|
|
512
|
+
safeIntegerAtLeast(audio.fadeInFrames, 0, "Clip fade-in");
|
|
513
|
+
safeIntegerAtLeast(audio.fadeOutFrames, 0, "Clip fade-out");
|
|
514
|
+
if (audio.fadeInFrames + audio.fadeOutFrames > clip.durationFrames) {
|
|
515
|
+
throw new EngineFault({
|
|
516
|
+
code: "INVALID_RANGE",
|
|
517
|
+
message: "Clip fades exceed clip duration",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
clip.audio = { ...audio };
|
|
521
|
+
}
|
|
522
|
+
function applySpeed(sequence, operation, warnings, operationId) {
|
|
523
|
+
const clip = requiredClip(sequence, operation.clipId);
|
|
524
|
+
mutableTrack(sequence, clip.trackId);
|
|
525
|
+
if (clip.source.kind !== "timed") {
|
|
526
|
+
throw new EngineFault({
|
|
527
|
+
code: "INVALID_INPUT",
|
|
528
|
+
message: "Speed and reverse require a timed clip source",
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
const timed = clip;
|
|
532
|
+
timed.speed = normalizeRational(operation.speed);
|
|
533
|
+
timed.reverse = operation.reverse;
|
|
534
|
+
timed.audioPolicy = operation.audioPolicy;
|
|
535
|
+
if (operation.audioPolicy === "preserve-pitch" &&
|
|
536
|
+
!rationalEquals(timed.speed, { numerator: 1, denominator: 1 })) {
|
|
537
|
+
warnings.push({
|
|
538
|
+
code: "AUDIO_PITCH_CHANGE",
|
|
539
|
+
message: "Speed adjustment requires pitch-preserving audio processing",
|
|
540
|
+
operationId,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function applyTransition(sequence, operation) {
|
|
545
|
+
const outgoing = requiredClip(sequence, operation.outgoingClipId);
|
|
546
|
+
const incoming = requiredClip(sequence, operation.incomingClipId);
|
|
547
|
+
const track = mutableTrack(sequence, outgoing.trackId);
|
|
548
|
+
if (track.kind !== "video" || incoming.trackId !== track.trackId) {
|
|
549
|
+
throw new EngineFault({
|
|
550
|
+
code: "INVALID_INPUT",
|
|
551
|
+
message: "Transitions require two clips on the same video track",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
sequence.transitions = sequence.transitions.filter((transition) => transition.outgoingClipId !== outgoing.clipId ||
|
|
555
|
+
transition.incomingClipId !== incoming.clipId);
|
|
556
|
+
if (operation.transition === null)
|
|
557
|
+
return;
|
|
558
|
+
const transition = structuredClone(operation.transition);
|
|
559
|
+
if (transition.trackId !== track.trackId ||
|
|
560
|
+
transition.outgoingClipId !== outgoing.clipId ||
|
|
561
|
+
transition.incomingClipId !== incoming.clipId) {
|
|
562
|
+
throw new EngineFault({
|
|
563
|
+
code: "INVALID_INPUT",
|
|
564
|
+
message: "Transition identity does not match its clip pair",
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
safeIntegerAtLeast(transition.durationFrames, 1, "Transition duration");
|
|
568
|
+
if (transition.durationFrames > outgoing.durationFrames ||
|
|
569
|
+
transition.durationFrames > incoming.durationFrames ||
|
|
570
|
+
clipEnd(outgoing) !== clipStart(incoming)) {
|
|
571
|
+
throw new EngineFault({
|
|
572
|
+
code: "INVALID_RANGE",
|
|
573
|
+
message: "Transition clips must be adjacent with sufficient handles",
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
sequence.transitions.push(transition);
|
|
577
|
+
}
|
|
578
|
+
function applyCaption(context, sequence, cue) {
|
|
579
|
+
const track = mutableTrack(sequence, cue.trackId);
|
|
580
|
+
if (track.kind !== "caption") {
|
|
581
|
+
throw new EngineFault({
|
|
582
|
+
code: "INVALID_INPUT",
|
|
583
|
+
message: "Caption cues require a caption track",
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
safeIntegerAtLeast(cue.timelineStartFrame, 0, "Caption start");
|
|
587
|
+
safeIntegerAtLeast(cue.durationFrames, 1, "Caption duration");
|
|
588
|
+
requiredText(cue.text, "Caption text");
|
|
589
|
+
requiredText(cue.styleId, "Caption style ID");
|
|
590
|
+
if (cue.transcriptSelection) {
|
|
591
|
+
const transcript = context.store.db
|
|
592
|
+
.prepare("SELECT 1 AS present FROM transcripts WHERE transcript_id=?")
|
|
593
|
+
.get(cue.transcriptSelection.transcriptId);
|
|
594
|
+
if (!transcript) {
|
|
595
|
+
throw new EngineFault({
|
|
596
|
+
code: "NOT_FOUND",
|
|
597
|
+
message: `Caption transcript not found: ${cue.transcriptSelection.transcriptId}`,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
normalizeSourceRange(cue.transcriptSelection.range);
|
|
601
|
+
}
|
|
602
|
+
sequence.captions = sequence.captions.filter((item) => item.cueId !== cue.cueId);
|
|
603
|
+
sequence.captions.push(structuredClone(cue));
|
|
604
|
+
}
|
|
605
|
+
function applyBatchReplace(context, sequence, operation, operationId) {
|
|
606
|
+
applyRemoveRange(sequence, operation.trackIds, operation.range, operation.ripple);
|
|
607
|
+
operation.placements.forEach((placement, index) => {
|
|
608
|
+
applyInsert(context, sequence, {
|
|
609
|
+
kind: "insert-clip",
|
|
610
|
+
clipId: deterministicUuid(operationId, `placement:${index}`),
|
|
611
|
+
placement,
|
|
612
|
+
mode: "overwrite",
|
|
613
|
+
}, operationId);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function validatePlacement(context, sequence, track, placement) {
|
|
617
|
+
if (track.kind === "caption") {
|
|
618
|
+
throw new EngineFault({
|
|
619
|
+
code: "INVALID_INPUT",
|
|
620
|
+
message: "Media clips cannot be placed on caption tracks",
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
safeIntegerAtLeast(placement.timelineStartFrame, 0, "Clip timeline start");
|
|
624
|
+
safeIntegerAtLeast(placement.durationFrames, 1, "Clip duration");
|
|
625
|
+
context.artifactRowById(placement.source.artifactId);
|
|
626
|
+
if (placement.source.kind === "still") {
|
|
627
|
+
if (track.kind !== "video") {
|
|
628
|
+
throw new EngineFault({
|
|
629
|
+
code: "INVALID_INPUT",
|
|
630
|
+
message: "Still sources require a video track",
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
requiredText(placement.source.sourcePath, "Still source path");
|
|
634
|
+
assertObject(context, placement.source.objectHash);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
const stream = validateTimedSource(context, placement.source.artifactId, placement.source.range);
|
|
638
|
+
if (stream.kind !== track.kind) {
|
|
639
|
+
throw new EngineFault({
|
|
640
|
+
code: "INVALID_INPUT",
|
|
641
|
+
message: `${stream.kind} source cannot be placed on ${track.kind} track`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
normalizeRational(placement.speed ?? { numerator: 1, denominator: 1 });
|
|
645
|
+
if (!placement.audioPolicy) {
|
|
646
|
+
throw new EngineFault({
|
|
647
|
+
code: "INVALID_INPUT",
|
|
648
|
+
message: "Timed clip placement requires an audio policy",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (placement.transform)
|
|
653
|
+
validateTransform(placement.transform);
|
|
654
|
+
if (placement.audio) {
|
|
655
|
+
if (!Number.isFinite(placement.audio.gainDb)) {
|
|
656
|
+
throw new Error("Clip gainDb must be finite");
|
|
657
|
+
}
|
|
658
|
+
safeIntegerAtLeast(placement.audio.fadeInFrames, 0, "Clip fade-in");
|
|
659
|
+
safeIntegerAtLeast(placement.audio.fadeOutFrames, 0, "Clip fade-out");
|
|
660
|
+
}
|
|
661
|
+
const end = placement.timelineStartFrame + placement.durationFrames;
|
|
662
|
+
if (!Number.isSafeInteger(end)) {
|
|
663
|
+
throw new EngineFault({
|
|
664
|
+
code: "INVALID_RANGE",
|
|
665
|
+
message: "Clip timeline range exceeds safe integer precision",
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
if (sequence.sequenceId !== track.sequenceId) {
|
|
669
|
+
throw new EngineFault({
|
|
670
|
+
code: "INVALID_INPUT",
|
|
671
|
+
message: "Target track belongs to another sequence",
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function validateTimedSource(context, artifactId, rangeInput) {
|
|
676
|
+
const range = normalizeSourceRange(rangeInput);
|
|
677
|
+
const row = context.store.db
|
|
678
|
+
.prepare(`SELECT stream_id, artifact_id, object_hash, kind,
|
|
679
|
+
time_base_numerator, time_base_denominator, duration_ticks
|
|
680
|
+
FROM artifact_streams WHERE stream_id=?`)
|
|
681
|
+
.get(range.streamId);
|
|
682
|
+
if (!row) {
|
|
683
|
+
throw new EngineFault({
|
|
684
|
+
code: "NOT_FOUND",
|
|
685
|
+
message: `Source stream not found: ${range.streamId}`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
if (row.artifact_id !== artifactId ||
|
|
689
|
+
row.object_hash !== range.objectHash ||
|
|
690
|
+
!rationalEquals(range.timeBase, {
|
|
691
|
+
numerator: row.time_base_numerator,
|
|
692
|
+
denominator: row.time_base_denominator,
|
|
693
|
+
})) {
|
|
694
|
+
throw new EngineFault({
|
|
695
|
+
code: "SOURCE_REPLACED",
|
|
696
|
+
message: "Timed source identity does not match its immutable stream",
|
|
697
|
+
details: { streamId: range.streamId, objectHash: range.objectHash },
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
if (sourceRangeEndTick(range) > row.duration_ticks) {
|
|
701
|
+
throw new EngineFault({
|
|
702
|
+
code: "INVALID_RANGE",
|
|
703
|
+
message: "Timed source range exceeds stream duration",
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
assertObject(context, range.objectHash);
|
|
707
|
+
return row;
|
|
708
|
+
}
|
|
709
|
+
function validateTransform(transform) {
|
|
710
|
+
const finite = [
|
|
711
|
+
transform.positionX,
|
|
712
|
+
transform.positionY,
|
|
713
|
+
transform.scaleX,
|
|
714
|
+
transform.scaleY,
|
|
715
|
+
transform.anchorX,
|
|
716
|
+
transform.anchorY,
|
|
717
|
+
transform.rotationDegrees,
|
|
718
|
+
transform.cropTop,
|
|
719
|
+
transform.cropRight,
|
|
720
|
+
transform.cropBottom,
|
|
721
|
+
transform.cropLeft,
|
|
722
|
+
transform.opacity,
|
|
723
|
+
];
|
|
724
|
+
if (!finite.every(Number.isFinite)) {
|
|
725
|
+
throw new Error("Clip transform values must be finite");
|
|
726
|
+
}
|
|
727
|
+
if (transform.scaleX <= 0 || transform.scaleY <= 0) {
|
|
728
|
+
throw new Error("Clip transform scale must be positive");
|
|
729
|
+
}
|
|
730
|
+
for (const crop of [
|
|
731
|
+
transform.cropTop,
|
|
732
|
+
transform.cropRight,
|
|
733
|
+
transform.cropBottom,
|
|
734
|
+
transform.cropLeft,
|
|
735
|
+
transform.opacity,
|
|
736
|
+
]) {
|
|
737
|
+
if (crop < 0 || crop > 1) {
|
|
738
|
+
throw new Error("Clip crop and opacity values must be between zero and one");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return structuredClone(transform);
|
|
742
|
+
}
|
|
743
|
+
function validateSequenceProjection(context, sequence) {
|
|
744
|
+
const clipIds = new Set();
|
|
745
|
+
for (const clip of sequence.clips) {
|
|
746
|
+
if (clipIds.has(clip.clipId)) {
|
|
747
|
+
throw new EngineFault({
|
|
748
|
+
code: "ALREADY_EXISTS",
|
|
749
|
+
message: `Duplicate clip ID: ${clip.clipId}`,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
clipIds.add(clip.clipId);
|
|
753
|
+
const track = requiredTrack(sequence, clip.trackId);
|
|
754
|
+
if (track.kind === "caption") {
|
|
755
|
+
throw new EngineFault({
|
|
756
|
+
code: "INVALID_INPUT",
|
|
757
|
+
message: "Media clip is assigned to a caption track",
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
validatePlacement(context, sequence, track, placementFromClip(clip));
|
|
761
|
+
assertNoForbiddenOverlap(sequence, clip);
|
|
762
|
+
}
|
|
763
|
+
for (const transition of sequence.transitions) {
|
|
764
|
+
const outgoing = requiredClip(sequence, transition.outgoingClipId);
|
|
765
|
+
const incoming = requiredClip(sequence, transition.incomingClipId);
|
|
766
|
+
if (outgoing.trackId !== transition.trackId ||
|
|
767
|
+
incoming.trackId !== transition.trackId) {
|
|
768
|
+
throw new EngineFault({
|
|
769
|
+
code: "INVALID_INPUT",
|
|
770
|
+
message: `Transition has invalid track identity: ${transition.transitionId}`,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
for (const caption of sequence.captions) {
|
|
775
|
+
const track = requiredTrack(sequence, caption.trackId);
|
|
776
|
+
if (track.kind !== "caption") {
|
|
777
|
+
throw new EngineFault({
|
|
778
|
+
code: "INVALID_INPUT",
|
|
779
|
+
message: `Caption has invalid track: ${caption.cueId}`,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function assertNoForbiddenOverlap(sequence, candidate) {
|
|
785
|
+
const track = requiredTrack(sequence, candidate.trackId);
|
|
786
|
+
if (track.kind === "audio")
|
|
787
|
+
return;
|
|
788
|
+
const overlap = clipsOnTrack(sequence, candidate.trackId).find((clip) => clip.clipId !== candidate.clipId &&
|
|
789
|
+
rangesOverlap(clipStart(candidate), clipEnd(candidate), clipStart(clip), clipEnd(clip)));
|
|
790
|
+
if (overlap) {
|
|
791
|
+
throw new EngineFault({
|
|
792
|
+
code: "INVALID_RANGE",
|
|
793
|
+
message: `Clip overlaps ${overlap.clipId} on a non-overlapping track`,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function clipFromPlacement(clipId, placement) {
|
|
798
|
+
const common = {
|
|
799
|
+
clipId,
|
|
800
|
+
trackId: placement.trackId,
|
|
801
|
+
timelineStartFrame: placement.timelineStartFrame,
|
|
802
|
+
durationFrames: placement.durationFrames,
|
|
803
|
+
enabled: true,
|
|
804
|
+
...(placement.transform
|
|
805
|
+
? { transform: structuredClone(placement.transform) }
|
|
806
|
+
: {}),
|
|
807
|
+
...(placement.audio ? { audio: structuredClone(placement.audio) } : {}),
|
|
808
|
+
};
|
|
809
|
+
if (placement.source.kind === "still") {
|
|
810
|
+
return { ...common, source: structuredClone(placement.source) };
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
...common,
|
|
814
|
+
source: structuredClone(placement.source),
|
|
815
|
+
speed: placement.speed ?? { numerator: 1, denominator: 1 },
|
|
816
|
+
reverse: placement.reverse ?? false,
|
|
817
|
+
audioPolicy: placement.audioPolicy ?? "preserve-pitch",
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
function placementFromClip(clip) {
|
|
821
|
+
const timed = clip.source.kind === "timed" ? clip : undefined;
|
|
822
|
+
return {
|
|
823
|
+
trackId: clip.trackId,
|
|
824
|
+
timelineStartFrame: clip.timelineStartFrame,
|
|
825
|
+
durationFrames: clip.durationFrames,
|
|
826
|
+
source: structuredClone(clip.source),
|
|
827
|
+
...(timed
|
|
828
|
+
? {
|
|
829
|
+
speed: timed.speed,
|
|
830
|
+
reverse: timed.reverse,
|
|
831
|
+
audioPolicy: timed.audioPolicy,
|
|
832
|
+
}
|
|
833
|
+
: {}),
|
|
834
|
+
...(clip.transform ? { transform: clip.transform } : {}),
|
|
835
|
+
...(clip.audio ? { audio: clip.audio } : {}),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
function sliceClip(clip, startFrame, endFrame, seed) {
|
|
839
|
+
const offset = startFrame - clip.timelineStartFrame;
|
|
840
|
+
const durationFrames = endFrame - startFrame;
|
|
841
|
+
const sliced = {
|
|
842
|
+
...structuredClone(clip),
|
|
843
|
+
clipId: deterministicUuid(clip.clipId, seed),
|
|
844
|
+
timelineStartFrame: startFrame,
|
|
845
|
+
durationFrames,
|
|
846
|
+
};
|
|
847
|
+
if (clip.source.kind === "timed") {
|
|
848
|
+
const startTicks = Math.floor((clip.source.range.durationTicks * offset) / clip.durationFrames);
|
|
849
|
+
const endTicks = Math.floor((clip.source.range.durationTicks * (offset + durationFrames)) /
|
|
850
|
+
clip.durationFrames);
|
|
851
|
+
sliced.source = {
|
|
852
|
+
...clip.source,
|
|
853
|
+
range: {
|
|
854
|
+
...clip.source.range,
|
|
855
|
+
startTick: clip.source.range.startTick + startTicks,
|
|
856
|
+
durationTicks: endTicks - startTicks,
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
return sliced;
|
|
861
|
+
}
|
|
862
|
+
function resizeTimedRange(range, durationFrames) {
|
|
863
|
+
safeIntegerAtLeast(durationFrames, 1, "Trim duration");
|
|
864
|
+
return {
|
|
865
|
+
...range,
|
|
866
|
+
durationTicks: Math.min(range.durationTicks, durationFrames),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function removeClip(sequence, clipId) {
|
|
870
|
+
sequence.clips = sequence.clips.filter((clip) => clip.clipId !== clipId);
|
|
871
|
+
sequence.transitions = sequence.transitions.filter((transition) => transition.outgoingClipId !== clipId &&
|
|
872
|
+
transition.incomingClipId !== clipId);
|
|
873
|
+
}
|
|
874
|
+
function requiredClip(sequence, clipId) {
|
|
875
|
+
const clip = sequence.clips.find((item) => item.clipId === clipId);
|
|
876
|
+
if (!clip) {
|
|
877
|
+
throw new EngineFault({
|
|
878
|
+
code: "NOT_FOUND",
|
|
879
|
+
message: `Clip not found: ${clipId}`,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
return clip;
|
|
883
|
+
}
|
|
884
|
+
function requiredTrack(sequence, trackId) {
|
|
885
|
+
const track = sequence.tracks.find((item) => item.trackId === trackId);
|
|
886
|
+
if (!track) {
|
|
887
|
+
throw new EngineFault({
|
|
888
|
+
code: "NOT_FOUND",
|
|
889
|
+
message: `Track not found: ${trackId}`,
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
return track;
|
|
893
|
+
}
|
|
894
|
+
function mutableTrack(sequence, trackId) {
|
|
895
|
+
const track = requiredTrack(sequence, trackId);
|
|
896
|
+
if (track.locked) {
|
|
897
|
+
throw new EngineFault({
|
|
898
|
+
code: "TRACK_LOCKED",
|
|
899
|
+
message: `Track is locked: ${trackId}`,
|
|
900
|
+
ownerId: trackId,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return track;
|
|
904
|
+
}
|
|
905
|
+
function clipsOnTrack(sequence, trackId) {
|
|
906
|
+
return sequence.clips.filter((clip) => clip.trackId === trackId);
|
|
907
|
+
}
|
|
908
|
+
function clipStart(clip) {
|
|
909
|
+
return clip.timelineStartFrame;
|
|
910
|
+
}
|
|
911
|
+
function clipEnd(clip) {
|
|
912
|
+
return clip.timelineStartFrame + clip.durationFrames;
|
|
913
|
+
}
|
|
914
|
+
function rangesOverlap(leftStart, leftEnd, rightStart, rightEnd) {
|
|
915
|
+
return leftStart < rightEnd && rightStart < leftEnd;
|
|
916
|
+
}
|
|
917
|
+
function assertSequenceRange(sequence, range) {
|
|
918
|
+
const normalized = normalizeSequenceRange(range);
|
|
919
|
+
if (normalized.sequenceId !== sequence.sequenceId) {
|
|
920
|
+
throw new EngineFault({
|
|
921
|
+
code: "INVALID_RANGE",
|
|
922
|
+
message: "Sequence range belongs to another sequence",
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function assertObject(context, objectHash) {
|
|
927
|
+
const object = context.store.db
|
|
928
|
+
.prepare("SELECT 1 AS present FROM objects WHERE object_hash=?")
|
|
929
|
+
.get(objectHash);
|
|
930
|
+
if (!object) {
|
|
931
|
+
throw new EngineFault({
|
|
932
|
+
code: "OBJECT_UNAVAILABLE",
|
|
933
|
+
message: `Source object is unavailable: ${objectHash}`,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function persistSequenceProjection(context, sequence) {
|
|
938
|
+
const trackIds = sequence.tracks.map((track) => track.trackId);
|
|
939
|
+
if (trackIds.length === 0) {
|
|
940
|
+
throw new EngineFault({
|
|
941
|
+
code: "SCHEMA_INCOMPATIBLE",
|
|
942
|
+
message: "A sequence must have at least one track",
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
const placeholders = trackIds.map(() => "?").join(",");
|
|
946
|
+
const clipIds = sequence.clips.map((clip) => clip.clipId);
|
|
947
|
+
if (clipIds.length === 0) {
|
|
948
|
+
context.store.db
|
|
949
|
+
.prepare(`DELETE FROM sequence_clips WHERE track_id IN (${placeholders})`)
|
|
950
|
+
.run(...trackIds);
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
const clipPlaceholders = clipIds.map(() => "?").join(",");
|
|
954
|
+
context.store.db
|
|
955
|
+
.prepare(`DELETE FROM sequence_clips
|
|
956
|
+
WHERE track_id IN (${placeholders})
|
|
957
|
+
AND clip_id NOT IN (${clipPlaceholders})`)
|
|
958
|
+
.run(...trackIds, ...clipIds);
|
|
959
|
+
}
|
|
960
|
+
upsertClips(context, sequence.clips);
|
|
961
|
+
deleteMissingForTracks(context, "transitions", "transition_id", trackIds, sequence.transitions.map((transition) => transition.transitionId));
|
|
962
|
+
upsertTransitions(context, sequence.transitions);
|
|
963
|
+
deleteMissingForTracks(context, "caption_cues", "cue_id", trackIds, sequence.captions.map((cue) => cue.cueId));
|
|
964
|
+
upsertCaptions(context, sequence.captions);
|
|
965
|
+
}
|
|
966
|
+
function deleteMissingForTracks(context, table, idColumn, trackIds, ids) {
|
|
967
|
+
const trackPlaceholders = trackIds.map(() => "?").join(",");
|
|
968
|
+
if (ids.length === 0) {
|
|
969
|
+
context.store.db
|
|
970
|
+
.prepare(`DELETE FROM ${table} WHERE track_id IN (${trackPlaceholders})`)
|
|
971
|
+
.run(...trackIds);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const idPlaceholders = ids.map(() => "?").join(",");
|
|
975
|
+
context.store.db
|
|
976
|
+
.prepare(`DELETE FROM ${table}
|
|
977
|
+
WHERE track_id IN (${trackPlaceholders})
|
|
978
|
+
AND ${idColumn} NOT IN (${idPlaceholders})`)
|
|
979
|
+
.run(...trackIds, ...ids);
|
|
980
|
+
}
|
|
981
|
+
function upsertClips(context, clips) {
|
|
982
|
+
const upsertClip = context.store.db.prepare(`INSERT INTO sequence_clips(
|
|
983
|
+
clip_id, track_id, source_kind, artifact_id, source_path,
|
|
984
|
+
stream_id, object_hash, source_start_tick, source_duration_ticks,
|
|
985
|
+
time_base_numerator, time_base_denominator,
|
|
986
|
+
timeline_start_frame, duration_frames, speed_numerator,
|
|
987
|
+
speed_denominator, reverse, audio_policy, gain_db, audio_muted,
|
|
988
|
+
fade_in_frames, fade_out_frames, enabled
|
|
989
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
990
|
+
ON CONFLICT(clip_id) DO UPDATE SET
|
|
991
|
+
track_id=excluded.track_id,
|
|
992
|
+
source_kind=excluded.source_kind,
|
|
993
|
+
artifact_id=excluded.artifact_id,
|
|
994
|
+
source_path=excluded.source_path,
|
|
995
|
+
stream_id=excluded.stream_id,
|
|
996
|
+
object_hash=excluded.object_hash,
|
|
997
|
+
source_start_tick=excluded.source_start_tick,
|
|
998
|
+
source_duration_ticks=excluded.source_duration_ticks,
|
|
999
|
+
time_base_numerator=excluded.time_base_numerator,
|
|
1000
|
+
time_base_denominator=excluded.time_base_denominator,
|
|
1001
|
+
timeline_start_frame=excluded.timeline_start_frame,
|
|
1002
|
+
duration_frames=excluded.duration_frames,
|
|
1003
|
+
speed_numerator=excluded.speed_numerator,
|
|
1004
|
+
speed_denominator=excluded.speed_denominator,
|
|
1005
|
+
reverse=excluded.reverse,
|
|
1006
|
+
audio_policy=excluded.audio_policy,
|
|
1007
|
+
gain_db=excluded.gain_db,
|
|
1008
|
+
audio_muted=excluded.audio_muted,
|
|
1009
|
+
fade_in_frames=excluded.fade_in_frames,
|
|
1010
|
+
fade_out_frames=excluded.fade_out_frames,
|
|
1011
|
+
enabled=excluded.enabled`);
|
|
1012
|
+
const deleteLink = context.store.db.prepare("DELETE FROM clip_links WHERE clip_id=?");
|
|
1013
|
+
const insertLink = context.store.db.prepare(`INSERT INTO clip_links(link_group_id, clip_id, role)
|
|
1014
|
+
VALUES (?, ?, 'linked')`);
|
|
1015
|
+
const deleteTransform = context.store.db.prepare("DELETE FROM clip_transforms WHERE clip_id=?");
|
|
1016
|
+
const insertTransform = context.store.db.prepare(`INSERT INTO clip_transforms(
|
|
1017
|
+
clip_id, fit, position_x, position_y, scale_x, scale_y,
|
|
1018
|
+
anchor_x, anchor_y, rotation_degrees, crop_top, crop_right,
|
|
1019
|
+
crop_bottom, crop_left, opacity, blend_mode
|
|
1020
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
1021
|
+
for (const clip of clips) {
|
|
1022
|
+
const timed = clip.source.kind === "timed" ? clip : undefined;
|
|
1023
|
+
upsertClip.run(clip.clipId, clip.trackId, clip.source.kind, clip.source.artifactId, clip.source.kind === "still" ? clip.source.sourcePath : null, timed?.source.range.streamId ?? null, clip.source.kind === "still"
|
|
1024
|
+
? clip.source.objectHash
|
|
1025
|
+
: clip.source.range.objectHash, timed?.source.range.startTick ?? null, timed?.source.range.durationTicks ?? null, timed?.source.range.timeBase.numerator ?? null, timed?.source.range.timeBase.denominator ?? null, clip.timelineStartFrame, clip.durationFrames, timed?.speed.numerator ?? null, timed?.speed.denominator ?? null, timed ? (timed.reverse ? 1 : 0) : null, timed?.audioPolicy ?? null, clip.audio?.gainDb ?? null, clip.audio ? (clip.audio.muted ? 1 : 0) : null, clip.audio?.fadeInFrames ?? null, clip.audio?.fadeOutFrames ?? null, clip.enabled ? 1 : 0);
|
|
1026
|
+
deleteLink.run(clip.clipId);
|
|
1027
|
+
if (clip.linkGroupId)
|
|
1028
|
+
insertLink.run(clip.linkGroupId, clip.clipId);
|
|
1029
|
+
deleteTransform.run(clip.clipId);
|
|
1030
|
+
if (clip.transform) {
|
|
1031
|
+
const transform = clip.transform;
|
|
1032
|
+
insertTransform.run(clip.clipId, transform.fit, transform.positionX, transform.positionY, transform.scaleX, transform.scaleY, transform.anchorX, transform.anchorY, transform.rotationDegrees, transform.cropTop, transform.cropRight, transform.cropBottom, transform.cropLeft, transform.opacity, transform.blendMode);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
function upsertTransitions(context, transitions) {
|
|
1037
|
+
const upsert = context.store.db.prepare(`INSERT INTO transitions(
|
|
1038
|
+
transition_id, track_id, outgoing_clip_id, incoming_clip_id,
|
|
1039
|
+
kind, duration_frames, alignment, parameters_json
|
|
1040
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, '{}')
|
|
1041
|
+
ON CONFLICT(transition_id) DO UPDATE SET
|
|
1042
|
+
track_id=excluded.track_id,
|
|
1043
|
+
outgoing_clip_id=excluded.outgoing_clip_id,
|
|
1044
|
+
incoming_clip_id=excluded.incoming_clip_id,
|
|
1045
|
+
kind=excluded.kind,
|
|
1046
|
+
duration_frames=excluded.duration_frames,
|
|
1047
|
+
alignment=excluded.alignment`);
|
|
1048
|
+
for (const transition of transitions) {
|
|
1049
|
+
upsert.run(transition.transitionId, transition.trackId, transition.outgoingClipId, transition.incomingClipId, transition.kind, transition.durationFrames, transition.alignment);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function upsertCaptions(context, captions) {
|
|
1053
|
+
const upsert = context.store.db.prepare(`INSERT INTO caption_cues(
|
|
1054
|
+
cue_id, track_id, timeline_start_frame, duration_frames,
|
|
1055
|
+
text, speaker, style_id, transcript_id, transcript_revision,
|
|
1056
|
+
start_word_id, end_word_id, source_range_json
|
|
1057
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1058
|
+
ON CONFLICT(cue_id) DO UPDATE SET
|
|
1059
|
+
track_id=excluded.track_id,
|
|
1060
|
+
timeline_start_frame=excluded.timeline_start_frame,
|
|
1061
|
+
duration_frames=excluded.duration_frames,
|
|
1062
|
+
text=excluded.text,
|
|
1063
|
+
speaker=excluded.speaker,
|
|
1064
|
+
style_id=excluded.style_id,
|
|
1065
|
+
transcript_id=excluded.transcript_id,
|
|
1066
|
+
transcript_revision=excluded.transcript_revision,
|
|
1067
|
+
start_word_id=excluded.start_word_id,
|
|
1068
|
+
end_word_id=excluded.end_word_id,
|
|
1069
|
+
source_range_json=excluded.source_range_json`);
|
|
1070
|
+
for (const cue of captions) {
|
|
1071
|
+
upsert.run(cue.cueId, cue.trackId, cue.timelineStartFrame, cue.durationFrames, cue.text, cue.speaker ?? null, cue.styleId, cue.transcriptSelection?.transcriptId ?? null, cue.transcriptSelection?.transcriptRevision ?? null, cue.transcriptSelection?.startWordId ?? null, cue.transcriptSelection?.endWordId ?? null, cue.transcriptSelection
|
|
1072
|
+
? canonicalJson(cue.transcriptSelection.range)
|
|
1073
|
+
: null);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function invalidateEditedArtifacts(context, artifactIds, now) {
|
|
1077
|
+
const invalidate = context.store.db.prepare(`UPDATE runtime_workspace_entries
|
|
1078
|
+
SET invalidated_at=?, hydrated_at=NULL, last_accessed_at=?
|
|
1079
|
+
WHERE artifact_id=?`);
|
|
1080
|
+
for (const artifactId of artifactIds)
|
|
1081
|
+
invalidate.run(now, now, artifactId);
|
|
1082
|
+
}
|
|
1083
|
+
function revisionConflicts(context, intent, writeSet) {
|
|
1084
|
+
if (intent.baseRevision === context.store.head)
|
|
1085
|
+
return [];
|
|
1086
|
+
const commits = context.store.db.doltLog();
|
|
1087
|
+
const baseIndex = commits.findIndex((commit) => commit.commit_hash === intent.baseRevision ||
|
|
1088
|
+
commit.commit_hash.startsWith(intent.baseRevision));
|
|
1089
|
+
if (baseIndex < 0) {
|
|
1090
|
+
return [
|
|
1091
|
+
{
|
|
1092
|
+
code: "STALE_REVISION",
|
|
1093
|
+
message: `Base revision not found: ${intent.baseRevision}`,
|
|
1094
|
+
resource: `sequence:${intent.sequenceId}`,
|
|
1095
|
+
currentRevision: context.store.head,
|
|
1096
|
+
},
|
|
1097
|
+
];
|
|
1098
|
+
}
|
|
1099
|
+
const requested = new Set(writeSet.filter((resource) => !resource.startsWith("sequence:")));
|
|
1100
|
+
const overlapping = new Set();
|
|
1101
|
+
for (let index = 0; index < baseIndex; index += 1) {
|
|
1102
|
+
const parsed = parseCommitMessage(commits[index].message);
|
|
1103
|
+
if (!parsed)
|
|
1104
|
+
continue;
|
|
1105
|
+
for (const resource of parsed.writeSet) {
|
|
1106
|
+
if (requested.has(resource))
|
|
1107
|
+
overlapping.add(resource);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return [...overlapping].sort().map((resource) => ({
|
|
1111
|
+
code: "OVERLAPPING_WRITE",
|
|
1112
|
+
message: `A newer action changed ${resource}`,
|
|
1113
|
+
resource,
|
|
1114
|
+
currentRevision: context.store.head,
|
|
1115
|
+
}));
|
|
1116
|
+
}
|
|
1117
|
+
function assertCommittable(preview, suppliedHash) {
|
|
1118
|
+
if (preview.conflicts.length > 0) {
|
|
1119
|
+
const stale = preview.conflicts.some((conflict) => conflict.code === "STALE_REVISION");
|
|
1120
|
+
throw new EngineFault({
|
|
1121
|
+
code: stale ? "STALE_REVISION" : "ACTION_CONFLICT",
|
|
1122
|
+
message: preview.conflicts.map((conflict) => conflict.message).join("; "),
|
|
1123
|
+
details: { conflicts: preview.conflicts },
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
if (preview.previewHash !== suppliedHash) {
|
|
1127
|
+
throw new EngineFault({
|
|
1128
|
+
code: "ACTION_CONFLICT",
|
|
1129
|
+
message: "Edit preview changed before commit",
|
|
1130
|
+
details: {
|
|
1131
|
+
expectedPreviewHash: preview.previewHash,
|
|
1132
|
+
suppliedPreviewHash: suppliedHash,
|
|
1133
|
+
conflict: "PREVIEW_CHANGED",
|
|
1134
|
+
},
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
function editCommits(context) {
|
|
1139
|
+
const commits = [];
|
|
1140
|
+
for (const commit of context.store.db.doltLog()) {
|
|
1141
|
+
const parsed = parseCommitMessage(commit.message);
|
|
1142
|
+
if (!parsed)
|
|
1143
|
+
continue;
|
|
1144
|
+
commits.push({ hash: commit.commit_hash, date: commit.date, parsed });
|
|
1145
|
+
}
|
|
1146
|
+
return commits;
|
|
1147
|
+
}
|
|
1148
|
+
function findEditBatchCommit(context, actionId) {
|
|
1149
|
+
return editCommits(context).find((commit) => commit.parsed.operation === "commit_edit" &&
|
|
1150
|
+
commit.parsed.details.actionId === actionId);
|
|
1151
|
+
}
|
|
1152
|
+
function restoreTarget(context, request) {
|
|
1153
|
+
if (request.targetActionId) {
|
|
1154
|
+
const commit = findEditBatchCommit(context, request.targetActionId);
|
|
1155
|
+
if (!commit || typeof commit.parsed.details.sequenceId !== "string") {
|
|
1156
|
+
throw new EngineFault({
|
|
1157
|
+
code: "NOT_FOUND",
|
|
1158
|
+
message: `Edit action not found: ${request.targetActionId}`,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
sequenceId: commit.parsed.details.sequenceId,
|
|
1163
|
+
revision: commit.hash,
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
if (!request.targetRevision) {
|
|
1167
|
+
throw new EngineFault({
|
|
1168
|
+
code: "INVALID_INPUT",
|
|
1169
|
+
message: "Restore requires a target action or revision",
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
sequenceId: createSequencesApi(context).getPrimary().sequenceId,
|
|
1174
|
+
revision: request.targetRevision,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function requiredEditBatch(context, actionId) {
|
|
1178
|
+
const commit = findEditBatchCommit(context, actionId);
|
|
1179
|
+
if (!commit) {
|
|
1180
|
+
throw new EngineFault({
|
|
1181
|
+
code: "NOT_FOUND",
|
|
1182
|
+
message: `Edit batch not found: ${actionId}`,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
const details = commit.parsed.details;
|
|
1186
|
+
return {
|
|
1187
|
+
actionId,
|
|
1188
|
+
commandId: String(details.commandId),
|
|
1189
|
+
intentVersion: Number(details.intentVersion),
|
|
1190
|
+
sourceSurface: details.sourceSurface,
|
|
1191
|
+
actor: String(details.actor),
|
|
1192
|
+
sequenceId: String(details.sequenceId),
|
|
1193
|
+
baseRevision: commit.parsed.baseRevision ?? "",
|
|
1194
|
+
committedRevision: commit.hash,
|
|
1195
|
+
operations: Array.isArray(details.operations)
|
|
1196
|
+
? details.operations
|
|
1197
|
+
: [],
|
|
1198
|
+
affectedRanges: Array.isArray(details.affectedRanges)
|
|
1199
|
+
? details.affectedRanges
|
|
1200
|
+
: [],
|
|
1201
|
+
writeSet: commit.parsed.writeSet,
|
|
1202
|
+
previewHash: String(details.previewHash),
|
|
1203
|
+
beforeHash: String(details.beforeHash),
|
|
1204
|
+
afterHash: String(details.afterHash),
|
|
1205
|
+
confirmationPolicy: details.confirmationPolicy,
|
|
1206
|
+
warnings: Array.isArray(details.warnings)
|
|
1207
|
+
? details.warnings
|
|
1208
|
+
: [],
|
|
1209
|
+
createdAt: commitDateMs(commit.date),
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
function editWriteSet(sequenceId, operations) {
|
|
1213
|
+
const values = new Set([`sequence:${sequenceId}`]);
|
|
1214
|
+
for (const item of operations) {
|
|
1215
|
+
const operation = item.operation;
|
|
1216
|
+
if ("clipId" in operation && operation.clipId) {
|
|
1217
|
+
values.add(`clip:${operation.clipId}`);
|
|
1218
|
+
}
|
|
1219
|
+
if (operation.kind === "insert-clip" || operation.kind === "restore-clip") {
|
|
1220
|
+
values.add(`track:${operation.placement.trackId}`);
|
|
1221
|
+
}
|
|
1222
|
+
else if (operation.kind === "remove-range") {
|
|
1223
|
+
operation.trackIds.forEach((trackId) => values.add(`track:${trackId}`));
|
|
1224
|
+
}
|
|
1225
|
+
else if (operation.kind === "move-clip") {
|
|
1226
|
+
values.add(`track:${operation.trackId}`);
|
|
1227
|
+
}
|
|
1228
|
+
else if (operation.kind === "set-transition") {
|
|
1229
|
+
values.add(`clip:${operation.outgoingClipId}`);
|
|
1230
|
+
values.add(`clip:${operation.incomingClipId}`);
|
|
1231
|
+
if (operation.transition) {
|
|
1232
|
+
values.add(`transition:${operation.transition.transitionId}`);
|
|
1233
|
+
values.add(`track:${operation.transition.trackId}`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
else if (operation.kind === "upsert-caption-cue") {
|
|
1237
|
+
values.add(`caption:${operation.cue.cueId}`);
|
|
1238
|
+
values.add(`track:${operation.cue.trackId}`);
|
|
1239
|
+
}
|
|
1240
|
+
else if (operation.kind === "batch-replace-range") {
|
|
1241
|
+
operation.trackIds.forEach((trackId) => values.add(`track:${trackId}`));
|
|
1242
|
+
operation.placements.forEach((placement) => values.add(`track:${placement.trackId}`));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return [...values].sort();
|
|
1246
|
+
}
|
|
1247
|
+
function restoreWriteSet(before, after) {
|
|
1248
|
+
return unique([
|
|
1249
|
+
`sequence:${before.sequenceId}`,
|
|
1250
|
+
...before.tracks.map((track) => `track:${track.trackId}`),
|
|
1251
|
+
...after.tracks.map((track) => `track:${track.trackId}`),
|
|
1252
|
+
...before.clips.map((clip) => `clip:${clip.clipId}`),
|
|
1253
|
+
...after.clips.map((clip) => `clip:${clip.clipId}`),
|
|
1254
|
+
]).sort();
|
|
1255
|
+
}
|
|
1256
|
+
function affectedSequenceRanges(sequenceId, operations) {
|
|
1257
|
+
const ranges = [];
|
|
1258
|
+
for (const item of operations) {
|
|
1259
|
+
const operation = item.operation;
|
|
1260
|
+
if (operation.kind === "insert-clip" || operation.kind === "restore-clip") {
|
|
1261
|
+
ranges.push({
|
|
1262
|
+
sequenceId,
|
|
1263
|
+
startFrame: operation.placement.timelineStartFrame,
|
|
1264
|
+
durationFrames: operation.placement.durationFrames,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
else if (operation.kind === "remove-range" ||
|
|
1268
|
+
operation.kind === "batch-replace-range") {
|
|
1269
|
+
ranges.push(operation.range);
|
|
1270
|
+
}
|
|
1271
|
+
else if (operation.kind === "move-clip") {
|
|
1272
|
+
ranges.push({
|
|
1273
|
+
sequenceId,
|
|
1274
|
+
startFrame: operation.timelineStartFrame,
|
|
1275
|
+
durationFrames: 1,
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
else if (operation.kind === "trim-clip") {
|
|
1279
|
+
ranges.push({
|
|
1280
|
+
sequenceId,
|
|
1281
|
+
startFrame: operation.timelineStartFrame,
|
|
1282
|
+
durationFrames: operation.durationFrames,
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
else if (operation.kind === "split-clip") {
|
|
1286
|
+
ranges.push({
|
|
1287
|
+
sequenceId,
|
|
1288
|
+
startFrame: operation.splitFrame,
|
|
1289
|
+
durationFrames: 1,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
else if (operation.kind === "upsert-caption-cue") {
|
|
1293
|
+
ranges.push({
|
|
1294
|
+
sequenceId,
|
|
1295
|
+
startFrame: operation.cue.timelineStartFrame,
|
|
1296
|
+
durationFrames: operation.cue.durationFrames,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (ranges.length === 0)
|
|
1301
|
+
return [];
|
|
1302
|
+
const startFrame = Math.min(...ranges.map((range) => range.startFrame));
|
|
1303
|
+
const endFrame = Math.max(...ranges.map((range) => range.startFrame + range.durationFrames));
|
|
1304
|
+
return [{ sequenceId, startFrame, durationFrames: endFrame - startFrame }];
|
|
1305
|
+
}
|
|
1306
|
+
function sequenceDiff(before, after) {
|
|
1307
|
+
const beforeClips = new Map(before.clips.map((clip) => [clip.clipId, clip]));
|
|
1308
|
+
const afterClips = new Map(after.clips.map((clip) => [clip.clipId, clip]));
|
|
1309
|
+
const insertedClipIds = [...afterClips.keys()]
|
|
1310
|
+
.filter((id) => !beforeClips.has(id))
|
|
1311
|
+
.sort();
|
|
1312
|
+
const removedClipIds = [...beforeClips.keys()]
|
|
1313
|
+
.filter((id) => !afterClips.has(id))
|
|
1314
|
+
.sort();
|
|
1315
|
+
const changedClipIds = [...afterClips.keys()]
|
|
1316
|
+
.filter((id) => beforeClips.has(id) &&
|
|
1317
|
+
canonicalJson(beforeClips.get(id)) !==
|
|
1318
|
+
canonicalJson(afterClips.get(id)))
|
|
1319
|
+
.sort();
|
|
1320
|
+
const beforeCaptions = new Map(before.captions.map((caption) => [caption.cueId, caption]));
|
|
1321
|
+
const changedCaptionCueIds = after.captions
|
|
1322
|
+
.filter((caption) => canonicalJson(beforeCaptions.get(caption.cueId)) !==
|
|
1323
|
+
canonicalJson(caption))
|
|
1324
|
+
.map((caption) => caption.cueId)
|
|
1325
|
+
.sort();
|
|
1326
|
+
const changedTrackIds = unique([...insertedClipIds, ...removedClipIds, ...changedClipIds].flatMap((clipId) => {
|
|
1327
|
+
const clip = afterClips.get(clipId) ?? beforeClips.get(clipId);
|
|
1328
|
+
return clip ? [clip.trackId] : [];
|
|
1329
|
+
})).sort();
|
|
1330
|
+
return {
|
|
1331
|
+
insertedClipIds,
|
|
1332
|
+
removedClipIds,
|
|
1333
|
+
changedClipIds,
|
|
1334
|
+
changedTrackIds,
|
|
1335
|
+
changedCaptionCueIds,
|
|
1336
|
+
beforeDurationFrames: sequenceDuration(before),
|
|
1337
|
+
afterDurationFrames: sequenceDuration(after),
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function sequenceDuration(sequence) {
|
|
1341
|
+
return Math.max(0, ...sequence.clips.map(clipEnd), ...sequence.captions.map((caption) => caption.timelineStartFrame + caption.durationFrames));
|
|
1342
|
+
}
|
|
1343
|
+
function sequenceHash(sequence) {
|
|
1344
|
+
const { revision: _revision, ...semantic } = sequence;
|
|
1345
|
+
return hashJson(semantic);
|
|
1346
|
+
}
|
|
1347
|
+
function hashJson(value) {
|
|
1348
|
+
return `sha256:${createHash("sha256")
|
|
1349
|
+
.update(canonicalJson(value))
|
|
1350
|
+
.digest("hex")}`;
|
|
1351
|
+
}
|
|
1352
|
+
function deterministicUuid(namespace, value) {
|
|
1353
|
+
const bytes = createHash("sha256")
|
|
1354
|
+
.update(namespace)
|
|
1355
|
+
.update("\0")
|
|
1356
|
+
.update(value)
|
|
1357
|
+
.digest()
|
|
1358
|
+
.subarray(0, 16);
|
|
1359
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
1360
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
1361
|
+
const hex = bytes.toString("hex");
|
|
1362
|
+
return [
|
|
1363
|
+
hex.slice(0, 8),
|
|
1364
|
+
hex.slice(8, 12),
|
|
1365
|
+
hex.slice(12, 16),
|
|
1366
|
+
hex.slice(16, 20),
|
|
1367
|
+
hex.slice(20),
|
|
1368
|
+
].join("-");
|
|
1369
|
+
}
|
|
1370
|
+
function cloneSequence(sequence) {
|
|
1371
|
+
return structuredClone(sequence);
|
|
1372
|
+
}
|
|
1373
|
+
function sequenceValue(result) {
|
|
1374
|
+
if (!result.ok)
|
|
1375
|
+
throw new EngineFault(result.error);
|
|
1376
|
+
return result.value;
|
|
1377
|
+
}
|
|
1378
|
+
function requiredText(value, label) {
|
|
1379
|
+
const normalized = value.trim();
|
|
1380
|
+
if (!normalized)
|
|
1381
|
+
throw new Error(`${label} is required`);
|
|
1382
|
+
return normalized;
|
|
1383
|
+
}
|
|
1384
|
+
function safeIntegerAtLeast(value, minimum, label) {
|
|
1385
|
+
if (!Number.isSafeInteger(value) || value < minimum) {
|
|
1386
|
+
throw new Error(`${label} must be a safe integer of at least ${minimum}`);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
function unique(values) {
|
|
1390
|
+
return [...new Set(values)];
|
|
1391
|
+
}
|
|
1392
|
+
//# sourceMappingURL=edits.js.map
|