@miraiclip/assistant 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -44,6 +44,21 @@ function parseToolArguments(raw) {
44
44
  return { __invalidArguments: raw };
45
45
  }
46
46
  }
47
+ var RETRYABLE = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
48
+ function retryDelayMs(res, message, attempt) {
49
+ const header = Number(res.headers.get("retry-after-ms") ?? NaN) || Number(res.headers.get("retry-after") ?? NaN) * 1e3;
50
+ const m = /try again in ([\d.]+)\s*(ms|s)/i.exec(message);
51
+ const asked = header || (m ? Number(m[1]) * (m[2].toLowerCase() === "ms" ? 1 : 1e3) : 0);
52
+ const backoff = Math.min(3e4, 1e3 * 2 ** attempt);
53
+ return Math.round(Math.min(6e4, Math.max(asked + 250, asked ? 0 : backoff)) * (1 + Math.random() * 0.25));
54
+ }
55
+ function sleep(ms, signal) {
56
+ return new Promise((resolve, reject) => {
57
+ if (signal?.aborted) return reject(signal.reason ?? new Error("aborted"));
58
+ const t = setTimeout(resolve, ms);
59
+ signal?.addEventListener("abort", () => (clearTimeout(t), reject(signal.reason ?? new Error("aborted"))), { once: true });
60
+ });
61
+ }
47
62
  var FINISH = {
48
63
  stop: "stop",
49
64
  tool_calls: "tool_calls",
@@ -75,15 +90,21 @@ function openAIChatModel(options) {
75
90
  if (options.apiKey) headers["Authorization"] = `Bearer ${options.apiKey}`;
76
91
  if (options.organization) headers["OpenAI-Organization"] = options.organization;
77
92
  if (options.project) headers["OpenAI-Project"] = options.project;
78
- const res = await doFetch(`${base}/chat/completions`, {
79
- method: "POST",
80
- headers,
81
- body: JSON.stringify(body),
82
- ...opts.signal ? { signal: opts.signal } : {}
83
- });
84
- if (!res.ok) {
93
+ const maxRetries = options.maxRetries ?? 4;
94
+ let res;
95
+ for (let attempt = 0; ; attempt++) {
96
+ res = await doFetch(`${base}/chat/completions`, {
97
+ method: "POST",
98
+ headers,
99
+ body: JSON.stringify(body),
100
+ ...opts.signal ? { signal: opts.signal } : {}
101
+ });
102
+ if (res.ok) break;
85
103
  const err = await res.json().catch(() => null);
86
- throw new ChatModelError(id, err?.error?.message ?? `HTTP ${res.status}`, res.status);
104
+ const message = err?.error?.message ?? `HTTP ${res.status}`;
105
+ const permanent = /quota|credits|billing/i.test(message);
106
+ if (attempt >= maxRetries || !RETRYABLE.has(res.status) || permanent) throw new ChatModelError(id, message, res.status);
107
+ await sleep(retryDelayMs(res, message, attempt), opts.signal);
87
108
  }
88
109
  if (!stream || !res.body || !(res.headers.get("content-type") ?? "").includes("event-stream")) {
89
110
  return fromCompletion(await res.json(), opts.onText);
@@ -260,6 +281,161 @@ async function remoteChatModel(endpoint, options = {}) {
260
281
  };
261
282
  }
262
283
 
284
+ // src/limits.ts
285
+ function rateLimitedChatModel(model, options) {
286
+ const tpm = options.tokensPerMinute ?? Infinity;
287
+ const rpm = options.requestsPerMinute ?? Infinity;
288
+ const estimate = options.estimate ?? ((r) => Math.ceil(JSON.stringify(r).length / 4) + (r.maxOutputTokens ?? 1e3));
289
+ const window = [];
290
+ let gate = Promise.resolve();
291
+ const prune = (now) => {
292
+ while (window.length && now - window[0].at >= 6e4) window.shift();
293
+ };
294
+ const used = () => window.reduce((n2, e) => n2 + e.tokens, 0);
295
+ const reserve = (tokens, signal) => {
296
+ const want = Math.min(tokens, tpm);
297
+ const turn = gate.then(async () => {
298
+ for (; ; ) {
299
+ if (signal?.aborted) throw signal.reason ?? new Error("aborted");
300
+ const now = Date.now();
301
+ prune(now);
302
+ if (window.length < rpm && used() + want <= tpm) break;
303
+ const oldest = window[0];
304
+ await new Promise((r) => setTimeout(r, oldest ? Math.max(50, 6e4 - (now - oldest.at)) : 50));
305
+ }
306
+ const entry = { at: Date.now(), tokens: want };
307
+ window.push(entry);
308
+ return entry;
309
+ });
310
+ gate = turn.then(
311
+ () => void 0,
312
+ () => void 0
313
+ );
314
+ return turn;
315
+ };
316
+ return {
317
+ ...model,
318
+ async complete(request, opts = {}) {
319
+ const entry = await reserve(estimate(request), opts.signal);
320
+ const response = await model.complete(request, opts);
321
+ const real = (response.usage?.inputTokens ?? 0) + (response.usage?.outputTokens ?? 0);
322
+ if (real > 0) entry.tokens = real;
323
+ return response;
324
+ }
325
+ };
326
+ }
327
+
328
+ // src/state.ts
329
+ import { animationPreset, describeAnimation, describeProject, findCuts, keyframeTimeUs, readAnimation } from "@miraiclip/core";
330
+ function describeForAssistant(doc) {
331
+ const lines = [
332
+ describeProject(doc),
333
+ "",
334
+ "track names: " + doc.trackOrder.map((id) => {
335
+ const t = doc.tracks[id];
336
+ const flags = [t?.hidden && "hidden", t?.muted && "muted", t?.solo && "solo", t?.locked && "locked"].filter(Boolean);
337
+ return `${id} = "${t?.name ?? id}"${flags.length ? ` (${flags.join(", ")})` : ""}`;
338
+ }).join(", ") + " (track flags muted / solo / locked / hidden: track/set-property)"
339
+ ];
340
+ const cuts = findCuts(doc);
341
+ lines.push(
342
+ cuts.length ? `cuts (where one clip ends and the next starts on the same track; transitions go here): ${cuts.map((c) => `${n(c.atUs / 1e6)}s ${c.fromClipId}\u2192${c.toClipId}${c.transition ? ` (${c.transition.kind})` : ""}`).join(", ")}` : "cuts: none"
343
+ );
344
+ const clips = Object.values(doc.clips).sort((a, b) => a.trackId.localeCompare(b.trackId) || a.startUs - b.startUs);
345
+ if (clips.length) {
346
+ lines.push("clip details (transform x/y: 0.5 = center; scale 1 = full size; times in seconds):");
347
+ for (const clip of clips) lines.push(`- ${clip.id}: ${details(clip)}`);
348
+ }
349
+ return lines.join("\n");
350
+ }
351
+ var n = (v) => Number(v.toFixed(3));
352
+ function details(clip) {
353
+ const parts = [`${clip.kind} on ${clip.trackId}`, `${n(clip.startUs / 1e6)}\u2013${n((clip.startUs + clip.durationUs) / 1e6)}s`];
354
+ if (clip.kind !== "audio") {
355
+ const t = clip.transform;
356
+ parts.push(`x ${n(t.x)} y ${n(t.y)} scale ${n(t.scale)}${t.rotation ? ` rotation ${n(t.rotation)}\xB0` : ""} opacity ${n(t.opacity)}`);
357
+ }
358
+ if (clip.kind === "text") {
359
+ const c = clip;
360
+ parts.push(`font ${c.fontFamily} ${c.fontSizePx}px${c.fontWeight ? ` weight ${c.fontWeight}` : ""} color ${c.color}${c.textAlign ? ` align ${c.textAlign}` : ""}`);
361
+ }
362
+ if (clip.kind === "caption") {
363
+ const c = clip;
364
+ parts.push(
365
+ `words "${c.words.map((w) => w.text).join(" ").slice(0, 80)}" \xB7 style preset ${c.style.preset} color ${c.style.color} highlight ${c.style.highlightColor} box ${c.style.backgroundColor ?? "none"} (style.backgroundColor)`
366
+ );
367
+ }
368
+ if (clip.kind === "html") {
369
+ const c = clip;
370
+ parts.push(`params ${JSON.stringify(c.params)} (template placeholders: ${htmlPlaceholders(c.template).join(", ") || "none"})`);
371
+ }
372
+ if ("volume" in clip) {
373
+ const c = clip;
374
+ parts.push(`volume ${n(c.volume)}${c.fadeInUs ? ` fade-in ${n(c.fadeInUs / 1e6)}s` : ""}${c.fadeOutUs ? ` fade-out ${n(c.fadeOutUs / 1e6)}s` : ""}`);
375
+ parts.push(`source from ${n(c.trimStartUs / 1e6)}s`);
376
+ }
377
+ if (clip.effects?.length) parts.push(`effects ${clip.effects.map((e) => `${e.kind}#${e.id}${e.enabled ? "" : " (off)"}`).join(", ")}`);
378
+ const volumeKeys = clip.animations?.volume ?? [];
379
+ if (volumeKeys.length) parts.push(`volume keyframes ${volumeKeys.map((k) => `${n((clip.startUs + keyframeTimeUs(k, clip.durationUs)) / 1e6)}s=${n(k.value)}`).join(", ")}`);
380
+ const visual = Object.entries(clip.animations ?? {}).some(([prop, list]) => prop !== "volume" && list && list.length);
381
+ if (visual) {
382
+ const read = readClipAnimation(clip);
383
+ parts.push(read.custom ? `custom keyframes (not presets): ${describeKeyframes(clip)}` : `animation ${describeAnimation(read.recipe)}`);
384
+ }
385
+ return parts.join(" \xB7 ");
386
+ }
387
+ function htmlPlaceholders(template) {
388
+ return [...new Set([...template.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)].map((m) => m[1]))];
389
+ }
390
+ function readClipAnimation(clip) {
391
+ const read = readAnimation(clip);
392
+ if (read.custom) return { recipe: {}, custom: true };
393
+ if (read.stale && read.recipe.loop) {
394
+ const preset = animationPreset(read.recipe.loop.preset);
395
+ const props = Object.keys(preset?.build(clip.transform, 1e6, "linear") ?? {});
396
+ const count = props.reduce((sum, prop) => sum + (clip.animations?.[prop]?.length ?? 0), 0);
397
+ if (count < 4) return { recipe: {}, custom: true };
398
+ }
399
+ return { recipe: read.recipe, custom: false };
400
+ }
401
+ function describeKeyframes(clip) {
402
+ return Object.entries(clip.animations ?? {}).filter(([prop, list]) => prop !== "volume" && list?.length).map(([prop, list]) => `${prop} ${list.map((k) => `${n((clip.startUs + keyframeTimeUs(k, clip.durationUs)) / 1e6)}s ${n(k.value)}`).join(" \u2192 ")}`).join("; ");
403
+ }
404
+
405
+ // src/strict.ts
406
+ function branches(schema) {
407
+ const alts = schema.anyOf ?? schema.oneOf;
408
+ return alts ? alts.flatMap(branches) : [schema];
409
+ }
410
+ function unknownIn(schema, value, prefix = "") {
411
+ const props = schema.properties;
412
+ if (!props || schema.additionalProperties !== void 0 && schema.additionalProperties !== false) return [];
413
+ const out = [];
414
+ for (const [key, v] of Object.entries(value)) {
415
+ const sub = props[key];
416
+ if (!sub) out.push(prefix + key);
417
+ else if (!prefix && v && typeof v === "object" && !Array.isArray(v)) {
418
+ const objectBranch = branches(sub).find((b) => b.properties);
419
+ if (objectBranch) out.push(...unknownIn(objectBranch, v, `${key}.`));
420
+ }
421
+ }
422
+ return out;
423
+ }
424
+ function unknownFields(schema, payload) {
425
+ if (!schema || !payload || typeof payload !== "object" || Array.isArray(payload)) return null;
426
+ const value = payload;
427
+ let candidates = branches(schema).filter((b) => b.properties);
428
+ if (candidates.length === 0) return null;
429
+ const byKind = candidates.filter((b) => b.properties.kind?.const !== void 0 && b.properties.kind.const === value.kind);
430
+ if (byKind.length) candidates = byKind;
431
+ let best = null;
432
+ for (const b of candidates) {
433
+ const unknown = unknownIn(b, value);
434
+ if (!best || unknown.length < best.unknown.length) best = { unknown, allowed: Object.keys(b.properties) };
435
+ }
436
+ return best && best.unknown.length ? best : null;
437
+ }
438
+
263
439
  // src/tools.ts
264
440
  import {
265
441
  ANIMATION_EASINGS,
@@ -268,12 +444,12 @@ import {
268
444
  applyCommands,
269
445
  commandCatalog,
270
446
  builtinTransitionParamSchemas,
271
- describeAnimation,
272
- describeProject,
273
- findCuts,
447
+ describeAnimation as describeAnimation2,
448
+ EFFECT_CATALOG,
449
+ findCuts as findCuts2,
274
450
  fitAnimation,
275
- readAnimation,
276
- toToolDefinitions
451
+ toToolDefinitions,
452
+ tryDispatch
277
453
  } from "@miraiclip/core";
278
454
  function defineTool(tool) {
279
455
  if (!/^[a-zA-Z0-9_-]{1,64}$/.test(tool.name)) throw new Error(`Tool name must match ^[a-zA-Z0-9_-]{1,64}$ (got "${tool.name}")`);
@@ -297,7 +473,17 @@ function toolsFromDefinitions(definitions, run, options = {}) {
297
473
  });
298
474
  }
299
475
  var S = 1e6;
476
+ var SLIDE_IN = { "from-right": "left", "from-left": "right", "from-bottom": "up", "from-top": "down" };
477
+ var SLIDE_OUT = { "to-left": "left", "to-right": "right", "to-top": "up", "to-bottom": "down" };
478
+ function slideAliases(input) {
479
+ const out = {};
480
+ if (typeof input.in === "string" && SLIDE_IN[input.in]) out.in = SLIDE_IN[input.in];
481
+ if (typeof input.out === "string" && SLIDE_OUT[input.out]) out.out = SLIDE_OUT[input.out];
482
+ return out;
483
+ }
484
+ var BACKGROUND_TEMPLATE = '<div style="width:100%;height:100%;background:{{color}}"></div>';
300
485
  var seconds = (us) => `${Math.round(us / 1e5) / 10}s`;
486
+ var n3 = (v) => Number(v.toFixed(3));
301
487
  var toUs = (s) => typeof s === "number" && Number.isFinite(s) ? Math.round(s * S) : void 0;
302
488
  var PHRASES = {
303
489
  "clip/add": (p) => p.kind === "text" ? `Added text "${String(p.text ?? "").slice(0, 40)}" at ${seconds(Number(p.startUs))}` : `Added a ${p.kind} clip at ${seconds(Number(p.startUs))}`,
@@ -340,14 +526,14 @@ function editorTools(options = {}) {
340
526
  return [
341
527
  defineTool({
342
528
  name: "get_state",
343
- description: `The project as it is now (after your edits so far): settings, assets, tracks with clips (ids, kinds, time ranges), transitions. ${US_NOTE}`,
529
+ description: `The project as it is now (after your edits so far): settings, assets, tracks with clips (ids, kinds, time ranges), transitions, and each clip's position, size, opacity, text style, volume, effects and animation. ${US_NOTE}`,
344
530
  inputSchema: {
345
531
  type: "object",
346
532
  properties: { includeJson: { type: "boolean", description: "Also return the full document JSON (verbose; only for exact field values)." } },
347
533
  additionalProperties: false
348
534
  },
349
535
  run(input, { project }) {
350
- const summary = describeProject(project.toJSON());
536
+ const summary = describeForAssistant(project.toJSON());
351
537
  return { ok: true, result: input.includeJson ? { summary, document: project.toJSON() } : summary };
352
538
  }
353
539
  }),
@@ -373,14 +559,12 @@ function editorTools(options = {}) {
373
559
  run(input, { project }) {
374
560
  const commands = input.commands;
375
561
  if (!Array.isArray(commands) || commands.length === 0) return { ok: false, error: "commands must be a non-empty array of { type, payload }" };
376
- const result = applyCommands(project, commands);
377
- if (!result.ok) return { ok: false, error: result };
378
- return { ok: true, result: { applied: result.applied }, changes: describeCommands(commands) };
562
+ return applyChecked(project, commands);
379
563
  }
380
564
  }),
381
565
  defineTool({
382
566
  name: "add_transition",
383
- description: "Add (or replace, or remove) a transition on cuts \u2014 where one clip ends exactly where the next starts on the same video track. Pick the cut by its two clip ids, by a time near it, or all cuts. A transition overlaps both clips around the cut, so its length is capped by the spare footage there; cuts with none are skipped and reported.",
567
+ description: 'Add (or replace, or remove) a transition on cuts \u2014 where one clip ends exactly where the next starts on the same video track. Pick the cut by its two clip ids, by a time near it, or all cuts. A transition overlaps both clips around the cut, so its length is capped by the spare footage there; cuts with none are skipped and reported. When a video clip directly follows another on its track, "fade into / dissolve to / slide in / wipe to" that clip means a transition on the cut before it, not an animation. Slide / wipe direction is the direction of motion: "from the left" = right.',
384
568
  inputSchema: {
385
569
  type: "object",
386
570
  properties: {
@@ -396,15 +580,23 @@ function editorTools(options = {}) {
396
580
  additionalProperties: false
397
581
  },
398
582
  run(input, { project }) {
399
- const cuts = findCuts(project.getState().doc);
583
+ const cuts = findCuts2(project.getState().doc);
400
584
  if (cuts.length === 0) return { ok: false, error: "There are no cuts: transitions need two clips touching on the same video track." };
401
585
  let targets;
402
586
  if (input.allCuts === true) targets = cuts;
403
587
  else if (typeof input.fromClipId === "string" || typeof input.toClipId === "string") {
404
588
  targets = cuts.filter((c) => (!input.fromClipId || c.fromClipId === input.fromClipId) && (!input.toClipId || c.toClipId === input.toClipId));
589
+ if (targets.length === 0) return { ok: false, error: { error: noCutReason(project, input.fromClipId, input.toClipId), cuts: cuts.map(cutInfo) } };
405
590
  } else {
406
591
  const at = toUs(input.nearSeconds) ?? project.getState().playheadUs;
407
- targets = [cuts.reduce((a, b) => Math.abs(b.atUs - at) < Math.abs(a.atUs - at) ? b : a)];
592
+ const nearest = cuts.reduce((a, b) => Math.abs(b.atUs - at) < Math.abs(a.atUs - at) ? b : a);
593
+ if (input.nearSeconds !== void 0 && Math.abs(nearest.atUs - at) > 1.5 * S) {
594
+ return {
595
+ ok: false,
596
+ error: { error: `There is no cut near ${seconds(at)} (the nearest is at ${seconds(nearest.atUs)}). Transitions only go on cuts; nothing was added. Use one of these cuts, or tell the user there isn't one there.`, cuts: cuts.map(cutInfo) }
597
+ };
598
+ }
599
+ targets = [nearest];
408
600
  }
409
601
  if (targets.length === 0) return { ok: false, error: { error: "no cut matches", cuts: cuts.map(cutInfo) } };
410
602
  const kind = String(input.kind);
@@ -437,14 +629,14 @@ function editorTools(options = {}) {
437
629
  }),
438
630
  defineTool({
439
631
  name: "animate_clip",
440
- description: `Give a visual clip (video, image, text) an entrance (in), a loop while on screen, and/or an exit (out), as keyframes. Slots you leave out keep their current animation; "none" removes one; reset:true removes all. In/out presets: ${presetIds("in").join(", ")}. Loops: ${presetIds("loop").join(", ")}. Exits stay on the clip's end through trims.`,
632
+ description: `Give a visual clip (video, image, text) an entrance (in), a loop while on screen, and/or an exit (out), as keyframes. Slots you leave out keep their current animation; "none" removes one; reset:true removes all. In/out presets: ${presetIds("in").join(", ")}. Loops: ${presetIds("loop").join(", ")}. Exits stay on the clip's end through trims. Slides: give the edge, in: "from-right" (enters from the right edge, moving left), "from-left", "from-top", "from-bottom"; out: "to-left", "to-right", "to-top", "to-bottom". For text, logos and overlays; between two video clips prefer add_transition. The clip's current animation is in get_state; pass only the slots to change. Fading the whole VIDEO in at the start and out at the end is two calls: in on the FIRST clip of the main video track, out on the LAST one (an out on the first clip would fade to black at its cut).`,
441
633
  inputSchema: {
442
634
  type: "object",
443
635
  properties: {
444
636
  clipId: { type: "string" },
445
- in: { type: "string", enum: [...presetIds("in"), "none"] },
637
+ in: { type: "string", enum: [...presetIds("in"), ...Object.keys(SLIDE_IN), "none"], description: 'For slides, prefer "from-left" / "from-right" / "from-top" / "from-bottom": the edge the clip enters from.' },
446
638
  loop: { type: "string", enum: [...presetIds("loop"), "none"] },
447
- out: { type: "string", enum: [...presetIds("out"), "none"] },
639
+ out: { type: "string", enum: [...presetIds("out"), ...Object.keys(SLIDE_OUT), "none"], description: 'For slides, prefer "to-left" / "to-right" / "to-top" / "to-bottom": the edge the clip leaves by.' },
448
640
  inSeconds: { type: "number", minimum: 0.2, maximum: 3, description: "Default 0.6." },
449
641
  outSeconds: { type: "number", minimum: 0.2, maximum: 3, description: "Default 0.6." },
450
642
  easing: { type: "string", enum: ANIMATION_EASINGS.map((e) => e.id), description: "Default smooth." },
@@ -454,10 +646,18 @@ function editorTools(options = {}) {
454
646
  additionalProperties: false
455
647
  },
456
648
  run(input, { project }) {
649
+ input = { ...input, ...slideAliases(input) };
457
650
  const clip = project.getState().doc.clips[String(input.clipId)];
458
651
  if (!clip) return { ok: false, error: `no clip "${input.clipId}"` };
459
652
  if (clip.kind === "audio") return { ok: false, error: "audio clips have no picture to animate (use volume or fades)" };
460
- const current = input.reset === true ? {} : readAnimation(clip).recipe;
653
+ const reading = readClipAnimation(clip);
654
+ if (reading.custom && input.reset !== true) {
655
+ return {
656
+ ok: false,
657
+ error: `${clip.id} has its own keyframes (${describeKeyframes(clip)}), not presets; presets would overwrite them. To remove one property's animation, apply_commands keyframe/clear { clipId, property }. To edit them, use set_keyframes. To replace them all with presets, pass reset: true. Nothing was changed.`
658
+ };
659
+ }
660
+ const current = input.reset === true ? {} : reading.recipe;
461
661
  const next = { ...current };
462
662
  const easing = typeof input.easing === "string" ? input.easing : "smooth";
463
663
  for (const slot of ["in", "loop", "out"]) {
@@ -473,12 +673,397 @@ function editorTools(options = {}) {
473
673
  const recipe = fitAnimation(next, clip.durationUs);
474
674
  const result = applyCommands(project, animationCommands(clip, recipe));
475
675
  if (!result.ok) return { ok: false, error: result };
476
- const summary = describeAnimation(recipe) || "no animation";
477
- return { ok: true, result: { clipId: clip.id, animation: summary }, changes: [`${clip.id}: ${summary}`] };
676
+ const summary = describeAnimation2(recipe) || "no animation";
677
+ const atCut = input.in && input.in !== "none" && clip.kind === "video" ? findCuts2(project.getState().doc).find((c) => c.toClipId === clip.id) : void 0;
678
+ return {
679
+ ok: true,
680
+ result: {
681
+ clipId: clip.id,
682
+ animation: summary,
683
+ ...atCut ? { note: `${clip.id} starts at a cut right after ${atCut.fromClipId}. If the user meant going from ${atCut.fromClipId} into ${clip.id} ("fade into", "slide in", "dissolve to"), that is add_transition on this cut: undo this entrance with in:"none" and add the transition.` } : {}
684
+ },
685
+ changes: [`${clip.id}: ${summary}`]
686
+ };
687
+ }
688
+ }),
689
+ defineTool({
690
+ name: "add_effect",
691
+ description: "Add a visual effect to one or more clips (video, image, text). Kinds, with their params (min\u2013max, default): " + EFFECT_CATALOG.map((e) => `${e.kind} (${e.label}${e.params.length ? `: ${e.params.map((p) => p.type === "number" ? `${p.key} ${p.min}\u2013${p.max}, ${p.default}` : `${p.key} color`).join("; ")}` : ""})`).join(", ") + ". Omitted params take their defaults. Black & white is mono; brightness, contrast, saturation and hue are colorAdjust params (-1\u20131, 0 = unchanged).",
692
+ inputSchema: {
693
+ type: "object",
694
+ properties: {
695
+ clipIds: { type: "array", items: { type: "string" }, minItems: 1 },
696
+ kind: { type: "string", enum: EFFECT_CATALOG.map((e) => e.kind) },
697
+ params: { type: "object", description: "Effect params by key; see the list above." }
698
+ },
699
+ required: ["clipIds", "kind"],
700
+ additionalProperties: false
701
+ },
702
+ run(input, { project }) {
703
+ const ids = input.clipIds ?? [];
704
+ const doc = project.getState().doc;
705
+ const missing = ids.filter((id) => !doc.clips[id]);
706
+ if (missing.length) return { ok: false, error: `no clip ${missing.map((m) => `"${m}"`).join(", ")}` };
707
+ const audio = ids.filter((id) => doc.clips[id].kind === "audio");
708
+ if (audio.length) return { ok: false, error: `audio clips have no picture: ${audio.join(", ")}` };
709
+ const params = input.params && typeof input.params === "object" ? input.params : void 0;
710
+ const out = applyChecked(project, ids.map((clipId) => ({ type: "effect/add", payload: { clipId, kind: input.kind, ...params ? { params } : {} } })));
711
+ if (!out.ok) return out;
712
+ const label = EFFECT_CATALOG.find((e) => e.kind === input.kind)?.label ?? String(input.kind);
713
+ return { ...out, changes: [`Added ${label} to ${ids.join(", ")}`] };
714
+ }
715
+ }),
716
+ defineTool({
717
+ name: "remove_effects",
718
+ description: "Delete effects from clips: one kind, or every effect when kind is omitted. To turn effects off but keep them, use set_effects_enabled.",
719
+ inputSchema: {
720
+ type: "object",
721
+ properties: {
722
+ clipIds: { type: "array", items: { type: "string" }, minItems: 1 },
723
+ kind: { type: "string", description: "Only effects of this kind (default: all)." }
724
+ },
725
+ required: ["clipIds"],
726
+ additionalProperties: false
727
+ },
728
+ run(input, { project }) {
729
+ const doc = project.getState().doc;
730
+ const commands = [];
731
+ for (const id of input.clipIds ?? []) {
732
+ const clip = doc.clips[id];
733
+ if (!clip) return { ok: false, error: `no clip "${id}"` };
734
+ for (const e of clip.effects ?? []) if (!input.kind || e.kind === input.kind) commands.push({ type: "effect/remove", payload: { clipId: id, effectId: e.id } });
735
+ }
736
+ if (!commands.length) return { ok: true, result: { removed: 0, note: "those clips had no matching effects" } };
737
+ const out = applyChecked(project, commands);
738
+ return out.ok ? { ...out, result: { removed: commands.length }, changes: [`Removed ${commands.length} effect${commands.length > 1 ? "s" : ""}`] } : out;
739
+ }
740
+ }),
741
+ defineTool({
742
+ name: "trim_clip",
743
+ description: "Trim a clip like an editor's edge drag, in seconds. removeFromStart cuts that much off the beginning (the clip's end stays put; for video/audio the source moves on too). removeFromEnd cuts off the end. setDuration sets the length, keeping the start. startSeconds / endSeconds put the clip's edges at those timeline times (e.g. 0 and the composition length to cover the whole video; a video/audio clip's source shifts with its start edge). ripple:true shifts later clips on the same track to close (or open) the gap.",
744
+ inputSchema: {
745
+ type: "object",
746
+ properties: {
747
+ clipId: { type: "string" },
748
+ removeFromStart: { type: "number", exclusiveMinimum: 0 },
749
+ removeFromEnd: { type: "number", exclusiveMinimum: 0 },
750
+ setDuration: { type: "number", exclusiveMinimum: 0 },
751
+ startSeconds: { type: "number", minimum: 0, description: "Timeline time for the clip's start edge." },
752
+ endSeconds: { type: "number", exclusiveMinimum: 0, description: "Timeline time for the clip's end edge." },
753
+ ripple: { type: "boolean" }
754
+ },
755
+ required: ["clipId"],
756
+ additionalProperties: false
757
+ },
758
+ run(input, { project }) {
759
+ const doc = project.getState().doc;
760
+ const clip = doc.clips[String(input.clipId)];
761
+ if (!clip) return { ok: false, error: `no clip "${input.clipId}"` };
762
+ const head = toUs(input.removeFromStart) ?? 0;
763
+ const tail = toUs(input.removeFromEnd) ?? 0;
764
+ const ripple = input.ripple === true;
765
+ const startAt = toUs(input.startSeconds);
766
+ const endAt = toUs(input.endSeconds);
767
+ if ((startAt !== void 0 || endAt !== void 0) && (head || tail)) return { ok: false, error: "use either startSeconds / endSeconds or removeFromStart / removeFromEnd, not both" };
768
+ let duration = clip.durationUs - head - tail;
769
+ const set = toUs(input.setDuration);
770
+ if (set) duration = set;
771
+ let start = ripple ? clip.startUs : clip.startUs + head;
772
+ let sourceShift = head;
773
+ if (startAt !== void 0) {
774
+ start = startAt;
775
+ sourceShift = startAt - clip.startUs;
776
+ if (endAt === void 0 && !set) duration = clip.startUs + clip.durationUs - startAt;
777
+ }
778
+ if (endAt !== void 0) duration = endAt - start;
779
+ if (duration <= 0) return { ok: false, error: `that leaves the clip ${seconds(duration)} long; it is ${seconds(clip.durationUs)} (${seconds(clip.startUs)}\u2013${seconds(clip.startUs + clip.durationUs)})` };
780
+ const trimmable = "trimStartUs" in clip;
781
+ if (trimmable && clip.trimStartUs + sourceShift < 0) {
782
+ return { ok: false, error: `${clip.id}'s media has only ${seconds(clip.trimStartUs)} before its current start, so its start edge can move back at most that far` };
783
+ }
784
+ const commands = [
785
+ {
786
+ type: "clip/trim",
787
+ payload: {
788
+ clipId: clip.id,
789
+ startUs: start,
790
+ durationUs: duration,
791
+ ...trimmable && sourceShift ? { trimStartUs: clip.trimStartUs + sourceShift } : {}
792
+ }
793
+ }
794
+ ];
795
+ if (ripple) {
796
+ const oldEnd = clip.startUs + clip.durationUs;
797
+ const delta = start + duration - oldEnd;
798
+ const later = Object.values(doc.clips).filter((c) => c.trackId === clip.trackId && c.id !== clip.id && c.startUs >= oldEnd).sort((x, y) => delta < 0 ? x.startUs - y.startUs : y.startUs - x.startUs);
799
+ for (const c of later) commands.push({ type: "clip/move", payload: { clipId: c.id, startUs: Math.max(0, c.startUs + delta) } });
800
+ }
801
+ const out = applyChecked(project, commands);
802
+ if (!out.ok) return out;
803
+ const after = project.getState().doc.clips[clip.id];
804
+ return { ...out, changes: [`Trimmed ${clip.id} to ${seconds(after.startUs)}\u2013${seconds(after.startUs + after.durationUs)}`] };
805
+ }
806
+ }),
807
+ defineTool({
808
+ name: "set_effects_enabled",
809
+ description: "Turn effects off (or back on) without deleting them, keeping their settings: one kind, or every effect when kind is omitted. To delete effects use remove_effects.",
810
+ inputSchema: {
811
+ type: "object",
812
+ properties: {
813
+ clipIds: { type: "array", items: { type: "string" }, minItems: 1 },
814
+ kind: { type: "string", description: "Only effects of this kind (default: all)." },
815
+ enabled: { type: "boolean" }
816
+ },
817
+ required: ["clipIds", "enabled"],
818
+ additionalProperties: false
819
+ },
820
+ run(input, { project }) {
821
+ const doc = project.getState().doc;
822
+ const enabled = input.enabled === true;
823
+ const commands = [];
824
+ for (const id of input.clipIds ?? []) {
825
+ const clip = doc.clips[id];
826
+ if (!clip) return { ok: false, error: `no clip "${id}"` };
827
+ for (const e of clip.effects ?? []) {
828
+ if ((!input.kind || e.kind === input.kind) && e.enabled !== enabled) commands.push({ type: "effect/update", payload: { clipId: id, effectId: e.id, enabled } });
829
+ }
830
+ }
831
+ if (!commands.length) return { ok: true, result: { changed: 0, note: `no matching effects that are ${enabled ? "off" : "on"}` } };
832
+ const out = applyChecked(project, commands);
833
+ return out.ok ? { ...out, result: { changed: commands.length }, changes: [`Turned ${enabled ? "on" : "off"} ${commands.length} effect${commands.length > 1 ? "s" : ""}`] } : out;
834
+ }
835
+ }),
836
+ defineTool({
837
+ name: "set_keyframes",
838
+ description: `Animate one property of a clip so it CHANGES over time, with keyframes at TIMELINE seconds (the same times as get_state). Replaces that property's existing keyframes (including a preset's) unless keep:true. Between points the value moves smoothly; before the first and after the last it holds. Properties: x, y (0\u20131, 0.5 = center), scale (1 = full size), rotation (degrees; 360 = one full turn, so a static 360 looks like 0), opacity (0\u20131), volume (0\u20131, audio/video). Not for constant values ("rotate 45\xB0", "music at 30%", "louder": set those with clip/set-property) or for removing an animation (keyframe/clear). Use it for motion and timed changes the presets don't cover: a spin (rotation 0 \u2192 360 over the clip), a pan, ducking music (volume: normal \u2192 low just before a range, low \u2192 normal just after), or fading audio between two times.`,
839
+ inputSchema: {
840
+ type: "object",
841
+ properties: {
842
+ clipId: { type: "string" },
843
+ property: { type: "string", enum: ["x", "y", "scale", "rotation", "opacity", "volume"] },
844
+ points: {
845
+ type: "array",
846
+ minItems: 1,
847
+ items: {
848
+ type: "object",
849
+ properties: { atSeconds: { type: "number", minimum: 0 }, value: { type: "number" } },
850
+ required: ["atSeconds", "value"],
851
+ additionalProperties: false
852
+ }
853
+ },
854
+ easing: { type: "string", enum: ["linear", "easeIn", "easeOut", "easeInOut", "hold"], description: "Curve between points (default linear)." },
855
+ keep: { type: "boolean", description: "Keep the property's other keyframes (default false: replace them)." }
856
+ },
857
+ required: ["clipId", "property", "points"],
858
+ additionalProperties: false
859
+ },
860
+ run(input, { project }) {
861
+ const clip = project.getState().doc.clips[String(input.clipId)];
862
+ if (!clip) return { ok: false, error: `no clip "${input.clipId}"` };
863
+ const property = String(input.property);
864
+ if (property === "volume" && !("volume" in clip)) return { ok: false, error: `${clip.id} is a ${clip.kind} clip and has no volume` };
865
+ if (property !== "volume" && clip.kind === "audio") return { ok: false, error: "audio clips only animate volume" };
866
+ const end = clip.startUs + clip.durationUs;
867
+ const points = input.points.map((p) => ({ atUs: toUs(p.atSeconds), value: p.value }));
868
+ const outside = points.filter((p) => p.atUs < clip.startUs - 1e3 || p.atUs > end + 1e3);
869
+ if (outside.length) return { ok: false, error: `${clip.id} runs ${seconds(clip.startUs)}\u2013${seconds(end)}; points must be inside that range (got ${outside.map((p) => seconds(p.atUs)).join(", ")})` };
870
+ if (input.keep !== true && new Set(points.map((p) => p.value)).size < 2) {
871
+ const how = property === "volume" ? "clip/set-property { volume }" : `clip/set-property { transform: { ${property} } }`;
872
+ return {
873
+ ok: false,
874
+ error: `Keyframes are for a value that CHANGES over time; ${points.length === 1 ? "one point" : "these points"} hold${points.length === 1 ? "s" : ""} a single value. For a constant ${property}, set it directly with apply_commands ${how}. To remove an animation, apply keyframe/clear { clipId, property }. Nothing was changed.`
875
+ };
876
+ }
877
+ const commands = [];
878
+ if (input.keep !== true && (clip.animations?.[property]?.length ?? 0) > 0) commands.push({ type: "keyframe/clear", payload: { clipId: clip.id, property } });
879
+ const easing = typeof input.easing === "string" ? input.easing : "linear";
880
+ for (const p of points) {
881
+ commands.push({ type: "keyframe/set", payload: { clipId: clip.id, property, timeUs: Math.min(clip.durationUs, Math.max(0, p.atUs - clip.startUs)), value: p.value, easing } });
882
+ }
883
+ const out = applyChecked(project, commands);
884
+ if (!out.ok) return out;
885
+ const list = points.map((p) => `${seconds(p.atUs)} ${n3(p.value)}`).join(" \u2192 ");
886
+ return { ...out, changes: [`${clip.id}: ${property} ${list}`] };
887
+ }
888
+ }),
889
+ defineTool({
890
+ name: "set_background",
891
+ description: `A solid color background behind everything (an html clip on a Background track at the very bottom). Adds one, recolors or re-times the existing one, or removes it with color "none". Covers the whole video unless fromSeconds / toSeconds say otherwise. Only visible where nothing opaque is on top (behind scaled-down, faded or transparent clips, or where there's no footage).`,
892
+ inputSchema: {
893
+ type: "object",
894
+ properties: {
895
+ color: { type: "string", description: 'CSS color, e.g. "#1E3A8A", or "none" to remove the background.' },
896
+ fromSeconds: { type: "number", minimum: 0 },
897
+ toSeconds: { type: "number", exclusiveMinimum: 0 }
898
+ },
899
+ required: ["color"],
900
+ additionalProperties: false
901
+ },
902
+ run(input, { project }) {
903
+ const doc = project.getState().doc;
904
+ const color = String(input.color).trim();
905
+ const existing = Object.values(doc.clips).find(
906
+ (c) => c.kind === "html" && (c.template === BACKGROUND_TEMPLATE || doc.tracks[c.trackId]?.name === "Background")
907
+ );
908
+ if (color.toLowerCase() === "none") {
909
+ if (!existing) return { ok: true, result: { note: "there is no background to remove" } };
910
+ const commands2 = [{ type: "clip/remove", payload: { clipId: existing.id } }];
911
+ if (!Object.values(doc.clips).some((c) => c.trackId === existing.trackId && c.id !== existing.id)) commands2.push({ type: "track/remove", payload: { id: existing.trackId } });
912
+ const out2 = applyChecked(project, commands2);
913
+ return out2.ok ? { ...out2, changes: ["Removed the background"] } : out2;
914
+ }
915
+ let endUs = 0;
916
+ for (const c of Object.values(doc.clips)) if (c.id !== existing?.id) endUs = Math.max(endUs, c.startUs + c.durationUs);
917
+ const startUs = toUs(input.fromSeconds) ?? (existing && input.toSeconds === void 0 ? existing.startUs : 0);
918
+ const stopUs = toUs(input.toSeconds) ?? (existing && input.fromSeconds === void 0 ? existing.startUs + existing.durationUs : endUs || 10 * S);
919
+ if (stopUs <= startUs) return { ok: false, error: `toSeconds must be after fromSeconds (got ${seconds(startUs)}\u2013${seconds(stopUs)})` };
920
+ const commands = [];
921
+ if (existing) {
922
+ const params = existing;
923
+ const key = htmlPlaceholders(params.template).find((k) => /colou?r|background|bg/i.test(k)) ?? "color";
924
+ if (params.params[key] !== color) commands.push({ type: "clip/set-property", payload: { clipId: existing.id, params: { [key]: color } } });
925
+ if (existing.startUs !== startUs || existing.durationUs !== stopUs - startUs) commands.push({ type: "clip/trim", payload: { clipId: existing.id, startUs, durationUs: stopUs - startUs } });
926
+ if (!commands.length) return { ok: true, result: { note: "the background already looks like that" } };
927
+ } else {
928
+ const trackId = doc.tracks.background ? `background-${Object.keys(doc.tracks).length}` : "background";
929
+ commands.push({ type: "track/add", payload: { id: trackId, kind: "video", name: "Background", index: 0 } });
930
+ commands.push({ type: "clip/add", payload: { kind: "html", id: `${trackId}-clip`, trackId, startUs, durationUs: stopUs - startUs, template: BACKGROUND_TEMPLATE, params: { color } } });
931
+ }
932
+ const out = applyChecked(project, commands);
933
+ if (!out.ok) return out;
934
+ return { ...out, changes: [`${existing ? "Changed the background to" : "Added a background:"} ${color}, ${seconds(startUs)}\u2013${seconds(stopUs)}`] };
935
+ }
936
+ }),
937
+ defineTool({
938
+ name: "close_gaps",
939
+ description: "Close the empty gaps on a track so its clips run back to back, keeping their order (the first clip stays where it is, or starts at 0 with fromZero).",
940
+ inputSchema: {
941
+ type: "object",
942
+ properties: { trackId: { type: "string" }, fromZero: { type: "boolean" } },
943
+ required: ["trackId"],
944
+ additionalProperties: false
945
+ },
946
+ run(input, { project }) {
947
+ const doc = project.getState().doc;
948
+ if (!doc.tracks[String(input.trackId)]) return { ok: false, error: `no track "${input.trackId}"` };
949
+ const clips = Object.values(doc.clips).filter((c) => c.trackId === input.trackId).sort((a, b) => a.startUs - b.startUs);
950
+ let cursor = input.fromZero === true ? 0 : clips[0]?.startUs ?? 0;
951
+ const commands = [];
952
+ for (const c of clips) {
953
+ if (c.startUs !== cursor) commands.push({ type: "clip/move", payload: { clipId: c.id, startUs: cursor } });
954
+ cursor += c.durationUs;
955
+ }
956
+ if (!commands.length) return { ok: true, result: { moved: 0, note: "no gaps on that track" } };
957
+ const out = applyChecked(project, commands);
958
+ return out.ok ? { ...out, changes: [`Closed ${commands.length} gap${commands.length > 1 ? "s" : ""} on ${input.trackId}`] } : out;
478
959
  }
479
960
  })
480
961
  ];
481
962
  }
963
+ var Abort = class extends Error {
964
+ };
965
+ function applyChecked(project, commands) {
966
+ const catalog = project.commandCatalog();
967
+ const nothing = commands.length > 1 ? "Nothing in this batch was applied (not even the commands before it): resend the whole corrected batch." : "Nothing was applied.";
968
+ const templates = /* @__PURE__ */ new Map();
969
+ for (const [index, command] of commands.entries()) {
970
+ if (!catalog[command?.type]) {
971
+ const looksLikeTool = /^[a-z]+(_[a-z]+)+$/.test(String(command?.type));
972
+ return {
973
+ ok: false,
974
+ error: {
975
+ failedIndex: index,
976
+ kind: "unknown-command",
977
+ commandType: command?.type,
978
+ error: `"${command?.type}" is not a command${looksLikeTool ? ": it is a tool. Call it as its own tool call, not inside apply_commands" : ""}. ${nothing}`,
979
+ validTypes: Object.keys(catalog).sort()
980
+ }
981
+ };
982
+ }
983
+ const htmlError = checkHtmlParams(project, command, templates);
984
+ if (htmlError) return { ok: false, error: { failedIndex: index, commandType: command.type, error: `${htmlError} ${nothing}` } };
985
+ const extra = unknownFields(catalog[command?.type], command?.payload);
986
+ if (extra) {
987
+ return {
988
+ ok: false,
989
+ error: {
990
+ failedIndex: index,
991
+ commandType: command.type,
992
+ error: `unknown field${extra.unknown.length > 1 ? "s" : ""} ${extra.unknown.map((f) => `"${f}"`).join(", ")}: ${command.type} would ignore ${extra.unknown.length > 1 ? "them" : "it"}. Its fields are: ${extra.allowed.join(", ")}. ${nothing}`
993
+ }
994
+ };
995
+ }
996
+ }
997
+ const unchanged = [];
998
+ let failure;
999
+ try {
1000
+ project.transaction(() => {
1001
+ commands.forEach((command, index) => {
1002
+ const before = project.getState().doc;
1003
+ const result = tryDispatch(project, command);
1004
+ if (!result.ok) {
1005
+ const hint = rejectionHint(result.error, command);
1006
+ const skipped = commands.length > 1 ? { notApplied: describeCommands(commands.filter((_, i) => i !== index)) } : {};
1007
+ failure = { failedIndex: index, ...result.error, ...hint ? { hint } : {}, note: nothing, ...skipped };
1008
+ throw new Abort();
1009
+ }
1010
+ if (project.getState().doc === before) unchanged.push(index);
1011
+ });
1012
+ });
1013
+ } catch (err) {
1014
+ if (err instanceof Abort) return { ok: false, error: failure };
1015
+ throw err;
1016
+ }
1017
+ const changed = commands.filter((_, i) => !unchanged.includes(i));
1018
+ return {
1019
+ ok: true,
1020
+ result: {
1021
+ applied: changed.length,
1022
+ ...unchanged.length ? { unchanged: unchanged.map((i) => ({ index: i, type: commands[i].type, note: "changed nothing: the project already had these values. Check the field names and values." })) } : {}
1023
+ },
1024
+ ...changed.length ? { changes: describeCommands(changed) } : {}
1025
+ };
1026
+ }
1027
+ function checkHtmlParams(project, command, templates) {
1028
+ const p = command.payload ?? {};
1029
+ let template;
1030
+ if (command.type === "clip/add" && p.kind === "html" && typeof p.template === "string") {
1031
+ template = p.template;
1032
+ if (typeof p.id === "string") templates.set(p.id, template);
1033
+ } else if (command.type === "clip/set-property" && p.params && typeof p.params === "object") {
1034
+ const clip = project.getState().doc.clips[String(p.clipId)];
1035
+ template = templates.get(String(p.clipId)) ?? (clip?.kind === "html" ? clip.template : void 0);
1036
+ }
1037
+ if (template === void 0 || !p.params || typeof p.params !== "object") return void 0;
1038
+ const names = htmlPlaceholders(template);
1039
+ const unknown = Object.keys(p.params).filter((k) => !names.includes(k));
1040
+ if (!unknown.length) return void 0;
1041
+ return `html params ${unknown.map((k) => `"${k}"`).join(", ")} match no {{placeholder}} in the template, so they would change nothing. Its placeholders: ${names.join(", ") || "none"}.`;
1042
+ }
1043
+ function rejectionHint(error, _command) {
1044
+ switch (error.code) {
1045
+ case "no-audio":
1046
+ return "fadeInUs / fadeOutUs fade audio only. To fade a text, image or video picture in or out, call animate_clip (in / out: fade).";
1047
+ case "asset-not-found":
1048
+ return "Clips can only use assets already in the project (see get_state). Audio found with search_audio is added with add_audio, which imports it.";
1049
+ case "not-caption":
1050
+ case "not-html":
1051
+ case "not-text":
1052
+ return "That field belongs to another clip kind: text clips take color / font fields, caption clips take style, html clips take params (named by the template's {{placeholders}}, shown in get_state).";
1053
+ default:
1054
+ return void 0;
1055
+ }
1056
+ }
1057
+ function noCutReason(project, fromId, toId) {
1058
+ const doc = project.getState().doc;
1059
+ const a = typeof fromId === "string" ? doc.clips[fromId] : void 0;
1060
+ const b = typeof toId === "string" ? doc.clips[toId] : void 0;
1061
+ const tail = "Nothing was added. Don't substitute a different cut: tell the user, and offer an animation (animate_clip) instead if that fits.";
1062
+ for (const [given, clip] of [[fromId, a], [toId, b]]) if (typeof given === "string" && !clip) return `There is no clip "${given}". ${tail}`;
1063
+ if (a && b && a.trackId !== b.trackId) return `${a.id} is on track ${a.trackId} and ${b.id} on track ${b.trackId}: a transition joins two clips that touch on the SAME track, so these two have no cut between them. ${tail}`;
1064
+ if (a && b) return `${a.id} and ${b.id} don't touch (${a.id} ends at ${seconds(a.startUs + a.durationUs)}, ${b.id} starts at ${seconds(b.startUs)}). ${tail}`;
1065
+ return `No cut ${a ? `starts after ${a.id}` : `leads into ${b.id}`} (it isn't directly after/before another clip on its track). ${tail}`;
1066
+ }
482
1067
  function cutInfo(c) {
483
1068
  return { fromClipId: c.fromClipId, toClipId: c.toClipId, atSeconds: c.atUs / S, maxSeconds: c.maxTransitionUs / S, ...c.transition ? { transition: c.transition.kind } : {} };
484
1069
  }
@@ -568,21 +1153,33 @@ function commitFork(project, fork, label) {
568
1153
  }
569
1154
 
570
1155
  // src/run.ts
571
- import { describeProject as describeProject2 } from "@miraiclip/core";
572
1156
  var MAX_TOOL_RESULT = 2e4;
573
1157
  var MAX_OLD_TOOL_RESULT = 2e3;
574
1158
  var DEFAULT_INSTRUCTIONS = `You are the editing assistant inside a video editor. You change the user's project by calling tools; every change you make lands as ONE undo step when you finish, so act confidently.
575
1159
 
576
1160
  How to work:
577
1161
  - The current project is summarized below. Clip, track and asset ids there are what tools expect. Call get_state to re-read it after big changes.
578
- - Prefer the high-level tools when they fit (add_transition, animate_clip, and any audio or media tools). Use apply_commands for everything else, batching related commands; look up a command's payload with get_command_schema the first time you use it.
1162
+ - Prefer the high-level tools when they fit (add_transition, animate_clip, add_effect, set_effects_enabled, set_background, trim_clip, set_keyframes, close_gaps, and any audio or media tools). Call tools directly; apply_commands takes only editing commands like "clip/add", never tool names. Use apply_commands for everything else, batching related commands; look up a command's payload with get_command_schema the first time you use it, and use only the fields it lists.
1163
+ - Fading a picture (text, image, logo, video) in or out is animate_clip (in / out: fade). fadeInUs / fadeOutUs are audio fades. "Fade the video in at the start / out at the end" means the first clip's entrance and the last clip's exit.
1164
+ - Constant values are set directly with clip/set-property: rotation ("rotate 45\xB0", "tilt"), volume ("lower to 30%", "louder"), position, size, opacity. Only values that CHANGE over a time range ("duck the music between 10 and 15 s", "fade to silence between 18 and 20 s", "spin one full turn", "pan across") are keyframes: set_keyframes, with times in timeline seconds. Remove an animation with keyframe/clear.
1165
+ - "Cuts" are the ones listed in the project summary; use their times (e.g. one sound per cut).
1166
+ - "Add N more seconds of footage" adds a new clip (clip/add with trimStartUs for the source point) after the last one; it does not stretch an existing clip.
1167
+ - Caption looks (box behind the text, outline, shadow, colors, word preset) are the caption clip's style (clip/set-property { style }).
1168
+ - "The whole video" means from 0 to the composition length (trim_clip startSeconds / endSeconds).
1169
+ - A solid color background behind everything is set_background (add, recolor, remove). Other html clips are recolored through the params their template uses (shown in the clip details). A new track goes on top unless track/add gets index 0 (the bottom).
1170
+ - "Replace X with Y" removes X and adds Y in the same turn, whichever tool adds Y (audio tools take replaceClipId, which does both).
1171
+ - Solo / mute / lock / hide a track with track/set-property (solo, muted, locked, hidden). "Solo X" is solo: true on X, not muting the others.
1172
+ - Relative requests ("bigger", "louder", "a bit brighter", "twice as big") start from the current values in the clip details below: make a visible change in the asked direction (about 25% for "bigger"/"smaller", \xD72 for "twice").
1173
+ - When something is selected, "this", "it", "the clip" and any request that names no target mean ONLY the selection, even with words like "at the end" (the end of that clip). Edit nothing else. "Here" / "now" / "this point" mean the playhead. "Clips" without a kind mean the video clips on the main video track.
1174
+ - "Again" / "also" / "another" mean keep the original and add a copy (clip/duplicate, or clip/add with the same asset); never move the original. "Instead" means move or replace.
1175
+ - Removing a clip leaves a gap; when asked to close it, also run close_gaps (or trim_clip with ripple).
579
1176
  - Commands use integer microseconds (1 second = 1000000). High-level tools take seconds.
580
1177
  - Positions (transform x, y) are fractions of the frame: 0.5, 0.5 is the center.
581
1178
  - Only reference media that exists in the project or that a tool returned. Never invent file URLs.
582
- - If a tool fails, read the error, fix the input and retry. If something can't be done, say why.
1179
+ - If a tool fails, read the error, fix the input and retry. A failed apply_commands batch applied NONE of its commands: resend all of them, corrected. If something can't be done, say why.
583
1180
  - If the request is ambiguous in a way that matters, ask one short question instead of guessing.
584
1181
 
585
- When you're done, reply in one to three short sentences: what you changed, in plain words (times in seconds, names over ids). No markdown headings.`;
1182
+ When you're done, reply in one to three short sentences: what you changed, in plain words (times in seconds, names over ids). Describe only edits your tools reported as applied; if a tool says something changed nothing or failed, fix it or say so. No markdown headings.`;
586
1183
  function createAssistant(options) {
587
1184
  const tools = options.tools ?? editorTools();
588
1185
  const byName = new Map(tools.map((t) => [t.name, t]));
@@ -602,8 +1199,9 @@ function createAssistant(options) {
602
1199
  DEFAULT_INSTRUCTIONS,
603
1200
  options.instructions?.trim(),
604
1201
  `Current project:
605
- ${describeProject2(fork.toJSON())}`,
606
- turn.context?.trim() ? `What the user is looking at: ${turn.context.trim()}` : void 0
1202
+ ${describeForAssistant(fork.toJSON())}`,
1203
+ turn.context?.trim() ? `What the user is looking at: ${turn.context.trim()}` : void 0,
1204
+ selectionNote(fork.toJSON(), fork.getState().selection)
607
1205
  ].filter(Boolean).join("\n\n");
608
1206
  let reply = "";
609
1207
  let status = "no-changes";
@@ -730,6 +1328,12 @@ function abortError() {
730
1328
  err.name = "AbortError";
731
1329
  return err;
732
1330
  }
1331
+ function selectionNote(doc, selection) {
1332
+ const clips = selection.map((id) => doc.clips[id]).filter((c) => !!c);
1333
+ if (!clips.length) return void 0;
1334
+ const list = clips.map((c) => `${c.id} (${c.kind}, ${Number((c.startUs / 1e6).toFixed(2))}\u2013${Number(((c.startUs + c.durationUs) / 1e6).toFixed(2))}s)`).join(", ");
1335
+ return `Selected: ${list}. In this request, "this", "it", "the clip" and anything that names no target mean ONLY ${clips.map((c) => c.id).join(", ")}. Leave every other clip alone.`;
1336
+ }
733
1337
 
734
1338
  // src/testing.ts
735
1339
  function scriptedChatModel(steps, options = {}) {
@@ -745,15 +1349,17 @@ function scriptedChatModel(steps, options = {}) {
745
1349
  i++;
746
1350
  const s = typeof step === "string" ? { text: step } : step;
747
1351
  if (s.text) opts.onText?.(s.text);
748
- const toolCalls = (s.toolCalls ?? []).map((c, n) => ({ id: c.id ?? `call_${i}_${n}`, name: c.name, arguments: c.arguments }));
1352
+ const toolCalls = (s.toolCalls ?? []).map((c, n2) => ({ id: c.id ?? `call_${i}_${n2}`, name: c.name, arguments: c.arguments }));
749
1353
  return { content: s.text ?? "", toolCalls, finish: toolCalls.length ? "tool_calls" : "stop", usage: { inputTokens: 10, outputTokens: 5 } };
750
1354
  }
751
1355
  });
752
1356
  return Object.assign(model, { requests });
753
1357
  }
754
1358
  export {
1359
+ BACKGROUND_TEMPLATE,
755
1360
  ChatModelError,
756
1361
  DEFAULT_INSTRUCTIONS,
1362
+ applyChecked,
757
1363
  applyCommandsSchema,
758
1364
  commitFork,
759
1365
  createAssistant,
@@ -761,14 +1367,18 @@ export {
761
1367
  defineChatModel,
762
1368
  defineTool,
763
1369
  describeCommands,
1370
+ describeForAssistant,
764
1371
  editorTools,
765
1372
  forkProject,
766
1373
  openAIChatModel,
767
1374
  parseToolArguments,
1375
+ rateLimitedChatModel,
768
1376
  remoteChatModel,
1377
+ retryDelayMs,
769
1378
  scriptedChatModel,
770
1379
  toOpenAIMessages,
771
1380
  toolsFromDefinitions,
1381
+ unknownFields,
772
1382
  withExplicitIds
773
1383
  };
774
1384
  //# sourceMappingURL=index.js.map