@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/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.2.9",
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
 
@@ -17,11 +18,19 @@ export async function generateImage(options) {
17
18
  }
18
19
 
19
20
  export async function uploadTempMediaImage(options) {
21
+ return uploadTempMediaByKind(options, "images");
22
+ }
23
+
24
+ export async function uploadTempMediaVideo(options) {
25
+ return uploadTempMediaByKind(options, "videos");
26
+ }
27
+
28
+ async function uploadTempMediaByKind(options, kind) {
20
29
  const form = new FormData();
21
30
  form.append("file", new Blob([options.file], cleanObject({
22
31
  type: options.contentType,
23
- })), options.filename ?? "image");
24
- return requestJsonFromPlan(options, planRequest(options, "/v1/temp-media/images", {
32
+ })), options.filename ?? (kind === "videos" ? "video" : "image"));
33
+ return requestJsonFromPlan(options, planRequest(options, `/v1/temp-media/${kind}`, {
25
34
  method: "POST",
26
35
  headers: authHeaders(options.apiKey),
27
36
  body: form,
@@ -30,6 +39,13 @@ export async function uploadTempMediaImage(options) {
30
39
 
31
40
  export function planImageRequest(options) {
32
41
  const model = options.model ?? "nano-banana-pro-preview";
42
+ const mediaInputs = orderedMediaInputs(options);
43
+ assertReferenceLimits(mediaInputs, "image", model);
44
+ if (mediaInputs.some((entry) => entry.kind === "video")) {
45
+ throw new Error("Image models only accept image inputs.");
46
+ }
47
+ const imageUrls = mediaInputs
48
+ .map((entry) => entry.url);
33
49
  if (model.startsWith("gpt")) {
34
50
  const responseFormat = options.response_format ?? options.responseFormat ?? "url";
35
51
  const tempUrl = options.temp_url ?? options.tempUrl;
@@ -41,6 +57,7 @@ export function planImageRequest(options) {
41
57
  quality: options.quality,
42
58
  response_format: responseFormat,
43
59
  temp_url: tempUrl ?? true,
60
+ images: imageUrls.length > 0 ? imageUrls : undefined,
44
61
  }));
45
62
  }
46
63
 
@@ -52,7 +69,12 @@ export function planImageRequest(options) {
52
69
  "x-goog-api-key": options.apiKey,
53
70
  },
54
71
  body: {
55
- contents: [{ parts: [{ text: options.prompt }] }],
72
+ contents: [{
73
+ parts: [
74
+ { text: options.prompt },
75
+ ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
76
+ ],
77
+ }],
56
78
  temp_url: true,
57
79
  },
58
80
  });
@@ -68,17 +90,22 @@ export function getVideo(options, taskId) {
68
90
 
69
91
  export function planVideoRequest(options) {
70
92
  const model = options.model ?? "seedance-2.0-pro";
71
- const imageUrls = [
72
- ...arrayOption(options.image_url),
73
- ...arrayOption(options.imageUrl),
74
- ...arrayOption(options.first_frame_url),
75
- ...arrayOption(options.firstFrameUrl),
76
- ...arrayOption(options.last_frame_url),
77
- ...arrayOption(options.lastFrameUrl),
78
- ];
93
+ const mediaInputs = orderedMediaInputs(options);
94
+ assertReferenceLimits(mediaInputs, "video", model);
95
+ if (mediaInputs.some((entry) => entry.role === "first_frame" && entry.kind === "video")
96
+ || mediaInputs.some((entry) => entry.role === "last_frame" && entry.kind === "video")) {
97
+ throw new Error("Video first-frame and last-frame inputs must be images.");
98
+ }
99
+ const imageUrls = mediaInputs
100
+ .filter((entry) => entry.kind !== "video")
101
+ .map((entry) => entry.url);
102
+ const videoUrls = mediaInputs
103
+ .filter((entry) => entry.kind === "video")
104
+ .map((entry) => entry.url);
79
105
  const ratio = validateOptionalValue(
80
- optionValue(options, "ratio", "aspect") ?? (isMiniMaxModel(model) ? "16:9" : undefined),
81
- ["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"],
82
109
  "ratio",
83
110
  );
84
111
  const resolution = validateOptionalValue(
@@ -91,19 +118,24 @@ export function planVideoRequest(options) {
91
118
  prompt: options.prompt,
92
119
  duration: options.duration === undefined && isMiniMaxModel(model)
93
120
  ? 5
94
- : parseOptionalInteger(options.duration),
121
+ : options.duration === undefined && isSeedance25Model(model)
122
+ ? -1
123
+ : parseOptionalFloat(options.duration),
95
124
  aspect: ratio,
96
125
  ratio,
97
126
  resolution,
98
127
  quality: resolution,
99
128
  fps: parseOptionalInteger(options.fps),
100
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,
101
133
  temp_url: true,
102
134
  images: !isSeedanceModel(model) && imageUrls.length > 0 ? imageUrls : undefined,
103
135
  });
104
- const seedanceContent = buildSeedanceContent(options);
136
+ const seedanceContent = buildSeedanceContent(options, mediaInputs);
105
137
  if ((isSeedanceModel(model) || isMiniMaxModel(model)) && seedanceContent.length > 0) {
106
- return planJsonPost(options, "/v1/video/generations", cleanObject({
138
+ return planJsonPost(options, videoGenerationPath(model, videoUrls), cleanObject({
107
139
  ...basePayload,
108
140
  content: seedanceContent,
109
141
  }));
@@ -260,6 +292,7 @@ async function requestJsonFromPlan(options, plan) {
260
292
  if (!response.ok || body?.success === false) {
261
293
  throw new FlatkeyError(extractErrorMessage(body, response.status), {
262
294
  status: response.status,
295
+ code: body?.code ?? body?.error?.code,
263
296
  });
264
297
  }
265
298
  return body;
@@ -284,6 +317,7 @@ async function requestBinaryArtifactFromPlan(options, plan) {
284
317
  logResponseBody(options, body);
285
318
  throw new FlatkeyError(extractErrorMessage(body, response.status), {
286
319
  status: response.status,
320
+ code: body?.code ?? body?.error?.code,
287
321
  });
288
322
  }
289
323
  return {
@@ -392,34 +426,24 @@ function isMiniMaxModel(model) {
392
426
  return /^minimax-h3$/i.test(model);
393
427
  }
394
428
 
395
- function buildSeedanceContent(options) {
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
+
436
+ function buildSeedanceContent(options, mediaInputs = orderedMediaInputs(options)) {
396
437
  const content = [];
397
438
  if (options.prompt !== undefined) {
398
439
  content.push({ type: "text", text: options.prompt });
399
440
  }
400
- for (const url of arrayOption(options.image_url)) {
401
- content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
402
- }
403
- for (const url of arrayOption(options.imageUrl)) {
404
- content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
405
- }
406
- for (const url of arrayOption(options.first_frame_url)) {
407
- content.push({ type: "image_url", image_url: { url }, role: "first_frame" });
408
- }
409
- for (const url of arrayOption(options.firstFrameUrl)) {
410
- content.push({ type: "image_url", image_url: { url }, role: "first_frame" });
411
- }
412
- for (const url of arrayOption(options.last_frame_url)) {
413
- content.push({ type: "image_url", image_url: { url }, role: "last_frame" });
414
- }
415
- for (const url of arrayOption(options.lastFrameUrl)) {
416
- content.push({ type: "image_url", image_url: { url }, role: "last_frame" });
417
- }
418
- for (const url of arrayOption(options.video_url)) {
419
- content.push({ type: "video_url", video_url: { url }, role: "reference_video" });
420
- }
421
- for (const url of arrayOption(options.videoUrl)) {
422
- content.push({ type: "video_url", video_url: { url }, role: "reference_video" });
441
+ for (const entry of mediaInputs) {
442
+ if (entry.kind === "video") {
443
+ content.push({ type: "video_url", video_url: { url: entry.url }, role: entry.role ?? "reference_video" });
444
+ continue;
445
+ }
446
+ content.push({ type: "image_url", image_url: { url: entry.url }, role: entry.role ?? "reference_image" });
423
447
  }
424
448
  return content;
425
449
  }
@@ -435,6 +459,64 @@ function cleanObject(value) {
435
459
  );
436
460
  }
437
461
 
462
+ function assertReferenceLimits(mediaInputs, kind, model) {
463
+ const imageInputs = mediaInputs.filter((entry) => entry.kind !== "video");
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
+ }
477
+ }
478
+
479
+ function orderedMediaInputs(options) {
480
+ const hidden = Array.isArray(options.__media_inputs) ? options.__media_inputs : [];
481
+ if (hidden.length > 0 && hidden[0] && Object.prototype.hasOwnProperty.call(hidden[0], "url")) {
482
+ return hidden;
483
+ }
484
+ if (hidden.length > 0) {
485
+ return hidden.map((entry) => ({
486
+ ...entry,
487
+ url: entry.url ?? entry.value,
488
+ kind: entry.kind ?? inferMediaKindFromName(entry.name, entry.value),
489
+ }));
490
+ }
491
+
492
+ const legacy = [];
493
+ const push = (name, value, kind = "image", role) => {
494
+ for (const item of arrayOption(value)) {
495
+ legacy.push({ name, url: item, kind: kind === "image" ? inferMediaKindFromName(name, item) : kind, role });
496
+ }
497
+ };
498
+ push("file", options.file, "image");
499
+ push("image", options.image, "image");
500
+ push("image_url", options.image_url, "image");
501
+ push("first_frame", options.first_frame, "image", "first_frame");
502
+ push("first_frame_url", options.first_frame_url, "image", "first_frame");
503
+ push("last_frame", options.last_frame, "image", "last_frame");
504
+ push("last_frame_url", options.last_frame_url, "image", "last_frame");
505
+ push("video_url", options.video_url, "video", "reference_video");
506
+ return legacy;
507
+ }
508
+
509
+ function inferMediaKindFromName(name, value) {
510
+ const lowerName = String(name ?? "").toLowerCase();
511
+ if (lowerName.includes("video")) return "video";
512
+ if (typeof value === "string" && /\.(mp4|mov)$/i.test(value)) return "video";
513
+ return "image";
514
+ }
515
+
516
+ function isSeedance25Model(model) {
517
+ return String(model ?? "").toLowerCase().replaceAll(/[-_.]/g, "").includes("seedance25");
518
+ }
519
+
438
520
  async function readJson(response) {
439
521
  try {
440
522
  return await response.json();
package/src/artifacts.js CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, extname, join } from "node:path";
4
4
 
@@ -32,15 +32,23 @@ export async function persistArtifacts({
32
32
  const items = extractItems(response);
33
33
  const artifacts = [];
34
34
  await mkdir(output ? dirname(output) : outDir, { recursive: true });
35
+ const startIndex = output ? 0 : await nextArtifactIndex({ kind, outDir });
35
36
 
36
37
  for (const [index, item] of items.entries()) {
37
- const artifact = await persistItem({ kind, item, outDir, output, index, fetchImpl });
38
+ const artifact = await persistItem({ kind, item, outDir, output, index: index + startIndex, fetchImpl });
38
39
  if (artifact) artifacts.push(artifact);
39
40
  }
40
41
 
41
42
  return artifacts;
42
43
  }
43
44
 
45
+ export async function nextArtifactPath({ kind, outDir = "flatkey-output" }) {
46
+ const extension = DEFAULT_EXTENSIONS[kind] ?? "bin";
47
+ const index = await nextArtifactIndex({ kind, outDir });
48
+ const number = String(index + 1).padStart(2, "0");
49
+ return join(outDir, `${kind}-${number}.${extension}`);
50
+ }
51
+
44
52
  function expandHomePath(path) {
45
53
  if (typeof path !== "string") return path;
46
54
  if (path === "~") return homedir();
@@ -136,3 +144,23 @@ function artifactPath({ kind, outDir, output, index, extension }) {
136
144
  const number = String(index + 1).padStart(2, "0");
137
145
  return join(outDir, `${kind}-${number}.${extension}`);
138
146
  }
147
+
148
+ async function nextArtifactIndex({ kind, outDir }) {
149
+ const extension = DEFAULT_EXTENSIONS[kind] ?? "bin";
150
+ const pattern = new RegExp(`^${escapeRegExp(kind)}-(\\d+)\\.${escapeRegExp(extension)}$`);
151
+ let max = 0;
152
+ try {
153
+ for (const entry of await readdir(outDir)) {
154
+ const match = pattern.exec(entry);
155
+ if (!match) continue;
156
+ max = Math.max(max, Number.parseInt(match[1], 10));
157
+ }
158
+ } catch {
159
+ return 0;
160
+ }
161
+ return max;
162
+ }
163
+
164
+ function escapeRegExp(value) {
165
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
166
+ }