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:
@@ -3,9 +3,9 @@
|
||||
"capcutMateDir": "/Users/lc/capcut-mate",
|
||||
"capcutMateApiBase": "http://capcut.muyetools.cn/openapi/capcut-mate/v1",
|
||||
"imgbbApiKey": "deprecated",
|
||||
"gptImageApiBaseUrl": "https://yunwu.ai",
|
||||
"gptImageApiBaseUrl": "https://api.quickrouter.ai",
|
||||
"gptImageModel": "gpt-image-2",
|
||||
"gptImageApiKey": "sk-dCwuieejiPseElK3ami0PHJXKf0U2UlUkMXOCP34Xg1LBnC4",
|
||||
"gptImageApiKey": "sk-xPqKE09CWaVTJKD8sOHOMwYivFB820j7XSL5JKNsgdUA1ID9",
|
||||
"geminiApiBaseUrl": "https://yunwu.ai",
|
||||
"geminiModel": "gemini-3.1-flash-image-preview",
|
||||
"geminiApiKey": "sk-dCwuieejiPseElK3ami0PHJXKf0U2UlUkMXOCP34Xg1LBnC4",
|
||||
@@ -22,9 +22,10 @@
|
||||
"kelingApiBaseUrl": "https://api-beijing.klingai.com",
|
||||
"kelingApiKey": "AR4kMTEGaaM4d4QgJmLYMgHmEFABJPFE",
|
||||
"kelingSecretAccessKey": "aBCrHLYTPPgMm3mnE8RBMAtmY9FLTGT3",
|
||||
"kelingModel": "kling-v2-5-turbo",
|
||||
"kelingYunwuApiBase": "https://api.yunwu.ai/kling",
|
||||
"kelingYunwuApiKey": "sk-dCwuieejiPseElK3ami0PHJXKf0U2UlUkMXOCP34Xg1LBnC4",
|
||||
"kelingModel": "kling-v1-6",
|
||||
"kelingYunwuApiBase": "https://api.quickrouter.ai/kling",
|
||||
"kelingYunwuApiKey": "sk-xPqKE09CWaVTJKD8sOHOMwYivFB820j7XSL5JKNsgdUA1ID9",
|
||||
"kelingYunwuVideoEndpoint": "multi-image2video",
|
||||
"ossRegion": "oss-cn-hangzhou",
|
||||
"ossAccessKeyId": "LTAI5tPV9Ag3csf41GZjaLTA",
|
||||
"ossAccessKeySecret": "kDqlGeJTKw6tJtFYiaY8vQTFuVIQDs",
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
367
.claude/skills/video-from-script/scripts/gen-covers-batch.js
Normal file
367
.claude/skills/video-from-script/scripts/gen-covers-batch.js
Normal 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)
|
||||
})
|
||||
268
.claude/skills/video-from-script/scripts/gen-covers-direct.js
Normal file
268
.claude/skills/video-from-script/scripts/gen-covers-direct.js
Normal 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)
|
||||
})
|
||||
167
.claude/skills/video-from-script/scripts/gen-covers-final.js
Normal file
167
.claude/skills/video-from-script/scripts/gen-covers-final.js
Normal 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); });
|
||||
147
.claude/skills/video-from-script/scripts/gen-covers-gpt.js
Normal file
147
.claude/skills/video-from-script/scripts/gen-covers-gpt.js
Normal 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); });
|
||||
195
.claude/skills/video-from-script/scripts/gen-covers-kling.js
Normal file
195
.claude/skills/video-from-script/scripts/gen-covers-kling.js
Normal 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); });
|
||||
112
.claude/skills/video-from-script/scripts/gen-covers-t2i.js
Normal file
112
.claude/skills/video-from-script/scripts/gen-covers-t2i.js
Normal 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); });
|
||||
170
.claude/skills/video-from-script/scripts/gen-covers-v2.js
Normal file
170
.claude/skills/video-from-script/scripts/gen-covers-v2.js
Normal 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); });
|
||||
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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -35,12 +35,15 @@ assemble + 草稿重命名完成后,立即生成封面。**每个账号有固
|
||||
|
||||
### 封面规则
|
||||
|
||||
- 读取 `account.json` 的 `cover` 字段,获取:模板参考图、排版风格、渠道配置
|
||||
- 核心文字 = `topicA`(选题方案A,封面双句)
|
||||
- 读取 `account.json` 的 `cover` 字段,获取:排版风格、渠道配置、文字规则
|
||||
- **参考图** = 当前风格的 references(来自 manifest.references),即本次视频生成使用的风格参考图。封面必须使用这些图作为视觉风格参考
|
||||
- **封面文字** = topicA 和 topicB 中**字数较短者**(如相同优先 topicB)。大字居中或上下分布
|
||||
- **封面提示词** = 基于**本期文案全文理解**其核心冲突和情绪→生成封面画面 prompt。画面需与文案核心观点共振,而非仅风格装饰
|
||||
- **字体** = 选择与当前风格气质匹配的字体(参考 account.json 中 `styles.{风格}.coverFont`)
|
||||
- **同账号字体构图必须统一**:同一账号的所有封面,字体、文字位置、字号比例必须保持一致,不能每篇各变。不同账号可以有各自的方案。新账号首次锁定方案后,后续全部沿用。
|
||||
- **必须产出两个尺寸**:
|
||||
- `9:16`(1024×1792)→ 抖音封面
|
||||
- `3:4`(1024×1365)→ 小红书封面
|
||||
- 参考图:账号 references 目录下所有图 + `cover_template.png`(账号封面模板)
|
||||
- 图片模型:`cover.imageModel`(默认 `gpt-image`)
|
||||
- 保存路径:`output/{name}/covers/cover_{序号}_{抖音|小红书}.png`
|
||||
- **生成完毕后立即上传 OSS**,URL 写入 manifest(`coverUrl_抖音` / `coverUrl_小红书`)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"name": "产品宣传片",
|
||||
"description": "PPT内容/卖点 → 结构化解析 → 分镜脚本 → 图片提示词 → 生图 → AI视频 → 成片,支持无风格参考图时由产品图自动派生风格",
|
||||
"defaultFormat": "9:16",
|
||||
"imageModel": "gemini",
|
||||
"videoModel": "veo3-fast",
|
||||
"imageModel": "gpt-image",
|
||||
"videoModel": "kling",
|
||||
"batchSize": 30,
|
||||
"ttsVoice": "",
|
||||
"ttsVoice": "Chinese (Mandarin)_IntellectualGirl",
|
||||
"ttsInstruction": "",
|
||||
"storyboardPrompt": "prompts/产品分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
@@ -14,6 +14,19 @@
|
||||
"references": [],
|
||||
"referenceMode": "product-as-style",
|
||||
"referenceModeNote": "优先使用references中的风格参考图;无风格图时,由产品参考图自动派生光影/构图/背景风格",
|
||||
"cover": {
|
||||
"imageModel": "gpt-image",
|
||||
"style": "产品爆炸流",
|
||||
"styleNote": "产品居中+热带水果四散爆炸,橙金渐变背景,高饱和度。文字居上,白色粗体大字,金色描边",
|
||||
"template": "references/cover_template.png",
|
||||
"channels": ["抖音", "小红书"],
|
||||
"sizes": {
|
||||
"抖音": "1024x1792",
|
||||
"小红书": "1024x1365"
|
||||
},
|
||||
"textRule": "topicA上下两行居中,白色粗体,金色描边",
|
||||
"lockedAt": "2026-06-06"
|
||||
},
|
||||
"capcut": {
|
||||
"effects": [],
|
||||
"filter": "",
|
||||
@@ -37,17 +50,37 @@
|
||||
"defaultBGM": "",
|
||||
"transitions": {
|
||||
"strategy": "rhythm",
|
||||
"default": { "name": "溶解", "duration": 400000 },
|
||||
"default": {
|
||||
"name": "溶解",
|
||||
"duration": 400000
|
||||
},
|
||||
"byPosition": {
|
||||
"hook": { "name": "闪白", "duration": 100000 },
|
||||
"keypoint": { "name": "闪白", "duration": 150000 },
|
||||
"body": { "name": "溶解", "duration": 400000 },
|
||||
"closing": { "name": "闪黑", "duration": 300000 }
|
||||
"hook": {
|
||||
"name": "闪白",
|
||||
"duration": 100000
|
||||
},
|
||||
"keypoint": {
|
||||
"name": "闪白",
|
||||
"duration": 150000
|
||||
},
|
||||
"body": {
|
||||
"name": "溶解",
|
||||
"duration": 400000
|
||||
},
|
||||
"closing": {
|
||||
"name": "闪黑",
|
||||
"duration": 300000
|
||||
}
|
||||
}
|
||||
},
|
||||
"kenBurns": {
|
||||
"enabled": true,
|
||||
"default": { "startScale": 1.0, "scaleRate": 0.7, "panXRate": 0, "panYRate": 0 }
|
||||
"default": {
|
||||
"startScale": 1.0,
|
||||
"scaleRate": 0.7,
|
||||
"panXRate": 0,
|
||||
"panYRate": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
accounts/product_viral_factory/references/cover_template.png
Normal file
BIN
accounts/product_viral_factory/references/cover_template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@@ -22,7 +22,23 @@
|
||||
"channels": { "9:16": "抖音", "3:4": "小红书" },
|
||||
"style": "动态场景人物+大字封面:主体人物动作场景占画面60-70%,核心文字大字居中或上下分布,背景深暗,暖橙-冷蓝双光源对抗,漫威色调半写实。参考 cover_template.png 排版。",
|
||||
"textSource": "topicA",
|
||||
"imageModel": "gpt-image"
|
||||
"imageModel": "gpt-image",
|
||||
"coverPromptTemplate": "prompts/封面提示词.md",
|
||||
"text": {
|
||||
"fontFamily": "PingFang SC, Heiti SC, STHeiti, Arial, sans-serif",
|
||||
"fontWeight": "900",
|
||||
"fontSizeRatio": 0.10,
|
||||
"fillColor": "#FFFFFF",
|
||||
"strokeColor": "#C8960C",
|
||||
"strokeWidthRatio": 0.06,
|
||||
"position": "center",
|
||||
"letterSpacingRatio": 0.05,
|
||||
"shadowOffsetRatio": 0.04,
|
||||
"shadowBlurRatio": 0.08,
|
||||
"shadowColor": "rgba(0,0,0,0.7)",
|
||||
"lineHeightRatio": 1.3,
|
||||
"splitChar": "·"
|
||||
}
|
||||
},
|
||||
"storyboardPrompt": "prompts/分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
|
||||
59
accounts/执黑先行/prompts/封面提示词.md
Normal file
59
accounts/执黑先行/prompts/封面提示词.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 封面提示词模板 v2|执黑先行账号专用版
|
||||
|
||||
## 一、铁律
|
||||
|
||||
1. **GPT Image edits 一步出图**:GPT 直接生成带文字的海报,不再分两步(先生图再叠字)
|
||||
2. **文字由 GPT 渲染**:不经过 Sharp SVG,GPT 自己在画面里画字
|
||||
3. **styleDesc 来自 imagePrompt 首句**:`imagePrompt.split('。')[0] + '。'`
|
||||
4. **editPrompt 严格按模板拼接**:不得自行改写结构
|
||||
|
||||
## 二、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. 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.
|
||||
```
|
||||
|
||||
| 占位符 | 来源 | 示例 |
|
||||
|--------|------|------|
|
||||
| `{ratio}` | 渠道:抖音=`9:16`,小红书=`3:4` | `9:16` |
|
||||
| `{styleDesc}` | `manifest.items[0].imagePrompt` 首句 + `。` | `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.` |
|
||||
| `{line1}` | topicA 按 `·` 拆分后的前半句 | `闭嘴做事` |
|
||||
| `{line2}` | topicA 按 `·` 拆分后的后半句(无 `·` 则为空行或重复) | `才是狠人` |
|
||||
|
||||
## 三、尺寸
|
||||
|
||||
| 渠道 | 尺寸 | 比例 |
|
||||
|------|------|------|
|
||||
| 抖音 | 1088×1920 | 9:16 |
|
||||
| 小红书 | 1152×1536 | 3:4 |
|
||||
|
||||
## 四、参考图
|
||||
|
||||
- 使用 `manifest.items[0].url`(第一帧场景大图,高质量 PNG/JPEG)
|
||||
- 下载到 `covers/_ref.png`
|
||||
- **禁止用视频抽帧代替**
|
||||
|
||||
## 五、完整流程
|
||||
|
||||
```
|
||||
1. 读取 manifest.topicA → 按 · 拆分为 line1、line2
|
||||
2. 读取 manifest.items[0].imagePrompt → styleDesc(首句 + 。)
|
||||
3. 下载 manifest.items[0].url → covers/_ref.png(第一帧场景大图)
|
||||
4. 对每个渠道(抖音/小红书):
|
||||
a. 按模板拼接 editPrompt(填入 ratio + styleDesc + line1 + line2)
|
||||
b. GPT Image edits API(gpt-image-2)+ _ref.png
|
||||
c. 保存 → OSS 上传 → URL 回写 manifest
|
||||
5. 标记 cover phase = done
|
||||
```
|
||||
|
||||
## 六、自检清单
|
||||
|
||||
```
|
||||
[ ] 参考图是 items[0].url 的场景大图,非视频抽帧
|
||||
[ ] styleDesc = imagePrompt 首句(精确画风描述)
|
||||
[ ] line1/line2 = topicA 按 · 拆分
|
||||
[ ] editPrompt 严格按模板,未自行改写
|
||||
[ ] GPT 一步出图,无 Sharp 叠字步骤
|
||||
[ ] 两个尺寸都输出(抖音 9:16 + 小红书 3:4)
|
||||
[ ] OSS 上传后 URL 回写 manifest
|
||||
```
|
||||
@@ -1,28 +1,122 @@
|
||||
{
|
||||
"id": "执黑先行二号-风格延伸",
|
||||
"name": "执黑先行二号-风格延伸",
|
||||
"description": "执黑先行风格延伸测试账号,用于探索新视觉风格,不影响主账号",
|
||||
"description": "执黑先行风格延伸账号,支持9种视觉风格切换。每个风格有独立参考图和提示词模板。",
|
||||
"pipeline": "image-video",
|
||||
"defaultFormat": "9:16",
|
||||
"imageModel": "gemini",
|
||||
"videoModel": "kling",
|
||||
"defaultStyle": "硬核线条",
|
||||
"imageModel": "gpt-image",
|
||||
"videoModel": "veo3-fast-frames",
|
||||
"batchSize": 30,
|
||||
"styles": {
|
||||
"执黑先行二号-风格延伸": {
|
||||
"references": []
|
||||
"剪纸报": {
|
||||
"references": [
|
||||
"references/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png"
|
||||
],
|
||||
"imagePrompt": "prompts/剪纸报/图片提示词.md",
|
||||
"coverFont": "方正粗宋或方正剪纸简体,粗犷有力有手工感"
|
||||
},
|
||||
"反向达尔文": {
|
||||
"references": [
|
||||
"references/反向达尔文/微信图片_20260522232915_271_18.png",
|
||||
"references/反向达尔文/微信图片_20260522232915_276_18.png",
|
||||
"references/反向达尔文/微信图片_20260522232915_279_18.png"
|
||||
],
|
||||
"imagePrompt": "prompts/反向达尔文/图片提示词.md",
|
||||
"coverFont": "造字工房力黑或思源黑体Heavy,粗壮有力反常规"
|
||||
},
|
||||
"梦核人物": {
|
||||
"references": [
|
||||
"references/梦核人物/微信图片_20260522232203_259_18.png",
|
||||
"references/梦核人物/微信图片_20260522232204_260_18.png",
|
||||
"references/梦核人物/微信图片_20260522232204_261_18.png",
|
||||
"references/梦核人物/微信图片_20260522232204_262_18.png",
|
||||
"references/梦核人物/微信图片_20260522232204_263_18.png",
|
||||
"references/梦核人物/微信图片_20260522232204_264_18.png",
|
||||
"references/梦核人物/微信图片_20260522232204_265_18.png"
|
||||
],
|
||||
"imagePrompt": "prompts/梦核人物/图片提示词.md",
|
||||
"coverFont": "思源宋体Medium或方正兰亭黑,清冷有距离感"
|
||||
},
|
||||
"梦核怪异城市": {
|
||||
"references": [
|
||||
"references/梦核怪异城市/微信图片_20260522231537_252_18.png",
|
||||
"references/梦核怪异城市/微信图片_20260522231537_253_18.png",
|
||||
"references/梦核怪异城市/微信图片_20260522231537_254_18.png",
|
||||
"references/梦核怪异城市/微信图片_20260522231537_255_18.png",
|
||||
"references/梦核怪异城市/微信图片_20260522231537_256_18.png",
|
||||
"references/梦核怪异城市/微信图片_20260522231537_257_18.png",
|
||||
"references/梦核怪异城市/微信图片_20260522231537_258_18.png"
|
||||
],
|
||||
"imagePrompt": "prompts/梦核怪异城市/图片提示词.md",
|
||||
"coverFont": "造字工房力黑或思源黑体Heavy,冷硬工业感"
|
||||
},
|
||||
"梦核水池": {
|
||||
"references": [
|
||||
"references/梦核水池/微信图片_20260522230621_239_18.png",
|
||||
"references/梦核水池/微信图片_20260522230621_245_18.png",
|
||||
"references/梦核水池/微信图片_20260522232446_266_18.png",
|
||||
"references/梦核水池/微信图片_20260522232447_267_18.png",
|
||||
"references/梦核水池/微信图片_20260522232447_268_18.png",
|
||||
"references/梦核水池/微信图片_20260522232447_269_18.png",
|
||||
"references/梦核水池/微信图片_20260522232447_270_18.png"
|
||||
],
|
||||
"imagePrompt": "prompts/梦核水池/图片提示词.md",
|
||||
"coverFont": "思源黑体Light或方正细等线,疏离清冷"
|
||||
},
|
||||
"梦核花池": {
|
||||
"references": [
|
||||
"references/梦核花池/微信图片_20260522231256_246_18.png",
|
||||
"references/梦核花池/微信图片_20260522231256_247_18.png",
|
||||
"references/梦核花池/微信图片_20260522231256_248_18.png",
|
||||
"references/梦核花池/微信图片_20260522231256_249_18.png",
|
||||
"references/梦核花池/微信图片_20260522231256_250_18.png",
|
||||
"references/梦核花池/微信图片_20260522231256_251_18.png"
|
||||
],
|
||||
"imagePrompt": "prompts/梦核花池/图片提示词.md",
|
||||
"coverFont": "方正兰亭黑或思源宋体Light,梦幻轻柔"
|
||||
},
|
||||
"毛毡风格": {
|
||||
"references": [
|
||||
"references/毛毡风格/0d5d2aad-496f-46ae-8467-80de136242ae.png"
|
||||
],
|
||||
"imagePrompt": "prompts/毛毡风格/图片提示词.md",
|
||||
"coverFont": "方正少儿或造字工房童真,温暖手工感"
|
||||
},
|
||||
"水墨": {
|
||||
"references": [
|
||||
"references/水墨/5bbcc329-dd8e-4b84-b901-97daa00943c9.png"
|
||||
],
|
||||
"imagePrompt": "prompts/水墨/图片提示词.md",
|
||||
"coverFont": "方正榜书行楷或方正启体,书法韵味"
|
||||
},
|
||||
"硬核线条": {
|
||||
"references": [
|
||||
"references/硬核线条/微信图片_20260522223358_119_18.png",
|
||||
"references/硬核线条/微信图片_20260522223358_120_18.png",
|
||||
"references/硬核线条/微信图片_20260522223358_121_18.png",
|
||||
"references/硬核线条/微信图片_20260522223358_122_18.png",
|
||||
"references/硬核线条/微信图片_20260522223358_125_18.png",
|
||||
"references/硬核线条/微信图片_20260522223359_126_18.png",
|
||||
"references/硬核线条/微信图片_20260522223359_127_18.png",
|
||||
"references/硬核线条/微信图片_20260522223359_128_18.png"
|
||||
],
|
||||
"imagePrompt": "prompts/硬核线条/图片提示词.md",
|
||||
"coverFont": "思源黑体Heavy或造字工房力黑,几何感有冲击力"
|
||||
}
|
||||
},
|
||||
"ttsVoice": "谢尔比",
|
||||
"ttsRate": 1.3,
|
||||
"ttsVoice": "不羁青年",
|
||||
"ttsRate": 1.15,
|
||||
"ttsInstruction": "用一种过来人的口吻说话,像是经历了太多懒得废话的大哥,语气里带点漫不经心和不耐烦。声音不用太用力,轻描淡写但每个字都砸在点上。偶尔轻哼一口气,显得很松弛。",
|
||||
"cover": {
|
||||
"enabled": true,
|
||||
"template": "references/cover_template.png",
|
||||
"formats": ["9:16", "3:4"],
|
||||
"channels": { "9:16": "抖音", "3:4": "小红书" },
|
||||
"style": "动态场景人物+大字封面:主体人物动作场景占画面60-70%,核心文字大字居中或上下分布,背景深暗,暖橙-冷蓝双光源对抗,漫威色调半写实。参考新视觉风格方向。",
|
||||
"textSource": "topicA",
|
||||
"imageModel": "gpt-image"
|
||||
"style": "封面视觉风格跟随当前选择的视觉风格,参考图 = 当前风格的references。大字居中,字体搭配风格气质。",
|
||||
"textSource": "topicB",
|
||||
"textRule": "使用 topicA 和 topicB 中字数较短者作为封面文字。如字数相同,优先 topicB(短标题,更直接)。",
|
||||
"imageModel": "gpt-image",
|
||||
"coverPromptRule": "基于本期文案全文理解其核心冲突和情绪,生成一个封面画面提示词。风格参考图 = 当前风格的 references 目录下所有图。封面文字居中或上下分布,字号大。整体画面需与文案核心观点共振。"
|
||||
},
|
||||
"storyboardPrompt": "prompts/分镜.md",
|
||||
"imageStylePrompt": "prompts/图片提示词.md",
|
||||
|
||||
56
accounts/执黑先行二号-风格延伸/prompts/剪纸报/图片提示词.md
Normal file
56
accounts/执黑先行二号-风格延伸/prompts/剪纸报/图片提示词.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 剪纸报 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**中国剪纸+报纸拼贴**。传统剪纸造型(人物/物件简化成几何轮廓)叠加报纸印刷质感,红黑白为主,粗犷有力。
|
||||
媒介感:paper-cut silhouette, newspaper halftone texture, bold woodblock print, flat graphic composition
|
||||
色彩系统:中国红 + 墨黑 + 旧报纸米白 + 少量金箔点缀
|
||||
光影系统:无真实光影,纯平面色块对比,黑色硬边阴影作为装饰分割线而非真实光照
|
||||
构图特征:平面构成式,对称中有破局,大量留白但有力量感
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:中国红/墨黑/旧报米白 三色主导,金箔点缀
|
||||
主色调:大红 #CC0000 / 墨黑 #1a1a1a / 米白 #f5f0e8
|
||||
辅助色:金箔 #C9A96E(仅文字或边饰,不超画面5%)
|
||||
整体氛围:粗犷有力、民间智慧、大巧不工
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:无真实光源 — 纯平面色块关系
|
||||
"光感":依靠红黑对比制造视觉冲击,亮部=红色块,暗部=黑色块
|
||||
阴影:黑色硬边几何阴影(装饰性,非物理阴影)
|
||||
氛围层:旧报纸网点纹理叠加,微做旧感
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止真实光影和渐变
|
||||
- 禁止写实人物/3D建模
|
||||
- 禁止超过4种颜色
|
||||
- 禁止细线条——线条至少3px粗
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
chinese paper-cut newspaper collage style, bold red black and off-white color palette, flat graphic composition, woodblock print texture, halftone newspaper grain, folk art meets propaganda poster aesthetic, no gradients, no realistic shading, strong geometric silhouettes
|
||||
|
||||
**MJ 版:**
|
||||
chinese paper-cut style, newspaper collage, red black cream color palette, flat vector graphics, bold silhouettes, halftone texture, woodblock print, graphic poster art --ar 9:16 --style raw --no 3d, realistic, gradient, photograph
|
||||
|
||||
**GPT Image 版:**
|
||||
Chinese traditional paper-cut art fused with newspaper collage aesthetic. Flat graphic silhouettes in bold red (#CC0000), ink black, and aged newspaper cream. Halftone dot grain texture overlay. Folk art energy with propaganda-poster boldness. No realistic lighting or 3D depth.
|
||||
|
||||
## 四、构图特有规则
|
||||
|
||||
- 人物和物件必须剪纸化——简化成外轮廓剪影,内部仅关键特征线
|
||||
- 文字/金句可入画——作为画面构成元素(仿报纸标题排版)
|
||||
- 留白区域保持 30-40%
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/反向达尔文/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/反向达尔文/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 反向达尔文 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**反向达尔文——强者淘汰、弱者胜出**的视觉悖论。颠倒自然法则的超现实画面,弱者凌驾强者,小吞噬大。
|
||||
媒介感:dark surrealism, biological uncanny, reverse evolution, symbolic power inversion
|
||||
色彩系统:深紫+暗绿+骨白+铁锈橙,生物实验室色调
|
||||
光影系统:单一冷光源从下方或侧面打来,unsettling, 法医/实验室灯光感
|
||||
构图特征:不对称,重心偏移到画面下半部,压抑感
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:深紫/暗绿/骨白,生物实验室+腐烂色调
|
||||
主色调:深紫 #2D1B4E / 暗绿 #1B3D2B / 骨白 #E8DDD0
|
||||
辅助色:铁锈橙 #B85C3A / 荧光绿 #4AF04A(仅小面积点缀)
|
||||
整体氛围:不适、悖论、自然法则被颠覆
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:单一冷荧光灯管,从下往上或从侧面切入
|
||||
光感:实验室/解剖台的冰冷感,不是戏剧光而是审视光
|
||||
阴影:长而不自然,像标本室灯光
|
||||
氛围层:微颗粒感,像旧生物教科书插图
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止温暖色调
|
||||
- 禁止对称构图
|
||||
- 禁止自然日光
|
||||
- 禁止和谐美好的画面
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
dark surrealist biological illustration, reverse evolution symbolism, unsettling single cold light source from below, purple-green-bone white palette, laboratory specimen aesthetic, asymmetric composition, the weak dominating the strong, scientific unease, vintage biology textbook grain
|
||||
|
||||
**MJ 版:**
|
||||
dark surrealism, reverse evolution, biological uncanny, cold laboratory lighting, deep purple dark green bone white, asymmetric horror, specimen room --ar 9:16 --style raw --no warm, sunlight, beautiful, harmonious
|
||||
|
||||
**GPT Image 版:**
|
||||
Dark surrealist biological illustration depicting reverse evolution — the weak overtaking the strong. Cold fluorescent light from below, purple-green-bone color palette. Laboratory specimen room atmosphere. Asymmetric composition with unsettling tension. Vintage scientific illustration grain.
|
||||
@@ -1,6 +1,8 @@
|
||||
# 图片提示词生成器 v1|执黑先行二号-风格延伸(测试账号)
|
||||
# 图片提示词生成器 v2|执黑先行二号-风格延伸(多风格账号)
|
||||
|
||||
> **版本说明**:本账号用于新视觉风格测试。§二(色彩与光影)和 §九(图像风格参考)待参考图分析后注入,其余结构框架与主账号一致。
|
||||
> **版本说明**:本账号支持 9 种视觉风格。本文为共享结构框架(铁律/维度/导演词库/冲突设计),每个风格的 **色彩光影规则、画风词尾、风格识别** 见独立文件:`prompts/{风格名}/图片提示词.md`。生成图片时,先读本共享框架,再读当前风格的专属规则文件。
|
||||
>
|
||||
> **可用风格**:剪纸报 | 反向达尔文 | 梦核人物 | 梦核怪异城市 | 梦核水池 | 梦核花池 | 毛毡风格 | 水墨 | 硬核线条
|
||||
|
||||
---
|
||||
|
||||
@@ -15,32 +17,11 @@
|
||||
|
||||
---
|
||||
|
||||
## 二、色彩与光影硬性规则(待参考图注入)
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
> ⚠️ **本节待定**:参考图分析完成后,由以下模板填入具体色彩与光影规则。
|
||||
> 在参考图确认前,暂时沿用执黑先行主账号的色彩规则作为测试基底。
|
||||
|
||||
### 当前测试基底(参考图确认后替换)
|
||||
|
||||
```
|
||||
色彩基调:【待参考图定义】
|
||||
主色调:【待参考图定义】
|
||||
辅助色:【待参考图定义】
|
||||
整体氛围:【待参考图定义】
|
||||
```
|
||||
|
||||
### 光影规则(待参考图定义)
|
||||
|
||||
```
|
||||
光源质感:【待参考图定义】
|
||||
光感:【待参考图定义】
|
||||
阴影处理:【待参考图定义】
|
||||
氛围层:【待参考图定义】
|
||||
```
|
||||
|
||||
### 禁止出现的光影(待参考图定义)
|
||||
|
||||
- 【待参考图定义——与新风格冲突的色系将在此列出】
|
||||
> 每个风格的色彩光影规则见 `prompts/{风格名}/图片提示词.md` 的「§二、色彩与光影硬性规则」。
|
||||
> 生成 imagePrompt 前必须读取当前风格的专属规则文件,使用其中定义的色彩基调、光影规则和禁止项。
|
||||
> 如果当前风格的 §二 为待定状态 → 则回退使用执黑先行主账号色彩规则作为测试基底。
|
||||
|
||||
---
|
||||
|
||||
@@ -96,7 +77,7 @@
|
||||
硬边阴影几何块(hard geometric shadow blocks),
|
||||
高级时装感构图,clean-cut 少年感穿搭,高级简洁,现代都市冷酷感
|
||||
|
||||
画风补充:【待参考图定义画风词】
|
||||
画风补充:使用当前风格专属规则文件中的「画风词尾」→ 根据目标模型选择对应版本(Gemini/MJ/GPT Image)
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
@@ -111,7 +92,7 @@ no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
人物站在光影交界处,effortless cool,
|
||||
动作驱动构图(action-driven composition)
|
||||
|
||||
画风补充:【待参考图定义画风词】
|
||||
画风补充:使用当前风格专属规则文件中的「画风词尾」→ 根据目标模型选择对应版本(Gemini/MJ/GPT Image)
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
@@ -126,7 +107,7 @@ no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
克制但有视觉张力,clean-cut 少年感,
|
||||
都市年轻人的孤独感,高级感留白,浪漫夜色氛围(只一点点)
|
||||
|
||||
画风补充:【待参考图定义画风词】
|
||||
画风补充:使用当前风格专属规则文件中的「画风词尾」→ 根据目标模型选择对应版本(Gemini/MJ/GPT Image)
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
```
|
||||
@@ -157,16 +138,14 @@ imagePrompt 必须按以下顺序写全以下维度,不得遗漏:
|
||||
[5. 情绪与氛围]
|
||||
这个画面传递什么情绪/什么氛围/旁白的什么情绪被视觉化了
|
||||
|
||||
[6. 色彩与光影硬性层(必须使用 §二 硬性规则)]
|
||||
参考图定义的色彩规则(待填入)
|
||||
[6. 色彩与光影硬性层(使用当前风格专属规则 §二)]
|
||||
参考当前风格 `prompts/{风格名}/图片提示词.md` 中定义的色彩规则
|
||||
|
||||
[7. 导演构图层]
|
||||
来自 directorRef 指定导演的完整构图词组(不替换,不混合)
|
||||
|
||||
[8. 画风词尾(待参考图定义,固定不变)]
|
||||
【参考图分析后替换此处为新风格的固定词尾】
|
||||
cinematic composition, full bleed, no border, no frame, no margin,
|
||||
no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
[8. 画风词尾(当前风格专属,目标模型版本)]
|
||||
使用当前风格 `prompts/{风格名}/图片提示词.md` 中对应目标模型的画风词尾,完整附加
|
||||
```
|
||||
|
||||
---
|
||||
@@ -209,28 +188,10 @@ no text, no watermark, no logo. Vertical format, aspect ratio 9:16.
|
||||
|
||||
---
|
||||
|
||||
## 九、图像风格参考(待参考图注入)
|
||||
## 九、图像风格参考
|
||||
|
||||
> ⚠️ **本节待定**:参考图上传并分析后,在此填入新风格的完整描述。
|
||||
|
||||
### 风格识别(待定)
|
||||
|
||||
```
|
||||
核心风格:【待参考图分析】
|
||||
媒介感:【待参考图分析】
|
||||
色彩系统:【待参考图分析】
|
||||
光影系统:【待参考图分析】
|
||||
构图特征:【待参考图分析】
|
||||
```
|
||||
|
||||
### 通用图像提示词模板(待定)
|
||||
|
||||
```
|
||||
【参考图分析完成后,在此生成三个模型版本的风格词:
|
||||
- Gemini 版:英文自然语言句子
|
||||
- MJ 版:英文逗号短语 + --ar 9:16 --style raw
|
||||
- Kling 版:中文描述】
|
||||
```
|
||||
> 每个风格的完整风格识别、画风词尾(三个模型版本)见 `prompts/{风格名}/图片提示词.md` 的「§一、风格识别」和「§三、画风词尾」。
|
||||
> 生成 imagePrompt 时必须先读取当前风格的专属规则文件。
|
||||
|
||||
---
|
||||
|
||||
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/梦核人物/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/梦核人物/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 梦核人物 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**Dreamcore 梦核人物**。人物悬浮/漂浮于水中或虚空中,面部平静但环境诡异,制造熟悉又陌生的不安感。
|
||||
媒介感:dreamcore digital art, liminal character portrait, floating in void/water, ethereal surrealism
|
||||
色彩系统:深青绿/冷蓝/柔粉/紫雾,水下或暮光色调
|
||||
光影系统:漫射光从上方穿透水面,或柔光从人物面部散发,无硬阴影
|
||||
构图特征:人物居中或偏上悬浮,大量负空间,孤独感
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:深青绿水 + 暮光蓝紫 + 皮肤柔粉,水下梦境
|
||||
主色调:深青绿 #0D3B3B / 冷蓝 #2C3E6B / 紫雾 #5B4A7A
|
||||
辅助色:柔粉 #E8C4B8(皮肤)/ 白花瓣 #F5F0F0(点缀)
|
||||
整体氛围:漂浮、孤独、平静中带不安、阈限感
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:漫射顶光,像水面上方有光穿透下来
|
||||
光感:柔焦、朦胧、边缘发光(rim light),不是锐利光
|
||||
阴影:几乎无硬阴影,暗部来自水体深度而非遮挡
|
||||
氛围层:微光粒子/花瓣/气泡悬浮,underwater caustics
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止锐利阴影
|
||||
- 禁止暖调日光
|
||||
- 禁止人物直接看镜头(侧脸/俯视/闭眼为主)
|
||||
- 禁止地面/硬表面——人物必须悬浮或浮在水中
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
dreamcore digital illustration, figure floating suspended in deep teal water or void, soft diffused top light filtering through surface, ethereal rim glow, floating flower petals, liminal uneasy tranquility, soft focus, no sharp shadows, underwater caustics, melancholic serenity
|
||||
|
||||
**MJ 版:**
|
||||
dreamcore aesthetic, figure floating in water void, soft diffused light, teal blue purple palette, ethereal surreal, liminal space, floating petals, soft focus glow --ar 9:16 --style raw --no sharp, harsh light, ground, direct eye contact
|
||||
|
||||
**GPT Image 版:**
|
||||
Dreamcore digital art of a figure floating suspended in deep teal water/void. Soft diffused light filtering from above like underwater sunlight. Ethereal rim glow around the silhouette. Floating white petals. Liminal, melancholic tranquility with subtle unease. No sharp shadows, no ground plane.
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/梦核怪异城市/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/梦核怪异城市/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 梦核怪异城市 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**Dreamcore 怪异城市**。超现实反乌托邦城市场景,建筑扭曲/折叠/融化,霓虹灯在雾中流血,巨物恐惧。
|
||||
媒介感:surreal dystopian cityscape, brutalist architecture bending, neon bleeding in fog, megalophobia
|
||||
色彩系统:混凝土灰+琥珀雾+霓虹血红+冷蓝夜
|
||||
光影系统:雾中散射霓虹光,amber fog glow,建筑表面冷硬反射
|
||||
构图特征:极端仰角或俯角,建筑占据画面80%,人物极小作尺度参照
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:混凝土灰 + 琥珀色雾 + 霓虹血红
|
||||
主色调:混凝土灰 #3D3D3D / 琥珀雾 #C8782A / 冷蓝夜 #1A2436
|
||||
辅助色:霓虹红 #FF1A1A / 暗金 #8B6914(窗户光点)
|
||||
整体氛围:窒息、巨物压迫、城市是活体怪物
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:霓虹灯管和琥珀色雾中散射光
|
||||
光感:雾吞光——光源在雾中形成光晕而非直射
|
||||
阴影:建筑投影长而扭曲,不遵循物理透视
|
||||
氛围层:浓雾、霓虹光晕、建筑表面冰冷反射
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止晴天/日光
|
||||
- 禁止普通城市街景
|
||||
- 禁止建筑是直的——至少一处扭曲/折叠
|
||||
- 禁止画面中出现超过2个人物(保持孤独感)
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
surreal dystopian cityscape photography, brutalist tower folding inward at impossible angles, amber fog swallowing upper floors, neon red bleeding into concrete, lone tiny silhouette at base for scale, megalophobia, cinematic cold blue night, no daylight, architecture as living monster
|
||||
|
||||
**MJ 版:**
|
||||
dystopian surreal cityscape, brutalist architecture bending, amber fog, neon crimson bleed, megalophobia, lone silhouette, cold blue night, impossible geometry --ar 9:16 --style raw --no daylight, normal building, sunny
|
||||
|
||||
**GPT Image 版:**
|
||||
Surreal dystopian cityscape. Brutalist towers folding and twisting at impossible angles. Amber-colored fog consuming upper floors. Neon crimson light bleeding through haze into wet concrete. A lone tiny human silhouette at the base for scale. Megalophobia. Architecture as living, breathing monster. Cold blue night.
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/梦核水池/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/梦核水池/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 梦核水池 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**Dreamcore 阈限水池**。空旷室内泳池/水池空间,陶瓷砖,骨白色,冷蓝灰调。一人独处巨大空池。阈限空间美学。
|
||||
媒介感:liminal space architectural photography, hyperrealistic, empty indoor pool, backrooms adjacent
|
||||
色彩系统:骨白瓷砖+冷蓝灰水+苍白银光,极简极冷
|
||||
光影系统:单一苍白顶光或侧光,水面倒映天花板,无暖色
|
||||
构图特征:大量负空间,人物极小在远处,对称或准对称
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:骨白/冷蓝灰/苍白银,极冷极简
|
||||
主色调:骨白瓷砖 #E8E4DD / 冷蓝灰水 #7B93A0 / 苍白银 #C8CCD0
|
||||
辅助色:湿灰 #5A6065 / 暗角黑 #1A1A1A(仅远处暗角)
|
||||
整体氛围:空旷、孤立、时间停止、阈限不安
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:单一苍白荧光或天窗自然光,从正上方或高侧
|
||||
光感:无暖意的白光,水面反射苍白天花板
|
||||
阴影:极淡,仅远处暗角有深阴影
|
||||
氛围层:水面微波纹、瓷砖湿润反光、空气中有微尘
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止任何暖色
|
||||
- 禁止超过2个人物
|
||||
- 禁止填满画面——负空间必须>50%
|
||||
- 禁止室外光线/阳光
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
liminal space architectural photography, hyperrealistic, empty indoor swimming pool, bone white ceramic tiles, cold blue-grey water, single pale white light shaft from above, lone figure at far end dwarfed by empty basin, negative space dominant, water reflection on ceiling, backrooms atmosphere, no warm tones
|
||||
|
||||
**MJ 版:**
|
||||
liminal space photography, empty indoor pool, bone white tiles, cold blue grey tones, single pale light, lone figure tiny in distance, backrooms aesthetic, hyperrealistic --ar 9:16 --style raw --no warm, sunlight, crowd, filled
|
||||
|
||||
**GPT Image 版:**
|
||||
Liminal space architectural photograph of a vast empty indoor swimming pool. Bone-white ceramic tiles. Cold blue-grey water perfectly still. A single pale silver-white light shaft descending from above. One tiny figure standing at the far end, dwarfed by the emptiness. Negative space over 50%. Eerie backrooms tranquility. No warm colors.
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/梦核花池/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/梦核花池/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 梦核花池 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**Dreamcore 花池**。静水面上覆盖花瓣,天空倒映在水中,人物站在池边或水中。梦幻+淡淡不安,花瓣掩盖了什么。
|
||||
媒介感:dreamcore flower pool aesthetic, surreal still water covered in petals, liminal garden
|
||||
色彩系统:柔粉+淡紫+苍白天空+深水暗绿,花瓣色调主导
|
||||
光影系统:阴天漫射光或暮光,花瓣在水面投下微影
|
||||
构图特征:水面占画面2/3,花瓣漂浮构成视觉引导线
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:柔粉花瓣 + 苍白天空倒映 + 暗绿水底
|
||||
主色调:柔粉 #E8B4B8 / 淡紫 #C4B0D0 / 苍白天 #D8DCE0
|
||||
辅助色:深水绿 #2D4A3A(水底深处)/ 纯白花瓣 #FFFFFF
|
||||
整体氛围:美丽+不安、覆盖=隐藏、梦幻阈限
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:阴天均匀漫射光,或暮光(太阳刚落)
|
||||
光感:柔、平、无方向性——花瓣是唯一色彩焦点
|
||||
阴影:极淡,仅花瓣在水面的微投影
|
||||
氛围层:水面镜面反射天空、花瓣缓慢漂移、微风水纹
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止强烈日光
|
||||
- 禁止鲜艳饱和色(花瓣外)
|
||||
- 禁止水面有波浪——必须是镜面或微纹
|
||||
- 禁止花瓣以外的视觉焦点
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
dreamcore flower pool aesthetic photography, luminous still pool surface covered in pale pink and white petals slowly drifting, mirror reflection of overcast dream sky, lone figure at water edge, melancholic serenity, hidden depths beneath beauty, muted pastel palette, liminal garden, no harsh light
|
||||
|
||||
**MJ 版:**
|
||||
dreamcore flower pool, still water covered in pale pink petals, overcast sky reflection, lone figure at edge, liminal aesthetic, pastel muted, melancholic dream --ar 9:16 --style raw --no harsh sunlight, waves, vibrant colors
|
||||
|
||||
**GPT Image 版:**
|
||||
Dreamcore flower pool photograph. Luminous still water surface covered in slowly drifting pale pink and white petals. Pool surface perfectly mirrors an overcast dream sky. A lone figure stands at the water's edge, face unseen. Beauty masking something beneath. Muted pastel palette. Liminal garden melancholia.
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/毛毡风格/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/毛毡风格/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 毛毡风格 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**毛毡/羊毛毡定格动画**。所有人物、物件、场景都是毛毡材质,手工感温暖但有重量。像是 Wes Anderson 遇上 Laika 工作室。
|
||||
媒介感:felt stop-motion animation, needle-felted wool, tactile handmade, miniature set
|
||||
色彩系统:温暖羊毛色调——焦糖棕/奶油白/橄榄绿/陶土红
|
||||
光影系统:柔和的微型场景布光,暖色桌面灯,毛毡表面吸光无锐利反射
|
||||
构图特征:微缩场景感,物件有手工不完美痕迹
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:暖羊毛色系——焦糖/奶油/橄榄/陶土
|
||||
主色调:焦糖棕 #C4956A / 奶油白 #F5F0E5 / 橄榄绿 #7B8B5A
|
||||
辅助色:陶土红 #C4674A / 芥末黄 #D4B860
|
||||
整体氛围:温暖、手工、质朴、有触感、微不完美
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:暖色桌面台灯,模拟定格动画布光
|
||||
光感:柔、暖、无锐利高光——毛毡表面吸光
|
||||
阴影:柔和投影,边缘模糊
|
||||
氛围层:毛毡纤维纹理可见、微缩场景浅景深
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止写实渲染/3D光滑表面
|
||||
- 禁止冷色调
|
||||
- 禁止完美几何——每件物品有手工痕迹
|
||||
- 禁止大景深——微缩场景用小景深
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
needle-felted wool stop-motion aesthetic, tactile handmade miniature set, warm caramel cream olive terracotta palette, soft warm desk lamp lighting, shallow depth of field, visible wool fiber texture, Wes Anderson meets Laika Studios, imperfect handcrafted charm, no smooth surfaces, no cold tones
|
||||
|
||||
**MJ 版:**
|
||||
felt stop-motion animation style, needle-felted wool miniature, warm caramel tones, soft desk lamp lighting, handmade tactile, shallow dof, Laika aesthetic --ar 9:16 --style raw --no 3d render, cold, smooth, perfect
|
||||
|
||||
**GPT Image 版:**
|
||||
Needle-felted wool stop-motion animation aesthetic. All subjects and environments made of tactile felt material. Warm caramel, cream, olive green, and terracotta palette. Soft warm desk lamp lighting with gentle shadows. Visible wool fiber texture. Shallow depth of field as miniature set. Handcrafted imperfections add charm. Wes Anderson meets Laika Studios.
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/水墨/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/水墨/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 水墨 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**中国传统水墨画**,宣纸质地,纯墨色阶,无彩色。枯笔飞白、浓淡干湿、留白即空间。
|
||||
媒介感:traditional Chinese ink wash painting on aged rice paper, masterwork, sumi-e
|
||||
色彩系统:纯墨色阶——焦墨/浓墨/重墨/淡墨/清墨 + 宣纸米白 + 朱砂印章(仅印)
|
||||
光影系统:无光——依靠墨色浓淡区分层次,"计白当黑",留白即空间
|
||||
构图特征:散点透视,留白占40-60%,题字+印章为画面构成
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:纯墨色 + 宣纸米白,无彩色
|
||||
主色调:焦墨 #0D0D0D / 浓墨 #1A1A1A / 重墨 #2D2D2D
|
||||
辅助色:淡墨 #6B6B6B / 清墨 #A8A8A8 / 宣纸 #F2EDE4 / 朱砂 #C41E1E(仅印章)
|
||||
整体氛围:留白、意境、克制、东方哲学
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:无光源——墨色浓淡=空间深度
|
||||
"光感":留白 = 光/气/水/空,不画光但光在其中
|
||||
阴影:墨色渲染即阴影,枯笔飞白=受光面
|
||||
氛围层:宣纸纤维纹理、墨迹渗透、笔触可见
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止任何彩色(除朱砂印章外)
|
||||
- 禁止西方透视
|
||||
- 禁止真实光影/投影
|
||||
- 禁止填满画面——留白必须≥40%
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
traditional Chinese ink wash painting on aged xuan rice paper, masterwork quality, pure ink gradients only — no color, dry brush flying white strokes, ink bleeding into paper fiber, negative space as visual element, lone scholar or figure in vast emptiness, literati painting aesthetic, red seal stamp only accent, no perspective depth, flat layered composition
|
||||
|
||||
**MJ 版:**
|
||||
chinese ink wash painting, sumi-e, aged rice paper, pure black ink gradients, dry brush, negative space, no color, literati style --ar 9:16 --style raw --no color, perspective, photograph, 3d
|
||||
|
||||
**GPT Image 版:**
|
||||
Traditional Chinese ink wash painting (sumi-e) on aged xuan rice paper. Pure black ink gradients from jet black to pale wash — absolutely no color except a tiny red seal stamp. Dry brush flying white strokes. Ink bleeding into paper fibers. Vast negative space (40%+). Literati painting aesthetic with calligraphic brushwork. Flat layered composition without Western perspective.
|
||||
50
accounts/执黑先行二号-风格延伸/prompts/硬核线条/图片提示词.md
Normal file
50
accounts/执黑先行二号-风格延伸/prompts/硬核线条/图片提示词.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 硬核线条 · 图片提示词规则
|
||||
|
||||
> **继承**:所有铁律、维度结构、导演词库、冲突设计沿用 `prompts/图片提示词.md` 共享框架。
|
||||
> 本文仅定义本风格的 **色彩光影、画风词尾、禁止项**。
|
||||
|
||||
---
|
||||
|
||||
## 一、风格识别
|
||||
|
||||
核心风格:**Bold Art Deco 几何平面插画**,硬边几何线条,纯平面色块,苏联构成主义+装饰艺术。力量感、控制感。
|
||||
媒介感:bold art deco graphic illustration, geometric flat forms, constructivist propaganda, hard edges
|
||||
色彩系统:深红+墨黑+冷金+象牙白,强对比
|
||||
光影系统:无真实光影——冷色光刃切割画面(装饰性几何光条),硬边阴影块
|
||||
构图特征:对角线分割,几何块面构成,主体居中强势
|
||||
|
||||
## 二、色彩与光影硬性规则
|
||||
|
||||
```
|
||||
色彩基调:深红/墨黑/象牙白 强对比,冷金色点缀
|
||||
主色调:深红 #8B0000 / 墨黑 #0D0D0D / 象牙白 #F5F0E8
|
||||
辅助色:冷金 #C9A96E / 钢蓝灰 #3D4A5C(冷光刃色)
|
||||
整体氛围:强势、控制、几何力量、不怒自威
|
||||
```
|
||||
|
||||
### 光影规则
|
||||
|
||||
```
|
||||
光源质感:无真实光源——几何光刃作为装饰性画面切割
|
||||
"光感":冷色光刃(钢蓝灰)对角切割画面,非自然光
|
||||
阴影:纯黑色硬边几何阴影块(装饰性)
|
||||
氛围层:平面色块对比,无渐变
|
||||
```
|
||||
|
||||
### 禁止
|
||||
|
||||
- 禁止真实光影/渐变
|
||||
- 禁止圆润曲线——所有边缘必须是硬直线或几何弧
|
||||
- 禁止超过5种颜色
|
||||
- 禁止软弱/犹豫的人物姿态
|
||||
|
||||
## 三、画风词尾(三个模型版本)
|
||||
|
||||
**Gemini 版:**
|
||||
bold art deco graphic illustration, geometric flat forms, constructivist propaganda style, deep red black and ivory palette, hard diagonal cold light blades slicing across frame, pure flat color blocks, no gradients, commanding figure in sharp geometric silhouette, money/power symbols as geometric ornaments, Soviet poster meets luxury art deco
|
||||
|
||||
**MJ 版:**
|
||||
art deco graphic illustration, constructivist, geometric flat forms, deep red black ivory palette, hard edges, cold light blades, propaganda poster, bold silhouettes --ar 9:16 --style raw --no gradient, realistic, photograph, soft, curved
|
||||
|
||||
**GPT Image 版:**
|
||||
Bold art deco graphic illustration with constructivist propaganda energy. Pure flat color blocks in deep red, black, and ivory white. Hard diagonal cold light blades slicing across the composition as geometric decoration. Commanding figures in sharp geometric silhouettes. Money and power symbols orbiting as flat geometric ornaments. No gradients. No soft edges. Every line is hard and intentional.
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/剪纸报/7b730552-efb6-426a-ae69-d2c8d396fe16.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/反向达尔文/微信图片_20260522232915_271_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/反向达尔文/微信图片_20260522232915_276_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/反向达尔文/微信图片_20260522232915_279_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232203_259_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_260_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_261_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_262_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_263_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_264_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核人物/微信图片_20260522232204_265_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_252_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_253_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_254_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_255_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_256_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_257_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核怪异城市/微信图片_20260522231537_258_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_239_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522230621_245_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232446_266_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232447_267_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232447_268_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232447_269_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核水池/微信图片_20260522232447_270_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_246_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_247_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_248_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_249_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_250_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/梦核花池/微信图片_20260522231256_251_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/毛毡风格/0d5d2aad-496f-46ae-8467-80de136242ae.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/水墨/5bbcc329-dd8e-4b84-b901-97daa00943c9.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_119_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_120_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_121_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_122_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223358_125_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223359_126_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223359_127_18.png
|
||||
@@ -0,0 +1 @@
|
||||
/Users/lc/Pictures/参考图片/硬核线条/微信图片_20260522223359_128_18.png
|
||||
Reference in New Issue
Block a user