From de0b8cca3f50097b7ef49fc3734c297956813487 Mon Sep 17 00:00:00 2001 From: Kiyeon Jeon Date: Mon, 5 Oct 2026 00:20:29 +0900 Subject: [PATCH] refactor: run vibe generate video and fill-gaps on the video executor `vibe generate video` carried its own copy of every provider's submit, poll, upload, and download code (913 lines). It now validates, picks the provider, runs the dry run, and calls `executeVideoGenerate`, the same executor the MCP tool and builds use (432 lines). - Unknown model aliases are checked against the catalog for any provider. - Progress comes through an `onProgress` hook; the spinner shows when the provider accepts the job (with its task ID), the percentage, and the download. - A failed call keeps the task ID and maps the error kind to the CLI's auth, usage, or retryable envelope. - `--no-wait` and timed-out waits record the job with its handle. - Veo's character reference images and person setting move into the Veo adapter. - fill-gaps uses the Kling generator through the contract. - The unused `_shared/video-utils.ts` is removed; `uploadToImgbb` moves into `upload-host.ts`, its only user. Fixes Runway progress, which is a 0-1 fraction and showed as "0%"/"1%". Claude-Session: https://claude.ai/code/session_011S1BLTaFctZNQzGERpkCuA --- .../ai-providers/src/gemini/veo-generator.ts | 15 + .../runway/RunwayProvider.contract.test.ts | 20 +- .../ai-providers/src/runway/RunwayProvider.ts | 6 +- .../src/commands/_shared/execute-fill-gaps.ts | 116 ++- .../cli/src/commands/_shared/video-utils.ts | 285 ------- packages/cli/src/commands/ai-video.ts | 15 +- packages/cli/src/commands/generate/video.ts | 701 +++--------------- packages/cli/src/utils/upload-host.ts | 49 +- 8 files changed, 257 insertions(+), 950 deletions(-) delete mode 100644 packages/cli/src/commands/_shared/video-utils.ts diff --git a/packages/ai-providers/src/gemini/veo-generator.ts b/packages/ai-providers/src/gemini/veo-generator.ts index 4b3061cd..527a4dbe 100644 --- a/packages/ai-providers/src/gemini/veo-generator.ts +++ b/packages/ai-providers/src/gemini/veo-generator.ts @@ -11,6 +11,18 @@ import { providerRequest } from "../shared/http.js"; import { resolveVideoModel } from "../video/models.js"; import { GeminiProvider, type VeoModel } from "./GeminiProvider.js"; +function veoReferenceImages(request: VideoRequest): Array<{ base64: string; mimeType: string }> | undefined { + const images = (request.references ?? []) + .filter((r) => r.kind === "image" && r.url.startsWith("data:")) + .slice(0, 3) + .map((r) => { + const m = r.url.match(/^data:(.+?);base64,(.*)$/s); + return m ? { mimeType: m[1], base64: m[2] } : undefined; + }) + .filter((r): r is { base64: string; mimeType: string } => !!r); + return images.length > 0 ? images : undefined; +} + export class VeoGenerator implements VideoGenerator { readonly id = "veo"; readonly imageInput = "data-uri" as const; @@ -42,6 +54,9 @@ export class VeoGenerator implements VideoGenerator { lastFrame: request.lastFrame, negativePrompt: request.negativePrompt, resolution: request.resolution, + // Veo keeps up to 3 inline reference images for character consistency. + referenceImages: veoReferenceImages(request), + personGeneration: request.providerOptions?.personGeneration as string | undefined, }); if (result.status === "failed" || !result.id) { throw classifyProviderError({ provider: this.id, message: result.error ?? "Veo generation failed" }); diff --git a/packages/ai-providers/src/runway/RunwayProvider.contract.test.ts b/packages/ai-providers/src/runway/RunwayProvider.contract.test.ts index 5a753d16..8d19010e 100644 --- a/packages/ai-providers/src/runway/RunwayProvider.contract.test.ts +++ b/packages/ai-providers/src/runway/RunwayProvider.contract.test.ts @@ -1,5 +1,7 @@ +import { describe, expect, it } from "vitest"; + import { describeVideoContract } from "../video/contract.testkit.js"; -import { jsonResponse, type FakeProviderNetwork } from "../testing/fake-provider-network.js"; +import { FakeProviderNetwork, jsonResponse } from "../testing/fake-provider-network.js"; import { RunwayProvider } from "./RunwayProvider.js"; const HOST = "api.dev.runwayml.com"; @@ -35,3 +37,19 @@ describeVideoContract("RunwayProvider", { cancel: true, continuations: [], }); + +describe("RunwayProvider progress", () => { + it("reports Runway's 0-1 progress fraction as a percentage", async () => { + const net = new FakeProviderNetwork(); + net.on("GET", HOST, /^\/v1\/tasks\/t-1$/, () => jsonResponse({ id: "t-1", status: "RUNNING", progress: 0.42 })); + net.install(); + try { + const runway = new RunwayProvider(); + await runway.initialize({ apiKey: "k" }); + const state = await runway.getVideoJob({ provider: "runway", id: "t-1", model: "gen4.5", submittedAt: "" }); + expect(state.progress).toBe(42); + } finally { + net.uninstall(); + } + }); +}); diff --git a/packages/ai-providers/src/runway/RunwayProvider.ts b/packages/ai-providers/src/runway/RunwayProvider.ts index 3916a224..42a38a74 100644 --- a/packages/ai-providers/src/runway/RunwayProvider.ts +++ b/packages/ai-providers/src/runway/RunwayProvider.ts @@ -156,7 +156,11 @@ export class RunwayProvider implements AIProvider, VideoGenerator { headers: this.headers(apiKey), }); const task = (await response.json()) as RunwayTaskResponse; - const state: VideoJobState = { status: STATUS[task.status] ?? "pending", progress: task.progress }; + const state: VideoJobState = { + status: STATUS[task.status] ?? "pending", + // Runway reports progress as a 0-1 fraction; the contract uses 0-100. + progress: typeof task.progress === "number" ? Math.round(task.progress * 100) : undefined, + }; if (task.status === "SUCCEEDED" && task.output?.length) { state.videoUrl = task.output[0]; } diff --git a/packages/cli/src/commands/_shared/execute-fill-gaps.ts b/packages/cli/src/commands/_shared/execute-fill-gaps.ts index 20e705ac..e8622d7a 100644 --- a/packages/cli/src/commands/_shared/execute-fill-gaps.ts +++ b/packages/cli/src/commands/_shared/execute-fill-gaps.ts @@ -13,13 +13,14 @@ import { existsSync } from "node:fs"; import { readFile, writeFile, mkdir, rename as renameFs } from "node:fs/promises"; import { resolve, dirname } from "node:path"; -import { KlingProvider } from "@vibeframe/ai-providers"; +import { waitForVideoJob, type VideoGenerator, type VideoRequest } from "@vibeframe/ai-providers"; import { Project, type ProjectFile } from "../../engine/index.js"; import { getApiKey } from "../../utils/api-key.js"; import { resolveUploadHost, type UploadHost } from "../../utils/upload-host.js"; import { execSafe, ffprobeDuration } from "../../utils/exec-safe.js"; import { resolveTimelineFile } from "../../utils/project-resolver.js"; -import { downloadVideo, formatTime } from "../ai-helpers.js"; +import { formatTime } from "../ai-helpers.js"; +import { openVideoGenerator } from "./video-jobs.js"; export interface ExecuteFillGapsOptions { /** Timeline file or directory (resolved relative to cwd). */ @@ -179,6 +180,30 @@ async function uploadFrame( } } +/** Submit one clip, wait for it, and download it; a failure comes back as `{error}`. */ +async function generateClip( + generator: VideoGenerator, + request: VideoRequest, + onProgress: (message: string) => void, + label: string, +): Promise<{ bytes: Uint8Array; durationSec?: number } | { error: string }> { + try { + const job = await generator.submitVideo(request); + onProgress(`${label} (task: ${job.id})...`); + const state = await waitForVideoJob(generator, job, { + timeoutMs: 600_000, + onProgress: (s) => onProgress(`${label}... ${s.status}`), + }); + if (state.status !== "completed") { + return { error: `${state.error?.message ?? `generation ${state.status}`} (task ${job.id})` }; + } + onProgress("Downloading generated video..."); + return { bytes: await generator.downloadVideo(job, state), durationSec: state.durationSec }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + // ─── main entry point ──────────────────────────────────────────────────── export async function executeFillGaps( @@ -342,16 +367,7 @@ export async function executeFillGaps( }; } - const kling = new KlingProvider(); - await kling.initialize({ apiKey }); - - if (!kling.isConfigured()) { - return { - success: false, - error: "Invalid KLING_API_KEY (set your Kling API key, or the legacy ACCESS_KEY:SECRET_KEY pair)", - humanLines, - }; - } + const kling = await openVideoGenerator("kling", apiKey); // Determine output directory for generated videos const projectDir = dirname(filePath); @@ -442,46 +458,25 @@ export async function executeFillGaps( const prompt = options.prompt || "Continue the scene naturally with subtle motion"; - const result = await kling.generateVideo(prompt, { + const clipRequest = (image: string, durationSec: number): VideoRequest => ({ prompt, - referenceImage: frameUrl, - duration: parseInt(klingDuration) as 5 | 10, - aspectRatio: options.ratio || "16:9", - mode: options.mode || "std", + image, + durationSec, + aspectRatio: (options.ratio || "16:9") as VideoRequest["aspectRatio"], + providerOptions: { mode: options.mode || "std" }, }); - if (result.status === "failed") { - humanLines.push(` Failed to start generation: ${result.error}`); + const first = await generateClip(kling, clipRequest(frameUrl, parseInt(klingDuration)), onProgress, "Generating video"); + if ("error" in first) { + humanLines.push(` Generation failed: ${first.error}`); continue; } - onProgress(`Generating video (task: ${result.id})...`); - - const finalResult = await kling.waitForCompletion( - result.id, - "image2video", - (status) => onProgress(`Generating video... ${status.status}`), - 600000, - ); - - if ( - finalResult.status !== "completed" || - !finalResult.videoUrl || - !finalResult.videoId - ) { - humanLines.push(` Generation failed: ${finalResult.error || "Unknown error"}`); - continue; - } - - // Download the generated video const videoFileName = `gap-fill-${gap.start.toFixed(2)}-${gap.end.toFixed(2)}.mp4`; const videoPath = resolve(footageDir, videoFileName); + await writeFile(videoPath, first.bytes); - onProgress("Downloading generated video..."); - const videoBuffer = await downloadVideo(finalResult.videoUrl); - await writeFile(videoPath, videoBuffer); - - generatedDuration = finalResult.duration || parseInt(klingDuration); + generatedDuration = first.durationSec || parseInt(klingDuration); generatedVideos.push(videoPath); humanLines.push(` Generated: ${videoFileName} (${generatedDuration}s)`); @@ -522,30 +517,14 @@ export async function executeFillGaps( break; } - const segResult = await kling.generateVideo(prompt, { - prompt, - referenceImage: extUpload.url, - duration: parseInt(segmentDuration) as 5 | 10, - aspectRatio: options.ratio || "16:9", - mode: options.mode || "std", - }); - - if (segResult.status === "failed") { - humanLines.push(` Segment generation failed: ${segResult.error}`); - break; - } - - const segFinalResult = await kling.waitForCompletion( - segResult.id, - "image2video", - (status) => onProgress(`Generating segment... ${status.status}`), - 600000, + const segment = await generateClip( + kling, + clipRequest(extUpload.url, parseInt(segmentDuration)), + onProgress, + "Generating segment", ); - - if (segFinalResult.status !== "completed" || !segFinalResult.videoUrl) { - humanLines.push( - ` Segment generation failed: ${segFinalResult.error || "Unknown error"}`, - ); + if ("error" in segment) { + humanLines.push(` Segment generation failed: ${segment.error}`); break; } @@ -553,8 +532,7 @@ export async function executeFillGaps( footageDir, `gap-fill-${gap.start.toFixed(2)}-${gap.end.toFixed(2)}-seg${segmentIndex}.mp4`, ); - const segVideoBuffer = await downloadVideo(segFinalResult.videoUrl); - await writeFile(segVideoPath, segVideoBuffer); + await writeFile(segVideoPath, segment.bytes); const concatListPath = resolve(footageDir, `concat-${gap.start.toFixed(2)}.txt`); const concatList = @@ -578,7 +556,7 @@ export async function executeFillGaps( } generatedVideos.push(segVideoPath); - generatedDuration += segFinalResult.duration || parseInt(segmentDuration); + generatedDuration += segment.durationSec || parseInt(segmentDuration); segmentIndex++; humanLines.push(` Added segment, total: ${generatedDuration.toFixed(1)}s`); diff --git a/packages/cli/src/commands/_shared/video-utils.ts b/packages/cli/src/commands/_shared/video-utils.ts deleted file mode 100644 index 71339205..00000000 --- a/packages/cli/src/commands/_shared/video-utils.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * @module _shared/video-utils - * @description Shared helpers for the script-to-video pipeline: - * StoryboardSegment type, retry constants, ImgBB upload, FFmpeg/Kling - * video extension, error logging, and the generic wait-for-completion - * loop. Split out of `ai-script-pipeline.ts` in v0.69 (Plan G Phase 4). - */ - -import { writeFile, unlink, rename } from "node:fs/promises"; -import { resolve, basename } from "node:path"; -import chalk from "chalk"; -import type { KlingProvider, RunwayProvider } from "@vibeframe/ai-providers"; -import { getVideoDuration, extendVideoNaturally } from "../../utils/audio.js"; -import { execSafe } from "../../utils/exec-safe.js"; -import { downloadVideo } from "../ai-helpers.js"; - -/** A single scene segment from the Claude-generated storyboard. */ -export interface StoryboardSegment { - /** 1-based scene index (assigned during generation) */ - index?: number; - /** Narrative description of the scene */ - description: string; - /** Visual prompt for image/video generation */ - visuals: string; - /** Art style directive (e.g. "cinematic", "anime") */ - visualStyle?: string; - /** Character appearance description for consistency */ - characterDescription?: string; - /** Reference to previous scene for continuity */ - previousSceneLink?: string; - /** Voiceover narration text */ - narration?: string; - /** Audio direction (e.g. "upbeat music") */ - audio?: string; - /** Text lines to overlay on the video */ - textOverlays?: string[]; - /** Scene duration in seconds (updated to match narration) */ - duration: number; - /** Cumulative start time in seconds */ - startTime: number; -} - -/** Default retry count for video generation API calls. */ -export const DEFAULT_VIDEO_RETRIES = 2; -/** Delay between retries in milliseconds. */ -export const RETRY_DELAY_MS = 5000; - -/** Sleep helper. */ -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** - * Upload image to ImgBB and return the URL. - * Used for Kling v2.5/v2.6 image-to-video which requires URL (not base64). - */ -export async function uploadToImgbb( - imageBuffer: Buffer, - apiKey: string, - /** Seconds until ImgBB deletes the image (60 to 15,552,000). Omit for a permanent upload. */ - expirationSeconds?: number, -): Promise<{ success: boolean; url?: string; error?: string }> { - try { - const base64Image = imageBuffer.toString("base64"); - - const formData = new URLSearchParams(); - formData.append("key", apiKey); - formData.append("image", base64Image); - if (expirationSeconds !== undefined) { - formData.append("expiration", String(expirationSeconds)); - } - - const response = await fetch("https://api.imgbb.com/1/upload", { - method: "POST", - body: formData, - }); - - if (!response.ok) { - return { - success: false, - error: `ImgBB API error (${response.status}): ${response.statusText}`, - }; - } - - const data = (await response.json()) as { - success?: boolean; - data?: { url?: string }; - error?: { message?: string }; - }; - - if (data.success && data.data?.url) { - return { success: true, url: data.data.url }; - } else { - return { success: false, error: data.error?.message || "Upload failed" }; - } - } catch (err) { - return { success: false, error: String(err) }; - } -} - -/** - * Extend a video to target duration using Kling extend API when possible, - * with fallback to FFmpeg-based extendVideoNaturally. - * - * When the extension ratio > 1.4 and a Kling provider + videoId are available, - * uses the Kling video-extend API for natural continuation instead of freeze frames. - */ -export async function extendVideoToTarget( - videoPath: string, - targetDuration: number, - outputDir: string, - sceneLabel: string, - options?: { - kling?: KlingProvider; - videoId?: string; - onProgress?: (message: string) => void; - }, -): Promise { - const actualDuration = await getVideoDuration(videoPath); - if (actualDuration >= targetDuration - 0.1) return; - - const ratio = targetDuration / actualDuration; - const extendedPath = resolve(outputDir, `${basename(videoPath, ".mp4")}-extended.mp4`); - - // Try Kling extend API for large gaps (ratio > 1.4) where freeze frames look bad - if (ratio > 1.4 && options?.kling && options?.videoId) { - try { - options.onProgress?.(`${sceneLabel}: Extending via Kling API...`); - const extendResult = await options.kling.extendVideo(options.videoId, { - duration: "5", - }); - - if (extendResult.status !== "failed" && extendResult.id) { - const waitResult = await options.kling.waitForExtendCompletion( - extendResult.id, - (status) => { - options.onProgress?.(`${sceneLabel}: extend ${status.status}...`); - }, - 600000, - ); - - if (waitResult.status === "completed" && waitResult.videoUrl) { - // Download extended video - const extendedVideoPath = resolve( - outputDir, - `${basename(videoPath, ".mp4")}-kling-ext.mp4`, - ); - const buffer = await downloadVideo(waitResult.videoUrl); - await writeFile(extendedVideoPath, buffer); - - // Concatenate original + extension - const concatPath = resolve( - outputDir, - `${basename(videoPath, ".mp4")}-concat.mp4`, - ); - const listPath = resolve( - outputDir, - `${basename(videoPath, ".mp4")}-concat.txt`, - ); - await writeFile( - listPath, - `file '${videoPath}'\nfile '${extendedVideoPath}'`, - "utf-8", - ); - await execSafe("ffmpeg", [ - "-y", "-f", "concat", "-safe", "0", "-i", listPath, - "-c", "copy", concatPath, - ]); - - // Trim to exact target duration if concatenated video is longer - const concatDuration = await getVideoDuration(concatPath); - if (concatDuration > targetDuration + 0.5) { - await execSafe("ffmpeg", [ - "-y", "-i", concatPath, "-t", targetDuration.toFixed(2), - "-c", "copy", extendedPath, - ]); - await unlink(concatPath); - } else { - await rename(concatPath, extendedPath); - } - - // Cleanup temp files - await unlink(extendedVideoPath).catch(() => {}); - await unlink(listPath).catch(() => {}); - await unlink(videoPath); - await rename(extendedPath, videoPath); - return; - } - } - // If Kling extend failed, fall through to FFmpeg fallback - options.onProgress?.( - `${sceneLabel}: Kling extend failed, using FFmpeg fallback...`, - ); - } catch { - options.onProgress?.( - `${sceneLabel}: Kling extend error, using FFmpeg fallback...`, - ); - } - } - - // FFmpeg-based fallback (slowdown + frame interpolation + freeze frame) - await extendVideoNaturally(videoPath, targetDuration, extendedPath); - await unlink(videoPath); - await rename(extendedPath, videoPath); -} - -/** - * Log a provider failure to stderr. Centralizes the "what went wrong" output - * so call sites stop silently dropping provider errors into failedScenes. - */ -export function logSceneFailure( - provider: string, - sceneLabel: string, - err: unknown, -): void { - let msg: string; - if (err instanceof Error) { - msg = err.message; - } else if (typeof err === "string") { - msg = err; - } else if ( - err && - typeof err === "object" && - "error" in err && - typeof (err as { error: unknown }).error === "string" - ) { - msg = (err as { error: string }).error; - } else { - msg = String(err); - } - console.error(chalk.dim(`\n [${provider} ${sceneLabel}: ${msg}]`)); -} - -/** - * Wait for video completion with retry logic. - */ -export async function waitForVideoWithRetry( - provider: KlingProvider | RunwayProvider, - taskId: string, - providerType: "kling" | "runway", - maxRetries: number, - onProgress?: (message: string) => void, - timeout?: number, -): Promise<{ videoUrl: string } | null> { - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - let result; - if (providerType === "kling") { - result = await (provider as KlingProvider).waitForCompletion( - taskId, - "image2video", - (status) => onProgress?.(status.status || "processing"), - timeout || 600000, - ); - } else { - result = await (provider as RunwayProvider).waitForCompletion( - taskId, - (status) => { - const progress = - status.progress !== undefined ? `${status.progress}%` : status.status; - onProgress?.(progress || "processing"); - }, - timeout || 300000, - ); - } - - if (result.status === "completed" && result.videoUrl) { - return { videoUrl: result.videoUrl }; - } - - // If failed, signal need for resubmission - if (attempt < maxRetries) { - onProgress?.(`⚠ Failed, will need resubmission...`); - return null; - } - } catch (err) { - if (attempt < maxRetries) { - const errMsg = err instanceof Error ? err.message : String(err); - onProgress?.(`⚠ Error waiting (${errMsg.slice(0, 30)}), retry ${attempt + 1}/${maxRetries}...`); - await sleep(RETRY_DELAY_MS); - } - } - } - return null; -} diff --git a/packages/cli/src/commands/ai-video.ts b/packages/cli/src/commands/ai-video.ts index 487569ef..c10276cc 100644 --- a/packages/cli/src/commands/ai-video.ts +++ b/packages/cli/src/commands/ai-video.ts @@ -52,10 +52,10 @@ function failure(error: unknown, prefix: string): { success: false; error: strin async function settle( generator: VideoGenerator, job: VideoJob, - opts: { wait: boolean; output?: string } + opts: { wait: boolean; output?: string; onProgress?: (state: VideoJobState) => void } ): Promise<{ state: VideoJobState; outputPath?: string }> { const state = opts.wait - ? await waitForVideoJob(generator, job, { timeoutMs: WAIT_TIMEOUT_MS }) + ? await waitForVideoJob(generator, job, { timeoutMs: WAIT_TIMEOUT_MS, onProgress: opts.onProgress }) : await generator.getVideoJob(job); const outputPath = opts.output && state.status === "completed" @@ -99,6 +99,10 @@ export interface VideoGenerateOptions { * can still be polled and downloaded. */ onSubmitted?: (job: VideoJob) => void | Promise; + /** Called after every poll while waiting. */ + onProgress?: (state: VideoJobState) => void; + /** Veo only: `allow_all` or `allow_adult`. */ + personGeneration?: string; } export interface VideoGenerateResult { @@ -141,7 +145,10 @@ export async function executeVideoGenerate(options: VideoGenerateOptions): Promi negativePrompt: options.negative, seed: options.seed, generateAudio: options.generateAudio, - providerOptions: options.mode ? { mode: options.mode } : undefined, + providerOptions: + options.mode || options.personGeneration + ? { ...(options.mode ? { mode: options.mode } : {}), ...(options.personGeneration ? { personGeneration: options.personGeneration } : {}) } + : undefined, }; const job = await generator.submitVideo(request); @@ -151,7 +158,7 @@ export async function executeVideoGenerate(options: VideoGenerateOptions): Promi } let settled: Awaited>; try { - settled = await settle(generator, job, { wait: true, output: options.output }); + settled = await settle(generator, job, { wait: true, output: options.output, onProgress: options.onProgress }); } catch (error) { // Running out of wait time is not a failure: the job may still finish. if (isProviderError(error) && error.kind === "timeout") { diff --git a/packages/cli/src/commands/generate/video.ts b/packages/cli/src/commands/generate/video.ts index e681d565..46b42fc0 100644 --- a/packages/cli/src/commands/generate/video.ts +++ b/packages/cli/src/commands/generate/video.ts @@ -1,31 +1,19 @@ /** * @module generate/video * @description `vibe generate video` (alias `vid`) — multi-provider video - * generation. fal.ai (Seedance 2.0), Grok, Veo (Gemini), Kling, Runway, - * plus Gemini Omni 1.1 Flash (`-p omni`). - * Split out of `generate.ts` in v0.69 (Plan G Phase 2). + * generation (Seedance via fal.ai, Grok, Kling, Runway, Gemini Omni, and Veo + * until 2026-10-22). Validation, provider choice, and the dry run live here; + * generation itself is `executeVideoGenerate`, the same executor the MCP + * tool and builds use. */ import type { Command } from "commander"; import { resolve } from "node:path"; -import { readFile, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import chalk from "chalk"; import ora from "ora"; import imageSize from "image-size"; -import { - GeminiProvider, - GrokProvider, - KlingProvider, - RunwayProvider, - FalProvider, - OmniProvider, - estimateSeedanceVideoCostUsd, - resolveGrokVideoModel, - resolveKlingModel, - resolveSeedanceVariant, - resolveVeoModel, - type MediaReference, -} from "@vibeframe/ai-providers"; +import { estimateSeedanceVideoCostUsd, modelAliases, resolveSeedanceVariant } from "@vibeframe/ai-providers"; import { requireApiKey, hasConfiguredApiKey } from "../../utils/api-key.js"; import { checkModelLifecycle, videoModelSpec } from "../../utils/model-lifecycle.js"; import { hasTTY, prompt as promptText } from "../../utils/tty.js"; @@ -37,14 +25,23 @@ import { log, exitWithError, apiError, - authError, + providerFailure, usageError, } from "../output.js"; import { rejectControlChars, validateOutputPath } from "../validate.js"; import { loadProviderDefaults, resolveProvider } from "../../utils/provider-resolver.js"; -import { resolveUploadHost } from "../../utils/upload-host.js"; -import { downloadVideo } from "../ai-helpers.js"; -import { createAndWriteJobRecord, type JobRecord } from "../_shared/status-jobs.js"; +import { executeVideoGenerate, type VideoGenerateOptions } from "../ai-video.js"; +import { createAndWriteJobRecord } from "../_shared/status-jobs.js"; +import { VIDEO_PROVIDER_ENV } from "../_shared/video-jobs.js"; + +const PROVIDER_LABELS: Readonly> = { + seedance: "Seedance via fal.ai", + grok: "Grok Imagine", + kling: "Kling", + runway: "Runway", + omni: "Gemini Omni 1.1 Flash", + veo: "Veo 3.1", +}; export function registerVideoCommand(parent: Command): void { parent @@ -205,27 +202,9 @@ Examples: provider = resolved?.name ?? "grok"; } - // Read image early so we can auto-detect aspect ratio before dry-run - let referenceImage: string | undefined; - let referenceImageBuffer: Buffer | undefined; - let referenceImageMimeType: string | undefined; - let isImageToVideo = false; + // Read the image early so the aspect ratio is known before the dry run. if (options.image) { - const imagePath = resolve(process.cwd(), options.image); - const imageBuffer = await readFile(imagePath); - const ext = options.image.toLowerCase().split(".").pop(); - const mimeTypes: Record = { - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - webp: "image/webp", - }; - const mimeType = mimeTypes[ext || "png"] || "image/png"; - referenceImageBuffer = imageBuffer; - referenceImageMimeType = mimeType; - referenceImage = `data:${mimeType};base64,${imageBuffer.toString("base64")}`; - isImageToVideo = true; + const imageBuffer = await readFile(resolve(process.cwd(), options.image)); // Auto-detect aspect ratio from image dimensions when not explicitly set if (!options.ratio) { @@ -259,15 +238,13 @@ Examples: // Reject unknown model aliases before any dry run or spend, so agents // learn about a typo from the dry run instead of the real call. - try { - if (provider === "seedance" || provider === "fal") resolveSeedanceVariant(options.seedanceModel); - if (provider === "grok") resolveGrokVideoModel(options.grokModel); - if (provider === "kling") resolveKlingModel(options.klingModel); - if (provider === "veo") resolveVeoModel(options.veoModel); - } catch (err) { - exitWithError(usageError(err instanceof Error ? err.message : String(err))); + const modelSpec = videoModelSpec(provider, options); + if (!modelSpec) { + exitWithError( + usageError(`Unknown ${provider} model. Valid: ${modelAliases(provider, "video").join(", ")}.`) + ); } - const lifecycleWarnings = checkModelLifecycle(videoModelSpec(provider, options)); + const lifecycleWarnings = checkModelLifecycle(modelSpec); if (!options.dryRun) printWarnings(lifecycleWarnings); if (options.dryRun) { @@ -305,438 +282,94 @@ Examples: return; } - const envKeyMap: Record = { - runway: "RUNWAY_API_SECRET", - kling: "KLING_API_KEY", - veo: "GOOGLE_API_KEY", - omni: "GOOGLE_API_KEY", - grok: "XAI_API_KEY", - seedance: "FAL_API_KEY", - }; - const providerNameMap: Record = { - runway: "Runway", - kling: "Kling", - veo: "Veo", - omni: "Gemini Omni 1.1 Flash", - grok: "Grok", - seedance: "Seedance 2.0 via fal.ai", - }; - const envKey = envKeyMap[provider]; - const providerName = providerNameMap[provider]; - const apiKey = await requireApiKey(envKey, providerName, options.apiKey); - - // Runway gen4_turbo requires an input image; gen4.5 supports text-to-video - const runwayModel = (options.runwayModel as string) || "gen4.5"; - if (provider === "runway" && !options.image && runwayModel !== "gen4.5") { - exitWithError( - usageError( - `Runway ${runwayModel} requires an input image. Use -i or use gen4.5 for text-to-video.` - ) - ); + const providerLabel = PROVIDER_LABELS[provider] ?? provider; + const apiKey = await requireApiKey(VIDEO_PROVIDER_ENV[provider], providerLabel, options.apiKey); + const spinner = isJsonMode() ? null : ora(`Starting ${providerLabel} video generation...`).start(); + + const result = await executeVideoGenerate({ + prompt, + provider: provider as VideoGenerateOptions["provider"], + image: options.image, + endImage: options.endImage ?? options.lastFrame, + refImages: options.refImages, + refVideos: options.refVideos, + refAudio: options.refAudio, + duration: parseFloat(options.duration), + ratio: options.ratio, + seed: options.seed !== undefined ? parseInt(options.seed, 10) : undefined, + mode: provider === "kling" ? options.mode : undefined, + negative: options.negative, + resolution: options.resolution, + veoModel: options.veoModel, + runwayModel: options.runwayModel, + seedanceModel: options.seedanceModel, + grokModel: options.grokModel, + klingModel: options.klingModel, + generateAudio: options.generateAudio, + personGeneration: options.person, + output: options.output, + wait: options.wait, + apiKey, + onSubmitted: (job) => { + if (!spinner) return; + spinner.stopAndPersist({ symbol: chalk.green("✔"), text: `${providerLabel} accepted the job (task ${job.id})` }); + spinner.start("Generating video (usually 1-3 minutes)..."); + }, + onProgress: (state) => { + if (!spinner) return; + spinner.text = + state.status === "completed" + ? options.output + ? "Downloading video..." + : "Video ready" + : state.progress !== undefined + ? `Generating video... ${Math.round(state.progress)}%` + : `Generating video... ${state.status}`; + }, + }); + + if (!result.success) { + spinner?.fail(result.error ?? "Generation failed"); + const task = result.taskId ? ` (${provider} task ${result.taskId})` : ""; + exitWithError(providerFailure(`${result.error ?? "Generation failed"}${task}`, result.errorKind)); } - const spinner = ora(`Initializing ${providerName}...`).start(); - - spinner.text = "Starting video generation..."; - - let result; - let finalResult; - - if (provider === "runway") { - const runway = new RunwayProvider(); - await runway.initialize({ apiKey }); - - result = await runway.generateVideo(prompt, { + // Still running (--no-wait, or the wait ran out): record the job so + // `vibe status job` can poll, download, and cache it. + if (result.status !== "completed") { + const job = await createAndWriteJobRecord({ + jobType: "generate-video", + provider, + providerTaskId: result.taskId!, + providerJob: result.job, + status: "running", + command: "generate video --no-wait", prompt, - referenceImage, - model: runwayModel, - duration: parseInt(options.duration), - aspectRatio: options.ratio as "16:9" | "9:16", - seed: options.seed ? parseInt(options.seed) : undefined, + outputPath: options.output, }); - - if (result.status === "failed") { - spinner.fail(result.error || "Failed to start generation"); - exitWithError(apiError(result.error || "Failed to start generation", true)); - } - - if (!options.wait) { - const job = await recordVideoNoWaitJob({ - provider, - providerTaskId: result.id, - prompt, - providerTaskType: "text2video", - }); - spinner.succeed(chalk.green("Generation started")); - if (isJsonMode()) { - outputSuccess({ - command: "generate video", - startedAt, - data: noWaitVideoData(provider, result.id, job), - }); - return; - } - console.log(); - printVideoStarted(`Runway ${runwayModel}`, result.id); - console.log(chalk.dim("Check status with:")); - console.log(chalk.dim(` vibe status job ${job.id} --json`)); - console.log(); - return; - } - - if (!isJsonMode()) { - printVideoStarted(`Runway ${runwayModel}`, result.id); - } - - spinner.text = "Generating video (this may take 1-2 minutes)..."; - - finalResult = await runway.waitForCompletion( - result.id, - (status) => { - if (status.progress !== undefined) { - spinner.text = `Generating video... ${status.progress}%`; - } - }, - 300000 - ); - } else if (provider === "kling") { - const kling = new KlingProvider(); - await kling.initialize({ apiKey }); - - if (!kling.isConfigured()) { - spinner.fail("Invalid API key format"); - exitWithError(authError("KLING_API_KEY", "Kling")); - } - - // Kling v2.x requires image URL, not base64 — auto-upload through - // the configured temporary upload host (ImgBB by default, S3 when - // VIBE_UPLOAD_PROVIDER=s3). - let klingImage = referenceImage; - if (klingImage && klingImage.startsWith("data:")) { - try { - const uploadHost = await resolveUploadHost(); - spinner.text = `Uploading image via ${uploadHost.provider} for Kling...`; - const upload = await uploadHost.uploadImage(referenceImageBuffer!, { - filename: options.image, - mimeType: referenceImageMimeType, - }); - klingImage = upload.url; - } catch (err) { - spinner.fail("Image upload failed"); - const message = err instanceof Error ? err.message : String(err); - if (message.includes("IMGBB_API_KEY")) { - exitWithError(authError("IMGBB_API_KEY", "ImgBB")); - } - exitWithError(apiError(message, true)); - } - spinner.text = "Starting video generation..."; - } - - result = await kling.generateVideo(prompt, { - prompt, - model: resolveKlingModel(options.klingModel), - referenceImage: klingImage, - duration: parseInt(options.duration), - aspectRatio: options.ratio as "16:9" | "9:16" | "1:1", - negativePrompt: options.negative, - mode: options.mode as "std" | "pro", - }); - - if (result.status === "failed") { - spinner.fail(result.error || "Failed to start generation"); - exitWithError(apiError(result.error || "Failed to start generation", true)); - } - - const taskType = isImageToVideo ? "image2video" : "text2video"; - if (!options.wait) { - const job = await recordVideoNoWaitJob({ - provider, - providerTaskId: result.id, - prompt, - providerTaskType: taskType, - }); - spinner.succeed(chalk.green("Generation started")); - if (isJsonMode()) { - outputSuccess({ - command: "generate video", - startedAt, - data: noWaitVideoData(provider, result.id, job), - }); - return; - } - console.log(); - printVideoStarted("Kling AI", result.id, taskType); - console.log(chalk.dim("Check status with:")); - console.log(chalk.dim(` vibe status job ${job.id} --json`)); - console.log(); - return; - } - - if (!isJsonMode()) { - printVideoStarted("Kling AI", result.id, taskType); - } - spinner.text = "Generating video (this may take 2-5 minutes)..."; - - finalResult = await kling.waitForCompletion( - result.id, - taskType, - (status) => { - spinner.text = `Generating video... ${status.status}`; - }, - 600000 - ); - } else if (provider === "veo") { - const gemini = new GeminiProvider(); - await gemini.initialize({ apiKey }); - - // Map Veo model alias to full model ID - const veoModel = resolveVeoModel(options.veoModel); - - const veoDuration = parseInt(options.duration) <= 6 ? 6 : 8; - - // Prepare last frame if provided - let lastFrame: string | undefined; - if (options.lastFrame) { - const lastFramePath = resolve(process.cwd(), options.lastFrame); - const lastFrameBuffer = await readFile(lastFramePath); - const ext = options.lastFrame.toLowerCase().split(".").pop(); - const mimeType = - ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext || "png"}`; - lastFrame = `data:${mimeType};base64,${lastFrameBuffer.toString("base64")}`; - } - - // Prepare reference images if provided - let refImages: Array<{ base64: string; mimeType: string }> | undefined; - if (options.refImages && options.refImages.length > 0) { - refImages = []; - for (const refPath of options.refImages.slice(0, 3)) { - const absRefPath = resolve(process.cwd(), refPath); - const refBuffer = await readFile(absRefPath); - const ext = refPath.toLowerCase().split(".").pop(); - const mimeType = - ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext || "png"}`; - refImages.push({ base64: refBuffer.toString("base64"), mimeType }); - } - } - - result = await gemini.generateVideo(prompt, { - prompt, - referenceImage, - duration: veoDuration, - aspectRatio: options.ratio as "16:9" | "9:16" | "1:1", - model: veoModel, - negativePrompt: options.negative, - resolution: options.resolution as "720p" | "1080p" | "4k" | undefined, - lastFrame, - referenceImages: refImages, - personGeneration: options.person as "allow_all" | "allow_adult" | undefined, - }); - - if (result.status === "failed") { - spinner.fail(result.error || "Failed to start generation"); - exitWithError(apiError(result.error || "Failed to start generation", true)); - } - - if (!options.wait) { - const job = await recordVideoNoWaitJob({ - provider, - providerTaskId: result.id, - prompt, - }); - spinner.succeed(chalk.green("Generation started")); - if (isJsonMode()) { - outputSuccess({ - command: "generate video", - startedAt, - data: noWaitVideoData(provider, result.id, job), - }); - return; - } - console.log(); - printVideoStarted("Google Veo 3.1", result.id); - console.log(chalk.dim(`Check status with: vibe status job ${job.id} --json`)); - console.log(); - return; - } - - if (!isJsonMode()) { - printVideoStarted("Google Veo 3.1", result.id); - } - spinner.text = "Generating video (this may take 1-3 minutes)..."; - finalResult = await gemini.waitForVideoCompletion( - result.id, - (status) => { - spinner.text = `Generating video... ${status.status}`; - }, - 300000 - ); - } else if (provider === "grok") { - const grok = new GrokProvider(); - await grok.initialize({ apiKey }); - - result = await grok.generateVideo(prompt, { - prompt, - model: resolveGrokVideoModel(options.grokModel), - referenceImage, - duration: parseInt(options.duration), - aspectRatio: options.ratio as "16:9" | "9:16" | "1:1", - resolution: options.resolution, - generateAudio: options.generateAudio, - }); - - if (result.status === "failed") { - spinner.fail(result.error || "Failed to start generation"); - exitWithError(apiError(result.error || "Failed to start generation", true)); - } - - if (!options.wait) { - const job = await recordVideoNoWaitJob({ - provider, - providerTaskId: result.id, - prompt, + spinner?.succeed(chalk.green(`Generation started: ${providerLabel} task ${result.taskId}`)); + if (isJsonMode()) { + outputSuccess({ + command: "generate video", + startedAt, + warnings: lifecycleWarnings, + data: { + provider, + taskId: result.taskId, + status: job.status, + jobId: job.id, + statusCommand: job.retryWith[0], + }, }); - spinner.succeed(chalk.green("Generation started")); - if (isJsonMode()) { - outputSuccess({ - command: "generate video", - startedAt, - data: noWaitVideoData(provider, result.id, job), - }); - return; - } - console.log(); - printVideoStarted("xAI Grok Imagine", result.id); - console.log(chalk.dim("Check status with:")); - console.log(chalk.dim(` vibe status job ${job.id} --json`)); - console.log(); return; } - - if (!isJsonMode()) { - printVideoStarted("xAI Grok Imagine", result.id); - } - spinner.text = "Generating video (this may take 1-3 minutes)..."; - finalResult = await grok.waitForCompletion( - result.id, - (status) => { - spinner.text = `Generating video... ${status.status}`; - }, - 300000 - ); - } else if (provider === "seedance") { - // fal.ai → ByteDance Seedance 2.0 (Artificial Analysis #2 on - // both video leaderboards). The fal client's `subscribe` blocks - // until the queue produces a final URL, so we don't need a - // separate wait/poll loop like the other providers. - const fal = new FalProvider(); - await fal.initialize({ apiKey }); - const seedanceReferences = await prepareSeedanceReferences({ - refImages: options.refImages, - refVideos: options.refVideos, - refAudio: options.refAudio, - }); - - // Seedance 2.0 image-to-video needs an HTTPS URL. base64 / data - // URIs aren't accepted, so use the configured temporary upload host. - let falImage = referenceImage; - if (falImage && falImage.startsWith("data:") && seedanceReferences.length === 0) { - try { - const uploadHost = await resolveUploadHost(); - spinner.text = `Uploading image via ${uploadHost.provider} for Seedance...`; - const upload = await uploadHost.uploadImage(referenceImageBuffer!, { - filename: options.image, - mimeType: referenceImageMimeType, - }); - falImage = upload.url; - } catch (err) { - spinner.fail("Image upload failed"); - const message = err instanceof Error ? err.message : String(err); - if (message.includes("IMGBB_API_KEY")) { - exitWithError(authError("IMGBB_API_KEY", "ImgBB")); - } - exitWithError(apiError(message, true)); - } - } - - let seedanceEndImage: string | undefined; - const seedanceEndImagePath = options.endImage ?? options.lastFrame; - if (seedanceEndImagePath && seedanceReferences.length === 0) { - try { - const absEndImagePath = resolve(process.cwd(), seedanceEndImagePath); - const endImageBuffer = await readFile(absEndImagePath); - const ext = seedanceEndImagePath.toLowerCase().split(".").pop(); - const mimeType = - ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext || "png"}`; - const uploadHost = await resolveUploadHost(); - spinner.text = `Uploading end image via ${uploadHost.provider} for Seedance...`; - const upload = await uploadHost.uploadImage(endImageBuffer, { - filename: seedanceEndImagePath, - mimeType, - }); - seedanceEndImage = upload.url; - } catch (err) { - spinner.fail("End image upload failed"); - const message = err instanceof Error ? err.message : String(err); - if (message.includes("IMGBB_API_KEY")) { - exitWithError(authError("IMGBB_API_KEY", "ImgBB")); - } - exitWithError(apiError(message, true)); - } - } - - const falModel = resolveSeedanceVariant(options.seedanceModel); - spinner.text = `Generating video with fal.ai ${falModel} (this may take 1-3 minutes)...`; - result = await fal.generateVideo(prompt, { - prompt, - referenceImage: seedanceReferences.length > 0 ? undefined : falImage, - references: seedanceReferences.length > 0 ? seedanceReferences : undefined, - duration: options.duration ? parseInt(options.duration) : undefined, - aspectRatio: options.ratio as "16:9" | "9:16" | "1:1" | "4:5", - negativePrompt: options.negative, - model: falModel, - resolution: options.resolution, - generateAudio: options.generateAudio, - lastFrame: seedanceEndImage, - }); - finalResult = result; - } else if (provider === "omni") { - // Gemini Omni 1.1 Flash on `/v1beta/interactions`. The call is - // synchronous; the provider waits for the video file itself. - const omni = new OmniProvider(); - await omni.initialize({ apiKey }); - spinner.text = "Generating video with Gemini Omni 1.1 Flash..."; - let lastFrame: string | undefined; - if (options.lastFrame) { - const lastFramePath = resolve(process.cwd(), options.lastFrame); - const ext = options.lastFrame.toLowerCase().split(".").pop(); - const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext || "png"}`; - lastFrame = `data:${mimeType};base64,${(await readFile(lastFramePath)).toString("base64")}`; - } - result = await omni.generateVideo(prompt, { - prompt, - referenceImage, - lastFrame, - aspectRatio: options.ratio as "16:9" | "9:16" | "1:1", - resolution: options.resolution, - }); - finalResult = result; - } - - if (!finalResult || finalResult.status !== "completed") { - spinner.fail(finalResult?.error || "Generation failed"); - exitWithError(apiError(finalResult?.error || "Generation failed", true)); + console.log(chalk.dim(` Check it with: ${job.retryWith[0]}`)); + return; } - spinner.succeed(chalk.green("Video generated")); - + spinner?.succeed(chalk.green("Video generated")); + const cost = realRunCost(provider, options); if (isJsonMode()) { - let outputPath: string | undefined; - if (options.output && finalResult.videoUrl) { - const buffer = await downloadVideo(finalResult.videoUrl, apiKey); - outputPath = resolve(process.cwd(), options.output); - await writeFile(outputPath, buffer); - } - const cost = realRunCost(provider, options); outputSuccess({ command: "generate video", startedAt, @@ -744,37 +377,19 @@ Examples: warnings: [...lifecycleWarnings, ...cost.warnings], data: { provider, - taskId: result?.id, - videoUrl: finalResult.videoUrl, - duration: finalResult.duration, - outputPath, + taskId: result.taskId, + videoUrl: result.videoUrl, + duration: result.duration, + outputPath: result.outputPath, }, }); return; } - console.log(); - if (finalResult.videoUrl) { - console.log(`Video URL: ${finalResult.videoUrl}`); - } - if (finalResult.duration) { - console.log(`Duration: ${finalResult.duration}s`); - } + if (result.videoUrl) console.log(`Video URL: ${result.videoUrl}`); + if (result.duration) console.log(`Duration: ${result.duration}s`); + if (result.outputPath) console.log(chalk.green(`Saved to: ${result.outputPath}`)); console.log(); - - if (options.output && finalResult.videoUrl) { - const downloadSpinner = ora("Downloading video...").start(); - try { - const buffer = await downloadVideo(finalResult.videoUrl, apiKey); - const outputPath = resolve(process.cwd(), options.output); - await writeFile(outputPath, buffer); - downloadSpinner.succeed(chalk.green(`Saved to: ${outputPath}`)); - } catch (err) { - downloadSpinner.fail( - chalk.red(`Failed to download video: ${err instanceof Error ? err.message : err}`) - ); - } - } } catch (error) { exitWithError(apiError(`Video generation failed: ${(error as Error).message}`)); } @@ -819,95 +434,3 @@ export function realRunCost(provider: string, options: { ], }; } - -async function prepareSeedanceReferences(opts: { - refImages?: string[]; - refVideos?: string[]; - refAudio?: string[]; -}): Promise { - const references: MediaReference[] = []; - for (const sourcePath of opts.refImages ?? []) { - references.push({ - kind: "image", - url: await fileInputToUrlOrDataUri(sourcePath, "image/png"), - sourcePath, - }); - } - for (const sourcePath of opts.refVideos ?? []) { - references.push({ - kind: "video", - url: await fileInputToUrlOrDataUri(sourcePath, "video/mp4"), - sourcePath, - }); - } - for (const sourcePath of opts.refAudio ?? []) { - references.push({ - kind: "audio", - url: await fileInputToUrlOrDataUri(sourcePath, "audio/mpeg"), - sourcePath, - }); - } - return references; -} - -async function fileInputToUrlOrDataUri(input: string, fallbackMimeType: string): Promise { - if (input.startsWith("http://") || input.startsWith("https://") || input.startsWith("data:")) { - return input; - } - const absPath = resolve(process.cwd(), input); - const buffer = await readFile(absPath); - return `data:${mimeTypeForPath(input, fallbackMimeType)};base64,${buffer.toString("base64")}`; -} - -function mimeTypeForPath(path: string, fallback: string): string { - const ext = path.toLowerCase().split(".").pop(); - const mimeTypes: Record = { - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - webp: "image/webp", - mp4: "video/mp4", - mov: "video/quicktime", - mp3: "audio/mpeg", - wav: "audio/wav", - }; - return mimeTypes[ext || ""] || fallback; -} - -async function recordVideoNoWaitJob(opts: { - provider: string; - providerTaskId: string; - providerTaskType?: "text2video" | "image2video"; - prompt: string; -}): Promise { - return createAndWriteJobRecord({ - jobType: "generate-video", - provider: opts.provider, - providerTaskId: opts.providerTaskId, - providerTaskType: opts.providerTaskType, - status: "running", - command: "generate video --no-wait", - prompt: opts.prompt, - }); -} - -function noWaitVideoData(provider: string, taskId: string, job: JobRecord): Record { - return { - provider, - taskId, - status: job.status, - jobId: job.id, - statusCommand: `vibe status job ${job.id} --project ${job.projectDir} --json`, - providerStatusCommand: job.retryWith.find((item) => item.startsWith("vibe generate video-status")), - }; -} - -function printVideoStarted(providerLabel: string, taskId: string, taskType?: string): void { - console.log(); - console.log(chalk.bold.cyan("Video Generation Started")); - console.log(chalk.dim("─".repeat(60))); - console.log(`Provider: ${chalk.bold(providerLabel)}`); - console.log(`Task ID: ${chalk.bold(taskId)}`); - if (taskType) console.log(`Type: ${taskType}`); -} diff --git a/packages/cli/src/utils/upload-host.ts b/packages/cli/src/utils/upload-host.ts index 6b70054c..da5339fe 100644 --- a/packages/cli/src/utils/upload-host.ts +++ b/packages/cli/src/utils/upload-host.ts @@ -1,7 +1,6 @@ import { createHash, createHmac, randomUUID } from "node:crypto"; import { extname } from "node:path"; import { loadConfig, getApiKeyFromConfig } from "../config/index.js"; -import { uploadToImgbb } from "../commands/_shared/video-utils.js"; export type UploadHostProvider = "imgbb" | "s3"; @@ -259,3 +258,51 @@ export async function resolveUploadHost(): Promise { }, }; } + +/** + * Upload an image to ImgBB and return its URL (for providers that only take + * image URLs, such as Kling). + */ +export async function uploadToImgbb( + imageBuffer: Buffer, + apiKey: string, + /** Seconds until ImgBB deletes the image (60 to 15,552,000). Omit for a permanent upload. */ + expirationSeconds?: number, +): Promise<{ success: boolean; url?: string; error?: string }> { + try { + const base64Image = imageBuffer.toString("base64"); + + const formData = new URLSearchParams(); + formData.append("key", apiKey); + formData.append("image", base64Image); + if (expirationSeconds !== undefined) { + formData.append("expiration", String(expirationSeconds)); + } + + const response = await fetch("https://api.imgbb.com/1/upload", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + return { + success: false, + error: `ImgBB API error (${response.status}): ${response.statusText}`, + }; + } + + const data = (await response.json()) as { + success?: boolean; + data?: { url?: string }; + error?: { message?: string }; + }; + + if (data.success && data.data?.url) { + return { success: true, url: data.data.url }; + } else { + return { success: false, error: data.error?.message || "Upload failed" }; + } + } catch (err) { + return { success: false, error: String(err) }; + } +}