// Фоновое отслеживание незавершённых задач: результаты из 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(); const missingSince = new Map(); /** Положение незавершённой задачи в очереди 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>; } /** Файлы из 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>('/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); }