Files
video-create/.claude/skills/video-from-script/scripts/gen-covers.js

211 lines
8.4 KiB
JavaScript
Raw Normal View History

#!/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)
})