Compare commits
8 Commits
feat/agent
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b829521492 | ||
| ac6f110f28 | |||
| 65af6c92fc | |||
| 4495ea8af1 | |||
| 44ab03e81c | |||
| 051b77b80c | |||
| 8f3c145db9 | |||
| 83668cea3c |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"jianyingDraftPath": "/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
|
"jianyingDraftPath": "/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft",
|
||||||
"capcutMateDir": "C:/Users/45070/capcut-mate",
|
"capcutMateDir": "/Users/lc/capcut-mate",
|
||||||
"capcutMateApiBase": "http://capcut.muyetools.cn/openapi/capcut-mate/v1",
|
"capcutMateApiBase": "http://capcut.muyetools.cn/openapi/capcut-mate/v1",
|
||||||
"imgbbApiKey": "deprecated",
|
"imgbbApiKey": "deprecated",
|
||||||
"geminiApiBaseUrl": "https://yunwu.ai",
|
"geminiApiBaseUrl": "https://yunwu.ai",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"id": "cosyvoice-v3-plus-bailian-155c1d86a5564d4ca981147d79e309b1",
|
"id": "cosyvoice-v3-plus-bailian-155c1d86a5564d4ca981147d79e309b1",
|
||||||
"model": "cosyvoice-v3-plus",
|
"model": "cosyvoice-v3-plus",
|
||||||
"instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
|
"instruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
|
||||||
"style": "沉稳有力男声(v3-plus模型)"
|
"style": "沉稳有力男声"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "六沉",
|
"name": "六沉",
|
||||||
@@ -52,6 +52,13 @@
|
|||||||
"model": "cosyvoice-v3.5-plus",
|
"model": "cosyvoice-v3.5-plus",
|
||||||
"instruction": "音量由正常对话迅速增强至高喊,性格直率,情绪易激动且外露",
|
"instruction": "音量由正常对话迅速增强至高喊,性格直率,情绪易激动且外露",
|
||||||
"style": "直率激动,由低到高"
|
"style": "直率激动,由低到高"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "谢尔比",
|
||||||
|
"id": "cosyvoice-v3.5-plus-bailian-2e272ca7a5784f0b8e79013c891fe23e",
|
||||||
|
"model": "cosyvoice-v3.5-plus",
|
||||||
|
"instruction": "用一种过来人的口吻说话,像是经历了太多懒得废话的大哥,语气里带点漫不经心和不耐烦。声音不用太用力,轻描淡写但每个字都砸在点上。偶尔轻哼一口气,显得很松弛。",
|
||||||
|
"style": "漫不经心,松弛有力"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,19 @@ const { SKILLS_DIR, ACCOUNTS_DIR, loadConfig, resolveVoice } = require('./lib/pi
|
|||||||
// output/ 在项目根的父级(美图/output/)
|
// output/ 在项目根的父级(美图/output/)
|
||||||
const OUTPUT_BASE = path.join(SKILLS_DIR, '..', '..', '..', 'output')
|
const OUTPUT_BASE = path.join(SKILLS_DIR, '..', '..', '..', 'output')
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 工具函数
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** 从 batch item 解析草稿地址:自身 draftUrl 优先,否则读子任务 manifest */
|
||||||
|
function resolveDraftUrl(item) {
|
||||||
|
if (item.draftUrl) return item.draftUrl
|
||||||
|
if (!item.manifestPath) return ''
|
||||||
|
try {
|
||||||
|
return readJson(path.resolve(item.manifestPath)).draftUrl || ''
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CLI 参数解析
|
// CLI 参数解析
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -259,7 +272,11 @@ function cmdMark(args) {
|
|||||||
if (args.draftName) item.draftName = args.draftName
|
if (args.draftName) item.draftName = args.draftName
|
||||||
if (args.forwardCopy) item.forwardCopy = args.forwardCopy
|
if (args.forwardCopy) item.forwardCopy = args.forwardCopy
|
||||||
if (args.hashtags) item.hashtags = args.hashtags
|
if (args.hashtags) item.hashtags = args.hashtags
|
||||||
if (args.draftUrl) item.draftUrl = args.draftUrl
|
if (args.draftUrl) {
|
||||||
|
item.draftUrl = args.draftUrl
|
||||||
|
} else if (args.status === 'completed' && !item.draftUrl) {
|
||||||
|
item.draftUrl = resolveDraftUrl(item) || undefined
|
||||||
|
}
|
||||||
|
|
||||||
batch.stats = calcStats(batch.items)
|
batch.stats = calcStats(batch.items)
|
||||||
writeJson(manifestPath, batch)
|
writeJson(manifestPath, batch)
|
||||||
@@ -304,6 +321,11 @@ function cmdNext(args) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原子标记为 processing,防止同一行被重复取出
|
||||||
|
item.status = 'processing'
|
||||||
|
batch.stats = calcStats(batch.items)
|
||||||
|
writeJson(manifestPath, batch)
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
done: false,
|
done: false,
|
||||||
row: item.row,
|
row: item.row,
|
||||||
@@ -359,19 +381,18 @@ function cmdExport(args) {
|
|||||||
}
|
}
|
||||||
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
|
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
|
||||||
|
|
||||||
// 选题列:topicA(方案A)> 旧字段 topic > 原 title
|
|
||||||
const topicDisplay = item.topicA || item.topic || item.title || ''
|
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
row: item.row,
|
row: item.row,
|
||||||
选题: topicDisplay,
|
选题一: item.topicA || item.topic || item.title || '',
|
||||||
|
选题二: item.topicB || '',
|
||||||
脚本: script,
|
脚本: script,
|
||||||
账号: item.account,
|
账号: item.account,
|
||||||
模式: item.mode,
|
模式: item.mode,
|
||||||
音色: item.voice || '',
|
音色: item.voice || '',
|
||||||
转发文案带话题: forwardFull,
|
转发文案带话题: forwardFull,
|
||||||
草稿名称: item.draftName || '',
|
草稿名称: item.draftName || '',
|
||||||
草稿地址: item.draftUrl || '',
|
草稿地址: resolveDraftUrl(item),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,19 +400,26 @@ function cmdExport(args) {
|
|||||||
rows.sort((a, b) => a.row - b.row)
|
rows.sort((a, b) => a.row - b.row)
|
||||||
|
|
||||||
const format = args.format || 'csv'
|
const format = args.format || 'csv'
|
||||||
const dateStr = formatDate(new Date())
|
|
||||||
const baseName = path.basename(manifestPath, '.json')
|
// 生成文件名:账号名_MMDD_起号~止号
|
||||||
|
const accountName = rows[0]?.账号 || batch.defaultAccount || 'unknown'
|
||||||
|
const dateMMDD = formatDate(new Date()).slice(4) // MMDD
|
||||||
|
const rowNums = rows.map(r => r.row)
|
||||||
|
const rowStart = String(Math.min(...rowNums)).padStart(2, '0')
|
||||||
|
const rowEnd = String(Math.max(...rowNums)).padStart(2, '0')
|
||||||
|
const exportName = `${accountName}_${dateMMDD}_${rowStart}~${rowEnd}`
|
||||||
|
|
||||||
if (format === 'xlsx') {
|
if (format === 'xlsx') {
|
||||||
exportXlsx(manifestPath, rows)
|
exportXlsx(manifestPath, rows, exportName)
|
||||||
} else {
|
} else {
|
||||||
exportCsv(manifestPath, rows)
|
exportCsv(manifestPath, rows, exportName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCsv(manifestPath, rows) {
|
function exportCsv(manifestPath, rows, exportName) {
|
||||||
const outPath = manifestPath.replace('.json', '_export.csv')
|
const batchDir = path.dirname(manifestPath)
|
||||||
const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址']
|
const outPath = path.join(batchDir, `${exportName}.csv`)
|
||||||
|
const headers = ['选题一', '选题二', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址']
|
||||||
|
|
||||||
const lines = [headers.join(',')]
|
const lines = [headers.join(',')]
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
@@ -415,10 +443,10 @@ function exportCsv(manifestPath, rows) {
|
|||||||
printTable(rows, headers)
|
printTable(rows, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportXlsx(manifestPath, rows) {
|
function exportXlsx(manifestPath, rows, exportName) {
|
||||||
try {
|
try {
|
||||||
const XLSX = require('xlsx')
|
const XLSX = require('xlsx')
|
||||||
const headers = ['选题', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址']
|
const headers = ['选题一', '选题二', '脚本', '账号', '模式', '音色', '转发文案带话题', '草稿名称', '草稿地址']
|
||||||
const data = rows.map(r => headers.map(h => r[h] || ''))
|
const data = rows.map(r => headers.map(h => r[h] || ''))
|
||||||
data.unshift(headers)
|
data.unshift(headers)
|
||||||
|
|
||||||
@@ -426,7 +454,8 @@ function exportXlsx(manifestPath, rows) {
|
|||||||
const wb = XLSX.utils.book_new()
|
const wb = XLSX.utils.book_new()
|
||||||
XLSX.utils.book_append_sheet(wb, ws, '视频清单')
|
XLSX.utils.book_append_sheet(wb, ws, '视频清单')
|
||||||
|
|
||||||
const outPath = manifestPath.replace('.json', '_export.xlsx')
|
const batchDir = path.dirname(manifestPath)
|
||||||
|
const outPath = path.join(batchDir, `${exportName}.xlsx`)
|
||||||
XLSX.writeFile(wb, outPath)
|
XLSX.writeFile(wb, outPath)
|
||||||
console.log(`表格已导出: ${outPath}`)
|
console.log(`表格已导出: ${outPath}`)
|
||||||
console.log(` 共 ${rows.length} 条记录`)
|
console.log(` 共 ${rows.length} 条记录`)
|
||||||
@@ -436,7 +465,7 @@ function exportXlsx(manifestPath, rows) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'MODULE_NOT_FOUND') {
|
if (err.code === 'MODULE_NOT_FOUND') {
|
||||||
console.warn('xlsx 模块未安装,改用 CSV 格式')
|
console.warn('xlsx 模块未安装,改用 CSV 格式')
|
||||||
exportCsv(manifestPath, rows)
|
exportCsv(manifestPath, rows, exportName)
|
||||||
} else {
|
} else {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,8 +388,14 @@ async function generate(imageUrl, prompt, options = {}) {
|
|||||||
// 批量并行生成(支持 manifest.json)
|
// 批量并行生成(支持 manifest.json)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
function isQuotaError(msg) {
|
||||||
|
if (!msg) return false
|
||||||
|
const s = msg.toLowerCase()
|
||||||
|
return /quota|limit|exceed|insufficient|余额|额度|超限|rate.?limit|too.?many/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
async function batchGenerate(tasks, options = {}) {
|
async function batchGenerate(tasks, options = {}) {
|
||||||
const { outputDir = './output', concurrency = 2, duration = 5, mode = 'std' } = options
|
const { outputDir = './output', concurrency = 5, duration = 5, mode = 'std' } = options
|
||||||
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true })
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
|
||||||
@@ -406,14 +412,19 @@ async function batchGenerate(tasks, options = {}) {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: 并行提交
|
// 严格并发 ≤ 5:每批提交后等轮询完成再提交下一批
|
||||||
|
const batchSize = Math.min(concurrency, 5)
|
||||||
const modeLabel = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图'
|
const modeLabel = tasks.some(t => t.lastFrameUrl) ? '首尾帧' : '单图'
|
||||||
console.log(`\n📡 并行提交 ${tasks.length} 个可灵视频任务(并发: ${concurrency},模式: ${modeLabel})...`)
|
console.log(`\n📡 并行提交 ${tasks.length} 个可灵视频任务(并发: ${batchSize},模式: ${modeLabel})...`)
|
||||||
|
|
||||||
const submitted = []
|
const results = []
|
||||||
for (let i = 0; i < tasks.length; i += concurrency) {
|
|
||||||
const batch = tasks.slice(i, i + concurrency)
|
for (let i = 0; i < tasks.length; i += batchSize) {
|
||||||
const batchResults = await Promise.allSettled(
|
const batch = tasks.slice(i, i + batchSize)
|
||||||
|
const batchLabel = `[${i + 1}-${Math.min(i + batchSize, tasks.length)}/${tasks.length}]`
|
||||||
|
|
||||||
|
// 提交本批
|
||||||
|
const submitResults = await Promise.allSettled(
|
||||||
batch.map(async (task, j) => {
|
batch.map(async (task, j) => {
|
||||||
const idx = i + j
|
const idx = i + j
|
||||||
const prompt = task.videoPrompt || task.prompt
|
const prompt = task.videoPrompt || task.prompt
|
||||||
@@ -429,50 +440,54 @@ async function batchGenerate(tasks, options = {}) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
submitted.push(...batchResults.map(r => r.value || r.reason))
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingTasks = submitted.filter(s => s.taskId)
|
const submitted = submitResults.map(r => r.status === 'fulfilled' ? r.value : r.reason)
|
||||||
|
const hitQuota = submitted.some(s => !s.taskId && isQuotaError(s.error))
|
||||||
|
const pendingTasks = submitted.filter(s => s.taskId)
|
||||||
|
|
||||||
if (pendingTasks.length === 0) {
|
// 额度不足时:记录本批失败 + 跳过剩余
|
||||||
console.error('\n❌ 所有任务提交失败')
|
if (hitQuota) {
|
||||||
return tasks.map((task, idx) => ({
|
const remaining = tasks.length - i - batch.length
|
||||||
success: false, ...task,
|
for (const s of submitted) {
|
||||||
error: (submitted.find(s => s.idx === idx) || {}).error || '提交失败',
|
results.push({ success: false, ...s.task, error: s.error || '提交失败' })
|
||||||
}))
|
}
|
||||||
}
|
for (let j = i + batchSize; j < tasks.length; j++) {
|
||||||
|
results.push({ success: false, ...tasks[j], error: '额度不足,未提交' })
|
||||||
// Phase 2: 并行轮询
|
}
|
||||||
console.log(`\n⏳ 并行等待 ${pendingTasks.length} 个视频生成...`)
|
console.log(`\n⚠️ 额度不足,跳过剩余 ${remaining + submitted.filter(s => !s.taskId).length} 个任务`)
|
||||||
|
break
|
||||||
const pollResults = await Promise.allSettled(
|
|
||||||
pendingTasks.map(async ({ idx, taskId, task }) => {
|
|
||||||
const prompt = task.videoPrompt || task.prompt
|
|
||||||
const result = await pollWithRetry(taskId, prompt, {
|
|
||||||
outputDir, duration, mode,
|
|
||||||
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
|
for (const s of submitted) {
|
||||||
})
|
if (!s.taskId) results.push({ success: false, ...s.task, error: s.error || '提交失败' })
|
||||||
if (pollResult && pollResult.status === 'fulfilled') {
|
}
|
||||||
results.push({ success: true, ...tasks[i], ...pollResult.value })
|
if (pendingTasks.length === 0) continue
|
||||||
} else {
|
|
||||||
const reason = pollResult?.reason?.message || '生成失败'
|
// 轮询本批
|
||||||
results.push({ success: false, ...tasks[i], error: reason })
|
console.log(`\n⏳ ${batchLabel} 等待 ${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, duration, mode,
|
||||||
|
imageUrl: task.image, lastFrameUrl: task.lastFrameUrl,
|
||||||
|
})
|
||||||
|
return { idx, ...result, task }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// 合并本批轮询结果
|
||||||
|
for (const s of submitted) {
|
||||||
|
if (!s.taskId) continue
|
||||||
|
const pollResult = pollResults.find(r => r.status === 'fulfilled' && r.value.idx === s.idx)
|
||||||
|
if (pollResult) {
|
||||||
|
results.push({ success: true, ...s.task, ...pollResult.value })
|
||||||
|
} else {
|
||||||
|
const reason = pollResults.find(r => r.value?.idx === s.idx || r.reason?.idx === s.idx)?.reason?.message || '生成失败'
|
||||||
|
results.push({ success: false, ...s.task, error: reason })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,14 @@ async function phaseAssemble(manifest, manifestPath, options) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { assemble } = require('../capcut_assemble')
|
const { assemble } = require('../capcut_assemble')
|
||||||
|
const { BASE_URL } = require('./capcut-api')
|
||||||
const result = await assemble(assembleArgs)
|
const result = await assemble(assembleArgs)
|
||||||
// 保存草稿地址到 manifest,供批量导出使用
|
// 保存草稿地址到 manifest,供批量导出使用
|
||||||
if (result && result.draftUrl) {
|
// 用 BASE_URL + draft_id 构造公网可下载的绝对路径
|
||||||
manifest.draftUrl = result.draftUrl
|
if (result && result.draftId) {
|
||||||
|
manifest.draftUrl = `${BASE_URL}/get_draft?draft_id=${result.draftId}`
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
||||||
log('assemble', `草稿地址已保存: ${result.draftUrl}`)
|
log('assemble', `草稿地址已保存: ${manifest.draftUrl}`)
|
||||||
}
|
}
|
||||||
log('assemble', '成片完成')
|
log('assemble', '成片完成')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -8,6 +8,30 @@
|
|||||||
const path = require('path')
|
const path = require('path')
|
||||||
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
|
const { saveManifest, ensureDir, log, getManifestDir } = require('./pipeline-utils')
|
||||||
|
|
||||||
|
function isQuotaError(msg) {
|
||||||
|
if (!msg) return false
|
||||||
|
const s = msg.toLowerCase()
|
||||||
|
return /quota|limit|exceed|insufficient|余额|额度|超限|rate.?limit|too.?many/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPollResult(item, val, dir) {
|
||||||
|
if (val.ok && val.result?.file) {
|
||||||
|
item.video = path.relative(dir, val.result.file).replace(/\\/g, '/')
|
||||||
|
item.videoDuration = val.result.duration
|
||||||
|
item.status = 'done'
|
||||||
|
delete item.videoTaskId
|
||||||
|
} else if (val.item) {
|
||||||
|
if (val.isTaskFailure) {
|
||||||
|
item.status = 'failed'
|
||||||
|
item.error = val.error || '视频生成未返回文件'
|
||||||
|
delete item.videoTaskId
|
||||||
|
} else {
|
||||||
|
log('videos', ` item ${item.id} 生成超时(保留 taskId 待恢复): ${val.error}`)
|
||||||
|
item.status = 'pending'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function phaseVideos(manifest, manifestPath, options) {
|
async function phaseVideos(manifest, manifestPath, options) {
|
||||||
const dir = getManifestDir(manifestPath)
|
const dir = getManifestDir(manifestPath)
|
||||||
const videosDir = path.join(dir, 'videos')
|
const videosDir = path.join(dir, 'videos')
|
||||||
@@ -16,13 +40,11 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
const accountConfig = options.accountConfig || {}
|
const accountConfig = options.accountConfig || {}
|
||||||
const videoModel = manifest.videoModel || accountConfig.videoModel || 'veo3-fast-frames'
|
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 => {
|
const videoCandidates = manifest.items.filter(it => {
|
||||||
if (it.confirmed === false) return false
|
if (it.confirmed === false) return false
|
||||||
if (!it.url || !it.videoPrompt) return false
|
if (!it.url || !it.videoPrompt) return false
|
||||||
|
// 已有视频(本地文件或远程 URL)且状态为 done → 跳过,避免重复生成
|
||||||
|
if (it.status === 'done' && (it.video || it.videoUrl)) return false
|
||||||
return ['done', 'pending', 'failed'].includes(it.status)
|
return ['done', 'pending', 'failed'].includes(it.status)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -34,7 +56,9 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
if (it.confirmed === false) reasons.push("confirmed=false")
|
if (it.confirmed === false) reasons.push("confirmed=false")
|
||||||
if (!it.url) reasons.push("缺少 url(图片未上传)")
|
if (!it.url) reasons.push("缺少 url(图片未上传)")
|
||||||
if (!it.videoPrompt) reasons.push("缺少 videoPrompt")
|
if (!it.videoPrompt) reasons.push("缺少 videoPrompt")
|
||||||
if (it.confirmed !== false && it.url && it.videoPrompt && !["done","pending","failed"].includes(it.status)) {
|
if (it.status === 'done' && (it.video || it.videoUrl)) {
|
||||||
|
reasons.push("视频已生成,已跳过")
|
||||||
|
} else if (!["done","pending","failed"].includes(it.status)) {
|
||||||
reasons.push("status=" + (it.status || "undefined") + "(不在 done/pending/failed 中)")
|
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(" - item", it.id || manifest.items.indexOf(it), ":", reasons.length > 0 ? reasons.join(", ") : "已满足全部条件(不应在此)")
|
||||||
@@ -44,21 +68,32 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
console.log()
|
console.log()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已有视频(本地或 OSS)且状态为 done 的跳过,其余清理后重新生成
|
|
||||||
const items = []
|
const items = []
|
||||||
for (const it of videoCandidates) {
|
for (const it of videoCandidates) {
|
||||||
|
// 磁盘兜底:本地视频文件已存在则恢复引用并跳过
|
||||||
|
if (!it.video && it.id) {
|
||||||
|
const fs = require('fs')
|
||||||
|
const existingVideos = fs.readdirSync(videosDir).filter(f =>
|
||||||
|
f.includes('_item' + it.id + '_') || f.includes('_item' + it.id + '.')
|
||||||
|
)
|
||||||
|
if (existingVideos.length > 0) {
|
||||||
|
it.video = 'videos/' + existingVideos[existingVideos.length - 1]
|
||||||
|
it.status = 'done'
|
||||||
|
delete it.videoTaskId
|
||||||
|
log('videos', ` item ${it.id} 发现已有视频文件 ${it.video},跳过生成`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
if (it.video || it.videoUrl) {
|
if (it.video || it.videoUrl) {
|
||||||
if (it.status === 'done') continue
|
if (it.status === 'done') continue
|
||||||
delete it.video
|
delete it.video
|
||||||
delete it.videoUrl
|
delete it.videoUrl
|
||||||
delete it.videoDuration
|
delete it.videoDuration
|
||||||
delete it.videoTaskId
|
|
||||||
}
|
}
|
||||||
items.push(it)
|
items.push(it)
|
||||||
}
|
}
|
||||||
if (items.length === 0) { log('videos', '无待处理 item,跳过'); return }
|
if (items.length === 0) { log('videos', '无待处理 item,跳过'); return }
|
||||||
|
|
||||||
// 选择生成器
|
|
||||||
let Api, pollFn
|
let Api, pollFn
|
||||||
const modelLower = videoModel.toLowerCase()
|
const modelLower = videoModel.toLowerCase()
|
||||||
if (modelLower.includes('grok')) {
|
if (modelLower.includes('grok')) {
|
||||||
@@ -73,6 +108,11 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ratio = manifest.format || '9:16'
|
const ratio = manifest.format || '9:16'
|
||||||
|
const pollOpts = (item) => ({
|
||||||
|
outputDir: videosDir, aspectRatio: ratio,
|
||||||
|
imageUrl: item.url, lastFrameUrl: item.lastFrameUrl || '',
|
||||||
|
})
|
||||||
|
|
||||||
log('videos', `共 ${items.length} 个, 模型: ${videoModel}`)
|
log('videos', `共 ${items.length} 个, 模型: ${videoModel}`)
|
||||||
|
|
||||||
// Phase 1: 恢复已有任务(有 videoTaskId 的 item)
|
// Phase 1: 恢复已有任务(有 videoTaskId 的 item)
|
||||||
@@ -93,12 +133,7 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
recovered.map(async (item) => {
|
recovered.map(async (item) => {
|
||||||
try {
|
try {
|
||||||
log('videos', ` 恢复 item ${item.id}: ${item.videoTaskId}`)
|
log('videos', ` 恢复 item ${item.id}: ${item.videoTaskId}`)
|
||||||
const result = await pollFn(item.videoTaskId, item.videoPrompt, {
|
const result = await pollFn(item.videoTaskId, item.videoPrompt, pollOpts(item))
|
||||||
outputDir: videosDir,
|
|
||||||
aspectRatio: ratio,
|
|
||||||
imageUrl: item.url,
|
|
||||||
lastFrameUrl: item.lastFrameUrl || '',
|
|
||||||
})
|
|
||||||
if (result.file) {
|
if (result.file) {
|
||||||
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
|
item.video = path.relative(dir, result.file).replace(/\\/g, '/')
|
||||||
item.videoDuration = result.duration
|
item.videoDuration = result.duration
|
||||||
@@ -107,9 +142,14 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
log('videos', ` item ${item.id} 恢复成功`)
|
log('videos', ` item ${item.id} 恢复成功`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('videos', ` item ${item.id} 恢复失败: ${err.message},将重新提交`)
|
if (err.isTaskFailure === true) {
|
||||||
delete item.videoTaskId
|
log('videos', ` item ${item.id} 恢复失败(任务失败): ${err.message},将重新提交`)
|
||||||
needSubmit.push(item)
|
delete item.videoTaskId
|
||||||
|
needSubmit.push(item)
|
||||||
|
} else {
|
||||||
|
log('videos', ` item ${item.id} 恢复超时(保留 taskId 下次重试): ${err.message}`)
|
||||||
|
item.status = 'pending'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -118,14 +158,16 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
|
|
||||||
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
|
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
|
||||||
|
|
||||||
// Phase 2: 提交新任务(并发 5,Kling 最大并发)
|
// Phase 2+3: 分批提交+轮询(严格并发 ≤ 5,等一批完成再提交下一批)
|
||||||
const concurrency = 5
|
const concurrency = 5
|
||||||
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency})...`)
|
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency})...`)
|
||||||
|
|
||||||
const submitted = []
|
|
||||||
for (let i = 0; i < needSubmit.length; i += concurrency) {
|
for (let i = 0; i < needSubmit.length; i += concurrency) {
|
||||||
const batch = needSubmit.slice(i, i + concurrency)
|
const batch = needSubmit.slice(i, i + concurrency).filter(item => !item.videoTaskId)
|
||||||
const batchResults = await Promise.allSettled(
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
|
// 提交本批
|
||||||
|
const submitResults = await Promise.allSettled(
|
||||||
batch.map(async (item) => {
|
batch.map(async (item) => {
|
||||||
const extraOpts = item.lastFrameUrl
|
const extraOpts = item.lastFrameUrl
|
||||||
? { aspectRatio: ratio, lastFrameUrl: item.lastFrameUrl, mode: 'pro' }
|
? { aspectRatio: ratio, lastFrameUrl: item.lastFrameUrl, mode: 'pro' }
|
||||||
@@ -138,60 +180,54 @@ async function phaseVideos(manifest, manifestPath, options) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
for (const r of batchResults) {
|
|
||||||
|
const submitted = []
|
||||||
|
let hitQuota = false
|
||||||
|
for (const r of submitResults) {
|
||||||
const val = r.status === 'fulfilled' ? r.value : { item: null, taskId: null, error: r.reason }
|
const val = r.status === 'fulfilled' ? r.value : { item: null, taskId: null, error: r.reason }
|
||||||
submitted.push(val)
|
submitted.push(val)
|
||||||
if (val.item && val.taskId) {
|
if (val.item && val.taskId) {
|
||||||
val.item.videoTaskId = val.taskId
|
val.item.videoTaskId = val.taskId
|
||||||
|
} else if (val.item && !val.taskId) {
|
||||||
|
val.item.status = 'failed'
|
||||||
|
val.item.error = val.error || '提交失败'
|
||||||
|
if (isQuotaError(val.error)) hitQuota = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveManifest(manifestPath, manifest)
|
saveManifest(manifestPath, manifest)
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: 轮询新任务
|
if (hitQuota) {
|
||||||
const pending = submitted.filter(s => s.taskId)
|
log('videos', ` ⚠️ 额度不足,停止提交(跳过剩余 ${needSubmit.length - i - batch.length} 个任务)`)
|
||||||
if (pending.length === 0) {
|
break
|
||||||
log('videos', '所有任务提交失败')
|
}
|
||||||
for (const s of submitted) {
|
|
||||||
if (s.item) { s.item.status = 'failed'; s.item.error = s.error || '提交失败' }
|
// 轮询本批
|
||||||
|
const pending = submitted.filter(s => s.taskId)
|
||||||
|
if (pending.length === 0) continue
|
||||||
|
|
||||||
|
const end = Math.min(i + concurrency, needSubmit.length)
|
||||||
|
log('videos', ` [${i + 1}-${end}/${needSubmit.length}] 等待 ${pending.length} 个视频生成...`)
|
||||||
|
|
||||||
|
const pollResults = await Promise.allSettled(
|
||||||
|
pending.map(async ({ item, taskId }) => {
|
||||||
|
try {
|
||||||
|
const result = await pollFn(taskId, item.videoPrompt, pollOpts(item))
|
||||||
|
return { item, result, ok: true }
|
||||||
|
} catch (err) {
|
||||||
|
return { item, error: err.message, ok: false, isTaskFailure: err.isTaskFailure === true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const r of pollResults) {
|
||||||
|
const val = r.status === 'fulfilled'
|
||||||
|
? r.value
|
||||||
|
: { ok: false, error: r.reason?.message || String(r.reason), isTaskFailure: r.reason?.isTaskFailure === true }
|
||||||
|
applyPollResult(val.item || {}, val, dir)
|
||||||
}
|
}
|
||||||
saveManifest(manifestPath, manifest)
|
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
|
// 上传视频到 OSS
|
||||||
const { uploadFile } = require('../oss-upload')
|
const { uploadFile } = require('../oss-upload')
|
||||||
const videoItems = manifest.items.filter(it => it.video && !it.videoUrl)
|
const videoItems = manifest.items.filter(it => it.video && !it.videoUrl)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* 共享视频轮询重试工具
|
* 共享视频轮询重试工具
|
||||||
*
|
*
|
||||||
* 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。
|
* 提供 pollWithRetry 工厂函数,供 kling/veo/grok 三个视频生成器共用。
|
||||||
* 两层重试:轮询级(同一 taskId,处理网络瞬断)→ 任务级(创建新 task + 优化提示词)
|
* 两层重试:轮询级(同一 taskId,处理网络瞬断/超时)→ 任务级(仅 API 明确返回 failed 才创建新 task)
|
||||||
|
*
|
||||||
|
* 铁律:超时 ≠ 失败。超时说明服务端还在跑,创建新任务会重复计费。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
@@ -10,9 +12,11 @@ const fs = require('fs')
|
|||||||
const https = require('https')
|
const https = require('https')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
|
||||||
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket/i
|
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket|超时|processing|pending/i
|
||||||
|
|
||||||
const POLL_RETRIES = 2 // 同一 task 轮询重试次数
|
const TASK_FAILED_RE = /\bfailed\b|失败/i
|
||||||
|
|
||||||
|
const POLL_RETRIES = 5 // 同一 task 轮询重试次数(含超时等待)
|
||||||
const POLL_RETRY_DELAY = 5000 // 轮询重试间隔 ms
|
const POLL_RETRY_DELAY = 5000 // 轮询重试间隔 ms
|
||||||
const TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms
|
const TASK_RETRY_DELAY = 5000 // 任务级重试间隔 ms
|
||||||
|
|
||||||
@@ -20,6 +24,10 @@ function isTransientError(err) {
|
|||||||
return TRANSIENT_RE.test(err.message || '')
|
return TRANSIENT_RE.test(err.message || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTaskFailed(err) {
|
||||||
|
return TASK_FAILED_RE.test(err.message || '')
|
||||||
|
}
|
||||||
|
|
||||||
async function download(url, outputPath) {
|
async function download(url, outputPath) {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
const protocol = url.startsWith('https') ? https : http
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -57,9 +65,16 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
|
|||||||
let currentTaskId = taskId
|
let currentTaskId = taskId
|
||||||
let currentPrompt = prompt
|
let currentPrompt = prompt
|
||||||
let lastError = null
|
let lastError = null
|
||||||
|
let lastErrorWasTaskFailure = false
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
|
// 只有 API 明确返回 failed 才创建新任务。超时/网络问题禁止创建新任务(原任务仍在服务端运行,重复计费)
|
||||||
|
if (!lastErrorWasTaskFailure) {
|
||||||
|
const err = new Error(`视频生成超时,放弃等待: ${lastError}`)
|
||||||
|
err.isTaskFailure = false
|
||||||
|
throw err
|
||||||
|
}
|
||||||
if (optimizePrompt) {
|
if (optimizePrompt) {
|
||||||
currentPrompt = optimizePrompt(prompt, lastError, attempt)
|
currentPrompt = optimizePrompt(prompt, lastError, attempt)
|
||||||
}
|
}
|
||||||
@@ -89,8 +104,10 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err.message
|
lastError = err.message
|
||||||
|
lastErrorWasTaskFailure = isTaskFailed(err)
|
||||||
if (isTransientError(err) && pollAttempt < POLL_RETRIES) {
|
if (isTransientError(err) && pollAttempt < POLL_RETRIES) {
|
||||||
console.log(` ⚠ 轮询瞬断 (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`)
|
const tag = isTaskFailed(err) ? '失败' : '超时/瞬断'
|
||||||
|
console.log(` ⚠ 轮询${tag} (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`)
|
||||||
await new Promise(r => setTimeout(r, POLL_RETRY_DELAY))
|
await new Promise(r => setTimeout(r, POLL_RETRY_DELAY))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -98,13 +115,15 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries && lastErrorWasTaskFailure) {
|
||||||
await new Promise(r => setTimeout(r, TASK_RETRY_DELAY))
|
await new Promise(r => setTimeout(r, TASK_RETRY_DELAY))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`重试 ${maxRetries} 次后仍失败: ${lastError}`)
|
const finalErr = new Error(`重试 ${maxRetries} 次后仍失败: ${lastError}`)
|
||||||
|
finalErr.isTaskFailure = true
|
||||||
|
throw finalErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { makePollWithRetry, POLL_RETRIES, POLL_RETRY_DELAY, TASK_RETRY_DELAY }
|
module.exports = { makePollWithRetry, isTaskFailed, POLL_RETRIES, POLL_RETRY_DELAY, TASK_RETRY_DELAY }
|
||||||
|
|||||||
@@ -131,11 +131,12 @@ node .claude/skills/video-from-script/scripts/batch-pipeline.js rename-drafts --
|
|||||||
node .claude/skills/video-from-script/scripts/batch-pipeline.js export --file output/batch_XXX/batch-manifest.json
|
node .claude/skills/video-from-script/scripts/batch-pipeline.js export --file output/batch_XXX/batch-manifest.json
|
||||||
```
|
```
|
||||||
|
|
||||||
输出 CSV 表格,列:`选题 | 脚本 | 账号 | 模式 | 音色 | 转发文案带话题 | 草稿名称`
|
输出 CSV 表格,列:`选题一 | 选题二 | 脚本 | 账号 | 模式 | 音色 | 转发文案带话题 | 草稿名称 | 草稿地址`
|
||||||
|
|
||||||
- **选题**列 = 方案A(封面双句)
|
- **选题一**列 = 方案A(封面双句)
|
||||||
|
- **选题二**列 = 方案B(封面短标题)
|
||||||
- **草稿名称**列 = `账号名_月日_序号_方案B`
|
- **草稿名称**列 = `账号名_月日_序号_方案B`
|
||||||
- CSV 文件路径:`batch-manifest_export.csv`(与 manifest 同目录)
|
- 导出文件命名:`账号名_MMDD_起号~止号.csv`,如 `执黑先行_0516_01~16.csv`(与 manifest 同目录)
|
||||||
- 导出后询问用户是否打包草稿到桌面
|
- 导出后询问用户是否打包草稿到桌面
|
||||||
|
|
||||||
## 草稿箱改名
|
## 草稿箱改名
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
"references": []
|
"references": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ttsVoice": "斯内普",
|
"ttsVoice": "谢尔比",
|
||||||
"ttsRate": 1.3,
|
"ttsRate": 1.3,
|
||||||
"ttsInstruction": "用沉稳有力的男性声音朗读,语速适中,语气坚定有力,像是一个有经历有力量的人在平静地讲述生活的方向",
|
"ttsInstruction": "用一种过来人的口吻说话,像是经历了太多懒得废话的大哥,语气里带点漫不经心和不耐烦。声音不用太用力,轻描淡写但每个字都砸在点上。偶尔轻哼一口气,显得很松弛。",
|
||||||
"storyboardPrompt": "prompts/分镜.md",
|
"storyboardPrompt": "prompts/分镜.md",
|
||||||
"imageStylePrompt": "prompts/图片提示词.md",
|
"imageStylePrompt": "prompts/图片提示词.md",
|
||||||
"videoStylePrompt": "prompts/视频提示词.md",
|
"videoStylePrompt": "prompts/视频提示词.md",
|
||||||
|
|||||||
Reference in New Issue
Block a user