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:
210
.claude/skills/video-from-script/scripts/gen-covers.js
Normal file
210
.claude/skills/video-from-script/scripts/gen-covers.js
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user