init: video-create project with skills and accounts
This commit is contained in:
580
.claude/skills/video-from-script/scripts/grok-video-generator.js
Normal file
580
.claude/skills/video-from-script/scripts/grok-video-generator.js
Normal file
@@ -0,0 +1,580 @@
|
||||
#!/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://...", // 图片 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user