Files
sprite-ai/server/index.ts
T
2026-09-23 21:56:40 +03:00

412 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Сервер 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<Buffer> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks);
}
async function readJson<T>(req: Req): Promise<Partial<T>> {
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<FileRef> | 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<Text2ImgInput> | 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<ImageBatch> {
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<FormData['get']>;
const files = form.getAll('files').filter((f): f is Exclude<Entry, string | null> => 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<ProjectModelRequest>(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<CleanupRequest>(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<object>(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<ImportRequest>(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<string, string> = {
'.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();