skin-tokens

This commit is contained in:
Aggtaa
2026-09-30 07:38:21 +03:00
parent 8db94e5c31
commit 9cad7452bb
16 changed files with 1314 additions and 78 deletions
+101 -20
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type {
BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput,
TextureArtifacts,
AnimArtifacts, RigArtifacts, TextureArtifacts,
} from '../shared/api.ts';
import { DB_PATH } from './config.ts';
@@ -79,12 +79,13 @@ db.exec(`
);
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
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,
@@ -125,7 +126,35 @@ addColumns('Projects', {
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'`);
@@ -215,12 +244,12 @@ interface BatchRow {
files: string | null;
}
interface ImageRow { id: number; batchId: number; filename: string; subfolder: string; type: string; projectId: number | null }
interface ImageRow { id: number; batchId: number; filename: string; subfolder: string; type: string; projectIds: string | null }
/** Картинки с id и проектом (если из картинки создан проект). */
/** Картинки с 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`;
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
@@ -253,8 +282,10 @@ const bq = {
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, projectId: i.projectId,
id: i.id, filename: i.filename, subfolder: i.subfolder, type: i.type, projectIds: ids(i.projectIds),
});
function toBatch(row: BatchRow, images: BatchImage[]): ImageBatch {
@@ -353,13 +384,13 @@ export function deleteBatch(b: ImageBatch) {
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
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 OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'),
byImage: db.prepare('SELECT id FROM Projects WHERE imageId = ?'),
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 = ?'),
@@ -371,12 +402,25 @@ const pq = {
// новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся
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(?, 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 = ?'),
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-модели проекта поставлена в очередь. */
@@ -420,6 +464,40 @@ 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);
@@ -429,7 +507,9 @@ 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);
@@ -437,11 +517,13 @@ 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 },
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 } : {}),
};
}
@@ -468,10 +550,9 @@ export function getImage(id: number): (BatchImage & { batchId: number }) | null
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 createProjectFor(imageId: number): number {
return Number(pq.insert.run(Date.now(), imageId).lastInsertRowid);
}
export function getProject(id: number): Project | null {