trellis.2. unified 3d workflows
This commit is contained in:
+68
-35
@@ -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<T>(fn: () => T): T {
|
||||
@@ -340,9 +353,9 @@ export function deleteBatch(b: ImageBatch) {
|
||||
|
||||
const SELECT_PROJECT = `
|
||||
SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError,
|
||||
p.model, p.modelPreview, p.modelUpdated, p.modelJobId, p.modelError, p.modelFaces,
|
||||
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type
|
||||
FROM Projects p JOIN Images i ON i.id = p.imageId`;
|
||||
p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError,
|
||||
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType
|
||||
FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`;
|
||||
|
||||
const pq = {
|
||||
insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'),
|
||||
@@ -355,11 +368,15 @@ const pq = {
|
||||
clearClean: db.prepare('UPDATE Projects SET clean = NULL, cleanUpdated = NULL, cleanError = NULL WHERE id = ?'),
|
||||
byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'),
|
||||
startModel: db.prepare('UPDATE Projects SET modelJobId = ?, modelError = NULL WHERE id = ?'),
|
||||
finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?, model), modelPreview = COALESCE(?, modelPreview),
|
||||
modelFaces = COALESCE(?, modelFaces), modelUpdated = ?, modelError = ?, modelJobId = NULL WHERE id = ?`),
|
||||
clearModel: db.prepare(`UPDATE Projects SET model = NULL, modelPreview = NULL, modelFaces = NULL, modelUpdated = NULL, modelError = NULL
|
||||
WHERE id = ?`),
|
||||
setModelFaces: db.prepare('UPDATE Projects SET modelFaces = ? WHERE id = ?'),
|
||||
// новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся
|
||||
finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model),
|
||||
texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END,
|
||||
modelError = ?3, modelJobId = NULL WHERE id = ?4`),
|
||||
byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'),
|
||||
startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'),
|
||||
finishTexture: db.prepare('UPDATE Projects SET texture = COALESCE(?, texture), textureError = ?, textureJobId = NULL WHERE id = ?'),
|
||||
clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'),
|
||||
clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'),
|
||||
};
|
||||
|
||||
/** Генерация 3D-модели проекта поставлена в очередь. */
|
||||
@@ -371,17 +388,38 @@ export function projectByModelJob(jobId: string): number | null {
|
||||
return (pq.byModelJob.get(jobId) as { id: number } | undefined)?.id ?? null;
|
||||
}
|
||||
|
||||
/** Генерация завершена: новая модель заменяет прежнюю (при ошибке прежняя остаётся). */
|
||||
export function finishProjectModel(id: number, model: FileRef | null, preview: FileRef | null, error?: string) {
|
||||
pq.finishModel.run(model ? JSON.stringify(model) : null, preview ? JSON.stringify(preview) : null,
|
||||
model ? glbTriangles(model) : null, Date.now(), error ?? null, id);
|
||||
/**
|
||||
* Генерация завершена: новая модель заменяет прежнюю, прежние текстуры убираются (TRELLIS.2 сразу даёт свои).
|
||||
* При ошибке (model = null) всё остаётся как было.
|
||||
*/
|
||||
export function finishProjectModel(id: number, model: ModelArtifacts | null, texture: TextureArtifacts | null, error?: string) {
|
||||
pq.finishModel.run(model ? JSON.stringify(model) : null, texture ? JSON.stringify(texture) : null, error ?? null, id);
|
||||
}
|
||||
|
||||
/** Убрать 3D-модель из проекта (файлы в ComfyUI/output остаются). */
|
||||
/** Текстурирование проекта поставлено в очередь. */
|
||||
export function startProjectTexture(id: number, jobId: string) {
|
||||
pq.startTexture.run(jobId, id);
|
||||
}
|
||||
|
||||
export function projectByTextureJob(jobId: string): number | null {
|
||||
return (pq.byTextureJob.get(jobId) as { id: number } | undefined)?.id ?? null;
|
||||
}
|
||||
|
||||
/** Текстурирование завершено: новый результат заменяет прежний (при ошибке прежний остаётся). */
|
||||
export function finishProjectTexture(id: number, texture: TextureArtifacts | null, error?: string) {
|
||||
pq.finishTexture.run(texture ? JSON.stringify(texture) : null, error ?? null, id);
|
||||
}
|
||||
|
||||
/** Убрать модель из проекта вместе с текстурами — они сделаны для неё (файлы в ComfyUI/output остаются). */
|
||||
export function clearProjectModel(id: number) {
|
||||
pq.clearModel.run(id);
|
||||
}
|
||||
|
||||
/** Убрать текстурную группу (файлы в ComfyUI/output остаются). */
|
||||
export function clearProjectTexture(id: number) {
|
||||
pq.clearTexture.run(id);
|
||||
}
|
||||
|
||||
/** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */
|
||||
export function clearProjectClean(id: number) {
|
||||
pq.clearClean.run(id);
|
||||
@@ -389,26 +427,21 @@ export function clearProjectClean(id: number) {
|
||||
|
||||
interface ProjectRow {
|
||||
id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null;
|
||||
model: string | null; modelPreview: string | null; modelUpdated: number | null; modelJobId: string | null; modelError: string | null;
|
||||
modelFaces: number | null;
|
||||
imageId: number; batchId: number; filename: string; subfolder: string; type: string;
|
||||
model: string | null; modelJobId: string | null; modelError: string | null;
|
||||
texture: string | null; textureJobId: string | null; textureError: string | null;
|
||||
imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string;
|
||||
}
|
||||
|
||||
const json = (s: string | null) => (s ? JSON.parse(s) : null);
|
||||
|
||||
function toProject(r: ProjectRow): Project {
|
||||
// модели, прикреплённые до появления modelFaces, — число полигонов считается из файла один раз
|
||||
if (r.model && r.modelFaces == null) {
|
||||
r.modelFaces = glbTriangles(JSON.parse(r.model));
|
||||
if (r.modelFaces != null) pq.setModelFaces.run(r.modelFaces, r.id);
|
||||
}
|
||||
return {
|
||||
id: r.id, created: r.created, batchId: r.batchId,
|
||||
id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text',
|
||||
image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id },
|
||||
clean: json(r.clean), cleanUpdated: r.cleanUpdated,
|
||||
cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}),
|
||||
model: json(r.model), modelPreview: json(r.modelPreview), modelUpdated: r.modelUpdated,
|
||||
modelFaces: r.modelFaces, modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
|
||||
model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
|
||||
texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+82
-20
@@ -7,21 +7,23 @@ import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream as WebStream } from 'node:stream/web';
|
||||
import type {
|
||||
CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, QueueInfo,
|
||||
SettingsPage, Text2ImgInput, Text2ImgJob,
|
||||
CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest,
|
||||
QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob,
|
||||
} from '../shared/api.ts';
|
||||
import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts';
|
||||
import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts';
|
||||
import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts';
|
||||
import {
|
||||
clearProjectClean, clearProjectModel, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, getProject,
|
||||
getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean,
|
||||
startProjectModel, transaction, updateTextInput,
|
||||
clearProjectClean, clearProjectModel, clearProjectTexture, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage,
|
||||
getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean,
|
||||
startProjectModel, startProjectTexture, transaction, updateTextInput,
|
||||
} from './db.ts';
|
||||
import { glbWithoutNormals, outputPath } from './glb.ts';
|
||||
import { migrateOldRuns } from './migrate.ts';
|
||||
import { liveStatus, startTracker } from './tracker.ts';
|
||||
import { cleanupGraph, escapeTemplate, kindOf, modelGraph, renderTemplate, text2imgGraph } from './workflows.ts';
|
||||
import {
|
||||
cleanupGraph, escapeTemplate, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph,
|
||||
} from './workflows.ts';
|
||||
|
||||
class BadRequest extends Error {}
|
||||
class NotFound extends Error {}
|
||||
@@ -33,7 +35,7 @@ const UPLOAD_DIR = 'img2glb_upload';
|
||||
type Req = http.IncomingMessage;
|
||||
type Res = http.ServerResponse;
|
||||
|
||||
const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model'];
|
||||
const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model', 'texture'];
|
||||
|
||||
function send(res: Res, code: number, obj: unknown) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
@@ -208,43 +210,101 @@ function projectOrThrow(id: number): Project {
|
||||
function withLiveProject(p: Project): Project {
|
||||
const live = p.cleanJobId ? liveStatus(p.cleanJobId) : undefined;
|
||||
const modelLive = p.modelJobId ? liveStatus(p.modelJobId) : undefined;
|
||||
return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}) };
|
||||
const textureLive = p.textureJobId ? liveStatus(p.textureJobId) : undefined;
|
||||
return { ...p, ...(live ? { live } : {}), ...(modelLive ? { modelLive } : {}), ...(textureLive ? { textureLive } : {}) };
|
||||
}
|
||||
|
||||
const outputRel = (f: FileRef) => (f.subfolder ? `${f.subfolder}/${f.filename}` : f.filename);
|
||||
const textureSizeOf = (v: unknown) => ([1024, 2048, 4096].includes(Number(v)) ? Number(v) : 2048);
|
||||
|
||||
/**
|
||||
* 3D-модель проекта из его очищенной картинки (Hunyuan3D-2 mini) + PNG-превью.
|
||||
* Новая модель заменяет прежнюю; история не хранится.
|
||||
* 3D-модель проекта из очищенной картинки: Hunyuan3D-2 mini (без текстур) или TRELLIS.2 (сразу с текстурами).
|
||||
* Новая модель заменяет прежнюю вместе с текстурами; история не хранится.
|
||||
*/
|
||||
async function projectModel(req: Req, res: Res, projectId: number) {
|
||||
const p = projectOrThrow(projectId);
|
||||
if (!p.clean) throw new Conflict('сначала почистите исходник');
|
||||
if (p.modelJobId) throw new Conflict('модель уже генерируется');
|
||||
if (p.textureJobId) throw new Conflict('идёт текстурирование');
|
||||
const b = await readJson<ProjectModelRequest>(req);
|
||||
const src = sourceImage(p.clean);
|
||||
const seed = parseSeed(b.seed);
|
||||
const steps = Math.round(clamp(b.steps, 1, 100, 30));
|
||||
const octree = Math.round(clamp(b.octree, 64, 512, 380));
|
||||
const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000));
|
||||
const id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces }));
|
||||
const generator = b.generator === 'trellis' ? 'trellis' : 'hunyuan';
|
||||
let id: string;
|
||||
let params: ModelJob['params'];
|
||||
if (generator === 'trellis') {
|
||||
const seed = parseSeed(b.seed) % 2 ** 32; // seed + 1 используется на стадии цвета
|
||||
const faces = Math.round(clamp(b.faces, 1000, 2_000_000, 100000));
|
||||
const textureSize = textureSizeOf(b.textureSize);
|
||||
const pbr = b.pbr === true;
|
||||
id = await queuePrompt(trellisGraph({ image: src.image, prefix: 'img2glb_trellis/' + src.stem, seed, faces, textureSize, pbr }));
|
||||
params = { projectId, generator, seed, faces, textureSize, pbr };
|
||||
} else {
|
||||
const seed = parseSeed(b.seed);
|
||||
const steps = Math.round(clamp(b.steps, 1, 100, 30));
|
||||
const octree = Math.round(clamp(b.octree, 64, 512, 380));
|
||||
const faces = Math.round(clamp(b.faces, 0, 2_000_000, 5000));
|
||||
id = await queuePrompt(modelGraph({ image: src.image, prefix: 'img2glb_model/' + src.stem, seed, steps, octree, faces }));
|
||||
params = { projectId, generator, seed, steps, octree, faces };
|
||||
}
|
||||
transaction(() => {
|
||||
// задача нужна трекеру только до завершения — потом удаляется
|
||||
insertJob({
|
||||
id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [],
|
||||
params: { projectId, seed, steps, octree, faces },
|
||||
id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [], params,
|
||||
} satisfies ModelJob);
|
||||
startProjectModel(projectId, id);
|
||||
});
|
||||
send(res, 200, withLiveProject(projectOrThrow(projectId)));
|
||||
}
|
||||
|
||||
/** Убрать 3D-модель из проекта — после этого можно сгенерировать заново. */
|
||||
/** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */
|
||||
function removeProjectModel(res: Res, id: number) {
|
||||
const p = projectOrThrow(id);
|
||||
if (p.modelJobId) throw new Conflict('идёт генерация модели');
|
||||
if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация');
|
||||
clearProjectModel(id);
|
||||
send(res, 200, withLiveProject(projectOrThrow(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстура Hunyuan3D-2 Paint для модели проекта без текстур (нода custom_nodes/img2glb_hunyuan_paint):
|
||||
* текстурированная модель, base color, развёртка, 6 видов. Новый результат заменяет прежний.
|
||||
*/
|
||||
async function projectTexture(req: Req, res: Res, projectId: number) {
|
||||
const p = projectOrThrow(projectId);
|
||||
if (!p.clean) throw new Conflict('сначала почистите исходник');
|
||||
if (!p.model) throw new Conflict('сначала сделайте 3D-модель');
|
||||
if (p.model.generator !== 'hunyuan') throw new Conflict('модель уже с текстурами');
|
||||
if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация');
|
||||
const b = await readJson<ProjectTextureRequest>(req);
|
||||
const src = sourceImage(p.clean);
|
||||
const seed = parseSeed(b.seed) % 2 ** 32;
|
||||
const textureSize = textureSizeOf(b.textureSize);
|
||||
const delight = b.delight !== false;
|
||||
const id = await queuePrompt(paintGraph({
|
||||
image: src.image, mesh: outputRel(p.model.glb), prefix: 'img2glb_paint/' + src.stem, seed, textureSize, delight,
|
||||
}));
|
||||
transaction(() => {
|
||||
// задача нужна трекеру только до завершения — потом удаляется
|
||||
insertJob({
|
||||
id, type: 'texture', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [],
|
||||
params: { projectId, seed, textureSize, delight },
|
||||
} satisfies TextureJob);
|
||||
startProjectTexture(projectId, id);
|
||||
});
|
||||
send(res, 200, withLiveProject(projectOrThrow(projectId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Убрать текстурную группу. Текстуры TRELLIS.2 сделаны одной задачей с моделью — уходит и модель;
|
||||
* текстуры Paint — только они.
|
||||
*/
|
||||
function removeProjectTexture(res: Res, id: number) {
|
||||
const p = projectOrThrow(id);
|
||||
if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация');
|
||||
if (p.texture?.source === 'trellis') clearProjectModel(id);
|
||||
else clearProjectTexture(id);
|
||||
send(res, 200, withLiveProject(projectOrThrow(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона.
|
||||
* Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится.
|
||||
@@ -383,12 +443,14 @@ http.createServer(async (req, res) => {
|
||||
}
|
||||
if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject));
|
||||
if (route === 'POST /api/projects') return await createProject(req, res);
|
||||
const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model))?$/.exec(u.pathname);
|
||||
const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model|texture))?$/.exec(u.pathname);
|
||||
if (pm && req.method === 'GET' && !pm[2]) return send(res, 200, withLiveProject(projectOrThrow(Number(pm[1]))));
|
||||
if (pm && req.method === 'POST' && pm[2] === 'cleanup') return await projectCleanup(req, res, Number(pm[1]));
|
||||
if (pm && req.method === 'DELETE' && pm[2] === 'clean') return removeProjectClean(res, Number(pm[1]));
|
||||
if (pm && req.method === 'POST' && pm[2] === 'model') return await projectModel(req, res, Number(pm[1]));
|
||||
if (pm && req.method === 'DELETE' && pm[2] === 'model') return removeProjectModel(res, Number(pm[1]));
|
||||
if (pm && req.method === 'POST' && pm[2] === 'texture') return await projectTexture(req, res, Number(pm[1]));
|
||||
if (pm && req.method === 'DELETE' && pm[2] === 'texture') return removeProjectTexture(res, Number(pm[1]));
|
||||
if (req.method === 'GET' && u.pathname.startsWith('/api/settings/')) return send(res, 200, getSettings(settingsPage(tail('/api/settings/'))));
|
||||
if (req.method === 'PUT' && u.pathname.startsWith('/api/settings/')) return await saveSettings(req, res, settingsPage(tail('/api/settings/')));
|
||||
if (route === 'POST /api/import') return await importOld(req, res);
|
||||
|
||||
+50
-6
@@ -1,10 +1,14 @@
|
||||
// Фоновое отслеживание незавершённых задач: результаты из ComfyUI записываются в базу,
|
||||
// даже если ни одна страница не открыта.
|
||||
import type { FileRef, Job, LiveStatus } from '../shared/api.ts';
|
||||
import type {
|
||||
FileRef, Job, LiveStatus, ModelArtifacts, ModelGenerator, TextureArtifacts, TextureSource,
|
||||
} from '../shared/api.ts';
|
||||
import { comfyJson } from './comfy.ts';
|
||||
import { glbTriangles } from './glb.ts';
|
||||
import { TEXTURE_VIEWS } from './workflows.ts';
|
||||
import {
|
||||
batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, pendingJobs, projectByCleanJob,
|
||||
projectByModelJob, transaction,
|
||||
batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, finishProjectTexture, pendingJobs,
|
||||
projectByCleanJob, projectByModelJob, projectByTextureJob, transaction,
|
||||
} from './db.ts';
|
||||
|
||||
/** Сколько ждать задачу, которой нет ни в очереди, ни в истории ComfyUI (ComfyUI перезапускался). */
|
||||
@@ -40,6 +44,36 @@ function outputFiles(h: HistoryEntry) {
|
||||
return { images, models };
|
||||
}
|
||||
|
||||
type Files = { images?: FileRef[]; models?: FileRef[] };
|
||||
/** Файл по метке префикса (см. workflows.ts): <prefix><tag>_NNNNN_.png. */
|
||||
const tagged = (files: FileRef[] | undefined, tag: string) => files?.find(i => i.filename.includes(tag + '_')) ?? null;
|
||||
|
||||
/**
|
||||
* Модель без текстур: у Hunyuan3D-2 mini — единственный GLB, у TRELLIS.2 — <prefix>_mesh_…glb; плюс превью.
|
||||
*/
|
||||
function modelArtifacts(generator: ModelGenerator, r: Files): ModelArtifacts | null {
|
||||
const glb = generator === 'trellis' ? tagged(r.models, '_mesh') : r.models?.[0];
|
||||
if (!glb) return null;
|
||||
return { generator, glb, preview: tagged(r.images, '_preview'), faces: glbTriangles(glb), updated: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстурная группа: текстурированный GLB (не _mesh), base color, metallic, roughness, развёртка и виды —
|
||||
* рендеры _view<угол> у TRELLIS.2 или 6 нарисованных видов _mv у Paint.
|
||||
*/
|
||||
function textureArtifacts(source: TextureSource, r: Files): TextureArtifacts | null {
|
||||
const glb = r.models?.find(m => !m.filename.includes('_mesh_')) ?? null;
|
||||
if (!glb) return null;
|
||||
const images = r.images ?? [];
|
||||
const views = source === 'trellis'
|
||||
? TEXTURE_VIEWS.map(a => tagged(images, '_view' + a)).filter((i): i is FileRef => i != null)
|
||||
: images.filter(i => i.filename.includes('_mv_')).sort((x, y) => x.filename.localeCompare(y.filename));
|
||||
return {
|
||||
source, glb, faces: glbTriangles(glb), baseColor: tagged(images, '_basecolor'), metallic: tagged(images, '_metallic'),
|
||||
roughness: tagged(images, '_roughness'), uv: tagged(images, '_uv'), views, updated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершение задачи. Картинки text2img прикрепляются к её батчу (Images.batchId);
|
||||
* результат очистки и модель проекта заменяют прежние в проекте, а сама задача удаляется — история не хранится.
|
||||
@@ -52,11 +86,21 @@ function finish(job: Job, state: 'done' | 'error', result: { error?: string; ima
|
||||
deleteJob(job.id);
|
||||
return;
|
||||
}
|
||||
// модель проекта: GLB и превью — в проект, задача удаляется (история не хранится)
|
||||
// модель проекта (у TRELLIS.2 — сразу с текстурами) — в проект, задача удаляется (история не хранится)
|
||||
const modelProject = job.type === 'model' ? job.params.projectId ?? projectByModelJob(job.id) : null;
|
||||
if (modelProject != null) {
|
||||
if (job.type === 'model' && modelProject != null) {
|
||||
const generator = job.params.generator ?? 'hunyuan';
|
||||
const done = state === 'done';
|
||||
finishProjectModel(modelProject, done ? result.models?.[0] ?? null : null, done ? result.images?.[0] ?? null : null, result.error);
|
||||
const model = done ? modelArtifacts(generator, result) : null;
|
||||
const texture = done && generator === 'trellis' ? textureArtifacts('trellis', result) : null;
|
||||
finishProjectModel(modelProject, model, texture, result.error ?? (done && !model ? 'нет GLB в результате' : undefined));
|
||||
deleteJob(job.id);
|
||||
return;
|
||||
}
|
||||
// текстура Hunyuan3D-2 Paint — в проект, задача удаляется
|
||||
const textureProject = job.type === 'texture' ? job.params.projectId ?? projectByTextureJob(job.id) : null;
|
||||
if (textureProject != null) {
|
||||
finishProjectTexture(textureProject, state === 'done' ? textureArtifacts('paint', result) : null, result.error);
|
||||
deleteJob(job.id);
|
||||
return;
|
||||
}
|
||||
|
||||
+129
-3
@@ -57,15 +57,141 @@ export function modelGraph(o: {
|
||||
'10': { class_type: 'DecimateMesh', inputs: { mesh: ['9', 0], target_face_count: o.faces, placement_mode: 'midpoint' } },
|
||||
'11': { class_type: 'MeshSmoothNormals', inputs: { mesh: ['10', 0], crease_angle: 180.0 } },
|
||||
'12': { class_type: 'SaveGLB', inputs: { mesh: ['11', 0], filename_prefix: o.prefix } },
|
||||
'13': { class_type: 'RotateMesh', inputs: { mesh: ['11', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } },
|
||||
'14': { class_type: 'RenderMesh', inputs: { mesh: ['13', 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } },
|
||||
'15': { class_type: 'SaveImage', inputs: { images: ['14', 0], filename_prefix: o.prefix + '_preview' } },
|
||||
...meshPreview(['11', 0], o.prefix, 13),
|
||||
};
|
||||
}
|
||||
|
||||
/** PNG-превью меша без текстур в ракурсе 3/4 → <prefix>_preview_…; узлы id, id+1, id+2. */
|
||||
function meshPreview(mesh: Link, prefix: string, id: number): Graph {
|
||||
return {
|
||||
[id]: { class_type: 'RotateMesh', inputs: { mesh, mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } },
|
||||
[id + 1]: { class_type: 'RenderMesh', inputs: { mesh: [String(id), 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } },
|
||||
[id + 2]: { class_type: 'SaveImage', inputs: { images: [String(id + 1), 0], filename_prefix: prefix + '_preview' } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Ракурсы рендеров текстурированной модели TRELLIS.2, градусы поворота вокруг вертикали. */
|
||||
export const TEXTURE_VIEWS = [0, 90, 180, 270];
|
||||
|
||||
/**
|
||||
* Общая часть TRELLIS.2 (встроенные ноды ComfyUI, по шаблону 3d_pixal3d_trellis2_image_to_model): по картинке —
|
||||
* плотный меш формы (нода 22) и цвет-воксели (нода 25).
|
||||
*/
|
||||
function trellisNodes(o: { image: string; seed: number }): Graph {
|
||||
return {
|
||||
// картинка → маска → кадр (TRELLIS.2: pad_factor 1.0) → условие DINOv3
|
||||
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
|
||||
'2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } },
|
||||
'3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } },
|
||||
'4': { class_type: 'ImageCropToMask', inputs: {
|
||||
images: ['1', 0], masks: ['3', 0], width: 1024, height: 1024, pad_factor: 1.0, grow_mask: 0, background: '#000000' } },
|
||||
'5': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'dino_v3_L_naf_fp32.safetensors' } },
|
||||
'6': { class_type: 'Trellis2Conditioning', inputs: { clip_vision_model: ['5', 0], image: ['4', 0] } },
|
||||
'7': { class_type: 'UNETLoader', inputs: { unet_name: 'trellis_2_int8_convrot.safetensors', weight_dtype: 'default' } },
|
||||
'8': { class_type: 'VAELoader', inputs: { vae_name: 'trellis_2_shape_vae_bf16.safetensors' } },
|
||||
'9': { class_type: 'VAELoader', inputs: { vae_name: 'trellis_2_texture_vae_bf16.safetensors' } },
|
||||
// структура (разреженные воксели)
|
||||
'10': { class_type: 'CFGOverride', inputs: { model: ['7', 0], cfg: 1, start_percent: 0.667, end_percent: 1 } },
|
||||
'11': { class_type: 'RescaleCFG', inputs: { model: ['10', 0], multiplier: 0.7 } },
|
||||
'12': { class_type: 'ModelSamplingSD3', inputs: { model: ['11', 0], shift: 5 } },
|
||||
'13': { class_type: 'EmptyTrellis2LatentStructure', inputs: { batch_size: 1 } },
|
||||
'14': { class_type: 'KSampler', inputs: { model: ['12', 0], positive: ['6', 0], negative: ['6', 1], latent_image: ['13', 0],
|
||||
seed: o.seed, steps: 12, cfg: 7.5, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||||
'15': { class_type: 'VaeDecodeStructureTrellis2', inputs: { samples: ['14', 0], vae: ['8', 0], resolution: '32' } },
|
||||
// форма 512 → 1536
|
||||
'16': { class_type: 'Trellis2ShapeStage', inputs: { positive: ['6', 0], negative: ['6', 1], voxel: ['15', 0] } },
|
||||
'17': { class_type: 'CFGOverride', inputs: { model: ['7', 0], cfg: 1, start_percent: 0.769, end_percent: 1 } },
|
||||
'18': { class_type: 'RescaleCFG', inputs: { model: ['17', 0], multiplier: 0.5 } },
|
||||
'19': { class_type: 'KSampler', inputs: { model: ['18', 0], positive: ['16', 0], negative: ['16', 1], latent_image: ['16', 2],
|
||||
seed: o.seed, steps: 20, cfg: 7.5, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||||
'20': { class_type: 'Trellis2UpsampleStage', inputs: { positive: ['16', 0], negative: ['16', 1], shape_latent: ['19', 0], vae: ['8', 0],
|
||||
target_resolution: 1536 } },
|
||||
'21': { class_type: 'KSampler', inputs: { model: ['18', 0], positive: ['20', 0], negative: ['20', 1], latent_image: ['20', 2],
|
||||
seed: o.seed, steps: 12, cfg: 7.5, sampler_name: 'euler', scheduler: 'simple', denoise: 1 } },
|
||||
'22': { class_type: 'VaeDecodeShapeTrellis', inputs: { samples: ['21', 0], vae: ['8', 0] } },
|
||||
// цвет-воксели
|
||||
'23': { class_type: 'Trellis2TextureStage', inputs: { positive: ['20', 0], negative: ['20', 1], shape_latent: ['21', 0] } },
|
||||
'24': { class_type: 'KSampler', inputs: { model: ['7', 0], positive: ['23', 0], negative: ['23', 1], latent_image: ['23', 2],
|
||||
seed: o.seed + 1, steps: 12, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||||
'25': { class_type: 'VaeDecodeTextureTrellis', inputs: { samples: ['24', 0], vae: ['9', 0], shape_subdivides: ['22', 1] } },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстурированная модель (узел textured) и её файлы: GLB, base color, развёртка (узел unwrapped — меш с UV).
|
||||
* Файлы: <prefix>_NNNNN_.glb, <prefix>_basecolor_…, <prefix>_uv_….
|
||||
*/
|
||||
function saveTextured(g: Graph, prefix: string, unwrapped: string, baseColor: Link, textured: Link) {
|
||||
g['32'] = { class_type: 'MeshSmoothNormals', inputs: { mesh: textured, crease_angle: 180 } };
|
||||
g['33'] = { class_type: 'SaveGLB', inputs: { mesh: ['32', 0], filename_prefix: prefix } };
|
||||
g['34'] = { class_type: 'SaveImage', inputs: { images: baseColor, filename_prefix: prefix + '_basecolor' } };
|
||||
g['35'] = { class_type: 'RenderUVAtlas', inputs: { mesh: [unwrapped, 0], resolution: 1024 } };
|
||||
g['36'] = { class_type: 'SaveImage', inputs: { images: ['35', 0], filename_prefix: prefix + '_uv' } };
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Модель TRELLIS.2 целиком: своя форма по картинке → ремеш и упрощение до o.faces треугольников (это и модель
|
||||
* без текстур, <prefix>_mesh_…glb + превью) → UV-развёртка → текстуры из цвет-вокселей: base color и, если o.pbr,
|
||||
* metallic и roughness → текстурированный GLB, развёртка и рендеры с 4 сторон (<prefix>_view<угол>_…).
|
||||
*/
|
||||
export function trellisGraph(o: {
|
||||
image: string; prefix: string; seed: number; faces: number; textureSize: number; pbr: boolean;
|
||||
}): Graph {
|
||||
const g = trellisNodes(o);
|
||||
g['26'] = { class_type: 'RemeshMesh', inputs: { mesh: ['22', 0], resolution: 768, sign_mode: 'udf', 'sign_mode.qef': false,
|
||||
'sign_mode.drop_inverted_components': false, 'sign_mode.drop_enclosed_components': false, band: 1, project_back: 0,
|
||||
fix_poles: false, smooth_iters: 20, drop_small_components: 0.01, precluster_max_verts: 20000000 } };
|
||||
g['27'] = { class_type: 'DecimateMesh', inputs: { mesh: ['26', 0], target_face_count: o.faces, placement_mode: 'midpoint' } };
|
||||
// модель без текстур
|
||||
g['38'] = { class_type: 'MeshSmoothNormals', inputs: { mesh: ['27', 0], crease_angle: 180 } };
|
||||
g['39'] = { class_type: 'SaveGLB', inputs: { mesh: ['38', 0], filename_prefix: o.prefix + '_mesh' } };
|
||||
Object.assign(g, meshPreview(['38', 0], o.prefix, 60));
|
||||
// текстуры: каждый тексель проецируется на плотную поверхность TRELLIS.2 и берёт цвет из её цвет-вокселей
|
||||
g['28'] = { class_type: 'UnwrapMesh', inputs: { mesh: ['27', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } };
|
||||
g['29'] = { class_type: 'BakeTextureFromVoxel', inputs: { mesh: ['28', 0], voxel_colors: ['25', 0], texture_size: o.textureSize,
|
||||
reference_mesh: ['22', 0] } };
|
||||
g['30'] = { class_type: 'ApplyTextureToMesh', inputs: {
|
||||
mesh: ['28', 0], base_color: ['29', 0], ...(o.pbr ? { metallic: ['29', 1], roughness: ['29', 2] } : {}) } };
|
||||
saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]);
|
||||
if (o.pbr) {
|
||||
g['52'] = { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_metallic' } };
|
||||
g['53'] = { class_type: 'SaveImage', inputs: { images: ['29', 2], filename_prefix: o.prefix + '_roughness' } };
|
||||
}
|
||||
TEXTURE_VIEWS.forEach((angle, i) => {
|
||||
const r = String(40 + i * 3), v = String(41 + i * 3), s = String(42 + i * 3);
|
||||
g[r] = { class_type: 'RotateMesh', inputs: { mesh: ['32', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': angle, 'mode.angle_z': 0 } };
|
||||
g[v] = { class_type: 'RenderMesh', inputs: { mesh: [r, 0], mode: 'texture', width: 512, height: 512, background: '#E7E5E4' } };
|
||||
g[s] = { class_type: 'SaveImage', inputs: { images: [v, 0], filename_prefix: `${o.prefix}_view${angle}` } };
|
||||
});
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Текстура Hunyuan3D-2 Paint (turbo) для модели проекта — нода custom_nodes/img2glb_hunyuan_paint:
|
||||
* Delight снимает свет с очищенной картинки, мультивью-модель рисует 6 видов (<prefix>_mv_…) по нормалям и
|
||||
* позициям меша (o.mesh — путь в output) после UV-развёртки, виды запекаются в текстуру. Геометрия не меняется.
|
||||
*/
|
||||
export function paintGraph(o: { image: string; mesh: string; prefix: string; seed: number; textureSize: number; delight: boolean }): Graph {
|
||||
const g: Graph = {
|
||||
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
|
||||
// маска объекта: фон картинки заменяется белым, как в оригинальном пайплайне (там картинка RGBA)
|
||||
'2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } },
|
||||
'3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } },
|
||||
'26': { class_type: 'Img2GlbLoadOutputMesh', inputs: { path: o.mesh } },
|
||||
'28': { class_type: 'UnwrapMesh', inputs: { mesh: ['26', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } },
|
||||
'29': { class_type: 'Img2GlbHunyuanPaint', inputs: { mesh: ['28', 0], image: ['1', 0], mask: ['3', 0],
|
||||
texture_size: o.textureSize, seed: o.seed, delight: o.delight } },
|
||||
'30': { class_type: 'ApplyTextureToMesh', inputs: { mesh: ['28', 0], base_color: ['29', 0] } },
|
||||
'37': { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_mv' } },
|
||||
};
|
||||
return saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]);
|
||||
}
|
||||
|
||||
/** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */
|
||||
export function kindOf(graph: unknown): JobKind {
|
||||
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type);
|
||||
if (types.includes('Img2GlbHunyuanPaint')) return 'текстура';
|
||||
if (types.includes('SaveGLB')) return '3D-модель';
|
||||
if (types.includes('RemoveBackground')) return 'очистка фона';
|
||||
if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация';
|
||||
|
||||
Reference in New Issue
Block a user