@flatkey-ai/cli 0.3.4 → 0.3.6
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 +24 -0
- package/package.json +1 -1
- package/src/api.js +67 -6
- package/src/artifacts.js +36 -16
- package/src/cli.js +3 -1
- package/src/help.js +13 -0
package/README.md
CHANGED
|
@@ -123,6 +123,30 @@ flatkey image generate \
|
|
|
123
123
|
-o cover.png
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
Reference images (`--file` / `--image-url`) are sent as an image edit: gpt
|
|
127
|
+
models go through `/v1/images/edits`, Gemini models receive them as native
|
|
128
|
+
`fileData` parts, so the input image is preserved instead of being ignored.
|
|
129
|
+
|
|
130
|
+
Animated GIFs (gpt-image-2.6):
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
flatkey image generate \
|
|
134
|
+
--prompt "a paper crane folding itself, soft studio light" \
|
|
135
|
+
--model gpt-image-2.6 \
|
|
136
|
+
--output-format gif \
|
|
137
|
+
--frame-count 9 \
|
|
138
|
+
--frame-duration-ms 200 \
|
|
139
|
+
--quality high \
|
|
140
|
+
--complexity low \
|
|
141
|
+
-o crane.gif
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`--output-format` accepts `png`, `jpeg`, `webp`, or `gif`. `--frame-count`,
|
|
145
|
+
`--frame-duration-ms`, `--quality`, and `--complexity` shape the animation.
|
|
146
|
+
These options are gpt image model only; passing them with a Gemini model fails
|
|
147
|
+
instead of silently returning a still image. When no `-o` path is given, saved
|
|
148
|
+
artifacts use the requested format's extension (e.g. `flatkey-output/image-01.gif`).
|
|
149
|
+
|
|
126
150
|
Upload a local image and get a temporary signed URL:
|
|
127
151
|
|
|
128
152
|
```bash
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { withCliRequestHeaders } from "./requestHeaders.js";
|
|
|
3
3
|
export const DEFAULT_BASE_URL = "https://router.flatkey.ai";
|
|
4
4
|
export const DEFAULT_MODELS_BASE_URL = "https://console.flatkey.ai";
|
|
5
5
|
export const DEFAULT_CONSOLE_URL = "https://console.flatkey.ai";
|
|
6
|
+
export const IMAGE_OUTPUT_FORMATS = ["png", "jpeg", "webp", "gif"];
|
|
6
7
|
|
|
7
8
|
export class FlatkeyError extends Error {
|
|
8
9
|
constructor(message, { status, code } = {}) {
|
|
@@ -44,23 +45,45 @@ export function planImageRequest(options) {
|
|
|
44
45
|
if (mediaInputs.some((entry) => entry.kind === "video")) {
|
|
45
46
|
throw new Error("Image models only accept image inputs.");
|
|
46
47
|
}
|
|
47
|
-
const imageUrls = mediaInputs
|
|
48
|
-
.map((entry) => entry.url);
|
|
49
48
|
if (model.startsWith("gpt")) {
|
|
50
49
|
const responseFormat = options.response_format ?? options.responseFormat ?? "url";
|
|
51
50
|
const tempUrl = options.temp_url ?? options.tempUrl;
|
|
52
|
-
|
|
51
|
+
const payload = cleanObject({
|
|
53
52
|
model,
|
|
54
53
|
prompt: options.prompt,
|
|
55
54
|
size: options.size,
|
|
56
55
|
n: parseOptionalInteger(options.n),
|
|
57
56
|
quality: options.quality,
|
|
57
|
+
// Animated GIF output and its frame controls are gpt-image-2.6 features.
|
|
58
|
+
output_format: validateOptionalValue(
|
|
59
|
+
optionValue(options, "output_format", "outputFormat"),
|
|
60
|
+
IMAGE_OUTPUT_FORMATS,
|
|
61
|
+
"output format",
|
|
62
|
+
),
|
|
63
|
+
frame_count: parseOptionalPositiveInteger(
|
|
64
|
+
optionValue(options, "frame_count", "frameCount"),
|
|
65
|
+
"frame count",
|
|
66
|
+
),
|
|
67
|
+
frame_duration_ms: parseOptionalPositiveInteger(
|
|
68
|
+
optionValue(options, "frame_duration_ms", "frameDurationMs"),
|
|
69
|
+
"frame duration",
|
|
70
|
+
),
|
|
71
|
+
complexity: options.complexity,
|
|
58
72
|
response_format: responseFormat,
|
|
59
73
|
temp_url: tempUrl ?? true,
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
});
|
|
75
|
+
if (mediaInputs.length === 0) {
|
|
76
|
+
return planJsonPost(options, "/v1/images/generations", payload);
|
|
77
|
+
}
|
|
78
|
+
// Reference images are only honoured on the edits endpoint; generations
|
|
79
|
+
// silently ignores them and runs a plain text-to-image.
|
|
80
|
+
return planJsonPost(options, "/v1/images/edits", {
|
|
81
|
+
...payload,
|
|
82
|
+
images: mediaInputs.map((entry) => ({ image_url: entry.url })),
|
|
83
|
+
});
|
|
62
84
|
}
|
|
63
85
|
|
|
86
|
+
assertGptImageOnlyOptions(options, model);
|
|
64
87
|
const path = `/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(options.apiKey)}`;
|
|
65
88
|
return planRequest(options, path, {
|
|
66
89
|
method: "POST",
|
|
@@ -72,7 +95,10 @@ export function planImageRequest(options) {
|
|
|
72
95
|
contents: [{
|
|
73
96
|
parts: [
|
|
74
97
|
{ text: options.prompt },
|
|
75
|
-
|
|
98
|
+
// Gemini native parts: an OpenAI-style image_url part is ignored upstream.
|
|
99
|
+
...mediaInputs.map((entry) => ({
|
|
100
|
+
fileData: { mimeType: imageMimeType(entry), fileUri: entry.url },
|
|
101
|
+
})),
|
|
76
102
|
],
|
|
77
103
|
}],
|
|
78
104
|
temp_url: true,
|
|
@@ -80,6 +106,32 @@ export function planImageRequest(options) {
|
|
|
80
106
|
});
|
|
81
107
|
}
|
|
82
108
|
|
|
109
|
+
// gpt-image animation and complexity controls have no Gemini equivalent, so
|
|
110
|
+
// silently dropping them would hand back a still PNG with a .gif name.
|
|
111
|
+
const GPT_IMAGE_ONLY_OPTIONS = [
|
|
112
|
+
["output_format", "outputFormat"],
|
|
113
|
+
["frame_count", "frameCount"],
|
|
114
|
+
["frame_duration_ms", "frameDurationMs"],
|
|
115
|
+
["complexity"],
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
function assertGptImageOnlyOptions(options, model) {
|
|
119
|
+
for (const aliases of GPT_IMAGE_ONLY_OPTIONS) {
|
|
120
|
+
if (optionValue(options, ...aliases) === undefined) continue;
|
|
121
|
+
const flag = `--${aliases[0].replaceAll("_", "-")}`;
|
|
122
|
+
throw new Error(`${flag} is only supported by gpt image models, not ${model}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function imageMimeType(entry) {
|
|
127
|
+
if (entry.mime) return entry.mime;
|
|
128
|
+
const path = String(entry.url ?? "").split(/[?#]/)[0].toLowerCase();
|
|
129
|
+
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
|
130
|
+
if (path.endsWith(".webp")) return "image/webp";
|
|
131
|
+
if (path.endsWith(".gif")) return "image/gif";
|
|
132
|
+
return "image/png";
|
|
133
|
+
}
|
|
134
|
+
|
|
83
135
|
export function generateVideo(options) {
|
|
84
136
|
return requestJsonFromPlan(options, planVideoRequest(options));
|
|
85
137
|
}
|
|
@@ -393,6 +445,15 @@ function parseOptionalInteger(value) {
|
|
|
393
445
|
return Number.parseInt(value, 10);
|
|
394
446
|
}
|
|
395
447
|
|
|
448
|
+
function parseOptionalPositiveInteger(value, name) {
|
|
449
|
+
if (value === undefined) return undefined;
|
|
450
|
+
const parsed = Number.parseInt(value, 10);
|
|
451
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
452
|
+
throw new Error(`Invalid ${name}: ${value}. Expected a positive integer.`);
|
|
453
|
+
}
|
|
454
|
+
return parsed;
|
|
455
|
+
}
|
|
456
|
+
|
|
396
457
|
function parseOptionalFloat(value) {
|
|
397
458
|
if (value === undefined) return undefined;
|
|
398
459
|
return Number.parseFloat(value);
|
package/src/artifacts.js
CHANGED
|
@@ -16,39 +16,65 @@ const MIME_EXTENSIONS = {
|
|
|
16
16
|
"audio/mpeg": "mp3",
|
|
17
17
|
"audio/mp3": "mp3",
|
|
18
18
|
"audio/wav": "wav",
|
|
19
|
+
"image/gif": "gif",
|
|
19
20
|
"image/jpeg": "jpg",
|
|
20
21
|
"image/png": "png",
|
|
22
|
+
"image/webp": "webp",
|
|
21
23
|
"video/mp4": "mp4",
|
|
22
24
|
};
|
|
23
25
|
|
|
26
|
+
const FORMAT_EXTENSIONS = {
|
|
27
|
+
gif: "gif",
|
|
28
|
+
jpeg: "jpg",
|
|
29
|
+
jpg: "jpg",
|
|
30
|
+
png: "png",
|
|
31
|
+
webp: "webp",
|
|
32
|
+
};
|
|
33
|
+
|
|
24
34
|
export async function persistArtifacts({
|
|
25
35
|
kind,
|
|
26
36
|
response,
|
|
27
37
|
outDir = "flatkey-output",
|
|
28
38
|
output,
|
|
39
|
+
format,
|
|
29
40
|
fetch: fetchImpl = fetch,
|
|
30
41
|
}) {
|
|
31
42
|
output = expandHomePath(output);
|
|
32
43
|
const items = extractItems(response);
|
|
33
44
|
const artifacts = [];
|
|
45
|
+
const extension = artifactExtension({ kind, format });
|
|
34
46
|
await mkdir(output ? dirname(output) : outDir, { recursive: true });
|
|
35
|
-
const startIndex = output ? 0 : await nextArtifactIndex({ kind, outDir });
|
|
47
|
+
const startIndex = output ? 0 : await nextArtifactIndex({ kind, outDir, format });
|
|
36
48
|
|
|
37
49
|
for (const [index, item] of items.entries()) {
|
|
38
|
-
const artifact = await persistItem({
|
|
50
|
+
const artifact = await persistItem({
|
|
51
|
+
kind,
|
|
52
|
+
item,
|
|
53
|
+
outDir,
|
|
54
|
+
output,
|
|
55
|
+
index: index + startIndex,
|
|
56
|
+
extension,
|
|
57
|
+
fetchImpl,
|
|
58
|
+
});
|
|
39
59
|
if (artifact) artifacts.push(artifact);
|
|
40
60
|
}
|
|
41
61
|
|
|
42
62
|
return artifacts;
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
export async function nextArtifactPath({ kind, outDir = "flatkey-output" }) {
|
|
46
|
-
const extension =
|
|
47
|
-
const index = await nextArtifactIndex({ kind, outDir });
|
|
65
|
+
export async function nextArtifactPath({ kind, outDir = "flatkey-output", format }) {
|
|
66
|
+
const extension = artifactExtension({ kind, format });
|
|
67
|
+
const index = await nextArtifactIndex({ kind, outDir, format });
|
|
48
68
|
const number = String(index + 1).padStart(2, "0");
|
|
49
69
|
return join(outDir, `${kind}-${number}.${extension}`);
|
|
50
70
|
}
|
|
51
71
|
|
|
72
|
+
function artifactExtension({ kind, format }) {
|
|
73
|
+
return FORMAT_EXTENSIONS[String(format ?? "").toLowerCase()]
|
|
74
|
+
?? DEFAULT_EXTENSIONS[kind]
|
|
75
|
+
?? "bin";
|
|
76
|
+
}
|
|
77
|
+
|
|
52
78
|
function expandHomePath(path) {
|
|
53
79
|
if (typeof path !== "string") return path;
|
|
54
80
|
if (path === "~") return homedir();
|
|
@@ -56,7 +82,7 @@ function expandHomePath(path) {
|
|
|
56
82
|
return path;
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
|
|
85
|
+
async function persistItem({ kind, item, outDir, output, index, extension, fetchImpl }) {
|
|
60
86
|
const dataUrl = getString(item, ["url", "video_url", "data_url", "dataUrl", "temp_url"]);
|
|
61
87
|
if (dataUrl?.startsWith("data:")) {
|
|
62
88
|
const parsed = parseDataUrl(dataUrl);
|
|
@@ -66,13 +92,7 @@ async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
|
|
|
66
92
|
}
|
|
67
93
|
if (dataUrl?.startsWith("http://") || dataUrl?.startsWith("https://")) {
|
|
68
94
|
if (output) {
|
|
69
|
-
const path = artifactPath({
|
|
70
|
-
kind,
|
|
71
|
-
outDir,
|
|
72
|
-
output,
|
|
73
|
-
index,
|
|
74
|
-
extension: DEFAULT_EXTENSIONS[kind] ?? "bin",
|
|
75
|
-
});
|
|
95
|
+
const path = artifactPath({ kind, outDir, output, index, extension });
|
|
76
96
|
await downloadArtifact({ url: dataUrl, path, fetchImpl });
|
|
77
97
|
return { path };
|
|
78
98
|
}
|
|
@@ -81,7 +101,7 @@ async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
|
|
|
81
101
|
|
|
82
102
|
const base64 = getString(item, ["b64_json", "base64", "data"]);
|
|
83
103
|
if (base64) {
|
|
84
|
-
const path = artifactPath({ kind, outDir, output, index, extension
|
|
104
|
+
const path = artifactPath({ kind, outDir, output, index, extension });
|
|
85
105
|
await writeFile(path, Buffer.from(base64, "base64"));
|
|
86
106
|
return { path };
|
|
87
107
|
}
|
|
@@ -145,8 +165,8 @@ function artifactPath({ kind, outDir, output, index, extension }) {
|
|
|
145
165
|
return join(outDir, `${kind}-${number}.${extension}`);
|
|
146
166
|
}
|
|
147
167
|
|
|
148
|
-
async function nextArtifactIndex({ kind, outDir }) {
|
|
149
|
-
const extension =
|
|
168
|
+
async function nextArtifactIndex({ kind, outDir, format }) {
|
|
169
|
+
const extension = artifactExtension({ kind, format });
|
|
150
170
|
const pattern = new RegExp(`^${escapeRegExp(kind)}-(\\d+)\\.${escapeRegExp(extension)}$`);
|
|
151
171
|
let max = 0;
|
|
152
172
|
try {
|
package/src/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ const COMMAND_OPTIONS = {
|
|
|
32
32
|
credits: new Set([]),
|
|
33
33
|
help: new Set(["ai", "command"]),
|
|
34
34
|
image: new Set([]),
|
|
35
|
-
"image generate": new Set(["file", "image", "image_url", "model", "n", "prompt", "quality", "size"]),
|
|
35
|
+
"image generate": new Set(["complexity", "file", "frame_count", "frame_duration_ms", "image", "image_url", "model", "n", "output_format", "prompt", "quality", "size"]),
|
|
36
36
|
"image upload": new Set(["file"]),
|
|
37
37
|
login: new Set(["console_url", "no_open", "open"]),
|
|
38
38
|
logout: new Set([]),
|
|
@@ -669,6 +669,7 @@ async function handleGenerate(command, deps) {
|
|
|
669
669
|
return { kind: command.group, text, output, response };
|
|
670
670
|
}
|
|
671
671
|
const outDir = command.options.out ?? "flatkey-output";
|
|
672
|
+
const format = command.group === "image" ? command.options.output_format : undefined;
|
|
672
673
|
const output = command.options.output
|
|
673
674
|
?? (command.group === "video" ? await nextArtifactPath({ kind: command.group, outDir }) : undefined);
|
|
674
675
|
const artifacts = await persistArtifacts({
|
|
@@ -676,6 +677,7 @@ async function handleGenerate(command, deps) {
|
|
|
676
677
|
response: artifactResponse,
|
|
677
678
|
outDir,
|
|
678
679
|
output,
|
|
680
|
+
format,
|
|
679
681
|
fetch: deps.fetch,
|
|
680
682
|
});
|
|
681
683
|
return {
|
package/src/help.js
CHANGED
|
@@ -15,6 +15,7 @@ JSON mode:
|
|
|
15
15
|
|
|
16
16
|
Commands:
|
|
17
17
|
- flatkey image generate --prompt "<prompt>" --json [--model <model>] [--file <path>] [--image-url <url>] [--output image.png]
|
|
18
|
+
- flatkey image generate --prompt "<prompt>" --json --model gpt-image-2.6 --output-format gif [--frame-count 9] [--frame-duration-ms 200] [--quality high] [--complexity low] [--output loop.gif]
|
|
18
19
|
- 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
20
|
- flatkey video copy --source input.mp4 --json [--model seedance2] [--frame-ratio 80] [--depth-resolution 480p] [--output video.mp4]
|
|
20
21
|
- flatkey audio generate --prompt "<text>" --json [--voice-id <voice_id>] [--model eleven_multilingual_v2] [--output speech.mp3]
|
|
@@ -83,6 +84,14 @@ Environment:
|
|
|
83
84
|
Video options:
|
|
84
85
|
--ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, 1:1, or adaptive
|
|
85
86
|
--resolution <value> 480p, 720p, or 1080p
|
|
87
|
+
|
|
88
|
+
Image options (gpt image models):
|
|
89
|
+
--size <size> Image size, e.g. 1024x1024
|
|
90
|
+
--quality <quality> Image quality, e.g. high
|
|
91
|
+
--output-format <format> png, jpeg, webp, or gif
|
|
92
|
+
--frame-count <count> Animation frames for gif output
|
|
93
|
+
--frame-duration-ms <ms> Milliseconds per gif frame
|
|
94
|
+
--complexity <level> Animation complexity, e.g. low
|
|
86
95
|
`;
|
|
87
96
|
}
|
|
88
97
|
|
|
@@ -168,6 +177,10 @@ Options:
|
|
|
168
177
|
--size <size> Image size for OpenAI-style image models
|
|
169
178
|
--quality <quality> Image quality for supported models
|
|
170
179
|
--n <count> Number of images for supported models
|
|
180
|
+
--output-format <format> png, jpeg, webp, or gif; gpt image models only
|
|
181
|
+
--frame-count <count> Animation frames for gif output, gpt image models only
|
|
182
|
+
--frame-duration-ms <ms> Milliseconds per gif frame, gpt image models only
|
|
183
|
+
--complexity <level> Animation complexity, e.g. low; gpt image models only
|
|
171
184
|
--file <path> Local image input, repeatable
|
|
172
185
|
--image-url <url> Remote image input, repeatable
|
|
173
186
|
--output, -o <file> Write image file
|