initial commit

This commit is contained in:
Aggtaa
2026-09-23 21:56:40 +03:00
commit 77da3ec94e
39 changed files with 9667 additions and 0 deletions
+54
View File
@@ -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 недоступен');
}
}
+11
View File
@@ -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
View File
@@ -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);
}
+69
View File
@@ -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
View File
@@ -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();
+80
View File
@@ -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}` : ''));
}
+118
View File
@@ -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);
}
+158
View File
@@ -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: '' } };
}