@gobi-ai/cli 0.9.8 → 0.9.10

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.
@@ -4,12 +4,12 @@
4
4
  "name": "gobi-ai"
5
5
  },
6
6
  "description": "Claude Code plugin for the Gobi collaborative knowledge platform CLI",
7
- "version": "0.9.8",
7
+ "version": "0.9.10",
8
8
  "plugins": [
9
9
  {
10
10
  "name": "gobi",
11
11
  "description": "Manage the Gobi collaborative knowledge platform from the command line. Search and ask brains, publish brain documents, create threads, manage sessions, generate images and videos.",
12
- "version": "0.9.8",
12
+ "version": "0.9.10",
13
13
  "author": {
14
14
  "name": "gobi-ai"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gobi",
3
3
  "description": "Manage the Gobi collaborative knowledge platform from the command line",
4
- "version": "0.9.8",
4
+ "version": "0.9.10",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
@@ -19,6 +19,89 @@ async function pollStatus(path, terminalStates, intervalMs = 3000) {
19
19
  }
20
20
  throw new Error(`Polling timed out after ${POLL_MAX_DURATION_MS / 1000}s`);
21
21
  }
22
+ /**
23
+ * Download a video binary from the media-gen download endpoint.
24
+ * Handles three cases:
25
+ * 1. Direct binary response (redirect: "follow" returns the file)
26
+ * 2. JSON response with downloadUrl (need to fetch that URL)
27
+ * 3. Redirect (302) with Location header
28
+ */
29
+ async function downloadVideoToFile(videoId, outputPath) {
30
+ const { writeFile, mkdir } = await import("fs/promises");
31
+ const { dirname } = await import("path");
32
+ const token = await getValidToken();
33
+ const dlUrl = `${BASE_URL}/media-gen/videos/${videoId}/download`;
34
+ // Try following redirects first
35
+ const res = await fetch(dlUrl, {
36
+ headers: { Authorization: `Bearer ${token}` },
37
+ redirect: "follow",
38
+ });
39
+ if (!res.ok) {
40
+ throw new ApiError(res.status, `/media-gen/videos/${videoId}/download`, await res.text());
41
+ }
42
+ const ct = res.headers.get("content-type") || "";
43
+ // If the response is JSON, extract downloadUrl and fetch the actual binary
44
+ if (ct.includes("application/json")) {
45
+ const json = (await res.json());
46
+ const inner = (json.data || json);
47
+ const url = (inner.downloadUrl || inner.download_url || inner.url);
48
+ if (!url)
49
+ throw new Error("Download endpoint returned JSON without a downloadUrl");
50
+ const videoRes = await fetch(url);
51
+ if (!videoRes.ok)
52
+ throw new Error(`Failed to fetch video from ${url}: ${videoRes.status}`);
53
+ const buffer = Buffer.from(await videoRes.arrayBuffer());
54
+ await mkdir(dirname(outputPath), { recursive: true });
55
+ await writeFile(outputPath, buffer);
56
+ return { contentType: videoRes.headers.get("content-type") || "video/mp4", size: buffer.length };
57
+ }
58
+ // Direct binary response
59
+ const buffer = Buffer.from(await res.arrayBuffer());
60
+ await mkdir(dirname(outputPath), { recursive: true });
61
+ await writeFile(outputPath, buffer);
62
+ return { contentType: ct || "video/mp4", size: buffer.length };
63
+ }
64
+ const MIME_MAP = {
65
+ ".png": "image/png",
66
+ ".jpg": "image/jpeg",
67
+ ".jpeg": "image/jpeg",
68
+ ".webp": "image/webp",
69
+ ".gif": "image/gif",
70
+ ".mp4": "video/mp4",
71
+ ".mov": "video/quicktime",
72
+ ".mp3": "audio/mpeg",
73
+ ".wav": "audio/wav",
74
+ };
75
+ /**
76
+ * Upload a local file and return its media ID.
77
+ * Handles init → PUT → finalize in one call.
78
+ */
79
+ async function uploadFile(filePath) {
80
+ const { readFile, stat } = await import("fs/promises");
81
+ const { basename, extname } = await import("path");
82
+ const buffer = await readFile(filePath);
83
+ const fileName = basename(filePath);
84
+ const fileSize = (await stat(filePath)).size;
85
+ const ext = extname(filePath).toLowerCase();
86
+ const contentType = MIME_MAP[ext] || "application/octet-stream";
87
+ const initResp = (await apiPost("/media-gen/media/initialize", {
88
+ fileName, contentType, fileSize,
89
+ }));
90
+ const initData = unwrapResp(initResp);
91
+ const mediaId = initData.mediaId;
92
+ const uploadUrl = initData.uploadUrl;
93
+ if (!mediaId || !uploadUrl)
94
+ throw new Error("Upload init failed: missing mediaId or uploadUrl");
95
+ const putRes = await fetch(uploadUrl, {
96
+ method: "PUT",
97
+ headers: { "Content-Type": contentType },
98
+ body: buffer,
99
+ });
100
+ if (!putRes.ok)
101
+ throw new Error(`Upload PUT failed: ${putRes.status}`);
102
+ await apiPost("/media-gen/media/finalize", { mediaId });
103
+ return mediaId;
104
+ }
22
105
  function extractImageUrl(data) {
23
106
  return (data.downloadUrl || data.download_url || data.url);
24
107
  }
@@ -30,44 +113,15 @@ export function registerMediaCommand(program) {
30
113
  // Upload
31
114
  // ════════════════════════════════════════════════════════════════════
32
115
  media
33
- .command("upload-init")
34
- .description("Get a presigned upload URL for a media file.")
35
- .requiredOption("--file-name <fileName>", "Name of the file to upload")
36
- .requiredOption("--content-type <contentType>", "MIME type (e.g. image/png, video/mp4)")
37
- .option("--file-size <fileSize>", "File size in bytes")
38
- .action(async (opts) => {
39
- const body = {
40
- fileName: opts.fileName,
41
- contentType: opts.contentType,
42
- };
43
- if (opts.fileSize)
44
- body.fileSize = parseInt(opts.fileSize, 10);
45
- const resp = (await apiPost("/media-gen/media/initialize", body));
46
- const data = unwrapResp(resp);
116
+ .command("upload <file>")
117
+ .description("Upload a local file and return its media ID.")
118
+ .action(async (file) => {
119
+ const mediaId = await uploadFile(file);
47
120
  if (isJsonMode(media)) {
48
- jsonOut(data);
121
+ jsonOut({ mediaId });
49
122
  return;
50
123
  }
51
- console.log(`Upload initialized!\n` +
52
- ` Media ID: ${data.mediaId}\n` +
53
- ` Upload URL: ${data.uploadUrl}\n\n` +
54
- `PUT your file to the upload URL, then run:\n` +
55
- ` gobi media upload-finalize --media-id ${data.mediaId}`);
56
- });
57
- media
58
- .command("upload-finalize")
59
- .description("Confirm that a media upload is complete.")
60
- .requiredOption("--media-id <mediaId>", "Media ID from upload-init")
61
- .action(async (opts) => {
62
- const resp = (await apiPost("/media-gen/media/finalize", {
63
- mediaId: opts.mediaId,
64
- }));
65
- const data = unwrapResp(resp);
66
- if (isJsonMode(media)) {
67
- jsonOut(data);
68
- return;
69
- }
70
- console.log(`Upload finalized for media ${opts.mediaId}.`);
124
+ console.log(`Uploaded → Media ID: ${mediaId}`);
71
125
  });
72
126
  // ════════════════════════════════════════════════════════════════════
73
127
  // Avatars & Voices
@@ -120,7 +174,7 @@ export function registerMediaCommand(program) {
120
174
  .requiredOption("--avatar-id <avatarId>", "Avatar to use")
121
175
  .requiredOption("--voice-id <voiceId>", "Voice to use")
122
176
  .requiredOption("--script <script>", "Script for the avatar to read")
123
- .option("--background-media-id <backgroundMediaId>", "Background media ID (from upload)")
177
+ .option("--background <file>", "Background image file (auto-uploaded)")
124
178
  .option("--wait", "Poll until generation completes")
125
179
  .option("-o, --output <path>", "Download video to this path when done (implies --wait)")
126
180
  .action(async (opts) => {
@@ -132,60 +186,26 @@ export function registerMediaCommand(program) {
132
186
  voiceId: opts.voiceId,
133
187
  script: opts.script,
134
188
  };
135
- if (opts.backgroundMediaId)
136
- body.backgroundMediaId = opts.backgroundMediaId;
189
+ if (opts.background)
190
+ body.backgroundMediaId = await uploadFile(opts.background);
137
191
  const resp = (await apiPost("/media-gen/videos", body));
138
192
  let data = unwrapResp(resp);
139
- const videoId = data.id || data.videoId;
193
+ const videoId = data.id || data.videoId || data.jobId;
140
194
  if (shouldWait && videoId) {
141
195
  console.log(`Video ${videoId} queued — polling for completion…`);
142
196
  data = await pollStatus(`/media-gen/videos/${videoId}/status`, ["inference_complete", "inference_failed"]);
143
197
  }
198
+ // After polling, the status response may contain the real videoId for download
199
+ const downloadId = data.videoId || data.id || videoId;
144
200
  // Download video to file if -o specified
145
- if (opts.output && videoId && data.status === "inference_complete") {
146
- const token = await getValidToken();
147
- const dlUrl = `${BASE_URL}/media-gen/videos/${videoId}/download`;
148
- const dlRes = await fetch(dlUrl, {
149
- headers: { Authorization: `Bearer ${token}` },
150
- redirect: "follow",
151
- });
152
- if (dlRes.ok) {
153
- const { writeFile, mkdir } = await import("fs/promises");
154
- const { dirname } = await import("path");
155
- const buffer = Buffer.from(await dlRes.arrayBuffer());
156
- await mkdir(dirname(opts.output), { recursive: true });
157
- await writeFile(opts.output, buffer);
158
- const contentType = dlRes.headers.get("content-type") || "video/mp4";
159
- if (isJsonMode(media)) {
160
- jsonOut({ ...data, filename: opts.output, contentType, size: buffer.length });
161
- return;
162
- }
163
- console.log(`Video saved to ${opts.output} (${buffer.length} bytes)`);
201
+ if (opts.output && downloadId && data.status === "inference_complete") {
202
+ const { contentType, size } = await downloadVideoToFile(downloadId, opts.output);
203
+ if (isJsonMode(media)) {
204
+ jsonOut({ ...data, filename: opts.output, contentType, size });
164
205
  return;
165
206
  }
166
- // If direct download fails, try getting the URL and fetching that
167
- const dlRes2 = await fetch(dlUrl, {
168
- headers: { Authorization: `Bearer ${token}` },
169
- redirect: "manual",
170
- });
171
- const location = dlRes2.headers.get("location");
172
- if (location) {
173
- const videoRes = await fetch(location);
174
- if (videoRes.ok) {
175
- const { writeFile, mkdir } = await import("fs/promises");
176
- const { dirname } = await import("path");
177
- const buffer = Buffer.from(await videoRes.arrayBuffer());
178
- await mkdir(dirname(opts.output), { recursive: true });
179
- await writeFile(opts.output, buffer);
180
- const contentType = videoRes.headers.get("content-type") || "video/mp4";
181
- if (isJsonMode(media)) {
182
- jsonOut({ ...data, filename: opts.output, contentType, size: buffer.length });
183
- return;
184
- }
185
- console.log(`Video saved to ${opts.output} (${buffer.length} bytes)`);
186
- return;
187
- }
188
- }
207
+ console.log(`Video saved to ${opts.output} (${size} bytes)`);
208
+ return;
189
209
  }
190
210
  if (isJsonMode(media)) {
191
211
  jsonOut(data);
@@ -193,10 +213,10 @@ export function registerMediaCommand(program) {
193
213
  }
194
214
  const status = data.status || "queued";
195
215
  console.log(`Video created!\n` +
196
- ` ID: ${videoId}\n` +
216
+ ` ID: ${downloadId}\n` +
197
217
  ` Status: ${status}`);
198
218
  if (status === "inference_complete") {
199
- console.log(` Download: gobi media video-download ${videoId}`);
219
+ console.log(` Download: gobi media video-download ${downloadId}`);
200
220
  }
201
221
  });
202
222
  media
@@ -244,49 +264,14 @@ export function registerMediaCommand(program) {
244
264
  const data = await pollStatus(`/media-gen/videos/${id}/status`, ["inference_complete", "inference_failed"]);
245
265
  // Download if -o specified and completed
246
266
  if (opts.output && data.status === "inference_complete") {
247
- const token = await getValidToken();
248
- const dlUrl = `${BASE_URL}/media-gen/videos/${id}/download`;
249
- const dlRes = await fetch(dlUrl, {
250
- headers: { Authorization: `Bearer ${token}` },
251
- redirect: "follow",
252
- });
253
- if (dlRes.ok) {
254
- const { writeFile, mkdir } = await import("fs/promises");
255
- const { dirname } = await import("path");
256
- const buffer = Buffer.from(await dlRes.arrayBuffer());
257
- await mkdir(dirname(opts.output), { recursive: true });
258
- await writeFile(opts.output, buffer);
259
- const contentType = dlRes.headers.get("content-type") || "video/mp4";
260
- if (isJsonMode(media)) {
261
- jsonOut({ ...data, filename: opts.output, contentType, size: buffer.length });
262
- return;
263
- }
264
- console.log(`Video ${id} — ${data.status}\nSaved to ${opts.output} (${buffer.length} bytes)`);
267
+ const dlId = (data.videoId || data.id || id);
268
+ const { contentType, size } = await downloadVideoToFile(dlId, opts.output);
269
+ if (isJsonMode(media)) {
270
+ jsonOut({ ...data, filename: opts.output, contentType, size });
265
271
  return;
266
272
  }
267
- // Try manual redirect
268
- const dlRes2 = await fetch(dlUrl, {
269
- headers: { Authorization: `Bearer ${token}` },
270
- redirect: "manual",
271
- });
272
- const location = dlRes2.headers.get("location");
273
- if (location) {
274
- const videoRes = await fetch(location);
275
- if (videoRes.ok) {
276
- const { writeFile, mkdir } = await import("fs/promises");
277
- const { dirname } = await import("path");
278
- const buffer = Buffer.from(await videoRes.arrayBuffer());
279
- await mkdir(dirname(opts.output), { recursive: true });
280
- await writeFile(opts.output, buffer);
281
- const contentType = videoRes.headers.get("content-type") || "video/mp4";
282
- if (isJsonMode(media)) {
283
- jsonOut({ ...data, filename: opts.output, contentType, size: buffer.length });
284
- return;
285
- }
286
- console.log(`Video ${id} — ${data.status}\nSaved to ${opts.output} (${buffer.length} bytes)`);
287
- return;
288
- }
289
- }
273
+ console.log(`Video ${id} ${data.status}\nSaved to ${opts.output} (${size} bytes)`);
274
+ return;
290
275
  }
291
276
  if (isJsonMode(media)) {
292
277
  jsonOut(data);
@@ -312,48 +297,13 @@ export function registerMediaCommand(program) {
312
297
  const url = `${BASE_URL}/media-gen/videos/${id}/download`;
313
298
  // If -o specified, download directly to file
314
299
  if (opts.output) {
315
- const res = await fetch(url, {
316
- headers: { Authorization: `Bearer ${token}` },
317
- redirect: "follow",
318
- });
319
- if (res.ok) {
320
- const { writeFile, mkdir } = await import("fs/promises");
321
- const { dirname } = await import("path");
322
- const buffer = Buffer.from(await res.arrayBuffer());
323
- await mkdir(dirname(opts.output), { recursive: true });
324
- await writeFile(opts.output, buffer);
325
- const contentType = res.headers.get("content-type") || "video/mp4";
326
- if (isJsonMode(media)) {
327
- jsonOut({ filename: opts.output, contentType, size: buffer.length });
328
- return;
329
- }
330
- console.log(`Video saved to ${opts.output} (${buffer.length} bytes)`);
300
+ const { contentType, size } = await downloadVideoToFile(id, opts.output);
301
+ if (isJsonMode(media)) {
302
+ jsonOut({ filename: opts.output, contentType, size });
331
303
  return;
332
304
  }
333
- // If direct follow didn't work, try manual redirect
334
- const res2 = await fetch(url, {
335
- headers: { Authorization: `Bearer ${token}` },
336
- redirect: "manual",
337
- });
338
- const location = res2.headers.get("location");
339
- if (location) {
340
- const videoRes = await fetch(location);
341
- if (videoRes.ok) {
342
- const { writeFile, mkdir } = await import("fs/promises");
343
- const { dirname } = await import("path");
344
- const buffer = Buffer.from(await videoRes.arrayBuffer());
345
- await mkdir(dirname(opts.output), { recursive: true });
346
- await writeFile(opts.output, buffer);
347
- const contentType = videoRes.headers.get("content-type") || "video/mp4";
348
- if (isJsonMode(media)) {
349
- jsonOut({ filename: opts.output, contentType, size: buffer.length });
350
- return;
351
- }
352
- console.log(`Video saved to ${opts.output} (${buffer.length} bytes)`);
353
- return;
354
- }
355
- }
356
- throw new ApiError(res.status, `/media-gen/videos/${id}/download`, "Failed to download video");
305
+ console.log(`Video saved to ${opts.output} (${size} bytes)`);
306
+ return;
357
307
  }
358
308
  // No -o: just return the URL (existing behavior)
359
309
  const res = await fetch(url, {
@@ -384,6 +334,223 @@ export function registerMediaCommand(program) {
384
334
  console.log(`Download URL for video ${id}:\n ${data.url || data.downloadUrl || JSON.stringify(data)}`);
385
335
  });
386
336
  // ════════════════════════════════════════════════════════════════════
337
+ // Cinematic Video
338
+ // ════════════════════════════════════════════════════════════════════
339
+ media
340
+ .command("cinematic-create")
341
+ .description("Create a cinematic video from a text prompt.")
342
+ .requiredOption("--prompt <prompt>", "Text prompt describing the video")
343
+ .option("--name <name>", "Name for the video (auto-generated if omitted)")
344
+ .option("--aspect-ratio <aspectRatio>", "Aspect ratio: 16:9, 9:16, 1:1")
345
+ .option("--duration <seconds>", "Duration in seconds (4-8)")
346
+ .option("--resolution <resolution>", "Resolution: 720p, 1080p")
347
+ .option("--enhance-prompt", "Enhance the prompt with AI")
348
+ .option("--generate-audio", "Generate audio for the video")
349
+ .option("--negative-prompt <negativePrompt>", "Negative prompt")
350
+ .option("--sample-count <count>", "Number of samples (1-4)")
351
+ .option("--first-frame <file>", "First frame image file (auto-uploaded)")
352
+ .option("--last-frame <file>", "Last frame image file (auto-uploaded)")
353
+ .option("--reference-images <files>", "Comma-separated reference image files (auto-uploaded, max 3)")
354
+ .option("--wait", "Poll until generation completes")
355
+ .option("-o, --output <path>", "Download video to this path when done (implies --wait)")
356
+ .action(async (opts) => {
357
+ const shouldWait = opts.wait || !!opts.output;
358
+ const autoName = opts.name || `cinematic-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
359
+ const body = {
360
+ name: autoName,
361
+ prompt: opts.prompt,
362
+ };
363
+ if (opts.aspectRatio)
364
+ body.aspectRatio = opts.aspectRatio;
365
+ if (opts.duration) {
366
+ const v = parseInt(opts.duration, 10);
367
+ if (Number.isNaN(v))
368
+ throw new Error("--duration must be a number");
369
+ body.durationSeconds = v;
370
+ }
371
+ if (opts.resolution)
372
+ body.resolution = opts.resolution;
373
+ if (opts.enhancePrompt)
374
+ body.enhancePrompt = true;
375
+ if (opts.generateAudio)
376
+ body.generateAudio = true;
377
+ if (opts.negativePrompt)
378
+ body.negativePrompt = opts.negativePrompt;
379
+ if (opts.sampleCount) {
380
+ const v = parseInt(opts.sampleCount, 10);
381
+ if (Number.isNaN(v))
382
+ throw new Error("--sample-count must be a number");
383
+ body.sampleCount = v;
384
+ }
385
+ if (opts.firstFrame)
386
+ body.firstFrameImageMediaId = await uploadFile(opts.firstFrame);
387
+ if (opts.lastFrame)
388
+ body.lastFrameImageMediaId = await uploadFile(opts.lastFrame);
389
+ if (opts.referenceImages) {
390
+ const files = opts.referenceImages.split(",").map((s) => s.trim());
391
+ body.referenceImageMediaIds = await Promise.all(files.map((f) => uploadFile(f)));
392
+ }
393
+ const resp = (await apiPost("/media-gen/videos/cinematic", body));
394
+ let data = unwrapResp(resp);
395
+ const videoId = data.id || data.videoId || data.jobId;
396
+ if (shouldWait && videoId) {
397
+ console.log(`Cinematic video ${videoId} queued — polling for completion…`);
398
+ data = await pollStatus(`/media-gen/videos/${videoId}/status`, ["inference_complete", "inference_failed"]);
399
+ }
400
+ // After polling, the status response may contain the real videoId for download
401
+ const downloadId = data.videoId || data.id || videoId;
402
+ // Download video to file if -o specified
403
+ if (opts.output && downloadId && data.status === "inference_complete") {
404
+ const { contentType, size } = await downloadVideoToFile(downloadId, opts.output);
405
+ if (isJsonMode(media)) {
406
+ jsonOut({ ...data, filename: opts.output, contentType, size });
407
+ return;
408
+ }
409
+ console.log(`Cinematic video saved to ${opts.output} (${size} bytes)`);
410
+ return;
411
+ }
412
+ if (isJsonMode(media)) {
413
+ jsonOut(data);
414
+ return;
415
+ }
416
+ const status = data.status || "queued";
417
+ console.log(`Cinematic video created!\n` +
418
+ ` ID: ${downloadId}\n` +
419
+ ` Status: ${status}`);
420
+ if (status === "inference_complete") {
421
+ console.log(` Download: gobi media video-download ${downloadId}`);
422
+ }
423
+ });
424
+ // ════════════════════════════════════════════════════════════════════
425
+ // Custom Avatars
426
+ // ════════════════════════════════════════════════════════════════════
427
+ media
428
+ .command("avatar-design")
429
+ .description("Start a design-your-avatar job.")
430
+ .option("--name <name>", "Name for the avatar (auto-generated if omitted)")
431
+ .requiredOption("--gender <gender>", "Gender for the avatar design")
432
+ .requiredOption("--age <age>", "Age range for the avatar")
433
+ .requiredOption("--ethnicity <ethnicity>", "Ethnicity for the avatar")
434
+ .requiredOption("--outfit <outfit>", "Outfit description")
435
+ .requiredOption("--background <background>", "Background description")
436
+ .option("--no-portrait", "Generate full-body instead of portrait")
437
+ .option("--audio <file>", "Custom voice audio file (auto-uploaded)")
438
+ .option("--wait", "Poll until variants are ready")
439
+ .action(async (opts) => {
440
+ const body = {
441
+ name: opts.name || `avatar-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`,
442
+ gender: opts.gender,
443
+ age: opts.age,
444
+ ethnicity: opts.ethnicity,
445
+ outfit: opts.outfit,
446
+ background: opts.background,
447
+ isPortrait: opts.portrait,
448
+ };
449
+ if (opts.audio)
450
+ body.audioMediaId = await uploadFile(opts.audio);
451
+ const resp = (await apiPost("/media-gen/avatars/design", body));
452
+ let data = unwrapResp(resp);
453
+ const jobId = data.jobId || data.id;
454
+ if (opts.wait && jobId) {
455
+ console.log(`Avatar design job ${jobId} — polling for completion…`);
456
+ data = await pollStatus(`/media-gen/avatars/jobs/${jobId}/status`, ["variants_ready", "complete", "failed"]);
457
+ }
458
+ if (isJsonMode(media)) {
459
+ jsonOut(data);
460
+ return;
461
+ }
462
+ const status = data.status || "queued";
463
+ console.log(`Avatar design started!\n` +
464
+ ` Job ID: ${jobId}\n` +
465
+ ` Status: ${status}`);
466
+ if (status === "variants_ready") {
467
+ console.log(` Confirm: gobi media avatar-confirm --job-id ${jobId}`);
468
+ }
469
+ });
470
+ media
471
+ .command("avatar-confirm")
472
+ .description("Confirm avatar variant(s) after design.")
473
+ .requiredOption("--job-id <jobId>", "Job ID from avatar-design")
474
+ .option("--variant <variant>", "Variant to confirm (1 or 2); omit to confirm both")
475
+ .action(async (opts) => {
476
+ const body = { jobId: opts.jobId };
477
+ if (opts.variant) {
478
+ const v = parseInt(opts.variant, 10);
479
+ if (Number.isNaN(v))
480
+ throw new Error("--variant must be a number (1 or 2)");
481
+ body.variant = v;
482
+ }
483
+ const resp = (await apiPost("/media-gen/avatars/confirm", body));
484
+ const data = unwrapResp(resp);
485
+ if (isJsonMode(media)) {
486
+ jsonOut(data);
487
+ return;
488
+ }
489
+ const avatarId = data.avatarId || data.id;
490
+ console.log(`Avatar confirmed!\n` +
491
+ ` Avatar ID: ${avatarId || JSON.stringify(data)}`);
492
+ });
493
+ media
494
+ .command("avatar-from-selfie")
495
+ .description("Create an avatar from a selfie (instant or enhanced with prompt).")
496
+ .option("--name <name>", "Name for the avatar (auto-generated if omitted)")
497
+ .requiredOption("--photo <file>", "Selfie photo file (auto-uploaded)")
498
+ .option("--prompt <prompt>", "Enhancement prompt (triggers async enhance flow)")
499
+ .option("--audio <file>", "Custom voice audio file (auto-uploaded)")
500
+ .option("--wait", "Poll until job completes (only for enhance flow)")
501
+ .action(async (opts) => {
502
+ const body = {
503
+ name: opts.name || `avatar-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`,
504
+ photoMediaId: await uploadFile(opts.photo),
505
+ };
506
+ if (opts.prompt)
507
+ body.prompt = opts.prompt;
508
+ if (opts.audio)
509
+ body.audioMediaId = await uploadFile(opts.audio);
510
+ const resp = (await apiPost("/media-gen/avatars/from-selfie", body));
511
+ let data = unwrapResp(resp);
512
+ const jobId = data.jobId || data.id;
513
+ // Enhance flow is async — poll if --wait
514
+ if (opts.wait && opts.prompt && jobId) {
515
+ console.log(`Avatar enhance job ${jobId} — polling for completion…`);
516
+ data = await pollStatus(`/media-gen/avatars/jobs/${jobId}/status`, ["variants_ready", "complete", "failed"]);
517
+ }
518
+ if (isJsonMode(media)) {
519
+ jsonOut(data);
520
+ return;
521
+ }
522
+ if (opts.prompt) {
523
+ const status = data.status || "queued";
524
+ console.log(`Avatar enhance started!\n` +
525
+ ` Job ID: ${jobId}\n` +
526
+ ` Status: ${status}`);
527
+ }
528
+ else {
529
+ const avatarId = data.avatarId || data.id;
530
+ console.log(`Avatar created from selfie!\n` +
531
+ ` Avatar ID: ${avatarId || JSON.stringify(data)}`);
532
+ }
533
+ });
534
+ media
535
+ .command("avatar-job-status <jobId>")
536
+ .description("Check avatar job status.")
537
+ .option("--wait", "Poll until a terminal state is reached")
538
+ .action(async (jobId, opts) => {
539
+ let data;
540
+ if (opts.wait) {
541
+ data = await pollStatus(`/media-gen/avatars/jobs/${jobId}/status`, ["variants_ready", "complete", "failed"]);
542
+ }
543
+ else {
544
+ const resp = (await apiGet(`/media-gen/avatars/jobs/${jobId}/status`));
545
+ data = unwrapResp(resp);
546
+ }
547
+ if (isJsonMode(media)) {
548
+ jsonOut(data);
549
+ return;
550
+ }
551
+ console.log(`Avatar job ${jobId} — status: ${data.status || "unknown"}`);
552
+ });
553
+ // ════════════════════════════════════════════════════════════════════
387
554
  // Images
388
555
  // ════════════════════════════════════════════════════════════════════
389
556
  media
@@ -395,7 +562,7 @@ export function registerMediaCommand(program) {
395
562
  .option("--aspect-ratio <aspectRatio>", "Aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4)")
396
563
  .option("--negative-prompt <negativePrompt>", "Negative prompt")
397
564
  .option("--seed <seed>", "Random seed for reproducibility")
398
- .option("--reference-media-id <referenceMediaId>", "Reference image media ID")
565
+ .option("--reference-image <file>", "Reference image file (auto-uploaded)")
399
566
  .option("--wait", "Poll until generation completes")
400
567
  .option("-o, --output <path>", "Download image to this path when done (implies --wait)")
401
568
  .action(async (opts) => {
@@ -413,8 +580,8 @@ export function registerMediaCommand(program) {
413
580
  body.negativePrompt = opts.negativePrompt;
414
581
  if (opts.seed)
415
582
  body.seed = parseInt(opts.seed, 10);
416
- if (opts.referenceMediaId)
417
- body.referenceMediaId = opts.referenceMediaId;
583
+ if (opts.referenceImage)
584
+ body.referenceMediaId = await uploadFile(opts.referenceImage);
418
585
  const resp = (await apiPost("/media-gen/images/generate", body));
419
586
  let data = unwrapResp(resp);
420
587
  const jobId = data.jobId || data.id;
@@ -471,19 +638,23 @@ export function registerMediaCommand(program) {
471
638
  media
472
639
  .command("image-edit")
473
640
  .description("Edit an existing image with a prompt (image-to-image).")
474
- .requiredOption("--media-id <mediaId>", "Source image media ID")
641
+ .requiredOption("--image <file>", "Source image file (auto-uploaded)")
475
642
  .requiredOption("--prompt <prompt>", "Edit instruction")
476
- .requiredOption("--name <name>", "Name for the edited image")
643
+ .option("--name <name>", "Name for the edited image (auto-generated if omitted)")
477
644
  .option("--wait", "Poll until generation completes")
645
+ .option("-o, --output <path>", "Download image to this path when done (implies --wait)")
478
646
  .action(async (opts) => {
647
+ const shouldWait = opts.wait || !!opts.output;
648
+ const mediaId = await uploadFile(opts.image);
649
+ const autoName = opts.name || opts.prompt.slice(0, 50).replace(/[^a-zA-Z0-9-_ ]/g, "").trim().replace(/\s+/g, "-");
479
650
  const resp = (await apiPost("/media-gen/images/edit", {
480
- mediaId: opts.mediaId,
651
+ mediaId,
481
652
  prompt: opts.prompt,
482
- name: opts.name,
653
+ name: autoName,
483
654
  }));
484
655
  let data = unwrapResp(resp);
485
656
  const jobId = data.jobId || data.id;
486
- if (opts.wait && jobId) {
657
+ if (shouldWait && jobId) {
487
658
  console.log(`Image edit job ${jobId} — polling for completion…`);
488
659
  data = await pollStatus(`/media-gen/images/${jobId}`, [
489
660
  "completed",
@@ -492,6 +663,29 @@ export function registerMediaCommand(program) {
492
663
  "inference_failed",
493
664
  ]);
494
665
  }
666
+ // Download image to file if -o specified
667
+ if (opts.output && jobId) {
668
+ const token = await getValidToken();
669
+ const query = "";
670
+ const url = `${BASE_URL}/media-gen/images/${jobId}/download${query}`;
671
+ const res = await fetch(url, {
672
+ headers: { Authorization: `Bearer ${token}` },
673
+ });
674
+ if (res.ok) {
675
+ const { writeFile, mkdir } = await import("fs/promises");
676
+ const { dirname } = await import("path");
677
+ const buffer = Buffer.from(await res.arrayBuffer());
678
+ await mkdir(dirname(opts.output), { recursive: true });
679
+ await writeFile(opts.output, buffer);
680
+ const contentType = res.headers.get("content-type") || "image/png";
681
+ if (isJsonMode(media)) {
682
+ jsonOut({ ...data, filename: opts.output, contentType, size: buffer.length });
683
+ return;
684
+ }
685
+ console.log(`Image saved to ${opts.output} (${buffer.length} bytes)`);
686
+ return;
687
+ }
688
+ }
495
689
  if (isJsonMode(media)) {
496
690
  jsonOut(data);
497
691
  return;
@@ -507,21 +701,28 @@ export function registerMediaCommand(program) {
507
701
  media
508
702
  .command("image-inpaint")
509
703
  .description("Inpaint an image region using a mask.")
510
- .requiredOption("--media-id <mediaId>", "Source image media ID")
511
- .requiredOption("--mask-media-id <maskMediaId>", "Mask image media ID")
704
+ .requiredOption("--image <file>", "Source image file (auto-uploaded)")
705
+ .requiredOption("--mask <file>", "Mask image file (auto-uploaded)")
512
706
  .requiredOption("--prompt <prompt>", "Inpainting prompt")
513
- .requiredOption("--name <name>", "Name for the inpainted image")
707
+ .option("--name <name>", "Name for the inpainted image (auto-generated if omitted)")
514
708
  .option("--wait", "Poll until generation completes")
709
+ .option("-o, --output <path>", "Download image to this path when done (implies --wait)")
515
710
  .action(async (opts) => {
711
+ const shouldWait = opts.wait || !!opts.output;
712
+ const [mediaId, maskMediaId] = await Promise.all([
713
+ uploadFile(opts.image),
714
+ uploadFile(opts.mask),
715
+ ]);
716
+ const autoName = opts.name || opts.prompt.slice(0, 50).replace(/[^a-zA-Z0-9-_ ]/g, "").trim().replace(/\s+/g, "-");
516
717
  const resp = (await apiPost("/media-gen/images/inpaint", {
517
- mediaId: opts.mediaId,
518
- maskMediaId: opts.maskMediaId,
718
+ mediaId,
719
+ maskMediaId,
519
720
  prompt: opts.prompt,
520
- name: opts.name,
721
+ name: autoName,
521
722
  }));
522
723
  let data = unwrapResp(resp);
523
724
  const jobId = data.jobId || data.id;
524
- if (opts.wait && jobId) {
725
+ if (shouldWait && jobId) {
525
726
  console.log(`Inpaint job ${jobId} — polling for completion…`);
526
727
  data = await pollStatus(`/media-gen/images/${jobId}`, [
527
728
  "completed",
@@ -530,6 +731,28 @@ export function registerMediaCommand(program) {
530
731
  "inference_failed",
531
732
  ]);
532
733
  }
734
+ // Download image to file if -o specified
735
+ if (opts.output && jobId) {
736
+ const token = await getValidToken();
737
+ const url = `${BASE_URL}/media-gen/images/${jobId}/download`;
738
+ const res = await fetch(url, {
739
+ headers: { Authorization: `Bearer ${token}` },
740
+ });
741
+ if (res.ok) {
742
+ const { writeFile, mkdir } = await import("fs/promises");
743
+ const { dirname } = await import("path");
744
+ const buffer = Buffer.from(await res.arrayBuffer());
745
+ await mkdir(dirname(opts.output), { recursive: true });
746
+ await writeFile(opts.output, buffer);
747
+ const contentType = res.headers.get("content-type") || "image/png";
748
+ if (isJsonMode(media)) {
749
+ jsonOut({ ...data, filename: opts.output, contentType, size: buffer.length });
750
+ return;
751
+ }
752
+ console.log(`Image saved to ${opts.output} (${buffer.length} bytes)`);
753
+ return;
754
+ }
755
+ }
533
756
  if (isJsonMode(media)) {
534
757
  jsonOut(data);
535
758
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobi-ai/cli",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "CLI client for the Gobi collaborative knowledge platform",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,8 +3,10 @@ name: gobi-media
3
3
  description: >-
4
4
  Gobi media generation: generate images from text prompts (thumbnails,
5
5
  assets, logos), edit and inpaint images, create avatar videos with voice
6
- narration, list available avatars and voices, upload media files. Use when
7
- the user wants to generate images, create videos, or manage media.
6
+ narration, create cinematic videos from prompts, design custom avatars or
7
+ create avatars from selfies, list available avatars and voices, upload
8
+ media files. Use when the user wants to generate images, create videos,
9
+ or manage media.
8
10
  allowed-tools: Bash(gobi:*)
9
11
  metadata:
10
12
  author: gobi-ai
@@ -65,18 +67,64 @@ gobi --json media video-create --avatar-id "<AVATAR_ID>" --voice-id "<VOICE_ID>"
65
67
 
66
68
  The `-o` flag implies `--wait` and downloads the video when done.
67
69
 
68
- **IMPORTANT: Avatars are pre-built system avatars ONLY.** You CANNOT create custom avatars from uploaded images. The `gobi media avatars` list is the complete set of available avatars. Do NOT attempt to upload an image and use its mediaId as an avatarId — it will fail.
70
+ To use a custom image as the **background** of a video, pass it directly as `--background <file>` (auto-uploaded):
69
71
 
70
- To use a custom image (e.g. a generated image) as the **background** of a video, upload it first via `upload-init` / `upload-finalize`, then pass the mediaId as `--background-media-id`:
72
+ ```bash
73
+ gobi --json media video-create --avatar-id "<AVATAR_ID>" --voice-id "<VOICE_ID>" --script "<SCRIPT>" --background media/bg.png -o media/<NAME>.mp4
74
+ ```
75
+
76
+ ## Typical Workflow (Cinematic Video)
77
+
78
+ Generate a cinematic video from a text prompt (no avatar needed):
79
+
80
+ ```bash
81
+ gobi --json media cinematic-create --prompt "<PROMPT>" --aspect-ratio "<RATIO>" -o media/<NAME>.mp4
82
+ ```
83
+
84
+ Options: `--duration <4-8>`, `--resolution <720p|1080p>`, `--enhance-prompt`, `--generate-audio`, `--negative-prompt`, `--sample-count <1-4>`, `--first-frame <file>`, `--last-frame <file>`, `--reference-images <files>`.
85
+
86
+ ## Typical Workflow (Image Editing)
87
+
88
+ Edit an existing image with a prompt — single command:
71
89
 
72
90
  ```bash
73
- # 1. Upload custom image as background
74
- gobi --json media upload-init --file-name "bg.png" --content-type "image/png" --file-size <SIZE>
75
- curl -T "media/bg.png" -H "Content-Type: image/png" "<UPLOAD_URL>"
76
- gobi --json media upload-finalize --media-id "<MEDIA_ID>"
91
+ gobi --json media image-edit --image media/source.png --prompt "<EDIT_INSTRUCTION>" -o media/<NAME>.png
92
+ ```
93
+
94
+ All file arguments (`--image`, `--mask`, `--background`, `--photo`, `--audio`, `--reference-image`, `--first-frame`, `--last-frame`) accept local file paths and auto-upload them. No need to manually upload first.
95
+
96
+ ## Custom Avatars
97
+
98
+ Three ways to create custom avatars:
77
99
 
78
- # 2. Create video with pre-built avatar + custom background
79
- gobi --json media video-create --avatar-id "<AVATAR_ID>" --voice-id "<VOICE_ID>" --script "<SCRIPT>" --background-media-id "<MEDIA_ID>" -o media/<NAME>.mp4
100
+ ### 1. Design from scratch
101
+
102
+ ```bash
103
+ gobi --json media avatar-design --gender "<GENDER>" --age "<AGE>" --ethnicity "<ETHNICITY>" --outfit "<OUTFIT>" --background "<BACKGROUND>" --wait
104
+ ```
105
+
106
+ When `variants_ready`, confirm with:
107
+
108
+ ```bash
109
+ gobi --json media avatar-confirm --job-id "<JOB_ID>"
110
+ ```
111
+
112
+ ### 2. From a selfie (instant)
113
+
114
+ ```bash
115
+ gobi --json media avatar-from-selfie --photo media/selfie.png
116
+ ```
117
+
118
+ ### 3. From a selfie (enhanced with prompt)
119
+
120
+ ```bash
121
+ gobi --json media avatar-from-selfie --photo media/selfie.png --prompt "<ENHANCEMENT>" --wait
122
+ ```
123
+
124
+ Check any avatar job status with:
125
+
126
+ ```bash
127
+ gobi --json media avatar-job-status <jobId> --wait
80
128
  ```
81
129
 
82
130
  **IMPORTANT: After downloading, show the video using Obsidian wiki-link syntax EXACTLY like this:**
@@ -91,8 +139,7 @@ Do NOT use markdown image/link syntax `![](...)` or `gobi://` URLs. Always use `
91
139
 
92
140
  ### Upload
93
141
 
94
- - `gobi media upload-init`Get a presigned upload URL for a media file.
95
- - `gobi media upload-finalize` — Confirm that a media upload is complete.
142
+ - `gobi media upload <file>` Upload a local file and return its media ID. Content type is auto-detected.
96
143
 
97
144
  ### Avatars & Voices
98
145
 
@@ -102,17 +149,27 @@ Do NOT use markdown image/link syntax `![](...)` or `gobi://` URLs. Always use `
102
149
  ### Videos
103
150
 
104
151
  - `gobi media video-create` — Create an avatar video generation job.
152
+ - `gobi media cinematic-create` — Create a cinematic video from a text prompt.
105
153
  - `gobi media video-list` — List all videos.
106
154
  - `gobi media video-get` — Get video metadata.
107
155
  - `gobi media video-status` — Poll video generation status.
108
156
  - `gobi media video-download` — Download a completed video (`-o` to save to file).
109
157
 
158
+ ### Custom Avatars
159
+
160
+ - `gobi media avatar-design` — Start a design-your-avatar job.
161
+ - `gobi media avatar-confirm` — Confirm avatar variant(s) after design.
162
+ - `gobi media avatar-from-selfie` — Create an avatar from a selfie (instant or enhanced).
163
+ - `gobi media avatar-job-status` — Check avatar job status.
164
+
110
165
  ### Images
111
166
 
112
167
  - `gobi media image-generate` — Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
113
168
  - `gobi media image-edit` — Edit an existing image with a prompt (image-to-image).
114
169
  - `gobi media image-inpaint` — Inpaint an image region using a mask.
115
170
  - `gobi media image-status` — Check image generation job status.
171
+ - `gobi media image-download` — Download a generated image.
172
+ - `gobi media image-status` — Check image generation job status.
116
173
 
117
174
  ## Reference Documentation
118
175
 
@@ -6,50 +6,39 @@ Usage: gobi media [options] [command]
6
6
  Media generation commands (videos, images).
7
7
 
8
8
  Options:
9
- -h, --help display help for command
9
+ -h, --help display help for command
10
10
 
11
11
  Commands:
12
- upload-init [options] Get a presigned upload URL for a media file.
13
- upload-finalize [options] Confirm that a media upload is complete.
14
- avatars List available avatars.
15
- voices List available voices.
16
- video-create [options] Create an avatar video generation job.
17
- video-list List all videos.
18
- video-get <id> Get video metadata.
19
- video-status [options] <id> Poll video generation status.
20
- video-download [options] <id> Download a completed video (or get its URL).
21
- image-generate [options] Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
22
- image-edit [options] Edit an existing image with a prompt (image-to-image).
23
- image-inpaint [options] Inpaint an image region using a mask.
24
- image-status [options] <jobId> Check image generation job status.
25
- image-download [options] <jobId> Download a generated image.
26
- help [command] display help for command
12
+ upload <file> Upload a local file and return its media ID.
13
+ avatars List available avatars.
14
+ voices List available voices.
15
+ video-create [options] Create an avatar video generation job.
16
+ video-list List all videos.
17
+ video-get <id> Get video metadata.
18
+ video-status [options] <id> Poll video generation status.
19
+ video-download [options] <id> Download a completed video (or get its URL).
20
+ cinematic-create [options] Create a cinematic video from a text prompt.
21
+ avatar-design [options] Start a design-your-avatar job.
22
+ avatar-confirm [options] Confirm avatar variant(s) after design.
23
+ avatar-from-selfie [options] Create an avatar from a selfie (instant or enhanced with prompt).
24
+ avatar-job-status [options] <jobId> Check avatar job status.
25
+ image-generate [options] Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
26
+ image-edit [options] Edit an existing image with a prompt (image-to-image).
27
+ image-inpaint [options] Inpaint an image region using a mask.
28
+ image-status [options] <jobId> Check image generation job status.
29
+ image-download [options] <jobId> Download a generated image.
30
+ help [command] display help for command
27
31
  ```
28
32
 
29
- ## upload-init
33
+ ## upload
30
34
 
31
35
  ```
32
- Usage: gobi media upload-init [options]
36
+ Usage: gobi media upload [options] <file>
33
37
 
34
- Get a presigned upload URL for a media file.
38
+ Upload a local file and return its media ID.
35
39
 
36
40
  Options:
37
- --file-name <fileName> Name of the file to upload
38
- --content-type <contentType> MIME type (e.g. image/png, video/mp4)
39
- --file-size <fileSize> File size in bytes
40
- -h, --help display help for command
41
- ```
42
-
43
- ## upload-finalize
44
-
45
- ```
46
- Usage: gobi media upload-finalize [options]
47
-
48
- Confirm that a media upload is complete.
49
-
50
- Options:
51
- --media-id <mediaId> Media ID from upload-init
52
- -h, --help display help for command
41
+ -h, --help display help for command
53
42
  ```
54
43
 
55
44
  ## avatars
@@ -82,14 +71,14 @@ Usage: gobi media video-create [options]
82
71
  Create an avatar video generation job.
83
72
 
84
73
  Options:
85
- --name <name> Name for the video (auto-generated if omitted)
86
- --avatar-id <avatarId> Avatar to use
87
- --voice-id <voiceId> Voice to use
88
- --script <script> Script for the avatar to read
89
- --background-media-id <backgroundMediaId> Background media ID (from upload)
90
- --wait Poll until generation completes
91
- -o, --output <path> Download video to this path when done (implies --wait)
92
- -h, --help display help for command
74
+ --name <name> Name for the video (auto-generated if omitted)
75
+ --avatar-id <avatarId> Avatar to use
76
+ --voice-id <voiceId> Voice to use
77
+ --script <script> Script for the avatar to read
78
+ --background <file> Background image file (auto-uploaded)
79
+ --wait Poll until generation completes
80
+ -o, --output <path> Download video to this path when done (implies --wait)
81
+ -h, --help display help for command
93
82
  ```
94
83
 
95
84
  ## video-list
@@ -139,6 +128,92 @@ Options:
139
128
  -h, --help display help for command
140
129
  ```
141
130
 
131
+ ## cinematic-create
132
+
133
+ ```
134
+ Usage: gobi media cinematic-create [options]
135
+
136
+ Create a cinematic video from a text prompt.
137
+
138
+ Options:
139
+ --prompt <prompt> Text prompt describing the video
140
+ --name <name> Name for the video (auto-generated if omitted)
141
+ --aspect-ratio <aspectRatio> Aspect ratio: 16:9, 9:16, 1:1
142
+ --duration <seconds> Duration in seconds (4-8)
143
+ --resolution <resolution> Resolution: 720p, 1080p
144
+ --enhance-prompt Enhance the prompt with AI
145
+ --generate-audio Generate audio for the video
146
+ --negative-prompt <negativePrompt> Negative prompt
147
+ --sample-count <count> Number of samples (1-4)
148
+ --first-frame <file> First frame image file (auto-uploaded)
149
+ --last-frame <file> Last frame image file (auto-uploaded)
150
+ --reference-images <files> Comma-separated reference image files (auto-uploaded, max 3)
151
+ --wait Poll until generation completes
152
+ -o, --output <path> Download video to this path when done (implies --wait)
153
+ -h, --help display help for command
154
+ ```
155
+
156
+ ## avatar-design
157
+
158
+ ```
159
+ Usage: gobi media avatar-design [options]
160
+
161
+ Start a design-your-avatar job.
162
+
163
+ Options:
164
+ --name <name> Name for the avatar (auto-generated if omitted)
165
+ --gender <gender> Gender for the avatar design
166
+ --age <age> Age range for the avatar
167
+ --ethnicity <ethnicity> Ethnicity for the avatar
168
+ --outfit <outfit> Outfit description
169
+ --background <background> Background description
170
+ --no-portrait Generate full-body instead of portrait
171
+ --audio <file> Custom voice audio file (auto-uploaded)
172
+ --wait Poll until variants are ready
173
+ -h, --help display help for command
174
+ ```
175
+
176
+ ## avatar-confirm
177
+
178
+ ```
179
+ Usage: gobi media avatar-confirm [options]
180
+
181
+ Confirm avatar variant(s) after design.
182
+
183
+ Options:
184
+ --job-id <jobId> Job ID from avatar-design
185
+ --variant <variant> Variant to confirm (1 or 2); omit to confirm both
186
+ -h, --help display help for command
187
+ ```
188
+
189
+ ## avatar-from-selfie
190
+
191
+ ```
192
+ Usage: gobi media avatar-from-selfie [options]
193
+
194
+ Create an avatar from a selfie (instant or enhanced with prompt).
195
+
196
+ Options:
197
+ --name <name> Name for the avatar (auto-generated if omitted)
198
+ --photo <file> Selfie photo file (auto-uploaded)
199
+ --prompt <prompt> Enhancement prompt (triggers async enhance flow)
200
+ --audio <file> Custom voice audio file (auto-uploaded)
201
+ --wait Poll until job completes (only for enhance flow)
202
+ -h, --help display help for command
203
+ ```
204
+
205
+ ## avatar-job-status
206
+
207
+ ```
208
+ Usage: gobi media avatar-job-status [options] <jobId>
209
+
210
+ Check avatar job status.
211
+
212
+ Options:
213
+ --wait Poll until a terminal state is reached
214
+ -h, --help display help for command
215
+ ```
216
+
142
217
  ## image-generate
143
218
 
144
219
  ```
@@ -147,16 +222,16 @@ Usage: gobi media image-generate [options]
147
222
  Generate an image from a text prompt. Types: image (default), thumbnail (YouTube-optimized), asset (logo/product). Aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
148
223
 
149
224
  Options:
150
- --prompt <prompt> Text prompt for image generation
151
- --name <name> Name for the generated image (auto-generated from prompt if omitted)
152
- --type <type> Generation type: image (default), thumbnail (YouTube-optimized), asset (logo/product)
153
- --aspect-ratio <aspectRatio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4)
154
- --negative-prompt <negativePrompt> Negative prompt
155
- --seed <seed> Random seed for reproducibility
156
- --reference-media-id <referenceMediaId> Reference image media ID
157
- --wait Poll until generation completes
158
- -o, --output <path> Download image to this path when done (implies --wait)
159
- -h, --help display help for command
225
+ --prompt <prompt> Text prompt for image generation
226
+ --name <name> Name for the generated image (auto-generated from prompt if omitted)
227
+ --type <type> Generation type: image (default), thumbnail (YouTube-optimized), asset (logo/product)
228
+ --aspect-ratio <aspectRatio> Aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4)
229
+ --negative-prompt <negativePrompt> Negative prompt
230
+ --seed <seed> Random seed for reproducibility
231
+ --reference-image <file> Reference image file (auto-uploaded)
232
+ --wait Poll until generation completes
233
+ -o, --output <path> Download image to this path when done (implies --wait)
234
+ -h, --help display help for command
160
235
  ```
161
236
 
162
237
  ## image-edit
@@ -167,11 +242,12 @@ Usage: gobi media image-edit [options]
167
242
  Edit an existing image with a prompt (image-to-image).
168
243
 
169
244
  Options:
170
- --media-id <mediaId> Source image media ID
171
- --prompt <prompt> Edit instruction
172
- --name <name> Name for the edited image
173
- --wait Poll until generation completes
174
- -h, --help display help for command
245
+ --image <file> Source image file (auto-uploaded)
246
+ --prompt <prompt> Edit instruction
247
+ --name <name> Name for the edited image (auto-generated if omitted)
248
+ --wait Poll until generation completes
249
+ -o, --output <path> Download image to this path when done (implies --wait)
250
+ -h, --help display help for command
175
251
  ```
176
252
 
177
253
  ## image-inpaint
@@ -182,12 +258,13 @@ Usage: gobi media image-inpaint [options]
182
258
  Inpaint an image region using a mask.
183
259
 
184
260
  Options:
185
- --media-id <mediaId> Source image media ID
186
- --mask-media-id <maskMediaId> Mask image media ID
187
- --prompt <prompt> Inpainting prompt
188
- --name <name> Name for the inpainted image
189
- --wait Poll until generation completes
190
- -h, --help display help for command
261
+ --image <file> Source image file (auto-uploaded)
262
+ --mask <file> Mask image file (auto-uploaded)
263
+ --prompt <prompt> Inpainting prompt
264
+ --name <name> Name for the inpainted image (auto-generated if omitted)
265
+ --wait Poll until generation completes
266
+ -o, --output <path> Download image to this path when done (implies --wait)
267
+ -h, --help display help for command
191
268
  ```
192
269
 
193
270
  ## image-status