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

148 lines
7.1 KiB
JavaScript
Raw Blame History

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