- 新增 gen-covers 系列脚本(kling/gpt/t2i/batch/direct/final等方案) - 执黑先行二号添加9种风格提示词目录(梦核/剪纸/水墨/毛毡/硬核线条等) - 执黑先行添加封面提示词、执黑先行二号更新图片提示词 - product_viral_factory 账号配置扩充并添加 cover_template 参考图 - capcut_assemble/kling-video-generator/oss-upload/poll-utils 细节修复 - CLAUDE.md 更新流程文档 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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)
|
||
})
|