refactor: generate.js v2.0 · 动态模板注册表驱动 · 移除硬编码import · 新增listTemplates导出 · CLI增强

This commit is contained in:
bingshuo 2026-05-27 18:31:48 +08:00
parent 991452b544
commit f348d89272

View File

@ -1,221 +1,185 @@
#!/usr/bin/env node
/** /**
* *
* 铸渊图片工作室 · 神笔马良模式 * 铸渊封面工作室 · 生成引擎 · v2.0
* *
* *
* 冰朔你只需要告诉我内容和场景 * 动态模板加载新增模板只需
* 我来理解设计生成 * 1. 创建 templates/xxx.js
* * 2. templates/registry.js 注册
* 用法对话中我调用 * 3. 此文件零改动
* fromText("内容...", { for: "小红书" })
* fromText("内容...", { for: "海报", mood: "温暖" })
*
* 或直接调用底层
* generate({ design: {...}, content: {...} })
*/ */
import { renderToImage, renderCarousel } from './renderer.js' import { renderToImage, renderCarousel } from './renderer.js'
import { import {
xiaohongshuCard, xiaohongshuCarousel TEMPLATE_REGISTRY,
} from './templates/xiaohongshu.js' listTemplates,
import { jikeCard } from './templates/jike.js' getTemplate,
import { posterCard } from './templates/poster.js' getDefaultTemplate,
import { dynamicCard } from './templates/dynamic.js' prepareRenderData,
} from './templates/registry.js'
import { import {
STYLES, useScheme, analyzeText,
analyzeText, recommendScheme, recommendLayout, recommendSize recommendScheme,
recommendLayout,
} from './config.js' } from './config.js'
/* /*
* 神笔马良 · 主入口 * 神笔马良 · 自动分析 + 生成
*
* 冰朔给我一段自由文字和场景提示
* 我自动分析内容推荐设计生成图片
* */ * */
/** /**
* @param {string} text - 冰朔说的内容自由文本 * @param {string} text - 内容自由文本
* @param {object} hint - 场景提示可选 * @param {object} options
* @param {string} hint.for - 发在哪"小红书" / "即刻" / "海报" / ... * @param {string} options.templateId - 模板ID默认自动选择
* @param {string} hint.mood - 情绪"温暖" / "正式" / "极简" / ... * @param {string} options.presetId - 预设风格ID
* @param {string} hint.scheme - 配色方案名可选覆盖自动推荐 * @param {string} options.title - 标题不传则从文本提取
* @param {string} hint.title - 如果冰朔没有明确标题我帮她提炼 * @param {string} options.layout - 布局
* @param {string} hint.output - 输出文件名 * @param {string} options.output - 输出文件名
* @returns {Promise<{files: string[], analysis: object}>} * @returns {Promise<{files: string[], analysis: object}>}
*/ */
export async function fromText(text, hint = {}) { export async function fromText(text, options = {}) {
/* 1. 分析内容 */ // 分析内容
const analysis = analyzeText(text) const analysis = analyzeText(text)
/* 2. 确定平台/尺寸 */ // 选择模板
const platform = (hint.for || '').toLowerCase() const template = options.templateId
const size = hint.width && hint.height ? getTemplate(options.templateId)
? { width: hint.width, height: hint.height } : getDefaultTemplate()
: recommendSize(platform)
/* 3. 确定配色 */ if (!template) {
const schemeName = hint.scheme || recommendScheme(analysis) throw new Error('没有可用的模板')
useScheme(schemeName) }
/* 4. 确定布局 */ // 提取内容
const layout = hint.layout || recommendLayout(analysis)
/* 5. 提取/组织内容 */
const lines = text.split('\n').filter(Boolean) const lines = text.split('\n').filter(Boolean)
const title = hint.title || lines[0] || '' const title = options.title || lines[0] || ''
const bodyLines = hint.title ? lines : lines.slice(1) const bodyLines = options.title ? lines : lines.slice(1)
const body = bodyLines.join('\n') const body = bodyLines.join('\n')
/* 6. 检测海报关键词 */ // 自动选择布局
const isPoster = analysis.detected.notice || analysis.detected.recruit const layout = options.layout
|| /海报/i.test(platform) || recommendLayout(analysis)
|| 'default'
const isJike = /即刻/i.test(platform) || (size.width === size.height && !isPoster) // 自动选择预设
const isXiaohongshu = /小红书/i.test(platform) || size.height > size.width * 1.2 let presetId = options.presetId
if (!presetId && template.presets.length > 0) {
/* 7. 生成 */ const scheme = recommendScheme(analysis)
let files = [] const presetMap = {
const baseName = hint.output || `card_${Date.now()}` cream: 'warm',
rose: 'rose',
if (isPoster) { green: 'green',
/* ── 走海报模板,但动态匹配风格 ── */ night: 'dark',
const style = analysis.detected.notice ? 'notice' }
: analysis.detected.recruit ? 'recruit' presetId = presetMap[scheme] || template.presets[0].id
: 'default'
// 尝试从文本中提取详细信息
const detailLines = bodyLines.filter(l =>
//.test(l) || /时间|地点|要求|联系人|电话|邮箱|地址/.test(l)
)
const mainBody = bodyLines.filter(l => !detailLines.includes(l)).join('\n')
const html = posterCard({
title,
body: mainBody,
subtitle: analysis.type === 'quote' ? title : '',
tag: analysis.detected.notice ? '通 知' : analysis.detected.recruit ? '征 稿' : '',
details: detailLines,
cta: '',
footer: '',
style,
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
else if (isJike) {
/* ── 走即刻风格 ── */
const html = jikeCard({
title,
body,
meta: analysis.type === 'quote' ? '' : '',
emoji: analysis.mood === 'warm' ? '💛' : '✦',
layout: analysis.isShort ? 'quote' : 'default',
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
else if (isXiaohongshu) {
/* ── 走小红书风格 ── */
const html = xiaohongshuCard({
title,
body,
subtitle: '',
tag: '',
layout: analysis.isShort ? 'quote'
: analysis.type === 'list' ? 'default'
: 'default',
})
files = [await renderToImage(html, { ...size, name: baseName })]
}
else {
/* ── 动态模板 · 万能兜底 ── */
const html = dynamicCard({
title,
body,
lines: analysis.lines,
type: analysis.type,
})
files = [await renderToImage(html, { ...size, name: baseName })]
} }
return { files, analysis: { ...analysis, scheme: schemeName, size } } // 准备渲染数据
const renderData = prepareRenderData(template.id, presetId, {
title,
body,
subtitle: options.subtitle || '',
tag: options.tag || '',
layout,
})
if (!renderData) {
throw new Error(`模板 "${template.id}" 不存在`)
}
// 渲染
const html = template.render(renderData)
const baseName = options.output || `cover_${Date.now()}`
const file = await renderToImage(html, {
...template.sizes,
name: baseName,
format: 'png',
})
return {
files: [file],
analysis: {
...analysis,
templateId: template.id,
templateName: template.name,
presetId: renderData.presetId,
layout: renderData.layout,
sizes: template.sizes,
},
}
} }
/* /*
* 底层 generate 直接指定所有参数 * 精确生成 · 指定所有参数
* */ * */
export async function generate(opts = {}) { export async function generate(opts = {}) {
// 如果给的是自由文本,走 fromText
if (opts.text) {
return fromText(opts.text, {
templateId: opts.templateId,
presetId: opts.presetId,
title: opts.title,
subtitle: opts.subtitle,
tag: opts.tag,
layout: opts.layout,
output: opts.output,
})
}
const { const {
templateId = '',
presetId = '',
title = '', title = '',
body = '', body = '',
subtitle = '', subtitle = '',
tag = '', tag = '',
quote = '',
emoji = '',
footer = '',
details = [],
cta = '',
layout = 'default', layout = 'default',
style = 'default',
scheme = '',
output = '', output = '',
width, width,
height, height,
platform = '',
type = '',
} = opts } = opts
// 如果给的是自由文本,走 fromText const template = templateId ? getTemplate(templateId) : getDefaultTemplate()
if (opts.text) { if (!template) {
return fromText(opts.text, { throw new Error(`模板不存在: ${templateId || '(无默认模板)'}` )
for: opts.for || platform,
mood: opts.mood,
scheme: opts.scheme,
title: opts.title,
output: opts.output,
width: opts.width,
height: opts.height,
layout: opts.layout,
})
} }
// 尺寸 const renderData = prepareRenderData(template.id, presetId, {
const size = width && height title,
body,
subtitle,
tag,
layout,
})
const sizes = width && height
? { width, height } ? { width, height }
: (STYLES.sizes[type] || STYLES.sizes.square) : template.sizes
if (scheme) useScheme(scheme) const html = template.render({ ...renderData, sizes })
const baseName = output || `cover_${Date.now()}`
const file = await renderToImage(html, { ...sizes, name: baseName })
const baseName = output || `card_${Date.now()}` return {
files: [file],
/* ── 检测内容特征,自动选模板 ── */ templateId: template.id,
const isPoster = details.length > 0 || cta templateName: template.name,
|| /notice|recruit|海报/.test(style) presetId: renderData.presetId,
const isJike = type === 'jike' || layout === 'quote' layout: renderData.layout,
const isShort = !body && !details && !cta
let html, files
if (isPoster) {
html = posterCard({ title, subtitle, body, details, cta, footer, tag, style })
files = [await renderToImage(html, { ...size, name: baseName })]
} }
else if (isJike) {
html = jikeCard({ title, body, meta: subtitle, emoji, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
}
else {
html = xiaohongshuCard({ title, body, subtitle, tag, quote, footer, layout })
files = [await renderToImage(html, { ...size, name: baseName })]
}
return { files, type }
} }
/*
* 导出模板列表 API 使用
* */
export { listTemplates, getTemplate, getDefaultTemplate }
/* /*
* CLI 入口 * CLI 入口
* */ * */
@ -231,74 +195,44 @@ async function cli() {
if (args[i].startsWith('--')) break if (args[i].startsWith('--')) break
values.push(args[i]) values.push(args[i])
} }
return values.length === 0 ? undefined return values.length === 1 ? values[0] : values.join(' ')
: values.length === 1 ? values[0]
: values
} }
// 神笔马良模式:--text "内容" --for "小红书"
const text = getArg('text') || getArg('t') const text = getArg('text') || getArg('t')
if (text) { if (text) {
console.log(`🎨 铸渊正在理解你的内容...`) console.log('🎨 铸渊正在理解你的内容...')
const hint = { const result = await fromText(text, {
for: getArg('for') || getArg('f') || '', templateId: getArg('template'),
mood: getArg('mood') || '', presetId: getArg('preset'),
scheme: getArg('scheme') || '', title: getArg('title'),
title: getArg('title') || '', output: getArg('output'),
output: getArg('output') || '', })
} console.log('✅ 生成完成!')
const result = await fromText(text, hint) console.log(` 📐 模板: ${result.analysis.templateName}`)
console.log(`✅ 生成完成!`) console.log(` 🎨 预设: ${result.analysis.presetId}`)
console.log(` 📐 检测类型: ${result.analysis.type}`) console.log(` 📐 布局: ${result.analysis.layout}`)
console.log(` 🎨 配色方案: ${result.analysis.scheme}`)
result.files.forEach(f => console.log(` 📄 ${f}`)) result.files.forEach(f => console.log(` 📄 ${f}`))
return return
} }
// 传统模式 // 帮助
const type = args.find(a => !a.startsWith('--')) console.log(`
if (!type || type === 'help') { 铸渊封面工作室 v2.0 · 神笔马良
console.log(`
铸渊图片工作室 · 神笔马良
🎨 模式推荐 🎨 自动模式推荐
node generate.js --text "你的内容" --for "发在哪" node generate.js --text "你的内容" --template xiaohongshu --preset tech
📐 传统模式 📋 可用模板
node generate.js <类型> --title "标题" --body "内容" ${listTemplates().map(t => `· ${t.icon} ${t.name} (${t.id}) — ${t.description}`).join('\n ')}
🎨 可用预设
${Object.values(TEMPLATE_REGISTRY).flatMap(t =>
t.presets.map(p => `· ${p.name} (${p.id}) — 来自 ${t.name}`)
).join('\n ')}
示例: 示例:
node generate.js --text "分享三个提高效率的方法\n1. 早上先做最难的事\n2. 一次只做一件事" --for "小红书" node generate.js --text "零基础手搓AI封面生成器\n- 不需要GPU\n- 一行命令部署\n- 语言驱动排版" --template xiaohongshu --preset tech
node generate.js --text "五一放假通知\n时间5月1日-5日\n祝大家节日快乐" --for "海报"
node generate.js --text "岁月不居,时节如流" --for "即刻"
`) `)
process.exit(0)
}
const opts = {
type,
title: getArg('title') || '',
body: (getArg('body') || '').replace(/\\n/g, '\n'),
subtitle: getArg('subtitle') || '',
tag: getArg('tag') || '',
quote: getArg('quote') || '',
emoji: getArg('emoji') || '',
footer: getArg('footer') || '',
details: (() => {
const d = getArg('details')
return Array.isArray(d) ? d : (d ? [d] : [])
})(),
cta: getArg('cta') || '',
layout: getArg('layout') || 'default',
style: getArg('style') || 'default',
scheme: getArg('scheme') || '',
output: getArg('output') || '',
}
console.log(`🎨 生成${type}卡片...`)
const result = await generate(opts)
console.log(`✅ 生成完成!`)
result.files.forEach(f => console.log(` 📄 ${f}`))
} }
if (process.argv[1] && process.argv[1].includes('generate.js')) { if (process.argv[1] && process.argv[1].includes('generate.js')) {