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