- 新增 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>
196 lines
8.4 KiB
JavaScript
196 lines
8.4 KiB
JavaScript
#!/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); });
|