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/ node_modules/
data/ data/
dist/
+66 -33
View File
@@ -3,10 +3,10 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import type { 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'; } from '../shared/api.ts';
import { DB_PATH } from './config.ts'; import { DB_PATH } from './config.ts';
import { glbTriangles } from './glb.ts';
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new DatabaseSync(DB_PATH); const db = new DatabaseSync(DB_PATH);
@@ -104,17 +104,30 @@ addColumns('Projects', {
cleanJobId: 'TEXT', // prompt_id идущей очистки cleanJobId: 'TEXT', // prompt_id идущей очистки
cleanError: 'TEXT', // ошибка последней очистки 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', { addColumns('Projects', {
model: 'TEXT', // JSON FileRef GLB model: 'TEXT', // JSON ModelArtifacts: модель без текстур
modelPreview: 'TEXT', // JSON FileRef PNG-превью
modelUpdated: 'INTEGER', // мс
modelJobId: 'TEXT', // prompt_id идущей генерации modelJobId: 'TEXT', // prompt_id идущей генерации
modelError: 'TEXT', // ошибка последней генерации modelError: 'TEXT', // ошибка последней генерации
modelFaces: 'INTEGER', // число треугольников в GLB (из файла) texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды
textureJobId: 'TEXT', // prompt_id идущего текстурирования
textureError: 'TEXT', // ошибка последнего текстурирования
}); });
// истории очисток и 3D-моделей не хранятся (результат — в проекте) — завершённые задачи удаляются // истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model') AND state <> 'pending'`); db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`);
/** Несколько записей одной транзакцией. */ /** Несколько записей одной транзакцией. */
export function transaction<T>(fn: () => T): T { export function transaction<T>(fn: () => T): T {
@@ -340,9 +353,9 @@ export function deleteBatch(b: ImageBatch) {
const SELECT_PROJECT = ` const SELECT_PROJECT = `
SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError, 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, p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError,
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type 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`; FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`;
const pq = { const pq = {
insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'), 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 = ?'), clearClean: db.prepare('UPDATE Projects SET clean = NULL, cleanUpdated = NULL, cleanError = NULL WHERE id = ?'),
byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'), byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'),
startModel: db.prepare('UPDATE Projects SET modelJobId = ?, modelError = NULL WHERE id = ?'), 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 = ?`), finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model),
clearModel: db.prepare(`UPDATE Projects SET model = NULL, modelPreview = NULL, modelFaces = NULL, modelUpdated = NULL, modelError = NULL texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END,
WHERE id = ?`), modelError = ?3, modelJobId = NULL WHERE id = ?4`),
setModelFaces: db.prepare('UPDATE Projects SET modelFaces = ? WHERE id = ?'), 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-модели проекта поставлена в очередь. */ /** Генерация 3D-модели проекта поставлена в очередь. */
@@ -371,17 +388,38 @@ export function projectByModelJob(jobId: string): number | null {
return (pq.byModelJob.get(jobId) as { id: number } | undefined)?.id ?? 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) { * Генерация завершена: новая модель заменяет прежнюю, прежние текстуры убираются (TRELLIS.2 сразу даёт свои).
pq.finishModel.run(model ? JSON.stringify(model) : null, preview ? JSON.stringify(preview) : null, * При ошибке (model = null) всё остаётся как было.
model ? glbTriangles(model) : null, Date.now(), error ?? null, id); */
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) { export function clearProjectModel(id: number) {
pq.clearModel.run(id); pq.clearModel.run(id);
} }
/** Убрать текстурную группу (файлы в ComfyUI/output остаются). */
export function clearProjectTexture(id: number) {
pq.clearTexture.run(id);
}
/** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */ /** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */
export function clearProjectClean(id: number) { export function clearProjectClean(id: number) {
pq.clearClean.run(id); pq.clearClean.run(id);
@@ -389,26 +427,21 @@ export function clearProjectClean(id: number) {
interface ProjectRow { interface ProjectRow {
id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null; 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; model: string | null; modelJobId: string | null; modelError: string | null;
modelFaces: number | null; texture: string | null; textureJobId: string | null; textureError: string | null;
imageId: number; batchId: number; filename: string; subfolder: string; type: string; imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string;
} }
const json = (s: string | null) => (s ? JSON.parse(s) : null); const json = (s: string | null) => (s ? JSON.parse(s) : null);
function toProject(r: ProjectRow): Project { 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 { 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 }, image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id },
clean: json(r.clean), cleanUpdated: r.cleanUpdated, clean: json(r.clean), cleanUpdated: r.cleanUpdated,
cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}), cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}),
model: json(r.model), modelPreview: json(r.modelPreview), modelUpdated: r.modelUpdated, model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
modelFaces: r.modelFaces, 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 { Readable } from 'node:stream';
import type { ReadableStream as WebStream } from 'node:stream/web'; import type { ReadableStream as WebStream } from 'node:stream/web';
import type { import type {
CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, QueueInfo, CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest,
SettingsPage, Text2ImgInput, Text2ImgJob, QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob,
} from '../shared/api.ts'; } from '../shared/api.ts';
import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts'; import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts';
import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts'; import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts';
import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts'; import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts';
import { import {
clearProjectClean, clearProjectModel, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, getProject, clearProjectClean, clearProjectModel, clearProjectTexture, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage,
getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean, getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean,
startProjectModel, transaction, updateTextInput, startProjectModel, startProjectTexture, transaction, updateTextInput,
} from './db.ts'; } from './db.ts';
import { glbWithoutNormals, outputPath } from './glb.ts'; import { glbWithoutNormals, outputPath } from './glb.ts';
import { migrateOldRuns } from './migrate.ts'; import { migrateOldRuns } from './migrate.ts';
import { liveStatus, startTracker } from './tracker.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 BadRequest extends Error {}
class NotFound extends Error {} class NotFound extends Error {}
@@ -33,7 +35,7 @@ const UPLOAD_DIR = 'img2glb_upload';
type Req = http.IncomingMessage; type Req = http.IncomingMessage;
type Res = http.ServerResponse; 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) { function send(res: Res, code: number, obj: unknown) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); 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 { function withLiveProject(p: Project): Project {
const live = p.cleanJobId ? liveStatus(p.cleanJobId) : undefined; const live = p.cleanJobId ? liveStatus(p.cleanJobId) : undefined;
const modelLive = p.modelJobId ? liveStatus(p.modelJobId) : 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) { async function projectModel(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId); const p = projectOrThrow(projectId);
if (!p.clean) throw new Conflict('сначала почистите исходник'); if (!p.clean) throw new Conflict('сначала почистите исходник');
if (p.modelJobId) throw new Conflict('модель уже генерируется'); if (p.modelJobId) throw new Conflict('модель уже генерируется');
if (p.textureJobId) throw new Conflict('идёт текстурирование');
const b = await readJson<ProjectModelRequest>(req); const b = await readJson<ProjectModelRequest>(req);
const src = sourceImage(p.clean); 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 seed = parseSeed(b.seed);
const steps = Math.round(clamp(b.steps, 1, 100, 30)); const steps = Math.round(clamp(b.steps, 1, 100, 30));
const octree = Math.round(clamp(b.octree, 64, 512, 380)); const octree = Math.round(clamp(b.octree, 64, 512, 380));
const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000)); 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(() => { transaction(() => {
// задача нужна трекеру только до завершения — потом удаляется // задача нужна трекеру только до завершения — потом удаляется
insertJob({ insertJob({
id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], params,
params: { projectId, seed, steps, octree, faces },
} satisfies ModelJob); } satisfies ModelJob);
startProjectModel(projectId, id); startProjectModel(projectId, id);
}); });
send(res, 200, withLiveProject(projectOrThrow(projectId))); send(res, 200, withLiveProject(projectOrThrow(projectId)));
} }
/** Убрать 3D-модель из проекта — после этого можно сгенерировать заново. */ /** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */
function removeProjectModel(res: Res, id: number) { function removeProjectModel(res: Res, id: number) {
const p = projectOrThrow(id); const p = projectOrThrow(id);
if (p.modelJobId) throw new Conflict('идёт генерация модели'); if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация');
clearProjectModel(id); clearProjectModel(id);
send(res, 200, withLiveProject(projectOrThrow(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, кадрирование по маске в квадрат с отступом, заливка фона. * Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона.
* Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится. * Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится.
@@ -383,12 +443,14 @@ http.createServer(async (req, res) => {
} }
if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject)); if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject));
if (route === 'POST /api/projects') return await createProject(req, res); 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 === '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 === '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 === '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 === '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 === '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 === '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 (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 === 'POST /api/import') return await importOld(req, res);
+50 -6
View File
@@ -1,10 +1,14 @@
// Фоновое отслеживание незавершённых задач: результаты из ComfyUI записываются в базу, // Фоновое отслеживание незавершённых задач: результаты из 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 { comfyJson } from './comfy.ts';
import { glbTriangles } from './glb.ts';
import { TEXTURE_VIEWS } from './workflows.ts';
import { import {
batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, pendingJobs, projectByCleanJob, batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, finishProjectTexture, pendingJobs,
projectByModelJob, transaction, projectByCleanJob, projectByModelJob, projectByTextureJob, transaction,
} from './db.ts'; } from './db.ts';
/** Сколько ждать задачу, которой нет ни в очереди, ни в истории ComfyUI (ComfyUI перезапускался). */ /** Сколько ждать задачу, которой нет ни в очереди, ни в истории ComfyUI (ComfyUI перезапускался). */
@@ -40,6 +44,36 @@ function outputFiles(h: HistoryEntry) {
return { images, models }; 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); * Завершение задачи. Картинки text2img прикрепляются к её батчу (Images.batchId);
* результат очистки и модель проекта заменяют прежние в проекте, а сама задача удаляется — история не хранится. * результат очистки и модель проекта заменяют прежние в проекте, а сама задача удаляется — история не хранится.
@@ -52,11 +86,21 @@ function finish(job: Job, state: 'done' | 'error', result: { error?: string; ima
deleteJob(job.id); deleteJob(job.id);
return; return;
} }
// модель проекта: GLB и превью — в проект, задача удаляется (история не хранится) // модель проекта (у TRELLIS.2 — сразу с текстурами) — в проект, задача удаляется (история не хранится)
const modelProject = job.type === 'model' ? job.params.projectId ?? projectByModelJob(job.id) : null; 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'; 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); deleteJob(job.id);
return; 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' } }, '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 } }, '11': { class_type: 'MeshSmoothNormals', inputs: { mesh: ['10', 0], crease_angle: 180.0 } },
'12': { class_type: 'SaveGLB', inputs: { mesh: ['11', 0], filename_prefix: o.prefix } }, '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 } }, ...meshPreview(['11', 0], o.prefix, 13),
'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' } },
}; };
} }
/** 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). */ /** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */
export function kindOf(graph: unknown): JobKind { export function kindOf(graph: unknown): JobKind {
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type); 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('SaveGLB')) return '3D-модель';
if (types.includes('RemoveBackground')) return 'очистка фона'; if (types.includes('RemoveBackground')) return 'очистка фона';
if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация'; if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация';
+105 -14
View File
@@ -43,6 +43,8 @@ export interface Project {
created: number; created: number;
/** батч, из которого картинка */ /** батч, из которого картинка */
batchId: number; batchId: number;
/** откуда исходная картинка: сгенерирована (text — SDXL) или загружена */
imageSource: 'text' | 'upload';
/** исходная картинка */ /** исходная картинка */
image: BatchImage; image: BatchImage;
/** последняя очищенная картинка (история очисток не хранится) */ /** последняя очищенная картинка (история очисток не хранится) */
@@ -54,26 +56,87 @@ export interface Project {
cleanError?: string; cleanError?: string;
/** положение идущей очистки в очереди ComfyUI */ /** положение идущей очистки в очереди ComfyUI */
live?: LiveStatus; live?: LiveStatus;
/** последняя 3D-модель из очищенной картинки: GLB и PNG-превью (история не хранится) */ /**
model: FileRef | null; * 3D-модель без текстур (история не хранится). Генерирует её Hunyuan3D-2 mini или TRELLIS.2;
modelPreview: FileRef | null; * TRELLIS.2 заодно заполняет и texture.
modelUpdated: number | null; */
/** число треугольников в GLB (null — неизвестно) */ model: ModelArtifacts | null;
modelFaces: number | null;
/** идёт генерация модели: prompt_id в ComfyUI */ /** идёт генерация модели: prompt_id в ComfyUI */
modelJobId: string | null; modelJobId: string | null;
modelError?: string; modelError?: string;
/** положение идущей генерации модели в очереди ComfyUI */ /** положение идущей генерации в очереди ComfyUI */
modelLive?: LiveStatus; 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-модели проекта. */ /** Генерация 3D-модели проекта. */
export interface ProjectModelRequest { export interface ProjectModelRequest {
generator: ModelGenerator;
/** Пусто — случайный. */ /** Пусто — случайный. */
seed: number | string; seed: number | string;
/** Hunyuan3D-2 mini */
steps: number | string; steps: number | string;
octree: number | string; octree: number | string;
/** целевое число треугольников после упрощения */
faces: 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 { interface BatchBase {
@@ -130,7 +193,7 @@ export interface CleanupRequest {
} }
// ---------- задачи ComfyUI (таблица jobs; отслеживаются трекером) ---------- // ---------- задачи ComfyUI (таблица jobs; отслеживаются трекером) ----------
export type JobType = 'text2img' | 'cleanup' | 'model'; export type JobType = 'text2img' | 'cleanup' | 'model' | 'texture';
export type JobState = 'pending' | 'done' | 'error'; export type JobState = 'pending' | 'done' | 'error';
/** Состояние незавершённой задачи в очереди ComfyUI (не хранится, считается сервером на лету). */ /** Состояние незавершённой задачи в очереди ComfyUI (не хранится, считается сервером на лету). */
@@ -161,10 +224,23 @@ export interface CleanupParams {
export interface ModelParams { export interface ModelParams {
/** проект, к которому прикрепляется модель */ /** проект, к которому прикрепляется модель */
projectId: number; projectId: number;
generator: ModelGenerator;
seed: number; seed: number;
steps: number;
octree: number;
faces: 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> { interface JobOf<T extends JobType, P> {
@@ -180,7 +256,7 @@ interface JobOf<T extends JobType, P> {
params: P; params: P;
/** результаты-картинки (для model — PNG-превью) */ /** результаты-картинки (для model — PNG-превью) */
images: FileRef[]; images: FileRef[];
/** GLB-файлы (только model) */ /** GLB-файлы (model, texture) */
models: FileRef[]; models: FileRef[];
live?: LiveStatus; live?: LiveStatus;
} }
@@ -188,7 +264,8 @@ interface JobOf<T extends JobType, P> {
export type Text2ImgJob = JobOf<'text2img', Text2ImgParams>; export type Text2ImgJob = JobOf<'text2img', Text2ImgParams>;
export type CleanupJob = JobOf<'cleanup', CleanupParams>; export type CleanupJob = JobOf<'cleanup', CleanupParams>;
export type ModelJob = JobOf<'model', ModelParams>; 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) ---------- // ---------- значения полей форм (тоже в SQLite) ----------
export interface Text2ImgSettings { export interface Text2ImgSettings {
@@ -209,17 +286,31 @@ export interface CleanupSettings {
} }
export interface ModelSettings { export interface ModelSettings {
generator: ModelGenerator;
seed: string; seed: string;
/** Hunyuan3D-2 mini */
steps: string; steps: string;
octree: string; octree: string;
faces: 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; export type SettingsPage = keyof SettingsByPage;
// ---------- очередь ComfyUI ---------- // ---------- очередь ComfyUI ----------
export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'другое'; export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'текстура' | 'другое';
export interface QueueItem { export interface QueueItem {
id: string; 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 type { FileRef } from '../../shared/api.ts';
import { fileUrl } from '../lib/api.ts'; import { fileUrl } from '../lib/api.ts';
@@ -20,10 +20,8 @@ function loadModelViewer() {
return loading; 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; options: readonly (readonly [T, string])[]; value: T; onChange: (v: T) => void;
}) { }) {
return ( 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 показывается превью; что показывать вообще * Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще
* (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer. * (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer.
*/ */
export function ModelView({ glb, preview, view, onOpen }: { export function ModelView({ glb, preview, onOpen }: {
glb?: FileRef | null; preview?: FileRef | null; view: View; onOpen: (src: string, caption: string) => void; glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void;
}) { }) {
const [ready, setReady] = useState(false); // скрипт model-viewer загружен const [ready, setReady] = useState(false); // скрипт model-viewer загружен
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB
const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани) const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани)
const ref = useRef<HTMLElement>(null); 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 }); : '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder });
useEffect(() => { useEffect(() => {
if (view !== '3d' || !src) return;
let alive = true; let alive = true;
loadModelViewer().then(() => { if (alive) setReady(true); }, () => { if (alive) setState('error'); }); loadModelViewer().then(() => { if (alive) setReady(true); }, () => { if (alive) setState('error'); });
return () => { alive = false; }; return () => { alive = false; };
}, [view, src]); }, [src]);
useEffect(() => { setState('loading'); }, [src]); useEffect(() => { setState('loading'); }, [src]);
@@ -72,13 +64,12 @@ export function ModelView({ glb, preview, view, onOpen }: {
el.addEventListener('load', onLoad); el.addEventListener('load', onLoad);
el.addEventListener('error', onError); el.addEventListener('error', onError);
return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); }; return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); };
}, [ready, src, view]); }, [ready, src]);
const previewImg = preview && ( const previewImg = preview && (
<img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} /> <img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} />
); );
if (view === '3d' && glb) {
return ( return (
<div className="mv-wrap"> <div className="mv-wrap">
{ready && ( {ready && (
@@ -94,20 +85,4 @@ export function ModelView({ glb, preview, view, onOpen }: {
</span> </span>
</div> </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 { useCallback, useEffect, useState } from 'react';
import type { import type {
CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, Text2ImgInput, TextBatch, UploadBatch, CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, ProjectTextureRequest, Text2ImgInput, TextBatch,
UploadBatch,
} from '../../shared/api.ts'; } from '../../shared/api.ts';
import { api, parse } from './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 }), cleanup: (id: number, params: CleanupRequest) => api<Project>(`/api/projects/${id}/cleanup`, { body: params }),
removeClean: (id: number) => api<Project>(`/api/projects/${id}/clean`, { method: 'DELETE' }), 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 }), model: (id: number, params: ProjectModelRequest) => api<Project>(`/api/projects/${id}/model`, { body: params }),
/** Удалить модель — вместе со всеми текстурами. */
removeModel: (id: number) => api<Project>(`/api/projects/${id}/model`, { method: 'DELETE' }), 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 } }), forImage: (imageId: number) => api<Project>('/api/projects', { body: { imageId } }),
get: (id: number) => api<Project>('/api/projects/' + id), get: (id: number) => api<Project>('/api/projects/' + id),
@@ -82,7 +88,7 @@ export function useProject(id: number | null) {
useEffect(() => { setProject(null); void refresh(); }, [refresh]); useEffect(() => { setProject(null); void refresh(); }, [refresh]);
const working = !!project?.cleanJobId || !!project?.modelJobId; const working = !!project?.cleanJobId || !!project?.modelJobId || !!project?.textureJobId;
useEffect(() => { useEffect(() => {
if (!working) return; if (!working) return;
const t = setInterval(() => { void refresh(); }, POLL_MS); 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 cleanStateText = (p: Project) => stepStateText(p.cleanJobId, p.live, p.cleanError, 'обработка');
export const modelStateText = (p: Project) => stepStateText(p.modelJobId, p.modelLive, p.modelError, 'генерация'); 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 с. */ /** Один батч; пока он в работе — перечитывается каждые 1.5 с. */
export function useBatch(id: number | null) { export function useBatch(id: number | null) {
+211 -40
View File
@@ -1,14 +1,21 @@
import { useState, type FormEvent, type ReactNode } from 'react'; import { useState, type FormEvent, type ReactNode } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import type { CleanupSettings, ModelSettings, Project as ProjectT } from '../../shared/api.ts'; import type {
import { ModelLinks, ModelView, ViewToggle, type View } from '../components/ModelView.tsx'; 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 { useViewer } from '../components/Viewer.tsx';
import { fileUrl } from '../lib/api.ts'; 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'; import { useSettings } from '../lib/settings.ts';
const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' }; 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 Open = (src: string, caption: string) => void;
type OnProject = (p: ProjectT) => void; type OnProject = (p: ProjectT) => void;
@@ -94,51 +101,221 @@ function CleanBlock({ project, onProject, open }: { project: ProjectT; onProject
} }
/** /**
* Квадрат «3D-модель» (показывается, только когда есть очищенная картинка): есть модель — переключатель * Форма генерации 3D-модели: Hunyuan3D-2 mini (только геометрия) или TRELLIS.2 (геометрия сразу с текстурами).
* «картинка / 3D» (по умолчанию 3D), «удалить», GLB и PNG; нет — форма Hunyuan3D на её месте; идёт генерация — её состояние. * Идёт генерация — её состояние.
*/ */
function ModelBlock({ project, onProject, open, view, setView }: { function ModelFormBlock({ project, onProject }: { project: ProjectT; onProject: OnProject }) {
project: ProjectT; onProject: OnProject; open: Open; view: View; setView: (v: View) => void;
}) {
const { values: v, set } = useSettings('model', MODEL_DEFAULTS); const { values: v, set } = useSettings('model', MODEL_DEFAULTS);
const { busy, error, act } = useAct(onProject); const { busy, error, act } = useAct(onProject);
const st = modelStateText(project); const st = modelStateText(project);
if (project.modelJobId) return <div className="box">{st?.text}</div>; if (project.modelJobId) return <div className="box">{st?.text}</div>;
// модель или форма — только по записи о модели в базе (загрузка model-viewer на это не влияет) const trellis = v.generator === 'trellis';
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>
);
}
return ( return (
<FormBox title="3D-модель" busy={busy} error={error || (st?.error ? st.text : '')} <FormBox title="3D-модель" busy={busy} error={error || (st?.error ? st.text : '')}
hint="Hunyuan3D-2 mini: очищенная картинка → GLB-меш (только геометрия, ~1.5–2 мин) и PNG-превью." hint={trellis
onSubmit={() => void act(() => projectApi.model(project.id, { seed: v.seed, steps: v.steps, octree: v.octree, faces: v.faces }))}> ? '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> <label><span>Seed <small>— пусто = случайный</small></span>
<input type="number" min={0} step={1} placeholder="случайный" value={v.seed} onChange={e => set('seed', e.target.value)} /> <input type="number" min={0} step={1} placeholder="случайный" value={v.seed} onChange={e => set('seed', e.target.value)} />
</label> </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> <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)} /> <input type="number" min={1} max={100} step={1} value={v.steps} onChange={e => set('steps', e.target.value)} />
</label> </label>
<label><span>Детализация <small>— octree 256…512</small></span> <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)} /> <input type="number" min={64} max={512} step={1} value={v.octree} onChange={e => set('octree', e.target.value)} />
</label> </label>
<label><span>Полигонов <small>— 0 = без упрощения</small></span> <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)} /> <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> </label>
</FormBox> </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 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 треугольников». */ /** «1 треугольник», «3 треугольника», «10000 треугольников». */
function triangles(n: number): string { function triangles(n: number): string {
const d = n % 10, dd = n % 100; const d = n % 10, dd = n % 100;
@@ -146,12 +323,14 @@ function triangles(n: number): string {
return `${n} ${word}`; return `${n} ${word}`;
} }
/** Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (или форма Hunyuan3D). */ /**
* Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (Hunyuan3D-2 mini или TRELLIS.2)
* и карточки её артефактов; для модели без текстур — форма Hunyuan3D-2 Paint.
*/
export function Project() { export function Project() {
const id = Number(useParams().id) || null; const id = Number(useParams().id) || null;
const { project, setProject, error } = useProject(id); const { project, setProject, error } = useProject(id);
const viewer = useViewer(); const viewer = useViewer();
const [view, setView] = useState<View>('3d');
if (!id) return <p className="err">Неверный номер проекта.</p>; if (!id) return <p className="err">Неверный номер проекта.</p>;
if (error && !project) return <p className="err">{error} <Link to="/projects">К проектам</Link></p>; if (error && !project) return <p className="err">{error} <Link to="/projects">К проектам</Link></p>;
@@ -161,29 +340,21 @@ export function Project() {
return ( return (
<> <>
<h1>Проект #{project.id}</h1> <h1>Проект #{project.id}</h1>
<p className="sub"> <div className="stage steps">
{new Date(project.created).toLocaleString()} · из батча <Link to={'/batch/' + project.batchId}>#{project.batchId}</Link>
</p>
<div className="stage three">
<div className="pane"> <div className="pane">
<div className="pane-title">Исходник</div> <div className="pane-title"><Link to={'/batch/' + project.batchId}>Исходник</Link></div>
<div className="box"> <div className="box">
<img src={fileUrl(img)} alt={img.filename} onClick={() => viewer.open(fileUrl(img), img.filename)} /> <img src={fileUrl(img)} alt={img.filename} onClick={() => viewer.open(fileUrl(img), img.filename)} />
<a className="dl" href={fileUrl(img)} download={img.filename}>скачать</a> <a className="dl" href={fileUrl(img)} download={img.filename}>скачать</a>
</div> </div>
<div className="name"><Origin by={project.imageSource === 'upload' ? 'Upload' : 'SDXL'} time={project.created} /></div>
</div> </div>
<div className="pane"> <div className="pane">
<div className="pane-title">Очищенная</div> <div className="pane-title">Очищенная</div>
<CleanBlock project={project} onProject={setProject} open={viewer.open} /> <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> </div>
{project.clean && ( {project.clean && <Artifacts project={project} onProject={setProject} open={viewer.open} />}
<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>
)}
</div> </div>
{viewer.element} {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; } .stage { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.pane { display: grid; gap: 6px; align-content: start; justify-items: start; } .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 { 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; .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; } 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; } .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 label { gap: 3px; }
.step-form input { padding: 6px 9px; } .step-form input { padding: 6px 9px; }
.step-form input[type=color] { height: 32px; } .step-form input[type=color] { height: 32px; }
.stage.three { grid-template-columns: repeat(3, 1fr); } .step-form label.check { display: flex; flex-direction: row; align-items: center; gap: 6px; }
@media (max-width: 900px) { .stage.three { grid-template-columns: 1fr 1fr; } } .step-form label.check input { width: auto; padding: 0; }
@media (max-width: 600px) { .stage.three { grid-template-columns: 1fr; } } .stage.steps { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.box-seg { position: absolute; top: 6px; right: 6px; } .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; } .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 { 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; } .project-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
-1
View File
@@ -1,7 +1,6 @@
@echo off @echo off
rem img2glb: собрать фронтенд при необходимости и запустить сервер (порт 7000). ComfyUI 0.37 должен быть запущен. rem img2glb: собрать фронтенд при необходимости и запустить сервер (порт 7000). ComfyUI 0.37 должен быть запущен.
cd /d "%~dp0" cd /d "%~dp0"
set PATH=%~dp0..\node;%PATH%
if not exist dist\index.html call npm run build || goto :err if not exist dist\index.html call npm run build || goto :err
start "" http://127.0.0.1:7000 start "" http://127.0.0.1:7000
call npm start call npm start