269 lines
9.6 KiB
JavaScript
269 lines
9.6 KiB
JavaScript
|
|
#!/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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|||
|
|
<defs>
|
|||
|
|
<radialGradient id="vignette" cx="50%" cy="50%" r="50%">
|
|||
|
|
<stop offset="0%" stop-color="black" stop-opacity="0" />
|
|||
|
|
<stop offset="60%" stop-color="black" stop-opacity="0" />
|
|||
|
|
<stop offset="100%" stop-color="black" stop-opacity="0.55" />
|
|||
|
|
</radialGradient>
|
|||
|
|
</defs>
|
|||
|
|
<rect width="${width}" height="${height}" fill="url(#vignette)" />
|
|||
|
|
</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 += `<text x="${width / 2}" y="${y}" text-anchor="middle" font-size="${fontSize}" font-family="'PingFang SC', 'Heiti SC', 'STHeiti', 'Arial', sans-serif" font-weight="900" fill="white" stroke="#C8960C" stroke-width="${Math.round(fontSize * 0.06)}" paint-order="stroke fill" letter-spacing="${Math.round(fontSize * 0.05)}">${line}</text>`
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
|
|||
|
|
<style>
|
|||
|
|
text {
|
|||
|
|
filter: drop-shadow(0 ${Math.round(fontSize * 0.04)} ${Math.round(fontSize * 0.08)} rgba(0,0,0,0.7));
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
${textElements}
|
|||
|
|
</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)
|
|||
|
|
})
|