@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Flatkey media generation CLI for image, video, audio, credits, status, and models.",
5
5
  "type": "module",
6
6
  "bin": {
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
- return planJsonPost(options, "/v1/images/generations", cleanObject({
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
- images: imageUrls.length > 0 ? imageUrls : undefined,
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
- ...imageUrls.map((url) => ({ type: "image_url", image_url: { url } })),
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({ kind, item, outDir, output, index: index + startIndex, fetchImpl });
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 = DEFAULT_EXTENSIONS[kind] ?? "bin";
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: DEFAULT_EXTENSIONS[kind] });
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 = DEFAULT_EXTENSIONS[kind] ?? "bin";
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