#!/usr/bin/env node /** * 封面生成 - 首帧 + 文字叠加(API不可用时的回退方案) * 从剪映草稿提取首帧 → 加暗角提升文字可读性 → Sharp 文字叠加 → OSS 上传 * * 用法: node gen-covers-direct.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') const OUTPUT_BASE = path.join(__dirname, '..', '..', '..', 'output', '执黑先行_补封面') fs.mkdirSync(OUTPUT_BASE, { recursive: true }) 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' }, ] // ============================================================================ // Utils // ============================================================================ 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 } async function addVignette(inputPath, outputPath) { const metadata = await sharp(inputPath).metadata() const { width, height } = metadata // Create a radial gradient overlay for vignette effect const centerX = width / 2 const centerY = height / 2 const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY) * 0.85 const svg = ` ` await sharp(inputPath) .composite([{ input: Buffer.from(svg), top: 0, left: 0 }]) .png({ quality: 95 }) .toFile(outputPath) } 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) { await ossClient.put(ossName, filePath) const url = await ossClient.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 }) return url } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } // ============================================================================ // Cover size definitions // ============================================================================ const COVER_SIZES = { '9:16': { label: '抖音', width: 1088, height: 1920 }, '3:4': { label: '小红书', width: 1152, height: 1536 }, } // ============================================================================ // 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: ${path.basename(videoPath)}`) execSync(`ffmpeg -y -i "${videoPath}" -vframes 1 -q:v 2 "${framePath}" 2>/dev/null`, { timeout: 10000 }) // Add vignette const vignettePath = path.join(coverDir, '_vignette.png') console.log(` Adding vignette...`) await addVignette(framePath, vignettePath) const results = [] for (const [ratio, { label, width, height }] of Object.entries(COVER_SIZES)) { console.log(`\n 📐 Generating ${label} cover (${width}x${height})...`) // Resize to cover dimensions (cover/fill mode) const sizedPath = path.join(coverDir, `_sized_${label}.png`) await sharp(vignettePath) .resize(width, height, { fit: 'cover', position: 'center' }) .png({ quality: 95 }) .toFile(sizedPath) // Add text overlay const suffix = `cover_${seq}_${label}` const coverFinalPath = path.join(coverDir, `${suffix}.png`) console.log(` Adding text: "${topicA}"`) await addTextOverlay(sizedPath, topicA, coverFinalPath) // Upload to OSS const ossName = `covers/执黑先行/${suffix}.png` console.log(` Uploading to OSS...`) const url = await uploadToOSS(coverFinalPath, ossName) console.log(` ✅ ${label}: done`) results.push({ label, ratio, width, height, url, localPath: coverFinalPath }) } 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 }) if (i < DRAFTS.length - 1) { console.log(`\n ⏸️ Brief pause...`) await sleep(1000) } } 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)}`) 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}) | [小红书](${xhsUrl}) |`) } const succeeded = allResults.filter(r => !r.error).length console.log(`\nDone. ${succeeded}/${DRAFTS.length} succeeded.`) } main().catch(err => { console.error('Cover generation failed:', err.message) process.exit(1) })