Files
video-create/.claude/skills/video-from-script/scripts/gen-covers-final.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

168 lines
8.1 KiB
JavaScript

#!/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); });