@flatkey-ai/cli 0.1.16 → 0.1.18

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.16",
3
+ "version": "0.1.18",
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
@@ -43,7 +43,20 @@ export function generateVideo(options) {
43
43
  return requestJsonFromPlan(options, planVideoRequest(options));
44
44
  }
45
45
 
46
+ export function getVideo(options, taskId) {
47
+ return requestJsonFromPlan(options, planVideoStatusRequest(options, taskId));
48
+ }
49
+
46
50
  export function planVideoRequest(options) {
51
+ const model = options.model ?? "seedance-2.0-pro";
52
+ const imageUrls = [
53
+ ...arrayOption(options.image_url),
54
+ ...arrayOption(options.imageUrl),
55
+ ...arrayOption(options.first_frame_url),
56
+ ...arrayOption(options.firstFrameUrl),
57
+ ...arrayOption(options.last_frame_url),
58
+ ...arrayOption(options.lastFrameUrl),
59
+ ];
47
60
  const ratio = validateOptionalValue(
48
61
  optionValue(options, "ratio", "aspect"),
49
62
  ["16:9", "9:16", "4:3", "3:4", "21:9", "1:1"],
@@ -54,8 +67,8 @@ export function planVideoRequest(options) {
54
67
  ["480p", "720p", "1080p"],
55
68
  "resolution",
56
69
  );
57
- return planJsonPost(options, "/v1/video/generations", cleanObject({
58
- model: options.model ?? "veo-3",
70
+ const basePayload = cleanObject({
71
+ model,
59
72
  prompt: options.prompt,
60
73
  duration: parseOptionalInteger(options.duration),
61
74
  aspect: ratio,
@@ -63,7 +76,20 @@ export function planVideoRequest(options) {
63
76
  resolution,
64
77
  quality: resolution,
65
78
  fps: parseOptionalInteger(options.fps),
66
- }));
79
+ images: !isSeedanceModel(model) && imageUrls.length > 0 ? imageUrls : undefined,
80
+ });
81
+ const seedanceContent = buildSeedanceContent(options);
82
+ if (isSeedanceModel(model) && seedanceContent.length > 0) {
83
+ return planJsonPost(options, "/v1/video/generations", cleanObject({
84
+ ...basePayload,
85
+ content: seedanceContent,
86
+ }));
87
+ }
88
+ return planJsonPost(options, "/v1/video/generations", basePayload);
89
+ }
90
+
91
+ export function planVideoStatusRequest(options, taskId) {
92
+ return planRequest(options, `/v1/videos/${encodeURIComponent(taskId)}`);
67
93
  }
68
94
 
69
95
  export function generateAudio(options) {
@@ -275,6 +301,47 @@ function validateOptionalValue(value, allowed, name) {
275
301
  throw new Error(`Invalid ${name}: ${value}. Allowed values: ${allowed.join(", ")}`);
276
302
  }
277
303
 
304
+ function isSeedanceModel(model) {
305
+ return /seedance/i.test(model);
306
+ }
307
+
308
+ function buildSeedanceContent(options) {
309
+ const content = [];
310
+ if (options.prompt !== undefined) {
311
+ content.push({ type: "text", text: options.prompt });
312
+ }
313
+ for (const url of arrayOption(options.image_url)) {
314
+ content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
315
+ }
316
+ for (const url of arrayOption(options.imageUrl)) {
317
+ content.push({ type: "image_url", image_url: { url }, role: "reference_image" });
318
+ }
319
+ for (const url of arrayOption(options.first_frame_url)) {
320
+ content.push({ type: "image_url", image_url: { url }, role: "first_frame" });
321
+ }
322
+ for (const url of arrayOption(options.firstFrameUrl)) {
323
+ content.push({ type: "image_url", image_url: { url }, role: "first_frame" });
324
+ }
325
+ for (const url of arrayOption(options.last_frame_url)) {
326
+ content.push({ type: "image_url", image_url: { url }, role: "last_frame" });
327
+ }
328
+ for (const url of arrayOption(options.lastFrameUrl)) {
329
+ content.push({ type: "image_url", image_url: { url }, role: "last_frame" });
330
+ }
331
+ for (const url of arrayOption(options.video_url)) {
332
+ content.push({ type: "video_url", video_url: { url }, role: "reference_video" });
333
+ }
334
+ for (const url of arrayOption(options.videoUrl)) {
335
+ content.push({ type: "video_url", video_url: { url }, role: "reference_video" });
336
+ }
337
+ return content;
338
+ }
339
+
340
+ function arrayOption(value) {
341
+ if (value === undefined) return [];
342
+ return Array.isArray(value) ? value : [value];
343
+ }
344
+
278
345
  function cleanObject(value) {
279
346
  return Object.fromEntries(
280
347
  Object.entries(value).filter(([, entry]) => entry !== undefined),
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,6 +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
20
  const COMMAND_OPTIONS = {
20
21
  "audio generate": new Set(["model", "prompt", "similarity_boost", "stability", "style", "voice_id"]),
21
22
  "audio music": new Set(["music_length_ms", "prompt"]),
@@ -35,7 +36,7 @@ const COMMAND_OPTIONS = {
35
36
  "text generate": new Set(["model", "prompt"]),
36
37
  version: new Set([]),
37
38
  video: new Set([]),
38
- "video generate": new Set(["aspect", "duration", "fps", "model", "prompt", "ratio", "resolution"]),
39
+ "video generate": new Set(["aspect", "duration", "first_frame_url", "fps", "image_url", "last_frame_url", "model", "prompt", "ratio", "resolution", "video_url"]),
39
40
  };
40
41
 
41
42
  export function parseArgv(argv) {
@@ -115,12 +116,21 @@ function parseOptions(tokens) {
115
116
  options[name] = true;
116
117
  continue;
117
118
  }
118
- options[name] = next;
119
+ if (REPEATABLE_OPTIONS.has(name)) {
120
+ options[name] = [...asArray(options[name]), next];
121
+ } else {
122
+ options[name] = next;
123
+ }
119
124
  index += 1;
120
125
  }
121
126
  return options;
122
127
  }
123
128
 
129
+ function asArray(value) {
130
+ if (value === undefined) return [];
131
+ return Array.isArray(value) ? value : [value];
132
+ }
133
+
124
134
  function validateCommandOptions(command) {
125
135
  const key = command.action ? `${command.group} ${command.action}` : command.group;
126
136
  const allowed = COMMAND_OPTIONS[key] ?? new Set();
@@ -215,15 +225,11 @@ export async function runCommand(command, deps = {}) {
215
225
  }
216
226
 
217
227
  async function handleLogin(command, deps) {
218
- const { ensureDeviceId, readConfig, resolveOrigins, writeAuthConfig } = await import("./config.js");
219
- const { createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
228
+ const { ensureDeviceId, readConfig, writeAuthConfig } = await import("./config.js");
229
+ const { DEFAULT_CONSOLE_URL, createDeviceAuthorization, pollDeviceAuthorization } = await import("./api.js");
220
230
  const deviceId = await ensureDeviceId({ configDir: deps.configDir });
221
231
  const version = await readPackageVersion();
222
- const { consoleOrigin } = await resolveOrigins({
223
- consoleUrl: command.options.console_url,
224
- env: deps.env ?? process.env,
225
- configDir: deps.configDir,
226
- });
232
+ const consoleOrigin = firstNonEmpty(command.options.console_url, DEFAULT_CONSOLE_URL);
227
233
  const authorization = await createDeviceAuthorization({
228
234
  consoleUrl: consoleOrigin,
229
235
  deviceId,
@@ -397,6 +403,7 @@ async function handleGenerate(command, deps) {
397
403
  generateImage,
398
404
  generateText,
399
405
  generateVideo,
406
+ getVideo,
400
407
  planAudioMusicRequest,
401
408
  planAudioRequest,
402
409
  planAudioSfxRequest,
@@ -422,6 +429,7 @@ async function handleGenerate(command, deps) {
422
429
  ...command.options,
423
430
  apiKey,
424
431
  baseUrl: routerOrigin,
432
+ env: deps.env ?? process.env,
425
433
  fetch: deps.fetch,
426
434
  };
427
435
  if (command.options.dry_run) {
@@ -455,6 +463,9 @@ async function handleGenerate(command, deps) {
455
463
  ? await generateAudioMusic(options)
456
464
  : await generateAudio(options)
457
465
  : await generateText(options);
466
+ const artifactResponse = command.group === "video"
467
+ ? await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep })
468
+ : response;
458
469
  if (command.group === "text") {
459
470
  const text = extractText(response);
460
471
  const output = await writeTextOutput(text, command.options.output);
@@ -462,17 +473,75 @@ async function handleGenerate(command, deps) {
462
473
  }
463
474
  const artifacts = await persistArtifacts({
464
475
  kind: command.group,
465
- response,
476
+ response: artifactResponse,
466
477
  outDir: command.options.out ?? "flatkey-output",
467
478
  output: command.options.output,
468
479
  fetch: deps.fetch,
469
480
  });
470
- return { kind: command.group, artifacts, response: scrubArtifactResponse(response) };
481
+ return { kind: command.group, artifacts, response: scrubArtifactResponse(artifactResponse) };
471
482
  } finally {
472
483
  animation.stop();
473
484
  }
474
485
  }
475
486
 
487
+ async function waitForVideoResult(response, options) {
488
+ if (hasArtifact(response)) return response;
489
+ const taskId = videoTaskId(response);
490
+ if (!taskId) return response;
491
+
492
+ const timeoutMs = parsePositiveInteger(
493
+ options.video_wait_timeout_ms ?? options.env?.FLATKEY_VIDEO_WAIT_TIMEOUT_MS,
494
+ 600_000,
495
+ );
496
+ const intervalMs = parsePositiveInteger(
497
+ options.video_poll_interval_ms ?? options.env?.FLATKEY_VIDEO_POLL_INTERVAL_MS,
498
+ 5_000,
499
+ );
500
+ const deadline = Date.now() + timeoutMs;
501
+ let last = response;
502
+
503
+ while (Date.now() <= deadline) {
504
+ last = await options.getVideo(options, taskId);
505
+ if (hasArtifact(last) || isVideoDone(last)) return last;
506
+ if (isVideoFailed(last)) {
507
+ const message = last?.error?.message ?? last?.message ?? `Video generation failed: ${taskId}`;
508
+ throw new Error(message);
509
+ }
510
+ await delay(intervalMs, { sleep: options.sleep });
511
+ }
512
+
513
+ throw new Error(`Video task still processing after ${Math.round(timeoutMs / 1000)}s: ${taskId}`);
514
+ }
515
+
516
+ function hasArtifact(response) {
517
+ return Boolean(
518
+ response?.metadata?.url
519
+ || response?.url
520
+ || response?.video_url?.url
521
+ || response?.data?.some?.((item) => item?.url || item?.b64_json || item?.base64 || item?.data)
522
+ || response?.artifacts?.some?.((item) => item?.url || item?.path)
523
+ || response?.content?.some?.((item) => item?.url || item?.video_url?.url || item?.data_url || item?.b64_json || item?.base64 || item?.data),
524
+ );
525
+ }
526
+
527
+ function videoTaskId(response) {
528
+ return firstNonEmpty(response?.id, response?.task_id, response?.taskId, response?.data?.id, response?.data?.task_id);
529
+ }
530
+
531
+ function isVideoDone(response) {
532
+ return ["completed", "succeeded", "success"].includes(String(response?.status ?? "").toLowerCase());
533
+ }
534
+
535
+ function isVideoFailed(response) {
536
+ return ["failed", "failure", "cancelled", "canceled"].includes(String(response?.status ?? "").toLowerCase())
537
+ || Boolean(response?.error);
538
+ }
539
+
540
+ function parsePositiveInteger(value, fallback) {
541
+ const parsed = Number.parseInt(value, 10);
542
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
543
+ }
544
+
476
545
  function scrubArtifactResponse(value) {
477
546
  if (Array.isArray(value)) return value.map(scrubArtifactResponse);
478
547
  if (!value || typeof value !== "object") {
package/src/help.js CHANGED
@@ -208,20 +208,28 @@ Options:
208
208
  video: `Usage: flatkey video generate --prompt <txt> [options]
209
209
 
210
210
  Options:
211
- --model <model> Model id, default veo-3
211
+ --model <model> Model id, default seedance-2.0-pro
212
212
  --duration <seconds> Video duration
213
213
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
214
214
  --resolution <value> 480p, 720p, or 1080p
215
+ --image-url <url> Add reference image URL, repeatable
216
+ --video-url <url> Add reference video URL, repeatable
217
+ --first-frame-url <url> Add first frame image URL
218
+ --last-frame-url <url> Add last frame image URL
215
219
  --fps <fps> Frames per second
216
220
  --output, -o <file> Write video file
217
221
  --json Print machine-readable JSON`,
218
222
  "video generate": `Usage: flatkey video generate --prompt <txt> [options]
219
223
 
220
224
  Options:
221
- --model <model> Model id, default veo-3
225
+ --model <model> Model id, default seedance-2.0-pro
222
226
  --duration <seconds> Video duration
223
227
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
224
228
  --resolution <value> 480p, 720p, or 1080p
229
+ --image-url <url> Add reference image URL, repeatable
230
+ --video-url <url> Add reference video URL, repeatable
231
+ --first-frame-url <url> Add first frame image URL
232
+ --last-frame-url <url> Add last frame image URL
225
233
  --fps <fps> Frames per second
226
234
  --output, -o <file> Write video file
227
235
  --json Print machine-readable JSON`,