diff --git a/README.md b/README.md index 648f0a4..03c52b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ListenHub CLI -Command-line interface for [ListenHub](https://listenhub.ai) — create podcasts, text-to-speech, explainer videos, slides, AI images, and music from your terminal. +Command-line interface for [ListenHub](https://listenhub.ai) — create podcasts, text-to-speech, explainer videos, slides, AI images, music, and videos from your terminal. [中文文档](README.zh-CN.md) @@ -76,6 +76,15 @@ listenhub tts create --text "Hello, world" --lang en | `listenhub image list` | List AI images | | `listenhub image get ` | Get image details | +### Video Generation + +| Command | Description | +| -------------------------- | ------------------------------ | +| `listenhub video create` | Create a video generation task | +| `listenhub video list` | List video tasks | +| `listenhub video get ` | Get video task details | +| `listenhub video estimate` | Estimate credit cost | + ### Other | Command | Description | @@ -109,6 +118,9 @@ listenhub music cover --audio ./song.mp3 # Local image for reference (jpg, png, webp, gif; max 10MB) listenhub image create --prompt "inspired by this" --reference ./photo.jpg +# Local video for reference (mp4, mov; max 50MB) +listenhub video create --prompt "same style" --reference-video ./clip.mp4 --input-video-duration 5 + # URLs are passed through directly listenhub music cover --audio https://example.com/song.mp3 ``` @@ -158,6 +170,23 @@ listenhub image create \ --aspect-ratio 16:9 --size 4K ``` +### Video generation + +```bash +# Text-to-video +listenhub video create --prompt "A cat playing piano in a jazz bar" + +# Image-to-video (first frame) +listenhub video create --prompt "Camera slowly zooms out" --first-frame ./scene.png + +# With reference video +listenhub video create --prompt "Same style dancing" \ + --reference-video ./clip.mp4 --input-video-duration 8 + +# Estimate credits +listenhub video estimate --model doubao-seedance-2-pro --resolution 1080p --duration 10 +``` + ### JSON output for scripting ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index fb08f75..eefa0cf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # ListenHub CLI -[ListenHub](https://listenhub.ai) 的命令行工具 — 在终端里创建播客、语音合成、讲解视频、幻灯片、AI 图片和音乐。 +[ListenHub](https://listenhub.ai) 的命令行工具 — 在终端里创建播客、语音合成、讲解视频、幻灯片、AI 图片、音乐和视频。 [English](README.md) @@ -76,6 +76,15 @@ listenhub tts create --text "你好世界" --lang zh | `listenhub image list` | 列出图片 | | `listenhub image get ` | 查看图片详情 | +### 视频生成 + +| 命令 | 说明 | +| -------------------------- | ---------------- | +| `listenhub video create` | 创建视频生成任务 | +| `listenhub video list` | 列出视频任务 | +| `listenhub video get ` | 查看视频任务详情 | +| `listenhub video estimate` | 预估积分消耗 | + ### 其他 | 命令 | 说明 | @@ -100,7 +109,7 @@ listenhub tts create --text "你好世界" --lang zh ## 本地文件上传 -`music cover` 和 `image create` 支持引用本地文件。CLI 自动检测本地路径,校验格式和大小,上传到云存储后传给 API。 +`music cover`、`image create` 和 `video create` 支持引用本地文件。CLI 自动检测本地路径,校验格式和大小,上传到云存储后传给 API。 ```bash # 本地音频文件用于翻唱(mp3, wav, flac, m4a, ogg, aac;最大 20MB) @@ -109,6 +118,9 @@ listenhub music cover --audio ./song.mp3 # 本地图片用于参考(jpg, png, webp, gif;最大 10MB) listenhub image create --prompt "以此为灵感" --reference ./photo.jpg +# 本地视频用于参考(mp4, mov;最大 50MB) +listenhub video create --prompt "同样风格" --reference-video ./clip.mp4 --input-video-duration 5 + # URL 直接透传 listenhub music cover --audio https://example.com/song.mp3 ``` @@ -158,6 +170,23 @@ listenhub image create \ --aspect-ratio 16:9 --size 4K ``` +### 视频生成 + +```bash +# 文字生成视频 +listenhub video create --prompt "一只猫在爵士酒吧弹钢琴" + +# 图生视频(首帧) +listenhub video create --prompt "镜头缓缓拉远" --first-frame ./scene.png + +# 带参考视频 +listenhub video create --prompt "相同风格的舞蹈" \ + --reference-video ./clip.mp4 --input-video-duration 8 + +# 预估积分 +listenhub video estimate --model doubao-seedance-2-pro --resolution 1080p --duration 10 +``` + ### 脚本中使用 JSON 输出 ```bash diff --git a/package.json b/package.json index c0f244e..6e8c5af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@marswave/listenhub-cli", - "version": "0.0.4", + "version": "0.0.5", "description": "Command-line interface for ListenHub", "license": "MIT", "repository": "marswaveai/listenhub-cli", @@ -25,7 +25,7 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "@marswave/listenhub-sdk": "^0.0.4", + "@marswave/listenhub-sdk": "^0.0.6", "commander": "^14.0.3", "open": "^10.0.0", "ora": "^8.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0468a9..e935df0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@marswave/listenhub-sdk': - specifier: ^0.0.4 - version: 0.0.4 + specifier: ^0.0.6 + version: 0.0.6 commander: specifier: ^14.0.3 version: 14.0.3 @@ -57,8 +57,8 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@marswave/listenhub-sdk@0.0.4': - resolution: {integrity: sha512-24kmN+TS2xuIObGAN9LzYG4LNucbPsjlbOivRPCvh49w0dmuNO1UPQRVK3bNWyYyT/cdFfajENrtYAfIv+3Atw==} + '@marswave/listenhub-sdk@0.0.6': + resolution: {integrity: sha512-XHb/RqZWPFj4pPrPg9bADBayttGi7wNojGJO5Rm05RheRMUdX7Yrw1g7p3kDqfbtij9hvEFnOXRqC4YFUCMztQ==} engines: {node: '>=20'} '@napi-rs/wasm-runtime@1.1.4': @@ -1118,7 +1118,7 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@marswave/listenhub-sdk@0.0.4': + '@marswave/listenhub-sdk@0.0.6': dependencies: ky: 1.14.3 diff --git a/source/_shared/mp4-duration.ts b/source/_shared/mp4-duration.ts new file mode 100644 index 0000000..2ac6b16 --- /dev/null +++ b/source/_shared/mp4-duration.ts @@ -0,0 +1,91 @@ +import {open} from 'node:fs/promises'; + +export async function getMp4Duration(filePath: string): Promise { + const file = await open(filePath, 'r'); + try { + const moovOffset = await findAtom(file, 'moov', 0, await fileSize(file)); + if (moovOffset === undefined) { + throw new Error(`Cannot read video duration: moov atom not found in ${filePath}`); + } + + const moovHeader = await readAtomHeader(file, moovOffset); + const moovEnd = moovOffset + moovHeader.size; + const mvhdOffset = await findAtom(file, 'mvhd', moovOffset + 8, moovEnd); + if (mvhdOffset === undefined) { + throw new Error(`Cannot read video duration: mvhd atom not found in ${filePath}`); + } + + const dataOffset = mvhdOffset + 8; + + const versionBuf = Buffer.alloc(1); + await file.read(versionBuf, 0, 1, dataOffset); + const version = versionBuf[0]!; + + let timescale: number; + let duration: bigint; + + if (version === 0) { + const buf = Buffer.alloc(8); + await file.read(buf, 0, 8, dataOffset + 4 + 8); + timescale = buf.readUInt32BE(0); + duration = BigInt(buf.readUInt32BE(4)); + } else if (version === 1) { + const buf = Buffer.alloc(12); + await file.read(buf, 0, 12, dataOffset + 4 + 16); + timescale = buf.readUInt32BE(0); + duration = buf.readBigUInt64BE(4); + } else { + throw new Error(`Cannot read video duration: unsupported mvhd version ${String(version)}`); + } + + if (timescale === 0) { + throw new Error(`Cannot read video duration: timescale is 0`); + } + + return Math.round(Number(duration) / timescale); + } finally { + await file.close(); + } +} + +interface AtomHeader { + size: number; + type: string; +} + +async function readAtomHeader( + file: Awaited>, + offset: number, +): Promise { + const buf = Buffer.alloc(8); + const {bytesRead} = await file.read(buf, 0, 8, offset); + if (bytesRead < 8) { + return {size: 0, type: ''}; + } + + const size = buf.readUInt32BE(0); + const type = buf.toString('ascii', 4, 8); + return {size, type}; +} + +async function findAtom( + file: Awaited>, + target: string, + start: number, + end: number, +): Promise { + let offset = start; + while (offset < end) { + const header = await readAtomHeader(file, offset); // eslint-disable-line no-await-in-loop + if (header.size === 0) break; + if (header.type === target) return offset; + offset += header.size; + } + + return undefined; +} + +async function fileSize(file: Awaited>): Promise { + const stat = await file.stat(); + return stat.size; +} diff --git a/source/_shared/polling.ts b/source/_shared/polling.ts index 42cc3dc..9d65dc8 100644 --- a/source/_shared/polling.ts +++ b/source/_shared/polling.ts @@ -4,6 +4,7 @@ import type { ListenHubClient, LyricsTaskDetail, MusicTaskDetail, + VideoGenerationTaskDetail, } from '@marswave/listenhub-sdk'; import ora from 'ora'; import {CliTimeoutError} from './output.js'; @@ -127,6 +128,42 @@ export async function pollMusicTaskUntilDone( throw new CliTimeoutError(`Timed out after ${timeoutS}s`); } +export async function pollVideoTaskUntilDone( + client: ListenHubClient, + taskId: string, + options: {timeout?: number; json?: boolean}, +): Promise { + const timeoutS = options.timeout ?? 1200; + const maxAttempts = Math.ceil(timeoutS / (pollIntervalMs / 1000)); + const spinner = options.json + ? undefined + : ora({text: `Generating video... (1/${maxAttempts})`}).start(); + + for (let i = 0; i < maxAttempts; i++) { + if (i > 0) { + await sleep(pollIntervalMs); // eslint-disable-line no-await-in-loop + } + + const task = await client.getVideoGenerationTask(taskId); // eslint-disable-line no-await-in-loop + if (task.status === 'success') { + spinner?.succeed('Video created successfully'); + return task; + } + + if (task.status === 'failed') { + spinner?.fail('Video creation failed'); + throw new Error('Video creation failed'); + } + + if (spinner) { + spinner.text = `Generating video... (${String(i + 2)}/${maxAttempts})`; + } + } + + spinner?.fail('Timed out'); + throw new CliTimeoutError(`Timed out after ${timeoutS}s`); +} + const lyricsIntervalMs = 5000; export async function pollLyricsTaskUntilDone( diff --git a/source/_shared/upload.ts b/source/_shared/upload.ts index ac88fbe..61ae777 100644 --- a/source/_shared/upload.ts +++ b/source/_shared/upload.ts @@ -2,19 +2,22 @@ import {access, readFile, stat} from 'node:fs/promises'; import path from 'node:path'; import type {ListenHubClient} from '@marswave/listenhub-sdk'; -type FileAcceptType = 'audio' | 'image'; +type FileAcceptType = 'audio' | 'image' | 'video'; const audioExtensions = new Set(['.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac']); const imageExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); +const videoExtensions = new Set(['.mp4', '.mov']); const maxSizeBytes: Record = { audio: 20 * 1024 * 1024, image: 10 * 1024 * 1024, + video: 50 * 1024 * 1024, }; const categoryForType: Record = { audio: 'episode', image: 'banana', + video: 'episode', }; const mimeTypes = new Map([ @@ -29,16 +32,20 @@ const mimeTypes = new Map([ ['.png', 'image/png'], ['.webp', 'image/webp'], ['.gif', 'image/gif'], + ['.mp4', 'video/mp4'], + ['.mov', 'video/quicktime'], ]); function allowedExtensions(accept: FileAcceptType): Set { - return accept === 'audio' ? audioExtensions : imageExtensions; + if (accept === 'audio') return audioExtensions; + if (accept === 'video') return videoExtensions; + return imageExtensions; } export async function resolveFileOrUrl( client: ListenHubClient, input: string, - options: {accept: FileAcceptType}, + options: {accept: FileAcceptType; category?: string}, ): Promise { const trimmed = input.trim(); @@ -77,7 +84,7 @@ export async function resolveFileOrUrl( // Get presigned upload URL const contentType = mimeTypes.get(ext)!; const fileKey = path.basename(filePath); - const category = categoryForType[options.accept]; + const category = options.category ?? categoryForType[options.accept]; const {presignedUrl, fileUrl} = await client.createFileUpload({ fileKey, contentType, diff --git a/source/cli.ts b/source/cli.ts index 7390544..d1f800e 100644 --- a/source/cli.ts +++ b/source/cli.ts @@ -10,6 +10,7 @@ import {register as registerPodcast} from './podcast/_cli.js'; import {register as registerSlides} from './slides/_cli.js'; import {register as registerSpeakers} from './speakers/_cli.js'; import {register as registerTts} from './tts/_cli.js'; +import {register as registerVideo} from './video/_cli.js'; const program = new Command(); program.name('listenhub').description('ListenHub CLI').version('0.1.0'); @@ -23,6 +24,7 @@ registerImage(program); registerMusic(program); registerLyrics(program); registerSpeakers(program); +registerVideo(program); registerCreation(program); program.parse(); diff --git a/source/video/_cli.ts b/source/video/_cli.ts new file mode 100644 index 0000000..7e4d8fb --- /dev/null +++ b/source/video/_cli.ts @@ -0,0 +1,100 @@ +import type {Command} from 'commander'; +import {getClient} from '../_shared/client.js'; +import {handleError} from '../_shared/output.js'; +import { + type VideoCreateOptions, + type VideoEstimateOptions, + type VideoListOptions, + createVideo, + estimateCredits, + getVideo, + listVideos, +} from './video.js'; + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +export function register(program: Command) { + const cmd = program.command('video').description('SeeDance video generation'); + + cmd + .command('create') + .description('Create a video generation task') + .requiredOption('--prompt ', 'Video description') + .option('--model ', 'Model: doubao-seedance-2-pro, doubao-seedance-2-fast') + .option('--resolution ', 'Resolution: 480p, 720p, 1080p') + .option('--ratio ', 'Aspect ratio: 16:9, 4:3, 1:1, 3:4, 9:16, 21:9') + .option('--duration ', 'Video duration in seconds (4-15)', Number) + .option('--first-frame ', 'First frame image') + .option('--last-frame ', 'Last frame image (requires --first-frame)') + .option('--reference-image ', 'Reference image (repeatable, max 9)', collect, []) + .option('--reference-video ', 'Reference video (repeatable, max 3)', collect, []) + .option('--reference-audio ', 'Reference audio (repeatable, max 3)', collect, []) + .option( + '--input-video-duration ', + 'Reference video duration (2-15, required with --reference-video)', + Number, + ) + .option('--no-generate-audio', 'Disable audio generation') + .option('--seed ', 'Random seed (-1 to 4294967295)', Number) + .option('--no-wait', 'Return immediately without polling') + .option('--timeout ', 'Polling timeout', Number, 1200) + .option('-j, --json', 'Output JSON', false) + .action(async (options: VideoCreateOptions) => { + try { + const client = await getClient(); + await createVideo(client, options); + } catch (error) { + handleError(error, options.json); + } + }); + + cmd + .command('get ') + .description('Get video task details') + .option('-j, --json', 'Output JSON', false) + .action(async (taskId: string, options: {json: boolean}) => { + try { + const client = await getClient(); + await getVideo(client, taskId, options.json); + } catch (error) { + handleError(error, options.json); + } + }); + + cmd + .command('list') + .description('List video generation tasks') + .option('--page ', 'Page number', Number, 1) + .option('--page-size ', 'Items per page', Number, 20) + .option('--status ', 'Filter: pending, generating, uploading, success, failed') + .option('-j, --json', 'Output JSON', false) + .action(async (options: VideoListOptions) => { + try { + const client = await getClient(); + await listVideos(client, options); + } catch (error) { + handleError(error, options.json); + } + }); + + cmd + .command('estimate') + .description('Estimate credit cost') + .requiredOption('--model ', 'Model name') + .requiredOption('--resolution ', 'Resolution') + .requiredOption('--duration ', 'Duration (4-15)', Number) + .option('--ratio ', 'Aspect ratio', '16:9') + .option('--has-video-input', 'Has reference video input', false) + .option('--input-video-duration ', 'Reference video duration', Number) + .option('-j, --json', 'Output JSON', false) + .action(async (options: VideoEstimateOptions) => { + try { + const client = await getClient(); + await estimateCredits(client, options); + } catch (error) { + handleError(error, options.json); + } + }); +} diff --git a/source/video/video.ts b/source/video/video.ts new file mode 100644 index 0000000..0f89034 --- /dev/null +++ b/source/video/video.ts @@ -0,0 +1,335 @@ +import path from 'node:path'; +import type { + CreateVideoGenerationParams, + EstimateVideoGenerationCreditsParams, + ListenHubClient, + VideoContentItem, + VideoGenerationModel, + VideoGenerationRatio, + VideoGenerationResolution, + VideoGenerationTaskStatus, +} from '@marswave/listenhub-sdk'; +import {getMp4Duration} from '../_shared/mp4-duration.js'; +import {printDetail, printJson, printTable} from '../_shared/output.js'; +import {pollVideoTaskUntilDone} from '../_shared/polling.js'; +import {resolveFileOrUrl} from '../_shared/upload.js'; + +export type VideoCreateOptions = { + prompt: string; + model?: string; + resolution?: string; + ratio?: string; + duration?: number; + firstFrame?: string; + lastFrame?: string; + referenceImage: string[]; + referenceVideo: string[]; + referenceAudio: string[]; + inputVideoDuration?: number; + generateAudio: boolean; + seed?: number; + wait: boolean; + timeout: number; + json: boolean; +}; + +export type VideoListOptions = { + page: number; + pageSize: number; + status?: string; + json: boolean; +}; + +export type VideoEstimateOptions = { + model: string; + resolution: string; + duration: number; + ratio: string; + hasVideoInput: boolean; + inputVideoDuration?: number; + json: boolean; +}; + +const allowedVideoAudioExtensions = new Set(['.mp3', '.wav']); +const allowedVideoExtensions = new Set(['.mp4', '.mov']); + +function validateCreateOptions(options: VideoCreateOptions): void { + if (options.duration !== undefined && (options.duration < 4 || options.duration > 15)) { + throw new Error('Duration must be between 4 and 15 seconds'); + } + + if (options.seed !== undefined && (options.seed < -1 || options.seed > 4_294_967_295)) { + throw new Error('Seed must be between -1 and 4294967295'); + } + + if ( + options.resolution === '1080p' && + options.model && + options.model !== 'doubao-seedance-2-pro' + ) { + throw new Error('1080p resolution requires --model doubao-seedance-2-pro'); + } + + if (options.lastFrame && !options.firstFrame) { + throw new Error('--last-frame requires --first-frame'); + } + + const hasFrameMode = Boolean(options.firstFrame || options.lastFrame); + const hasReferenceMode = + options.referenceImage.length > 0 || + options.referenceVideo.length > 0 || + options.referenceAudio.length > 0; + + if (hasFrameMode && hasReferenceMode) { + throw new Error( + 'Cannot mix frame mode (--first-frame/--last-frame) with reference mode (--reference-image/--reference-video/--reference-audio)', + ); + } + + if (options.referenceVideo.length > 0 && options.inputVideoDuration === undefined) { + const hasLocalVideo = options.referenceVideo.some( + (v) => !v.startsWith('http://') && !v.startsWith('https://'), + ); + if (!hasLocalVideo) { + throw new Error('--input-video-duration is required when using --reference-video with URLs'); + } + } + + if (options.inputVideoDuration !== undefined && options.referenceVideo.length === 0) { + throw new Error('--input-video-duration requires --reference-video'); + } + + if ( + options.inputVideoDuration !== undefined && + (options.inputVideoDuration < 2 || options.inputVideoDuration > 15) + ) { + throw new Error('Input video duration must be between 2 and 15 seconds'); + } + + if ( + options.referenceAudio.length > 0 && + options.referenceImage.length === 0 && + options.referenceVideo.length === 0 + ) { + throw new Error('--reference-audio requires --reference-image or --reference-video'); + } + + if (options.referenceImage.length > 9) { + throw new Error('Too many reference images (max 9)'); + } + + if (options.referenceVideo.length > 3) { + throw new Error('Too many reference videos (max 3)'); + } + + if (options.referenceAudio.length > 3) { + throw new Error('Too many reference audios (max 3)'); + } + + for (const file of options.referenceAudio) { + if (!file.startsWith('http://') && !file.startsWith('https://')) { + const ext = path.extname(file).toLowerCase(); + if (!allowedVideoAudioExtensions.has(ext)) { + throw new Error('Reference audio must be .mp3 or .wav'); + } + } + } + + for (const file of options.referenceVideo) { + if (!file.startsWith('http://') && !file.startsWith('https://')) { + const ext = path.extname(file).toLowerCase(); + if (!allowedVideoExtensions.has(ext)) { + throw new Error('Reference video must be .mp4 or .mov'); + } + } + } +} + +export async function createVideo( + client: ListenHubClient, + options: VideoCreateOptions, +): Promise { + if (options.referenceVideo.length > 0 && options.inputVideoDuration === undefined) { + const localVideo = options.referenceVideo.find( + (v) => !v.startsWith('http://') && !v.startsWith('https://'), + ); + if (localVideo) { + const filePath = path.resolve(localVideo.trim()); + const detected = await getMp4Duration(filePath); + if (detected >= 2 && detected <= 15) { + options.inputVideoDuration = detected; + } else { + throw new Error( + `Reference video is ${String(detected)}s long; --input-video-duration (2-15) is required to specify how much to use`, + ); + } + } + } + + validateCreateOptions(options); + + const content: VideoContentItem[] = [{type: 'text', text: options.prompt}]; + + if (options.firstFrame) { + const url = await resolveFileOrUrl(client, options.firstFrame, { + accept: 'image', + category: 'episode', + }); + content.push({type: 'image_url', image_url: {url}, role: 'first_frame'}); + } + + if (options.lastFrame) { + const url = await resolveFileOrUrl(client, options.lastFrame, { + accept: 'image', + category: 'episode', + }); + content.push({type: 'image_url', image_url: {url}, role: 'last_frame'}); + } + + for (const ref of options.referenceImage) { + const url = await resolveFileOrUrl(client, ref, {accept: 'image', category: 'episode'}); // eslint-disable-line no-await-in-loop + content.push({type: 'image_url', image_url: {url}, role: 'reference_image'}); + } + + for (const ref of options.referenceVideo) { + const url = await resolveFileOrUrl(client, ref, {accept: 'video', category: 'episode'}); // eslint-disable-line no-await-in-loop + content.push({type: 'video_url', video_url: {url}, role: 'reference_video'}); + } + + for (const ref of options.referenceAudio) { + const url = await resolveFileOrUrl(client, ref, {accept: 'audio', category: 'episode'}); // eslint-disable-line no-await-in-loop + content.push({type: 'audio_url', audio_url: {url}, role: 'reference_audio'}); + } + + const params: CreateVideoGenerationParams = { + content, + ...(options.model && {model: options.model as VideoGenerationModel}), + ...(options.resolution && {resolution: options.resolution as VideoGenerationResolution}), + ...(options.ratio && {ratio: options.ratio as VideoGenerationRatio}), + ...(options.duration !== undefined && {duration: options.duration}), + ...(!options.generateAudio && {generateAudio: false}), + ...(options.seed !== undefined && {seed: options.seed}), + ...(options.inputVideoDuration !== undefined && { + inputVideoDuration: options.inputVideoDuration, + }), + }; + + const {taskId} = await client.createVideoGeneration(params); + + if (!options.wait) { + if (options.json) { + printJson({taskId}); + } else { + console.log(`✓ Video task submitted: ${taskId}`); + } + + return; + } + + const task = await pollVideoTaskUntilDone(client, taskId, { + timeout: options.timeout, + json: options.json, + }); + + if (options.json) { + printJson(task); + } else { + printDetail('Video created', [ + ['ID:', task.id], + ['Video:', task.videoUrl], + ['Duration:', task.duration ? `${String(task.duration)}s` : undefined], + ['Resolution:', task.resolution], + ['Ratio:', task.ratio], + ['Seed:', task.seed], + ['Credits:', task.creditCharged], + ]); + } +} + +export async function getVideo( + client: ListenHubClient, + taskId: string, + json: boolean, +): Promise { + const task = await client.getVideoGenerationTask(taskId); + + if (json) { + printJson(task); + return; + } + + printDetail('Video task details', [ + ['ID:', task.id], + ['Status:', task.status], + ['Model:', task.model], + ['Video:', task.videoUrl], + ['Duration:', task.duration ? `${String(task.duration)}s` : undefined], + ['Resolution:', task.resolution], + ['Ratio:', task.ratio], + ['Seed:', task.seed], + ['Credits:', task.creditCharged], + ['Created:', new Date(task.createdAt).toISOString()], + ]); +} + +export async function listVideos( + client: ListenHubClient, + options: VideoListOptions, +): Promise { + const {items} = await client.listVideoGenerationTasks({ + page: options.page, + pageSize: options.pageSize, + ...(options.status && {status: options.status as VideoGenerationTaskStatus}), + }); + + if (options.json) { + printJson(items); + return; + } + + const headers = ['ID', 'Model', 'Status', 'Duration', 'Created']; + const rows = items.map((item) => [ + item.id, + item.model, + item.status, + item.params.duration ? `${String(item.params.duration)}s` : '-', + new Date(item.createdAt).toISOString().slice(0, 10), + ]); + printTable(headers, rows); +} + +export async function estimateCredits( + client: ListenHubClient, + options: VideoEstimateOptions, +): Promise { + if (options.hasVideoInput && options.inputVideoDuration === undefined) { + throw new Error('--input-video-duration is required when using --has-video-input'); + } + + if (!options.hasVideoInput && options.inputVideoDuration !== undefined) { + throw new Error('--input-video-duration requires --has-video-input'); + } + + const params: EstimateVideoGenerationCreditsParams = { + model: options.model as VideoGenerationModel, + resolution: options.resolution as VideoGenerationResolution, + duration: options.duration, + ...(options.ratio && {ratio: options.ratio as VideoGenerationRatio}), + ...(options.hasVideoInput && {hasVideoInput: true}), + ...(options.inputVideoDuration !== undefined && { + inputVideoDuration: options.inputVideoDuration, + }), + }; + + const result = await client.estimateVideoGenerationCredits(params); + + if (options.json) { + printJson(result); + return; + } + + printDetail('Credit estimate', [ + ['Tokens:', result.tokens], + ['Credits:', result.credits], + ]); +}