diff --git a/image-studio/config.js b/image-studio/config.js index 0cdac14..1cf04e0 100644 --- a/image-studio/config.js +++ b/image-studio/config.js @@ -36,11 +36,14 @@ export const STYLES = { mono: '"JetBrains Mono", "Fira Code", monospace', }, - /* 卡片尺寸 */ + /* 卡片尺寸 —— 这些只是参考,可以随时改 */ sizes: { xiaohongshu: { width: 1080, height: 1440 }, // 3:4 jike: { width: 1080, height: 1080 }, // 1:1 poster: { width: 1080, height: 1920 }, // 9:16 海报 + wechat: { width: 1080, height: 1350 }, // 4:5 朋友圈 + square: { width: 1080, height: 1080 }, // 1:1 通用方图 + wide: { width: 1920, height: 1080 }, // 16:9 宽图 }, /* 排版 */ @@ -70,55 +73,170 @@ export const STYLES = { } -/* ── 预先定义几套配色方案 ── */ - +/* ── 配色方案 ── */ export const COLOR_SCHEMES = { - - /* 光湖极简(默认) */ guanghu: { name: '光湖极简', - colors: { - primary: '#1a1a2e', bg: '#faf8f5', accent: '#0f3460', - highlight: '#e94560', gold: '#c9a96e', warm: '#f5e6c8', - } + primary: '#1a1a2e', bg: '#faf8f5', accent: '#0f3460', + highlight: '#e94560', gold: '#c9a96e', warm: '#f5e6c8', }, - - /* 奶油暖调 */ cream: { name: '奶油暖调', - colors: { - primary: '#5d4037', bg: '#fef7f0', accent: '#8d6e63', - highlight: '#e07a5f', gold: '#d4a373', warm: '#fae1dd', - } + primary: '#5d4037', bg: '#fef7f0', accent: '#8d6e63', + highlight: '#e07a5f', gold: '#d4a373', warm: '#fae1dd', }, - - /* 暗夜深蓝 */ night: { name: '暗夜深蓝', - colors: { - primary: '#e0e0e0', bg: '#0d1117', accent: '#58a6ff', - highlight: '#f78166', gold: '#d4a373', warm: '#21262d', - } + primary: '#e0e0e0', bg: '#0d1117', accent: '#58a6ff', + highlight: '#f78166', gold: '#d4a373', warm: '#21262d', }, - - /* 文艺绿植 */ green: { name: '文艺绿植', - colors: { - primary: '#2d3e2f', bg: '#f5f9f2', accent: '#5a8f5a', - highlight: '#c78b5c', gold: '#b8a06e', warm: '#e8f0e0', - } + primary: '#2d3e2f', bg: '#f5f9f2', accent: '#5a8f5a', + highlight: '#c78b5c', gold: '#b8a06e', warm: '#e8f0e0', }, + rose: { + name: '玫瑰粉调', + primary: '#4a1942', bg: '#fdf2f8', accent: '#9d4e8d', + highlight: '#e8435e', gold: '#c9a96e', warm: '#fce4ec', + }, +} + + +export function useScheme(name) { + const scheme = COLOR_SCHEMES[name] + if (!scheme) return false + const c = STYLES.colors + c.primary = scheme.primary; c.bg = scheme.bg + c.accent = scheme.accent; c.highlight = scheme.highlight + c.gold = scheme.gold; c.warm = scheme.warm + STYLES.name = scheme.name + return true +} + + +/* ═══════════════════════════════════════════════════ + * 内容感知 · 自动检测函数 + * ═══════════════════════════════════════════════════ + * + * 冰朔给我一段文字,我分析它是什么类型的、什么情绪、 + * 适合什么布局。不需要她告诉我。 + */ + +/** + * 分析文本特征,返回检测结果 + */ +export function analyzeText(text) { + if (!text) return { type: 'empty' } + + const t = text.trim() + + /* ── 特征检测 ── */ + const hasList = /^[-•·*]\s|^\d+[\.\)]\s/m.test(t) // 列表项 + const isShort = t.length < 30 // 短句(金句) + const isMedium = t.length < 100 // 中等长度 + const hasTitle = t.includes('标题') || t.includes(':') // 可能有标题 + const lines = t.split('\n').filter(Boolean) + const lineCount = lines.length + + const keywords = { + notice: /通知|公告|放假|放假安排|节假日|放假通知/i, + recruit: /征稿|收稿|投稿|征集|诚征|招聘|招募|稿酬/i, + share: /干货|教程|指南|技巧|方法|如何|步骤|经验|分享/i, + quote: /说|说过|句话|名言|语录|金句|记得|曾经/i, + product: /新品|上线|发布|推出|产品|功能|更新/i, + event: /活动|沙龙|讲座|直播|分享会|聚会/i, + } + + const detected = {} + let type = 'general' + + for (const [key, re] of Object.entries(keywords)) { + if (re.test(t)) detected[key] = true + } + + /* ── 类型推断 ── */ + if (detected.notice) type = 'notice' + else if (detected.recruit) type = 'recruit' + else if (detected.quote || isShort) type = 'quote' + else if (detected.share || hasList) type = 'list' + else if (detected.product) type = 'product' + else if (detected.event) type = 'event' + else if (lineCount >= 4) type = 'article' + else if (isMedium) type = 'paragraph' + + /* ── 情绪推断(基于关键词) ── */ + let mood = 'neutral' + const warmWords = /感谢|温暖|爱|喜欢|开心|快乐|幸福|美好|谢谢/i + const urgentWords = /紧急|重要|注意|必读|截止|最后/i + const elegantWords = /岁月|时光|静好|诗意|远方|温柔/i + + if (elegantWords.test(t)) mood = 'elegant' + else if (warmWords.test(t)) mood = 'warm' + else if (urgentWords.test(t)) mood = 'urgent' + + return { + type, + mood, + lineCount, + isShort, + hasList, + lines, + detected, + text: t, + } } /** - * 切换到指定配色方案 + * 根据内容分析结果推荐配色方案 */ -export function useScheme(name) { - const scheme = COLOR_SCHEMES[name] - if (!scheme) return false - Object.assign(STYLES.colors, scheme.colors) - STYLES.name = scheme.name - return true +export function recommendScheme(analysis) { + const { mood, type } = analysis + + if (mood === 'warm') return 'cream' + if (mood === 'elegant') return 'green' + if (mood === 'urgent') return 'guanghu' + + if (type === 'recruit') return 'cream' + if (type === 'notice') return 'guanghu' + if (type === 'quote') return 'rose' + + return 'guanghu' +} + + +/** + * 根据内容分析推荐布局 + */ +export function recommendLayout(analysis) { + const { type, hasList, isShort, lineCount } = analysis + + if (isShort) return 'quote' + if (type === 'notice') return 'notice' + if (type === 'recruit') return 'recruit' + if (hasList) return 'list' + if (lineCount <= 3) return 'minimal' + + return 'default' +} + + +/** + * 根据内容分析推荐尺寸 + * @param {'xiaohongshu'|'jike'|'poster'|'wechat'|'wide'|string} platform + */ +export function recommendSize(platform) { + const sizes = STYLES.sizes + if (sizes[platform]) return sizes[platform] + + // 关键词匹配 + if (/小红书/i.test(platform)) return sizes.xiaohongshu + if (/即刻/i.test(platform)) return sizes.jike + if (/海报/i.test(platform) || /通知/i.test(platform) || /收稿/i.test(platform)) return sizes.poster + if (/朋友圈/i.test(platform)) return sizes.wechat + if (/宽/i.test(platform) || /横/i.test(platform)) return sizes.wide + + // 默认识别 + return sizes.square } diff --git a/image-studio/generate.js b/image-studio/generate.js index 060c90a..69f5aa9 100644 --- a/image-studio/generate.js +++ b/image-studio/generate.js @@ -2,52 +2,153 @@ /** * ═══════════════════════════════════════════════════ - * 铸渊图片工作室 · 主入口 + * 铸渊图片工作室 · 神笔马良模式 * ═══════════════════════════════════════════════════ * - * 冰朔,你告诉我内容和想要的类型,我帮你生成。 + * 冰朔,你只需要告诉我内容和场景。 + * 我来理解、设计、生成。 * - * 用法: - * node generate.js xiaohongshu --title "标题" --body "正文" - * node generate.js jike --title "观点" --body "内容" - * node generate.js poster --title "海报标题" --details "时间:5月1日" "地点:广州" + * 用法(对话中我调用): + * fromText("内容...", { for: "小红书" }) + * fromText("内容...", { for: "海报", mood: "温暖" }) * - * 也可以直接在代码里调用 API: - * import { generate } from './generate.js' - * await generate({ type: 'xiaohongshu', title: '...', body: '...' }) + * 或直接调用底层: + * generate({ design: {...}, content: {...} }) */ import { renderToImage, renderCarousel } from './renderer.js' -import { xiaohongshuCard, xiaohongshuCarousel } from './templates/xiaohongshu.js' +import { + xiaohongshuCard, xiaohongshuCarousel +} from './templates/xiaohongshu.js' import { jikeCard } from './templates/jike.js' import { posterCard } from './templates/poster.js' -import { STYLES, useScheme } from './config.js' +import { dynamicCard } from './templates/dynamic.js' +import { + STYLES, useScheme, + analyzeText, recommendScheme, recommendLayout, recommendSize +} from './config.js' +/* ═══════════════════════════════════════════════════ + * 神笔马良 · 主入口 + * + * 冰朔给我一段自由文字和场景提示, + * 我自动分析内容、推荐设计、生成图片。 + * ═══════════════════════════════════════════════════ */ + /** - * 统一生成入口 - * @param {object} opts - * @param {'xiaohongshu'|'jike'|'poster'} opts.type - 卡片类型 - * @param {string} opts.title - 标题 - * @param {string} opts.body - 正文 - * @param {string} opts.subtitle - 副标题 - * @param {string} opts.tag - 标签 - * @param {string} opts.quote - 金句(小红书 quote 布局用) - * @param {string} opts.emoji - Emoji(即刻用) - * @param {string} opts.footer - 底部文字 - * @param {string[]} opts.details - 详情列表(海报用) - * @param {string} opts.cta - 行动号召(海报用) - * @param {'default'|'quote'|'list'|'minimal'} opts.layout - 布局 - * @param {'default'|'notice'|'recruit'} opts.style - 海报风格 - * @param {string} opts.scheme - 配色方案名 - * @param {string} opts.output - 输出文件名(不含扩展名) - * @param {boolean} opts.carousel - 是否拆为多页轮播 - * @param {string[]} opts.pages - 多页正文(轮播用) - * @returns {Promise<{files: string[], type: string}>} + * @param {string} text - 冰朔说的内容(自由文本) + * @param {object} hint - 场景提示(可选) + * @param {string} hint.for - 发在哪("小红书" / "即刻" / "海报" / ...) + * @param {string} hint.mood - 情绪("温暖" / "正式" / "极简" / ...) + * @param {string} hint.scheme - 配色方案名(可选,覆盖自动推荐) + * @param {string} hint.title - 如果冰朔没有明确标题,我帮她提炼 + * @param {string} hint.output - 输出文件名 + * @returns {Promise<{files: string[], analysis: object}>} */ +export async function fromText(text, hint = {}) { + /* 1. 分析内容 */ + const analysis = analyzeText(text) + + /* 2. 确定平台/尺寸 */ + const platform = (hint.for || '').toLowerCase() + const size = hint.width && hint.height + ? { width: hint.width, height: hint.height } + : recommendSize(platform) + + /* 3. 确定配色 */ + const schemeName = hint.scheme || recommendScheme(analysis) + useScheme(schemeName) + + /* 4. 确定布局 */ + const layout = hint.layout || recommendLayout(analysis) + + /* 5. 提取/组织内容 */ + const lines = text.split('\n').filter(Boolean) + const title = hint.title || lines[0] || '' + const bodyLines = hint.title ? lines : lines.slice(1) + const body = bodyLines.join('\n') + + /* 6. 检测海报关键词 */ + const isPoster = analysis.detected.notice || analysis.detected.recruit + || /海报/i.test(platform) + + const isJike = /即刻/i.test(platform) || (size.width === size.height && !isPoster) + const isXiaohongshu = /小红书/i.test(platform) || size.height > size.width * 1.2 + + /* 7. 生成 */ + let files = [] + const baseName = hint.output || `card_${Date.now()}` + + if (isPoster) { + /* ── 走海报模板,但动态匹配风格 ── */ + const style = analysis.detected.notice ? 'notice' + : analysis.detected.recruit ? 'recruit' + : '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 } } +} + + +/* ═══════════════════════════════════════════════════ + * 底层 generate —— 直接指定所有参数 + * ═══════════════════════════════════════════════════ */ + export async function generate(opts = {}) { const { - type = 'xiaohongshu', title = '', body = '', subtitle = '', @@ -61,93 +162,70 @@ export async function generate(opts = {}) { style = 'default', scheme = '', output = '', - carousel = false, - pages = [], + width, + height, + platform = '', + type = '', } = opts - // 切换配色 + // 如果给的是自由文本,走 fromText + if (opts.text) { + return fromText(opts.text, { + 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 size = width && height + ? { width, height } + : (STYLES.sizes[type] || STYLES.sizes.square) + if (scheme) useScheme(scheme) - const baseName = output || `${type}_${Date.now()}` - const size = STYLES.sizes[type] || STYLES.sizes.xiaohongshu + const baseName = output || `card_${Date.now()}` - let files = [] + /* ── 检测内容特征,自动选模板 ── */ + const isPoster = details.length > 0 || cta + || /notice|recruit|海报/.test(style) + const isJike = type === 'jike' || layout === 'quote' + const isShort = !body && !details && !cta - switch (type) { - case 'xiaohongshu': { - if (carousel && pages.length > 0) { - const htmls = xiaohongshuCarousel({ title, subtitle, tag, quote, layout }, pages) - files = await renderCarousel(htmls, { ...size, baseName }) - } else { - const html = xiaohongshuCard({ title, body, subtitle, tag, quote, footer, layout }) - files = [await renderToImage(html, { ...size, name: baseName })] - } - break - } + let html, files - case 'jike': { - const html = jikeCard({ title, body, meta: subtitle, emoji, layout }) - files = [await renderToImage(html, { ...size, name: baseName })] - break - } - - case 'poster': { - const html = posterCard({ title, subtitle, body, details, cta, footer, tag, style }) - files = [await renderToImage(html, { ...size, name: baseName })] - break - } - - default: - throw new Error(`未知卡片类型: ${type}`) + 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 } } -/* ── CLI 入口 ── */ +/* ═══════════════════════════════════════════════════ + * CLI 入口 + * ═══════════════════════════════════════════════════ */ + async function cli() { const args = process.argv.slice(2) - const type = args.find(a => !a.startsWith('--')) - - if (!type || type === 'help') { - console.log(` -铸渊图片工作室 · 用法 - - node generate.js <类型> [选项] - -类型: - xiaohongshu 小红书竖版卡片 (1080×1440) - jike 即刻方卡 (1080×1080) - poster 海报 (1080×1920) - -选项: - --title 标题 - --body 正文 - --subtitle 副标题/元信息 - --tag 标签 - --quote 金句(小红书 quote 布局) - --emoji Emoji(即刻) - --details 详情(海报,可多个) - --cta 行动号召(海报) - --footer 底部文字 - --layout 布局: default|quote|list|minimal - --style 海报风格: default|notice|recruit - --scheme 配色: guanghu|cream|night|green - --output 输出文件名 - -示例: - node generate.js xiaohongshu --title "如何高效学习" --body "第一点\\n第二点\\n第三点" - node generate.js jike --title "一个思考" --body "今天想到的..." --emoji "💡" - node generate.js poster --title "放假通知" --style notice --details "时间:5月1日" "地点:广州" -`) - process.exit(0) - } function getArg(key) { - const idx = args.indexOf(key) + const idx = args.indexOf(`--${key}`) if (idx === -1) return undefined - // 收集后续所有非 -- 开头的参数,直到遇到下一个 -- const values = [] for (let i = idx + 1; i < args.length; i++) { if (args[i].startsWith('--')) break @@ -158,29 +236,63 @@ async function cli() { : values } + // 神笔马良模式:--text "内容" --for "小红书" + const text = getArg('text') || getArg('t') + if (text) { + console.log(`🎨 铸渊正在理解你的内容...`) + const hint = { + for: getArg('for') || getArg('f') || '', + mood: getArg('mood') || '', + scheme: getArg('scheme') || '', + title: getArg('title') || '', + output: getArg('output') || '', + } + const result = await fromText(text, hint) + console.log(`✅ 生成完成!`) + console.log(` 📐 检测类型: ${result.analysis.type}`) + console.log(` 🎨 配色方案: ${result.analysis.scheme}`) + result.files.forEach(f => console.log(` 📄 ${f}`)) + return + } + + // 传统模式 + const type = args.find(a => !a.startsWith('--')) + if (!type || type === 'help') { + console.log(` +铸渊图片工作室 · 神笔马良 + + 🎨 新模式(推荐): + node generate.js --text "你的内容" --for "发在哪" + + 📐 传统模式: + node generate.js <类型> --title "标题" --body "内容" + +示例: + node generate.js --text "分享三个提高效率的方法\n1. 早上先做最难的事\n2. 一次只做一件事" --for "小红书" + 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: (() => { - const b = getArg('--body') - const text = Array.isArray(b) ? b.join('\n') : (b || '') - // 将 \n 转义符转为真实换行 - return text.replace(/\\n/g, '\n') - })(), - subtitle: getArg('--subtitle') || '', - tag: getArg('--tag') || '', - quote: getArg('--quote') || '', - emoji: getArg('--emoji') || '', - footer: getArg('--footer') || '', + 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') + 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') || '', + cta: getArg('cta') || '', + layout: getArg('layout') || 'default', + style: getArg('style') || 'default', + scheme: getArg('scheme') || '', + output: getArg('output') || '', } console.log(`🎨 生成${type}卡片...`) @@ -189,8 +301,7 @@ async function cli() { 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')) { cli().catch(err => { console.error('❌ 生成失败:', err.message) process.exit(1) diff --git a/image-studio/output/test_ma,Liang_1.png b/image-studio/output/test_ma,Liang_1.png new file mode 100644 index 0000000..98003fd Binary files /dev/null and b/image-studio/output/test_ma,Liang_1.png differ diff --git a/image-studio/output/test_maliang_poster.png b/image-studio/output/test_maliang_poster.png new file mode 100644 index 0000000..18e08d9 Binary files /dev/null and b/image-studio/output/test_maliang_poster.png differ diff --git a/image-studio/output/test_maliang_quote.png b/image-studio/output/test_maliang_quote.png new file mode 100644 index 0000000..92b5d6e Binary files /dev/null and b/image-studio/output/test_maliang_quote.png differ diff --git a/image-studio/templates/dynamic.js b/image-studio/templates/dynamic.js new file mode 100644 index 0000000..e8c6846 --- /dev/null +++ b/image-studio/templates/dynamic.js @@ -0,0 +1,192 @@ +/** + * ═══════════════════════════════════════════════════ + * 万能动态模板 · 自适应任何内容 + * ═══════════════════════════════════════════════════ + * + * 不是固定类型。是根据冰朔的文字内容,现场设计。 + */ + +import { STYLES } from '../config.js' + + +/** + * 万能动态卡片 + * @param {object} data + * @param {string} data.title - 提炼的标题 + * @param {string} data.body - 正文 + * @param {string[]} data.lines - 原文行数组 + * @param {string} data.type - 内容类型(analyzeText 的结果) + */ +export function dynamicCard(data) { + const { title, body, lines = [], type = 'general' } = data + const C = STYLES.colors + const F = STYLES.fonts + const T = STYLES.typography + const W = 1080 + const H = 1080 + + // 动态计算字号:内容越多字越小 + const totalChars = (title + body).length + const titleSize = totalChars < 20 ? 52 : totalChars < 50 ? 44 : 36 + const bodySize = totalChars < 50 ? 32 : totalChars < 150 ? 26 : 22 + + // 检测有没有列表 + const hasList = lines.some(l => /^[-•·*]\s/.test(l.trim())) + // 检测是不是一句话 + const isSingleLine = lines.length <= 2 && totalChars < 40 + + /* ── 生成内容 HTML ── */ + let contentHtml = '' + + if (isSingleLine) { + /* 一句话 · 大字居中 */ + contentHtml = ` +
${escapeHtml(l)}
`) + .join('\n') + contentHtml = ` +