@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 +39 -1
- package/package.json +4 -2
- package/scripts/install-video-runtime.js +14 -0
- package/src/api.js +123 -41
- package/src/artifacts.js +30 -2
- package/src/cli.js +340 -63
- package/src/help.js +33 -12
- package/src/video-copy.js +251 -0
- package/src/video-runtime.js +119 -0
package/README.md
CHANGED
|
@@ -140,7 +140,29 @@ flatkey video generate \
|
|
|
140
140
|
-o launch.mp4
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
|
|
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
|
+
"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,
|
|
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: [{
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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")
|
|
81
|
-
|
|
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
|
-
:
|
|
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,
|
|
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
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
}
|