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
+1
View File
@@ -1,2 +1,3 @@
node_modules/
data/
dist/
+66 -33
View File
@@ -3,10 +3,10 @@ import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type {
BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, Project, SettingsPage, Text2ImgInput,
BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput,
TextureArtifacts,
} from '../shared/api.ts';
import { DB_PATH } from './config.ts';
import { glbTriangles } from './glb.ts';
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new DatabaseSync(DB_PATH);
@@ -104,17 +104,30 @@ addColumns('Projects', {
cleanJobId: 'TEXT', // prompt_id идущей очистки
cleanError: 'TEXT', // ошибка последней очистки
});
// последняя 3D-модель проекта из очищенной картинки (история не хранится)
// 3D-артефакты проекта (история не хранится): модель без текстур и текстурная группа (TRELLIS.2 или Paint).
// v2: прежние раздельные столбцы model*/texture*/trellis*/paint* убраны, 3D-результаты начаты с нуля.
if (!(db.prepare("SELECT 1 FROM meta WHERE key = 'projects.artifacts.v2'").get())) {
const have = new Set((db.prepare('PRAGMA table_info(Projects)').all() as { name: string }[]).map(c => c.name));
for (const c of ['model', 'modelPreview', 'modelUpdated', 'modelJobId', 'modelError', 'modelFaces',
'texture', 'textureUpdated', 'textureFaces', 'textureJobId', 'textureError',
'trellis', 'trellisUpdated', 'trellisFaces', 'trellisJobId', 'trellisError',
'paint', 'paintUpdated', 'paintFaces', 'paintJobId', 'paintError']) {
if (have.has(c)) db.exec(`ALTER TABLE Projects DROP COLUMN ${c}`);
}
db.exec(`DELETE FROM jobs WHERE type IN ('model', 'texture', 'trellis', 'paint')`);
db.exec(`DELETE FROM settings WHERE page IN ('trellis', 'paint')`);
db.exec(`INSERT INTO meta (key, value) VALUES ('projects.artifacts.v2', '1')`);
}
addColumns('Projects', {
model: 'TEXT', // JSON FileRef GLB
modelPreview: 'TEXT', // JSON FileRef PNG-превью
modelUpdated: 'INTEGER', // мс
model: 'TEXT', // JSON ModelArtifacts: модель без текстур
modelJobId: 'TEXT', // prompt_id идущей генерации
modelError: 'TEXT', // ошибка последней генерации
modelFaces: 'INTEGER', // число треугольников в GLB (из файла)
texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды
textureJobId: 'TEXT', // prompt_id идущего текстурирования
textureError: 'TEXT', // ошибка последнего текстурирования
});
// истории очисток и 3D-моделей не хранятся (результат — в проекте) — завершённые задачи удаляются
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model') AND state <> 'pending'`);
// истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`);
/** Несколько записей одной транзакцией. */
export function transaction<T>(fn: () => T): T {
@@ -340,9 +353,9 @@ export function deleteBatch(b: ImageBatch) {
const SELECT_PROJECT = `
SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError,
p.model, p.modelPreview, p.modelUpdated, p.modelJobId, p.modelError, p.modelFaces,
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type
FROM Projects p JOIN Images i ON i.id = p.imageId`;
p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError,
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType
FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`;
const pq = {
insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'),
@@ -355,11 +368,15 @@ const pq = {
clearClean: db.prepare('UPDATE Projects SET clean = NULL, cleanUpdated = NULL, cleanError = NULL WHERE id = ?'),
byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'),
startModel: db.prepare('UPDATE Projects SET modelJobId = ?, modelError = NULL WHERE id = ?'),
finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?, model), modelPreview = COALESCE(?, modelPreview),
modelFaces = COALESCE(?, modelFaces), modelUpdated = ?, modelError = ?, modelJobId = NULL WHERE id = ?`),
clearModel: db.prepare(`UPDATE Projects SET model = NULL, modelPreview = NULL, modelFaces = NULL, modelUpdated = NULL, modelError = NULL
WHERE id = ?`),
setModelFaces: db.prepare('UPDATE Projects SET modelFaces = ? WHERE id = ?'),
// новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся
finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model),
texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END,
modelError = ?3, modelJobId = NULL WHERE id = ?4`),
byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'),
startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'),
finishTexture: db.prepare('UPDATE Projects SET texture = COALESCE(?, texture), textureError = ?, textureJobId = NULL WHERE id = ?'),
clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'),
clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'),
};
/** Генерация 3D-модели проекта поставлена в очередь. */
@@ -371,17 +388,38 @@ export function projectByModelJob(jobId: string): number | null {
return (pq.byModelJob.get(jobId) as { id: number } | undefined)?.id ?? null;
}
/** Генерация завершена: новая модель заменяет прежнюю (при ошибке прежняя остаётся). */
export function finishProjectModel(id: number, model: FileRef | null, preview: FileRef | null, error?: string) {
pq.finishModel.run(model ? JSON.stringify(model) : null, preview ? JSON.stringify(preview) : null,
model ? glbTriangles(model) : null, Date.now(), error ?? null, id);
/**
* Генерация завершена: новая модель заменяет прежнюю, прежние текстуры убираются (TRELLIS.2 сразу даёт свои).
* При ошибке (model = null) всё остаётся как было.
*/
export function finishProjectModel(id: number, model: ModelArtifacts | null, texture: TextureArtifacts | null, error?: string) {
pq.finishModel.run(model ? JSON.stringify(model) : null, texture ? JSON.stringify(texture) : null, error ?? null, id);
}
/** Убрать 3D-модель из проекта (файлы в ComfyUI/output остаются). */
/** Текстурирование проекта поставлено в очередь. */
export function startProjectTexture(id: number, jobId: string) {
pq.startTexture.run(jobId, id);
}
export function projectByTextureJob(jobId: string): number | null {
return (pq.byTextureJob.get(jobId) as { id: number } | undefined)?.id ?? null;
}
/** Текстурирование завершено: новый результат заменяет прежний (при ошибке прежний остаётся). */
export function finishProjectTexture(id: number, texture: TextureArtifacts | null, error?: string) {
pq.finishTexture.run(texture ? JSON.stringify(texture) : null, error ?? null, id);
}
/** Убрать модель из проекта вместе с текстурами — они сделаны для неё (файлы в ComfyUI/output остаются). */
export function clearProjectModel(id: number) {
pq.clearModel.run(id);
}
/** Убрать текстурную группу (файлы в ComfyUI/output остаются). */
export function clearProjectTexture(id: number) {
pq.clearTexture.run(id);
}
/** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */
export function clearProjectClean(id: number) {
pq.clearClean.run(id);
@@ -389,26 +427,21 @@ export function clearProjectClean(id: number) {
interface ProjectRow {
id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null;
model: string | null; modelPreview: string | null; modelUpdated: number | null; modelJobId: string | null; modelError: string | null;
modelFaces: number | null;
imageId: number; batchId: number; filename: string; subfolder: string; type: string;
model: string | null; modelJobId: string | null; modelError: string | null;
texture: string | null; textureJobId: string | null; textureError: string | null;
imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string;
}
const json = (s: string | null) => (s ? JSON.parse(s) : null);
function toProject(r: ProjectRow): Project {
// модели, прикреплённые до появления modelFaces, — число полигонов считается из файла один раз
if (r.model && r.modelFaces == null) {
r.modelFaces = glbTriangles(JSON.parse(r.model));
if (r.modelFaces != null) pq.setModelFaces.run(r.modelFaces, r.id);
}
return {
id: r.id, created: r.created, batchId: r.batchId,
id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text',
image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id },
clean: json(r.clean), cleanUpdated: r.cleanUpdated,
cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}),
model: json(r.model), modelPreview: json(r.modelPreview), modelUpdated: r.modelUpdated,
modelFaces: r.modelFaces, modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}),
};
}
+78 -16
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 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));
const id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces }));
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);
+50 -6
View File
@@ -1,10 +1,14 @@
// Фоновое отслеживание незавершённых задач: результаты из ComfyUI записываются в базу,
// даже если ни одна страница не открыта.
import type { FileRef, Job, LiveStatus } from '../shared/api.ts';
import type {
FileRef, Job, LiveStatus, ModelArtifacts, ModelGenerator, TextureArtifacts, TextureSource,
} from '../shared/api.ts';
import { comfyJson } from './comfy.ts';
import { glbTriangles } from './glb.ts';
import { TEXTURE_VIEWS } from './workflows.ts';
import {
batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, pendingJobs, projectByCleanJob,
projectByModelJob, transaction,
batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, finishProjectTexture, pendingJobs,
projectByCleanJob, projectByModelJob, projectByTextureJob, transaction,
} from './db.ts';
/** Сколько ждать задачу, которой нет ни в очереди, ни в истории ComfyUI (ComfyUI перезапускался). */
@@ -40,6 +44,36 @@ function outputFiles(h: HistoryEntry) {
return { images, models };
}
type Files = { images?: FileRef[]; models?: FileRef[] };
/** Файл по метке префикса (см. workflows.ts): <prefix><tag>_NNNNN_.png. */
const tagged = (files: FileRef[] | undefined, tag: string) => files?.find(i => i.filename.includes(tag + '_')) ?? null;
/**
* Модель без текстур: у Hunyuan3D-2 mini — единственный GLB, у TRELLIS.2 — <prefix>_mesh_…glb; плюс превью.
*/
function modelArtifacts(generator: ModelGenerator, r: Files): ModelArtifacts | null {
const glb = generator === 'trellis' ? tagged(r.models, '_mesh') : r.models?.[0];
if (!glb) return null;
return { generator, glb, preview: tagged(r.images, '_preview'), faces: glbTriangles(glb), updated: Date.now() };
}
/**
* Текстурная группа: текстурированный GLB (не _mesh), base color, metallic, roughness, развёртка и виды —
* рендеры _view<угол> у TRELLIS.2 или 6 нарисованных видов _mv у Paint.
*/
function textureArtifacts(source: TextureSource, r: Files): TextureArtifacts | null {
const glb = r.models?.find(m => !m.filename.includes('_mesh_')) ?? null;
if (!glb) return null;
const images = r.images ?? [];
const views = source === 'trellis'
? TEXTURE_VIEWS.map(a => tagged(images, '_view' + a)).filter((i): i is FileRef => i != null)
: images.filter(i => i.filename.includes('_mv_')).sort((x, y) => x.filename.localeCompare(y.filename));
return {
source, glb, faces: glbTriangles(glb), baseColor: tagged(images, '_basecolor'), metallic: tagged(images, '_metallic'),
roughness: tagged(images, '_roughness'), uv: tagged(images, '_uv'), views, updated: Date.now(),
};
}
/**
* Завершение задачи. Картинки text2img прикрепляются к её батчу (Images.batchId);
* результат очистки и модель проекта заменяют прежние в проекте, а сама задача удаляется — история не хранится.
@@ -52,11 +86,21 @@ function finish(job: Job, state: 'done' | 'error', result: { error?: string; ima
deleteJob(job.id);
return;
}
// модель проекта: GLB и превью — в проект, задача удаляется (история не хранится)
// модель проекта (у TRELLIS.2 — сразу с текстурами) — в проект, задача удаляется (история не хранится)
const modelProject = job.type === 'model' ? job.params.projectId ?? projectByModelJob(job.id) : null;
if (modelProject != null) {
if (job.type === 'model' && modelProject != null) {
const generator = job.params.generator ?? 'hunyuan';
const done = state === 'done';
finishProjectModel(modelProject, done ? result.models?.[0] ?? null : null, done ? result.images?.[0] ?? null : null, result.error);
const model = done ? modelArtifacts(generator, result) : null;
const texture = done && generator === 'trellis' ? textureArtifacts('trellis', result) : null;
finishProjectModel(modelProject, model, texture, result.error ?? (done && !model ? 'нет GLB в результате' : undefined));
deleteJob(job.id);
return;
}
// текстура Hunyuan3D-2 Paint — в проект, задача удаляется
const textureProject = job.type === 'texture' ? job.params.projectId ?? projectByTextureJob(job.id) : null;
if (textureProject != null) {
finishProjectTexture(textureProject, state === 'done' ? textureArtifacts('paint', result) : null, result.error);
deleteJob(job.id);
return;
}
+129 -3
View File
@@ -57,15 +57,141 @@ export function modelGraph(o: {
'10': { class_type: 'DecimateMesh', inputs: { mesh: ['9', 0], target_face_count: o.faces, placement_mode: 'midpoint' } },
'11': { class_type: 'MeshSmoothNormals', inputs: { mesh: ['10', 0], crease_angle: 180.0 } },
'12': { class_type: 'SaveGLB', inputs: { mesh: ['11', 0], filename_prefix: o.prefix } },
'13': { class_type: 'RotateMesh', inputs: { mesh: ['11', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } },
'14': { class_type: 'RenderMesh', inputs: { mesh: ['13', 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } },
'15': { class_type: 'SaveImage', inputs: { images: ['14', 0], filename_prefix: o.prefix + '_preview' } },
...meshPreview(['11', 0], o.prefix, 13),
};
}
/** PNG-превью меша без текстур в ракурсе 3/4 → <prefix>_preview_…; узлы id, id+1, id+2. */
function meshPreview(mesh: Link, prefix: string, id: number): Graph {
return {
[id]: { class_type: 'RotateMesh', inputs: { mesh, mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } },
[id + 1]: { class_type: 'RenderMesh', inputs: { mesh: [String(id), 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } },
[id + 2]: { class_type: 'SaveImage', inputs: { images: [String(id + 1), 0], filename_prefix: prefix + '_preview' } },
};
}
/** Ракурсы рендеров текстурированной модели TRELLIS.2, градусы поворота вокруг вертикали. */
export const TEXTURE_VIEWS = [0, 90, 180, 270];
/**
* Общая часть TRELLIS.2 (встроенные ноды ComfyUI, по шаблону 3d_pixal3d_trellis2_image_to_model): по картинке —
* плотный меш формы (нода 22) и цвет-воксели (нода 25).
*/
function trellisNodes(o: { image: string; seed: number }): Graph {
return {
// картинка → маска → кадр (TRELLIS.2: pad_factor 1.0) → условие DINOv3
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
'2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } },
'3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } },
'4': { class_type: 'ImageCropToMask', inputs: {
images: ['1', 0], masks: ['3', 0], width: 1024, height: 1024, pad_factor: 1.0, grow_mask: 0, background: '#000000' } },
'5': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'dino_v3_L_naf_fp32.safetensors' } },
'6': { class_type: 'Trellis2Conditioning', inputs: { clip_vision_model: ['5', 0], image: ['4', 0] } },
'7': { class_type: 'UNETLoader', inputs: { unet_name: 'trellis_2_int8_convrot.safetensors', weight_dtype: 'default' } },
'8': { class_type: 'VAELoader', inputs: { vae_name: 'trellis_2_shape_vae_bf16.safetensors' } },
'9': { class_type: 'VAELoader', inputs: { vae_name: 'trellis_2_texture_vae_bf16.safetensors' } },
// структура (разреженные воксели)
'10': { class_type: 'CFGOverride', inputs: { model: ['7', 0], cfg: 1, start_percent: 0.667, end_percent: 1 } },
'11': { class_type: 'RescaleCFG', inputs: { model: ['10', 0], multiplier: 0.7 } },
'12': { class_type: 'ModelSamplingSD3', inputs: { model: ['11', 0], shift: 5 } },
'13': { class_type: 'EmptyTrellis2LatentStructure', inputs: { batch_size: 1 } },
'14': { class_type: 'KSampler', inputs: { model: ['12', 0], positive: ['6', 0], negative: ['6', 1], latent_image: ['13', 0],
seed: o.seed, steps: 12, cfg: 7.5, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
'15': { class_type: 'VaeDecodeStructureTrellis2', inputs: { samples: ['14', 0], vae: ['8', 0], resolution: '32' } },
// форма 512 → 1536
'16': { class_type: 'Trellis2ShapeStage', inputs: { positive: ['6', 0], negative: ['6', 1], voxel: ['15', 0] } },
'17': { class_type: 'CFGOverride', inputs: { model: ['7', 0], cfg: 1, start_percent: 0.769, end_percent: 1 } },
'18': { class_type: 'RescaleCFG', inputs: { model: ['17', 0], multiplier: 0.5 } },
'19': { class_type: 'KSampler', inputs: { model: ['18', 0], positive: ['16', 0], negative: ['16', 1], latent_image: ['16', 2],
seed: o.seed, steps: 20, cfg: 7.5, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
'20': { class_type: 'Trellis2UpsampleStage', inputs: { positive: ['16', 0], negative: ['16', 1], shape_latent: ['19', 0], vae: ['8', 0],
target_resolution: 1536 } },
'21': { class_type: 'KSampler', inputs: { model: ['18', 0], positive: ['20', 0], negative: ['20', 1], latent_image: ['20', 2],
seed: o.seed, steps: 12, cfg: 7.5, sampler_name: 'euler', scheduler: 'simple', denoise: 1 } },
'22': { class_type: 'VaeDecodeShapeTrellis', inputs: { samples: ['21', 0], vae: ['8', 0] } },
// цвет-воксели
'23': { class_type: 'Trellis2TextureStage', inputs: { positive: ['20', 0], negative: ['20', 1], shape_latent: ['21', 0] } },
'24': { class_type: 'KSampler', inputs: { model: ['7', 0], positive: ['23', 0], negative: ['23', 1], latent_image: ['23', 2],
seed: o.seed + 1, steps: 12, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
'25': { class_type: 'VaeDecodeTextureTrellis', inputs: { samples: ['24', 0], vae: ['9', 0], shape_subdivides: ['22', 1] } },
};
}
/**
* Текстурированная модель (узел textured) и её файлы: GLB, base color, развёртка (узел unwrapped — меш с UV).
* Файлы: <prefix>_NNNNN_.glb, <prefix>_basecolor_…, <prefix>_uv_….
*/
function saveTextured(g: Graph, prefix: string, unwrapped: string, baseColor: Link, textured: Link) {
g['32'] = { class_type: 'MeshSmoothNormals', inputs: { mesh: textured, crease_angle: 180 } };
g['33'] = { class_type: 'SaveGLB', inputs: { mesh: ['32', 0], filename_prefix: prefix } };
g['34'] = { class_type: 'SaveImage', inputs: { images: baseColor, filename_prefix: prefix + '_basecolor' } };
g['35'] = { class_type: 'RenderUVAtlas', inputs: { mesh: [unwrapped, 0], resolution: 1024 } };
g['36'] = { class_type: 'SaveImage', inputs: { images: ['35', 0], filename_prefix: prefix + '_uv' } };
return g;
}
/**
* Модель TRELLIS.2 целиком: своя форма по картинке → ремеш и упрощение до o.faces треугольников (это и модель
* без текстур, <prefix>_mesh_…glb + превью) → UV-развёртка → текстуры из цвет-вокселей: base color и, если o.pbr,
* metallic и roughness → текстурированный GLB, развёртка и рендеры с 4 сторон (<prefix>_view<угол>_…).
*/
export function trellisGraph(o: {
image: string; prefix: string; seed: number; faces: number; textureSize: number; pbr: boolean;
}): Graph {
const g = trellisNodes(o);
g['26'] = { class_type: 'RemeshMesh', inputs: { mesh: ['22', 0], resolution: 768, sign_mode: 'udf', 'sign_mode.qef': false,
'sign_mode.drop_inverted_components': false, 'sign_mode.drop_enclosed_components': false, band: 1, project_back: 0,
fix_poles: false, smooth_iters: 20, drop_small_components: 0.01, precluster_max_verts: 20000000 } };
g['27'] = { class_type: 'DecimateMesh', inputs: { mesh: ['26', 0], target_face_count: o.faces, placement_mode: 'midpoint' } };
// модель без текстур
g['38'] = { class_type: 'MeshSmoothNormals', inputs: { mesh: ['27', 0], crease_angle: 180 } };
g['39'] = { class_type: 'SaveGLB', inputs: { mesh: ['38', 0], filename_prefix: o.prefix + '_mesh' } };
Object.assign(g, meshPreview(['38', 0], o.prefix, 60));
// текстуры: каждый тексель проецируется на плотную поверхность TRELLIS.2 и берёт цвет из её цвет-вокселей
g['28'] = { class_type: 'UnwrapMesh', inputs: { mesh: ['27', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } };
g['29'] = { class_type: 'BakeTextureFromVoxel', inputs: { mesh: ['28', 0], voxel_colors: ['25', 0], texture_size: o.textureSize,
reference_mesh: ['22', 0] } };
g['30'] = { class_type: 'ApplyTextureToMesh', inputs: {
mesh: ['28', 0], base_color: ['29', 0], ...(o.pbr ? { metallic: ['29', 1], roughness: ['29', 2] } : {}) } };
saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]);
if (o.pbr) {
g['52'] = { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_metallic' } };
g['53'] = { class_type: 'SaveImage', inputs: { images: ['29', 2], filename_prefix: o.prefix + '_roughness' } };
}
TEXTURE_VIEWS.forEach((angle, i) => {
const r = String(40 + i * 3), v = String(41 + i * 3), s = String(42 + i * 3);
g[r] = { class_type: 'RotateMesh', inputs: { mesh: ['32', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': angle, 'mode.angle_z': 0 } };
g[v] = { class_type: 'RenderMesh', inputs: { mesh: [r, 0], mode: 'texture', width: 512, height: 512, background: '#E7E5E4' } };
g[s] = { class_type: 'SaveImage', inputs: { images: [v, 0], filename_prefix: `${o.prefix}_view${angle}` } };
});
return g;
}
/**
* Текстура Hunyuan3D-2 Paint (turbo) для модели проекта — нода custom_nodes/img2glb_hunyuan_paint:
* Delight снимает свет с очищенной картинки, мультивью-модель рисует 6 видов (<prefix>_mv_…) по нормалям и
* позициям меша (o.mesh — путь в output) после UV-развёртки, виды запекаются в текстуру. Геометрия не меняется.
*/
export function paintGraph(o: { image: string; mesh: string; prefix: string; seed: number; textureSize: number; delight: boolean }): Graph {
const g: Graph = {
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
// маска объекта: фон картинки заменяется белым, как в оригинальном пайплайне (там картинка RGBA)
'2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } },
'3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } },
'26': { class_type: 'Img2GlbLoadOutputMesh', inputs: { path: o.mesh } },
'28': { class_type: 'UnwrapMesh', inputs: { mesh: ['26', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } },
'29': { class_type: 'Img2GlbHunyuanPaint', inputs: { mesh: ['28', 0], image: ['1', 0], mask: ['3', 0],
texture_size: o.textureSize, seed: o.seed, delight: o.delight } },
'30': { class_type: 'ApplyTextureToMesh', inputs: { mesh: ['28', 0], base_color: ['29', 0] } },
'37': { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_mv' } },
};
return saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]);
}
/** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */
export function kindOf(graph: unknown): JobKind {
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type);
if (types.includes('Img2GlbHunyuanPaint')) return 'текстура';
if (types.includes('SaveGLB')) return '3D-модель';
if (types.includes('RemoveBackground')) return 'очистка фона';
if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация';
+105 -14
View File
@@ -43,6 +43,8 @@ export interface Project {
created: number;
/** батч, из которого картинка */
batchId: number;
/** откуда исходная картинка: сгенерирована (text — SDXL) или загружена */
imageSource: 'text' | 'upload';
/** исходная картинка */
image: BatchImage;
/** последняя очищенная картинка (история очисток не хранится) */
@@ -54,26 +56,87 @@ export interface Project {
cleanError?: string;
/** положение идущей очистки в очереди ComfyUI */
live?: LiveStatus;
/** последняя 3D-модель из очищенной картинки: GLB и PNG-превью (история не хранится) */
model: FileRef | null;
modelPreview: FileRef | null;
modelUpdated: number | null;
/** число треугольников в GLB (null — неизвестно) */
modelFaces: number | null;
/**
* 3D-модель без текстур (история не хранится). Генерирует её Hunyuan3D-2 mini или TRELLIS.2;
* TRELLIS.2 заодно заполняет и texture.
*/
model: ModelArtifacts | null;
/** идёт генерация модели: prompt_id в ComfyUI */
modelJobId: string | null;
modelError?: string;
/** положение идущей генерации модели в очереди ComfyUI */
/** положение идущей генерации в очереди ComfyUI */
modelLive?: LiveStatus;
/** текстурированная модель и её текстуры (от TRELLIS.2 или Hunyuan3D-2 Paint) */
texture: TextureArtifacts | null;
/** идёт текстурирование (Paint): prompt_id в ComfyUI */
textureJobId: string | null;
textureError?: string;
textureLive?: LiveStatus;
}
/** Чем сгенерирована 3D-модель. */
export type ModelGenerator = 'hunyuan' | 'trellis';
export const GENERATOR_NAMES: Record<ModelGenerator, string> = { hunyuan: 'Hunyuan3D-2 mini', trellis: 'TRELLIS.2' };
/** Модель без текстур. */
export interface ModelArtifacts {
generator: ModelGenerator;
glb: FileRef;
/** PNG-превью (рендер без текстур) */
preview: FileRef | null;
/** число треугольников в GLB */
faces: number | null;
/** мс */
updated: number;
}
/** Откуда текстуры: вместе с моделью от TRELLIS.2 или отдельным текстурированием Hunyuan3D-2 Paint. */
export type TextureSource = 'trellis' | 'paint';
/** Текстурированная модель и всё, что к ней относится; каких-то файлов может не быть (у Paint — metallic, roughness). */
export interface TextureArtifacts {
source: TextureSource;
/** текстурированная модель */
glb: FileRef | null;
faces: number | null;
baseColor: FileRef | null;
/** металличность (канал B карты metallicRoughness glTF) в оттенках серого */
metallic: FileRef | null;
/** шероховатость (канал G карты metallicRoughness glTF) в оттенках серого */
roughness: FileRef | null;
/** изображение UV-развёртки */
uv: FileRef | null;
/** виды: у TRELLIS.2 — рендеры модели с 4 сторон, у Paint — 6 видов, нарисованных моделью */
views: FileRef[];
/** мс */
updated: number;
}
/** Генерация 3D-модели проекта. */
export interface ProjectModelRequest {
generator: ModelGenerator;
/** Пусто — случайный. */
seed: number | string;
/** Hunyuan3D-2 mini */
steps: number | string;
octree: number | string;
/** целевое число треугольников после упрощения */
faces: number | string;
/** TRELLIS.2: размер текстуры, px */
textureSize: number | string;
/** TRELLIS.2: металличность и шероховатость (карты metallic, roughness) в модели */
pbr: boolean;
}
/** Текстурирование модели проекта (Hunyuan3D-2 Paint). */
export interface ProjectTextureRequest {
/** Пусто — случайный. */
seed: number | string;
/** размер текстуры, px */
textureSize: number | string;
/** снимать блики и тени с картинки (Hunyuan3D Delight) */
delight: boolean;
}
interface BatchBase {
@@ -130,7 +193,7 @@ export interface CleanupRequest {
}
// ---------- задачи ComfyUI (таблица jobs; отслеживаются трекером) ----------
export type JobType = 'text2img' | 'cleanup' | 'model';
export type JobType = 'text2img' | 'cleanup' | 'model' | 'texture';
export type JobState = 'pending' | 'done' | 'error';
/** Состояние незавершённой задачи в очереди ComfyUI (не хранится, считается сервером на лету). */
@@ -161,10 +224,23 @@ export interface CleanupParams {
export interface ModelParams {
/** проект, к которому прикрепляется модель */
projectId: number;
generator: ModelGenerator;
seed: number;
steps: number;
octree: number;
faces: number;
/** Hunyuan3D-2 mini */
steps?: number;
octree?: number;
/** TRELLIS.2 */
textureSize?: number;
pbr?: boolean;
}
export interface TextureParams {
/** проект, к которому прикрепляется результат */
projectId: number;
seed: number;
textureSize: number;
delight: boolean;
}
interface JobOf<T extends JobType, P> {
@@ -180,7 +256,7 @@ interface JobOf<T extends JobType, P> {
params: P;
/** результаты-картинки (для model — PNG-превью) */
images: FileRef[];
/** GLB-файлы (только model) */
/** GLB-файлы (model, texture) */
models: FileRef[];
live?: LiveStatus;
}
@@ -188,7 +264,8 @@ interface JobOf<T extends JobType, P> {
export type Text2ImgJob = JobOf<'text2img', Text2ImgParams>;
export type CleanupJob = JobOf<'cleanup', CleanupParams>;
export type ModelJob = JobOf<'model', ModelParams>;
export type Job = Text2ImgJob | CleanupJob | ModelJob;
export type TextureJob = JobOf<'texture', TextureParams>;
export type Job = Text2ImgJob | CleanupJob | ModelJob | TextureJob;
// ---------- значения полей форм (тоже в SQLite) ----------
export interface Text2ImgSettings {
@@ -209,17 +286,31 @@ export interface CleanupSettings {
}
export interface ModelSettings {
generator: ModelGenerator;
seed: string;
/** Hunyuan3D-2 mini */
steps: string;
octree: string;
faces: string;
/** TRELLIS.2 */
trellisFaces: string;
textureSize: string;
trellisPbr: boolean;
}
export type SettingsByPage = { text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings };
export interface TextureSettings {
seed: string;
textureSize: string;
delight: boolean;
}
export type SettingsByPage = {
text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings; texture: TextureSettings;
};
export type SettingsPage = keyof SettingsByPage;
// ---------- очередь ComfyUI ----------
export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'другое';
export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'текстура' | 'другое';
export interface QueueItem {
id: string;
+8 -33
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { FileRef } from '../../shared/api.ts';
import { fileUrl } from '../lib/api.ts';
@@ -20,10 +20,8 @@ function loadModelViewer() {
return loading;
}
export type View = 'img' | '3d';
/** Переключатель из нескольких кнопок. */
function Seg<T extends string>({ options, value, onChange }: {
export function Seg<T extends string>({ options, value, onChange }: {
options: readonly (readonly [T, string])[]; value: T; onChange: (v: T) => void;
}) {
return (
@@ -35,32 +33,26 @@ function Seg<T extends string>({ options, value, onChange }: {
);
}
/** Переключатель «картинка / 3D». */
export function ViewToggle({ value, onChange }: { value: View; onChange: (v: View) => void }) {
return <Seg options={[['img', 'картинка'], ['3d', '3D']] as const} value={value} onChange={onChange} />;
}
/**
* Готовая модель: интерактивный GLB или PNG-превью.
* Готовая модель: интерактивный GLB с переключателем «сглаживание / без».
* Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще
* (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer.
*/
export function ModelView({ glb, preview, view, onOpen }: {
glb?: FileRef | null; preview?: FileRef | null; view: View; onOpen: (src: string, caption: string) => void;
export function ModelView({ glb, preview, onOpen }: {
glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void;
}) {
const [ready, setReady] = useState(false); // скрипт model-viewer загружен
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB
const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани)
const ref = useRef<HTMLElement>(null);
const src = !glb ? '' : smooth ? fileUrl(glb)
const src = smooth ? fileUrl(glb)
: '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder });
useEffect(() => {
if (view !== '3d' || !src) return;
let alive = true;
loadModelViewer().then(() => { if (alive) setReady(true); }, () => { if (alive) setState('error'); });
return () => { alive = false; };
}, [view, src]);
}, [src]);
useEffect(() => { setState('loading'); }, [src]);
@@ -72,13 +64,12 @@ export function ModelView({ glb, preview, view, onOpen }: {
el.addEventListener('load', onLoad);
el.addEventListener('error', onError);
return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); };
}, [ready, src, view]);
}, [ready, src]);
const previewImg = preview && (
<img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} />
);
if (view === '3d' && glb) {
return (
<div className="mv-wrap">
{ready && (
@@ -95,19 +86,3 @@ export function ModelView({ glb, preview, view, onOpen }: {
</div>
);
}
return previewImg || <span>превью нет</span>;
}
/** Ссылки «GLB» и «PNG» для скачивания; before — ещё кнопки слева от них. */
export function ModelLinks({ glb, preview, before }: { glb?: FileRef | null; preview?: FileRef | null; before?: ReactNode }) {
const items = [[glb, 'GLB', 'main'], [preview, 'PNG', '']] as const;
return (
<div className="links">
{before}
{items.map(([i, text, cls]) => i && (
<a key={text} href={fileUrl(i)} download={i.filename} className={cls} title={'скачать ' + i.filename}
onClick={e => e.stopPropagation()}>{text}</a>
))}
</div>
);
}
+10 -3
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type {
CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, Text2ImgInput, TextBatch, UploadBatch,
CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, ProjectTextureRequest, Text2ImgInput, TextBatch,
UploadBatch,
} from '../../shared/api.ts';
import { api, parse } from './api.ts';
@@ -28,9 +29,14 @@ export const projectApi = {
/** Очистка исходника проекта; результат заменит прежнюю очищенную картинку. */
cleanup: (id: number, params: CleanupRequest) => api<Project>(`/api/projects/${id}/cleanup`, { body: params }),
removeClean: (id: number) => api<Project>(`/api/projects/${id}/clean`, { method: 'DELETE' }),
/** 3D-модель из очищенной картинки; новая заменит прежнюю. */
/** 3D-модель из очищенной картинки (Hunyuan3D-2 mini или TRELLIS.2); новая заменит прежнюю вместе с текстурами. */
model: (id: number, params: ProjectModelRequest) => api<Project>(`/api/projects/${id}/model`, { body: params }),
/** Удалить модель — вместе со всеми текстурами. */
removeModel: (id: number) => api<Project>(`/api/projects/${id}/model`, { method: 'DELETE' }),
/** Текстура Hunyuan3D-2 Paint для модели без текстур. */
texture: (id: number, params: ProjectTextureRequest) => api<Project>(`/api/projects/${id}/texture`, { body: params }),
/** Удалить текстурную группу (у TRELLIS.2 — вместе с моделью). */
removeTexture: (id: number) => api<Project>(`/api/projects/${id}/texture`, { method: 'DELETE' }),
/** Проект из картинки: новый или уже созданный из неё. */
forImage: (imageId: number) => api<Project>('/api/projects', { body: { imageId } }),
get: (id: number) => api<Project>('/api/projects/' + id),
@@ -82,7 +88,7 @@ export function useProject(id: number | null) {
useEffect(() => { setProject(null); void refresh(); }, [refresh]);
const working = !!project?.cleanJobId || !!project?.modelJobId;
const working = !!project?.cleanJobId || !!project?.modelJobId || !!project?.textureJobId;
useEffect(() => {
if (!working) return;
const t = setInterval(() => { void refresh(); }, POLL_MS);
@@ -103,6 +109,7 @@ function stepStateText(jobId: string | null, live: LiveStatus | undefined, error
export const cleanStateText = (p: Project) => stepStateText(p.cleanJobId, p.live, p.cleanError, 'обработка');
export const modelStateText = (p: Project) => stepStateText(p.modelJobId, p.modelLive, p.modelError, 'генерация');
export const textureStateText = (p: Project) => stepStateText(p.textureJobId, p.textureLive, p.textureError, 'текстурирование');
/** Один батч; пока он в работе — перечитывается каждые 1.5 с. */
export function useBatch(id: number | null) {
+211 -40
View File
@@ -1,14 +1,21 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { Link, useParams } from 'react-router';
import type { CleanupSettings, ModelSettings, Project as ProjectT } from '../../shared/api.ts';
import { ModelLinks, ModelView, ViewToggle, type View } from '../components/ModelView.tsx';
import type {
CleanupSettings, FileRef, ModelGenerator, ModelSettings, Project as ProjectT, TextureSettings, TextureSource,
} from '../../shared/api.ts';
import { GENERATOR_NAMES } from '../../shared/api.ts';
import { ModelView } from '../components/ModelView.tsx';
import { useViewer } from '../components/Viewer.tsx';
import { fileUrl } from '../lib/api.ts';
import { cleanStateText, modelStateText, projectApi, useProject } from '../lib/batches.ts';
import { cleanStateText, modelStateText, projectApi, textureStateText, useProject } from '../lib/batches.ts';
import { useSettings } from '../lib/settings.ts';
const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' };
const MODEL_DEFAULTS: ModelSettings = { seed: '0', steps: '30', octree: '380', faces: '5000' };
const MODEL_DEFAULTS: ModelSettings = {
generator: 'hunyuan', seed: '0', steps: '30', octree: '380', faces: '5000', trellisFaces: '100000', textureSize: '2048',
trellisPbr: false,
};
const TEXTURE_DEFAULTS: TextureSettings = { seed: '0', textureSize: '2048', delight: true };
type Open = (src: string, caption: string) => void;
type OnProject = (p: ProjectT) => void;
@@ -94,51 +101,221 @@ function CleanBlock({ project, onProject, open }: { project: ProjectT; onProject
}
/**
* Квадрат «3D-модель» (показывается, только когда есть очищенная картинка): есть модель — переключатель
* «картинка / 3D» (по умолчанию 3D), «удалить», GLB и PNG; нет — форма Hunyuan3D на её месте; идёт генерация — её состояние.
* Форма генерации 3D-модели: Hunyuan3D-2 mini (только геометрия) или TRELLIS.2 (геометрия сразу с текстурами).
* Идёт генерация — её состояние.
*/
function ModelBlock({ project, onProject, open, view, setView }: {
project: ProjectT; onProject: OnProject; open: Open; view: View; setView: (v: View) => void;
}) {
function ModelFormBlock({ project, onProject }: { project: ProjectT; onProject: OnProject }) {
const { values: v, set } = useSettings('model', MODEL_DEFAULTS);
const { busy, error, act } = useAct(onProject);
const st = modelStateText(project);
if (project.modelJobId) return <div className="box">{st?.text}</div>;
// модель или форма — только по записи о модели в базе (загрузка model-viewer на это не влияет)
if (project.model) {
return (
<div className="box">
<ModelView glb={project.model} preview={project.modelPreview} view={view} onOpen={open} />
<span className="box-seg"><ViewToggle value={view} onChange={setView} /></span>
<ModelLinks glb={project.model} preview={project.modelPreview}
before={<RemoveLink what="3D-модель" busy={busy} onRemove={() => void act(() => projectApi.removeModel(project.id))} />} />
{error && <div className="box-error">{error}</div>}
</div>
);
}
const trellis = v.generator === 'trellis';
return (
<FormBox title="3D-модель" busy={busy} error={error || (st?.error ? st.text : '')}
hint="Hunyuan3D-2 mini: очищенная картинка → GLB-меш (только геометрия, ~1.5–2 мин) и PNG-превью."
onSubmit={() => void act(() => projectApi.model(project.id, { seed: v.seed, steps: v.steps, octree: v.octree, faces: v.faces }))}>
hint={trellis
? 'TRELLIS.2 (встроенные ноды ComfyUI): модель сразу с текстурами — base color (и metallic, roughness), UV-развёртка, рендеры с 4 сторон.'
: 'Hunyuan3D-2 mini: только геометрия (~1.5–2 мин); текстуры потом — Hunyuan3D-2 Paint.'}
onSubmit={() => void act(() => projectApi.model(project.id, {
generator: v.generator, seed: v.seed, steps: v.steps, octree: v.octree,
faces: trellis ? v.trellisFaces : v.faces, textureSize: v.textureSize, pbr: v.trellisPbr,
}))}>
<label>Модель
<select value={v.generator} onChange={e => set('generator', e.target.value as ModelGenerator)}>
{(Object.keys(GENERATOR_NAMES) as ModelGenerator[]).map(g => <option key={g} value={g}>{GENERATOR_NAMES[g]}</option>)}
</select>
</label>
<label><span>Seed <small>— пусто = случайный</small></span>
<input type="number" min={0} step={1} placeholder="случайный" value={v.seed} onChange={e => set('seed', e.target.value)} />
</label>
{trellis ? (
<>
<label><span>Треугольников <small>— после упрощения</small></span>
<input type="number" min={1000} max={2000000} step={1000} value={v.trellisFaces} onChange={e => set('trellisFaces', e.target.value)} />
</label>
<TextureSizeField value={v.textureSize} onChange={x => set('textureSize', x)} />
<label className="check">
<input type="checkbox" checked={v.trellisPbr} onChange={e => set('trellisPbr', e.target.checked)} />
<span>Генерировать с металлом и неровностями <small>— карты metallic и roughness</small></span>
</label>
</>
) : (
<>
<label><span>Шаги <small>— 30 обычно достаточно</small></span>
<input type="number" min={1} max={100} step={1} value={v.steps} onChange={e => set('steps', e.target.value)} />
</label>
<label><span>Детализация <small>— octree 256…512</small></span>
<input type="number" min={64} max={512} step={1} value={v.octree} onChange={e => set('octree', e.target.value)} />
</label>
<label><span>Полигонов <small>— 0 = без упрощения</small></span>
<input type="number" min={0} max={2000000} step={100} value={v.faces} onChange={e => set('faces', e.target.value)} />
<label><span>Треугольников <small>— 0 = без упрощения</small></span>
<input type="number" min={0} max={2000000} step={500} value={v.faces} onChange={e => set('faces', e.target.value)} />
</label>
</>
)}
</FormBox>
);
}
function TextureSizeField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<label>Размер текстуры, px
<select value={value} onChange={e => onChange(e.target.value)}>
{['1024', '2048', '4096'].map(s => <option key={s}>{s}</option>)}
</select>
</label>
);
}
/** Форма текстурирования Hunyuan3D-2 Paint (для модели без текстур); идёт текстурирование — его состояние. */
function PaintFormBlock({ project, onProject }: { project: ProjectT; onProject: OnProject }) {
const { values: v, set } = useSettings('texture', TEXTURE_DEFAULTS);
const { busy, error, act } = useAct(onProject);
const st = textureStateText(project);
if (project.textureJobId) return <div className="box">{st?.text}</div>;
return (
<FormBox title="Текстуры" busy={busy} error={error || (st?.error ? st.text : '')}
hint="Hunyuan3D-2 Paint turbo рисует 6 видов модели по очищенной картинке и запекает их в текстуру: base color, UV-развёртка, виды."
onSubmit={() => void act(() => projectApi.texture(project.id, { seed: v.seed, textureSize: v.textureSize, delight: v.delight }))}>
<label><span>Seed <small>— пусто = случайный</small></span>
<input type="number" min={0} step={1} placeholder="случайный" value={v.seed} onChange={e => set('seed', e.target.value)} />
</label>
<TextureSizeField value={v.textureSize} onChange={x => set('textureSize', x)} />
<label className="check">
<input type="checkbox" checked={v.delight} onChange={e => set('delight', e.target.checked)} />
<span>Delight <small>— снять блики и тени с картинки</small></span>
</label>
</FormBox>
);
}
/** Карточки 3D-артефактов; group — что удаляется вместе с карточкой. */
type CardId = 'model' | 'textured' | 'baseColor' | 'metallic' | 'roughness' | 'uv' | 'views';
const CARD_NAMES: Record<CardId, string> = {
model: 'модель без текстур', textured: 'текстурированная модель', baseColor: 'base color', metallic: 'metallic',
roughness: 'roughness', uv: 'UV-развёртка', views: 'виды',
};
/** Какие карточки есть у проекта. */
function presentCards(p: ProjectT): CardId[] {
const t = p.texture;
const cards: CardId[] = p.model ? ['model'] : [];
if (t) {
if (t.glb) cards.push('textured');
for (const k of ['baseColor', 'metallic', 'roughness', 'uv'] as const) if (t[k]) cards.push(k);
if (t.views.length) cards.push('views');
}
return cards;
}
/**
* Что уйдёт вместе с карточкой: модель — со всем; текстурная карточка — со всей текстурной группой,
* а у TRELLIS.2 (модель и текстуры — одна задача) — и с моделью.
*/
function removedWith(p: ProjectT, card: CardId): CardId[] {
const all = presentCards(p);
if (card === 'model' || p.texture?.source === 'trellis') return all;
return all.filter(c => c !== 'model');
}
/** Одна карточка артефакта: заголовок, квадрат с содержимым, «удалить» с предупреждением и скачивание. */
function ArtifactCard({ project, card, title, onProject, children, download, date }: {
project: ProjectT; card: CardId; title: string; onProject: OnProject; children: ReactNode;
download: [FileRef | null | undefined, string][]; date: ReactNode;
}) {
const { busy, error, act } = useAct(onProject);
const removed = removedWith(project, card);
const remove = () => {
const others = removed.filter(c => c !== card).map(c => CARD_NAMES[c]);
const msg = `Удалить «${CARD_NAMES[card]}» из проекта?` + (others.length ? `\nВместе с ней будут удалены: ${others.join(', ')}.` : '')
+ '\nФайлы в ComfyUI/output останутся.';
if (busy || !confirm(msg)) return;
void act(() => (removed.includes('model') ? projectApi.removeModel(project.id) : projectApi.removeTexture(project.id)));
};
const working = !!project.modelJobId || !!project.textureJobId;
return (
<div className="pane">
<div className="pane-title"><span title={title}>{title}</span></div>
<div className="box">
{children}
<div className="links">
{!working && <a href="#" onClick={e => { e.preventDefault(); remove(); }}>удалить</a>}
{download.map(([f, text]) => f && (
<a key={text} href={fileUrl(f)} download={f.filename} className={text === 'GLB' ? 'main' : ''} title={'скачать ' + f.filename}
onClick={e => e.stopPropagation()}>{text}</a>
))}
</div>
{error && <div className="box-error">{error}</div>}
</div>
<div className="name">{date}</div>
</div>
);
}
function Picture({ file, open }: { file: FileRef; open: Open }) {
return <img src={fileUrl(file)} alt={file.filename} onClick={() => open(fileUrl(file), file.filename)} />;
}
/** 3D-часть проекта: форма генерации или карточки артефактов; для модели без текстур — форма Paint. */
function Artifacts({ project, onProject, open }: { project: ProjectT; onProject: OnProject; open: Open }) {
const m = project.model;
const t = project.texture;
if (!m) {
return (
<div className="pane">
<div className="pane-title">3D-модель</div>
<ModelFormBlock project={project} onProject={onProject} />
</div>
);
}
const texBy = t && TEXTURE_SOURCE_NAMES[t.source];
const card = (id: CardId, title: string, children: ReactNode, download: [FileRef | null | undefined, string][], updated: number) => (
<ArtifactCard key={id} project={project} card={id} title={title} onProject={onProject} download={download}
date={<Origin by={id === 'model' ? GENERATOR_NAMES[m.generator] : texBy ?? ''} time={updated} />}>
{children}
</ArtifactCard>
);
const image = (id: CardId, title: string, f: FileRef | null) =>
f && t && card(id, title, <Picture file={f} open={open} />, [[f, 'PNG']], t.updated);
const tri = (n: number | null) => (n != null ? `, ${triangles(n)}` : '');
return (
<>
{project.modelJobId
? <div className="pane"><div className="pane-title">3D-модель</div><ModelFormBlock project={project} onProject={onProject} /></div>
: card('model', `Модель без текстур${tri(m.faces)}`,
<ModelView glb={m.glb} preview={m.preview} onOpen={open} />,
[[m.glb, 'GLB'], [m.preview, 'PNG']], m.updated)}
{t && t.glb && card('textured', `Текстурированная модель${tri(t.faces)}`,
<ModelView glb={t.glb} preview={t.views[0]} onOpen={open} />, [[t.glb, 'GLB']], t.updated)}
{image('baseColor', 'Base color', t?.baseColor ?? null)}
{image('metallic', 'Metallic', t?.metallic ?? null)}
{image('roughness', 'Roughness', t?.roughness ?? null)}
{image('uv', 'UV-развёртка', t?.uv ?? null)}
{t && t.views.length > 0 && card('views', t.source === 'paint' ? 'Виды (нарисованные Paint)' : 'Виды',
<div className={'views-grid' + (t.views.length > 4 ? ' six' : '')}>
{t.views.map(f => <Picture key={f.filename} file={f} open={open} />)}
</div>, [], t.updated)}
{!t && m.generator === 'hunyuan' && !project.modelJobId && (
<div className="pane">
<div className="pane-title">Текстуры</div>
<PaintFormBlock project={project} onProject={onProject} />
</div>
)}
</>
);
}
const when = (t: number | null) => (t ? new Date(t).toLocaleString() : '');
/** Чем сделан текстурный артефакт. */
const TEXTURE_SOURCE_NAMES: Record<TextureSource, string> = { trellis: 'TRELLIS.2', paint: 'Hunyuan3D-2 Paint' };
/** Подпись под карточкой: чем получено и когда. */
function Origin({ by, time }: { by: string; time: number | null }) {
return <><b className="origin">{by}</b>{time ? ' · ' + when(time) : ''}</>;
}
/** «1 треугольник», «3 треугольника», «10000 треугольников». */
function triangles(n: number): string {
const d = n % 10, dd = n % 100;
@@ -146,12 +323,14 @@ function triangles(n: number): string {
return `${n} ${word}`;
}
/** Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (или форма Hunyuan3D). */
/**
* Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (Hunyuan3D-2 mini или TRELLIS.2)
* и карточки её артефактов; для модели без текстур — форма Hunyuan3D-2 Paint.
*/
export function Project() {
const id = Number(useParams().id) || null;
const { project, setProject, error } = useProject(id);
const viewer = useViewer();
const [view, setView] = useState<View>('3d');
if (!id) return <p className="err">Неверный номер проекта.</p>;
if (error && !project) return <p className="err">{error} <Link to="/projects">К проектам</Link></p>;
@@ -161,29 +340,21 @@ export function Project() {
return (
<>
<h1>Проект #{project.id}</h1>
<p className="sub">
{new Date(project.created).toLocaleString()} · из батча <Link to={'/batch/' + project.batchId}>#{project.batchId}</Link>
</p>
<div className="stage three">
<div className="stage steps">
<div className="pane">
<div className="pane-title">Исходник</div>
<div className="pane-title"><Link to={'/batch/' + project.batchId}>Исходник</Link></div>
<div className="box">
<img src={fileUrl(img)} alt={img.filename} onClick={() => viewer.open(fileUrl(img), img.filename)} />
<a className="dl" href={fileUrl(img)} download={img.filename}>скачать</a>
</div>
<div className="name"><Origin by={project.imageSource === 'upload' ? 'Upload' : 'SDXL'} time={project.created} /></div>
</div>
<div className="pane">
<div className="pane-title">Очищенная</div>
<CleanBlock project={project} onProject={setProject} open={viewer.open} />
<div className="name">{project.clean ? when(project.cleanUpdated) : ''}</div>
<div className="name">{project.clean && <Origin by="BiRefNet" time={project.cleanUpdated} />}</div>
</div>
{project.clean && (
<div className="pane">
<div className="pane-title">3D-модель{project.model && project.modelFaces != null ? `, ${triangles(project.modelFaces)}` : ''}</div>
<ModelBlock project={project} onProject={setProject} open={viewer.open} view={view} setView={setView} />
<div className="name">{project.model ? when(project.modelUpdated) : ''}</div>
</div>
)}
{project.clean && <Artifacts project={project} onProject={setProject} open={viewer.open} />}
</div>
{viewer.element}
</>
+9 -5
View File
@@ -52,8 +52,10 @@ details .sub-field { margin-top: 12px; }
/* ---------- панели исходник / результат ---------- */
.stage { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.pane { display: grid; gap: 6px; align-content: start; justify-items: start; }
.pane-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 10px; }
.pane-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 10px; min-width: 0; max-width: 100%; }
.pane-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pane .name { font-size: 12px; color: var(--muted); word-break: break-all; min-height: 18px; }
.pane .name .origin { color: var(--text); font-weight: 600; }
.box { position: relative; width: 100%; max-width: 420px; aspect-ratio: 1; background: var(--img-bg); border-radius: 10px;
overflow: hidden; display: grid; place-items: center; color: var(--muted); font-size: 13px; text-align: center; padding: 0; }
.box img, .cell img { width: 100%; height: 100%; object-fit: contain; display: block; cursor: zoom-in; }
@@ -126,10 +128,12 @@ fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 14px; min-width
.step-form label { gap: 3px; }
.step-form input { padding: 6px 9px; }
.step-form input[type=color] { height: 32px; }
.stage.three { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 900px) { .stage.three { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .stage.three { grid-template-columns: 1fr; } }
.box-seg { position: absolute; top: 6px; right: 6px; }
.step-form label.check { display: flex; flex-direction: row; align-items: center; gap: 6px; }
.step-form label.check input { width: auto; padding: 0; }
.stage.steps { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.views-grid { display: grid; grid-template-columns: 1fr 1fr; width: 100%; height: 100%; }
.views-grid img { width: 100%; height: 100%; object-fit: contain; }
.views-grid.six { grid-template-columns: repeat(3, 1fr); }
.box-error { position: absolute; left: 6px; bottom: 34px; right: 6px; color: var(--error); font-size: 12px; }
.project-thumb { width: calc(var(--thumb) * 2); height: calc(var(--thumb) * 2); border-radius: 6px; overflow: hidden; background: var(--img-bg); }
.project-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
-1
View File
@@ -1,7 +1,6 @@
@echo off
rem img2glb: собрать фронтенд при необходимости и запустить сервер (порт 7000). ComfyUI 0.37 должен быть запущен.
cd /d "%~dp0"
set PATH=%~dp0..\node;%PATH%
if not exist dist\index.html call npm run build || goto :err
start "" http://127.0.0.1:7000
call npm start