// Сервер 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 { const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer); return Buffer.concat(chunks); } async function readJson(req: Req): Promise> { 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 | 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 | 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 { 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; const files = form.getAll('files').filter((f): f is Exclude => 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(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(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(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(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(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 = { '.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();