'use strict'; // #41 Phase 2: text-to-image for AI signage. Two backends, both BYO/self-hostable: // - 'comfyui' -> local ComfyUI (SDXL) via its prompt/history/view API // - 'openai' -> any OpenAI-compatible /images/generations endpoint // Returns a data: URL (base64 PNG) the Designer can drop straight onto a layer. // The caller is responsible for the SSRF check on the base URL. const NEGATIVE = 'text, words, letters, watermark, signature, logo, blurry, low quality, deformed'; function buildComfyWorkflow(prompt, ckpt, width, height, seed) { return { '3': { class_type: 'KSampler', inputs: { seed, steps: 25, cfg: 7, sampler_name: 'dpmpp_2m', scheduler: 'karras', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0] } }, '4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: ckpt } }, '5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } }, '6': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['4', 1] } }, '7': { class_type: 'CLIPTextEncode', inputs: { text: NEGATIVE, clip: ['4', 1] } }, '8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } }, '9': { class_type: 'SaveImage', inputs: { filename_prefix: 'signage', images: ['8', 0] } }, }; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function comfyGenerate(baseUrl, prompt, model, width, height, signal) { const ckpt = model || 'sd_xl_base_1.0.safetensors'; const seed = Math.floor(Math.random() * 1e15); const wf = buildComfyWorkflow(prompt, ckpt, width, height, seed); const sub = await fetch(baseUrl + '/prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: wf }), signal, }); if (!sub.ok) throw new Error(`ComfyUI rejected the job (${sub.status}): ${(await sub.text().catch(() => '')).slice(0, 150)}`); const { prompt_id } = await sub.json(); if (!prompt_id) throw new Error('ComfyUI did not return a prompt id'); // poll history until this prompt produces an output for (let i = 0; i < 120; i++) { if (signal && signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' }); await sleep(1000); const h = await fetch(`${baseUrl}/history/${prompt_id}`, { signal }).then((r) => r.json()).catch(() => null); const entry = h && h[prompt_id]; if (!entry) continue; const outputs = entry.outputs || {}; for (const nodeId of Object.keys(outputs)) { const imgs = outputs[nodeId].images; if (imgs && imgs.length) { const im = imgs[0]; const q = new URLSearchParams({ filename: im.filename, subfolder: im.subfolder || '', type: im.type || 'output' }); const buf = Buffer.from(await (await fetch(`${baseUrl}/view?${q}`, { signal })).arrayBuffer()); return 'data:image/png;base64,' + buf.toString('base64'); } } if (entry.status && entry.status.status_str === 'error') throw new Error('ComfyUI reported a generation error'); } throw new Error('ComfyUI image timed out'); } // 'openai' (real cloud) only accepts a fixed set of sizes; 'sdcpp' (local) takes // exact dimensions and is VRAM-bound, so we keep those small. Both speak the same // /v1/images/generations API. function sizeFor(provider, width, height) { if (provider === 'sdcpp') return `${width}x${height}`; return width > height ? '1792x1024' : (height > width ? '1024x1792' : '1024x1024'); } async function openaiCompatGenerate(baseUrl, key, prompt, model, size, signal) { const body = { prompt, n: 1, size, response_format: 'b64_json' }; if (model) body.model = model; // omit for sd.cpp (uses its loaded checkpoint) const res = await fetch(baseUrl + '/images/generations', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, body: JSON.stringify(body), signal, }); if (!res.ok) throw new Error(`Image endpoint error ${res.status}: ${(await res.text().catch(() => '')).slice(0, 150)}`); const j = await res.json(); const b64 = j && j.data && j.data[0] && j.data[0].b64_json; if (b64) return 'data:image/png;base64,' + b64; const url = j && j.data && j.data[0] && j.data[0].url; if (url) { const buf = Buffer.from(await (await fetch(url, { signal })).arrayBuffer()); return 'data:image/png;base64,' + buf.toString('base64'); } throw new Error('Image endpoint returned no image'); } // generateImage(opts) -> data URL. opts: { provider, baseUrl, apiKey, model, // prompt, width, height, timeoutMs } async function generateImage(opts) { const { provider, baseUrl, apiKey, model, prompt } = opts; const width = opts.width || 1024; const height = opts.height || 1024; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), opts.timeoutMs || 180000); try { if (provider === 'openai' || provider === 'sdcpp') { return await openaiCompatGenerate(baseUrl, apiKey || 'none', prompt, model, sizeFor(provider, width, height), controller.signal); } return await comfyGenerate(baseUrl, prompt, model, width, height, controller.signal); } catch (e) { if (e.name === 'AbortError') throw new Error('image generation timed out'); throw e; } finally { clearTimeout(timer); } } module.exports = { generateImage };