@flatkey-ai/cli 0.1.17 → 0.1.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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
@@ -14,6 +14,16 @@ export async function generateImage(options) {
14
14
  return requestJsonFromPlan(options, planImageRequest(options));
15
15
  }
16
16
 
17
+ export async function uploadTempMediaImage(options) {
18
+ const form = new FormData();
19
+ form.append("file", new Blob([options.file]), options.filename ?? "image");
20
+ return requestJsonFromPlan(options, planRequest(options, "/v1/temp-media/images", {
21
+ method: "POST",
22
+ headers: authHeaders(options.apiKey),
23
+ body: form,
24
+ }));
25
+ }
26
+
17
27
  export function planImageRequest(options) {
18
28
  const model = options.model ?? "nano-banana-pro-preview";
19
29
  if (model.startsWith("gpt")) {
@@ -43,6 +53,10 @@ export function generateVideo(options) {
43
53
  return requestJsonFromPlan(options, planVideoRequest(options));
44
54
  }
45
55
 
56
+ export function getVideo(options, taskId) {
57
+ return requestJsonFromPlan(options, planVideoStatusRequest(options, taskId));
58
+ }
59
+
46
60
  export function planVideoRequest(options) {
47
61
  const model = options.model ?? "seedance-2.0-pro";
48
62
  const imageUrls = [
@@ -84,6 +98,10 @@ export function planVideoRequest(options) {
84
98
  return planJsonPost(options, "/v1/video/generations", basePayload);
85
99
  }
86
100
 
101
+ export function planVideoStatusRequest(options, taskId) {
102
+ return planRequest(options, `/v1/videos/${encodeURIComponent(taskId)}`);
103
+ }
104
+
87
105
  export function generateAudio(options) {
88
106
  return requestBinaryArtifactFromPlan(options, planAudioRequest(options));
89
107
  }
@@ -215,7 +233,11 @@ async function requestJsonFromPlan(options, plan) {
215
233
  const response = await fetchImpl(plan.url, {
216
234
  method: plan.method,
217
235
  headers: plan.headers,
218
- body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
236
+ body: plan.body === undefined
237
+ ? undefined
238
+ : isFormData(plan.body)
239
+ ? plan.body
240
+ : JSON.stringify(plan.body),
219
241
  });
220
242
  const body = await readJson(response);
221
243
  if (!response.ok) {
@@ -226,6 +248,10 @@ async function requestJsonFromPlan(options, plan) {
226
248
  return body;
227
249
  }
228
250
 
251
+ function isFormData(value) {
252
+ return typeof FormData !== "undefined" && value instanceof FormData;
253
+ }
254
+
229
255
  async function requestBinaryArtifactFromPlan(options, plan) {
230
256
  const fetchImpl = options.fetch ?? fetch;
231
257
  const response = await fetchImpl(plan.url, {
package/src/artifacts.js CHANGED
@@ -47,7 +47,7 @@ function expandHomePath(path) {
47
47
  }
48
48
 
49
49
  async function persistItem({ kind, item, outDir, output, index, fetchImpl }) {
50
- const dataUrl = getString(item, ["url", "data_url", "dataUrl"]);
50
+ const dataUrl = getString(item, ["url", "video_url", "data_url", "dataUrl"]);
51
51
  if (dataUrl?.startsWith("data:")) {
52
52
  const parsed = parseDataUrl(dataUrl);
53
53
  const path = artifactPath({ kind, outDir, output, index, extension: parsed.extension });
@@ -90,15 +90,20 @@ async function downloadArtifact({ url, path, fetchImpl }) {
90
90
  function extractItems(response) {
91
91
  if (Array.isArray(response?.data)) return response.data;
92
92
  if (Array.isArray(response?.artifacts)) return response.artifacts;
93
+ if (Array.isArray(response?.content)) return response.content;
93
94
  if (Array.isArray(response?.candidates)) {
94
95
  return response.candidates.flatMap((candidate) => candidate.content?.parts ?? []);
95
96
  }
97
+ if (typeof response?.metadata?.url === "string") return [{ url: response.metadata.url }];
98
+ if (typeof response?.url === "string") return [response];
99
+ if (typeof response?.video_url?.url === "string") return [{ url: response.video_url.url }];
96
100
  return [];
97
101
  }
98
102
 
99
103
  function getString(item, keys) {
100
104
  for (const key of keys) {
101
105
  if (typeof item?.[key] === "string") return item[key];
106
+ if (typeof item?.[key]?.url === "string") return item[key].url;
102
107
  if (typeof item?.inlineData?.[key] === "string") return item.inlineData[key];
103
108
  if (typeof item?.inline_data?.[key] === "string") return item.inline_data[key];
104
109
  }
package/src/cli.js CHANGED
@@ -16,7 +16,7 @@ const COMMANDS = new Set([
16
16
 
17
17
  const GROUP_ACTIONS = new Set(["audio", "auth", "image", "text", "video"]);
18
18
  const GLOBAL_OPTIONS = new Set(["api_key", "base_url", "dry_run", "help", "json", "output", "out"]);
19
- const REPEATABLE_OPTIONS = new Set(["image_url", "video_url"]);
19
+ const REPEATABLE_OPTIONS = new Set(["image", "image_url", "video_url"]);
20
20
  const COMMAND_OPTIONS = {
21
21
  "audio generate": new Set(["model", "prompt", "similarity_boost", "stability", "style", "voice_id"]),
22
22
  "audio music": new Set(["music_length_ms", "prompt"]),
@@ -27,6 +27,7 @@ const COMMAND_OPTIONS = {
27
27
  help: new Set(["ai", "command"]),
28
28
  image: new Set([]),
29
29
  "image generate": new Set(["model", "n", "prompt", "quality", "size"]),
30
+ "image upload": new Set(["file"]),
30
31
  login: new Set(["console_url", "no_open", "open"]),
31
32
  logout: new Set([]),
32
33
  models: new Set(["type"]),
@@ -36,7 +37,7 @@ const COMMAND_OPTIONS = {
36
37
  "text generate": new Set(["model", "prompt"]),
37
38
  version: new Set([]),
38
39
  video: new Set([]),
39
- "video generate": new Set(["aspect", "duration", "first_frame_url", "fps", "image_url", "last_frame_url", "model", "prompt", "ratio", "resolution", "video_url"]),
40
+ "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"]),
40
41
  };
41
42
 
42
43
  export function parseArgv(argv) {
@@ -214,10 +215,15 @@ export async function runCommand(command, deps = {}) {
214
215
  }
215
216
  const validAction = command.group === "audio"
216
217
  ? ["generate", "sfx", "music"].includes(command.action)
217
- : command.action === "generate";
218
+ : command.group === "image"
219
+ ? ["generate", "upload"].includes(command.action)
220
+ : command.action === "generate";
218
221
  if (!validAction) {
219
222
  throw new Error(`Unknown action for ${command.group}: ${command.action}`);
220
223
  }
224
+ if (command.group === "image" && command.action === "upload") {
225
+ return handleImageUpload(command, deps);
226
+ }
221
227
  return handleGenerate(command, { ...deps, stdout, stderr });
222
228
  }
223
229
 
@@ -394,6 +400,46 @@ async function handleAuthStatus(command, deps) {
394
400
  return command.options.json ? status : formatAuthStatus(status);
395
401
  }
396
402
 
403
+ async function handleImageUpload(command, deps) {
404
+ const { basename } = await import("node:path");
405
+ const { readFile } = await import("node:fs/promises");
406
+ const { resolveApiKey, resolveOrigins } = await import("./config.js");
407
+ const { uploadTempMediaImage } = await import("./api.js");
408
+ const file = command.options.file;
409
+ if (typeof file !== "string" || file.trim() === "") {
410
+ throw new Error("Missing --file value. Run `flatkey image upload --help` to see supported options.");
411
+ }
412
+ const apiKey = await resolveApiKey({
413
+ apiKey: command.options.api_key,
414
+ env: deps.env ?? process.env,
415
+ configDir: deps.configDir,
416
+ });
417
+ const { routerOrigin } = await resolveOrigins({
418
+ baseUrl: command.options.base_url,
419
+ env: deps.env ?? process.env,
420
+ configDir: deps.configDir,
421
+ });
422
+ const response = await uploadTempMediaImage({
423
+ apiKey,
424
+ baseUrl: routerOrigin,
425
+ file: await readFile(await expandHomePath(file)),
426
+ filename: basename(file),
427
+ fetch: deps.fetch,
428
+ });
429
+ const data = response?.data ?? response;
430
+ if (!data?.signed_url) {
431
+ throw new Error("Image upload failed: missing signed_url.");
432
+ }
433
+ return {
434
+ kind: "upload",
435
+ url: data?.signed_url,
436
+ objectKey: data?.object_key,
437
+ expiresAt: data?.expires_at,
438
+ expiresIn: data?.expires_in,
439
+ response,
440
+ };
441
+ }
442
+
397
443
  async function handleGenerate(command, deps) {
398
444
  const { resolveApiKey, resolveOrigins } = await import("./config.js");
399
445
  const {
@@ -403,12 +449,14 @@ async function handleGenerate(command, deps) {
403
449
  generateImage,
404
450
  generateText,
405
451
  generateVideo,
452
+ getVideo,
406
453
  planAudioMusicRequest,
407
454
  planAudioRequest,
408
455
  planAudioSfxRequest,
409
456
  planImageRequest,
410
457
  planTextRequest,
411
458
  planVideoRequest,
459
+ uploadTempMediaImage,
412
460
  } = await import("./api.js");
413
461
  const { persistArtifacts } = await import("./artifacts.js");
414
462
  const { createAnimation } = await import("./animation.js");
@@ -428,8 +476,12 @@ async function handleGenerate(command, deps) {
428
476
  ...command.options,
429
477
  apiKey,
430
478
  baseUrl: routerOrigin,
479
+ env: deps.env ?? process.env,
431
480
  fetch: deps.fetch,
432
481
  };
482
+ if (command.group === "video" && !command.options.dry_run) {
483
+ await uploadVideoLocalImages(options, { uploadTempMediaImage });
484
+ }
433
485
  if (command.options.dry_run) {
434
486
  const request = command.group === "image"
435
487
  ? planImageRequest(options)
@@ -461,6 +513,9 @@ async function handleGenerate(command, deps) {
461
513
  ? await generateAudioMusic(options)
462
514
  : await generateAudio(options)
463
515
  : await generateText(options);
516
+ const artifactResponse = command.group === "video"
517
+ ? await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep })
518
+ : response;
464
519
  if (command.group === "text") {
465
520
  const text = extractText(response);
466
521
  const output = await writeTextOutput(text, command.options.output);
@@ -468,17 +523,111 @@ async function handleGenerate(command, deps) {
468
523
  }
469
524
  const artifacts = await persistArtifacts({
470
525
  kind: command.group,
471
- response,
526
+ response: artifactResponse,
472
527
  outDir: command.options.out ?? "flatkey-output",
473
528
  output: command.options.output,
474
529
  fetch: deps.fetch,
475
530
  });
476
- return { kind: command.group, artifacts, response: scrubArtifactResponse(response) };
531
+ return { kind: command.group, artifacts, response: scrubArtifactResponse(artifactResponse) };
477
532
  } finally {
478
533
  animation.stop();
479
534
  }
480
535
  }
481
536
 
537
+ async function uploadVideoLocalImages(options, deps) {
538
+ const localImages = asArray(options.image);
539
+ const firstFrame = options.first_frame;
540
+ const lastFrame = options.last_frame;
541
+ if (localImages.length === 0 && !firstFrame && !lastFrame) return;
542
+
543
+ const uploads = [];
544
+ for (const file of localImages) {
545
+ uploads.push(await uploadLocalImage(file, options, deps));
546
+ }
547
+ if (uploads.length > 0) {
548
+ options.image_url = [...asArray(options.image_url), ...uploads];
549
+ }
550
+ if (firstFrame) {
551
+ options.first_frame_url = await uploadLocalImage(firstFrame, options, deps);
552
+ }
553
+ if (lastFrame) {
554
+ options.last_frame_url = await uploadLocalImage(lastFrame, options, deps);
555
+ }
556
+ }
557
+
558
+ async function uploadLocalImage(file, options, deps) {
559
+ const { basename } = await import("node:path");
560
+ const { readFile } = await import("node:fs/promises");
561
+ const response = await deps.uploadTempMediaImage({
562
+ apiKey: options.apiKey,
563
+ baseUrl: options.baseUrl,
564
+ file: await readFile(await expandHomePath(file)),
565
+ filename: basename(file),
566
+ fetch: options.fetch,
567
+ });
568
+ const url = response?.data?.signed_url ?? response?.signed_url;
569
+ if (!url) throw new Error(`Image upload failed: missing signed_url for ${file}`);
570
+ return url;
571
+ }
572
+
573
+ async function waitForVideoResult(response, options) {
574
+ if (hasArtifact(response)) return response;
575
+ const taskId = videoTaskId(response);
576
+ if (!taskId) return response;
577
+
578
+ const timeoutMs = parsePositiveInteger(
579
+ options.video_wait_timeout_ms ?? options.env?.FLATKEY_VIDEO_WAIT_TIMEOUT_MS,
580
+ 600_000,
581
+ );
582
+ const intervalMs = parsePositiveInteger(
583
+ options.video_poll_interval_ms ?? options.env?.FLATKEY_VIDEO_POLL_INTERVAL_MS,
584
+ 5_000,
585
+ );
586
+ const deadline = Date.now() + timeoutMs;
587
+ let last = response;
588
+
589
+ while (Date.now() <= deadline) {
590
+ last = await options.getVideo(options, taskId);
591
+ if (hasArtifact(last) || isVideoDone(last)) return last;
592
+ if (isVideoFailed(last)) {
593
+ const message = last?.error?.message ?? last?.message ?? `Video generation failed: ${taskId}`;
594
+ throw new Error(message);
595
+ }
596
+ await delay(intervalMs, { sleep: options.sleep });
597
+ }
598
+
599
+ throw new Error(`Video task still processing after ${Math.round(timeoutMs / 1000)}s: ${taskId}`);
600
+ }
601
+
602
+ function hasArtifact(response) {
603
+ return Boolean(
604
+ response?.metadata?.url
605
+ || response?.url
606
+ || response?.video_url?.url
607
+ || response?.data?.some?.((item) => item?.url || item?.b64_json || item?.base64 || item?.data)
608
+ || response?.artifacts?.some?.((item) => item?.url || item?.path)
609
+ || response?.content?.some?.((item) => item?.url || item?.video_url?.url || item?.data_url || item?.b64_json || item?.base64 || item?.data),
610
+ );
611
+ }
612
+
613
+ function videoTaskId(response) {
614
+ return firstNonEmpty(response?.id, response?.task_id, response?.taskId, response?.data?.id, response?.data?.task_id);
615
+ }
616
+
617
+ function isVideoDone(response) {
618
+ return ["completed", "succeeded", "success"].includes(String(response?.status ?? "").toLowerCase());
619
+ }
620
+
621
+ function isVideoFailed(response) {
622
+ return ["failed", "failure", "cancelled", "canceled"].includes(String(response?.status ?? "").toLowerCase())
623
+ || Boolean(response?.error);
624
+ }
625
+
626
+ function parsePositiveInteger(value, fallback) {
627
+ const parsed = Number.parseInt(value, 10);
628
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
629
+ }
630
+
482
631
  function scrubArtifactResponse(value) {
483
632
  if (Array.isArray(value)) return value.map(scrubArtifactResponse);
484
633
  if (!value || typeof value !== "object") {
@@ -606,6 +755,7 @@ function formatHuman(result) {
606
755
  if (typeof result === "string") return result;
607
756
  if (!result || typeof result !== "object") return String(result);
608
757
  if (result.dryRun && result.request) return formatDryRun(result);
758
+ if (result.kind === "upload") return formatUploadResult(result);
609
759
  if (result.kind) return formatGenerationResult(result);
610
760
  if (Array.isArray(result.models)) return formatModels(result.models);
611
761
  if (Array.isArray(result.voices)) return formatVoices(result.voices);
@@ -625,6 +775,13 @@ function formatDryRun(result) {
625
775
  return lines.join("\n");
626
776
  }
627
777
 
778
+ function formatUploadResult(result) {
779
+ const lines = ["Image uploaded:"];
780
+ if (result.url) lines.push(`URL: ${result.url}`);
781
+ if (result.expiresIn) lines.push(`Expires in: ${result.expiresIn}s`);
782
+ return lines.join("\n");
783
+ }
784
+
628
785
  function formatGenerationResult(result) {
629
786
  if (result.kind === "text") {
630
787
  const lines = [result.text || "(empty text)"];
package/src/help.js CHANGED
@@ -52,6 +52,7 @@ Commands:
52
52
  auth status Show saved auth state
53
53
  onboard --api-key <key> Save Flatkey API key from https://console.flatkey.ai/keys
54
54
  image generate --prompt <txt> Generate image
55
+ image upload --file <path> Upload local image and return a temporary URL
55
56
  video generate --prompt <txt> Generate video
56
57
  audio generate --prompt <txt> Generate speech with ElevenLabs voices
57
58
  audio sfx --prompt <txt> Generate sound effects
@@ -141,14 +142,18 @@ Options:
141
142
 
142
143
  Options:
143
144
  --ai Print agent-focused usage guide`,
144
- image: `Usage: flatkey image generate --prompt <txt> [options]
145
+ image: `Usage: flatkey image <command> [options]
146
+
147
+ Commands:
148
+ generate --prompt <txt> Generate image
149
+ upload --file <path> Upload local image and return a temporary URL
145
150
 
146
151
  Options:
147
- --model <model> Model id
148
- --size <size> Image size for OpenAI-style image models
149
- --quality <quality> Image quality for supported models
150
- --n <count> Number of images for supported models
151
- --output, -o <file> Write image file
152
+ --json Print machine-readable JSON`,
153
+ "image upload": `Usage: flatkey image upload --file <path> [options]
154
+
155
+ Options:
156
+ --file <path> Local image file to upload
152
157
  --json Print machine-readable JSON`,
153
158
  "image generate": `Usage: flatkey image generate --prompt <txt> [options]
154
159
 
@@ -212,9 +217,12 @@ Options:
212
217
  --duration <seconds> Video duration
213
218
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
214
219
  --resolution <value> 480p, 720p, or 1080p
220
+ --image <file> Upload local reference image, repeatable
215
221
  --image-url <url> Add reference image URL, repeatable
222
+ --first-frame <file> Upload local first frame image
216
223
  --video-url <url> Add reference video URL, repeatable
217
224
  --first-frame-url <url> Add first frame image URL
225
+ --last-frame <file> Upload local last frame image
218
226
  --last-frame-url <url> Add last frame image URL
219
227
  --fps <fps> Frames per second
220
228
  --output, -o <file> Write video file
@@ -226,9 +234,12 @@ Options:
226
234
  --duration <seconds> Video duration
227
235
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
228
236
  --resolution <value> 480p, 720p, or 1080p
237
+ --image <file> Upload local reference image, repeatable
229
238
  --image-url <url> Add reference image URL, repeatable
239
+ --first-frame <file> Upload local first frame image
230
240
  --video-url <url> Add reference video URL, repeatable
231
241
  --first-frame-url <url> Add first frame image URL
242
+ --last-frame <file> Upload local last frame image
232
243
  --last-frame-url <url> Add last frame image URL
233
244
  --fps <fps> Frames per second
234
245
  --output, -o <file> Write video file