initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
// Клиент ComfyUI API.
|
||||
import { COMFY } from './config.ts';
|
||||
|
||||
export class ComfyError extends Error {}
|
||||
|
||||
export async function comfyJson<T = unknown>(pathname: string, body?: unknown): Promise<T> {
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(new URL(pathname, COMFY), body === undefined ? undefined : {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
throw new ComfyError('ComfyUI недоступен по адресу ' + COMFY.origin);
|
||||
}
|
||||
const text = await r.text();
|
||||
let json: { error?: { message?: string } | string } | undefined;
|
||||
try { json = JSON.parse(text); } catch { /* не JSON */ }
|
||||
if (!r.ok) {
|
||||
const e = json?.error;
|
||||
throw new ComfyError(typeof e === 'string' ? e : e?.message ?? JSON.stringify(json ?? text));
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
/** Ставит граф в очередь, возвращает prompt_id. */
|
||||
export async function queuePrompt(graph: object): Promise<string> {
|
||||
const r = await comfyJson<{ prompt_id: string }>('/prompt', { prompt: graph });
|
||||
return r.prompt_id;
|
||||
}
|
||||
|
||||
/** Загрузка картинки в ComfyUI/input/<subfolder>. При совпадении имени ComfyUI сам даёт новое имя. */
|
||||
export async function uploadImage(name: string, data: Blob, subfolder: string) {
|
||||
const form = new FormData();
|
||||
form.append('image', data, name);
|
||||
form.append('subfolder', subfolder);
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(new URL('/upload/image', COMFY), { method: 'POST', body: form });
|
||||
} catch {
|
||||
throw new ComfyError('ComfyUI недоступен');
|
||||
}
|
||||
if (!r.ok) throw new ComfyError('ComfyUI не принял файл: ' + await r.text());
|
||||
const j = await r.json() as { name: string; subfolder?: string; type?: string };
|
||||
return { filename: j.name, subfolder: j.subfolder ?? '', type: j.type ?? 'input' };
|
||||
}
|
||||
|
||||
/** GET /view — файл из папок ComfyUI как есть (Response для потоковой отдачи). */
|
||||
export async function view(params: URLSearchParams): Promise<Response> {
|
||||
try {
|
||||
return await fetch(new URL('/view?' + params, COMFY));
|
||||
} catch {
|
||||
throw new ComfyError('ComfyUI недоступен');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import path from 'node:path';
|
||||
|
||||
export const PORT = Number(process.env.PORT ?? 7000);
|
||||
export const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
export const COMFY = new URL(process.env.COMFY ?? 'http://127.0.0.1:8188');
|
||||
/** Папка output ComfyUI — для списка файлов на странице «Прогоны». */
|
||||
export const COMFY_OUTPUT = process.env.COMFY_OUTPUT ?? 'D:\\AI\\ComfyUI-0.37.0\\ComfyUI\\output';
|
||||
/** База SQLite с историей задач и значениями полей форм. */
|
||||
export const DB_PATH = process.env.DB_PATH ?? path.join(import.meta.dirname, '..', 'data', 'img2glb.sqlite');
|
||||
/** Собранный фронтенд (npm run build). */
|
||||
export const DIST = path.join(import.meta.dirname, '..', 'dist');
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
// SQLite (встроенный node:sqlite): задачи ComfyUI, батчи картинок и их источники, значения полей форм.
|
||||
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,
|
||||
} 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);
|
||||
db.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- задачи ComfyUI (все типы); отслеживаются трекером
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY, -- prompt_id ComfyUI
|
||||
type TEXT NOT NULL, -- text2img | cleanup | model
|
||||
created INTEGER NOT NULL, -- мс
|
||||
state TEXT NOT NULL, -- pending | done | error
|
||||
error TEXT,
|
||||
source TEXT, -- JSON FileRef исходника
|
||||
params TEXT NOT NULL, -- JSON параметров
|
||||
images TEXT NOT NULL DEFAULT '[]', -- JSON FileRef[]
|
||||
models TEXT NOT NULL DEFAULT '[]' -- JSON FileRef[]
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS jobs_type_created ON jobs (type, created DESC);
|
||||
CREATE INDEX IF NOT EXISTS jobs_state ON jobs (state);
|
||||
|
||||
-- значения полей форм страниц
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
page TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- батч картинок: результат одного запуска text2img или одной загрузки файлов
|
||||
CREATE TABLE IF NOT EXISTS ImageBatches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created INTEGER NOT NULL, -- мс
|
||||
updated INTEGER NOT NULL,
|
||||
sourceType TEXT NOT NULL, -- text | upload
|
||||
sourceId INTEGER NOT NULL, -- ImageBatchTextSources.id | ImageBatchUploadSources.id (по sourceType)
|
||||
state TEXT NOT NULL, -- draft | pending | done | error
|
||||
error TEXT,
|
||||
jobId TEXT, -- text: prompt_id последнего запуска в ComfyUI
|
||||
seedUsed INTEGER, -- text: фактический seed последнего запуска
|
||||
prompt TEXT, -- text: итоговый позитивный промпт последнего запуска
|
||||
clonedFrom INTEGER REFERENCES ImageBatches(id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ImageBatches_created ON ImageBatches (created DESC);
|
||||
|
||||
-- пользовательский ввод text2img — всё, что нужно, чтобы повторить запуск
|
||||
CREATE TABLE IF NOT EXISTS ImageBatchTextSources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
shape TEXT NOT NULL,
|
||||
colors TEXT NOT NULL,
|
||||
template TEXT NOT NULL, -- как ввёл пользователь; пусто — шаблон по умолчанию
|
||||
negative TEXT NOT NULL,
|
||||
batchSize INTEGER NOT NULL,
|
||||
seed TEXT NOT NULL -- как введено; пусто — случайный
|
||||
);
|
||||
|
||||
-- загрузка файлов пользователем (один или несколько файлов за раз)
|
||||
CREATE TABLE IF NOT EXISTS ImageBatchUploadSources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
files TEXT NOT NULL -- JSON: исходные имена файлов
|
||||
);
|
||||
|
||||
-- картинки батчей (сгенерированные и загруженные)
|
||||
CREATE TABLE IF NOT EXISTS Images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batchId INTEGER NOT NULL REFERENCES ImageBatches(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
subfolder TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- input | output
|
||||
UNIQUE (type, subfolder, filename)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS Images_batchId ON Images (batchId);
|
||||
|
||||
-- проект: работа над одной картинкой из батча (из одной картинки — только один проект)
|
||||
CREATE TABLE IF NOT EXISTS Projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created INTEGER NOT NULL, -- мс
|
||||
imageId INTEGER NOT NULL UNIQUE REFERENCES Images(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Столбцы, добавленные позже: в существующих базах — ALTER TABLE.
|
||||
function addColumns(table: string, columns: Record<string, string>) {
|
||||
const have = new Set((db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(c => c.name));
|
||||
for (const [name, def] of Object.entries(columns)) if (!have.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`);
|
||||
}
|
||||
// последняя очищенная картинка проекта (история очисток не хранится)
|
||||
addColumns('Projects', {
|
||||
clean: 'TEXT', // JSON FileRef последней очищенной картинки
|
||||
cleanUpdated: 'INTEGER', // мс
|
||||
cleanJobId: 'TEXT', // prompt_id идущей очистки
|
||||
cleanError: 'TEXT', // ошибка последней очистки
|
||||
});
|
||||
// последняя 3D-модель проекта из очищенной картинки (история не хранится)
|
||||
addColumns('Projects', {
|
||||
model: 'TEXT', // JSON FileRef GLB
|
||||
modelPreview: 'TEXT', // JSON FileRef PNG-превью
|
||||
modelUpdated: 'INTEGER', // мс
|
||||
modelJobId: 'TEXT', // prompt_id идущей генерации
|
||||
modelError: 'TEXT', // ошибка последней генерации
|
||||
modelFaces: 'INTEGER', // число треугольников в GLB (из файла)
|
||||
});
|
||||
// истории очисток и 3D-моделей не хранятся (результат — в проекте) — завершённые задачи удаляются
|
||||
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model') AND state <> 'pending'`);
|
||||
|
||||
/** Несколько записей одной транзакцией. */
|
||||
export function transaction<T>(fn: () => T): T {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const res = fn();
|
||||
db.exec('COMMIT');
|
||||
return res;
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- jobs ----------
|
||||
|
||||
interface JobRow {
|
||||
id: string; type: string; created: number; state: string; error: string | null;
|
||||
source: string | null; params: string; images: string; models: string;
|
||||
}
|
||||
|
||||
function toJob(row: JobRow): Job {
|
||||
return {
|
||||
id: row.id, type: row.type, created: row.created, state: row.state, ...(row.error ? { error: row.error } : {}),
|
||||
source: row.source ? JSON.parse(row.source) : null, params: JSON.parse(row.params),
|
||||
images: JSON.parse(row.images), models: JSON.parse(row.models),
|
||||
} as Job;
|
||||
}
|
||||
|
||||
const jq = {
|
||||
insert: db.prepare(`INSERT OR IGNORE INTO jobs (id, type, created, state, error, source, params, images, models)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
pending: db.prepare(`SELECT * FROM jobs WHERE state = 'pending'`),
|
||||
finish: db.prepare('UPDATE jobs SET state = ?, error = ?, images = ?, models = ? WHERE id = ?'),
|
||||
};
|
||||
|
||||
export function insertJob(j: Job) {
|
||||
jq.insert.run(j.id, j.type, j.created, j.state, j.error ?? null, j.source ? JSON.stringify(j.source) : null,
|
||||
JSON.stringify(j.params), JSON.stringify(j.images), JSON.stringify(j.models));
|
||||
}
|
||||
|
||||
export function pendingJobs(): Job[] {
|
||||
return (jq.pending.all() as unknown as JobRow[]).map(toJob);
|
||||
}
|
||||
|
||||
export function finishJob(id: string, state: Exclude<JobState, 'pending'>, result: { error?: string; images?: FileRef[]; models?: FileRef[] }) {
|
||||
jq.finish.run(state, result.error ?? null, JSON.stringify(result.images ?? []), JSON.stringify(result.models ?? []), id);
|
||||
}
|
||||
|
||||
|
||||
// ---------- settings, meta ----------
|
||||
|
||||
const sq = {
|
||||
get: db.prepare('SELECT value FROM settings WHERE page = ?'),
|
||||
put: db.prepare('INSERT INTO settings (page, value) VALUES (?, ?) ON CONFLICT(page) DO UPDATE SET value = excluded.value'),
|
||||
getMeta: db.prepare('SELECT value FROM meta WHERE key = ?'),
|
||||
setMeta: db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'),
|
||||
};
|
||||
|
||||
export function getSettings(page: SettingsPage): object {
|
||||
const row = sq.get.get(page) as { value: string } | undefined;
|
||||
return row ? JSON.parse(row.value) : {};
|
||||
}
|
||||
|
||||
export function putSettings(page: SettingsPage, value: object) {
|
||||
sq.put.run(page, JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function getMeta(key: string): string | null {
|
||||
return (sq.getMeta.get(key) as { value: string } | undefined)?.value ?? null;
|
||||
}
|
||||
|
||||
export function setMeta(key: string, value: string) {
|
||||
sq.setMeta.run(key, value);
|
||||
}
|
||||
|
||||
// ---------- ImageBatches + источники + Images ----------
|
||||
|
||||
interface BatchRow {
|
||||
id: number; created: number; updated: number; sourceType: string; sourceId: number; state: string; error: string | null;
|
||||
jobId: string | null; seedUsed: number | null; prompt: string | null; clonedFrom: number | null;
|
||||
// из ImageBatchTextSources
|
||||
shape: string | null; colors: string | null; template: string | null; negative: string | null; batchSize: number | null; seed: string | null;
|
||||
// из ImageBatchUploadSources
|
||||
files: string | null;
|
||||
}
|
||||
|
||||
interface ImageRow { id: number; batchId: number; filename: string; subfolder: string; type: string; projectId: number | null }
|
||||
|
||||
/** Картинки с id и проектом (если из картинки создан проект). */
|
||||
const SELECT_IMAGES = `
|
||||
SELECT i.id, i.batchId, i.filename, i.subfolder, i.type, p.id AS projectId
|
||||
FROM Images i LEFT JOIN Projects p ON p.imageId = i.id`;
|
||||
|
||||
const SELECT_BATCH = `
|
||||
SELECT b.*, t.shape, t.colors, t.template, t.negative, t.batchSize, t.seed, u.files
|
||||
FROM ImageBatches b
|
||||
LEFT JOIN ImageBatchTextSources t ON b.sourceType = 'text' AND t.id = b.sourceId
|
||||
LEFT JOIN ImageBatchUploadSources u ON b.sourceType = 'upload' AND u.id = b.sourceId`;
|
||||
|
||||
const bq = {
|
||||
get: db.prepare(SELECT_BATCH + ' WHERE b.id = ?'),
|
||||
list: db.prepare(SELECT_BATCH + ' ORDER BY b.created DESC, b.id DESC LIMIT ?'),
|
||||
byJob: db.prepare('SELECT id FROM ImageBatches WHERE jobId = ?'),
|
||||
insertBatch: db.prepare(`INSERT INTO ImageBatches (created, updated, sourceType, sourceId, state, seedUsed, prompt, clonedFrom)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
insertText: db.prepare(`INSERT INTO ImageBatchTextSources (shape, colors, template, negative, batchSize, seed)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`),
|
||||
updateText: db.prepare(`UPDATE ImageBatchTextSources SET shape = ?, colors = ?, template = ?, negative = ?, batchSize = ?, seed = ?
|
||||
WHERE id = ?`),
|
||||
insertUpload: db.prepare('INSERT INTO ImageBatchUploadSources (files) VALUES (?)'),
|
||||
touch: db.prepare('UPDATE ImageBatches SET updated = ? WHERE id = ?'),
|
||||
start: db.prepare(`UPDATE ImageBatches SET state = 'pending', error = NULL, jobId = ?, seedUsed = ?, prompt = ?, updated = ? WHERE id = ?`),
|
||||
finish: db.prepare('UPDATE ImageBatches SET state = ?, error = ?, updated = ? WHERE id = ?'),
|
||||
removeBatch: db.prepare('DELETE FROM ImageBatches WHERE id = ?'),
|
||||
removeText: db.prepare('DELETE FROM ImageBatchTextSources WHERE id = ?'),
|
||||
removeUpload: db.prepare('DELETE FROM ImageBatchUploadSources WHERE id = ?'),
|
||||
images: db.prepare(SELECT_IMAGES + ' WHERE i.batchId = ? ORDER BY i.id'),
|
||||
listImages: db.prepare(SELECT_IMAGES + `
|
||||
WHERE i.batchId IN (SELECT id FROM ImageBatches ORDER BY created DESC, id DESC LIMIT ?) ORDER BY i.id`),
|
||||
image: db.prepare(SELECT_IMAGES + ' WHERE i.id = ?'),
|
||||
attach: db.prepare('INSERT OR IGNORE INTO Images (batchId, filename, subfolder, type) VALUES (?, ?, ?, ?)'),
|
||||
imageBatch: db.prepare('SELECT batchId FROM Images WHERE type = ? AND subfolder = ? AND filename = ?'),
|
||||
};
|
||||
|
||||
const ref = (i: ImageRow): BatchImage => ({
|
||||
id: i.id, filename: i.filename, subfolder: i.subfolder, type: i.type, projectId: i.projectId,
|
||||
});
|
||||
|
||||
function toBatch(row: BatchRow, images: BatchImage[]): ImageBatch {
|
||||
const base = {
|
||||
id: row.id, created: row.created, updated: row.updated, sourceId: row.sourceId, state: row.state as BatchState,
|
||||
...(row.error ? { error: row.error } : {}), clonedFrom: row.clonedFrom, images,
|
||||
};
|
||||
if (row.sourceType === 'upload') {
|
||||
return { ...base, sourceType: 'upload', source: { id: row.sourceId, files: row.files ? JSON.parse(row.files) : [] } };
|
||||
}
|
||||
return {
|
||||
...base, sourceType: 'text', jobId: row.jobId, seedUsed: row.seedUsed, prompt: row.prompt ?? '',
|
||||
source: {
|
||||
id: row.sourceId,
|
||||
input: { shape: row.shape ?? '', colors: row.colors ?? '', template: row.template ?? '', negative: row.negative ?? '',
|
||||
batch: row.batchSize ?? 1, seed: row.seed ?? '' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getBatch(id: number): ImageBatch | null {
|
||||
const row = bq.get.get(id) as BatchRow | undefined;
|
||||
return row ? toBatch(row, (bq.images.all(id) as unknown as ImageRow[]).map(ref)) : null;
|
||||
}
|
||||
|
||||
export function listBatches(limit = 1000): ImageBatch[] {
|
||||
const byBatch = new Map<number, BatchImage[]>();
|
||||
for (const i of bq.listImages.all(limit) as unknown as ImageRow[]) byBatch.set(i.batchId, [...(byBatch.get(i.batchId) ?? []), ref(i)]);
|
||||
return (bq.list.all(limit) as unknown as BatchRow[]).map(row => toBatch(row, byBatch.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
export function batchByJob(jobId: string): number | null {
|
||||
return (bq.byJob.get(jobId) as { id: number } | undefined)?.id ?? null;
|
||||
}
|
||||
|
||||
/** В каком батче картинка (или null). */
|
||||
export function batchOfImage(i: FileRef): number | null {
|
||||
return (bq.imageBatch.get(i.type, i.subfolder, i.filename) as { batchId: number } | undefined)?.batchId ?? null;
|
||||
}
|
||||
|
||||
/** Новый text-батч с источником. Без transaction — вызывать внутри неё, если нужно вместе с другими записями. */
|
||||
export function insertTextBatch(input: Text2ImgInput, o: { created?: number; state?: BatchState; clonedFrom?: number | null;
|
||||
seedUsed?: number | null; prompt?: string | null } = {}): number {
|
||||
const src = bq.insertText.run(input.shape, input.colors, input.template, input.negative, input.batch, input.seed);
|
||||
const now = Date.now();
|
||||
const res = bq.insertBatch.run(o.created ?? now, now, 'text', Number(src.lastInsertRowid), o.state ?? 'draft',
|
||||
o.seedUsed ?? null, o.prompt ?? null, o.clonedFrom ?? null);
|
||||
return Number(res.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function createTextBatch(input: Text2ImgInput, clonedFrom: number | null = null): number {
|
||||
return transaction(() => insertTextBatch(input, { clonedFrom }));
|
||||
}
|
||||
|
||||
/** Новый upload-батч с загруженными картинками (одна загрузка — один батч). */
|
||||
export function createUploadBatch(originalNames: string[], images: FileRef[]): number {
|
||||
return transaction(() => {
|
||||
const src = bq.insertUpload.run(JSON.stringify(originalNames));
|
||||
const now = Date.now();
|
||||
const id = Number(bq.insertBatch.run(now, now, 'upload', Number(src.lastInsertRowid), 'done', null, null, null).lastInsertRowid);
|
||||
attachImages(id, images);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTextInput(batch: ImageBatch, input: Text2ImgInput) {
|
||||
transaction(() => {
|
||||
bq.updateText.run(input.shape, input.colors, input.template, input.negative, input.batch, input.seed, batch.sourceId);
|
||||
bq.touch.run(Date.now(), batch.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function startBatch(id: number, jobId: string, seedUsed: number, prompt: string) {
|
||||
bq.start.run(jobId, seedUsed, prompt, Date.now(), id);
|
||||
}
|
||||
|
||||
/** Прикрепить картинки к батчу (без своей транзакции). */
|
||||
export function attachImages(id: number, images: FileRef[]) {
|
||||
for (const i of images) bq.attach.run(id, i.filename, i.subfolder, i.type);
|
||||
}
|
||||
|
||||
/** Завершение запуска: картинки к батчу + состояние (без своей транзакции — трекер вызывает внутри своей). */
|
||||
export function finishBatch(id: number, state: 'done' | 'error', images: FileRef[], error?: string) {
|
||||
attachImages(id, images);
|
||||
bq.finish.run(state, error ?? null, Date.now(), id);
|
||||
}
|
||||
|
||||
export function deleteBatch(b: ImageBatch) {
|
||||
transaction(() => {
|
||||
bq.removeBatch.run(b.id);
|
||||
(b.sourceType === 'text' ? bq.removeText : bq.removeUpload).run(b.sourceId);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Projects ----------
|
||||
|
||||
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`;
|
||||
|
||||
const pq = {
|
||||
insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'),
|
||||
byImage: db.prepare('SELECT id FROM Projects WHERE imageId = ?'),
|
||||
get: db.prepare(SELECT_PROJECT + ' WHERE p.id = ?'),
|
||||
list: db.prepare(SELECT_PROJECT + ' ORDER BY p.created DESC, p.id DESC LIMIT ?'),
|
||||
byCleanJob: db.prepare('SELECT id FROM Projects WHERE cleanJobId = ?'),
|
||||
startClean: db.prepare('UPDATE Projects SET cleanJobId = ?, cleanError = NULL WHERE id = ?'),
|
||||
finishClean: db.prepare('UPDATE Projects SET clean = COALESCE(?, clean), cleanUpdated = ?, cleanError = ?, cleanJobId = NULL WHERE id = ?'),
|
||||
clearClean: db.prepare('UPDATE Projects SET clean = NULL, cleanUpdated = NULL, cleanError = NULL WHERE id = ?'),
|
||||
byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'),
|
||||
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 = ?'),
|
||||
};
|
||||
|
||||
/** Генерация 3D-модели проекта поставлена в очередь. */
|
||||
export function startProjectModel(id: number, jobId: string) {
|
||||
pq.startModel.run(jobId, id);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Убрать 3D-модель из проекта (файлы в ComfyUI/output остаются). */
|
||||
export function clearProjectModel(id: number) {
|
||||
pq.clearModel.run(id);
|
||||
}
|
||||
|
||||
/** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */
|
||||
export function clearProjectClean(id: number) {
|
||||
pq.clearClean.run(id);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function listProjects(limit = 1000): Project[] {
|
||||
return (pq.list.all(limit) as unknown as ProjectRow[]).map(toProject);
|
||||
}
|
||||
|
||||
/** Очистка исходника проекта поставлена в очередь. */
|
||||
export function startProjectClean(id: number, jobId: string) {
|
||||
pq.startClean.run(jobId, id);
|
||||
}
|
||||
|
||||
export function projectByCleanJob(jobId: string): number | null {
|
||||
return (pq.byCleanJob.get(jobId) as { id: number } | undefined)?.id ?? null;
|
||||
}
|
||||
|
||||
/** Очистка завершена: результат заменяет прежнюю очищенную картинку (при ошибке прежняя остаётся). */
|
||||
export function finishProjectClean(id: number, clean: FileRef | null, error?: string) {
|
||||
pq.finishClean.run(clean ? JSON.stringify(clean) : null, Date.now(), error ?? null, id);
|
||||
}
|
||||
|
||||
export function getImage(id: number): (BatchImage & { batchId: number }) | null {
|
||||
const row = bq.image.get(id) as ImageRow | undefined;
|
||||
return row ? { ...ref(row), batchId: row.batchId } : null;
|
||||
}
|
||||
|
||||
/** Проект из картинки: из одной картинки — только один проект; если уже есть, возвращается он. */
|
||||
export function projectForImage(imageId: number): number {
|
||||
pq.insert.run(Date.now(), imageId);
|
||||
return (pq.byImage.get(imageId) as { id: number }).id;
|
||||
}
|
||||
|
||||
export function getProject(id: number): Project | null {
|
||||
const row = pq.get.get(id) as ProjectRow | undefined;
|
||||
return row ? toProject(row) : null;
|
||||
}
|
||||
|
||||
/** Удаление задачи (история очисток не хранится). */
|
||||
export function deleteJob(id: string) {
|
||||
db.prepare('DELETE FROM jobs WHERE id = ?').run(id);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Сведения о GLB-файле из его JSON-чанка (без загрузки геометрии).
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { FileRef } from '../shared/api.ts';
|
||||
import { COMFY_OUTPUT } from './config.ts';
|
||||
|
||||
interface Gltf {
|
||||
meshes?: { primitives?: { indices?: number; mode?: number; attributes?: { POSITION?: number } }[] }[];
|
||||
accessors?: { count?: number }[];
|
||||
}
|
||||
|
||||
/** Путь к файлу в ComfyUI/output или null (не output или выход за папку). */
|
||||
export function outputPath(ref: FileRef): string | null {
|
||||
if (ref.type !== 'output') return null;
|
||||
const file = path.resolve(COMFY_OUTPUT, ref.subfolder, ref.filename);
|
||||
return file.startsWith(path.resolve(COMFY_OUTPUT) + path.sep) ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тот же GLB без нормалей — для просмотра «без сглаживания»: по спецификации glTF при отсутствии NORMAL
|
||||
* просмотрщик считает плоские нормали (three.js в model-viewer включает flatShading). Геометрия не меняется,
|
||||
* переписывается только JSON-чанк.
|
||||
*/
|
||||
export function glbWithoutNormals(glb: Buffer): Buffer {
|
||||
if (glb.toString('latin1', 0, 4) !== 'glTF' || glb.toString('latin1', 16, 20) !== 'JSON') throw new Error('не GLB');
|
||||
const jsonLen = glb.readUInt32LE(12);
|
||||
const gltf = JSON.parse(glb.toString('utf8', 20, 20 + jsonLen)) as Gltf;
|
||||
for (const mesh of gltf.meshes ?? []) for (const prim of mesh.primitives ?? []) delete (prim.attributes as Record<string, unknown>)?.NORMAL;
|
||||
let json = Buffer.from(JSON.stringify(gltf), 'utf8');
|
||||
if (json.length % 4) json = Buffer.concat([json, Buffer.alloc(4 - (json.length % 4), 0x20)]); // выравнивание пробелами
|
||||
const rest = glb.subarray(20 + jsonLen); // BIN-чанк как есть
|
||||
const head = Buffer.alloc(20);
|
||||
head.write('glTF', 0, 'latin1');
|
||||
head.writeUInt32LE(2, 4);
|
||||
head.writeUInt32LE(20 + json.length + rest.length, 8);
|
||||
head.writeUInt32LE(json.length, 12);
|
||||
head.write('JSON', 16, 'latin1');
|
||||
return Buffer.concat([head, json, rest]);
|
||||
}
|
||||
|
||||
/** Число треугольников в GLB из ComfyUI/output (или null, если файла нет или формат не тот). */
|
||||
export function glbTriangles(ref: FileRef): number | null {
|
||||
const file = outputPath(ref);
|
||||
if (!file) return null;
|
||||
let fd: number | undefined;
|
||||
try {
|
||||
fd = fs.openSync(file, 'r');
|
||||
// заголовок GLB (12 байт) + заголовок первого чанка (8 байт): длина и тип 'JSON'
|
||||
const head = Buffer.alloc(20);
|
||||
fs.readSync(fd, head, 0, 20, 0);
|
||||
if (head.toString('latin1', 0, 4) !== 'glTF' || head.toString('latin1', 16, 20) !== 'JSON') return null;
|
||||
const json = Buffer.alloc(head.readUInt32LE(12));
|
||||
fs.readSync(fd, json, 0, json.length, 20);
|
||||
const gltf = JSON.parse(json.toString('utf8')) as Gltf;
|
||||
let triangles = 0;
|
||||
for (const mesh of gltf.meshes ?? []) {
|
||||
for (const prim of mesh.primitives ?? []) {
|
||||
if ((prim.mode ?? 4) !== 4) continue; // только TRIANGLES
|
||||
const acc = prim.indices ?? prim.attributes?.POSITION;
|
||||
triangles += Math.floor((gltf.accessors?.[acc ?? -1]?.count ?? 0) / 3);
|
||||
}
|
||||
}
|
||||
return triangles;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
+411
@@ -0,0 +1,411 @@
|
||||
// Сервер img2glb: API поверх ComfyUI, история в SQLite, раздача собранного фронтенда (dist).
|
||||
// Запуск: node server/index.ts (PORT, HOST, COMFY, COMFY_OUTPUT, DB_PATH — переменные окружения)
|
||||
// При первом запуске старые прогоны text2img переносятся в ImageBatches из метаданных PNG (migrate.ts).
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
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,
|
||||
} 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,
|
||||
} 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';
|
||||
|
||||
class BadRequest extends Error {}
|
||||
class NotFound extends Error {}
|
||||
class Conflict extends Error {}
|
||||
|
||||
/** Подпапка ComfyUI/input для загруженных пользователем картинок. */
|
||||
const UPLOAD_DIR = 'img2glb_upload';
|
||||
|
||||
type Req = http.IncomingMessage;
|
||||
type Res = http.ServerResponse;
|
||||
|
||||
const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model'];
|
||||
|
||||
function send(res: Res, code: number, obj: unknown) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
async function readBuffer(req: Req): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) chunks.push(c as Buffer);
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async function readJson<T>(req: Req): Promise<Partial<T>> {
|
||||
try {
|
||||
return JSON.parse((await readBuffer(req)).toString() || '{}');
|
||||
} catch {
|
||||
throw new BadRequest('некорректный JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(v: unknown, min: number, max: number, def: number): number {
|
||||
const n = v === '' || v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? Math.min(Math.max(n, min), max) : def;
|
||||
}
|
||||
|
||||
/** Пустое значение — случайный seed. */
|
||||
function parseSeed(v: unknown): number {
|
||||
if (v === '' || v == null) return Math.floor(Math.random() * 2 ** 48);
|
||||
const seed = parseInt(String(v), 10);
|
||||
if (!Number.isFinite(seed) || seed < 0) throw new BadRequest('seed должен быть целым числом ≥ 0');
|
||||
return seed;
|
||||
}
|
||||
|
||||
function safeName(s: string): string {
|
||||
return s.replace(/[^\p{L}\p{N}._-]+/gu, '_');
|
||||
}
|
||||
|
||||
/** FileRef → имя для LoadImage с аннотацией папки: "img2glb_src/x.png [output]". Выход за папку проверяет ComfyUI. */
|
||||
function sourceImage(src: Partial<FileRef> | undefined) {
|
||||
const filename = String(src?.filename ?? '');
|
||||
if (!filename) throw new BadRequest('не указана картинка');
|
||||
if (!['input', 'output', 'temp'].includes(String(src?.type))) throw new BadRequest('неверный type картинки');
|
||||
const ref: FileRef = { filename, subfolder: String(src?.subfolder ?? ''), type: String(src?.type) };
|
||||
return {
|
||||
ref,
|
||||
image: (ref.subfolder ? ref.subfolder + '/' : '') + filename + ' [' + ref.type + ']',
|
||||
stem: safeName(path.basename(filename, path.extname(filename))),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- батчи картинок ----------
|
||||
|
||||
function batchIdOf(v: string): number {
|
||||
const id = Number(v);
|
||||
if (!Number.isInteger(id) || id <= 0) throw new BadRequest('неверный id батча');
|
||||
return id;
|
||||
}
|
||||
|
||||
function batchOrThrow(id: number): ImageBatch {
|
||||
const b = getBatch(id);
|
||||
if (!b) throw new NotFound('батч не найден');
|
||||
return b;
|
||||
}
|
||||
|
||||
function withLiveBatch(b: ImageBatch): ImageBatch {
|
||||
const live = b.sourceType === 'text' && b.state === 'pending' && b.jobId ? liveStatus(b.jobId) : undefined;
|
||||
return live ? { ...b, live } : b;
|
||||
}
|
||||
|
||||
const LOCKED = 'у батча уже есть картинки или он в работе — его можно только продублировать';
|
||||
|
||||
/** Ввод text2img из запроса: обрезка пробелов и точек в конце, проверка seed. */
|
||||
function parseInput(v: Partial<Text2ImgInput> | undefined): Text2ImgInput {
|
||||
const seed = String(v?.seed ?? '').trim();
|
||||
if (seed !== '' && !/^\d+$/.test(seed)) throw new BadRequest('seed должен быть целым числом ≥ 0 или пустым');
|
||||
return {
|
||||
shape: String(v?.shape ?? '').trim().replace(/\.+$/, ''),
|
||||
colors: String(v?.colors ?? '').trim().replace(/\.+$/, ''),
|
||||
template: String(v?.template ?? ''),
|
||||
negative: String(v?.negative ?? ''),
|
||||
batch: Math.round(clamp(v?.batch, 1, 8, 1)),
|
||||
seed,
|
||||
};
|
||||
}
|
||||
|
||||
/** Запуск text-батча: граф в очередь ComfyUI, задача в jobs, батч → pending. */
|
||||
async function runBatch(b: ImageBatch): Promise<ImageBatch> {
|
||||
if (b.sourceType !== 'text') throw new Conflict('загруженные картинки нельзя запустить');
|
||||
if (isLocked(b)) throw new Conflict(LOCKED);
|
||||
const { shape, colors, negative, batch } = b.source.input;
|
||||
const template = b.source.input.template.trim() || DEFAULT_TEMPLATE;
|
||||
if (template.includes('{a}') && !shape) throw new BadRequest('Заполните поле «Форма персонажа»');
|
||||
const seed = parseSeed(b.source.input.seed);
|
||||
const fString = escapeTemplate(template);
|
||||
const prompt = renderTemplate(fString, { a: shape, b: colors });
|
||||
const id = await queuePrompt(text2imgGraph({ template: fString, shape, colors, negative, batch, seed }));
|
||||
transaction(() => {
|
||||
insertJob({
|
||||
id, type: 'text2img', created: Date.now(), state: 'pending', source: null, images: [], models: [],
|
||||
params: { batchId: b.id, shape, colors, template, negative, batch, seed, prompt },
|
||||
} satisfies Text2ImgJob);
|
||||
startBatch(b.id, id, seed, prompt);
|
||||
});
|
||||
return batchOrThrow(b.id);
|
||||
}
|
||||
|
||||
/** Новый text-батч; run — сразу запустить. */
|
||||
async function createBatch(req: Req, res: Res) {
|
||||
const body = await readJson<{ input: Text2ImgInput; run: boolean }>(req);
|
||||
const b = batchOrThrow(createTextBatch(parseInput(body.input)));
|
||||
send(res, 200, withLiveBatch(body.run ? await runBatch(b) : b));
|
||||
}
|
||||
|
||||
async function editBatch(req: Req, res: Res, id: number) {
|
||||
const b = batchOrThrow(id);
|
||||
if (b.sourceType !== 'text') throw new Conflict('у загруженных картинок нет промпта');
|
||||
if (isLocked(b)) throw new Conflict(LOCKED);
|
||||
updateTextInput(b, parseInput((await readJson<{ input: Text2ImgInput }>(req)).input));
|
||||
send(res, 200, batchOrThrow(id));
|
||||
}
|
||||
|
||||
/** Клон text-батча: тот же ввод, без картинок, можно править и запускать. */
|
||||
function duplicateBatch(res: Res, id: number) {
|
||||
const b = batchOrThrow(id);
|
||||
if (b.sourceType !== 'text') throw new Conflict('загруженные картинки нельзя продублировать');
|
||||
send(res, 200, batchOrThrow(createTextBatch(b.source.input, b.id)));
|
||||
}
|
||||
|
||||
function removeBatch(res: Res, id: number) {
|
||||
const b = batchOrThrow(id);
|
||||
if (isLocked(b)) throw new Conflict('батч с картинками или в работе удалить нельзя');
|
||||
deleteBatch(b);
|
||||
send(res, 200, {});
|
||||
}
|
||||
|
||||
/** Загрузка файлов (multipart, поле files): сохраняются в ComfyUI/input/img2glb_upload, одна загрузка — один батч. */
|
||||
async function uploadBatch(req: Req, res: Res) {
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await new Request('http://local/', {
|
||||
method: 'POST', headers: { 'content-type': String(req.headers['content-type'] ?? '') },
|
||||
body: Readable.toWeb(req) as ReadableStream, duplex: 'half',
|
||||
} as RequestInit).formData();
|
||||
} catch {
|
||||
throw new BadRequest('ожидается multipart/form-data с полем files');
|
||||
}
|
||||
type Entry = ReturnType<FormData['get']>;
|
||||
const files = form.getAll('files').filter((f): f is Exclude<Entry, string | null> => typeof f !== 'string' && f.size > 0);
|
||||
if (!files.length) throw new BadRequest('нет файлов');
|
||||
const bad = files.find(f => !f.type.startsWith('image/'));
|
||||
if (bad) throw new BadRequest('не картинка: ' + bad.name);
|
||||
const images: FileRef[] = [];
|
||||
for (const f of files) images.push(await uploadImage(safeName(path.basename(f.name)) || 'upload.png', f, UPLOAD_DIR));
|
||||
send(res, 200, batchOrThrow(createUploadBatch(files.map(f => f.name), images)));
|
||||
}
|
||||
|
||||
// ---------- проекты ----------
|
||||
|
||||
/** Проект из картинки батча: создаёт новый или возвращает уже созданный из этой картинки. */
|
||||
async function createProject(req: Req, res: Res) {
|
||||
const imageId = Number((await readJson<{ imageId: number }>(req)).imageId);
|
||||
if (!Number.isInteger(imageId) || !getImage(imageId)) throw new NotFound('картинка не найдена');
|
||||
send(res, 200, withLiveProject(projectOrThrow(projectForImage(imageId))));
|
||||
}
|
||||
|
||||
function projectOrThrow(id: number): Project {
|
||||
const p = getProject(id);
|
||||
if (!p) throw new NotFound('проект не найден');
|
||||
return p;
|
||||
}
|
||||
|
||||
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 } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D-модель проекта из его очищенной картинки (Hunyuan3D-2 mini) + PNG-превью.
|
||||
* Новая модель заменяет прежнюю; история не хранится.
|
||||
*/
|
||||
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('модель уже генерируется');
|
||||
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 }));
|
||||
transaction(() => {
|
||||
// задача нужна трекеру только до завершения — потом удаляется
|
||||
insertJob({
|
||||
id, type: 'model', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [],
|
||||
params: { projectId, seed, steps, octree, faces },
|
||||
} 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('идёт генерация модели');
|
||||
clearProjectModel(id);
|
||||
send(res, 200, withLiveProject(projectOrThrow(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона.
|
||||
* Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится.
|
||||
*/
|
||||
async function projectCleanup(req: Req, res: Res, projectId: number) {
|
||||
const p = projectOrThrow(projectId);
|
||||
if (p.cleanJobId) throw new Conflict('очистка уже идёт');
|
||||
const b = await readJson<CleanupRequest>(req);
|
||||
const src = sourceImage(p.image);
|
||||
const size = Math.round(clamp(b.size, 64, 4096, 1024) / 8) * 8;
|
||||
// Объект вписывается по центру в квадрат (size - 2*margin). ImageCropToMask принимает pad_factor ≤ 2 → margin ≤ size/4.
|
||||
const margin = Math.round(clamp(b.margin, 0, size / 4, 154));
|
||||
const grow = Math.round(clamp(b.grow, -32, 32, 0));
|
||||
const background = /^#[0-9a-fA-F]{6}$/.test(String(b.background)) ? String(b.background).toUpperCase() : '#FFFFFF';
|
||||
const id = await queuePrompt(cleanupGraph({
|
||||
image: src.image, prefix: 'img2glb_clean/' + src.stem, size, pad: size / (size - 2 * margin), grow, background,
|
||||
}));
|
||||
transaction(() => {
|
||||
// задача нужна трекеру только до завершения — потом удаляется
|
||||
insertJob({
|
||||
id, type: 'cleanup', created: Date.now(), state: 'pending', source: src.ref, images: [], models: [],
|
||||
params: { projectId, size, margin, grow, background },
|
||||
} satisfies CleanupJob);
|
||||
startProjectClean(projectId, id);
|
||||
});
|
||||
send(res, 200, withLiveProject(projectOrThrow(projectId)));
|
||||
}
|
||||
|
||||
/** Убрать очищенную картинку из проекта — после этого можно почистить заново. */
|
||||
function removeProjectClean(res: Res, id: number) {
|
||||
const p = projectOrThrow(id);
|
||||
if (p.cleanJobId) throw new Conflict('идёт очистка');
|
||||
clearProjectClean(id);
|
||||
send(res, 200, projectOrThrow(id));
|
||||
}
|
||||
|
||||
// ---------- настройки ----------
|
||||
|
||||
function settingsPage(v: string): SettingsPage {
|
||||
if (!SETTINGS_PAGES.includes(v as SettingsPage)) throw new BadRequest('неизвестная страница');
|
||||
return v as SettingsPage;
|
||||
}
|
||||
|
||||
async function saveSettings(req: Req, res: Res, page: SettingsPage) {
|
||||
const value = await readJson<object>(req);
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new BadRequest('ожидается объект');
|
||||
putSettings(page, value);
|
||||
send(res, 200, value);
|
||||
}
|
||||
|
||||
/** Однократный перенос значений полей из localStorage старой версии; уже сохранённые не перезаписываются. */
|
||||
async function importOld(req: Req, res: Res) {
|
||||
const b = await readJson<ImportRequest>(req);
|
||||
transaction(() => {
|
||||
for (const page of SETTINGS_PAGES) {
|
||||
const s = b.settings?.[page];
|
||||
if (s && typeof s === 'object' && !Object.keys(getSettings(page)).length) putSettings(page, s);
|
||||
}
|
||||
});
|
||||
send(res, 200, {});
|
||||
}
|
||||
|
||||
// ---------- файлы и ComfyUI ----------
|
||||
|
||||
/** Очередь для индикатора. Элемент очереди ComfyUI: [номер, prompt_id, граф, extra_data, outputs]. */
|
||||
async function queue(res: Res) {
|
||||
const q = await comfyJson<{ queue_running?: unknown[][]; queue_pending?: unknown[][] }>('/queue');
|
||||
const map = (list?: unknown[][]) => (list ?? []).map(x => ({ id: String(x[1]), kind: kindOf(x[2]) }));
|
||||
send(res, 200, { running: map(q.queue_running), pending: map(q.queue_pending).reverse() } satisfies QueueInfo);
|
||||
}
|
||||
|
||||
/** Файл из ComfyUI (/view). preview=webp;70 — облегчённое превью для миниатюр. */
|
||||
async function image(res: Res, params: URLSearchParams) {
|
||||
const q = new URLSearchParams({
|
||||
filename: params.get('filename') ?? '', subfolder: params.get('subfolder') ?? '', type: params.get('type') ?? 'output',
|
||||
});
|
||||
const preview = params.get('preview') ?? '';
|
||||
if (/^(webp|jpeg);\d{1,3}$/.test(preview)) q.set('preview', preview);
|
||||
const r = await view(q);
|
||||
res.writeHead(r.status, { 'Content-Type': r.headers.get('content-type') ?? 'application/octet-stream', 'Cache-Control': 'max-age=86400' });
|
||||
if (r.body) Readable.fromWeb(r.body as WebStream).pipe(res);
|
||||
else res.end();
|
||||
}
|
||||
|
||||
/** GLB из ComfyUI/output без нормалей — просмотр «без сглаживания» (плоские грани). */
|
||||
function glbFlat(res: Res, params: URLSearchParams) {
|
||||
const file = outputPath({ filename: params.get('filename') ?? '', subfolder: params.get('subfolder') ?? '', type: 'output' });
|
||||
if (!file || !/\.glb$/i.test(file) || !fs.existsSync(file)) throw new NotFound('модель не найдена');
|
||||
let body: Buffer;
|
||||
try { body = glbWithoutNormals(fs.readFileSync(file)); } catch { throw new BadRequest('файл не GLB'); }
|
||||
res.writeHead(200, { 'Content-Type': 'model/gltf-binary', 'Cache-Control': 'max-age=86400' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// ---------- фронтенд ----------
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
|
||||
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json', '.wasm': 'application/wasm',
|
||||
};
|
||||
/** Страницы приложения (маршруты React Router) — отдаётся index.html. */
|
||||
const isPage = (p: string) => ['/', '/projects', '/text2img', '/cleanup', '/img2model'].includes(p)
|
||||
|| /^\/(batch|project)\/\d+$/.test(p);
|
||||
|
||||
function serveStatic(res: Res, pathname: string) {
|
||||
const file = isPage(pathname) ? path.join(DIST, 'index.html') : path.join(DIST, path.normalize(pathname));
|
||||
if (!file.startsWith(DIST + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
return send(res, 404, { error: isPage(pathname) ? 'фронтенд не собран: npm run build' : 'not found' });
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(file)] ?? 'application/octet-stream',
|
||||
// файлы в /assets/ с хешем в имени — кэшируются навсегда; страницы — всегда свежие
|
||||
'Cache-Control': pathname.startsWith('/assets/') ? 'max-age=31536000, immutable' : 'no-cache',
|
||||
});
|
||||
fs.createReadStream(file).pipe(res);
|
||||
}
|
||||
|
||||
// ---------- маршруты ----------
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const u = new URL(req.url ?? '/', 'http://x');
|
||||
const route = req.method + ' ' + u.pathname;
|
||||
const tail = (prefix: string) => decodeURIComponent(u.pathname.slice(prefix.length));
|
||||
try {
|
||||
if (route === 'GET /api/batches') return send(res, 200, listBatches().map(withLiveBatch));
|
||||
if (route === 'POST /api/batches') return await createBatch(req, res);
|
||||
if (route === 'POST /api/batches/upload') return await uploadBatch(req, res);
|
||||
const bm = /^\/api\/batches\/(\d+)(?:\/(input|run|duplicate))?$/.exec(u.pathname);
|
||||
if (bm) {
|
||||
const id = batchIdOf(bm[1]);
|
||||
if (req.method === 'GET' && !bm[2]) return send(res, 200, withLiveBatch(batchOrThrow(id)));
|
||||
if (req.method === 'DELETE' && !bm[2]) return removeBatch(res, id);
|
||||
if (req.method === 'PUT' && bm[2] === 'input') return await editBatch(req, res, id);
|
||||
if (req.method === 'POST' && bm[2] === 'run') return send(res, 200, withLiveBatch(await runBatch(batchOrThrow(id))));
|
||||
if (req.method === 'POST' && bm[2] === 'duplicate') return duplicateBatch(res, id);
|
||||
}
|
||||
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);
|
||||
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 (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);
|
||||
if (route === 'GET /api/queue') return await queue(res);
|
||||
if (route === 'GET /api/image') return await image(res, u.searchParams);
|
||||
if (route === 'GET /api/glb-flat') return glbFlat(res, u.searchParams);
|
||||
if (u.pathname.startsWith('/api/')) return send(res, 404, { error: 'not found' });
|
||||
if (req.method === 'GET') return serveStatic(res, u.pathname);
|
||||
send(res, 405, { error: 'method not allowed' });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const code = e instanceof BadRequest ? 400 : e instanceof NotFound ? 404 : e instanceof Conflict ? 409
|
||||
: e instanceof ComfyError ? 502 : 500;
|
||||
if (!res.headersSent) send(res, code, { error: message });
|
||||
else res.destroy();
|
||||
}
|
||||
}).listen(PORT, HOST, () => console.log(`http://${HOST}:${PORT} (ComfyUI: ${COMFY.origin}, база: ${DB_PATH})`));
|
||||
|
||||
migrateOldRuns();
|
||||
startTracker();
|
||||
@@ -0,0 +1,80 @@
|
||||
// Однократный перенос старых прогонов text2img в ImageBatches.
|
||||
// ComfyUI (SaveImage) записывает в каждый PNG граф запуска (текстовый чанк "prompt") — из него
|
||||
// восстанавливается пользовательский ввод; картинки одного батча — подряд идущие файлы с одинаковым графом.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { COMFY_OUTPUT } from './config.ts';
|
||||
import { attachImages, batchOfImage, getMeta, insertTextBatch, setMeta, transaction } from './db.ts';
|
||||
import { inputFromGraph } from './workflows.ts';
|
||||
|
||||
const KEY = 'migration.imageBatches.v1';
|
||||
const SRC_DIR = 'img2glb_src';
|
||||
/** Картинки одного батча сохраняются почти одновременно. */
|
||||
const SAME_BATCH_MS = 15_000;
|
||||
|
||||
/** Текстовые чанки PNG (tEXt, iTXt, zTXt). */
|
||||
function pngTexts(file: string): Record<string, string> {
|
||||
const b = fs.readFileSync(file);
|
||||
const out: Record<string, string> = {};
|
||||
let o = 8;
|
||||
while (o + 8 <= b.length) {
|
||||
const len = b.readUInt32BE(o);
|
||||
const type = b.toString('latin1', o + 4, o + 8);
|
||||
const d = b.subarray(o + 8, o + 8 + len);
|
||||
const nul = d.indexOf(0);
|
||||
if (type === 'tEXt') {
|
||||
out[d.toString('latin1', 0, nul)] = d.toString('latin1', nul + 1);
|
||||
} else if (type === 'zTXt') {
|
||||
out[d.toString('latin1', 0, nul)] = zlib.inflateSync(d.subarray(nul + 2)).toString('latin1');
|
||||
} else if (type === 'iTXt') {
|
||||
const compressed = d[nul + 1] === 1;
|
||||
let p = d.indexOf(0, nul + 3) + 1; // после языка
|
||||
p = d.indexOf(0, p) + 1; // после переведённого ключа
|
||||
const text = d.subarray(p);
|
||||
out[d.toString('latin1', 0, nul)] = (compressed ? zlib.inflateSync(text) : text).toString('utf8');
|
||||
} else if (type === 'IEND') {
|
||||
break;
|
||||
}
|
||||
o += 12 + len;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function migrateOldRuns() {
|
||||
if (getMeta(KEY)) return;
|
||||
const dir = path.join(COMFY_OUTPUT, SRC_DIR);
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(dir).filter(n => /\.png$/i.test(n)); } catch { /* папки нет — переносить нечего */ }
|
||||
|
||||
const files = names
|
||||
.map(name => ({ name, mtime: fs.statSync(path.join(dir, name)).mtimeMs }))
|
||||
.filter(f => batchOfImage({ filename: f.name, subfolder: SRC_DIR, type: 'output' }) == null)
|
||||
.sort((a, b) => a.mtime - b.mtime || a.name.localeCompare(b.name));
|
||||
|
||||
// группировка: одинаковый граф, подряд, в пределах SAME_BATCH_MS от первой картинки
|
||||
const groups: { graph: string; first: number; names: string[] }[] = [];
|
||||
let skipped = 0;
|
||||
for (const f of files) {
|
||||
let graph: string | undefined;
|
||||
try { graph = pngTexts(path.join(dir, f.name)).prompt; } catch { /* битый PNG */ }
|
||||
if (!graph) { skipped++; continue; }
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.graph === graph && f.mtime - last.first <= SAME_BATCH_MS) last.names.push(f.name);
|
||||
else groups.push({ graph, first: f.mtime, names: [f.name] });
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
transaction(() => {
|
||||
for (const g of groups) {
|
||||
let parsed;
|
||||
try { parsed = inputFromGraph(JSON.parse(g.graph)); } catch { parsed = null; }
|
||||
if (!parsed) { skipped += g.names.length; continue; }
|
||||
const id = insertTextBatch(parsed.input, { created: Math.round(g.first), state: 'done', seedUsed: parsed.seed, prompt: parsed.prompt });
|
||||
attachImages(id, g.names.map(filename => ({ filename, subfolder: SRC_DIR, type: 'output' })));
|
||||
created++;
|
||||
}
|
||||
setMeta(KEY, new Date().toISOString());
|
||||
});
|
||||
console.log(`Перенос старых прогонов: батчей ${created}, картинок ${files.length - skipped}` + (skipped ? `, пропущено ${skipped}` : ''));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Фоновое отслеживание незавершённых задач: результаты из ComfyUI записываются в базу,
|
||||
// даже если ни одна страница не открыта.
|
||||
import type { FileRef, Job, LiveStatus } from '../shared/api.ts';
|
||||
import { comfyJson } from './comfy.ts';
|
||||
import {
|
||||
batchByJob, deleteJob, finishBatch, finishJob, finishProjectClean, finishProjectModel, pendingJobs, projectByCleanJob,
|
||||
projectByModelJob, transaction,
|
||||
} from './db.ts';
|
||||
|
||||
/** Сколько ждать задачу, которой нет ни в очереди, ни в истории ComfyUI (ComfyUI перезапускался). */
|
||||
const LOST_AFTER_MS = 30_000;
|
||||
|
||||
const live = new Map<string, LiveStatus>();
|
||||
const missingSince = new Map<string, number>();
|
||||
|
||||
/** Положение незавершённой задачи в очереди ComfyUI — для показа на страницах. */
|
||||
export function liveStatus(id: string): LiveStatus | undefined {
|
||||
return live.get(id);
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
status?: { status_str?: string; completed?: boolean; messages?: [string, { exception_message?: string }][] };
|
||||
outputs?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Файлы из outputs истории: картинки (SaveImage) и GLB (SaveGLB кладёт их под другим ключом) — по расширению. */
|
||||
function outputFiles(h: HistoryEntry) {
|
||||
const images: FileRef[] = [];
|
||||
const models: FileRef[] = [];
|
||||
for (const node of Object.values(h.outputs ?? {})) {
|
||||
for (const list of Object.values(node)) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
for (const i of list as FileRef[]) {
|
||||
if (typeof i?.filename !== 'string') continue;
|
||||
if (/\.glb$/i.test(i.filename)) models.push(i);
|
||||
else if (/\.(png|webp|jpe?g)$/i.test(i.filename)) images.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { images, models };
|
||||
}
|
||||
|
||||
/**
|
||||
* Завершение задачи. Картинки text2img прикрепляются к её батчу (Images.batchId);
|
||||
* результат очистки и модель проекта заменяют прежние в проекте, а сама задача удаляется — история не хранится.
|
||||
*/
|
||||
function finish(job: Job, state: 'done' | 'error', result: { error?: string; images?: FileRef[]; models?: FileRef[] }) {
|
||||
transaction(() => {
|
||||
if (job.type === 'cleanup') {
|
||||
const projectId = job.params.projectId ?? projectByCleanJob(job.id);
|
||||
if (projectId != null) finishProjectClean(projectId, state === 'done' ? result.images?.[0] ?? null : null, result.error);
|
||||
deleteJob(job.id);
|
||||
return;
|
||||
}
|
||||
// модель проекта: GLB и превью — в проект, задача удаляется (история не хранится)
|
||||
const modelProject = job.type === 'model' ? job.params.projectId ?? projectByModelJob(job.id) : null;
|
||||
if (modelProject != null) {
|
||||
const done = state === 'done';
|
||||
finishProjectModel(modelProject, done ? result.models?.[0] ?? null : null, done ? result.images?.[0] ?? null : null, result.error);
|
||||
deleteJob(job.id);
|
||||
return;
|
||||
}
|
||||
finishJob(job.id, state, result);
|
||||
const batchId = job.type === 'text2img' ? job.params.batchId ?? batchByJob(job.id) : null;
|
||||
if (batchId != null) finishBatch(batchId, state, result.images ?? [], result.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
const jobs = pendingJobs();
|
||||
if (!jobs.length) {
|
||||
live.clear();
|
||||
missingSince.clear();
|
||||
return;
|
||||
}
|
||||
// ComfyUI недоступен — задачи остаются незавершёнными до его возвращения
|
||||
const q = await comfyJson<{ queue_running?: unknown[][]; queue_pending?: unknown[][] }>('/queue');
|
||||
const running = new Set((q.queue_running ?? []).map(x => String(x[1])));
|
||||
const queued = (q.queue_pending ?? []).map(x => String(x[1]));
|
||||
|
||||
for (const job of jobs) {
|
||||
if (running.has(job.id)) {
|
||||
live.set(job.id, { state: 'running' });
|
||||
missingSince.delete(job.id);
|
||||
continue;
|
||||
}
|
||||
const pos = queued.indexOf(job.id);
|
||||
if (pos >= 0) {
|
||||
live.set(job.id, { state: 'queued', position: pos + 1 });
|
||||
missingSince.delete(job.id);
|
||||
continue;
|
||||
}
|
||||
const h = (await comfyJson<Record<string, HistoryEntry>>('/history/' + encodeURIComponent(job.id)))[job.id];
|
||||
if (h?.status?.status_str === 'error') {
|
||||
const err = (h.status.messages ?? []).find(m => m[0] === 'execution_error' || m[0] === 'execution_interrupted');
|
||||
finish(job, 'error', { error: err ? err[1].exception_message ?? 'прервано' : 'ошибка выполнения' });
|
||||
} else if (h) {
|
||||
finish(job, 'done', outputFiles(h));
|
||||
} else {
|
||||
// между постановкой в очередь и появлением в истории задачу может не быть видно — ждём
|
||||
const since = missingSince.get(job.id) ?? Date.now();
|
||||
missingSince.set(job.id, since);
|
||||
if (Date.now() - since < LOST_AFTER_MS) continue;
|
||||
finish(job, 'error', { error: 'задача не найдена в ComfyUI (перезапускался?)' });
|
||||
}
|
||||
live.delete(job.id);
|
||||
missingSince.delete(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
export function startTracker(intervalMs = 1500) {
|
||||
setInterval(async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { await tick(); } catch { /* ComfyUI недоступен — попробуем на следующем шаге */ } finally { busy = false; }
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Графы ComfyUI в API-формате. Только встроенные ноды ComfyUI 0.37.
|
||||
import type { JobKind, Text2ImgInput } from '../shared/api.ts';
|
||||
|
||||
type Graph = Record<string, { class_type: string; inputs: Record<string, unknown> }>;
|
||||
|
||||
/** SDXL: промпт по шаблону (StringFormat, {a} — форма, {b} — цвета) → картинки в output/img2glb_src. */
|
||||
export function text2imgGraph(o: {
|
||||
template: string; shape: string; colors: string; negative: string; batch: number; seed: number;
|
||||
}): Graph {
|
||||
return {
|
||||
'1': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'sd_xl_base_1.0.safetensors' } },
|
||||
'2': { class_type: 'PrimitiveStringMultiline', inputs: { value: o.shape } },
|
||||
'3': { class_type: 'PrimitiveStringMultiline', inputs: { value: o.colors } },
|
||||
'4': { class_type: 'StringFormat', inputs: { 'values.a': ['2', 0], 'values.b': ['3', 0], f_string: o.template } },
|
||||
'5': { class_type: 'PrimitiveStringMultiline', inputs: { value: o.negative } },
|
||||
'6': { class_type: 'CLIPTextEncode', inputs: { text: ['4', 0], clip: ['1', 1] } },
|
||||
'7': { class_type: 'CLIPTextEncode', inputs: { text: ['5', 0], clip: ['1', 1] } },
|
||||
'8': { class_type: 'EmptyLatentImage', inputs: { width: 1024, height: 1024, batch_size: o.batch } },
|
||||
'9': { class_type: 'KSampler', inputs: {
|
||||
model: ['1', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['8', 0],
|
||||
seed: o.seed, steps: 25, cfg: 7.0, sampler_name: 'dpmpp_2m', scheduler: 'karras', denoise: 1.0 } },
|
||||
'10': { class_type: 'VAEDecode', inputs: { samples: ['9', 0], vae: ['1', 2] } },
|
||||
'11': { class_type: 'SaveImage', inputs: { images: ['10', 0], filename_prefix: 'img2glb_src/character' } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Подготовка для Hunyuan3D: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона. */
|
||||
export function cleanupGraph(o: {
|
||||
image: string; prefix: string; size: number; pad: number; grow: number; background: string;
|
||||
}): Graph {
|
||||
return {
|
||||
'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: o.size, height: o.size, pad_factor: o.pad, grow_mask: o.grow, background: o.background } },
|
||||
'5': { class_type: 'SaveImage', inputs: { images: ['4', 0], filename_prefix: o.prefix } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Очищенная картинка → GLB через Hunyuan3D-2 mini + PNG-превью меша в ракурсе 3/4. */
|
||||
export function modelGraph(o: {
|
||||
image: string; prefix: string; seed: number; steps: number; octree: number; faces: number;
|
||||
}): Graph {
|
||||
return {
|
||||
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
|
||||
'2': { class_type: 'ImageOnlyCheckpointLoader', inputs: { ckpt_name: 'hunyuan3d-dit-v2-mini_fp16.safetensors' } },
|
||||
'3': { class_type: 'CLIPVisionEncode', inputs: { clip_vision: ['2', 1], image: ['1', 0], crop: 'none' } },
|
||||
'4': { class_type: 'Hunyuan3Dv2Conditioning', inputs: { clip_vision_output: ['3', 0] } },
|
||||
'5': { class_type: 'ModelSamplingAuraFlow', inputs: { model: ['2', 0], shift: 1.0 } },
|
||||
'6': { class_type: 'EmptyLatentHunyuan3Dv2', inputs: { resolution: 512, batch_size: 1 } },
|
||||
'7': { class_type: 'KSampler', inputs: {
|
||||
model: ['5', 0], positive: ['4', 0], negative: ['4', 1], latent_image: ['6', 0],
|
||||
seed: o.seed, steps: o.steps, cfg: 5.0, sampler_name: 'euler', scheduler: 'normal', denoise: 1.0 } },
|
||||
'8': { class_type: 'VAEDecodeHunyuan3D', inputs: { vae: ['2', 2], samples: ['7', 0], num_chunks: 8000, octree_resolution: o.octree } },
|
||||
'9': { class_type: 'VoxelToMesh', inputs: { voxel: ['8', 0], algorithm: 'surface net', threshold: 0.6 } },
|
||||
'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' } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */
|
||||
export function kindOf(graph: unknown): JobKind {
|
||||
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type);
|
||||
if (types.includes('SaveGLB')) return '3D-модель';
|
||||
if (types.includes('RemoveBackground')) return 'очистка фона';
|
||||
if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация';
|
||||
return 'другое';
|
||||
}
|
||||
|
||||
/** То же, что Python str.format для {a}/{b}: {{ и }} превращаются в одиночные скобки. */
|
||||
export function renderTemplate(template: string, values: { a: string; b: string }): string {
|
||||
return template.replace(/\{\{|\}\}|\{([ab])\}/g, (m, k: 'a' | 'b' | undefined) => (k ? values[k] : m[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Шаблон пользователя → f_string для StringFormat (Python str.format): все скобки, кроме {a} и {b}, удваиваются.
|
||||
* Значения {a}/{b} format не разбирает — их не экранируем.
|
||||
*/
|
||||
export function escapeTemplate(template: string): string {
|
||||
return template.split(/(\{[ab]\})/).map(p => /^\{[ab]\}$/.test(p) ? p : p.replace(/\{/g, '{{').replace(/\}/g, '}}')).join('');
|
||||
}
|
||||
|
||||
/** Обратно к шаблону пользователя: {{ → {, }} → }. */
|
||||
export function unescapeTemplate(fString: string): string {
|
||||
return fString.replace(/\{\{/g, '{').replace(/\}\}/g, '}');
|
||||
}
|
||||
|
||||
// ---------- разбор графа text2img (из метаданных PNG) обратно в пользовательский ввод ----------
|
||||
|
||||
type Link = [string, number];
|
||||
const isLink = (v: unknown): v is Link => Array.isArray(v) && typeof v[0] === 'string';
|
||||
|
||||
/** Значение строкового входа: строка или ссылка на строковую ноду (Primitive, StringConcatenate, StringFormat). */
|
||||
function resolveString(graph: Graph, v: unknown, depth = 0): string | null {
|
||||
if (typeof v === 'string') return v;
|
||||
if (!isLink(v) || depth > 20) return null;
|
||||
const node = graph[v[0]];
|
||||
if (!node) return null;
|
||||
const i = node.inputs;
|
||||
switch (node.class_type) {
|
||||
case 'PrimitiveStringMultiline':
|
||||
case 'PrimitiveString':
|
||||
return resolveString(graph, i.value, depth + 1);
|
||||
case 'StringConcatenate': {
|
||||
const a = resolveString(graph, i.string_a, depth + 1);
|
||||
const b = resolveString(graph, i.string_b, depth + 1);
|
||||
return a == null || b == null ? null : a + String(i.delimiter ?? '') + b;
|
||||
}
|
||||
case 'StringFormat': {
|
||||
const f = resolveString(graph, i.f_string, depth + 1);
|
||||
if (f == null) return null;
|
||||
return renderTemplate(f, { a: resolveString(graph, i['values.a'], depth + 1) ?? '', b: resolveString(graph, i['values.b'], depth + 1) ?? '' });
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Пользовательский ввод из графа text2img. Если промпт собран по шаблону (StringFormat) — форма, цвета и шаблон
|
||||
* восстанавливаются как были; иначе итоговый текст промпта становится шаблоном без плейсхолдеров.
|
||||
*/
|
||||
export function inputFromGraph(graphJson: unknown): { input: Text2ImgInput; seed: number; prompt: string } | null {
|
||||
const graph = graphJson as Graph;
|
||||
if (!graph || typeof graph !== 'object') return null;
|
||||
const ks = Object.values(graph).find(n => n?.class_type === 'KSampler');
|
||||
if (!ks || !isLink(ks.inputs.positive) || !isLink(ks.inputs.negative)) return null;
|
||||
const pos = graph[ks.inputs.positive[0]];
|
||||
const neg = graph[ks.inputs.negative[0]];
|
||||
if (pos?.class_type !== 'CLIPTextEncode') return null;
|
||||
const prompt = resolveString(graph, pos.inputs.text);
|
||||
if (prompt == null) return null;
|
||||
const latent = isLink(ks.inputs.latent_image) ? graph[ks.inputs.latent_image[0]] : undefined;
|
||||
const seed = Number(ks.inputs.seed);
|
||||
const base = {
|
||||
negative: (neg?.class_type === 'CLIPTextEncode' ? resolveString(graph, neg.inputs.text) : null) ?? '',
|
||||
batch: Number(latent?.inputs.batch_size ?? 1),
|
||||
seed: String(seed),
|
||||
};
|
||||
const textLink = pos.inputs.text;
|
||||
const fmt = isLink(textLink) ? graph[textLink[0]] : undefined;
|
||||
if (fmt?.class_type === 'StringFormat') {
|
||||
return {
|
||||
seed, prompt, input: {
|
||||
...base,
|
||||
template: unescapeTemplate(resolveString(graph, fmt.inputs.f_string) ?? ''),
|
||||
shape: resolveString(graph, fmt.inputs['values.a']) ?? '',
|
||||
colors: resolveString(graph, fmt.inputs['values.b']) ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return { seed, prompt, input: { ...base, template: prompt, shape: '', colors: '' } };
|
||||
}
|
||||
Reference in New Issue
Block a user