Files
video-create/.claude/skills/video-from-script/scripts/gen-covers-direct.js
lc 9cbdabda31 feat: 封面生成流水线、执黑先行二号风格扩展、账号配置更新
- 新增 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>
2026-06-06 16:44:47 +08:00

269 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
})