Files
sprite-ai/server/index.ts
T
2026-09-30 07:38:21 +03:00

633 lines
35 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, ProjectTextureRequest,
QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob,
} from '../shared/api.ts';
import { DEFAULT_TEMPLATE, isLocked, isUntextured } from '../shared/api.ts';
import { COMFY, COMFY_OUTPUT, DB_PATH, DIST, HOST, PORT } from './config.ts';
import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts';
import {
addProjectAnim, clearProjectClean, clearProjectModel, clearProjectRig, clearProjectTexture, finishProjectRig, removeProjectAnim,
startProjectRig, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage,
getProject, getSettings, insertJob, listBatches, listProjects, createProjectFor, putSettings, startBatch, startProjectClean,
startProjectModel, startProjectTexture, transaction, updateTextInput,
} from './db.ts';
import { glbTriangles, glbWithoutNormals, graftSkin, outputPath, normalizeHumanoid } from './glb.ts';
import { rigBusy, runRetarget, runSkinTokens, type Fit } from './rig.ts';
import { migrateOldRuns } from './migrate.ts';
import { liveStatus, startTracker } from './tracker.ts';
import {
cleanupGraph, escapeTemplate, hy3dWrapperGraph, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph,
} 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', 'texture', 'rig', 'retarget'];
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(createProjectFor(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;
const textureLive = p.textureJobId ? liveStatus(p.textureJobId) : undefined;
return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}), ...(textureLive ? { textureLive } : {}) };
}
const outputRel = (f: FileRef) => (f.subfolder ? `${f.subfolder}/${f.filename}` : f.filename);
const textureSizeOf = (v: unknown) => ([1024, 2048, 4096].includes(Number(v)) ? Number(v) : 2048);
/**
* 3D-модель проекта из очищенной картинки: Hunyuan3D-2 mini (без текстур) или TRELLIS.2 (сразу с текстурами).
* Новая модель заменяет прежнюю вместе с текстурами; история не хранится.
*/
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('модель уже генерируется');
if (p.textureJobId) throw new Conflict('идёт текстурирование');
if (p.rigJobId) throw new Conflict('идёт риггинг');
const b = await readJson<ProjectModelRequest>(req);
const src = sourceImage(p.clean);
const generator = b.generator === 'trellis' || b.generator === 'hy3dwrapper' ? b.generator : 'hunyuan';
let id: string;
let params: ModelJob['params'];
if (generator === 'trellis') {
const seed = parseSeed(b.seed) % 2 ** 32; // seed + 1 используется на стадии цвета
const faces = Math.round(clamp(b.faces, 1000, 2_000_000, 100000));
const textureSize = textureSizeOf(b.textureSize);
const pbr = b.pbr === true;
id = await queuePrompt(trellisGraph({ image: src.image, prefix: 'img2glb_trellis/' + src.stem, seed, faces, textureSize, pbr }));
params = { projectId, generator, seed, faces, textureSize, pbr };
} else if (generator === 'hy3dwrapper') {
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) / 8) * 8; // Hy3DVAEDecode: шаг 8
const faces = Math.round(clamp(b.faces, 100, 2_000_000, 5000));
const remeshVertices = b.remesh ? Math.round(clamp(b.remeshVertices, 100, 2_000_000, 5000)) : 0;
id = await queuePrompt(hy3dWrapperGraph({
image: src.image, prefix: 'img2glb_hy3d/' + src.stem, seed, steps, octree, faces, remeshVertices,
}));
params = { projectId, generator, seed, steps, octree, faces, remeshVertices };
} else {
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));
id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces }));
params = { projectId, generator, seed, steps, octree, faces };
}
transaction(() => {
// задача нужна трекеру только до завершения — потом удаляется
insertJob({
id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], params,
} satisfies ModelJob);
startProjectModel(projectId, id);
});
send(res, 200, withLiveProject(projectOrThrow(projectId)));
}
/** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */
function removeProjectModel(res: Res, id: number) {
const p = projectOrThrow(id);
if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
clearProjectModel(id);
send(res, 200, withLiveProject(projectOrThrow(id)));
}
/**
* Текстура Hunyuan3D-2 Paint для модели проекта без текстур (нода custom_nodes/img2glb_hunyuan_paint):
* текстурированная модель, base color, развёртка, 6 видов. Новый результат заменяет прежний.
*/
async function projectTexture(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId);
if (!p.clean) throw new Conflict('сначала почистите исходник');
if (!p.model) throw new Conflict('сначала сделайте 3D-модель');
if (!isUntextured(p.model.generator)) throw new Conflict('модель уже с текстурами');
if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
const b = await readJson<ProjectTextureRequest>(req);
const src = sourceImage(p.clean);
const seed = parseSeed(b.seed) % 2 ** 32;
const textureSize = textureSizeOf(b.textureSize);
const delight = b.delight !== false;
const id = await queuePrompt(paintGraph({
image: src.image, mesh: outputRel(p.model.glb), prefix: 'img2glb_paint/' + src.stem, seed, textureSize, delight,
}));
transaction(() => {
// задача нужна трекеру только до завершения — потом удаляется
insertJob({
id, type: 'texture', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [],
params: { projectId, seed, textureSize, delight },
} satisfies TextureJob);
startProjectTexture(projectId, id);
});
send(res, 200, withLiveProject(projectOrThrow(projectId)));
}
/**
* Убрать текстурную группу. Текстуры TRELLIS.2 сделаны одной задачей с моделью — уходит и модель;
* текстуры Paint — только они.
*/
function removeProjectTexture(res: Res, id: number) {
const p = projectOrThrow(id);
if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
if (p.texture?.source === 'trellis') clearProjectModel(id);
else clearProjectTexture(id);
send(res, 200, withLiveProject(projectOrThrow(id)));
}
const RIG_DIR = 'img2glb_rig';
/** Готовые скелеты (GLB, можно с анимацией) для рига — выбираются в форме по относительному пути. */
const MOTIONS_DIR = path.join(import.meta.dirname, '..', 'data', 'motions');
/** GLB в app/data/motions и подпапках: относительные пути через «/», по алфавиту. */
function listMotions(): string[] {
if (!fs.existsSync(MOTIONS_DIR)) return [];
return (fs.readdirSync(MOTIONS_DIR, { recursive: true }) as string[])
.filter(f => f.toLowerCase().endsWith('.glb'))
.map(f => f.replace(/\\/g, '/'))
.sort((a, b) => a.localeCompare(b));
}
/**
* Риг SkinTokens (skin-tokens.cpp на CPU в WSL) для модели проекта — текстурированной, если есть.
* multipart: skeleton — необязательный GLB со скелетом или motion — путь GLB в app/data/motions (listMotions);
* тогда skin: только веса под него, с совмещением fit;
* без него — rig: скелет генерируется. skin-tokens.cpp теряет UV и материалы, поэтому скелет и веса переносятся
* на исходный GLB (graftSkin). Работает минуты — ответ сразу, результат пишется в проект по завершении.
*/
async function projectRig(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId);
if (!p.model) throw new Conflict('сначала сделайте 3D-модель');
if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
if (rigBusy()) throw new Conflict('идёт риггинг другого проекта — SkinTokens занимает весь процессор');
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');
}
const fitValue = String(form.get('fit') ?? 'global');
const fit: Fit = fitValue === 'none' || fitValue === 'articulated' ? fitValue : 'global';
const postprocess = form.get('postprocess') === 'true';
const basedOn = p.texture?.glb ? 'textured' : 'model';
const base = basedOn === 'textured' ? p.texture!.glb! : p.model.glb;
const basePath = outputPath(base);
if (!basePath || !fs.existsSync(basePath)) throw new NotFound('нет файла модели');
const stem = safeName(path.basename(base.filename, '.glb'));
const dir = path.join(COMFY_OUTPUT, RIG_DIR);
fs.mkdirSync(dir, { recursive: true });
const stamp = Date.now();
let skeleton: FileRef | null = null;
const file = form.get('skeleton');
if (file && typeof file !== 'string' && file.size > 0) {
const buf = Buffer.from(await file.arrayBuffer());
if (buf.toString('latin1', 0, 4) !== 'glTF') throw new BadRequest('скелет должен быть GLB');
skeleton = { filename: `${stem}_skeleton_${stamp}.glb`, subfolder: RIG_DIR, type: 'output' };
fs.writeFileSync(path.join(dir, skeleton.filename), buf);
}
// встроенный анимированный скелет (ходьба): веса под него, его анимация переносится в результат
const motion = !skeleton ? String(form.get('motion') ?? '') : '';
if (motion && !listMotions().includes(motion)) throw new BadRequest('нет такого скелета в motions: ' + motion);
const skeletonPath = skeleton ? path.join(dir, skeleton.filename) : motion ? path.join(MOTIONS_DIR, motion) : null;
const raw = path.join(dir, `${stem}_skintokens_${stamp}.glb`);
const out: FileRef = { filename: `${stem}_rig_${stamp}.glb`, subfolder: RIG_DIR, type: 'output' };
const jobId = 'skintokens:' + stamp;
startProjectRig(projectId, jobId);
void runSkinTokens({ mesh: basePath, skeleton: skeletonPath, fit, postprocess, output: raw })
.then(() => {
const glb = graftSkin(fs.readFileSync(basePath), fs.readFileSync(raw), skeletonPath ? fs.readFileSync(skeletonPath) : undefined);
fs.writeFileSync(path.join(dir, out.filename), glb);
fs.rmSync(raw, { force: true });
const joints = (JSON.parse(glb.toString('utf8', 20, 20 + glb.readUInt32LE(12))) as { skins: { joints: unknown[] }[] }).skins[0].joints.length;
finishProjectRig(projectId, {
mode: skeletonPath ? 'skin' : 'rig', glb: out, skeleton, ...(motion ? { motion } : {}), basedOn, faces: glbTriangles(out), joints,
seconds: Math.round((Date.now() - stamp) / 1000), updated: Date.now(),
});
})
.catch((e: Error) => finishProjectRig(projectId, null, e.message));
send(res, 200, withLiveProject(projectOrThrow(projectId)));
}
/**
* Новая анимация из app/data/motions (добавляется к прежним) на сгенерированный SkinTokens скелет рига — отдельным
* шагом, без риггинга (секунды):
* движение Mixamo → SOMA30 под этот скелет → retarget-generated по файлу рига → на исходную модель (graftSkin).
*/
async function projectRetarget(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId);
if (!p.rig || p.rig.mode !== 'rig') throw new Conflict('нужен риг со скелетом, сгенерированным SkinTokens');
if (p.rigJobId) throw new Conflict('идёт риггинг');
const body = await readJson<{ motions?: string[]; motion?: string }>(req);
const motions = [...new Set((body.motions ?? (body.motion ? [body.motion] : [])).map(String))];
if (!motions.length) throw new BadRequest('не выбрано ни одного движения');
const known = listMotions();
const unknown = motions.find(m => !known.includes(m));
if (unknown) throw new BadRequest('нет такого файла в motions: ' + unknown);
const rigPath = outputPath(p.rig.glb);
const base = p.rig.basedOn === 'textured' ? p.texture?.glb : p.model?.glb;
const basePath = base && outputPath(base);
if (!rigPath || !fs.existsSync(rigPath) || !basePath || !fs.existsSync(basePath)) throw new NotFound('нет файла рига или модели');
const dir = path.join(COMFY_OUTPUT, RIG_DIR);
const stamp = Date.now();
const stem = safeName(path.basename(p.rig.glb.filename, '.glb'));
const jobs = motions.map((m, k) => ({
motion: path.join(MOTIONS_DIR, m),
soma: path.join(dir, `${stem}_soma30_${stamp}_${k}.glb`),
output: path.join(dir, `${stem}_retarget_${stamp}_${k}.glb`),
}));
const out: FileRef = { filename: `${stem}_anim_${stamp}.glb`, subfolder: RIG_DIR, type: 'output' };
// имя анимации в GLB — имя файла движения (Walking65.glb → Walking65)
const animName = (m: string) => path.basename(m, path.extname(m));
// скелет — в топологию, которую принимает retarget-generated (руки на ключицах, носки у ног)
const fixed = normalizeHumanoid(fs.readFileSync(rigPath));
const humanoid = fixed ? path.join(dir, `${stem}_humanoid_${stamp}.glb`) : rigPath;
try {
if (fixed) fs.writeFileSync(humanoid, fixed.glb);
await runRetarget({ rig: humanoid, jobs });
const named = jobs.map((j, k) => ({ glb: fs.readFileSync(j.output), name: animName(motions[k]) }));
fs.writeFileSync(path.join(dir, out.filename), graftSkin(fs.readFileSync(basePath), fs.readFileSync(jobs[0].output), undefined, named));
addProjectAnim(projectId, {
glb: out, motion: motions.join(', '), motions, seconds: Math.round((Date.now() - stamp) / 100) / 10, updated: Date.now(),
});
} catch (e) {
addProjectAnim(projectId, null, (e as Error).message);
} finally {
for (const j of jobs) { fs.rmSync(j.soma, { force: true }); fs.rmSync(j.output, { force: true }); }
if (fixed) fs.rmSync(humanoid, { force: true });
}
send(res, 200, withLiveProject(projectOrThrow(projectId)));
}
/** Убрать одну анимацию из проекта (?file=имя GLB). */
function deleteProjectAnim(res: Res, id: number, file: string) {
if (!projectOrThrow(id).anims.some(a => a.glb.filename === file)) throw new NotFound('нет такой анимации');
removeProjectAnim(id, file);
send(res, 200, withLiveProject(projectOrThrow(id)));
}
/** Убрать риг из проекта. */
function removeProjectRig(res: Res, id: number) {
const p = projectOrThrow(id);
if (p.rigJobId) throw new Conflict('идёт риггинг');
clearProjectRig(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|texture|rig|retarget))?$/.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 (pm && req.method === 'POST' && pm[2] === 'texture') return await projectTexture(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'texture') return removeProjectTexture(res, Number(pm[1]));
if (pm && req.method === 'POST' && pm[2] === 'rig') return await projectRig(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'rig') return removeProjectRig(res, Number(pm[1]));
if (pm && req.method === 'POST' && pm[2] === 'retarget') return await projectRetarget(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'retarget') return deleteProjectAnim(res, Number(pm[1]), u.searchParams.get('file') ?? '');
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/motions') return send(res, 200, listMotions());
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();