Files
sprite-ai/server/tracker.ts
T
2026-09-23 21:56:40 +03:00

119 lines
5.5 KiB
TypeScript

// Фоновое отслеживание незавершённых задач: результаты из 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);
}