trellis.2. unified 3d workflows
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
data/
|
data/
|
||||||
|
dist/
|
||||||
+66
-33
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 && (
|
||||||
@@ -95,19 +86,3 @@ export function ModelView({ glb, preview, view, onOpen }: {
|
|||||||
</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
@@ -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
@@ -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
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user