From f91aa9433651da49f6c47fd9e85293968c69b391 Mon Sep 17 00:00:00 2001 From: Coder Date: Wed, 26 Aug 2026 19:17:36 +0700 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8,=20=D1=83=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B9,=20=D0=BC=D1=83=D0=BB=D1=8C=D1=82=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0,=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20=D1=80=D0=B5=D1=84=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D1=81=D0=BE=D0=BC=20=D0=B8=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=8C=D1=8E,=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BF=D1=82=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 ++ public/app.js | 988 ++++++++++++++++++++++++++++++++++++++++------ public/index.html | 37 +- public/style.css | 366 +++++++++++++++-- server.js | 391 ++++++++++++++++-- 5 files changed, 1619 insertions(+), 190 deletions(-) diff --git a/README.md b/README.md index f18d8fd..5be7904 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,28 @@ Codex CLI) и увеличивает разрешение через домаш `GET /api/jobs/:id/status` каждые 3 секунды; страницу можно закрыть — готовая версия появится в истории. +- **Дерево истории**: вместо плоской ленты версии показаны деревом — каждая + исходная фотография образует группу со своей веткой (исходник → правки → + апскейлы), потомки выстраиваются по `parentId` с отступами, а сироты + (версии, чей родитель уже удалён или оттеснён лимитом) показываются как + отдельные корни. Ветку можно свернуть, под каждой edit-версией виден её + промпт (обрезка до двух строк, полный текст — в подсказке). +- **Удаление версий**: на узле (при наведении) есть кнопка «Удалить» — + версия удаляется вместе со всеми потомками и их файлами + (`DELETE /api/history/:id`, каскадно по `parentId`). +- **Мультизагрузка**: в поле «Фотография» можно выбрать сразу несколько + файлов — каждый становится отдельной исходной версией (своей группой), + импорт идёт последовательно с прогрессом «Загрузка N из M…». +- **Редактирование с референсом и областью**: перед правкой можно задать + референс — файл или версию из истории. На превью референса рисуется + прямоугольник области; сервер передаёт Codex обе картинки (повторяемый + флаг `-i`) и кладёт координаты области в промпт. Если область не выбрана, + референс всё равно передаётся как ориентир по сюжету и ракурсу. +- **Подсказки промптов**: кнопка «💡 Предложить промпт» — мгновенные + варианты из истории (`GET /api/suggest-prompts`), а «Спросить Codex» — + асинхронный джоб `POST /api/suggest` (тот же механизм опроса статуса, что + и у редактирования). + ## Требования - Node.js 20 или новее @@ -79,6 +101,11 @@ npm start `GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login` с JSON-телом `{"username": "...", "password": "..."}`. +Остальные эндпоинты (все требуют авторизации): `GET /api/history`, +`POST /api/history/import`, `DELETE /api/history/:id`, +`GET /api/suggest-prompts`, `POST /api/suggest`, `POST /api/preview`, +`POST /api/edit`, `POST /api/upscale`, `GET /api/jobs/:id/status`. + ## Логирование Все действия сервера пишутся в стандартный вывод контейнера — смотрите их в diff --git a/public/app.js b/public/app.js index 1642480..9f0aa49 100644 --- a/public/app.js +++ b/public/app.js @@ -44,11 +44,32 @@ 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 fileSelectionSequence = 0; +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(); @@ -129,9 +150,21 @@ function setBusy(busy) { 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"); @@ -181,91 +214,95 @@ function showSourcePreviewFromUrl(url, title, meta) { fileMeta.textContent = meta || "Готово к обработке"; } -async function selectFile(file) { - const selectionToken = ++fileSelectionSequence; +async function importPhoto(file) { + const form = new FormData(); + form.append("photo", file, file.name); - if (!file) { - return; + 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 fileType = String(file.type || "").toLowerCase(); + const data = await response.json(); + return data.version; +} - if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) { - setJobStatus( - "error", - "Поддерживаются только фотографии JPEG, PNG, WebP или HEIC." - ); - return; - } +async function handleFiles(fileList) { + const files = Array.from(fileList || []).filter((file) => { + const fileType = String(file.type || "").toLowerCase(); - if (file.size > MAX_FILE_SIZE) { - setJobStatus( - "error", - "Файл слишком большой. Максимальный размер — 25 МБ." - ); + 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); - setJobStatus("loading", "Загружаем фотографию в историю…"); - const form = new FormData(); - form.append("photo", file, file.name); + for (let i = 0; i < files.length; i++) { + const file = files[i]; - try { - 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(); - - if (selectionToken !== fileSelectionSequence) { - return; - } - - historyVersions.push(data.version); - currentVersionId = data.version.id; - - renderHistory(); - resetResult(); - showSourcePreviewFromUrl( - data.version.url, - file.name, - `${formatFileSize(file.size)} · готово к обработке` - ); - updateDownloadCurrent(data.version); - setJobStatus("", ""); - setBusy(false); - } catch (error) { - if (selectionToken !== fileSelectionSequence) { - return; - } - - setBusy(false); setJobStatus( - "error", - error instanceof Error - ? error.message - : "Не удалось загрузить фотографию." + "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() { @@ -335,6 +372,24 @@ async function runJob(kind) { 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(); @@ -489,61 +544,285 @@ function formatHistoryDate(iso) { function historyItemBadge(version) { if (version.kind === "original") { - return "загрузка"; + return "Исходник"; } if (version.kind === "upscale") { - return "4× upscale"; + return "Апскейл"; } - return "Codex"; + 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-item").forEach((item) => item.remove()); + list.querySelectorAll(".history-group").forEach((group) => group.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 || "Версия"); + const childrenMap = buildChildrenMap(historyVersions); + const roots = historyVersions.filter( + (version) => + version.kind === "original" || + !version.parentId || + !childrenMap.has(version.parentId) + ); - if (version.id === currentVersionId) { - item.classList.add("is-current"); + 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.src = version.url; + thumb.className = "history-node-thumb"; + thumb.src = root.url; thumb.alt = ""; thumb.loading = "lazy"; const meta = document.createElement("span"); - meta.className = "history-item-meta"; + meta.className = "history-node-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); + title.className = "history-node-title"; + title.textContent = root.label || "Версия"; const badge = document.createElement("span"); - badge.className = "history-item-badge"; - badge.textContent = historyItemBadge(version); + badge.className = "history-node-badge"; + badge.textContent = historyItemBadge(root); - item.append(thumb, meta, badge); - item.addEventListener("click", () => selectHistoryVersion(version)); - list.appendChild(item); + 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); } } @@ -564,34 +843,42 @@ function selectHistoryVersion(version) { : "Готово к обработке" ); 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 { - 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 (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}` - : "Готово к обработке" - ); - } + await refreshHistory(true); } catch { historyVersions = []; } finally { @@ -600,6 +887,59 @@ async function loadHistoryOnStart() { } } +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; @@ -727,6 +1067,355 @@ async function checkHealth() { } } +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) => { @@ -737,7 +1426,7 @@ dropZone.addEventListener("keydown", (event) => { }); photoInput.addEventListener("change", () => { - selectFile(photoInput.files?.[0]); + handleFiles(photoInput.files); photoInput.value = ""; }); @@ -761,7 +1450,7 @@ photoInput.addEventListener("change", () => { dropZone.addEventListener("drop", (event) => { if (!isBusy) { - selectFile(event.dataTransfer?.files?.[0]); + handleFiles(event.dataTransfer?.files); } }); @@ -772,6 +1461,49 @@ promptInput.addEventListener("input", () => { 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); @@ -779,6 +1511,10 @@ window.addEventListener("beforeunload", () => { if (sourceObjectUrl) { URL.revokeObjectURL(sourceObjectUrl); } + + if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { + URL.revokeObjectURL(referenceState.source.objectUrl); + } }); checkHealth(); diff --git a/public/index.html b/public/index.html index 4de1584..a4f77d0 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,7 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E" > - +
@@ -66,6 +66,7 @@ id="photo-input" type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif" + multiple hidden > @@ -82,6 +83,26 @@ +
+ Референс (необязательно) +
+ + + +
+ + + +
+