Initial commit: AI-фоторедактор (Codex-редактирование, ComfyUI upscale, HEIC, история версий, Docker, Gitea Actions)
This commit is contained in:
575
public/app.js
Normal file
575
public/app.js
Normal file
@@ -0,0 +1,575 @@
|
||||
"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();
|
||||
Reference in New Issue
Block a user