// Сервер img2glb: API поверх ComfyUI, история в SQLite, раздача собранного фронтенда (dist). // Запуск: node server/index.ts (PORT, HOST, COMFY, COMFY_OUTPUT, DB_PATH — переменные окружения) // При первом запуске старые прогоны text2img переносятся в ImageBatches из метаданных PNG (migrate.ts). import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { Readable } from 'node:stream'; import type { ReadableStream as WebStream } from 'node:stream/web'; import type { CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, } from '../shared/api.ts'; import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts'; import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts'; import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts'; import { clearProjectClean, clearProjectModel, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean, startProjectModel, transaction, updateTextInput, } from './db.ts'; import { glbWithoutNormals, outputPath } from './glb.ts'; import { migrateOldRuns } from './migrate.ts'; import { liveStatus, startTracker } from './tracker.ts'; import { cleanupGraph, escapeTemplate, kindOf, modelGraph, renderTemplate, text2imgGraph } from './workflows.ts'; class BadRequest extends Error {} class NotFound extends Error {} class Conflict extends Error {} /** Подпапка ComfyUI/input для загруженных пользователем картинок. */ const UPLOAD_DIR = 'img2glb_upload'; type Req = http.IncomingMessage; type Res = http.ServerResponse; const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model']; function send(res: Res, code: number, obj: unknown) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)); } async function readBuffer(req: Req): Promise { const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer); return Buffer.concat(chunks); } async function readJson(req: Req): Promise> { try { return JSON.parse((await readBuffer(req)).toString() || '{}'); } catch { throw new BadRequest('некорректный JSON'); } } function clamp(v: unknown, min: number, max: number, def: number): number { const n = v === '' || v == null ? NaN : Number(v); return Number.isFinite(n) ? Math.min(Math.max(n, min), max) : def; } /** Пустое значение — случайный seed. */ function parseSeed(v: unknown): number { if (v === '' || v == null) return Math.floor(Math.random() * 2 ** 48); const seed = parseInt(String(v), 10); if (!Number.isFinite(seed) || seed < 0) throw new BadRequest('seed должен быть целым числом ≥ 0'); return seed; } function safeName(s: string): string { return s.replace(/[^\p{L}\p{N}._-]+/gu, '_'); } /** FileRef → имя для LoadImage с аннотацией папки: "img2glb_src/x.png [output]". Выход за папку проверяет ComfyUI. */ function sourceImage(src: Partial | undefined) { const filename = String(src?.filename ?? ''); if (!filename) throw new BadRequest('не указана картинка'); if (!['input', 'output', 'temp'].includes(String(src?.type))) throw new BadRequest('неверный type картинки'); const ref: FileRef = { filename, subfolder: String(src?.subfolder ?? ''), type: String(src?.type) }; return { ref, image: (ref.subfolder ? ref.subfolder + '/' : '') + filename + ' [' + ref.type + ']', stem: safeName(path.basename(filename, path.extname(filename))), }; } // ---------- батчи картинок ---------- function batchIdOf(v: string): number { const id = Number(v); if (!Number.isInteger(id) || id <= 0) throw new BadRequest('неверный id батча'); return id; } function batchOrThrow(id: number): ImageBatch { const b = getBatch(id); if (!b) throw new NotFound('батч не найден'); return b; } function withLiveBatch(b: ImageBatch): ImageBatch { const live = b.sourceType === 'text' && b.state === 'pending' && b.jobId ? liveStatus(b.jobId) : undefined; return live ? { ...b, live } : b; } const LOCKED = 'у батча уже есть картинки или он в работе — его можно только продублировать'; /** Ввод text2img из запроса: обрезка пробелов и точек в конце, проверка seed. */ function parseInput(v: Partial | undefined): Text2ImgInput { const seed = String(v?.seed ?? '').trim(); if (seed !== '' && !/^\d+$/.test(seed)) throw new BadRequest('seed должен быть целым числом ≥ 0 или пустым'); return { shape: String(v?.shape ?? '').trim().replace(/\.+$/, ''), colors: String(v?.colors ?? '').trim().replace(/\.+$/, ''), template: String(v?.template ?? ''), negative: String(v?.negative ?? ''), batch: Math.round(clamp(v?.batch, 1, 8, 1)), seed, }; } /** Запуск text-батча: граф в очередь ComfyUI, задача в jobs, батч → pending. */ async function runBatch(b: ImageBatch): Promise { if (b.sourceType !== 'text') throw new Conflict('загруженные картинки нельзя запустить'); if (isLocked(b)) throw new Conflict(LOCKED); const { shape, colors, negative, batch } = b.source.input; const template = b.source.input.template.trim() || DEFAULT_TEMPLATE; if (template.includes('{a}') && !shape) throw new BadRequest('Заполните поле «Форма персонажа»'); const seed = parseSeed(b.source.input.seed); const fString = escapeTemplate(template); const prompt = renderTemplate(fString, { a: shape, b: colors }); const id = await queuePrompt(text2imgGraph({ template: fString, shape, colors, negative, batch, seed })); transaction(() => { insertJob({ id, type: 'text2img', created: Date.now(), state: 'pending', source: null, images: [], models: [], params: { batchId: b.id, shape, colors, template, negative, batch, seed, prompt }, } satisfies Text2ImgJob); startBatch(b.id, id, seed, prompt); }); return batchOrThrow(b.id); } /** Новый text-батч; run — сразу запустить. */ async function createBatch(req: Req, res: Res) { const body = await readJson<{ input: Text2ImgInput; run: boolean }>(req); const b = batchOrThrow(createTextBatch(parseInput(body.input))); send(res, 200, withLiveBatch(body.run ? await runBatch(b) : b)); } async function editBatch(req: Req, res: Res, id: number) { const b = batchOrThrow(id); if (b.sourceType !== 'text') throw new Conflict('у загруженных картинок нет промпта'); if (isLocked(b)) throw new Conflict(LOCKED); updateTextInput(b, parseInput((await readJson<{ input: Text2ImgInput }>(req)).input)); send(res, 200, batchOrThrow(id)); } /** Клон text-батча: тот же ввод, без картинок, можно править и запускать. */ function duplicateBatch(res: Res, id: number) { const b = batchOrThrow(id); if (b.sourceType !== 'text') throw new Conflict('загруженные картинки нельзя продублировать'); send(res, 200, batchOrThrow(createTextBatch(b.source.input, b.id))); } function removeBatch(res: Res, id: number) { const b = batchOrThrow(id); if (isLocked(b)) throw new Conflict('батч с картинками или в работе удалить нельзя'); deleteBatch(b); send(res, 200, {}); } /** Загрузка файлов (multipart, поле files): сохраняются в ComfyUI/input/img2glb_upload, одна загрузка — один батч. */ async function uploadBatch(req: Req, res: Res) { let form: FormData; try { form = await new Request('http://local/', { method: 'POST', headers: { 'content-type': String(req.headers['content-type'] ?? '') }, body: Readable.toWeb(req) as ReadableStream, duplex: 'half', } as RequestInit).formData(); } catch { throw new BadRequest('ожидается multipart/form-data с полем files'); } type Entry = ReturnType; const files = form.getAll('files').filter((f): f is Exclude => typeof f !== 'string' && f.size > 0); if (!files.length) throw new BadRequest('нет файлов'); const bad = files.find(f => !f.type.startsWith('image/')); if (bad) throw new BadRequest('не картинка: ' + bad.name); const images: FileRef[] = []; for (const f of files) images.push(await uploadImage(safeName(path.basename(f.name)) || 'upload.png', f, UPLOAD_DIR)); send(res, 200, batchOrThrow(createUploadBatch(files.map(f => f.name), images))); } // ---------- проекты ---------- /** Проект из картинки батча: создаёт новый или возвращает уже созданный из этой картинки. */ async function createProject(req: Req, res: Res) { const imageId = Number((await readJson<{ imageId: number }>(req)).imageId); if (!Number.isInteger(imageId) || !getImage(imageId)) throw new NotFound('картинка не найдена'); send(res, 200, withLiveProject(projectOrThrow(projectForImage(imageId)))); } function projectOrThrow(id: number): Project { const p = getProject(id); if (!p) throw new NotFound('проект не найден'); return p; } function withLiveProject(p: Project): Project { const live = p.cleanJobId ? liveStatus(p.cleanJobId) : undefined; const modelLive = p.modelJobId ? liveStatus(p.modelJobId) : undefined; return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}) }; } /** * 3D-модель проекта из его очищенной картинки (Hunyuan3D-2 mini) + PNG-превью. * Новая модель заменяет прежнюю; история не хранится. */ async function projectModel(req: Req, res: Res, projectId: number) { const p = projectOrThrow(projectId); if (!p.clean) throw new Conflict('сначала почистите исходник'); if (p.modelJobId) throw new Conflict('модель уже генерируется'); const b = await readJson(req); const src = sourceImage(p.clean); const seed = parseSeed(b.seed); const steps = Math.round(clamp(b.steps, 1, 100, 30)); const octree = Math.round(clamp(b.octree, 64, 512, 380)); const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000)); const id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces })); transaction(() => { // задача нужна трекеру только до завершения — потом удаляется insertJob({ id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], params: { projectId, seed, steps, octree, faces }, } satisfies ModelJob); startProjectModel(projectId, id); }); send(res, 200, withLiveProject(projectOrThrow(projectId))); } /** Убрать 3D-модель из проекта — после этого можно сгенерировать заново. */ function removeProjectModel(res: Res, id: number) { const p = projectOrThrow(id); if (p.modelJobId) throw new Conflict('идёт генерация модели'); clearProjectModel(id); send(res, 200, withLiveProject(projectOrThrow(id))); } /** * Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона. * Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится. */ async function projectCleanup(req: Req, res: Res, projectId: number) { const p = projectOrThrow(projectId); if (p.cleanJobId) throw new Conflict('очистка уже идёт'); const b = await readJson(req); const src = sourceImage(p.image); const size = Math.round(clamp(b.size, 64, 4096, 1024) / 8) * 8; // Объект вписывается по центру в квадрат (size - 2*margin). ImageCropToMask принимает pad_factor ≤ 2 → margin ≤ size/4. const margin = Math.round(clamp(b.margin, 0, size / 4, 154)); const grow = Math.round(clamp(b.grow, -32, 32, 0)); const background = /^#[0-9a-fA-F]{6}$/.test(String(b.background)) ? String(b.background).toUpperCase() : '#FFFFFF'; const id = await queuePrompt(cleanupGraph({ image: src.image, prefix: 'img2glb_clean/' + src.stem, size, pad: size / (size - 2 * margin), grow, background, })); transaction(() => { // задача нужна трекеру только до завершения — потом удаляется insertJob({ id, type: 'cleanup', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], params: { projectId, size, margin, grow, background }, } satisfies CleanupJob); startProjectClean(projectId, id); }); send(res, 200, withLiveProject(projectOrThrow(projectId))); } /** Убрать очищенную картинку из проекта — после этого можно почистить заново. */ function removeProjectClean(res: Res, id: number) { const p = projectOrThrow(id); if (p.cleanJobId) throw new Conflict('идёт очистка'); clearProjectClean(id); send(res, 200, projectOrThrow(id)); } // ---------- настройки ---------- function settingsPage(v: string): SettingsPage { if (!SETTINGS_PAGES.includes(v as SettingsPage)) throw new BadRequest('неизвестная страница'); return v as SettingsPage; } async function saveSettings(req: Req, res: Res, page: SettingsPage) { const value = await readJson(req); if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new BadRequest('ожидается объект'); putSettings(page, value); send(res, 200, value); } /** Однократный перенос значений полей из localStorage старой версии; уже сохранённые не перезаписываются. */ async function importOld(req: Req, res: Res) { const b = await readJson(req); transaction(() => { for (const page of SETTINGS_PAGES) { const s = b.settings?.[page]; if (s && typeof s === 'object' && !Object.keys(getSettings(page)).length) putSettings(page, s); } }); send(res, 200, {}); } // ---------- файлы и ComfyUI ---------- /** Очередь для индикатора. Элемент очереди ComfyUI: [номер, prompt_id, граф, extra_data, outputs]. */ async function queue(res: Res) { const q = await comfyJson<{ queue_running?: unknown[][]; queue_pending?: unknown[][] }>('/queue'); const map = (list?: unknown[][]) => (list ?? []).map(x => ({ id: String(x[1]), kind: kindOf(x[2]) })); send(res, 200, { running: map(q.queue_running), pending: map(q.queue_pending).reverse() } satisfies QueueInfo); } /** Файл из ComfyUI (/view). preview=webp;70 — облегчённое превью для миниатюр. */ async function image(res: Res, params: URLSearchParams) { const q = new URLSearchParams({ filename: params.get('filename') ?? '', subfolder: params.get('subfolder') ?? '', type: params.get('type') ?? 'output', }); const preview = params.get('preview') ?? ''; if (/^(webp|jpeg);\d{1,3}$/.test(preview)) q.set('preview', preview); const r = await view(q); res.writeHead(r.status, { 'Content-Type': r.headers.get('content-type') ?? 'application/octet-stream', 'Cache-Control': 'max-age=86400' }); if (r.body) Readable.fromWeb(r.body as WebStream).pipe(res); else res.end(); } /** GLB из ComfyUI/output без нормалей — просмотр «без сглаживания» (плоские грани). */ function glbFlat(res: Res, params: URLSearchParams) { const file = outputPath({ filename: params.get('filename') ?? '', subfolder: params.get('subfolder') ?? '', type: 'output' }); if (!file || !/\.glb$/i.test(file) || !fs.existsSync(file)) throw new NotFound('модель не найдена'); let body: Buffer; try { body = glbWithoutNormals(fs.readFileSync(file)); } catch { throw new BadRequest('файл не GLB'); } res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'max-age=86400' }); res.end(body); } // ---------- фронтенд ---------- const MIME: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.wasm': 'application/wasm', }; /** Страницы приложения (маршруты React Router) — отдаётся index.html. */ const isPage = (p: string) => ['/', '/projects', '/text2img', '/cleanup', '/img2model'].includes(p) || /^\/(batch|project)\/\d+$/.test(p); function serveStatic(res: Res, pathname: string) { const file = isPage(pathname) ? path.join(DIST, 'index.html') : path.join(DIST, path.normalize(pathname)); if (!file.startsWith(DIST + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { return send(res, 404, { error: isPage(pathname) ? 'фронтенд не собран: npm run build' : 'not found' }); } res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] ?? 'application/octet-stream', // файлы в /assets/ с хешем в имени — кэшируются навсегда; страницы — всегда свежие 'Cache-Control': pathname.startsWith('/assets/') ? 'max-age=31536000, immutable' : 'no-cache', }); fs.createReadStream(file).pipe(res); } // ---------- маршруты ---------- http.createServer(async (req, res) => { const u = new URL(req.url ?? '/', 'http://x'); const route = req.method + ' ' + u.pathname; const tail = (prefix: string) => decodeURIComponent(u.pathname.slice(prefix.length)); try { if (route === 'GET /api/batches') return send(res, 200, listBatches().map(withLiveBatch)); if (route === 'POST /api/batches') return await createBatch(req, res); if (route === 'POST /api/batches/upload') return await uploadBatch(req, res); const bm = /^\/api\/batches\/(\d+)(?:\/(input|run|duplicate))?$/.exec(u.pathname); if (bm) { const id = batchIdOf(bm[1]); if (req.method === 'GET' && !bm[2]) return send(res, 200, withLiveBatch(batchOrThrow(id))); if (req.method === 'DELETE' && !bm[2]) return removeBatch(res, id); if (req.method === 'PUT' && bm[2] === 'input') return await editBatch(req, res, id); if (req.method === 'POST' && bm[2] === 'run') return send(res, 200, withLiveBatch(await runBatch(batchOrThrow(id)))); if (req.method === 'POST' && bm[2] === 'duplicate') return duplicateBatch(res, id); } if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject)); if (route === 'POST /api/projects') return await createProject(req, res); const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model))?$/.exec(u.pathname); if (pm && req.method === 'GET' && !pm[2]) return send(res, 200, withLiveProject(projectOrThrow(Number(pm[1])))); if (pm && req.method === 'POST' && pm[2] === 'cleanup') return await projectCleanup(req, res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'clean') return removeProjectClean(res, Number(pm[1])); if (pm && req.method === 'POST' && pm[2] === 'model') return await projectModel(req, res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'model') return removeProjectModel(res, Number(pm[1])); if (req.method === 'GET' && u.pathname.startsWith('/api/settings/')) return send(res, 200, getSettings(settingsPage(tail('/api/settings/')))); if (req.method === 'PUT' && u.pathname.startsWith('/api/settings/')) return await saveSettings(req, res, settingsPage(tail('/api/settings/'))); if (route === 'POST /api/import') return await importOld(req, res); if (route === 'GET /api/queue') return await queue(res); if (route === 'GET /api/image') return await image(res, u.searchParams); if (route === 'GET /api/glb-flat') return glbFlat(res, u.searchParams); if (u.pathname.startsWith('/api/')) return send(res, 404, { error: 'not found' }); if (req.method === 'GET') return serveStatic(res, u.pathname); send(res, 405, { error: 'method not allowed' }); } catch (e) { const message = e instanceof Error ? e.message : String(e); const code = e instanceof BadRequest ? 400 : e instanceof NotFound ? 404 : e instanceof Conflict ? 409 : e instanceof ComfyError ? 502 : 500; if (!res.headersSent) send(res, code, { error: message }); else res.destroy(); } }).listen(PORT, HOST, () => console.log(`http://${HOST}:${PORT} (ComfyUI: ${COMFY.origin}, база: ${DB_PATH})`)); migrateOldRuns(); startTracker();