"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"); const downloadCurrent = document.querySelector("#download-current"); const authOverlay = document.querySelector("#auth-overlay"); const loginForm = document.querySelector("#login-form"); const loginUsername = document.querySelector("#login-username"); const loginPassword = document.querySelector("#login-password"); const loginError = document.querySelector("#login-error"); const loginSubmit = document.querySelector("#login-submit"); const logoutButton = document.querySelector("#logout-button"); const referenceUploadButton = document.querySelector("#reference-upload-button"); const referenceHistorySelect = document.querySelector("#reference-history-select"); const referenceClearButton = document.querySelector("#reference-clear-button"); const referenceFileInput = document.querySelector("#reference-file-input"); const referencePreview = document.querySelector("#reference-preview"); const referenceCanvas = document.querySelector("#reference-canvas"); const referenceRegionInfo = document.querySelector("#reference-region-info"); const referenceRegionReset = document.querySelector("#reference-region-reset"); const referenceNote = document.querySelector("#reference-note"); const suggestButton = document.querySelector("#suggest-button"); const suggestPanel = document.querySelector("#suggest-panel"); const suggestHistoryChips = document.querySelector("#suggest-history-chips"); const suggestCodexButton = document.querySelector("#suggest-codex-button"); const suggestCodexStatus = document.querySelector("#suggest-codex-status"); const suggestCodexChips = document.querySelector("#suggest-codex-chips"); let currentVersionId = null; let historyVersions = []; let sourceObjectUrl = null; let isBusy = false; let collapsedGroups = new Map(); // groupId (id корня) -> true (свёрнута) let referenceState = null; // { source: {type:'file', file, objectUrl} | {type:'history', versionId, url}, region: {x,y,w,h} | null } let suggestRunning = false; let suggestPollFailures = 0; const REFERENCE_MAX_DIMENSION = 1600; 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 versionFileName(version) { const kindName = { original: "original", edit: "edited", upscale: "upscaled" }[version.kind] || "photo"; let stamp = ""; try { const d = new Date(version.createdAt); stamp = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } catch { stamp = "photo"; } return `${kindName}-${stamp}.${version.ext || "jpg"}`; } function updateDownloadCurrent(version) { if (!version) { downloadCurrent.hidden = true; downloadCurrent.removeAttribute("href"); downloadCurrent.removeAttribute("download"); return; } downloadCurrent.href = version.url; downloadCurrent.download = versionFileName(version); downloadCurrent.hidden = false; } 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; suggestButton.disabled = busy; suggestCodexButton.disabled = busy || suggestRunning || !currentVersionId; referenceUploadButton.disabled = busy; referenceHistorySelect.disabled = busy; referenceClearButton.disabled = busy; referenceRegionReset.disabled = busy; dropZone.setAttribute("aria-disabled", String(busy)); } function updateSuggestCodexButton() { suggestCodexButton.disabled = isBusy || suggestRunning || !currentVersionId; } function resetResult() { resultImage.hidden = true; resultImage.removeAttribute("src"); resultPlaceholder.hidden = false; resultLabel.textContent = ""; downloadLink.hidden = true; downloadLink.removeAttribute("href"); downloadLink.removeAttribute("download"); } function clearEditorState() { historyVersions = []; currentVersionId = null; clearSourcePreview(); resetResult(); renderHistory(); updateDownloadCurrent(null); } 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 importPhoto(file) { const form = new FormData(); form.append("photo", file, file.name); const response = await apiFetch("/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(); return data.version; } async function handleFiles(fileList) { const files = Array.from(fileList || []).filter((file) => { const fileType = String(file.type || "").toLowerCase(); if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) { setJobStatus( "error", "Поддерживаются только фотографии JPEG, PNG, WebP или HEIC." ); return false; } if (file.size > MAX_FILE_SIZE) { setJobStatus( "error", "Файл слишком большой. Максимальный размер — 25 МБ." ); return false; } return true; }); if (files.length === 0) { return; } setBusy(true); for (let i = 0; i < files.length; i++) { const file = files[i]; setJobStatus( "loading", files.length > 1 ? `Загрузка ${i + 1} из ${files.length}…` : "Загружаем фотографию в историю…" ); try { await importPhoto(file); } catch (error) { setBusy(false); setJobStatus( "error", error instanceof Error ? error.message : "Не удалось загрузить фотографию." ); return; } } await refreshHistory(true); setJobStatus("", ""); setBusy(false); } async function selectFile(file) { return handleFiles(file ? [file] : []); } 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 apiFetch(url, options = {}) { const response = await fetch(url, { ...options, credentials: "same-origin" }); if (response.status === 401) { clearEditorState(); showAuthOverlay(); throw new Error("Требуется авторизация."); } return response; } 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); if (referenceState) { if (referenceState.source.type === "history") { form.append("referenceId", referenceState.source.versionId); } const blob = await new Promise((resolve) => referenceCanvas.toBlob(resolve, "image/jpeg", 0.92) ); if (blob) { form.append("reference", blob, "reference_annotated.jpg"); } if (referenceState.region) { form.append("region", JSON.stringify(referenceState.region)); } } } resetResult(); setBusy(true); setJobStatus( "loading", isEdit ? "Codex редактирует фотографию… Обычно 1–3 минуты. Страницу можно закрыть — результат появится в истории." : "Увеличиваем разрешение фотографии… Страницу можно закрыть — результат появится в истории." ); try { const response = await apiFetch(endpoint, { method: "POST", body: form, cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось обработать фотографию."); } if (!data.jobId) { throw new Error("Сервер не вернул идентификатор задания."); } pollJob(data.jobId, isEdit); } catch (error) { setBusy(false); setJobStatus( "error", error instanceof Error ? error.message : "Не удалось обработать фотографию." ); } } let jobPollFailures = 0; async function pollJob(jobId, isEdit) { try { const response = await apiFetch(`/api/jobs/${jobId}/status`, { cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось получить статус задания."); } jobPollFailures = 0; if (data.status === "running") { setTimeout(() => pollJob(jobId, isEdit), 3000); return; } if (data.status === "done") { renderJobSuccess(data, isEdit); setBusy(false); return; } if (data.status === "error") { throw new Error(data.error || "Задание завершилось с ошибкой."); } throw new Error("Неизвестный статус задания."); } catch (error) { if (error instanceof Error && error.message === "Требуется авторизация.") { setBusy(false); return; } jobPollFailures += 1; if (jobPollFailures >= 5) { setBusy(false); setJobStatus( "error", error instanceof Error ? error.message : "Ошибка связи с сервером. Проверьте результат в истории." ); return; } setTimeout(() => pollJob(jobId, isEdit), 3000); } } function renderJobSuccess(data, isEdit) { const version = data.version; if (version) { historyVersions.push(version); currentVersionId = version.id; updateDownloadCurrent(version); 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" }); } } 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 "Апскейл"; } return "Правка"; } function buildChildrenMap(versions) { const map = new Map(); // parentId -> [versions] в порядке истории for (const version of versions) { const key = version.parentId || ""; if (!map.has(key)) { map.set(key, []); } map.get(key).push(version); } return map; } function countSubtreeSize(id, childrenMap) { let count = 1; const children = childrenMap.get(id); if (children) { for (const child of children) { count += countSubtreeSize(child.id, childrenMap); } } return count; } function renderTreeNode(version, depth, childrenMap, container) { const node = document.createElement("div"); node.className = "history-node"; node.style.setProperty("--depth", depth); if (version.id === currentVersionId) { node.classList.add("is-current"); } const main = document.createElement("button"); main.type = "button"; main.className = "history-node-main"; main.setAttribute("aria-label", version.label || "Версия"); main.addEventListener("click", () => selectHistoryVersion(version)); const thumb = document.createElement("img"); thumb.className = "history-node-thumb"; thumb.src = version.url; thumb.alt = ""; thumb.loading = "lazy"; const meta = document.createElement("span"); meta.className = "history-node-meta"; const title = document.createElement("strong"); title.className = "history-node-title"; title.textContent = version.label || "Версия"; const badge = document.createElement("span"); badge.className = "history-node-badge"; badge.textContent = historyItemBadge(version); const date = document.createElement("span"); date.className = "history-node-date"; date.textContent = formatHistoryDate(version.createdAt); meta.append(title, badge, date); if (version.kind === "edit" && version.prompt) { const prompt = document.createElement("div"); prompt.className = "history-node-prompt"; prompt.textContent = version.prompt; prompt.title = version.prompt; meta.appendChild(prompt); } main.append(thumb, meta); const actions = document.createElement("div"); actions.className = "history-node-actions"; const download = document.createElement("a"); download.className = "history-node-action history-download"; download.href = version.url; download.download = versionFileName(version); download.textContent = "Скачать"; download.addEventListener("click", (event) => event.stopPropagation()); const remove = document.createElement("button"); remove.type = "button"; remove.className = "history-node-action history-delete"; remove.textContent = "Удалить"; remove.addEventListener("click", (event) => { event.stopPropagation(); deleteVersion(version); }); actions.append(download, remove); node.append(main, actions); if (childrenMap.has(version.id)) { const body = document.createElement("div"); body.className = "history-node-children"; for (const child of childrenMap.get(version.id)) { renderTreeNode(child, depth + 1, childrenMap, body); } node.appendChild(body); } container.appendChild(node); } function rebuildReferenceSelect() { const current = referenceState?.source?.type === "history" ? referenceState.source.versionId : ""; referenceHistorySelect.innerHTML = ""; const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = "— из истории —"; referenceHistorySelect.appendChild(placeholder); for (const version of historyVersions) { const option = document.createElement("option"); option.value = version.id; option.textContent = `${version.label || "Версия"} · ${formatHistoryDate(version.createdAt)}`; referenceHistorySelect.appendChild(option); } referenceHistorySelect.value = current; } function renderHistory() { const list = document.querySelector("#history-list"); const empty = document.querySelector("#history-empty"); list.querySelectorAll(".history-group").forEach((group) => group.remove()); empty.hidden = historyVersions.length > 0; const childrenMap = buildChildrenMap(historyVersions); const roots = historyVersions.filter( (version) => version.kind === "original" || !version.parentId || !childrenMap.has(version.parentId) ); rebuildReferenceSelect(); for (const root of roots) { const group = document.createElement("div"); group.className = "history-group"; group.dataset.group = root.id; const rootNode = document.createElement("div"); rootNode.className = "history-node is-root"; rootNode.style.setProperty("--depth", 0); if (root.id === currentVersionId) { rootNode.classList.add("is-current"); } const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "history-node-toggle"; toggle.setAttribute("aria-label", "Свернуть ветку"); const collapsed = collapsedGroups.has(root.id); toggle.setAttribute("aria-expanded", String(!collapsed)); toggle.textContent = collapsed ? "▸" : "▾"; toggle.addEventListener("click", (event) => { event.stopPropagation(); if (collapsedGroups.has(root.id)) { collapsedGroups.delete(root.id); } else { collapsedGroups.set(root.id, true); } renderHistory(); }); const rootMain = document.createElement("button"); rootMain.type = "button"; rootMain.className = "history-node-main"; rootMain.setAttribute("aria-label", root.label || "Версия"); rootMain.addEventListener("click", () => selectHistoryVersion(root)); const thumb = document.createElement("img"); thumb.className = "history-node-thumb"; thumb.src = root.url; thumb.alt = ""; thumb.loading = "lazy"; const meta = document.createElement("span"); meta.className = "history-node-meta"; const title = document.createElement("strong"); title.className = "history-node-title"; title.textContent = root.label || "Версия"; const badge = document.createElement("span"); badge.className = "history-node-badge"; badge.textContent = historyItemBadge(root); const date = document.createElement("span"); date.className = "history-node-date"; date.textContent = formatHistoryDate(root.createdAt); meta.append(title, badge, date); if (root.kind === "edit" && root.prompt) { const prompt = document.createElement("div"); prompt.className = "history-node-prompt"; prompt.textContent = root.prompt; prompt.title = root.prompt; meta.appendChild(prompt); } rootMain.append(thumb, meta); const actions = document.createElement("div"); actions.className = "history-node-actions"; const download = document.createElement("a"); download.className = "history-node-action history-download"; download.href = root.url; download.download = versionFileName(root); download.textContent = "Скачать"; download.addEventListener("click", (event) => event.stopPropagation()); const remove = document.createElement("button"); remove.type = "button"; remove.className = "history-node-action history-delete"; remove.textContent = "Удалить"; remove.addEventListener("click", (event) => { event.stopPropagation(); deleteVersion(root); }); actions.append(download, remove); rootNode.append(toggle, rootMain, actions); group.appendChild(rootNode); const count = document.createElement("span"); count.className = "history-group-count"; const subtreeSize = countSubtreeSize(root.id, childrenMap); count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`; group.appendChild(count); if (!collapsed) { const body = document.createElement("div"); body.className = "history-group-body"; const children = childrenMap.get(root.id); if (children) { for (const child of children) { renderTreeNode(child, 1, childrenMap, body); } } group.appendChild(body); } list.appendChild(group); } } function selectHistoryVersion(version) { if (isBusy) { return; } currentVersionId = version.id; resetResult(); updateDownloadCurrent(version); showSourcePreviewFromUrl( version.url, version.label || "Версия", version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке" ); renderHistory(); updateSuggestCodexButton(); setJobStatus("", ""); } async function refreshHistory(selectLatest) { const response = await apiFetch("/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 (selectLatest && historyVersions.length > 0) { const latest = historyVersions[historyVersions.length - 1]; currentVersionId = latest.id; updateDownloadCurrent(latest); showSourcePreviewFromUrl( latest.url, latest.label || "Текущая фотография", latest.width && latest.height ? `${latest.width}×${latest.height}` : "Готово к обработке" ); } renderHistory(); updateSuggestCodexButton(); } async function loadHistoryOnStart() { try { await refreshHistory(true); } catch { historyVersions = []; } finally { renderHistory(); setBusy(false); } } async function deleteVersion(version) { if (!confirm("Удалить версию и все её ответвления?")) return; try { const response = await apiFetch(`/api/history/${version.id}`, { method: "DELETE", cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось удалить версию."); } historyVersions = Array.isArray(data.versions) ? data.versions : []; collapsedGroups.delete(version.id); const currentSurvives = historyVersions.some((v) => v.id === currentVersionId); if (!currentSurvives) { const parent = version.parentId ? historyVersions.find((v) => v.id === version.parentId) : null; if (parent) { selectHistoryVersion(parent); } else if (historyVersions.length > 0) { currentVersionId = null; resetResult(); updateDownloadCurrent(null); clearSourcePreview(); renderHistory(); } else { clearEditorState(); } } else { renderHistory(); } if ( referenceState?.source?.type === "history" && referenceState.source.versionId === version.id ) { clearReference(); } } catch (error) { setJobStatus( "error", error instanceof Error ? error.message : "Не удалось удалить версию." ); } } function showAuthOverlay() { authOverlay.hidden = false; logoutButton.hidden = true; loginError.hidden = true; loginPassword.value = ""; loginUsername.focus(); } function hideAuthOverlay() { authOverlay.hidden = true; logoutButton.hidden = false; } async function checkAuth() { try { const response = await apiFetch("/api/me", { cache: "no-store" }); const data = await readJsonResponse(response); if (data.ok) { hideAuthOverlay(); loadHistoryOnStart(); return; } } catch { // Сеть недоступна или 401 — считаем пользователя не вошедшим. } showAuthOverlay(); } async function submitLogin(event) { event.preventDefault(); loginError.hidden = true; loginSubmit.disabled = true; try { const response = await fetch("/api/login", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ username: loginUsername.value.trim(), password: loginPassword.value }) }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Неверный логин или пароль."); } loginPassword.value = ""; hideAuthOverlay(); loadHistoryOnStart(); } catch (error) { loginError.textContent = error instanceof Error ? error.message : "Неверный логин или пароль."; loginError.hidden = false; loginPassword.focus(); } finally { loginSubmit.disabled = false; } } async function logout() { try { await fetch("/api/logout", { method: "POST", credentials: "same-origin" }); } catch { // Выходим локально даже при ошибке сети. } clearEditorState(); showAuthOverlay(); } async function checkHealth() { setHealthState("loading", "Проверяем сервер обработки…"); try { const response = await fetch("/api/health", { cache: "no-store", credentials: "same-origin" }); 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 : "" ); } } function clearReference() { if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { URL.revokeObjectURL(referenceState.source.objectUrl); } referenceState = null; referencePreview.hidden = true; referenceCanvas.width = 0; referenceCanvas.height = 0; referenceNote.hidden = true; referenceRegionInfo.textContent = "Область не выбрана — потяните по превью"; referenceHistorySelect.value = ""; } function drawRegion(ctx) { const region = referenceState?.region; if (!region) { return; } ctx.strokeStyle = "rgba(220,38,38,0.95)"; ctx.lineWidth = 3; ctx.fillStyle = "rgba(220,38,38,0.18)"; ctx.fillRect(region.x, region.y, region.w, region.h); ctx.strokeRect(region.x, region.y, region.w, region.h); } function drawReferenceToCanvas(image) { const naturalWidth = image.naturalWidth || image.width; const naturalHeight = image.naturalHeight || image.height; if (!naturalWidth || !naturalHeight) { return; } const scale = Math.min( 1, REFERENCE_MAX_DIMENSION / Math.max(naturalWidth, naturalHeight) ); referenceCanvas.width = Math.round(naturalWidth * scale); referenceCanvas.height = Math.round(naturalHeight * scale); const ctx = referenceCanvas.getContext("2d"); ctx.clearRect(0, 0, referenceCanvas.width, referenceCanvas.height); ctx.drawImage(image, 0, 0, referenceCanvas.width, referenceCanvas.height); drawRegion(ctx); } function redrawReferenceCanvas() { const image = referenceState?.source?.image; if (!image || !referenceCanvas.width) { return; } const ctx = referenceCanvas.getContext("2d"); ctx.clearRect(0, 0, referenceCanvas.width, referenceCanvas.height); ctx.drawImage(image, 0, 0, referenceCanvas.width, referenceCanvas.height); drawRegion(ctx); updateReferenceNote(); } function updateReferenceNote() { if (!referenceState) { referenceNote.hidden = true; referenceRegionInfo.textContent = "Область не выбрана — потяните по превью"; return; } if (referenceState.region) { const region = referenceState.region; referenceNote.hidden = true; referenceRegionInfo.textContent = `Область: ${region.x},${region.y} ${region.w}×${region.h}`; } else { referenceNote.textContent = "Область не выбрана — Codex использует референс как ориентир"; referenceNote.hidden = false; referenceRegionInfo.textContent = "Область не выбрана — потяните по превью"; } } function canvasPointFromEvent(event) { const rect = referenceCanvas.getBoundingClientRect(); const x = (event.clientX - rect.left) * (referenceCanvas.width / rect.width); const y = (event.clientY - rect.top) * (referenceCanvas.height / rect.height); return { x: Math.min(Math.max(0, x), referenceCanvas.width), y: Math.min(Math.max(0, y), referenceCanvas.height) }; } function loadReferenceFromFile(file) { if (!file) { return; } const fileType = String(file.type || "").toLowerCase(); if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(fileType)) { if (isHeicFile(file)) { referenceNote.textContent = "HEIC не поддерживается как референс — выберите JPEG, PNG или WebP"; referenceNote.hidden = false; } else { setJobStatus("error", "Референс должен быть JPEG, PNG или WebP."); } return; } if (file.size > MAX_FILE_SIZE) { setJobStatus("error", "Файл референса слишком большой. Максимальный размер — 25 МБ."); return; } if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { URL.revokeObjectURL(referenceState.source.objectUrl); } const objectUrl = URL.createObjectURL(file); referenceState = { source: { type: "file", file, objectUrl }, region: null }; const image = new Image(); image.onload = () => { referenceState.source.image = image; drawReferenceToCanvas(image); referencePreview.hidden = false; updateReferenceNote(); }; image.onerror = () => { URL.revokeObjectURL(objectUrl); referenceState = null; setJobStatus("error", "Не удалось прочитать изображение референса."); }; image.src = objectUrl; } function loadReferenceFromHistory(version) { if (!version) { return; } if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { URL.revokeObjectURL(referenceState.source.objectUrl); } referenceState = { source: { type: "history", versionId: version.id, url: version.url }, region: null }; const image = new Image(); image.onload = () => { referenceState.source.image = image; drawReferenceToCanvas(image); referencePreview.hidden = false; updateReferenceNote(); }; image.onerror = () => { referenceState = null; setJobStatus("error", "Не удалось загрузить референс из истории."); }; image.src = version.url; } let regionDragStart = null; referenceCanvas.addEventListener("pointerdown", (event) => { if (isBusy || !referenceState) { return; } event.preventDefault(); referenceCanvas.setPointerCapture(event.pointerId); const pos = canvasPointFromEvent(event); regionDragStart = pos; referenceState.region = { x: pos.x, y: pos.y, w: 0, h: 0 }; redrawReferenceCanvas(); updateReferenceNote(); }); referenceCanvas.addEventListener("pointermove", (event) => { if (isBusy || !referenceState || !regionDragStart) { return; } const pos = canvasPointFromEvent(event); const x = Math.min(regionDragStart.x, pos.x); const y = Math.min(regionDragStart.y, pos.y); const w = Math.max(regionDragStart.x, pos.x) - x; const h = Math.max(regionDragStart.y, pos.y) - y; referenceState.region = { x, y, w, h }; redrawReferenceCanvas(); updateReferenceNote(); }); referenceCanvas.addEventListener("pointerup", () => { if (regionDragStart) { regionDragStart = null; if ( referenceState?.region && (referenceState.region.w < 3 || referenceState.region.h < 3) ) { referenceState.region = null; redrawReferenceCanvas(); updateReferenceNote(); } } }); referenceCanvas.addEventListener("pointercancel", () => { regionDragStart = null; }); function renderSuggestChips(container, items) { container.innerHTML = ""; for (const item of items) { const chip = document.createElement("button"); chip.type = "button"; chip.className = "suggest-chip"; chip.textContent = item; chip.title = item; chip.addEventListener("click", () => { promptInput.value = item; promptInput.dispatchEvent(new Event("input")); }); container.appendChild(chip); } } async function loadSuggestHistoryChips() { try { const response = await apiFetch("/api/suggest-prompts", { cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось получить подсказки."); } renderSuggestChips(suggestHistoryChips, data.prompts || []); } catch (error) { if (error instanceof Error && error.message === "Требуется авторизация.") { return; } suggestCodexStatus.textContent = "Не удалось загрузить подсказки из истории."; } } async function runSuggest() { if (isBusy || suggestRunning || !currentVersionId) { return; } suggestRunning = true; suggestCodexButton.disabled = true; suggestCodexStatus.textContent = "Codex думает…"; const form = new FormData(); form.append("sourceId", currentVersionId); try { const response = await apiFetch("/api/suggest", { method: "POST", body: form, cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось запросить подсказку."); } if (!data.jobId) { throw new Error("Сервер не вернул идентификатор задания."); } pollSuggestJob(data.jobId); } catch (error) { suggestRunning = false; updateSuggestCodexButton(); suggestCodexStatus.textContent = error instanceof Error ? error.message : "Не удалось запросить подсказку."; } } async function pollSuggestJob(jobId) { try { const response = await apiFetch(`/api/jobs/${jobId}/status`, { cache: "no-store" }); const data = await readJsonResponse(response); if (!response.ok || !data.ok) { throw new Error(data.error || "Не удалось получить статус задания."); } suggestPollFailures = 0; if (data.status === "running") { setTimeout(() => pollSuggestJob(jobId), 3000); return; } if (data.status === "done") { renderSuggestChips(suggestCodexChips, data.suggestions || []); suggestCodexStatus.textContent = "Варианты готовы — нажмите, чтобы вставить"; suggestRunning = false; updateSuggestCodexButton(); return; } if (data.status === "error") { throw new Error(data.error || "Задание завершилось с ошибкой."); } throw new Error("Неизвестный статус задания."); } catch (error) { if (error instanceof Error && error.message === "Требуется авторизация.") { setBusy(false); suggestRunning = false; suggestCodexButton.disabled = true; return; } suggestPollFailures += 1; if (suggestPollFailures >= 5) { suggestRunning = false; updateSuggestCodexButton(); suggestCodexStatus.textContent = error instanceof Error ? error.message : "Ошибка связи с сервером. Попробуйте ещё раз."; return; } setTimeout(() => pollSuggestJob(jobId), 3000); } } dropZone.addEventListener("click", openFilePicker); dropZone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openFilePicker(); } }); photoInput.addEventListener("change", () => { handleFiles(photoInput.files); 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) { handleFiles(event.dataTransfer?.files); } }); promptInput.addEventListener("input", () => { promptCounter.textContent = `${promptInput.value.length} / 1000`; }); editButton.addEventListener("click", () => runJob("edit")); upscaleButton.addEventListener("click", () => runJob("upscale")); referenceUploadButton.addEventListener("click", () => { if (!isBusy) { referenceFileInput.click(); } }); referenceFileInput.addEventListener("change", () => { loadReferenceFromFile(referenceFileInput.files?.[0]); referenceFileInput.value = ""; }); referenceHistorySelect.addEventListener("change", () => { const version = historyVersions.find( (entry) => entry.id === referenceHistorySelect.value ); if (version) { loadReferenceFromHistory(version); } }); referenceClearButton.addEventListener("click", clearReference); referenceRegionReset.addEventListener("click", () => { if (!referenceState) { return; } referenceState.region = null; redrawReferenceCanvas(); updateReferenceNote(); }); suggestButton.addEventListener("click", () => { suggestPanel.hidden = !suggestPanel.hidden; if (!suggestPanel.hidden) { loadSuggestHistoryChips(); } }); suggestCodexButton.addEventListener("click", runSuggest); loginForm.addEventListener("submit", submitLogin); logoutButton.addEventListener("click", logout); window.addEventListener("beforeunload", () => { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); } if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { URL.revokeObjectURL(referenceState.source.objectUrl); } }); checkHealth(); checkAuth();