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)
|
|||
|
|
})
|