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

595 lines
19 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
/**
* VEO Video Generator - 图生视频工具Google Veo 模型)
*
* 功能:
* - 提交图生视频任务Veo2/Veo3 模型)
* - 支持 enhance_prompt中文提示词自动转英文
* - 支持 enable_upsample超分辨率
* - 轮询直到完成60-300秒
* - 失败自动优化提示词重试最多3次
* - 批量并行生成 + manifest.json 文案透传
*
* 用法:
* node veo-video-generator.js --image ./ref.jpg --prompt "zoom in slowly" -o ./output
* node veo-video-generator.js batch ./manifest.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.veoApiBaseUrl,
apiKey: cfg.veoApiKey || '',
model: cfg.veoModel || 'veo3-fast-frames',
enhancePrompt: cfg.veoEnhancePrompt !== undefined ? cfg.veoEnhancePrompt : true,
enableUpsample: cfg.veoEnableUpsample !== undefined ? cfg.veoEnableUpsample : true,
pollInterval: 10000,
maxPollTime: 600000, // 单次最大等待 10 分钟Veo3 可能更慢)
maxRetries: 3,
}
// 模型图片数量限制
const MODEL_IMAGE_LIMIT = {
'veo2': 1,
'veo2-fast': 1,
'veo3-fast': 1,
'veo3-fast-frames': 1,
}
// veo3 只支持 16:9 和 9:16
const VEO3_RATIOS = ['16:9', '9:16']
// ============================================================================
// 提示词优化(失败时自动调整)
// ============================================================================
const PromptOptimizer = {
optimize(prompt, failReason, attempt) {
let optimized = prompt
if (attempt === 1) {
optimized = simplifyPrompt(prompt)
console.log(` 重试策略: 简化提示词`)
}
if (attempt === 2) {
optimized = `${simplifyPrompt(prompt)}, smooth motion, high quality`
console.log(` 重试策略: 简化 + 安全后缀`)
}
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 VeoApi = {
async create(imageUrl, prompt, options = {}) {
const {
aspectRatio = '9:16',
model = Config.model,
enhancePrompt = Config.enhancePrompt,
enableUpsample = Config.enableUpsample,
lastFrameUrl = '', // 首尾帧模式:结束帧 URL
} = options
// veo3 画幅校验
if (model.includes('veo3') && !VEO3_RATIOS.includes(aspectRatio)) {
throw new Error(`veo3 模型仅支持 ${VEO3_RATIOS.join('/')} 画幅`)
}
// 单图模式: [imageUrl],首尾帧模式: [firstFrame, lastFrame]
const images = []
if (imageUrl) images.push(imageUrl)
if (lastFrameUrl) images.push(lastFrameUrl)
const mode = lastFrameUrl ? '首尾帧' : '单图'
const body = {
model,
prompt,
images,
enhance_prompt: enhancePrompt,
enable_upsample: enableUpsample,
aspect_ratio: aspectRatio,
}
console.log(`\n📡 提交 VEO 视频任务 [${mode}]`)
console.log(` 模型: ${model}`)
console.log(` 提示词: ${prompt.substring(0, 80)}...`)
if (lastFrameUrl) {
console.log(` 起始帧: ${imageUrl.substring(0, 60)}...`)
console.log(` 结束帧: ${lastFrameUrl.substring(0, 60)}...`)
} else {
console.log(` 参考图: ${imageUrl ? imageUrl.substring(0, 60) + '...' : '无'}`)
}
console.log(` 画幅: ${aspectRatio}`)
console.log(` 中文增强: ${enhancePrompt}`)
console.log(` 超分: ${enableUpsample}`)
const res = await fetch(`${Config.baseUrl}/v1/video/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${Config.apiKey}`,
},
body: JSON.stringify(body),
})
const result = await res.json()
if (!result.id) {
throw new Error(`VEO 提交失败: ${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}`,
'Accept': 'application/json',
},
})
return await res.json()
},
async poll(taskId) {
const startTime = Date.now()
let lastProgress = 0
console.log(`\n⏳ 等待 VEO 视频生成(预计 60-300 秒)...`)
while (Date.now() - startTime < Config.maxPollTime) {
const task = await VeoApi.query(taskId)
if (task.status === 'completed') {
console.log(`\n✅ 视频生成完成!`)
console.log(` 视频: ${task.video_url}`)
return {
success: true,
videoUrl: task.video_url,
}
}
if (task.status === 'failed') {
throw new Error(task.error || task.message || 'VEO 生成失败')
}
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(`VEO 生成超时 (${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 = '16:9' } = options
if (!Config.apiKey) throw new Error('未配置 veoApiKey请在 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}`)
}
const taskId = await VeoApi.create(imageUrl, currentPrompt, { aspectRatio, lastFrameUrl: options.lastFrameUrl })
const result = await VeoApi.poll(taskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const videoFile = path.join(outputDir, `${timestamp}_veo.mp4`)
await download(result.videoUrl, videoFile)
console.log(` 下载完成: ${videoFile}`)
return {
success: true,
taskId,
prompt: currentPrompt,
originalPrompt: prompt,
attempts: attempt + 1,
files: [videoFile],
}
} 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(`VEO 视频生成失败(已重试 ${Config.maxRetries} 次): ${lastError}`)
}
// ============================================================================
// 批量并行生成(支持 manifest.json 输入输出)
// ============================================================================
async function batchGenerate(tasks, options = {}) {
const { outputDir = './output' } = options
let aspectRatio = options.aspectRatio || '16:9'
const concurrency = options.concurrency || 2
if (!Config.apiKey) throw new Error('未配置 veoApiKey请在 config.json 中添加')
fs.mkdirSync(outputDir, { recursive: true })
// 支持 manifest 格式
if (tasks.items && Array.isArray(tasks.items)) {
// manifest 级画幅format > defaultFormat > 命令行默认值
if (tasks.format || tasks.defaultFormat) {
aspectRatio = tasks.format || tasks.defaultFormat || aspectRatio
}
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 || '',
lastFrameUrl: item.lastFrameUrl || '', // 首尾帧模式
}))
}
// Phase 1: 并行提交
const mode = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图'
console.log(`\n📡 并行提交 ${tasks.length} 个 VEO 视频任务(并发: ${concurrency},模式: ${mode}...`)
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 VeoApi.create(task.image, prompt, { aspectRatio, lastFrameUrl: task.lastFrameUrl })
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, imageUrl: task.image, lastFrameUrl: task.lastFrameUrl })
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
const manifestItems = results
.filter(r => r.success && r.files && r.files.length > 0)
.map(r => {
const item = {
file: path.basename(r.files[0]),
duration: 8, // Veo 默认 ~8 秒
}
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')
fs.writeFileSync(manifestPath, JSON.stringify({ items: manifestItems }, 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 VeoApi.create(
options.imageUrl || '',
currentPrompt,
{ aspectRatio: options.aspectRatio, lastFrameUrl: options.lastFrameUrl || '' }
)
}
const result = await VeoApi.poll(currentTaskId)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const videoFile = path.join(options.outputDir || './output', `${timestamp}_veo.mp4`)
await download(result.videoUrl, videoFile)
return {
taskId: currentTaskId,
prompt: currentPrompt,
originalPrompt: prompt,
attempts: attempt + 1,
file: videoFile,
files: [videoFile],
duration: 8,
}
} 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(`
🎬 VEO Video Generator - 图生视频工具Google Veo 模型)
用法:
node veo-video-generator.js --image <url> --prompt "指令" [options]
node veo-video-generator.js --image <url> --last-frame <url> --prompt "过渡" [options]
node veo-video-generator.js batch <manifest.json> [options]
选项:
-o, --output <dir> 输出目录 (默认: ./output)
-a, --ar <ratio> 宽高比 (veo3 仅 16:9/9:16默认: 16:9)
--model <model> 模型: veo2/veo2-fast/veo3-fast/veo3-fast-frames (默认: veo3-fast-frames)
--last-frame <url> 结束帧 URL首尾帧模式
--no-enhance 关闭中文提示词增强
--no-upsample 关闭超分辨率
--retries <n> 失败重试次数 (默认: 3)
-h, --help 帮助
模式:
单图模式: --image <url> --prompt "运动描述"
首尾帧模式: --image <首帧url> --last-frame <尾帧url> --prompt "过渡描述"
示例:
# 单图
node veo-video-generator.js --image http://img.com/ref.jpg --prompt "zoom in" -a 16:9
node veo-video-generator.js --image http://img.com/ref.jpg --prompt "缓慢放大" -a 9:16
# 首尾帧
node veo-video-generator.js --image http://img.com/first.jpg --last-frame http://img.com/last.jpg --prompt "从静止到运动" -a 16:9
# 批量(自动检测单图/首尾帧)
node veo-video-generator.js batch ./manifest.json -o ./videos
manifest.json首尾帧模式由生图阶段生成:
{
"mode": "framePair",
"items": [
{
"file": "scene_01_first.png",
"url": "http://...",
"lastFrame": "scene_01_last.png",
"lastFrameUrl": "http://...",
"text": "字幕文案",
"videoPrompt": "machines start up, cinematic transition"
}
]
}
`)
}
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',
imageUrl: '',
lastFrameUrl: '',
prompt: '',
enhancePrompt: Config.enhancePrompt,
enableUpsample: Config.enableUpsample,
}
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 === '--model') {
Config.model = args[++i]
} else if (arg === '--no-enhance') {
options.enhancePrompt = false
} else if (arg === '--no-upsample') {
options.enableUpsample = false
} else if (arg === '--image') {
options.imageUrl = args[++i]
} else if (arg === '--last-frame') {
options.lastFrameUrl = 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('请提供 manifest.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, VeoApi, PromptOptimizer }
if (require.main === module) {
main().catch(err => {
console.error(`\n❌ 错误: ${err.message}`)
process.exit(1)
})
}