"use strict"; const MAX_FILE_SIZE = 25 * 1024 * 1024; const ACCEPTED_TYPES = new Set([ "image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif" ]); const healthStatus = document.querySelector("#health-status"); const healthText = document.querySelector("#health-text"); const dropZone = document.querySelector("#drop-zone"); const photoInput = document.querySelector("#photo-input"); const dropTitle = document.querySelector("#drop-title"); const fileMeta = document.querySelector("#file-meta"); const promptInput = document.querySelector("#prompt"); const promptCounter = document.querySelector("#prompt-counter"); const editButton = document.querySelector("#edit-button"); const upscaleButton = document.querySelector("#upscale-button"); const workspace = document.querySelector("#workspace"); const sourceImage = document.querySelector("#source-image"); const sourcePlaceholder = document.querySelector("#source-placeholder"); const resultImage = document.querySelector("#result-image"); const resultPlaceholder = document.querySelector("#result-placeholder"); const resultLabel = document.querySelector("#result-label"); const jobStatus = document.querySelector("#job-status"); const jobStatusText = document.querySelector("#job-status-text"); const downloadLink = document.querySelector("#download-link"); let currentVersionId = null; let historyVersions = []; let sourceObjectUrl = null; let isBusy = false; let fileSelectionSequence = 0; 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") ); } function formatFileSize(bytes) { if (bytes < 1024 * 1024) { return `${Math.max(1, Math.round(bytes / 1024))} КБ`; } return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`; } function setHealthState(state, message, title = "") { healthStatus.className = `health-status health-${state}`; healthText.textContent = message; healthStatus.title = title; } function setJobStatus(kind, message) { if (!message) { jobStatus.hidden = true; jobStatus.removeAttribute("data-kind"); jobStatusText.textContent = ""; return; } jobStatus.hidden = false; jobStatus.dataset.kind = kind; jobStatusText.textContent = message; } function setBusy(busy) { isBusy = busy; workspace.setAttribute("aria-busy", String(busy)); editButton.disabled = busy || !currentVersionId; upscaleButton.disabled = busy || !currentVersionId; promptInput.disabled = busy; photoInput.disabled = busy; dropZone.setAttribute("aria-disabled", String(busy)); } function resetResult() { resultImage.hidden = true; resultImage.removeAttribute("src"); resultPlaceholder.hidden = false; resultLabel.textContent = ""; downloadLink.hidden = true; downloadLink.removeAttribute("href"); downloadLink.removeAttribute("download"); } function clearSourcePreview() { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); sourceObjectUrl = null; } sourceImage.hidden = true; sourceImage.removeAttribute("src"); sourcePlaceholder.hidden = false; } function showSourcePreview(url) { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); sourceObjectUrl = null; } sourceImage.src = url; sourceImage.hidden = false; sourcePlaceholder.hidden = true; } function showSourcePreviewFromUrl(url, title, meta) { showSourcePreview(url); dropZone.classList.add("has-file"); dropTitle.textContent = title || "Текущая фотография"; fileMeta.textContent = meta || "Готово к обработке"; } async function selectFile(file) { const selectionToken = ++fileSelectionSequence; if (!file) { return; } const fileType = String(file.type || "").toLowerCase(); if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) { setJobStatus( "error", "Поддерживаются только фотографии JPEG, PNG, WebP или HEIC." ); return; } if (file.size > MAX_FILE_SIZE) { setJobStatus( "error", "Файл слишком большой. Максимальный размер — 25 МБ." ); return; } setBusy(true); setJobStatus("loading", "Загружаем фотографию в историю…"); const form = new FormData(); form.append("photo", file, file.name); try { const response = await fetch("/api/history/import", { method: "POST", body: form, cache: "no-store" }); if (!response.ok) { let message = `Не удалось загрузить фотографию. HTTP ${response.status}.`; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const data = await response.json().catch(() => null); if (data?.error) { message = data.error; } } throw new Error(message); } const data = await response.json(); if (selectionToken !== fileSelectionSequence) { return; } historyVersions.push(data.version); currentVersionId = data.version.id; renderHistory(); resetResult(); showSourcePreviewFromUrl( data.version.url, file.name, `${formatFileSize(file.size)} · готово к обработке` ); setJobStatus("", ""); setBusy(false); } catch (error) { if (selectionToken !== fileSelectionSequence) { return; } setBusy(false); setJobStatus( "error", error instanceof Error ? error.message : "Не удалось загрузить фотографию." ); } } function openFilePicker() { if (!isBusy) { photoInput.click(); } } function preventDragDefaults(event) { event.preventDefault(); event.stopPropagation(); } async function readJsonResponse(response) { const contentType = response.headers.get("content-type") || ""; if (!contentType.includes("application/json")) { throw new Error( `Сервер вернул неожиданный ответ HTTP ${response.status}.` ); } return response.json(); } async function runJob(kind) { if (isBusy) { return; } if (!currentVersionId) { setJobStatus("error", "Сначала выберите фотографию."); return; } const prompt = promptInput.value.trim(); if (kind === "edit" && !prompt) { setJobStatus( "error", "Введите промпт — опишите, как нужно изменить фотографию." ); promptInput.focus(); return; } const isEdit = kind === "edit"; const endpoint = isEdit ? "/api/edit" : "/api/upscale"; const form = new FormData(); form.append("sourceId", currentVersionId); if (isEdit) { form.append("prompt", prompt); } resetResult(); setBusy(true); setJobStatus( "loading", isEdit ? "Codex редактирует фотографию… Обычно 1–3 минуты." : "Увеличиваем разрешение фотографии…" ); try { const response = await fetch(endpoint, { method: "POST", body: form, cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось обработать фотографию."); } const version = data.version; if (version) { historyVersions.push(version); currentVersionId = version.id; renderHistory(); } const url = version?.url || data.result?.url; resultImage.src = url; resultImage.hidden = false; resultPlaceholder.hidden = true; resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale"; downloadLink.href = url; downloadLink.download = data.result?.filename || (isEdit ? "edited-photo.jpg" : "upscaled-photo.png"); downloadLink.hidden = false; const summary = isEdit && data.summary ? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180) : ""; setJobStatus( "success", isEdit ? summary ? `Готово — фотография отредактирована. Codex: ${summary}` : "Готово — фотография отредактирована." : `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.` ); if (window.matchMedia("(max-width: 1040px)").matches) { resultImage.scrollIntoView({ behavior: "smooth", block: "center" }); } } catch (error) { setJobStatus( "error", error instanceof Error ? error.message : "Не удалось обработать фотографию." ); } finally { setBusy(false); } } function formatHistoryDate(iso) { try { return new Date(iso).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); } catch { return ""; } } function historyItemBadge(version) { if (version.kind === "original") { return "загрузка"; } if (version.kind === "upscale") { return "4× upscale"; } return "Codex"; } function renderHistory() { const list = document.querySelector("#history-list"); const empty = document.querySelector("#history-empty"); list.querySelectorAll(".history-item").forEach((item) => item.remove()); empty.hidden = historyVersions.length > 0; for (const version of historyVersions) { const item = document.createElement("button"); item.type = "button"; item.className = "history-item"; item.dataset.id = version.id; item.setAttribute("aria-label", version.label || "Версия"); if (version.id === currentVersionId) { item.classList.add("is-current"); } const thumb = document.createElement("img"); thumb.src = version.url; thumb.alt = ""; thumb.loading = "lazy"; const meta = document.createElement("span"); meta.className = "history-item-meta"; const title = document.createElement("strong"); title.textContent = version.label || "Версия"; const sub = document.createElement("span"); const size = version.width && version.height ? ` · ${version.width}×${version.height}` : ""; sub.textContent = `${formatHistoryDate(version.createdAt)}${size}`; meta.append(title, sub); const badge = document.createElement("span"); badge.className = "history-item-badge"; badge.textContent = historyItemBadge(version); item.append(thumb, meta, badge); item.addEventListener("click", () => selectHistoryVersion(version)); list.appendChild(item); } } function selectHistoryVersion(version) { if (isBusy) { return; } currentVersionId = version.id; resetResult(); showSourcePreviewFromUrl( version.url, version.label || "Версия", version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке" ); renderHistory(); setJobStatus("", ""); } async function loadHistoryOnStart() { try { const response = await fetch("/api/history", { cache: "no-store" }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); historyVersions = Array.isArray(data.versions) ? data.versions : []; if (historyVersions.length > 0) { const latest = historyVersions[historyVersions.length - 1]; currentVersionId = latest.id; showSourcePreviewFromUrl( latest.url, latest.label || "Текущая фотография", latest.width && latest.height ? `${latest.width}×${latest.height}` : "Готово к обработке" ); } } catch { historyVersions = []; } finally { renderHistory(); setBusy(false); } } async function checkHealth() { setHealthState("loading", "Проверяем сервер обработки…"); try { const response = await fetch("/api/health", { cache: "no-store" }); const data = await readJsonResponse(response); if (data.ok) { const modelTitle = data.selectedUpscaleModel ? `Upscale: ${data.selectedUpscaleModel}` : ""; setHealthState( "ok", "Сервер обработки: доступен", modelTitle ); return; } if (data.comfyAvailable) { const missing = Array.isArray(data.missingNodes) ? data.missingNodes.join(", ") : ""; setHealthState( "warning", data.message || "Сервер настроен не полностью", missing ? `Отсутствуют узлы: ${missing}` : "" ); return; } setHealthState( "error", "Сервер обработки: недоступен", data.message || "" ); } catch (error) { setHealthState( "error", "Сервер обработки: недоступен", error instanceof Error ? error.message : "" ); } } dropZone.addEventListener("click", openFilePicker); dropZone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openFilePicker(); } }); photoInput.addEventListener("change", () => { selectFile(photoInput.files?.[0]); photoInput.value = ""; }); ["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => { dropZone.addEventListener(eventName, preventDragDefaults); }); ["dragenter", "dragover"].forEach((eventName) => { dropZone.addEventListener(eventName, () => { if (!isBusy) { dropZone.classList.add("is-dragging"); } }); }); ["dragleave", "drop"].forEach((eventName) => { dropZone.addEventListener(eventName, () => { dropZone.classList.remove("is-dragging"); }); }); dropZone.addEventListener("drop", (event) => { if (!isBusy) { selectFile(event.dataTransfer?.files?.[0]); } }); promptInput.addEventListener("input", () => { promptCounter.textContent = `${promptInput.value.length} / 1000`; }); editButton.addEventListener("click", () => runJob("edit")); upscaleButton.addEventListener("click", () => runJob("upscale")); window.addEventListener("beforeunload", () => { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); } }); checkHealth(); loadHistoryOnStart();