#!/usr/bin/env node /** * 批量封面生成 - 为执黑先行旧草稿补生成封面 * 从剪映草稿目录提取首帧 → GPT Image edit → Sharp 文字叠加 → OSS 上传 * * 用法: node gen-covers-batch.js */ const fs = require('fs') const path = require('path') const https = require('https') const { execSync } = require('child_process') const sharp = require('sharp') const OSS = require('ali-oss') // ============================================================================ // Config // ============================================================================ const configPath = path.join(__dirname, '..', '..', 'config.json') const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8')) const DRAFT_BASE = cfg.jianyingDraftPath || path.join(require('os').homedir(), 'Movies/JianyingPro/User Data/Projects/com.lveditor.draft') // Output dir for covers const OUTPUT_BASE = path.join(__dirname, '..', '..', '..', 'output', '执黑先行_补封面') fs.mkdirSync(OUTPUT_BASE, { recursive: true }) // OSS client const ossClient = new OSS({ region: cfg.ossRegion, accessKeyId: cfg.ossAccessKeyId, accessKeySecret: cfg.ossAccessKeySecret, bucket: cfg.ossBucket, secure: true, }) // ============================================================================ // 8 drafts to process // ============================================================================ const DRAFTS = [ { dir: '执黑先行_0513_01_闭嘴搞钱', topicA: '闭嘴做事·才是狠人', seq: '01' }, { dir: '执黑先行_0513_02_穷是原罪', topicA: '你只是没钱罢了', seq: '02' }, { dir: '执黑先行_0513_03_别跪', topicA: '别跪了·你配的上', seq: '03' }, { dir: '执黑先行_0513_04_阶层密码', topicA: '没人说的·阶层真相', seq: '04' }, { dir: '执黑先行_0513_05_能量掠夺', topicA: '出轨真相·不是道德', seq: '05' }, { dir: '执黑先行_0513_06_不凑合', topicA: '不凑合·最好的风水', seq: '06' }, { dir: '执黑先行_0513_07_真领导', topicA: '跟对人·只管赢', seq: '07' }, { dir: '执黑先行_0513_10_榨你最狠', topicA: '哭惨的人·榨你最狠', seq: '10' }, ] // 执黑先行 cover style (locked) const COVER_STYLE = `Dynamic cinematic scene with a powerful figure occupying 60-70% of the frame, semi-realistic Marvel-tone style, dramatic warm orange and cool blue dual-lighting from opposing sides creating tension, dark atmospheric background, cinematic composition with strong focal point, film-grain texture, high contrast, poster-like framing` // ============================================================================ // Utils // ============================================================================ function fetch(url, options = {}) { const method = options.method || 'GET' const headers = options.headers || {} const body = options.body return new Promise((resolve, reject) => { const u = new URL(url) const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers, timeout: 120000, }, (res) => { const chunks = [] res.on('data', c => chunks.push(c)) res.on('end', () => { const data = Buffer.concat(chunks) resolve({ status: res.statusCode, data, json: () => JSON.parse(data.toString()) }) }) }) req.on('error', reject) req.on('timeout', () => { req.destroy(); reject(new Error('timeout')) }) if (body) req.write(body) req.end() }) } function getFirstVideoPath(draftDir) { const contentPath = path.join(draftDir, 'draft_content.json') const data = JSON.parse(fs.readFileSync(contentPath, 'utf-8')) const videos = data.materials.videos const tracks = data.tracks let firstVidId = null for (const track of tracks) { if (track.type === 'video') { const segs = track.segments || [] if (segs.length > 0) { firstVidId = segs[0].material_id break } } } if (!firstVidId) return null for (const v of videos) { if (v.id === firstVidId) return v.path } return null } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } async function gptImageEdit(prompt, imagePath, size, retries = 3) { const boundary = '----FormBoundary' + Math.random().toString(36).slice(2) const imgBuf = fs.readFileSync(imagePath) const imgName = path.basename(imagePath) const parts = [] const addField = (name, value) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`)) } const addFile = (name, filename, buf, mime) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`)) parts.push(buf) parts.push(Buffer.from('\r\n')) } const model = cfg.gptImageModel || 'gpt-image-2' addField('model', model) addField('prompt', prompt) if (size) addField('size', size) addFile('image', imgName, imgBuf, 'image/png') parts.push(Buffer.from(`--${boundary}--\r\n`)) const body = Buffer.concat(parts) const baseUrl = cfg.gptImageApiBaseUrl || 'https://yunwu.ai' const apiKey = cfg.gptImageApiKey || '' let lastError = null for (let attempt = 0; attempt <= retries; attempt++) { if (attempt > 0) { const delay = [5, 15, 30][attempt - 1] || 30 console.log(` ⏳ Retry ${attempt}/${retries} after ${delay}s...`) await sleep(delay * 1000) } const res = await fetch(`${baseUrl}/v1/images/edits`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(body.length), }, body, }) if (res.status === 200) { return res.json() } const errText = res.data.toString().substring(0, 500) if (res.status === 429) { console.log(` ⚠️ Rate limited (429), attempt ${attempt + 1}/${retries + 1}`) lastError = new Error(`GPT Image edit rate limited after ${retries + 1} attempts: ${errText}`) } else { throw new Error(`GPT Image edit failed: ${res.status} ${errText}`) } } throw lastError } async 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) }) }) } async function addTextOverlay(imagePath, text, outputPath) { const metadata = await sharp(imagePath).metadata() const { width, height } = metadata const fontSize = Math.round(width * 0.10) const parts = text.split('·') const lines = parts.length === 2 ? parts : [text] const lineHeight = fontSize * 1.3 const totalHeight = lines.length * lineHeight const startY = (height - totalHeight) / 2 + fontSize * 0.8 let textElements = '' lines.forEach((line, i) => { const y = startY + i * lineHeight textElements += `${line}` }) const svg = ` ${textElements} ` await sharp(imagePath) .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) .png({ quality: 95 }) .toFile(outputPath) } async function uploadToOSS(filePath, ossName) { const result = await ossClient.put(ossName, filePath) // Generate signed URL const url = await ossClient.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 }) return url } // ============================================================================ // Process one draft // ============================================================================ async function processDraft(draft, index) { const { dir, topicA, seq } = draft const draftDir = path.join(DRAFT_BASE, dir) const coverDir = path.join(OUTPUT_BASE, `covers_${seq}`) fs.mkdirSync(coverDir, { recursive: true }) console.log(`\n${'='.repeat(60)}`) console.log(`[${index + 1}/8] ${dir}`) console.log(`Topic: ${topicA}`) console.log(`${'='.repeat(60)}`) // Step 1: Extract first frame const videoPath = getFirstVideoPath(draftDir) if (!videoPath || !fs.existsSync(videoPath)) { throw new Error(`Video not found: ${videoPath}`) } const framePath = path.join(coverDir, '_first_frame.png') console.log(` Extracting first frame from: ${path.basename(videoPath)}`) execSync(`ffmpeg -y -i "${videoPath}" -vframes 1 -q:v 2 "${framePath}" 2>/dev/null`, { timeout: 10000 }) console.log(` Frame saved: ${framePath}`) // Step 2: Generate covers in both sizes const sizes = [ { label: '抖音', size: '1088x1920', suffix: `cover_${seq}_抖音` }, { label: '小红书', size: '1152x1536', suffix: `cover_${seq}_小红书` }, ] const results = [] for (const { label, size, suffix } of sizes) { console.log(`\n 🎨 Generating ${label} cover (${size})...`) const editPrompt = `Create a vertical social media cover in ${size} aspect ratio. Style reference: ${COVER_STYLE}. Take the visual aesthetic from the reference image (color palette, lighting mood, character style) and recompose it into a tighter, more poster-like cover composition. The composition should have a strong focal point. NO text, NO watermark, NO letters, NO words whatsoever.` const result = await gptImageEdit(editPrompt, framePath, size) let coverUrl if (result.data?.[0]?.url) { coverUrl = result.data[0].url } else if (result.data?.[0]?.b64_json) { const buf = Buffer.from(result.data[0].b64_json, 'base64') const rawPath = path.join(coverDir, `${suffix}_raw.png`) fs.writeFileSync(rawPath, buf) coverUrl = rawPath } else { throw new Error(`GPT Image no URL: ${JSON.stringify(result).substring(0, 300)}`) } // Download if URL const coverRawPath = path.join(coverDir, `${suffix}_raw.png`) if (coverUrl.startsWith('http')) { console.log(` Downloading generated cover...`) await download(coverUrl, coverRawPath) } // Add text overlay const coverFinalPath = path.join(coverDir, `${suffix}.png`) console.log(` Adding text overlay: "${topicA}"`) await addTextOverlay(coverRawPath, topicA, coverFinalPath) // Upload to OSS const ossName = `covers/执黑先行/${suffix}.png` console.log(` Uploading to OSS...`) const url = await uploadToOSS(coverFinalPath, ossName) console.log(` ✅ ${label}: ${url.substring(0, 80)}...`) results.push({ label, size, url, localPath: coverFinalPath }) // Small delay between covers to avoid rate limits await sleep(2000) } return results } // ============================================================================ // Main // ============================================================================ async function main() { const allResults = [] for (let i = 0; i < DRAFTS.length; i++) { try { const results = await processDraft(DRAFTS[i], i) allResults.push({ draft: DRAFTS[i], results, error: null }) // Delay between drafts to avoid rate limits if (i < DRAFTS.length - 1) { console.log(`\n ⏸️ Waiting 3s before next draft...`) await sleep(3000) } } catch (err) { console.error(`\n❌ Failed [${DRAFTS[i].dir}]: ${err.message}`) allResults.push({ draft: DRAFTS[i], results: [], error: err.message }) } } // Summary console.log(`\n\n${'='.repeat(60)}`) console.log(`SUMMARY`) console.log(`${'='.repeat(60)}`) // Save JSON results const summaryPath = path.join(OUTPUT_BASE, 'cover_results.json') fs.writeFileSync(summaryPath, JSON.stringify(allResults, null, 2), 'utf-8') console.log(`Results saved to: ${summaryPath}`) // Print table console.log(`\n| # | 草稿 | 选题 | 抖音封面 | 小红书封面 |`) console.log(`|---|------|------|---------|-----------|`) for (const item of allResults) { const d = item.draft const douyin = item.results.find(r => r.label === '抖音') const xhs = item.results.find(r => r.label === '小红书') const dyUrl = douyin ? douyin.url : (item.error || 'FAILED') const xhsUrl = xhs ? xhs.url : (item.error || 'FAILED') console.log(`| ${d.seq} | ${d.dir} | ${d.topicA} | ${dyUrl.substring(0, 60)}... | ${xhsUrl.substring(0, 60)}... |`) } // Cleanup temp files for (const item of allResults) { if (item.error) continue // Keep final covers, remove raw/frames } console.log(`\nDone. ${allResults.filter(r => !r.error).length}/${DRAFTS.length} succeeded.`) } main().catch(err => { console.error('Batch cover generation failed:', err.message) process.exit(1) })