v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

138
public/js/ui.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* ui.js — Утилиты форматирования, отображения статусов и DOM-элементов
*/
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
export const ACCEPTED_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
export function isHeicFile(file) {
const type = String(file?.type || "").toLowerCase();
const name = String(file?.name || "").toLowerCase();
return (
type === "image/heic" ||
type === "image/heif" ||
name.endsWith(".heic") ||
name.endsWith(".heif")
);
}
export function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return "";
const units = ["Б", "КБ", "МБ", "ГБ"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export function versionFileName(version) {
const dateStr = version?.createdAt
? new Date(version.createdAt).toISOString().replace(/[:.]/g, "-").slice(0, 19)
: "photo";
const ext = version?.ext || "jpg";
const label = version?.label ? version.label.replace(/[^\p{L}\p{N}._-]+/gu, "-") : "version";
return `kadr-${label}-${dateStr}.${ext}`;
}
export function formatHistoryDate(iso) {
if (!iso) return "";
try {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
});
const time = d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
return `${date}, ${time}`;
} catch {
return iso;
}
}
export function setJobStatus(kind, message) {
const jobStatus = document.querySelector("#job-status");
const jobStatusText = document.querySelector("#job-status-text");
if (!jobStatus || !jobStatusText) return;
if (kind === "idle") {
jobStatus.hidden = true;
jobStatus.classList.remove("status-error");
jobStatusText.textContent = "";
return;
}
jobStatus.hidden = false;
jobStatus.classList.toggle("status-error", kind === "error");
jobStatusText.textContent = message;
}
export function resetResult() {
const resultImage = document.querySelector("#result-image");
const resultPlaceholder = document.querySelector("#result-placeholder");
const resultLabel = document.querySelector("#result-label");
const downloadLink = document.querySelector("#download-link");
if (resultImage) {
resultImage.hidden = true;
resultImage.removeAttribute("src");
}
if (resultPlaceholder) resultPlaceholder.hidden = false;
if (resultLabel) resultLabel.textContent = "";
if (downloadLink) downloadLink.hidden = true;
}
export function clearSourcePreview() {
const sourceImage = document.querySelector("#source-image");
const sourcePlaceholder = document.querySelector("#source-placeholder");
const downloadCurrent = document.querySelector("#download-current");
if (sourceImage) {
sourceImage.hidden = true;
sourceImage.removeAttribute("src");
}
if (sourcePlaceholder) sourcePlaceholder.hidden = false;
if (downloadCurrent) downloadCurrent.hidden = true;
}
export function showSourcePreview(url, title, meta) {
const sourceImage = document.querySelector("#source-image");
const sourcePlaceholder = document.querySelector("#source-placeholder");
const dropTitle = document.querySelector("#drop-title");
const fileMeta = document.querySelector("#file-meta");
if (sourceImage) {
sourceImage.src = url;
sourceImage.hidden = false;
}
if (sourcePlaceholder) sourcePlaceholder.hidden = true;
if (dropTitle && title) dropTitle.textContent = title;
if (fileMeta && meta) fileMeta.textContent = meta;
}
export function updateDownloadCurrent(version) {
const downloadCurrent = document.querySelector("#download-current");
if (!downloadCurrent) return;
if (!version || !version.url) {
downloadCurrent.hidden = true;
return;
}
downloadCurrent.href = version.url;
downloadCurrent.download = versionFileName(version);
downloadCurrent.hidden = false;
}