// 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, ModelArtifacts, Project, SettingsPage, Text2ImgInput, TextureArtifacts, } from '../shared/api.ts'; import { DB_PATH } from './config.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) { 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-артефакты проекта (история не хранится): модель без текстур и текстурная группа (TRELLIS.2 или Paint). // v2: прежние раздельные столбцы model*/texture*/trellis*/paint* убраны, 3D-результаты начаты с нуля. if (!(db.prepare("SELECT 1 FROM meta WHERE key = 'projects.artifacts.v2'").get())) { const have = new Set((db.prepare('PRAGMA table_info(Projects)').all() as { name: string }[]).map(c => c.name)); for (const c of ['model', 'modelPreview', 'modelUpdated', 'modelJobId', 'modelError', 'modelFaces', 'texture', 'textureUpdated', 'textureFaces', 'textureJobId', 'textureError', 'trellis', 'trellisUpdated', 'trellisFaces', 'trellisJobId', 'trellisError', 'paint', 'paintUpdated', 'paintFaces', 'paintJobId', 'paintError']) { if (have.has(c)) db.exec(`ALTER TABLE Projects DROP COLUMN ${c}`); } db.exec(`DELETE FROM jobs WHERE type IN ('model', 'texture', 'trellis', 'paint')`); db.exec(`DELETE FROM settings WHERE page IN ('trellis', 'paint')`); db.exec(`INSERT INTO meta (key, value) VALUES ('projects.artifacts.v2', '1')`); } addColumns('Projects', { model: 'TEXT', // JSON ModelArtifacts: модель без текстур modelJobId: 'TEXT', // prompt_id идущей генерации modelError: 'TEXT', // ошибка последней генерации texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды textureJobId: 'TEXT', // prompt_id идущего текстурирования textureError: 'TEXT', // ошибка последнего текстурирования }); // истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`); /** Несколько записей одной транзакцией. */ export function transaction(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, 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(); 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.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError, i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`; const pq = { insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'), 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(?1, model), texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END, modelError = ?3, modelJobId = NULL WHERE id = ?4`), byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'), startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'), finishTexture: db.prepare('UPDATE Projects SET texture = COALESCE(?, texture), textureError = ?, textureJobId = NULL WHERE id = ?'), clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'), clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'), }; /** Генерация 3D-модели проекта поставлена в очередь. */ 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; } /** * Генерация завершена: новая модель заменяет прежнюю, прежние текстуры убираются (TRELLIS.2 сразу даёт свои). * При ошибке (model = null) всё остаётся как было. */ export function finishProjectModel(id: number, model: ModelArtifacts | null, texture: TextureArtifacts | null, error?: string) { pq.finishModel.run(model ? JSON.stringify(model) : null, texture ? JSON.stringify(texture) : null, error ?? null, id); } /** Текстурирование проекта поставлено в очередь. */ export function startProjectTexture(id: number, jobId: string) { pq.startTexture.run(jobId, id); } export function projectByTextureJob(jobId: string): number | null { return (pq.byTextureJob.get(jobId) as { id: number } | undefined)?.id ?? null; } /** Текстурирование завершено: новый результат заменяет прежний (при ошибке прежний остаётся). */ export function finishProjectTexture(id: number, texture: TextureArtifacts | null, error?: string) { pq.finishTexture.run(texture ? JSON.stringify(texture) : null, error ?? null, id); } /** Убрать модель из проекта вместе с текстурами — они сделаны для неё (файлы в ComfyUI/output остаются). */ export function clearProjectModel(id: number) { pq.clearModel.run(id); } /** Убрать текстурную группу (файлы в ComfyUI/output остаются). */ export function clearProjectTexture(id: number) { pq.clearTexture.run(id); } /** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */ export function clearProjectClean(id: number) { pq.clearClean.run(id); } interface ProjectRow { id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null; model: string | null; modelJobId: string | null; modelError: string | null; texture: string | null; textureJobId: string | null; textureError: string | null; imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string; } const json = (s: string | null) => (s ? JSON.parse(s) : null); function toProject(r: ProjectRow): Project { return { id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text', image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id }, clean: json(r.clean), cleanUpdated: r.cleanUpdated, cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}), model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}), texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}), }; } 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); }