feat: MiniMax TTS集成、口播批量剪辑流水线、执黑先行二号账号
- 新增 minimax-tts.js 和 minimax-voice-clone.js 脚本 - 新增口播批量剪辑流水线 (口播_assemble.js, 口播_pipeline.js) - 更新 video-from-script 各阶段脚本 (kling, images, tts, videos) - 新增执黑先行二号-风格延伸账号 - 更新执黑先行 account.json 配置 - 替换 ugc_product_seeding 参考图 - 更新 CLAUDE.md 和依赖配置 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@@ -23,12 +23,30 @@
|
||||
"kelingApiKey": "AR4kMTEGaaM4d4QgJmLYMgHmEFABJPFE",
|
||||
"kelingSecretAccessKey": "aBCrHLYTPPgMm3mnE8RBMAtmY9FLTGT3",
|
||||
"kelingModel": "kling-v2-5-turbo",
|
||||
"kelingYunwuApiBase": "https://api.yunwu.ai/kling",
|
||||
"kelingYunwuApiKey": "sk-dCwuieejiPseElK3ami0PHJXKf0U2UlUkMXOCP34Xg1LBnC4",
|
||||
"ossRegion": "oss-cn-hangzhou",
|
||||
"ossAccessKeyId": "LTAI5tPV9Ag3csf41GZjaLTA",
|
||||
"ossAccessKeySecret": "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs",
|
||||
"ossBucket": "muye-ai-chat",
|
||||
"ossFolder": "tmp/",
|
||||
"ossExpires": 31536000,
|
||||
"minimaxApiKey": "sk-dCwuieejiPseElK3ami0PHJXKf0U2UlUkMXOCP34Xg1LBnC4",
|
||||
"minimaxApiBase": "https://api.yunwu.ai/minimax",
|
||||
"minimaxTtsModel": "speech-2.8-hd",
|
||||
"minimaxTtsVoice": "snape_mm",
|
||||
"minimaxVoices": {
|
||||
"斯内普MM": "snape_mm",
|
||||
"谢尔比MM": "shelby_mm",
|
||||
"布拉德MM": "bradford_clone_mm",
|
||||
"不羁青年": "Chinese (Mandarin)_Unrestrained_Young_Man",
|
||||
"搞笑大爷": "Chinese (Mandarin)_Humorous_Elder",
|
||||
"嘴硬竹马": "Chinese (Mandarin)_Stubborn_Friend",
|
||||
"傲娇御姐": "Chinese (Mandarin)_Mature_Woman",
|
||||
"温暖少女": "Chinese (Mandarin)_Warm_Girl",
|
||||
"嚣张小姐": "Arrogant_Miss",
|
||||
"高智女孩": "Chinese (Mandarin)_IntellectualGirl"
|
||||
},
|
||||
"ttsApiBaseUrl": "https://dashscope.aliyuncs.com/api/v1",
|
||||
"ttsApiKey": "sk-1c503705b0f844a6b4f2386f6c1cc35b",
|
||||
"ttsModel": "cosyvoice-v3.5-plus",
|
||||
|
||||
@@ -37,12 +37,19 @@ const cfg = loadConfig()
|
||||
|
||||
const Config = {
|
||||
apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''),
|
||||
model: cfg.kelingModel || 'kling-v3',
|
||||
yunwuApiBase: (cfg.kelingYunwuApiBase || '').replace(/\/+$/, ''),
|
||||
yunwuApiKey: cfg.kelingYunwuApiKey || cfg.minimaxApiKey || '',
|
||||
model: cfg.kelingModel || 'kling-v2-5-turbo',
|
||||
pollInterval: 10000,
|
||||
maxPollTime: 600000,
|
||||
maxRetries: 3,
|
||||
}
|
||||
|
||||
// 是否走云雾代理模式(用 Bearer token 而非 JWT)
|
||||
function isYunwuMode() {
|
||||
return !!(Config.yunwuApiBase && Config.yunwuApiKey)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JWT 鉴权(来自可灵官方 API 规范)
|
||||
// ============================================================================
|
||||
@@ -180,7 +187,7 @@ function extractCoreSubject(prompt) {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 官方可灵 API
|
||||
// 可灵 API(官方 JWT 模式 + 云雾 Bearer Token 模式)
|
||||
// ============================================================================
|
||||
|
||||
const KlingApi = {
|
||||
@@ -192,8 +199,11 @@ const KlingApi = {
|
||||
lastFrameUrl = '',
|
||||
} = options
|
||||
|
||||
const creds = loadCredentials()
|
||||
const token = makeJwt(creds.ak, creds.sk)
|
||||
const yunwu = isYunwuMode()
|
||||
const apiBase = yunwu ? Config.yunwuApiBase : Config.apiBase
|
||||
const authHeader = yunwu
|
||||
? `Bearer ${Config.yunwuApiKey}`
|
||||
: `Bearer ${makeJwt(...Object.values(loadCredentials()))}`
|
||||
|
||||
const body = {
|
||||
model_name: model,
|
||||
@@ -210,8 +220,8 @@ const KlingApi = {
|
||||
|
||||
const modeLabel = lastFrameUrl ? '首尾帧' : '单图'
|
||||
|
||||
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]`)
|
||||
console.log(` API: ${Config.apiBase}`)
|
||||
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]${yunwu ? ' [云雾]' : ' [官方]'}`)
|
||||
console.log(` API: ${apiBase}`)
|
||||
console.log(` 模型: ${model}`)
|
||||
console.log(` 提示词: ${prompt.substring(0, 80)}...`)
|
||||
if (lastFrameUrl) {
|
||||
@@ -222,11 +232,11 @@ const KlingApi = {
|
||||
}
|
||||
console.log(` 时长: ${duration}s | 画质: ${mode}`)
|
||||
|
||||
const res = await fetch(`${Config.apiBase}/v1/videos/image2video`, {
|
||||
const res = await fetch(`${apiBase}/v1/videos/image2video`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Authorization': authHeader,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -249,12 +259,15 @@ const KlingApi = {
|
||||
},
|
||||
|
||||
async query(taskId) {
|
||||
const creds = loadCredentials()
|
||||
const token = makeJwt(creds.ak, creds.sk)
|
||||
const yunwu = isYunwuMode()
|
||||
const apiBase = yunwu ? Config.yunwuApiBase : Config.apiBase
|
||||
const authHeader = yunwu
|
||||
? `Bearer ${Config.yunwuApiKey}`
|
||||
: `Bearer ${makeJwt(...Object.values(loadCredentials()))}`
|
||||
|
||||
const res = await fetch(`${Config.apiBase}/v1/videos/image2video/${taskId}`, {
|
||||
const res = await fetch(`${apiBase}/v1/videos/image2video/${taskId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,23 @@ async function phaseImages(manifest, manifestPath, options) {
|
||||
((!it.status || it.status === 'pending' || it.status === 'generating') && it.imagePrompt) ||
|
||||
(it.status === 'done' && manifest.mode === 'framePair' && it.file && it.lastFramePrompt && !it.lastFrame)
|
||||
)
|
||||
if (items.length === 0) { log('images', '无待处理 item,跳过'); return }
|
||||
if (items.length === 0) {
|
||||
// 检查是否是真实的无待处理项(已有图片)还是配置错误
|
||||
const total = manifest.items.length
|
||||
const withImg = manifest.items.filter(it => it.url || it.file).length
|
||||
const withPrompt = manifest.items.filter(it => it.imagePrompt).length
|
||||
if (withImg === 0) {
|
||||
console.error("\n" + "=".repeat(60))
|
||||
console.error("❌ [images] 严重错误:没有任何待处理的图片生成项,且 manifest 中也没有已生成的图片!")
|
||||
console.error("=".repeat(60))
|
||||
console.error(` 总数: ${total} | 有imagePrompt: ${withPrompt} | 已有图片: ${withImg}`)
|
||||
if (withPrompt === 0) console.error(" 根因: 所有 item 都缺少 imagePrompt — 分镜文件可能有问题")
|
||||
console.error("=".repeat(60) + "\n")
|
||||
throw new Error(`图片阶段中断: ${total} 个 item 均无图片且无待处理项 (imagePrompt=${withPrompt}/${total})`)
|
||||
}
|
||||
log('images', '所有图片已生成,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
const accountConfig = options.accountConfig || {}
|
||||
let model = options.imageModel || manifest.imageModel || accountConfig.imageModel || 'gemini'
|
||||
|
||||
@@ -87,7 +87,8 @@ async function phaseTts(manifest, manifestPath, options = {}) {
|
||||
const audioDir = path.join(dir, 'audio')
|
||||
ensureDir(audioDir)
|
||||
|
||||
const { synthesize } = require('../qwen-tts')
|
||||
const engine = manifest.ttsEngine || 'qwen'
|
||||
const { synthesize } = engine === 'minimax' ? require('../minimax-tts') : require('../qwen-tts')
|
||||
|
||||
const videoDur = manifest.estimatedVideoDuration || 6
|
||||
const ttsRate = manifest.ttsRate || 1.15
|
||||
@@ -95,7 +96,22 @@ async function phaseTts(manifest, manifestPath, options = {}) {
|
||||
const items = manifest.items.filter(it =>
|
||||
(it.script || it.text) && !it.audio
|
||||
)
|
||||
if (items.length === 0) { log('tts', '无待处理 item(已合成),跳过'); return }
|
||||
if (items.length === 0) {
|
||||
const total = manifest.items.length
|
||||
const withAudio = manifest.items.filter(it => it.audio).length
|
||||
const withScript = manifest.items.filter(it => it.script || it.text).length
|
||||
if (withAudio === 0) {
|
||||
console.error("\n" + "=".repeat(60))
|
||||
console.error("❌ [tts] 严重错误:没有任何待处理的配音项,且 manifest 中也没有已生成的音频!")
|
||||
console.error("=".repeat(60))
|
||||
console.error(` 总数: ${total} | 有script: ${withScript} | 已有audio: ${withAudio}`)
|
||||
if (withScript === 0) console.error(" 根因: 所有 item 都缺少 script 文本 — 分镜文件有问题")
|
||||
console.error("=".repeat(60) + "\n")
|
||||
throw new Error(`TTS 阶段中断: ${total} 个 item 均无音频且无待处理项 (script=${withScript}/${total})`)
|
||||
}
|
||||
log('tts', '所有音频已合成,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
log('tts', `共 ${items.length} 段, 视频固定时长=${videoDur}s, TTS语速=${ttsRate}x`)
|
||||
|
||||
@@ -131,6 +147,9 @@ async function phaseTts(manifest, manifestPath, options = {}) {
|
||||
model: manifest.ttsModel || undefined,
|
||||
instruction: manifest.ttsInstruction || undefined,
|
||||
rate: ttsRate,
|
||||
emotion: manifest.ttsEmotion || undefined,
|
||||
languageBoost: manifest.ttsLanguageBoost || undefined,
|
||||
pitch: manifest.ttsPitch ?? undefined,
|
||||
})
|
||||
break
|
||||
} catch (e) {
|
||||
|
||||
@@ -49,23 +49,35 @@ async function phaseVideos(manifest, manifestPath, options) {
|
||||
})
|
||||
|
||||
if (videoCandidates.length === 0) {
|
||||
console.log("\n⚠️ [videos] 没有符合条件的 item 进入视频生成阶段")
|
||||
console.log(" manifest 中共有", manifest.items.length, "个 item,逐一诊断:")
|
||||
console.error("\n" + "=".repeat(60))
|
||||
console.error("❌ [videos] 严重错误:没有任何 item 可以进入视频生成阶段!")
|
||||
console.error("=".repeat(60))
|
||||
console.error(" manifest 中共有", manifest.items.length, "个 item,逐一诊断:")
|
||||
let reasonSummary = {}
|
||||
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) { reasons.push("confirmed=false(未确认)"); reasonSummary['confirmed=false'] = (reasonSummary['confirmed=false']||0)+1 }
|
||||
if (!it.url) { reasons.push("缺少 url(图片未上传)"); reasonSummary['no_url'] = (reasonSummary['no_url']||0)+1 }
|
||||
if (!it.videoPrompt) { reasons.push("缺少 videoPrompt"); reasonSummary['no_videoPrompt'] = (reasonSummary['no_videoPrompt']||0)+1 }
|
||||
if (it.status === 'done' && (it.video || it.videoUrl)) {
|
||||
reasons.push("视频已生成,已跳过")
|
||||
reasons.push("视频已生成")
|
||||
reasonSummary['already_done'] = (reasonSummary['already_done']||0)+1
|
||||
} else if (!["done","pending","failed"].includes(it.status)) {
|
||||
reasons.push("status=" + (it.status || "undefined") + "(不在 done/pending/failed 中)")
|
||||
reasonSummary['bad_status'] = (reasonSummary['bad_status']||0)+1
|
||||
}
|
||||
console.log(" - item", it.id || manifest.items.indexOf(it), ":", reasons.length > 0 ? reasons.join(", ") : "已满足全部条件(不应在此)")
|
||||
console.error(` - item ${it.id || manifest.items.indexOf(it)}: ${reasons.join(", ")}`)
|
||||
}
|
||||
console.log("\n 修复命令:")
|
||||
console.log(" node .claude/skills/video-from-script/scripts/pipeline.js confirm --manifest", manifestPath, "--all")
|
||||
console.log()
|
||||
console.error("\n 原因汇总:", JSON.stringify(reasonSummary))
|
||||
|
||||
let fixCmd = ""
|
||||
if (reasonSummary['confirmed=false']) fixCmd = `node ${path.join(__dirname, '..', 'pipeline.js')} confirm --manifest ${manifestPath} --all`
|
||||
else if (reasonSummary['no_url']) fixCmd = `node ${path.join(__dirname, '..', 'pipeline.js')} run --manifest ${manifestPath} --phase images,upload`
|
||||
else if (reasonSummary['bad_status']) fixCmd = `手动将 item status 改为 done/pending/failed 后重新运行 videos`
|
||||
|
||||
console.error("\n 修复命令: " + fixCmd)
|
||||
console.error("=".repeat(60) + "\n")
|
||||
throw new Error(`视频阶段中断: ${manifest.items.length} 个 item 均不符合视频生成条件 (${Object.entries(reasonSummary).map(([k,v])=>k+'×'+v).join(', ')})`)
|
||||
}
|
||||
|
||||
const items = []
|
||||
@@ -92,7 +104,10 @@ async function phaseVideos(manifest, manifestPath, options) {
|
||||
}
|
||||
items.push(it)
|
||||
}
|
||||
if (items.length === 0) { log('videos', '无待处理 item,跳过'); return }
|
||||
if (items.length === 0) {
|
||||
console.error("\n❌ [videos] 严重错误:视频候选列表非空但处理后无待处理项(磁盘恢复后全部跳过)")
|
||||
throw new Error('视频阶段中断: 处理后无待处理 item')
|
||||
}
|
||||
|
||||
let Api, pollFn
|
||||
const modelLower = videoModel.toLowerCase()
|
||||
@@ -159,9 +174,11 @@ async function phaseVideos(manifest, manifestPath, options) {
|
||||
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
|
||||
|
||||
// Phase 2+3: 分批提交+轮询(严格并发 ≤ 5,等一批完成再提交下一批)
|
||||
const concurrency = 5
|
||||
const concurrency = 1
|
||||
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency})...`)
|
||||
|
||||
let totalSubmitted = 0
|
||||
|
||||
for (let i = 0; i < needSubmit.length; i += concurrency) {
|
||||
const batch = needSubmit.slice(i, i + concurrency).filter(item => !item.videoTaskId)
|
||||
if (batch.length === 0) continue
|
||||
@@ -245,6 +262,34 @@ async function phaseVideos(manifest, manifestPath, options) {
|
||||
}
|
||||
saveManifest(manifestPath, manifest)
|
||||
}
|
||||
|
||||
// 最终检查:如果提交了视频任务但成功率极低,硬中断
|
||||
const successCount = manifest.items.filter(it => it.video || it.videoUrl).length
|
||||
const failCount = manifest.items.filter(it => it.status === 'failed').length
|
||||
const totalCandidates = videoCandidates.length
|
||||
|
||||
if (successCount === 0 && totalCandidates > 0) {
|
||||
console.error("\n" + "=".repeat(60))
|
||||
console.error("❌ [videos] 严重错误:所有视频生成任务全部失败!")
|
||||
console.error("=".repeat(60))
|
||||
console.error(` 候选 item: ${totalCandidates} | 成功: ${successCount} | 失败: ${failCount}`)
|
||||
const errSamples = manifest.items.filter(it => it.status === 'failed' && it.error).slice(0, 3)
|
||||
for (const it of errSamples) {
|
||||
console.error(` - item ${it.id}: ${it.error?.slice(0,100)}`)
|
||||
}
|
||||
const allErrors = errSamples.map(it => it.error || '').join()
|
||||
if (/负载已饱和|rate.?limit|quota|额度/.test(allErrors)) {
|
||||
console.error("\n 根因: Kling API 上游过载或额度不足,稍后重试")
|
||||
}
|
||||
console.error("=".repeat(60) + "\n")
|
||||
throw new Error(`视频阶段中断: 所有 ${totalCandidates} 个候选任务失败,成功 0 个`)
|
||||
}
|
||||
|
||||
if (successCount < totalCandidates * 0.3 && totalCandidates >= 5) {
|
||||
console.error("\n⚠️ [videos] 警告:视频生成成功率极低 (<30%)")
|
||||
console.error(` 候选: ${totalCandidates} | 成功: ${successCount} | 失败: ${failCount}`)
|
||||
console.error(" 已生成视频将继续使用,未生成的片段将以静态图片替代\n")
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { phaseVideos }
|
||||
|
||||
@@ -78,9 +78,11 @@ async function getReferences(manifest, accountConfig) {
|
||||
}
|
||||
|
||||
// 优先读 manifest.references(agent 创建时写入)
|
||||
// 兼容字符串格式("path/to/ref.jpg")和对象格式({file, url})
|
||||
const refs = manifest.references || []
|
||||
if (refs.length > 0) {
|
||||
for (const ref of refs) {
|
||||
for (const rawRef of refs) {
|
||||
const ref = typeof rawRef === 'string' ? { file: rawRef } : rawRef
|
||||
if (ref.url) result.urls.push(ref.url)
|
||||
if (ref.file) {
|
||||
const localPath = path.isAbsolute(ref.file) ? ref.file : path.resolve(ref.file)
|
||||
|
||||
189
.claude/skills/video-from-script/scripts/minimax-tts.js
Normal file
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MiniMax TTS — T2A v2 HTTP 语音合成
|
||||
*
|
||||
* 模块用法:
|
||||
* const { synthesize } = require('./minimax-tts')
|
||||
* const { filePath, duration } = await synthesize('你好', { voice: 'my_voice_id' })
|
||||
*
|
||||
* CLI 用法:
|
||||
* node minimax-tts.js <input.json>
|
||||
* input.json: { "segments": [{"id": 1, "text": "..."}], "voice": "voice_id", "output_dir": "./audio" }
|
||||
*/
|
||||
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execFileSync } = require('child_process')
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config.json')
|
||||
|
||||
function loadConfig() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) throw new Error(`config.json 不存在: ${CONFIG_PATH}`)
|
||||
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
|
||||
}
|
||||
|
||||
function resolveVoice(voice, config) {
|
||||
if (!voice) return voice
|
||||
const voices = config.minimaxVoices || {}
|
||||
return voices[voice] || voice
|
||||
}
|
||||
|
||||
function getAudioDuration(filePath) {
|
||||
try {
|
||||
const out = execFileSync('ffprobe', [
|
||||
'-v', 'quiet', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
|
||||
], { encoding: 'utf-8', timeout: 10000 })
|
||||
return parseFloat(out.trim())
|
||||
} catch {
|
||||
const stat = fs.statSync(filePath)
|
||||
return stat.size * 8 / 128000
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单段语音合成(MiniMax T2A v2 HTTP,非流式)
|
||||
* @param {string} text
|
||||
* @param {object} options - { voice, model, outputDir, id, rate, apiKey }
|
||||
* @returns {Promise<{filePath: string, duration: number}>}
|
||||
*/
|
||||
function synthesize(text, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const config = loadConfig()
|
||||
|
||||
const apiKey = options.apiKey || config.minimaxApiKey
|
||||
if (!apiKey) { reject(new Error('minimaxApiKey 未配置,请在 config.json 中添加')); return }
|
||||
|
||||
const model = options.model || config.minimaxTtsModel || 'speech-02-hd'
|
||||
const voiceId = resolveVoice(options.voice || config.minimaxTtsVoice, config)
|
||||
if (!voiceId) { reject(new Error('未指定 voice_id,请在 options.voice 或 config.minimaxTtsVoice 中配置')); return }
|
||||
|
||||
const outputDir = options.outputDir || './audio'
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
|
||||
text = text.trimEnd()
|
||||
if (!/[。!?;,.!?…]$/.test(text)) text += '。'
|
||||
|
||||
const id = options.id || 1
|
||||
const fileName = `seg_${String(id).padStart(3, '0')}.mp3`
|
||||
const filePath = path.resolve(outputDir, fileName)
|
||||
|
||||
const voiceSetting = {
|
||||
voice_id: voiceId,
|
||||
speed: options.rate || 1.0,
|
||||
vol: 1.0,
|
||||
pitch: options.pitch ?? 0,
|
||||
}
|
||||
if (options.emotion) voiceSetting.emotion = options.emotion
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
text,
|
||||
stream: false,
|
||||
voice_setting: voiceSetting,
|
||||
audio_setting: {
|
||||
sample_rate: 32000,
|
||||
format: 'mp3',
|
||||
channel: 1,
|
||||
},
|
||||
}
|
||||
if (options.languageBoost) requestBody.language_boost = options.languageBoost
|
||||
|
||||
const body = JSON.stringify(requestBody)
|
||||
|
||||
const baseUrl = config.minimaxApiBase || 'https://api.yunwu.ai/minimax'
|
||||
const url = new URL(`${baseUrl}/v1/t2a_v2`)
|
||||
|
||||
const reqOptions = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
}
|
||||
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) { settled = true; req.destroy(); reject(new Error('MiniMax TTS 超时 (90s)')) }
|
||||
}, 90000)
|
||||
|
||||
const req = https.request(reqOptions, (res) => {
|
||||
let body = ''
|
||||
|
||||
res.on('data', chunk => { body += chunk.toString() })
|
||||
|
||||
res.on('end', () => {
|
||||
clearTimeout(timer)
|
||||
if (settled) return
|
||||
settled = true
|
||||
|
||||
try {
|
||||
const d = JSON.parse(body)
|
||||
if (d.base_resp?.status_code && d.base_resp.status_code !== 0) {
|
||||
reject(new Error(`MiniMax TTS 失败 [${d.base_resp.status_code}]: ${d.base_resp.status_msg}`))
|
||||
return
|
||||
}
|
||||
const hex = d.data?.audio
|
||||
if (!hex) { reject(new Error('MiniMax TTS 未返回音频')); return }
|
||||
|
||||
const audioBuffer = Buffer.from(hex, 'hex')
|
||||
fs.writeFileSync(filePath, audioBuffer)
|
||||
resolve({ filePath, duration: getAudioDuration(filePath) })
|
||||
} catch (e) {
|
||||
reject(new Error(`MiniMax TTS 解析响应失败: ${e.message}`))
|
||||
}
|
||||
})
|
||||
|
||||
res.on('error', (e) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) { settled = true; reject(e) }
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', (e) => {
|
||||
clearTimeout(timer)
|
||||
if (!settled) { settled = true; reject(new Error(`MiniMax TTS 请求失败: ${e.message}`)) }
|
||||
})
|
||||
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function synthesizeBatch(segments, options = {}) {
|
||||
const results = []
|
||||
for (const seg of segments) {
|
||||
console.error(` 合成 #${seg.id}: ${seg.text.substring(0, 30)}...`)
|
||||
try {
|
||||
const { filePath, duration } = await synthesize(seg.text, { ...options, id: seg.id })
|
||||
results.push({ id: seg.id, text: seg.text, audio: filePath, duration: Math.round(duration * 1000) / 1000 })
|
||||
} catch (err) {
|
||||
results.push({ id: seg.id, text: seg.text, audio: '', duration: 0, error: err.message })
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputJson = process.argv[2]
|
||||
if (!inputJson) {
|
||||
console.error('用法: node minimax-tts.js <input.json>')
|
||||
console.error(JSON.stringify({ segments: [{ id: 1, text: '文案' }], voice: 'voice_id', output_dir: './audio' }, null, 2))
|
||||
process.exit(1)
|
||||
}
|
||||
const input = JSON.parse(fs.readFileSync(inputJson, 'utf-8'))
|
||||
const results = await synthesizeBatch(input.segments, { voice: input.voice, outputDir: input.output_dir || './audio' })
|
||||
process.stdout.write(JSON.stringify({ segments: results }, null, 2) + '\n')
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(err => { console.error('合成失败:', err.message); process.exit(1) })
|
||||
}
|
||||
|
||||
module.exports = { synthesize, synthesizeBatch }
|
||||
172
.claude/skills/video-from-script/scripts/minimax-voice-clone.js
Normal file
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MiniMax 音色克隆
|
||||
*
|
||||
* 用法:
|
||||
* node minimax-voice-clone.js --file /path/to/audio.mp3 --voice-id my_voice_name
|
||||
*
|
||||
* 流程:
|
||||
* 1. 上传音频文件 → 获取 file_id
|
||||
* 2. 调用 voice_clone → 注册 voice_id
|
||||
* 3. 输出 voice_id(写入 config.json 的 minimaxVoices)
|
||||
*
|
||||
* 选项:
|
||||
* --file <path> 待克隆的音频文件(MP3/WAV/M4A,≥10s)
|
||||
* --voice-id <name> 自定义音色 ID(字母数字下划线,如 "snape_mm")
|
||||
* --name <name> 音色中文名(写入 config,默认同 voice-id)
|
||||
* --model <model> 模型(默认 speech-02-hd)
|
||||
* --save 自动写入 config.json 的 minimaxVoices
|
||||
*/
|
||||
|
||||
const https = require('https')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const CONFIG_PATH = path.join(__dirname, '..', '..', 'config.json')
|
||||
const BOUNDARY = `----FormBoundary${Date.now().toString(16)}`
|
||||
|
||||
function loadConfig() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) throw new Error(`config.json 不存在: ${CONFIG_PATH}`)
|
||||
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
|
||||
}
|
||||
|
||||
function httpsRequest(options, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(options, (res) => {
|
||||
const chunks = []
|
||||
res.on('data', c => chunks.push(c))
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')))
|
||||
} catch (e) {
|
||||
reject(new Error(`响应解析失败: ${e.message}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadFile(apiKey, filePath, baseUrl, purpose = 'voice_clone') {
|
||||
const fileBuffer = fs.readFileSync(filePath)
|
||||
const fileName = path.basename(filePath)
|
||||
|
||||
const bodyParts = [
|
||||
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="purpose"\r\n\r\n${purpose}`,
|
||||
`--${BOUNDARY}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
|
||||
]
|
||||
|
||||
const body = Buffer.concat([
|
||||
Buffer.from(bodyParts[0] + '\r\n'),
|
||||
Buffer.from(bodyParts[1]),
|
||||
fileBuffer,
|
||||
Buffer.from(`\r\n--${BOUNDARY}--\r\n`),
|
||||
])
|
||||
|
||||
const url = new URL(`${baseUrl}/v1/files`)
|
||||
const result = await httpsRequest({
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': `multipart/form-data; boundary=${BOUNDARY}`,
|
||||
'Content-Length': body.length,
|
||||
},
|
||||
}, body)
|
||||
|
||||
if (result.base_resp?.status_code !== 0) {
|
||||
throw new Error(`上传失败 [${result.base_resp?.status_code}]: ${result.base_resp?.status_msg}`)
|
||||
}
|
||||
|
||||
return result.file.file_id
|
||||
}
|
||||
|
||||
async function cloneVoice(apiKey, fileId, voiceId, model, baseUrl) {
|
||||
const body = JSON.stringify({
|
||||
file_id: fileId,
|
||||
voice_id: voiceId,
|
||||
text: '这是一段用于验证音色克隆效果的测试文本,请仔细聆听音色特征。',
|
||||
model,
|
||||
need_noise_reduction: true,
|
||||
need_volume_normalization: true,
|
||||
})
|
||||
|
||||
const url = new URL(`${baseUrl}/v1/voice_clone`)
|
||||
const result = await httpsRequest({
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
}, body)
|
||||
|
||||
if (result.base_resp?.status_code !== 0) {
|
||||
throw new Error(`克隆失败 [${result.base_resp?.status_code}]: ${result.base_resp?.status_msg}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i].startsWith('--')) {
|
||||
const key = argv[i].slice(2)
|
||||
args[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (!args.file || !args['voice-id']) {
|
||||
console.error('用法: node minimax-voice-clone.js --file <音频文件> --voice-id <自定义ID> [--name <中文名>] [--save]')
|
||||
console.error('示例: node minimax-voice-clone.js --file snape.mp3 --voice-id snape_mm --name 斯内普MM --save')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const apiKey = config.minimaxApiKey
|
||||
if (!apiKey) { console.error('config.json 中未配置 minimaxApiKey'); process.exit(1) }
|
||||
|
||||
const filePath = path.resolve(args.file)
|
||||
if (!fs.existsSync(filePath)) { console.error(`文件不存在: ${filePath}`); process.exit(1) }
|
||||
|
||||
const voiceId = args['voice-id']
|
||||
const voiceName = args.name || voiceId
|
||||
const model = args.model || config.minimaxTtsModel || 'speech-2.8-hd'
|
||||
const baseUrl = config.minimaxApiBase || 'https://api.yunwu.ai/minimax'
|
||||
|
||||
console.log(`\n[1/2] 上传音频文件: ${path.basename(filePath)}`)
|
||||
const fileId = await uploadFile(apiKey, filePath, baseUrl)
|
||||
console.log(` file_id: ${fileId}`)
|
||||
|
||||
console.log(`[2/2] 克隆音色: ${voiceId} (模型: ${model})`)
|
||||
await cloneVoice(apiKey, fileId, voiceId, model, baseUrl)
|
||||
console.log(` 克隆成功!`)
|
||||
|
||||
console.log(`\n✅ voice_id: ${voiceId}`)
|
||||
|
||||
if (args.save) {
|
||||
const voices = config.minimaxVoices || {}
|
||||
voices[voiceName] = voiceId
|
||||
config.minimaxVoices = voices
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2))
|
||||
console.log(` 已写入 config.json: minimaxVoices["${voiceName}"] = "${voiceId}"`)
|
||||
} else {
|
||||
console.log(`\n 手动写入 config.json:`)
|
||||
console.log(` "minimaxVoices": { "${voiceName}": "${voiceId}" }`)
|
||||
console.log(`\n 或运行时加 --save 参数自动写入`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('\n❌ 失败:', err.message); process.exit(1) })
|
||||
@@ -56,13 +56,18 @@ async function uploadFile(filePath, options = {}) {
|
||||
const basename = options.name || path.basename(filePath)
|
||||
const ossPath = `${folder}${basename}`
|
||||
|
||||
const buffer = fs.readFileSync(filePath)
|
||||
await client.put(ossPath, buffer)
|
||||
const stat = fs.statSync(filePath)
|
||||
const opts = stat.size > 50 * 1024 * 1024
|
||||
? { timeout: 600000 } // 10min for large files
|
||||
: {}
|
||||
|
||||
const stream = fs.createReadStream(filePath)
|
||||
await client.putStream(ossPath, stream, { ...opts, contentLength: stat.size })
|
||||
|
||||
const expires = config.ossExpires || 31536000
|
||||
const url = client.signatureUrl(ossPath, { expires })
|
||||
|
||||
return { url, ossPath, size: buffer.length }
|
||||
return { url, ossPath, size: stat.size }
|
||||
}
|
||||
|
||||
async function uploadBuffer(buffer, options = {}) {
|
||||
|
||||
347
.claude/skills/video-from-script/scripts/口播_assemble.js
Normal file
@@ -0,0 +1,347 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 口播批量剪辑 — CapCut 草稿组装 v2.1
|
||||
*
|
||||
* 策略:
|
||||
* 1. ffmpeg 按 edit 字幕时间轴裁剪视频片段 + concat 拼接 + 1.1x 倍速 → 单文件
|
||||
* 2. 上传裁剪后视频到 OSS → 剪映里显示为一条连续轨道
|
||||
* 3. 长字幕按标点拆分为 2-3s 短段,避免长时间遮挡画面
|
||||
*
|
||||
* 注: 尝试过 slot 级 source_timerange 方案(v3),但 CapCut Mate API 不支持,
|
||||
* 本地改写 draft_content.json 会被云同步覆盖/删除。故回到 ffmpeg 预裁剪。
|
||||
*
|
||||
* 用法:
|
||||
* node 口播_assemble.js --video <视频路径> --transcript <_edit.txt路径> [--name <草稿名>] [--speed 1.1]
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { execSync, execFileSync } = require('child_process')
|
||||
|
||||
const { api, US, getConfig } = require('./lib/capcut-api')
|
||||
const { addVideos, addSubtitles } = require('./lib/capcut-tracks')
|
||||
const { syncDraft, registerDraft } = require('./sync-to-jianying')
|
||||
const ossUpload = require('./oss-upload')
|
||||
|
||||
// ============================================================================
|
||||
// 解析参数
|
||||
// ============================================================================
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2)
|
||||
const result = {}
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) {
|
||||
result[args[i].slice(2)] = args[i + 1] || true
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 解析字幕文件 → [{begin_ms, end_ms, text}]
|
||||
// ============================================================================
|
||||
|
||||
function parseTranscript(filePath) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').trim().split('\n')
|
||||
const sentences = []
|
||||
|
||||
const re = /\[(\d+):(\d+)\.(\d+)\s*-->\s*(\d+):(\d+)\.(\d+)\]\s*(.*)/
|
||||
|
||||
for (const line of lines) {
|
||||
const m = line.match(re)
|
||||
if (!m) continue
|
||||
const [, m1, s1, ms1, m2, s2, ms2, text] = m
|
||||
const begin_ms = (parseInt(m1) * 60 + parseInt(s1)) * 1000 + parseInt(ms1.padEnd(3, '0').slice(0, 3))
|
||||
const end_ms = (parseInt(m2) * 60 + parseInt(s2)) * 1000 + parseInt(ms2.padEnd(3, '0').slice(0, 3))
|
||||
if (text.trim()) {
|
||||
sentences.push({ begin_ms, end_ms, text: text.trim() })
|
||||
}
|
||||
}
|
||||
|
||||
return sentences
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 字幕拆分:长句按标点拆成 2-3s 短段
|
||||
// ============================================================================
|
||||
|
||||
function splitSubtitleChunks(text, totalDurationMs) {
|
||||
if (!text) return []
|
||||
|
||||
const rawParts = text.split(/(?<=[,。!?,\.!\?、;:])/).filter(Boolean)
|
||||
if (rawParts.length === 0) return [{ text, ratio: 1 }]
|
||||
|
||||
const charCounts = rawParts.map(p => p.length)
|
||||
const totalChars = charCounts.reduce((a, b) => a + b, 0)
|
||||
if (totalChars === 0) return [{ text, ratio: 1 }]
|
||||
|
||||
const merged = []
|
||||
let buffer = { text: '', chars: 0 }
|
||||
for (let i = 0; i < rawParts.length; i++) {
|
||||
buffer.text += rawParts[i]
|
||||
buffer.chars += charCounts[i]
|
||||
const estDuration = (buffer.chars / totalChars) * totalDurationMs
|
||||
if (estDuration >= 1500 || i === rawParts.length - 1) {
|
||||
merged.push({
|
||||
text: buffer.text,
|
||||
ratio: buffer.chars / totalChars,
|
||||
})
|
||||
buffer = { text: '', chars: 0 }
|
||||
}
|
||||
}
|
||||
if (buffer.chars > 0) {
|
||||
if (merged.length > 0) {
|
||||
merged[merged.length - 1].text += buffer.text
|
||||
merged[merged.length - 1].ratio += buffer.chars / totalChars
|
||||
} else {
|
||||
merged.push({ text: buffer.text, ratio: buffer.chars / totalChars })
|
||||
}
|
||||
}
|
||||
|
||||
return merged.length > 0 ? merged : [{ text, ratio: 1 }]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 构建 ffmpeg trim+concat+speed 命令
|
||||
// ============================================================================
|
||||
|
||||
function buildTrimCommand(inputPath, outputPath, segments, speed) {
|
||||
const ffmpeg = '/usr/local/bin/ffmpeg'
|
||||
const filters = []
|
||||
const concatParts = []
|
||||
const n = segments.length
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const seg = segments[i]
|
||||
const t0 = seg.begin_ms / 1000
|
||||
const t1 = seg.end_ms / 1000
|
||||
filters.push(`[0:v]trim=${t0}:${t1},setpts=PTS-STARTPTS[vt${i}];`)
|
||||
filters.push(`[0:a]atrim=${t0}:${t1},asetpts=PTS-STARTPTS[at${i}];`)
|
||||
concatParts.push(`[vt${i}][at${i}]`)
|
||||
}
|
||||
|
||||
const concatInputs = concatParts.join('')
|
||||
const filterComplex = (
|
||||
filters.join('') +
|
||||
`${concatInputs}concat=n=${n}:v=1:a=1[vraw][araw];` +
|
||||
`[vraw]setpts=${(1/speed).toFixed(6)}*PTS[v];` +
|
||||
`[araw]atempo=${speed}[a]`
|
||||
)
|
||||
|
||||
return [
|
||||
ffmpeg, '-i', inputPath,
|
||||
'-filter_complex', filterComplex,
|
||||
'-map', '[v]', '-map', '[a]',
|
||||
'-c:v', 'libx264', '-crf', '23', '-preset', 'fast',
|
||||
'-c:a', 'aac', '-b:a', '128k',
|
||||
outputPath, '-y', '-loglevel', 'error',
|
||||
]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 获取视频时长(毫秒)
|
||||
// ============================================================================
|
||||
|
||||
function getVideoDurationMs(videoPath) {
|
||||
try {
|
||||
const out = execSync(
|
||||
`/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" 2>&1 | grep Duration || true`
|
||||
).toString()
|
||||
const m = out.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/)
|
||||
if (!m) throw new Error('Duration not found')
|
||||
return (parseInt(m[1]) * 3600 + parseInt(m[2]) * 60 + parseInt(m[3])) * 1000
|
||||
+ Math.round(parseInt(m[4].padEnd(3, '0').slice(0, 3)))
|
||||
} catch (e) {
|
||||
throw new Error(`无法读取视频时长: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 获取视频分辨率
|
||||
// ============================================================================
|
||||
|
||||
function getVideoResolution(videoPath) {
|
||||
try {
|
||||
const out = execSync(
|
||||
`/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" 2>&1 | grep 'Video:' || true`
|
||||
).toString()
|
||||
const m = out.match(/(\d{3,4})x(\d{3,4})/)
|
||||
if (m) return { width: parseInt(m[1]), height: parseInt(m[2]) }
|
||||
} catch {}
|
||||
return { width: 1080, height: 1920 }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 获取下一个全局序号
|
||||
// ============================================================================
|
||||
|
||||
function getNextGlobalIndex() {
|
||||
const config = getConfig()
|
||||
const draftDir = config.jianyingDraftPath
|
||||
if (!fs.existsSync(draftDir)) return 1
|
||||
|
||||
const folders = fs.readdirSync(draftDir)
|
||||
let maxIdx = 0
|
||||
for (const f of folders) {
|
||||
const m = f.match(/_(\d{2})_/)
|
||||
if (m) maxIdx = Math.max(maxIdx, parseInt(m[1]))
|
||||
}
|
||||
return maxIdx + 1
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主流程
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs()
|
||||
|
||||
if (!args.video || !args.transcript) {
|
||||
console.error('用法: node 口播_assemble.js --video <视频> --transcript <字幕文件> [--name <草稿名>] [--speed 1.1]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const videoPath = path.resolve(args.video)
|
||||
const transcriptPath = path.resolve(args.transcript)
|
||||
const speed = parseFloat(args.speed) || 1.1
|
||||
|
||||
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
|
||||
if (!fs.existsSync(transcriptPath)) { console.error(`字幕文件不存在: ${transcriptPath}`); process.exit(1) }
|
||||
|
||||
// 账号字幕样式
|
||||
const accountDir = path.resolve(__dirname, '..', '..', '..', 'accounts', '口播批量剪辑')
|
||||
const accountConfig = fs.existsSync(path.join(accountDir, 'account.json'))
|
||||
? JSON.parse(fs.readFileSync(path.join(accountDir, 'account.json'), 'utf-8'))
|
||||
: {}
|
||||
const subtitleStyle = accountConfig.capcut?.subtitleStyle || {}
|
||||
|
||||
// 1. 解析字幕
|
||||
console.log('[1/6] 解析字幕...')
|
||||
const sentences = parseTranscript(transcriptPath)
|
||||
console.log(` ${sentences.length} 句字幕`)
|
||||
if (sentences.length === 0) {
|
||||
console.error('字幕文件为空或格式不对')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(' --- 保留时间段(已删除气口/停顿)---')
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
const s = sentences[i]
|
||||
const segDur = (s.end_ms - s.begin_ms) / 1000
|
||||
console.log(` [${i+1}] ${(s.begin_ms/1000).toFixed(2)}s → ${(s.end_ms/1000).toFixed(2)}s (${segDur.toFixed(1)}s) ${s.text.slice(0,20)}...`)
|
||||
}
|
||||
|
||||
// 2. 读取视频信息
|
||||
console.log('[2/6] 读取视频信息...')
|
||||
const { width: vw, height: vh } = getVideoResolution(videoPath)
|
||||
const width = 1080
|
||||
const height = 1920
|
||||
console.log(` 原始分辨率: ${vw}x${vh}`)
|
||||
|
||||
// 3. 压缩到1080p + 按字幕裁剪 + 倍速
|
||||
console.log(`[3/6] 裁剪视频(${sentences.length}段)+ ${speed}x 倍速...`)
|
||||
|
||||
// 先压缩到 1080p
|
||||
const tmpCompressed = `/tmp/口播_compressed_${Date.now()}.mp4`
|
||||
execSync(
|
||||
`/usr/local/bin/ffmpeg -i "${videoPath.replace(/"/g, '\\"')}" -vf "scale=1920:1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -preset fast -c:a aac -b:a 128k "${tmpCompressed}" -y -loglevel error`,
|
||||
{ stdio: 'pipe' }
|
||||
)
|
||||
console.log(` 压缩后: ${(fs.statSync(tmpCompressed).size / 1024 / 1024).toFixed(1)} MB`)
|
||||
|
||||
// trim + concat + speed
|
||||
const tmpTrimmed = `/tmp/口播_trimmed_${Date.now()}.mp4`
|
||||
const trimArgs = buildTrimCommand(tmpCompressed, tmpTrimmed, sentences, speed)
|
||||
execFileSync(trimArgs[0], trimArgs.slice(1), { stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 })
|
||||
console.log(` 剪辑后: ${(fs.statSync(tmpTrimmed).size / 1024 / 1024).toFixed(1)} MB`)
|
||||
|
||||
const trimmedDurationMs = getVideoDurationMs(tmpTrimmed)
|
||||
const totalSourceMs = sentences.reduce((a, s) => a + (s.end_ms - s.begin_ms), 0)
|
||||
console.log(` 剪辑后时长: ${(trimmedDurationMs / 1000).toFixed(1)}s (原 ${totalSourceMs / 1000}s ÷ ${speed})`)
|
||||
|
||||
fs.unlinkSync(tmpCompressed)
|
||||
|
||||
// 4. 上传到 OSS
|
||||
console.log('[4/6] 上传到 OSS...')
|
||||
const { url: videoUrl } = await ossUpload.uploadFile(tmpTrimmed)
|
||||
fs.unlinkSync(tmpTrimmed)
|
||||
console.log(` ${videoUrl.slice(0, 60)}...`)
|
||||
|
||||
// 5. 创建草稿 + 添加视频 + 字幕
|
||||
console.log('[5/6] 创建草稿 + 添加视频 + 字幕...')
|
||||
const draftRes = await api('create_draft', { width, height })
|
||||
const draftUrl = draftRes.draft_url
|
||||
const draftId = new URL(draftUrl).searchParams.get('draft_id')
|
||||
console.log(` draft_id: ${draftId}`)
|
||||
|
||||
// 单段视频(已裁剪拼接+倍速)
|
||||
const durationUs = trimmedDurationMs * 1000
|
||||
const videoItems = [{ videoUrl, id: 'main' }]
|
||||
const videoTimeline = [{ start: 0, end: durationUs, duration: durationUs }]
|
||||
|
||||
await addVideos(draftUrl, path.dirname(videoPath), videoItems, videoTimeline, width, height)
|
||||
|
||||
// 字幕 timeline(按倍速后时间轴,逐句拆分短段)
|
||||
const subtitleItems = []
|
||||
const subtitleTimeline = []
|
||||
let cumUs = 0
|
||||
|
||||
for (const s of sentences) {
|
||||
const segDurationMs = (s.end_ms - s.begin_ms) / speed
|
||||
const segDurationUs = Math.round(segDurationMs * 1000)
|
||||
const chunks = splitSubtitleChunks(s.text, segDurationMs)
|
||||
|
||||
let chunkCumMs = 0
|
||||
for (const chunk of chunks) {
|
||||
const chunkDurationMs = chunk.ratio * segDurationMs
|
||||
const chunkDurationUs = Math.round(chunkDurationMs * 1000)
|
||||
const chunkStartUs = cumUs + Math.round(chunkCumMs * 1000)
|
||||
|
||||
subtitleItems.push({ text: chunk.text })
|
||||
subtitleTimeline.push({
|
||||
start: chunkStartUs,
|
||||
end: chunkStartUs + chunkDurationUs,
|
||||
duration: chunkDurationUs,
|
||||
})
|
||||
chunkCumMs += chunkDurationMs
|
||||
}
|
||||
|
||||
cumUs += segDurationUs
|
||||
}
|
||||
|
||||
console.log(` 字幕拆分为 ${subtitleItems.length} 段`)
|
||||
await addSubtitles(draftUrl, subtitleItems, subtitleTimeline, subtitleStyle, false)
|
||||
|
||||
// 6. 同步到本地剪映
|
||||
console.log('[6/6] 同步到剪映...')
|
||||
const today = new Date()
|
||||
const mmdd = String(today.getMonth() + 1).padStart(2, '0') + String(today.getDate()).padStart(2, '0')
|
||||
const idx = String(getNextGlobalIndex()).padStart(2, '0')
|
||||
const shortTitle = args.name || path.basename(videoPath, path.extname(videoPath)).replace(/[\s()]/g, '').slice(0, 6)
|
||||
const draftName = `口播批量剪辑_${mmdd}_${idx}_${shortTitle}`
|
||||
|
||||
await syncDraft(draftUrl, { name: draftName })
|
||||
registerDraft(draftId, draftName, durationUs)
|
||||
|
||||
// 重命名草稿文件夹(Mac 版剪映显示名 = 文件夹名)
|
||||
const config = getConfig()
|
||||
const oldDir = path.join(config.jianyingDraftPath, draftId)
|
||||
const newDir = path.join(config.jianyingDraftPath, draftName)
|
||||
if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
|
||||
fs.renameSync(oldDir, newDir)
|
||||
console.log(` 草稿文件夹已重命名: ${draftName}`)
|
||||
}
|
||||
|
||||
console.log(`\n完成!`)
|
||||
console.log(` 草稿名称: ${draftName}`)
|
||||
console.log(` 草稿地址: ${draftUrl}`)
|
||||
console.log(` 倍速: ${speed}x | 视频: 1段连续 | 字幕: ${subtitleItems.length}段`)
|
||||
console.log(`\n下一步: 封面生成`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('组装失败:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
573
.claude/skills/video-from-script/scripts/口播_pipeline.js
Normal file
@@ -0,0 +1,573 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 口播视频生产编排器
|
||||
*
|
||||
* 流程: 视频 → ASR转写 → 用户编辑字幕 → 裁剪组装 → 封面生成 → 输出表格
|
||||
*
|
||||
* 用法:
|
||||
* # 单视频
|
||||
* node 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>]
|
||||
* node 口播_pipeline.js assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]
|
||||
*
|
||||
* # 批量
|
||||
* node 口播_pipeline.js batch-init --file <csv> [--account <账号>]
|
||||
* node 口播_pipeline.js batch-assemble --file <batch-manifest> --row <N>
|
||||
* node 口播_pipeline.js export --file <batch-manifest> [--format csv|xlsx]
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync, execFileSync } = require('child_process')
|
||||
const os = require('os')
|
||||
|
||||
const SKILLS_DIR = path.resolve(__dirname, '..')
|
||||
const ACCOUNTS_DIR = path.resolve(SKILLS_DIR, '..', '..', 'accounts')
|
||||
const OUTPUT_BASE = path.resolve(SKILLS_DIR, '..', '..', 'output')
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }) }
|
||||
function readJson(p) { return JSON.parse(fs.readFileSync(p, 'utf-8')) }
|
||||
function writeJson(p, data) {
|
||||
ensureDir(path.dirname(p))
|
||||
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
function formatDate(d) {
|
||||
return [d.getFullYear(), String(d.getMonth()+1).padStart(2,'0'), String(d.getDate()).padStart(2,'0')].join('')
|
||||
}
|
||||
function calcStats(items) {
|
||||
const stats = { total: items.length, pending: 0, processing: 0, completed: 0, failed: 0 }
|
||||
for (const it of items) {
|
||||
if (stats[it.status] !== undefined) stats[it.status]++
|
||||
else stats.pending++
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI 参数解析
|
||||
// ============================================================================
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const k = argv[i]
|
||||
if (k.startsWith('--') && argv[i+1] && !argv[i+1].startsWith('--')) {
|
||||
args[k.slice(2)] = argv[++i]
|
||||
} else if (k.startsWith('--')) {
|
||||
args[k.slice(2)] = true
|
||||
} else if (!args.command) {
|
||||
args.command = k
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ASR 转写(调用 Paraformer)
|
||||
// ============================================================================
|
||||
|
||||
function runAsr(videoPath, outputPath) {
|
||||
const asrScript = path.resolve(os.homedir(), '.claude', 'scripts', 'asr_transcribe.py')
|
||||
if (!fs.existsSync(asrScript)) {
|
||||
throw new Error(`ASR 脚本不存在: ${asrScript}`)
|
||||
}
|
||||
console.log(' 转写中...')
|
||||
execSync(`python3 "${asrScript}" "${videoPath}" --output "${outputPath}"`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 120000,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 组装(调用 口播_assemble.js)
|
||||
// ============================================================================
|
||||
|
||||
function runAssemble(videoPath, transcriptPath, draftName, speed = 1.1) {
|
||||
const scriptPath = path.resolve(__dirname, '口播_assemble.js')
|
||||
const result = execFileSync('node', [
|
||||
scriptPath,
|
||||
'--video', videoPath,
|
||||
'--transcript', transcriptPath,
|
||||
'--name', draftName,
|
||||
'--speed', String(speed),
|
||||
], { stdio: 'pipe', timeout: 300000 }).toString()
|
||||
|
||||
// 从输出中提取 draft_url 和 draft_id
|
||||
let draftUrl = ''
|
||||
let draftId = ''
|
||||
for (const line of result.split('\n')) {
|
||||
if (line.includes('草稿地址:')) draftUrl = line.split('草稿地址:')[1].trim()
|
||||
if (line.includes('草稿名称:')) draftName = line.split('草稿名称:')[1].trim()
|
||||
if (line.includes('draft_id:')) draftId = line.split('draft_id:')[1].trim()
|
||||
}
|
||||
|
||||
console.log(result)
|
||||
return { draftUrl, draftName, draftId }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 封面生成(占位 — 后续接入完整封面流程)
|
||||
// ============================================================================
|
||||
|
||||
async function generateCovers(manifest, draftName, topicA) {
|
||||
// 封面后续完整实现,当前返回空
|
||||
console.log(' 封面: 待账号模板锁定后启用')
|
||||
return { cover9x16: '', cover3x4: '' }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// === 单视频模式 ===
|
||||
// ============================================================================
|
||||
|
||||
function cmdInit(args) {
|
||||
if (!args.video || !args.name) {
|
||||
console.error('用法: 口播_pipeline.js init --video <路径> --name <标题> [--account <账号>]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const videoPath = path.resolve(args.video)
|
||||
const title = args.name
|
||||
const account = args.account || '口播批量剪辑'
|
||||
|
||||
if (!fs.existsSync(videoPath)) {
|
||||
console.error(`视频不存在: ${videoPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 验证账号
|
||||
const accountJsonPath = path.join(ACCOUNTS_DIR, account, 'account.json')
|
||||
if (!fs.existsSync(accountJsonPath)) {
|
||||
console.error(`账号不存在: ${account},可用账号:`)
|
||||
for (const d of fs.readdirSync(ACCOUNTS_DIR)) {
|
||||
if (d.startsWith('_')) continue
|
||||
if (fs.existsSync(path.join(ACCOUNTS_DIR, d, 'account.json'))) console.error(` - ${d}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// 创建输出目录
|
||||
const dateStr = formatDate(new Date())
|
||||
let seq = 1
|
||||
while (fs.existsSync(path.join(OUTPUT_BASE, `${account}_${dateStr}_${String(seq).padStart(3,'0')}`))) seq++
|
||||
const outDir = path.join(OUTPUT_BASE, `${account}_${dateStr}_${String(seq).padStart(3,'0')}`)
|
||||
ensureDir(outDir)
|
||||
|
||||
// 复制视频到输出目录
|
||||
const videoBasename = path.basename(videoPath)
|
||||
const localVideo = path.join(outDir, 'source', videoBasename)
|
||||
ensureDir(path.dirname(localVideo))
|
||||
fs.copyFileSync(videoPath, localVideo)
|
||||
console.log(`视频已复制: ${localVideo}`)
|
||||
|
||||
// ASR 转写
|
||||
console.log('[ASR] 语音转文字...')
|
||||
const rawTxt = path.join(outDir, 'transcript_raw.txt')
|
||||
runAsr(localVideo, rawTxt)
|
||||
|
||||
// 复制一份作为 edit 模板
|
||||
const editTxt = path.join(outDir, 'transcript_edit.txt')
|
||||
fs.copyFileSync(rawTxt, editTxt)
|
||||
|
||||
// 写 manifest
|
||||
const manifest = {
|
||||
account,
|
||||
name: title,
|
||||
type: 'talking-head',
|
||||
videoPath: `source/${videoBasename}`,
|
||||
transcriptRaw: 'transcript_raw.txt',
|
||||
transcriptEdit: 'transcript_edit.txt',
|
||||
speed: 1.1,
|
||||
status: 'editing', // editing → assembled → done
|
||||
draftName: '',
|
||||
draftUrl: '',
|
||||
topicA: '',
|
||||
topicB: '',
|
||||
forwardCopy: '',
|
||||
hashtags: '',
|
||||
coverUrl_抖音: '',
|
||||
coverUrl_小红书: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const manifestPath = path.join(outDir, 'manifest.json')
|
||||
writeJson(manifestPath, manifest)
|
||||
|
||||
console.log(`\n初始化完成: ${manifestPath}`)
|
||||
console.log(` 视频: ${localVideo}`)
|
||||
console.log(` 原始字幕: ${rawTxt}`)
|
||||
console.log(` 编辑字幕: ${editTxt} ← 请编辑此文件(删气口/错字/冗余)`)
|
||||
console.log(`\n编辑完成后运行:`)
|
||||
console.log(` node .claude/skills/video-from-script/scripts/口播_pipeline.js assemble --manifest ${manifestPath}`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
function cmdAssemble(args) {
|
||||
if (!args.manifest) {
|
||||
console.error('用法: 口播_pipeline.js assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const manifestPath = path.resolve(args.manifest)
|
||||
const manifest = readJson(manifestPath)
|
||||
const outDir = path.dirname(manifestPath)
|
||||
|
||||
const videoPath = path.resolve(outDir, manifest.videoPath)
|
||||
const editTxt = path.resolve(outDir, manifest.transcriptEdit)
|
||||
|
||||
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
|
||||
if (!fs.existsSync(editTxt)) { console.error(`字幕文件不存在: ${editTxt},请先编辑 transcript_edit.txt`); process.exit(1) }
|
||||
|
||||
// 更新元数据
|
||||
if (args.topicA) manifest.topicA = args.topicA
|
||||
if (args.forwardCopy) manifest.forwardCopy = args.forwardCopy
|
||||
if (args.hashtags) manifest.hashtags = args.hashtags
|
||||
if (args.topicB) manifest.topicB = args.topicB
|
||||
|
||||
// 草稿名称
|
||||
const today = new Date()
|
||||
const mmdd = formatDate(today).slice(4)
|
||||
const draftName = `${manifest.account}_${mmdd}_${manifest.name}`
|
||||
manifest.draftName = draftName
|
||||
|
||||
// 组装
|
||||
console.log('[Assemble] 视频裁剪 + 字幕 + 倍速...')
|
||||
manifest.status = 'assembling'
|
||||
writeJson(manifestPath, manifest)
|
||||
|
||||
const result = runAssemble(videoPath, editTxt, manifest.name, manifest.speed || 1.1)
|
||||
manifest.draftUrl = result.draftUrl
|
||||
manifest.draftName = result.draftName || draftName
|
||||
|
||||
// 封面
|
||||
console.log('\n[Cover] 封面生成...')
|
||||
// 封面后续接入
|
||||
|
||||
manifest.status = 'done'
|
||||
manifest.completedAt = new Date().toISOString()
|
||||
writeJson(manifestPath, manifest)
|
||||
|
||||
// 输出表格行
|
||||
const htags = manifest.hashtags || ''
|
||||
let forwardBody = (manifest.forwardCopy || '').trim()
|
||||
if (htags && forwardBody.endsWith(htags)) forwardBody = forwardBody.slice(0, -htags.length).trim()
|
||||
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
|
||||
|
||||
console.log('\n' + '='.repeat(70))
|
||||
console.log('成片输出')
|
||||
console.log('='.repeat(70))
|
||||
console.log(` 草稿名称: ${manifest.draftName}`)
|
||||
console.log(` 选题(封面): ${manifest.topicA || '(未指定)'}`)
|
||||
console.log(` 转发文案: ${forwardFull || '(未指定)'}`)
|
||||
console.log(` draftUrl: ${manifest.draftUrl}`)
|
||||
console.log(` 封面_抖音: ${manifest.coverUrl_抖音 || '(待生成)'}`)
|
||||
console.log(` 封面_小红书: ${manifest.coverUrl_小红书 || '(待生成)'}`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// === 批量模式 ===
|
||||
// ============================================================================
|
||||
|
||||
function parseCsv(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const lines = content.split(/\r?\n/).filter(l => l.trim())
|
||||
if (lines.length < 2) return []
|
||||
|
||||
// 解析 CSV 行(处理引号内逗号)
|
||||
function parseLine(line) {
|
||||
const result = []; let cur = ''; let inQ = false
|
||||
for (const ch of line) {
|
||||
if (ch === '"') inQ = !inQ
|
||||
else if (ch === ',' && !inQ) { result.push(cur); cur = '' }
|
||||
else cur += ch
|
||||
}
|
||||
result.push(cur)
|
||||
return result
|
||||
}
|
||||
|
||||
const headers = parseLine(lines[0]).map(h => h.trim())
|
||||
const rows = []
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const vals = parseLine(lines[i])
|
||||
const row = {}
|
||||
headers.forEach((h, j) => { row[h] = (vals[j] || '').trim() })
|
||||
if (Object.values(row).some(v => v)) rows.push(row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function cmdBatchInit(args) {
|
||||
if (!args.file) {
|
||||
console.error('用法: 口播_pipeline.js batch-init --file <csv> [--account <账号>]')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const csvPath = path.resolve(args.file)
|
||||
if (!fs.existsSync(csvPath)) { console.error(`文件不存在: ${csvPath}`); process.exit(1) }
|
||||
|
||||
const rows = parseCsv(csvPath)
|
||||
if (rows.length === 0) { console.error('CSV 为空'); process.exit(1) }
|
||||
|
||||
const defaultAccount = args.account || '口播批量剪辑'
|
||||
|
||||
// 创建 batch 目录
|
||||
const dateStr = formatDate(new Date())
|
||||
let seq = 1
|
||||
while (fs.existsSync(path.join(OUTPUT_BASE, `batch_口播_${dateStr}_${String(seq).padStart(3,'0')}`))) seq++
|
||||
const batchDir = path.join(OUTPUT_BASE, `batch_口播_${dateStr}_${String(seq).padStart(3,'0')}`)
|
||||
ensureDir(batchDir)
|
||||
|
||||
// 构建 items
|
||||
const items = []
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
const videoPath = row['视频路径'] || row['video'] || row['视频'] || ''
|
||||
const title = row['选题'] || row['标题'] || row['title'] || row['名称'] || ''
|
||||
const account = row['账号'] || row['account'] || defaultAccount
|
||||
const forwardRaw = row['转发文案带话题'] || row['转发文案'] || row['forwardCopy'] || ''
|
||||
|
||||
if (!videoPath) {
|
||||
console.warn(` ⚠ 第 ${i+2} 行缺少视频路径,跳过`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证视频存在
|
||||
if (!fs.existsSync(path.resolve(videoPath))) {
|
||||
console.warn(` ⚠ 视频不存在: ${videoPath}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析转发文案
|
||||
let forwardCopy = forwardRaw; let hashtags = ''
|
||||
const hMatch = forwardRaw.match(/(#[^\s#]+)/g)
|
||||
if (hMatch) {
|
||||
hashtags = hMatch.join('')
|
||||
forwardCopy = forwardRaw.replace(/#[^\s#]+/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// 复制视频
|
||||
const vExt = path.extname(videoPath)
|
||||
const localVideo = path.join(batchDir, 'videos', `row_${String(i+1).padStart(3,'0')}${vExt}`)
|
||||
ensureDir(path.dirname(localVideo))
|
||||
fs.copyFileSync(path.resolve(videoPath), localVideo)
|
||||
|
||||
// ASR 转写
|
||||
const rawTxt = path.join(batchDir, 'transcripts', `row_${String(i+1).padStart(3,'0')}_raw.txt`)
|
||||
const editTxt = path.join(batchDir, 'transcripts', `row_${String(i+1).padStart(3,'0')}_edit.txt`)
|
||||
ensureDir(path.dirname(rawTxt))
|
||||
try {
|
||||
runAsr(localVideo, rawTxt)
|
||||
fs.copyFileSync(rawTxt, editTxt)
|
||||
} catch (e) {
|
||||
console.warn(` ⚠ ASR 转写失败: ${e.message}`)
|
||||
// 创建一个空文件标记需要手动处理
|
||||
fs.writeFileSync(rawTxt, '# ASR 失败,请手动填入字幕\n', 'utf-8')
|
||||
fs.copyFileSync(rawTxt, editTxt)
|
||||
}
|
||||
|
||||
items.push({
|
||||
row: i + 1,
|
||||
title: title || '',
|
||||
account,
|
||||
status: 'editing', // editing → assembled → done
|
||||
videoPath: path.relative(batchDir, localVideo),
|
||||
transcriptRaw: path.relative(batchDir, rawTxt),
|
||||
transcriptEdit: path.relative(batchDir, editTxt),
|
||||
topicA: '',
|
||||
topicB: '',
|
||||
draftName: '',
|
||||
draftUrl: '',
|
||||
forwardCopy,
|
||||
hashtags,
|
||||
coverUrl_抖音: '',
|
||||
coverUrl_小红书: '',
|
||||
speed: 1.1,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.error('没有有效行')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const batchManifest = {
|
||||
source: path.basename(csvPath),
|
||||
type: 'talking-head-batch',
|
||||
createdAt: new Date().toISOString(),
|
||||
defaults: { account: defaultAccount, speed: 1.1 },
|
||||
stats: calcStats(items),
|
||||
items,
|
||||
}
|
||||
|
||||
const manifestPath = path.join(batchDir, 'batch-manifest.json')
|
||||
writeJson(manifestPath, batchManifest)
|
||||
|
||||
console.log(`\n批量任务已创建: ${manifestPath}`)
|
||||
console.log(` 来源: ${path.basename(csvPath)}`)
|
||||
console.log(` 总数: ${items.length}`)
|
||||
console.log(` 账号: ${defaultAccount}`)
|
||||
console.log(` 视频目录: ${path.join(batchDir, 'videos')}/`)
|
||||
console.log(` 字幕目录: ${path.join(batchDir, 'transcripts')}/`)
|
||||
console.log(`\n 请编辑 transcripts/ 目录下的 _edit.txt 文件(删气口/错字/冗余),`)
|
||||
console.log(` 编辑完后运行 batch-assemble。`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
function cmdBatchAssemble(args) {
|
||||
if (!args.file || !args.row) {
|
||||
console.error('用法: 口播_pipeline.js batch-assemble --file <batch-manifest> --row <N>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const manifestPath = path.resolve(args.file)
|
||||
const batch = readJson(manifestPath)
|
||||
const batchDir = path.dirname(manifestPath)
|
||||
|
||||
const item = batch.items.find(it => it.row === args.row)
|
||||
if (!item) { console.error(`行 ${args.row} 不存在`); process.exit(1) }
|
||||
|
||||
const videoPath = path.resolve(batchDir, item.videoPath)
|
||||
const editTxt = path.resolve(batchDir, item.transcriptEdit)
|
||||
|
||||
if (!fs.existsSync(videoPath)) { console.error(`视频不存在: ${videoPath}`); process.exit(1) }
|
||||
if (!fs.existsSync(editTxt)) { console.error(`字幕文件不存在: ${editTxt}`); process.exit(1) }
|
||||
|
||||
const today = new Date()
|
||||
const mmdd = formatDate(today).slice(4)
|
||||
const shortTitle = item.topicB || item.title || `row${String(item.row).padStart(2,'0')}`
|
||||
const draftName = `${item.account}_${mmdd}_${String(item.row).padStart(2,'0')}_${shortTitle}`
|
||||
|
||||
console.log(`[#${item.row}] 组装中...`)
|
||||
|
||||
// 更新状态
|
||||
item.status = 'assembling'
|
||||
batch.stats = calcStats(batch.items)
|
||||
writeJson(manifestPath, batch)
|
||||
|
||||
try {
|
||||
const result = runAssemble(videoPath, editTxt, shortTitle, item.speed || 1.1)
|
||||
item.draftUrl = result.draftUrl
|
||||
item.draftName = result.draftName || draftName
|
||||
item.status = 'completed'
|
||||
item.error = null
|
||||
console.log(`[#${item.row}] 组装完成: ${item.draftName}`)
|
||||
} catch (e) {
|
||||
item.status = 'failed'
|
||||
item.error = e.message
|
||||
console.error(`[#${item.row}] 组装失败: ${e.message}`)
|
||||
}
|
||||
|
||||
batch.stats = calcStats(batch.items)
|
||||
writeJson(manifestPath, batch)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// export: 批量输出表格
|
||||
// ============================================================================
|
||||
|
||||
function cmdExport(args) {
|
||||
const manifestPath = path.resolve(args.file)
|
||||
const batch = readJson(manifestPath)
|
||||
|
||||
const rows = []
|
||||
for (const item of batch.items) {
|
||||
const htags = item.hashtags || ''
|
||||
let forwardBody = (item.forwardCopy || '').trim()
|
||||
if (htags && forwardBody.endsWith(htags)) forwardBody = forwardBody.slice(0, -htags.length).trim()
|
||||
const forwardFull = [forwardBody, htags].filter(Boolean).join('')
|
||||
|
||||
rows.push({
|
||||
序号: item.row,
|
||||
草稿名称: item.draftName || '',
|
||||
选题_封面: item.topicA || item.title || '',
|
||||
转发文案带话题: forwardFull,
|
||||
draftUrl: item.draftUrl || '',
|
||||
封面_抖音: item.coverUrl_抖音 || '',
|
||||
封面_小红书: item.coverUrl_小红书 || '',
|
||||
})
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a.序号 - b.序号)
|
||||
|
||||
// 生成文件名
|
||||
const accountName = batch.defaults.account || '口播批量剪辑'
|
||||
const dateMMDD = formatDate(new Date()).slice(4)
|
||||
const nums = rows.map(r => r.序号)
|
||||
const rowStart = String(Math.min(...nums)).padStart(2, '0')
|
||||
const rowEnd = String(Math.max(...nums)).padStart(2, '0')
|
||||
const exportName = `${accountName}_${dateMMDD}_${rowStart}~${rowEnd}`
|
||||
|
||||
const outPath = path.join(path.dirname(manifestPath), `${exportName}.csv`)
|
||||
const headers = ['序号', '草稿名称', '选题_封面', '转发文案带话题', 'draftUrl', '封面_抖音', '封面_小红书']
|
||||
|
||||
const lines = [headers.join(',')]
|
||||
for (const r of rows) {
|
||||
const vals = headers.map(h => {
|
||||
const v = String(r[h] || '')
|
||||
if (v.includes(',') || v.includes('"') || v.includes('\n')) return `"${v.replace(/"/g,'""')}"`
|
||||
return v
|
||||
})
|
||||
lines.push(vals.join(','))
|
||||
}
|
||||
|
||||
fs.writeFileSync(outPath, lines.join('\n'), 'utf-8')
|
||||
console.log(`表格已导出: ${outPath}`)
|
||||
console.log(` 共 ${rows.length} 条\n`)
|
||||
|
||||
// 打印表格
|
||||
const widths = headers.map(h => {
|
||||
const maxD = rows.reduce((m, r) => Math.max(m, String(r[h]||'').length), 0)
|
||||
return Math.min(Math.max(maxD, h.length), 50)
|
||||
})
|
||||
|
||||
const sep = '|-' + widths.map(w => '-'.repeat(w)).join('-|-') + '-|'
|
||||
const hdr = '| ' + headers.map((h, i) => h.padEnd(widths[i])).join(' | ') + ' |'
|
||||
|
||||
console.log(sep)
|
||||
console.log(hdr)
|
||||
console.log(sep)
|
||||
for (const r of rows) {
|
||||
const line = '| ' + headers.map((h, i) => String(r[h]||'').padEnd(widths[i])).join(' | ') + ' |'
|
||||
console.log(line)
|
||||
}
|
||||
console.log(sep)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI 入口
|
||||
// ============================================================================
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
const cmd = args.command
|
||||
|
||||
if (cmd === 'init') cmdInit(args)
|
||||
else if (cmd === 'assemble') cmdAssemble(args)
|
||||
else if (cmd === 'batch-init') cmdBatchInit(args)
|
||||
else if (cmd === 'batch-assemble') cmdBatchAssemble(args)
|
||||
else if (cmd === 'export') cmdExport(args)
|
||||
else {
|
||||
console.log('口播视频生产编排器')
|
||||
console.log('')
|
||||
console.log('单视频:')
|
||||
console.log(' init --video <路径> --name <标题> [--account <账号>]')
|
||||
console.log(' assemble --manifest <path> [--topic-a <封面标题>] [--forward-copy <转发文案>] [--hashtags <话题>]')
|
||||
console.log('')
|
||||
console.log('批量:')
|
||||
console.log(' batch-init --file <csv> [--account <账号>]')
|
||||
console.log(' batch-assemble --file <batch-manifest> --row <N>')
|
||||
console.log(' export --file <batch-manifest> [--format csv|xlsx]')
|
||||
console.log('')
|
||||
console.log('批量 CSV 格式: 视频路径,选题,转发文案带话题,账号')
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main()
|
||||
|
||||
module.exports = { cmdInit, cmdAssemble, cmdBatchInit, cmdBatchAssemble, cmdExport }
|
||||
64
CLAUDE.md
@@ -16,7 +16,69 @@
|
||||
|
||||
所有执行细节按 [SKILL.md](.claude/skills/video-from-script/SKILL.md) 执行。
|
||||
|
||||
核心步骤:**意图确认 → 风格校验 → 分镜规划(shotDesc/directorRef) → 图片提示词 → 生图+人工确认硬卡点 → 视频提示词 → 上传+生视频 → TTS+成片**
|
||||
核心步骤:**意图确认 → 风格校验 → 分镜规划(shotDesc/directorRef) → 图片提示词 → 生图+人工确认硬卡点 → 视频提示词 → 上传+生视频 → TTS+成片 → 封面生成**
|
||||
|
||||
# 成片后强制执行(全账号通用)
|
||||
|
||||
## 1. 草稿中文命名
|
||||
|
||||
assemble 完成后必须立即重命名剪映草稿,格式:`账号名_月日_全局序号_短标题`
|
||||
|
||||
- **全局序号**:扫描 `~/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/` 下所有文件夹名,提取最大两位数编号 +1(跨账号共享,不独立计数)
|
||||
- **短标题**:≤4字,提炼内容核心
|
||||
- 改名:`mv` 草稿文件夹,同步修复 `draft_content.json` 内路径(替换旧数字ID为新中文文件夹名)
|
||||
- 示例:`执黑先行_0514_16_借鸡生蛋`、`UGC产品种草_0520_17_轮毂修复`
|
||||
|
||||
## 2. 封面生成(成片后必须执行)
|
||||
|
||||
assemble + 草稿重命名完成后,立即生成封面。**每个账号有固定的封面排版风格,执行前必须确认账号的 `cover` 配置已设定。**
|
||||
|
||||
### 封面规则
|
||||
|
||||
- 读取 `account.json` 的 `cover` 字段,获取:模板参考图、排版风格、渠道配置
|
||||
- 核心文字 = `topicA`(选题方案A,封面双句)
|
||||
- **必须产出两个尺寸**:
|
||||
- `9:16`(1024×1792)→ 抖音封面
|
||||
- `3:4`(1024×1365)→ 小红书封面
|
||||
- 参考图:账号 references 目录下所有图 + `cover_template.png`(账号封面模板)
|
||||
- 图片模型:`cover.imageModel`(默认 `gpt-image`)
|
||||
- 保存路径:`output/{name}/covers/cover_{序号}_{抖音|小红书}.png`
|
||||
- **生成完毕后立即上传 OSS**,URL 写入 manifest(`coverUrl_抖音` / `coverUrl_小红书`)
|
||||
- 封面 OSS URL 写入成片表格(见下方表格规则)
|
||||
|
||||
### 执黑先行封面风格(已锁定)
|
||||
|
||||
参考 `accounts/执黑先行/references/cover_template.png`:
|
||||
- 动态场景人物,主体人物动作场景占画面 60-70%
|
||||
- 核心大字居中或上下分布,白色重字体,金色描边或高亮
|
||||
- 深暗背景,暖橙-冷蓝双光源对抗,漫威色调半写实
|
||||
- 文字内容:选题A(如「越勤奋·越无能」)
|
||||
|
||||
### 新账号封面流程
|
||||
|
||||
新账号首次执行前,必须先:
|
||||
1. 生成 3 个封面方案供用户选择
|
||||
2. 用户确认后将选定方案存为 `cover_template.png`
|
||||
3. 在 `account.json` 写入 `cover` 配置
|
||||
4. **锁定后不变**,后续批量直接按模板执行,不再出方案
|
||||
|
||||
## 3. 批量成片表格输出
|
||||
|
||||
assemble + 封面生成完成后,输出批量表格并写入 CSV 文件。
|
||||
|
||||
**表格格式**(列顺序固定):
|
||||
|
||||
| 序号 | 草稿名称 | 选题(封面) | 转发文案带话题 | draftUrl | 封面_抖音(9:16) | 封面_小红书(3:4) |
|
||||
|------|---------|------------|---------|---------|----------------|----------------|
|
||||
|
||||
- `draftUrl` = `manifest.draftUrl`(CapCut Mate 整个草稿地址,**非**单段视频 OSS 链接)
|
||||
- 选题 = 方案A(封面双句,「·」分隔)
|
||||
- CSV 文件保存到 `output/{name}/账号名_MMDD_起号~止号.csv`
|
||||
- 每次修改草稿(重跑 TTS/视频/assemble)必须重新输出新的 draftUrl 并更新 CSV
|
||||
|
||||
**规则**:
|
||||
- **非批量模式**:不生成表格,只回复草稿名 + draftUrl
|
||||
- **批量模式**:生成完整表格 + CSV 文件
|
||||
|
||||
每步必须经过用户确认或质量卡点才能进入下一步。
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.9 MiB |
BIN
accounts/ugc_product_seeding/references/gubot_lsb300pro_ref.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
accounts/ugc_product_seeding/references/hepburn_char_ref.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
35
accounts/口播批量剪辑/account.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "口播批量剪辑",
|
||||
"name": "口播批量剪辑",
|
||||
"description": "口播视频素材批量剪辑账号。用户提供视频文件,ASR提取字幕,修改错别字+删减冗余,生成含字幕的剪映草稿。无AI生图/生视频,原视频画面直接使用。",
|
||||
"pipeline": "transcript-edit",
|
||||
"defaultFormat": "9:16",
|
||||
"imageModel": "gpt-image",
|
||||
"videoModel": null,
|
||||
"ttsVoice": null,
|
||||
"cover": {
|
||||
"enabled": true,
|
||||
"template": null,
|
||||
"formats": ["9:16", "3:4"],
|
||||
"channels": { "9:16": "抖音", "3:4": "小红书" },
|
||||
"style": "待首次确认后锁定",
|
||||
"textSource": "topicA",
|
||||
"imageModel": "gpt-image"
|
||||
},
|
||||
"capcut": {
|
||||
"subtitleStyle": {
|
||||
"font": "思源黑体 Heavy",
|
||||
"fontSize": 26,
|
||||
"color": "#FFFFFF",
|
||||
"highlightColor": "#F5A623",
|
||||
"bold": true,
|
||||
"hasShadow": true,
|
||||
"shadowColor": "#000000",
|
||||
"shadowAlpha": 0.6,
|
||||
"transformY": -300,
|
||||
"alignment": 1,
|
||||
"inAnimation": "淡入",
|
||||
"outAnimation": "淡出"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,15 @@
|
||||
"ttsVoice": "谢尔比",
|
||||
"ttsRate": 1.3,
|
||||
"ttsInstruction": "用一种过来人的口吻说话,像是经历了太多懒得废话的大哥,语气里带点漫不经心和不耐烦。声音不用太用力,轻描淡写但每个字都砸在点上。偶尔轻哼一口气,显得很松弛。",
|
||||
"cover": {
|
||||
"enabled": true,
|
||||
"template": "references/cover_template.png",
|
||||
"formats": ["9:16", "3:4"],
|
||||
"channels": { "9:16": "抖音", "3:4": "小红书" },
|
||||
"style": "动态场景人物+大字封面:主体人物动作场景占画面60-70%,核心文字大字居中或上下分布,背景深暗,暖橙-冷蓝双光源对抗,漫威色调半写实。参考 cover_template.png 排版。",
|
||||
"textSource": "topicA",
|
||||
"imageModel": "gpt-image"
|
||||
},
|
||||
"storyboardPrompt": "prompts/分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
"videoStylePrompt": "prompts/视频提示词.md",
|
||||
|
||||
BIN
accounts/执黑先行/references/cover_template.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
84
accounts/执黑先行二号-风格延伸/account.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"id": "执黑先行二号-风格延伸",
|
||||
"name": "执黑先行二号-风格延伸",
|
||||
"description": "执黑先行风格延伸测试账号,用于探索新视觉风格,不影响主账号",
|
||||
"pipeline": "image-video",
|
||||
"defaultFormat": "9:16",
|
||||
"imageModel": "gemini",
|
||||
"videoModel": "kling",
|
||||
"batchSize": 30,
|
||||
"styles": {
|
||||
"执黑先行二号-风格延伸": {
|
||||
"references": []
|
||||
}
|
||||
},
|
||||
"ttsVoice": "谢尔比",
|
||||
"ttsRate": 1.3,
|
||||
"ttsInstruction": "用一种过来人的口吻说话,像是经历了太多懒得废话的大哥,语气里带点漫不经心和不耐烦。声音不用太用力,轻描淡写但每个字都砸在点上。偶尔轻哼一口气,显得很松弛。",
|
||||
"cover": {
|
||||
"enabled": true,
|
||||
"template": "references/cover_template.png",
|
||||
"formats": ["9:16", "3:4"],
|
||||
"channels": { "9:16": "抖音", "3:4": "小红书" },
|
||||
"style": "动态场景人物+大字封面:主体人物动作场景占画面60-70%,核心文字大字居中或上下分布,背景深暗,暖橙-冷蓝双光源对抗,漫威色调半写实。参考新视觉风格方向。",
|
||||
"textSource": "topicA",
|
||||
"imageModel": "gpt-image"
|
||||
},
|
||||
"storyboardPrompt": "prompts/分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
"videoStylePrompt": "prompts/视频提示词.md",
|
||||
"capcut": {
|
||||
"effects": [],
|
||||
"filter": "自然日光:30",
|
||||
"subtitleStyle": {
|
||||
"font": "思源黑体 Heavy",
|
||||
"fontSize": 12,
|
||||
"color": "#FFFFFF",
|
||||
"highlightColor": "#F5A623",
|
||||
"bold": true,
|
||||
"hasShadow": true,
|
||||
"shadowColor": "#3D2B1F",
|
||||
"shadowAlpha": 0.6,
|
||||
"transformY": -380,
|
||||
"alignment": 1,
|
||||
"inAnimation": "淡入",
|
||||
"outAnimation": "淡出"
|
||||
},
|
||||
"keywordStyle": {
|
||||
"textEffect": "简约白色黑边花字",
|
||||
"fontSize": 60,
|
||||
"color": "#F5A623",
|
||||
"bold": true,
|
||||
"transformY": 0,
|
||||
"inAnimation": "打字机效果",
|
||||
"outAnimation": "模糊淡出",
|
||||
"inAnimDuration": 300000,
|
||||
"outAnimDuration": 300000
|
||||
},
|
||||
"defaultBGM": "",
|
||||
"transitions": {
|
||||
"strategy": "rhythm",
|
||||
"default": { "name": "叠化", "duration": 200000 },
|
||||
"byPosition": {
|
||||
"hook": { "name": "叠化", "duration": 200000 },
|
||||
"keypoint": { "name": "叠化", "duration": 250000 },
|
||||
"body": { "name": "叠化", "duration": 300000 },
|
||||
"closing": { "name": "叠化", "duration": 400000 }
|
||||
},
|
||||
"byDirector": {
|
||||
"tarantino": { "name": "叠化", "duration": 250000 },
|
||||
"kitano": { "name": "叠化", "duration": 300000 },
|
||||
"fincher": { "name": "叠化", "duration": 200000 }
|
||||
}
|
||||
},
|
||||
"kenBurns": {
|
||||
"enabled": true,
|
||||
"default": { "startScale": 1.0, "scaleRate": 0.9, "panXRate": 0, "panYRate": 0 },
|
||||
"byDirector": {
|
||||
"tarantino": { "startScale": 1.0, "scaleRate": 0.85, "panXRate": 0, "panYRate": 0 },
|
||||
"kitano": { "startScale": 1.03, "scaleRate": 0.5, "panXRate": 0, "panYRate": 0 },
|
||||
"fincher": { "startScale": 1.0, "scaleRate": 0.4, "panXRate": 0, "panYRate": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
428
accounts/执黑先行二号-风格延伸/prompts/分镜.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# 分镜脚本生成 Agent v11|执黑先行账号专用版
|
||||
|
||||
## 一、铁律(违反则整条打回重写,不可妥协)
|
||||
|
||||
1. **导演禁止混合**:全部 shot 必须使用同一导演,中途不得切换
|
||||
2. **每个 shot 前0帧必须有视觉动作**:视频一播放就必须有动作发生,禁止任何静置展示
|
||||
3. **每个 shot 必须有具体视觉物件**:禁止纯姿态/纯站/纯坐/纯走路描述
|
||||
4. **好看是第一优先级**:任何身份/物品/场景都要时尚有型——**酷 + 时尚 + 潮流 + 少年感 + 浪漫点缀**
|
||||
5. **性张力**:出现人物时,整体气质要有少年感+潮流感+女人味(女性)/ 少年感+酷(男性);不是单一穿搭元素,是整体气质的平衡
|
||||
6. **人物多样性**:人物数量和组合由文案决定——文案提到几个人就几个人(可以是单人可以是多人),同一成片内 shot 之间可以男/女/男女混合,不必每个 shot 都一样;整体上避免全片只有单一性别独占。
|
||||
|
||||
---
|
||||
|
||||
## 二、色彩与光影硬性规则(每张图必须使用)
|
||||
|
||||
以下色彩和光影规则为执黑先行账号的统一要求,**所有 shot 无条件执行**,不得以任何理由降低标准。
|
||||
|
||||
### 硬性色彩要求
|
||||
|
||||
```
|
||||
色彩基调:暖调为主,饱满有活力,拒绝灰暗压抑
|
||||
主色调:暖橙金 / 金色 / 琥珀色(至少出现其一)
|
||||
辅助色:夜色蓝 / 青色 / 深蓝(与暖光形成对比)
|
||||
整体氛围:鲜亮、温暖、有氛围感,像被金色夕阳或城市灯光包裹
|
||||
```
|
||||
|
||||
### 硬性光影要求
|
||||
|
||||
```
|
||||
光源质感:有温度的光,拒绝纯冷白或去饱和灰调
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
阴影处理:阴影可以是冷色但必须通透,拒绝"死黑"
|
||||
氛围层:必须有空气感/光雾感/温度感,夜间场景要有暖色光晕、灯光散射
|
||||
浪漫点缀(只一点点):warm glow / golden rim light / amber haze / 光线穿透感
|
||||
```
|
||||
|
||||
### 禁止出现的光影
|
||||
|
||||
- 去饱和冷蓝影调(Fincher 原版"desaturated cool-blue grade"禁止)
|
||||
- 大面积纯黑死黑(Kitano 纯暗部禁止)
|
||||
- 纯冷白光源 / 无温度的灰调光
|
||||
- 灰暗、低饱和、平淡无光的画面
|
||||
|
||||
---
|
||||
|
||||
## 三、"好看"的完整定义(写死)
|
||||
|
||||
好看 = **酷 + 时尚 + 潮流 + 甜酷风 + 自然生活化 + 浪漫点缀**
|
||||
|
||||
**核心:自然有力量的酷,不是表演出来的酷。**
|
||||
|
||||
**男性(少年感):**
|
||||
- 干净、清爽、不油腻、轮廓清晰、永远年轻
|
||||
- clean-cut silhouette / effortless cool / 简单有力穿搭 / 像年轻时的 Steve McQueen / James Dean
|
||||
- unpretentious cool / youthful energy / no frills / minimal but intentional
|
||||
|
||||
**女性(甜酷风 + 赫本形象):**
|
||||
- 赫本长相为核心参照(面部留白 / 眉峰平缓干净 / 气质克制 / 眼神平静有内容 / 妆容有辨识度)
|
||||
- 甜酷风:女人味的甜 + 酷感外壳,甜和酷同时成立,不是甜腻也不是假小子
|
||||
- 清爽、精致、高级、时尚、潮流、干净利落
|
||||
- 通用词:Audrey Hepburn modern urban translation / sweet yet cool / feminine elegance meets street edge / graceful but edgy / elegant urban cool / clean feminine charm
|
||||
|
||||
**时尚潮流感:**
|
||||
- 人物:streetwear / 高端潮牌 / 设计师款 / 干净利落
|
||||
- 物品:设计感 / 有质感 / 不普通
|
||||
- 场景:城市感 / 霓虹 / 极简 / 杂志感
|
||||
|
||||
**浪漫点缀(只一点点):**
|
||||
- a hint of warm glow / a single warm light source / subtle atmospheric haze
|
||||
- romantic night city ambiance / barely-there warmth / one soft accent light
|
||||
- 少量暖光 / 夜色氛围 / 微妙情绪
|
||||
|
||||
**自然生活化(重要):**
|
||||
- 人物动作有生活质感,不是舞台表演;动作有力量但不做作
|
||||
- 避免:永远在摆pose / 永远俯视镜头 / 永远愤怒冷酷表情
|
||||
|
||||
**禁止**:土气 / 油腻 / 老气 / 普通职场照 / 邋遢 / 无轮廓 / 过度甜腻 / 女性去女性化 / 装逼感
|
||||
|
||||
---
|
||||
|
||||
## 四、性张力规则
|
||||
|
||||
**性张力来自整体气质,不是单一元素。**
|
||||
|
||||
核心:甜酷风(女性)+ 少年感(男性)+ 潮流感 同时成立。
|
||||
|
||||
- **女性**:赫本长相 + 甜酷风(女人味的甜 + 酷感外壳);清爽精致高级,甜和酷同时成立;穿搭可以是修身/宽松/设计师款,重点是干净利落有设计感
|
||||
- **男性**:不管年龄,永远少年感,clean-cut / effortless cool / 轮廓清晰 / 简单有力
|
||||
- **穿搭是气质的外化**:可以有修身紧身,也可以宽松但有型,重点是干净利落
|
||||
|
||||
**正确示范:**
|
||||
- 女性:赫本风现代都市版 + 甜酷设计师款 / 精致优雅 + 城市潮流感
|
||||
- 男性:干净利落的基本款叠穿 / 简单 streetwear / 轮廓清晰少年感
|
||||
|
||||
**禁止**:油腻 / 土气 / 老气 / 无轮廓 / 过度暴露 / 过度甜腻 / 邋遢 / 女性去女性化
|
||||
|
||||
---
|
||||
|
||||
## 五、导演构图与镜头运动词库
|
||||
|
||||
导演词库**只对应构图方式和镜头运动语言**,色彩和光影统一使用 §二 硬性规则。
|
||||
|
||||
### Fincher|精确几何·冷峻分析
|
||||
|
||||
**构图关键词:**
|
||||
- 硬边阴影切割画面,主体精确落在光影交界线上
|
||||
- 精确对称构图但有一处破坏
|
||||
- 建筑感构图(architectural composition)
|
||||
- 硬边阴影几何块(hard geometric shadow blocks)
|
||||
- 去饱和色调,高级时装感构图
|
||||
- clean-cut 少年感穿搭,高级简洁
|
||||
- 现代都市感,都市冷酷感
|
||||
|
||||
**镜头运动关键词:**
|
||||
- 缓慢推进(slow push-in),积累压迫感
|
||||
- 快速横切(fast whip pan),制造切换感
|
||||
- 定焦推进(static push),凝固张力
|
||||
- 垂直下降(vertical drop),揭示空间纵深
|
||||
|
||||
### Tarantino|力量爆发·暖冷对抗
|
||||
|
||||
**构图关键词:**
|
||||
- 冷暖双色光源在主体身上形成边缘对抗
|
||||
- 实用光源(台灯/霓虹/街灯)制造暖色轮廓光
|
||||
- 硬边阴影分割画面,暖光勾亮轮廓
|
||||
- 都市街头潮流感,少年感 streetwear,动作有爆发力,clean-cut
|
||||
- 人物站在光影交界处,冷暖双色对比,effortless cool
|
||||
- 动作驱动构图(action-driven composition)
|
||||
|
||||
**镜头运动关键词:**
|
||||
- 快速拉远(fast pull-back),揭示全貌
|
||||
- 跟拍横移(tracking shot),跟随运动主体
|
||||
- 快速甩镜(fast whip pan),切换冲突
|
||||
- 俯冲视角(crash zoom down),制造压迫感
|
||||
|
||||
### Kitano|克制孤独·都市哲思
|
||||
|
||||
**构图关键词:**
|
||||
- 负空间构图(negative space composition)
|
||||
- 主体孤立在画面中心,大面积留黑但有光感
|
||||
- 冷蓝实用光源从上方或侧方切入,暖色城市远光渗透
|
||||
- 都市夜景,霓虹灯在暗部中发光,有温度的光
|
||||
- 主体孤立在画面中心,克制但有视觉张力,clean-cut 少年感
|
||||
- 都市年轻人的孤独感,高级感留白,浪漫夜色氛围(只一点点)
|
||||
|
||||
**镜头运动关键词:**
|
||||
- 缓慢横移(slow tracking),积累情绪
|
||||
- 静止凝视(static hold),沉默张力
|
||||
- 快速仰起(fast tilt-up),揭示孤独感
|
||||
- 低角度跟拍(low-angle tracking),赋予力量感
|
||||
|
||||
---
|
||||
|
||||
## 六、时间线铁律(违反则整条打回重写,不可妥协)
|
||||
|
||||
### ⚠️ 铁律一:音频是神圣的,不可修改
|
||||
|
||||
- TTS 语速 = **1.15x**(固定),不得加速/减速/裁剪/静音
|
||||
- **口播文案不可改字**:script 内容由用户定,分镜只决定切割位置
|
||||
- **音频时长 = 视频时长**:画面必须配合音频,音频不配合画面
|
||||
- Kling 每个视频片段固定 **6 秒**
|
||||
|
||||
### ⚠️ 铁律二:分镜规划前必须先完成文案预切割
|
||||
|
||||
**第一步(在写任何 shotDesc 之前)**:按以下规则把完整口播文案切分成 shot:
|
||||
|
||||
1. 估算公式:每个 shot 的 TTS 时长 = **script 字数 ÷ 5**(秒)
|
||||
- 语速基准:1.15x 正常中文说话速率 ≈ 5字/秒
|
||||
2. 每个 shot 估算时长必须 **≤ 6 秒**(= 最多 **30字**)
|
||||
3. 相邻两段合并后 ≤ 30字 → **强制合并**为一个 shot
|
||||
4. 任何 shot 估算 > 6s → **强制在语义断点处拆分**
|
||||
|
||||
| TTS 估算 | 决策 |
|
||||
|---------|------|
|
||||
| < 3秒(<15字) | 强制合并至相邻 Shot(合并后 ≤ 30字) |
|
||||
| 3–6秒(15–30字) | 可独立 shot |
|
||||
| > 6秒(>30字) | **强制拆分,每新 shot ≤ 30字** |
|
||||
|
||||
### ⚠️ 铁律三:视频 vs 音频的唯一合法处理策略
|
||||
|
||||
| 情况 | ratio = 视频(6s) ÷ 音频 | 策略 |
|
||||
|------|------------------------|------|
|
||||
| 视频 > 音频,ratio 1.1–2x | ✅ 视频加速(speed_up)|
|
||||
| 视频 >> 音频,ratio > 2x | ✅ 视频截断(trim)|
|
||||
| 视频 ≈ 音频,ratio 0.9–1.1x | ✅ 无需调整(none)|
|
||||
| **音频 > 视频,ratio < 0.9** | ❌ **禁止!** 必须打回分镜重新拆分 script |
|
||||
|
||||
**绝对禁止**:视频放慢 / 冻结帧 / 音频加减速 / 音频裁剪
|
||||
|
||||
### ⚠️ 铁律四:音频实测 > 6s 时的补救
|
||||
|
||||
如果 TTS 实测超过 6s(估算误差导致):
|
||||
1. 在语义断点处把 **音频文件物理切割**(ffmpeg -t / -ss)
|
||||
2. 把对应 script 同步切分为多个 shot
|
||||
3. 每个新 shot 独立配一个 Kling 6s 视频
|
||||
4. **禁止**:拉伸视频 / 截断音频内容 / 改变语速
|
||||
|
||||
---
|
||||
|
||||
## 七、shotDesc 质量规则(逐条检查,违反则重写)
|
||||
|
||||
**1. 冲突(最高优先级)**
|
||||
- 每个 shot 必须有明确的冲突类型(期待 vs 现实 / 想要 vs 不能要 / 表面 vs 内心 / 我 vs 你 / 行动 vs 停滞 / 过去 vs 现在)
|
||||
- 冲突的核心必须强关联当前 shot 的文案——看了画面能联想到旁白说了什么
|
||||
- 当旁白没有表面冲突 → 从全文理解,提取抽象冲突内核,用象征/暗喻/比喻制造视觉冲突
|
||||
- 冲突感强化词叠加使用:scene already in progress when clip begins / conflict at peak not at start / no establishing shot
|
||||
|
||||
**2. 中途入场(前0帧冲击)**
|
||||
- 视频一播放,动作就已在进行中,禁止从平静开始
|
||||
- 前 0 帧必须有动作发生(镜头动或人/物动),禁止任何静置展示
|
||||
- 动作要足够强烈:大幅位移、闯入、撞碎、甩离——不是微动
|
||||
|
||||
**3. 视觉物件**
|
||||
- 包含至少一个具体可辨识的物件(道具/装置/环境元素)
|
||||
- 物件是冲突的载体
|
||||
|
||||
**3. 动作弧**
|
||||
- 包含完整动作(起点→终点),幅度肉眼可见
|
||||
- 动作本身即冲突的视觉化
|
||||
- 人物组合由文案决定:单人多人都可以,shot 间可以变化
|
||||
|
||||
**4. 概念对应**
|
||||
- 画面直击当前 script 的核心论点
|
||||
- 文案说"做大"→画面有跨越/扩张;文案说"闭嘴"→画面有封闭/沉默的压迫感
|
||||
|
||||
**5. 好看 + 甜酷风 + 浪漫点缀**
|
||||
- 出现的女性:赫本长相 + 甜酷风(甜 + 酷同时成立)+ 女人味重要 + 潮流时尚
|
||||
- 出现的男性:少年感(干净/清爽/不油腻/轮廓清晰)+ 潮流时尚
|
||||
- 出现物品:设计感/质感,不普通
|
||||
- 场景:城市感/霓虹/极简/杂志感
|
||||
- 浪漫点缀(只一点点):warm glow / 夜色氛围 / 微妙情绪
|
||||
- 核心:女性甜酷风+赫本+女人味 / 男性少年感 + 潮流感 同时成立
|
||||
|
||||
**6. 禁止项**
|
||||
- 禁止纯站着/坐着/走路开场
|
||||
- 禁止无物件的画面
|
||||
- 禁止表情/手部微动作为完整动作弧
|
||||
- 禁止土气/油腻/老气形象
|
||||
- 禁止女性去女性化(boylish / 假小子 / 无女人味)
|
||||
|
||||
---
|
||||
|
||||
## 八、抽象冲突制造方法
|
||||
|
||||
当旁白没有表面冲突时,从全文理解冲突内核,用象征手法呈现:
|
||||
|
||||
| 旁白类型 | 冲突内核 | 象征/暗喻画面 |
|
||||
|---------|---------|--------------|
|
||||
| 概念型/哲理型 | 内在矛盾 | 镜像/分裂/枷锁/镜像扭曲 |
|
||||
| 积累型 | 压迫与爆发 | 空间收缩/物体崩裂/光线突破 |
|
||||
| 对比型 | 两方对抗 | 光影分割/颜色对立/身体分界 |
|
||||
| 沉默型 | 压抑的张力 | 悬停/临界状态/即将发生的瞬间 |
|
||||
|
||||
---
|
||||
|
||||
## 八、中途入场原则:0帧冲击
|
||||
|
||||
### 核心原理:「In Medias Res」
|
||||
|
||||
**每个片段不从"开始"拍,而是从"已经发生到最紧张的那一秒"开始。**
|
||||
|
||||
> 冲击力 = 动作已在进行 + 信息不完整引发好奇 + 画面或人物有明显运动
|
||||
|
||||
视频一播放,观众就必须看到动作正在发生。不要给观众"平静的起点"——让他们用前3秒追赶画面,他们就没有时间划走。
|
||||
|
||||
### 中途入场的两种来源
|
||||
|
||||
#### A 类 —「镜头动」:摄影机运动制造冲击
|
||||
|
||||
| 运动方式 | 描述 |
|
||||
|---------|------|
|
||||
| 急速下降落地 | 镜头从高处快速下降至地面,揭示已发生的动作 |
|
||||
| 旋转切入 | 镜头旋转进入场景,dutch 角度修正 |
|
||||
| 极速推进(Smash Zoom) | 从极宽到极近,一秒内完成,不渐变 |
|
||||
| 跟不上的跟拍 | 镜头追赶主体,主体已在前方,部分出画 |
|
||||
| 遮挡物后突然暴露 | 镜头或主体移动,场景信息突然释放 |
|
||||
|
||||
#### B 类 —「人/物动」:主体运动制造冲击
|
||||
|
||||
| 运动方式 | 描述 |
|
||||
|---------|------|
|
||||
| 突然转身/回头 | clip opens mid-turn,头发甩入画,面部在第一帧锁定镜头 |
|
||||
| 闯入画面 | 人物从边缘高速入画,无预警,镜头捕捉其跨步瞬间 |
|
||||
| 抓握/推开 | clip opens on 手已抓住手腕/衣领——张力已在峰值,无前置 |
|
||||
| 物体砸向镜头 | 物件在开场帧直接扔向镜头,极致近景冲击,然后切反应 |
|
||||
| 从画面外拉入 | 人物被拉入/拽入,已失去平衡,观众立刻问"谁在拉" |
|
||||
|
||||
### 冲突感强化词(叠加使用)
|
||||
|
||||
```
|
||||
scene already in progress when clip begins
|
||||
conflict at peak not at start
|
||||
no establishing shot — drop viewer into middle of action
|
||||
whip pan opening, subject in motion frame one
|
||||
asymmetric power dynamic immediately visible
|
||||
something has just happened or is about to — viewer must catch up
|
||||
```
|
||||
|
||||
### 按情绪分类的开场模板
|
||||
|
||||
| 情绪类型 | 开场策略 |
|
||||
|---------|---------|
|
||||
| 愤怒/对抗 | clip opens on face inches from face,already shouting or silent with rage |
|
||||
| 欲望/吸引 | clip opens mid-turn,subject's gaze snaps to something off-screen |
|
||||
| 逃跑/追逐 | clip opens on feet already running,ground blurring below,camera low |
|
||||
| 沉默的张力 | clip opens on two figures, backs to each other, already in standoff |
|
||||
| 意外/反转 | clip opens on mundane detail — something is wrong that viewer can't immediately name |
|
||||
|
||||
---
|
||||
|
||||
## 九、冲突感设计:文案→画面的视觉转化
|
||||
|
||||
### 核心思路
|
||||
|
||||
文案口播的本质是**情绪在时间轴上的变化**。每一段文案背后都有一个隐藏的冲突结构。分镜的任务不是"配图",而是**把文案的潜台词可视化**。
|
||||
|
||||
### 六种冲突类型
|
||||
|
||||
| 冲突类型 | 文案信号词 | 画面方向 |
|
||||
|---------|---------|---------|
|
||||
| **期待 vs 现实** | 以为/结果/没想到 | 人物表情从期待到落空 |
|
||||
| **想要 vs 不能要** | 明明/但是/偏偏 | 人物靠近又克制后退 |
|
||||
| **过去 vs 现在** | 曾经/现在/回头看 | 同一空间两种状态并置 |
|
||||
| **表面 vs 内心** | 笑着/装作/其实 | 面部微表情与动作不一致 |
|
||||
| **我 vs 你** | 你从来/我一直/凭什么 | 两人物理距离与心理距离的反差 |
|
||||
| **行动 vs 停滞** | 我决定/却还是/没走 | 人物中途停下或转身未完成 |
|
||||
|
||||
### 快速判断冲突的三个问题
|
||||
|
||||
每次拿到一段文案,只问:
|
||||
1. **「谁想要什么,得不到?」** → 欲望冲突
|
||||
2. **「谁知道什么,说不出?」** → 信息冲突
|
||||
3. **「谁在走,谁在留?」** → 行动冲突
|
||||
|
||||
三个问题至少能回答一个,画面就有了。
|
||||
|
||||
### 图文结合三关系(选对才有效)
|
||||
|
||||
| 关系 | 说明 | 效果 |
|
||||
|------|------|------|
|
||||
| **平行** | 画面直接呈现文案内容 | 清晰但无惊喜 |
|
||||
| **对位** | 画面呈现文案的反面或潜台词 | 有层次感,耐看 |
|
||||
| **延伸** | 画面在文案结束后继续发展 | 余韵感强,留白最强 |
|
||||
|
||||
**最优组合 = 对位 + 延伸**
|
||||
> 文案说"我放下了" → 画面是人物手指慢慢松开某样东西,但镜头停在那个东西上,没有跟着人走
|
||||
|
||||
---
|
||||
|
||||
## 十、自检清单(输出前必须逐条核对)
|
||||
|
||||
```
|
||||
[ ] 每个 shot 有明确的冲突类型(期待vs现实/想要vs不能/表面vs内心/我vs你/行动vs停滞/过去vs现在)
|
||||
[ ] 中途入场:动作已在进行中,观众必须追赶画面
|
||||
[ ] 所有 shot 使用同一导演,无混合
|
||||
[ ] 色彩和光影使用 §二 硬性规则,无去饱和冷蓝/纯黑/灰调光
|
||||
[ ] 每个 shot 有具体视觉物件(不是纯姿态)
|
||||
[ ] 每个 shot 前0帧动作幅度够大:大幅位移/闯入/撞碎/甩离(不是微动)
|
||||
[ ] TTS 估算全部 ≤ 6s
|
||||
[ ] 所有 script 拼接 = 原文一字不差
|
||||
[ ] 出现的人物:女性=赫本长相+甜酷风+女人味 / 男性=少年感+潮流时尚,整体气质平衡,无土气/油腻
|
||||
[ ] 场景或物件有潮流元素,有浪漫点缀(只一点点)
|
||||
[ ] 当旁白无表面冲突时,已用象征/暗喻手法制造冲突画面
|
||||
[ ] 人物数量和组合由文案决定,单人/多人/混合均可,shot 间可以变化
|
||||
[ ] 色彩:暖调为主(暖橙金/金色/琥珀色),有温度感的光,拒绝灰暗无光
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、导演选择方法
|
||||
|
||||
根据文案整体气质选定一位导演,全部 shot 统一使用:
|
||||
|
||||
| 文案气质 | 推荐导演 | 镜头语言 |
|
||||
|---------|---------|---------|
|
||||
| 冷静分析、揭示真相、有深度 | Fincher | 极慢推镜 / 上帝俯瞰 / 静止锁定机位 |
|
||||
| 力量主张、跨越对抗、爆发感 | Tarantino | 后备箱仰角 / 长镜对话 / 脚部特写切入 |
|
||||
| 孤独积累、克制沉默、都市哲思 | Kitano | 静止长镜 / 侧面轮廓 / 空镜插入 |
|
||||
|
||||
**导演镜头运动示例(分镜规划时可直接使用):**
|
||||
|
||||
**Fincher — 「冷静的上帝视角」**
|
||||
- imperceptibly slow push-in toward subject, almost imperceptible camera drift forward
|
||||
- straight down top-down crane shot, figures reduced to geometric shapes
|
||||
- locked-off symmetrical wide shot, zero camera movement, subject moves through perfectly composed frame
|
||||
- smooth tracking shot following subject from behind at fixed distance, steadicam glide
|
||||
|
||||
**Tarantino — 「人物即舞台,对话即表演」**
|
||||
- extreme low angle looking up at characters from below ground level, figures tower against sky
|
||||
- slow alternating over-the-shoulder shots during dialogue, gradual push-in per cut
|
||||
- low camera tracking laterally alongside walking characters, slow motion or normal speed
|
||||
- unexpected close-up cutaway to feet, shoes, hands, low angle floor-level framing
|
||||
|
||||
**Kitano — 「沉默比台词更重」**
|
||||
- completely static locked-off shot held for extended duration, characters enter and exit without camera following
|
||||
- scene cuts abruptly before or after expected climax, violence happens off-screen
|
||||
- characters staged in strict profile, facing opposite directions, emotional distance through staging
|
||||
- sudden cut to empty landscape — ocean, sky, empty road — held stillness as punctuation
|
||||
|
||||
---
|
||||
|
||||
## 十二、输出格式
|
||||
|
||||
**第一步:输出总览行**
|
||||
|
||||
```
|
||||
文案共识别 X 个语义场景 | 预计总时长 XX 秒 | 共 X 个 Shot
|
||||
导演:XXX(禁止混合)
|
||||
[TTS规划] S1: Xs / S2: Xs / ...
|
||||
[色彩与光影] 暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗
|
||||
[人物安排] 人物数量和组合由文案决定(如:单人/双人多视角/男女混合),shot间可变化
|
||||
```
|
||||
|
||||
**第二步:逐条自检清单核对**
|
||||
|
||||
**第三步:输出 JSON**
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"shotDesc": "英文画面描述(必须含:冲突动作 + 视觉物件 + 时尚潮流感 + 性张力穿搭 + 浪漫点缀)",
|
||||
"script": "原文逐字摘取,禁止改写",
|
||||
"duration": "TTS估算秒数(=字数÷5,保留1位小数)",
|
||||
"directorRef": "fincher | tarantino | kitano(全部统一)"
|
||||
}
|
||||
]
|
||||
```
|
||||
268
accounts/执黑先行二号-风格延伸/prompts/图片提示词.md
Normal file
@@ -0,0 +1,268 @@
|
||||
# 图片提示词生成器 v1|执黑先行二号-风格延伸(测试账号)
|
||||
|
||||
> **版本说明**:本账号用于新视觉风格测试。§二(色彩与光影)和 §九(图像风格参考)待参考图分析后注入,其余结构框架与主账号一致。
|
||||
|
||||
---
|
||||
|
||||
## 一、铁律(违反则回写失败,必须重写)
|
||||
|
||||
1. **导演只对应构图**:imagePrompt 光影层必须使用分镜指定的同一导演词库,全程不切换。色彩和光影氛围统一使用 §二 硬性规则
|
||||
2. **好看 = 酷 + 时尚 + 潮流 + 浪漫点缀**:任何身份/物品/场景都要时尚有型
|
||||
3. **性张力(穿搭剪裁)**:出现人物时,穿搭必须修身/紧身/露肤适度,体现身材轮廓但不暴露
|
||||
4. **当旁白无表面冲突时**:从全文理解冲突内核,用象征/暗喻/比喻制造视觉冲突画面
|
||||
5. **shotDesc 决定画什么,directorRef 决定构图执行方式**:禁止擅自改变画面内容
|
||||
6. **人物由文案决定**:单人多人都可以,shot间可变化;整体避免单一性别独占
|
||||
|
||||
---
|
||||
|
||||
## 二、色彩与光影硬性规则(待参考图注入)
|
||||
|
||||
> ⚠️ **本节待定**:参考图分析完成后,由以下模板填入具体色彩与光影规则。
|
||||
> 在参考图确认前,暂时沿用执黑先行主账号的色彩规则作为测试基底。
|
||||
|
||||
### 当前测试基底(参考图确认后替换)
|
||||
|
||||
```
|
||||
色彩基调:【待参考图定义】
|
||||
主色调:【待参考图定义】
|
||||
辅助色:【待参考图定义】
|
||||
整体氛围:【待参考图定义】
|
||||
```
|
||||
|
||||
### 光影规则(待参考图定义)
|
||||
|
||||
```
|
||||
光源质感:【待参考图定义】
|
||||
光感:【待参考图定义】
|
||||
阴影处理:【待参考图定义】
|
||||
氛围层:【待参考图定义】
|
||||
```
|
||||
|
||||
### 禁止出现的光影(待参考图定义)
|
||||
|
||||
- 【待参考图定义——与新风格冲突的色系将在此列出】
|
||||
|
||||
---
|
||||
|
||||
## 三、"好看"的完整定义(写死)
|
||||
|
||||
好看 = **酷 + 时尚 + 潮流 + 甜酷风 + 浪漫点缀**
|
||||
|
||||
**男性(少年感):**
|
||||
- 干净、清爽、不油腻、轮廓清晰、永远年轻
|
||||
- clean-cut silhouette / effortless cool / 像年轻时的 Steve McQueen / James Dean
|
||||
- unpretentious cool / youthful energy / no frills / minimal but intentional
|
||||
|
||||
**女性(甜酷风 + 赫本形象):**
|
||||
- 赫本长相为核心参照(面部留白 / 眉峰平缓干净 / 气质克制 / 眼神平静有内容 / 妆容有辨识度)
|
||||
- 甜酷风:女人味的甜 + 酷感外壳,甜和酷同时成立
|
||||
- 清爽、精致、高级、时尚、潮流、干净利落
|
||||
- 通用词:Audrey Hepburn modern urban / sweet yet cool / feminine elegance meets street edge / graceful but edgy / elegant urban cool / clean feminine charm
|
||||
|
||||
**时尚潮流感:**
|
||||
- 人物:streetwear / 高端潮牌 / 设计师款 / 干净利落
|
||||
- 物品:设计感 / 质感 / 不普通
|
||||
- 场景:城市感 / 霓虹 / 极简 / 杂志感
|
||||
|
||||
**浪漫点缀(只一点点):**
|
||||
- a hint of warm glow / a single warm light source / subtle atmospheric haze
|
||||
- romantic city night ambiance / barely-there warmth / one soft accent light
|
||||
|
||||
**禁止**:土气 / 油腻 / 老气 / 普通职场照 / 邋遢 / 无轮廓 / 过度甜腻 / 女性去女性化
|
||||
|
||||
---
|
||||
|
||||
## 四、性张力规则
|
||||
|
||||
**性张力来自整体气质,不是单一元素。**
|
||||
|
||||
核心:甜酷风(女性)+ 少年感(男性)+ 潮流感 同时成立。
|
||||
|
||||
- **女性**:赫本长相 + 甜酷风(女人味的甜 + 酷感外壳);清爽精致高级,甜和酷同时成立;穿搭可以是修身/宽松/设计师款,重点是干净利落有设计感
|
||||
- **男性**:不管年龄,永远少年感,clean-cut / effortless cool / 轮廓清晰 / 简单有力
|
||||
- **穿搭是气质的外化**:可以有修身紧身,也可以宽松但有型,重点是干净利落
|
||||
|
||||
**禁止**:油腻 / 土气 / 老气 / 无轮廓 / 过度暴露 / 过度甜腻 / 邋遢 / 女性去女性化
|
||||
|
||||
---
|
||||
|
||||
## 五、导演构图词库(只对应构图,色彩用 §二 硬性规则)
|
||||
|
||||
### Fincher 构图层
|
||||
|
||||
```
|
||||
硬边阴影切割画面,主体精确落在光影交界线上,
|
||||
精确对称构图但有一处破坏,建筑感构图,
|
||||
硬边阴影几何块(hard geometric shadow blocks),
|
||||
高级时装感构图,clean-cut 少年感穿搭,高级简洁,现代都市冷酷感
|
||||
|
||||
画风补充:【待参考图定义画风词】
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
|
||||
### Tarantino 构图层
|
||||
|
||||
```
|
||||
冷暖双色光源在主体身上形成边缘对抗,
|
||||
实用光源(台灯/霓虹/街灯)制造轮廓光,
|
||||
硬边阴影分割画面,暖光勾亮轮廓,
|
||||
都市街头潮流感,少年感 streetwear,动作有爆发力,clean-cut,
|
||||
人物站在光影交界处,effortless cool,
|
||||
动作驱动构图(action-driven composition)
|
||||
|
||||
画风补充:【待参考图定义画风词】
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
|
||||
### Kitano 构图层
|
||||
|
||||
```
|
||||
负空间构图(negative space composition),
|
||||
主体孤立在画面中心,大面积留白但有光感,
|
||||
实用光源从上方或侧方切入,暖色城市远光渗透,
|
||||
都市夜景,霓虹灯在暗部中发光,有温度的光,
|
||||
克制但有视觉张力,clean-cut 少年感,
|
||||
都市年轻人的孤独感,高级感留白,浪漫夜色氛围(只一点点)
|
||||
|
||||
画风补充:【待参考图定义画风词】
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、imagePrompt 维度结构(必须覆盖全部维度)
|
||||
|
||||
imagePrompt 必须按以下顺序写全以下维度,不得遗漏:
|
||||
|
||||
```
|
||||
[1. 主体描述]
|
||||
人物:是谁/在做什么/具体的动作和姿态(来自 shotDesc,完整保留)
|
||||
|
||||
[2. 外貌与气质]
|
||||
- 脸:赫本长相(女)/ 轮廓清晰(男)
|
||||
- 表情:克制/平静/有内容(不是空洞,不是夸张)
|
||||
- 气质:甜酷(女)/ 少年感(男)/ effortless cool
|
||||
|
||||
[3. 穿搭细节]
|
||||
具体描述穿什么/什么材质/什么剪裁/什么颜色/有什么配饰
|
||||
(至少写清楚上身+下身+一个配饰或细节)
|
||||
|
||||
[4. 环境细节]
|
||||
具体描述场景里有什么/光从哪来/背景是什么
|
||||
(至少写清楚背景材质+光源+一个空间细节)
|
||||
|
||||
[5. 情绪与氛围]
|
||||
这个画面传递什么情绪/什么氛围/旁白的什么情绪被视觉化了
|
||||
|
||||
[6. 色彩与光影硬性层(必须使用 §二 硬性规则)]
|
||||
参考图定义的色彩规则(待填入)
|
||||
|
||||
[7. 导演构图层]
|
||||
来自 directorRef 指定导演的完整构图词组(不替换,不混合)
|
||||
|
||||
[8. 画风词尾(待参考图定义,固定不变)]
|
||||
【参考图分析后替换此处为新风格的固定词尾】
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、自检清单(输出 imagePrompt 前逐条核对)
|
||||
|
||||
```
|
||||
[ ] shotDesc 画面内容 100% 保留,无删减替换
|
||||
[ ] 人物组合由文案决定,单人/多人/混合均可
|
||||
[ ] 人物外貌:女性=赫本+甜酷 / 男性=少年感+轮廓清晰,有具体描述
|
||||
[ ] 穿搭细节:上身+下身+至少一个配饰或细节(具体描述)
|
||||
[ ] 环境细节:背景材质+光源+至少一个空间细节(具体描述)
|
||||
[ ] 情绪与氛围:有写,不是空白
|
||||
[ ] 无土气/油腻/老气形象
|
||||
[ ] 色彩使用 §二 硬性规则(参考图确认后检查项更新)
|
||||
[ ] 构图使用 directorRef 指定导演的完整构图词组,无混合
|
||||
[ ] 图文结合:shotDesc 里的视觉物件是否直接对应旁白的核心概念
|
||||
[ ] shotDesc 中的动作是"即将发生"的瞬间,非"已完成"状态
|
||||
[ ] 字数 ≥ 200字
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、冲突感设计:文案→画面的视觉转化
|
||||
|
||||
### 核心思路
|
||||
|
||||
文案口播的本质是**情绪在时间轴上的变化**。每一段文案背后都有一个隐藏的冲突结构。图片的任务不是"配图",而是**把文案的潜台词可视化**。
|
||||
|
||||
### 六种冲突类型
|
||||
|
||||
| 冲突类型 | 文案信号词 | 画面方向 |
|
||||
|---------|---------|---------|
|
||||
| **期待 vs 现实** | 以为/结果/没想到 | 人物表情从期待到落空 |
|
||||
| **想要 vs 不能要** | 明明/但是/偏偏 | 人物靠近又克制后退 |
|
||||
| **过去 vs 现在** | 曾经/现在/回头看 | 同一空间两种状态并置 |
|
||||
| **表面 vs 内心** | 笑着/装作/其实 | 面部微表情与动作不一致 |
|
||||
| **我 vs 你** | 你从来/我一直/凭什么 | 两人物理距离与心理距离的反差 |
|
||||
| **行动 vs 停滞** | 我决定/却还是/没走 | 人物中途停下或转身未完成 |
|
||||
|
||||
---
|
||||
|
||||
## 九、图像风格参考(待参考图注入)
|
||||
|
||||
> ⚠️ **本节待定**:参考图上传并分析后,在此填入新风格的完整描述。
|
||||
|
||||
### 风格识别(待定)
|
||||
|
||||
```
|
||||
核心风格:【待参考图分析】
|
||||
媒介感:【待参考图分析】
|
||||
色彩系统:【待参考图分析】
|
||||
光影系统:【待参考图分析】
|
||||
构图特征:【待参考图分析】
|
||||
```
|
||||
|
||||
### 通用图像提示词模板(待定)
|
||||
|
||||
```
|
||||
【参考图分析完成后,在此生成三个模型版本的风格词:
|
||||
- Gemini 版:英文自然语言句子
|
||||
- MJ 版:英文逗号短语 + --ar 9:16 --style raw
|
||||
- Kling 版:中文描述】
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、输入规范
|
||||
|
||||
```
|
||||
【shotDesc】当前 Shot 的英文分镜描述
|
||||
【当前旁白】该 Shot 对应的中文口播旁白
|
||||
【完整文案】完整口播文案原文
|
||||
【directorRef】fincher / tarantino / kitano(分镜指定)
|
||||
【目标模型】Gemini / MidJourney / Kling
|
||||
```
|
||||
|
||||
缺少任意一项 → 返回错误并终止。
|
||||
|
||||
---
|
||||
|
||||
## 十一、输出格式
|
||||
|
||||
```
|
||||
### Shot [N] 图片提示词 | [导演] | [模型]
|
||||
**图文对应:** 旁白"XXX" → 视觉象征"XXX"
|
||||
**叙事定位:** 一句话说明这帧在整体叙事中的位置
|
||||
**维度覆盖自检:**
|
||||
[ ] 1.主体描述(来自shotDesc,完整,含视觉象征物件)
|
||||
[ ] 2.外貌与气质(赫本/少年感/具体表情)
|
||||
[ ] 3.穿搭细节(上身+下身+配饰,具体)
|
||||
[ ] 4.环境细节(背景+光源+空间细节,具体)
|
||||
[ ] 5.情绪与氛围(有写)
|
||||
[ ] 6.色彩与光影硬性层(使用 §二 规则)
|
||||
[ ] 7.导演构图层(完整使用,无混合)
|
||||
[ ] 8.画风词尾(固定附加)
|
||||
**imagePrompt:**
|
||||
[完整提示词,必须覆盖1-8全部维度]
|
||||
```
|
||||
619
accounts/执黑先行二号-风格延伸/prompts/视频提示词.md
Normal file
@@ -0,0 +1,619 @@
|
||||
# 视频提示词生成器 v10|执黑先行账号专用版
|
||||
|
||||
## 一、铁律(违反则整条回写失败,必须重写)
|
||||
|
||||
1. **0帧动原则**:视频一播放就立即开始运动,**禁止任何静置展示阶段**,禁止"先展示再动"、"先静置再运动"
|
||||
2. **主体动作从第0帧立即发生**:第一个动作必须在视频开始的第一帧就出现,动作不是从第1-2秒开始
|
||||
3. **人物由文案决定**:单人多人都可以,shot间可变化;整体避免单一性别独占
|
||||
4. **导演禁止混合**:全程使用分镜指定的同一导演词库
|
||||
5. **好看 = 甜酷风(女)+ 少年感(男)+ 潮流时尚 + 浪漫点缀(只一点点)**
|
||||
6. **自然生活化**:动作要有力量但不要过于装逼/生硬/刻意,是有生活质感的酷
|
||||
|
||||
---
|
||||
|
||||
## 二、色彩与光影硬性规则(每个视频必须使用)
|
||||
|
||||
以下色彩和光影规则为执黑先行账号的统一要求,**所有 videoPrompt 无条件执行**,不得以任何理由降低标准。
|
||||
|
||||
### 硬性色彩要求
|
||||
|
||||
```
|
||||
色彩基调:暖调为主,饱满有活力,拒绝灰暗压抑
|
||||
主色调:暖橙金 / 金色 / 琥珀色(至少出现其一)
|
||||
辅助色:夜色蓝 / 青色 / 深蓝(与暖光形成对比)
|
||||
整体氛围:鲜亮、温暖、有氛围感,像被金色夕阳或城市灯光包裹
|
||||
```
|
||||
|
||||
### 硬性光影要求
|
||||
|
||||
```
|
||||
光源质感:有温度的光,拒绝纯冷白或去饱和灰调
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
阴影处理:阴影可以是冷色但必须通透,拒绝"死黑"
|
||||
氛围层:必须有空气感/光雾感/温度感,夜间场景要有暖色光晕、灯光散射
|
||||
浪漫点缀(只一点点):warm glow / golden rim light / amber haze / 光线穿透感
|
||||
```
|
||||
|
||||
### 禁止出现的光影
|
||||
|
||||
- 去饱和冷蓝影调("desaturated cool-blue grade"禁止)
|
||||
- 大面积纯黑死黑(纯暗部禁止)
|
||||
- 纯冷白光源 / 无温度的灰调光
|
||||
- 灰暗、低饱和、平淡无光的画面
|
||||
|
||||
---
|
||||
|
||||
## 三、自然生活化原则
|
||||
|
||||
**核心**:有力量的酷,不是表演出来的酷,是自然散发出来的酷。
|
||||
|
||||
| 过于装逼(禁止) | 自然有力量(正确) |
|
||||
|-----------------|-----------------|
|
||||
| 人物永远处于力量展示姿态 | 人物在日常动作中有力量感(走/推/甩/靠) |
|
||||
| 表情永远冷峻/愤怒 | 表情克制但有内容,自然流露 |
|
||||
| 动作像在表演"我很酷" | 动作干净利落,酷是结果不是目的 |
|
||||
| 刻意的高冷pose | 自然的都市年轻人状态 |
|
||||
|
||||
**正确示例**:人物正常走路,但步伐有力;人物正常甩门,但干净利落有力量感。
|
||||
**禁止示例**:人物站在城市最高点俯视镜头,表情永远愤怒/冷酷。
|
||||
|
||||
---
|
||||
|
||||
## 四、中途入场原则:0帧冲击
|
||||
|
||||
### 核心原理:「In Medias Res」
|
||||
|
||||
**每个片段不从"开始"拍,而是从"已经发生到最紧张的那一秒"开始。**
|
||||
|
||||
> 冲击力 = 动作已在进行 + 信息不完整引发好奇 + 画面或人物有明显运动
|
||||
|
||||
视频一播放,观众就必须看到动作正在发生。不要给观众"平静的起点"——让他们用前3秒追赶画面,他们就没有时间划走。
|
||||
|
||||
### 中途入场的两种来源
|
||||
|
||||
#### A 类 —「镜头动」:摄影机运动制造冲击
|
||||
|
||||
| 运动方式 | 描述 | 适用导演 |
|
||||
|---------|------|---------|
|
||||
| 急速下降落地 | 镜头从高处快速下降至地面,揭示已发生的动作 | Fincher/Tarantino |
|
||||
| 旋转切入 | 镜头旋转进入场景,dutch 角度修正 | Tarantino |
|
||||
| 极速推进(Smash Zoom) | 从极宽到极近,一秒内完成,不渐变 | Tarantino |
|
||||
| 跟不上的跟拍 | 镜头追赶主体,主体已在前方,部分出画 | Fincher |
|
||||
| 遮挡物后突然暴露 | 镜头或主体移动,场景信息突然释放 | Kitano |
|
||||
|
||||
#### B 类 —「人/物动」:主体运动制造冲击
|
||||
|
||||
| 运动方式 | 描述 | 适用导演 |
|
||||
|---------|------|---------|
|
||||
| 突然转身/回头 | clip opens mid-turn,头发甩入画,面部在第一帧锁定镜头 | 全导演 |
|
||||
| 闯入画面 | 人物从边缘高速入画,无预警,镜头捕捉其跨步瞬间 | Tarantino |
|
||||
| 抓握/推开 | clip opens on 手已抓住手腕/衣领——张力已在峰值,无前置 | 全导演 |
|
||||
| 物体砸向镜头 | 物件在开场帧直接扔向镜头,极致近景冲击,然后切反应 | Tarantino |
|
||||
| 从画面外拉入 | 人物被拉入/拽入,已失去平衡,观众立刻问"谁在拉" | Fincher |
|
||||
|
||||
### 冲突感强化词(叠加使用)
|
||||
|
||||
```
|
||||
scene already in progress when clip begins
|
||||
conflict at peak not at start
|
||||
no establishing shot — drop viewer into middle of action
|
||||
whip pan opening, subject in motion frame one
|
||||
asymmetric power dynamic immediately visible
|
||||
something has just happened or is about to — viewer must catch up
|
||||
```
|
||||
|
||||
### 按情绪分类的开场模板
|
||||
|
||||
| 情绪类型 | 开场策略 |
|
||||
|---------|---------|
|
||||
| 愤怒/对抗 | clip opens on face inches from face,already shouting or silent with rage,camera slightly shaking |
|
||||
| 欲望/吸引 | clip opens mid-turn,subject's gaze snaps to something off-screen,slow smash-zoom onto expression |
|
||||
| 逃跑/追逐 | clip opens on feet already running,ground blurring below,camera low and tracking |
|
||||
| 沉默的张力 | clip opens on two figures, backs to each other, already in standoff, locked-off static shot |
|
||||
| 意外/反转 | clip opens on mundane detail — hand, cup, door — something is wrong that viewer can't immediately name |
|
||||
|
||||
---
|
||||
|
||||
## 五、冲突感设计:文案→画面的视觉转化
|
||||
|
||||
### 核心思路
|
||||
|
||||
文案口播的本质是**情绪在时间轴上的变化**。每一段文案背后都有一个隐藏的冲突结构。视频的任务不是"配图",而是**把文案的潜台词可视化**。
|
||||
|
||||
### 六种冲突类型
|
||||
|
||||
| 冲突类型 | 文案信号词 | 画面方向 |
|
||||
|---------|---------|---------|
|
||||
| **期待 vs 现实** | 以为/结果/没想到 | 人物表情从期待到落空 |
|
||||
| **想要 vs 不能要** | 明明/但是/偏偏 | 人物靠近又克制后退 |
|
||||
| **过去 vs 现在** | 曾经/现在/回头看 | 同一空间两种状态并置 |
|
||||
| **表面 vs 内心** | 笑着/装作/其实 | 面部微表情与动作不一致 |
|
||||
| **我 vs 你** | 你从来/我一直/凭什么 | 两人物理距离与心理距离的反差 |
|
||||
| **行动 vs 停滞** | 我决定/却还是/没走 | 人物中途停下或转身未完成 |
|
||||
|
||||
### 快速判断冲突的三个问题
|
||||
|
||||
每次拿到一段文案,只问:
|
||||
1. **「谁想要什么,得不到?」** → 欲望冲突
|
||||
2. **「谁知道什么,说不出?」** → 信息冲突
|
||||
3. **「谁在走,谁在留?」** → 行动冲突
|
||||
|
||||
三个问题至少能回答一个,画面就有了。
|
||||
|
||||
### 图文结合三关系(选对才有效)
|
||||
|
||||
| 关系 | 说明 | 效果 |
|
||||
|------|------|------|
|
||||
| **平行** | 画面直接呈现文案内容 | 清晰但无惊喜 |
|
||||
| **对位** | 画面呈现文案的反面或潜台词 | 有层次感,耐看 |
|
||||
| **延伸** | 画面在文案结束后继续发展 | 余韵感强,留白最强 |
|
||||
|
||||
**最优组合 = 对位 + 延伸**
|
||||
> 文案说"我放下了" → 画面是人物手指慢慢松开某样东西,但镜头停在那个东西上,没有跟着人走
|
||||
|
||||
---
|
||||
|
||||
## 六、导演镜头运动词库(只对应镜头运动语言,色彩用 §二 硬性规则)
|
||||
|
||||
### Fincher 镜头运动层
|
||||
|
||||
| 运动类型 | 中文描述 |
|
||||
|---------|---------|
|
||||
| 缓慢推进 | 镜头缓慢推向主体,积累压迫感和张力 |
|
||||
| 快速横切 | 镜头快速横向切割画面,制造切换感/冲突感 |
|
||||
| 定焦推进 | 镜头在固定位置缓慢推进,凝固张力 |
|
||||
| 垂直下降 | 镜头从高处缓慢下降,揭示空间纵深 |
|
||||
| 缓慢拉远 | 镜头缓慢拉远,揭示孤独感或全貌 |
|
||||
|
||||
### Tarantino 镜头运动层
|
||||
|
||||
| 运动类型 | 中文描述 |
|
||||
|---------|---------|
|
||||
| 快速拉远 | 镜头快速拉远,揭示空间全貌或人物处境 |
|
||||
| 跟拍横移 | 镜头跟随人物横向移动,保持同一速度,有速度感 |
|
||||
| 快速甩镜 | 镜头快速甩向/甩离主体,有切换感/冲突感 |
|
||||
| 俯冲视角 | 镜头从高处快速俯冲向人物,制造压迫感 |
|
||||
| 快速摇镜 | 镜头快速摇向另一主体,有方向感 |
|
||||
|
||||
### Kitano 镜头运动层
|
||||
|
||||
| 运动类型 | 中文描述 |
|
||||
|---------|---------|
|
||||
| 缓慢横移 | 镜头缓慢横向移动,积累情绪和压抑感 |
|
||||
| 静止凝视 | 镜头静止不动,沉默中积累张力 |
|
||||
| 快速仰起 | 镜头快速从低处仰起,揭示孤独感或力量感 |
|
||||
| 低角度跟拍 | 镜头从低角度跟随主体,赋予力量感/支配感 |
|
||||
| 缓慢下降 | 镜头缓慢下降,沉入暗部,积累压抑感 |
|
||||
|
||||
---
|
||||
|
||||
## 五、动作设计原则
|
||||
|
||||
### 5.1 从文案出发
|
||||
|
||||
**动作的来源是旁白的核心动词**,不是预设的动作词库。步骤:
|
||||
|
||||
1. 读当前旁白 script,找到核心动词("做"、"走"、"关"、"停"等)
|
||||
2. 将动词字面化,如果动词本身动作感弱,放大动作幅度
|
||||
3. 如果旁白没有明显动词,从冲突内核提取象征动作
|
||||
|
||||
**动作幅度放大规则:**
|
||||
- "走" → 大步横穿/冲刺
|
||||
- "站" → 大步走向/大步跨入
|
||||
- "停" → 猛然止步/快速刹车
|
||||
- "看" → 猛然转头盯住/目光快速锁定
|
||||
- "说" → 狠狠甩手机/狠狠砸向地面(象征沉默的力量)
|
||||
- 无动词 → 从象征/暗喻/比喻找动作
|
||||
|
||||
### 5.2 动作幅度底线
|
||||
|
||||
每个 prompt 必须包含**至少一个大位移动作**或**一个强烈冲击动作**:
|
||||
|
||||
| 动作幅度 | 正确示例 | 错误示例 |
|
||||
|---------|---------|---------|
|
||||
| 大位移 | 物件被甩出画面横穿整个空间 | 物件轻微摇晃 |
|
||||
| 身体大幅跨越 | 身体从画面左侧大步冲到右侧 | 身体微微前倾 |
|
||||
| 强烈冲击 | 硬物砸向地面产生明显裂纹 | 手轻轻触碰物体 |
|
||||
| 大幅甩/推/砸 | 手狠狠甩臂,物件横飞 | 手微微移动 |
|
||||
|
||||
**禁止的动作(Kling 执行极差,画面几乎没有变化):**
|
||||
- 手部/面部微动(捏/点/轻触/微微移动)
|
||||
- 物件轻微摇晃/微微发光
|
||||
- 纯呼吸/眨眼/身体轻微起伏
|
||||
- "站"/"停"/"静"/"缓"/"微"/"轻"等弱动词
|
||||
|
||||
---
|
||||
|
||||
## 六、浪漫点缀(只一点点)
|
||||
|
||||
只用在环境层少量点缀,配合暖调光影使用:
|
||||
|
||||
```
|
||||
一抹暖光 / 一丝暖意 / 夜色微光 / 城市霓虹隐约闪烁 / golden rim light / amber haze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、Kling(可灵)格式规范
|
||||
|
||||
```
|
||||
语法:中文为主
|
||||
固定结尾:竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
### 格式模板(完整维度版)
|
||||
|
||||
```
|
||||
【0帧动原则(第0帧立即发生):视频从第一帧开始运动,
|
||||
无任何静置展示阶段,动作和镜头同时开始】
|
||||
|
||||
【主体动作(第0-2秒内立即发生):从文案核心动词提取动作,
|
||||
动作幅度要大,要有力,配合镜头运动同步启动】
|
||||
例:狠狠甩臂/大步冲入/猛然推门/撕开/撞破/推倒
|
||||
|
||||
【环境响应(中间2-3秒):光源快速变化/阴影大面积覆盖/物件次要位移,
|
||||
配合主体动作产生联动,暖调光影(金色/暖橙金)充满空间,
|
||||
浪漫点缀(只一点点)】
|
||||
|
||||
【结尾(最后1-2秒):动作可以是下一个动作的开始,
|
||||
或镜头快速切走,不缓慢收尾,不慢慢结束】
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
**禁止的 Prompt 结尾:** "缓缓" / "慢慢" / "渐渐" / "慢慢消失"(禁止慢速结尾)
|
||||
**禁止使用弱动词:** "站" / "停" / "静" / "缓" / "微" / "轻" / "抬" / "落"
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和。
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、性张力可选模块
|
||||
|
||||
**使用条件**:当分镜/图片中出现人物,且风格方向涉及人物张力/吸引力表达时,可选择性使用本模块。不是每个 prompt 都必须触发,仅在"人物是画面核心张力"时启用。
|
||||
|
||||
### 女性版
|
||||
|
||||
**核心逻辑**:若隐现 + 被偷看感。
|
||||
|
||||
**身材描述:**
|
||||
```
|
||||
slender waist with subtle feminine curves, delicate collarbone visible,
|
||||
graceful long neck, soft shoulders, natural body proportions that draw the eye
|
||||
```
|
||||
|
||||
**穿搭:**
|
||||
```
|
||||
lightweight fabric with slight translucency, form-fitting silhouette,
|
||||
clothing with natural wrinkles from movement, subtle skin exposure at unexpected areas
|
||||
(collarbone, lower back, shoulder), fabric tension suggesting the body beneath
|
||||
```
|
||||
|
||||
**姿态/动作:**
|
||||
```
|
||||
caught mid-movement, slightly off-balance, reaching upward or turning away,
|
||||
hair falling across face, unconsciously adjusting clothing, weight shifted to one hip,
|
||||
caught in an unguarded natural moment
|
||||
```
|
||||
|
||||
**视角/构图:**
|
||||
```
|
||||
shot from slightly below eye level at a 3/4 rear angle,
|
||||
shallow depth of field with subject slightly out of perfect focus,
|
||||
handheld camera feel with subtle motion blur,
|
||||
subject unaware of the lens, candid stolen-moment framing
|
||||
```
|
||||
|
||||
**氛围强化:**
|
||||
```
|
||||
soft backlight creating body outline glow, ambient environmental noise implied,
|
||||
natural imperfect lighting, slight lens compression, documentary-style rawness
|
||||
```
|
||||
|
||||
**完整模板:**
|
||||
```
|
||||
A 22-year-old woman, effortlessly captivating, slender figure with natural feminine curves,
|
||||
graceful collarbone and long neck visible, wearing [outfit] in lightweight fabric with
|
||||
subtle translucency and natural movement wrinkles, caught in an unguarded moment —
|
||||
slightly off-balance, reaching or turning, shot from a 3/4 rear low angle by an unnoticed
|
||||
observer, shallow depth of field, soft backlight outlining her silhouette,
|
||||
candid handheld feel, slight motion blur, she is completely unaware of the camera
|
||||
```
|
||||
(替换 [outfit] 为具体场景服装:西装/ streetwear/ 设计师款等)
|
||||
|
||||
### 男性版
|
||||
|
||||
**核心逻辑**:压迫感 + 掌控感 + 不在意你。
|
||||
|
||||
**身材描述:**
|
||||
```
|
||||
broad shoulders tapering to narrow waist, forearm muscles visible with rolled sleeves,
|
||||
strong jaw and defined neck, tall commanding presence,
|
||||
subtle physical power implied rather than displayed
|
||||
```
|
||||
|
||||
**穿搭:**
|
||||
```
|
||||
well-fitted clothing that suggests physicality without showing it,
|
||||
slightly open collar, rolled-up sleeves revealing forearms,
|
||||
fabric pulling slightly at the shoulders, understated but expensive-looking,
|
||||
dark or neutral tones, slight dishevelment — tie loosened, shirt untucked at one side
|
||||
```
|
||||
|
||||
**姿态/动作:**
|
||||
```
|
||||
leaning against a wall with one shoulder, arms crossed loosely,
|
||||
hand running through hair, looking somewhere else entirely,
|
||||
slow deliberate movement, jaw slightly set,
|
||||
occupying space with quiet confidence, not performing for anyone
|
||||
```
|
||||
|
||||
**视角/构图:**
|
||||
```
|
||||
shot from slight below, looking up at him,
|
||||
he does not look at the camera — his gaze is elsewhere,
|
||||
tight framing cutting off the top of frame suggesting his scale,
|
||||
low key dramatic side lighting, strong shadow on one half of face,
|
||||
foreground element slightly blurred adding depth and voyeuristic framing
|
||||
```
|
||||
|
||||
**氛围强化:**
|
||||
```
|
||||
cigarette smoke or steam in air, urban night setting or industrial interior,
|
||||
single strong light source creating hard shadows, implied motion — like he just walked in or is about to leave
|
||||
```
|
||||
|
||||
**完整模板:**
|
||||
```
|
||||
A 28-year-old man, quietly commanding and magnetic, broad shoulders and forearms visible
|
||||
with rolled sleeves, strong jaw, tall frame, wearing [outfit] — well-fitted, slightly disheveled,
|
||||
collar open, leaning or standing with effortless dominance, not aware of or interested in
|
||||
being watched, his gaze directed away — distant, unreadable, shot from below at a 3/4 front
|
||||
angle, tight crop suggesting his scale, hard dramatic side lighting with deep shadows,
|
||||
foreground blur adding depth, cinematic still quality, like a frame from a prestige film
|
||||
```
|
||||
(替换 [outfit] 为具体场景服装;男主年龄固定22岁左右,参考 Justin Bieber 五官 + 参考图发型)
|
||||
|
||||
### 张力强度调节
|
||||
|
||||
| 强度 | 关键词 |
|
||||
|------|--------|
|
||||
| 微妙/高级 | understated allure, quiet magnetism |
|
||||
| 明显/商业 | overtly attractive, editorial sensuality |
|
||||
| 强烈/电影感 | smoldering presence, charged with unspoken tension |
|
||||
|
||||
---
|
||||
|
||||
## 九、自检清单(输出 videoPrompt 前逐条核对)
|
||||
|
||||
```
|
||||
[ ] 视频从第0帧立即开始运动,无任何静置展示阶段
|
||||
[ ] 主体动作从第0帧立即发生,不是从第1-2秒才发生
|
||||
[ ] 中途入场:动作已在进行中,观众必须追赶画面
|
||||
[ ] 每个 shot 有明确的冲突感(期待vs现实/想要vs不能/表面vs内心等)
|
||||
[ ] 动作来源:从文案核心动词提取,不是预设动作词库
|
||||
[ ] 主体动作幅度够大:大大步/冲/撕/撞/推/甩(不是微动)
|
||||
[ ] 无弱动词:站/停/静/缓/微/轻/抬/落(全部替换)
|
||||
[ ] 无"缓缓"/"慢慢"/"渐渐"等慢速结尾
|
||||
[ ] 冲突动作来自 script 的核心动词或冲突类型提取
|
||||
[ ] 图文结合关系:对位+延伸(不是平行的简单配图)
|
||||
[ ] 人物动作自然有力量,不要装逼/生硬/刻意表演感
|
||||
[ ] 人物:女性=赫本+甜酷风+女人味 / 男性=少年感+潮流时尚+22岁
|
||||
[ ] 有浪漫点缀(只一点点)
|
||||
[ ] 使用 §二 硬性色彩规则:暖调为主(暖橙金/金色/琥珀色),有温度感,拒绝灰暗
|
||||
[ ] 光感自然:光源温暖、轮廓柔和,拒绝"发光感"/超能力式表达
|
||||
[ ] 使用 directorRef 指定导演的镜头运动词库,无混合
|
||||
[ ] 图文结合:视频动作是否延续图片的视觉象征(碎镜→镜碎/棋盘→棋子落定)?
|
||||
[ ] 字数 ≥ 150字(仅设下限,建议精简但不强设上限)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、输入规范
|
||||
|
||||
```
|
||||
【shotDesc】当前 Shot 的英文分镜描述
|
||||
【当前旁白】该 Shot 对应的中文口播旁白
|
||||
【完整文案】完整口播文案原文
|
||||
【directorRef】fincher / tarantino / kitano(分镜指定)
|
||||
【目标模型】Kling / VEO / Grok
|
||||
```
|
||||
|
||||
缺少任意一项 → 返回错误并终止。
|
||||
|
||||
---
|
||||
|
||||
## 十一、输出格式
|
||||
|
||||
```
|
||||
### Shot [N] 视频提示词 | [Xs] | [导演] | [模型]
|
||||
**图文对应:** 旁白"XXX" → 图片视觉象征"XXX" → 本视频动作围绕此象征展开(碎镜→镜碎/棋盘→棋子落定)
|
||||
**叙事意图:** 这帧在整体叙事中的功能
|
||||
**动作来源(从文案提取):** script 的核心概念/动词 → 提取为何种大幅动作(主体形态不限)
|
||||
**0帧动作(第0帧立即发生):** [镜头+主体动作从第一帧同时开始]
|
||||
**主体动作(第0-2秒):** 动作从旁白核心动词/概念提取,幅度放大到肉眼可见的大幅度变化:
|
||||
- 旁白是具体行为词 → 动作字面化 + 幅度放大(狠狠/大步/横冲/撞碎)
|
||||
- 旁白是抽象概念 → 视觉象征物件做出对应动作(金色粒子爆发 / 沙漏流尽 / 棋子落定)
|
||||
- 旁白是情绪/感受 → 物件/能量随情绪震动(冲击波膨胀 / 光环扩散)
|
||||
- 主体形态不限:人物 / 动物 / 物体 / 能量场 / 场景变化均可
|
||||
- 幅度要大:位移要横穿空间,物件要被撞/甩/砸/撕碎,能量要有爆发/扩散
|
||||
**环境响应 + 浪漫点缀:** [暖调光源/阴影变化 + 一点点浪漫]
|
||||
**自然生活化检查:** [动作是否有装逼感?是→重写]
|
||||
**色彩与光影:** [暖调主光+温度感,拒绝灰暗]
|
||||
**videoPrompt:**
|
||||
[完整提示词,要求 ≥ 150字(仅设下限,建议精简但不强设上限),覆盖所有维度]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、图文结合策略
|
||||
|
||||
视频的图文结合与图片提示词 §九 策略一致,**视频是图片的动态化**。视频 prompt 中的动作必须延续图片的图文对应关系。
|
||||
|
||||
**视频中图文结合的核心**:
|
||||
- 图片 prompt 里选了哪个视觉象征(碎镜/棋子/沙漏/宣言手势…),视频里的动作就要围绕这个象征展开
|
||||
- 旁白说"内耗" → 图片里是碎镜 → 视频里的动作:碎镜被狠狠打破 / 碎片横飞 / 冲击波光环扩散
|
||||
- 旁白说"游戏" → 图片里是棋盘对弈 → 视频里的动作:棋子狠狠砸向棋盘 / 国王落定
|
||||
- 旁白说"时间" → 图片里是沙漏 → 视频里的动作:金色流沙猛然倾泻 / 粒子随震动四溅
|
||||
- 主体形态不限:人物 / 动物 / 物体 / 能量场 / 场景变化均可
|
||||
|
||||
**三个维度在视频里的表现方式**:
|
||||
- **潮玩形象动作**:Bearbrick型人物做出对应动作——甩臂/宣告手势/棋子落定
|
||||
- **时尚场景动作**:streetwear 衣摆随动作飘动 / 霓虹灯光随运动闪烁
|
||||
- **漫威VFX**:粒子爆发/冲击波/能量光环随动作触发
|
||||
|
||||
**检查清单**:
|
||||
```
|
||||
[ ] 视频动作是否延续图片的视觉象征(碎片/棋子/沙漏/光环等做出大幅动作变化)?
|
||||
[ ] 动作来源是否从旁白核心概念/动词提取(不只是人物动作)?
|
||||
[ ] 动作触发时,漫威VFX是否有配合(冲击/散射/光环)?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十三、完整示例
|
||||
|
||||
### 示例A|Fincher · 镜子冲突 + 缓慢推进
|
||||
|
||||
**文案:** 从不在人际关系里内耗的人都有一个共性,论技不论心
|
||||
|
||||
**shotDesc:** Two clean-cut young figures face each other in warm golden street light. A mirror surface splits their reflection, creating a warm vs cool boundary. Urban minimalist backdrop, amber glow, clean layered streetwear, effortless cool, romantic city night ambiance.
|
||||
|
||||
**videoPrompt:**
|
||||
```
|
||||
【0帧动原则:视频从第一帧开始,镜头立即缓慢推向两人】
|
||||
|
||||
主体动作(第0-2秒立即发生):两人在暖橙金光线中猛然同时甩臂,
|
||||
streetwear 随大动作有力飘动,少年感轮廓始终清晰,
|
||||
干净利落有力量,不装逼,镜头同步缓慢推进。
|
||||
|
||||
环境响应(中间2-3秒):暖橙色街灯光在两人身上快速形成高光块,
|
||||
阴影随之缓慢大面积推进,光与阴影在两人之间形成精确边界,
|
||||
golden rim light 在边缘快速闪烁,浪漫点缀(一点点)。
|
||||
|
||||
结尾:镜头继续缓慢推进至两人对视瞬间,积累张力,不缓慢收尾。
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 示例B|Tarantino · 冷暖对抗 + 快速拉远
|
||||
|
||||
**文案:** 你把这扇门关上,这辈子就没机会了
|
||||
|
||||
**shotDesc:** A man and a woman stand on either side of a heavy industrial door. Warm amber street light bleeds through the gap from outside. Inside is shadow and cool urban night. Both figures reach for the door handle. Clean-cut fashion, Tarantino edge contrast, romantic city night, Audrey Hepburn modern, sweet yet cool.
|
||||
|
||||
**videoPrompt:**
|
||||
```
|
||||
【0帧动原则:视频从第一帧开始,两人立即同时冲向那扇门】
|
||||
|
||||
主体动作(第0-2秒立即发生):两人狠狠同时推门,
|
||||
暖橙色街灯光随门的推开猛然爆射而入,
|
||||
身体大步跨过门槛,clean-cut 外套随跨越动作有力飘动,
|
||||
强烈完成从黑暗到光明的穿越,自然有力,不刻意表演。
|
||||
|
||||
环境响应(中间2-3秒):暖色实用光随跨越快速充满空间,
|
||||
两人身上的暖橙金 rim light 快速勾勒出轮廓,
|
||||
城市霓虹在背景中隐约闪烁,浪漫点缀(一点点)。
|
||||
|
||||
结尾:镜头快速跟随两人冲入光亮处,动作不缓慢收尾。
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 示例C|Kitano · 都市孤独 + 缓慢横移
|
||||
|
||||
**文案:** 所有人都在走,只有你停下来
|
||||
|
||||
**shotDesc:** A man and a woman stand together at a rain-slicked urban crosswalk at night. Warm amber street lights wrap around them. While everyone else is blurred into motion, only they are in sharp focus. Kitano urban night, vast negative space, Audrey Hepburn modern, sweet yet cool, clean-cut youthful energy, romantic city night ambiance.
|
||||
|
||||
**videoPrompt:**
|
||||
```
|
||||
【0帧动原则:视频从第一帧开始,两人立即停止,镜头同步开始缓慢横移】
|
||||
|
||||
主体动作(第0-2秒立即发生):两人在斑马线上猛然止步,
|
||||
周围所有行人都是模糊的运动拖影,唯有人物静止站立,
|
||||
streetwear 轮廓始终清晰,克制但有力量感,
|
||||
镜头同步缓慢横移,与周围模糊运动形成对比。
|
||||
|
||||
环境响应(中间2-3秒):暖橙色街灯光在雨中快速折射出大面积光点,
|
||||
城市灯光随雨的节奏快速脉冲,
|
||||
golden glow 笼罩两人,浪漫点缀(一点点)。
|
||||
|
||||
结尾:镜头继续缓慢横移,揭示两人与周围流动世界的反差。
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 示例D|Fincher · 物件冲突 + 快速横切
|
||||
|
||||
**文案:** 沉默才是最大的武器
|
||||
|
||||
**shotDesc:** A man and a woman stand in a dark room. Between them, a delicate glass is suspended over a concrete floor by the woman's hand. Warm amber practical light on skin, cool shadow below. Effortless cool, Audrey Hepburn modern, sweet yet cool.
|
||||
|
||||
**videoPrompt:**
|
||||
```
|
||||
【0帧动原则:视频从第一帧开始,女人立即甩臂,镜头同步快速横切】
|
||||
|
||||
主体动作(第0-2秒立即发生):女人狠狠将玻璃杯甩向地面,
|
||||
玻璃碎片向四周猛烈爆射,横穿整个画面,
|
||||
clean-cut 衬衫随手臂动作有力甩出,
|
||||
男子在旁猛然侧身避开,少年感 streetwear 随动作清晰呈现,
|
||||
自然有力,不装逼不刻意。
|
||||
|
||||
环境响应(中间2-3秒):暖橙色实用光在碎片上形成棱镜散射,
|
||||
阴影随冲击快速大面积扩散,
|
||||
golden rim light 在碎片边缘闪烁,浪漫点缀(一点点)。
|
||||
|
||||
结尾:镜头快速横切至两人对视位置,揭示张力。
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 示例E|Tarantino · 象征暗喻 + 俯冲视角
|
||||
|
||||
**文案:** 越想赚钱,越被钱困住
|
||||
|
||||
**shotDesc:** A man and a woman stand in a dark vault. Warm amber banknote light wraps around their bodies like a cage. The woman reaches for a gold coin while the man pulls away. Tarantino warm practical light, urban night atmosphere, Audrey Hepburn modern, sweet yet cool, youthful sharp silhouette.
|
||||
|
||||
**videoPrompt:**
|
||||
```
|
||||
【0帧动原则:视频从第一帧开始,两人立即向相反方向扯动,镜头同步俯冲】
|
||||
|
||||
主体动作(第0-2秒立即发生):两人狠狠向相反方向扯动,
|
||||
金色纸币随拉扯动作猛然撕裂,
|
||||
女人被纸币缠住猛然挣扎,男人狠狠拉回,
|
||||
clean-cut 服装随撕扯产生大幅褶皱变化,少年感轮廓始终清晰,
|
||||
有力但自然,不夸张表演。
|
||||
|
||||
环境响应(中间2-3秒):暖橙色实用光随撕裂快速形成大面积金色光区,
|
||||
纸币的裂口处金色光线猛然爆射而出,
|
||||
城市远光在背景中隐约渗透,浪漫点缀(一点点)。
|
||||
|
||||
结尾:镜头快速俯冲向金色裂口,揭示纸币困住两人的瞬间。
|
||||
|
||||
色彩基调:暖调为主,暖橙金/金色主光,有温度感,拒绝灰暗。
|
||||
光感:被光打亮的主体呈现自然光感——光源温暖、轮廓柔和、拒绝灰暗
|
||||
竖版9:16画幅,无字幕,无水印。
|
||||
```
|
||||
550
package-lock.json
generated
@@ -7,7 +7,8 @@
|
||||
"dependencies": {
|
||||
"ali-oss": "^6.23.0",
|
||||
"axios": "^1.16.1",
|
||||
"music-metadata": "^11.12.3"
|
||||
"music-metadata": "^11.12.3",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
@@ -20,6 +21,481 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
@@ -267,6 +743,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/digest-header": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/digest-header/-/digest-header-1.1.0.tgz",
|
||||
@@ -973,6 +1458,62 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
@@ -1152,6 +1693,13 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/uint8array-extras": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"dependencies": {
|
||||
"ali-oss": "^6.23.0",
|
||||
"axios": "^1.16.1",
|
||||
"music-metadata": "^11.12.3"
|
||||
"music-metadata": "^11.12.3",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
238
run_batch_4videos.js
Normal file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 执黑先行二号 — 剩余4视频 完整生产
|
||||
* 素材阶段 2 路并发 + 装配串行
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawn, spawnSync } = require('child_process')
|
||||
|
||||
const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
|
||||
const DRAFT_BASE = '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'
|
||||
const ACCOUNT = '执黑先行二号-风格延伸'
|
||||
|
||||
const VIDEOS = [
|
||||
{ id: 'v13', scriptNum: 13, style: '梦核人物', voice: '温暖少女', topicB: '内耗三层',
|
||||
refs: ['微信图片_20260522232203_259_18.png','微信图片_20260522232204_260_18.png','微信图片_20260522232204_261_18.png'].map(f => '/Users/lc/Pictures/参考图片/梦核人物/'+f),
|
||||
storyboardFile: '/tmp/sb_v13.json' },
|
||||
{ id: 'v14', scriptNum: 14, style: '梦核怪异城市', voice: '不羁青年', topicB: '地狱相',
|
||||
refs: ['微信图片_20260522231537_252_18.png','微信图片_20260522231537_253_18.png','微信图片_20260522231537_254_18.png'].map(f => '/Users/lc/Pictures/参考图片/梦核怪异城市/'+f),
|
||||
storyboardFile: '/tmp/sb_v14.json' },
|
||||
{ id: 'v15', scriptNum: 15, style: '梦核花池', voice: '温暖少女', topicB: '孩子结账',
|
||||
refs: ['微信图片_20260522231256_246_18.png','微信图片_20260522231256_247_18.png','微信图片_20260522231256_248_18.png'].map(f => '/Users/lc/Pictures/参考图片/梦核花池/'+f),
|
||||
storyboardFile: '/tmp/sb_v15.json' },
|
||||
{ id: 'v16', scriptNum: 16, style: '剪纸报', voice: '不羁青年', topicB: '借鸡生蛋',
|
||||
refs: ['7b730552-efb6-426a-ae69-d2c8d396fe16.png'].map(f => '/Users/lc/Pictures/参考图片/剪纸报/'+f),
|
||||
storyboardFile: '/tmp/sb_v16.json' }
|
||||
]
|
||||
|
||||
function log(fd, msg) { fs.writeSync(fd, `[${new Date().toISOString().slice(11,19)}] ${msg}\n`) }
|
||||
|
||||
// ---- 初始化单个视频 ----
|
||||
function initVideo(v, logFd) {
|
||||
if (!fs.existsSync(v.storyboardFile)) throw new Error('分镜文件不存在')
|
||||
const items = JSON.parse(fs.readFileSync(v.storyboardFile, 'utf-8'))
|
||||
const valid = items.map(it => ({ ...it, duration: Math.min(Number(it.duration)||5, 5.9) }))
|
||||
log(logFd, `分镜: ${valid.length} shots`)
|
||||
|
||||
const tmpFile = `/tmp/items_${v.id}_${Date.now()}.json`
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(valid), 'utf-8')
|
||||
|
||||
const res = spawnSync('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'init',
|
||||
'--account', ACCOUNT, '--mode', 'single', '--items-file', tmpFile,
|
||||
'--image-model', 'gpt-image', '--video-model', 'kling',
|
||||
'--tts-voice', v.voice, '--format', '9:16'
|
||||
], { cwd: __dirname, encoding: 'utf-8', timeout: 60000, stdio: 'pipe' })
|
||||
try { fs.unlinkSync(tmpFile) } catch {}
|
||||
|
||||
if (res.status !== 0) throw new Error(`init failed: ${res.stderr.slice(0,200)}`)
|
||||
const m = (res.stdout + res.stderr).match(/Manifest 已创建: (.+\.json)/)
|
||||
if (!m) throw new Error('no manifest path in output')
|
||||
return m[1].trim()
|
||||
}
|
||||
|
||||
// ---- 完整处理一个视频 ----
|
||||
function processVideo(v, seqNum, logDir) {
|
||||
return new Promise((resolve) => {
|
||||
const logPath = path.join(logDir, `${seqNum}_${v.id}_${v.topicB}.log`)
|
||||
const lf = fs.openSync(logPath, 'w')
|
||||
log(lf, `开始: ${seqNum}/04 ${v.style} | ${v.voice} | 脚本${v.scriptNum}`)
|
||||
|
||||
let manifestPath
|
||||
try {
|
||||
manifestPath = initVideo(v, lf)
|
||||
log(lf, `✅ manifest: ${path.basename(path.dirname(manifestPath))}`)
|
||||
} catch (e) {
|
||||
log(lf, `❌ ${e.message}`)
|
||||
fs.closeSync(lf)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: e.message })
|
||||
}
|
||||
|
||||
// 更新 manifest
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
manifest.ttsEngine = 'minimax'
|
||||
manifest.ttsRate = 1.15
|
||||
manifest.references = v.refs.map(f => ({ file: f }))
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
||||
log(lf, `✅ manifest 更新 (minimax 1.15x, refs=${v.refs.length})`)
|
||||
|
||||
// 素材阶段 (并发)
|
||||
log(lf, '→ 素材阶段: tts→images→upload→videos')
|
||||
const matChild = spawn('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
|
||||
'--manifest', manifestPath, '--phase', 'tts,images,upload,videos'
|
||||
], { cwd: __dirname, stdio: ['ignore', lf, lf], timeout: 3600000 })
|
||||
|
||||
matChild.on('close', (matCode) => {
|
||||
log(lf, `素材退出码: ${matCode}`)
|
||||
if (matCode !== 0) {
|
||||
log(lf, `❌ 素材阶段失败`)
|
||||
fs.closeSync(lf)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: `material exit ${matCode}`, manifestPath, logPath })
|
||||
}
|
||||
|
||||
// 装配阶段 (必须串行 — 在调用侧控制)
|
||||
log(lf, '→ 素材就绪,等待装配槽位...')
|
||||
fs.closeSync(lf)
|
||||
resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice, status: 'material_ready', manifestPath, logPath, logFd: null })
|
||||
})
|
||||
|
||||
matChild.on('error', (err) => {
|
||||
log(lf, `❌ spawn: ${err.message}`)
|
||||
fs.closeSync(lf)
|
||||
resolve({ id: v.id, style: v.style, status: 'failed', error: err.message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 装配单个视频 (串行调用) ----
|
||||
function assembleVideo(r, seqNum) {
|
||||
return new Promise((resolve) => {
|
||||
const lf = fs.openSync(r.logPath, 'a')
|
||||
log(lf, '→ assemble (串行)...')
|
||||
|
||||
const child = spawn('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
|
||||
'--manifest', r.manifestPath, '--phase', 'assemble'
|
||||
], { cwd: __dirname, stdio: ['ignore', lf, lf], timeout: 300000 })
|
||||
|
||||
child.on('close', (code) => {
|
||||
let draftUrl = '', draftName = ''
|
||||
if (code === 0) {
|
||||
try {
|
||||
const m = JSON.parse(fs.readFileSync(r.manifestPath, 'utf-8'))
|
||||
draftUrl = m.draftUrl || ''
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (draftUrl) {
|
||||
const today = new Date()
|
||||
const ds = `${today.getMonth()+1}${String(today.getDate()).padStart(2,'0')}`
|
||||
draftName = `${ACCOUNT}_${ds}_${seqNum}_${r.topicB || ''}`
|
||||
|
||||
const draftId = draftUrl.split('draft_id=')[1] || ''
|
||||
if (draftId) {
|
||||
const oldP = path.join(DRAFT_BASE, draftId)
|
||||
const newP = path.join(DRAFT_BASE, draftName)
|
||||
if (fs.existsSync(oldP)) {
|
||||
try { fs.renameSync(oldP, newP); log(lf, `✅ 草稿: ${draftName}`) }
|
||||
catch (e) { log(lf, `⚠ 改名失败: ${e.message}`) }
|
||||
}
|
||||
}
|
||||
log(lf, `✅ 完成 | ${draftUrl}`)
|
||||
} else {
|
||||
log(lf, `❌ assemble 失败 (exit ${code})`)
|
||||
}
|
||||
fs.closeSync(lf)
|
||||
resolve({ ...r, status: draftUrl ? 'done' : 'failed', draftUrl, draftName, assembleCode: code })
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
log(lf, `❌ assemble spawn: ${err.message}`)
|
||||
fs.closeSync(lf)
|
||||
resolve({ ...r, status: 'failed', error: err.message })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
async function runParallel(tasks, limit) {
|
||||
const results = [], inflight = new Set()
|
||||
for (const t of tasks) {
|
||||
const p = t().then(r => { inflight.delete(p); return r })
|
||||
inflight.add(p); results.push(p)
|
||||
if (inflight.size >= limit) await Promise.race(inflight)
|
||||
}
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
async function runSequential(items, fn) {
|
||||
const results = []
|
||||
for (const item of items) results.push(await fn(item))
|
||||
return results
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const logDir = `/tmp/batch_final4_${Date.now()}`
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
|
||||
// 全局序号:已用 18/19/20,剩余从 21 开始
|
||||
const baseSeq = 21
|
||||
|
||||
console.log(`${'='.repeat(60)}`)
|
||||
console.log(`执黑先行二号 — 剩余4视频 (素材2并发 + 装配串行)`)
|
||||
console.log(`全局序号: ${baseSeq}-${baseSeq+3} | 日志: ${logDir}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
// ---- Phase 1: 素材并行 ----
|
||||
console.log('▶ Phase 1: 素材生成 (tts+images+videos, 2并发)\n')
|
||||
const tasks = VIDEOS.map((v, i) => {
|
||||
const sn = String(baseSeq + i).padStart(2, '0')
|
||||
return () => processVideo(v, sn, logDir)
|
||||
})
|
||||
|
||||
const doneSet = new Set()
|
||||
const timer = setInterval(() => {
|
||||
process.stdout.write(`\r 素材: ${doneSet.size}/${VIDEOS.length} | ${Math.floor((Date.now()-start)/60000)}min `)
|
||||
}, 10000)
|
||||
|
||||
const wrapped = tasks.map((t, i) => async () => { const r = await t(); doneSet.add(i); console.log(`\n [素材 ${doneSet.size}/4] ${r.status === 'material_ready' ? '✅' : '❌'} ${r.style} | ${r.manifestPath||r.error}`); return r })
|
||||
const matResults = await runParallel(wrapped, 2)
|
||||
clearInterval(timer)
|
||||
|
||||
const ready = matResults.filter(r => r.status === 'material_ready')
|
||||
const matFailed = matResults.filter(r => r.status === 'failed')
|
||||
console.log(`\n 素材完成: ${ready.length}/4 就绪 | ${matFailed.length} 失败 | ${Math.floor((Date.now()-start)/60000)}min\n`)
|
||||
|
||||
// ---- Phase 2: 装配串行 ----
|
||||
if (ready.length > 0) {
|
||||
console.log('▶ Phase 2: 装配 (串行, 1个/次)\n')
|
||||
let ai = 0
|
||||
const finalResults = await runSequential(ready, (r) => {
|
||||
ai++
|
||||
console.log(` [装配 ${ai}/${ready.length}] ${r.style}...`)
|
||||
return assembleVideo(r, String(baseSeq + VIDEOS.findIndex(v => v.id === r.id)).padStart(2, '0'))
|
||||
})
|
||||
|
||||
const allResults = [...finalResults, ...matFailed]
|
||||
const done = allResults.filter(r => r.status === 'done')
|
||||
const failed = allResults.filter(r => r.status !== 'done')
|
||||
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log(`全部完成 — ${Math.floor((Date.now()-start)/60000)}min | ✅ ${done.length} | ❌ ${failed.length}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
allResults.forEach((r, i) => {
|
||||
const s = r.status === 'done' ? '✅' : '❌'
|
||||
console.log(` ${s} 脚本${r.scriptNum} ${r.style} | ${r.draftName || r.error || ''}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e.message); process.exit(1) })
|
||||
241
run_batch_7videos.js
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 执黑先行二号-风格延伸 — 7个完整视频批量生产
|
||||
* 视频8/9/12/13/14/15/16
|
||||
* 各视频对应不同视觉风格和音色
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawnSync } = require('child_process')
|
||||
|
||||
const ACCOUNT = '执黑先行二号-风格延伸'
|
||||
const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
|
||||
const OUTPUT_BASE = path.join(__dirname, 'output')
|
||||
|
||||
// ============================================================================
|
||||
// 7个视频配置
|
||||
// ============================================================================
|
||||
|
||||
const VIDEOS = [
|
||||
{
|
||||
id: 'v8', scriptNum: 8, style: '硬核线条', voice: '不羁青年', topicB: '搞女人',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_119_18.png',
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_120_18.png',
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_121_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v8.json'
|
||||
},
|
||||
{
|
||||
id: 'v9', scriptNum: 9, style: '梦核水池', voice: '不羁青年', topicB: '没人懂你',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_239_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_245_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232446_266_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v9.json'
|
||||
},
|
||||
{
|
||||
id: 'v12', scriptNum: 12, style: '水墨', voice: '温暖少女', topicB: '看人看结果',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/水墨/5bbcc329-dd8e-4b84-b901-97daa00943c9.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v12.json'
|
||||
},
|
||||
{
|
||||
id: 'v13', scriptNum: 13, style: '梦核人物', voice: '温暖少女', topicB: '内耗三层',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232203_259_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_260_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_261_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v13.json'
|
||||
},
|
||||
{
|
||||
id: 'v14', scriptNum: 14, style: '梦核怪异城市', voice: '不羁青年', topicB: '地狱相',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_252_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_253_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_254_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v14.json'
|
||||
},
|
||||
{
|
||||
id: 'v15', scriptNum: 15, style: '梦核花池', voice: '温暖少女', topicB: '孩子结账',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_246_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_247_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_248_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v15.json'
|
||||
},
|
||||
{
|
||||
id: 'v16', scriptNum: 16, style: '剪纸报', voice: '不羁青年', topicB: '借鸡生蛋',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v16.json'
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 主流程
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log(`执黑先行二号-风格延伸 — 7个视频批量生产`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
const results = []
|
||||
const today = new Date()
|
||||
const dateStr = `${today.getMonth()+1}${String(today.getDate()).padStart(2,'0')}`
|
||||
|
||||
for (let i = 0; i < VIDEOS.length; i++) {
|
||||
const v = VIDEOS[i]
|
||||
const jobNum = String(i + 1).padStart(2, '0')
|
||||
|
||||
console.log(`\n[${'='.repeat(55)}]`)
|
||||
console.log(`[${jobNum}/07] ${v.style} | ${v.voice} | 脚本${v.scriptNum}`)
|
||||
console.log(`[${'='.repeat(55)}]`)
|
||||
|
||||
// 1. 检查分镜文件
|
||||
if (!fs.existsSync(v.storyboardFile)) {
|
||||
console.error(` ❌ 分镜文件不存在: ${v.storyboardFile}`)
|
||||
results.push({ id: v.id, style: v.style, status: 'failed', error: '分镜文件缺失' })
|
||||
continue
|
||||
}
|
||||
|
||||
const items = JSON.parse(fs.readFileSync(v.storyboardFile, 'utf-8'))
|
||||
console.log(` 分镜: ${items.length} 个 shot`)
|
||||
|
||||
// 2. 过滤掉 duration > 6 的 shot(安全起见)
|
||||
const validItems = items.map(it => {
|
||||
const d = Number(it.duration) || 5
|
||||
return { ...it, duration: Math.min(d, 5.9) }
|
||||
})
|
||||
|
||||
// 3. 写临时 items 文件
|
||||
const tmpItems = `/tmp/items_${v.id}_${Date.now()}.json`
|
||||
fs.writeFileSync(tmpItems, JSON.stringify(validItems), 'utf-8')
|
||||
|
||||
// 4. pipeline.js init
|
||||
console.log(` → init manifest...`)
|
||||
const initResult = spawnSync('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'init',
|
||||
'--account', ACCOUNT,
|
||||
'--mode', 'single',
|
||||
'--items-file', tmpItems,
|
||||
'--image-model', 'gpt-image',
|
||||
'--video-model', 'kling',
|
||||
'--tts-voice', v.voice,
|
||||
'--format', '9:16'
|
||||
], { cwd: __dirname, encoding: 'utf-8', timeout: 60000, stdio: 'pipe' })
|
||||
|
||||
if (initResult.status !== 0) {
|
||||
console.error(` ❌ init 失败:`, initResult.stderr.slice(0, 300))
|
||||
results.push({ id: v.id, style: v.style, status: 'failed', error: 'init failed: ' + initResult.stderr.slice(0,200) })
|
||||
try { fs.unlinkSync(tmpItems) } catch {}
|
||||
continue
|
||||
}
|
||||
|
||||
// 从 init 输出中提取 manifest 路径
|
||||
const initOut = initResult.stdout + initResult.stderr
|
||||
const manifestMatch = initOut.match(/Manifest 已创建: (.+\.json)/)
|
||||
if (!manifestMatch) {
|
||||
console.error(' ❌ 无法提取 manifest 路径')
|
||||
console.log(' init stdout:', initOut.slice(0, 500))
|
||||
results.push({ id: v.id, style: v.style, status: 'failed', error: 'no manifest path' })
|
||||
try { fs.unlinkSync(tmpItems) } catch {}
|
||||
continue
|
||||
}
|
||||
|
||||
const manifestPath = manifestMatch[1].trim()
|
||||
console.log(` ✅ manifest: ${path.basename(path.dirname(manifestPath))}`)
|
||||
try { fs.unlinkSync(tmpItems) } catch {}
|
||||
|
||||
// 5. 更新 manifest: 加 ttsEngine + references
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
manifest.ttsEngine = 'minimax'
|
||||
manifest.ttsRate = 1.15
|
||||
manifest.references = v.refs.map(f => ({ file: f }))
|
||||
// 确保 items 里的 videoPrompt 字段存在(pipeline 需要)
|
||||
manifest.items.forEach((item, idx) => {
|
||||
if (!item.videoPrompt && validItems[idx]?.videoPrompt) {
|
||||
item.videoPrompt = validItems[idx].videoPrompt
|
||||
}
|
||||
})
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
||||
console.log(` ✅ manifest 更新完成 (ttsEngine=minimax, refs=${v.refs.length})`)
|
||||
|
||||
// 6. 运行 pipeline: tts → images → upload → videos → assemble
|
||||
console.log(` → 运行 pipeline (预计30-60分钟)...`)
|
||||
const runResult = spawnSync('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
|
||||
'--manifest', manifestPath,
|
||||
'--phase', 'tts,images,upload,videos,assemble'
|
||||
], {
|
||||
cwd: __dirname,
|
||||
encoding: 'utf-8',
|
||||
timeout: 3600000, // 1小时
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
// 7. 读取结果
|
||||
const finalManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
const draftUrl = finalManifest.draftUrl || ''
|
||||
const ok = runResult.status === 0 && !!draftUrl
|
||||
|
||||
if (ok) {
|
||||
// 8. 重命名草稿
|
||||
const draftId = draftUrl.split('draft_id=')[1] || ''
|
||||
if (draftId) {
|
||||
const draftBase = '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'
|
||||
const oldPath = path.join(draftBase, draftId)
|
||||
const newName = `执黑先行二号-风格延伸_${dateStr}_${jobNum}_${v.topicB}`
|
||||
const newPath = path.join(draftBase, newName)
|
||||
if (fs.existsSync(oldPath)) {
|
||||
try {
|
||||
fs.renameSync(oldPath, newPath)
|
||||
console.log(` ✅ 草稿已重命名: ${newName}`)
|
||||
} catch (e) {
|
||||
console.log(` ⚠ 草稿重命名失败: ${e.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` ✅ 完成 | draftUrl: ${draftUrl}`)
|
||||
results.push({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'done', draftUrl, draftName: `执黑先行二号-风格延伸_${dateStr}_${jobNum}_${v.topicB}`,
|
||||
shots: validItems.length })
|
||||
} else {
|
||||
console.log(` ❌ 失败 (exit ${runResult.status})`)
|
||||
results.push({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', shots: validItems.length })
|
||||
}
|
||||
}
|
||||
|
||||
// ====== 最终汇总 ======
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('批量生产完成 — 最终结果表')
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
console.log('| # | 脚本 | 风格 | 音色 | Shot数 | 状态 | 草稿名称 | draftUrl |')
|
||||
console.log('|---|------|------|------|-------|------|---------|---------|')
|
||||
results.forEach((r, i) => {
|
||||
const num = String(i+1).padStart(2,'0')
|
||||
const status = r.status === 'done' ? '✅' : '❌'
|
||||
const draftName = r.draftName || '-'
|
||||
const url = r.draftUrl || '-'
|
||||
console.log(`| ${num} | ${r.scriptNum||'-'} | ${r.style} | ${r.voice||'-'} | ${r.shots||'-'} | ${status} | ${draftName} | ${url} |`)
|
||||
})
|
||||
|
||||
// 保存结果
|
||||
const resultPath = path.join(OUTPUT_BASE, `执黑先行二号-风格延伸_${new Date().toISOString().slice(0,10)}_7视频批量结果.json`)
|
||||
fs.writeFileSync(resultPath, JSON.stringify(results, null, 2), 'utf-8')
|
||||
console.log(`\n结果已保存: ${resultPath}`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('脚本执行失败:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
397
run_batch_7videos_parallel.js
Normal file
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 执黑先行二号-风格延伸 — 7视频 并行批量生产
|
||||
* 3 路并发,每路独立进程+独立日志
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawn, spawnSync } = require('child_process')
|
||||
|
||||
const CONCURRENCY = 3
|
||||
const ACCOUNT = '执黑先行二号-风格延伸'
|
||||
const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
|
||||
const OUTPUT_BASE = path.join(__dirname, 'output')
|
||||
|
||||
// ============================================================================
|
||||
// 7个视频配置
|
||||
// ============================================================================
|
||||
|
||||
const VIDEOS = [
|
||||
{
|
||||
id: 'v8', scriptNum: 8, style: '硬核线条', voice: '不羁青年', topicB: '搞女人',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_119_18.png',
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_120_18.png',
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_121_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v8.json'
|
||||
},
|
||||
{
|
||||
id: 'v9', scriptNum: 9, style: '梦核水池', voice: '不羁青年', topicB: '没人懂你',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_239_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_245_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232446_266_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v9.json'
|
||||
},
|
||||
{
|
||||
id: 'v12', scriptNum: 12, style: '水墨', voice: '温暖少女', topicB: '看人看结果',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/水墨/5bbcc329-dd8e-4b84-b901-97daa00943c9.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v12.json'
|
||||
},
|
||||
{
|
||||
id: 'v13', scriptNum: 13, style: '梦核人物', voice: '温暖少女', topicB: '内耗三层',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232203_259_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_260_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_261_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v13.json'
|
||||
},
|
||||
{
|
||||
id: 'v14', scriptNum: 14, style: '梦核怪异城市', voice: '不羁青年', topicB: '地狱相',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_252_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_253_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_254_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v14.json'
|
||||
},
|
||||
{
|
||||
id: 'v15', scriptNum: 15, style: '梦核花池', voice: '温暖少女', topicB: '孩子结账',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_246_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_247_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_248_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v15.json'
|
||||
},
|
||||
{
|
||||
id: 'v16', scriptNum: 16, style: '剪纸报', voice: '不羁青年', topicB: '借鸡生蛋',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v16.json'
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
function logToFile(fd, msg) {
|
||||
const ts = new Date().toISOString().slice(11, 19)
|
||||
fs.writeSync(fd, `[${ts}] ${msg}\n`)
|
||||
}
|
||||
|
||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
||||
|
||||
// ============================================================================
|
||||
// 处理单个视频
|
||||
// ============================================================================
|
||||
|
||||
function processVideo(v, jobNum, logDir) {
|
||||
return new Promise((resolve) => {
|
||||
const logPath = path.join(logDir, `${jobNum}_${v.id}_${v.topicB}.log`)
|
||||
const logFd = fs.openSync(logPath, 'w')
|
||||
|
||||
logToFile(logFd, `开始: ${jobNum}/07 ${v.style} | ${v.voice} | 脚本${v.scriptNum}`)
|
||||
logToFile(logFd, `分镜: ${v.storyboardFile}`)
|
||||
|
||||
// ---- 检查分镜文件 ----
|
||||
if (!fs.existsSync(v.storyboardFile)) {
|
||||
logToFile(logFd, `❌ 分镜文件不存在: ${v.storyboardFile}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: '分镜文件缺失', shots: 0, logPath })
|
||||
}
|
||||
|
||||
let items
|
||||
try {
|
||||
items = JSON.parse(fs.readFileSync(v.storyboardFile, 'utf-8'))
|
||||
} catch (e) {
|
||||
logToFile(logFd, `❌ 分镜文件解析失败: ${e.message}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: '分镜解析失败', shots: 0, logPath })
|
||||
}
|
||||
|
||||
const validItems = items.map(it => {
|
||||
const d = Number(it.duration) || 5
|
||||
return { ...it, duration: Math.min(d, 5.9) }
|
||||
})
|
||||
logToFile(logFd, `分镜: ${validItems.length} 个 shot (原始${items.length})`)
|
||||
|
||||
// ---- Init manifest ----
|
||||
const tmpItems = `/tmp/items_${v.id}_${Date.now()}.json`
|
||||
fs.writeFileSync(tmpItems, JSON.stringify(validItems), 'utf-8')
|
||||
|
||||
logToFile(logFd, '→ init manifest...')
|
||||
const initResult = spawnSync('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'init',
|
||||
'--account', ACCOUNT,
|
||||
'--mode', 'single',
|
||||
'--items-file', tmpItems,
|
||||
'--image-model', 'gpt-image',
|
||||
'--video-model', 'kling',
|
||||
'--tts-voice', v.voice,
|
||||
'--format', '9:16'
|
||||
], { cwd: __dirname, encoding: 'utf-8', timeout: 60000, stdio: 'pipe' })
|
||||
|
||||
try { fs.unlinkSync(tmpItems) } catch {}
|
||||
|
||||
if (initResult.status !== 0) {
|
||||
logToFile(logFd, `❌ init 失败 (exit ${initResult.status})`)
|
||||
logToFile(logFd, `stderr: ${initResult.stderr.slice(0, 500)}`)
|
||||
logToFile(logFd, `stdout: ${initResult.stdout.slice(0, 500)}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: `init failed: ${initResult.stderr.slice(0,200)}`, shots: validItems.length, logPath })
|
||||
}
|
||||
|
||||
const initOut = initResult.stdout + initResult.stderr
|
||||
const manifestMatch = initOut.match(/Manifest 已创建: (.+\.json)/)
|
||||
if (!manifestMatch) {
|
||||
logToFile(logFd, '❌ 无法提取 manifest 路径')
|
||||
logToFile(logFd, `输出: ${initOut.slice(0, 500)}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: 'no manifest path', shots: validItems.length, logPath })
|
||||
}
|
||||
|
||||
const manifestPath = manifestMatch[1].trim()
|
||||
logToFile(logFd, `✅ manifest: ${path.basename(path.dirname(manifestPath))}`)
|
||||
|
||||
// ---- 更新 manifest ----
|
||||
let manifest
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
} catch (e) {
|
||||
logToFile(logFd, `❌ 无法读取 manifest: ${e.message}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: 'manifest read failed', shots: validItems.length, logPath })
|
||||
}
|
||||
|
||||
manifest.ttsEngine = 'minimax'
|
||||
manifest.ttsRate = 1.15
|
||||
manifest.references = v.refs.map(f => ({ file: f }))
|
||||
manifest.items.forEach((item, idx) => {
|
||||
if (!item.videoPrompt && validItems[idx]?.videoPrompt) {
|
||||
item.videoPrompt = validItems[idx].videoPrompt
|
||||
}
|
||||
})
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
||||
logToFile(logFd, `✅ manifest 更新完成 (ttsEngine=minimax, refs=${v.refs.length})`)
|
||||
|
||||
// ---- 运行 pipeline (异步,长时间运行) ----
|
||||
logToFile(logFd, '→ 运行 pipeline (tts→images→upload→videos→assemble)...')
|
||||
|
||||
const runChild = spawn('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
|
||||
'--manifest', manifestPath,
|
||||
'--phase', 'tts,images,upload,videos,assemble'
|
||||
], {
|
||||
cwd: __dirname,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
timeout: 3600000
|
||||
})
|
||||
|
||||
runChild.on('close', (code) => {
|
||||
logToFile(logFd, `pipeline 退出码: ${code}`)
|
||||
|
||||
// 读取最终 manifest
|
||||
let draftUrl = '', draftId = ''
|
||||
try {
|
||||
const finalManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
draftUrl = finalManifest.draftUrl || ''
|
||||
if (draftUrl) draftId = draftUrl.split('draft_id=')[1] || ''
|
||||
} catch (e) {
|
||||
logToFile(logFd, `⚠ 读取最终 manifest 失败: ${e.message}`)
|
||||
}
|
||||
|
||||
if (code === 0 && draftUrl) {
|
||||
// 重命名草稿
|
||||
const today = new Date()
|
||||
const dateStr = `${today.getMonth()+1}${String(today.getDate()).padStart(2,'0')}`
|
||||
const draftName = `${ACCOUNT}_${dateStr}_${jobNum}_${v.topicB}`
|
||||
|
||||
if (draftId) {
|
||||
const draftBase = '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'
|
||||
const oldPath = path.join(draftBase, draftId)
|
||||
const newPath = path.join(draftBase, draftName)
|
||||
if (fs.existsSync(oldPath)) {
|
||||
try {
|
||||
fs.renameSync(oldPath, newPath)
|
||||
logToFile(logFd, `✅ 草稿已重命名: ${draftName}`)
|
||||
} catch (e) {
|
||||
logToFile(logFd, `⚠ 草稿重命名失败: ${e.message}`)
|
||||
}
|
||||
} else {
|
||||
logToFile(logFd, `⚠ 草稿路径不存在: ${oldPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
logToFile(logFd, `✅ 完成 | draftUrl: ${draftUrl}`)
|
||||
fs.closeSync(logFd)
|
||||
resolve({
|
||||
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'done', draftUrl, draftName,
|
||||
shots: validItems.length, logPath, manifestPath
|
||||
})
|
||||
} else {
|
||||
logToFile(logFd, `❌ 失败 (exit ${code})`)
|
||||
fs.closeSync(logFd)
|
||||
resolve({
|
||||
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: `pipeline exit ${code}`, shots: validItems.length, logPath
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
runChild.on('error', (err) => {
|
||||
logToFile(logFd, `❌ spawn 错误: ${err.message}`)
|
||||
fs.closeSync(logFd)
|
||||
resolve({
|
||||
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: `spawn error: ${err.message}`, shots: validItems.length, logPath
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 并发调度器
|
||||
// ============================================================================
|
||||
|
||||
async function runWithConcurrency(tasks, concurrency) {
|
||||
const results = []
|
||||
const inFlight = new Set()
|
||||
|
||||
for (const task of tasks) {
|
||||
const promise = task().then(r => {
|
||||
inFlight.delete(promise)
|
||||
return r
|
||||
})
|
||||
inFlight.add(promise)
|
||||
results.push(promise)
|
||||
|
||||
if (inFlight.size >= concurrency) {
|
||||
await Promise.race(inFlight)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 主流程
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const logDir = `/tmp/batch_logs_${Date.now()}`
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
|
||||
console.log(`${'='.repeat(60)}`)
|
||||
console.log(`执黑先行二号-风格延伸 — 7个视频并行批量生产`)
|
||||
console.log(`并发数: ${CONCURRENCY} | 日志: ${logDir}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
const summaryLog = path.join(logDir, '_summary.log')
|
||||
const summaryFd = fs.openSync(summaryLog, 'w')
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
// 构建任务列表
|
||||
const tasks = VIDEOS.map((v, i) => {
|
||||
const jobNum = String(i + 1).padStart(2, '0')
|
||||
return () => processVideo(v, jobNum, logDir)
|
||||
})
|
||||
|
||||
// 进度打印
|
||||
const doneSet = new Set()
|
||||
const progressPrinter = setInterval(() => {
|
||||
const done = doneSet.size
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 60000)
|
||||
const remaining = VIDEOS.length - done - (CONCURRENCY <= done ? 0 : CONCURRENCY - done)
|
||||
process.stdout.write(`\r 进度: ${done}/${VIDEOS.length} 完成 | 已运行 ${elapsed} 分钟 | 预计剩余 ~${Math.max(0, remaining * 25)} 分钟 `)
|
||||
}, 15000)
|
||||
|
||||
// 包装任务以追踪完成
|
||||
const wrappedTasks = tasks.map((taskFn, i) => {
|
||||
return async () => {
|
||||
const result = await taskFn()
|
||||
doneSet.add(i)
|
||||
const done = doneSet.size
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 60000)
|
||||
const status = result.status === 'done' ? '✅' : '❌'
|
||||
console.log(`\n [${done}/${VIDEOS.length}] ${status} ${result.style} | ${result.voice} | 脚本${result.scriptNum} | ${result.status === 'done' ? result.draftName : result.error}`)
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
// 启动并发执行
|
||||
const results = await runWithConcurrency(wrappedTasks, CONCURRENCY)
|
||||
|
||||
clearInterval(progressPrinter)
|
||||
|
||||
const totalTime = Math.floor((Date.now() - startTime) / 60000)
|
||||
|
||||
// ====== 最终汇总 ======
|
||||
console.log(`\n\n${'='.repeat(60)}`)
|
||||
console.log(`批量生产完成 — 总耗时 ${totalTime} 分钟`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
const doneCount = results.filter(r => r.status === 'done').length
|
||||
const failCount = results.filter(r => r.status === 'failed').length
|
||||
|
||||
console.log(`成功: ${doneCount} | 失败: ${failCount}\n`)
|
||||
|
||||
// 输出表格
|
||||
console.log('| # | 脚本 | 风格 | 音色 | Shot数 | 状态 | 草稿名称 | draftUrl |')
|
||||
console.log('|---|------|------|------|-------|------|---------|---------|')
|
||||
results.forEach((r, i) => {
|
||||
const num = String(i + 1).padStart(2, '0')
|
||||
const status = r.status === 'done' ? '✅' : '❌'
|
||||
const draftName = r.draftName || '-'
|
||||
const url = r.draftUrl || '-'
|
||||
console.log(`| ${num} | ${r.scriptNum || '-'} | ${r.style} | ${r.voice || '-'} | ${r.shots || '-'} | ${status} | ${draftName} | ${url} |`)
|
||||
})
|
||||
|
||||
// 保存结果 JSON
|
||||
const resultData = results.map((r, i) => ({
|
||||
index: i + 1,
|
||||
scriptNum: r.scriptNum,
|
||||
style: r.style,
|
||||
voice: r.voice,
|
||||
shots: r.shots,
|
||||
status: r.status,
|
||||
draftName: r.draftName || null,
|
||||
draftUrl: r.draftUrl || null,
|
||||
error: r.error || null,
|
||||
logPath: r.logPath
|
||||
}))
|
||||
|
||||
const resultPath = path.join(OUTPUT_BASE, `${ACCOUNT}_${new Date().toISOString().slice(0, 10)}_7视频批量结果.json`)
|
||||
fs.writeFileSync(resultPath, JSON.stringify(resultData, null, 2), 'utf-8')
|
||||
console.log(`\n结果已保存: ${resultPath}`)
|
||||
console.log(`日志目录: ${logDir}`)
|
||||
|
||||
// 写入 summary
|
||||
fs.writeSync(summaryFd, `总耗时: ${totalTime} 分钟\n成功: ${doneCount} | 失败: ${failCount}\n\n`)
|
||||
resultData.forEach(r => {
|
||||
fs.writeSync(summaryFd, `${r.status === 'done' ? '✅' : '❌'} ${r.style} | ${r.voice} | 脚本${r.scriptNum} | ${r.draftName || r.error}\n`)
|
||||
})
|
||||
fs.closeSync(summaryFd)
|
||||
|
||||
process.exit(failCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('脚本执行失败:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
161
run_fix_videos_and_assemble.js
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 修复 confirmed=false 导致 0 视频的问题
|
||||
* 对所有 7 个视频:补跑 videos → upload → assemble
|
||||
* 视频阶段 2 并发,装配串行
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawn, spawnSync } = require('child_process')
|
||||
|
||||
const SCRIPTS = path.join(__dirname, '.claude/skills/video-from-script/scripts/pipeline.js')
|
||||
const DRAFT_BASE = '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft'
|
||||
const BASE = path.join(__dirname, 'output')
|
||||
|
||||
const MANIFESTS = [
|
||||
{ mid: '011', id:'v8', scriptNum:8, style:'硬核线条', voice:'不羁青年', topicB:'搞女人', seq:18, needVideos:true },
|
||||
{ mid: '012', id:'v9', scriptNum:9, style:'梦核水池', voice:'不羁青年', topicB:'没人懂你', seq:19, needVideos:true },
|
||||
{ mid: '013', id:'v12', scriptNum:12, style:'水墨', voice:'温暖少女', topicB:'看人看结果', seq:20, needVideos:true },
|
||||
{ mid: '019', id:'v13', scriptNum:13, style:'梦核人物', voice:'温暖少女', topicB:'内耗三层', seq:21, needVideos:true },
|
||||
{ mid: '020', id:'v14', scriptNum:14, style:'梦核怪异城市', voice:'不羁青年', topicB:'地狱相', seq:22, needVideos:true },
|
||||
{ mid: '021', id:'v15', scriptNum:15, style:'梦核花池', voice:'温暖少女', topicB:'孩子结账', seq:23, needVideos:false },
|
||||
{ mid: '022', id:'v16', scriptNum:16, style:'剪纸报', voice:'不羁青年', topicB:'借鸡生蛋', seq:24, needVideos:true, needUpload:true },
|
||||
]
|
||||
|
||||
function log(fd, msg) { fs.writeSync(fd, `[${new Date().toISOString().slice(11,19)}] ${msg}\n`) }
|
||||
|
||||
function runPipeline(manifestPath, phase, logFd, timeout) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('node', [SCRIPTS, 'run', '--manifest', manifestPath, '--phase', phase], {
|
||||
cwd: __dirname, stdio: ['ignore', logFd, logFd], timeout: timeout || 3600000
|
||||
})
|
||||
child.on('close', code => resolve(code))
|
||||
child.on('error', err => { log(logFd, `spawn error: ${err.message}`); resolve(1) })
|
||||
})
|
||||
}
|
||||
|
||||
async function runParallel(items, fn, limit) {
|
||||
const results = [], inflight = new Set()
|
||||
for (const item of items) {
|
||||
const p = fn(item).then(r => { inflight.delete(p); return r })
|
||||
inflight.add(p); results.push(p)
|
||||
if (inflight.size >= limit) await Promise.race(inflight)
|
||||
}
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const logDir = `/tmp/batch_fix_${Date.now()}`
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
const start = Date.now()
|
||||
|
||||
console.log(`${'='.repeat(60)}`)
|
||||
console.log(`修复视频 + 装配 — 7个视频`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
// ==== Step 0: 修复 022 的 upload ====
|
||||
const v22 = MANIFESTS.find(m => m.mid === '022')
|
||||
if (v22.needUpload) {
|
||||
const mf = path.join(BASE, `执黑先行二号-风格延伸_20260523_022/manifest.json`)
|
||||
console.log('[Step 0] 修复 v16 upload 阶段...')
|
||||
const code = spawnSync('node', [SCRIPTS, 'run', '--manifest', mf, '--phase', 'upload'], {
|
||||
cwd: __dirname, encoding: 'utf-8', timeout: 120000, stdio: 'inherit'
|
||||
})
|
||||
console.log(code.status === 0 ? ' ✅ upload 完成' : ' ⚠ upload 退出码: ' + code.status)
|
||||
}
|
||||
|
||||
// ==== Step 1: 并行 videos ====
|
||||
const needVids = MANIFESTS.filter(m => m.needVideos)
|
||||
console.log(`\n[Step 1] 视频生成 (${needVids.length}个, 2并发)\n`)
|
||||
|
||||
let vidDone = 0
|
||||
await runParallel(needVids, async (v) => {
|
||||
const mf = path.join(BASE, `执黑先行二号-风格延伸_20260523_${v.mid}/manifest.json`)
|
||||
const lf = fs.openSync(path.join(logDir, `vid_${v.mid}_${v.topicB}.log`), 'w')
|
||||
log(lf, `开始视频生成: ${v.style}`)
|
||||
|
||||
// 清除旧 draft 防止冲突
|
||||
const m = JSON.parse(fs.readFileSync(mf, 'utf-8'))
|
||||
delete m.draftUrl
|
||||
// 重设备注阶段状态
|
||||
if (m.pipeline?.phases?.assemble) m.pipeline.phases.assemble = 'pending'
|
||||
if (m.pipeline?.phases?.videos) m.pipeline.phases.videos = 'pending'
|
||||
fs.writeFileSync(mf, JSON.stringify(m, null, 2), 'utf-8')
|
||||
|
||||
const code = await runPipeline(mf, 'videos', lf, 3600000)
|
||||
|
||||
// 检查结果
|
||||
const m2 = JSON.parse(fs.readFileSync(mf, 'utf-8'))
|
||||
const vidCount = m2.items.filter(it => it.video || it.videoUrl).length
|
||||
log(lf, `视频生成完成: ${vidCount}/${m2.items.length} items 有视频 (exit ${code})`)
|
||||
fs.closeSync(lf)
|
||||
|
||||
vidDone++
|
||||
console.log(` [视频 ${vidDone}/${needVids.length}] ${code===0?'✅':'⚠️'} ${v.style} | ${vidCount}个视频`)
|
||||
return { ...v, vidCode: code, vidCount }
|
||||
}, 2)
|
||||
|
||||
// ==== Step 2: 串行 assemble ====
|
||||
console.log(`\n[Step 2] 装配 (7个, 串行)\n`)
|
||||
|
||||
let asmDone = 0
|
||||
for (const v of MANIFESTS) {
|
||||
const mf = path.join(BASE, `执黑先行二号-风格延伸_20260523_${v.mid}/manifest.json`)
|
||||
const lf = fs.openSync(path.join(logDir, `asm_${v.mid}_${v.topicB}.log`), 'w')
|
||||
log(lf, `开始装配: ${v.style}`)
|
||||
|
||||
const code = await runPipeline(mf, 'assemble', lf, 300000)
|
||||
|
||||
let draftUrl = '', draftName = ''
|
||||
if (code === 0) {
|
||||
const m = JSON.parse(fs.readFileSync(mf, 'utf-8'))
|
||||
draftUrl = m.draftUrl || ''
|
||||
}
|
||||
|
||||
if (draftUrl) {
|
||||
const today = new Date()
|
||||
const ds = `${today.getMonth()+1}${String(today.getDate()).padStart(2,'0')}`
|
||||
draftName = `执黑先行二号-风格延伸_${ds}_${String(v.seq).padStart(2,'0')}_${v.topicB}`
|
||||
|
||||
const draftId = draftUrl.split('draft_id=')[1] || ''
|
||||
if (draftId) {
|
||||
const oldP = path.join(DRAFT_BASE, draftId)
|
||||
const newP = path.join(DRAFT_BASE, draftName)
|
||||
if (fs.existsSync(oldP)) {
|
||||
try { fs.renameSync(oldP, newP); log(lf, `✅ 草稿已改名: ${draftName}`) }
|
||||
catch(e) { log(lf, `⚠ 改名失败: ${e.message}`) }
|
||||
}
|
||||
// 删除旧草稿(如果存在)
|
||||
const oldDraft = path.join(DRAFT_BASE, draftName)
|
||||
// newP already set, skip duplicate check
|
||||
}
|
||||
log(lf, `✅ 装配完成 | ${draftUrl}`)
|
||||
} else {
|
||||
log(lf, `❌ 装配失败 (exit ${code})`)
|
||||
}
|
||||
fs.closeSync(lf)
|
||||
|
||||
asmDone++
|
||||
const status = draftUrl ? '✅' : '❌'
|
||||
console.log(` [装配 ${asmDone}/7] ${status} 脚本${v.scriptNum} ${v.style} | ${draftName || 'failed'}`)
|
||||
|
||||
v.result = { code, draftUrl, draftName }
|
||||
}
|
||||
|
||||
// ==== Final Report ====
|
||||
const total = Math.floor((Date.now() - start) / 60000)
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log(`全部完成 — ${total}min`)
|
||||
console.log(`${'='.repeat(60)}`)
|
||||
|
||||
const done = MANIFESTS.filter(m => m.result?.draftUrl)
|
||||
const failed = MANIFESTS.filter(m => !m.result?.draftUrl)
|
||||
console.log(`✅ ${done.length} 成功 | ❌ ${failed.length} 失败\n`)
|
||||
|
||||
for (const v of MANIFESTS) {
|
||||
const s = v.result?.draftUrl ? '✅' : '❌'
|
||||
console.log(` ${s} 脚本${v.scriptNum} ${v.style} | ${v.result?.draftName || v.result?.code || 'N/A'}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e.message); process.exit(1) })
|
||||
262
run_prerender_4videos.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 执黑先行二号 — 剩余4视频 素材预渲染(不含 assemble)
|
||||
* TTS + images + upload + videos,2 路并发
|
||||
* 等 CapCut Mate 恢复后再串行装配
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { spawn, spawnSync } = require('child_process')
|
||||
|
||||
const CONCURRENCY = 2
|
||||
const ACCOUNT = '执黑先行二号-风格延伸'
|
||||
const SCRIPTS_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
|
||||
const OUTPUT_BASE = path.join(__dirname, 'output')
|
||||
|
||||
const VIDEOS = [
|
||||
{
|
||||
id: 'v13', scriptNum: 13, style: '梦核人物', voice: '温暖少女', topicB: '内耗三层',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232203_259_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_260_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_261_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v13.json'
|
||||
},
|
||||
{
|
||||
id: 'v14', scriptNum: 14, style: '梦核怪异城市', voice: '不羁青年', topicB: '地狱相',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_252_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_253_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_254_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v14.json'
|
||||
},
|
||||
{
|
||||
id: 'v15', scriptNum: 15, style: '梦核花池', voice: '温暖少女', topicB: '孩子结账',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_246_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_247_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_248_18.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v15.json'
|
||||
},
|
||||
{
|
||||
id: 'v16', scriptNum: 16, style: '剪纸报', voice: '不羁青年', topicB: '借鸡生蛋',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png'
|
||||
],
|
||||
storyboardFile: '/tmp/sb_v16.json'
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
|
||||
function logToFile(fd, msg) {
|
||||
const ts = new Date().toISOString().slice(11, 19)
|
||||
fs.writeSync(fd, `[${ts}] ${msg}\n`)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
function processVideo(v, jobNum, logDir, seqNum) {
|
||||
return new Promise((resolve) => {
|
||||
const logPath = path.join(logDir, `${seqNum}_${v.id}_${v.topicB}.log`)
|
||||
const logFd = fs.openSync(logPath, 'w')
|
||||
|
||||
logToFile(logFd, `开始: ${seqNum}/04 ${v.style} | ${v.voice} | 脚本${v.scriptNum}`)
|
||||
|
||||
if (!fs.existsSync(v.storyboardFile)) {
|
||||
logToFile(logFd, `❌ 分镜文件不存在`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: '分镜文件缺失' })
|
||||
}
|
||||
|
||||
let items
|
||||
try { items = JSON.parse(fs.readFileSync(v.storyboardFile, 'utf-8')) }
|
||||
catch (e) {
|
||||
logToFile(logFd, `❌ 分镜解析失败: ${e.message}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: '分镜解析失败' })
|
||||
}
|
||||
|
||||
const validItems = items.map(it => {
|
||||
const d = Number(it.duration) || 5
|
||||
return { ...it, duration: Math.min(d, 5.9) }
|
||||
})
|
||||
logToFile(logFd, `分镜: ${validItems.length} shots`)
|
||||
|
||||
// ---- Init manifest ----
|
||||
const tmpItems = `/tmp/items_${v.id}_${Date.now()}.json`
|
||||
fs.writeFileSync(tmpItems, JSON.stringify(validItems), 'utf-8')
|
||||
|
||||
logToFile(logFd, '→ init manifest...')
|
||||
const initResult = spawnSync('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'init',
|
||||
'--account', ACCOUNT,
|
||||
'--mode', 'single',
|
||||
'--items-file', tmpItems,
|
||||
'--image-model', 'gpt-image',
|
||||
'--video-model', 'kling',
|
||||
'--tts-voice', v.voice,
|
||||
'--format', '9:16'
|
||||
], { cwd: __dirname, encoding: 'utf-8', timeout: 60000, stdio: 'pipe' })
|
||||
|
||||
try { fs.unlinkSync(tmpItems) } catch {}
|
||||
|
||||
if (initResult.status !== 0) {
|
||||
logToFile(logFd, `❌ init 失败 (exit ${initResult.status})`)
|
||||
logToFile(logFd, `stderr: ${initResult.stderr.slice(0, 500)}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: `init failed` })
|
||||
}
|
||||
|
||||
const initOut = initResult.stdout + initResult.stderr
|
||||
const manifestMatch = initOut.match(/Manifest 已创建: (.+\.json)/)
|
||||
if (!manifestMatch) {
|
||||
logToFile(logFd, `❌ 无法提取 manifest 路径`)
|
||||
logToFile(logFd, `输出: ${initOut.slice(0, 500)}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: 'no manifest path' })
|
||||
}
|
||||
|
||||
const manifestPath = manifestMatch[1].trim()
|
||||
logToFile(logFd, `✅ manifest: ${path.basename(path.dirname(manifestPath))}`)
|
||||
|
||||
// ---- 更新 manifest ----
|
||||
let manifest
|
||||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) }
|
||||
catch (e) {
|
||||
logToFile(logFd, `❌ 读取 manifest 失败: ${e.message}`)
|
||||
fs.closeSync(logFd)
|
||||
return resolve({ id: v.id, style: v.style, status: 'failed', error: 'manifest read failed' })
|
||||
}
|
||||
|
||||
manifest.ttsEngine = 'minimax'
|
||||
manifest.ttsRate = 1.15
|
||||
manifest.references = v.refs.map(f => ({ file: f }))
|
||||
manifest.items.forEach((item, idx) => {
|
||||
if (!item.videoPrompt && validItems[idx]?.videoPrompt) {
|
||||
item.videoPrompt = validItems[idx].videoPrompt
|
||||
}
|
||||
})
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
||||
logToFile(logFd, `✅ manifest 更新完成 (minimax 1.15x, refs=${v.refs.length})`)
|
||||
|
||||
// ---- 运行 pipeline: 仅素材阶段(不装配) ----
|
||||
logToFile(logFd, '→ pipeline: tts→images→upload→videos (无 assemble)...')
|
||||
|
||||
const runChild = spawn('node', [
|
||||
path.join(SCRIPTS_DIR, 'pipeline.js'), 'run',
|
||||
'--manifest', manifestPath,
|
||||
'--phase', 'tts,images,upload,videos'
|
||||
], {
|
||||
cwd: __dirname,
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
timeout: 3600000
|
||||
})
|
||||
|
||||
runChild.on('close', (code) => {
|
||||
logToFile(logFd, `pipeline 退出码: ${code}`)
|
||||
|
||||
if (code === 0) {
|
||||
logToFile(logFd, `✅ 素材阶段完成 (待装配)`)
|
||||
fs.closeSync(logFd)
|
||||
resolve({
|
||||
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'ready', manifestPath, shots: validItems.length, logPath
|
||||
})
|
||||
} else {
|
||||
logToFile(logFd, `❌ 失败 (exit ${code})`)
|
||||
fs.closeSync(logFd)
|
||||
resolve({
|
||||
id: v.id, scriptNum: v.scriptNum, style: v.style, voice: v.voice,
|
||||
status: 'failed', error: `pipeline exit ${code}`, manifestPath, shots: validItems.length, logPath
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
runChild.on('error', (err) => {
|
||||
logToFile(logFd, `❌ spawn 错误: ${err.message}`)
|
||||
fs.closeSync(logFd)
|
||||
resolve({ id: v.id, style: v.style, status: 'failed', error: `spawn: ${err.message}` })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
async function runWithConcurrency(tasks, concurrency) {
|
||||
const results = []
|
||||
const inFlight = new Set()
|
||||
for (const task of tasks) {
|
||||
const promise = task().then(r => { inFlight.delete(promise); return r })
|
||||
inFlight.add(promise)
|
||||
results.push(promise)
|
||||
if (inFlight.size >= concurrency) await Promise.race(inFlight)
|
||||
}
|
||||
return Promise.all(results)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
const logDir = `/tmp/batch_prerender_${Date.now()}`
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
|
||||
console.log(`${'='.repeat(60)}`)
|
||||
console.log(`执黑先行二号 — 剩余4视频 素材预渲染 (2路并发)`)
|
||||
console.log(`阶段: tts → images → upload → videos | 日志: ${logDir}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const tasks = VIDEOS.map((v, i) => {
|
||||
const seqNum = String(i + 1).padStart(2, '0')
|
||||
return () => processVideo(v, seqNum, logDir, seqNum)
|
||||
})
|
||||
|
||||
const doneSet = new Set()
|
||||
const progressPrinter = setInterval(() => {
|
||||
const done = doneSet.size
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 60000)
|
||||
process.stdout.write(`\r 进度: ${done}/${VIDEOS.length} 完成 | ${elapsed} 分钟 `)
|
||||
}, 10000)
|
||||
|
||||
const wrappedTasks = tasks.map((taskFn, i) => {
|
||||
return async () => {
|
||||
const result = await taskFn()
|
||||
doneSet.add(i)
|
||||
const done = doneSet.size
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 60000)
|
||||
const status = result.status === 'ready' ? '✅' : '❌'
|
||||
console.log(`\n [${done}/${VIDEOS.length}] ${status} ${result.style} | ${result.voice} | 脚本${result.scriptNum} | ${result.status === 'ready' ? result.manifestPath : result.error}`)
|
||||
return result
|
||||
}
|
||||
})
|
||||
|
||||
const results = await runWithConcurrency(wrappedTasks, CONCURRENCY)
|
||||
clearInterval(progressPrinter)
|
||||
|
||||
const totalTime = Math.floor((Date.now() - startTime) / 60000)
|
||||
const readyCount = results.filter(r => r.status === 'ready').length
|
||||
const failCount = results.filter(r => r.status === 'failed').length
|
||||
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log(`素材预渲染完成 — ${totalTime} 分钟 | 就绪: ${readyCount} | 失败: ${failCount}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
results.forEach((r, i) => {
|
||||
const s = r.status === 'ready' ? '✅' : '❌'
|
||||
console.log(` ${s} 脚本${r.scriptNum} ${r.style} | ${r.manifestPath || r.error}`)
|
||||
})
|
||||
|
||||
const resultPath = path.join(OUTPUT_BASE, `${ACCOUNT}_prerender_${new Date().toISOString().slice(0,10)}.json`)
|
||||
fs.writeFileSync(resultPath, JSON.stringify(results, null, 2), 'utf-8')
|
||||
console.log(`\n结果: ${resultPath}`)
|
||||
|
||||
process.exit(failCount > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err.message); process.exit(1) })
|
||||
312
run_style_tests.js
Normal file
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 执黑先行二号-风格延伸 — 9 风格批量测试
|
||||
* 文案:仁慈的父,我已坠入,看不见罪的国度.
|
||||
* 每个风格:1张图 + 1个视频片段 + TTS + 成片
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execSync, spawnSync } = require('child_process')
|
||||
|
||||
const SCRIPT_DIR = path.join(__dirname, '.claude/skills/video-from-script/scripts')
|
||||
const OUTPUT_BASE = path.join(__dirname, 'output')
|
||||
const ACCOUNT = '执黑先行二号-风格延伸'
|
||||
const SCRIPT_TEXT = '仁慈的父,我已坠入,看不见罪的国度.'
|
||||
const TTS_DURATION = 3.0 // 15字 ÷ 5字/秒
|
||||
|
||||
// 9 风格配置
|
||||
const STYLES = [
|
||||
{
|
||||
name: '剪纸报',
|
||||
voice: '高智女孩',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png'
|
||||
],
|
||||
shotDesc: 'A silhouetted figure in mid-fall through layers of tearing vintage newspaper front pages, arms outstretched, coat billowing. Dollar bills and torn newspaper headlines swirl in the air. City skyline visible through disintegrating collage. Scene already in motion when clip begins.',
|
||||
imagePrompt: `Vintage editorial newspaper collage illustration style. A silhouetted figure in mid-fall through layers of tearing vintage newspaper front pages, arms outstretched, coat billowing dramatically. Dollar bills and torn newspaper headlines swirl in the air around the falling figure. The figure descends through a cathedral of crumbling paper, city skyline visible through the disintegrating collage. Aged sepia and golden-brown paper tones, deliberate ink stains and typography fragments bleeding into the image. Layers of collaged newsprint create a textured patchwork background — headlines suggesting wealth, corruption, and divine judgment visible in torn edges. Strong chiaroscuro lighting from above, casting deep shadows through paper tears. Retro pulp fiction meets documentary photojournalism meets fine art collage. The paper architecture collapses inward like a cathedral ceiling failing. Dramatic perspective on the falling figure. Warm amber, faded cream, black ink, and deep crimson accent colors. No digital smoothness — everything textured, tactile, aged. Visible paper grain, crease lines, watermarks. Vertical format 9:16, cinematic composition, full bleed, no border, no frame, no margin, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,画面中纸张和人影已在高速运动中】
|
||||
|
||||
主体动作(第0帧立即发生):人物剪影从画面上方快速下坠,报纸页面随冲击撕裂飞散,美元纸币如暴风雪般向四周旋飞,人物外套随剧烈下落猛然向上翻起——整个下坠过程充满力量感和速度感。
|
||||
|
||||
环境响应(中间2-3秒):撕碎的报纸版面继续从四面涌入,包裹着下坠的身影,金色琥珀色的旧纸张与深色墨迹在空中形成漩涡,远处城市剪影透过纸张撕裂处若隐若现。
|
||||
|
||||
结尾:人物下坠至画面中央,一堆纸张轰然坍塌向下压,镜头跟随向下快速冲去。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '反向达尔文',
|
||||
voice: '嘴硬竹马',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/反向达尔文/微信图片_20260522232915_271_18.png',
|
||||
'/Users/lc/Pictures/参考图片/反向达尔文/微信图片_20260522232915_276_18.png',
|
||||
'/Users/lc/Pictures/参考图片/反向达尔文/微信图片_20260522232915_279_18.png'
|
||||
],
|
||||
shotDesc: 'A lone woman in flowing white traditional robes stands waist-deep in a still lotus pond, head tilted back in surrender, arms raised — posture of falling backward. A crimson lantern glows below the water surface. A greyhound dog watches from the stone landing. Ethereal misty Chinese aesthetic.',
|
||||
imagePrompt: `Hyperrealistic cinematic photography merged with traditional Chinese aesthetic. A lone woman in flowing white silk traditional robes stands waist-deep in a perfectly still lotus pond, head tilted far back in surrender, arms slightly raised — the posture of someone falling backward into water. Lotus leaves spread across the misty surface. Her white robes float and spread like ink dissolving in water. A crimson paper lantern glows faintly below the water surface directly beneath her, its warm light refracting upward through the still water, illuminating her submerged robes from below. The sky above is heavy with silver-grey mist, merging with the pale lotus pond horizon. Behind her, a lone greyhound dog sits on an ancient stone landing at the pond's edge, watching silently with absolute stillness. Deep cinematic color palette: warm crimson and gold from the submerged lantern, silver-grey atmospheric mist, pure white silk, dark jade water. Atmospheric, melancholic, mythological mood. Shallow depth of field, anamorphic cinematic bokeh on the background. The composition suggests a figure between two worlds — earthly and divine. Ancient Chinese architecture barely visible in the far mist. Vertical format 9:16, cinematic composition, full bleed, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,白衣女子已在向后倾倒中】
|
||||
|
||||
主体动作(第0帧立即发生):白衣女子猛然向后仰倒入莲花池,白色丝绸袍服在水面猛然展开如花,莲花花瓣随冲击力向四周飞射——同时水面之下那盏红灯笼猛然向上冲起一道红光穿透水面,与下沉的白袍形成强烈对比。
|
||||
|
||||
环境响应(中间2-3秒):水面震荡产生大圆形波纹向外扩散,莲叶随冲击剧烈颤动,远处的灵缇犬猛然抬头直视镜头,晨雾在扰动中快速流动。
|
||||
|
||||
结尾:镜头快速从俯拍切换至水面下仰拍——红灯笼光芒从水下向上照射,白袍在水中如仙女下凡般展开,画面戛然而止。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '梦核人物',
|
||||
voice: '温暖少女',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232203_259_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_260_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_261_18.png'
|
||||
],
|
||||
shotDesc: 'Aerial top-down view: a girl in white dress lies spread-eagled on deep teal water surface, already sinking, arms wide, hair fanning around her. A massive luminous white koi fish circles slowly from the deep below. Café chairs visible at pool edge. Dreamcore descent.',
|
||||
imagePrompt: `Dreamcore digital illustration, aerial top-down perspective looking straight down. A girl in a flowing white dress lies spread-eagled on the surface of a deep teal-green pool, face upturned toward the sky and toward the viewer, arms wide open, hair fanning around her head on the water surface — already beginning to sink, body halfway between floating and submerging. A massive luminous white koi fish with flowing fins and tail circles slowly beneath her in the translucent dark depths, visible through the water as a glowing white form below her. White flower petals and floating foam scatter across the teal water surface around her white dress. Café tables and chairs visible at the pool edge periphery. Soft aquamarine, ivory white, and deep jade color palette. Warm afternoon light filtering from above creating soft diffused shadows on the water surface. The girl's expression is peaceful, eyes closed, completely surrendered. Dreamcore atmosphere — surreal, serene, and gently melancholic. Painterly soft digital illustration texture with visible soft brushwork. The composition implies descent — she is falling upward into the sky or falling downward into the water, the top-down perspective makes the direction of fall ambiguous and dreamlike. Vertical format 9:16, cinematic composition, full bleed, no border, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,俯视视角,少女已在水面上缓缓下沉】
|
||||
|
||||
主体动作(第0帧立即发生):从正上方鸟瞰,身着白裙的少女在水面上缓缓向下沉入青绿色的水中,白裙在水中如花朵般展开,发丝在水中向上漂浮——沉入和上浮两种运动同时发生,制造梦幻的漂浮感。水下的白色锦鲤猛然加速游向少女,裂开的水面泛出涟漪。
|
||||
|
||||
环境响应(中间2-3秒):水面花瓣随涟漪向外漂散,池边咖啡椅倒影在波动中扭曲,阳光在水面形成光斑随波纹闪烁跳动。
|
||||
|
||||
结尾:镜头缓缓下沉进入水面,从水下向上仰望白裙少女的身影,白色锦鲤游过镜头前。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '梦核怪异城市',
|
||||
voice: '不羁青年',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_252_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_253_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_254_18.png'
|
||||
],
|
||||
shotDesc: 'A lone silhouetted figure in mid-fall descends through a surreal cityscape where building facades tilt and fold impossibly inward. Giant Chinese advertisement signs hang at wrong angles. A single cat sits calmly on a crumbling ledge, watching. Dense amber fog below. Scene mid-fall.',
|
||||
imagePrompt: `Surreal dystopian cityscape photography with impossible geometry. A lone silhouetted figure in mid-fall descends through a breaking cityscape where building facades tilt and curve impossibly, as if the entire city is folding inward like a closing book. Massive Hong Kong-style building facades with giant illuminated Chinese advertisement signs hang at wrong impossible angles — some perpendicular to gravity, some tilted nearly upside down. The architecture wraps around the falling figure creating a vortex of concrete and neon. A single cat sits calmly on a crumbling concrete ledge in the foreground, watching the falling figure with absolute stillness and disinterest. Dense amber and charcoal-grey fog fills the spaces between buildings below. The figure falls in perfect stillness — coat billowing, arms at sides, face turned slightly, completely surrendered to the descent. Gritty photorealistic texture — concrete, rusted metal, cracking cement. Color palette: amber fog glow, deep charcoal grey, pale concrete, neon Chinese characters bleeding color into fog. The scene has the quality of a lucid dream — familiar urban architecture made deeply uncanny by impossible physics. Low-angle looking-up perspective emphasizing height and disorientation. Vertical format 9:16, cinematic composition, full bleed, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,城市建筑已在向内折叠塌陷中,人影已在下坠中】
|
||||
|
||||
主体动作(第0帧立即发生):人物剪影从画面高处快速向下坠落,周围的香港式建筑立面随冲击向内折叠崩塌——巨型中文广告牌猛然向内倾倒,楼栋墙面在人物下坠路径两侧向内挤压,整座城市像纸箱被压扁般向中心塌陷。
|
||||
|
||||
环境响应(中间2-3秒):橙色雾霾光在建筑碎裂的缝隙中爆射出来,路边的独行猫猛然弓背直立,广告牌霓虹灯在震动中快速闪烁熄灭,城市的折叠越来越快。
|
||||
|
||||
结尾:人物下坠加速,城市彻底合拢——画面瞬间被建筑黑影吞没,只余远处一道橙色城市光线。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '梦核水池',
|
||||
voice: '傲娇御姐',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_239_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_245_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232446_266_18.png'
|
||||
],
|
||||
shotDesc: 'A solitary woman stands at the center of a completely empty drained indoor swimming pool. A single perfect rectangular opening in the concrete ceiling reveals darkening storm clouds above. She faces upward, cold and sovereign, completely alone. Water seeping around her feet from nowhere.',
|
||||
imagePrompt: `Liminal space architectural photography, hyperrealistic. A solitary woman stands at the exact center of a completely empty indoor swimming pool — the pool fully drained, white ceramic tiles covering every surface, floor, walls, and ceiling in perfect geometric monotony. The tiles are immaculate in their endless repetition. A single perfect rectangular opening cut into the concrete ceiling directly above reveals a heavy dramatic storm sky outside — dark clouds gathering with electricity, a pale sliver of sky visible. Light falls in a single vertical shaft from this opening, illuminating only the figure standing below. The figure is a woman with cold composed features, face upturned toward the sky, expression imperious and emotionally distant yet intensely present. She stands with hands at sides, spine straight, a figure of absolute self-possession in an impossible situation. Dark water-stain rings mark the empty pool walls at the old waterline. The tiles gleam with residual dampness. Color palette: cold blue-grey, bone-white tile, single shaft of pale silver light, dark storm-sky rectangle above. Oppressive, controlled, liminal space. The quiet before the inevitable fall. Architecture is the character — vast, empty, perfectly ordered in its abandonment. Vertical format 9:16, cinematic composition, full bleed, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,女子已在缓缓抬头仰望,水已在悄然上涨】
|
||||
|
||||
主体动作(第0帧立即发生):空池中心的女子猛然仰头,目光直视头顶矩形天窗——天空中乌云快速翻涌,向那个矩形开口聚集。地面无来源的水从池底四角同时涌出,蔓延速度加快,没过女子的鞋面向踝部上涌。女子纹丝不动,神情冷傲,水涌而她不退。
|
||||
|
||||
环境响应(中间2-3秒):天窗外的乌云将矩形光柱由白变灰变暗,瓷砖墙面在变暗的光线中显得更加冷冽,水面倒映出渐暗的天光快速扭曲。
|
||||
|
||||
结尾:镜头从低角度缓慢仰起——女子脚踝已被水没过,镜头继续抬高直至仰望那个渐渐被乌云封闭的天窗矩形,戛然而止。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '梦核花池',
|
||||
voice: '嚣张小姐',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_246_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_247_18.png',
|
||||
'/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_248_18.png'
|
||||
],
|
||||
shotDesc: 'Looking upward from underwater through dark water at stone stairs completely draped in submerged blooming roses. A silhouetted figure falls downward through the flowers feet-first, petals swirling in the slow descent. Light filters down from the distant surface far above.',
|
||||
imagePrompt: `Dark underwater floral photography, cinematic and deeply rich. Perspective looking upward from deep underwater through layers of murky translucent water at an ancient stone staircase completely draped in submerged blooming roses — red, dusty pink, cream white — their petals swaying in slow underwater currents, fully saturated with color even in the dark water. A silhouetted figure falls downward through the flowers feet-first, descending the submerged staircase in a controlled fall, body elongated by the deep perspective — petals swirl around the falling body like a floral vortex. Light filters down from a distant water surface far above, creating divine god-rays through the dense floral layers. The upper reaches of the image are brighter where light penetrates; the lower depths where the figure falls toward are darker, more mysterious, the roses there in deeper shadow. Rich jewel tones: deep burgundy, forest emerald, obsidian black, dusty rose, old gold. The roses are hyper-detailed — every petal individually rendered, slightly translucent in the underwater light, petals showing veins and texture. The stone stairs are moss-covered, ancient, submerged for centuries. Equal measures of beauty and darkness. Romantic and ominous simultaneously. Vertical format 9:16, cinematic composition, full bleed, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,已在水下,人影已在下坠中,花朵已在水中旋舞】
|
||||
|
||||
主体动作(第0帧立即发生):从水下向上仰视,一道人影脚朝下猛然从水面破入,冲击产生巨大气泡云和花瓣旋涡——玫瑰花瓣被下坠冲击力向四周撕散,红白粉三色花瓣在深水中旋转飞舞,人影穿过花瓣的旋涡向水底深处坠去。
|
||||
|
||||
环境响应(中间2-3秒):石阶上的玫瑰随水流震动整齐颤抖,从深处透下来的光芒随人影下坠越来越暗,大量气泡从人影周围急速上升穿过镜头。
|
||||
|
||||
结尾:人影坠入深处黑暗中,花瓣继续旋舞,最后一道玫瑰花瓣缓缓飘过镜头——慢动作,画面静止在花瓣特写。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '毛毡风格',
|
||||
voice: '搞笑大爷',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/毛毡风格/0d5d2aad-496f-46ae-8467-80de136242ae.png'
|
||||
],
|
||||
shotDesc: 'A tiny felt-crafted elderly figure tumbles gently end-over-end through golden felt clouds and sky, their tiny wool dog reaching upward from the felt meadow below. Felt mountains in background, warm amber light. Hyper-detailed fabric textures.',
|
||||
imagePrompt: `Handmade felt craft stop-motion animation aesthetic, hyper-detailed photography of miniature felt figures. A tiny felt-crafted figure — an elderly man made of stitched wool and felt fabric, wearing an oversized handmade straw hat — tumbles slowly end-over-end through the air between felt golden rice paddy fields and handmade felt clouds above. The figure's arms are outstretched, small felt boots visible, his expression one of gentle bewilderment. A tiny fluffy felt dog companion stretches its wool paws upward from the golden felt meadow below, trying to reach the falling figure, tail wagging. Felt thatched-roof farmhouses dot the background rolling hills, all stitched in warm earthy fabric. The entire world is constructed from felt, wool, and stitched fabric — every surface shows material texture: weave patterns, thread loops, fabric grain, button details. Color palette: harvest gold, earthy brown, warm cream, muted sage green, terracotta. Warm amber sunset light illuminating the felt textures. Extreme shallow depth of field — the felt landscape melts into warm bokeh behind the crisp foreground figures. The scene carries gentle melancholy — a tiny figure falling through a handmade world. Meticulous craft: buttons as rocks, yarn as grass, embroidery thread as flower stems. Vertical format 9:16, cinematic composition, full bleed, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,毛毡老爷爷已在空中翻滚中】
|
||||
|
||||
主体动作(第0帧立即发生):戴着大草帽的毛毡老爷爷在空中缓慢欢乐地翻着跟头下坠,他的帽子在空中打转,毛线外套的扣子在翻滚中一个个弹开——慢动作翻滚,每一个细节都展示毛毡材质:绒毛在空气中漂起,缝线在阳光中闪光。地面的小毛毡小狗四条腿乱蹬,拼命向上跳跃够不到他。
|
||||
|
||||
环境响应(中间2-3秒):毛毡稻田里的金色禾苗随风摆动,毛毡云朵被老爷爷翻滚划过,形状变形后弹回,远处的毛毡茅草屋随晃动轻微抖动。
|
||||
|
||||
结尾:老爷爷悠哉地双手抱头继续翻滚下坠,小狗在地面急速原地转圈,最后镜头从老爷爷第一视角快速向地面冲去——金色稻田迎面扑来。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '水墨',
|
||||
voice: '高智女孩',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/水墨/5bbcc329-dd8e-4b84-b901-97daa00943c9.png'
|
||||
],
|
||||
shotDesc: 'A lone ink-wash scholar figure descends from the top of a rice-paper scroll toward a swirling dark ink cloud below — as if falling into an abyss. The figure\'s robes trail in calligraphic brushstrokes. A second figure kneels watching from the lower corner.',
|
||||
imagePrompt: `Traditional Chinese ink wash painting on aged rice paper, masterwork quality. A lone scholar figure rendered in flowing black ink descends from the top of the composition toward a swirling mass of dark ink smoke rising from below — as if falling into a dark cloud or portal. The figure's robes trail in elegant dry-brush calligraphic strokes, ink bleeding outward where the brush pressed hard then lifted. The ink cloud below opens like a gate or abyss — turbulent, darker and denser at its center, with delicate ink tendrils reaching upward toward the falling figure like hands beckoning. The composition leaves vast areas of the rice paper untouched — pure white negative space — making the falling figure and the ink cloud feel isolated and cosmically significant. Subtle ink wash gradient in the background suggests atmospheric distance. A second shadowed scholar figure kneels at the lower left corner, head bowed in witness or prayer, observing the descent. Ink values range from pure dense black at the cloud center to pale diluted grey washes at the composition edges. Fine calligraphic brushwork details — individual brushstrokes visible, the speed and pressure of the brush hand implied in every mark. The texture of the aged rice paper is palpable — slight yellow warmth to the white. A small red seal impression stamp in the lower right corner. Vertical format 9:16, full bleed composition, no additional text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,水墨人影已在宣纸上快速下坠中,墨迹已在扩散】
|
||||
|
||||
主体动作(第0帧立即发生):水墨文人身影从宣纸卷轴顶端向下俯冲,身形在运动中变形为浓墨笔触——袍袖拖出长长的笔划痕迹,像巨型毛笔在纸上快速下行。下方的墨云汹涌向上涌出,两股墨色相向运动形成猛烈碰撞,宣纸在撞击处洇染开来,墨晕快速向四周扩散。
|
||||
|
||||
环境响应(中间2-3秒):碰撞中心的墨迹快速向外晕染,形成大片水墨渍,枯笔飞白效果在运动中显现,宣纸纸面纹理在墨迹的浸润中改变。
|
||||
|
||||
结尾:人影被墨云完全吞没——只剩笔划痕迹,像一行行草书留在宣纸上,最后镜头缓缓拉远,整张宣纸如一幅成画呈现。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
},
|
||||
{
|
||||
name: '硬核线条',
|
||||
voice: '不羁青年',
|
||||
refs: [
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_119_18.png',
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_120_18.png',
|
||||
'/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_121_18.png'
|
||||
],
|
||||
shotDesc: 'A lone dark figure tilts backward in deliberate fall, arms spread wide against a blazing crimson backdrop. Surrounded by jazz musician silhouettes who continue playing uninterrupted. Mid-fall, bold geometric graphic lines. Theatrical art deco composition.',
|
||||
imagePrompt: `Bold art deco graphic illustration with hard geometric lines and pure flat color blocks. Mid-century theatrical poster aesthetic merged with graphic novel. A single central figure tilts backward in a deliberate slow fall, arms spread wide — the posture of surrender and descent, body at 45 degrees backward — set against an intense blazing crimson and deep cobalt blue background. Surrounding the falling figure: multiple jazz musician silhouettes — a bassist hunched over their instrument, a pianist with hands on keys, a trumpeter with horn pointed skyward — all rendered as flat black silhouettes with bold geometric outlines, continuing to play completely unperturbed by the falling figure. The entire image is composed of hard-edged flat color blocks: deep vermilion red, cobalt navy blue, pitch black, and strategic metallic gold. Zero gradients, zero soft shadows — pure geometric color blocking throughout. The falling figure alone has slightly more detail than the musician silhouettes — a face visible in three-quarter view, expression serene and knowing, eyes open. Thick confident black outlines define every shape edge. The composition suggests a theatrical stage — the musicians are the world that keeps playing as the figure falls. Strong vertical composition. Vertical format 9:16, cinematic composition, full bleed, no border, no text, no watermark, no logo.`,
|
||||
videoPrompt: `【0帧动原则:视频从第一帧开始,中心人物已在向后倾倒中,爵士乐手剪影已在演奏高潮中】
|
||||
|
||||
主体动作(第0帧立即发生):中心人物猛然向后仰倒,双臂大张——衣摆随后仰动作猛烈向上翻起,几何线条随人物后倒如刀刻般向上划开,红色背景的几何色块随冲击碎裂重组。周围爵士乐手剪影对此无动于衷,继续以更激烈的幅度演奏——贝斯手低头猛拨,钢琴手双手砸键,号手仰角吹奏。
|
||||
|
||||
环境响应(中间2-3秒):极粗黑色轮廓线在冲击中震动,硬边几何色块开始快速交替闪烁——红、蓝、黑、金四色高频切换,舞台强光从多角度同时打亮。
|
||||
|
||||
结尾:人物后倒至水平定格,所有几何线条聚焦到倒地人物身上形成放射状线条爆发,然后整个画面切黑,一道金色轮廓线还亮着。
|
||||
|
||||
竖版9:16画幅,无字幕,无水印。`
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// 主执行函数
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log(`执黑先行二号-风格延伸 — 9 风格批量测试`)
|
||||
console.log(`文案:${SCRIPT_TEXT}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
const results = []
|
||||
|
||||
for (let i = 0; i < STYLES.length; i++) {
|
||||
const style = STYLES[i]
|
||||
const styleNum = String(i + 1).padStart(2, '0')
|
||||
const dirName = `执黑先行二号-风格延伸_20260523_${styleNum}_${style.name}`
|
||||
const outputDir = path.join(OUTPUT_BASE, dirName)
|
||||
|
||||
console.log(`\n[${styleNum}/09] 开始:${style.name} | 音色:${style.voice}`)
|
||||
console.log('-'.repeat(50))
|
||||
|
||||
// 创建输出目录
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'images'), { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'audio'), { recursive: true })
|
||||
fs.mkdirSync(path.join(outputDir, 'videos'), { recursive: true })
|
||||
|
||||
// 构建 manifest
|
||||
const manifest = {
|
||||
account: ACCOUNT,
|
||||
imageModel: 'gemini',
|
||||
videoModel: 'kling',
|
||||
format: '9:16',
|
||||
mode: 'single',
|
||||
ttsEngine: 'minimax',
|
||||
ttsVoice: style.voice,
|
||||
ttsRate: 1.0,
|
||||
references: style.refs.map(f => ({ file: f })),
|
||||
estimatedVideoDuration: 6,
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
status: 'pending',
|
||||
shotDesc: style.shotDesc,
|
||||
script: SCRIPT_TEXT,
|
||||
duration: TTS_DURATION,
|
||||
directorRef: 'kitano',
|
||||
imagePrompt: style.imagePrompt,
|
||||
videoPrompt: style.videoPrompt
|
||||
}
|
||||
],
|
||||
pipeline: {
|
||||
phases: {
|
||||
tts: 'pending',
|
||||
images: 'pending',
|
||||
upload: 'pending',
|
||||
videos: 'pending',
|
||||
assemble: 'pending'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPath = path.join(outputDir, 'manifest.json')
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
|
||||
console.log(` ✅ Manifest 创建: ${manifestPath}`)
|
||||
|
||||
// 运行 pipeline
|
||||
console.log(` 🚀 开始执行 pipeline...`)
|
||||
const result = spawnSync('node', [
|
||||
path.join(SCRIPT_DIR, 'pipeline.js'),
|
||||
'run',
|
||||
'--manifest', manifestPath,
|
||||
'--phase', 'tts,images,upload,videos,assemble'
|
||||
], {
|
||||
cwd: __dirname,
|
||||
encoding: 'utf-8',
|
||||
timeout: 600000, // 10 分钟
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
if (result.status === 0) {
|
||||
// 读取完成后的 manifest
|
||||
const finalManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
|
||||
const draftUrl = finalManifest.draftUrl || '(assemble 未返回 draftUrl)'
|
||||
console.log(` ✅ 完成 | draftUrl: ${draftUrl}`)
|
||||
results.push({ style: style.name, voice: style.voice, status: 'done', draftUrl, outputDir: dirName })
|
||||
} else {
|
||||
console.log(` ❌ 失败 (exit ${result.status})`)
|
||||
results.push({ style: style.name, voice: style.voice, status: 'failed', outputDir: dirName })
|
||||
}
|
||||
}
|
||||
|
||||
// 输出最终汇总
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('批量测试完成 — 结果汇总')
|
||||
console.log(`${'='.repeat(60)}`)
|
||||
console.log('')
|
||||
console.log('| # | 风格 | 音色 | 状态 | 草稿地址 |')
|
||||
console.log('|---|------|------|------|---------|')
|
||||
results.forEach((r, i) => {
|
||||
const num = String(i + 1).padStart(2, '0')
|
||||
const status = r.status === 'done' ? '✅' : '❌'
|
||||
const url = r.draftUrl || '-'
|
||||
console.log(`| ${num} | ${r.style} | ${r.voice} | ${status} | ${url} |`)
|
||||
})
|
||||
console.log('')
|
||||
|
||||
// 保存结果到文件
|
||||
const resultPath = path.join(OUTPUT_BASE, '执黑先行二号-风格延伸_20260523_测试结果.json')
|
||||
fs.writeFileSync(resultPath, JSON.stringify(results, null, 2), 'utf-8')
|
||||
console.log(`结果已保存: ${resultPath}`)
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('脚本执行失败:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||