#!/usr/bin/env node /** * GPT Image 封面批量 - edits 图生图(首帧参考) * 当前 API 已恢复,b64_json 返回 */ const fs = require('fs'), path = require('path'), https = require('https'); const { execSync } = require('child_process'); const sharp = require('sharp'); const OSS = require('ali-oss'); const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config.json'), 'utf-8')); const DB = cfg.jianyingDraftPath; const oss = new OSS({ region: cfg.ossRegion, accessKeyId: cfg.ossAccessKeyId, accessKeySecret: cfg.ossAccessKeySecret, bucket: cfg.ossBucket, secure: true }); const AK = cfg.gptImageApiKey; const OUT = '/Users/lc/video-create/output/执黑先行_补封面/gpt_final'; fs.mkdirSync(OUT, { recursive: true }); const LIST = [ { d:'执黑先行_0513_01_闭嘴搞钱', t:'闭嘴做事·才是狠人', s:'01' }, { d:'执黑先行_0513_02_穷是原罪', t:'你只是没钱罢了', s:'02' }, { d:'执黑先行_0513_03_别跪', t:'别跪了·你配的上', s:'03' }, { d:'执黑先行_0513_04_阶层密码', t:'没人说的·阶层真相', s:'04' }, { d:'执黑先行_0513_05_能量掠夺', t:'出轨真相·不是道德', s:'05' }, { d:'执黑先行_0513_06_不凑合', t:'不凑合·最好的风水', s:'06' }, { d:'执黑先行_0513_07_真领导', t:'跟对人·只管赢', s:'07' }, { d:'执黑先行_0513_10_榨你最狠', t:'哭惨的人·榨你最狠', s:'10' }, ]; function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function api(method, urlPath, headers, body) { return new Promise((resolve, reject) => { const u = new URL('https://yunwu.ai' + urlPath); const opts = { hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers, timeout: 120000 }; const req = https.request(opts, (res) => { const c = []; res.on('data', d => c.push(d)); res.on('end', () => { const d = Buffer.concat(c).toString(); resolve({ status: res.statusCode, data: d, json: () => JSON.parse(d) }); }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } async function gptEdit(prompt, imgPath, size, retries=3) { for (let a = 0; a <= retries; a++) { if (a > 0) { console.log(` retry ${a}/${retries} after ${a*5}s...`); await sleep(a*5000); } const boundary = '----FB' + Math.random().toString(36).slice(2); const imgBuf = fs.readFileSync(imgPath); 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: image/png\r\n\r\n`)); parts.push(buf); parts.push(Buffer.from('\r\n')); }; af('model', 'gpt-image-2'); af('prompt', prompt); af('size', size); aff('image', path.basename(imgPath), imgBuf); parts.push(Buffer.from(`--${boundary}--\r\n`)); const body = Buffer.concat(parts); const res = await api('POST', '/v1/images/edits', { 'Authorization': 'Bearer ' + AK, '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 Buffer.from(j.data[0].b64_json, 'base64'); if (j.data?.[0]?.url) return j.data[0].url; } console.log(` ⚠️ ${res.status}: ${res.data.substring(0,100)}`); } throw new Error('GPT edits failed after retries'); } function getFirstVideo(draftDir) { const data = JSON.parse(fs.readFileSync(path.join(draftDir, 'draft_content.json'), 'utf-8')); for (const tr of data.tracks) { if (tr.type === 'video' && tr.segments?.length > 0) for (const v of data.materials.videos) if (v.id === tr.segments[0].material_id) return v.path; } return null; } async function addText(imgPath, text, outPath) { const meta = await sharp(imgPath).metadata(); const { width: w, height: h } = meta; const fs2 = Math.round(w * 0.10); const parts = text.split('·'), lines = parts.length === 2 ? parts : [text]; const lh = fs2 * 1.3, startY = (h - lh * lines.length) / 2 + fs2 * 0.8; let els = ''; lines.forEach((line, i) => { const y = startY + i * lh; els += `${line}`; }); const svg = `${els}`; await sharp(imgPath).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).png({ quality: 95 }).toFile(outPath); } async function doOne(item, idx) { const { d, t, s } = item; const dir = path.join(OUT, s); fs.mkdirSync(dir, { recursive: true }); console.log(`\n${'='.repeat(50)}\n[${idx+1}/8] ${t}\n${'='.repeat(50)}`); const vp = getFirstVideo(path.join(DB, d)); if (!vp) throw new Error('Video not found'); const frame = path.join(dir, '_f.png'); execSync(`ffmpeg -y -i "${vp}" -vframes 1 -q:v 2 "${frame}" 2>/dev/null`, { timeout: 10000 }); console.log(' Frame ready'); const sizes = [ { lb: '抖音', sz: '1088x1920', nm: `cover_${s}_抖音` }, { lb: '小红书', sz: '1152x1536', nm: `cover_${s}_小红书` }, ]; for (const { lb, sz, nm } of sizes) { console.log(` 🎨 ${lb} (${sz})...`); const prompt = `Transform this scene into a tighter poster-like cover composition. Keep the same visual style, color palette, lighting mood and atmosphere. Make it more dramatic with strong focal point, like a movie poster. NO text, NO watermark, NO letters.`; const result = await gptEdit(prompt, frame, sz); const rawPath = path.join(dir, `_${lb}.png`); if (Buffer.isBuffer(result)) { fs.writeFileSync(rawPath, result); } else { // download URL await new Promise((resolve, reject) => { const file = fs.createWriteStream(rawPath); https.get(result, (res) => { res.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', reject); }); } console.log(` GPT done, adding text...`); const final = path.join(dir, `${nm}.png`); await addText(rawPath, t, final); const ossName = `covers/执黑先行/gpt/${nm}.png`; await oss.put(ossName, final); const url = await oss.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 }); console.log(` ✅ ${lb}: ${url.substring(0,80)}...`); try { fs.unlinkSync(rawPath); } catch (_) {} } } async function main() { for (let i = 0; i < LIST.length; i++) { try { await doOne(LIST[i], i); if (i < LIST.length-1) { console.log(' ⏸️ 3s...'); await sleep(3000); } } catch (e) { console.error(`❌ [${LIST[i].s}]: ${e.message}`); } } console.log('\n✅ Done.'); } main().catch(e => { console.error(e.message); process.exit(1); });