@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flatkey-ai/cli",
3
- "version": "0.2.8",
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"],
@@ -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 url of arrayOption(options.image_url)) {
393
- content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
394
- }
395
- for (const url of arrayOption(options.imageUrl)) {
396
- content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
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 (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]
@@ -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