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