Files
photo-editor/public/app.js

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