Files
photo-editor/public/app.js

725 lines
18 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");
let currentVersionId = null;
let historyVersions = [];
let sourceObjectUrl = null;
let isBusy = false;
let fileSelectionSequence = 0;
function isHeicFile(file) {
const type = String(file?.type || "").toLowerCase();
const name = String(file?.name || "").toLowerCase();
return (
type === "image/heic" ||
type === "image/heif" ||
name.endsWith(".heic") ||
name.endsWith(".heif")
);
}
function formatFileSize(bytes) {
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
function 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;
dropZone.setAttribute("aria-disabled", String(busy));
}
function resetResult() {
resultImage.hidden = true;
resultImage.removeAttribute("src");
resultPlaceholder.hidden = false;
resultLabel.textContent = "";
downloadLink.hidden = true;
downloadLink.removeAttribute("href");
downloadLink.removeAttribute("download");
}
function 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 selectFile(file) {
const selectionToken = ++fileSelectionSequence;
if (!file) {
return;
}
const fileType = String(file.type || "").toLowerCase();
if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) {
setJobStatus(
"error",
"Поддерживаются только фотографии JPEG, PNG, WebP или HEIC."
);
return;
}
if (file.size > MAX_FILE_SIZE) {
setJobStatus(
"error",
"Файл слишком большой. Максимальный размер — 25 МБ."
);
return;
}
setBusy(true);
setJobStatus("loading", "Загружаем фотографию в историю…");
const form = new FormData();
form.append("photo", file, file.name);
try {
const response = await 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
: "Не удалось загрузить фотографию."
);
}
}
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);
}
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 || "Не удалось обработать фотографию.");
}
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"
});
}
} catch (error) {
setJobStatus(
"error",
error instanceof Error
? error.message
: "Не удалось обработать фотографию."
);
} finally {
setBusy(false);
}
}
function formatHistoryDate(iso) {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
} catch {
return "";
}
}
function historyItemBadge(version) {
if (version.kind === "original") {
return "загрузка";
}
if (version.kind === "upscale") {
return "4× upscale";
}
return "Codex";
}
function renderHistory() {
const list = document.querySelector("#history-list");
const empty = document.querySelector("#history-empty");
list.querySelectorAll(".history-item").forEach((item) => item.remove());
empty.hidden = historyVersions.length > 0;
for (const version of historyVersions) {
const item = document.createElement("button");
item.type = "button";
item.className = "history-item";
item.dataset.id = version.id;
item.setAttribute("aria-label", version.label || "Версия");
if (version.id === currentVersionId) {
item.classList.add("is-current");
}
const thumb = document.createElement("img");
thumb.src = version.url;
thumb.alt = "";
thumb.loading = "lazy";
const meta = document.createElement("span");
meta.className = "history-item-meta";
const title = document.createElement("strong");
title.textContent = version.label || "Версия";
const sub = document.createElement("span");
const size =
version.width && version.height
? ` · ${version.width}×${version.height}`
: "";
sub.textContent = `${formatHistoryDate(version.createdAt)}${size}`;
meta.append(title, sub);
const badge = document.createElement("span");
badge.className = "history-item-badge";
badge.textContent = historyItemBadge(version);
item.append(thumb, meta, badge);
item.addEventListener("click", () => selectHistoryVersion(version));
list.appendChild(item);
}
}
function selectHistoryVersion(version) {
if (isBusy) {
return;
}
currentVersionId = version.id;
resetResult();
updateDownloadCurrent(version);
showSourcePreviewFromUrl(
version.url,
version.label || "Версия",
version.width && version.height
? `${version.width}×${version.height}`
: "Готово к обработке"
);
renderHistory();
setJobStatus("", "");
}
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}`
: "Готово к обработке"
);
}
} catch {
historyVersions = [];
} finally {
renderHistory();
setBusy(false);
}
}
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 : ""
);
}
}
dropZone.addEventListener("click", openFilePicker);
dropZone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openFilePicker();
}
});
photoInput.addEventListener("change", () => {
selectFile(photoInput.files?.[0]);
photoInput.value = "";
});
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, preventDragDefaults);
});
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, () => {
if (!isBusy) {
dropZone.classList.add("is-dragging");
}
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove("is-dragging");
});
});
dropZone.addEventListener("drop", (event) => {
if (!isBusy) {
selectFile(event.dataTransfer?.files?.[0]);
}
});
promptInput.addEventListener("input", () => {
promptCounter.textContent = `${promptInput.value.length} / 1000`;
});
editButton.addEventListener("click", () => runJob("edit"));
upscaleButton.addEventListener("click", () => runJob("upscale"));
loginForm.addEventListener("submit", submitLogin);
logoutButton.addEventListener("click", logout);
window.addEventListener("beforeunload", () => {
if (sourceObjectUrl) {
URL.revokeObjectURL(sourceObjectUrl);
}
});
checkHealth();
checkAuth();