|
@@ -0,0 +1,170 @@
|
|
|
|
|
+// fmode-image v2.0 — 纯ESM零依赖版
|
|
|
|
|
+// 架构图/应用界面/场景插图/整页PPT生成器
|
|
|
|
|
+
|
|
|
|
|
+import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
|
|
+import { homedir } from 'node:os';
|
|
|
|
|
+import { join, dirname } from 'node:path';
|
|
|
|
|
+import { fileURLToPath } from 'node:url';
|
|
|
|
|
+
|
|
|
|
|
+const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
+
|
|
|
|
|
+// ===== 模式注册表 =====
|
|
|
|
|
+export const MODES = {
|
|
|
|
|
+ '--arch': {
|
|
|
|
|
+ prefix: '纯白背景扁平风格架构图。',
|
|
|
|
|
+ suffix: '简洁扁平商务风格,纯白背景适合PPT。',
|
|
|
|
|
+ size: '1792x1024', cost: 0.5, desc: '架构/分层/闭环图',
|
|
|
|
|
+ },
|
|
|
|
|
+ '--app': {
|
|
|
|
|
+ prefix: '现代SaaS应用界面截图,浅色主题。',
|
|
|
|
|
+ suffix: '界面细节清晰锐利,中文界面文字,像真实在运行的产品。',
|
|
|
|
|
+ size: '1792x1024', cost: 0.5, desc: '产品界面(表现力最强)',
|
|
|
|
|
+ },
|
|
|
|
|
+ '--explode': {
|
|
|
|
|
+ prefix: '技术爆炸图,内部分层剖开并标注数据流向。',
|
|
|
|
|
+ suffix: '扁平科技风格,纯白背景,层级与流向清晰。',
|
|
|
|
|
+ size: '1792x1024', cost: 0.5, desc: '内部运转/拆解图',
|
|
|
|
|
+ },
|
|
|
|
|
+ '--product': {
|
|
|
|
|
+ prefix: '产品设计效果图,纯白背景,多角度组合渲染。',
|
|
|
|
|
+ suffix: '工业设计质感,材质细腻,白底适合PPT,写实渲染。',
|
|
|
|
|
+ size: '1792x1024', cost: 0.5, desc: '产品设计图(硬件/工业)',
|
|
|
|
|
+ },
|
|
|
|
|
+ '--scene': {
|
|
|
|
|
+ prefix: '扁平插画场景。',
|
|
|
|
|
+ suffix: '清新明亮风格。',
|
|
|
|
|
+ size: '1024x1024', cost: 0.3, desc: '场景/人物/痛点插图',
|
|
|
|
|
+ },
|
|
|
|
|
+ '--detail': {
|
|
|
|
|
+ prefix: '精密细节特写图。',
|
|
|
|
|
+ suffix: '写实质感,清晰锐利。',
|
|
|
|
|
+ size: '1024x1024', cost: 0.3, desc: '零件/材质/实物特写',
|
|
|
|
|
+ },
|
|
|
|
|
+ '--slide': {
|
|
|
|
|
+ prefix: '一页16:9演示幻灯片。',
|
|
|
|
|
+ suffix: '留白充足,字号层级分明,商务演示风格。',
|
|
|
|
|
+ size: '1920x1080', cost: 0.5, desc: '整页PPT直接使用',
|
|
|
|
|
+ },
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// ===== 凭据自动获取 =====
|
|
|
|
|
+export function getApiKey() {
|
|
|
|
|
+ let key = process.env.FMODE_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || '';
|
|
|
|
|
+ if (key && !key.includes('***') && key.length > 10) return key;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const p = join(homedir(), '.fmode', 'config.json');
|
|
|
|
|
+ if (existsSync(p)) {
|
|
|
|
|
+ const c = JSON.parse(readFileSync(p, 'utf-8'));
|
|
|
|
|
+ for (const k of ['api_key', 'FMODE_API_KEY', 'fmodeApiToken']) if (c[k]) return c[k];
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const p = join(homedir(), '.fmode', 'config.yaml');
|
|
|
|
|
+ if (existsSync(p)) {
|
|
|
|
|
+ for (const l of readFileSync(p, 'utf-8').split('\n')) {
|
|
|
|
|
+ for (const kw of ['api_key', 'FMODE_API_KEY', 'fmodeApiToken']) {
|
|
|
|
|
+ if (l.includes(kw)) {
|
|
|
|
|
+ const v = l.split(':').slice(1).join(':').trim().replace(/["']/g, '');
|
|
|
|
|
+ if (v && v !== '${FMODE_API_KEY}') return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+
|
|
|
|
|
+ for (const p of ['/opt/data/.env', '.env']) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (existsSync(p)) {
|
|
|
|
|
+ for (const l of readFileSync(p, 'utf-8').split('\n')) {
|
|
|
|
|
+ if (l.includes('FMODE_API_KEY')) {
|
|
|
|
|
+ const v = l.split('=').slice(1).join('=').trim().replace(/["']/g, '');
|
|
|
|
|
+ if (v && !v.includes('***') && v.length > 10) return v;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ throw new Error('No API Key found. Set FMODE_API_KEY env or ~/.fmode/config.json');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ===== API调用(纯fetch,零依赖) =====
|
|
|
|
|
+export async function callImageAPI(prompt, size) {
|
|
|
|
|
+ const key = getApiKey();
|
|
|
|
|
+ const resp = await fetch('https://api.fmode.cn/v1/images/generations', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ model: 'gpt-image-2.5-sunburst', prompt, n: 1, size }),
|
|
|
|
|
+ signal: AbortSignal.timeout(300000),
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!resp.ok) throw new Error(`API ${resp.status}: ${(await resp.text().catch(()=>'')).slice(0,200)}`);
|
|
|
|
|
+ const data = await resp.json();
|
|
|
|
|
+ if (data.data?.[0]?.b64_json) return Buffer.from(data.data[0].b64_json, 'base64');
|
|
|
|
|
+ if (data.data?.[0]?.url) {
|
|
|
|
|
+ const r = await fetch(data.data[0].url, { signal: AbortSignal.timeout(60000) });
|
|
|
|
|
+ if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
|
|
|
|
+ return Buffer.from(await r.arrayBuffer());
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error('Response has no image data');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ===== 统一生成 =====
|
|
|
|
|
+export async function gen(mode, prompt, name = 'output') {
|
|
|
|
|
+ const cfg = MODES[mode];
|
|
|
|
|
+ if (!cfg) throw new Error(`Unknown mode: ${mode}`);
|
|
|
|
|
+ const fullPrompt = `${cfg.prefix}${prompt} ${cfg.suffix}`;
|
|
|
|
|
+ const outdir = process.env.SKILL_IMAGE_OUTPUT || '.';
|
|
|
|
|
+ mkdirSync(outdir, { recursive: true });
|
|
|
|
|
+ const outPath = join(outdir, `${name}.png`);
|
|
|
|
|
+ const imgData = await callImageAPI(fullPrompt, cfg.size);
|
|
|
|
|
+ writeFileSync(outPath, imgData);
|
|
|
|
|
+ console.log(` OK ${cfg.desc} | ${Math.round(imgData.length/1024)}KB | ${cfg.size} | ${outPath}`);
|
|
|
|
|
+ return outPath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export const genArch = (p, n) => gen('--arch', p, n);
|
|
|
|
|
+export const genApp = (p, n) => gen('--app', p, n);
|
|
|
|
|
+export const genExplode = (p, n) => gen('--explode', p, n);
|
|
|
|
|
+export const genProduct = (p, n) => gen('--product', p, n);
|
|
|
|
|
+export const genScene = (p, n) => gen('--scene', p, n);
|
|
|
|
|
+export const genDetail = (p, n) => gen('--detail', p, n);
|
|
|
|
|
+export const genSlide = (p, n) => gen('--slide', p, n);
|
|
|
|
|
+
|
|
|
|
|
+export async function batch(tasks) {
|
|
|
|
|
+ let total = 0;
|
|
|
|
|
+ for (const [mode, name, prompt] of tasks) {
|
|
|
|
|
+ process.stdout.write(`${name}: `);
|
|
|
|
|
+ try { await gen(mode, prompt, name); total += MODES[mode].cost; }
|
|
|
|
|
+ catch (e) { console.error(` FAIL: ${e.message}`); }
|
|
|
|
|
+ }
|
|
|
|
|
+ console.log(`\nTotal cost: ¥${total.toFixed(1)}`);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function main() {
|
|
|
|
|
+ const args = process.argv.slice(2);
|
|
|
|
|
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
|
|
|
|
|
+ console.log(`
|
|
|
|
|
+fmode-image v2.0 — AI架构图和场景插图生成器
|
|
|
|
|
+
|
|
|
|
|
+用法:
|
|
|
|
|
+ npx fmode-image --arch "prompt" [name] 架构图 ¥0.5
|
|
|
|
|
+ npx fmode-image --app "prompt" [name] 应用界面 ¥0.5
|
|
|
|
|
+ npx fmode-image --scene "prompt" [name] 场景插图 ¥0.3
|
|
|
|
|
+ npx fmode-image --slide "prompt" [name] 整页PPT ¥0.5
|
|
|
|
|
+ npx fmode-image --explode "prompt" [name] 爆炸图 ¥0.5
|
|
|
|
|
+ npx fmode-image --detail "prompt" [name] 细节图 ¥0.3
|
|
|
|
|
+`);
|
|
|
|
|
+ process.exit(0);
|
|
|
|
|
+ }
|
|
|
|
|
+ const mode = args[0];
|
|
|
|
|
+ if (!MODES[mode]) { console.error(`Unknown mode: ${mode}`); process.exit(1); }
|
|
|
|
|
+ const prompt = args.slice(1, -1).join(' ') || args[1] || '';
|
|
|
|
|
+ const name = args.length > 2 ? args[args.length - 1] : 'output';
|
|
|
|
|
+ if (!prompt) { console.error('Error: prompt required'); process.exit(1); }
|
|
|
|
|
+ try { await gen(mode, prompt, name); }
|
|
|
|
|
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+if (process.argv[1] === fileURLToPath(import.meta.url)) main();
|