@flatkey-ai/cli 0.2.9 → 0.3.3

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/src/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import { DEFAULT_BASE_URL } from "./api.js";
3
+ import { nextArtifactPath, persistArtifacts } from "./artifacts.js";
3
4
 
4
5
  const COMMANDS = new Set([
5
6
  "audio",
@@ -20,6 +21,8 @@ const COMMANDS = new Set([
20
21
  const GROUP_ACTIONS = new Set(["audio", "auth", "image", "text", "video"]);
21
22
  const GLOBAL_OPTIONS = new Set(["api_key", "base_url", "console_url", "dry_run", "help", "json", "output", "out", "verbose"]);
22
23
  const REPEATABLE_OPTIONS = new Set(["image", "image_url", "video_url"]);
24
+ const MEDIA_INPUT_OPTIONS = new Set(["file", "image", "image_url", "video_url", "first_frame", "first_frame_url", "last_frame", "last_frame_url"]);
25
+ const MAX_REFERENCE_IMAGES = 5;
23
26
  const COMMAND_OPTIONS = {
24
27
  "audio generate": new Set(["model", "prompt", "similarity_boost", "stability", "style", "voice_id"]),
25
28
  "audio music": new Set(["music_length_ms", "prompt"]),
@@ -29,7 +32,7 @@ const COMMAND_OPTIONS = {
29
32
  credits: new Set([]),
30
33
  help: new Set(["ai", "command"]),
31
34
  image: new Set([]),
32
- "image generate": new Set(["model", "n", "prompt", "quality", "size"]),
35
+ "image generate": new Set(["file", "image", "image_url", "model", "n", "prompt", "quality", "size"]),
33
36
  "image upload": new Set(["file"]),
34
37
  login: new Set(["console_url", "no_open", "open"]),
35
38
  logout: new Set([]),
@@ -40,7 +43,8 @@ const COMMAND_OPTIONS = {
40
43
  "text generate": new Set(["model", "prompt"]),
41
44
  version: new Set([]),
42
45
  video: new Set([]),
43
- "video generate": new Set(["aspect", "duration", "first_frame", "first_frame_url", "fps", "generate_audio", "image", "image_url", "last_frame", "last_frame_url", "model", "prompt", "ratio", "resolution", "video_url"]),
46
+ "video generate": new Set(["aspect", "duration", "file", "first_frame", "first_frame_url", "fps", "generate_audio", "image", "image_url", "last_frame", "last_frame_url", "model", "prompt", "ratio", "resolution", "video_url"]),
47
+ "video copy": new Set(["aspect", "depth_location", "depth_model", "depth_resolution", "duration", "fps", "frame_ratio", "generate_audio", "keep_depth", "model", "prompt", "ratio", "resolution", "source"]),
44
48
  };
45
49
 
46
50
  export function parseArgv(argv) {
@@ -61,7 +65,7 @@ export function parseArgv(argv) {
61
65
  return validateCommandOptions({
62
66
  group: "help",
63
67
  action: undefined,
64
- options: { command: maybeAction, ...parseOptions(rest) },
68
+ options: { command: maybeAction, ...parseOptions(rest, repeatableOptionsFor("help")) },
65
69
  });
66
70
  }
67
71
 
@@ -81,7 +85,7 @@ export function parseArgv(argv) {
81
85
  group,
82
86
  action,
83
87
  options: {
84
- ...parseOptions(optionTokens.filter((token) => token !== "help" && token !== "--help" && token !== "-h")),
88
+ ...parseOptions(optionTokens.filter((token) => token !== "help" && token !== "--help" && token !== "-h"), repeatableOptionsFor(group, action)),
85
89
  help: true,
86
90
  },
87
91
  });
@@ -90,7 +94,7 @@ export function parseArgv(argv) {
90
94
  return validateCommandOptions({
91
95
  group,
92
96
  action,
93
- options: parseOptions(optionTokens),
97
+ options: parseOptions(optionTokens, repeatableOptionsFor(group, action)),
94
98
  });
95
99
  }
96
100
 
@@ -98,8 +102,9 @@ function isHelpToken(token) {
98
102
  return token === "help" || token === "--help" || token === "-h";
99
103
  }
100
104
 
101
- function parseOptions(tokens) {
105
+ function parseOptions(tokens, repeatableOptions = REPEATABLE_OPTIONS) {
102
106
  const options = {};
107
+ const mediaInputs = [];
103
108
  for (let index = 0; index < tokens.length; index += 1) {
104
109
  const token = tokens[index];
105
110
  if (token === "-o") {
@@ -120,16 +125,39 @@ function parseOptions(tokens) {
120
125
  options[name] = true;
121
126
  continue;
122
127
  }
123
- if (REPEATABLE_OPTIONS.has(name)) {
128
+ if (repeatableOptions.has(name)) {
124
129
  options[name] = [...asArray(options[name]), next];
125
130
  } else {
126
131
  options[name] = next;
127
132
  }
133
+ if (MEDIA_INPUT_OPTIONS.has(name)) {
134
+ mediaInputs.push({ name, value: next });
135
+ }
128
136
  index += 1;
129
137
  }
138
+ if (mediaInputs.length > 0) {
139
+ Object.defineProperty(options, "__media_inputs", {
140
+ value: mediaInputs,
141
+ enumerable: false,
142
+ writable: true,
143
+ });
144
+ }
130
145
  return options;
131
146
  }
132
147
 
148
+ function repeatableOptionsFor(group, action) {
149
+ if (group === "image" && action === "generate") {
150
+ return new Set([...REPEATABLE_OPTIONS, "file"]);
151
+ }
152
+ if (group === "video" && action === "generate") {
153
+ return new Set([...REPEATABLE_OPTIONS, "file"]);
154
+ }
155
+ if (group === "video" && action === "copy") {
156
+ return REPEATABLE_OPTIONS;
157
+ }
158
+ return REPEATABLE_OPTIONS;
159
+ }
160
+
133
161
  function asArray(value) {
134
162
  if (value === undefined) return [];
135
163
  return Array.isArray(value) ? value : [value];
@@ -139,6 +167,7 @@ function validateCommandOptions(command) {
139
167
  const key = command.action ? `${command.group} ${command.action}` : command.group;
140
168
  const allowed = COMMAND_OPTIONS[key] ?? new Set();
141
169
  for (const option of Object.keys(command.options)) {
170
+ if (option.startsWith("_")) continue;
142
171
  if (GLOBAL_OPTIONS.has(option) || allowed.has(option)) continue;
143
172
  const flag = `--${option.replaceAll("_", "-")}`;
144
173
  const helpCommand = command.action
@@ -224,7 +253,9 @@ export async function runCommand(command, deps = {}) {
224
253
  ? ["generate", "sfx", "music"].includes(command.action)
225
254
  : command.group === "image"
226
255
  ? ["generate", "upload"].includes(command.action)
227
- : command.action === "generate";
256
+ : command.group === "video"
257
+ ? ["generate", "copy"].includes(command.action)
258
+ : command.action === "generate";
228
259
  if (!validAction) {
229
260
  throw new Error(`Unknown action for ${command.group}: ${command.action}`);
230
261
  }
@@ -238,13 +269,17 @@ export async function runCommand(command, deps = {}) {
238
269
  }
239
270
 
240
271
  async function handleLogin(command, deps) {
241
- const { ensureDeviceId, readConfig, writeAuthConfig } = await import("./config.js");
272
+ const { ensureDeviceId, readConfig, resolveOrigins, writeAuthConfig } = await import("./config.js");
242
273
  const { DEFAULT_CONSOLE_URL, createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
243
274
  const deviceId = await ensureDeviceId({ configDir: deps.configDir });
244
275
  const version = await readPackageVersion();
245
- const consoleOrigin = firstNonEmpty(command.options.console_url, DEFAULT_CONSOLE_URL);
276
+ const { consoleOrigin } = await resolveOrigins({
277
+ consoleUrl: command.options.console_url,
278
+ env: deps.env ?? process.env,
279
+ configDir: deps.configDir,
280
+ });
246
281
  const requestOptions = {
247
- consoleUrl: consoleOrigin,
282
+ consoleUrl: consoleOrigin ?? DEFAULT_CONSOLE_URL,
248
283
  fetch: deps.fetch,
249
284
  verbose: command.options.verbose,
250
285
  verboseLog: verboseLogger(deps.stderr, command.options.verbose),
@@ -426,6 +461,10 @@ async function handleImageUpload(command, deps) {
426
461
  if (typeof file !== "string" || file.trim() === "") {
427
462
  throw new Error("Missing --file value. Run `flatkey image upload --help` to see supported options.");
428
463
  }
464
+ const media = mediaInfoFromPath(file);
465
+ if (!media || media.kind !== "image") {
466
+ throw new Error(`Unsupported image file type: ${file}`);
467
+ }
429
468
  const apiKey = await resolveApiKey({
430
469
  apiKey: command.options.api_key,
431
470
  env: deps.env ?? process.env,
@@ -441,13 +480,13 @@ async function handleImageUpload(command, deps) {
441
480
  baseUrl: consoleOrigin ?? DEFAULT_CONSOLE_URL,
442
481
  file: await readFile(await expandHomePath(file)),
443
482
  filename: basename(file),
444
- contentType: imageContentTypeFromPath(file),
483
+ contentType: media.contentType,
445
484
  fetch: deps.fetch,
446
485
  verbose: command.options.verbose,
447
486
  verboseLog: verboseLogger(deps.stderr, command.options.verbose),
448
487
  });
449
488
  const data = response?.data ?? response;
450
- const signedUrl = extractUploadedImageUrl(response);
489
+ const signedUrl = extractUploadedMediaUrl(response);
451
490
  if (!signedUrl) {
452
491
  throw new Error("Image upload failed: missing signed_url.");
453
492
  }
@@ -479,9 +518,10 @@ async function handleGenerate(command, deps) {
479
518
  planTextRequest,
480
519
  planVideoRequest,
481
520
  uploadTempMediaImage,
521
+ uploadTempMediaVideo,
482
522
  } = await import("./api.js");
483
- const { persistArtifacts } = await import("./artifacts.js");
484
523
  const { createAnimation } = await import("./animation.js");
524
+ const { prepareVideoCopy } = await import("./video-copy.js");
485
525
  const apiKey = command.options.dry_run
486
526
  ? (command.options.api_key ?? "FLATKEY_API_KEY")
487
527
  : await resolveApiKey({
@@ -509,8 +549,46 @@ async function handleGenerate(command, deps) {
509
549
  fetch: deps.fetch,
510
550
  verboseLog: verboseLogger(deps.stderr, command.options.verbose),
511
551
  };
512
- if (command.group === "video" && !command.options.dry_run) {
513
- await uploadVideoLocalImages(options, { uploadTempMediaImage });
552
+ if (Array.isArray(command.options.__media_inputs)) {
553
+ options.__media_inputs = command.options.__media_inputs;
554
+ }
555
+ let videoCopy;
556
+ if (command.group === "video" && command.action === "copy") {
557
+ if (!command.options.source) {
558
+ throw new Error("Missing --source value. Run `flatkey video copy --help` to see supported options.");
559
+ }
560
+ videoCopy = await (deps.prepareVideoCopy ?? prepareVideoCopy)(command.options, deps);
561
+ options.prompt ??= "Recreate the reference video as faithfully as possible while following the supplied depth motion.";
562
+ options.duration = videoCopy.duration;
563
+ options.__media_inputs = [{
564
+ name: "depth_video",
565
+ value: videoCopy.depthPath,
566
+ kind: "local",
567
+ optionName: "file",
568
+ role: "reference_video",
569
+ }];
570
+ options.__copy_reference_mode = "depth-video";
571
+ }
572
+ if (command.group === "image" || command.group === "video") {
573
+ try {
574
+ await prepareMediaInputs(command, options, {
575
+ apiKey,
576
+ baseUrl: options.baseUrl,
577
+ tempMediaBaseUrl: tempMediaBaseUrl ?? DEFAULT_CONSOLE_URL,
578
+ fetch: deps.fetch,
579
+ verbose: command.options.verbose,
580
+ verboseLog: verboseLogger(deps.stderr, command.options.verbose),
581
+ uploadTempMediaImage,
582
+ uploadTempMediaVideo,
583
+ });
584
+ } catch (error) {
585
+ await videoCopy?.cleanup?.();
586
+ throw error;
587
+ }
588
+ }
589
+ if (command.group === "video" && command.action === "copy"
590
+ && !options.__media_inputs?.some((entry) => entry.kind === "video")) {
591
+ throw new Error("Video copy failed to prepare its depth-video reference.");
514
592
  }
515
593
  if (command.options.dry_run) {
516
594
  const request = command.group === "image"
@@ -524,7 +602,14 @@ async function handleGenerate(command, deps) {
524
602
  ? planAudioMusicRequest(options)
525
603
  : planAudioRequest(options)
526
604
  : planTextRequest(options);
527
- return { dryRun: true, kind: command.group, request: redactRequest(request) };
605
+ await videoCopy?.cleanup?.();
606
+ return {
607
+ dryRun: true,
608
+ kind: command.group,
609
+ request: redactRequest(request),
610
+ videoCopy: scrubVideoCopy(videoCopy),
611
+ copyReferenceMode: options.__copy_reference_mode,
612
+ };
528
613
  }
529
614
  const animation = createAnimation({
530
615
  json: Boolean(command.options.json),
@@ -532,20 +617,52 @@ async function handleGenerate(command, deps) {
532
617
  });
533
618
  animation.start(command.group);
534
619
  try {
535
- const response = command.group === "image"
536
- ? await generateImage(options)
537
- : command.group === "video"
538
- ? await generateVideo(options)
539
- : command.group === "audio"
540
- ? command.action === "sfx"
541
- ? await generateAudioSfx(options)
542
- : command.action === "music"
543
- ? await generateAudioMusic(options)
544
- : await generateAudio(options)
545
- : await generateText(options);
546
- const artifactResponse = command.group === "video"
547
- ? await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep })
548
- : response;
620
+ let response;
621
+ try {
622
+ response = command.group === "image"
623
+ ? await generateImage(options)
624
+ : command.group === "video"
625
+ ? await generateVideo(options)
626
+ : command.group === "audio"
627
+ ? command.action === "sfx"
628
+ ? await generateAudioSfx(options)
629
+ : command.action === "music"
630
+ ? await generateAudioMusic(options)
631
+ : await generateAudio(options)
632
+ : await generateText(options);
633
+ response = command.group === "video"
634
+ ? await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep })
635
+ : response;
636
+ } catch (error) {
637
+ if (!videoCopy || options.__copy_reference_mode !== "depth-video" || !isDepthVideoFetchFailure(error)) {
638
+ throw error;
639
+ }
640
+ options.__media_inputs = (videoCopy.referencePaths ?? []).map((value, index) => ({
641
+ name: `depth_frame_${String(index + 1).padStart(2, "0")}`,
642
+ value,
643
+ kind: "local",
644
+ optionName: "image",
645
+ role: index === 0
646
+ ? "first_frame"
647
+ : index === (videoCopy.referencePaths.length - 1)
648
+ ? "last_frame"
649
+ : "reference_image",
650
+ }));
651
+ await prepareMediaInputs(command, options, {
652
+ apiKey,
653
+ baseUrl: options.baseUrl,
654
+ tempMediaBaseUrl: tempMediaBaseUrl ?? DEFAULT_CONSOLE_URL,
655
+ fetch: deps.fetch,
656
+ verbose: command.options.verbose,
657
+ verboseLog: verboseLogger(deps.stderr, command.options.verbose),
658
+ uploadTempMediaImage,
659
+ uploadTempMediaVideo,
660
+ });
661
+ options.__copy_reference_mode = "depth-keyframes-fallback";
662
+ response = await generateVideo(options);
663
+ response = await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep });
664
+ }
665
+ const artifactResponse = response;
549
666
  if (command.group === "text") {
550
667
  const text = extractText(response);
551
668
  const output = await writeTextOutput(text, command.options.output);
@@ -553,7 +670,7 @@ async function handleGenerate(command, deps) {
553
670
  }
554
671
  const outDir = command.options.out ?? "flatkey-output";
555
672
  const output = command.options.output
556
- ?? (command.group === "video" ? join(outDir, "video-01.mp4") : undefined);
673
+ ?? (command.group === "video" ? await nextArtifactPath({ kind: command.group, outDir }) : undefined);
557
674
  const artifacts = await persistArtifacts({
558
675
  kind: command.group,
559
676
  response: artifactResponse,
@@ -561,64 +678,201 @@ async function handleGenerate(command, deps) {
561
678
  output,
562
679
  fetch: deps.fetch,
563
680
  });
564
- return { kind: command.group, artifacts, response: scrubArtifactResponse(artifactResponse) };
681
+ return {
682
+ kind: command.group,
683
+ artifacts,
684
+ response: scrubArtifactResponse(artifactResponse),
685
+ ...(videoCopy ? { copyReferenceMode: options.__copy_reference_mode } : {}),
686
+ };
565
687
  } finally {
566
688
  animation.stop();
689
+ await videoCopy?.cleanup?.();
567
690
  }
568
691
  }
569
692
 
570
- async function uploadVideoLocalImages(options, deps) {
571
- const localImages = asArray(options.image);
572
- const firstFrame = options.first_frame;
573
- const lastFrame = options.last_frame;
574
- if (localImages.length === 0 && !firstFrame && !lastFrame) return;
693
+ async function prepareMediaInputs(command, options, deps) {
694
+ const mediaInputs = orderedMediaInputs(command, options);
695
+ if (mediaInputs.length === 0) return;
696
+ assertMediaReferenceLimit(mediaInputs, command.group, options.model);
575
697
 
576
- const uploads = [];
577
- for (const file of localImages) {
578
- uploads.push(await uploadLocalImage(file, options, deps));
698
+ const resolved = [];
699
+ for (const input of mediaInputs) {
700
+ if (input.kind === "local") {
701
+ resolved.push(await uploadLocalMedia(input.value, command.group, deps, input.optionName ?? input.name));
702
+ } else {
703
+ resolved.push(await confirmRemoteMediaUrl(input.value, command.group, deps, input.name));
704
+ }
705
+ }
706
+ options.__media_inputs = resolved;
707
+ }
708
+
709
+ function assertMediaReferenceLimit(mediaInputs, commandGroup, model) {
710
+ const imageInputs = mediaInputs.filter((entry) => entry.kind !== "video");
711
+ const videoInputs = mediaInputs.filter((entry) => entry.kind === "video");
712
+ if (isSeedance25Model(model)) {
713
+ if (imageInputs.length > 30) {
714
+ throw new Error(`Too many reference images for flatkey ${commandGroup} generate with Seedance 2.5: maximum 30.`);
715
+ }
716
+ if (videoInputs.length > 10) {
717
+ throw new Error(`Too many reference videos for flatkey ${commandGroup} generate with Seedance 2.5: maximum 10.`);
718
+ }
719
+ return;
579
720
  }
580
- if (uploads.length > 0) {
581
- options.image_url = [...asArray(options.image_url), ...uploads];
721
+ if (imageInputs.length > MAX_REFERENCE_IMAGES) {
722
+ throw new Error(`Too many reference images for flatkey ${commandGroup} generate: maximum ${MAX_REFERENCE_IMAGES}.`);
582
723
  }
583
- if (firstFrame) {
584
- options.first_frame_url = await uploadLocalImage(firstFrame, options, deps);
724
+ }
725
+
726
+ function isSeedance25Model(model) {
727
+ return String(model ?? "").toLowerCase().replaceAll(/[-_.]/g, "").includes("seedance25");
728
+ }
729
+
730
+ function orderedMediaInputs(command, options) {
731
+ const mediaInputs = Array.isArray(options.__media_inputs) ? options.__media_inputs : [];
732
+ if (mediaInputs.length > 0 && mediaInputs[0] && Object.prototype.hasOwnProperty.call(mediaInputs[0], "kind")) {
733
+ return mediaInputs;
585
734
  }
586
- if (lastFrame) {
587
- options.last_frame_url = await uploadLocalImage(lastFrame, options, deps);
735
+ if (mediaInputs.length > 0) {
736
+ return mediaInputs.map((entry) => ({
737
+ name: entry.name,
738
+ value: entry.value,
739
+ kind: entry.name.endsWith("_url") ? "url" : "local",
740
+ }));
588
741
  }
742
+
743
+ const legacyInputs = [];
744
+ const pushLocal = (name, value) => {
745
+ for (const file of asArray(value)) {
746
+ legacyInputs.push({ name, value: file, kind: "local" });
747
+ }
748
+ };
749
+ const pushUrl = (name, value) => {
750
+ for (const url of asArray(value)) {
751
+ legacyInputs.push({ name, value: url, kind: "url" });
752
+ }
753
+ };
754
+
755
+ pushLocal("file", options.file);
756
+ pushLocal("image", options.image);
757
+ pushUrl("image_url", options.image_url);
758
+ if (command.group === "video") {
759
+ pushUrl("video_url", options.video_url);
760
+ pushLocal("first_frame", options.first_frame);
761
+ pushUrl("first_frame_url", options.first_frame_url);
762
+ pushLocal("last_frame", options.last_frame);
763
+ pushUrl("last_frame_url", options.last_frame_url);
764
+ }
765
+ return legacyInputs;
589
766
  }
590
767
 
591
- async function uploadLocalImage(file, options, deps) {
768
+ async function uploadLocalMedia(file, commandGroup, deps, optionName) {
592
769
  const { basename } = await import("node:path");
593
770
  const { readFile } = await import("node:fs/promises");
594
- const response = await deps.uploadTempMediaImage({
595
- apiKey: options.apiKey,
596
- baseUrl: options.tempMediaBaseUrl ?? options.baseUrl,
771
+ const media = mediaInfoFromPath(file);
772
+ const allowedKinds = allowedMediaKindsForOption(commandGroup, optionName);
773
+ if (!media || !allowedKinds.has(media.kind)) {
774
+ throw new Error(`Unsupported --${optionName.replaceAll("_", "-")} file type for flatkey ${commandGroup} generate: ${file}`);
775
+ }
776
+ const uploader = media.kind === "video" ? deps.uploadTempMediaVideo : deps.uploadTempMediaImage;
777
+ const response = await uploader({
778
+ apiKey: deps.apiKey,
779
+ baseUrl: deps.tempMediaBaseUrl ?? deps.baseUrl,
597
780
  file: await readFile(await expandHomePath(file)),
598
781
  filename: basename(file),
599
- contentType: imageContentTypeFromPath(file),
600
- fetch: options.fetch,
601
- verbose: options.verbose,
602
- verboseLog: options.verboseLog,
782
+ contentType: media.contentType,
783
+ fetch: deps.fetch,
784
+ verbose: deps.verbose,
785
+ verboseLog: deps.verboseLog,
603
786
  });
604
- const url = extractUploadedImageUrl(response);
605
- if (!url) throw new Error(`Image upload failed: missing signed_url for ${file}`);
606
- return url;
787
+ const url = extractUploadedMediaUrl(response);
788
+ if (!url) throw new Error(`Media upload failed: missing signed_url for ${file}`);
789
+ return {
790
+ name: optionName,
791
+ value: url,
792
+ kind: media.kind,
793
+ role: mediaRoleFromOption(optionName, media.kind),
794
+ mime: media.contentType,
795
+ };
607
796
  }
608
797
 
609
- function extractUploadedImageUrl(response) {
798
+ async function confirmRemoteMediaUrl(url, commandGroup, deps, optionName) {
799
+ const fetchImpl = deps.fetch ?? fetch;
800
+ const response = await fetchImpl(url, { method: "HEAD" });
801
+ const media = mediaInfoFromMime(headerValue(response?.headers, "content-type"));
802
+ const allowedKinds = allowedMediaKindsForOption(commandGroup, optionName);
803
+ if (!media || !allowedKinds.has(media.kind)) {
804
+ throw new Error(`Unsupported --${optionName.replaceAll("_", "-")} MIME type for flatkey ${commandGroup} generate: ${headerValue(response?.headers, "content-type") ?? "unknown"}`);
805
+ }
806
+ return {
807
+ name: optionName,
808
+ value: url,
809
+ kind: media.kind,
810
+ role: mediaRoleFromOption(optionName, media.kind),
811
+ mime: media.contentType,
812
+ };
813
+ }
814
+
815
+ function extractUploadedMediaUrl(response) {
610
816
  const data = response?.data ?? response;
611
817
  return data?.signed_url ?? data?.signedUrl ?? data?.url ?? response?.signed_url ?? response?.signedUrl ?? response?.url;
612
818
  }
613
819
 
614
- function imageContentTypeFromPath(file) {
820
+ function mediaInfoFromPath(file) {
615
821
  const lower = String(file).toLowerCase();
616
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
617
- if (lower.endsWith(".png")) return "image/png";
618
- if (lower.endsWith(".webp")) return "image/webp";
822
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return { kind: "image", contentType: "image/jpeg" };
823
+ if (lower.endsWith(".png")) return { kind: "image", contentType: "image/png" };
824
+ if (lower.endsWith(".webp")) return { kind: "image", contentType: "image/webp" };
825
+ if (lower.endsWith(".mp4")) return { kind: "video", contentType: "video/mp4" };
826
+ if (lower.endsWith(".mov")) return { kind: "video", contentType: "video/quicktime" };
827
+ if (lower.endsWith(".bmp")) return { kind: "image", contentType: "image/bmp" };
828
+ if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return { kind: "image", contentType: "image/tiff" };
829
+ if (lower.endsWith(".gif")) return { kind: "image", contentType: "image/gif" };
830
+ if (lower.endsWith(".heic")) return { kind: "image", contentType: "image/heic" };
831
+ if (lower.endsWith(".heif")) return { kind: "image", contentType: "image/heif" };
619
832
  return undefined;
620
833
  }
621
834
 
835
+ function mediaInfoFromMime(contentType) {
836
+ if (typeof contentType !== "string") return undefined;
837
+ const lower = contentType.toLowerCase();
838
+ if (lower.startsWith("image/jpeg")) return { kind: "image", contentType: "image/jpeg" };
839
+ if (lower.startsWith("image/png")) return { kind: "image", contentType: "image/png" };
840
+ if (lower.startsWith("image/webp")) return { kind: "image", contentType: "image/webp" };
841
+ if (lower.startsWith("video/mp4")) return { kind: "video", contentType: "video/mp4" };
842
+ if (lower.startsWith("video/quicktime")) return { kind: "video", contentType: "video/quicktime" };
843
+ if (lower.startsWith("image/bmp")) return { kind: "image", contentType: "image/bmp" };
844
+ if (lower.startsWith("image/tiff")) return { kind: "image", contentType: "image/tiff" };
845
+ if (lower.startsWith("image/gif")) return { kind: "image", contentType: "image/gif" };
846
+ if (lower.startsWith("image/heic")) return { kind: "image", contentType: "image/heic" };
847
+ if (lower.startsWith("image/heif")) return { kind: "image", contentType: "image/heif" };
848
+ return undefined;
849
+ }
850
+
851
+ function mediaRoleFromOption(optionName, kind) {
852
+ if (optionName === "first_frame" || optionName === "first_frame_url") return "first_frame";
853
+ if (optionName === "last_frame" || optionName === "last_frame_url") return "last_frame";
854
+ if (kind === "video") return "reference_video";
855
+ return "reference_image";
856
+ }
857
+
858
+ function allowedMediaKindsForOption(commandGroup, optionName) {
859
+ if (commandGroup === "image") return new Set(["image"]);
860
+ if (optionName === "file") return new Set(["image", "video"]);
861
+ if (optionName === "video_url") return new Set(["video"]);
862
+ if (optionName === "first_frame" || optionName === "first_frame_url" || optionName === "last_frame" || optionName === "last_frame_url" || optionName === "image" || optionName === "image_url") {
863
+ return new Set(["image"]);
864
+ }
865
+ return new Set(["image"]);
866
+ }
867
+
868
+ function headerValue(headers, name) {
869
+ if (!headers) return undefined;
870
+ if (typeof headers.get === "function") {
871
+ return headers.get(name) ?? headers.get(name.toLowerCase());
872
+ }
873
+ return headers[name] ?? headers[name.toLowerCase()];
874
+ }
875
+
622
876
  async function waitForVideoResult(response, options) {
623
877
  if (hasArtifact(response)) return response;
624
878
  const taskId = videoTaskId(response);
@@ -715,6 +969,29 @@ function scrubArtifactResponse(value) {
715
969
  );
716
970
  }
717
971
 
972
+ function scrubVideoCopy(value) {
973
+ if (!value) return undefined;
974
+ return {
975
+ source: value.source,
976
+ depthPath: value.depthPath,
977
+ referencePaths: value.referencePaths,
978
+ duration: value.duration,
979
+ sourceInfo: value.sourceInfo,
980
+ helper: value.helper,
981
+ };
982
+ }
983
+
984
+ function isDepthVideoFetchFailure(error) {
985
+ const code = String(error?.code ?? "").toLowerCase();
986
+ const message = String(error?.message ?? error).toLowerCase();
987
+ return code.includes("fail_to_fetch")
988
+ || code.includes("video_fetch")
989
+ || message.includes("fail_to_fetch")
990
+ || message.includes("failed to fetch")
991
+ || message.includes("unable to fetch")
992
+ || message.includes("video reference");
993
+ }
994
+
718
995
  async function handleVoices(command, deps) {
719
996
  const { resolveApiKey, resolveOrigins } = await import("./config.js");
720
997
  const { getVoices } = await import("./api.js");