Files
video-create/.claude/skills/video-from-script/scripts/grok-video-generator.js

581 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <url> --prompt "指令" [options]
node grok-video-generator.js batch <manifest.json|tasks.json> [options]
选项:
-o, --output <dir> 输出目录 (默认: ./output)
-a, --ar <ratio> 宽高比 (默认: 9:16)
-s, --size <size> 分辨率 (默认: 720P)
--mode <mode> 生成模式 (默认: custom)
--model <model> 模型名称 (默认: grok-video-3)
--retries <n> 失败重试次数 (默认: 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://...", // 图片 URLOSS 上传后的地址)
"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)
})
}