@flatkey-ai/cli 0.2.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.2.9",
3
+ "version": "0.3.2",
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
@@ -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, "/v1/temp-media/images", {
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: [{ parts: [{ text: options.prompt }] }],
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 imageUrls = [
72
- ...arrayOption(options.image_url),
73
- ...arrayOption(options.imageUrl),
74
- ...arrayOption(options.first_frame_url),
75
- ...arrayOption(options.firstFrameUrl),
76
- ...arrayOption(options.last_frame_url),
77
- ...arrayOption(options.lastFrameUrl),
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"],
@@ -101,7 +123,7 @@ export function planVideoRequest(options) {
101
123
  temp_url: true,
102
124
  images: !isSeedanceModel(model) && imageUrls.length > 0 ? imageUrls : undefined,
103
125
  });
104
- const seedanceContent = buildSeedanceContent(options);
126
+ const seedanceContent = buildSeedanceContent(options, mediaInputs);
105
127
  if ((isSeedanceModel(model) || isMiniMaxModel(model)) && seedanceContent.length > 0) {
106
128
  return planJsonPost(options, "/v1/video/generations", cleanObject({
107
129
  ...basePayload,
@@ -392,34 +414,17 @@ function isMiniMaxModel(model) {
392
414
  return /^minimax-h3$/i.test(model);
393
415
  }
394
416
 
395
- function buildSeedanceContent(options) {
417
+ function buildSeedanceContent(options, mediaInputs = orderedMediaInputs(options)) {
396
418
  const content = [];
397
419
  if (options.prompt !== undefined) {
398
420
  content.push({ type: "text", text: options.prompt });
399
421
  }
400
- for (const url of arrayOption(options.image_url)) {
401
- content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
402
- }
403
- for (const url of arrayOption(options.imageUrl)) {
404
- content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
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" });
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" });
423
428
  }
424
429
  return content;
425
430
  }
@@ -435,6 +440,49 @@ function cleanObject(value) {
435
440
  );
436
441
  }
437
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
+
438
486
  async function readJson(response) {
439
487
  try {
440
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", "generate_audio", "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 (REPEATABLE_OPTIONS.has(name)) {
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 = firstNonEmpty(command.options.console_url, DEFAULT_CONSOLE_URL);
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: imageContentTypeFromPath(file),
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 = extractUploadedImageUrl(response);
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 (command.group === "video" && !command.options.dry_run) {
513
- await uploadVideoLocalImages(options, { uploadTempMediaImage });
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" ? join(outDir, "video-01.mp4") : undefined);
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 uploadVideoLocalImages(options, deps) {
571
- const localImages = asArray(options.image);
572
- const firstFrame = options.first_frame;
573
- const lastFrame = options.last_frame;
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 uploads = [];
577
- for (const file of localImages) {
578
- uploads.push(await uploadLocalImage(file, options, deps));
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
- if (uploads.length > 0) {
581
- options.image_url = [...asArray(options.image_url), ...uploads];
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 (firstFrame) {
584
- options.first_frame_url = await uploadLocalImage(firstFrame, options, deps);
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
- if (lastFrame) {
587
- options.last_frame_url = await uploadLocalImage(lastFrame, options, deps);
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 uploadLocalImage(file, options, deps) {
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 response = await deps.uploadTempMediaImage({
595
- apiKey: options.apiKey,
596
- baseUrl: options.tempMediaBaseUrl ?? options.baseUrl,
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: imageContentTypeFromPath(file),
600
- fetch: options.fetch,
601
- verbose: options.verbose,
602
- verboseLog: options.verboseLog,
689
+ contentType: media.contentType,
690
+ fetch: deps.fetch,
691
+ verbose: deps.verbose,
692
+ verboseLog: deps.verboseLog,
603
693
  });
604
- const url = extractUploadedImageUrl(response);
605
- if (!url) throw new Error(`Image upload failed: missing signed_url for ${file}`);
606
- return url;
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 extractUploadedImageUrl(response) {
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 imageContentTypeFromPath(file) {
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]
@@ -221,6 +226,7 @@ Options:
221
226
  --generate-audio <true|false> Generate audio, default true
222
227
  --image <file> Upload local reference image, repeatable
223
228
  --image-url <url> Add reference image URL, repeatable
229
+ --file <path> Local image or video input, repeatable
224
230
  --first-frame <file> Upload local first frame image
225
231
  --video-url <url> Add reference video URL, repeatable
226
232
  --first-frame-url <url> Add first frame image URL
@@ -239,6 +245,7 @@ Options:
239
245
  --generate-audio <true|false> Generate audio, default true
240
246
  --image <file> Upload local reference image, repeatable
241
247
  --image-url <url> Add reference image URL, repeatable
248
+ --file <path> Local image or video input, repeatable
242
249
  --first-frame <file> Upload local first frame image
243
250
  --video-url <url> Add reference video URL, repeatable
244
251
  --first-frame-url <url> Add first frame image URL