diff --git a/.gitignore b/.gitignore index 902b281..1058e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ data/ +dist/ \ No newline at end of file diff --git a/server/db.ts b/server/db.ts index 620d20c..b49a0a7 100644 --- a/server/db.ts +++ b/server/db.ts @@ -3,10 +3,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import type { - BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, Project, SettingsPage, Text2ImgInput, + BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput, + TextureArtifacts, } from '../shared/api.ts'; import { DB_PATH } from './config.ts'; -import { glbTriangles } from './glb.ts'; fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); const db = new DatabaseSync(DB_PATH); @@ -104,17 +104,30 @@ addColumns('Projects', { cleanJobId: 'TEXT', // prompt_id идущей очистки cleanError: 'TEXT', // ошибка последней очистки }); -// последняя 3D-модель проекта из очищенной картинки (история не хранится) +// 3D-артефакты проекта (история не хранится): модель без текстур и текстурная группа (TRELLIS.2 или Paint). +// v2: прежние раздельные столбцы model*/texture*/trellis*/paint* убраны, 3D-результаты начаты с нуля. +if (!(db.prepare("SELECT 1 FROM meta WHERE key = 'projects.artifacts.v2'").get())) { + const have = new Set((db.prepare('PRAGMA table_info(Projects)').all() as { name: string }[]).map(c => c.name)); + for (const c of ['model', 'modelPreview', 'modelUpdated', 'modelJobId', 'modelError', 'modelFaces', + 'texture', 'textureUpdated', 'textureFaces', 'textureJobId', 'textureError', + 'trellis', 'trellisUpdated', 'trellisFaces', 'trellisJobId', 'trellisError', + 'paint', 'paintUpdated', 'paintFaces', 'paintJobId', 'paintError']) { + if (have.has(c)) db.exec(`ALTER TABLE Projects DROP COLUMN ${c}`); + } + db.exec(`DELETE FROM jobs WHERE type IN ('model', 'texture', 'trellis', 'paint')`); + db.exec(`DELETE FROM settings WHERE page IN ('trellis', 'paint')`); + db.exec(`INSERT INTO meta (key, value) VALUES ('projects.artifacts.v2', '1')`); +} addColumns('Projects', { - model: 'TEXT', // JSON FileRef GLB - modelPreview: 'TEXT', // JSON FileRef PNG-превью - modelUpdated: 'INTEGER', // мс - modelJobId: 'TEXT', // prompt_id идущей генерации - modelError: 'TEXT', // ошибка последней генерации - modelFaces: 'INTEGER', // число треугольников в GLB (из файла) + model: 'TEXT', // JSON ModelArtifacts: модель без текстур + modelJobId: 'TEXT', // prompt_id идущей генерации + modelError: 'TEXT', // ошибка последней генерации + texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды + textureJobId: 'TEXT', // prompt_id идущего текстурирования + textureError: 'TEXT', // ошибка последнего текстурирования }); -// истории очисток и 3D-моделей не хранятся (результат — в проекте) — завершённые задачи удаляются -db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model') AND state <> 'pending'`); +// истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются +db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`); /** Несколько записей одной транзакцией. */ export function transaction(fn: () => T): T { @@ -340,9 +353,9 @@ export function deleteBatch(b: ImageBatch) { const SELECT_PROJECT = ` SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError, - p.model, p.modelPreview, p.modelUpdated, p.modelJobId, p.modelError, p.modelFaces, - i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type - FROM Projects p JOIN Images i ON i.id = p.imageId`; + p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError, + i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType + FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`; const pq = { insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'), @@ -355,11 +368,15 @@ const pq = { clearClean: db.prepare('UPDATE Projects SET clean = NULL, cleanUpdated = NULL, cleanError = NULL WHERE id = ?'), byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'), startModel: db.prepare('UPDATE Projects SET modelJobId = ?, modelError = NULL WHERE id = ?'), - finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?, model), modelPreview = COALESCE(?, modelPreview), - modelFaces = COALESCE(?, modelFaces), modelUpdated = ?, modelError = ?, modelJobId = NULL WHERE id = ?`), - clearModel: db.prepare(`UPDATE Projects SET model = NULL, modelPreview = NULL, modelFaces = NULL, modelUpdated = NULL, modelError = NULL - WHERE id = ?`), - setModelFaces: db.prepare('UPDATE Projects SET modelFaces = ? WHERE id = ?'), + // новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся + finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model), + texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END, + modelError = ?3, modelJobId = NULL WHERE id = ?4`), + byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'), + startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'), + finishTexture: db.prepare('UPDATE Projects SET texture = COALESCE(?, texture), textureError = ?, textureJobId = NULL WHERE id = ?'), + clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'), + clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'), }; /** Генерация 3D-модели проекта поставлена в очередь. */ @@ -371,17 +388,38 @@ export function projectByModelJob(jobId: string): number | null { return (pq.byModelJob.get(jobId) as { id: number } | undefined)?.id ?? null; } -/** Генерация завершена: новая модель заменяет прежнюю (при ошибке прежняя остаётся). */ -export function finishProjectModel(id: number, model: FileRef | null, preview: FileRef | null, error?: string) { - pq.finishModel.run(model ? JSON.stringify(model) : null, preview ? JSON.stringify(preview) : null, - model ? glbTriangles(model) : null, Date.now(), error ?? null, id); +/** + * Генерация завершена: новая модель заменяет прежнюю, прежние текстуры убираются (TRELLIS.2 сразу даёт свои). + * При ошибке (model = null) всё остаётся как было. + */ +export function finishProjectModel(id: number, model: ModelArtifacts | null, texture: TextureArtifacts | null, error?: string) { + pq.finishModel.run(model ? JSON.stringify(model) : null, texture ? JSON.stringify(texture) : null, error ?? null, id); } -/** Убрать 3D-модель из проекта (файлы в ComfyUI/output остаются). */ +/** Текстурирование проекта поставлено в очередь. */ +export function startProjectTexture(id: number, jobId: string) { + pq.startTexture.run(jobId, id); +} + +export function projectByTextureJob(jobId: string): number | null { + return (pq.byTextureJob.get(jobId) as { id: number } | undefined)?.id ?? null; +} + +/** Текстурирование завершено: новый результат заменяет прежний (при ошибке прежний остаётся). */ +export function finishProjectTexture(id: number, texture: TextureArtifacts | null, error?: string) { + pq.finishTexture.run(texture ? JSON.stringify(texture) : null, error ?? null, id); +} + +/** Убрать модель из проекта вместе с текстурами — они сделаны для неё (файлы в ComfyUI/output остаются). */ export function clearProjectModel(id: number) { pq.clearModel.run(id); } +/** Убрать текстурную группу (файлы в ComfyUI/output остаются). */ +export function clearProjectTexture(id: number) { + pq.clearTexture.run(id); +} + /** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */ export function clearProjectClean(id: number) { pq.clearClean.run(id); @@ -389,26 +427,21 @@ export function clearProjectClean(id: number) { interface ProjectRow { id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null; - model: string | null; modelPreview: string | null; modelUpdated: number | null; modelJobId: string | null; modelError: string | null; - modelFaces: number | null; - imageId: number; batchId: number; filename: string; subfolder: string; type: string; + model: string | null; modelJobId: string | null; modelError: string | null; + texture: string | null; textureJobId: string | null; textureError: string | null; + imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string; } const json = (s: string | null) => (s ? JSON.parse(s) : null); function toProject(r: ProjectRow): Project { - // модели, прикреплённые до появления modelFaces, — число полигонов считается из файла один раз - if (r.model && r.modelFaces == null) { - r.modelFaces = glbTriangles(JSON.parse(r.model)); - if (r.modelFaces != null) pq.setModelFaces.run(r.modelFaces, r.id); - } return { - id: r.id, created: r.created, batchId: r.batchId, + id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text', image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id }, clean: json(r.clean), cleanUpdated: r.cleanUpdated, cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}), - model: json(r.model), modelPreview: json(r.modelPreview), modelUpdated: r.modelUpdated, - modelFaces: r.modelFaces, modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}), + model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}), + texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}), }; } diff --git a/server/index.ts b/server/index.ts index 8737fcc..9e7645c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,21 +7,23 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import type { ReadableStream as WebStream } from 'node:stream/web'; import type { - CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, QueueInfo, - SettingsPage, Text2ImgInput, Text2ImgJob, + CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest, + QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob, } from '../shared/api.ts'; import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts'; import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts'; import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts'; import { - clearProjectClean, clearProjectModel, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, getProject, - getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean, - startProjectModel, transaction, updateTextInput, + clearProjectClean, clearProjectModel, clearProjectTexture, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, + getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean, + startProjectModel, startProjectTexture, transaction, updateTextInput, } from './db.ts'; import { glbWithoutNormals, outputPath } from './glb.ts'; import { migrateOldRuns } from './migrate.ts'; import { liveStatus, startTracker } from './tracker.ts'; -import { cleanupGraph, escapeTemplate, kindOf, modelGraph, renderTemplate, text2imgGraph } from './workflows.ts'; +import { + cleanupGraph, escapeTemplate, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph, +} from './workflows.ts'; class BadRequest extends Error {} class NotFound extends Error {} @@ -33,7 +35,7 @@ const UPLOAD_DIR = 'img2glb_upload'; type Req = http.IncomingMessage; type Res = http.ServerResponse; -const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model']; +const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model', 'texture']; function send(res: Res, code: number, obj: unknown) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); @@ -208,43 +210,101 @@ function projectOrThrow(id: number): Project { function withLiveProject(p: Project): Project { const live = p.cleanJobId ? liveStatus(p.cleanJobId) : undefined; const modelLive = p.modelJobId ? liveStatus(p.modelJobId) : undefined; - return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}) }; + const textureLive = p.textureJobId ? liveStatus(p.textureJobId) : undefined; + return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}), ...(textureLive ? { textureLive } : {}) }; } +const outputRel = (f: FileRef) => (f.subfolder ? `${f.subfolder}/${f.filename}` : f.filename); +const textureSizeOf = (v: unknown) => ([1024, 2048, 4096].includes(Number(v)) ? Number(v) : 2048); + /** - * 3D-модель проекта из его очищенной картинки (Hunyuan3D-2 mini) + PNG-превью. - * Новая модель заменяет прежнюю; история не хранится. + * 3D-модель проекта из очищенной картинки: Hunyuan3D-2 mini (без текстур) или TRELLIS.2 (сразу с текстурами). + * Новая модель заменяет прежнюю вместе с текстурами; история не хранится. */ async function projectModel(req: Req, res: Res, projectId: number) { const p = projectOrThrow(projectId); if (!p.clean) throw new Conflict('сначала почистите исходник'); if (p.modelJobId) throw new Conflict('модель уже генерируется'); + if (p.textureJobId) throw new Conflict('идёт текстурирование'); const b = await readJson(req); const src = sourceImage(p.clean); - const seed = parseSeed(b.seed); - const steps = Math.round(clamp(b.steps, 1, 100, 30)); - const octree = Math.round(clamp(b.octree, 64, 512, 380)); - const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000)); - const id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces })); + const generator = b.generator === 'trellis' ? 'trellis' : 'hunyuan'; + let id: string; + let params: ModelJob['params']; + if (generator === 'trellis') { + const seed = parseSeed(b.seed) % 2 ** 32; // seed + 1 используется на стадии цвета + const faces = Math.round(clamp(b.faces, 1000, 2_000_000, 100000)); + const textureSize = textureSizeOf(b.textureSize); + const pbr = b.pbr === true; + id = await queuePrompt(trellisGraph({ image: src.image, prefix: 'img2glb_trellis/' + src.stem, seed, faces, textureSize, pbr })); + params = { projectId, generator, seed, faces, textureSize, pbr }; + } else { + const seed = parseSeed(b.seed); + const steps = Math.round(clamp(b.steps, 1, 100, 30)); + const octree = Math.round(clamp(b.octree, 64, 512, 380)); + const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000)); + id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces })); + params = { projectId, generator, seed, steps, octree, faces }; + } transaction(() => { // задача нужна трекеру только до завершения — потом удаляется insertJob({ - id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], - params: { projectId, seed, steps, octree, faces }, + id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], params, } satisfies ModelJob); startProjectModel(projectId, id); }); send(res, 200, withLiveProject(projectOrThrow(projectId))); } -/** Убрать 3D-модель из проекта — после этого можно сгенерировать заново. */ +/** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */ function removeProjectModel(res: Res, id: number) { const p = projectOrThrow(id); - if (p.modelJobId) throw new Conflict('идёт генерация модели'); + if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); clearProjectModel(id); send(res, 200, withLiveProject(projectOrThrow(id))); } +/** + * Текстура Hunyuan3D-2 Paint для модели проекта без текстур (нода custom_nodes/img2glb_hunyuan_paint): + * текстурированная модель, base color, развёртка, 6 видов. Новый результат заменяет прежний. + */ +async function projectTexture(req: Req, res: Res, projectId: number) { + const p = projectOrThrow(projectId); + if (!p.clean) throw new Conflict('сначала почистите исходник'); + if (!p.model) throw new Conflict('сначала сделайте 3D-модель'); + if (p.model.generator !== 'hunyuan') throw new Conflict('модель уже с текстурами'); + if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); + const b = await readJson(req); + const src = sourceImage(p.clean); + const seed = parseSeed(b.seed) % 2 ** 32; + const textureSize = textureSizeOf(b.textureSize); + const delight = b.delight !== false; + const id = await queuePrompt(paintGraph({ + image: src.image, mesh: outputRel(p.model.glb), prefix: 'img2glb_paint/' + src.stem, seed, textureSize, delight, + })); + transaction(() => { + // задача нужна трекеру только до завершения — потом удаляется + insertJob({ + id, type: 'texture', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], + params: { projectId, seed, textureSize, delight }, + } satisfies TextureJob); + startProjectTexture(projectId, id); + }); + send(res, 200, withLiveProject(projectOrThrow(projectId))); +} + +/** + * Убрать текстурную группу. Текстуры TRELLIS.2 сделаны одной задачей с моделью — уходит и модель; + * текстуры Paint — только они. + */ +function removeProjectTexture(res: Res, id: number) { + const p = projectOrThrow(id); + if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); + if (p.texture?.source === 'trellis') clearProjectModel(id); + else clearProjectTexture(id); + send(res, 200, withLiveProject(projectOrThrow(id))); +} + /** * Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона. * Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится. @@ -383,12 +443,14 @@ http.createServer(async (req, res) => { } if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject)); if (route === 'POST /api/projects') return await createProject(req, res); - const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model))?$/.exec(u.pathname); + const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model|texture))?$/.exec(u.pathname); if (pm && req.method === 'GET' && !pm[2]) return send(res, 200, withLiveProject(projectOrThrow(Number(pm[1])))); if (pm && req.method === 'POST' && pm[2] === 'cleanup') return await projectCleanup(req, res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'clean') return removeProjectClean(res, Number(pm[1])); if (pm && req.method === 'POST' && pm[2] === 'model') return await projectModel(req, res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'model') return removeProjectModel(res, Number(pm[1])); + if (pm && req.method === 'POST' && pm[2] === 'texture') return await projectTexture(req, res, Number(pm[1])); + if (pm && req.method === 'DELETE' && pm[2] === 'texture') return removeProjectTexture(res, Number(pm[1])); if (req.method === 'GET' && u.pathname.startsWith('/api/settings/')) return send(res, 200, getSettings(settingsPage(tail('/api/settings/')))); if (req.method === 'PUT' && u.pathname.startsWith('/api/settings/')) return await saveSettings(req, res, settingsPage(tail('/api/settings/'))); if (route === 'POST /api/import') return await importOld(req, res); diff --git a/server/tracker.ts b/server/tracker.ts index 1c9587d..2576ed3 100644 --- a/server/tracker.ts +++ b/server/tracker.ts @@ -1,10 +1,14 @@ // Фоновое отслеживание незавершённых задач: результаты из ComfyUI записываются в базу, // даже если ни одна страница не открыта. -import type { FileRef, Job, LiveStatus } from '../shared/api.ts'; +import type { + FileRef, Job, LiveStatus, ModelArtifacts, ModelGenerator, TextureArtifacts, TextureSource, +} from '../shared/api.ts'; import { comfyJson } from './comfy.ts'; +import { glbTriangles } from './glb.ts'; +import { TEXTURE_VIEWS } from './workflows.ts'; import { - batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, pendingJobs, projectByCleanJob, - projectByModelJob, transaction, + batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, finishProjectTexture, pendingJobs, + projectByCleanJob, projectByModelJob, projectByTextureJob, transaction, } from './db.ts'; /** Сколько ждать задачу, которой нет ни в очереди, ни в истории ComfyUI (ComfyUI перезапускался). */ @@ -40,6 +44,36 @@ function outputFiles(h: HistoryEntry) { return { images, models }; } +type Files = { images?: FileRef[]; models?: FileRef[] }; +/** Файл по метке префикса (см. workflows.ts): _NNNNN_.png. */ +const tagged = (files: FileRef[] | undefined, tag: string) => files?.find(i => i.filename.includes(tag + '_')) ?? null; + +/** + * Модель без текстур: у Hunyuan3D-2 mini — единственный GLB, у TRELLIS.2 — _mesh_…glb; плюс превью. + */ +function modelArtifacts(generator: ModelGenerator, r: Files): ModelArtifacts | null { + const glb = generator === 'trellis' ? tagged(r.models, '_mesh') : r.models?.[0]; + if (!glb) return null; + return { generator, glb, preview: tagged(r.images, '_preview'), faces: glbTriangles(glb), updated: Date.now() }; +} + +/** + * Текстурная группа: текстурированный GLB (не _mesh), base color, metallic, roughness, развёртка и виды — + * рендеры _view<угол> у TRELLIS.2 или 6 нарисованных видов _mv у Paint. + */ +function textureArtifacts(source: TextureSource, r: Files): TextureArtifacts | null { + const glb = r.models?.find(m => !m.filename.includes('_mesh_')) ?? null; + if (!glb) return null; + const images = r.images ?? []; + const views = source === 'trellis' + ? TEXTURE_VIEWS.map(a => tagged(images, '_view' + a)).filter((i): i is FileRef => i != null) + : images.filter(i => i.filename.includes('_mv_')).sort((x, y) => x.filename.localeCompare(y.filename)); + return { + source, glb, faces: glbTriangles(glb), baseColor: tagged(images, '_basecolor'), metallic: tagged(images, '_metallic'), + roughness: tagged(images, '_roughness'), uv: tagged(images, '_uv'), views, updated: Date.now(), + }; +} + /** * Завершение задачи. Картинки text2img прикрепляются к её батчу (Images.batchId); * результат очистки и модель проекта заменяют прежние в проекте, а сама задача удаляется — история не хранится. @@ -52,11 +86,21 @@ function finish(job: Job, state: 'done' | 'error', result: { error?: string; ima deleteJob(job.id); return; } - // модель проекта: GLB и превью — в проект, задача удаляется (история не хранится) + // модель проекта (у TRELLIS.2 — сразу с текстурами) — в проект, задача удаляется (история не хранится) const modelProject = job.type === 'model' ? job.params.projectId ?? projectByModelJob(job.id) : null; - if (modelProject != null) { + if (job.type === 'model' && modelProject != null) { + const generator = job.params.generator ?? 'hunyuan'; const done = state === 'done'; - finishProjectModel(modelProject, done ? result.models?.[0] ?? null : null, done ? result.images?.[0] ?? null : null, result.error); + const model = done ? modelArtifacts(generator, result) : null; + const texture = done && generator === 'trellis' ? textureArtifacts('trellis', result) : null; + finishProjectModel(modelProject, model, texture, result.error ?? (done && !model ? 'нет GLB в результате' : undefined)); + deleteJob(job.id); + return; + } + // текстура Hunyuan3D-2 Paint — в проект, задача удаляется + const textureProject = job.type === 'texture' ? job.params.projectId ?? projectByTextureJob(job.id) : null; + if (textureProject != null) { + finishProjectTexture(textureProject, state === 'done' ? textureArtifacts('paint', result) : null, result.error); deleteJob(job.id); return; } diff --git a/server/workflows.ts b/server/workflows.ts index e059cb1..c9ed627 100644 --- a/server/workflows.ts +++ b/server/workflows.ts @@ -57,15 +57,141 @@ export function modelGraph(o: { '10': { class_type: 'DecimateMesh', inputs: { mesh: ['9', 0], target_face_count: o.faces, placement_mode: 'midpoint' } }, '11': { class_type: 'MeshSmoothNormals', inputs: { mesh: ['10', 0], crease_angle: 180.0 } }, '12': { class_type: 'SaveGLB', inputs: { mesh: ['11', 0], filename_prefix: o.prefix } }, - '13': { class_type: 'RotateMesh', inputs: { mesh: ['11', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } }, - '14': { class_type: 'RenderMesh', inputs: { mesh: ['13', 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } }, - '15': { class_type: 'SaveImage', inputs: { images: ['14', 0], filename_prefix: o.prefix + '_preview' } }, + ...meshPreview(['11', 0], o.prefix, 13), }; } +/** PNG-превью меша без текстур в ракурсе 3/4 → _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). + * Файлы: _NNNNN_.glb, _basecolor_…, _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 треугольников (это и модель + * без текстур, _mesh_…glb + превью) → UV-развёртка → текстуры из цвет-вокселей: base color и, если o.pbr, + * metallic и roughness → текстурированный GLB, развёртка и рендеры с 4 сторон (_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 видов (_mv_…) по нормалям и + * позициям меша (o.mesh — путь в output) после UV-развёртки, виды запекаются в текстуру. Геометрия не меняется. + */ +export function paintGraph(o: { image: string; mesh: string; prefix: string; seed: number; textureSize: number; delight: boolean }): Graph { + const g: Graph = { + '1': { class_type: 'LoadImage', inputs: { image: o.image } }, + // маска объекта: фон картинки заменяется белым, как в оригинальном пайплайне (там картинка RGBA) + '2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } }, + '3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } }, + '26': { class_type: 'Img2GlbLoadOutputMesh', inputs: { path: o.mesh } }, + '28': { class_type: 'UnwrapMesh', inputs: { mesh: ['26', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } }, + '29': { class_type: 'Img2GlbHunyuanPaint', inputs: { mesh: ['28', 0], image: ['1', 0], mask: ['3', 0], + texture_size: o.textureSize, seed: o.seed, delight: o.delight } }, + '30': { class_type: 'ApplyTextureToMesh', inputs: { mesh: ['28', 0], base_color: ['29', 0] } }, + '37': { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_mv' } }, + }; + return saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]); +} + /** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */ export function kindOf(graph: unknown): JobKind { const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type); + if (types.includes('Img2GlbHunyuanPaint')) return 'текстура'; if (types.includes('SaveGLB')) return '3D-модель'; if (types.includes('RemoveBackground')) return 'очистка фона'; if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация'; diff --git a/shared/api.ts b/shared/api.ts index 15749eb..2258e3d 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -43,6 +43,8 @@ export interface Project { created: number; /** батч, из которого картинка */ batchId: number; + /** откуда исходная картинка: сгенерирована (text — SDXL) или загружена */ + imageSource: 'text' | 'upload'; /** исходная картинка */ image: BatchImage; /** последняя очищенная картинка (история очисток не хранится) */ @@ -54,26 +56,87 @@ export interface Project { cleanError?: string; /** положение идущей очистки в очереди ComfyUI */ live?: LiveStatus; - /** последняя 3D-модель из очищенной картинки: GLB и PNG-превью (история не хранится) */ - model: FileRef | null; - modelPreview: FileRef | null; - modelUpdated: number | null; - /** число треугольников в GLB (null — неизвестно) */ - modelFaces: number | null; + /** + * 3D-модель без текстур (история не хранится). Генерирует её Hunyuan3D-2 mini или TRELLIS.2; + * TRELLIS.2 заодно заполняет и texture. + */ + model: ModelArtifacts | null; /** идёт генерация модели: prompt_id в ComfyUI */ modelJobId: string | null; modelError?: string; - /** положение идущей генерации модели в очереди ComfyUI */ + /** положение идущей генерации в очереди ComfyUI */ modelLive?: LiveStatus; + /** текстурированная модель и её текстуры (от TRELLIS.2 или Hunyuan3D-2 Paint) */ + texture: TextureArtifacts | null; + /** идёт текстурирование (Paint): prompt_id в ComfyUI */ + textureJobId: string | null; + textureError?: string; + textureLive?: LiveStatus; +} + +/** Чем сгенерирована 3D-модель. */ +export type ModelGenerator = 'hunyuan' | 'trellis'; + +export const GENERATOR_NAMES: Record = { hunyuan: 'Hunyuan3D-2 mini', trellis: 'TRELLIS.2' }; + +/** Модель без текстур. */ +export interface ModelArtifacts { + generator: ModelGenerator; + glb: FileRef; + /** PNG-превью (рендер без текстур) */ + preview: FileRef | null; + /** число треугольников в GLB */ + faces: number | null; + /** мс */ + updated: number; +} + +/** Откуда текстуры: вместе с моделью от TRELLIS.2 или отдельным текстурированием Hunyuan3D-2 Paint. */ +export type TextureSource = 'trellis' | 'paint'; + +/** Текстурированная модель и всё, что к ней относится; каких-то файлов может не быть (у Paint — metallic, roughness). */ +export interface TextureArtifacts { + source: TextureSource; + /** текстурированная модель */ + glb: FileRef | null; + faces: number | null; + baseColor: FileRef | null; + /** металличность (канал B карты metallicRoughness glTF) в оттенках серого */ + metallic: FileRef | null; + /** шероховатость (канал G карты metallicRoughness glTF) в оттенках серого */ + roughness: FileRef | null; + /** изображение UV-развёртки */ + uv: FileRef | null; + /** виды: у TRELLIS.2 — рендеры модели с 4 сторон, у Paint — 6 видов, нарисованных моделью */ + views: FileRef[]; + /** мс */ + updated: number; } /** Генерация 3D-модели проекта. */ export interface ProjectModelRequest { + generator: ModelGenerator; /** Пусто — случайный. */ seed: number | string; + /** Hunyuan3D-2 mini */ steps: number | string; octree: number | string; + /** целевое число треугольников после упрощения */ faces: number | string; + /** TRELLIS.2: размер текстуры, px */ + textureSize: number | string; + /** TRELLIS.2: металличность и шероховатость (карты metallic, roughness) в модели */ + pbr: boolean; +} + +/** Текстурирование модели проекта (Hunyuan3D-2 Paint). */ +export interface ProjectTextureRequest { + /** Пусто — случайный. */ + seed: number | string; + /** размер текстуры, px */ + textureSize: number | string; + /** снимать блики и тени с картинки (Hunyuan3D Delight) */ + delight: boolean; } interface BatchBase { @@ -130,7 +193,7 @@ export interface CleanupRequest { } // ---------- задачи ComfyUI (таблица jobs; отслеживаются трекером) ---------- -export type JobType = 'text2img' | 'cleanup' | 'model'; +export type JobType = 'text2img' | 'cleanup' | 'model' | 'texture'; export type JobState = 'pending' | 'done' | 'error'; /** Состояние незавершённой задачи в очереди ComfyUI (не хранится, считается сервером на лету). */ @@ -161,10 +224,23 @@ export interface CleanupParams { export interface ModelParams { /** проект, к которому прикрепляется модель */ projectId: number; + generator: ModelGenerator; seed: number; - steps: number; - octree: number; faces: number; + /** Hunyuan3D-2 mini */ + steps?: number; + octree?: number; + /** TRELLIS.2 */ + textureSize?: number; + pbr?: boolean; +} + +export interface TextureParams { + /** проект, к которому прикрепляется результат */ + projectId: number; + seed: number; + textureSize: number; + delight: boolean; } interface JobOf { @@ -180,7 +256,7 @@ interface JobOf { params: P; /** результаты-картинки (для model — PNG-превью) */ images: FileRef[]; - /** GLB-файлы (только model) */ + /** GLB-файлы (model, texture) */ models: FileRef[]; live?: LiveStatus; } @@ -188,7 +264,8 @@ interface JobOf { export type Text2ImgJob = JobOf<'text2img', Text2ImgParams>; export type CleanupJob = JobOf<'cleanup', CleanupParams>; export type ModelJob = JobOf<'model', ModelParams>; -export type Job = Text2ImgJob | CleanupJob | ModelJob; +export type TextureJob = JobOf<'texture', TextureParams>; +export type Job = Text2ImgJob | CleanupJob | ModelJob | TextureJob; // ---------- значения полей форм (тоже в SQLite) ---------- export interface Text2ImgSettings { @@ -209,17 +286,31 @@ export interface CleanupSettings { } export interface ModelSettings { + generator: ModelGenerator; seed: string; + /** Hunyuan3D-2 mini */ steps: string; octree: string; faces: string; + /** TRELLIS.2 */ + trellisFaces: string; + textureSize: string; + trellisPbr: boolean; } -export type SettingsByPage = { text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings }; +export interface TextureSettings { + seed: string; + textureSize: string; + delight: boolean; +} + +export type SettingsByPage = { + text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings; texture: TextureSettings; +}; export type SettingsPage = keyof SettingsByPage; // ---------- очередь ComfyUI ---------- -export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'другое'; +export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'текстура' | 'другое'; export interface QueueItem { id: string; diff --git a/src/components/ModelView.tsx b/src/components/ModelView.tsx index b49c263..5532e39 100644 --- a/src/components/ModelView.tsx +++ b/src/components/ModelView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { FileRef } from '../../shared/api.ts'; import { fileUrl } from '../lib/api.ts'; @@ -20,10 +20,8 @@ function loadModelViewer() { return loading; } -export type View = 'img' | '3d'; - /** Переключатель из нескольких кнопок. */ -function Seg({ options, value, onChange }: { +export function Seg({ options, value, onChange }: { options: readonly (readonly [T, string])[]; value: T; onChange: (v: T) => void; }) { return ( @@ -35,32 +33,26 @@ function Seg({ options, value, onChange }: { ); } -/** Переключатель «картинка / 3D». */ -export function ViewToggle({ value, onChange }: { value: View; onChange: (v: View) => void }) { - return ; -} - /** - * Готовая модель: интерактивный GLB или PNG-превью. + * Готовая модель: интерактивный GLB с переключателем «сглаживание / без». * Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще * (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer. */ -export function ModelView({ glb, preview, view, onOpen }: { - glb?: FileRef | null; preview?: FileRef | null; view: View; onOpen: (src: string, caption: string) => void; +export function ModelView({ glb, preview, onOpen }: { + glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void; }) { const [ready, setReady] = useState(false); // скрипт model-viewer загружен const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани) const ref = useRef(null); - const src = !glb ? '' : smooth ? fileUrl(glb) + const src = smooth ? fileUrl(glb) : '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder }); useEffect(() => { - if (view !== '3d' || !src) return; let alive = true; loadModelViewer().then(() => { if (alive) setReady(true); }, () => { if (alive) setState('error'); }); return () => { alive = false; }; - }, [view, src]); + }, [src]); useEffect(() => { setState('loading'); }, [src]); @@ -72,42 +64,25 @@ export function ModelView({ glb, preview, view, onOpen }: { el.addEventListener('load', onLoad); el.addEventListener('error', onError); return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); }; - }, [ready, src, view]); + }, [ready, src]); const previewImg = preview && ( {preview.filename} onOpen(fileUrl(preview), preview.filename)} /> ); - if (view === '3d' && glb) { - return ( -
- {ready && ( - - )} - {state !== 'loaded' && (previewImg || загрузка 3D…)} - {state === 'error' && 3D не загрузилась} - - setSmooth(v === 'smooth')} /> - -
- ); - } - return previewImg || превью нет; -} - -/** Ссылки «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 ( -
- {before} - {items.map(([i, text, cls]) => i && ( - e.stopPropagation()}>{text} - ))} +
+ {ready && ( + + )} + {state !== 'loaded' && (previewImg || загрузка 3D…)} + {state === 'error' && 3D не загрузилась} + + setSmooth(v === 'smooth')} /> +
); } diff --git a/src/lib/batches.ts b/src/lib/batches.ts index 53ae868..daab099 100644 --- a/src/lib/batches.ts +++ b/src/lib/batches.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { - CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, Text2ImgInput, TextBatch, UploadBatch, + CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, ProjectTextureRequest, Text2ImgInput, TextBatch, + UploadBatch, } from '../../shared/api.ts'; import { api, parse } from './api.ts'; @@ -28,9 +29,14 @@ export const projectApi = { /** Очистка исходника проекта; результат заменит прежнюю очищенную картинку. */ cleanup: (id: number, params: CleanupRequest) => api(`/api/projects/${id}/cleanup`, { body: params }), removeClean: (id: number) => api(`/api/projects/${id}/clean`, { method: 'DELETE' }), - /** 3D-модель из очищенной картинки; новая заменит прежнюю. */ + /** 3D-модель из очищенной картинки (Hunyuan3D-2 mini или TRELLIS.2); новая заменит прежнюю вместе с текстурами. */ model: (id: number, params: ProjectModelRequest) => api(`/api/projects/${id}/model`, { body: params }), + /** Удалить модель — вместе со всеми текстурами. */ removeModel: (id: number) => api(`/api/projects/${id}/model`, { method: 'DELETE' }), + /** Текстура Hunyuan3D-2 Paint для модели без текстур. */ + texture: (id: number, params: ProjectTextureRequest) => api(`/api/projects/${id}/texture`, { body: params }), + /** Удалить текстурную группу (у TRELLIS.2 — вместе с моделью). */ + removeTexture: (id: number) => api(`/api/projects/${id}/texture`, { method: 'DELETE' }), /** Проект из картинки: новый или уже созданный из неё. */ forImage: (imageId: number) => api('/api/projects', { body: { imageId } }), get: (id: number) => api('/api/projects/' + id), @@ -82,7 +88,7 @@ export function useProject(id: number | null) { useEffect(() => { setProject(null); void refresh(); }, [refresh]); - const working = !!project?.cleanJobId || !!project?.modelJobId; + const working = !!project?.cleanJobId || !!project?.modelJobId || !!project?.textureJobId; useEffect(() => { if (!working) return; const t = setInterval(() => { void refresh(); }, POLL_MS); @@ -103,6 +109,7 @@ function stepStateText(jobId: string | null, live: LiveStatus | undefined, error export const cleanStateText = (p: Project) => stepStateText(p.cleanJobId, p.live, p.cleanError, 'обработка'); export const modelStateText = (p: Project) => stepStateText(p.modelJobId, p.modelLive, p.modelError, 'генерация'); +export const textureStateText = (p: Project) => stepStateText(p.textureJobId, p.textureLive, p.textureError, 'текстурирование'); /** Один батч; пока он в работе — перечитывается каждые 1.5 с. */ export function useBatch(id: number | null) { diff --git a/src/pages/Project.tsx b/src/pages/Project.tsx index e14d6bf..2f1f7bc 100644 --- a/src/pages/Project.tsx +++ b/src/pages/Project.tsx @@ -1,14 +1,21 @@ import { useState, type FormEvent, type ReactNode } from 'react'; import { Link, useParams } from 'react-router'; -import type { CleanupSettings, ModelSettings, Project as ProjectT } from '../../shared/api.ts'; -import { ModelLinks, ModelView, ViewToggle, type View } from '../components/ModelView.tsx'; +import type { + CleanupSettings, FileRef, ModelGenerator, ModelSettings, Project as ProjectT, TextureSettings, TextureSource, +} from '../../shared/api.ts'; +import { GENERATOR_NAMES } from '../../shared/api.ts'; +import { ModelView } from '../components/ModelView.tsx'; import { useViewer } from '../components/Viewer.tsx'; import { fileUrl } from '../lib/api.ts'; -import { cleanStateText, modelStateText, projectApi, useProject } from '../lib/batches.ts'; +import { cleanStateText, modelStateText, projectApi, textureStateText, useProject } from '../lib/batches.ts'; import { useSettings } from '../lib/settings.ts'; const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' }; -const MODEL_DEFAULTS: ModelSettings = { seed: '0', steps: '30', octree: '380', faces: '5000' }; +const MODEL_DEFAULTS: ModelSettings = { + generator: 'hunyuan', seed: '0', steps: '30', octree: '380', faces: '5000', trellisFaces: '100000', textureSize: '2048', + trellisPbr: false, +}; +const TEXTURE_DEFAULTS: TextureSettings = { seed: '0', textureSize: '2048', delight: true }; type Open = (src: string, caption: string) => void; type OnProject = (p: ProjectT) => void; @@ -94,51 +101,221 @@ function CleanBlock({ project, onProject, open }: { project: ProjectT; onProject } /** - * Квадрат «3D-модель» (показывается, только когда есть очищенная картинка): есть модель — переключатель - * «картинка / 3D» (по умолчанию 3D), «удалить», GLB и PNG; нет — форма Hunyuan3D на её месте; идёт генерация — её состояние. + * Форма генерации 3D-модели: Hunyuan3D-2 mini (только геометрия) или TRELLIS.2 (геометрия сразу с текстурами). + * Идёт генерация — её состояние. */ -function ModelBlock({ project, onProject, open, view, setView }: { - project: ProjectT; onProject: OnProject; open: Open; view: View; setView: (v: View) => void; -}) { +function ModelFormBlock({ project, onProject }: { project: ProjectT; onProject: OnProject }) { const { values: v, set } = useSettings('model', MODEL_DEFAULTS); const { busy, error, act } = useAct(onProject); const st = modelStateText(project); if (project.modelJobId) return
{st?.text}
; - // модель или форма — только по записи о модели в базе (загрузка model-viewer на это не влияет) - if (project.model) { - return ( -
- - - void act(() => projectApi.removeModel(project.id))} />} /> - {error &&
{error}
} -
- ); - } + const trellis = v.generator === 'trellis'; return ( void act(() => projectApi.model(project.id, { seed: v.seed, steps: v.steps, octree: v.octree, faces: v.faces }))}> + hint={trellis + ? 'TRELLIS.2 (встроенные ноды ComfyUI): модель сразу с текстурами — base color (и metallic, roughness), UV-развёртка, рендеры с 4 сторон.' + : 'Hunyuan3D-2 mini: только геометрия (~1.5–2 мин); текстуры потом — Hunyuan3D-2 Paint.'} + onSubmit={() => void act(() => projectApi.model(project.id, { + generator: v.generator, seed: v.seed, steps: v.steps, octree: v.octree, + faces: trellis ? v.trellisFaces : v.faces, textureSize: v.textureSize, pbr: v.trellisPbr, + }))}> + - + ); +} + +function TextureSizeField({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( + + ); +} + +/** Форма текстурирования 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
{st?.text}
; + return ( + void act(() => projectApi.texture(project.id, { seed: v.seed, textureSize: v.textureSize, delight: v.delight }))}> + - - ); } +/** Карточки 3D-артефактов; group — что удаляется вместе с карточкой. */ +type CardId = 'model' | 'textured' | 'baseColor' | 'metallic' | 'roughness' | 'uv' | 'views'; + +const CARD_NAMES: Record = { + 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 ( +
+
{title}
+
+ {children} +
+ {!working && { e.preventDefault(); remove(); }}>удалить} + {download.map(([f, text]) => f && ( + e.stopPropagation()}>{text} + ))} +
+ {error &&
{error}
} +
+
{date}
+
+ ); +} + +function Picture({ file, open }: { file: FileRef; open: Open }) { + return {file.filename} 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 ( +
+
3D-модель
+ +
+ ); + } + const texBy = t && TEXTURE_SOURCE_NAMES[t.source]; + const card = (id: CardId, title: string, children: ReactNode, download: [FileRef | null | undefined, string][], updated: number) => ( + }> + {children} + + ); + const image = (id: CardId, title: string, f: FileRef | null) => + f && t && card(id, title, , [[f, 'PNG']], t.updated); + const tri = (n: number | null) => (n != null ? `, ${triangles(n)}` : ''); + return ( + <> + {project.modelJobId + ?
3D-модель
+ : card('model', `Модель без текстур${tri(m.faces)}`, + , + [[m.glb, 'GLB'], [m.preview, 'PNG']], m.updated)} + {t && t.glb && card('textured', `Текстурированная модель${tri(t.faces)}`, + , [[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)' : 'Виды', +
4 ? ' six' : '')}> + {t.views.map(f => )} +
, [], t.updated)} + {!t && m.generator === 'hunyuan' && !project.modelJobId && ( +
+
Текстуры
+ +
+ )} + + ); +} + const when = (t: number | null) => (t ? new Date(t).toLocaleString() : ''); +/** Чем сделан текстурный артефакт. */ +const TEXTURE_SOURCE_NAMES: Record = { trellis: 'TRELLIS.2', paint: 'Hunyuan3D-2 Paint' }; + +/** Подпись под карточкой: чем получено и когда. */ +function Origin({ by, time }: { by: string; time: number | null }) { + return <>{by}{time ? ' · ' + when(time) : ''}; +} + /** «1 треугольник», «3 треугольника», «10000 треугольников». */ function triangles(n: number): string { const d = n % 10, dd = n % 100; @@ -146,12 +323,14 @@ function triangles(n: number): string { return `${n} ${word}`; } -/** Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (или форма Hunyuan3D). */ +/** + * Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (Hunyuan3D-2 mini или TRELLIS.2) + * и карточки её артефактов; для модели без текстур — форма Hunyuan3D-2 Paint. + */ export function Project() { const id = Number(useParams().id) || null; const { project, setProject, error } = useProject(id); const viewer = useViewer(); - const [view, setView] = useState('3d'); if (!id) return

Неверный номер проекта.

; if (error && !project) return

{error} К проектам

; @@ -161,29 +340,21 @@ export function Project() { return ( <>

Проект #{project.id}

-

- {new Date(project.created).toLocaleString()} · из батча #{project.batchId} -

-
+
-
Исходник
+
Исходник
{img.filename} viewer.open(fileUrl(img), img.filename)} /> скачать
+
Очищенная
-
{project.clean ? when(project.cleanUpdated) : ''}
+
{project.clean && }
- {project.clean && ( -
-
3D-модель{project.model && project.modelFaces != null ? `, ${triangles(project.modelFaces)}` : ''}
- -
{project.model ? when(project.modelUpdated) : ''}
-
- )} + {project.clean && }
{viewer.element} diff --git a/src/styles.css b/src/styles.css index eb199ab..5fd4901 100644 --- a/src/styles.css +++ b/src/styles.css @@ -52,8 +52,10 @@ details .sub-field { margin-top: 12px; } /* ---------- панели исходник / результат ---------- */ .stage { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .pane { display: grid; gap: 6px; align-content: start; justify-items: start; } -.pane-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 10px; } +.pane-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 10px; min-width: 0; max-width: 100%; } +.pane-title > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pane .name { font-size: 12px; color: var(--muted); word-break: break-all; min-height: 18px; } +.pane .name .origin { color: var(--text); font-weight: 600; } .box { position: relative; width: 100%; max-width: 420px; aspect-ratio: 1; background: var(--img-bg); border-radius: 10px; overflow: hidden; display: grid; place-items: center; color: var(--muted); font-size: 13px; text-align: center; padding: 0; } .box img, .cell img { width: 100%; height: 100%; object-fit: contain; display: block; cursor: zoom-in; } @@ -126,10 +128,12 @@ fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 14px; min-width .step-form label { gap: 3px; } .step-form input { padding: 6px 9px; } .step-form input[type=color] { height: 32px; } -.stage.three { grid-template-columns: repeat(3, 1fr); } -@media (max-width: 900px) { .stage.three { grid-template-columns: 1fr 1fr; } } -@media (max-width: 600px) { .stage.three { grid-template-columns: 1fr; } } -.box-seg { position: absolute; top: 6px; right: 6px; } +.step-form label.check { display: flex; flex-direction: row; align-items: center; gap: 6px; } +.step-form label.check input { width: auto; padding: 0; } +.stage.steps { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +.views-grid { display: grid; grid-template-columns: 1fr 1fr; width: 100%; height: 100%; } +.views-grid img { width: 100%; height: 100%; object-fit: contain; } +.views-grid.six { grid-template-columns: repeat(3, 1fr); } .box-error { position: absolute; left: 6px; bottom: 34px; right: 6px; color: var(--error); font-size: 12px; } .project-thumb { width: calc(var(--thumb) * 2); height: calc(var(--thumb) * 2); border-radius: 6px; overflow: hidden; background: var(--img-bg); } .project-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; } diff --git a/start.bat b/start.bat index aa141a9..d8c6a33 100644 --- a/start.bat +++ b/start.bat @@ -1,7 +1,6 @@ @echo off rem img2glb: собрать фронтенд при необходимости и запустить сервер (порт 7000). ComfyUI 0.37 должен быть запущен. cd /d "%~dp0" -set PATH=%~dp0..\node;%PATH% if not exist dist\index.html call npm run build || goto :err start "" http://127.0.0.1:7000 call npm start