@gobi-ai/cli 0.9.7 → 0.9.9

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.7",
7
+ "version": "0.9.9",
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.7",
12
+ "version": "0.9.9",
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.7",
4
+ "version": "0.9.9",
5
5
  "author": {
6
6
  "name": "gobi-ai"
7
7
  },
@@ -19,6 +19,48 @@ 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
+ }
22
64
  function extractImageUrl(data) {
23
65
  return (data.downloadUrl || data.download_url || data.url);
24
66
  }
@@ -136,56 +178,22 @@ export function registerMediaCommand(program) {
136
178
  body.backgroundMediaId = opts.backgroundMediaId;
137
179
  const resp = (await apiPost("/media-gen/videos", body));
138
180
  let data = unwrapResp(resp);
139
- const videoId = data.id || data.videoId;
181
+ const videoId = data.id || data.videoId || data.jobId;
140
182
  if (shouldWait && videoId) {
141
183
  console.log(`Video ${videoId} queued — polling for completion…`);
142
184
  data = await pollStatus(`/media-gen/videos/${videoId}/status`, ["inference_complete", "inference_failed"]);
143
185
  }
186
+ // After polling, the status response may contain the real videoId for download
187
+ const downloadId = data.videoId || data.id || videoId;
144
188
  // 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)`);
189
+ if (opts.output && downloadId && data.status === "inference_complete") {
190
+ const { contentType, size } = await downloadVideoToFile(downloadId, opts.output);
191
+ if (isJsonMode(media)) {
192
+ jsonOut({ ...data, filename: opts.output, contentType, size });
164
193
  return;
165
194
  }
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
- }
195
+ console.log(`Video saved to ${opts.output} (${size} bytes)`);
196
+ return;
189
197
  }
190
198
  if (isJsonMode(media)) {
191
199
  jsonOut(data);
@@ -193,10 +201,10 @@ export function registerMediaCommand(program) {
193
201
  }
194
202
  const status = data.status || "queued";
195
203
  console.log(`Video created!\n` +
196
- ` ID: ${videoId}\n` +
204
+ ` ID: ${downloadId}\n` +
197
205
  ` Status: ${status}`);
198
206
  if (status === "inference_complete") {
199
- console.log(` Download: gobi media video-download ${videoId}`);
207
+ console.log(` Download: gobi media video-download ${downloadId}`);
200
208
  }
201
209
  });
202
210
  media
@@ -244,49 +252,14 @@ export function registerMediaCommand(program) {
244
252
  const data = await pollStatus(`/media-gen/videos/${id}/status`, ["inference_complete", "inference_failed"]);
245
253
  // Download if -o specified and completed
246
254
  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)`);
255
+ const dlId = (data.videoId || data.id || id);
256
+ const { contentType, size } = await downloadVideoToFile(dlId, opts.output);
257
+ if (isJsonMode(media)) {
258
+ jsonOut({ ...data, filename: opts.output, contentType, size });
265
259
  return;
266
260
  }
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
- }
261
+ console.log(`Video ${id} ${data.status}\nSaved to ${opts.output} (${size} bytes)`);
262
+ return;
290
263
  }
291
264
  if (isJsonMode(media)) {
292
265
  jsonOut(data);
@@ -312,48 +285,13 @@ export function registerMediaCommand(program) {
312
285
  const url = `${BASE_URL}/media-gen/videos/${id}/download`;
313
286
  // If -o specified, download directly to file
314
287
  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)`);
288
+ const { contentType, size } = await downloadVideoToFile(id, opts.output);
289
+ if (isJsonMode(media)) {
290
+ jsonOut({ filename: opts.output, contentType, size });
331
291
  return;
332
292
  }
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");
293
+ console.log(`Video saved to ${opts.output} (${size} bytes)`);
294
+ return;
357
295
  }
358
296
  // No -o: just return the URL (existing behavior)
359
297
  const res = await fetch(url, {
@@ -384,6 +322,221 @@ export function registerMediaCommand(program) {
384
322
  console.log(`Download URL for video ${id}:\n ${data.url || data.downloadUrl || JSON.stringify(data)}`);
385
323
  });
386
324
  // ════════════════════════════════════════════════════════════════════
325
+ // Cinematic Video
326
+ // ════════════════════════════════════════════════════════════════════
327
+ media
328
+ .command("cinematic-create")
329
+ .description("Create a cinematic video from a text prompt.")
330
+ .requiredOption("--prompt <prompt>", "Text prompt describing the video")
331
+ .option("--name <name>", "Name for the video (auto-generated if omitted)")
332
+ .option("--aspect-ratio <aspectRatio>", "Aspect ratio: 16:9, 9:16, 1:1")
333
+ .option("--duration <seconds>", "Duration in seconds (4-8)")
334
+ .option("--resolution <resolution>", "Resolution: 720p, 1080p")
335
+ .option("--enhance-prompt", "Enhance the prompt with AI")
336
+ .option("--generate-audio", "Generate audio for the video")
337
+ .option("--negative-prompt <negativePrompt>", "Negative prompt")
338
+ .option("--sample-count <count>", "Number of samples (1-4)")
339
+ .option("--first-frame-media-id <mediaId>", "First frame image media ID")
340
+ .option("--last-frame-media-id <mediaId>", "Last frame image media ID")
341
+ .option("--reference-media-ids <ids>", "Comma-separated reference image media IDs (max 3)")
342
+ .option("--wait", "Poll until generation completes")
343
+ .option("-o, --output <path>", "Download video to this path when done (implies --wait)")
344
+ .action(async (opts) => {
345
+ const shouldWait = opts.wait || !!opts.output;
346
+ const autoName = opts.name || `cinematic-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
347
+ const body = {
348
+ name: autoName,
349
+ prompt: opts.prompt,
350
+ };
351
+ if (opts.aspectRatio)
352
+ body.aspectRatio = opts.aspectRatio;
353
+ if (opts.duration) {
354
+ const v = parseInt(opts.duration, 10);
355
+ if (Number.isNaN(v))
356
+ throw new Error("--duration must be a number");
357
+ body.durationSeconds = v;
358
+ }
359
+ if (opts.resolution)
360
+ body.resolution = opts.resolution;
361
+ if (opts.enhancePrompt)
362
+ body.enhancePrompt = true;
363
+ if (opts.generateAudio)
364
+ body.generateAudio = true;
365
+ if (opts.negativePrompt)
366
+ body.negativePrompt = opts.negativePrompt;
367
+ if (opts.sampleCount) {
368
+ const v = parseInt(opts.sampleCount, 10);
369
+ if (Number.isNaN(v))
370
+ throw new Error("--sample-count must be a number");
371
+ body.sampleCount = v;
372
+ }
373
+ if (opts.firstFrameMediaId)
374
+ body.firstFrameImageMediaId = opts.firstFrameMediaId;
375
+ if (opts.lastFrameMediaId)
376
+ body.lastFrameImageMediaId = opts.lastFrameMediaId;
377
+ if (opts.referenceMediaIds)
378
+ body.referenceImageMediaIds = opts.referenceMediaIds.split(",").map((s) => s.trim());
379
+ const resp = (await apiPost("/media-gen/videos/cinematic", body));
380
+ let data = unwrapResp(resp);
381
+ const videoId = data.id || data.videoId || data.jobId;
382
+ if (shouldWait && videoId) {
383
+ console.log(`Cinematic video ${videoId} queued — polling for completion…`);
384
+ data = await pollStatus(`/media-gen/videos/${videoId}/status`, ["inference_complete", "inference_failed"]);
385
+ }
386
+ // After polling, the status response may contain the real videoId for download
387
+ const downloadId = data.videoId || data.id || videoId;
388
+ // Download video to file if -o specified
389
+ if (opts.output && downloadId && data.status === "inference_complete") {
390
+ const { contentType, size } = await downloadVideoToFile(downloadId, opts.output);
391
+ if (isJsonMode(media)) {
392
+ jsonOut({ ...data, filename: opts.output, contentType, size });
393
+ return;
394
+ }
395
+ console.log(`Cinematic video saved to ${opts.output} (${size} bytes)`);
396
+ return;
397
+ }
398
+ if (isJsonMode(media)) {
399
+ jsonOut(data);
400
+ return;
401
+ }
402
+ const status = data.status || "queued";
403
+ console.log(`Cinematic video created!\n` +
404
+ ` ID: ${downloadId}\n` +
405
+ ` Status: ${status}`);
406
+ if (status === "inference_complete") {
407
+ console.log(` Download: gobi media video-download ${downloadId}`);
408
+ }
409
+ });
410
+ // ════════════════════════════════════════════════════════════════════
411
+ // Custom Avatars
412
+ // ════════════════════════════════════════════════════════════════════
413
+ media
414
+ .command("avatar-design")
415
+ .description("Start a design-your-avatar job.")
416
+ .requiredOption("--name <name>", "Name for the avatar")
417
+ .requiredOption("--gender <gender>", "Gender for the avatar design")
418
+ .requiredOption("--age <age>", "Age range for the avatar")
419
+ .requiredOption("--ethnicity <ethnicity>", "Ethnicity for the avatar")
420
+ .requiredOption("--outfit <outfit>", "Outfit description")
421
+ .requiredOption("--background <background>", "Background description")
422
+ .option("--no-portrait", "Generate full-body instead of portrait")
423
+ .option("--audio-media-id <mediaId>", "Custom voice audio media ID")
424
+ .option("--wait", "Poll until variants are ready")
425
+ .action(async (opts) => {
426
+ const body = {
427
+ name: opts.name,
428
+ gender: opts.gender,
429
+ age: opts.age,
430
+ ethnicity: opts.ethnicity,
431
+ outfit: opts.outfit,
432
+ background: opts.background,
433
+ isPortrait: opts.portrait,
434
+ };
435
+ if (opts.audioMediaId)
436
+ body.audioMediaId = opts.audioMediaId;
437
+ const resp = (await apiPost("/media-gen/avatars/design", body));
438
+ let data = unwrapResp(resp);
439
+ const jobId = data.jobId || data.id;
440
+ if (opts.wait && jobId) {
441
+ console.log(`Avatar design job ${jobId} — polling for completion…`);
442
+ data = await pollStatus(`/media-gen/avatars/jobs/${jobId}/status`, ["variants_ready", "complete", "failed"]);
443
+ }
444
+ if (isJsonMode(media)) {
445
+ jsonOut(data);
446
+ return;
447
+ }
448
+ const status = data.status || "queued";
449
+ console.log(`Avatar design started!\n` +
450
+ ` Job ID: ${jobId}\n` +
451
+ ` Status: ${status}`);
452
+ if (status === "variants_ready") {
453
+ console.log(` Confirm: gobi media avatar-confirm --job-id ${jobId}`);
454
+ }
455
+ });
456
+ media
457
+ .command("avatar-confirm")
458
+ .description("Confirm avatar variant(s) after design.")
459
+ .requiredOption("--job-id <jobId>", "Job ID from avatar-design")
460
+ .option("--variant <variant>", "Variant to confirm (1 or 2); omit to confirm both")
461
+ .action(async (opts) => {
462
+ const body = { jobId: opts.jobId };
463
+ if (opts.variant) {
464
+ const v = parseInt(opts.variant, 10);
465
+ if (Number.isNaN(v))
466
+ throw new Error("--variant must be a number (1 or 2)");
467
+ body.variant = v;
468
+ }
469
+ const resp = (await apiPost("/media-gen/avatars/confirm", body));
470
+ const data = unwrapResp(resp);
471
+ if (isJsonMode(media)) {
472
+ jsonOut(data);
473
+ return;
474
+ }
475
+ const avatarId = data.avatarId || data.id;
476
+ console.log(`Avatar confirmed!\n` +
477
+ ` Avatar ID: ${avatarId || JSON.stringify(data)}`);
478
+ });
479
+ media
480
+ .command("avatar-from-selfie")
481
+ .description("Create an avatar from a selfie (instant or enhanced with prompt).")
482
+ .requiredOption("--name <name>", "Name for the avatar")
483
+ .requiredOption("--photo-media-id <mediaId>", "Selfie photo media ID")
484
+ .option("--prompt <prompt>", "Enhancement prompt (triggers async enhance flow)")
485
+ .option("--audio-media-id <mediaId>", "Custom voice audio media ID")
486
+ .option("--wait", "Poll until job completes (only for enhance flow)")
487
+ .action(async (opts) => {
488
+ const body = {
489
+ name: opts.name,
490
+ photoMediaId: opts.photoMediaId,
491
+ };
492
+ if (opts.prompt)
493
+ body.prompt = opts.prompt;
494
+ if (opts.audioMediaId)
495
+ body.audioMediaId = opts.audioMediaId;
496
+ const resp = (await apiPost("/media-gen/avatars/from-selfie", body));
497
+ let data = unwrapResp(resp);
498
+ const jobId = data.jobId || data.id;
499
+ // Enhance flow is async — poll if --wait
500
+ if (opts.wait && opts.prompt && jobId) {
501
+ console.log(`Avatar enhance job ${jobId} — polling for completion…`);
502
+ data = await pollStatus(`/media-gen/avatars/jobs/${jobId}/status`, ["variants_ready", "complete", "failed"]);
503
+ }
504
+ if (isJsonMode(media)) {
505
+ jsonOut(data);
506
+ return;
507
+ }
508
+ if (opts.prompt) {
509
+ const status = data.status || "queued";
510
+ console.log(`Avatar enhance started!\n` +
511
+ ` Job ID: ${jobId}\n` +
512
+ ` Status: ${status}`);
513
+ }
514
+ else {
515
+ const avatarId = data.avatarId || data.id;
516
+ console.log(`Avatar created from selfie!\n` +
517
+ ` Avatar ID: ${avatarId || JSON.stringify(data)}`);
518
+ }
519
+ });
520
+ media
521
+ .command("avatar-job-status <jobId>")
522
+ .description("Check avatar job status.")
523
+ .option("--wait", "Poll until a terminal state is reached")
524
+ .action(async (jobId, opts) => {
525
+ let data;
526
+ if (opts.wait) {
527
+ data = await pollStatus(`/media-gen/avatars/jobs/${jobId}/status`, ["variants_ready", "complete", "failed"]);
528
+ }
529
+ else {
530
+ const resp = (await apiGet(`/media-gen/avatars/jobs/${jobId}/status`));
531
+ data = unwrapResp(resp);
532
+ }
533
+ if (isJsonMode(media)) {
534
+ jsonOut(data);
535
+ return;
536
+ }
537
+ console.log(`Avatar job ${jobId} — status: ${data.status || "unknown"}`);
538
+ });
539
+ // ════════════════════════════════════════════════════════════════════
387
540
  // Images
388
541
  // ════════════════════════════════════════════════════════════════════
389
542
  media
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobi-ai/cli",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
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
@@ -30,10 +32,11 @@ gobi --json media image-generate --prompt "a sunset over mountains"
30
32
  Single command — generate and download in one step:
31
33
 
32
34
  ```bash
33
- gobi --json media image-generate --prompt "<PROMPT>" -o media/<NAME>.png
35
+ gobi --json media image-generate --prompt "<PROMPT>" --aspect-ratio "<RATIO>" -o media/<NAME>.png
34
36
  ```
35
37
 
36
38
  Replace `<NAME>` with a short descriptive slug derived from the prompt (e.g., `happy-family`, `sunset-mountains`).
39
+ Replace `<RATIO>` with the desired aspect ratio: `1:1`, `16:9`, `9:16`, `4:3`, or `3:4`. Use `9:16` for Shorts/Reels.
37
40
 
38
41
  The `-o` flag implies `--wait` and downloads the image when done.
39
42
 
@@ -64,18 +67,56 @@ gobi --json media video-create --avatar-id "<AVATAR_ID>" --voice-id "<VOICE_ID>"
64
67
 
65
68
  The `-o` flag implies `--wait` and downloads the video when done.
66
69
 
67
- **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, upload it via `upload-init` / `upload-finalize`, then pass the mediaId as `--background-media-id`:
68
71
 
69
- 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-id "<MEDIA_ID>" -o media/<NAME>.mp4
74
+ ```
75
+
76
+ ## Typical Workflow (Cinematic Video)
77
+
78
+ Generate a cinematic video from a text prompt (no avatar needed):
70
79
 
71
80
  ```bash
72
- # 1. Upload custom image as background
73
- gobi --json media upload-init --file-name "bg.png" --content-type "image/png" --file-size <SIZE>
74
- curl -T "media/bg.png" -H "Content-Type: image/png" "<UPLOAD_URL>"
75
- gobi --json media upload-finalize --media-id "<MEDIA_ID>"
81
+ gobi --json media cinematic-create --prompt "<PROMPT>" --aspect-ratio "<RATIO>" -o media/<NAME>.mp4
82
+ ```
76
83
 
77
- # 2. Create video with pre-built avatar + custom background
78
- gobi --json media video-create --avatar-id "<AVATAR_ID>" --voice-id "<VOICE_ID>" --script "<SCRIPT>" --background-media-id "<MEDIA_ID>" -o media/<NAME>.mp4
84
+ Options: `--duration <4-8>`, `--resolution <720p|1080p>`, `--enhance-prompt`, `--generate-audio`, `--negative-prompt`, `--sample-count <1-4>`, `--first-frame-media-id`, `--last-frame-media-id`, `--reference-media-ids`.
85
+
86
+ ## Custom Avatars
87
+
88
+ Three ways to create custom avatars:
89
+
90
+ ### 1. Design from scratch
91
+
92
+ ```bash
93
+ gobi --json media avatar-design --name "<NAME>" --gender "<GENDER>" --age "<AGE>" --ethnicity "<ETHNICITY>" --outfit "<OUTFIT>" --background "<BACKGROUND>" --wait
94
+ ```
95
+
96
+ When `variants_ready`, confirm with:
97
+
98
+ ```bash
99
+ gobi --json media avatar-confirm --job-id "<JOB_ID>"
100
+ ```
101
+
102
+ ### 2. From a selfie (instant)
103
+
104
+ ```bash
105
+ gobi --json media avatar-from-selfie --name "<NAME>" --photo-media-id "<MEDIA_ID>"
106
+ ```
107
+
108
+ Upload the selfie first via `upload-init` / `upload-finalize` to get the media ID.
109
+
110
+ ### 3. From a selfie (enhanced with prompt)
111
+
112
+ ```bash
113
+ gobi --json media avatar-from-selfie --name "<NAME>" --photo-media-id "<MEDIA_ID>" --prompt "<ENHANCEMENT>" --wait
114
+ ```
115
+
116
+ Check any avatar job status with:
117
+
118
+ ```bash
119
+ gobi --json media avatar-job-status <jobId> --wait
79
120
  ```
80
121
 
81
122
  **IMPORTANT: After downloading, show the video using Obsidian wiki-link syntax EXACTLY like this:**
@@ -101,11 +142,19 @@ Do NOT use markdown image/link syntax `![](...)` or `gobi://` URLs. Always use `
101
142
  ### Videos
102
143
 
103
144
  - `gobi media video-create` — Create an avatar video generation job.
145
+ - `gobi media cinematic-create` — Create a cinematic video from a text prompt.
104
146
  - `gobi media video-list` — List all videos.
105
147
  - `gobi media video-get` — Get video metadata.
106
148
  - `gobi media video-status` — Poll video generation status.
107
149
  - `gobi media video-download` — Download a completed video (`-o` to save to file).
108
150
 
151
+ ### Custom Avatars
152
+
153
+ - `gobi media avatar-design` — Start a design-your-avatar job.
154
+ - `gobi media avatar-confirm` — Confirm avatar variant(s) after design.
155
+ - `gobi media avatar-from-selfie` — Create an avatar from a selfie (instant or enhanced).
156
+ - `gobi media avatar-job-status` — Check avatar job status.
157
+
109
158
  ### Images
110
159
 
111
160
  - `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
@@ -6,24 +6,29 @@ 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-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
+ cinematic-create [options] Create a cinematic video from a text prompt.
22
+ avatar-design [options] Start a design-your-avatar job.
23
+ avatar-confirm [options] Confirm avatar variant(s) after design.
24
+ avatar-from-selfie [options] Create an avatar from a selfie (instant or enhanced with prompt).
25
+ avatar-job-status [options] <jobId> Check avatar job status.
26
+ 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
27
+ image-edit [options] Edit an existing image with a prompt (image-to-image).
28
+ image-inpaint [options] Inpaint an image region using a mask.
29
+ image-status [options] <jobId> Check image generation job status.
30
+ image-download [options] <jobId> Download a generated image.
31
+ help [command] display help for command
27
32
  ```
28
33
 
29
34
  ## upload-init
@@ -139,6 +144,92 @@ Options:
139
144
  -h, --help display help for command
140
145
  ```
141
146
 
147
+ ## cinematic-create
148
+
149
+ ```
150
+ Usage: gobi media cinematic-create [options]
151
+
152
+ Create a cinematic video from a text prompt.
153
+
154
+ Options:
155
+ --prompt <prompt> Text prompt describing the video
156
+ --name <name> Name for the video (auto-generated if omitted)
157
+ --aspect-ratio <aspectRatio> Aspect ratio: 16:9, 9:16, 1:1
158
+ --duration <seconds> Duration in seconds (4-8)
159
+ --resolution <resolution> Resolution: 720p, 1080p
160
+ --enhance-prompt Enhance the prompt with AI
161
+ --generate-audio Generate audio for the video
162
+ --negative-prompt <negativePrompt> Negative prompt
163
+ --sample-count <count> Number of samples (1-4)
164
+ --first-frame-media-id <mediaId> First frame image media ID
165
+ --last-frame-media-id <mediaId> Last frame image media ID
166
+ --reference-media-ids <ids> Comma-separated reference image media IDs (max 3)
167
+ --wait Poll until generation completes
168
+ -o, --output <path> Download video to this path when done (implies --wait)
169
+ -h, --help display help for command
170
+ ```
171
+
172
+ ## avatar-design
173
+
174
+ ```
175
+ Usage: gobi media avatar-design [options]
176
+
177
+ Start a design-your-avatar job.
178
+
179
+ Options:
180
+ --name <name> Name for the avatar
181
+ --gender <gender> Gender for the avatar design
182
+ --age <age> Age range for the avatar
183
+ --ethnicity <ethnicity> Ethnicity for the avatar
184
+ --outfit <outfit> Outfit description
185
+ --background <background> Background description
186
+ --no-portrait Generate full-body instead of portrait
187
+ --audio-media-id <mediaId> Custom voice audio media ID
188
+ --wait Poll until variants are ready
189
+ -h, --help display help for command
190
+ ```
191
+
192
+ ## avatar-confirm
193
+
194
+ ```
195
+ Usage: gobi media avatar-confirm [options]
196
+
197
+ Confirm avatar variant(s) after design.
198
+
199
+ Options:
200
+ --job-id <jobId> Job ID from avatar-design
201
+ --variant <variant> Variant to confirm (1 or 2); omit to confirm both
202
+ -h, --help display help for command
203
+ ```
204
+
205
+ ## avatar-from-selfie
206
+
207
+ ```
208
+ Usage: gobi media avatar-from-selfie [options]
209
+
210
+ Create an avatar from a selfie (instant or enhanced with prompt).
211
+
212
+ Options:
213
+ --name <name> Name for the avatar
214
+ --photo-media-id <mediaId> Selfie photo media ID
215
+ --prompt <prompt> Enhancement prompt (triggers async enhance flow)
216
+ --audio-media-id <mediaId> Custom voice audio media ID
217
+ --wait Poll until job completes (only for enhance flow)
218
+ -h, --help display help for command
219
+ ```
220
+
221
+ ## avatar-job-status
222
+
223
+ ```
224
+ Usage: gobi media avatar-job-status [options] <jobId>
225
+
226
+ Check avatar job status.
227
+
228
+ Options:
229
+ --wait Poll until a terminal state is reached
230
+ -h, --help display help for command
231
+ ```
232
+
142
233
  ## image-generate
143
234
 
144
235
  ```