- 新增 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>
368 lines
13 KiB
JavaScript
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)
|
|
})
|