initial commit

This commit is contained in:
Aggtaa
2026-09-23 21:56:40 +03:00
commit 77da3ec94e
39 changed files with 9667 additions and 0 deletions
+411
View File
@@ -0,0 +1,411 @@
// Сервер 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();