Files
video-create/.claude/skills/video-from-script/scripts/gen-covers-batch.js
lc 9cbdabda31 feat: 封面生成流水线、执黑先行二号风格扩展、账号配置更新
- 新增 gen-covers 系列脚本(kling/gpt/t2i/batch/direct/final等方案)
- 执黑先行二号添加9种风格提示词目录(梦核/剪纸/水墨/毛毡/硬核线条等)
- 执黑先行添加封面提示词、执黑先行二号更新图片提示词
- product_viral_factory 账号配置扩充并添加 cover_template 参考图
- capcut_assemble/kling-video-generator/oss-upload/poll-utils 细节修复
- CLAUDE.md 更新流程文档

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:44:47 +08:00

368 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* 批量封面生成 - 为执黑先行旧草稿补生成封面
* 从剪映草稿目录提取首帧 → GPT Image edit → Sharp 文字叠加 → OSS 上传
*
* 用法: node gen-covers-batch.js
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
const { execSync } = require('child_process')
const sharp = require('sharp')
const OSS = require('ali-oss')
// ============================================================================
// Config
// ============================================================================
const configPath = path.join(__dirname, '..', '..', 'config.json')
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
const DRAFT_BASE = cfg.jianyingDraftPath || path.join(require('os').homedir(), 'Movies/JianyingPro/User Data/Projects/com.lveditor.draft')
// Output dir for covers
const OUTPUT_BASE = path.join(__dirname, '..', '..', '..', 'output', '执黑先行_补封面')
fs.mkdirSync(OUTPUT_BASE, { recursive: true })
// OSS client
const ossClient = new OSS({
region: cfg.ossRegion,
accessKeyId: cfg.ossAccessKeyId,
accessKeySecret: cfg.ossAccessKeySecret,
bucket: cfg.ossBucket,
secure: true,
})
// ============================================================================
// 8 drafts to process
// ============================================================================
const DRAFTS = [
{ dir: '执黑先行_0513_01_闭嘴搞钱', topicA: '闭嘴做事·才是狠人', seq: '01' },
{ dir: '执黑先行_0513_02_穷是原罪', topicA: '你只是没钱罢了', seq: '02' },
{ dir: '执黑先行_0513_03_别跪', topicA: '别跪了·你配的上', seq: '03' },
{ dir: '执黑先行_0513_04_阶层密码', topicA: '没人说的·阶层真相', seq: '04' },
{ dir: '执黑先行_0513_05_能量掠夺', topicA: '出轨真相·不是道德', seq: '05' },
{ dir: '执黑先行_0513_06_不凑合', topicA: '不凑合·最好的风水', seq: '06' },
{ dir: '执黑先行_0513_07_真领导', topicA: '跟对人·只管赢', seq: '07' },
{ dir: '执黑先行_0513_10_榨你最狠', topicA: '哭惨的人·榨你最狠', seq: '10' },
]
// 执黑先行 cover style (locked)
const COVER_STYLE = `Dynamic cinematic scene with a powerful figure occupying 60-70% of the frame, semi-realistic Marvel-tone style, dramatic warm orange and cool blue dual-lighting from opposing sides creating tension, dark atmospheric background, cinematic composition with strong focal point, film-grain texture, high contrast, poster-like framing`
// ============================================================================
// Utils
// ============================================================================
function fetch(url, options = {}) {
const method = options.method || 'GET'
const headers = options.headers || {}
const body = options.body
return new Promise((resolve, reject) => {
const u = new URL(url)
const req = https.request({
hostname: u.hostname, port: 443, path: u.pathname + u.search,
method, headers, timeout: 120000,
}, (res) => {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => {
const data = Buffer.concat(chunks)
resolve({ status: res.statusCode, data, json: () => JSON.parse(data.toString()) })
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')) })
if (body) req.write(body)
req.end()
})
}
function getFirstVideoPath(draftDir) {
const contentPath = path.join(draftDir, 'draft_content.json')
const data = JSON.parse(fs.readFileSync(contentPath, 'utf-8'))
const videos = data.materials.videos
const tracks = data.tracks
let firstVidId = null
for (const track of tracks) {
if (track.type === 'video') {
const segs = track.segments || []
if (segs.length > 0) {
firstVidId = segs[0].material_id
break
}
}
}
if (!firstVidId) return null
for (const v of videos) {
if (v.id === firstVidId) return v.path
}
return null
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function gptImageEdit(prompt, imagePath, size, retries = 3) {
const boundary = '----FormBoundary' + Math.random().toString(36).slice(2)
const imgBuf = fs.readFileSync(imagePath)
const imgName = path.basename(imagePath)
const parts = []
const addField = (name, value) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`))
}
const addFile = (name, filename, buf, mime) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`))
parts.push(buf)
parts.push(Buffer.from('\r\n'))
}
const model = cfg.gptImageModel || 'gpt-image-2'
addField('model', model)
addField('prompt', prompt)
if (size) addField('size', size)
addFile('image', imgName, imgBuf, 'image/png')
parts.push(Buffer.from(`--${boundary}--\r\n`))
const body = Buffer.concat(parts)
const baseUrl = cfg.gptImageApiBaseUrl || 'https://yunwu.ai'
const apiKey = cfg.gptImageApiKey || ''
let lastError = null
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) {
const delay = [5, 15, 30][attempt - 1] || 30
console.log(` ⏳ Retry ${attempt}/${retries} after ${delay}s...`)
await sleep(delay * 1000)
}
const res = await fetch(`${baseUrl}/v1/images/edits`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
})
if (res.status === 200) {
return res.json()
}
const errText = res.data.toString().substring(0, 500)
if (res.status === 429) {
console.log(` ⚠️ Rate limited (429), attempt ${attempt + 1}/${retries + 1}`)
lastError = new Error(`GPT Image edit rate limited after ${retries + 1} attempts: ${errText}`)
} else {
throw new Error(`GPT Image edit failed: ${res.status} ${errText}`)
}
}
throw lastError
}
async function download(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest)
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close()
try { fs.unlinkSync(dest) } catch (_) {}
return download(res.headers.location, dest).then(resolve, reject)
}
res.pipe(file)
file.on('finish', () => { file.close(); resolve() })
}).on('error', (err) => { try { fs.unlinkSync(dest) } catch (_) {}; reject(err) })
})
}
async function addTextOverlay(imagePath, text, outputPath) {
const metadata = await sharp(imagePath).metadata()
const { width, height } = metadata
const fontSize = Math.round(width * 0.10)
const parts = text.split('·')
const lines = parts.length === 2 ? parts : [text]
const lineHeight = fontSize * 1.3
const totalHeight = lines.length * lineHeight
const startY = (height - totalHeight) / 2 + fontSize * 0.8
let textElements = ''
lines.forEach((line, i) => {
const y = startY + i * lineHeight
textElements += `<text x="${width / 2}" y="${y}" text-anchor="middle" font-size="${fontSize}" font-family="'PingFang SC', 'Heiti SC', 'STHeiti', 'Arial', sans-serif" font-weight="900" fill="white" stroke="#C8960C" stroke-width="${Math.round(fontSize * 0.06)}" paint-order="stroke fill" letter-spacing="${Math.round(fontSize * 0.05)}">${line}</text>`
})
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<style>
text {
filter: drop-shadow(0 ${Math.round(fontSize * 0.04)} ${Math.round(fontSize * 0.08)} rgba(0,0,0,0.7));
}
</style>
${textElements}
</svg>`
await sharp(imagePath)
.composite([{ input: Buffer.from(svg), top: 0, left: 0 }])
.png({ quality: 95 })
.toFile(outputPath)
}
async function uploadToOSS(filePath, ossName) {
const result = await ossClient.put(ossName, filePath)
// Generate signed URL
const url = await ossClient.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 })
return url
}
// ============================================================================
// Process one draft
// ============================================================================
async function processDraft(draft, index) {
const { dir, topicA, seq } = draft
const draftDir = path.join(DRAFT_BASE, dir)
const coverDir = path.join(OUTPUT_BASE, `covers_${seq}`)
fs.mkdirSync(coverDir, { recursive: true })
console.log(`\n${'='.repeat(60)}`)
console.log(`[${index + 1}/8] ${dir}`)
console.log(`Topic: ${topicA}`)
console.log(`${'='.repeat(60)}`)
// Step 1: Extract first frame
const videoPath = getFirstVideoPath(draftDir)
if (!videoPath || !fs.existsSync(videoPath)) {
throw new Error(`Video not found: ${videoPath}`)
}
const framePath = path.join(coverDir, '_first_frame.png')
console.log(` Extracting first frame from: ${path.basename(videoPath)}`)
execSync(`ffmpeg -y -i "${videoPath}" -vframes 1 -q:v 2 "${framePath}" 2>/dev/null`, { timeout: 10000 })
console.log(` Frame saved: ${framePath}`)
// Step 2: Generate covers in both sizes
const sizes = [
{ label: '抖音', size: '1088x1920', suffix: `cover_${seq}_抖音` },
{ label: '小红书', size: '1152x1536', suffix: `cover_${seq}_小红书` },
]
const results = []
for (const { label, size, suffix } of sizes) {
console.log(`\n 🎨 Generating ${label} cover (${size})...`)
const editPrompt = `Create a vertical social media cover in ${size} aspect ratio. Style reference: ${COVER_STYLE}. Take the visual aesthetic from the reference image (color palette, lighting mood, character style) and recompose it into a tighter, more poster-like cover composition. The composition should have a strong focal point. NO text, NO watermark, NO letters, NO words whatsoever.`
const result = await gptImageEdit(editPrompt, framePath, size)
let coverUrl
if (result.data?.[0]?.url) {
coverUrl = result.data[0].url
} else if (result.data?.[0]?.b64_json) {
const buf = Buffer.from(result.data[0].b64_json, 'base64')
const rawPath = path.join(coverDir, `${suffix}_raw.png`)
fs.writeFileSync(rawPath, buf)
coverUrl = rawPath
} else {
throw new Error(`GPT Image no URL: ${JSON.stringify(result).substring(0, 300)}`)
}
// Download if URL
const coverRawPath = path.join(coverDir, `${suffix}_raw.png`)
if (coverUrl.startsWith('http')) {
console.log(` Downloading generated cover...`)
await download(coverUrl, coverRawPath)
}
// Add text overlay
const coverFinalPath = path.join(coverDir, `${suffix}.png`)
console.log(` Adding text overlay: "${topicA}"`)
await addTextOverlay(coverRawPath, topicA, coverFinalPath)
// Upload to OSS
const ossName = `covers/执黑先行/${suffix}.png`
console.log(` Uploading to OSS...`)
const url = await uploadToOSS(coverFinalPath, ossName)
console.log(`${label}: ${url.substring(0, 80)}...`)
results.push({ label, size, url, localPath: coverFinalPath })
// Small delay between covers to avoid rate limits
await sleep(2000)
}
return results
}
// ============================================================================
// Main
// ============================================================================
async function main() {
const allResults = []
for (let i = 0; i < DRAFTS.length; i++) {
try {
const results = await processDraft(DRAFTS[i], i)
allResults.push({ draft: DRAFTS[i], results, error: null })
// Delay between drafts to avoid rate limits
if (i < DRAFTS.length - 1) {
console.log(`\n ⏸️ Waiting 3s before next draft...`)
await sleep(3000)
}
} catch (err) {
console.error(`\n❌ Failed [${DRAFTS[i].dir}]: ${err.message}`)
allResults.push({ draft: DRAFTS[i], results: [], error: err.message })
}
}
// Summary
console.log(`\n\n${'='.repeat(60)}`)
console.log(`SUMMARY`)
console.log(`${'='.repeat(60)}`)
// Save JSON results
const summaryPath = path.join(OUTPUT_BASE, 'cover_results.json')
fs.writeFileSync(summaryPath, JSON.stringify(allResults, null, 2), 'utf-8')
console.log(`Results saved to: ${summaryPath}`)
// Print table
console.log(`\n| # | 草稿 | 选题 | 抖音封面 | 小红书封面 |`)
console.log(`|---|------|------|---------|-----------|`)
for (const item of allResults) {
const d = item.draft
const douyin = item.results.find(r => r.label === '抖音')
const xhs = item.results.find(r => r.label === '小红书')
const dyUrl = douyin ? douyin.url : (item.error || 'FAILED')
const xhsUrl = xhs ? xhs.url : (item.error || 'FAILED')
console.log(`| ${d.seq} | ${d.dir} | ${d.topicA} | ${dyUrl.substring(0, 60)}... | ${xhsUrl.substring(0, 60)}... |`)
}
// Cleanup temp files
for (const item of allResults) {
if (item.error) continue
// Keep final covers, remove raw/frames
}
console.log(`\nDone. ${allResults.filter(r => !r.error).length}/${DRAFTS.length} succeeded.`)
}
main().catch(err => {
console.error('Batch cover generation failed:', err.message)
process.exit(1)
})