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
+2
View File
@@ -0,0 +1,2 @@
node_modules/
data/
File diff suppressed because one or more lines are too long
+13
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>img2glb</title>
<script type="module" crossorigin src="/assets/index-C0OstMfa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BLVwiv9z.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>img2glb</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2018
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "img2glb-app",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/dev.ts",
"build": "tsc -b && vite build",
"typecheck": "tsc -b",
"start": "node --disable-warning=ExperimentalWarning server/index.ts"
},
"dependencies": {
"@google/model-viewer": "^4.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.9.0"
},
"devDependencies": {
"@types/node": "^22.18.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.9.0",
"vite": "^7.1.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
// Разработка одной командой: API-сервер (с перезапуском при изменениях) + Vite, который проксирует на него /api.
// API в режиме разработки слушает свой порт (по умолчанию 7010), чтобы не мешать рабочему серверу на 7000.
import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
const root = path.join(import.meta.dirname, '..');
const env = { ...process.env, API_PORT: process.env.API_PORT ?? '7010' };
const children: ChildProcess[] = [
spawn(process.execPath, ['--disable-warning=ExperimentalWarning', '--watch', 'server/index.ts'],
{ cwd: root, stdio: 'inherit', env: { ...env, PORT: env.API_PORT, HOST: '127.0.0.1' } }),
spawn(process.execPath, [path.join(root, 'node_modules', 'vite', 'bin', 'vite.js')],
{ cwd: root, stdio: 'inherit', env }),
];
function stop(code = 0) {
children.forEach(c => { if (c.exitCode === null) c.kill(); });
process.exit(code);
}
children.forEach(c => c.on('exit', code => stop(code ?? 0)));
process.on('SIGINT', () => stop());
process.on('SIGTERM', () => stop());
+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: '' } };
}
+240
View File
@@ -0,0 +1,240 @@
// Типы API между страницами и сервером.
/** Файл в папках ComfyUI (как в ответах ComfyUI и параметрах /view). */
export interface FileRef {
filename: string;
subfolder: string;
type: string; // 'input' | 'output' | 'temp'
}
export interface ApiError {
error: string;
}
// ---------- батчи картинок (ImageBatches + источники + Images) ----------
/** Пользовательский ввод text2img (ImageBatchTextSources) — всё, что нужно, чтобы повторить запуск. */
export interface Text2ImgInput {
shape: string;
colors: string;
/** Шаблон как его ввёл пользователь: {a} — форма, {b} — цвета. Пусто — шаблон по умолчанию. */
template: string;
negative: string;
/** Картинок в батче. */
batch: number;
/** Как введено: пусто — случайный при каждом запуске. */
seed: string;
}
/**
* draft — можно править и запускать; pending — в работе; done / error — завершён.
* Батч с картинками, в работе или из загрузки заблокирован: text-батч можно только продублировать.
*/
export type BatchState = 'draft' | 'pending' | 'done' | 'error';
/** Картинка батча (Images): id и проект, если из неё создан проект. */
export interface BatchImage extends FileRef {
id: number;
projectId: number | null;
}
/** Проект — работа над одной картинкой из батча. */
export interface Project {
id: number;
created: number;
/** батч, из которого картинка */
batchId: number;
/** исходная картинка */
image: BatchImage;
/** последняя очищенная картинка (история очисток не хранится) */
clean: FileRef | null;
cleanUpdated: number | null;
/** идёт очистка: prompt_id в ComfyUI */
cleanJobId: string | null;
/** ошибка последней очистки */
cleanError?: string;
/** положение идущей очистки в очереди ComfyUI */
live?: LiveStatus;
/** последняя 3D-модель из очищенной картинки: GLB и PNG-превью (история не хранится) */
model: FileRef | null;
modelPreview: FileRef | null;
modelUpdated: number | null;
/** число треугольников в GLB (null — неизвестно) */
modelFaces: number | null;
/** идёт генерация модели: prompt_id в ComfyUI */
modelJobId: string | null;
modelError?: string;
/** положение идущей генерации модели в очереди ComfyUI */
modelLive?: LiveStatus;
}
/** Генерация 3D-модели проекта. */
export interface ProjectModelRequest {
/** Пусто — случайный. */
seed: number | string;
steps: number | string;
octree: number | string;
faces: number | string;
}
interface BatchBase {
id: number;
created: number;
updated: number;
/** ImageBatchTextSources.id или ImageBatchUploadSources.id — по sourceType */
sourceId: number;
state: BatchState;
error?: string;
/** Из какого батча продублирован. */
clonedFrom: number | null;
/** Картинки батча (Images.batchId). */
images: BatchImage[];
live?: LiveStatus;
}
export interface TextBatch extends BatchBase {
sourceType: 'text';
source: { id: number; input: Text2ImgInput };
/** prompt_id последнего запуска в ComfyUI. */
jobId: string | null;
/** Фактический seed последнего запуска. */
seedUsed: number | null;
/** Итоговый позитивный промпт последнего запуска. */
prompt: string;
}
export interface UploadBatch extends BatchBase {
sourceType: 'upload';
/** Исходные имена загруженных файлов. */
source: { id: number; files: string[] };
}
export type ImageBatch = TextBatch | UploadBatch;
/** В батче есть картинка, из которой создан проект. */
export function hasProject(b: ImageBatch): boolean {
return b.images.some(i => i.projectId != null);
}
export function isLocked(b: ImageBatch): boolean {
return b.sourceType === 'upload' || b.state === 'pending' || b.images.length > 0;
}
// ---------- запросы на запуск ----------
/** Очистка исходника проекта. */
export interface CleanupRequest {
size: number | string;
/** Отступ от объекта до края кадра, px с каждой стороны. */
margin: number | string;
grow: number | string;
background: string;
}
// ---------- задачи ComfyUI (таблица jobs; отслеживаются трекером) ----------
export type JobType = 'text2img' | 'cleanup' | 'model';
export type JobState = 'pending' | 'done' | 'error';
/** Состояние незавершённой задачи в очереди ComfyUI (не хранится, считается сервером на лету). */
export type LiveStatus = { state: 'queued'; position: number } | { state: 'running' };
export interface Text2ImgParams {
/** батч (ImageBatches.id), к которому прикрепляются картинки */
batchId: number;
shape: string;
colors: string;
template: string;
negative: string;
batch: number;
seed: number;
/** Итоговый позитивный промпт, как его увидит модель. */
prompt: string;
}
export interface CleanupParams {
/** проект, к которому прикрепляется результат */
projectId: number;
size: number;
margin: number;
grow: number;
background: string;
}
export interface ModelParams {
/** проект, к которому прикрепляется модель */
projectId: number;
seed: number;
steps: number;
octree: number;
faces: number;
}
interface JobOf<T extends JobType, P> {
/** prompt_id в ComfyUI */
id: string;
type: T;
/** мс с эпохи */
created: number;
state: JobState;
error?: string;
/** картинка-исходник (для cleanup и model) */
source: FileRef | null;
params: P;
/** результаты-картинки (для model — PNG-превью) */
images: FileRef[];
/** GLB-файлы (только model) */
models: FileRef[];
live?: LiveStatus;
}
export type Text2ImgJob = JobOf<'text2img', Text2ImgParams>;
export type CleanupJob = JobOf<'cleanup', CleanupParams>;
export type ModelJob = JobOf<'model', ModelParams>;
export type Job = Text2ImgJob | CleanupJob | ModelJob;
// ---------- значения полей форм (тоже в SQLite) ----------
export interface Text2ImgSettings {
shape: string;
colors: string;
template: string;
negative: string;
batch: string;
seed: string;
promptsOpen: boolean;
}
export interface CleanupSettings {
size: string;
margin: string;
grow: string;
background: string;
}
export interface ModelSettings {
seed: string;
steps: string;
octree: string;
faces: string;
}
export type SettingsByPage = { text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings };
export type SettingsPage = keyof SettingsByPage;
// ---------- очередь ComfyUI ----------
export type JobKind = 'генерация' | 'очистка фона' | '3D-модель' | 'другое';
export interface QueueItem {
id: string;
kind: JobKind;
}
export interface QueueInfo {
running: QueueItem[];
pending: QueueItem[];
}
// ---------- перенос из localStorage старой версии ----------
export interface ImportRequest {
settings: Partial<{ [P in SettingsPage]: Partial<SettingsByPage[P]> }>;
}
export const DEFAULT_TEMPLATE =
'Game character. Full body visible. Empty arms. {a}, centered on white background. {b}. Game sprite aesthetic.';
+44
View File
@@ -0,0 +1,44 @@
import { Navigate, Route, Routes, useLocation, useSearchParams } from 'react-router';
import { Nav } from './components/Nav.tsx';
import { Batch } from './pages/Batch.tsx';
import { Project } from './pages/Project.tsx';
import { Projects } from './pages/Projects.tsx';
import { Sources } from './pages/Sources.tsx';
function title(pathname: string) {
if (pathname.startsWith('/batch/')) return 'Батч #' + pathname.slice(7);
if (pathname.startsWith('/project/')) return 'Проект #' + pathname.slice(9);
return ({ '/': 'Исходники', '/projects': 'Проекты' } as Record<string, string>)[pathname] ?? 'img2glb';
}
/** Старые ссылки /text2img?id=N → страница батча, /text2img → исходники. */
function LegacyText2Img() {
const id = useSearchParams()[0].get('id');
return <Navigate to={id ? '/batch/' + id : '/'} replace />;
}
/** Старые ссылки /cleanup?project=N → страница проекта (очистка теперь на ней). */
function LegacyCleanup() {
const id = useSearchParams()[0].get('project');
return <Navigate to={id ? '/project/' + id : '/projects'} replace />;
}
export function App() {
const { pathname } = useLocation();
document.title = title(pathname);
return (
<main className={pathname === '/' || pathname === '/projects' ? 'wide' : ''}>
<Nav />
<Routes>
<Route path="/" element={<Sources />} />
<Route path="/batch/:id" element={<Batch />} />
<Route path="/projects" element={<Projects />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/cleanup" element={<LegacyCleanup />} />
<Route path="/img2model" element={<Navigate to="/projects" replace />} />
<Route path="/text2img" element={<LegacyText2Img />} />
<Route path="*" element={<p className="empty">Страница не найдена.</p>} />
</Routes>
</main>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Link } from 'react-router';
import type { ImageBatch } from '../../shared/api.ts';
import { hasProject } from '../../shared/api.ts';
import { thumbUrl } from '../lib/api.ts';
import { previewPrompt } from './Text2ImgForm.tsx';
/** Текст состояния батча: «черновик», «в очереди: позиция 2 · 5 с», «генерация… 12 с», «ошибка: …». */
export function batchStateText(b: ImageBatch): { text: string; error: boolean } | null {
if (b.state === 'draft') return { text: 'черновик', error: false };
if (b.state === 'error') return { text: 'ошибка: ' + (b.error ?? ''), error: true };
if (b.state !== 'pending') return null;
const sec = Math.max(0, Math.round((Date.now() - b.updated) / 1000));
if (b.live?.state === 'queued') return { text: `в очереди: позиция ${b.live.position} · ${sec} с`, error: false };
if (b.live?.state === 'running') return { text: `генерация… ${sec} с`, error: false };
return { text: `ожидание… ${sec} с`, error: false };
}
/** «1 картинка», «3 картинки», «6 картинок». */
function pictures(n: number): string {
const d = n % 10, dd = n % 100;
const word = d === 1 && dd !== 11 ? 'картинка' : d >= 2 && d <= 4 && (dd < 12 || dd > 14) ? 'картинки' : 'картинок';
return `${n} ${word}`;
}
/**
* Карточка батча (вся — ссылка на его страницу): картинки батча, под ними строки «форма» и «цвета» (text)
* или «Uploaded» (upload). У старых прогонов без формы и цветов (промпт без шаблона) — промпт целиком.
* Картинки, из которых созданы проекты, и батч с хотя бы одной такой картинкой — с обводкой.
* Показываются только прикреплённые картинки; у запущенного батча недостающие до запрошенного количества —
* пустые блоки с «…»; у черновика — один блок «черновик, N картинок».
*/
export function BatchCard({ batch: b }: { batch: ImageBatch }) {
const lines = b.sourceType === 'upload' ? ['Uploaded']
: [b.source.input.shape, b.source.input.colors].filter(Boolean);
if (b.sourceType === 'text' && !lines.length) lines.push(b.prompt || previewPrompt(b.source.input));
const draft = b.sourceType === 'text' && b.state === 'draft';
// запущенный text-батч: недостающие до запрошенного количества картинки — пустые блоки с «…»
const missing = b.sourceType === 'text' && !draft ? Math.max(0, b.source.input.batch - b.images.length) : 0;
// подсказка: дата и (для text) промпт и негатив целиком
const tooltip = [
new Date(b.created).toLocaleString(),
...(b.sourceType === 'text' ? [
b.prompt || previewPrompt(b.source.input),
...(b.source.input.negative.trim() ? ['Негатив: ' + b.source.input.negative] : []),
] : []),
].join('\n');
return (
<Link to={'/batch/' + b.id} className={'run run-link' + (hasProject(b) ? ' in-project' : '')} title={tooltip}>
<div className="strip">
{b.images.map(i => (
<div key={i.id} className={'th' + (i.projectId != null ? ' in-project' : '')}>
<img src={thumbUrl(i)} alt={i.filename} loading="lazy" />
</div>
))}
{Array.from({ length: missing }, (_, n) => <div key={n} className="th">…</div>)}
{draft && <div className="th draft">черновик<br />{pictures(b.source.input.batch)}</div>}
</div>
{lines.map((line, n) => <div key={n} className="prompt">{line}</div>)}
</Link>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { FileRef } from '../../shared/api.ts';
import { fileUrl } from '../lib/api.ts';
// <model-viewer> (https://modelviewer.dev) — из node_modules, в сборку попадает отдельным чанком, интернет не нужен.
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'model-viewer': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
src?: string; alt?: string; loading?: string; 'camera-controls'?: string; 'camera-orbit'?: string;
'shadow-intensity'?: string; 'environment-image'?: string; exposure?: string;
};
}
}
}
let loading: Promise<unknown> | null = null;
function loadModelViewer() {
loading ??= import('@google/model-viewer');
return loading;
}
export type View = 'img' | '3d';
/** Переключатель из нескольких кнопок. */
function Seg<T extends string>({ options, value, onChange }: {
options: readonly (readonly [T, string])[]; value: T; onChange: (v: T) => void;
}) {
return (
<span className="seg" onClick={e => e.stopPropagation()}>
{options.map(([v, text]) => (
<button key={v} type="button" className={v === value ? 'on' : ''} onClick={() => onChange(v)}>{text}</button>
))}
</span>
);
}
/** Переключатель «картинка / 3D». */
export function ViewToggle({ value, onChange }: { value: View; onChange: (v: View) => void }) {
return <Seg options={[['img', 'картинка'], ['3d', '3D']] as const} value={value} onChange={onChange} />;
}
/**
* Готовая модель: интерактивный GLB или PNG-превью.
* Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще
* (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer.
*/
export function ModelView({ glb, preview, view, onOpen }: {
glb?: FileRef | null; preview?: FileRef | null; view: View; onOpen: (src: string, caption: string) => void;
}) {
const [ready, setReady] = useState(false); // скрипт model-viewer загружен
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB
const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани)
const ref = useRef<HTMLElement>(null);
const src = !glb ? '' : smooth ? fileUrl(glb)
: '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder });
useEffect(() => {
if (view !== '3d' || !src) return;
let alive = true;
loadModelViewer().then(() => { if (alive) setReady(true); }, () => { if (alive) setState('error'); });
return () => { alive = false; };
}, [view, src]);
useEffect(() => { setState('loading'); }, [src]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onLoad = () => setState('loaded');
const onError = () => setState('error');
el.addEventListener('load', onLoad);
el.addEventListener('error', onError);
return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); };
}, [ready, src, view]);
const previewImg = preview && (
<img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} />
);
if (view === '3d' && glb) {
return (
<div className="mv-wrap">
{ready && (
<model-viewer ref={ref} src={src} alt={glb.filename} loading="eager" camera-controls="" camera-orbit="35deg 80deg auto"
shadow-intensity="1" environment-image="neutral" exposure="0.8"
style={{ visibility: state === 'loaded' ? 'visible' : 'hidden' }} />
)}
{state !== 'loaded' && (previewImg || <span className="mv-note">загрузка 3D…</span>)}
{state === 'error' && <span className="mv-note err">3D не загрузилась</span>}
<span className="mv-seg">
<Seg options={[['smooth', 'сглаживание'], ['flat', 'без']]} value={smooth ? 'smooth' : 'flat'}
onChange={v => setSmooth(v === 'smooth')} />
</span>
</div>
);
}
return previewImg || <span>превью нет</span>;
}
/** Ссылки «GLB» и «PNG» для скачивания; before — ещё кнопки слева от них. */
export function ModelLinks({ glb, preview, before }: { glb?: FileRef | null; preview?: FileRef | null; before?: ReactNode }) {
const items = [[glb, 'GLB', 'main'], [preview, 'PNG', '']] as const;
return (
<div className="links">
{before}
{items.map(([i, text, cls]) => i && (
<a key={text} href={fileUrl(i)} download={i.filename} className={cls} title={'скачать ' + i.filename}
onClick={e => e.stopPropagation()}>{text}</a>
))}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { NavLink } from 'react-router';
import type { QueueInfo, QueueItem } from '../../shared/api.ts';
import { api } from '../lib/api.ts';
const LINKS = [
['/', 'Исходники'],
['/projects', 'Проекты'],
] as const;
export function Nav() {
return (
<nav className="nav">
{LINKS.map(([to, text]) => <NavLink key={to} to={to} end>{text}</NavLink>)}
<QueueIndicator />
</nav>
);
}
function counts(list: QueueItem[]): string {
const c = new Map<string, number>();
list.forEach(x => c.set(x.kind, (c.get(x.kind) ?? 0) + 1));
return [...c].map(([k, n]) => k + (n > 1 ? ' ×' + n : '')).join(', ');
}
/** Состояние очереди ComfyUI: свободен / чем занят и сколько ждёт / недоступен. Обновляется каждые 2 с. */
function QueueIndicator() {
const [q, setQ] = useState<QueueInfo | null>(null);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
const update = async () => {
if (document.hidden) return;
try {
const info = await api<QueueInfo>('/api/queue');
if (alive) { setQ(info); setError(''); }
} catch (e) {
if (alive) setError((e as Error).message);
}
};
void update();
const t = setInterval(update, 2000);
document.addEventListener('visibilitychange', update);
return () => { alive = false; clearInterval(t); document.removeEventListener('visibilitychange', update); };
}, []);
if (error) return <span className="queue down" title={error}><span className="dot" />ComfyUI недоступен</span>;
if (!q) return <span className="queue"><span className="dot" />ComfyUI…</span>;
if (!q.running.length && !q.pending.length) return <span className="queue idle"><span className="dot" />ComfyUI свободен</span>;
return (
<span className="queue busy" title={q.pending.length ? 'Ждут: ' + counts(q.pending) : ''}>
<span className="dot" />ComfyUI: <b>{counts(q.running) || '—'}</b>
{q.pending.length > 0 && <> · в очереди <b>{q.pending.length}</b></>}
</span>
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { CSSProperties, FormEvent, ReactNode } from 'react';
import type { Text2ImgInput, Text2ImgSettings } from '../../shared/api.ts';
import { DEFAULT_TEMPLATE } from '../../shared/api.ts';
export type Values = Text2ImgSettings;
export type SetValue = <K extends keyof Values>(k: K, v: Values[K]) => void;
/** Значения полей для новой генерации, если сохранённых ещё нет. */
export const DEFAULT_VALUES: Values = {
shape: 'A heavily armored fantasy knight',
colors: 'Deep navy plated armor with gold trim, red cape, glowing red eyes through the visor',
template: '',
negative: '(sprite sheet, character sheet, multiple views, front and back view, turnaround:1.5), (multiple characters, duplicate:1.4), '
+ '(cropped, feet out of frame:1.3), head out of frame, (weapon, sword, holding weapon:1.4), shield, busy background, scenery, '
+ 'cast shadow, text, watermark, signature, blurry, deformed, extra limbs',
batch: '4',
seed: '0',
promptsOpen: false,
};
export const toInput = (v: Values): Text2ImgInput => ({
shape: v.shape, colors: v.colors, template: v.template, negative: v.negative, batch: Number(v.batch), seed: v.seed,
});
export const toValues = (i: Text2ImgInput, promptsOpen: boolean): Values => ({ ...i, batch: String(i.batch), promptsOpen });
/** Итоговый промпт, как его соберёт сервер (форма и цвета — без точек в конце). */
export function previewPrompt(v: Pick<Values, 'shape' | 'colors' | 'template'>) {
const clean = (s: string) => s.trim().replace(/\.+$/, '');
const values: Record<string, string> = { '{a}': clean(v.shape), '{b}': clean(v.colors) };
return (v.template.trim() || DEFAULT_TEMPLATE).split(/(\{[ab]\})/).map(p => values[p] ?? p).join('');
}
/**
* Поля text2img. readOnly — поля недоступны (батч уже запущен), кнопка действия при этом работает.
* action — кнопка отправки формы («Сгенерировать» или «Дублировать»), extra — дополнительные кнопки рядом.
*/
export function Text2ImgForm({ values: v, set, readOnly, action, actionDisabled, onAction, extra }: {
values: Values; set: SetValue; readOnly: boolean;
action: string; actionDisabled: boolean; onAction: () => void; extra?: ReactNode;
}) {
return (
<form onSubmit={(e: FormEvent) => { e.preventDefault(); onAction(); }}>
<fieldset disabled={readOnly}>
<label>Форма персонажа {'{a}'}
<textarea rows={2} value={v.shape} onChange={e => set('shape', e.target.value)} />
</label>
<label><span>Цвета и декали {'{b}'} <small>— без точки в конце</small></span>
<textarea rows={2} value={v.colors} onChange={e => set('colors', e.target.value)} />
</label>
</fieldset>
<details open={v.promptsOpen} onToggle={e => set('promptsOpen', (e.target as HTMLDetailsElement).open)}>
<summary>Промпты</summary>
<fieldset disabled={readOnly}>
<label className="sub-field">
<span>Шаблон позитивного промпта <small>— {'{a}'} и {'{b}'} подставляются из полей выше; пусто = шаблон по умолчанию</small></span>
<textarea rows={3} placeholder={DEFAULT_TEMPLATE} value={v.template} onChange={e => set('template', e.target.value)} />
</label>
<label className="sub-field">Негативный промпт
<textarea rows={4} value={v.negative} onChange={e => set('negative', e.target.value)} />
</label>
</fieldset>
</details>
<div className="row" style={{ '--cols': 2 } as CSSProperties}>
<label>Картинок в батче
<select disabled={readOnly} value={v.batch} onChange={e => set('batch', e.target.value)}>
{['1', '2', '3', '4', '6', '8'].map(n => <option key={n}>{n}</option>)}
</select>
</label>
<label><span>Seed <small>— пусто = случайный</small></span>
<input disabled={readOnly} type="number" min={0} step={1} placeholder="случайный" value={v.seed}
onChange={e => set('seed', e.target.value)} />
</label>
<div className="actions">
{extra}
<button type="submit" disabled={actionDisabled}>{action}</button>
</div>
</div>
<div className="preview">Промпт: {previewPrompt(v)}</div>
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useCallback, useEffect, useState } from 'react';
/** Полноэкранный просмотр картинки: open(src, подпись), закрытие кликом или Esc. */
export function useViewer() {
const [shown, setShown] = useState<{ src: string; caption: string } | null>(null);
const open = useCallback((src: string, caption: string) => setShown({ src, caption }), []);
useEffect(() => {
if (!shown) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setShown(null); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [shown]);
const element = shown && (
<div className="viewer" onClick={() => setShown(null)}>
<img src={shown.src} alt="" />
<div className="cap">{shown.caption}</div>
</div>
);
return { open, element };
}
+35
View File
@@ -0,0 +1,35 @@
import type { ApiError, FileRef } from '../../shared/api.ts';
/** Запрос к API с JSON. Ошибки сервера — исключением с его текстом. */
export async function api<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
const method = init?.method ?? (init?.body === undefined ? 'GET' : 'POST');
const r = await fetch(path, {
method,
...(init?.body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(init.body) }),
});
return parse<T>(r);
}
/** Ответ API → данные. Не-JSON (прокси Vite без запущенного API, обрыв) — «сервер API недоступен». */
export async function parse<T>(r: Response): Promise<T> {
const j = await r.json().catch(() => null) as T | ApiError | null;
if (j === null) throw new Error(`сервер API недоступен (HTTP ${r.status})`);
if (!r.ok) throw new Error((j as ApiError).error ?? `HTTP ${r.status}`);
return j as T;
}
function refParams(i: FileRef) {
return new URLSearchParams({ filename: i.filename, subfolder: i.subfolder, type: i.type });
}
/** Файл как есть (для показа и скачивания). */
export function fileUrl(i: FileRef): string {
return '/api/image?' + refParams(i);
}
/** Облегчённое webp-превью для миниатюр. */
export function thumbUrl(i: FileRef): string {
const p = refParams(i);
p.set('preview', 'webp;70');
return '/api/image?' + p;
}
+132
View File
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import type {
CleanupRequest, ImageBatch, LiveStatus, Project, ProjectModelRequest, Text2ImgInput, TextBatch, UploadBatch,
} from '../../shared/api.ts';
import { api, parse } from './api.ts';
const POLL_MS = 1500;
export const batchApi = {
list: () => api<ImageBatch[]>('/api/batches'),
get: (id: number) => api<ImageBatch>('/api/batches/' + id),
/** Новый text-батч; run — сразу запустить. */
create: (input: Text2ImgInput, run: boolean) => api<TextBatch>('/api/batches', { body: { input, run } }),
updateInput: (id: number, input: Text2ImgInput) => api<TextBatch>(`/api/batches/${id}/input`, { method: 'PUT', body: { input } }),
run: (id: number) => api<TextBatch>(`/api/batches/${id}/run`, { method: 'POST', body: {} }),
duplicate: (id: number) => api<TextBatch>(`/api/batches/${id}/duplicate`, { method: 'POST', body: {} }),
remove: (id: number) => api(`/api/batches/${id}`, { method: 'DELETE' }),
/** Один или несколько файлов за раз — один новый батч. */
upload: async (files: File[]): Promise<UploadBatch> => {
const form = new FormData();
files.forEach(f => form.append('files', f, f.name));
return parse<UploadBatch>(await fetch('/api/batches/upload', { method: 'POST', body: form }));
},
};
export const projectApi = {
list: () => api<Project[]>('/api/projects'),
/** Очистка исходника проекта; результат заменит прежнюю очищенную картинку. */
cleanup: (id: number, params: CleanupRequest) => api<Project>(`/api/projects/${id}/cleanup`, { body: params }),
removeClean: (id: number) => api<Project>(`/api/projects/${id}/clean`, { method: 'DELETE' }),
/** 3D-модель из очищенной картинки; новая заменит прежнюю. */
model: (id: number, params: ProjectModelRequest) => api<Project>(`/api/projects/${id}/model`, { body: params }),
removeModel: (id: number) => api<Project>(`/api/projects/${id}/model`, { method: 'DELETE' }),
/** Проект из картинки: новый или уже созданный из неё. */
forImage: (imageId: number) => api<Project>('/api/projects', { body: { imageId } }),
get: (id: number) => api<Project>('/api/projects/' + id),
};
/** Все батчи с сервера; пока есть незавершённые — перечитываются каждые 1.5 с. */
export function useBatches() {
const [batches, setBatches] = useState<ImageBatch[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState('');
const refresh = useCallback(async () => {
try {
setBatches(await batchApi.list());
setError('');
} catch (e) {
setError((e as Error).message);
} finally {
setLoaded(true);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const pending = batches.some(b => b.state === 'pending');
useEffect(() => {
if (!pending) return;
const t = setInterval(() => { void refresh(); }, POLL_MS);
return () => clearInterval(t);
}, [pending, refresh]);
return { batches, loaded, error, refresh };
}
/** Один проект; пока идёт очистка или генерация модели — перечитывается каждые 1.5 с. */
export function useProject(id: number | null) {
const [project, setProject] = useState<Project | null>(null);
const [error, setError] = useState('');
const refresh = useCallback(async () => {
if (id == null) return;
try {
setProject(await projectApi.get(id));
setError('');
} catch (e) {
setError((e as Error).message);
}
}, [id]);
useEffect(() => { setProject(null); void refresh(); }, [refresh]);
const working = !!project?.cleanJobId || !!project?.modelJobId;
useEffect(() => {
if (!working) return;
const t = setInterval(() => { void refresh(); }, POLL_MS);
return () => clearInterval(t);
}, [working, refresh]);
return { project, setProject, error };
}
/** Состояние задачи проекта: «в очереди: позиция 1», «обработка…», «ошибка: …» или null. */
function stepStateText(jobId: string | null, live: LiveStatus | undefined, error: string | undefined, verb: string) {
if (jobId) {
if (live?.state === 'queued') return { text: `в очереди: позиция ${live.position}`, error: false };
return { text: live?.state === 'running' ? verb + '…' : 'ожидание…', error: false };
}
return error ? { text: 'ошибка: ' + error, error: true } : null;
}
export const cleanStateText = (p: Project) => stepStateText(p.cleanJobId, p.live, p.cleanError, 'обработка');
export const modelStateText = (p: Project) => stepStateText(p.modelJobId, p.modelLive, p.modelError, 'генерация');
/** Один батч; пока он в работе — перечитывается каждые 1.5 с. */
export function useBatch(id: number | null) {
const [batch, setBatch] = useState<ImageBatch | null>(null);
const [error, setError] = useState('');
const refresh = useCallback(async () => {
if (id == null) return;
try {
setBatch(await batchApi.get(id));
setError('');
} catch (e) {
setError((e as Error).message);
}
}, [id]);
useEffect(() => { setBatch(null); void refresh(); }, [refresh]);
const pending = batch?.state === 'pending';
useEffect(() => {
if (!pending) return;
const t = setInterval(() => { void refresh(); }, POLL_MS);
return () => clearInterval(t);
}, [pending, refresh]);
return { batch, setBatch, error, refresh };
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useRef, useState } from 'react';
import type { SettingsByPage, SettingsPage } from '../../shared/api.ts';
import { api } from './api.ts';
/**
* Значения полей формы страницы, хранятся на сервере (SQLite).
* До загрузки — значения по умолчанию; изменения сохраняются с задержкой 400 мс.
*/
export function useSettings<P extends SettingsPage>(page: P, defaults: SettingsByPage[P]) {
const [values, setValues] = useState(defaults);
const [loaded, setLoaded] = useState(false);
const dirty = useRef(false);
useEffect(() => {
let alive = true;
api<Partial<SettingsByPage[P]>>('/api/settings/' + page)
.then(saved => { if (alive && !dirty.current) setValues(v => ({ ...v, ...saved })); })
.catch(() => { /* сервер недоступен — работаем со значениями по умолчанию */ })
.finally(() => { if (alive) setLoaded(true); });
return () => { alive = false; };
}, [page]);
useEffect(() => {
if (!dirty.current) return;
const t = setTimeout(() => { api('/api/settings/' + page, { method: 'PUT', body: values }).catch(() => {}); }, 400);
return () => clearTimeout(t);
}, [page, values]);
const set = <K extends keyof SettingsByPage[P]>(key: K, value: SettingsByPage[P][K]) => {
dirty.current = true;
setValues(v => ({ ...v, [key]: value }));
};
return { values, set, loaded };
}
+17
View File
@@ -0,0 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App.tsx';
import { migrateLocalStorage } from './migrate.ts';
import './styles.css';
// история и поля старой версии (localStorage) — в базу сервера, до первого рендера
await migrateLocalStorage();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+38
View File
@@ -0,0 +1,38 @@
// Однократный перенос значений полей из localStorage старой версии страниц (web/) в базу сервера.
// Работает, только если новое приложение открыто по тому же адресу, что и старое (например, 127.0.0.1:7000).
// Истории старой версии не переносятся: прогоны генерации сервер восстанавливает из метаданных PNG,
// истории очисток и 3D-моделей больше нигде не показываются.
import type { ImportRequest } from '../shared/api.ts';
import { api } from './lib/api.ts';
const KEYS = ['charGenHistory', 'cleanupHistory', 'modelHistory', 'charGenFields', 'cleanupFields.v2', 'cleanupFields', 'img2modelFields'];
function read<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
export async function migrateLocalStorage() {
let present: string[];
try { present = KEYS.filter(k => localStorage.getItem(k) != null); } catch { return; }
if (!present.length) return;
const settings: ImportRequest['settings'] = {};
const t = read<Record<string, unknown>>('charGenFields');
if (t) settings.text2img = t;
const c = read<Record<string, unknown>>('cleanupFields.v2');
if (c) settings.cleanup = c;
const m = read<Record<string, unknown>>('img2modelFields');
if (m) settings.model = m;
try {
await api('/api/import', { body: { settings } satisfies ImportRequest });
present.forEach(k => { try { localStorage.removeItem(k); } catch { /* ничего */ } });
} catch {
// сервер недоступен — попробуем при следующем открытии
}
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import type { ImageBatch, TextBatch } from '../../shared/api.ts';
import { isLocked } from '../../shared/api.ts';
import { batchStateText } from '../components/BatchCard.tsx';
import { Text2ImgForm, toInput, toValues, type SetValue, type Values } from '../components/Text2ImgForm.tsx';
import { useViewer } from '../components/Viewer.tsx';
import { api, fileUrl } from '../lib/api.ts';
import { batchApi, projectApi, useBatch } from '../lib/batches.ts';
/**
* Картинки батча: клик — на весь экран; «в проект» — создать проект из картинки (или перейти в уже созданный);
* пока идёт генерация — заглушки.
*/
function Images({ batch }: { batch: ImageBatch }) {
const navigate = useNavigate();
const viewer = useViewer();
const [error, setError] = useState('');
const pending = batch.sourceType === 'text' && batch.state === 'pending'
? Math.max(0, batch.source.input.batch - batch.images.length) : 0;
if (!batch.images.length && !pending) return null;
async function toProject(imageId: number) {
setError('');
try {
navigate('/project/' + (await projectApi.forImage(imageId)).id);
} catch (e) {
setError((e as Error).message);
}
}
return (
<>
{error && <p className="err">{error}</p>}
<div className="grid batch-images">
{batch.images.map(i => (
<div key={i.id} className="cell">
<img src={fileUrl(i)} alt={i.filename} onClick={() => viewer.open(fileUrl(i), i.filename)} />
<div className="links">
<a href={'/project/' + (i.projectId ?? '')} className={i.projectId ? 'main' : ''}
title={i.projectId ? `Проект #${i.projectId}` : 'Создать проект из этой картинки'}
onClick={e => { e.preventDefault(); void toProject(i.id); }}>в проект</a>
<a href={fileUrl(i)} download={i.filename}>скачать</a>
</div>
</div>
))}
{Array.from({ length: pending }, (_, n) => <div key={n} className="cell pending">генерируется…</div>)}
</div>
{viewer.element}
</>
);
}
/**
* Поля text-батча. Черновик — правки сохраняются в батч (и запоминаются как значения для новой генерации),
* «Сгенерировать» запускает этот батч. Запущенный — поля только для чтения, кнопка «Дублировать».
*/
function TextBatchForm({ batch, onBatch }: { batch: TextBatch; onBatch: (b: ImageBatch) => void }) {
const navigate = useNavigate();
const [values, setValues] = useState<Values>(() => toValues(batch.source.input, false));
const [status, setStatus] = useState({ text: '', error: false });
const [busy, setBusy] = useState(false);
const dirty = useRef(false);
const locked = isLocked(batch);
// правки черновика — в батч и в значения для новой генерации, с задержкой
useEffect(() => {
if (!dirty.current || locked) return;
const t = setTimeout(() => {
const input = toInput(values);
Promise.all([
batchApi.updateInput(batch.id, input),
api('/api/settings/text2img', { method: 'PUT', body: values }),
]).then(() => { dirty.current = false; })
.catch(e => setStatus({ text: 'Не сохранено: ' + (e as Error).message, error: true }));
}, 500);
return () => clearTimeout(t);
}, [batch.id, values, locked]);
const set: SetValue = (k, v) => {
if (k !== 'promptsOpen') dirty.current = true;
setValues(prev => ({ ...prev, [k]: v }));
};
async function act(fn: () => Promise<void>) {
setBusy(true);
setStatus({ text: '', error: false });
try { await fn(); } catch (e) { setStatus({ text: 'Ошибка: ' + (e as Error).message, error: true }); } finally { setBusy(false); }
}
const run = () => act(async () => {
await batchApi.updateInput(batch.id, toInput(values));
dirty.current = false;
onBatch(await batchApi.run(batch.id));
});
const duplicate = () => act(async () => { navigate('/batch/' + (await batchApi.duplicate(batch.id)).id); });
const remove = () => act(async () => {
if (!confirm(`Удалить черновик #${batch.id}?`)) return;
await batchApi.remove(batch.id);
navigate('/');
});
return (
<>
<Text2ImgForm
values={values} set={set} readOnly={locked}
action={locked ? 'Дублировать' : 'Сгенерировать'} actionDisabled={busy}
onAction={locked ? duplicate : run}
extra={!locked && <button type="button" className="secondary" disabled={busy} onClick={remove}>Удалить</button>}
/>
<div id="status" className={status.error ? 'err' : ''}>{status.text}</div>
</>
);
}
/** Страница батча: сведения, поля text2img (только для text-батча) и картинки этого батча. */
export function Batch() {
const id = Number(useParams().id);
const { batch, setBatch, error } = useBatch(id || null);
if (!id) return <p className="err">Неверный номер батча.</p>;
if (error && !batch) return <p className="err">{error} <Link to="/">К исходникам</Link></p>;
if (!batch || batch.id !== id) return <p className="empty">Загрузка…</p>;
const st = batchStateText(batch);
return (
<>
<h1>Батч #{batch.id}</h1>
<p className="sub">
{batch.sourceType === 'text' ? 'Генерация SDXL · 1024×1024 · 25 шагов' : 'Загрузка файлов'}
{' · '}{new Date(batch.created).toLocaleString()}
{batch.clonedFrom ? <> · копия <Link to={'/batch/' + batch.clonedFrom}>#{batch.clonedFrom}</Link></> : null}
{batch.sourceType === 'text' && batch.seedUsed != null && batch.state !== 'draft' ? <> · seed {batch.seedUsed}</> : null}
{st && <> · <span className={st.error ? 'err' : 'state-text'}>{st.text}</span></>}
</p>
{batch.sourceType === 'text'
? <TextBatchForm key={batch.id} batch={batch} onBatch={setBatch} />
: <p className="preview">Файлы: {batch.source.files.join(', ')}</p>}
<Images batch={batch} />
</>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { Link, useParams } from 'react-router';
import type { CleanupSettings, ModelSettings, Project as ProjectT } from '../../shared/api.ts';
import { ModelLinks, ModelView, ViewToggle, type View } from '../components/ModelView.tsx';
import { useViewer } from '../components/Viewer.tsx';
import { fileUrl } from '../lib/api.ts';
import { cleanStateText, modelStateText, projectApi, useProject } from '../lib/batches.ts';
import { useSettings } from '../lib/settings.ts';
const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' };
const MODEL_DEFAULTS: ModelSettings = { seed: '0', steps: '30', octree: '380', faces: '5000' };
type Open = (src: string, caption: string) => void;
type OnProject = (p: ProjectT) => void;
/** Действие с проектом (запуск, удаление): блокировка кнопок на время запроса и текст ошибки. */
function useAct(onProject: OnProject) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function act(fn: () => Promise<ProjectT>) {
setBusy(true);
setError('');
try { onProject(await fn()); } catch (e) { setError((e as Error).message); } finally { setBusy(false); }
}
return { busy, error, act };
}
/** Ссылка «удалить» в углу результата — с подтверждением. */
function RemoveLink({ what, busy, onRemove }: { what: string; busy: boolean; onRemove: () => void }) {
return (
<a href="#" onClick={e => {
e.preventDefault();
if (!busy && confirm(`Удалить ${what} из проекта? Файлы в ComfyUI/output останутся.`)) onRemove();
}}>удалить</a>
);
}
/** Квадрат с формой вместо результата. */
function FormBox({ title, hint, busy, onSubmit, error, children }: {
title: string; hint: ReactNode; busy: boolean; onSubmit: () => void; error?: string; children: ReactNode;
}) {
return (
<form className="box step-form" onSubmit={(e: FormEvent) => { e.preventDefault(); onSubmit(); }}>
<div className="step-form-title">{title}</div>
<p className="step-form-hint">{hint}</p>
<fieldset>{children}</fieldset>
<button type="submit" disabled={busy}>Обработать</button>
{error && <div className="err">{error}</div>}
</form>
);
}
/**
* Квадрат «Очищенная»: есть очищенная картинка — она и кнопка «удалить»; нет — форма очистки на её месте;
* идёт очистка — её состояние. История очисток не хранится: новая очистка возможна после удаления прежней.
*/
function CleanBlock({ project, onProject, open }: { project: ProjectT; onProject: OnProject; open: Open }) {
const { values: v, set } = useSettings('cleanup', CLEAN_DEFAULTS);
const { busy, error, act } = useAct(onProject);
const clean = project.clean;
const st = cleanStateText(project);
if (project.cleanJobId) return <div className="box">{st?.text}</div>;
if (clean) {
return (
<div className="box">
<img src={fileUrl(clean)} alt={clean.filename} onClick={() => open(fileUrl(clean), clean.filename)} />
<div className="links">
<RemoveLink what="очищенную картинку" busy={busy} onRemove={() => void act(() => projectApi.removeClean(project.id))} />
<a href={fileUrl(clean)} download={clean.filename}>скачать</a>
</div>
{error && <div className="box-error">{error}</div>}
</div>
);
}
return (
<FormBox title="Очистка фона" busy={busy} error={error || (st?.error ? st.text : '')}
hint="Удаление фона (BiRefNet), персонаж вписывается по центру в квадрат «размер − 2 × отступ», фон заливается цветом."
onSubmit={() => void act(() => projectApi.cleanup(project.id, { size: v.size, margin: v.margin, grow: v.grow, background: v.background }))}>
<label><span>Размер, px <small>— квадрат</small></span>
<input type="number" min={64} max={4096} step={8} value={v.size} onChange={e => set('size', e.target.value)} />
</label>
<label><span>Отступ, px <small>— с каждой стороны, до ¼ размера</small></span>
<input type="number" min={0} max={1024} step={1} value={v.margin} onChange={e => set('margin', e.target.value)} />
</label>
<label><span>Расширить маску, px <small>— от −32 до 32</small></span>
<input type="number" min={-32} max={32} step={1} value={v.grow} onChange={e => set('grow', e.target.value)} />
</label>
<label>Цвет фона
<input type="color" value={v.background} onChange={e => set('background', e.target.value)} />
</label>
</FormBox>
);
}
/**
* Квадрат «3D-модель» (показывается, только когда есть очищенная картинка): есть модель — переключатель
* «картинка / 3D» (по умолчанию 3D), «удалить», GLB и PNG; нет — форма Hunyuan3D на её месте; идёт генерация — её состояние.
*/
function ModelBlock({ project, onProject, open, view, setView }: {
project: ProjectT; onProject: OnProject; open: Open; view: View; setView: (v: View) => void;
}) {
const { values: v, set } = useSettings('model', MODEL_DEFAULTS);
const { busy, error, act } = useAct(onProject);
const st = modelStateText(project);
if (project.modelJobId) return <div className="box">{st?.text}</div>;
// модель или форма — только по записи о модели в базе (загрузка model-viewer на это не влияет)
if (project.model) {
return (
<div className="box">
<ModelView glb={project.model} preview={project.modelPreview} view={view} onOpen={open} />
<span className="box-seg"><ViewToggle value={view} onChange={setView} /></span>
<ModelLinks glb={project.model} preview={project.modelPreview}
before={<RemoveLink what="3D-модель" busy={busy} onRemove={() => void act(() => projectApi.removeModel(project.id))} />} />
{error && <div className="box-error">{error}</div>}
</div>
);
}
return (
<FormBox title="3D-модель" busy={busy} error={error || (st?.error ? st.text : '')}
hint="Hunyuan3D-2 mini: очищенная картинка → GLB-меш (только геометрия, ~1.5–2 мин) и PNG-превью."
onSubmit={() => void act(() => projectApi.model(project.id, { seed: v.seed, steps: v.steps, octree: v.octree, faces: v.faces }))}>
<label><span>Seed <small>— пусто = случайный</small></span>
<input type="number" min={0} step={1} placeholder="случайный" value={v.seed} onChange={e => set('seed', e.target.value)} />
</label>
<label><span>Шаги <small>— 30 обычно достаточно</small></span>
<input type="number" min={1} max={100} step={1} value={v.steps} onChange={e => set('steps', e.target.value)} />
</label>
<label><span>Детализация <small>— octree 256…512</small></span>
<input type="number" min={64} max={512} step={1} value={v.octree} onChange={e => set('octree', e.target.value)} />
</label>
<label><span>Полигонов <small>— 0 = без упрощения</small></span>
<input type="number" min={0} max={2000000} step={100} value={v.faces} onChange={e => set('faces', e.target.value)} />
</label>
</FormBox>
);
}
const when = (t: number | null) => (t ? new Date(t).toLocaleString() : '');
/** «1 треугольник», «3 треугольника», «10000 треугольников». */
function triangles(n: number): string {
const d = n % 10, dd = n % 100;
const word = d === 1 && dd !== 11 ? 'треугольник' : d >= 2 && d <= 4 && (dd < 12 || dd > 14) ? 'треугольника' : 'треугольников';
return `${n} ${word}`;
}
/** Страница проекта: исходник → очищенная (или форма очистки) → 3D-модель (или форма Hunyuan3D). */
export function Project() {
const id = Number(useParams().id) || null;
const { project, setProject, error } = useProject(id);
const viewer = useViewer();
const [view, setView] = useState<View>('3d');
if (!id) return <p className="err">Неверный номер проекта.</p>;
if (error && !project) return <p className="err">{error} <Link to="/projects">К проектам</Link></p>;
if (!project) return <p className="empty">Загрузка…</p>;
const img = project.image;
return (
<>
<h1>Проект #{project.id}</h1>
<p className="sub">
{new Date(project.created).toLocaleString()} · из батча <Link to={'/batch/' + project.batchId}>#{project.batchId}</Link>
</p>
<div className="stage three">
<div className="pane">
<div className="pane-title">Исходник</div>
<div className="box">
<img src={fileUrl(img)} alt={img.filename} onClick={() => viewer.open(fileUrl(img), img.filename)} />
<a className="dl" href={fileUrl(img)} download={img.filename}>скачать</a>
</div>
</div>
<div className="pane">
<div className="pane-title">Очищенная</div>
<CleanBlock project={project} onProject={setProject} open={viewer.open} />
<div className="name">{project.clean ? when(project.cleanUpdated) : ''}</div>
</div>
{project.clean && (
<div className="pane">
<div className="pane-title">3D-модель{project.model && project.modelFaces != null ? `, ${triangles(project.modelFaces)}` : ''}</div>
<ModelBlock project={project} onProject={setProject} open={viewer.open} view={view} setView={setView} />
<div className="name">{project.model ? when(project.modelUpdated) : ''}</div>
</div>
)}
</div>
{viewer.element}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import type { Project } from '../../shared/api.ts';
import { thumbUrl } from '../lib/api.ts';
import { projectApi } from '../lib/batches.ts';
/** Список проектов, новые первыми. Карточка ведёт на страницу проекта. */
export function Projects() {
const [projects, setProjects] = useState<Project[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let alive = true;
projectApi.list()
.then(list => { if (alive) setProjects(list); })
.catch(e => { if (alive) setError((e as Error).message); })
.finally(() => { if (alive) setLoaded(true); });
return () => { alive = false; };
}, []);
return (
<div className="runs">
{error && <p className="empty err">{error}</p>}
{projects.map(p => (
<Link key={p.id} to={'/project/' + p.id} className="run run-link project-card"
title={`${new Date(p.created).toLocaleString()}\nиз батча #${p.batchId}`}>
<div className="project-thumb"><img src={thumbUrl(p.image)} alt={p.image.filename} loading="lazy" /></div>
<div className="prompt">Проект #{p.id}</div>
</Link>
))}
{loaded && !projects.length && !error &&
<p className="empty">Проектов пока нет — откройте батч в <Link to="/">Исходниках</Link> и нажмите «в проект» на картинке.</p>}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import type { Text2ImgSettings } from '../../shared/api.ts';
import { BatchCard } from '../components/BatchCard.tsx';
import { DEFAULT_VALUES, toInput } from '../components/Text2ImgForm.tsx';
import { api } from '../lib/api.ts';
import { batchApi, useBatches } from '../lib/batches.ts';
/** Исходники: все батчи картинок (генерации и загрузки), новые первыми. Карточка ведёт на страницу батча. */
export function Sources() {
const navigate = useNavigate();
const { batches, loaded, error } = useBatches();
const input = useRef<HTMLInputElement>(null);
const [over, setOver] = useState(false);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState({ text: '', error: false });
async function act(text: string, fn: () => Promise<number>) {
setBusy(true);
setStatus({ text, error: false });
try {
navigate('/batch/' + await fn());
} catch (e) {
setStatus({ text: 'Ошибка: ' + (e as Error).message, error: true });
setBusy(false);
}
}
/** Новый черновик с последними введёнными значениями — и сразу на его страницу. */
const newGeneration = () => act('Создание…', async () => {
const saved = await api<Partial<Text2ImgSettings>>('/api/settings/text2img').catch(() => ({}));
return (await batchApi.create(toInput({ ...DEFAULT_VALUES, ...saved }), false)).id;
});
const upload = (list: FileList | null | undefined) => {
const files = [...(list ?? [])];
if (files.length) void act(`Загрузка ${files.length} файл(ов)…`, async () => (await batchApi.upload(files)).id);
};
return (
<>
<div className="toolbar">
<button type="button" disabled={busy} onClick={() => void newGeneration()}>+ Новая генерация</button>
<div className={'upload-drop' + (over ? ' over' : '')}
onClick={() => input.current?.click()}
onDragOver={e => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={e => { e.preventDefault(); setOver(false); upload(e.dataTransfer.files); }}>
<b>Загрузить картинки</b> — нажмите или перетащите файлы (одна загрузка — один батч)
</div>
<input ref={input} type="file" accept="image/*" multiple hidden
onChange={e => { upload(e.target.files); e.target.value = ''; }} />
{status.text && <span className={status.error ? 'err' : 'empty'}>{status.text}</span>}
</div>
<div className="runs">
{error && <p className="empty err">{error}</p>}
{batches.map(b => <BatchCard key={b.id} batch={b} />)}
{loaded && !batches.length && !error && <p className="empty">Исходников пока нет — создайте генерацию или загрузите картинки.</p>}
</div>
</>
);
}
+139
View File
@@ -0,0 +1,139 @@
:root {
--bg: #f5f5f4; --panel: #ffffff; --text: #1c1917; --muted: #78716c; --border: #e7e5e4;
--accent: #2563eb; --accent-text: #ffffff; --error: #dc2626; --img-bg: #e7e5e4;
/* CSS-сантиметр = 37.8 px; на FullHD 23" (≈37.7 px/см) это почти физический сантиметр */
--thumb: 2cm;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #161616; --panel: #1f1f1f; --text: #f5f5f4; --muted: #a8a29e; --border: #333;
--accent: #3b82f6; --error: #f87171; --img-bg: #2a2a2a; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 1100px; margin: 0 auto; padding: 24px 16px 64px; }
main.wide { max-width: none; }
h1 { font-size: 22px; margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
.sub a { color: var(--accent); }
.err { color: var(--error); }
/* ---------- навигация и очередь ---------- */
.nav { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 13px; margin-bottom: 14px; }
.nav a { color: var(--accent); text-decoration: none; }
.nav a.active { color: var(--text); font-weight: 700; }
.queue { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
.queue .dot { width: 8px; height: 8px; border-radius: 50%; background: #a8a29e; flex: none; }
.queue.idle .dot { background: #16a34a; }
.queue.busy .dot { background: #f59e0b; animation: queue-pulse 1.2s ease-in-out infinite; }
.queue.down, .queue.down .dot { color: var(--error); }
.queue.down .dot { background: var(--error); }
.queue b { color: var(--text); font-weight: 600; }
@keyframes queue-pulse { 50% { opacity: .35; } }
/* ---------- формы ---------- */
form { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px; display: grid; gap: 14px; }
label { display: grid; gap: 6px; font-weight: 600; font-size: 13px; }
label small { font-weight: 400; color: var(--muted); }
textarea, input, select {
width: 100%; font: inherit; color: inherit; background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 9px 11px; resize: vertical;
}
input[type=color] { padding: 2px; height: 40px; }
textarea:focus, input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.row { display: grid; grid-template-columns: repeat(var(--cols, 2), 1fr) auto; gap: 14px; align-items: end; }
@media (max-width: 760px) { .row { grid-template-columns: 1fr 1fr; } .row button { grid-column: 1 / -1; } }
button { font: inherit; font-weight: 600; border: 0; border-radius: 8px; padding: 10px 22px; cursor: pointer;
background: var(--accent); color: var(--accent-text); }
button:disabled { opacity: .55; cursor: not-allowed; }
details summary { cursor: pointer; font-weight: 600; font-size: 13px; }
details .sub-field { margin-top: 12px; }
.preview { font-size: 12px; color: var(--muted); background: var(--bg); border-radius: 8px; padding: 8px 10px; }
/* ---------- панели исходник / результат ---------- */
.stage { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.pane { display: grid; gap: 6px; align-content: start; justify-items: start; }
.pane-title { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 10px; }
.pane .name { font-size: 12px; color: var(--muted); word-break: break-all; min-height: 18px; }
.box { position: relative; width: 100%; max-width: 420px; aspect-ratio: 1; background: var(--img-bg); border-radius: 10px;
overflow: hidden; display: grid; place-items: center; color: var(--muted); font-size: 13px; text-align: center; padding: 0; }
.box img, .cell img { width: 100%; height: 100%; object-fit: contain; display: block; cursor: zoom-in; }
.box.err { color: var(--error); padding: 12px; }
.links { position: absolute; right: 6px; bottom: 6px; display: flex; gap: 4px; }
.links a { background: rgba(0,0,0,.6); color: #fff; font-size: 12px; text-decoration: none; padding: 3px 8px; border-radius: 6px; }
.links a.main { background: var(--accent); }
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; background: var(--panel); }
.seg button { background: none; color: var(--muted); font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 0; }
.seg button.on { background: var(--accent); color: var(--accent-text); }
model-viewer { width: 100%; height: 100%; background: var(--img-bg); --poster-color: transparent; }
/* 3D-вид: model-viewer и поверх него превью, пока GLB грузится или если не загрузился */
.mv-wrap { position: relative; width: 100%; height: 100%; }
.mv-wrap > model-viewer, .mv-wrap > .mv-layer { position: absolute; inset: 0; }
.mv-layer { width: 100%; height: 100%; object-fit: contain; }
.mv-note { position: absolute; left: 0; right: 0; top: 50%; text-align: center; font-size: 12px; color: var(--muted); }
.mv-note.err { top: auto; bottom: 36px; color: var(--error); }
.mv-seg { position: absolute; top: 6px; left: 6px; }
/* ---------- история ---------- */
#status { min-height: 22px; margin: 16px 2px; color: var(--muted); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
.cell { position: relative; background: var(--img-bg); border-radius: 8px; overflow: hidden; aspect-ratio: 1; }
.cell > a.dl, .box > a.dl { position: absolute; right: 6px; bottom: 6px; background: rgba(0,0,0,.6); color: #fff; font-size: 12px;
text-decoration: none; padding: 3px 8px; border-radius: 6px; }
.cell.pending { display: grid; place-items: center; color: var(--muted); font-size: 13px; }
/* ---------- полноэкранный просмотр ---------- */
.viewer { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: grid; place-items: center; z-index: 10; cursor: zoom-out; }
.viewer img { max-width: 96vw; max-height: 90vh; }
.viewer .cap { position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; color: #ddd; font-size: 13px; }
/* ---------- прогоны ---------- */
.empty { color: var(--muted); }
.empty a { color: var(--accent); }
.empty.err { color: var(--error); flex-basis: 100%; }
.runs { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; }
.run { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 8px; max-width: 100%; }
.run .prompt + .prompt { margin-top: 1px; }
.run .prompt { font-size: 11px; color: var(--muted); margin-top: 6px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; width: 0; min-width: 100%; }
.strip { display: flex; gap: 4px; overflow-x: auto; }
a.run-link { color: inherit; text-decoration: none; display: block; cursor: pointer; }
a.run-link:hover { border-color: var(--accent); }
.actions { display: flex; gap: 8px; }
.state-text { color: var(--text); font-weight: 600; }
.batch-images { margin-top: 16px; }
button.secondary { background: none; color: var(--muted); border: 1px solid var(--border); }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 16px; }
.toolbar > a, .sub a { color: var(--accent); text-decoration: none; font-size: 13px; font-weight: 600; }
.upload-drop { border: 2px dashed var(--border); border-radius: 10px; padding: 10px 16px; color: var(--muted); font-size: 13px; cursor: pointer; }
.upload-drop.over { border-color: var(--accent); color: var(--accent); }
.upload-drop b { color: var(--accent); }
fieldset { border: 0; padding: 0; margin: 0; display: grid; gap: 14px; min-width: 0; }
.col { display: grid; gap: 4px; flex: none; }
.th { position: relative; width: var(--thumb); height: var(--thumb); border-radius: 5px; overflow: hidden;
background: var(--img-bg); display: grid; place-items: center; font-size: 10px; color: var(--muted); }
.th.none { background: none; }
/* картинка, из которой создан проект, и батч с такой картинкой */
.th.in-project, .run.in-project { outline: 2px solid var(--accent); outline-offset: -2px; }
.project-image { margin-top: 8px; }
/* форма в квадрате вместо результата шага проекта (очистка, 3D-модель) */
.box.step-form { place-items: stretch; align-content: center; gap: 8px; padding: 16px; border: 2px dashed var(--border);
background: none; text-align: left; color: var(--text); border-radius: 10px;
overflow: visible; /* на узком экране квадрат вытягивается по высоте, а не обрезает форму */ }
.step-form fieldset { gap: 8px; }
.step-form-title { font-weight: 700; font-size: 14px; }
.step-form-hint { margin: 0; font-size: 12px; color: var(--muted); }
.step-form label { gap: 3px; }
.step-form input { padding: 6px 9px; }
.step-form input[type=color] { height: 32px; }
.stage.three { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 900px) { .stage.three { grid-template-columns: 1fr 1fr; } }
@media (max-width: 600px) { .stage.three { grid-template-columns: 1fr; } }
.box-seg { position: absolute; top: 6px; right: 6px; }
.box-error { position: absolute; left: 6px; bottom: 34px; right: 6px; color: var(--error); font-size: 12px; }
.project-thumb { width: calc(var(--thumb) * 2); height: calc(var(--thumb) * 2); border-radius: 6px; overflow: hidden; background: var(--img-bg); }
.project-thumb img { width: 100%; height: 100%; object-fit: contain; display: block; }
.th.draft { width: auto; min-width: var(--thumb); padding: 0 8px; text-align: center; font-size: 11px; line-height: 1.3; }
.th img { width: 100%; height: 100%; object-fit: contain; display: block; cursor: pointer; }
.th .more { position: absolute; right: 2px; bottom: 2px; background: rgba(0,0,0,.65); color: #fff; font-size: 10px;
font-weight: 600; padding: 0 4px; border-radius: 4px; line-height: 15px; pointer-events: none; }
+11
View File
@@ -0,0 +1,11 @@
@echo off
rem img2glb: собрать фронтенд при необходимости и запустить сервер (порт 7000). ComfyUI 0.37 должен быть запущен.
cd /d "%~dp0"
set PATH=%~dp0..\node;%PATH%
if not exist dist\index.html call npm run build || goto :err
start "" http://127.0.0.1:7000
call npm start
goto :eof
:err
echo BUILD FAILED
pause
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "shared"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.server.json" }
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"],
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["server", "shared", "scripts", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// В разработке (npm run dev) Vite отдаёт фронтенд, а /api проксирует на API-сервер, который запускает scripts/dev.ts.
const apiPort = process.env.API_PORT ?? '7010';
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': `http://127.0.0.1:${apiPort}` },
},
build: {
outDir: 'dist',
chunkSizeWarningLimit: 1500,
},
});