633 lines
35 KiB
TypeScript
633 lines
35 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, 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();
|