Files
sprite-ai/server/db.ts
T
2026-09-30 07:38:21 +03:00

567 lines
30 KiB
TypeScript

// 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,
AnimArtifacts, RigArtifacts, 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 REFERENCES Images(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS Projects_imageId ON Projects (imageId);
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-артефакты проекта (история не хранится): модель без текстур и текстурная группа (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', // ошибка последнего текстурирования
rig: 'TEXT', // JSON RigArtifacts: риг SkinTokens
rigJobId: 'TEXT', // 'skintokens:<мс>' — идёт риггинг (процесс сервера)
rigError: 'TEXT', // ошибка последнего риггинга
anim: 'TEXT', // JSON AnimArtifacts[] (раньше — одна): движения на сгенерированном скелете рига
animError: 'TEXT', // ошибка последнего ретаргета
});
// риггинг — процесс самого сервера: после перезапуска незавершённый риггинг потерян
db.exec(`UPDATE Projects SET rigError = 'прервано: сервер перезапускался', rigJobId = NULL WHERE rigJobId IS NOT NULL`);
// раньше из картинки можно было сделать только один проект (imageId UNIQUE) — таблица пересобирается без ограничения
if ((db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'Projects'").get() as { sql: string }).sql
.match(/imageId\s+INTEGER\s+NOT\s+NULL\s+UNIQUE/i)) {
const cols = (db.prepare('PRAGMA table_info(Projects)').all() as { name: string; type: string }[]);
const extra = cols.filter(c => !['id', 'created', 'imageId'].includes(c.name));
const names = cols.map(c => c.name).join(', ');
db.exec('PRAGMA foreign_keys = OFF');
db.exec('BEGIN');
db.exec(`CREATE TABLE Projects_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created INTEGER NOT NULL,
imageId INTEGER NOT NULL REFERENCES Images(id) ON DELETE CASCADE
${extra.map(c => `, ${c.name} ${c.type}`).join('')}
)`);
db.exec(`INSERT INTO Projects_new (${names}) SELECT ${names} FROM Projects`);
db.exec('DROP TABLE Projects');
db.exec('ALTER TABLE Projects_new RENAME TO Projects');
db.exec('CREATE INDEX IF NOT EXISTS Projects_imageId ON Projects (imageId)');
db.exec('COMMIT');
db.exec('PRAGMA foreign_keys = ON');
}
// истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') 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; projectIds: string | null }
/** Картинки с id и проектами, созданными из них (через запятую). */
const SELECT_IMAGES = `
SELECT i.id, i.batchId, i.filename, i.subfolder, i.type, (SELECT GROUP_CONCAT(id) FROM Projects WHERE imageId = i.id) AS projectIds
FROM Images i`;
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 ids = (s: string | null) => (s ? s.split(',').map(Number).sort((a, b) => a - b) : []);
const ref = (i: ImageRow): BatchImage => ({
id: i.id, filename: i.filename, subfolder: i.subfolder, type: i.type, projectIds: ids(i.projectIds),
});
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.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError, p.rig, p.rigJobId, p.rigError, p.anim, p.animError,
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType,
(SELECT GROUP_CONCAT(id) FROM Projects WHERE imageId = i.id) AS projectIds
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 INTO Projects (created, imageId) VALUES (?, ?)'),
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,
rig = CASE WHEN ?1 IS NULL THEN rig ELSE NULL END,
anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL 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(?1, texture), rig = CASE WHEN ?1 IS NULL THEN rig ELSE NULL END,
anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL END,
textureError = ?2, textureJobId = NULL WHERE id = ?3`),
clearModel: db.prepare(`UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL, rig = NULL,
rigError = NULL, anim = NULL, animError = NULL WHERE id = ?`),
clearTexture: db.prepare(`UPDATE Projects SET texture = NULL, textureError = NULL, rig = NULL, rigError = NULL, anim = NULL,
animError = NULL WHERE id = ?`),
startRig: db.prepare('UPDATE Projects SET rigJobId = ?, rigError = NULL WHERE id = ?'),
finishRig: db.prepare(`UPDATE Projects SET rig = COALESCE(?1, rig), anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL END,
rigError = ?2, rigJobId = NULL WHERE id = ?3`),
clearRig: db.prepare('UPDATE Projects SET rig = NULL, rigError = NULL, anim = NULL, animError = NULL WHERE id = ?'),
getAnims: db.prepare('SELECT anim FROM Projects WHERE id = ?'),
setAnims: db.prepare('UPDATE Projects SET anim = ?, animError = ? WHERE id = ?'),
setAnimError: db.prepare('UPDATE Projects SET animError = ? 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);
}
/** Риггинг проекта начат (процесс сервера). */
export function startProjectRig(id: number, jobId: string) {
pq.startRig.run(jobId, id);
}
/** Риггинг завершён: новый риг заменяет прежний (при ошибке прежний остаётся). */
export function finishProjectRig(id: number, rig: RigArtifacts | null, error?: string) {
pq.finishRig.run(rig ? JSON.stringify(rig) : null, error ?? null, id);
}
/** Список анимаций из столбца anim (раньше там хранилась одна — объект). */
function animsOf(value: string | null): AnimArtifacts[] {
const v = value ? JSON.parse(value) : null;
return Array.isArray(v) ? v : v ? [v] : [];
}
/** Ретаргет завершён: новая анимация добавляется к прежним; при ошибке — только текст ошибки. */
export function addProjectAnim(id: number, anim: AnimArtifacts | null, error?: string) {
if (!anim) { pq.setAnimError.run(error ?? null, id); return; }
const list = animsOf((pq.getAnims.get(id) as { anim: string | null }).anim);
pq.setAnims.run(JSON.stringify([...list, anim]), null, id);
}
/** Убрать одну анимацию (по имени файла) из проекта; файл в ComfyUI/output остаётся. */
export function removeProjectAnim(id: number, filename: string) {
const list = animsOf((pq.getAnims.get(id) as { anim: string | null }).anim).filter(a => a.glb.filename !== filename);
pq.setAnims.run(list.length ? JSON.stringify(list) : null, null, id);
}
/** Убрать риг из проекта вместе с анимацией на нём (файлы в ComfyUI/output остаются). */
export function clearProjectRig(id: number) {
pq.clearRig.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;
rig: string | null; rigJobId: string | null; rigError: string | null; anim: string | null; animError: string | null;
imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string;
projectIds: string | null;
}
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, projectIds: ids(r.projectIds) },
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 } : {}),
rig: json(r.rig), rigJobId: r.rigJobId, ...(r.rigError ? { rigError: r.rigError } : {}),
anims: animsOf(r.anim), ...(r.animError ? { animError: r.animError } : {}),
};
}
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 createProjectFor(imageId: number): number {
return Number(pq.insert.run(Date.now(), imageId).lastInsertRowid);
}
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);
}