@flatkey-ai/cli 0.2.8 → 0.3.2
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/package.json +1 -1
- package/src/api.js +92 -36
- package/src/artifacts.js +30 -2
- package/src/cli.js +195 -46
- package/src/help.js +11 -2
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -17,11 +17,19 @@ export async function generateImage(options) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function uploadTempMediaImage(options) {
|
|
20
|
+
return uploadTempMediaByKind(options, "images");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function uploadTempMediaVideo(options) {
|
|
24
|
+
return uploadTempMediaByKind(options, "videos");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function uploadTempMediaByKind(options, kind) {
|
|
20
28
|
const form = new FormData();
|
|
21
29
|
form.append("file", new Blob([options.file], cleanObject({
|
|
22
30
|
type: options.contentType,
|
|
23
|
-
})), options.filename ?? "image");
|
|
24
|
-
return requestJsonFromPlan(options, planRequest(options,
|
|
31
|
+
})), options.filename ?? (kind === "videos" ? "video" : "image"));
|
|
32
|
+
return requestJsonFromPlan(options, planRequest(options, `/v1/temp-media/${kind}`, {
|
|
25
33
|
method: "POST",
|
|
26
34
|
headers: authHeaders(options.apiKey),
|
|
27
35
|
body: form,
|
|
@@ -30,6 +38,13 @@ export async function uploadTempMediaImage(options) {
|
|
|
30
38
|
|
|
31
39
|
export function planImageRequest(options) {
|
|
32
40
|
const model = options.model ?? "nano-banana-pro-preview";
|
|
41
|
+
const mediaInputs = orderedMediaInputs(options);
|
|
42
|
+
assertReferenceImageLimit(mediaInputs, "image");
|
|
43
|
+
if (mediaInputs.some((entry) => entry.kind === "video")) {
|
|
44
|
+
throw new Error("Image models only accept image inputs.");
|
|
45
|
+
}
|
|
46
|
+
const imageUrls = mediaInputs
|
|
47
|
+
.map((entry) => entry.url);
|
|
33
48
|
if (model.startsWith("gpt")) {
|
|
34
49
|
const responseFormat = options.response_format ?? options.responseFormat ?? "url";
|
|
35
50
|
const tempUrl = options.temp_url ?? options.tempUrl;
|
|
@@ -41,6 +56,7 @@ export function planImageRequest(options) {
|
|
|
41
56
|
quality: options.quality,
|
|
42
57
|
response_format: responseFormat,
|
|
43
58
|
temp_url: tempUrl ?? true,
|
|
59
|
+
images: imageUrls.length > 0 ? imageUrls : undefined,
|
|
44
60
|
}));
|
|
45
61
|
}
|
|
46
62
|
|
|
@@ -52,7 +68,12 @@ export function planImageRequest(options) {
|
|
|
52
68
|
"x-goog-api-key": options.apiKey,
|
|
53
69
|
},
|
|
54
70
|
body: {
|
|
55
|
-
contents: [{
|
|
71
|
+
contents: [{
|
|
72
|
+
parts: [
|
|
73
|
+
{ text: options.prompt },
|
|
74
|
+
...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
|
|
75
|
+
],
|
|
76
|
+
}],
|
|
56
77
|
temp_url: true,
|
|
57
78
|
},
|
|
58
79
|
});
|
|
@@ -68,14 +89,15 @@ export function getVideo(options, taskId) {
|
|
|
68
89
|
|
|
69
90
|
export function planVideoRequest(options) {
|
|
70
91
|
const model = options.model ?? "seedance-2.0-pro";
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
92
|
+
const mediaInputs = orderedMediaInputs(options);
|
|
93
|
+
assertReferenceImageLimit(mediaInputs, "video");
|
|
94
|
+
if (mediaInputs.some((entry) => entry.role === "first_frame" && entry.kind === "video")
|
|
95
|
+
|| mediaInputs.some((entry) => entry.role === "last_frame" && entry.kind === "video")) {
|
|
96
|
+
throw new Error("Video first-frame and last-frame inputs must be images.");
|
|
97
|
+
}
|
|
98
|
+
const imageUrls = mediaInputs
|
|
99
|
+
.filter((entry) => entry.kind !== "video")
|
|
100
|
+
.map((entry) => entry.url);
|
|
79
101
|
const ratio = validateOptionalValue(
|
|
80
102
|
optionValue(options, "ratio", "aspect") ?? (isMiniMaxModel(model) ? "16:9" : undefined),
|
|
81
103
|
["16:9", "9:16", "4:3", "3:4", "21:9", "1:1"],
|
|
@@ -97,10 +119,11 @@ export function planVideoRequest(options) {
|
|
|
97
119
|
resolution,
|
|
98
120
|
quality: resolution,
|
|
99
121
|
fps: parseOptionalInteger(options.fps),
|
|
122
|
+
generate_audio: parseOptionalBoolean(options.generate_audio) ?? true,
|
|
100
123
|
temp_url: true,
|
|
101
124
|
images: !isSeedanceModel(model) && imageUrls.length > 0 ? imageUrls : undefined,
|
|
102
125
|
});
|
|
103
|
-
const seedanceContent = buildSeedanceContent(options);
|
|
126
|
+
const seedanceContent = buildSeedanceContent(options, mediaInputs);
|
|
104
127
|
if ((isSeedanceModel(model) || isMiniMaxModel(model)) && seedanceContent.length > 0) {
|
|
105
128
|
return planJsonPost(options, "/v1/video/generations", cleanObject({
|
|
106
129
|
...basePayload,
|
|
@@ -363,6 +386,13 @@ function parseOptionalFloat(value) {
|
|
|
363
386
|
return Number.parseFloat(value);
|
|
364
387
|
}
|
|
365
388
|
|
|
389
|
+
function parseOptionalBoolean(value) {
|
|
390
|
+
if (value === undefined) return undefined;
|
|
391
|
+
if (value === true || value === "true") return true;
|
|
392
|
+
if (value === false || value === "false") return false;
|
|
393
|
+
throw new Error(`Invalid boolean value: ${value}. Allowed values: true, false`);
|
|
394
|
+
}
|
|
395
|
+
|
|
366
396
|
function optionValue(options, ...keys) {
|
|
367
397
|
for (const key of keys) {
|
|
368
398
|
if (options[key] !== undefined) return options[key];
|
|
@@ -384,34 +414,17 @@ function isMiniMaxModel(model) {
|
|
|
384
414
|
return /^minimax-h3$/i.test(model);
|
|
385
415
|
}
|
|
386
416
|
|
|
387
|
-
function buildSeedanceContent(options) {
|
|
417
|
+
function buildSeedanceContent(options, mediaInputs = orderedMediaInputs(options)) {
|
|
388
418
|
const content = [];
|
|
389
419
|
if (options.prompt !== undefined) {
|
|
390
420
|
content.push({ type: "text", text: options.prompt });
|
|
391
421
|
}
|
|
392
|
-
for (const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
for (const url of arrayOption(options.first_frame_url)) {
|
|
399
|
-
content.push({ type: "image_url", image_url: { url }, role: "first_frame" });
|
|
400
|
-
}
|
|
401
|
-
for (const url of arrayOption(options.firstFrameUrl)) {
|
|
402
|
-
content.push({ type: "image_url", image_url: { url }, role: "first_frame" });
|
|
403
|
-
}
|
|
404
|
-
for (const url of arrayOption(options.last_frame_url)) {
|
|
405
|
-
content.push({ type: "image_url", image_url: { url }, role: "last_frame" });
|
|
406
|
-
}
|
|
407
|
-
for (const url of arrayOption(options.lastFrameUrl)) {
|
|
408
|
-
content.push({ type: "image_url", image_url: { url }, role: "last_frame" });
|
|
409
|
-
}
|
|
410
|
-
for (const url of arrayOption(options.video_url)) {
|
|
411
|
-
content.push({ type: "video_url", video_url: { url }, role: "reference_video" });
|
|
412
|
-
}
|
|
413
|
-
for (const url of arrayOption(options.videoUrl)) {
|
|
414
|
-
content.push({ type: "video_url", video_url: { url }, role: "reference_video" });
|
|
422
|
+
for (const entry of mediaInputs) {
|
|
423
|
+
if (entry.kind === "video") {
|
|
424
|
+
content.push({ type: "video_url", video_url: { url: entry.url }, role: entry.role ?? "reference_video" });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
content.push({ type: "image_url", image_url: { url: entry.url }, role: entry.role ?? "reference_image" });
|
|
415
428
|
}
|
|
416
429
|
return content;
|
|
417
430
|
}
|
|
@@ -427,6 +440,49 @@ function cleanObject(value) {
|
|
|
427
440
|
);
|
|
428
441
|
}
|
|
429
442
|
|
|
443
|
+
function assertReferenceImageLimit(mediaInputs, kind) {
|
|
444
|
+
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.`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function orderedMediaInputs(options) {
|
|
450
|
+
const hidden = Array.isArray(options.__media_inputs) ? options.__media_inputs : [];
|
|
451
|
+
if (hidden.length > 0 && hidden[0] && Object.prototype.hasOwnProperty.call(hidden[0], "url")) {
|
|
452
|
+
return hidden;
|
|
453
|
+
}
|
|
454
|
+
if (hidden.length > 0) {
|
|
455
|
+
return hidden.map((entry) => ({
|
|
456
|
+
...entry,
|
|
457
|
+
url: entry.url ?? entry.value,
|
|
458
|
+
kind: entry.kind ?? inferMediaKindFromName(entry.name, entry.value),
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const legacy = [];
|
|
463
|
+
const push = (name, value, kind = "image", role) => {
|
|
464
|
+
for (const item of arrayOption(value)) {
|
|
465
|
+
legacy.push({ name, url: item, kind: kind === "image" ? inferMediaKindFromName(name, item) : kind, role });
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
push("file", options.file, "image");
|
|
469
|
+
push("image", options.image, "image");
|
|
470
|
+
push("image_url", options.image_url, "image");
|
|
471
|
+
push("first_frame", options.first_frame, "image", "first_frame");
|
|
472
|
+
push("first_frame_url", options.first_frame_url, "image", "first_frame");
|
|
473
|
+
push("last_frame", options.last_frame, "image", "last_frame");
|
|
474
|
+
push("last_frame_url", options.last_frame_url, "image", "last_frame");
|
|
475
|
+
push("video_url", options.video_url, "video", "reference_video");
|
|
476
|
+
return legacy;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function inferMediaKindFromName(name, value) {
|
|
480
|
+
const lowerName = String(name ?? "").toLowerCase();
|
|
481
|
+
if (lowerName.includes("video")) return "video";
|
|
482
|
+
if (typeof value === "string" && value.toLowerCase().endsWith(".mp4")) return "video";
|
|
483
|
+
return "image";
|
|
484
|
+
}
|
|
485
|
+
|
|
430
486
|
async function readJson(response) {
|
|
431
487
|
try {
|
|
432
488
|
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
|
+
}
|
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,7 @@ 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", "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"]),
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
export function parseArgv(argv) {
|
|
@@ -61,7 +64,7 @@ export function parseArgv(argv) {
|
|
|
61
64
|
return validateCommandOptions({
|
|
62
65
|
group: "help",
|
|
63
66
|
action: undefined,
|
|
64
|
-
options: { command: maybeAction, ...parseOptions(rest) },
|
|
67
|
+
options: { command: maybeAction, ...parseOptions(rest, repeatableOptionsFor("help")) },
|
|
65
68
|
});
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -81,7 +84,7 @@ export function parseArgv(argv) {
|
|
|
81
84
|
group,
|
|
82
85
|
action,
|
|
83
86
|
options: {
|
|
84
|
-
...parseOptions(optionTokens.filter((token) => token !== "help" && token !== "--help" && token !== "-h")),
|
|
87
|
+
...parseOptions(optionTokens.filter((token) => token !== "help" && token !== "--help" && token !== "-h"), repeatableOptionsFor(group, action)),
|
|
85
88
|
help: true,
|
|
86
89
|
},
|
|
87
90
|
});
|
|
@@ -90,7 +93,7 @@ export function parseArgv(argv) {
|
|
|
90
93
|
return validateCommandOptions({
|
|
91
94
|
group,
|
|
92
95
|
action,
|
|
93
|
-
options: parseOptions(optionTokens),
|
|
96
|
+
options: parseOptions(optionTokens, repeatableOptionsFor(group, action)),
|
|
94
97
|
});
|
|
95
98
|
}
|
|
96
99
|
|
|
@@ -98,8 +101,9 @@ function isHelpToken(token) {
|
|
|
98
101
|
return token === "help" || token === "--help" || token === "-h";
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
function parseOptions(tokens) {
|
|
104
|
+
function parseOptions(tokens, repeatableOptions = REPEATABLE_OPTIONS) {
|
|
102
105
|
const options = {};
|
|
106
|
+
const mediaInputs = [];
|
|
103
107
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
104
108
|
const token = tokens[index];
|
|
105
109
|
if (token === "-o") {
|
|
@@ -120,16 +124,36 @@ function parseOptions(tokens) {
|
|
|
120
124
|
options[name] = true;
|
|
121
125
|
continue;
|
|
122
126
|
}
|
|
123
|
-
if (
|
|
127
|
+
if (repeatableOptions.has(name)) {
|
|
124
128
|
options[name] = [...asArray(options[name]), next];
|
|
125
129
|
} else {
|
|
126
130
|
options[name] = next;
|
|
127
131
|
}
|
|
132
|
+
if (MEDIA_INPUT_OPTIONS.has(name)) {
|
|
133
|
+
mediaInputs.push({ name, value: next });
|
|
134
|
+
}
|
|
128
135
|
index += 1;
|
|
129
136
|
}
|
|
137
|
+
if (mediaInputs.length > 0) {
|
|
138
|
+
Object.defineProperty(options, "__media_inputs", {
|
|
139
|
+
value: mediaInputs,
|
|
140
|
+
enumerable: false,
|
|
141
|
+
writable: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
130
144
|
return options;
|
|
131
145
|
}
|
|
132
146
|
|
|
147
|
+
function repeatableOptionsFor(group, action) {
|
|
148
|
+
if (group === "image" && action === "generate") {
|
|
149
|
+
return new Set([...REPEATABLE_OPTIONS, "file"]);
|
|
150
|
+
}
|
|
151
|
+
if (group === "video" && action === "generate") {
|
|
152
|
+
return new Set([...REPEATABLE_OPTIONS, "file"]);
|
|
153
|
+
}
|
|
154
|
+
return REPEATABLE_OPTIONS;
|
|
155
|
+
}
|
|
156
|
+
|
|
133
157
|
function asArray(value) {
|
|
134
158
|
if (value === undefined) return [];
|
|
135
159
|
return Array.isArray(value) ? value : [value];
|
|
@@ -139,6 +163,7 @@ function validateCommandOptions(command) {
|
|
|
139
163
|
const key = command.action ? `${command.group} ${command.action}` : command.group;
|
|
140
164
|
const allowed = COMMAND_OPTIONS[key] ?? new Set();
|
|
141
165
|
for (const option of Object.keys(command.options)) {
|
|
166
|
+
if (option.startsWith("_")) continue;
|
|
142
167
|
if (GLOBAL_OPTIONS.has(option) || allowed.has(option)) continue;
|
|
143
168
|
const flag = `--${option.replaceAll("_", "-")}`;
|
|
144
169
|
const helpCommand = command.action
|
|
@@ -238,13 +263,17 @@ export async function runCommand(command, deps = {}) {
|
|
|
238
263
|
}
|
|
239
264
|
|
|
240
265
|
async function handleLogin(command, deps) {
|
|
241
|
-
const { ensureDeviceId, readConfig, writeAuthConfig } = await import("./config.js");
|
|
266
|
+
const { ensureDeviceId, readConfig, resolveOrigins, writeAuthConfig } = await import("./config.js");
|
|
242
267
|
const { DEFAULT_CONSOLE_URL, createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
|
|
243
268
|
const deviceId = await ensureDeviceId({ configDir: deps.configDir });
|
|
244
269
|
const version = await readPackageVersion();
|
|
245
|
-
const consoleOrigin =
|
|
270
|
+
const { consoleOrigin } = await resolveOrigins({
|
|
271
|
+
consoleUrl: command.options.console_url,
|
|
272
|
+
env: deps.env ?? process.env,
|
|
273
|
+
configDir: deps.configDir,
|
|
274
|
+
});
|
|
246
275
|
const requestOptions = {
|
|
247
|
-
consoleUrl: consoleOrigin,
|
|
276
|
+
consoleUrl: consoleOrigin ?? DEFAULT_CONSOLE_URL,
|
|
248
277
|
fetch: deps.fetch,
|
|
249
278
|
verbose: command.options.verbose,
|
|
250
279
|
verboseLog: verboseLogger(deps.stderr, command.options.verbose),
|
|
@@ -426,6 +455,10 @@ async function handleImageUpload(command, deps) {
|
|
|
426
455
|
if (typeof file !== "string" || file.trim() === "") {
|
|
427
456
|
throw new Error("Missing --file value. Run `flatkey image upload --help` to see supported options.");
|
|
428
457
|
}
|
|
458
|
+
const media = mediaInfoFromPath(file);
|
|
459
|
+
if (!media || media.kind !== "image") {
|
|
460
|
+
throw new Error(`Unsupported image file type: ${file}`);
|
|
461
|
+
}
|
|
429
462
|
const apiKey = await resolveApiKey({
|
|
430
463
|
apiKey: command.options.api_key,
|
|
431
464
|
env: deps.env ?? process.env,
|
|
@@ -441,13 +474,13 @@ async function handleImageUpload(command, deps) {
|
|
|
441
474
|
baseUrl: consoleOrigin ?? DEFAULT_CONSOLE_URL,
|
|
442
475
|
file: await readFile(await expandHomePath(file)),
|
|
443
476
|
filename: basename(file),
|
|
444
|
-
contentType:
|
|
477
|
+
contentType: media.contentType,
|
|
445
478
|
fetch: deps.fetch,
|
|
446
479
|
verbose: command.options.verbose,
|
|
447
480
|
verboseLog: verboseLogger(deps.stderr, command.options.verbose),
|
|
448
481
|
});
|
|
449
482
|
const data = response?.data ?? response;
|
|
450
|
-
const signedUrl =
|
|
483
|
+
const signedUrl = extractUploadedMediaUrl(response);
|
|
451
484
|
if (!signedUrl) {
|
|
452
485
|
throw new Error("Image upload failed: missing signed_url.");
|
|
453
486
|
}
|
|
@@ -479,8 +512,8 @@ async function handleGenerate(command, deps) {
|
|
|
479
512
|
planTextRequest,
|
|
480
513
|
planVideoRequest,
|
|
481
514
|
uploadTempMediaImage,
|
|
515
|
+
uploadTempMediaVideo,
|
|
482
516
|
} = await import("./api.js");
|
|
483
|
-
const { persistArtifacts } = await import("./artifacts.js");
|
|
484
517
|
const { createAnimation } = await import("./animation.js");
|
|
485
518
|
const apiKey = command.options.dry_run
|
|
486
519
|
? (command.options.api_key ?? "FLATKEY_API_KEY")
|
|
@@ -509,8 +542,20 @@ async function handleGenerate(command, deps) {
|
|
|
509
542
|
fetch: deps.fetch,
|
|
510
543
|
verboseLog: verboseLogger(deps.stderr, command.options.verbose),
|
|
511
544
|
};
|
|
512
|
-
if (
|
|
513
|
-
|
|
545
|
+
if (Array.isArray(command.options.__media_inputs)) {
|
|
546
|
+
options.__media_inputs = command.options.__media_inputs;
|
|
547
|
+
}
|
|
548
|
+
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
|
+
});
|
|
514
559
|
}
|
|
515
560
|
if (command.options.dry_run) {
|
|
516
561
|
const request = command.group === "image"
|
|
@@ -553,7 +598,7 @@ async function handleGenerate(command, deps) {
|
|
|
553
598
|
}
|
|
554
599
|
const outDir = command.options.out ?? "flatkey-output";
|
|
555
600
|
const output = command.options.output
|
|
556
|
-
?? (command.group === "video" ?
|
|
601
|
+
?? (command.group === "video" ? await nextArtifactPath({ kind: command.group, outDir }) : undefined);
|
|
557
602
|
const artifacts = await persistArtifacts({
|
|
558
603
|
kind: command.group,
|
|
559
604
|
response: artifactResponse,
|
|
@@ -567,58 +612,162 @@ async function handleGenerate(command, deps) {
|
|
|
567
612
|
}
|
|
568
613
|
}
|
|
569
614
|
|
|
570
|
-
async function
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
if (localImages.length === 0 && !firstFrame && !lastFrame) return;
|
|
615
|
+
async function prepareMediaInputs(command, options, deps) {
|
|
616
|
+
const mediaInputs = orderedMediaInputs(command, options);
|
|
617
|
+
if (mediaInputs.length === 0) return;
|
|
618
|
+
assertMediaReferenceLimit(mediaInputs, command.group);
|
|
575
619
|
|
|
576
|
-
const
|
|
577
|
-
for (const
|
|
578
|
-
|
|
620
|
+
const resolved = [];
|
|
621
|
+
for (const input of mediaInputs) {
|
|
622
|
+
if (input.kind === "local") {
|
|
623
|
+
resolved.push(await uploadLocalMedia(input.value, command.group, deps, input.name));
|
|
624
|
+
} else {
|
|
625
|
+
resolved.push(await confirmRemoteMediaUrl(input.value, command.group, deps, input.name));
|
|
626
|
+
}
|
|
579
627
|
}
|
|
580
|
-
|
|
581
|
-
|
|
628
|
+
options.__media_inputs = resolved;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function assertMediaReferenceLimit(mediaInputs, commandGroup) {
|
|
632
|
+
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}.`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function orderedMediaInputs(command, options) {
|
|
638
|
+
const mediaInputs = Array.isArray(options.__media_inputs) ? options.__media_inputs : [];
|
|
639
|
+
if (mediaInputs.length > 0 && mediaInputs[0] && Object.prototype.hasOwnProperty.call(mediaInputs[0], "kind")) {
|
|
640
|
+
return mediaInputs;
|
|
582
641
|
}
|
|
583
|
-
if (
|
|
584
|
-
|
|
642
|
+
if (mediaInputs.length > 0) {
|
|
643
|
+
return mediaInputs.map((entry) => ({
|
|
644
|
+
name: entry.name,
|
|
645
|
+
value: entry.value,
|
|
646
|
+
kind: entry.name.endsWith("_url") ? "url" : "local",
|
|
647
|
+
}));
|
|
585
648
|
}
|
|
586
|
-
|
|
587
|
-
|
|
649
|
+
|
|
650
|
+
const legacyInputs = [];
|
|
651
|
+
const pushLocal = (name, value) => {
|
|
652
|
+
for (const file of asArray(value)) {
|
|
653
|
+
legacyInputs.push({ name, value: file, kind: "local" });
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
const pushUrl = (name, value) => {
|
|
657
|
+
for (const url of asArray(value)) {
|
|
658
|
+
legacyInputs.push({ name, value: url, kind: "url" });
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
pushLocal("file", options.file);
|
|
663
|
+
pushLocal("image", options.image);
|
|
664
|
+
pushUrl("image_url", options.image_url);
|
|
665
|
+
if (command.group === "video") {
|
|
666
|
+
pushUrl("video_url", options.video_url);
|
|
667
|
+
pushLocal("first_frame", options.first_frame);
|
|
668
|
+
pushUrl("first_frame_url", options.first_frame_url);
|
|
669
|
+
pushLocal("last_frame", options.last_frame);
|
|
670
|
+
pushUrl("last_frame_url", options.last_frame_url);
|
|
588
671
|
}
|
|
672
|
+
return legacyInputs;
|
|
589
673
|
}
|
|
590
674
|
|
|
591
|
-
async function
|
|
675
|
+
async function uploadLocalMedia(file, commandGroup, deps, optionName) {
|
|
592
676
|
const { basename } = await import("node:path");
|
|
593
677
|
const { readFile } = await import("node:fs/promises");
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
678
|
+
const media = mediaInfoFromPath(file);
|
|
679
|
+
const allowedKinds = allowedMediaKindsForOption(commandGroup, optionName);
|
|
680
|
+
if (!media || !allowedKinds.has(media.kind)) {
|
|
681
|
+
throw new Error(`Unsupported --${optionName.replaceAll("_", "-")} file type for flatkey ${commandGroup} generate: ${file}`);
|
|
682
|
+
}
|
|
683
|
+
const uploader = media.kind === "video" ? deps.uploadTempMediaVideo : deps.uploadTempMediaImage;
|
|
684
|
+
const response = await uploader({
|
|
685
|
+
apiKey: deps.apiKey,
|
|
686
|
+
baseUrl: deps.tempMediaBaseUrl ?? deps.baseUrl,
|
|
597
687
|
file: await readFile(await expandHomePath(file)),
|
|
598
688
|
filename: basename(file),
|
|
599
|
-
contentType:
|
|
600
|
-
fetch:
|
|
601
|
-
verbose:
|
|
602
|
-
verboseLog:
|
|
689
|
+
contentType: media.contentType,
|
|
690
|
+
fetch: deps.fetch,
|
|
691
|
+
verbose: deps.verbose,
|
|
692
|
+
verboseLog: deps.verboseLog,
|
|
603
693
|
});
|
|
604
|
-
const url =
|
|
605
|
-
if (!url) throw new Error(`
|
|
606
|
-
return
|
|
694
|
+
const url = extractUploadedMediaUrl(response);
|
|
695
|
+
if (!url) throw new Error(`Media upload failed: missing signed_url for ${file}`);
|
|
696
|
+
return {
|
|
697
|
+
name: optionName,
|
|
698
|
+
value: url,
|
|
699
|
+
kind: media.kind,
|
|
700
|
+
role: mediaRoleFromOption(optionName, media.kind),
|
|
701
|
+
mime: media.contentType,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function confirmRemoteMediaUrl(url, commandGroup, deps, optionName) {
|
|
706
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
707
|
+
const response = await fetchImpl(url, { method: "HEAD" });
|
|
708
|
+
const media = mediaInfoFromMime(headerValue(response?.headers, "content-type"));
|
|
709
|
+
const allowedKinds = allowedMediaKindsForOption(commandGroup, optionName);
|
|
710
|
+
if (!media || !allowedKinds.has(media.kind)) {
|
|
711
|
+
throw new Error(`Unsupported --${optionName.replaceAll("_", "-")} MIME type for flatkey ${commandGroup} generate: ${headerValue(response?.headers, "content-type") ?? "unknown"}`);
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
name: optionName,
|
|
715
|
+
value: url,
|
|
716
|
+
kind: media.kind,
|
|
717
|
+
role: mediaRoleFromOption(optionName, media.kind),
|
|
718
|
+
mime: media.contentType,
|
|
719
|
+
};
|
|
607
720
|
}
|
|
608
721
|
|
|
609
|
-
function
|
|
722
|
+
function extractUploadedMediaUrl(response) {
|
|
610
723
|
const data = response?.data ?? response;
|
|
611
724
|
return data?.signed_url ?? data?.signedUrl ?? data?.url ?? response?.signed_url ?? response?.signedUrl ?? response?.url;
|
|
612
725
|
}
|
|
613
726
|
|
|
614
|
-
function
|
|
727
|
+
function mediaInfoFromPath(file) {
|
|
615
728
|
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";
|
|
729
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return { kind: "image", contentType: "image/jpeg" };
|
|
730
|
+
if (lower.endsWith(".png")) return { kind: "image", contentType: "image/png" };
|
|
731
|
+
if (lower.endsWith(".webp")) return { kind: "image", contentType: "image/webp" };
|
|
732
|
+
if (lower.endsWith(".mp4")) return { kind: "video", contentType: "video/mp4" };
|
|
733
|
+
return undefined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function mediaInfoFromMime(contentType) {
|
|
737
|
+
if (typeof contentType !== "string") return undefined;
|
|
738
|
+
const lower = contentType.toLowerCase();
|
|
739
|
+
if (lower.startsWith("image/jpeg")) return { kind: "image", contentType: "image/jpeg" };
|
|
740
|
+
if (lower.startsWith("image/png")) return { kind: "image", contentType: "image/png" };
|
|
741
|
+
if (lower.startsWith("image/webp")) return { kind: "image", contentType: "image/webp" };
|
|
742
|
+
if (lower.startsWith("video/mp4")) return { kind: "video", contentType: "video/mp4" };
|
|
619
743
|
return undefined;
|
|
620
744
|
}
|
|
621
745
|
|
|
746
|
+
function mediaRoleFromOption(optionName, kind) {
|
|
747
|
+
if (optionName === "first_frame" || optionName === "first_frame_url") return "first_frame";
|
|
748
|
+
if (optionName === "last_frame" || optionName === "last_frame_url") return "last_frame";
|
|
749
|
+
if (kind === "video") return "reference_video";
|
|
750
|
+
return "reference_image";
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function allowedMediaKindsForOption(commandGroup, optionName) {
|
|
754
|
+
if (commandGroup === "image") return new Set(["image"]);
|
|
755
|
+
if (optionName === "file") return new Set(["image", "video"]);
|
|
756
|
+
if (optionName === "video_url") return new Set(["video"]);
|
|
757
|
+
if (optionName === "first_frame" || optionName === "first_frame_url" || optionName === "last_frame" || optionName === "last_frame_url" || optionName === "image" || optionName === "image_url") {
|
|
758
|
+
return new Set(["image"]);
|
|
759
|
+
}
|
|
760
|
+
return new Set(["image"]);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function headerValue(headers, name) {
|
|
764
|
+
if (!headers) return undefined;
|
|
765
|
+
if (typeof headers.get === "function") {
|
|
766
|
+
return headers.get(name) ?? headers.get(name.toLowerCase());
|
|
767
|
+
}
|
|
768
|
+
return headers[name] ?? headers[name.toLowerCase()];
|
|
769
|
+
}
|
|
770
|
+
|
|
622
771
|
async function waitForVideoResult(response, options) {
|
|
623
772
|
if (hasArtifact(response)) return response;
|
|
624
773
|
const taskId = videoTaskId(response);
|
package/src/help.js
CHANGED
|
@@ -14,8 +14,8 @@ JSON mode:
|
|
|
14
14
|
- ASCII animation and human logs are disabled.
|
|
15
15
|
|
|
16
16
|
Commands:
|
|
17
|
-
- flatkey image generate --prompt "<prompt>" --json [--model <model>] [--output image.png]
|
|
18
|
-
- flatkey video generate --prompt "<prompt>" --json [--model seedance2] [--ratio 16:9] [--resolution 720p] [--output video.mp4]
|
|
17
|
+
- flatkey image generate --prompt "<prompt>" --json [--model <model>] [--file <path>] [--image-url <url>] [--output image.png]
|
|
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
19
|
- flatkey audio generate --prompt "<text>" --json [--voice-id <voice_id>] [--model eleven_multilingual_v2] [--output speech.mp3]
|
|
20
20
|
- flatkey audio sfx --prompt "<sound>" --json [--duration <seconds>] [--output sfx.mp3]
|
|
21
21
|
- flatkey audio music --prompt "<music prompt>" --json [--music-length-ms <ms>] [--output music.mp3]
|
|
@@ -66,6 +66,9 @@ Commands:
|
|
|
66
66
|
|
|
67
67
|
Global options:
|
|
68
68
|
--json Print machine-readable JSON
|
|
69
|
+
--file <path> Attach local media input
|
|
70
|
+
--image-url <url> Add remote image input, repeatable
|
|
71
|
+
--video-url <url> Add remote video input, repeatable
|
|
69
72
|
--output, -o <file> Write generated output to a local file
|
|
70
73
|
--verbose Print request/response logs to stderr
|
|
71
74
|
--base-url <url> Override Flatkey router URL
|
|
@@ -163,6 +166,8 @@ Options:
|
|
|
163
166
|
--size <size> Image size for OpenAI-style image models
|
|
164
167
|
--quality <quality> Image quality for supported models
|
|
165
168
|
--n <count> Number of images for supported models
|
|
169
|
+
--file <path> Local image input, repeatable
|
|
170
|
+
--image-url <url> Remote image input, repeatable
|
|
166
171
|
--output, -o <file> Write image file
|
|
167
172
|
--json Print machine-readable JSON`,
|
|
168
173
|
models: `Usage: flatkey models [options]
|
|
@@ -218,8 +223,10 @@ Options:
|
|
|
218
223
|
--duration <seconds> Video duration
|
|
219
224
|
--ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
|
|
220
225
|
--resolution <value> 480p, 720p, or 1080p
|
|
226
|
+
--generate-audio <true|false> Generate audio, default true
|
|
221
227
|
--image <file> Upload local reference image, repeatable
|
|
222
228
|
--image-url <url> Add reference image URL, repeatable
|
|
229
|
+
--file <path> Local image or video input, repeatable
|
|
223
230
|
--first-frame <file> Upload local first frame image
|
|
224
231
|
--video-url <url> Add reference video URL, repeatable
|
|
225
232
|
--first-frame-url <url> Add first frame image URL
|
|
@@ -235,8 +242,10 @@ Options:
|
|
|
235
242
|
--duration <seconds> Video duration
|
|
236
243
|
--ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
|
|
237
244
|
--resolution <value> 480p, 720p, or 1080p
|
|
245
|
+
--generate-audio <true|false> Generate audio, default true
|
|
238
246
|
--image <file> Upload local reference image, repeatable
|
|
239
247
|
--image-url <url> Add reference image URL, repeatable
|
|
248
|
+
--file <path> Local image or video input, repeatable
|
|
240
249
|
--first-frame <file> Upload local first frame image
|
|
241
250
|
--video-url <url> Add reference video URL, repeatable
|
|
242
251
|
--first-frame-url <url> Add first frame image URL
|