412 lines
21 KiB
TypeScript
412 lines
21 KiB
TypeScript
// Сервер 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();
|