#!/usr/bin/env node /** * Grok Video Generator - 图生视频工具 * * 功能: * - 提交图生视频任务(Grok 模型) * - 轮询直到完成(60-240秒) * - 失败自动优化提示词重试(最多3次) * - 下载结果视频 * * 用法: * node grok-video-generator.js --image ./ref.jpg --prompt "zoom in slowly" -o ./output * node grok-video-generator.js batch ./tasks.json -o ./output */ const fs = require('fs') const path = require('path') const https = require('https') const http = require('http') // ============================================================================ // 配置 // ============================================================================ function loadConfig() { const configPath = path.join(__dirname, '..', '..', 'config.json') if (fs.existsSync(configPath)) { return JSON.parse(fs.readFileSync(configPath, 'utf-8')) } return {} } const cfg = loadConfig() const Config = { baseUrl: cfg.grokApiBaseUrl || 'https://yunwu.ai', apiKey: cfg.grokApiKey || '', model: cfg.grokModel || 'grok-video-3', pollInterval: 10000, // 轮询间隔 10 秒(Grok 慢) maxPollTime: 300000, // 单次最大等待 5 分钟 maxRetries: 3, // 失败重试次数 } // ============================================================================ // 提示词优化(失败时自动调整) // ============================================================================ const PromptOptimizer = { /** * 根据失败原因优化提示词 */ optimize(prompt, failReason, attempt) { let optimized = prompt // 第1次重试:简化提示词 if (attempt === 1) { optimized = simplifyPrompt(prompt) console.log(` 重试策略: 简化提示词`) } // 第2次重试:添加安全后缀 if (attempt === 2) { optimized = `${simplifyPrompt(prompt)}, smooth motion, high quality` console.log(` 重试策略: 简化 + 安全后缀`) } // 第3次重试:极简提示词 if (attempt >= 3) { optimized = extractCoreSubject(prompt) console.log(` 重试策略: 极简提示词`) } return optimized } } function simplifyPrompt(prompt) { // 去掉过长描述,保留核心部分 const parts = prompt.split(',').map(s => s.trim()) return parts.slice(0, 3).join(', ') } function extractCoreSubject(prompt) { // 提取第一个逗号或句号前的内容 const match = prompt.match(/^([^.!,]+)/) return match ? match[1].trim() : 'cinematic motion' } // ============================================================================ // API // ============================================================================ const GrokApi = { /** * 提交图生视频任务 */ async create(imageUrl, prompt, options = {}) { const { aspectRatio = '9:16', size = '720P', mode = 'custom', model = Config.model, } = options let finalPrompt = prompt.trim() if (mode && !finalPrompt.includes('--mode')) { finalPrompt = `${finalPrompt} --mode=${mode}` } const body = { model, prompt: finalPrompt, aspect_ratio: aspectRatio, size, images: [imageUrl], } console.log(`\n📡 提交 Grok 视频任务`) console.log(` 模型: ${model}`) console.log(` 提示词: ${finalPrompt.substring(0, 80)}...`) console.log(` 参考图: ${imageUrl.substring(0, 60)}...`) console.log(` 画幅: ${aspectRatio}`) const res = await fetch(`${Config.baseUrl}/v1/video/create`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${Config.apiKey}`, }, body: JSON.stringify(body), }) const result = await res.json() if (!result.id) { throw new Error(`Grok 提交失败: ${JSON.stringify(result)}`) } console.log(` 任务 ID: ${result.id}`) return result.id }, /** * 查询任务状态 */ async query(taskId) { const res = await fetch(`${Config.baseUrl}/v1/video/query?id=${taskId}`, { headers: { 'Authorization': `Bearer ${Config.apiKey}` }, }) return await res.json() }, /** * 轮询直到完成 */ async poll(taskId) { const startTime = Date.now() let lastProgress = 0 console.log(`\n⏳ 等待 Grok 视频生成(预计 60-240 秒)...`) while (Date.now() - startTime < Config.maxPollTime) { const task = await GrokApi.query(taskId) if (task.status === 'completed') { console.log(`\n✅ 视频生成完成!`) console.log(` 视频: ${task.video_url}`) return { success: true, videoUrl: task.video_url, thumbnailUrl: task.thumbnail_url || '', } } if (task.status === 'failed' || task.error) { throw new Error(task.error || task.message || 'Grok 生成失败') } const progress = task.progress || 0 if (progress !== lastProgress) { lastProgress = progress const elapsed = Math.round((Date.now() - startTime) / 1000) process.stdout.write(` 进度: ${progress}% 已等待: ${elapsed}s 状态: ${task.status}\r`) } await new Promise(r => setTimeout(r, Config.pollInterval)) } throw new Error(`Grok 生成超时 (${Config.maxPollTime / 1000}s)`) }, } // ============================================================================ // 图片下载工具 // ============================================================================ async function download(url, outputPath) { const protocol = url.startsWith('https') ? https : http return new Promise((resolve, reject) => { const file = fs.createWriteStream(outputPath) protocol.get(url, (response) => { if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { file.close() fs.unlinkSync(outputPath) return download(response.headers.location, outputPath).then(resolve).catch(reject) } response.pipe(file) file.on('finish', () => { file.close(); resolve(outputPath) }) }).on('error', (err) => { file.close() if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath) reject(err) }) }) } // ============================================================================ // 核心流程(带重试) // ============================================================================ async function generate(imageUrl, prompt, options = {}) { const { outputDir = './output', aspectRatio = '9:16', size = '720P' } = options if (!Config.apiKey) throw new Error('未配置 grokApiKey,请在 config.json 中添加') fs.mkdirSync(outputDir, { recursive: true }) let currentPrompt = prompt let lastError = null for (let attempt = 0; attempt <= Config.maxRetries; attempt++) { try { if (attempt > 0) { currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt) console.log(`\n🔄 第 ${attempt} 次重试`) console.log(` 新提示词: ${currentPrompt}`) } // 1. 提交 const taskId = await GrokApi.create(imageUrl, currentPrompt, { aspectRatio, size }) // 2. 轮询 const result = await GrokApi.poll(taskId) // 3. 下载 const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const videoFile = path.join(outputDir, `${timestamp}_grok.mp4`) await download(result.videoUrl, videoFile) console.log(` 下载完成: ${videoFile}`) // 下载缩略图(如有) let thumbnailFile = null if (result.thumbnailUrl) { thumbnailFile = path.join(outputDir, `${timestamp}_thumb.jpg`) try { await download(result.thumbnailUrl, thumbnailFile) } catch (_) {} } return { success: true, taskId, prompt: currentPrompt, originalPrompt: prompt, attempts: attempt + 1, files: [videoFile], thumbnail: thumbnailFile, } } catch (err) { lastError = err.message console.error(` ❌ 第 ${attempt + 1} 次失败: ${err.message}`) if (attempt < Config.maxRetries) { console.log(` 等待 5 秒后重试...`) await new Promise(r => setTimeout(r, 5000)) } } } throw new Error(`Grok 视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`) } /** * 并行批量生成:先同时提交所有任务,再并行轮询结果 * 5 张图 ~2 分钟全部完成(而非串行的 ~10 分钟) * * 输入格式(支持两种): * 1. tasks 数组: [{ image, prompt, text, videoPrompt }] * 2. manifest.json: { items: [{ file, url, text, videoPrompt, keyword, keywordColor }] } * * videoPrompt 由图片生成阶段一并产出,描述视频运动(如 "slow zoom in on subject") */ async function batchGenerate(tasks, options = {}) { const { outputDir = './output', aspectRatio = '9:16', size = '720P' } = options const concurrency = options.concurrency || 3 if (!Config.apiKey) throw new Error('未配置 grokApiKey,请在 config.json 中添加') fs.mkdirSync(outputDir, { recursive: true }) // 如果 tasks 是 manifest 格式,转换 if (tasks.items && Array.isArray(tasks.items)) { tasks = tasks.items.map(item => ({ image: item.url || item.image || '', prompt: item.videoPrompt || item.prompt || 'cinematic motion', text: item.text || item.caption || '', keyword: item.keyword || '', keywordColor: item.keywordColor || '', file: item.file || '', })) } // Phase 1: 并行提交所有任务(限制并发数) console.log(`\n📡 并行提交 ${tasks.length} 个视频任务(并发: ${concurrency})...`) const submitted = [] for (let i = 0; i < tasks.length; i += concurrency) { const batch = tasks.slice(i, i + concurrency) const batchResults = await Promise.allSettled( batch.map(async (task, j) => { const idx = i + j const prompt = task.videoPrompt || task.prompt console.log(` [${idx + 1}/${tasks.length}] 提交: ${prompt.substring(0, 50)}...`) try { const taskId = await GrokApi.create(task.image, prompt, { aspectRatio, size }) return { idx, taskId, task, error: null } } catch (err) { console.error(` [${idx + 1}] 提交失败: ${err.message}`) return { idx, taskId: null, task, error: err.message } } }) ) submitted.push(...batchResults.map(r => r.value || r.reason)) } const pendingTasks = submitted.filter(s => s.taskId) if (pendingTasks.length === 0) { console.error('\n❌ 所有任务提交失败') return tasks.map((task, idx) => ({ success: false, ...task, error: (submitted.find(s => s.idx === idx) || {}).error || '提交失败', })) } // Phase 2: 并行轮询所有已提交任务 console.log(`\n⏳ 并行等待 ${pendingTasks.length} 个视频生成...`) const pollResults = await Promise.allSettled( pendingTasks.map(async ({ idx, taskId, task }) => { const prompt = task.videoPrompt || task.prompt const result = await pollWithRetry(taskId, prompt, { outputDir, aspectRatio, size }) return { idx, ...result, task } }) ) // 合并结果 const results = [] for (let i = 0; i < tasks.length; i++) { const submittedInfo = submitted.find(s => s.idx === i) if (!submittedInfo || !submittedInfo.taskId) { results.push({ success: false, ...tasks[i], error: submittedInfo?.error || '提交失败' }) continue } const pollResult = pollResults.find(r => { if (r.status === 'fulfilled') return r.value.idx === i return false }) if (pollResult && pollResult.status === 'fulfilled') { results.push({ success: true, ...tasks[i], ...pollResult.value }) } else { const reason = pollResult?.reason?.message || '生成失败' results.push({ success: false, ...tasks[i], error: reason }) } } const ok = results.filter(r => r.success).length console.log(`\n✨ 批量完成: ${ok}/${tasks.length} 成功`) // 输出 manifest.json(供 capcut_assemble.js 使用,文案与视频一一对应) const manifestItems = results .filter(r => r.success && r.files && r.files.length > 0) .map(r => { const item = { file: path.basename(r.files[0]), duration: 6, // Grok 固定 6 秒 } // 保留原始文案(text 或 caption 字段) if (r.text) item.text = r.text if (r.caption) item.caption = r.caption if (r.keyword) item.keyword = r.keyword if (r.keywordColor) item.keywordColor = r.keywordColor return item }) if (manifestItems.length > 0 && !options.skipManifestWrite) { const manifestPath = path.join(outputDir, 'manifest.json') const manifest = { items: manifestItems } fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) console.log(` 已生成 manifest.json(${manifestItems.length} 条,文案与视频对应)`) } return results } /** * 轮询 + 失败重试(单任务) */ async function pollWithRetry(taskId, prompt, options = {}) { let currentTaskId = taskId let currentPrompt = prompt let lastError = null for (let attempt = 0; attempt <= Config.maxRetries; attempt++) { try { if (attempt > 0) { currentPrompt = PromptOptimizer.optimize(prompt, lastError, attempt) console.log(`\n 🔄 重试 (任务 ${currentTaskId.substring(0, 8)}...): ${currentPrompt.substring(0, 50)}`) currentTaskId = await GrokApi.create( options.imageUrl || '', currentPrompt, { aspectRatio: options.aspectRatio, size: options.size } ) } const result = await GrokApi.poll(currentTaskId) const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const videoFile = path.join(options.outputDir || './output', `${timestamp}_grok.mp4`) await download(result.videoUrl, videoFile) let thumbnailFile = null if (result.thumbnailUrl) { thumbnailFile = path.join(options.outputDir || './output', `${timestamp}_thumb.jpg`) try { await download(result.thumbnailUrl, thumbnailFile) } catch (_) {} } return { taskId: currentTaskId, prompt: currentPrompt, originalPrompt: prompt, attempts: attempt + 1, file: videoFile, files: [videoFile], duration: 6, thumbnail: thumbnailFile, } } catch (err) { lastError = err.message if (attempt < Config.maxRetries) { await new Promise(r => setTimeout(r, 5000)) } } } throw new Error(`重试 ${Config.maxRetries} 次后仍失败: ${lastError}`) } // ============================================================================ // CLI // ============================================================================ function showHelp() { console.log(` 🎬 Grok Video Generator - 图生视频工具 用法: node grok-video-generator.js --image --prompt "指令" [options] node grok-video-generator.js batch [options] 选项: -o, --output 输出目录 (默认: ./output) -a, --ar 宽高比 (默认: 9:16) -s, --size 分辨率 (默认: 720P) --mode 生成模式 (默认: custom) --model 模型名称 (默认: grok-video-3) --retries 失败重试次数 (默认: 3) -h, --help 帮助 示例: node grok-video-generator.js --image http://img.com/ref.jpg --prompt "zoom in" node grok-video-generator.js batch ./manifest.json -o ./videos manifest.json 格式(由生图阶段生成,含 videoPrompt): { "items": [ { "file": "img_001.png", "url": "http://...", // 图片 URL(OSS 上传后的地址) "text": "这段视频的字幕文案", // CapCut 字幕 "keyword": "关键词", // 字幕高亮词 "videoPrompt": "slow zoom in on subject, cinematic" // 视频运动提示词 } ] } videoPrompt 在生图阶段由 AI 一并生成,描述视频运动而非图片内容。 批量完成后自动输出 manifest.json(含 text/duration),供 capcut_assemble.js 直接使用。 `) } async function main() { const args = process.argv.slice(2) if (args.includes('-h') || args.includes('--help') || args.length === 0) { showHelp() return } let command = 'single' let params = [] const options = { outputDir: './output', aspectRatio: '9:16', size: '720P', mode: 'custom', imageUrl: '', prompt: '', } let i = 0 if (args[0] === 'batch') { command = 'batch' i = 1 } while (i < args.length) { const arg = args[i] if (arg === '-o' || arg === '--output') { options.outputDir = args[++i] } else if (arg === '-a' || arg === '--ar') { options.aspectRatio = args[++i] } else if (arg === '-s' || arg === '--size') { options.size = args[++i] } else if (arg === '--mode') { options.mode = args[++i] } else if (arg === '--image') { options.imageUrl = args[++i] } else if (arg === '--prompt') { options.prompt = args[++i] } else if (arg === '--retries') { Config.maxRetries = parseInt(args[++i], 10) } else { params.push(arg) } i++ } if (command === 'batch') { const filePath = params[0] if (!filePath || !fs.existsSync(filePath)) { console.error('请提供 tasks.json 路径') process.exit(1) } const tasks = JSON.parse(fs.readFileSync(filePath, 'utf-8')) await batchGenerate(tasks, options) } else { if (!options.imageUrl) { console.error('请提供 --image 参数(图片 URL)') process.exit(1) } if (!options.prompt) { console.error('请提供 --prompt 参数') process.exit(1) } await generate(options.imageUrl, options.prompt, options) } } // ============================================================================ // 导出 // ============================================================================ module.exports = { generate, batchGenerate, pollWithRetry, GrokApi, PromptOptimizer } if (require.main === module) { main().catch(err => { console.error(`\n❌ 错误: ${err.message}`) process.exit(1) }) }