@mevdragon/vidfarm-devcli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -11,6 +11,7 @@ import { pipeline } from "node:stream/promises";
11
11
  import { computeCompositionGaps, insertMediaLayer, inspectComposition, replaceLayerWithMedia } from "./devcli/composition-edit.js";
12
12
  import { runCaptionsCommand } from "./devcli/captions.js";
13
13
  import { runClipsCommand } from "./devcli/clips.js";
14
+ import { runTransitionsCommand } from "./devcli/transitions.js";
14
15
  import { MAX_LOCAL_TRANSCRIBE_AUDIO_BYTES, loadSpeechAudioLocally, localGenerateSpeech, localTranscribeSpeech, resolveLocalSpeechAuth } from "./devcli/speech.js";
15
16
  import { inferSpeechProviderForVoice } from "./services/speech.js";
16
17
  import { initTelemetry, reportCliCrash } from "./devcli/telemetry.js";
@@ -148,10 +149,15 @@ Generate AI media and drop it on the timeline (for local coding agents):
148
149
  --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
149
150
  --transition <preset> Scene transition INTO this clip (the previous
150
151
  butt-cut clip is held beneath it automatically):
151
- crossfade|fade-black|slide-left|slide-right|
152
- slide-up|slide-down|wipe-left|wipe-right|wipe-up|
152
+ crossfade|fade-black|fade-white|flash|smoke|blur|
153
+ slide-left|slide-right|slide-up|slide-down|
154
+ whip-left|whip-right|wipe-left|wipe-right|wipe-up|
153
155
  wipe-down|zoom-in|zoom-out|circle-open
154
156
  --transition-duration <s> Transition length in seconds 0.1-2.5 (default 0.5)
157
+ --transition-out <preset> Exit transition OUT of this clip at its end:
158
+ fade|fade-black|fade-white|flash|smoke|blur|
159
+ slide-*|wipe-*|zoom-in|zoom-out|circle-close
160
+ --transition-out-duration <s> Exit length in seconds 0.1-2.5 (default 0.5)
155
161
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
156
162
  original vs decomposed (caption-free),
157
163
  non-billing (alias: ghostcut)
@@ -207,6 +213,16 @@ Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composit
207
213
  captions style <dir> Restyle existing cues (same text/timings)
208
214
  captions list <dir> Show the current animated caption cues [--json]
209
215
  captions clear <dir> Remove all animated caption layers
216
+
217
+ Scene transitions (between clips on a pulled/served composition — local file write):
218
+ transitions apply <dir> One pass over the whole timeline:
219
+ --preset <p> Junction preset at EVERY cut ("none" clears)
220
+ --duration <s> 0.1-2.5 (default 0.5)
221
+ --intro <p> --outro <p> First clip's entrance / last clip's exit
222
+ transitions set <dir> --layer <key> --in <p> --out <p> Configure one clip
223
+ transitions list <dir> Show transitions on every scene clip [--json]
224
+ transitions clear <dir> Remove all transitions
225
+ (run 'vidfarm transitions' for presets & all flags)
210
226
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
211
227
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
212
228
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -251,15 +267,22 @@ Files (multi-step flows the devcli handles for you):
251
267
  --folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
252
268
  --as <name> Name the saved file (required for --content/--stdin)
253
269
  --content <text> Inline text to save (needs --as); or pipe stdin with --stdin
270
+ --notes <text> Metadata notes (what it is / when to use it) — vector-embedded for search
254
271
  e.g. vidfarm put-file About.md --folder acme-skincare
255
272
  echo "..." | vidfarm put-file --stdin --as About.md --folder acme
256
273
  download <url> [dest] Stream any Vidfarm/media URL to disk
257
274
  files List My Files assets + folders → GET /api/v1/user/me/attachments
258
275
  --folder <path> Only files under this folder (e.g. a product/offer)
276
+ --search <query> Find files by MEANING (keyword + vector over → POST /api/v1/user/me/attachments/search
277
+ name/folder/notes) instead of listing everything
259
278
  get-file <id> [dest] Resolve a My Files id to its URL and download it
260
279
  --print Print text contents (md/txt/csv/json) instead of saving
261
- put-file / get-file / files are the My Files (persistent) read+write set;
280
+ annotate-file <id|name> Set metadata notes on one My Files entry → PATCH /api/v1/user/me/attachments/:id
281
+ --notes <text> The notes to attach (empty string clears); embedded for vector search
282
+ put-file / get-file / files / annotate-file are the My Files (persistent) set;
262
283
  upload is the throwaway temp store for dropping media into a composition.
284
+ Annotate assets you'll want back — esp. character refs (character_sprite_card.png
285
+ + about_character.md) — so 'files --search "the mascot"' finds them later.
263
286
  Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
264
287
  them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
265
288
  to that box's local disk store, free local render). When you DO need a durable
@@ -458,12 +481,18 @@ async function main() {
458
481
  case "save-file":
459
482
  await runPutFileCommand(rest);
460
483
  return;
484
+ case "annotate-file":
485
+ await runAnnotateFileCommand(rest);
486
+ return;
461
487
  case "clips":
462
488
  await runClipsCommand(rest);
463
489
  return;
464
490
  case "captions":
465
491
  await runCaptionsCommand(rest);
466
492
  return;
493
+ case "transitions":
494
+ await runTransitionsCommand(rest);
495
+ return;
467
496
  default:
468
497
  if (!command.startsWith("-")) {
469
498
  // Positional template id → default to booting the local editor.
@@ -2026,6 +2055,8 @@ function placeMediaOnDisk(input) {
2026
2055
  kenBurnsIntensity: input.kenBurnsIntensity,
2027
2056
  transition: input.transition,
2028
2057
  transitionDuration: input.transitionDuration,
2058
+ transitionOut: input.transitionOut,
2059
+ transitionOutDuration: input.transitionOutDuration,
2029
2060
  slug: input.slug
2030
2061
  };
2031
2062
  const result = input.replace
@@ -2059,6 +2090,8 @@ async function runGenerateCommand(argv) {
2059
2090
  "ken-burns-intensity": { type: "string" },
2060
2091
  transition: { type: "string" },
2061
2092
  "transition-duration": { type: "string" },
2093
+ "transition-out": { type: "string" },
2094
+ "transition-out-duration": { type: "string" },
2062
2095
  "layer-key": { type: "string" }
2063
2096
  }
2064
2097
  });
@@ -2134,6 +2167,8 @@ async function runGenerateCommand(argv) {
2134
2167
  kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
2135
2168
  transition: parsed.values.transition,
2136
2169
  transitionDuration: parsed.values["transition-duration"] ? Number(parsed.values["transition-duration"]) : undefined,
2170
+ transitionOut: parsed.values["transition-out"],
2171
+ transitionOutDuration: parsed.values["transition-out-duration"] ? Number(parsed.values["transition-out-duration"]) : undefined,
2137
2172
  layerKey: parsed.values["layer-key"]
2138
2173
  });
2139
2174
  if (!ctx.json)
@@ -2520,6 +2555,8 @@ async function runPlaceCommand(argv) {
2520
2555
  "ken-burns-intensity": { type: "string" },
2521
2556
  transition: { type: "string" },
2522
2557
  "transition-duration": { type: "string" },
2558
+ "transition-out": { type: "string" },
2559
+ "transition-out-duration": { type: "string" },
2523
2560
  "layer-key": { type: "string" },
2524
2561
  slug: { type: "string" },
2525
2562
  // Local file support: where a serve box serves /storage from, and which
@@ -2564,6 +2601,8 @@ async function runPlaceCommand(argv) {
2564
2601
  kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
2565
2602
  transition: parsed.values.transition,
2566
2603
  transitionDuration: num(parsed.values["transition-duration"]),
2604
+ transitionOut: parsed.values["transition-out"],
2605
+ transitionOutDuration: num(parsed.values["transition-out-duration"]),
2567
2606
  layerKey: parsed.values["layer-key"],
2568
2607
  slug: parsed.values.slug
2569
2608
  });
@@ -2889,11 +2928,25 @@ async function runDownloadCommand(argv) {
2889
2928
  // This is the same filesystem the /editor AI agent browses via the browse_files
2890
2929
  // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
2891
2930
  async function runFilesCommand(argv) {
2892
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" } } });
2931
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), folder: { type: "string" }, search: { type: "string" } } });
2893
2932
  const ctx = commonContext(parsed.values);
2933
+ const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
2934
+ if (parsed.values.search) {
2935
+ // Meaning-first lookup: keyword + vector search over every file's
2936
+ // name/folder/notes (BYOK embeddings; keyword-only without a saved key).
2937
+ const result = await apiRequest({
2938
+ method: "POST",
2939
+ host: ctx.host,
2940
+ path: "/api/v1/user/me/attachments/search",
2941
+ auth: ctx.auth,
2942
+ body: { query: String(parsed.values.search), ...(folderFilter ? { folder_path: folderFilter } : {}) }
2943
+ });
2944
+ assertApiOk(result, "files --search");
2945
+ emitResult(result, ctx.json);
2946
+ return;
2947
+ }
2894
2948
  const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
2895
2949
  assertApiOk(result, "files");
2896
- const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
2897
2950
  if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
2898
2951
  result.json = {
2899
2952
  ...result.json,
@@ -2902,6 +2955,34 @@ async function runFilesCommand(argv) {
2902
2955
  }
2903
2956
  emitResult(result, ctx.json);
2904
2957
  }
2958
+ // Attach metadata notes to one My Files entry (by id, or file name as a
2959
+ // fallback). Notes describe what the file IS and when to use it; the server
2960
+ // vector-embeds them so `files --search` finds the asset by meaning later —
2961
+ // the My Files analog of the clip library's semantic search.
2962
+ async function runAnnotateFileCommand(argv) {
2963
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), notes: { type: "string" } } });
2964
+ const fileRef = parsed.positionals[0];
2965
+ if (!fileRef)
2966
+ throw new Error("annotate-file requires a file id or name (run `files` to list ids).");
2967
+ if (parsed.values.notes == null)
2968
+ throw new Error("annotate-file requires --notes <text> (empty string clears existing notes).");
2969
+ const ctx = commonContext(parsed.values);
2970
+ const list = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
2971
+ assertApiOk(list, "annotate-file");
2972
+ const attachments = Array.isArray(list.json?.attachments) ? list.json.attachments : [];
2973
+ const match = attachments.find((file) => file?.id === fileRef) ?? attachments.find((file) => file?.fileName === fileRef);
2974
+ if (!match)
2975
+ throw new Error(`No file with id or name "${fileRef}" in My Files. Run \`files\` to list ids.`);
2976
+ const result = await apiRequest({
2977
+ method: "PATCH",
2978
+ host: ctx.host,
2979
+ path: `/api/v1/user/me/attachments/${encodeURIComponent(match.id)}`,
2980
+ auth: ctx.auth,
2981
+ body: { notes: String(parsed.values.notes) }
2982
+ });
2983
+ assertApiOk(result, "annotate-file");
2984
+ emitResult(result, ctx.json, [["Notes embedded", result.json?.notes_embedded ? "yes (vector-searchable)" : "no (keyword-searchable only — add a gemini/openai provider key to embed)"]]);
2985
+ }
2905
2986
  async function runGetFileCommand(argv) {
2906
2987
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
2907
2988
  const fileId = parsed.positionals[0];
@@ -2955,6 +3036,7 @@ async function runPutFileCommand(argv) {
2955
3036
  folder: { type: "string" },
2956
3037
  as: { type: "string" },
2957
3038
  content: { type: "string" },
3039
+ notes: { type: "string" },
2958
3040
  stdin: { type: "boolean", default: false }
2959
3041
  }
2960
3042
  });
@@ -2991,6 +3073,8 @@ async function runPutFileCommand(argv) {
2991
3073
  form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
2992
3074
  if (parsed.values.folder)
2993
3075
  form.append("folder_path", String(parsed.values.folder));
3076
+ if (parsed.values.notes)
3077
+ form.append("notes", String(parsed.values.notes));
2994
3078
  const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
2995
3079
  method: "POST",
2996
3080
  headers: buildAuthHeaders(ctx.auth),
@@ -176,7 +176,10 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
176
176
  // previous clip on its track — so that previous clip must stay active for
177
177
  // the transition window. Publish materializes this overlap into
178
178
  // data-duration (normalizePublishHtml); the preview grants it live so
179
- // butt-cut drafts preview exactly like the render.
179
+ // butt-cut drafts preview exactly like the render. A [data-transition-out]
180
+ // clip additionally animates AWAY (vf-tr-out-* keyframes) over its last
181
+ // data-transition-out-duration seconds — no hold needed, the exit window
182
+ // lives inside the clip's own span.
180
183
  function collectTransitionWindows() {
181
184
  const windows = [];
182
185
  for (const clip of clips) {
@@ -210,8 +213,26 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
210
213
  return hold;
211
214
  }
212
215
 
213
- function syncTransitionClip(clip, timing) {
216
+ function syncTransitionClip(clip, timing, hold) {
214
217
  if (typeof clip.getAnimations !== "function") return;
218
+ // Exit animations sit at the clip's tail via the inline --vf-tr-out-delay
219
+ // var. Timeline edits touch data-duration only, so re-derive the delay
220
+ // (duration + any live hold − out duration) before scrubbing; publish
221
+ // materializes the same value for the render producer.
222
+ if (clip.hasAttribute("data-transition-out")) {
223
+ let outDuration = Number(clip.getAttribute("data-transition-out-duration"));
224
+ if (!Number.isFinite(outDuration) || outDuration <= 0) outDuration = 0.5;
225
+ outDuration = Math.min(outDuration, timing.duration);
226
+ const expectedDelay = Math.max(0, timing.duration + (hold || 0) - outDuration);
227
+ try {
228
+ if (Math.abs((parseFloat(clip.style.getPropertyValue("--vf-tr-out-dur")) || 0) - outDuration) > 0.002) {
229
+ clip.style.setProperty("--vf-tr-out-dur", outDuration.toFixed(3) + "s");
230
+ }
231
+ if (Math.abs((parseFloat(clip.style.getPropertyValue("--vf-tr-out-delay")) || 0) - expectedDelay) > 0.002) {
232
+ clip.style.setProperty("--vf-tr-out-delay", expectedDelay.toFixed(3) + "s");
233
+ }
234
+ } catch {}
235
+ }
215
236
  const offsetMs = Math.max(0, (time - timing.start)) * 1000;
216
237
  let anims = [];
217
238
  try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
@@ -310,7 +331,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
310
331
  }
311
332
  if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
312
333
  if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
313
- if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
334
+ if (clip.hasAttribute("data-transition") || clip.hasAttribute("data-transition-out")) syncTransitionClip(clip, timing, hold);
314
335
  }
315
336
  if (clip instanceof HTMLMediaElement) {
316
337
  applyMediaState(clip);
@@ -6,14 +6,14 @@
6
6
  // pushes them identically to a browser-made edit. Pure DOM (linkedom) — no
7
7
  // ffmpeg, no network.
8
8
  import { parseHTML } from "linkedom";
9
- import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById, estimateCaptionWords, renderHyperframeLayerHtml } from "../hyperframes/composition.js";
9
+ import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, TRANSITION_OUT_PRESETS, TRANSITION_PRESETS, captionStylePresetById, clampTransitionDuration, estimateCaptionWords, renderHyperframeLayerHtml, transitionOutDelaySeconds } from "../hyperframes/composition.js";
10
10
  const KEN_BURNS_PRESETS = new Set([
11
11
  "zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "zoom-in-left", "zoom-in-right"
12
12
  ]);
13
- const TRANSITION_PRESET_SET = new Set([
14
- "crossfade", "fade-black", "slide-left", "slide-right", "slide-up", "slide-down",
15
- "wipe-left", "wipe-right", "wipe-up", "wipe-down", "zoom-in", "zoom-out", "circle-open"
16
- ]);
13
+ // Preset vocabulary comes straight from the canonical source so the devcli can
14
+ // never drift from the stylesheet.
15
+ const TRANSITION_PRESET_SET = new Set(TRANSITION_PRESETS);
16
+ const TRANSITION_OUT_PRESET_SET = new Set(TRANSITION_OUT_PRESETS);
17
17
  const round3 = (v) => Number(v.toFixed(3));
18
18
  const clampPercent = (v) => Math.min(100, Math.max(0, v));
19
19
  function serialize(document) {
@@ -231,13 +231,19 @@ function buildMediaClip(document, opts, id, track, geom) {
231
231
  // picks the keyframes; data-transition-duration + --vf-tr-dur set the span.
232
232
  // The overlap with the previous clip is granted by the runtime/publish
233
233
  // normalizer — the stored timeline stays butt-cut.
234
- const transitionDuration = Number.isFinite(opts.transitionDuration) && opts.transitionDuration > 0
235
- ? Math.max(0.1, Math.min(2.5, opts.transitionDuration))
236
- : 0.5;
234
+ const transitionDuration = clampTransitionDuration(opts.transitionDuration ?? 0.5);
237
235
  node.setAttribute("data-transition", opts.transition);
238
236
  node.setAttribute("data-transition-duration", String(round3(transitionDuration)));
239
237
  styles.push(`--vf-tr-dur:${round3(transitionDuration)}s`);
240
238
  }
239
+ if (opts.transitionOut && TRANSITION_OUT_PRESET_SET.has(opts.transitionOut)) {
240
+ // Exit transition: the inline --vf-tr-out-delay (clip duration − out
241
+ // duration) parks the animation window at the clip's tail.
242
+ const outDuration = clampTransitionDuration(opts.transitionOutDuration ?? 0.5);
243
+ node.setAttribute("data-transition-out", opts.transitionOut);
244
+ node.setAttribute("data-transition-out-duration", String(round3(outDuration)));
245
+ styles.push(`--vf-tr-out-dur:${round3(outDuration)}s`, `--vf-tr-out-delay:${transitionOutDelaySeconds(geom.duration, outDuration)}s`);
246
+ }
241
247
  node.setAttribute("style", `${styles.join(";")};`);
242
248
  return node;
243
249
  }
@@ -438,4 +444,166 @@ export function replaceLayerWithMedia(html, targetKey, opts) {
438
444
  void targetId;
439
445
  return { html: serialize(document), layerKey: id };
440
446
  }
447
+ function upsertStyleDecl(node, name, value) {
448
+ const styleValue = node.getAttribute("style") || "";
449
+ const escaped = name.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&");
450
+ const pattern = new RegExp(`(^|;)\\s*${escaped}\\s*:[^;]*`, "i");
451
+ let nextStyle;
452
+ if (value === null) {
453
+ nextStyle = styleValue.replace(pattern, (_match, lead) => lead);
454
+ }
455
+ else if (pattern.test(styleValue)) {
456
+ nextStyle = styleValue.replace(pattern, (_match, lead) => `${lead}${name}:${value}`);
457
+ }
458
+ else {
459
+ nextStyle = styleValue.length === 0 || styleValue.endsWith(";")
460
+ ? `${styleValue}${name}:${value}`
461
+ : `${styleValue};${name}:${value}`;
462
+ }
463
+ if (nextStyle !== styleValue)
464
+ node.setAttribute("style", nextStyle);
465
+ }
466
+ // Scene clips are what junction transitions connect: video/image layers and
467
+ // timeline proxies. Audio, captions, text overlays and the backing source
468
+ // video are excluded (same filter as the editor's timeline chips).
469
+ function isSceneClipNode(node) {
470
+ if (node?.hasAttribute?.("data-vf-playback-source"))
471
+ return false;
472
+ const tag = String(node?.tagName || "").toLowerCase();
473
+ if (tag === "audio")
474
+ return false;
475
+ const kind = node?.getAttribute?.("data-layer-kind");
476
+ return tag === "video" || tag === "img"
477
+ || node?.getAttribute?.("data-vf-timeline-proxy") === "video"
478
+ || kind === "video" || kind === "image";
479
+ }
480
+ function setNodeTransition(node, preset, durationSeconds) {
481
+ if (!preset || preset === "none") {
482
+ node.removeAttribute("data-transition");
483
+ node.removeAttribute("data-transition-duration");
484
+ upsertStyleDecl(node, "--vf-tr-dur", null);
485
+ return;
486
+ }
487
+ if (!TRANSITION_PRESET_SET.has(preset)) {
488
+ throw new Error(`Unknown transition preset "${preset}". Use one of: none, ${TRANSITION_PRESETS.join(", ")}.`);
489
+ }
490
+ const current = Number.parseFloat(node.getAttribute("data-transition-duration") ?? "");
491
+ const duration = clampTransitionDuration(durationSeconds ?? (Number.isFinite(current) ? current : undefined));
492
+ node.setAttribute("data-transition", preset);
493
+ node.setAttribute("data-transition-duration", String(round3(duration)));
494
+ upsertStyleDecl(node, "--vf-tr-dur", `${round3(duration)}s`);
495
+ }
496
+ function setNodeTransitionOut(node, preset, durationSeconds) {
497
+ if (!preset || preset === "none") {
498
+ node.removeAttribute("data-transition-out");
499
+ node.removeAttribute("data-transition-out-duration");
500
+ upsertStyleDecl(node, "--vf-tr-out-dur", null);
501
+ upsertStyleDecl(node, "--vf-tr-out-delay", null);
502
+ return;
503
+ }
504
+ if (!TRANSITION_OUT_PRESET_SET.has(preset)) {
505
+ throw new Error(`Unknown exit transition preset "${preset}". Use one of: none, ${TRANSITION_OUT_PRESETS.join(", ")}.`);
506
+ }
507
+ const current = Number.parseFloat(node.getAttribute("data-transition-out-duration") ?? "");
508
+ const duration = clampTransitionDuration(durationSeconds ?? (Number.isFinite(current) ? current : undefined));
509
+ const layerDuration = numAttr(node, "data-duration");
510
+ node.setAttribute("data-transition-out", preset);
511
+ node.setAttribute("data-transition-out-duration", String(round3(duration)));
512
+ upsertStyleDecl(node, "--vf-tr-out-dur", `${round3(duration)}s`);
513
+ upsertStyleDecl(node, "--vf-tr-out-delay", `${transitionOutDelaySeconds(Number.isFinite(layerDuration) ? layerDuration : 0, duration)}s`);
514
+ }
515
+ // Bulk pass over the whole timeline — the devcli twin of the AI's
516
+ // set_transitions action and the editor's junction chips.
517
+ export function applyTransitionsAcross(html, opts) {
518
+ if (opts.junction === undefined && opts.intro === undefined && opts.outro === undefined) {
519
+ throw new Error("applyTransitionsAcross needs a junction preset, an intro, and/or an outro.");
520
+ }
521
+ const { document, root } = readCompositionDoc(html);
522
+ const scenes = collectClips(root).filter(isSceneClipNode).map((node) => ({
523
+ node,
524
+ start: numAttr(node, "data-start") || 0,
525
+ duration: numAttr(node, "data-duration") || 0,
526
+ track: numAttr(node, "data-track-index") || 0
527
+ })).sort((a, b) => (a.start - b.start) || (a.track - b.track));
528
+ if (!scenes.length)
529
+ throw new Error("No scene clips (video/image layers) found in the composition.");
530
+ const firstByTrack = new Map();
531
+ for (const scene of scenes) {
532
+ const first = firstByTrack.get(scene.track);
533
+ if (!first || scene.start < first.start)
534
+ firstByTrack.set(scene.track, scene);
535
+ }
536
+ let junctions = 0;
537
+ if (opts.junction !== undefined) {
538
+ for (const scene of scenes) {
539
+ if (firstByTrack.get(scene.track) === scene)
540
+ continue;
541
+ setNodeTransition(scene.node, opts.junction, opts.duration);
542
+ junctions += 1;
543
+ }
544
+ }
545
+ const firstScene = scenes[0];
546
+ const lastScene = scenes.reduce((latest, scene) => (scene.start + scene.duration > latest.start + latest.duration ? scene : latest), scenes[0]);
547
+ if (opts.intro !== undefined && firstScene) {
548
+ setNodeTransition(firstScene.node, opts.intro, opts.duration);
549
+ }
550
+ if (opts.outro !== undefined && lastScene) {
551
+ setNodeTransitionOut(lastScene.node, opts.outro, opts.outroDuration ?? opts.duration);
552
+ }
553
+ return {
554
+ html: serialize(document),
555
+ junctions,
556
+ intro: opts.intro !== undefined && opts.intro !== "none" && opts.intro !== null,
557
+ outro: opts.outro !== undefined && opts.outro !== "none" && opts.outro !== null
558
+ };
559
+ }
560
+ // Per-layer control — the devcli twin of set_layer_media's transition fields.
561
+ export function setLayerTransitions(html, layerKey, opts) {
562
+ if (opts.transition === undefined && opts.transitionOut === undefined) {
563
+ throw new Error("setLayerTransitions needs --in and/or --out.");
564
+ }
565
+ const { document, root } = readCompositionDoc(html);
566
+ const node = resolveLayerNode(root, layerKey);
567
+ if (!node)
568
+ throw new Error(`Layer not found: ${layerKey}`);
569
+ if (!isSceneClipNode(node) && String(node.tagName || "").toLowerCase() === "audio") {
570
+ throw new Error("Transitions cannot be applied to audio layers.");
571
+ }
572
+ if (opts.transition !== undefined)
573
+ setNodeTransition(node, opts.transition, opts.transitionDuration);
574
+ if (opts.transitionOut !== undefined)
575
+ setNodeTransitionOut(node, opts.transitionOut, opts.transitionOutDuration);
576
+ return { html: serialize(document), layerKey: node.getAttribute("data-hf-id") || node.id || layerKey };
577
+ }
578
+ export function clearAllTransitions(html) {
579
+ const { document, root } = readCompositionDoc(html);
580
+ let cleared = 0;
581
+ for (const node of collectClips(root)) {
582
+ const had = node.hasAttribute("data-transition") || node.hasAttribute("data-transition-out");
583
+ if (!had)
584
+ continue;
585
+ setNodeTransition(node, null);
586
+ setNodeTransitionOut(node, null);
587
+ cleared += 1;
588
+ }
589
+ return { html: serialize(document), cleared };
590
+ }
591
+ export function readTransitions(html) {
592
+ const { root } = readCompositionDoc(html);
593
+ return collectClips(root).filter(isSceneClipNode).map((node) => {
594
+ const duration = Number.parseFloat(node.getAttribute("data-transition-duration") ?? "");
595
+ const outDuration = Number.parseFloat(node.getAttribute("data-transition-out-duration") ?? "");
596
+ return {
597
+ layerKey: node.getAttribute("data-hf-id") || node.id || "",
598
+ label: node.getAttribute("data-label"),
599
+ track: numAttr(node, "data-track-index") || 0,
600
+ start: numAttr(node, "data-start") || 0,
601
+ duration: numAttr(node, "data-duration") || 0,
602
+ transition: node.getAttribute("data-transition"),
603
+ transition_duration: Number.isFinite(duration) ? duration : null,
604
+ transition_out: node.getAttribute("data-transition-out"),
605
+ transition_out_duration: Number.isFinite(outDuration) ? outDuration : null
606
+ };
607
+ }).sort((a, b) => (a.start - b.start) || (a.track - b.track));
608
+ }
441
609
  //# sourceMappingURL=composition-edit.js.map