576 lines
14 KiB
JavaScript
576 lines
14 KiB
JavaScript
"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 редактирует фотографию… Обычно 1–3 минуты."
|
||
: "Увеличиваем разрешение фотографии…"
|
||
);
|
||
|
||
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();
|