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>
This commit is contained in:
lc
2026-06-06 16:44:47 +08:00
parent 1e110219ff
commit 9cbdabda31
71 changed files with 2444 additions and 119 deletions

View File

@@ -38,12 +38,14 @@ const { syncDraft, registerDraft } = require('./sync-to-jianying')
const ossUpload = require(path.join(__dirname, 'oss-upload'))
async function uploadToOSS(filePath) {
const { url } = await ossUpload.uploadFile(filePath)
async function uploadToOSS(filePath, ossName) {
const opts = ossName ? { name: ossName } : {}
const { url } = await ossUpload.uploadFile(filePath, opts)
return url
}
async function batchUploadToOSS(inputDir, files, concurrency = 3) {
const dirPrefix = path.basename(inputDir) + '_'
const urls = {}
const queue = [...files]
const workers = Array(Math.min(concurrency, queue.length)).fill(null).map(async () => {
@@ -52,11 +54,12 @@ async function batchUploadToOSS(inputDir, files, concurrency = 3) {
if (!file) break
const filePath = path.join(inputDir, file)
if (!fs.existsSync(filePath)) continue
const ossName = dirPrefix + path.basename(filePath)
try {
urls[file] = await uploadToOSS(filePath)
console.log(` 上传: ${file} -> OK`)
urls[file] = await uploadToOSS(filePath, ossName)
console.log(` 上传: ${ossName} -> OK`)
} catch (err) {
console.error(` 上传失败: ${file} - ${err.message}`)
console.error(` 上传失败: ${ossName} - ${err.message}`)
}
}
})
@@ -65,6 +68,7 @@ async function batchUploadToOSS(inputDir, files, concurrency = 3) {
}
async function batchUploadAudio(inputDir, items) {
const dirPrefix = path.basename(inputDir) + '_'
const urls = {}
for (const item of items) {
// 处理主音频
@@ -74,11 +78,12 @@ async function batchUploadAudio(inputDir, items) {
? item.audio
: path.resolve(inputDir, item.audio)
if (fs.existsSync(filePath)) {
const ossName = dirPrefix + path.basename(filePath)
try {
urls[item.audio] = await uploadToOSS(filePath)
console.log(` 上传: ${path.basename(filePath)} -> OK`)
urls[item.audio] = await uploadToOSS(filePath, ossName)
console.log(` 上传: ${ossName} -> OK`)
} catch (err) {
console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`)
console.error(` 上传失败: ${ossName} - ${err.message}`)
}
}
}
@@ -97,11 +102,12 @@ async function batchUploadAudio(inputDir, items) {
console.error(` 音频文件不存在: ${filePath}`)
continue
}
const ossName = dirPrefix + path.basename(filePath)
try {
urls[seg.audio] = await uploadToOSS(filePath)
console.log(` 上传: ${path.basename(filePath)} -> OK`)
urls[seg.audio] = await uploadToOSS(filePath, ossName)
console.log(` 上传: ${ossName} -> OK`)
} catch (err) {
console.error(` 上传失败: ${path.basename(filePath)} - ${err.message}`)
console.error(` 上传失败: ${ossName} - ${err.message}`)
}
}
}

View File

@@ -0,0 +1,367 @@
#!/usr/bin/env node
/**
* 批量封面生成 - 为执黑先行旧草稿补生成封面
* 从剪映草稿目录提取首帧 → GPT Image edit → Sharp 文字叠加 → OSS 上传
*
* 用法: node gen-covers-batch.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')
// Output dir for covers
const OUTPUT_BASE = path.join(__dirname, '..', '..', '..', 'output', '执黑先行_补封面')
fs.mkdirSync(OUTPUT_BASE, { recursive: true })
// OSS client
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' },
]
// 执黑先行 cover style (locked)
const COVER_STYLE = `Dynamic cinematic scene with a powerful figure occupying 60-70% of the frame, semi-realistic Marvel-tone style, dramatic warm orange and cool blue dual-lighting from opposing sides creating tension, dark atmospheric background, cinematic composition with strong focal point, film-grain texture, high contrast, poster-like framing`
// ============================================================================
// Utils
// ============================================================================
function fetch(url, options = {}) {
const method = options.method || 'GET'
const headers = options.headers || {}
const body = options.body
return new Promise((resolve, reject) => {
const u = new URL(url)
const req = https.request({
hostname: u.hostname, port: 443, path: u.pathname + u.search,
method, headers, timeout: 120000,
}, (res) => {
const chunks = []
res.on('data', c => chunks.push(c))
res.on('end', () => {
const data = Buffer.concat(chunks)
resolve({ status: res.statusCode, data, json: () => JSON.parse(data.toString()) })
})
})
req.on('error', reject)
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')) })
if (body) req.write(body)
req.end()
})
}
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
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function gptImageEdit(prompt, imagePath, size, retries = 3) {
const boundary = '----FormBoundary' + Math.random().toString(36).slice(2)
const imgBuf = fs.readFileSync(imagePath)
const imgName = path.basename(imagePath)
const parts = []
const addField = (name, value) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`))
}
const addFile = (name, filename, buf, mime) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`))
parts.push(buf)
parts.push(Buffer.from('\r\n'))
}
const model = cfg.gptImageModel || 'gpt-image-2'
addField('model', model)
addField('prompt', prompt)
if (size) addField('size', size)
addFile('image', imgName, imgBuf, 'image/png')
parts.push(Buffer.from(`--${boundary}--\r\n`))
const body = Buffer.concat(parts)
const baseUrl = cfg.gptImageApiBaseUrl || 'https://yunwu.ai'
const apiKey = cfg.gptImageApiKey || ''
let lastError = null
for (let attempt = 0; attempt <= retries; attempt++) {
if (attempt > 0) {
const delay = [5, 15, 30][attempt - 1] || 30
console.log(` ⏳ Retry ${attempt}/${retries} after ${delay}s...`)
await sleep(delay * 1000)
}
const res = await fetch(`${baseUrl}/v1/images/edits`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
})
if (res.status === 200) {
return res.json()
}
const errText = res.data.toString().substring(0, 500)
if (res.status === 429) {
console.log(` ⚠️ Rate limited (429), attempt ${attempt + 1}/${retries + 1}`)
lastError = new Error(`GPT Image edit rate limited after ${retries + 1} attempts: ${errText}`)
} else {
throw new Error(`GPT Image edit failed: ${res.status} ${errText}`)
}
}
throw lastError
}
async function download(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest)
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close()
try { fs.unlinkSync(dest) } catch (_) {}
return download(res.headers.location, dest).then(resolve, reject)
}
res.pipe(file)
file.on('finish', () => { file.close(); resolve() })
}).on('error', (err) => { try { fs.unlinkSync(dest) } catch (_) {}; reject(err) })
})
}
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) {
const result = await ossClient.put(ossName, filePath)
// Generate signed URL
const url = await ossClient.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 })
return url
}
// ============================================================================
// 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 from: ${path.basename(videoPath)}`)
execSync(`ffmpeg -y -i "${videoPath}" -vframes 1 -q:v 2 "${framePath}" 2>/dev/null`, { timeout: 10000 })
console.log(` Frame saved: ${framePath}`)
// Step 2: Generate covers in both sizes
const sizes = [
{ label: '抖音', size: '1088x1920', suffix: `cover_${seq}_抖音` },
{ label: '小红书', size: '1152x1536', suffix: `cover_${seq}_小红书` },
]
const results = []
for (const { label, size, suffix } of sizes) {
console.log(`\n 🎨 Generating ${label} cover (${size})...`)
const editPrompt = `Create a vertical social media cover in ${size} aspect ratio. Style reference: ${COVER_STYLE}. Take the visual aesthetic from the reference image (color palette, lighting mood, character style) and recompose it into a tighter, more poster-like cover composition. The composition should have a strong focal point. NO text, NO watermark, NO letters, NO words whatsoever.`
const result = await gptImageEdit(editPrompt, framePath, size)
let coverUrl
if (result.data?.[0]?.url) {
coverUrl = result.data[0].url
} else if (result.data?.[0]?.b64_json) {
const buf = Buffer.from(result.data[0].b64_json, 'base64')
const rawPath = path.join(coverDir, `${suffix}_raw.png`)
fs.writeFileSync(rawPath, buf)
coverUrl = rawPath
} else {
throw new Error(`GPT Image no URL: ${JSON.stringify(result).substring(0, 300)}`)
}
// Download if URL
const coverRawPath = path.join(coverDir, `${suffix}_raw.png`)
if (coverUrl.startsWith('http')) {
console.log(` Downloading generated cover...`)
await download(coverUrl, coverRawPath)
}
// Add text overlay
const coverFinalPath = path.join(coverDir, `${suffix}.png`)
console.log(` Adding text overlay: "${topicA}"`)
await addTextOverlay(coverRawPath, topicA, coverFinalPath)
// Upload to OSS
const ossName = `covers/执黑先行/${suffix}.png`
console.log(` Uploading to OSS...`)
const url = await uploadToOSS(coverFinalPath, ossName)
console.log(`${label}: ${url.substring(0, 80)}...`)
results.push({ label, size, url, localPath: coverFinalPath })
// Small delay between covers to avoid rate limits
await sleep(2000)
}
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 })
// Delay between drafts to avoid rate limits
if (i < DRAFTS.length - 1) {
console.log(`\n ⏸️ Waiting 3s before next draft...`)
await sleep(3000)
}
} 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)}`)
// Save JSON results
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.substring(0, 60)}... | ${xhsUrl.substring(0, 60)}... |`)
}
// Cleanup temp files
for (const item of allResults) {
if (item.error) continue
// Keep final covers, remove raw/frames
}
console.log(`\nDone. ${allResults.filter(r => !r.error).length}/${DRAFTS.length} succeeded.`)
}
main().catch(err => {
console.error('Batch cover generation failed:', err.message)
process.exit(1)
})

View File

@@ -0,0 +1,268 @@
#!/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)
})

View File

@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* 封面生成 - 按标准流程:首帧提取 → GPT Image edits(图生图) → Sharp文字 → OSS上传
* 对齐 gen-covers.js 逻辑,适配无 manifest 旧草稿
*/
const fs = require('fs'), path = require('path'), https = require('https');
const { execSync } = require('child_process');
const sharp = require('sharp');
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config.json'), 'utf-8'));
const DB = cfg.jianyingDraftPath;
const AK = cfg.gptImageApiKey;
const OUT = '/Users/lc/video-create/output/执黑先行_补封面/final';
fs.mkdirSync(OUT, { recursive: true });
const LIST = [
{ d:'执黑先行_0513_01_闭嘴搞钱', t:'闭嘴做事·才是狠人', s:'01' },
{ d:'执黑先行_0513_02_穷是原罪', t:'你只是没钱罢了', s:'02' },
{ d:'执黑先行_0513_03_别跪', t:'别跪了·你配的上', s:'03' },
{ d:'执黑先行_0513_04_阶层密码', t:'没人说的·阶层真相', s:'04' },
{ d:'执黑先行_0513_05_能量掠夺', t:'出轨真相·不是道德', s:'05' },
{ d:'执黑先行_0513_06_不凑合', t:'不凑合·最好的风水', s:'06' },
{ d:'执黑先行_0513_07_真领导', t:'跟对人·只管赢', s:'07' },
{ d:'执黑先行_0513_10_榨你最狠', t:'哭惨的人·榨你最狠', s:'10' },
];
// 执黑先行锁定封面风格
const STYLE = 'semi-realistic Marvel-tone cinematic style, warm orange and cool blue dual-lighting from opposing sides, dark atmospheric background, dramatic film-grain texture, high contrast poster composition';
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// GPT Image edits with retry (图生图)
async function gptEdit(prompt, imgPath, size, retries=4) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { const d = a * 10; console.log(` retry ${a}/${retries} in ${d}s...`); await sleep(d * 1000); }
try {
const boundary = '----FB' + Math.random().toString(36).slice(2);
const imgBuf = fs.readFileSync(imgPath);
const parts = [];
const af = (n, v) => parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`));
const aff = (n, fn, buf) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"; filename="${fn}"\r\nContent-Type: image/png\r\n\r\n`)); parts.push(buf); parts.push(Buffer.from('\r\n')); };
af('model', 'gpt-image-2'); af('prompt', prompt); af('size', size);
aff('image', path.basename(imgPath), imgBuf);
parts.push(Buffer.from(`--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await new Promise((resolve, reject) => {
const req = https.request({
hostname: 'yunwu.ai', port: 443, path: '/v1/images/edits', method: 'POST',
headers: { 'Authorization': 'Bearer ' + AK, 'Content-Type': 'multipart/form-data; boundary=' + boundary, 'Content-Length': String(body.length) },
timeout: 180000,
}, (res) => { const c = []; res.on('data', d => c.push(d)); res.on('end', () => resolve({ s: res.statusCode, d: Buffer.concat(c).toString() })); });
req.on('error', reject);
req.write(body); req.end();
});
if (res.s === 200) {
const j = JSON.parse(res.d);
if (j.data?.[0]?.b64_json) return Buffer.from(j.data[0].b64_json, 'base64');
if (j.data?.[0]?.url) return j.data[0].url;
throw new Error('No image in response');
}
console.log(` ⚠️ ${res.s}: ${res.d.substring(0,100)}`);
if (res.s >= 500 || res.s === 429) continue;
} catch (e) {
console.log(` ⚠️ ${e.message}`);
}
}
throw new Error('GPT edits exhausted');
}
// Sharp text overlay (matching gen-covers.js exactly)
async function addText(imgPath, text, outPath) {
const meta = await sharp(imgPath).metadata();
const { width: w, height: h } = meta;
const fs2 = Math.round(w * 0.10);
const parts = text.split('·'), lines = parts.length === 2 ? parts : [text];
const lh = fs2 * 1.3, startY = (h - lh * lines.length) / 2 + fs2 * 0.8;
let els = '';
lines.forEach((line, i) => {
const y = startY + i * lh;
els += `<text x="${w/2}" y="${y}" text-anchor="middle" font-size="${fs2}" font-family="'PingFang SC','Heiti SC','STHeiti','Arial',sans-serif" font-weight="900" fill="white" stroke="#C8960C" stroke-width="${Math.round(fs2*0.06)}" paint-order="stroke fill" letter-spacing="${Math.round(fs2*0.05)}">${line}</text>`;
});
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><style>text{filter:drop-shadow(0 ${Math.round(fs2*0.04)} ${Math.round(fs2*0.08)} rgba(0,0,0,0.7))}</style>${els}</svg>`;
await sharp(imgPath).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).png({ quality: 95 }).toFile(outPath);
}
// OSS upload with retry and fresh client each time (fix keepalive timeout)
async function uploadOSS(filePath, ossName, retries=3) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { console.log(` OSS retry ${a}/${retries}...`); await sleep(3000); }
try {
const OSS = require('ali-oss');
const client = new OSS({
region: cfg.ossRegion, accessKeyId: cfg.ossAccessKeyId, accessKeySecret: cfg.ossAccessKeySecret,
bucket: cfg.ossBucket, secure: true,
timeout: 60000,
});
await client.put(ossName, filePath, { timeout: 60000 });
return await client.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 });
} catch (e) {
console.log(` ⚠️ OSS: ${e.message?.substring(0,80)}`);
}
}
throw new Error('OSS upload exhausted');
}
function getFirstVideo(draftDir) {
const data = JSON.parse(fs.readFileSync(path.join(draftDir, 'draft_content.json'), 'utf-8'));
for (const tr of data.tracks) {
if (tr.type === 'video' && tr.segments?.length > 0)
for (const v of data.materials.videos) if (v.id === tr.segments[0].material_id) return v.path;
}
return null;
}
async function doOne(item, idx) {
const { d, t, s } = item;
const dir = path.join(OUT, s); fs.mkdirSync(dir, { recursive: true });
console.log(`\n${'='.repeat(50)}\n[${idx+1}/8] ${t}\n${'='.repeat(50)}`);
// Extract first frame
const vp = getFirstVideo(path.join(DB, d));
if (!vp) throw new Error('Video not found');
const frame = path.join(dir, '_frame.png');
execSync(`ffmpeg -y -i "${vp}" -vframes 1 -q:v 2 "${frame}" 2>/dev/null`, { timeout: 10000 });
console.log(' Frame ready');
const sizes = [
{ lb: '抖音', sz: '1088x1920', nm: `cover_${s}_抖音` },
{ lb: '小红书', sz: '1152x1536', nm: `cover_${s}_小红书` },
];
for (const { lb, sz, nm } of sizes) {
console.log(` 🎨 ${lb} (${sz})...`);
// Edit prompt matching gen-covers.js logic: keep style, posterize
const prompt = `Create a vertical social media cover. Keep the EXACT same art style, color palette, lighting, atmosphere and aesthetic as the reference image: ${STYLE}. Recompose into a tighter more poster-like composition with a strong focal point. NO text, NO watermark, NO letters, NO words whatsoever.`;
const result = await gptEdit(prompt, frame, sz);
const rawPath = path.join(dir, `_raw_${lb}.png`);
if (Buffer.isBuffer(result)) {
fs.writeFileSync(rawPath, result);
} else {
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(rawPath);
https.get(result, (res) => { res.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', reject);
});
}
console.log(` GPT done, adding text...`);
const final = path.join(dir, `${nm}.png`);
await addText(rawPath, t, final);
const ossName = `covers/执黑先行/${nm}.png`;
const url = await uploadOSS(final, ossName);
console.log(`${lb}`);
try { fs.unlinkSync(rawPath); } catch (_) {}
}
}
async function main() {
for (let i = 0; i < LIST.length; i++) {
try { await doOne(LIST[i], i); if (i < LIST.length-1) { console.log(' ⏸️ 3s...'); await sleep(3000); } }
catch (e) { console.error(`❌ [${LIST[i].s} ${LIST[i].t}]: ${e.message}`); }
}
console.log('\nDone.');
}
main().catch(e => { console.error(e.message); process.exit(1); });

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* GPT Image 封面批量 - edits 图生图(首帧参考)
* 当前 API 已恢复b64_json 返回
*/
const fs = require('fs'), path = require('path'), https = require('https');
const { execSync } = require('child_process');
const sharp = require('sharp');
const OSS = require('ali-oss');
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config.json'), 'utf-8'));
const DB = cfg.jianyingDraftPath;
const oss = new OSS({ region: cfg.ossRegion, accessKeyId: cfg.ossAccessKeyId, accessKeySecret: cfg.ossAccessKeySecret, bucket: cfg.ossBucket, secure: true });
const AK = cfg.gptImageApiKey;
const OUT = '/Users/lc/video-create/output/执黑先行_补封面/gpt_final';
fs.mkdirSync(OUT, { recursive: true });
const LIST = [
{ d:'执黑先行_0513_01_闭嘴搞钱', t:'闭嘴做事·才是狠人', s:'01' },
{ d:'执黑先行_0513_02_穷是原罪', t:'你只是没钱罢了', s:'02' },
{ d:'执黑先行_0513_03_别跪', t:'别跪了·你配的上', s:'03' },
{ d:'执黑先行_0513_04_阶层密码', t:'没人说的·阶层真相', s:'04' },
{ d:'执黑先行_0513_05_能量掠夺', t:'出轨真相·不是道德', s:'05' },
{ d:'执黑先行_0513_06_不凑合', t:'不凑合·最好的风水', s:'06' },
{ d:'执黑先行_0513_07_真领导', t:'跟对人·只管赢', s:'07' },
{ d:'执黑先行_0513_10_榨你最狠', t:'哭惨的人·榨你最狠', s:'10' },
];
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function api(method, urlPath, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL('https://yunwu.ai' + urlPath);
const opts = { hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers, timeout: 120000 };
const req = https.request(opts, (res) => {
const c = []; res.on('data', d => c.push(d));
res.on('end', () => {
const d = Buffer.concat(c).toString();
resolve({ status: res.statusCode, data: d, json: () => JSON.parse(d) });
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
async function gptEdit(prompt, imgPath, size, retries=3) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { console.log(` retry ${a}/${retries} after ${a*5}s...`); await sleep(a*5000); }
const boundary = '----FB' + Math.random().toString(36).slice(2);
const imgBuf = fs.readFileSync(imgPath);
const parts = [];
const af = (n, v) => parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`));
const aff = (n, fn, buf) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"; filename="${fn}"\r\nContent-Type: image/png\r\n\r\n`)); parts.push(buf); parts.push(Buffer.from('\r\n')); };
af('model', 'gpt-image-2'); af('prompt', prompt); af('size', size);
aff('image', path.basename(imgPath), imgBuf);
parts.push(Buffer.from(`--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await api('POST', '/v1/images/edits', {
'Authorization': 'Bearer ' + AK, 'Content-Type': 'multipart/form-data; boundary=' + boundary, 'Content-Length': String(body.length),
}, body);
if (res.status === 200) {
const j = res.json();
if (j.data?.[0]?.b64_json) return Buffer.from(j.data[0].b64_json, 'base64');
if (j.data?.[0]?.url) return j.data[0].url;
}
console.log(` ⚠️ ${res.status}: ${res.data.substring(0,100)}`);
}
throw new Error('GPT edits failed after retries');
}
function getFirstVideo(draftDir) {
const data = JSON.parse(fs.readFileSync(path.join(draftDir, 'draft_content.json'), 'utf-8'));
for (const tr of data.tracks) {
if (tr.type === 'video' && tr.segments?.length > 0)
for (const v of data.materials.videos) if (v.id === tr.segments[0].material_id) return v.path;
}
return null;
}
async function addText(imgPath, text, outPath) {
const meta = await sharp(imgPath).metadata();
const { width: w, height: h } = meta;
const fs2 = Math.round(w * 0.10);
const parts = text.split('·'), lines = parts.length === 2 ? parts : [text];
const lh = fs2 * 1.3, startY = (h - lh * lines.length) / 2 + fs2 * 0.8;
let els = '';
lines.forEach((line, i) => {
const y = startY + i * lh;
els += `<text x="${w/2}" y="${y}" text-anchor="middle" font-size="${fs2}" font-family="'PingFang SC','Heiti SC','STHeiti','Arial',sans-serif" font-weight="900" fill="white" stroke="#C8960C" stroke-width="${Math.round(fs2*0.06)}" paint-order="stroke fill" letter-spacing="${Math.round(fs2*0.05)}">${line}</text>`;
});
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><style>text{filter:drop-shadow(0 ${Math.round(fs2*0.04)} ${Math.round(fs2*0.08)} rgba(0,0,0,0.7))}</style>${els}</svg>`;
await sharp(imgPath).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).png({ quality: 95 }).toFile(outPath);
}
async function doOne(item, idx) {
const { d, t, s } = item;
const dir = path.join(OUT, s); fs.mkdirSync(dir, { recursive: true });
console.log(`\n${'='.repeat(50)}\n[${idx+1}/8] ${t}\n${'='.repeat(50)}`);
const vp = getFirstVideo(path.join(DB, d));
if (!vp) throw new Error('Video not found');
const frame = path.join(dir, '_f.png');
execSync(`ffmpeg -y -i "${vp}" -vframes 1 -q:v 2 "${frame}" 2>/dev/null`, { timeout: 10000 });
console.log(' Frame ready');
const sizes = [
{ lb: '抖音', sz: '1088x1920', nm: `cover_${s}_抖音` },
{ lb: '小红书', sz: '1152x1536', nm: `cover_${s}_小红书` },
];
for (const { lb, sz, nm } of sizes) {
console.log(` 🎨 ${lb} (${sz})...`);
const prompt = `Transform this scene into a tighter poster-like cover composition. Keep the same visual style, color palette, lighting mood and atmosphere. Make it more dramatic with strong focal point, like a movie poster. NO text, NO watermark, NO letters.`;
const result = await gptEdit(prompt, frame, sz);
const rawPath = path.join(dir, `_${lb}.png`);
if (Buffer.isBuffer(result)) {
fs.writeFileSync(rawPath, result);
} else {
// download URL
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(rawPath);
https.get(result, (res) => { res.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', reject);
});
}
console.log(` GPT done, adding text...`);
const final = path.join(dir, `${nm}.png`);
await addText(rawPath, t, final);
const ossName = `covers/执黑先行/gpt/${nm}.png`;
await oss.put(ossName, final);
const url = await oss.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 });
console.log(`${lb}: ${url.substring(0,80)}...`);
try { fs.unlinkSync(rawPath); } catch (_) {}
}
}
async function main() {
for (let i = 0; i < LIST.length; i++) {
try { await doOne(LIST[i], i); if (i < LIST.length-1) { console.log(' ⏸️ 3s...'); await sleep(3000); } }
catch (e) { console.error(`❌ [${LIST[i].s}]: ${e.message}`); }
}
console.log('\n✅ Done.');
}
main().catch(e => { console.error(e.message); process.exit(1); });

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env node
/**
* Kling 封面批量生成 - 云雾代理版
* 首帧上传OSS → Kling图生图 → 轮询 → 下载 → Sharp文字 → OSS上传
*/
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');
const configPath = path.join(__dirname, '..', '..', 'config.json');
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
const DRAFT_BASE = cfg.jianyingDraftPath;
const oss = new OSS({ region: cfg.ossRegion, accessKeyId: cfg.ossAccessKeyId, accessKeySecret: cfg.ossAccessKeySecret, bucket: cfg.ossBucket, secure: true });
const YUNWU_KEY = cfg.kelingYunwuApiKey;
const YUNWU_BASE = 'https://api.yunwu.ai/kling';
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' },
];
const OUT = '/Users/lc/video-create/output/执黑先行_补封面/kling_covers';
fs.mkdirSync(OUT, { recursive: true });
// =========== utils ===========
function api(method, urlPath, body) {
return new Promise((resolve, reject) => {
const u = new URL(YUNWU_BASE + urlPath);
const headers = { 'Authorization': 'Bearer ' + YUNWU_KEY, 'Content-Type': 'application/json' };
const payload = body ? JSON.stringify(body) : null;
if (payload) headers['Content-Length'] = String(Buffer.byteLength(payload));
const req = https.request({ hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers, timeout: 60000 }, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const d = Buffer.concat(chunks).toString();
try { resolve({ status: res.statusCode, data: JSON.parse(d) }); }
catch (_) { reject(new Error('Parse error: ' + d.substring(0, 200))); }
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
function dl(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest);
const proto = url.startsWith('https') ? https : require('http');
proto.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close(); try { fs.unlinkSync(dest); } catch (_) {}
return dl(res.headers.location, dest).then(resolve, reject);
}
res.pipe(file);
file.on('finish', () => { file.close(); resolve(); });
}).on('error', (e) => { try { fs.unlinkSync(dest); } catch (_) {}; reject(e); });
});
}
function getFirstVideo(draftDir) {
const data = JSON.parse(fs.readFileSync(path.join(draftDir, 'draft_content.json'), 'utf-8'));
for (const track of data.tracks) {
if (track.type === 'video' && track.segments?.length > 0) {
for (const v of data.materials.videos) if (v.id === track.segments[0].material_id) return v.path;
}
}
return null;
}
async function addText(imgPath, text, outPath) {
const meta = await sharp(imgPath).metadata();
const { width: w, height: h } = meta;
const fs2 = Math.round(w * 0.10);
const parts = text.split('·');
const lines = parts.length === 2 ? parts : [text];
const lh = fs2 * 1.3;
const startY = (h - lh * lines.length) / 2 + fs2 * 0.8;
let els = '';
lines.forEach((line, i) => {
const y = startY + i * lh;
els += `<text x="${w/2}" y="${y}" text-anchor="middle" font-size="${fs2}" font-family="'PingFang SC','Heiti SC','STHeiti','Arial',sans-serif" font-weight="900" fill="white" stroke="#C8960C" stroke-width="${Math.round(fs2*0.06)}" paint-order="stroke fill" letter-spacing="${Math.round(fs2*0.05)}">${line}</text>`;
});
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><style>text{filter:drop-shadow(0 ${Math.round(fs2*0.04)} ${Math.round(fs2*0.08)} rgba(0,0,0,0.7))}</style>${els}</svg>`;
await sharp(imgPath).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).png({ quality: 95 }).toFile(outPath);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// =========== process one ===========
async function genCover(refUrl, topicA, size, aspectRatio, outDir, ossName) {
console.log(` Kling submit (${size})...`);
const { data: submitData } = await api('POST', '/v1/images/multi-image2image', {
model_name: 'kling-v2-1',
prompt: 'A cinematic vertical social media cover poster. Reinterpret this scene into a tighter more dramatic poster composition. Keep the same visual aesthetic. Semi-realistic Marvel-tone style, warm orange and cool blue dual-lighting, dark atmospheric background, cinematic composition, strong focal point. NO text, NO watermark, NO words.',
n: 1,
aspect_ratio: aspectRatio,
subject_image_list: [{ subject_image: refUrl }],
style_image: refUrl,
});
if (submitData.code !== 0) throw new Error('Kling submit failed: ' + JSON.stringify(submitData).substring(0, 200));
const taskId = submitData.data.task_id;
// Poll
for (let i = 0; i < 40; i++) {
await sleep(3000);
const { data: pollData } = await api('GET', '/v1/images/multi-image2image/' + taskId);
const status = pollData.data?.task_status;
if (status === 'succeed') {
const imgUrl = pollData.data?.task_result?.images?.[0]?.url;
if (!imgUrl) throw new Error('No image URL in result');
const rawPath = path.join(outDir, '_kling_raw.png');
await dl(imgUrl, rawPath);
const finalPath = path.join(outDir, ossName.split('/').pop());
await addText(rawPath, topicA, finalPath);
await oss.put(ossName, finalPath);
const url = await oss.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 });
console.log(` ✅ done: ${url.substring(0, 80)}...`);
return url;
}
if (status === 'failed') throw new Error('Kling failed: ' + JSON.stringify(pollData).substring(0, 200));
if (i % 5 === 4) console.log(` polling... (${(i+1)*3}s)`);
}
throw new Error('Kling timeout');
}
async function processDraft(draft, idx) {
const { dir, topicA, seq } = draft;
const coverDir = path.join(OUT, 'draft_' + seq);
fs.mkdirSync(coverDir, { recursive: true });
console.log(`\n${'='.repeat(55)}`);
console.log(`[${idx+1}/8] ${topicA}`);
console.log(`${'='.repeat(55)}`);
const videoPath = getFirstVideo(path.join(DRAFT_BASE, dir));
if (!videoPath) throw new Error('Video not found');
const framePath = path.join(coverDir, '_frame.png');
execSync(`ffmpeg -y -i "${videoPath}" -vframes 1 -q:v 2 "${framePath}" 2>/dev/null`, { timeout: 10000 });
// Upload frame for Kling reference
const refOssName = `covers/执黑先行/kling_refs/frame_${seq}.png`;
await oss.put(refOssName, framePath);
const refUrl = await oss.signatureUrl(refOssName, { expires: 3600 });
// Re-upload frame so signatureUrl is fresh, then pass it through the genCover async
// Actually genCover re-uploads. Just pre-upload here.
console.log(` Frame uploaded as ref`);
const results = [];
// 抖音 9:16
results.push(await genCover(refUrl, topicA, '1088x1920', '9:16', coverDir, `covers/执黑先行/kling/cover_${seq}_抖音.png`));
// 小红书 3:4
results.push(await genCover(refUrl, topicA, '1152x1536', '3:4', coverDir, `covers/执黑先行/kling/cover_${seq}_小红书.png`));
// Clean up raw kling output
try { fs.unlinkSync(path.join(coverDir, '_kling_raw.png')); } catch (_) {}
try { fs.unlinkSync(framePath); } catch (_) {}
return results;
}
async function main() {
for (let i = 0; i < DRAFTS.length; i++) {
try {
await processDraft(DRAFTS[i], i);
if (i < DRAFTS.length - 1) { console.log(' ⏸️ pause 5s...'); await sleep(5000); }
} catch (err) {
console.error(`❌ Failed [${DRAFTS[i].dir}]: ${err.message}`);
}
}
console.log('\nAll done.');
}
main().catch(e => { console.error(e.message); process.exit(1); });

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
/** GPT Image 文生图封面 - generations endpoint, 纯文本 prompt */
const fs = require('fs'), path = require('path'), https = require('https');
const sharp = require('sharp');
const OSS = require('ali-oss');
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config.json'), 'utf-8'));
const oss = new OSS({ region: cfg.ossRegion, accessKeyId: cfg.ossAccessKeyId, accessKeySecret: cfg.ossAccessKeySecret, bucket: cfg.ossBucket, secure: true });
const AK = cfg.gptImageApiKey;
const OUT = '/Users/lc/video-create/output/执黑先行_补封面/t2i_final';
fs.mkdirSync(OUT, { recursive: true });
const LIST = [
{ s:'01', t:'闭嘴做事·才是狠人', p:'A cinematic vertical movie poster. A lone powerful figure in a sleek black suit stands in a dark modern cityscape at night, arms crossed, looking down with cold confidence. Semi-realistic Marvel-tone style. Dramatic warm orange light from one side, cold blue light from the other. Dark atmospheric background. High contrast. Poster composition. NO text, NO watermark, NO words.' },
{ s:'02', t:'你只是没钱罢了', p:'A cinematic vertical movie poster. A person standing alone in an empty luxurious penthouse overlooking a glittering city at night, hand in empty pockets, expression of raw realization. Semi-realistic Marvel-tone. Warm golden light clashing with cold blue moonlight. Dark moody atmosphere. High contrast poster composition. NO text, NO watermark, NO words.' },
{ s:'03', t:'别跪了·你配的上', p:'A cinematic vertical movie poster. A person rising from their knees in a grand marble hall, back straight, chin up, light breaking through tall windows behind them. Dignity and power. Semi-realistic Marvel-tone style. Warm amber light flooding in, cold blue shadows retreating. Dramatic composition. NO text, NO watermark, NO words.' },
{ s:'04', t:'没人说的·阶层真相', p:'A cinematic vertical movie poster. A figure standing at the edge of two worlds - below a crowded grey street, above a golden elite realm behind glass. Looking through an invisible wall. Semi-realistic Marvel-tone. Cold blue street below, warm gold above. Social commentary mood. NO text, NO watermark, NO words.' },
{ s:'05', t:'出轨真相·不是道德', p:'A cinematic vertical movie poster. Two silhouettes facing away from each other in a rain-soaked street at night, neon reflections on wet pavement, one looking back with a complex expression. Semi-realistic Marvel-tone style. Red neon glow mixed with cold blue rain. Emotional distance. NO text, NO watermark, NO words.' },
{ s:'06', t:'不凑合·最好的风水', p:'A cinematic vertical movie poster. A person confidently walking away from a crumbling grey building toward a vibrant sunlit path, head held high. Semi-realistic Marvel-tone. Warm golden sunlight breaking through, cold shadows behind. Empowering atmosphere. NO text, NO watermark, NO words.' },
{ s:'07', t:'跟对人·只管赢', p:'A cinematic vertical movie poster. A powerful mentor figure in shadow standing behind a rising challenger in spotlight, like a chess master and their strongest piece. Semi-realistic Marvel-tone style. Warm gold on the protégé, cool blue surrounding the mentor. Strategic power. NO text, NO watermark, NO words.' },
{ s:'10', t:'哭惨的人·榨你最狠', p:'A cinematic vertical movie poster. A person pulling away from grasping hands reaching from darkness, expression of cold clarity and self-preservation. Semi-realistic Marvel-tone style. The grasping hands in cold blue shadow, the escaping figure in warm protective light. Toxic relationship mood. NO text, NO watermark, NO words.' },
];
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function gptGen(prompt, size, retries=3) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { console.log(` retry ${a}/${retries} after ${a*5}s...`); await sleep(a*5000); }
try {
const body = JSON.stringify({ model: 'gpt-image-2', prompt, n: 1, size, quality: 'auto', format: 'png' });
const res = await new Promise((resolve, reject) => {
const req = https.request({
hostname: 'yunwu.ai', port: 443, path: '/v1/images/generations', method: 'POST',
headers: { 'Authorization': 'Bearer ' + AK, 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(body)) },
timeout: 120000,
}, (res) => { const c = []; res.on('data', d => c.push(d)); res.on('end', () => { resolve({ status: res.statusCode, data: Buffer.concat(c).toString() }); }); });
req.on('error', reject);
req.write(body); req.end();
});
if (res.status === 200) {
const j = JSON.parse(res.data);
if (j.data?.[0]?.b64_json) return Buffer.from(j.data[0].b64_json, 'base64');
if (j.data?.[0]?.url) return j.data[0].url;
}
console.log(` ⚠️ ${res.status}: ${res.data.substring(0,120)}`);
if (res.status === 429) continue; // rate limit, retry
if (res.status >= 500) continue; // server error, retry
} catch (e) {
console.log(` ⚠️ network: ${e.message}`);
// network error, retry
}
}
throw new Error('GPT gen failed after retries');
}
async function addText(imgPath, text, outPath) {
const meta = await sharp(imgPath).metadata();
const { width: w, height: h } = meta;
const fs2 = Math.round(w * 0.10);
const parts = text.split('·'), lines = parts.length === 2 ? parts : [text];
const lh = fs2 * 1.3, startY = (h - lh * lines.length) / 2 + fs2 * 0.8;
let els = '';
lines.forEach((line, i) => {
const y = startY + i * lh;
els += `<text x="${w/2}" y="${y}" text-anchor="middle" font-size="${fs2}" font-family="'PingFang SC','Heiti SC','STHeiti','Arial',sans-serif" font-weight="900" fill="white" stroke="#C8960C" stroke-width="${Math.round(fs2*0.06)}" paint-order="stroke fill" letter-spacing="${Math.round(fs2*0.05)}">${line}</text>`;
});
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><style>text{filter:drop-shadow(0 ${Math.round(fs2*0.04)} ${Math.round(fs2*0.08)} rgba(0,0,0,0.7))}</style>${els}</svg>`;
await sharp(imgPath).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).png({ quality: 95 }).toFile(outPath);
}
async function doOne(item, idx) {
const { s, t, p } = item;
const dir = path.join(OUT, s); fs.mkdirSync(dir, { recursive: true });
console.log(`\n${'='.repeat(50)}\n[${idx+1}/8] ${t}\n${'='.repeat(50)}`);
const sizes = [
{ lb: '抖音', sz: '1088x1920', nm: `cover_${s}_抖音` },
{ lb: '小红书', sz: '1152x1536', nm: `cover_${s}_小红书` },
];
for (const { lb, sz, nm } of sizes) {
console.log(` 🎨 ${lb} (${sz})...`);
const result = await gptGen(p, sz);
const rawPath = path.join(dir, `_${lb}.png`);
if (Buffer.isBuffer(result)) {
fs.writeFileSync(rawPath, result);
} else {
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(rawPath);
https.get(result, (res) => { res.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', reject);
});
}
console.log(` GPT done, adding text...`);
const final = path.join(dir, `${nm}.png`);
await addText(rawPath, t, final);
const ossName = `covers/执黑先行/t2i/${nm}.png`;
await oss.put(ossName, final);
const url = await oss.signatureUrl(ossName, { expires: cfg.ossExpires || 31536000 });
console.log(`${lb}`);
try { fs.unlinkSync(rawPath); } catch (_) {}
}
}
async function main() {
for (let i = 0; i < LIST.length; i++) {
try { await doOne(LIST[i], i); if (i < LIST.length-1) { console.log(' ⏸️ 3s...'); await sleep(3000); } }
catch (e) { console.error(`❌ [${LIST[i].s} ${LIST[i].t}]: ${e.message}`); }
}
console.log('\n✅ Done.');
}
main().catch(e => { console.error(e.message); process.exit(1); });

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env node
/**
* 封面生成 v2 — GPT direct 一步出图
* 对齐 prompts/封面提示词.md 模板
* 参考图=场景大图(优先)/视频首帧(降级) → GPT edits → 直接出带字海报 → OSS
*/
const fs = require('fs'), path = require('path'), https = require('https');
const { execSync } = require('child_process');
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config.json'), 'utf-8'));
const DB = cfg.jianyingDraftPath;
const AK = cfg.gptImageApiKey;
const OUT = '/Users/lc/video-create/output/执黑先行_补封面/v2_final';
fs.mkdirSync(OUT, { recursive: true });
// 8 drafts with topicA, line1/line2, imagePrompt styleDesc, and scene image path (if available)
const LIST = [
{
s:'01', t:'闭嘴做事·才是狠人', l1:'闭嘴做事', l2:'才是狠人',
ref: '/Users/lc/Movies/JianyingPro/User Data/Projects/com.lveditor.draft/执黑先行_0513_01_闭嘴搞钱/assets/images/scene_07_%E5%A4%A7%E9%83%A8%E5%88%86%E5%A4%B1%E8%B4%A5.jpeg.jpeg',
ip: 'A lone powerful figure in a sleek black designer coat strides through a rain-slicked urban street at night, warm amber street light cutting across their silhouette, confident posture, head held high. Audrey Hepburn modern urban elegance, sweet yet cool expression, clean-cut silhouette. Tailored black cashmere coat over fitted ivory silk blouse, high-waisted dark trousers, subtle gold earrings. Rain-slicked asphalt reflecting golden street lights, neon signs casting blue ambient glow, city skyscrapers behind. Quiet confidence, controlled power, the calm of someone who has stopped seeking approval and started winning. Warm amber-golden street light as main source, cool blue night ambient, golden rim light, atmospheric haze, shadows deep but translucent. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed, no border, no frame, no margin. Vertical format, aspect ratio 9:16.'
},
{
s:'02', t:'你只是没钱罢了', l1:'你只是没钱罢了', l2:'',
ref: null, d:'执黑先行_0513_02_穷是原罪',
ip: 'A person standing alone in an empty luxurious penthouse overlooking a glittering city at night, hand in empty pockets, expression of raw honest realization. Audrey Hepburn modern urban elegance, sweet yet cool with a hint of bitter truth, clean-cut silhouette. Simple elegant white shirt slightly wrinkled, dark slim trousers, barefoot on cold marble floor. Floor-to-ceiling windows showing city skyline, warm golden interior light clashing with cold blue night outside, empty space emphasizing isolation. Raw honesty, the moment of seeing things as they really are, no pretense. Warm amber light inside, cold blue moonlight through windows, golden rim light on figure, atmospheric haze. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
{
s:'03', t:'别跪了·你配的上', l1:'别跪了', l2:'你配的上',
ref: null, d:'执黑先行_0513_03_别跪',
ip: 'A person standing tall with perfect posture in a grand marble hall, chin lifted, light breaking through tall windows behind them, dignity radiating from their stance. Audrey Hepburn modern urban elegance, calm confident expression, clean-cut silhouette. Elegant fitted black blazer over a silk camisole, tailored wide-leg trousers, subtle pearl earrings, designer heels. Grand marble architecture with towering columns, warm golden sunlight flooding through tall windows, dust particles floating in light beams. Dignity, self-worth, the power of knowing your own value. Warm golden sunlight as main source, cool shadows in corners, golden rim light on the figure, atmospheric light haze. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
{
s:'04', t:'没人说的·阶层真相', l1:'没人说的', l2:'阶层真相',
ref: null, d:'执黑先行_0513_04_阶层密码',
ip: 'A figure standing at the edge of two contrasting worlds — below a crowded grey muted street of ordinary people, above a warm golden elite realm visible through floor-to-ceiling glass. The figure looks through this invisible barrier with knowing eyes. Audrey Hepburn modern urban elegance, expression of quiet understanding, clean-cut silhouette. Simple but expensive-looking dark coat, quality fabric, understated luxury. Split-level urban scene: grey muted street below, golden warm elite space above visible through glass, the figure positioned at the threshold between both worlds. Revelation, understanding the unspoken rules, seeing through the illusion. Cold blue-grey ambient below, warm golden light above, the figure bathed in both lighting zones at the boundary. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
{
s:'05', t:'出轨真相·不是道德', l1:'出轨真相', l2:'不是道德',
ref: null, d:'执黑先行_0513_05_能量掠夺',
ip: 'Two silhouettes facing away from each other in a rain-soaked city street at night, neon reflections on wet pavement, one looking back with a complex expression that is neither guilty nor angry — just clear. Audrey Hepburn modern urban elegance for both figures, clean-cut silhouettes, expressions of complex emotional truth rather than simple morality. Elegant trench coats, one dark one light, rain-soaked city street fashion, subtle details. Rain-soaked asphalt reflecting red and blue neon signs, steam rising from street vents, city lights blurred by rain. Emotional complexity, truth beyond simple right and wrong, the grey area of human desire. Red neon glow from one side, cool blue from the other, warm amber street lights, wet reflections creating multiple light sources. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
{
s:'06', t:'不凑合·最好的风水', l1:'不凑合', l2:'最好的风水',
ref: null, d:'执黑先行_0513_06_不凑合',
ip: 'A person confidently walking away from a crumbling grey low-energy building toward a vibrant sunlit golden path, head held high, not looking back. Audrey Hepburn modern urban elegance, expression of quiet determination and self-respect, clean-cut silhouette. Stylish tailored coat catching the wind, quality leather boots, a single statement gold bracelet catching sunlight. Contrast scene: behind is grey crumbling architecture in cold shadow, ahead is a sun-drenched vibrant path with warm golden light and hints of greenery. Empowerment, choosing yourself, the courage to leave what does not serve you. Cold blue-grey shadows behind the figure, warm golden sunlight ahead flooding the scene, golden rim light on the figure stepping forward. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
{
s:'07', t:'跟对人·只管赢', l1:'跟对人', l2:'只管赢',
ref: null, d:'执黑先行_0513_07_真领导',
ip: 'A powerful mentor-like figure in shadow standing behind a rising protégé in golden spotlight, like a chess grandmaster and their strongest piece in perfect alignment. The protégé has Audrey Hepburn modern urban elegance, expression of focused confidence, clean-cut silhouette. Protégé in sharp tailored blazer and sleek trousers, mentor figure in darker tones partially in shadow, both exuding effortless cool. Dramatic scene: protégé in warm golden spotlight center stage, mentor in cool blue shadow behind, chess-like floor pattern, high-end urban setting. Strategic power, alignment, the moment of recognizing the right path through the right person. Strong golden spotlight on protégé, cool blue ambient on mentor figure, dramatic light-shadow contrast, cinematic atmosphere. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
{
s:'10', t:'哭惨的人·榨你最狠', l1:'哭惨的人', l2:'榨你最狠',
ref: null, d:'执黑先行_0513_10_榨你最狠',
ip: 'A person pulling their hand away from multiple grasping hands reaching out from cold blue darkness, expression of cold clarity and self-preservation, the moment of breaking free. The central figure has Audrey Hepburn modern urban elegance, expression of clear-eyed boundary-setting, clean-cut silhouette. Central figure in a crisp white shirt with rolled-up sleeves, fitted dark trousers, pulling away decisively. Dark atmospheric void with grasping hands emerging from cold blue shadow, the central figure stepping into warm golden protective light, creating a stark contrast between toxic attachment and self-preservation. Boundary-setting, recognizing emotional vampires, the cold clarity of self-protection. Grasping hands in cold blue shadow, the escaping figure bathed in warm protective amber-golden light, dramatic contrast. modern urban digital illustration, semi-realistic, geometric color-block faces, visible brushwork texture, cinematic composition, full bleed. Vertical format, aspect ratio 9:16.'
},
];
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function gptEdit(prompt, imgPath, size, retries=4) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { console.log(` retry ${a}/${retries} in ${a*10}s...`); await sleep(a*10000); }
try {
const boundary = '----FB' + Math.random().toString(36).slice(2);
const imgBuf = fs.readFileSync(imgPath);
const parts = [];
const af = (n, v) => parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`));
const aff = (n, fn, buf) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"; filename="${fn}"\r\nContent-Type: image/${fn.endsWith('.jpeg')||fn.endsWith('.jpg')?'jpeg':'png'}\r\n\r\n`)); parts.push(buf); parts.push(Buffer.from('\r\n')); };
af('model', 'gpt-image-2'); af('prompt', prompt); af('size', size);
aff('image', path.basename(imgPath), imgBuf);
parts.push(Buffer.from(`--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await new Promise((resolve, reject) => {
const req = https.request({
hostname: 'yunwu.ai', port: 443, path: '/v1/images/edits', method: 'POST',
headers: { 'Authorization': 'Bearer ' + AK, 'Content-Type': 'multipart/form-data; boundary=' + boundary, 'Content-Length': String(body.length) },
timeout: 180000,
}, (res) => { const c = []; res.on('data', d => c.push(d)); res.on('end', () => resolve({ s: res.statusCode, d: Buffer.concat(c).toString() })); });
req.on('error', reject);
req.write(body); req.end();
});
if (res.s === 200) {
const j = JSON.parse(res.d);
if (j.data?.[0]?.b64_json) return Buffer.from(j.data[0].b64_json, 'base64');
if (j.data?.[0]?.url) return j.data[0].url;
throw new Error('No image');
}
console.log(` ⚠️ ${res.s}: ${res.d.substring(0,100)}`);
if (res.s >= 500 || res.s === 429) continue;
} catch (e) { console.log(` ⚠️ ${e.message}`); }
}
throw new Error('GPT edits exhausted');
}
function getFirstVideo(draftDir) {
const data = JSON.parse(fs.readFileSync(path.join(draftDir, 'draft_content.json'), 'utf-8'));
for (const tr of data.tracks) {
if (tr.type === 'video' && tr.segments?.length > 0)
for (const v of data.materials.videos) if (v.id === tr.segments[0].material_id) return v.path;
}
return null;
}
async function doOne(item, idx) {
const { s, t, l1, l2, ref, d, ip } = item;
const dir = path.join(OUT, s); fs.mkdirSync(dir, { recursive: true });
console.log(`\n${'='.repeat(50)}\n[${idx+1}/8] ${t}\n${'='.repeat(50)}`);
// Get reference image
let refPath;
if (ref && fs.existsSync(ref)) {
refPath = ref;
console.log(' Ref: scene image');
} else if (d) {
const vp = getFirstVideo(path.join(DB, d));
if (!vp) throw new Error('No reference available');
refPath = path.join(dir, '_frame.png');
execSync(`ffmpeg -y -i "${vp}" -vframes 1 -q:v 2 "${refPath}" 2>/dev/null`, { timeout: 10000 });
console.log(' Ref: video frame (scene image unavailable)');
} else {
throw new Error('No reference');
}
// styleDesc from imagePrompt first sentence
const styleDesc = ip.split('。')[0] + '.';
console.log(` Style: ${styleDesc.substring(0,80)}...`);
const sizes = [
{ lb: '抖音', sz: '1088x1920', ratio: '9:16', nm: `cover_${s}_抖音` },
{ lb: '小红书', sz: '1152x1536', ratio: '3:4', nm: `cover_${s}_小红书` },
];
for (const { lb, sz, ratio, nm } of sizes) {
// Build editPrompt from template
const line1 = l1 || t;
const line2 = l2 || '';
const textPart = line2
? `At the center of the image, prominently display the Chinese text "${line1}" on the first line and "${line2}" on the second line, in very large bold white characters with a thick golden metallic outline, like a movie poster title. The text should be the dominant visual element, taking up most of the frame.`
: `At the center of the image, prominently display the Chinese text "${line1}" in very large bold white characters with a thick golden metallic outline, like a movie poster title. The text should be the dominant visual element, taking up most of the frame.`;
const editPrompt = `Create a vertical ${ratio} social media cover poster. Keep the EXACT same art style, color palette, lighting, atmosphere and aesthetic as the reference image: ${styleDesc} The composition should be tighter and more poster-like with a strong focal point. ${textPart}`;
console.log(` 🎨 ${lb} (${sz})...`);
const result = await gptEdit(editPrompt, refPath, sz);
const outPath = path.join(dir, `${nm}.png`);
if (Buffer.isBuffer(result)) {
fs.writeFileSync(outPath, result);
} else {
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(outPath);
https.get(result, (res) => { res.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', reject);
});
}
console.log(`${lb}`);
}
// Clean up frame if extracted
if (d) { try { fs.unlinkSync(path.join(dir, '_frame.png')); } catch (_) {} }
}
async function main() {
for (let i = 0; i < LIST.length; i++) {
try { await doOne(LIST[i], i); if (i < LIST.length-1) { console.log(' ⏸️ 3s...'); await sleep(3000); } }
catch (e) { console.error(`❌ [${LIST[i].s} ${LIST[i].t}]: ${e.message}`); }
}
console.log('\nDone.');
}
main().catch(e => { console.error(e.message); process.exit(1); });

View File

@@ -0,0 +1,210 @@
#!/usr/bin/env node
/**
* 封面生成脚本 v2 — GPT 一步出图(文字由 GPT 直接渲染)
* 对齐 prompts/封面提示词.md 模板
*
* 用法: node gen-covers.js --manifest <path>
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
function loadConfig() {
const configPath = path.join(__dirname, '..', '..', 'config.json')
return JSON.parse(fs.readFileSync(configPath, 'utf-8'))
}
const cfg = loadConfig()
// ============================================================================
// 工具
// ============================================================================
function download(url, dest) {
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(dest)
https.get(url, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
file.close()
try { fs.unlinkSync(dest) } catch (_) {}
return download(res.headers.location, dest).then(resolve, reject)
}
res.pipe(file)
file.on('finish', () => { file.close(); resolve() })
}).on('error', (err) => { try { fs.unlinkSync(dest) } catch (_) {}; reject(err) })
})
}
function api(method, urlPath, headers, body) {
return new Promise((resolve, reject) => {
const u = new URL((cfg.gptImageApiBaseUrl || 'https://yunwu.ai') + urlPath)
const opts = { hostname: u.hostname, port: 443, path: u.pathname + u.search, method, headers, timeout: 180000 }
const req = https.request(opts, (res) => {
const c = []; res.on('data', d => c.push(d))
res.on('end', () => { const d = Buffer.concat(c); resolve({ status: res.statusCode, data: d, json: () => JSON.parse(d.toString()) }) })
})
req.on('error', reject)
if (body) req.write(body)
req.end()
})
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
// ============================================================================
// GPT Image edits带重试 + b64_json 支持)
// ============================================================================
async function gptImageEdit(prompt, imagePath, size, retries = 3) {
for (let a = 0; a <= retries; a++) {
if (a > 0) { console.log(` GPT 重试 ${a}/${retries}${a * 10}s 后)...`); await sleep(a * 10000) }
try {
const boundary = '----FB' + Math.random().toString(36).slice(2)
const imgBuf = fs.readFileSync(imagePath)
const imgExt = path.extname(imagePath).toLowerCase()
const mime = imgExt === '.jpg' || imgExt === '.jpeg' ? 'image/jpeg' : 'image/png'
const imgName = path.basename(imagePath)
const parts = []
const af = (n, v) => parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"\r\n\r\n${v}\r\n`))
const aff = (n, fn, buf) => { parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${n}"; filename="${fn}"\r\nContent-Type: ${mime}\r\n\r\n`)); parts.push(buf); parts.push(Buffer.from('\r\n')) }
af('model', cfg.gptImageModel || 'gpt-image-2')
af('prompt', prompt)
af('size', size)
aff('image', imgName, imgBuf)
parts.push(Buffer.from(`--${boundary}--\r\n`))
const body = Buffer.concat(parts)
const key = cfg.gptImageApiKey || ''
const res = await api('POST', '/v1/images/edits', {
'Authorization': 'Bearer ' + key,
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': String(body.length),
}, body)
if (res.status === 200) {
const j = res.json()
if (j.data?.[0]?.b64_json) return { b64: Buffer.from(j.data[0].b64_json, 'base64') }
if (j.data?.[0]?.url) return { url: j.data[0].url }
throw new Error('GPT 未返回图片')
}
console.log(` ⚠️ GPT ${res.status}: ${res.data.toString().substring(0, 100)}`)
if (res.status >= 500 || res.status === 429) continue
} catch (e) {
console.log(` ⚠️ 网络: ${e.message}`)
}
}
throw new Error('GPT edits 重试耗尽')
}
// ============================================================================
// 封面提示词构建
// ============================================================================
function buildEditPrompt(topicA, styleDesc, ratio) {
const parts = topicA.split('·')
const line1 = parts[0]
const line2 = parts.length === 2 ? parts[1] : ''
const textPart = line2
? `At the center of the image, prominently display the Chinese text "${line1}" on the first line and "${line2}" on the second line, in very large bold white characters with a thick golden metallic outline, like a movie poster title. The text should be the dominant visual element, taking up most of the frame.`
: `At the center of the image, prominently display the Chinese text "${line1}" in very large bold white characters with a thick golden metallic outline, like a movie poster title. The text should be the dominant visual element, taking up most of the frame.`
return `Create a vertical ${ratio} social media cover poster. Keep the EXACT same art style, color palette, lighting, atmosphere and aesthetic as the reference image: ${styleDesc} The composition should be tighter and more poster-like with a strong focal point. ${textPart}`
}
// ============================================================================
// OSS 上传
// ============================================================================
async function uploadToOSS(filePath, ossName) {
const ossUpload = require(path.join(__dirname, 'oss-upload'))
const { url } = await ossUpload.uploadFile(filePath, { name: ossName })
return url
}
// ============================================================================
// 主流程
// ============================================================================
async function main() {
const args = process.argv.slice(2)
const mi = args.indexOf('--manifest')
if (mi === -1 || !args[mi + 1]) {
console.error('用法: node gen-covers.js --manifest <path>')
process.exit(1)
}
const manifestPath = args[mi + 1]
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const inputDir = path.dirname(manifestPath)
const coversDir = path.join(inputDir, 'covers')
fs.mkdirSync(coversDir, { recursive: true })
const topicA = manifest.topicA
if (!topicA) { console.error('manifest 缺少 topicA'); process.exit(1) }
const firstItem = manifest.items[0]
if (!firstItem || !firstItem.url) { console.error('没有可用的图片 URL'); process.exit(1) }
// 风格描述 = imagePrompt 第一句
const imagePrompt = firstItem.imagePrompt
const styleDesc = imagePrompt.split('。')[0] + '。'
console.log(`封面: ${topicA}`)
console.log(`风格: ${styleDesc.substring(0, 80)}...`)
// 下载参考图
const refPath = path.join(coversDir, '_ref.png')
console.log('下载参考图...')
await download(firstItem.url, refPath)
const dirName = path.basename(inputDir)
const seqNum = dirName.split('_').pop()
const sizes = [
{ label: '抖音', ratio: '9:16', size: '1088x1920', suffix: `cover_${String(seqNum).padStart(3, '0')}_抖音` },
{ label: '小红书', ratio: '3:4', size: '1152x1536', suffix: `cover_${String(seqNum).padStart(3, '0')}_小红书` },
]
for (const { label, ratio, size, suffix } of sizes) {
console.log(`\n生成 ${label} 封面 (${size})...`)
// 按模板构建封面提示词(含文字)
const editPrompt = buildEditPrompt(topicA, styleDesc, ratio)
// GPT 一步出图
const result = await gptImageEdit(editPrompt, refPath, size)
const coverFinalPath = path.join(coversDir, `${suffix}.png`)
if (result.b64) {
fs.writeFileSync(coverFinalPath, result.b64)
} else if (result.url) {
console.log('下载生成图...')
await download(result.url, coverFinalPath)
}
// OSS 上传
const ossName = `covers/${dirName}/${suffix}.png`
console.log('上传 OSS...')
const url = await uploadToOSS(coverFinalPath, ossName)
if (label === '抖音') manifest.coverUrl_抖音 = url
else manifest.coverUrl_小红书 = url
console.log(`${label}`)
}
// 回写 manifest
manifest.pipeline = manifest.pipeline || {}
manifest.pipeline.phases = manifest.pipeline.phases || {}
manifest.pipeline.phases.cover = 'done'
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8')
console.log('\n封面已写入 manifest')
try { fs.unlinkSync(refPath) } catch (_) {}
}
main().catch(err => {
console.error('封面生成失败:', err.message)
process.exit(1)
})

View File

@@ -39,6 +39,7 @@ const Config = {
apiBase: (cfg.kelingApiBaseUrl || 'https://api-beijing.klingai.com').replace(/\/+$/, ''),
yunwuApiBase: (cfg.kelingYunwuApiBase || '').replace(/\/+$/, ''),
yunwuApiKey: cfg.kelingYunwuApiKey || cfg.minimaxApiKey || '',
yunwuVideoEndpoint: cfg.kelingYunwuVideoEndpoint || 'image2video',
model: cfg.kelingModel || 'kling-v2-5-turbo',
pollInterval: 10000,
maxPollTime: 600000,
@@ -197,6 +198,7 @@ const KlingApi = {
duration = 5,
mode = 'std',
lastFrameUrl = '',
aspectRatio = '9:16',
} = options
const yunwu = isYunwuMode()
@@ -205,22 +207,35 @@ const KlingApi = {
? `Bearer ${Config.yunwuApiKey}`
: `Bearer ${makeJwt(...Object.values(loadCredentials()))}`
const body = {
model_name: model,
image: imageUrl,
prompt,
duration: String(duration),
mode,
multi_shot: false,
}
const useMultiImage = yunwu && lastFrameUrl && Config.yunwuVideoEndpoint === 'multi-image2video'
if (lastFrameUrl) {
body.image_tail = lastFrameUrl
let body
if (useMultiImage) {
body = {
model_name: model,
image_list: [{ image: imageUrl }, { image: lastFrameUrl }],
prompt,
negative_prompt: '',
mode,
duration: String(duration),
aspect_ratio: aspectRatio,
}
} else {
body = {
model_name: model,
image: imageUrl,
prompt,
duration: String(duration),
mode,
multi_shot: false,
}
if (lastFrameUrl) body.image_tail = lastFrameUrl
}
const modeLabel = lastFrameUrl ? '首尾帧' : '单图'
const modeTag = useMultiImage ? ' [multi-image]' : ''
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]${yunwu ? ' [云雾]' : ' [官方]'}`)
console.log(`\n📡 提交可灵视频任务 [${modeLabel}]${yunwu ? ' [云雾]' : ' [官方]'}${modeTag}`)
console.log(` API: ${apiBase}`)
console.log(` 模型: ${model}`)
console.log(` 提示词: ${prompt.substring(0, 80)}...`)
@@ -232,7 +247,8 @@ const KlingApi = {
}
console.log(` 时长: ${duration}s | 画质: ${mode}`)
const res = await fetch(`${apiBase}/v1/videos/image2video`, {
const videoEndpoint = (yunwu && lastFrameUrl) ? Config.yunwuVideoEndpoint : 'image2video'
const res = await fetch(`${apiBase}/v1/videos/${videoEndpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -265,7 +281,8 @@ const KlingApi = {
? `Bearer ${Config.yunwuApiKey}`
: `Bearer ${makeJwt(...Object.values(loadCredentials()))}`
const res = await fetch(`${apiBase}/v1/videos/image2video/${taskId}`, {
const queryEndpoint = yunwu ? Config.yunwuVideoEndpoint : 'image2video'
const res = await fetch(`${apiBase}/v1/videos/${queryEndpoint}/${taskId}`, {
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json',

View File

@@ -174,7 +174,7 @@ async function phaseVideos(manifest, manifestPath, options) {
if (needSubmit.length === 0) { log('videos', '全部通过恢复完成'); return }
// Phase 2+3: 分批提交+轮询(严格并发 ≤ 5等一批完成再提交下一批
const concurrency = 1
const concurrency = 5
log('videos', `提交 ${needSubmit.length} 个新任务(并发: ${concurrency}...`)
let totalSubmitted = 0

View File

@@ -12,7 +12,8 @@ const fs = require('fs')
const https = require('https')
const http = require('http')
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket|超时|processing|pending/i
// "fetch failed" = Node fetch threw before receiving a response (network error, NOT a Kling task failure)
const TRANSIENT_RE = /timeout|ECONNRESET|ETIMEDOUT|network|socket|超时|processing|pending|fetch failed/i
const TASK_FAILED_RE = /\bfailed\b|失败/i
@@ -104,8 +105,11 @@ function makePollWithRetry({ Api, suffix, duration, maxRetries = 3, optimizeProm
}
} catch (err) {
lastError = err.message
lastErrorWasTaskFailure = isTaskFailed(err)
if (isTransientError(err) && pollAttempt < POLL_RETRIES) {
// Network errors (transient) must never be treated as task failures.
// isTaskFailed("fetch failed") would be true without this guard because "failed" matches TASK_FAILED_RE.
const transient = isTransientError(err)
lastErrorWasTaskFailure = !transient && isTaskFailed(err)
if (transient && pollAttempt < POLL_RETRIES) {
const tag = isTaskFailed(err) ? '失败' : '超时/瞬断'
console.log(` ⚠ 轮询${tag} (${pollAttempt + 1}/${POLL_RETRIES}): ${err.message.slice(0, 60)}`)
await new Promise(r => setTimeout(r, POLL_RETRY_DELAY))

View File

@@ -57,12 +57,10 @@ async function uploadFile(filePath, options = {}) {
const ossPath = `${folder}${basename}`
const stat = fs.statSync(filePath)
const opts = stat.size > 50 * 1024 * 1024
? { timeout: 600000 } // 10min for large files
: {}
const timeout = stat.size > 50 * 1024 * 1024 ? 600000 : 300000
const stream = fs.createReadStream(filePath)
await client.putStream(ossPath, stream, { ...opts, contentLength: stat.size })
await client.putStream(ossPath, stream, { timeout, contentLength: stat.size })
const expires = config.ossExpires || 31536000
const url = client.signatureUrl(ossPath, { expires })