@flatkey-ai/cli 0.3.2 → 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/README.md CHANGED
@@ -140,7 +140,29 @@ flatkey video generate \
140
140
  -o launch.mp4
141
141
  ```
142
142
 
143
- Video ratios: `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `1:1`.
143
+ ### Copy Video
144
+
145
+ Recreate a local 5-15 second video using a generated Depth Anything V2 video
146
+ reference:
147
+
148
+ ```bash
149
+ flatkey video copy \
150
+ --source input.mp4 \
151
+ --frame-ratio 80 \
152
+ --depth-resolution 480p \
153
+ -o result.mp4
154
+ ```
155
+
156
+ `video copy` preserves the source duration by default. The source and requested
157
+ duration must be between 5 and 15 seconds. Depth processing uses a platform
158
+ runtime downloaded by the CLI; runtime distribution is configured through
159
+ `FLATKEY_VIDEO_RUNTIME_BASE_URL` until the Flatkey runtime release channel is
160
+ published. The generated depth video is uploaded as the primary motion
161
+ reference. If the selected upstream model cannot fetch video references, the
162
+ CLI explicitly falls back to three depth keyframes and reports that mode in
163
+ JSON output as `depth-keyframes-fallback`.
164
+
165
+ Video ratios: `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `1:1`, `adaptive`.
144
166
  Video resolutions: `480p`, `720p`, `1080p`.
145
167
 
146
168
  Local reference images are uploaded through Flatkey temporary media first:
@@ -153,6 +175,22 @@ flatkey video generate \
153
175
  -o kitten.mp4
154
176
  ```
155
177
 
178
+ Seedance 2.5 accepts reference images and videos as ordered content inputs:
179
+
180
+ ```bash
181
+ flatkey video generate \
182
+ --model seedance-2-5-pro \
183
+ --prompt "保持人物、服装和镜头运动一致" \
184
+ --image-url https://example.com/character.png \
185
+ --video-url https://example.com/motion.mp4 \
186
+ --ratio adaptive \
187
+ -o result.mp4
188
+ ```
189
+
190
+ For Seedance 2.5, the CLI allows up to 30 reference images and 10 reference
191
+ videos in one request. Local files are uploaded before generation and all
192
+ inputs keep their command-line order. Reference videos can be `.mp4` or `.mov`.
193
+
156
194
  First/last frame:
157
195
 
158
196
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Flatkey media generation CLI for image, video, audio, credits, status, and models.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,9 +8,11 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
- "src"
11
+ "src",
12
+ "scripts"
12
13
  ],
13
14
  "scripts": {
15
+ "postinstall": "node scripts/install-video-runtime.js",
14
16
  "test": "node --test"
15
17
  },
16
18
  "engines": {
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { ensureVideoRuntime } from "../src/video-runtime.js";
3
+
4
+ try {
5
+ await ensureVideoRuntime();
6
+ } catch (error) {
7
+ const message = error?.message ?? String(error);
8
+ if (process.env.FLATKEY_VIDEO_RUNTIME_REQUIRED === "1") {
9
+ process.stderr.write(`${message}\n`);
10
+ process.exitCode = 1;
11
+ } else {
12
+ process.stderr.write(`Flatkey video runtime not installed during postinstall: ${message}\n`);
13
+ }
14
+ }
package/src/api.js CHANGED
@@ -5,10 +5,11 @@ export const DEFAULT_MODELS_BASE_URL = "https://console.flatkey.ai";
5
5
  export const DEFAULT_CONSOLE_URL = "https://console.flatkey.ai";
6
6
 
7
7
  export class FlatkeyError extends Error {
8
- constructor(message, { status } = {}) {
8
+ constructor(message, { status, code } = {}) {
9
9
  super(message);
10
10
  this.name = "FlatkeyError";
11
11
  this.status = status;
12
+ this.code = code;
12
13
  }
13
14
  }
14
15
 
@@ -39,7 +40,7 @@ async function uploadTempMediaByKind(options, kind) {
39
40
  export function planImageRequest(options) {
40
41
  const model = options.model ?? "nano-banana-pro-preview";
41
42
  const mediaInputs = orderedMediaInputs(options);
42
- assertReferenceImageLimit(mediaInputs, "image");
43
+ assertReferenceLimits(mediaInputs, "image", model);
43
44
  if (mediaInputs.some((entry) => entry.kind === "video")) {
44
45
  throw new Error("Image models only accept image inputs.");
45
46
  }
@@ -90,7 +91,7 @@ export function getVideo(options, taskId) {
90
91
  export function planVideoRequest(options) {
91
92
  const model = options.model ?? "seedance-2.0-pro";
92
93
  const mediaInputs = orderedMediaInputs(options);
93
- assertReferenceImageLimit(mediaInputs, "video");
94
+ assertReferenceLimits(mediaInputs, "video", model);
94
95
  if (mediaInputs.some((entry) => entry.role === "first_frame" && entry.kind === "video")
95
96
  || mediaInputs.some((entry) => entry.role === "last_frame" && entry.kind === "video")) {
96
97
  throw new Error("Video first-frame and last-frame inputs must be images.");
@@ -98,9 +99,13 @@ export function planVideoRequest(options) {
98
99
  const imageUrls = mediaInputs
99
100
  .filter((entry) => entry.kind !== "video")
100
101
  .map((entry) => entry.url);
102
+ const videoUrls = mediaInputs
103
+ .filter((entry) => entry.kind === "video")
104
+ .map((entry) => entry.url);
101
105
  const ratio = validateOptionalValue(
102
- optionValue(options, "ratio", "aspect") ?? (isMiniMaxModel(model) ? "16:9" : undefined),
103
- ["16:9", "9:16", "4:3", "3:4", "21:9", "1:1"],
106
+ optionValue(options, "ratio", "aspect")
107
+ ?? (isMiniMaxModel(model) ? "16:9" : isSeedance25Model(model) ? "adaptive" : undefined),
108
+ ["16:9", "9:16", "4:3", "3:4", "21:9", "1:1", "adaptive"],
104
109
  "ratio",
105
110
  );
106
111
  const resolution = validateOptionalValue(
@@ -113,19 +118,24 @@ export function planVideoRequest(options) {
113
118
  prompt: options.prompt,
114
119
  duration: options.duration === undefined && isMiniMaxModel(model)
115
120
  ? 5
116
- : parseOptionalInteger(options.duration),
121
+ : options.duration === undefined && isSeedance25Model(model)
122
+ ? -1
123
+ : parseOptionalFloat(options.duration),
117
124
  aspect: ratio,
118
125
  ratio,
119
126
  resolution,
120
127
  quality: resolution,
121
128
  fps: parseOptionalInteger(options.fps),
122
129
  generate_audio: parseOptionalBoolean(options.generate_audio) ?? true,
130
+ omni_reference_task_type: isSeedance25Model(model) && mediaInputs.some((entry) => entry.kind === "video" || entry.role === "reference_image")
131
+ ? "auto"
132
+ : undefined,
123
133
  temp_url: true,
124
134
  images: !isSeedanceModel(model) && imageUrls.length > 0 ? imageUrls : undefined,
125
135
  });
126
136
  const seedanceContent = buildSeedanceContent(options, mediaInputs);
127
137
  if ((isSeedanceModel(model) || isMiniMaxModel(model)) && seedanceContent.length > 0) {
128
- return planJsonPost(options, "/v1/video/generations", cleanObject({
138
+ return planJsonPost(options, videoGenerationPath(model, videoUrls), cleanObject({
129
139
  ...basePayload,
130
140
  content: seedanceContent,
131
141
  }));
@@ -282,6 +292,7 @@ async function requestJsonFromPlan(options, plan) {
282
292
  if (!response.ok || body?.success === false) {
283
293
  throw new FlatkeyError(extractErrorMessage(body, response.status), {
284
294
  status: response.status,
295
+ code: body?.code ?? body?.error?.code,
285
296
  });
286
297
  }
287
298
  return body;
@@ -306,6 +317,7 @@ async function requestBinaryArtifactFromPlan(options, plan) {
306
317
  logResponseBody(options, body);
307
318
  throw new FlatkeyError(extractErrorMessage(body, response.status), {
308
319
  status: response.status,
320
+ code: body?.code ?? body?.error?.code,
309
321
  });
310
322
  }
311
323
  return {
@@ -414,6 +426,13 @@ function isMiniMaxModel(model) {
414
426
  return /^minimax-h3$/i.test(model);
415
427
  }
416
428
 
429
+ function videoGenerationPath(model, videoUrls = []) {
430
+ // Seedance keeps text, image, and video-reference content on /v1/videos.
431
+ // The legacy route does not reliably fetch video references upstream.
432
+ if (isSeedanceModel(model)) return "/v1/videos";
433
+ return "/v1/video/generations";
434
+ }
435
+
417
436
  function buildSeedanceContent(options, mediaInputs = orderedMediaInputs(options)) {
418
437
  const content = [];
419
438
  if (options.prompt !== undefined) {
@@ -440,10 +459,21 @@ function cleanObject(value) {
440
459
  );
441
460
  }
442
461
 
443
- function assertReferenceImageLimit(mediaInputs, kind) {
462
+ function assertReferenceLimits(mediaInputs, kind, model) {
444
463
  const imageInputs = mediaInputs.filter((entry) => entry.kind !== "video");
445
- if (imageInputs.length <= 5) return;
446
- throw new Error(`Too many reference images for flatkey ${kind} generate: maximum 5.`);
464
+ const videoInputs = mediaInputs.filter((entry) => entry.kind === "video");
465
+ if (isSeedance25Model(model)) {
466
+ if (imageInputs.length > 30) {
467
+ throw new Error(`Too many reference images for flatkey ${kind} generate with Seedance 2.5: maximum 30.`);
468
+ }
469
+ if (videoInputs.length > 10) {
470
+ throw new Error(`Too many reference videos for flatkey ${kind} generate with Seedance 2.5: maximum 10.`);
471
+ }
472
+ return;
473
+ }
474
+ if (imageInputs.length > 5) {
475
+ throw new Error(`Too many reference images for flatkey ${kind} generate: maximum 5.`);
476
+ }
447
477
  }
448
478
 
449
479
  function orderedMediaInputs(options) {
@@ -479,10 +509,14 @@ function orderedMediaInputs(options) {
479
509
  function inferMediaKindFromName(name, value) {
480
510
  const lowerName = String(name ?? "").toLowerCase();
481
511
  if (lowerName.includes("video")) return "video";
482
- if (typeof value === "string" && value.toLowerCase().endsWith(".mp4")) return "video";
512
+ if (typeof value === "string" && /\.(mp4|mov)$/i.test(value)) return "video";
483
513
  return "image";
484
514
  }
485
515
 
516
+ function isSeedance25Model(model) {
517
+ return String(model ?? "").toLowerCase().replaceAll(/[-_.]/g, "").includes("seedance25");
518
+ }
519
+
486
520
  async function readJson(response) {
487
521
  try {
488
522
  return await response.json();
package/src/cli.js CHANGED
@@ -44,6 +44,7 @@ const COMMAND_OPTIONS = {
44
44
  version: new Set([]),
45
45
  video: new Set([]),
46
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"]),
47
48
  };
48
49
 
49
50
  export function parseArgv(argv) {
@@ -151,6 +152,9 @@ function repeatableOptionsFor(group, action) {
151
152
  if (group === "video" && action === "generate") {
152
153
  return new Set([...REPEATABLE_OPTIONS, "file"]);
153
154
  }
155
+ if (group === "video" && action === "copy") {
156
+ return REPEATABLE_OPTIONS;
157
+ }
154
158
  return REPEATABLE_OPTIONS;
155
159
  }
156
160
 
@@ -249,7 +253,9 @@ export async function runCommand(command, deps = {}) {
249
253
  ? ["generate", "sfx", "music"].includes(command.action)
250
254
  : command.group === "image"
251
255
  ? ["generate", "upload"].includes(command.action)
252
- : command.action === "generate";
256
+ : command.group === "video"
257
+ ? ["generate", "copy"].includes(command.action)
258
+ : command.action === "generate";
253
259
  if (!validAction) {
254
260
  throw new Error(`Unknown action for ${command.group}: ${command.action}`);
255
261
  }
@@ -515,6 +521,7 @@ async function handleGenerate(command, deps) {
515
521
  uploadTempMediaVideo,
516
522
  } = await import("./api.js");
517
523
  const { createAnimation } = await import("./animation.js");
524
+ const { prepareVideoCopy } = await import("./video-copy.js");
518
525
  const apiKey = command.options.dry_run
519
526
  ? (command.options.api_key ?? "FLATKEY_API_KEY")
520
527
  : await resolveApiKey({
@@ -545,17 +552,43 @@ async function handleGenerate(command, deps) {
545
552
  if (Array.isArray(command.options.__media_inputs)) {
546
553
  options.__media_inputs = command.options.__media_inputs;
547
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
+ }
548
572
  if (command.group === "image" || command.group === "video") {
549
- await prepareMediaInputs(command, options, {
550
- apiKey,
551
- baseUrl: options.baseUrl,
552
- tempMediaBaseUrl: tempMediaBaseUrl ?? DEFAULT_CONSOLE_URL,
553
- fetch: deps.fetch,
554
- verbose: command.options.verbose,
555
- verboseLog: verboseLogger(deps.stderr, command.options.verbose),
556
- uploadTempMediaImage,
557
- uploadTempMediaVideo,
558
- });
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.");
559
592
  }
560
593
  if (command.options.dry_run) {
561
594
  const request = command.group === "image"
@@ -569,7 +602,14 @@ async function handleGenerate(command, deps) {
569
602
  ? planAudioMusicRequest(options)
570
603
  : planAudioRequest(options)
571
604
  : planTextRequest(options);
572
- 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
+ };
573
613
  }
574
614
  const animation = createAnimation({
575
615
  json: Boolean(command.options.json),
@@ -577,20 +617,52 @@ async function handleGenerate(command, deps) {
577
617
  });
578
618
  animation.start(command.group);
579
619
  try {
580
- const response = command.group === "image"
581
- ? await generateImage(options)
582
- : command.group === "video"
583
- ? await generateVideo(options)
584
- : command.group === "audio"
585
- ? command.action === "sfx"
586
- ? await generateAudioSfx(options)
587
- : command.action === "music"
588
- ? await generateAudioMusic(options)
589
- : await generateAudio(options)
590
- : await generateText(options);
591
- const artifactResponse = command.group === "video"
592
- ? await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep })
593
- : 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;
594
666
  if (command.group === "text") {
595
667
  const text = extractText(response);
596
668
  const output = await writeTextOutput(text, command.options.output);
@@ -606,21 +678,27 @@ async function handleGenerate(command, deps) {
606
678
  output,
607
679
  fetch: deps.fetch,
608
680
  });
609
- 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
+ };
610
687
  } finally {
611
688
  animation.stop();
689
+ await videoCopy?.cleanup?.();
612
690
  }
613
691
  }
614
692
 
615
693
  async function prepareMediaInputs(command, options, deps) {
616
694
  const mediaInputs = orderedMediaInputs(command, options);
617
695
  if (mediaInputs.length === 0) return;
618
- assertMediaReferenceLimit(mediaInputs, command.group);
696
+ assertMediaReferenceLimit(mediaInputs, command.group, options.model);
619
697
 
620
698
  const resolved = [];
621
699
  for (const input of mediaInputs) {
622
700
  if (input.kind === "local") {
623
- resolved.push(await uploadLocalMedia(input.value, command.group, deps, input.name));
701
+ resolved.push(await uploadLocalMedia(input.value, command.group, deps, input.optionName ?? input.name));
624
702
  } else {
625
703
  resolved.push(await confirmRemoteMediaUrl(input.value, command.group, deps, input.name));
626
704
  }
@@ -628,10 +706,25 @@ async function prepareMediaInputs(command, options, deps) {
628
706
  options.__media_inputs = resolved;
629
707
  }
630
708
 
631
- function assertMediaReferenceLimit(mediaInputs, commandGroup) {
709
+ function assertMediaReferenceLimit(mediaInputs, commandGroup, model) {
632
710
  const imageInputs = mediaInputs.filter((entry) => entry.kind !== "video");
633
- if (imageInputs.length <= MAX_REFERENCE_IMAGES) return;
634
- throw new Error(`Too many reference images for flatkey ${commandGroup} generate: maximum ${MAX_REFERENCE_IMAGES}.`);
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;
720
+ }
721
+ if (imageInputs.length > MAX_REFERENCE_IMAGES) {
722
+ throw new Error(`Too many reference images for flatkey ${commandGroup} generate: maximum ${MAX_REFERENCE_IMAGES}.`);
723
+ }
724
+ }
725
+
726
+ function isSeedance25Model(model) {
727
+ return String(model ?? "").toLowerCase().replaceAll(/[-_.]/g, "").includes("seedance25");
635
728
  }
636
729
 
637
730
  function orderedMediaInputs(command, options) {
@@ -730,6 +823,12 @@ function mediaInfoFromPath(file) {
730
823
  if (lower.endsWith(".png")) return { kind: "image", contentType: "image/png" };
731
824
  if (lower.endsWith(".webp")) return { kind: "image", contentType: "image/webp" };
732
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" };
733
832
  return undefined;
734
833
  }
735
834
 
@@ -740,6 +839,12 @@ function mediaInfoFromMime(contentType) {
740
839
  if (lower.startsWith("image/png")) return { kind: "image", contentType: "image/png" };
741
840
  if (lower.startsWith("image/webp")) return { kind: "image", contentType: "image/webp" };
742
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" };
743
848
  return undefined;
744
849
  }
745
850
 
@@ -864,6 +969,29 @@ function scrubArtifactResponse(value) {
864
969
  );
865
970
  }
866
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
+
867
995
  async function handleVoices(command, deps) {
868
996
  const { resolveApiKey, resolveOrigins } = await import("./config.js");
869
997
  const { getVoices } = await import("./api.js");
package/src/help.js CHANGED
@@ -16,6 +16,7 @@ JSON mode:
16
16
  Commands:
17
17
  - flatkey image generate --prompt "<prompt>" --json [--model <model>] [--file <path>] [--image-url <url>] [--output image.png]
18
18
  - flatkey video generate --prompt "<prompt>" --json [--model seedance2] [--file <path>] [--image-url <url>] [--video-url <url>] [--ratio 16:9] [--resolution 720p] [--output video.mp4]
19
+ - flatkey video copy --source input.mp4 --json [--model seedance2] [--frame-ratio 80] [--depth-resolution 480p] [--output video.mp4]
19
20
  - flatkey audio generate --prompt "<text>" --json [--voice-id <voice_id>] [--model eleven_multilingual_v2] [--output speech.mp3]
20
21
  - flatkey audio sfx --prompt "<sound>" --json [--duration <seconds>] [--output sfx.mp3]
21
22
  - flatkey audio music --prompt "<music prompt>" --json [--music-length-ms <ms>] [--output music.mp3]
@@ -54,6 +55,7 @@ Commands:
54
55
  image generate --prompt <txt> Generate image
55
56
  image upload --file <path> Upload local image and return a temporary URL
56
57
  video generate --prompt <txt> Generate video
58
+ video copy --source <mp4> Recreate a 5-15s video from Depth Anything V2 motion
57
59
  audio generate --prompt <txt> Generate speech with ElevenLabs voices
58
60
  audio sfx --prompt <txt> Generate sound effects
59
61
  audio music --prompt <txt> Generate music
@@ -79,7 +81,7 @@ Environment:
79
81
  --console-url <url> Override Flatkey console URL for login
80
82
 
81
83
  Video options:
82
- --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
84
+ --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, 1:1, or adaptive
83
85
  --resolution <value> 480p, 720p, or 1080p
84
86
  `;
85
87
  }
@@ -216,22 +218,34 @@ Aliases:
216
218
 
217
219
  Options:
218
220
  --json Print machine-readable JSON`,
219
- video: `Usage: flatkey video generate --prompt <txt> [options]
221
+ video: `Usage: flatkey video <command> [options]
222
+
223
+ Commands:
224
+ generate --prompt <txt> Generate video
225
+ copy --source <mp4> Recreate a 5-15s video from local depth motion
220
226
 
221
227
  Options:
222
228
  --model <model> Model id, default seedance-2.0-pro
223
229
  --duration <seconds> Video duration
230
+ --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, 1:1, or adaptive
231
+ --resolution <value> 480p, 720p, or 1080p
232
+ --generate-audio <true|false> Generate audio, default true
233
+ --output, -o <file> Write video file
234
+ --json Print machine-readable JSON`,
235
+ "video copy": `Usage: flatkey video copy --source <mp4> [options]
236
+
237
+ Options:
238
+ --source <mp4> Local source video, must be 5-15 seconds
239
+ --frame-ratio <20..100> Percent of frames kept for depth, default 100
240
+ --depth-resolution <value> original, 480p, or 320p; default 480p
241
+ --depth-location <dir> Directory for temporary depth video files
242
+ --keep-depth Keep generated depth video and reference frames
243
+ --model <model> Model id, default seedance-2.0-pro
244
+ --prompt <txt> Prompt override for copy generation
245
+ --duration <seconds> Must match source duration, 5-15 seconds
224
246
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
225
247
  --resolution <value> 480p, 720p, or 1080p
226
248
  --generate-audio <true|false> Generate audio, default true
227
- --image <file> Upload local reference image, repeatable
228
- --image-url <url> Add reference image URL, repeatable
229
- --file <path> Local image or video input, repeatable
230
- --first-frame <file> Upload local first frame image
231
- --video-url <url> Add reference video URL, repeatable
232
- --first-frame-url <url> Add first frame image URL
233
- --last-frame <file> Upload local last frame image
234
- --last-frame-url <url> Add last frame image URL
235
249
  --fps <fps> Frames per second
236
250
  --output, -o <file> Write video file
237
251
  --json Print machine-readable JSON`,
@@ -247,7 +261,7 @@ Options:
247
261
  --image-url <url> Add reference image URL, repeatable
248
262
  --file <path> Local image or video input, repeatable
249
263
  --first-frame <file> Upload local first frame image
250
- --video-url <url> Add reference video URL, repeatable
264
+ --video-url <url> Add reference video URL, repeatable; Seedance 2.5 supports up to 10
251
265
  --first-frame-url <url> Add first frame image URL
252
266
  --last-frame <file> Upload local last frame image
253
267
  --last-frame-url <url> Add last frame image URL
@@ -0,0 +1,251 @@
1
+ import { access, mkdir, rm } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+
6
+ export const MIN_COPY_DURATION_SECONDS = 5;
7
+ export const MAX_COPY_DURATION_SECONDS = 15;
8
+ export const DEFAULT_FRAME_RATIO = 100;
9
+ export const DEFAULT_DEPTH_RESOLUTION = "480p";
10
+
11
+ export async function prepareVideoCopy(options, deps = {}) {
12
+ const source = await expandHomePath(options.source);
13
+ const sourceInfo = await inspectVideo(source, deps);
14
+ const duration = sourceInfo.duration;
15
+ validateCopyDuration(duration, "source video");
16
+
17
+ const requestedDuration = options.duration === undefined
18
+ ? duration
19
+ : parseDuration(options.duration, "--duration");
20
+ validateCopyDuration(requestedDuration, "--duration");
21
+ if (Math.abs(requestedDuration - duration) > 0.01) {
22
+ throw new Error(
23
+ `--duration must match the source video duration (${formatSeconds(duration)}s); time stretching is not supported by video copy.`,
24
+ );
25
+ }
26
+
27
+ const depthRoot = await resolveDepthLocation(options.depth_location, deps);
28
+ const workDir = join(depthRoot, `copy-${Date.now()}-${Math.random().toString(16).slice(2)}`);
29
+ await mkdir(workDir, { recursive: true });
30
+ const depthPath = join(workDir, "depth.mp4");
31
+ let helperResult;
32
+ try {
33
+ helperResult = await runDepthHelper({
34
+ source,
35
+ output: depthPath,
36
+ frameRatio: parseFrameRatio(options.frame_ratio),
37
+ resolution: parseDepthResolution(options.depth_resolution),
38
+ model: options.depth_model,
39
+ workDir,
40
+ }, deps);
41
+ } catch (error) {
42
+ if (!options.keep_depth) await rm(workDir, { recursive: true, force: true });
43
+ throw error;
44
+ }
45
+ let referencePaths;
46
+ try {
47
+ referencePaths = await (deps.extractReferenceFrames ?? extractReferenceFrames)({
48
+ source: helperResult.output ?? depthPath,
49
+ outputDir: workDir,
50
+ duration,
51
+ ffmpegPath: deps.ffmpegPath,
52
+ env: deps.env,
53
+ }, deps);
54
+ } catch (error) {
55
+ if (!options.keep_depth) await rm(workDir, { recursive: true, force: true });
56
+ throw error;
57
+ }
58
+
59
+ return {
60
+ source,
61
+ depthPath: helperResult.output ?? depthPath,
62
+ referencePaths,
63
+ workDir,
64
+ duration: requestedDuration,
65
+ sourceInfo,
66
+ helper: helperResult,
67
+ cleanup: async () => {
68
+ if (!options.keep_depth) {
69
+ await rm(workDir, { recursive: true, force: true });
70
+ }
71
+ },
72
+ };
73
+ }
74
+
75
+ export function validateCopyDuration(value, label = "duration") {
76
+ const duration = parseDuration(value, label);
77
+ if (duration < MIN_COPY_DURATION_SECONDS || duration > MAX_COPY_DURATION_SECONDS) {
78
+ throw new Error(
79
+ `${label} must be between ${MIN_COPY_DURATION_SECONDS} and ${MAX_COPY_DURATION_SECONDS} seconds; received ${formatSeconds(duration)}s.`,
80
+ );
81
+ }
82
+ return duration;
83
+ }
84
+
85
+ export function parseDuration(value, label = "duration") {
86
+ const duration = Number(value);
87
+ if (!Number.isFinite(duration) || duration <= 0) {
88
+ throw new Error(`${label} must be a positive number of seconds.`);
89
+ }
90
+ return duration;
91
+ }
92
+
93
+ export function parseFrameRatio(value = DEFAULT_FRAME_RATIO) {
94
+ const ratio = Number(value);
95
+ if (!Number.isInteger(ratio) || ratio < 20 || ratio > 100) {
96
+ throw new Error("--frame-ratio must be an integer between 20 and 100.");
97
+ }
98
+ return ratio;
99
+ }
100
+
101
+ export function parseDepthResolution(value = DEFAULT_DEPTH_RESOLUTION) {
102
+ const normalized = String(value).toLowerCase();
103
+ if (normalized === "original" || normalized === "480" || normalized === "480p") return normalized === "original" ? "original" : "480p";
104
+ if (normalized === "320" || normalized === "320p") return "320p";
105
+ throw new Error("--depth-resolution must be original, 480p, or 320p.");
106
+ }
107
+
108
+ export async function resolveDepthLocation(value, deps = {}) {
109
+ const candidates = [
110
+ value,
111
+ deps.tmpdir,
112
+ tmpdir(),
113
+ join(homedir(), ".tmp"),
114
+ join(process.cwd(), ".flatkey-tmp"),
115
+ ].filter(Boolean);
116
+
117
+ let lastError;
118
+ for (const candidate of candidates) {
119
+ const expanded = await expandHomePath(candidate);
120
+ try {
121
+ await mkdir(expanded, { recursive: true });
122
+ await access(expanded);
123
+ return join(expanded, "flatkey-video-copy");
124
+ } catch (error) {
125
+ lastError = error;
126
+ }
127
+ }
128
+ throw new Error(`Unable to create a writable depth-video directory${lastError ? `: ${lastError.message}` : "."}`);
129
+ }
130
+
131
+ export async function inspectVideo(source, deps = {}) {
132
+ const ffprobe = deps.ffprobePath ?? "ffprobe";
133
+ const result = await runProcess(ffprobe, [
134
+ "-v", "error",
135
+ "-show_entries", "format=duration:stream=width,height,avg_frame_rate",
136
+ "-of", "json",
137
+ source,
138
+ ], deps);
139
+ let metadata;
140
+ try {
141
+ metadata = JSON.parse(result.stdout);
142
+ } catch {
143
+ throw new Error(`ffprobe returned invalid metadata for ${source}.`);
144
+ }
145
+ const duration = Number(metadata?.format?.duration);
146
+ if (!Number.isFinite(duration) || duration <= 0) {
147
+ throw new Error(`Unable to read source video duration: ${source}`);
148
+ }
149
+ return {
150
+ duration,
151
+ width: Number(metadata?.streams?.[0]?.width) || undefined,
152
+ height: Number(metadata?.streams?.[0]?.height) || undefined,
153
+ frameRate: metadata?.streams?.[0]?.avg_frame_rate,
154
+ };
155
+ }
156
+
157
+ export async function runDepthHelper(options, deps = {}) {
158
+ let helper = deps.depthHelperPath ?? deps.env?.FLATKEY_DEPTH_HELPER ?? process.env.FLATKEY_DEPTH_HELPER;
159
+ if (!helper) {
160
+ const { ensureVideoRuntime } = await import("./video-runtime.js");
161
+ helper = (await ensureVideoRuntime({
162
+ env: deps.env,
163
+ fetch: deps.fetch,
164
+ platform: deps.platform,
165
+ arch: deps.arch,
166
+ })).helperPath;
167
+ }
168
+
169
+ const args = [
170
+ "--source", options.source,
171
+ "--output", options.output,
172
+ "--frame-ratio", String(options.frameRatio),
173
+ "--resolution", options.resolution,
174
+ "--work-dir", options.workDir,
175
+ ];
176
+ if (options.model) args.push("--model", options.model);
177
+ const result = await runProcess(helper, args, deps);
178
+ try {
179
+ return JSON.parse(result.stdout);
180
+ } catch {
181
+ throw new Error("The Flatkey Rust depth helper returned invalid JSON.");
182
+ }
183
+ }
184
+
185
+ export async function extractReferenceFrames(options, deps = {}) {
186
+ const ffmpeg = options.ffmpegPath
187
+ ?? deps.env?.FFMPEG_PATH
188
+ ?? process.env.FFMPEG_PATH
189
+ ?? "ffmpeg";
190
+ const duration = Number(options.duration);
191
+ if (!Number.isFinite(duration) || duration <= 0) {
192
+ throw new Error("Cannot extract depth reference frames without a valid duration.");
193
+ }
194
+
195
+ await mkdir(options.outputDir, { recursive: true });
196
+ const timestamps = [
197
+ 0,
198
+ duration / 2,
199
+ Math.max(0, duration - 1 / 30),
200
+ ];
201
+ const paths = [];
202
+ for (let index = 0; index < timestamps.length; index += 1) {
203
+ const output = join(options.outputDir, `depth-frame-${String(index + 1).padStart(2, "0")}.png`);
204
+ await runProcess(ffmpeg, [
205
+ "-v", "error",
206
+ "-y",
207
+ "-ss", timestamps[index].toFixed(6),
208
+ "-i", options.source,
209
+ "-frames:v", "1",
210
+ output,
211
+ ], deps);
212
+ paths.push(output);
213
+ }
214
+ return paths;
215
+ }
216
+
217
+ export function runProcess(command, args, deps = {}) {
218
+ if (deps.runProcess) return deps.runProcess(command, args);
219
+ return new Promise((resolve, reject) => {
220
+ const child = spawn(command, args, {
221
+ cwd: deps.cwd,
222
+ env: deps.env ?? process.env,
223
+ windowsHide: true,
224
+ });
225
+ let stdout = "";
226
+ let stderr = "";
227
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
228
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
229
+ child.once("error", (error) => {
230
+ reject(new Error(`Failed to start ${command}: ${error.message}`));
231
+ });
232
+ child.once("close", (code) => {
233
+ if (code === 0) {
234
+ resolve({ stdout, stderr, code });
235
+ return;
236
+ }
237
+ reject(new Error(`${command} failed with exit code ${code}${stderr.trim() ? `: ${stderr.trim()}` : "."}`));
238
+ });
239
+ });
240
+ }
241
+
242
+ async function expandHomePath(value) {
243
+ if (typeof value !== "string") return value;
244
+ if (value === "~") return homedir();
245
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
246
+ return value;
247
+ }
248
+
249
+ function formatSeconds(value) {
250
+ return Number(value).toFixed(3).replace(/\.?0+$/, "");
251
+ }
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const VIDEO_RUNTIME_VERSION = "0.1.0";
7
+
8
+ export function runtimePlatform(platform = process.platform, arch = process.arch) {
9
+ const supported = {
10
+ darwin: { arm64: "darwin-arm64", x64: "darwin-x64" },
11
+ linux: { arm64: "linux-arm64", x64: "linux-x64" },
12
+ win32: { arm64: "win32-arm64", x64: "win32-x64" },
13
+ };
14
+ const target = supported[platform]?.[arch];
15
+ if (!target) throw new Error(`Unsupported Flatkey video runtime platform: ${platform}-${arch}`);
16
+ return target;
17
+ }
18
+
19
+ export async function runtimeDirectory({ platform = process.platform, arch = process.arch, env = process.env } = {}) {
20
+ const target = runtimePlatform(platform, arch);
21
+ const base = env.FLATKEY_RUNTIME_HOME
22
+ ?? (platform === "win32"
23
+ ? join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "flatkey")
24
+ : platform === "darwin"
25
+ ? join(homedir(), "Library", "Caches", "flatkey")
26
+ : join(env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "flatkey"));
27
+ return join(base, "runtime", VIDEO_RUNTIME_VERSION, target);
28
+ }
29
+
30
+ export async function ensureVideoRuntime(options = {}) {
31
+ const directory = await runtimeDirectory(options);
32
+ const helperName = options.platform === "win32" || (options.platform ?? process.platform) === "win32"
33
+ ? "flatkey-depth.exe"
34
+ : "flatkey-depth";
35
+ const helperPath = join(directory, helperName);
36
+ try {
37
+ await stat(helperPath);
38
+ return { directory, helperPath, downloaded: false };
39
+ } catch {
40
+ // Continue to download below.
41
+ }
42
+
43
+ const baseUrl = options.baseUrl
44
+ ?? options.env?.FLATKEY_VIDEO_RUNTIME_BASE_URL
45
+ ?? process.env.FLATKEY_VIDEO_RUNTIME_BASE_URL;
46
+ if (!baseUrl) {
47
+ throw new Error(
48
+ `Flatkey video runtime is missing at ${helperPath}. Set FLATKEY_VIDEO_RUNTIME_BASE_URL to enable automatic download.`,
49
+ );
50
+ }
51
+ const target = runtimePlatform(options.platform, options.arch);
52
+ const fetchImpl = options.fetch ?? fetch;
53
+ const manifestUrl = `${baseUrl.replace(/\/$/, "")}/${VIDEO_RUNTIME_VERSION}/${target}/manifest.json`;
54
+ const manifestResponse = await fetchImpl(manifestUrl);
55
+ if (!manifestResponse.ok) throw new Error(`Failed to download Flatkey video runtime manifest: HTTP ${manifestResponse.status}`);
56
+ const manifest = await manifestResponse.json();
57
+ const archiveUrl = manifest.archive_url ?? manifest.url;
58
+ if (Array.isArray(manifest.files)) {
59
+ await mkdir(directory, { recursive: true });
60
+ for (const file of manifest.files) {
61
+ await downloadRuntimeFile(file, directory, fetchImpl);
62
+ }
63
+ await stat(helperPath);
64
+ return { directory, helperPath, downloaded: true };
65
+ }
66
+
67
+ const sha256 = manifest.sha256;
68
+ if (!archiveUrl || !sha256) throw new Error("Flatkey video runtime manifest is missing files or archive_url/sha256.");
69
+
70
+ const archivePath = join(tmpdir(), `flatkey-video-runtime-${Date.now()}.archive`);
71
+ try {
72
+ const response = await fetchImpl(archiveUrl);
73
+ if (!response.ok) throw new Error(`Failed to download Flatkey video runtime: HTTP ${response.status}`);
74
+ const bytes = Buffer.from(await response.arrayBuffer());
75
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
76
+ if (actualHash !== sha256.toLowerCase()) throw new Error("Flatkey video runtime checksum verification failed.");
77
+ await mkdir(directory, { recursive: true });
78
+ await writeFile(archivePath, bytes);
79
+ await extractArchive(archivePath, directory, options);
80
+ await stat(helperPath);
81
+ return { directory, helperPath, downloaded: true };
82
+ } finally {
83
+ await rm(archivePath, { force: true });
84
+ }
85
+ }
86
+
87
+ async function downloadRuntimeFile(file, directory, fetchImpl) {
88
+ if (!file?.path || !file?.url || !file?.sha256) {
89
+ throw new Error("Flatkey video runtime manifest contains an invalid file entry.");
90
+ }
91
+ if (file.path.includes("..") || file.path.startsWith("/") || /^[a-z]:/i.test(file.path)) {
92
+ throw new Error(`Flatkey video runtime manifest contains an unsafe path: ${file.path}`);
93
+ }
94
+ const response = await fetchImpl(file.url);
95
+ if (!response.ok) throw new Error(`Failed to download Flatkey video runtime file ${file.path}: HTTP ${response.status}`);
96
+ const bytes = Buffer.from(await response.arrayBuffer());
97
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
98
+ if (actualHash !== file.sha256.toLowerCase()) {
99
+ throw new Error(`Flatkey video runtime checksum verification failed for ${file.path}.`);
100
+ }
101
+ const destination = join(directory, file.path);
102
+ await mkdir(join(destination, ".."), { recursive: true });
103
+ await writeFile(destination, bytes);
104
+ if (file.executable || file.mode === "755") {
105
+ await chmod(destination, 0o755);
106
+ }
107
+ }
108
+
109
+ async function extractArchive(archivePath, directory, options) {
110
+ if (options.extractArchive) {
111
+ await options.extractArchive(archivePath, directory);
112
+ return;
113
+ }
114
+ throw new Error("Flatkey video runtime archive extraction is not configured for this build.");
115
+ }
116
+
117
+ export async function sha256File(path) {
118
+ return createHash("sha256").update(await readFile(path)).digest("hex");
119
+ }