Files
photo-editor/public/app.js

1522 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();