Files
video-create/.claude/skills/video-from-script/scripts/gen-covers.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

211 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 封面生成脚本 v2 — GPT 一步出图(文字由 GPT 直接渲染)
* 对齐 prompts/封面提示词.md 模板
*
* 用法: node gen-covers.js --manifest <path>
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
function loadConfig() {
const configPath = path.join(__dirname, '..', '..', 'config.json')
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
}
const cfg = loadConfig()
// ============================================================================
// 工具
// ============================================================================
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) })
})
}
function api(method, urlPath, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL((cfg.gptImageApiBaseUrl || 'https://yunwu.ai') + urlPath)
const opts = { hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers, timeout: 180000 }
const req = https.request(opts, (res) => {
const c = []; res.on('data', d => c.push(d))
res.on('end', () => { const d = Buffer.concat(c); resolve({ status: res.statusCode, data: d, json: () => JSON.parse(d.toString()) }) })
})
req.on('error', reject)
if (body) req.write(body)
req.end()
})
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
// ============================================================================
// GPT Image edits带重试 + b64_json 支持)
// ============================================================================
async function gptImageEdit(prompt, imagePath, size, retries = 3) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { console.log(` GPT 重试 ${a}/${retries}${a * 10}s 后)...`); await sleep(a * 10000) }
try {
const boundary = '----FB' + Math.random().toString(36).slice(2)
const imgBuf = fs.readFileSync(imagePath)
const imgExt = path.extname(imagePath).toLowerCase()
const mime = imgExt === '.jpg' || imgExt === '.jpeg' ? 'image/jpeg' : 'image/png'
const imgName = path.basename(imagePath)
const parts = []
const af = (n, v) => parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`))
const aff = (n, fn, buf) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"; filename="${fn}"\r\nContent-Type: ${mime}\r\n\r\n`)); parts.push(buf); parts.push(Buffer.from('\r\n')) }
af('model', cfg.gptImageModel || 'gpt-image-2')
af('prompt', prompt)
af('size', size)
aff('image', imgName, imgBuf)
parts.push(Buffer.from(`--${boundary}--\r\n`))
const body = Buffer.concat(parts)
const key = cfg.gptImageApiKey || ''
const res = await api('POST', '/v1/images/edits', {
'Authorization': 'Bearer ' + key,
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': String(body.length),
}, body)
if (res.status === 200) {
const j = res.json()
if (j.data?.[0]?.b64_json) return { b64: Buffer.from(j.data[0].b64_json, 'base64') }
if (j.data?.[0]?.url) return { url: j.data[0].url }
throw new Error('GPT 未返回图片')
}
console.log(` ⚠️ GPT ${res.status}: ${res.data.toString().substring(0, 100)}`)
if (res.status >= 500 || res.status === 429) continue
} catch (e) {
console.log(` ⚠️ 网络: ${e.message}`)
}
}
throw new Error('GPT edits 重试耗尽')
}
// ============================================================================
// 封面提示词构建
// ============================================================================
function buildEditPrompt(topicA, styleDesc, ratio) {
const parts = topicA.split('·')
const line1 = parts[0]
const line2 = parts.length === 2 ? parts[1] : ''
const textPart = line2
? `At the center of the image, prominently display the Chinese text "${line1}" on the first line and "${line2}" on the second line, in very large bold white characters with a thick golden metallic outline, like a movie poster title. The text should be the dominant visual element, taking up most of the frame.`
: `At the center of the image, prominently display the Chinese text "${line1}" in very large bold white characters with a thick golden metallic outline, like a movie poster title. The text should be the dominant visual element, taking up most of the frame.`
return `Create a vertical ${ratio} social media cover poster. Keep the EXACT same art style, color palette, lighting, atmosphere and aesthetic as the reference image: ${styleDesc} The composition should be tighter and more poster-like with a strong focal point. ${textPart}`
}
// ============================================================================
// OSS 上传
// ============================================================================
async function uploadToOSS(filePath, ossName) {
const ossUpload = require(path.join(__dirname, 'oss-upload'))
const { url } = await ossUpload.uploadFile(filePath, { name: ossName })
return url
}
// ============================================================================
// 主流程
// ============================================================================
async function main() {
const args = process.argv.slice(2)
const mi = args.indexOf('--manifest')
if (mi === -1 || !args[mi + 1]) {
console.error('用法: node gen-covers.js --manifest <path>')
process.exit(1)
}
const manifestPath = args[mi + 1]
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const inputDir = path.dirname(manifestPath)
const coversDir = path.join(inputDir, 'covers')
fs.mkdirSync(coversDir, { recursive: true })
const topicA = manifest.topicA
if (!topicA) { console.error('manifest 缺少 topicA'); process.exit(1) }
const firstItem = manifest.items[0]
if (!firstItem || !firstItem.url) { console.error('没有可用的图片 URL'); process.exit(1) }
// 风格描述 = imagePrompt 第一句
const imagePrompt = firstItem.imagePrompt
const styleDesc = imagePrompt.split('。')[0] + '。'
console.log(`封面: ${topicA}`)
console.log(`风格: ${styleDesc.substring(0, 80)}...`)
// 下载参考图
const refPath = path.join(coversDir, '_ref.png')
console.log('下载参考图...')
await download(firstItem.url, refPath)
const dirName = path.basename(inputDir)
const seqNum = dirName.split('_').pop()
const sizes = [
{ label: '抖音', ratio: '9:16', size: '1088x1920', suffix: `cover_${String(seqNum).padStart(3, '0')}_抖音` },
{ label: '小红书', ratio: '3:4', size: '1152x1536', suffix: `cover_${String(seqNum).padStart(3, '0')}_小红书` },
]
for (const { label, ratio, size, suffix } of sizes) {
console.log(`\n生成 ${label} 封面 (${size})...`)
// 按模板构建封面提示词(含文字)
const editPrompt = buildEditPrompt(topicA, styleDesc, ratio)
// GPT 一步出图
const result = await gptImageEdit(editPrompt, refPath, size)
const coverFinalPath = path.join(coversDir, `${suffix}.png`)
if (result.b64) {
fs.writeFileSync(coverFinalPath, result.b64)
} else if (result.url) {
console.log('下载生成图...')
await download(result.url, coverFinalPath)
}
// OSS 上传
const ossName = `covers/${dirName}/${suffix}.png`
console.log('上传 OSS...')
const url = await uploadToOSS(coverFinalPath, ossName)
if (label === '抖音') manifest.coverUrl_抖音 = url
else manifest.coverUrl_小红书 = url
console.log(`${label}`)
}
// 回写 manifest
manifest.pipeline = manifest.pipeline || {}
manifest.pipeline.phases = manifest.pipeline.phases || {}
manifest.pipeline.phases.cover = 'done'
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
console.log('\n封面已写入 manifest')
try { fs.unlinkSync(refPath) } catch (_) {}
}
main().catch(err => {
console.error('封面生成失败:', err.message)
process.exit(1)
})