@mevdragon/vidfarm-devcli 0.12.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";
@@ -81,6 +82,9 @@ Discover & inspiration (browse the viral-video catalog, add your own source):
81
82
  local MP4/MOV/WebM file — as a private (files: presign + PUT via
82
83
  template. --title (uploads; defaults to the /discover/templates/upload)
83
84
  template id), --tagline, --notes optional.
85
+ --raw (uploads) marks it as your own raw
86
+ project footage — the editor won't auto-
87
+ prompt Decompose on it.
84
88
  inspiration-rm <id> Delete a private inspiration/template you own → DELETE /discover/templates/:id
85
89
  inspiration-decompose <id> AI-decompose an inspiration into scenes → POST /api/v1/inspirations/:id/decompose
86
90
 
@@ -145,10 +149,15 @@ Generate AI media and drop it on the timeline (for local coding agents):
145
149
  --ken-burns-intensity <n> Travel/zoom strength 0.04-0.5 (default 0.18)
146
150
  --transition <preset> Scene transition INTO this clip (the previous
147
151
  butt-cut clip is held beneath it automatically):
148
- crossfade|fade-black|slide-left|slide-right|
149
- 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|
150
155
  wipe-down|zoom-in|zoom-out|circle-open
151
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)
152
161
  remove-video-captions <forkId> Read the two video sources: → GET .../compositions/:forkId/remove-video-captions
153
162
  original vs decomposed (caption-free),
154
163
  non-billing (alias: ghostcut)
@@ -204,6 +213,16 @@ Animated captions (word-by-word TikTok/CapCut styles on a pulled/served composit
204
213
  captions style <dir> Restyle existing cues (same text/timings)
205
214
  captions list <dir> Show the current animated caption cues [--json]
206
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)
207
226
  versions <forkId> List immutable version snapshots → GET .../compositions/:forkId/versions
208
227
  snapshot <forkId> Save the working state as a new version → POST .../compositions/:forkId/versions
209
228
  render <forkId> Render the fork to MP4 (--wait to poll) → POST .../compositions/:forkId/render
@@ -248,15 +267,22 @@ Files (multi-step flows the devcli handles for you):
248
267
  --folder <path> Namescope under a product/offer folder (e.g. acme-skincare)
249
268
  --as <name> Name the saved file (required for --content/--stdin)
250
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
251
271
  e.g. vidfarm put-file About.md --folder acme-skincare
252
272
  echo "..." | vidfarm put-file --stdin --as About.md --folder acme
253
273
  download <url> [dest] Stream any Vidfarm/media URL to disk
254
274
  files List My Files assets + folders → GET /api/v1/user/me/attachments
255
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
256
278
  get-file <id> [dest] Resolve a My Files id to its URL and download it
257
279
  --print Print text contents (md/txt/csv/json) instead of saving
258
- 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;
259
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.
260
286
  Local file paths: to avoid uploading assets to Vidfarm S3 at all, reference
261
287
  them straight from disk with 'place --src ./clip.mp4' on a 'serve' box (copied
262
288
  to that box's local disk store, free local render). When you DO need a durable
@@ -455,12 +481,18 @@ async function main() {
455
481
  case "save-file":
456
482
  await runPutFileCommand(rest);
457
483
  return;
484
+ case "annotate-file":
485
+ await runAnnotateFileCommand(rest);
486
+ return;
458
487
  case "clips":
459
488
  await runClipsCommand(rest);
460
489
  return;
461
490
  case "captions":
462
491
  await runCaptionsCommand(rest);
463
492
  return;
493
+ case "transitions":
494
+ await runTransitionsCommand(rest);
495
+ return;
464
496
  default:
465
497
  if (!command.startsWith("-")) {
466
498
  // Positional template id → default to booting the local editor.
@@ -621,6 +653,11 @@ async function runServeCommand(argv) {
621
653
  // local-render by definition — cloud rendering is the EXPLICIT
622
654
  // render_target="cloud" upstream handoff (the editor's "Render in Cloud").
623
655
  process.env.VIDFARM_JOB_STATE_MACHINE_ARN = "";
656
+ // Same trap for clip hunts: a dev-shell .env carrying the staging clip-scan
657
+ // ARN would route this box's /clips/scan to the cloud Step Functions. serve
658
+ // hunts are LOCAL-FIRST (local agent CLI / BYOK keys); the upstream cloud is
659
+ // the explicit backup inside the scan route itself.
660
+ process.env.VIDFARM_CLIP_SCAN_STATE_MACHINE_ARN = "";
624
661
  // Same trap as the state machine ARN: a dev-shell .env can carry the staging
625
662
  // media-lambda URL, which would route probe/thumbnail/trim/etc. to a cloud
626
663
  // Lambda that cannot fetch this box's localhost storage URLs ("fetch
@@ -1265,7 +1302,7 @@ async function runVideosCommand(argv) {
1265
1302
  emitResult(result, ctx.json, [["Browse ", discoverFrontendUrl(ctx.host)]]);
1266
1303
  }
1267
1304
  async function runInspirationAddCommand(argv) {
1268
- const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" } } });
1305
+ const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), title: { type: "string" }, tagline: { type: "string" }, notes: { type: "string" }, raw: { type: "boolean", default: false } } });
1269
1306
  const source = parsed.positionals[0];
1270
1307
  if (!source)
1271
1308
  throw new Error("inspiration-add requires a video URL (TikTok/YouTube/Instagram/X) or a local video file path.");
@@ -1285,7 +1322,10 @@ async function runInspirationAddCommand(argv) {
1285
1322
  fileName: path.basename(localPath),
1286
1323
  title: parsed.values.title,
1287
1324
  tagline: parsed.values.tagline,
1288
- notes: parsed.values.notes
1325
+ notes: parsed.values.notes,
1326
+ // --raw: this is the user's own footage starting a new project, not an
1327
+ // inspiration to study — the editor skips the auto Decompose prompt.
1328
+ origin: parsed.values.raw ? "raw" : undefined
1289
1329
  });
1290
1330
  emitResult(result, ctx.json, [["Discover ", discoverFrontendUrl(ctx.host)]]);
1291
1331
  return;
@@ -1380,7 +1420,7 @@ async function ingestUploadedVideo(ctx, input) {
1380
1420
  host: ctx.host,
1381
1421
  path: "/discover/templates",
1382
1422
  auth: ctx.auth,
1383
- body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes }
1423
+ body: { upload: { storage_key: storageKey, file_name: uploadedName }, title: input.title, tagline: input.tagline, notes: input.notes, origin: input.origin }
1384
1424
  });
1385
1425
  assertApiOk(result, "ingest");
1386
1426
  return result;
@@ -1555,7 +1595,10 @@ async function runCreateCommand(argv) {
1555
1595
  throw new Error(`create: could not fetch the generated video (${dl.status}).`);
1556
1596
  const buffer = Buffer.from(await dl.arrayBuffer());
1557
1597
  const title = parsed.values.title ?? prompt.slice(0, 80);
1558
- const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title });
1598
+ // origin:"raw" a prompt-generated video is the user's own new project, not
1599
+ // an imported inspiration; the editor must not auto-prompt Decompose (matters
1600
+ // especially with --no-decompose).
1601
+ const add = await ingestUploadedVideo(ctx, { buffer, fileName: "generated.mp4", contentType: "video/mp4", title, origin: "raw" });
1559
1602
  const inspirationId = add.json?.inspiration_id;
1560
1603
  if (!inspirationId)
1561
1604
  throw new Error("create: ingest did not return an inspiration id.");
@@ -2012,6 +2055,8 @@ function placeMediaOnDisk(input) {
2012
2055
  kenBurnsIntensity: input.kenBurnsIntensity,
2013
2056
  transition: input.transition,
2014
2057
  transitionDuration: input.transitionDuration,
2058
+ transitionOut: input.transitionOut,
2059
+ transitionOutDuration: input.transitionOutDuration,
2015
2060
  slug: input.slug
2016
2061
  };
2017
2062
  const result = input.replace
@@ -2045,6 +2090,8 @@ async function runGenerateCommand(argv) {
2045
2090
  "ken-burns-intensity": { type: "string" },
2046
2091
  transition: { type: "string" },
2047
2092
  "transition-duration": { type: "string" },
2093
+ "transition-out": { type: "string" },
2094
+ "transition-out-duration": { type: "string" },
2048
2095
  "layer-key": { type: "string" }
2049
2096
  }
2050
2097
  });
@@ -2120,6 +2167,8 @@ async function runGenerateCommand(argv) {
2120
2167
  kenBurnsIntensity: parsed.values["ken-burns-intensity"] ? Number(parsed.values["ken-burns-intensity"]) : undefined,
2121
2168
  transition: parsed.values.transition,
2122
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,
2123
2172
  layerKey: parsed.values["layer-key"]
2124
2173
  });
2125
2174
  if (!ctx.json)
@@ -2506,6 +2555,8 @@ async function runPlaceCommand(argv) {
2506
2555
  "ken-burns-intensity": { type: "string" },
2507
2556
  transition: { type: "string" },
2508
2557
  "transition-duration": { type: "string" },
2558
+ "transition-out": { type: "string" },
2559
+ "transition-out-duration": { type: "string" },
2509
2560
  "layer-key": { type: "string" },
2510
2561
  slug: { type: "string" },
2511
2562
  // Local file support: where a serve box serves /storage from, and which
@@ -2550,6 +2601,8 @@ async function runPlaceCommand(argv) {
2550
2601
  kenBurnsIntensity: num(parsed.values["ken-burns-intensity"]),
2551
2602
  transition: parsed.values.transition,
2552
2603
  transitionDuration: num(parsed.values["transition-duration"]),
2604
+ transitionOut: parsed.values["transition-out"],
2605
+ transitionOutDuration: num(parsed.values["transition-out-duration"]),
2553
2606
  layerKey: parsed.values["layer-key"],
2554
2607
  slug: parsed.values.slug
2555
2608
  });
@@ -2875,11 +2928,25 @@ async function runDownloadCommand(argv) {
2875
2928
  // This is the same filesystem the /editor AI agent browses via the browse_files
2876
2929
  // tool, so an agent CLI (Claude Code / Codex) can find assets the same way.
2877
2930
  async function runFilesCommand(argv) {
2878
- 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" } } });
2879
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
+ }
2880
2948
  const result = await apiRequest({ method: "GET", host: ctx.host, path: "/api/v1/user/me/attachments", auth: ctx.auth });
2881
2949
  assertApiOk(result, "files");
2882
- const folderFilter = parsed.values.folder ? String(parsed.values.folder).replace(/^\/+|\/+$/g, "") : undefined;
2883
2950
  if (folderFilter && result.json && Array.isArray(result.json.attachments)) {
2884
2951
  result.json = {
2885
2952
  ...result.json,
@@ -2888,6 +2955,34 @@ async function runFilesCommand(argv) {
2888
2955
  }
2889
2956
  emitResult(result, ctx.json);
2890
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
+ }
2891
2986
  async function runGetFileCommand(argv) {
2892
2987
  const parsed = parseArgs({ args: argv, allowPositionals: true, options: { ...commonOptions(), print: { type: "boolean", default: false } } });
2893
2988
  const fileId = parsed.positionals[0];
@@ -2941,6 +3036,7 @@ async function runPutFileCommand(argv) {
2941
3036
  folder: { type: "string" },
2942
3037
  as: { type: "string" },
2943
3038
  content: { type: "string" },
3039
+ notes: { type: "string" },
2944
3040
  stdin: { type: "boolean", default: false }
2945
3041
  }
2946
3042
  });
@@ -2977,6 +3073,8 @@ async function runPutFileCommand(argv) {
2977
3073
  form.append("file", new Blob([new Uint8Array(buffer)], contentType ? { type: contentType } : undefined), fileName);
2978
3074
  if (parsed.values.folder)
2979
3075
  form.append("folder_path", String(parsed.values.folder));
3076
+ if (parsed.values.notes)
3077
+ form.append("notes", String(parsed.values.notes));
2980
3078
  const res = await fetch(new URL("/api/v1/user/me/attachments/upload", ctx.host), {
2981
3079
  method: "POST",
2982
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; }
@@ -229,16 +250,65 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
229
250
  // Burns there is nothing to normalize — the scrub is simply the absolute
230
251
  // offset into the layer. Paused-by-stylesheet + scrubbed-here matches the
231
252
  // render producer's CSS adapter exactly.
253
+ //
254
+ // Stability: Chrome PERMANENTLY drops a paused fill:none CSS animation the
255
+ // moment its currentTime is pushed past the effect end, and never re-creates
256
+ // it while the computed animation-name is unchanged — so one forward scrub
257
+ // across a word's window would leave that word stuck in its resting state
258
+ // (invisible for word-pop/stack-up) on every later frame. Past the end, a
259
+ // parked fill:none animation looks identical to the resting styles, so park
260
+ // at 0 instead of overshooting. fill:forwards presets (karaoke-fill,
261
+ // cumulative-reveal) must overshoot to hold their final keyframe — finished
262
+ // + forwards animations keep applying their effect and survive. If a word's
263
+ // animation is missing anyway (dropped before this guard, or an innerHTML
264
+ // morph replaced the spans mid-scrub), restart it declaratively by cycling
265
+ // animation-name through none.
266
+ function captionWordAnimationFor(word) {
267
+ let anims = [];
268
+ try { anims = word.getAnimations(); } catch { return null; }
269
+ for (const anim of anims) {
270
+ if (typeof anim.animationName === "string" && anim.animationName.indexOf("vf-cap-") === 0) return anim;
271
+ }
272
+ return null;
273
+ }
274
+
232
275
  function syncCaptionClip(clip, timing) {
233
276
  if (typeof clip.getAnimations !== "function") return;
234
277
  const offsetMs = Math.max(0, (time - timing.start)) * 1000;
235
- let anims = [];
236
- try { anims = clip.getAnimations({ subtree: true }); } catch { return; }
237
- for (const anim of anims) {
238
- if (typeof anim.animationName !== "string" || anim.animationName.indexOf("vf-cap-") !== 0) continue;
278
+ let words = [];
279
+ try { words = Array.from(clip.querySelectorAll('[data-cap-word="true"]')); } catch { return; }
280
+ for (const word of words) {
281
+ if (typeof word.getAnimations !== "function") continue;
282
+ let anim = captionWordAnimationFor(word);
283
+ if (!anim) {
284
+ const inlineEndMs =
285
+ ((parseFloat(word.style.animationDelay) || 0) + (parseFloat(word.style.animationDuration) || 0)) * 1000;
286
+ let needed = offsetMs <= inlineEndMs;
287
+ if (!needed) {
288
+ try { needed = getComputedStyle(word).animationFillMode.indexOf("forward") !== -1 || getComputedStyle(word).animationFillMode.indexOf("both") !== -1; } catch {}
289
+ }
290
+ if (!needed) continue;
291
+ try {
292
+ word.style.animationName = "none";
293
+ void word.offsetWidth;
294
+ word.style.removeProperty("animation-name");
295
+ } catch { continue; }
296
+ anim = captionWordAnimationFor(word);
297
+ if (!anim) continue;
298
+ }
299
+ let target = offsetMs;
300
+ try {
301
+ const computed = anim.effect && typeof anim.effect.getComputedTiming === "function"
302
+ ? anim.effect.getComputedTiming()
303
+ : null;
304
+ if (computed && typeof computed.endTime === "number" && offsetMs >= computed.endTime
305
+ && computed.fill !== "forwards" && computed.fill !== "both") {
306
+ target = 0;
307
+ }
308
+ } catch {}
239
309
  try {
240
310
  anim.pause();
241
- anim.currentTime = offsetMs;
311
+ anim.currentTime = target;
242
312
  } catch {}
243
313
  }
244
314
  }
@@ -261,7 +331,7 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
261
331
  }
262
332
  if (clip.hasAttribute("data-kenburns")) syncKenBurnsClip(clip, timing);
263
333
  if (clip.hasAttribute("data-caption-animation")) syncCaptionClip(clip, timing);
264
- if (clip.hasAttribute("data-transition")) syncTransitionClip(clip, timing);
334
+ if (clip.hasAttribute("data-transition") || clip.hasAttribute("data-transition-out")) syncTransitionClip(clip, timing, hold);
265
335
  }
266
336
  if (clip instanceof HTMLMediaElement) {
267
337
  applyMediaState(clip);
@@ -369,7 +439,16 @@ export const COMPOSITION_RUNTIME_SCRIPT_BODY = `
369
439
  return playbackRate;
370
440
  },
371
441
  setMuted,
372
- refreshClips
442
+ refreshClips,
443
+ // Re-scrub caption word animations at the current playhead without a full
444
+ // apply(). The editor calls this after live-patching a caption layer's
445
+ // DOM (style/text edits rebuild the word spans, whose fresh CSS-paused
446
+ // animations sit at currentTime 0 — resting/invisible — until scrubbed).
447
+ syncCaptions() {
448
+ for (const clip of Array.from(document.querySelectorAll("[data-caption-animation]"))) {
449
+ if (clip instanceof HTMLElement) syncCaptionClip(clip, readTiming(clip));
450
+ }
451
+ }
373
452
  };
374
453
 
375
454
  refreshClips();
@@ -166,6 +166,9 @@ async function runScan(argv) {
166
166
  const guidance = buildEffectiveGuidance({ contentPrompt: guidancePromptRaw, avoidText }) || undefined;
167
167
  // ── BACKUP path: run the hunt on the deployed pipeline (--cloud) ──────────
168
168
  if (values.cloud) {
169
+ const apiProvider = values.provider && !["agent", "local", "local-agent"].includes(values.provider)
170
+ ? normalizeProvider(values.provider)
171
+ : undefined; // unset → the cloud auto-picks from the account's saved keys
169
172
  return runScanCloud({
170
173
  videoArg: positionals[0],
171
174
  sourceUrl: values.url?.trim() || undefined,
@@ -173,6 +176,7 @@ async function runScan(argv) {
173
176
  apiKey: values["api-key"],
174
177
  tracer: values.tracer,
175
178
  prompt: guidancePromptRaw,
179
+ provider: apiProvider,
176
180
  windows: rawWindows,
177
181
  durationBand,
178
182
  aspect,
@@ -431,6 +435,7 @@ async function runScanCloud(input) {
431
435
  body: JSON.stringify({
432
436
  ...(input.sourceUrl ? { source_url: input.sourceUrl } : { temp_file_id: tempFileId, filename: fileName }),
433
437
  prompt: input.prompt ?? "",
438
+ ...(input.provider ? { provider: input.provider } : {}),
434
439
  ...(input.tracer ? { tracer: input.tracer } : {}),
435
440
  hunt_spec: {
436
441
  ...(input.windows.length ? { windows: input.windows } : {}),
@@ -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