Files
video-create/.claude/skills/video-from-script/scripts/lib/phase-videos.js
sion123 163a83ab6d feat(video-from-script): 新增产品宣传片账户配置及 Ken Burns 效果支持
- 添加 product_viral_factory 账户配置,支持产品宣传片自动生成
- 集成 Ken Burns 效果到组装流程
- 优化视频生成阶段的空结果诊断和修复提示
- 改进 manifest 保存逻辑,处理 EPERM 权限错误
- 添加 .claudeignore 忽略生成文件
2026-05-12 01:28:40 +08:00

215 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* Phase: videos — 视频生成VEO / Grok / Kling
*
* 图生视频,批量提交,生成后自动上传 OSS
* 支持 task ID 恢复:中断后重跑时优先恢复已有任务
*/
const path = require('path')
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
async function phaseVideos(manifest, manifestPath, options) {
const dir = getManifestDir(manifestPath)
const videosDir = path.join(dir, 'videos')
ensureDir(videosDir)
const accountConfig = options.accountConfig || {}
const videoModel = manifest.videoModel || accountConfig.videoModel || 'veo3-fast-frames'
// 筛选需要生视频的 item
// done — 正常流程,图片已确认且已上传
// pending / failed — 重试场景agent 只需将 item 设为 pending 即可触发再生
// 前提:有 url图片已上传+ videoPrompt且 confirmed 未被显式拒绝
const videoCandidates = manifest.items.filter(it => {
if (it.confirmed === false) return false
if (!it.url || !it.videoPrompt) return false
return ['done', 'pending', 'failed'].includes(it.status)
})
if (videoCandidates.length === 0) {
console.log("\n⚠ [videos] 没有符合条件的 item 进入视频生成阶段")
console.log(" manifest 中共有", manifest.items.length, "个 item逐一诊断:")
for (const it of manifest.items) {
const reasons = []
if (it.confirmed === false) reasons.push("confirmed=false")
if (!it.url) reasons.push("缺少 url图片未上传")
if (!it.videoPrompt) reasons.push("缺少 videoPrompt")
if (it.confirmed !== false && it.url && it.videoPrompt && !["done","pending","failed"].includes(it.status)) {
reasons.push("status=" + (it.status || "undefined") + "(不在 done/pending/failed 中)")
}
console.log(" - item", it.id || manifest.items.indexOf(it), ":", reasons.length > 0 ? reasons.join(", ") : "已满足全部条件(不应在此)")
}
console.log("\n 修复命令:")
console.log(" node .claude/skills/video-from-script/scripts/pipeline.js confirm --manifest", manifestPath, "--all")
console.log()
}
// 已有视频(本地或 OSS且状态为 done 的跳过,其余清理后重新生成
const items = []
for (const it of videoCandidates) {
if (it.video || it.videoUrl) {
if (it.status === 'done') continue
delete it.video
delete it.videoUrl
delete it.videoDuration
delete it.videoTaskId
}
items.push(it)
}
if (items.length === 0) { log('videos', '无待处理 item跳过'); return }
// 选择生成器
let Api, pollFn
const modelLower = videoModel.toLowerCase()
if (modelLower.includes('grok')) {
const gen = require('../grok-video-generator')
Api = gen.GrokApi; pollFn = gen.pollWithRetry
} else if (modelLower.includes('kling')) {
const gen = require('../kling-video-generator')
Api = gen.KlingApi; pollFn = gen.pollWithRetry
} else {
const gen = require('../veo-video-generator')
Api = gen.VeoApi; pollFn = gen.pollWithRetry
}
const ratio = manifest.format || '9:16'
log('videos', `${items.length} 个, 模型: ${videoModel}`)
// Phase 1: 恢复已有任务(有 videoTaskId 的 item
const recovered = []
const needSubmit = []
for (const item of items) {
if (item.videoTaskId) {
recovered.push(item)
} else {
needSubmit.push(item)
}
}
if (recovered.length > 0) {
log('videos', `尝试恢复 ${recovered.length} 个中断任务...`)
await Promise.allSettled(
recovered.map(async (item) => {
try {
log('videos', ` 恢复 item ${item.id}: ${item.videoTaskId}`)
const result = await pollFn(item.videoTaskId, item.videoPrompt, {
outputDir: videosDir,
aspectRatio: ratio,
imageUrl: item.url,
lastFrameUrl: item.lastFrameUrl || '',
})
if (result.file) {
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
item.videoDuration = result.duration
item.status = 'done'
delete item.videoTaskId
log('videos', ` item ${item.id} 恢复成功`)
}
} catch (err) {
log('videos', ` item ${item.id} 恢复失败: ${err.message},将重新提交`)
delete item.videoTaskId
needSubmit.push(item)
}
})
)
saveManifest(manifestPath, manifest)
}
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
// Phase 2: 提交新任务(并发 3
const concurrency = 3
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
const submitted = []
for (let i = 0; i < needSubmit.length; i += concurrency) {
const batch = needSubmit.slice(i, i + concurrency)
const batchResults = await Promise.allSettled(
batch.map(async (item) => {
const extraOpts = item.lastFrameUrl
? { aspectRatio: ratio, lastFrameUrl: item.lastFrameUrl, mode: 'pro' }
: { aspectRatio: ratio }
try {
const taskId = await Api.create(item.url, item.videoPrompt, extraOpts)
return { item, taskId, error: null }
} catch (err) {
return { item, taskId: null, error: err.message }
}
})
)
for (const r of batchResults) {
const val = r.status === 'fulfilled' ? r.value : { item: null, taskId: null, error: r.reason }
submitted.push(val)
if (val.item && val.taskId) {
val.item.videoTaskId = val.taskId
}
}
saveManifest(manifestPath, manifest)
}
// Phase 3: 轮询新任务
const pending = submitted.filter(s => s.taskId)
if (pending.length === 0) {
log('videos', '所有任务提交失败')
for (const s of submitted) {
if (s.item) { s.item.status = 'failed'; s.item.error = s.error || '提交失败' }
}
saveManifest(manifestPath, manifest)
return
}
log('videos', `等待 ${pending.length} 个视频生成...`)
const pollResults = await Promise.allSettled(
pending.map(async ({ item, taskId }) => {
try {
const result = await pollFn(taskId, item.videoPrompt, {
outputDir: videosDir,
aspectRatio: ratio,
imageUrl: item.url,
lastFrameUrl: item.lastFrameUrl || '',
})
return { item, result, ok: true }
} catch (err) {
return { item, error: err.message, ok: false }
}
})
)
for (const r of pollResults) {
const val = r.status === 'fulfilled' ? r.value : { ok: false, error: r.reason?.message }
if (val.ok && val.result.file) {
val.item.video = path.relative(dir, val.result.file).replace(/\\/g, '/')
val.item.videoDuration = val.result.duration
val.item.status = 'done'
delete val.item.videoTaskId
} else if (val.item) {
val.item.status = 'failed'
val.item.error = val.error || '视频生成未返回文件'
delete val.item.videoTaskId
}
}
saveManifest(manifestPath, manifest)
// 上传视频到 OSS
const { uploadFile } = require('../oss-upload')
const videoItems = manifest.items.filter(it => it.video && !it.videoUrl)
if (videoItems.length > 0) {
log('videos', `上传 ${videoItems.length} 个视频到 OSS...`)
for (const item of videoItems) {
const videoPath = path.resolve(dir, item.video)
try {
const { url } = await uploadFile(videoPath)
item.videoUrl = url
log('videos', ` ${item.video} → OK`)
} catch (err) {
log('videos', ` ${item.video} 上传失败: ${err.message}`)
}
}
saveManifest(manifestPath, manifest)
}
}
module.exports = { phaseVideos }