#!/usr/bin/env node /** * 封面生成脚本 v2 — GPT 一步出图(文字由 GPT 直接渲染) * 对齐 prompts/封面提示词.md 模板 * * 用法: node gen-covers.js --manifest */ 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 ') 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) })