- 新增 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>
211 lines
8.4 KiB
JavaScript
211 lines
8.4 KiB
JavaScript
#!/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)
|
||
})
|