trellis.2. unified 3d workflows
This commit is contained in:
+82
-20
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user