@tangle-network/agent-app 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1730 @@
1
+ import {
2
+ MIN_SEQUENCE_CLIP_FRAMES,
3
+ assertClipFitsSequence,
4
+ chooseCaptionPlacement,
5
+ formatSeconds,
6
+ formatTimecode,
7
+ framesToSeconds,
8
+ secondsToFrames,
9
+ snapshotFrame,
10
+ trackIntervals
11
+ } from "./chunk-ZYBWGSAZ.js";
12
+ import {
13
+ DEFAULT_HEADER_NAMES,
14
+ buildHttpMcpServer
15
+ } from "./chunk-IJZJWKUK.js";
16
+
17
+ // src/sequences/operations.ts
18
+ var SEQUENCE_OPERATION_TYPES = [
19
+ "place_clip",
20
+ "add_caption",
21
+ "move_clip",
22
+ "trim_clip",
23
+ "split_clip",
24
+ "set_clip_text",
25
+ "set_clip_disabled",
26
+ "delete_clip",
27
+ "create_track",
28
+ "extend_sequence",
29
+ "queue_export"
30
+ ];
31
+
32
+ // src/sequences/validate.ts
33
+ var TRACK_KINDS = {
34
+ video: true,
35
+ audio: true,
36
+ caption: true,
37
+ reference: true,
38
+ agent: true
39
+ };
40
+ var EXPORT_FORMATS = {
41
+ mp4: true,
42
+ otio: true,
43
+ xml: true,
44
+ edl: true,
45
+ vtt: true,
46
+ srt: true,
47
+ contact_sheet: true
48
+ };
49
+ var MEDIA_KINDS = {
50
+ video: true,
51
+ image: true,
52
+ audio: true
53
+ };
54
+ var LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
55
+ function validateSequenceOperations(timeline, operations, ctx) {
56
+ assertPlayheadFrame(ctx.playheadFrame);
57
+ operations.forEach((operation, index) => {
58
+ try {
59
+ validateSequenceOperation(timeline, operation, ctx);
60
+ } catch (error) {
61
+ const reason = error instanceof Error ? error.message : String(error);
62
+ throw new Error(`operation ${index + 1} (${operation.type}): ${reason}`);
63
+ }
64
+ });
65
+ }
66
+ function validateSequenceOperation(timeline, operation, ctx) {
67
+ switch (operation.type) {
68
+ case "place_clip":
69
+ return validatePlaceClip(timeline, operation);
70
+ case "add_caption":
71
+ return validateAddCaption(timeline, operation, ctx);
72
+ case "move_clip":
73
+ return validateMoveClip(timeline, operation);
74
+ case "trim_clip":
75
+ return validateTrimClip(timeline, operation);
76
+ case "split_clip":
77
+ return validateSplitClip(timeline, operation);
78
+ case "set_clip_text":
79
+ return validateSetClipText(timeline, operation);
80
+ case "set_clip_disabled":
81
+ return validateSetClipDisabled(timeline, operation);
82
+ case "delete_clip":
83
+ return validateDeleteClip(timeline, operation);
84
+ case "create_track":
85
+ return validateCreateTrack(operation);
86
+ case "extend_sequence":
87
+ return validateExtendSequence(timeline, operation);
88
+ case "queue_export":
89
+ return validateQueueExport(operation);
90
+ default: {
91
+ const unknown = operation;
92
+ throw new Error(`unsupported operation type ${JSON.stringify(unknown.type)}`);
93
+ }
94
+ }
95
+ }
96
+ function validatePlaceClip(timeline, operation) {
97
+ if (operation.label.trim().length === 0) throw new Error("label must be non-empty");
98
+ if (operation.media) {
99
+ if (!(operation.media.kind in MEDIA_KINDS)) {
100
+ throw new Error(`unsupported media kind ${JSON.stringify(operation.media.kind)}`);
101
+ }
102
+ assertSequenceMediaUrl(operation.media.url);
103
+ }
104
+ if (operation.sourceInFrame !== void 0) assertSourceInFrame(operation.sourceInFrame);
105
+ if (operation.sourceOutFrame !== void 0) {
106
+ assertSourceWindow(operation.sourceInFrame ?? 0, operation.sourceOutFrame, operation.durationFrames);
107
+ }
108
+ assertOperationBounds(timeline, { startFrame: operation.startFrame, durationFrames: operation.durationFrames });
109
+ resolvePlaceClipTrack(timeline, operation);
110
+ }
111
+ function validateAddCaption(timeline, operation, ctx) {
112
+ if (operation.text.trim().length === 0) throw new Error("text must be non-empty");
113
+ if (operation.language !== void 0) assertLanguageTag(operation.language);
114
+ const target = resolveCaptionTarget(timeline, operation);
115
+ const placement = resolveCaptionPlacement(
116
+ timeline,
117
+ operation,
118
+ ctx,
119
+ target.kind === "existing" ? target.track.id : null
120
+ );
121
+ if (operation.startFrame !== void 0 || operation.durationFrames !== void 0) {
122
+ assertOperationBounds(timeline, placement);
123
+ }
124
+ }
125
+ function validateMoveClip(timeline, operation) {
126
+ const { clip, track } = requireMutableClip(timeline, operation.clipId);
127
+ assertOperationBounds(timeline, { startFrame: operation.startFrame, durationFrames: clip.durationFrames });
128
+ if (operation.trackId !== void 0) {
129
+ const destination = requireTrack(timeline, operation.trackId);
130
+ assertUnlocked(destination);
131
+ if (destination.kind !== track.kind) {
132
+ throw new Error(`moves a ${track.kind} clip to a ${destination.kind} track (${destination.id})`);
133
+ }
134
+ }
135
+ }
136
+ function validateTrimClip(timeline, operation) {
137
+ const { clip } = requireMutableClip(timeline, operation.clipId);
138
+ if (operation.sourceInFrame !== void 0) assertSourceInFrame(operation.sourceInFrame);
139
+ assertOperationBounds(timeline, { startFrame: operation.startFrame, durationFrames: operation.durationFrames });
140
+ const sourceInFrame = operation.sourceInFrame ?? clip.sourceInFrame;
141
+ const sourceOutFrame = operation.sourceOutFrame === void 0 ? clip.sourceOutFrame : operation.sourceOutFrame;
142
+ assertSourceWindow(sourceInFrame, sourceOutFrame, operation.durationFrames);
143
+ }
144
+ function validateSplitClip(timeline, operation) {
145
+ const { clip } = requireMutableClip(timeline, operation.clipId);
146
+ if (!Number.isInteger(operation.atFrame)) throw new Error("atFrame must be an integer");
147
+ if (clip.durationFrames < 2) {
148
+ throw new Error(`clip ${clip.id} is ${clip.durationFrames} frame(s) long; splitting needs at least 2 frames`);
149
+ }
150
+ const endFrame = clip.startFrame + clip.durationFrames;
151
+ if (operation.atFrame <= clip.startFrame || operation.atFrame >= endFrame) {
152
+ throw new Error(
153
+ `atFrame ${operation.atFrame} must fall strictly inside clip ${clip.id} (valid range ${clip.startFrame + 1}..${endFrame - 1})`
154
+ );
155
+ }
156
+ }
157
+ function validateSetClipText(timeline, operation) {
158
+ const { track } = requireMutableClip(timeline, operation.clipId);
159
+ if (track.kind !== "caption") {
160
+ throw new Error(`targets a clip on a ${track.kind} track; text edits apply only to caption clips`);
161
+ }
162
+ if (operation.text.trim().length === 0) {
163
+ throw new Error("text must be non-empty; use delete_clip to remove a caption");
164
+ }
165
+ if (operation.language !== void 0) assertLanguageTag(operation.language);
166
+ }
167
+ function validateSetClipDisabled(timeline, operation) {
168
+ requireMutableClip(timeline, operation.clipId);
169
+ }
170
+ function validateDeleteClip(timeline, operation) {
171
+ requireMutableClip(timeline, operation.clipId);
172
+ }
173
+ function validateCreateTrack(operation) {
174
+ if (!(operation.kind in TRACK_KINDS)) throw new Error(`unsupported track kind ${JSON.stringify(operation.kind)}`);
175
+ if (operation.name.trim().length === 0) throw new Error("name must be non-empty");
176
+ }
177
+ function validateExtendSequence(timeline, operation) {
178
+ if (!Number.isInteger(operation.durationFrames) || operation.durationFrames <= 0) {
179
+ throw new Error("durationFrames must be a positive integer");
180
+ }
181
+ const lastEnd = lastClipEndFrame(timeline);
182
+ if (operation.durationFrames < lastEnd) {
183
+ throw new Error(`durationFrames ${operation.durationFrames} is below the last clip end (frame ${lastEnd})`);
184
+ }
185
+ }
186
+ function validateQueueExport(operation) {
187
+ if (!(operation.format in EXPORT_FORMATS)) {
188
+ throw new Error(`unsupported export format ${JSON.stringify(operation.format)}`);
189
+ }
190
+ }
191
+ function parseSequenceOperations(input) {
192
+ if (!Array.isArray(input)) throw new Error("operations must be an array of sequence operations");
193
+ if (input.length === 0) throw new Error("operations must contain at least one operation");
194
+ return input.map((raw, index) => {
195
+ try {
196
+ return parseSequenceOperation(raw);
197
+ } catch (error) {
198
+ const reason = error instanceof Error ? error.message : String(error);
199
+ throw new Error(`operations[${index}]: ${reason}`);
200
+ }
201
+ });
202
+ }
203
+ function parseSequenceOperation(raw) {
204
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
205
+ throw new Error("each operation must be an object with a type field");
206
+ }
207
+ const record = raw;
208
+ const type = record.type;
209
+ if (typeof type !== "string" || !SEQUENCE_OPERATION_TYPES.includes(type)) {
210
+ throw new Error(`type must be one of: ${SEQUENCE_OPERATION_TYPES.join(", ")} (got ${JSON.stringify(type)})`);
211
+ }
212
+ switch (type) {
213
+ case "place_clip":
214
+ return {
215
+ type: "place_clip",
216
+ label: readString(record, "label"),
217
+ startFrame: readInt(record, "startFrame"),
218
+ durationFrames: readInt(record, "durationFrames"),
219
+ ...readOptional(record, "trackId", readString),
220
+ ...readOptional(record, "sourceInFrame", readInt),
221
+ ...readOptionalNullable(record, "sourceOutFrame", readInt),
222
+ ...readOptional(record, "disabled", readBool),
223
+ ...readOptional(record, "media", readMedia),
224
+ ...readOptional(record, "generationId", readString),
225
+ ...readOptional(record, "assetId", readString),
226
+ ...readOptional(record, "metadata", readRecord)
227
+ };
228
+ case "add_caption":
229
+ return {
230
+ type: "add_caption",
231
+ text: readString(record, "text"),
232
+ ...readOptional(record, "language", readString),
233
+ ...readOptional(record, "startFrame", readInt),
234
+ ...readOptional(record, "durationFrames", readInt),
235
+ ...readOptional(record, "trackId", readString)
236
+ };
237
+ case "move_clip":
238
+ return {
239
+ type: "move_clip",
240
+ clipId: readString(record, "clipId"),
241
+ startFrame: readInt(record, "startFrame"),
242
+ ...readOptional(record, "trackId", readString)
243
+ };
244
+ case "trim_clip":
245
+ return {
246
+ type: "trim_clip",
247
+ clipId: readString(record, "clipId"),
248
+ startFrame: readInt(record, "startFrame"),
249
+ durationFrames: readInt(record, "durationFrames"),
250
+ ...readOptional(record, "sourceInFrame", readInt),
251
+ ...readOptionalNullable(record, "sourceOutFrame", readInt)
252
+ };
253
+ case "split_clip":
254
+ return {
255
+ type: "split_clip",
256
+ clipId: readString(record, "clipId"),
257
+ atFrame: readInt(record, "atFrame")
258
+ };
259
+ case "set_clip_text":
260
+ return {
261
+ type: "set_clip_text",
262
+ clipId: readString(record, "clipId"),
263
+ text: readString(record, "text"),
264
+ ...readOptional(record, "language", readString)
265
+ };
266
+ case "set_clip_disabled":
267
+ return {
268
+ type: "set_clip_disabled",
269
+ clipId: readString(record, "clipId"),
270
+ disabled: readBool(record, "disabled")
271
+ };
272
+ case "delete_clip":
273
+ return { type: "delete_clip", clipId: readString(record, "clipId") };
274
+ case "create_track": {
275
+ const kind = readString(record, "kind");
276
+ if (!(kind in TRACK_KINDS)) throw new Error(`kind must be one of: ${Object.keys(TRACK_KINDS).join(", ")}`);
277
+ return { type: "create_track", kind, name: readString(record, "name") };
278
+ }
279
+ case "extend_sequence":
280
+ return { type: "extend_sequence", durationFrames: readInt(record, "durationFrames") };
281
+ case "queue_export": {
282
+ const format = readString(record, "format");
283
+ if (!(format in EXPORT_FORMATS)) throw new Error(`format must be one of: ${Object.keys(EXPORT_FORMATS).join(", ")}`);
284
+ return { type: "queue_export", format, ...readOptional(record, "metadata", readRecord) };
285
+ }
286
+ }
287
+ }
288
+ function readString(record, name) {
289
+ const value = record[name];
290
+ if (typeof value !== "string") throw new Error(`${name} must be a string (got ${describeJsonValue(value)})`);
291
+ return value;
292
+ }
293
+ function readInt(record, name) {
294
+ const value = record[name];
295
+ if (typeof value !== "number" || !Number.isInteger(value)) {
296
+ throw new Error(`${name} must be an integer frame count (got ${describeJsonValue(value)})`);
297
+ }
298
+ return value;
299
+ }
300
+ function readBool(record, name) {
301
+ const value = record[name];
302
+ if (typeof value !== "boolean") throw new Error(`${name} must be true or false (got ${describeJsonValue(value)})`);
303
+ return value;
304
+ }
305
+ function readRecord(record, name) {
306
+ const value = record[name];
307
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
308
+ throw new Error(`${name} must be an object (got ${describeJsonValue(value)})`);
309
+ }
310
+ return value;
311
+ }
312
+ function readMedia(record, name) {
313
+ const media = readRecord(record, name);
314
+ const url = readString(media, "url");
315
+ const kind = readString(media, "kind");
316
+ if (!(kind in MEDIA_KINDS)) throw new Error(`${name}.kind must be one of: ${Object.keys(MEDIA_KINDS).join(", ")}`);
317
+ return { url, kind };
318
+ }
319
+ function readOptional(record, name, reader) {
320
+ if (record[name] === void 0) return {};
321
+ return { [name]: reader(record, name) };
322
+ }
323
+ function readOptionalNullable(record, name, reader) {
324
+ if (record[name] === void 0) return {};
325
+ if (record[name] === null) return { [name]: null };
326
+ return { [name]: reader(record, name) };
327
+ }
328
+ function describeJsonValue(value) {
329
+ if (value === void 0) return "missing";
330
+ if (value === null) return "null";
331
+ if (Array.isArray(value)) return "an array";
332
+ return typeof value === "object" ? "an object" : `${typeof value} ${JSON.stringify(value)}`;
333
+ }
334
+ function captionTrackNameForLanguage(language) {
335
+ return `Captions (${language})`;
336
+ }
337
+ function resolvePlaceClipTrack(timeline, operation) {
338
+ const media = operation.media;
339
+ if (operation.trackId !== void 0) {
340
+ const track2 = requireTrack(timeline, operation.trackId);
341
+ assertUnlocked(track2);
342
+ if (track2.kind === "caption") {
343
+ throw new Error(`cannot target caption track ${track2.id}; use add_caption for caption content`);
344
+ }
345
+ if (media) {
346
+ const primary = media.kind === "audio" ? "audio" : "video";
347
+ if (track2.kind !== primary && track2.kind !== "reference") {
348
+ throw new Error(`media kind ${media.kind} requires a ${primary} or reference track; track ${track2.id} is ${track2.kind}`);
349
+ }
350
+ }
351
+ return track2;
352
+ }
353
+ if (!media) throw new Error("requires trackId when media is omitted \u2014 the target track kind cannot be inferred");
354
+ const wanted = media.kind === "audio" ? "audio" : "video";
355
+ const track = tracksBySortOrder(timeline).find((candidate) => candidate.kind === wanted && !candidate.locked);
356
+ if (!track) throw new Error(`requires an unlocked ${wanted} track and the sequence has none`);
357
+ return track;
358
+ }
359
+ function resolveCaptionTarget(timeline, operation) {
360
+ if (operation.trackId !== void 0) {
361
+ const track2 = requireTrack(timeline, operation.trackId);
362
+ if (track2.kind !== "caption") {
363
+ throw new Error(`targets ${track2.kind} track ${track2.id}; captions require a caption track`);
364
+ }
365
+ assertUnlocked(track2);
366
+ return { kind: "existing", track: track2 };
367
+ }
368
+ const captionTracks = tracksBySortOrder(timeline).filter((track2) => track2.kind === "caption");
369
+ if (operation.language !== void 0) {
370
+ const language = operation.language;
371
+ const matching = captionTracks.filter(
372
+ (track2) => track2.metadata.language === language || track2.name === captionTrackNameForLanguage(language)
373
+ );
374
+ const unlocked = matching.find((track2) => !track2.locked);
375
+ if (unlocked) return { kind: "existing", track: unlocked };
376
+ if (matching.length > 0) throw new Error(`caption track for language "${language}" is locked`);
377
+ return { kind: "create", language, name: captionTrackNameForLanguage(language) };
378
+ }
379
+ const track = captionTracks.find((candidate) => !candidate.locked);
380
+ if (!track) {
381
+ throw new Error("requires an unlocked caption track and the sequence has none; pass language to auto-create one or create_track first");
382
+ }
383
+ return { kind: "existing", track };
384
+ }
385
+ function resolveCaptionPlacement(timeline, operation, ctx, targetTrackId) {
386
+ assertPlayheadFrame(ctx.playheadFrame);
387
+ const fps = timeline.sequence.fps;
388
+ if (operation.startFrame === void 0 && operation.durationFrames === void 0) {
389
+ return chooseCaptionPlacement({
390
+ playheadFrame: ctx.playheadFrame,
391
+ fps,
392
+ sequenceDurationFrames: timeline.sequence.durationFrames,
393
+ occupiedIntervals: targetTrackId === null ? [] : trackIntervals(timeline, targetTrackId)
394
+ });
395
+ }
396
+ return {
397
+ startFrame: operation.startFrame ?? ctx.playheadFrame,
398
+ durationFrames: operation.durationFrames ?? fps * 3
399
+ };
400
+ }
401
+ function lastClipEndFrame(timeline) {
402
+ return timeline.clips.reduce((max, clip) => Math.max(max, clip.startFrame + clip.durationFrames), 0);
403
+ }
404
+ function assertSequenceMediaUrl(url) {
405
+ const trimmed = url.trim();
406
+ if (/^https?:\/\//i.test(trimmed)) return;
407
+ if (trimmed.startsWith("/api/")) return;
408
+ const shown = trimmed.length > 96 ? `${trimmed.slice(0, 96)}\u2026` : trimmed;
409
+ const lower = trimmed.toLowerCase();
410
+ if (lower.startsWith("file:") || lower.startsWith("data:") || lower.startsWith("/tmp/") || lower.startsWith("/home/")) {
411
+ throw new Error(`media url must reference a provider URL or rooted /api/ path, not a local sandbox file (${shown})`);
412
+ }
413
+ throw new Error(`media url must be http(s) or a rooted /api/ path (${shown})`);
414
+ }
415
+ function requireClip(timeline, clipId) {
416
+ const clip = timeline.clips.find((candidate) => candidate.id === clipId);
417
+ if (!clip) throw new Error(`references unknown clip ${clipId}`);
418
+ return clip;
419
+ }
420
+ function requireTrack(timeline, trackId) {
421
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
422
+ if (!track) throw new Error(`references unknown track ${trackId}`);
423
+ return track;
424
+ }
425
+ function requireMutableClip(timeline, clipId) {
426
+ const clip = requireClip(timeline, clipId);
427
+ const track = requireTrack(timeline, clip.trackId);
428
+ if (track.locked) throw new Error(`clip ${clip.id} sits on locked track "${track.name}" (${track.id})`);
429
+ return { clip, track };
430
+ }
431
+ function assertUnlocked(track) {
432
+ if (track.locked) throw new Error(`targets locked track "${track.name}" (${track.id})`);
433
+ }
434
+ function assertOperationBounds(timeline, bounds) {
435
+ assertClipFitsSequence({
436
+ startFrame: bounds.startFrame,
437
+ durationFrames: bounds.durationFrames,
438
+ sequenceDurationFrames: timeline.sequence.durationFrames,
439
+ // The label carries the numbers so the thrown message is actionable
440
+ // without access to the original arguments.
441
+ label: `clip [start=${bounds.startFrame} duration=${bounds.durationFrames}] in a ${timeline.sequence.durationFrames}-frame sequence:`
442
+ });
443
+ }
444
+ function assertSourceInFrame(sourceInFrame) {
445
+ if (!Number.isInteger(sourceInFrame) || sourceInFrame < 0) {
446
+ throw new Error("sourceInFrame must be a non-negative integer");
447
+ }
448
+ }
449
+ function assertSourceWindow(sourceInFrame, sourceOutFrame, durationFrames) {
450
+ if (sourceOutFrame === null) return;
451
+ if (!Number.isInteger(sourceOutFrame) || sourceOutFrame < 1) {
452
+ throw new Error("sourceOutFrame must be a positive integer or null");
453
+ }
454
+ if (sourceOutFrame <= sourceInFrame) {
455
+ throw new Error(`sourceOutFrame ${sourceOutFrame} must be greater than sourceInFrame ${sourceInFrame}`);
456
+ }
457
+ if (sourceInFrame + durationFrames > sourceOutFrame) {
458
+ throw new Error(
459
+ `needs ${durationFrames} source frames but the source window [${sourceInFrame}, ${sourceOutFrame}) holds ${sourceOutFrame - sourceInFrame} \u2014 shorten durationFrames, lower sourceInFrame, or pass sourceOutFrame (null releases it to the source's natural end)`
460
+ );
461
+ }
462
+ }
463
+ function assertLanguageTag(language) {
464
+ if (!LANGUAGE_TAG.test(language)) {
465
+ throw new Error(`language must be a BCP-47-style tag (got ${JSON.stringify(language)})`);
466
+ }
467
+ }
468
+ function assertPlayheadFrame(playheadFrame) {
469
+ if (!Number.isInteger(playheadFrame) || playheadFrame < 0) {
470
+ throw new Error("playheadFrame must be a non-negative integer");
471
+ }
472
+ }
473
+ function tracksBySortOrder(timeline) {
474
+ return [...timeline.tracks].sort((a, b) => a.sortOrder - b.sortOrder);
475
+ }
476
+
477
+ // src/sequences/apply.ts
478
+ async function applySequenceOperations(store, operations, ctx) {
479
+ if (operations.length === 0) throw new Error("operations must contain at least one operation");
480
+ let timeline = await store.getTimeline();
481
+ validateSequenceOperations(timeline, operations, ctx);
482
+ const results = [];
483
+ for (let index = 0; index < operations.length; index += 1) {
484
+ if (index > 0) timeline = await store.getTimeline();
485
+ results.push(await applySequenceOperation(store, timeline, operations[index], ctx));
486
+ }
487
+ return results;
488
+ }
489
+ async function applySequenceOperation(store, timeline, op, ctx) {
490
+ validateSequenceOperation(timeline, op, ctx);
491
+ switch (op.type) {
492
+ case "place_clip":
493
+ return applyPlaceClip(store, timeline, op);
494
+ case "add_caption":
495
+ return applyAddCaption(store, timeline, op, ctx);
496
+ case "move_clip": {
497
+ const patch = { startFrame: op.startFrame };
498
+ if (op.trackId !== void 0) patch.trackId = op.trackId;
499
+ return { kind: "clip", clip: await store.updateClip(op.clipId, patch) };
500
+ }
501
+ case "trim_clip": {
502
+ const patch = { startFrame: op.startFrame, durationFrames: op.durationFrames };
503
+ if (op.sourceInFrame !== void 0) patch.sourceInFrame = op.sourceInFrame;
504
+ if (op.sourceOutFrame !== void 0) patch.sourceOutFrame = op.sourceOutFrame;
505
+ return { kind: "clip", clip: await store.updateClip(op.clipId, patch) };
506
+ }
507
+ case "split_clip":
508
+ return applySplitClip(store, timeline, op);
509
+ case "set_clip_text": {
510
+ const patch = { text: op.text, label: clipLabelFromText(op.text) };
511
+ if (op.language !== void 0) patch.language = op.language;
512
+ return { kind: "clip", clip: await store.updateClip(op.clipId, patch) };
513
+ }
514
+ case "set_clip_disabled":
515
+ return { kind: "clip", clip: await store.updateClip(op.clipId, { disabled: op.disabled }) };
516
+ case "delete_clip": {
517
+ const snapshot = requireTimelineClip(timeline, op.clipId);
518
+ await store.deleteClip(op.clipId);
519
+ return { kind: "clip", clip: snapshot };
520
+ }
521
+ case "create_track":
522
+ return { kind: "track", track: await store.createTrack({ kind: op.kind, name: op.name }) };
523
+ case "extend_sequence":
524
+ return { kind: "sequence", sequence: await store.updateSequenceDuration(op.durationFrames) };
525
+ case "queue_export":
526
+ return { kind: "export", record: await store.createExport(op.format, op.metadata) };
527
+ }
528
+ }
529
+ async function applyPlaceClip(store, timeline, op) {
530
+ const track = resolvePlaceClipTrack(timeline, op);
531
+ const metadata = op.media ? { ...op.metadata ?? {}, media: { url: op.media.url, kind: op.media.kind } } : op.metadata;
532
+ const clip = await store.createClip({
533
+ trackId: track.id,
534
+ label: op.label,
535
+ startFrame: op.startFrame,
536
+ durationFrames: op.durationFrames,
537
+ sourceInFrame: op.sourceInFrame ?? 0,
538
+ ...op.sourceOutFrame !== void 0 ? { sourceOutFrame: op.sourceOutFrame } : {},
539
+ ...op.generationId !== void 0 ? { generationId: op.generationId } : {},
540
+ ...op.assetId !== void 0 ? { assetId: op.assetId } : {},
541
+ ...metadata !== void 0 ? { metadata } : {}
542
+ });
543
+ const final = op.disabled === true ? await store.updateClip(clip.id, { disabled: true }) : clip;
544
+ return { kind: "clip", clip: final };
545
+ }
546
+ async function applyAddCaption(store, timeline, op, ctx) {
547
+ const target = resolveCaptionTarget(timeline, op);
548
+ const placement = resolveCaptionPlacement(timeline, op, ctx, target.kind === "existing" ? target.track.id : null);
549
+ const track = target.kind === "existing" ? target.track : await store.createTrack({ kind: "caption", name: target.name });
550
+ const clip = await store.createClip({
551
+ trackId: track.id,
552
+ label: clipLabelFromText(op.text),
553
+ startFrame: placement.startFrame,
554
+ durationFrames: placement.durationFrames,
555
+ sourceInFrame: 0,
556
+ text: op.text,
557
+ ...op.language !== void 0 ? { language: op.language } : {}
558
+ });
559
+ return { kind: "clip", clip };
560
+ }
561
+ async function applySplitClip(store, timeline, op) {
562
+ const original = { ...requireTimelineClip(timeline, op.clipId) };
563
+ const offset = op.atFrame - original.startFrame;
564
+ const second = await store.createClip({
565
+ trackId: original.trackId,
566
+ label: original.label,
567
+ startFrame: op.atFrame,
568
+ durationFrames: original.durationFrames - offset,
569
+ sourceInFrame: original.sourceInFrame + offset,
570
+ sourceOutFrame: original.sourceOutFrame,
571
+ ...original.text !== void 0 ? { text: original.text } : {},
572
+ ...original.language !== void 0 ? { language: original.language } : {},
573
+ ...original.generationId !== void 0 ? { generationId: original.generationId } : {},
574
+ ...original.assetId !== void 0 ? { assetId: original.assetId } : {},
575
+ metadata: original.metadata
576
+ });
577
+ await store.updateClip(original.id, {
578
+ durationFrames: offset,
579
+ sourceOutFrame: original.sourceInFrame + offset
580
+ });
581
+ const secondFinal = original.disabled ? await store.updateClip(second.id, { disabled: true }) : second;
582
+ return { kind: "clip", clip: secondFinal };
583
+ }
584
+ function requireTimelineClip(timeline, clipId) {
585
+ const clip = timeline.clips.find((candidate) => candidate.id === clipId);
586
+ if (!clip) throw new Error(`references unknown clip ${clipId}`);
587
+ return clip;
588
+ }
589
+ function clipLabelFromText(text) {
590
+ return text.length > 120 ? text.slice(0, 120) : text;
591
+ }
592
+
593
+ // src/sequences/exports.ts
594
+ function buildSrt(timeline, opts = {}) {
595
+ const fps = timeline.sequence.fps;
596
+ const cues = collectCaptionCues(timeline, opts.language);
597
+ const blocks = cues.map((cue, index) => [
598
+ String(index + 1),
599
+ `${frameToSubtitleTime(cue.startFrame, fps, ",")} --> ${frameToSubtitleTime(cue.endFrame, fps, ",")}`,
600
+ ...cue.lines
601
+ ].join("\n"));
602
+ return `${blocks.join("\n\n")}
603
+ `;
604
+ }
605
+ function buildVtt(timeline, opts = {}) {
606
+ const fps = timeline.sequence.fps;
607
+ const cues = collectCaptionCues(timeline, opts.language);
608
+ const blocks = cues.map((cue, index) => [
609
+ String(index + 1),
610
+ `${frameToSubtitleTime(cue.startFrame, fps, ".")} --> ${frameToSubtitleTime(cue.endFrame, fps, ".")}`,
611
+ ...cue.lines
612
+ ].join("\n"));
613
+ return `WEBVTT
614
+
615
+ ${blocks.join("\n\n")}
616
+ `;
617
+ }
618
+ function collectCaptionCues(timeline, language) {
619
+ const captionTracks = timeline.tracks.filter((track) => track.kind === "caption");
620
+ const sortOrderByTrackId = new Map(captionTracks.map((track) => [track.id, track.sortOrder]));
621
+ const wanted = language?.toLowerCase();
622
+ const cues = timeline.clips.filter((clip) => sortOrderByTrackId.has(clip.trackId) && !clip.disabled && typeof clip.text === "string" && clip.text.trim().length > 0 && (wanted === void 0 || clip.language?.toLowerCase() === wanted)).map((clip) => ({
623
+ startFrame: clip.startFrame,
624
+ endFrame: clip.startFrame + clip.durationFrames,
625
+ lines: captionLines(clip.text)
626
+ })).sort((a, b) => a.startFrame - b.startFrame || a.endFrame - b.endFrame);
627
+ if (cues.length === 0) {
628
+ const scope = language === void 0 ? "" : ` in language '${language}'`;
629
+ throw new Error(
630
+ `sequence '${timeline.sequence.title}' has no caption clips with text${scope} \u2014 an empty subtitle file would fail silently; add captions${language === void 0 ? "" : " in that language"} first`
631
+ );
632
+ }
633
+ return cues;
634
+ }
635
+ function captionLines(text) {
636
+ return text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
637
+ }
638
+ function frameToSubtitleTime(frame, fps, separator) {
639
+ const totalMs = Math.round(framesToSeconds(frame, fps) * 1e3);
640
+ const ms = totalMs % 1e3;
641
+ const totalSeconds = Math.floor(totalMs / 1e3);
642
+ const hours = Math.floor(totalSeconds / 3600);
643
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
644
+ const seconds = totalSeconds % 60;
645
+ return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}${separator}${String(ms).padStart(3, "0")}`;
646
+ }
647
+ function buildEdl(timeline) {
648
+ const fps = timeline.sequence.fps;
649
+ const events = clipsOnTracks(timeline, ["video", "audio"]);
650
+ if (events.length === 0) {
651
+ throw new Error(
652
+ `sequence '${timeline.sequence.title}' has no enabled video or audio clips \u2014 an empty EDL would fail silently in the NLE`
653
+ );
654
+ }
655
+ const lines = [`TITLE: ${timeline.sequence.title}`, "FCM: NON-DROP FRAME", ""];
656
+ events.forEach(({ track, clip }, index) => {
657
+ const channel = track.kind === "video" ? "V" : "A";
658
+ const sourceIn = frameToEdlTimecode(clip.sourceInFrame, fps);
659
+ const sourceOut = frameToEdlTimecode(clip.sourceInFrame + clip.durationFrames, fps);
660
+ const recordIn = frameToEdlTimecode(clip.startFrame, fps);
661
+ const recordOut = frameToEdlTimecode(clip.startFrame + clip.durationFrames, fps);
662
+ lines.push(`${String(index + 1).padStart(3, "0")} AX ${channel} C ${sourceIn} ${sourceOut} ${recordIn} ${recordOut}`);
663
+ lines.push(`* FROM CLIP NAME: ${clip.label}`);
664
+ if (clip.media) lines.push(`* SOURCE FILE: ${clip.media.url}`);
665
+ lines.push("");
666
+ });
667
+ return lines.join("\n");
668
+ }
669
+ function frameToEdlTimecode(frame, fps) {
670
+ if (!Number.isInteger(frame) || frame < 0) throw new Error("frames must be a non-negative integer");
671
+ const frameWidth = Math.max(2, String(fps - 1).length);
672
+ const totalSeconds = Math.floor(frame / fps);
673
+ const hours = Math.floor(totalSeconds / 3600);
674
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
675
+ const seconds = totalSeconds % 60;
676
+ return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}:${String(frame % fps).padStart(frameWidth, "0")}`;
677
+ }
678
+ function buildOtio(timeline) {
679
+ const fps = timeline.sequence.fps;
680
+ const tracks = [...timeline.tracks].sort((a, b) => a.sortOrder - b.sortOrder).filter((track) => track.kind !== "agent");
681
+ return {
682
+ OTIO_SCHEMA: "Timeline.1",
683
+ name: timeline.sequence.title,
684
+ global_start_time: rationalTime(0, fps),
685
+ metadata: {
686
+ ...timeline.sequence.metadata,
687
+ sequenceId: timeline.sequence.id,
688
+ fps,
689
+ width: timeline.sequence.width,
690
+ height: timeline.sequence.height,
691
+ aspectRatio: timeline.sequence.aspectRatio,
692
+ durationFrames: timeline.sequence.durationFrames
693
+ },
694
+ tracks: {
695
+ OTIO_SCHEMA: "Stack.1",
696
+ name: "tracks",
697
+ children: tracks.map((track) => otioTrack(timeline, track, fps))
698
+ }
699
+ };
700
+ }
701
+ function otioTrack(timeline, track, fps) {
702
+ const clips = timeline.clips.filter((clip) => clip.trackId === track.id && !clip.disabled).sort((a, b) => a.startFrame - b.startFrame);
703
+ const children = [];
704
+ let cursorFrame = 0;
705
+ for (const clip of clips) {
706
+ if (clip.startFrame < cursorFrame) {
707
+ throw new Error(
708
+ `clip '${clip.label}' (${clip.id}) overlaps the previous clip on track '${track.name}' \u2014 OTIO tracks are sequential; move or trim the clip before exporting`
709
+ );
710
+ }
711
+ if (clip.startFrame > cursorFrame) {
712
+ children.push({
713
+ OTIO_SCHEMA: "Gap.1",
714
+ name: "",
715
+ source_range: timeRange(0, clip.startFrame - cursorFrame, fps)
716
+ });
717
+ }
718
+ children.push(otioClip(clip, fps));
719
+ cursorFrame = clip.startFrame + clip.durationFrames;
720
+ }
721
+ return {
722
+ OTIO_SCHEMA: "Track.1",
723
+ name: track.name,
724
+ kind: otioTrackKind(track.kind),
725
+ metadata: { ...track.metadata, sequenceTrackKind: track.kind },
726
+ children
727
+ };
728
+ }
729
+ function otioClip(clip, fps) {
730
+ const metadata = { ...clip.metadata };
731
+ if (clip.text !== void 0) metadata.text = clip.text;
732
+ if (clip.language !== void 0) metadata.language = clip.language;
733
+ if (clip.generationId !== void 0) metadata.generationId = clip.generationId;
734
+ if (clip.assetId !== void 0) metadata.assetId = clip.assetId;
735
+ return {
736
+ OTIO_SCHEMA: "Clip.2",
737
+ name: clip.label,
738
+ source_range: timeRange(clip.sourceInFrame, clip.durationFrames, fps),
739
+ media_reference: clip.media === void 0 ? { OTIO_SCHEMA: "MissingReference.1" } : {
740
+ OTIO_SCHEMA: "ExternalReference.1",
741
+ target_url: clip.media.url,
742
+ available_range: clip.media.durationSeconds === void 0 ? null : timeRange(0, secondsToFrames(clip.media.durationSeconds, fps), fps)
743
+ },
744
+ metadata
745
+ };
746
+ }
747
+ function otioTrackKind(kind) {
748
+ if (kind === "agent") throw new Error("agent tracks are excluded from OTIO export");
749
+ return kind === "audio" ? "Audio" : "Video";
750
+ }
751
+ function rationalTime(value, rate) {
752
+ return { OTIO_SCHEMA: "RationalTime.1", rate, value };
753
+ }
754
+ function timeRange(startValue, durationValue, rate) {
755
+ return {
756
+ OTIO_SCHEMA: "TimeRange.1",
757
+ start_time: rationalTime(startValue, rate),
758
+ duration: rationalTime(durationValue, rate)
759
+ };
760
+ }
761
+ function buildContactSheetManifest(timeline) {
762
+ const fps = timeline.sequence.fps;
763
+ const entries = clipsOnTracks(timeline, ["video"]).flatMap(({ clip }) => {
764
+ const media = clip.media;
765
+ if (media === void 0) return [];
766
+ if (media.providerStatus !== void 0 && media.providerStatus !== "completed") return [];
767
+ if (media.kind === "audio") return [];
768
+ const midpointOffset = Math.floor(clip.durationFrames / 2);
769
+ const frame = clip.startFrame + midpointOffset;
770
+ const sourceFrame = media.kind === "image" ? 0 : clip.sourceInFrame + midpointOffset;
771
+ return [{
772
+ clipId: clip.id,
773
+ trackId: clip.trackId,
774
+ label: clip.label,
775
+ frame,
776
+ timecode: formatTimecode(frame, fps),
777
+ sourceFrame,
778
+ sourceSeconds: framesToSeconds(sourceFrame, fps),
779
+ url: media.url,
780
+ mediaKind: media.kind
781
+ }];
782
+ });
783
+ if (entries.length === 0) {
784
+ throw new Error(
785
+ `sequence '${timeline.sequence.title}' has no sampleable video clips (enabled, media resolved and completed) \u2014 a contact sheet would be empty`
786
+ );
787
+ }
788
+ return {
789
+ sequenceId: timeline.sequence.id,
790
+ title: timeline.sequence.title,
791
+ fps,
792
+ width: timeline.sequence.width,
793
+ height: timeline.sequence.height,
794
+ entries
795
+ };
796
+ }
797
+ function clipsOnTracks(timeline, kinds) {
798
+ const trackById = new Map(
799
+ timeline.tracks.filter((track) => kinds.includes(track.kind)).map((track) => [track.id, track])
800
+ );
801
+ return timeline.clips.filter((clip) => !clip.disabled && trackById.has(clip.trackId)).map((clip) => ({ track: trackById.get(clip.trackId), clip })).sort((a, b) => a.clip.startFrame - b.clip.startFrame || a.track.sortOrder - b.track.sortOrder || a.clip.id.localeCompare(b.clip.id));
802
+ }
803
+ function pad2(value) {
804
+ return String(value).padStart(2, "0");
805
+ }
806
+
807
+ // src/sequences/captions.ts
808
+ var DEFAULT_MAX_WORDS_PER_CHUNK = 8;
809
+ var DEFAULT_MIN_DURATION_SECONDS = 0.8;
810
+ function buildCaptionChunks(segments, opts) {
811
+ const maxWordsPerChunk = opts.maxWordsPerChunk ?? DEFAULT_MAX_WORDS_PER_CHUNK;
812
+ const minDurationSeconds = opts.minDurationSeconds ?? DEFAULT_MIN_DURATION_SECONDS;
813
+ if (!Number.isInteger(maxWordsPerChunk) || maxWordsPerChunk < 1) {
814
+ throw new Error("maxWordsPerChunk must be a positive integer");
815
+ }
816
+ if (!Number.isFinite(minDurationSeconds) || minDurationSeconds < 0) {
817
+ throw new Error("minDurationSeconds must be a non-negative finite number");
818
+ }
819
+ segments.forEach((segment, index) => {
820
+ if (!Number.isFinite(segment.startSeconds) || segment.startSeconds < 0) {
821
+ throw new Error(`segment ${index} startSeconds must be a non-negative finite number`);
822
+ }
823
+ if (!Number.isFinite(segment.endSeconds) || segment.endSeconds < segment.startSeconds) {
824
+ throw new Error(`segment ${index} endSeconds must be a finite number >= startSeconds`);
825
+ }
826
+ });
827
+ const minDurationFrames = Math.max(secondsToFrames(minDurationSeconds, opts.fps), MIN_SEQUENCE_CLIP_FRAMES);
828
+ const ordered = [...segments].sort((a, b) => a.startSeconds - b.startSeconds);
829
+ const chunks = [];
830
+ let cursorFrame = 0;
831
+ for (const segment of ordered) {
832
+ const words = segment.text.trim().split(/\s+/).filter((word) => word.length > 0);
833
+ if (words.length === 0) continue;
834
+ const segmentDurationSeconds = segment.endSeconds - segment.startSeconds;
835
+ const chunkCount = Math.ceil(words.length / maxWordsPerChunk);
836
+ for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
837
+ const wordStart = chunkIndex * maxWordsPerChunk;
838
+ const wordEnd = Math.min(wordStart + maxWordsPerChunk, words.length);
839
+ const naturalStartFrame = secondsToFrames(
840
+ segment.startSeconds + wordStart / words.length * segmentDurationSeconds,
841
+ opts.fps
842
+ );
843
+ const naturalEndFrame = secondsToFrames(
844
+ segment.startSeconds + wordEnd / words.length * segmentDurationSeconds,
845
+ opts.fps
846
+ );
847
+ const startFrame = Math.max(naturalStartFrame, cursorFrame);
848
+ const durationFrames = Math.max(naturalEndFrame - startFrame, minDurationFrames);
849
+ chunks.push({
850
+ text: words.slice(wordStart, wordEnd).join(" "),
851
+ startFrame,
852
+ durationFrames
853
+ });
854
+ cursorFrame = startFrame + durationFrames;
855
+ }
856
+ }
857
+ return chunks;
858
+ }
859
+ var LANGUAGE_TAG_SHAPE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
860
+ function normalizeLanguageTag(tag) {
861
+ const trimmed = tag.trim();
862
+ if (trimmed.length === 0) throw new Error("language tag must be a non-empty string");
863
+ const normalized = trimmed.split("-").map((subtag, index) => {
864
+ const lower = subtag.toLowerCase();
865
+ if (index === 0) return lower;
866
+ if (subtag.length === 4 && /^[a-z]+$/.test(lower)) return lower.charAt(0).toUpperCase() + lower.slice(1);
867
+ if (subtag.length === 2 && /^[a-z]+$/.test(lower)) return lower.toUpperCase();
868
+ return lower;
869
+ }).join("-");
870
+ if (!LANGUAGE_TAG_SHAPE.test(normalized)) {
871
+ throw new Error(`invalid BCP-47 language tag '${tag}' \u2014 expected a shape like 'en', 'pt-BR', or 'zh-Hans'`);
872
+ }
873
+ return normalized;
874
+ }
875
+ function planLanguageFanout(opts) {
876
+ if (opts.languages.length === 0) {
877
+ throw new Error("languages must contain at least one BCP-47 tag");
878
+ }
879
+ const source = opts.sourceLanguage === void 0 ? null : normalizeLanguageTag(opts.sourceLanguage);
880
+ const seen = /* @__PURE__ */ new Set();
881
+ const planned = [];
882
+ for (const raw of opts.languages) {
883
+ const tag = normalizeLanguageTag(raw);
884
+ if (tag === source || seen.has(tag)) continue;
885
+ seen.add(tag);
886
+ planned.push(tag);
887
+ }
888
+ return planned;
889
+ }
890
+ function captionCoverage(timeline) {
891
+ const totalFrames = timeline.sequence.durationFrames;
892
+ if (!Number.isInteger(totalFrames) || totalFrames < 1) {
893
+ throw new Error("sequence durationFrames must be a positive integer");
894
+ }
895
+ const captionTrackIds = new Set(
896
+ timeline.tracks.filter((track) => track.kind === "caption").map((track) => track.id)
897
+ );
898
+ const intervalsByLanguage = /* @__PURE__ */ new Map();
899
+ for (const clip of timeline.clips) {
900
+ if (!captionTrackIds.has(clip.trackId) || clip.disabled) continue;
901
+ if (typeof clip.text !== "string" || clip.text.length === 0) continue;
902
+ const startFrame = Math.max(0, clip.startFrame);
903
+ const endFrame = Math.min(totalFrames, clip.startFrame + clip.durationFrames);
904
+ if (endFrame <= startFrame) continue;
905
+ const language = clip.language ?? null;
906
+ const intervals = intervalsByLanguage.get(language);
907
+ if (intervals) intervals.push({ startFrame, endFrame });
908
+ else intervalsByLanguage.set(language, [{ startFrame, endFrame }]);
909
+ }
910
+ const entries = [];
911
+ for (const [language, intervals] of intervalsByLanguage) {
912
+ const merged = mergeIntervals(intervals);
913
+ const coveredFrames = merged.reduce((sum, interval) => sum + (interval.endFrame - interval.startFrame), 0);
914
+ entries.push({ language, coveredFrames, totalFrames, gaps: complementIntervals(merged, totalFrames) });
915
+ }
916
+ entries.sort((a, b) => {
917
+ if (a.language === null) return b.language === null ? 0 : -1;
918
+ if (b.language === null) return 1;
919
+ return a.language < b.language ? -1 : a.language > b.language ? 1 : 0;
920
+ });
921
+ return entries;
922
+ }
923
+ function mergeIntervals(intervals) {
924
+ const sorted = [...intervals].sort((a, b) => a.startFrame - b.startFrame);
925
+ const merged = [];
926
+ for (const interval of sorted) {
927
+ const last = merged[merged.length - 1];
928
+ if (last && interval.startFrame <= last.endFrame) {
929
+ last.endFrame = Math.max(last.endFrame, interval.endFrame);
930
+ } else {
931
+ merged.push({ startFrame: interval.startFrame, endFrame: interval.endFrame });
932
+ }
933
+ }
934
+ return merged;
935
+ }
936
+ function complementIntervals(merged, totalFrames) {
937
+ const gaps = [];
938
+ let cursor = 0;
939
+ for (const interval of merged) {
940
+ if (interval.startFrame > cursor) gaps.push({ startFrame: cursor, endFrame: interval.startFrame });
941
+ cursor = Math.max(cursor, interval.endFrame);
942
+ }
943
+ if (cursor < totalFrames) gaps.push({ startFrame: cursor, endFrame: totalFrames });
944
+ return gaps;
945
+ }
946
+
947
+ // src/sequences/mcp-tools.ts
948
+ var SEQUENCE_EXPORT_FORMATS = ["mp4", "otio", "xml", "edl", "vtt", "srt", "contact_sheet"];
949
+ var SEQUENCE_TRACK_KINDS = ["video", "audio", "caption", "reference", "agent"];
950
+ var SEQUENCE_MEDIA_KINDS = ["video", "image", "audio"];
951
+ var MAX_CAPTION_BATCH = 500;
952
+ var MAX_INSTRUCTION_ARG_CHARS = 400;
953
+ function isRecord(value) {
954
+ return typeof value === "object" && value !== null && !Array.isArray(value);
955
+ }
956
+ function requireString(args, name) {
957
+ const value = args[name];
958
+ if (typeof value !== "string" || value.trim().length === 0) {
959
+ throw new Error(`${name} is required and must be a non-empty string`);
960
+ }
961
+ return value;
962
+ }
963
+ function optionalString(args, name) {
964
+ const value = args[name];
965
+ if (value === void 0 || value === null) return void 0;
966
+ if (typeof value !== "string" || value.trim().length === 0) {
967
+ throw new Error(`${name} must be a non-empty string when provided`);
968
+ }
969
+ return value;
970
+ }
971
+ function requireSeconds(args, name) {
972
+ const value = args[name];
973
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
974
+ throw new Error(`${name} is required and must be a non-negative number of seconds`);
975
+ }
976
+ return value;
977
+ }
978
+ function optionalSeconds(args, name) {
979
+ if (args[name] === void 0 || args[name] === null) return void 0;
980
+ return requireSeconds(args, name);
981
+ }
982
+ function requireBoolean(args, name) {
983
+ const value = args[name];
984
+ if (typeof value !== "boolean") throw new Error(`${name} is required and must be true or false`);
985
+ return value;
986
+ }
987
+ function optionalPositiveInteger(args, name, max) {
988
+ const value = args[name];
989
+ if (value === void 0 || value === null) return void 0;
990
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max) {
991
+ throw new Error(`${name} must be an integer between 1 and ${max}`);
992
+ }
993
+ return value;
994
+ }
995
+ function requireEnum(args, name, values) {
996
+ const value = args[name];
997
+ if (typeof value !== "string" || !values.includes(value)) {
998
+ throw new Error(`${name} must be one of: ${values.join(", ")}`);
999
+ }
1000
+ return value;
1001
+ }
1002
+ function optionalEnum(args, name, values) {
1003
+ if (args[name] === void 0 || args[name] === null) return void 0;
1004
+ return requireEnum(args, name, values);
1005
+ }
1006
+ function durationArgToFrames(seconds, name, fps) {
1007
+ const frames = secondsToFrames(seconds, fps);
1008
+ if (frames < MIN_SEQUENCE_CLIP_FRAMES) {
1009
+ throw new Error(`${name} (${formatSeconds(seconds)}) is shorter than ${MIN_SEQUENCE_CLIP_FRAMES} frame at ${fps} fps \u2014 minimum is ${formatSeconds(MIN_SEQUENCE_CLIP_FRAMES / fps)}`);
1010
+ }
1011
+ return frames;
1012
+ }
1013
+ function boundsArgsToFrames(startSeconds, durationSeconds, durationName, fps) {
1014
+ const startFrame = secondsToFrames(startSeconds, fps);
1015
+ const durationFrames = secondsToFrames(startSeconds + durationSeconds, fps) - startFrame;
1016
+ if (durationFrames < MIN_SEQUENCE_CLIP_FRAMES) {
1017
+ throw new Error(`${durationName} (${formatSeconds(durationSeconds)}) spans fewer than ${MIN_SEQUENCE_CLIP_FRAMES} frame at ${fps} fps \u2014 minimum is ${formatSeconds(MIN_SEQUENCE_CLIP_FRAMES / fps)}`);
1018
+ }
1019
+ return { startFrame, durationFrames };
1020
+ }
1021
+ function displayedFrameAt(seconds, fps) {
1022
+ return Math.floor(seconds * fps + 1e-6);
1023
+ }
1024
+ function clipView(clip, fps) {
1025
+ const endFrame = clip.startFrame + clip.durationFrames;
1026
+ return {
1027
+ id: clip.id,
1028
+ track_id: clip.trackId,
1029
+ label: clip.label,
1030
+ start_seconds: framesToSeconds(clip.startFrame, fps),
1031
+ duration_seconds: framesToSeconds(clip.durationFrames, fps),
1032
+ end_seconds: framesToSeconds(endFrame, fps),
1033
+ start_timecode: formatTimecode(clip.startFrame, fps),
1034
+ end_timecode: formatTimecode(endFrame, fps),
1035
+ start_frame: clip.startFrame,
1036
+ duration_frames: clip.durationFrames,
1037
+ source_in_frame: clip.sourceInFrame,
1038
+ source_out_frame: clip.sourceOutFrame,
1039
+ disabled: clip.disabled,
1040
+ ...clip.text !== void 0 ? { text: clip.text } : {},
1041
+ ...clip.language !== void 0 ? { language: clip.language } : {},
1042
+ ...clip.generationId !== void 0 ? { generation_id: clip.generationId } : {},
1043
+ ...clip.assetId !== void 0 ? { asset_id: clip.assetId } : {},
1044
+ ...clip.media ? {
1045
+ media: {
1046
+ url: clip.media.url,
1047
+ kind: clip.media.kind,
1048
+ ...clip.media.durationSeconds !== void 0 ? { duration_seconds: clip.media.durationSeconds } : {},
1049
+ ...clip.media.providerStatus !== void 0 ? { provider_status: clip.media.providerStatus } : {}
1050
+ }
1051
+ } : {}
1052
+ };
1053
+ }
1054
+ function trackView(track) {
1055
+ return {
1056
+ id: track.id,
1057
+ kind: track.kind,
1058
+ name: track.name,
1059
+ sort_order: track.sortOrder,
1060
+ locked: track.locked,
1061
+ muted: track.muted
1062
+ };
1063
+ }
1064
+ function sequenceView(sequence) {
1065
+ return {
1066
+ id: sequence.id,
1067
+ title: sequence.title,
1068
+ fps: sequence.fps,
1069
+ width: sequence.width,
1070
+ height: sequence.height,
1071
+ aspect_ratio: sequence.aspectRatio,
1072
+ status: sequence.status,
1073
+ duration_frames: sequence.durationFrames,
1074
+ duration_seconds: framesToSeconds(sequence.durationFrames, sequence.fps),
1075
+ duration_timecode: formatTimecode(sequence.durationFrames, sequence.fps)
1076
+ };
1077
+ }
1078
+ function exportView(record) {
1079
+ return {
1080
+ id: record.id,
1081
+ format: record.format,
1082
+ status: record.status,
1083
+ result_url: record.resultUrl,
1084
+ created_at: record.createdAt.toISOString()
1085
+ };
1086
+ }
1087
+ function decisionView(decision) {
1088
+ return {
1089
+ id: decision.id,
1090
+ clip_id: decision.clipId,
1091
+ kind: decision.kind,
1092
+ instruction: decision.instruction,
1093
+ reasoning_summary: decision.reasoningSummary,
1094
+ accepted: decision.accepted,
1095
+ created_at: decision.createdAt.toISOString()
1096
+ };
1097
+ }
1098
+ function timelineView(timeline, playheadFrame) {
1099
+ const fps = timeline.sequence.fps;
1100
+ return {
1101
+ sequence: sequenceView(timeline.sequence),
1102
+ playhead: {
1103
+ frame: playheadFrame,
1104
+ seconds: framesToSeconds(playheadFrame, fps),
1105
+ timecode: formatTimecode(playheadFrame, fps)
1106
+ },
1107
+ tracks: [...timeline.tracks].sort((a, b) => a.sortOrder - b.sortOrder).map(trackView),
1108
+ clips: [...timeline.clips].sort((a, b) => a.startFrame - b.startFrame).map((clip) => clipView(clip, fps))
1109
+ };
1110
+ }
1111
+ function applyResultView(result, fps) {
1112
+ switch (result.kind) {
1113
+ case "clip":
1114
+ return { kind: "clip", clip: clipView(result.clip, fps) };
1115
+ case "track":
1116
+ return { kind: "track", track: trackView(result.track) };
1117
+ case "export":
1118
+ return { kind: "export", export: exportView(result.record) };
1119
+ case "sequence":
1120
+ return { kind: "sequence", sequence: sequenceView(result.sequence) };
1121
+ }
1122
+ }
1123
+ function instructionSummary(toolName, args) {
1124
+ const body = JSON.stringify(args);
1125
+ const clipped = body.length > MAX_INSTRUCTION_ARG_CHARS ? `${body.slice(0, MAX_INSTRUCTION_ARG_CHARS - 3)}...` : body;
1126
+ return `${toolName} ${clipped}`;
1127
+ }
1128
+ async function runMutation(toolName, args, env, build) {
1129
+ const timeline = await env.store.getTimeline();
1130
+ const fps = timeline.sequence.fps;
1131
+ const { operations, clipId } = build(timeline);
1132
+ const context = { playheadFrame: env.playheadFrame };
1133
+ const results = await applySequenceOperations(env.store, operations, context);
1134
+ const decision = await env.store.recordDecision({
1135
+ clipId: clipId ?? null,
1136
+ kind: "agent_edit",
1137
+ instruction: instructionSummary(toolName, args),
1138
+ metadata: { tool: toolName, operation_count: operations.length }
1139
+ });
1140
+ return {
1141
+ changed: results.map((result) => applyResultView(result, fps)),
1142
+ decision_id: decision.id
1143
+ };
1144
+ }
1145
+ function secondsSchema(description) {
1146
+ return { type: "number", minimum: 0, description };
1147
+ }
1148
+ function objectSchema(properties, required) {
1149
+ return { type: "object", properties, required, additionalProperties: false };
1150
+ }
1151
+ var CLIP_ID_SCHEMA = { type: "string", description: "Clip id from get_timeline_state or get_clip" };
1152
+ var SEQUENCE_MCP_TOOLS = [
1153
+ {
1154
+ name: "get_timeline_state",
1155
+ description: "Read the full timeline: sequence settings (fps, duration), playhead, all tracks, and all clips with positions in seconds and m:ss.ff timecodes plus media URLs and provider status. Call this before editing to get real clip and track ids.",
1156
+ inputSchema: objectSchema({}, []),
1157
+ run: async (_args, env) => {
1158
+ const timeline = await env.store.getTimeline();
1159
+ return timelineView(timeline, env.playheadFrame);
1160
+ }
1161
+ },
1162
+ {
1163
+ name: "get_frame_at_time",
1164
+ description: "What is on screen and audible at one moment. seconds is sequence time (e.g. 12.5). Returns the active clips, visible caption text, and a one-line human-readable summary.",
1165
+ inputSchema: objectSchema({ seconds: secondsSchema("Sequence time in seconds") }, ["seconds"]),
1166
+ run: async (args, env) => {
1167
+ const timeline = await env.store.getTimeline();
1168
+ const fps = timeline.sequence.fps;
1169
+ const frame = displayedFrameAt(requireSeconds(args, "seconds"), fps);
1170
+ const snapshot = snapshotFrame(timeline, frame);
1171
+ const activeParts = snapshot.active.map(({ track, clip }) => {
1172
+ const range = `${formatTimecode(clip.startFrame, fps)}-${formatTimecode(clip.startFrame + clip.durationFrames, fps)}`;
1173
+ return `${track.kind} "${clip.label}" (${range})`;
1174
+ });
1175
+ const captionParts = snapshot.captions.map((caption) => `"${caption.text}"`);
1176
+ const summary = `At ${formatTimecode(frame, fps)} (${formatSeconds(snapshot.seconds)}): ` + (activeParts.length > 0 ? activeParts.join("; ") : "nothing active") + (captionParts.length > 0 ? `. Captions: ${captionParts.join(", ")}` : "");
1177
+ return {
1178
+ frame: snapshot.frame,
1179
+ seconds: snapshot.seconds,
1180
+ timecode: formatTimecode(frame, fps),
1181
+ summary,
1182
+ active: snapshot.active.map(({ track, clip }) => ({ track: trackView(track), clip: clipView(clip, fps) })),
1183
+ captions: snapshot.captions.map((caption) => ({
1184
+ text: caption.text,
1185
+ ...caption.language !== void 0 ? { language: caption.language } : {},
1186
+ clip_id: caption.clipId
1187
+ }))
1188
+ };
1189
+ }
1190
+ },
1191
+ {
1192
+ name: "get_clip",
1193
+ description: "Read one clip by id, including its position (seconds + timecode), source in/out points, text, and media/provider status.",
1194
+ inputSchema: objectSchema({ clip_id: CLIP_ID_SCHEMA }, ["clip_id"]),
1195
+ run: async (args, env) => {
1196
+ const clip = await env.store.getClip(requireString(args, "clip_id"));
1197
+ const timeline = await env.store.getTimeline();
1198
+ return clipView(clip, timeline.sequence.fps);
1199
+ }
1200
+ },
1201
+ {
1202
+ name: "place_clip",
1203
+ description: "Place a new clip on the timeline. Requires label, start_seconds, duration_seconds. Bind playable media with media_url + media_kind (must come together), or reference product media via generation_id / asset_id. track_id targets a specific track; omit it to use the first unlocked track matching the media kind.",
1204
+ inputSchema: objectSchema(
1205
+ {
1206
+ label: { type: "string", description: "Short human-readable clip name" },
1207
+ start_seconds: secondsSchema("Where the clip starts on the timeline, in seconds"),
1208
+ duration_seconds: secondsSchema("Clip length in seconds (at least one frame)"),
1209
+ media_url: { type: "string", description: "Playable media URL; requires media_kind" },
1210
+ media_kind: { type: "string", enum: [...SEQUENCE_MEDIA_KINDS], description: "Kind of the media behind media_url" },
1211
+ generation_id: { type: "string", description: "Product generation row backing this clip" },
1212
+ asset_id: { type: "string", description: "Product asset row backing this clip" },
1213
+ track_id: { type: "string", description: "Target track id; omit for automatic track choice" }
1214
+ },
1215
+ ["label", "start_seconds", "duration_seconds"]
1216
+ ),
1217
+ run: (args, env) => runMutation("place_clip", args, env, (timeline) => {
1218
+ const fps = timeline.sequence.fps;
1219
+ const mediaUrl = optionalString(args, "media_url");
1220
+ const mediaKind = optionalEnum(args, "media_kind", SEQUENCE_MEDIA_KINDS);
1221
+ if (mediaUrl === void 0 !== (mediaKind === void 0)) {
1222
+ throw new Error("media_url and media_kind must be provided together");
1223
+ }
1224
+ const generationId = optionalString(args, "generation_id");
1225
+ const assetId = optionalString(args, "asset_id");
1226
+ const trackId = optionalString(args, "track_id");
1227
+ const bounds = boundsArgsToFrames(
1228
+ requireSeconds(args, "start_seconds"),
1229
+ requireSeconds(args, "duration_seconds"),
1230
+ "duration_seconds",
1231
+ fps
1232
+ );
1233
+ return {
1234
+ operations: [
1235
+ {
1236
+ type: "place_clip",
1237
+ label: requireString(args, "label"),
1238
+ startFrame: bounds.startFrame,
1239
+ durationFrames: bounds.durationFrames,
1240
+ ...trackId !== void 0 ? { trackId } : {},
1241
+ ...mediaUrl !== void 0 && mediaKind !== void 0 ? { media: { url: mediaUrl, kind: mediaKind } } : {},
1242
+ ...generationId !== void 0 ? { generationId } : {},
1243
+ ...assetId !== void 0 ? { assetId } : {}
1244
+ }
1245
+ ]
1246
+ };
1247
+ })
1248
+ },
1249
+ {
1250
+ name: "add_caption",
1251
+ description: 'Add one caption clip. Omit start_seconds and duration_seconds to auto-place roughly 3 seconds of caption near the playhead without overlapping existing captions. language is a BCP-47 tag like "en" or "es".',
1252
+ inputSchema: objectSchema(
1253
+ {
1254
+ text: { type: "string", description: "Caption text" },
1255
+ language: { type: "string", description: "BCP-47 language tag; omit for the sequence default" },
1256
+ start_seconds: secondsSchema("Caption start in seconds; omit to auto-place near the playhead"),
1257
+ duration_seconds: secondsSchema("Caption length in seconds; omit for the ~3s default")
1258
+ },
1259
+ ["text"]
1260
+ ),
1261
+ run: (args, env) => runMutation("add_caption", args, env, (timeline) => {
1262
+ const fps = timeline.sequence.fps;
1263
+ const language = optionalString(args, "language");
1264
+ const startSeconds = optionalSeconds(args, "start_seconds");
1265
+ const durationSeconds = optionalSeconds(args, "duration_seconds");
1266
+ const bounds = startSeconds !== void 0 && durationSeconds !== void 0 ? boundsArgsToFrames(startSeconds, durationSeconds, "duration_seconds", fps) : {
1267
+ ...startSeconds !== void 0 ? { startFrame: secondsToFrames(startSeconds, fps) } : {},
1268
+ ...durationSeconds !== void 0 ? { durationFrames: durationArgToFrames(durationSeconds, "duration_seconds", fps) } : {}
1269
+ };
1270
+ return {
1271
+ operations: [
1272
+ {
1273
+ type: "add_caption",
1274
+ text: requireString(args, "text"),
1275
+ ...language !== void 0 ? { language } : {},
1276
+ ...bounds
1277
+ }
1278
+ ]
1279
+ };
1280
+ })
1281
+ },
1282
+ {
1283
+ name: "add_captions",
1284
+ description: `Add many caption clips in one call \u2014 use this for transcription output instead of repeated add_caption. Each item needs text, start_seconds, duration_seconds. One shared language applies to every caption. Max ${MAX_CAPTION_BATCH} per call.`,
1285
+ inputSchema: objectSchema(
1286
+ {
1287
+ captions: {
1288
+ type: "array",
1289
+ minItems: 1,
1290
+ maxItems: MAX_CAPTION_BATCH,
1291
+ description: "Caption entries in timeline order",
1292
+ items: objectSchema(
1293
+ {
1294
+ text: { type: "string", description: "Caption text" },
1295
+ start_seconds: secondsSchema("Caption start in seconds"),
1296
+ duration_seconds: secondsSchema("Caption length in seconds")
1297
+ },
1298
+ ["text", "start_seconds", "duration_seconds"]
1299
+ )
1300
+ },
1301
+ language: { type: "string", description: "BCP-47 language tag applied to every caption in the batch" }
1302
+ },
1303
+ ["captions"]
1304
+ ),
1305
+ run: (args, env) => runMutation("add_captions", args, env, (timeline) => {
1306
+ const fps = timeline.sequence.fps;
1307
+ const language = optionalString(args, "language");
1308
+ const raw = args.captions;
1309
+ if (!Array.isArray(raw) || raw.length === 0) {
1310
+ throw new Error("captions is required and must be a non-empty array of {text, start_seconds, duration_seconds}");
1311
+ }
1312
+ if (raw.length > MAX_CAPTION_BATCH) {
1313
+ throw new Error(`captions has ${raw.length} entries \u2014 max ${MAX_CAPTION_BATCH} per call; split the batch`);
1314
+ }
1315
+ const operations = raw.map((entry, index) => {
1316
+ if (!isRecord(entry)) throw new Error(`captions[${index}] must be an object with text, start_seconds, duration_seconds`);
1317
+ try {
1318
+ const bounds = boundsArgsToFrames(
1319
+ requireSeconds(entry, "start_seconds"),
1320
+ requireSeconds(entry, "duration_seconds"),
1321
+ "duration_seconds",
1322
+ fps
1323
+ );
1324
+ return {
1325
+ type: "add_caption",
1326
+ text: requireString(entry, "text"),
1327
+ startFrame: bounds.startFrame,
1328
+ durationFrames: bounds.durationFrames,
1329
+ ...language !== void 0 ? { language } : {}
1330
+ };
1331
+ } catch (err) {
1332
+ throw new Error(`captions[${index}]: ${err instanceof Error ? err.message : String(err)}`);
1333
+ }
1334
+ });
1335
+ return { operations };
1336
+ })
1337
+ },
1338
+ {
1339
+ name: "move_clip",
1340
+ description: "Move a clip so it starts at start_seconds, optionally onto another track via track_id. Duration and source in/out points are unchanged.",
1341
+ inputSchema: objectSchema(
1342
+ {
1343
+ clip_id: CLIP_ID_SCHEMA,
1344
+ start_seconds: secondsSchema("New clip start on the timeline, in seconds"),
1345
+ track_id: { type: "string", description: "Destination track id; omit to stay on the current track" }
1346
+ },
1347
+ ["clip_id", "start_seconds"]
1348
+ ),
1349
+ run: (args, env) => {
1350
+ const clipId = requireString(args, "clip_id");
1351
+ return runMutation("move_clip", args, env, (timeline) => {
1352
+ const trackId = optionalString(args, "track_id");
1353
+ return {
1354
+ clipId,
1355
+ operations: [
1356
+ {
1357
+ type: "move_clip",
1358
+ clipId,
1359
+ startFrame: secondsToFrames(requireSeconds(args, "start_seconds"), timeline.sequence.fps),
1360
+ ...trackId !== void 0 ? { trackId } : {}
1361
+ }
1362
+ ]
1363
+ };
1364
+ });
1365
+ }
1366
+ },
1367
+ {
1368
+ name: "trim_clip",
1369
+ description: "Set a clip to start_seconds + duration_seconds. source_in_seconds re-anchors where playback begins inside the source media (use it when trimming the head so the visible content stays aligned). A clip with an explicit source out-point (e.g. a split half) cannot grow past it \u2014 pass source_out_seconds to move the out-point when extending.",
1370
+ inputSchema: objectSchema(
1371
+ {
1372
+ clip_id: CLIP_ID_SCHEMA,
1373
+ start_seconds: secondsSchema("Clip start on the timeline, in seconds"),
1374
+ duration_seconds: secondsSchema("New clip length in seconds (at least one frame)"),
1375
+ source_in_seconds: secondsSchema("Offset into the source media where playback begins, in seconds"),
1376
+ source_out_seconds: secondsSchema("Offset into the source media where playback ends, in seconds; only needed to extend past a stored out-point")
1377
+ },
1378
+ ["clip_id", "start_seconds", "duration_seconds"]
1379
+ ),
1380
+ run: (args, env) => {
1381
+ const clipId = requireString(args, "clip_id");
1382
+ return runMutation("trim_clip", args, env, (timeline) => {
1383
+ const fps = timeline.sequence.fps;
1384
+ const sourceInSeconds = optionalSeconds(args, "source_in_seconds");
1385
+ const sourceOutSeconds = optionalSeconds(args, "source_out_seconds");
1386
+ const bounds = boundsArgsToFrames(
1387
+ requireSeconds(args, "start_seconds"),
1388
+ requireSeconds(args, "duration_seconds"),
1389
+ "duration_seconds",
1390
+ fps
1391
+ );
1392
+ return {
1393
+ clipId,
1394
+ operations: [
1395
+ {
1396
+ type: "trim_clip",
1397
+ clipId,
1398
+ startFrame: bounds.startFrame,
1399
+ durationFrames: bounds.durationFrames,
1400
+ ...sourceInSeconds !== void 0 ? { sourceInFrame: secondsToFrames(sourceInSeconds, fps) } : {},
1401
+ ...sourceOutSeconds !== void 0 ? { sourceOutFrame: secondsToFrames(sourceOutSeconds, fps) } : {}
1402
+ }
1403
+ ]
1404
+ };
1405
+ });
1406
+ }
1407
+ },
1408
+ {
1409
+ name: "split_clip",
1410
+ description: "Cut a clip into two at at_seconds (sequence time, strictly inside the clip). The original clip id keeps the left half; the returned clip is the new right half with its source in-point re-anchored at the cut.",
1411
+ inputSchema: objectSchema(
1412
+ {
1413
+ clip_id: CLIP_ID_SCHEMA,
1414
+ at_seconds: secondsSchema("Sequence time of the cut, in seconds; must fall strictly inside the clip")
1415
+ },
1416
+ ["clip_id", "at_seconds"]
1417
+ ),
1418
+ run: (args, env) => {
1419
+ const clipId = requireString(args, "clip_id");
1420
+ return runMutation("split_clip", args, env, (timeline) => ({
1421
+ clipId,
1422
+ operations: [
1423
+ {
1424
+ type: "split_clip",
1425
+ clipId,
1426
+ atFrame: secondsToFrames(requireSeconds(args, "at_seconds"), timeline.sequence.fps)
1427
+ }
1428
+ ]
1429
+ }));
1430
+ }
1431
+ },
1432
+ {
1433
+ name: "set_clip_text",
1434
+ description: "Replace a caption clip's text, optionally changing its BCP-47 language tag.",
1435
+ inputSchema: objectSchema(
1436
+ {
1437
+ clip_id: CLIP_ID_SCHEMA,
1438
+ text: { type: "string", description: "New caption text" },
1439
+ language: { type: "string", description: "BCP-47 language tag; omit to keep the current one" }
1440
+ },
1441
+ ["clip_id", "text"]
1442
+ ),
1443
+ run: (args, env) => {
1444
+ const clipId = requireString(args, "clip_id");
1445
+ return runMutation("set_clip_text", args, env, () => {
1446
+ const language = optionalString(args, "language");
1447
+ return {
1448
+ clipId,
1449
+ operations: [
1450
+ {
1451
+ type: "set_clip_text",
1452
+ clipId,
1453
+ text: requireString(args, "text"),
1454
+ ...language !== void 0 ? { language } : {}
1455
+ }
1456
+ ]
1457
+ };
1458
+ });
1459
+ }
1460
+ },
1461
+ {
1462
+ name: "delete_clip",
1463
+ description: "Remove a clip from the timeline permanently. Prefer set_clip_disabled to audition a cut without losing the clip.",
1464
+ inputSchema: objectSchema({ clip_id: CLIP_ID_SCHEMA }, ["clip_id"]),
1465
+ run: (args, env) => {
1466
+ const clipId = requireString(args, "clip_id");
1467
+ return runMutation("delete_clip", args, env, () => ({
1468
+ operations: [{ type: "delete_clip", clipId }]
1469
+ }));
1470
+ }
1471
+ },
1472
+ {
1473
+ name: "set_clip_disabled",
1474
+ description: "Disable (true) or re-enable (false) a clip without deleting it. Disabled clips do not render or sound.",
1475
+ inputSchema: objectSchema(
1476
+ {
1477
+ clip_id: CLIP_ID_SCHEMA,
1478
+ disabled: { type: "boolean", description: "true hides the clip; false restores it" }
1479
+ },
1480
+ ["clip_id", "disabled"]
1481
+ ),
1482
+ run: (args, env) => {
1483
+ const clipId = requireString(args, "clip_id");
1484
+ return runMutation("set_clip_disabled", args, env, () => ({
1485
+ clipId,
1486
+ operations: [{ type: "set_clip_disabled", clipId, disabled: requireBoolean(args, "disabled") }]
1487
+ }));
1488
+ }
1489
+ },
1490
+ {
1491
+ name: "create_track",
1492
+ description: `Add a track to the sequence. kind is one of: ${SEQUENCE_TRACK_KINDS.join(", ")}. New tracks sort below existing ones.`,
1493
+ inputSchema: objectSchema(
1494
+ {
1495
+ kind: { type: "string", enum: [...SEQUENCE_TRACK_KINDS], description: "Track kind" },
1496
+ name: { type: "string", description: "Track display name" }
1497
+ },
1498
+ ["kind", "name"]
1499
+ ),
1500
+ run: (args, env) => runMutation("create_track", args, env, () => ({
1501
+ operations: [
1502
+ {
1503
+ type: "create_track",
1504
+ kind: requireEnum(args, "kind", SEQUENCE_TRACK_KINDS),
1505
+ name: requireString(args, "name")
1506
+ }
1507
+ ]
1508
+ }))
1509
+ },
1510
+ {
1511
+ name: "extend_sequence",
1512
+ description: "Set the sequence's total duration in seconds. Growing always works; shrinking is rejected if any clip would fall past the new end.",
1513
+ inputSchema: objectSchema(
1514
+ { duration_seconds: secondsSchema("New total sequence duration in seconds") },
1515
+ ["duration_seconds"]
1516
+ ),
1517
+ run: (args, env) => runMutation("extend_sequence", args, env, (timeline) => ({
1518
+ operations: [
1519
+ {
1520
+ type: "extend_sequence",
1521
+ durationFrames: durationArgToFrames(requireSeconds(args, "duration_seconds"), "duration_seconds", timeline.sequence.fps)
1522
+ }
1523
+ ]
1524
+ }))
1525
+ },
1526
+ {
1527
+ name: "queue_export",
1528
+ description: `Queue an export of the sequence. format is one of: ${SEQUENCE_EXPORT_FORMATS.join(", ")}. Returns the queued export record; rendering happens asynchronously.`,
1529
+ inputSchema: objectSchema(
1530
+ { format: { type: "string", enum: [...SEQUENCE_EXPORT_FORMATS], description: "Export format" } },
1531
+ ["format"]
1532
+ ),
1533
+ run: (args, env) => runMutation("queue_export", args, env, () => ({
1534
+ operations: [{ type: "queue_export", format: requireEnum(args, "format", SEQUENCE_EXPORT_FORMATS) }]
1535
+ }))
1536
+ },
1537
+ {
1538
+ name: "list_decisions",
1539
+ description: "Read the sequence's edit-decision log (human edits, agent edits, exports, notes), newest first. limit caps the number of rows.",
1540
+ inputSchema: objectSchema(
1541
+ { limit: { type: "integer", minimum: 1, maximum: 1e3, description: "Max rows to return" } },
1542
+ []
1543
+ ),
1544
+ run: async (args, env) => {
1545
+ const limit = optionalPositiveInteger(args, "limit", 1e3);
1546
+ const decisions = await env.store.listDecisions(limit);
1547
+ return { decisions: decisions.map(decisionView) };
1548
+ }
1549
+ }
1550
+ ];
1551
+ function findSequenceMcpTool(name) {
1552
+ return SEQUENCE_MCP_TOOLS.find((tool) => tool.name === name);
1553
+ }
1554
+
1555
+ // src/sequences/mcp-handler.ts
1556
+ var SEQUENCES_MCP_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
1557
+ var LATEST_PROTOCOL_VERSION = SEQUENCES_MCP_PROTOCOL_VERSIONS[0];
1558
+ function isRecord2(value) {
1559
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1560
+ }
1561
+ function rpcResult(id, result) {
1562
+ return Response.json({ jsonrpc: "2.0", id, result });
1563
+ }
1564
+ function rpcError(id, code, message, status = 200) {
1565
+ return Response.json({ jsonrpc: "2.0", id, error: { code, message } }, { status });
1566
+ }
1567
+ function createSequencesMcpHandler(opts) {
1568
+ const playheadFrame = opts.playheadFrame ?? 0;
1569
+ if (!Number.isInteger(playheadFrame) || playheadFrame < 0) {
1570
+ throw new Error("playheadFrame must be a non-negative integer (frames at the sequence fps)");
1571
+ }
1572
+ const serverInfo = opts.serverInfo ?? { name: "sequences", version: "1.0.0" };
1573
+ return async (request) => {
1574
+ if (request.method !== "POST") {
1575
+ return new Response("sequences MCP accepts JSON-RPC 2.0 over POST only", {
1576
+ status: 405,
1577
+ headers: { Allow: "POST" }
1578
+ });
1579
+ }
1580
+ let body;
1581
+ try {
1582
+ body = await request.json();
1583
+ } catch {
1584
+ return rpcError(null, -32700, "Parse error: request body is not valid JSON", 400);
1585
+ }
1586
+ if (Array.isArray(body)) {
1587
+ return rpcError(null, -32600, "Invalid request: JSON-RPC batching is not supported", 400);
1588
+ }
1589
+ if (!isRecord2(body) || body.jsonrpc !== "2.0" || typeof body.method !== "string") {
1590
+ return rpcError(null, -32600, 'Invalid request: expected a JSON-RPC 2.0 object with jsonrpc "2.0" and a string method', 400);
1591
+ }
1592
+ const method = body.method;
1593
+ const params = isRecord2(body.params) ? body.params : {};
1594
+ if (!("id" in body) || body.id === void 0) {
1595
+ return new Response(null, { status: 202 });
1596
+ }
1597
+ const id = body.id;
1598
+ switch (method) {
1599
+ case "initialize": {
1600
+ const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : void 0;
1601
+ const protocolVersion = requested !== void 0 && SEQUENCES_MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : LATEST_PROTOCOL_VERSION;
1602
+ return rpcResult(id, {
1603
+ protocolVersion,
1604
+ capabilities: { tools: { listChanged: false } },
1605
+ serverInfo
1606
+ });
1607
+ }
1608
+ case "ping":
1609
+ return rpcResult(id, {});
1610
+ case "tools/list":
1611
+ return rpcResult(id, {
1612
+ tools: SEQUENCE_MCP_TOOLS.map((tool) => ({
1613
+ name: tool.name,
1614
+ description: tool.description,
1615
+ inputSchema: tool.inputSchema
1616
+ }))
1617
+ });
1618
+ case "tools/call": {
1619
+ const name = params.name;
1620
+ if (typeof name !== "string" || name.length === 0) {
1621
+ return rpcError(id, -32602, "tools/call requires params.name (string)");
1622
+ }
1623
+ const tool = findSequenceMcpTool(name);
1624
+ if (!tool) {
1625
+ return rpcError(
1626
+ id,
1627
+ -32602,
1628
+ `Unknown tool: ${name}. Available tools: ${SEQUENCE_MCP_TOOLS.map((t) => t.name).join(", ")}`
1629
+ );
1630
+ }
1631
+ if (params.arguments !== void 0 && !isRecord2(params.arguments)) {
1632
+ return rpcError(id, -32602, "tools/call params.arguments must be an object when provided");
1633
+ }
1634
+ const args = isRecord2(params.arguments) ? params.arguments : {};
1635
+ try {
1636
+ const result = await tool.run(args, { store: opts.store, playheadFrame });
1637
+ const payload = { content: [{ type: "text", text: JSON.stringify(result) }] };
1638
+ return rpcResult(id, payload);
1639
+ } catch (err) {
1640
+ const message = err instanceof Error ? err.message : String(err);
1641
+ const payload = {
1642
+ content: [{ type: "text", text: `${name} failed: ${message}` }],
1643
+ isError: true
1644
+ };
1645
+ return rpcResult(id, payload);
1646
+ }
1647
+ }
1648
+ default:
1649
+ return rpcError(id, -32601, `Method not found: ${method}`);
1650
+ }
1651
+ };
1652
+ }
1653
+
1654
+ // src/sequences/mcp-entry.ts
1655
+ var DEFAULT_SEQUENCES_MCP_DESCRIPTION = "Live timeline editor for the current video sequence: read timeline state, place/move/trim/split clips, add captions, manage tracks, and queue exports. All times are seconds.";
1656
+ function buildSequencesMcpServerEntry(opts) {
1657
+ if (opts.token.trim().length === 0) {
1658
+ throw new Error("buildSequencesMcpServerEntry requires a capability token \u2014 omit the sequences MCP server when none is available");
1659
+ }
1660
+ if (!opts.path.startsWith("/")) {
1661
+ throw new Error(`buildSequencesMcpServerEntry path must start with "/" (got "${opts.path}")`);
1662
+ }
1663
+ const description = opts.description ?? DEFAULT_SEQUENCES_MCP_DESCRIPTION;
1664
+ if (opts.ctx) {
1665
+ return buildHttpMcpServer({
1666
+ path: opts.path,
1667
+ baseUrl: opts.baseUrl,
1668
+ token: opts.token,
1669
+ ctx: opts.ctx,
1670
+ description,
1671
+ headerNames: opts.headerNames ?? DEFAULT_HEADER_NAMES
1672
+ });
1673
+ }
1674
+ return {
1675
+ transport: "http",
1676
+ url: `${opts.baseUrl.replace(/\/+$/, "")}${opts.path}`,
1677
+ headers: {
1678
+ Authorization: `Bearer ${opts.token}`,
1679
+ "Content-Type": "application/json"
1680
+ },
1681
+ enabled: true,
1682
+ metadata: { description }
1683
+ };
1684
+ }
1685
+
1686
+ export {
1687
+ SEQUENCE_OPERATION_TYPES,
1688
+ validateSequenceOperations,
1689
+ validateSequenceOperation,
1690
+ validatePlaceClip,
1691
+ validateAddCaption,
1692
+ validateMoveClip,
1693
+ validateTrimClip,
1694
+ validateSplitClip,
1695
+ validateSetClipText,
1696
+ validateSetClipDisabled,
1697
+ validateDeleteClip,
1698
+ validateCreateTrack,
1699
+ validateExtendSequence,
1700
+ validateQueueExport,
1701
+ parseSequenceOperations,
1702
+ captionTrackNameForLanguage,
1703
+ resolvePlaceClipTrack,
1704
+ resolveCaptionTarget,
1705
+ resolveCaptionPlacement,
1706
+ lastClipEndFrame,
1707
+ assertSequenceMediaUrl,
1708
+ applySequenceOperations,
1709
+ applySequenceOperation,
1710
+ buildSrt,
1711
+ buildVtt,
1712
+ buildEdl,
1713
+ buildOtio,
1714
+ buildContactSheetManifest,
1715
+ buildCaptionChunks,
1716
+ normalizeLanguageTag,
1717
+ planLanguageFanout,
1718
+ captionCoverage,
1719
+ SEQUENCE_EXPORT_FORMATS,
1720
+ SEQUENCE_TRACK_KINDS,
1721
+ SEQUENCE_MEDIA_KINDS,
1722
+ MAX_CAPTION_BATCH,
1723
+ SEQUENCE_MCP_TOOLS,
1724
+ findSequenceMcpTool,
1725
+ SEQUENCES_MCP_PROTOCOL_VERSIONS,
1726
+ createSequencesMcpHandler,
1727
+ DEFAULT_SEQUENCES_MCP_DESCRIPTION,
1728
+ buildSequencesMcpServerEntry
1729
+ };
1730
+ //# sourceMappingURL=chunk-3WAJWYKD.js.map