diff --git a/README.md b/README.md index f561e50..a7895a7 100644 --- a/README.md +++ b/README.md @@ -204,12 +204,14 @@ All commands below are under `listenhub openapi`. ### Video -| Command | Description | -| ------------------------ | ---------------------------- | -| `openapi video create` | Create video generation task | -| `openapi video get ` | Get video task details | -| `openapi video list` | List video tasks | -| `openapi video estimate` | Estimate credit cost | +| Command | Description | +| --------------------------------- | --------------------------------------------- | +| `openapi video create` | Create video generation task | +| `openapi video get ` | Get video task details | +| `openapi video list` | List video tasks | +| `openapi video estimate` | Estimate credit cost | +| `openapi video pixverse generate` | Create a PixVerse video task (atomic + agent) | +| `openapi video pixverse estimate` | Estimate PixVerse credit cost | ### Content @@ -294,6 +296,40 @@ listenhub openapi video create --prompt "Camera zooms out" \ listenhub openapi video estimate --model doubao-seedance-2-pro --resolution 1080p --duration 10 ``` +### OpenAPI: PixVerse video generation + +PixVerse covers atomic capabilities (`text_to_video`, `image_to_video`, `transition`, `multi_transition`, `fusion`, `restyle`, `mimic`, `lip_sync`) and the marketing `agent` (`ad_master` / `promo_mix`). `--capability` is required. `--language en` (default) uses the international service; `--language zh` uses the China service. Image/video/audio assets accept an optional `:duration` suffix (`url:seconds`). For rarely-used nested fields use the `--pixverse-json` escape hatch. + +```bash +# Text-to-video +listenhub openapi video pixverse generate --capability text_to_video \ + --prompt "A cat playing piano" --quality 720p --aspect-ratio 16:9 --duration 5 --no-wait -j + +# Image-to-video (assets accept url:duration) +listenhub openapi video pixverse generate --capability image_to_video \ + --image https://example.com/photo.jpg --prompt "Camera slowly zooms in" + +# Lip-sync TTS, reusing a prior succeeded PixVerse task +listenhub openapi video pixverse generate --capability lip_sync \ + --source-task-id 6a2016607ebd26d050c585ca \ + --lip-sync-tts --lip-sync-speaker-id speaker-1 --lip-sync-content "Hello world" + +# Marketing agent (promo_mix needs >=4 product images) +listenhub openapi video pixverse generate --capability agent --agent-type promo_mix \ + --quality 1080p --duration 30 \ + --image https://example.com/p1.jpg --image https://example.com/p2.jpg \ + --image https://example.com/p3.jpg --image https://example.com/p4.jpg + +# Escape hatch for nested pixverse fields +listenhub openapi video pixverse generate --capability fusion \ + --prompt "@hero stands in @bg" \ + --pixverse-json '{"imageReferences":[{"type":"subject","imageUrl":"https://example.com/hero.png","refName":"hero"},{"type":"background","imageUrl":"https://example.com/bg.png","refName":"bg"}]}' + +# Estimate credits +listenhub openapi video pixverse estimate --capability text_to_video --quality 720p --duration 5 +listenhub openapi video pixverse estimate --capability agent --agent-type ad_master --duration 30 +``` + ### OAuth: Music generation ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 1d83c73..3d01cb2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -204,12 +204,14 @@ listenhub openapi subscription -j ### 视频 -| 命令 | 说明 | -| ------------------------ | ---------------- | -| `openapi video create` | 创建视频生成任务 | -| `openapi video get ` | 查看视频任务详情 | -| `openapi video list` | 列出视频任务 | -| `openapi video estimate` | 预估积分消耗 | +| 命令 | 说明 | +| --------------------------------- | ------------------------------------------ | +| `openapi video create` | 创建视频生成任务 | +| `openapi video get ` | 查看视频任务详情 | +| `openapi video list` | 列出视频任务 | +| `openapi video estimate` | 预估积分消耗 | +| `openapi video pixverse generate` | 创建 PixVerse 视频任务(原子能力 + Agent) | +| `openapi video pixverse estimate` | 预估 PixVerse 积分消耗 | ### 内容提取 @@ -294,6 +296,40 @@ listenhub openapi video create --prompt "镜头缓缓拉远" \ listenhub openapi video estimate --model doubao-seedance-2-pro --resolution 1080p --duration 10 ``` +### OpenAPI:PixVerse 视频生成 + +PixVerse 支持原子能力(`text_to_video`、`image_to_video`、`transition`、`multi_transition`、`fusion`、`restyle`、`mimic`、`lip_sync`)和营销 `agent`(`ad_master` / `promo_mix`)。`--capability` 必填。`--language en`(默认)走国际站,`--language zh` 走国内站。图片、视频、音频素材可在 URL 后加 `:时长` 后缀(`url:秒数`)。少见的嵌套字段用 `--pixverse-json` 直接传 JSON。 + +```bash +# 文字生成视频 +listenhub openapi video pixverse generate --capability text_to_video \ + --prompt "一只猫在弹钢琴" --quality 720p --aspect-ratio 16:9 --duration 5 --no-wait -j + +# 图片生成视频(素材支持 url:时长) +listenhub openapi video pixverse generate --capability image_to_video \ + --image https://example.com/photo.jpg --prompt "镜头缓缓推近" + +# 口型同步 TTS,复用已成功的 PixVerse 任务 +listenhub openapi video pixverse generate --capability lip_sync \ + --source-task-id 6a2016607ebd26d050c585ca \ + --lip-sync-tts --lip-sync-speaker-id speaker-1 --lip-sync-content "你好世界" + +# 营销 Agent(promo_mix 至少 4 张商品图) +listenhub openapi video pixverse generate --capability agent --agent-type promo_mix \ + --quality 1080p --duration 30 \ + --image https://example.com/p1.jpg --image https://example.com/p2.jpg \ + --image https://example.com/p3.jpg --image https://example.com/p4.jpg + +# 用 --pixverse-json 传嵌套字段 +listenhub openapi video pixverse generate --capability fusion \ + --prompt "@hero 站在 @bg 前" \ + --pixverse-json '{"imageReferences":[{"type":"subject","imageUrl":"https://example.com/hero.png","refName":"hero"},{"type":"background","imageUrl":"https://example.com/bg.png","refName":"bg"}]}' + +# 预估积分 +listenhub openapi video pixverse estimate --capability text_to_video --quality 720p --duration 5 +listenhub openapi video pixverse estimate --capability agent --agent-type ad_master --duration 30 +``` + ### OAuth:音乐生成 ```bash diff --git a/package.json b/package.json index 6f087af..3f39c31 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "prepublishOnly": "pnpm run build" }, "dependencies": { - "@marswave/listenhub-sdk": "^0.0.13", + "@marswave/listenhub-sdk": "^0.0.15", "commander": "^14.0.3", "open": "^10.0.0", "ora": "^8.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0da6b4c..4bf9445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@marswave/listenhub-sdk': - specifier: ^0.0.13 - version: 0.0.13 + specifier: ^0.0.15 + version: 0.0.15 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.13': - resolution: {integrity: sha512-70DDiyk+z8onExIOimGq5SzPBz/P5KZlBXZeliFRoiZS13L89dwKPiRrp2bYvY+2xjxspbgT+9HWYFOjhbFvMQ==} + '@marswave/listenhub-sdk@0.0.15': + resolution: {integrity: sha512-lvu+yCK/gMTW4/J+ao50sxvcN+qDmQKRXACmey1toNThHXJK2YwAX2g337JL4txc+V2OKvZ31bLhfGNz/RpehQ==} 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.13': + '@marswave/listenhub-sdk@0.0.15': dependencies: ky: 1.14.3 diff --git a/source/openapi/video.ts b/source/openapi/video.ts index 78ab18a..d2fc695 100644 --- a/source/openapi/video.ts +++ b/source/openapi/video.ts @@ -1,6 +1,10 @@ import type {Command} from 'commander'; import type { + OpenAPICreatePixVerseVideoParams, OpenAPICreateVideoGenerationParams, + OpenAPIEstimatePixVerseCreditsParams, + OpenAPIPixVerseAsset, + OpenAPIPixVerseOptions, OpenAPIVideoGenerationTaskDetail, OpenAPIVideoGenerationTaskStatus, } from '@marswave/listenhub-sdk'; @@ -13,6 +17,91 @@ function collect(value: string, previous: string[]): string[] { return [...previous, value]; } +const pixVerseCapabilities = [ + 'text_to_video', + 'image_to_video', + 'transition', + 'multi_transition', + 'fusion', + 'restyle', + 'mimic', + 'lip_sync', + 'agent', +] as const; + +const pixVerseModels = ['pixverse', 'v6', 'v5', 'v4.5'] as const; +const pixVerseLanguages = ['zh', 'en'] as const; +const pixVerseQualities = ['360p', '540p', '720p', '1080p'] as const; +const pixVerseAspectRatios = ['9:16', '16:9', '1:1', '4:3', '3:4'] as const; +const pixVerseAgentTypes = ['ad_master', 'promo_mix'] as const; + +/** + * Parse an asset spec of the form `url` or `url:duration` (duration in seconds). + * The URL may itself contain colons (e.g. https://...), so only a trailing + * `:` is treated as the duration. + */ +function parsePixVerseAsset(spec: string): OpenAPIPixVerseAsset { + const trimmed = spec.trim(); + const match = /^(.*?):(\d+)$/.exec(trimmed); + const url = match?.[1]; + const durationText = match?.[2]; + if (url !== undefined && durationText !== undefined && /^https?:\/\//.test(url)) { + return {url, duration: Number(durationText)}; + } + + return {url: trimmed}; +} + +function ensureEnum( + value: string | undefined, + allowed: readonly T[], + flag: string, +): T | undefined { + if (value === undefined) { + return undefined; + } + + if (!allowed.includes(value as T)) { + throw new Error(`${flag} must be one of: ${allowed.join(', ')}`); + } + + return value as T; +} + +type PixVerseGenerateOptions = { + capability: string; + model?: string; + language?: string; + prompt?: string; + quality?: string; + aspectRatio?: string; + duration?: string; + sourceTaskId?: string; + image: string[]; + video: string[]; + audio: string[]; + agentType?: string; + sourceVideoId?: string; + restyleId?: string; + lipSyncTts?: boolean; + lipSyncSpeakerId?: string; + lipSyncContent?: string; + pixverseJson?: string; + wait: boolean; + timeout: string; + json: boolean; +}; + +type PixVerseEstimateOptions = { + capability: string; + model?: string; + language?: string; + quality?: string; + duration?: string; + agentType?: string; + json: boolean; +}; + type VideoCreateOptions = { prompt: string; firstFrame?: string; @@ -365,4 +454,237 @@ export function register(openapi: Command) { handleError(error, options.json); } }); + + registerPixVerse(video); +} + +function registerPixVerse(video: Command) { + const pixverse = video + .command('pixverse') + .description('PixVerse video generation (Agent API: atomic capabilities + marketing agent)'); + + pixverse + .command('generate') + .description('Create a PixVerse video generation task') + .requiredOption('--capability ', `Capability: ${pixVerseCapabilities.join(', ')}`) + .option('--model ', `Model: ${pixVerseModels.join(', ')} (default pixverse)`) + .option('--language ', `Service region: ${pixVerseLanguages.join(', ')} (default en)`) + .option('--prompt ', 'Video description / prompt (max 2048 chars)') + .option('--quality ', `Quality: ${pixVerseQualities.join(', ')} (default 720p)`) + .option( + '--aspect-ratio ', + `Aspect ratio: ${pixVerseAspectRatios.join(', ')} (default 16:9)`, + ) + .option('--duration ', 'Video duration in seconds (1-60, default 5)') + .option('--source-task-id ', 'Reuse a prior succeeded PixVerse task (restyle / lip_sync)') + .option( + '--image ', + 'Image asset URL, optional :duration suffix (repeatable, max 10)', + collect, + [] as string[], + ) + .option( + '--video ', + 'Video asset URL, optional :duration suffix (repeatable, max 2)', + collect, + [] as string[], + ) + .option( + '--audio ', + 'Audio asset URL, optional :duration suffix (repeatable, max 1)', + collect, + [] as string[], + ) + .option( + '--agent-type ', + `Agent type: ${pixVerseAgentTypes.join(', ')} (capability=agent)`, + ) + .option('--source-video-id ', 'PixVerse source video id (restyle)') + .option('--restyle-id ', 'PixVerse restyle id (restyle)') + .option('--lip-sync-tts', 'Enable lip-sync TTS (capability=lip_sync)') + .option('--lip-sync-speaker-id ', 'Lip-sync TTS speaker id') + .option('--lip-sync-content ', 'Lip-sync TTS content') + .option( + '--pixverse-json ', + 'Escape hatch: JSON for the nested pixverse object (merged with flag-derived fields; flags win)', + ) + .option('--no-wait', 'Do not wait for task completion') + .option('--timeout ', 'Polling timeout in seconds', '1200') + .option('-j, --json', 'Output JSON', false) + .action(async (options: PixVerseGenerateOptions) => { + try { + const capability = ensureEnum(options.capability, pixVerseCapabilities, '--capability')!; + + if (options.image.length > 10) { + throw new Error('Maximum 10 images allowed'); + } + + if (options.video.length > 2) { + throw new Error('Maximum 2 videos allowed'); + } + + if (options.audio.length > 1) { + throw new Error('Maximum 1 audio allowed'); + } + + if (options.duration !== undefined) { + const dur = Number(options.duration); + if (!Number.isInteger(dur) || dur < 1 || dur > 60) { + throw new Error('--duration must be an integer between 1 and 60'); + } + } + + // Build nested pixverse object from the escape hatch first, then let + // dedicated flags override individual fields. + let pixVerseOptions: OpenAPIPixVerseOptions = {}; + if (options.pixverseJson) { + try { + pixVerseOptions = JSON.parse(options.pixverseJson) as OpenAPIPixVerseOptions; + } catch { + throw new Error('--pixverse-json must be valid JSON'); + } + } + + const agentType = ensureEnum(options.agentType, pixVerseAgentTypes, '--agent-type'); + if (agentType !== undefined) { + pixVerseOptions.agentType = agentType; + } + + if (options.sourceVideoId !== undefined) { + pixVerseOptions.sourceVideoId = options.sourceVideoId; + } + + if (options.restyleId !== undefined) { + pixVerseOptions.restyleId = options.restyleId; + } + + if (options.lipSyncTts) { + pixVerseOptions.lipSyncTtsSwitch = true; + } + + if (options.lipSyncSpeakerId !== undefined) { + pixVerseOptions.lipSyncTtsSpeakerId = options.lipSyncSpeakerId; + } + + if (options.lipSyncContent !== undefined) { + pixVerseOptions.lipSyncTtsContent = options.lipSyncContent; + } + + // lip_sync TTS: the server validator gates on the nested `tts` + // object while the provider reads the lipSyncTts* fields. Populate + // both from the --lip-sync-* flags so the TTS path passes validation + // end to end (skip if --pixverse-json already provided a tts object). + if ( + capability === 'lip_sync' && + !pixVerseOptions.tts && + options.lipSyncSpeakerId !== undefined && + options.lipSyncContent !== undefined + ) { + pixVerseOptions.tts = { + speakerId: options.lipSyncSpeakerId, + content: options.lipSyncContent, + }; + } + + const params: OpenAPICreatePixVerseVideoParams = { + capability, + model: ensureEnum(options.model, pixVerseModels, '--model'), + language: ensureEnum(options.language, pixVerseLanguages, '--language'), + prompt: options.prompt, + quality: ensureEnum(options.quality, pixVerseQualities, '--quality'), + aspectRatio: ensureEnum(options.aspectRatio, pixVerseAspectRatios, '--aspect-ratio'), + duration: options.duration === undefined ? undefined : Number(options.duration), + sourceTaskId: options.sourceTaskId, + images: options.image.length > 0 ? options.image.map(parsePixVerseAsset) : undefined, + videos: options.video.length > 0 ? options.video.map(parsePixVerseAsset) : undefined, + audios: options.audio.length > 0 ? options.audio.map(parsePixVerseAsset) : undefined, + pixverse: Object.keys(pixVerseOptions).length > 0 ? pixVerseOptions : undefined, + }; + + const client = await getOpenAPIClient(); + const created = await client.createPixVerseVideoGeneration(params); + const taskId = normalizeVideoTaskId(created.taskId); + + if (!options.wait) { + if (options.json) { + printJson(created); + } else { + console.log(`\u2713 PixVerse video generation task created: ${taskId}`); + } + + return; + } + + const result = await pollOpenAPI({ + getStatus: async () => client.getVideoGenerationTask(taskId), + isDone: (r) => r.status === 'success', + isFailed: (r) => r.status === 'failed', + getErrorMessage: () => 'PixVerse video generation failed', + options: { + timeout: Number(options.timeout), + label: 'Generating PixVerse video', + json: options.json, + }, + }); + + if (options.json) { + printJson(result); + } else { + printVideoDetail(result); + } + } catch (error) { + handleError(error, options.json); + } + }); + + pixverse + .command('estimate') + .description('Estimate credits for a PixVerse video generation task') + .requiredOption('--capability ', `Capability: ${pixVerseCapabilities.join(', ')}`) + .option('--model ', `Model: ${pixVerseModels.join(', ')} (default pixverse)`) + .option('--language ', `Service region: ${pixVerseLanguages.join(', ')} (default en)`) + .option('--quality ', `Quality: ${pixVerseQualities.join(', ')} (default 720p)`) + .option('--duration ', 'Video duration in seconds (1-60, default 5)') + .option( + '--agent-type ', + `Agent type: ${pixVerseAgentTypes.join(', ')} (capability=agent)`, + ) + .option('-j, --json', 'Output JSON', false) + .action(async (options: PixVerseEstimateOptions) => { + try { + const capability = ensureEnum(options.capability, pixVerseCapabilities, '--capability')!; + + if (options.duration !== undefined) { + const dur = Number(options.duration); + if (!Number.isInteger(dur) || dur < 1 || dur > 60) { + throw new Error('--duration must be an integer between 1 and 60'); + } + } + + const agentType = ensureEnum(options.agentType, pixVerseAgentTypes, '--agent-type'); + + const params: OpenAPIEstimatePixVerseCreditsParams = { + capability, + model: ensureEnum(options.model, pixVerseModels, '--model'), + language: ensureEnum(options.language, pixVerseLanguages, '--language'), + quality: ensureEnum(options.quality, pixVerseQualities, '--quality'), + duration: options.duration === undefined ? undefined : Number(options.duration), + pixverse: agentType === undefined ? undefined : {agentType}, + }; + + const client = await getOpenAPIClient(); + const result = await client.estimatePixVerseVideoCredits(params); + + if (options.json) { + printJson(result); + } else { + printDetail('PixVerse Credit Estimate', [ + ['Tokens', result.tokens], + ['Credits', result.credits], + ]); + } + } catch (error) { + handleError(error, options.json); + } + }); } diff --git a/tests/openapi/commands.test.ts b/tests/openapi/commands.test.ts index b34ae38..6994b25 100644 --- a/tests/openapi/commands.test.ts +++ b/tests/openapi/commands.test.ts @@ -24,6 +24,8 @@ const mockClient = vi.hoisted(() => ({ getVideoGenerationTask: vi.fn(), listVideoGenerationTasks: vi.fn(), estimateVideoCredits: vi.fn(), + createPixVerseVideoGeneration: vi.fn(), + estimatePixVerseVideoCredits: vi.fn(), createContentExtract: vi.fn(), getContentExtract: vi.fn(), getSubscription: vi.fn(), @@ -208,6 +210,261 @@ describe('video create', () => { }); }); +describe('video pixverse generate', () => { + it('text_to_video: passes capability + prompt with --no-wait and prints response JSON', async () => { + const created = {taskId: '6a2016607ebd26d050c585ca', status: 'generating'}; + mockClient.createPixVerseVideoGeneration.mockResolvedValue(created); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + [ + 'video', + 'pixverse', + 'generate', + '--capability', + 'text_to_video', + '--prompt', + 'A cat playing piano', + '--no-wait', + '--json', + ], + {from: 'user'}, + ); + + expect(mockClient.createPixVerseVideoGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'text_to_video', + prompt: 'A cat playing piano', + }), + ); + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(created, null, 2)); + }); + + it('image_to_video: parses --image url:duration into images asset array', async () => { + mockClient.createPixVerseVideoGeneration.mockResolvedValue({ + taskId: '6a201660b9fc373811288f09', + status: 'generating', + }); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + [ + 'video', + 'pixverse', + 'generate', + '--capability', + 'image_to_video', + '--image', + 'https://img.example.com/a.jpg:5', + '--image', + 'https://img.example.com/b.jpg', + '--quality', + '1080p', + '--aspect-ratio', + '9:16', + '--duration', + '10', + '--no-wait', + '--json', + ], + {from: 'user'}, + ); + + expect(mockClient.createPixVerseVideoGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'image_to_video', + quality: '1080p', + aspectRatio: '9:16', + duration: 10, + images: [ + {url: 'https://img.example.com/a.jpg', duration: 5}, + {url: 'https://img.example.com/b.jpg'}, + ], + }), + ); + }); + + it('lip_sync: builds nested pixverse object from lip-sync flags', async () => { + mockClient.createPixVerseVideoGeneration.mockResolvedValue({ + taskId: '6a201660b9fc373811288f10', + status: 'generating', + }); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + [ + 'video', + 'pixverse', + 'generate', + '--capability', + 'lip_sync', + '--source-task-id', + '6a201660b9fc373811288f00', + '--lip-sync-tts', + '--lip-sync-speaker-id', + 'speaker-1', + '--lip-sync-content', + 'Hello world', + '--no-wait', + '--json', + ], + {from: 'user'}, + ); + + expect(mockClient.createPixVerseVideoGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'lip_sync', + sourceTaskId: '6a201660b9fc373811288f00', + // lip_sync TTS must populate BOTH the nested `tts` object (which the + // server validator gates on) and the lipSyncTts* fields (which the + // provider reads), so the flag-driven path passes validation. + pixverse: { + lipSyncTtsSwitch: true, + lipSyncTtsSpeakerId: 'speaker-1', + lipSyncTtsContent: 'Hello world', + tts: {speakerId: 'speaker-1', content: 'Hello world'}, + }, + }), + ); + }); + + it('agent: merges --agent-type into a --pixverse-json escape hatch (flags win)', async () => { + mockClient.createPixVerseVideoGeneration.mockResolvedValue({ + taskId: '6a201660b9fc373811288f11', + status: 'generating', + }); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + [ + 'video', + 'pixverse', + 'generate', + '--capability', + 'agent', + '--agent-type', + 'promo_mix', + '--quality', + '1080p', + '--duration', + '30', + '--pixverse-json', + '{"motionMode":"smooth"}', + '--no-wait', + '--json', + ], + {from: 'user'}, + ); + + expect(mockClient.createPixVerseVideoGeneration).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'agent', + quality: '1080p', + duration: 30, + pixverse: {motionMode: 'smooth', agentType: 'promo_mix'}, + }), + ); + }); + + it('rejects an invalid --capability', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + ['video', 'pixverse', 'generate', '--capability', 'bogus', '--no-wait', '--json'], + {from: 'user'}, + ); + + expect(mockClient.createPixVerseVideoGeneration).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + errorSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); + +describe('video pixverse estimate', () => { + it('calls estimatePixVerseVideoCredits with capability/quality/duration and prints JSON', async () => { + const estimate = {tokens: 1400, credits: 14}; + mockClient.estimatePixVerseVideoCredits.mockResolvedValue(estimate); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + [ + 'video', + 'pixverse', + 'estimate', + '--capability', + 'text_to_video', + '--quality', + '720p', + '--duration', + '5', + '--json', + ], + {from: 'user'}, + ); + + expect(mockClient.estimatePixVerseVideoCredits).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'text_to_video', + quality: '720p', + duration: 5, + }), + ); + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify(estimate, null, 2)); + }); + + it('passes agentType under pixverse for capability=agent', async () => { + mockClient.estimatePixVerseVideoCredits.mockResolvedValue({tokens: 3000, credits: 30}); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + + const parent = makeParent(); + registerVideo(parent); + + await parent.parseAsync( + [ + 'video', + 'pixverse', + 'estimate', + '--capability', + 'agent', + '--agent-type', + 'ad_master', + '--duration', + '30', + '--json', + ], + {from: 'user'}, + ); + + expect(mockClient.estimatePixVerseVideoCredits).toHaveBeenCalledWith( + expect.objectContaining({ + capability: 'agent', + duration: 30, + pixverse: {agentType: 'ad_master'}, + }), + ); + }); +}); + describe('storybook create', () => { it('passes source URLs and speaker IDs to createStorybook', async () => { mockClient.createStorybook.mockResolvedValue({episodeId: 'ep-111'});