trellis.2. unified 3d workflows

This commit is contained in:
Aggtaa
2026-09-24 13:34:06 +03:00
parent a47d23603c
commit 8db94e5c31
11 changed files with 690 additions and 177 deletions
+82 -20
View File
@@ -7,21 +7,23 @@ import path from 'node:path';
import { Readable } from 'node:stream';
import type { ReadableStream as WebStream } from 'node:stream/web';
import type {
CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, QueueInfo,
SettingsPage, Text2ImgInput, Text2ImgJob,
CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest,
QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob,
} from '../shared/api.ts';
import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts';
import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts';
import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts';
import {
clearProjectClean, clearProjectModel, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, getProject,
getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean,
startProjectModel, transaction, updateTextInput,
clearProjectClean, clearProjectModel, clearProjectTexture, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage,
getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean,
startProjectModel, startProjectTexture, transaction, updateTextInput,
} from './db.ts';
import { glbWithoutNormals, outputPath } from './glb.ts';
import { migrateOldRuns } from './migrate.ts';
import { liveStatus, startTracker } from './tracker.ts';
import { cleanupGraph, escapeTemplate, kindOf, modelGraph, renderTemplate, text2imgGraph } from './workflows.ts';
import {
cleanupGraph, escapeTemplate, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph,
} from './workflows.ts';
class BadRequest extends Error {}
class NotFound extends Error {}
@@ -33,7 +35,7 @@ const UPLOAD_DIR = 'img2glb_upload';
type Req = http.IncomingMessage;
type Res = http.ServerResponse;
const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model'];
const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model', 'texture'];
function send(res: Res, code: number, obj: unknown) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
@@ -208,43 +210,101 @@ function projectOrThrow(id: number): Project {
function withLiveProject(p: Project): Project {
const live = p.cleanJobId ? liveStatus(p.cleanJobId) : undefined;
const modelLive = p.modelJobId ? liveStatus(p.modelJobId) : undefined;
return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}) };
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) + PNG-превью.
* Новая модель заменяет прежнюю; история не хранится.
* 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('идёт текстурирование');
const b = await readJson<ProjectModelRequest>(req);
const src = sourceImage(p.clean);
const seed = parseSeed(b.seed);
const steps = Math.round(clamp(b.steps, 1, 100, 30));
const octree = Math.round(clamp(b.octree, 64, 512, 380));
const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000));
const id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces }));
const generator = b.generator === 'trellis' ? 'trellis' : '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 {
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: { projectId, seed, steps, octree, faces },
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)));
}
/** Убрать 3D-модель из проекта — после этого можно сгенерировать заново. */
/** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */
function removeProjectModel(res: Res, id: number) {
const p = projectOrThrow(id);
if (p.modelJobId) throw new Conflict('идёт генерация модели');
if (p.modelJobId || p.textureJobId) 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 (p.model.generator !== 'hunyuan') throw new Conflict('модель уже с текстурами');
if (p.modelJobId || p.textureJobId) 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) throw new Conflict('идёт генерация');
if (p.texture?.source === 'trellis') clearProjectModel(id);
else clearProjectTexture(id);
send(res, 200, withLiveProject(projectOrThrow(id)));
}
/**
* Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона.
* Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится.
@@ -383,12 +443,14 @@ http.createServer(async (req, res) => {
}
if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject));
if (route === 'POST /api/projects') return await createProject(req, res);
const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model))?$/.exec(u.pathname);
const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model|texture))?$/.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 (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);