Initial commit: AI-фоторедактор (Codex-редактирование, ComfyUI upscale, HEIC, история версий, Docker, Gitea Actions)

This commit is contained in:
2026-08-25 21:28:55 +07:00
commit fc74c8f543
13 changed files with 4628 additions and 0 deletions

575
public/app.js Normal file
View 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 редактирует фотографию… Обычно 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();

229
public/index.html Normal file
View File

@@ -0,0 +1,229 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
<meta
name="description"
content="Локальный редактор фотографий по текстовому промпту с увеличением разрешения."
>
<title>Кадр — AI-фоторедактор</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E"
>
<link rel="stylesheet" href="/style.css?v=7">
</head>
<body>
<header class="topbar">
<a class="brand" href="/" aria-label="Кадр, главная">
<span class="brand-mark" aria-hidden="true">К</span>
<span class="brand-name">Кадр</span>
</a>
<div
id="health-status"
class="health-status health-loading"
role="status"
aria-live="polite"
>
<span class="health-dot" aria-hidden="true"></span>
<span id="health-text">Проверяем сервер обработки…</span>
</div>
</header>
<main class="editor-layout">
<section class="control-panel" aria-labelledby="page-title">
<div class="intro">
<p class="eyebrow">Локальная AI-студия</p>
<h1 id="page-title">Меняйте кадр словами.</h1>
<p class="lead">
Загрузите фотографию, опишите желаемое изменение или увеличьте
разрешение одним нажатием.
</p>
</div>
<div class="control-stack">
<div class="field-group">
<span class="field-label">Фотография</span>
<div
id="drop-zone"
class="drop-zone"
role="button"
tabindex="0"
aria-label="Выбрать или перетащить фотографию"
>
<input
id="photo-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
hidden
>
<span class="upload-symbol" aria-hidden="true"></span>
<span class="drop-copy">
<strong id="drop-title">Перетащите фото сюда</strong>
<span id="file-meta">
или нажмите, чтобы выбрать JPEG, PNG или WebP
</span>
</span>
<span class="choose-chip">Выбрать</span>
</div>
</div>
<div class="field-group">
<label class="field-label" for="prompt">Промпт</label>
<textarea
id="prompt"
rows="4"
maxlength="1000"
placeholder="Например: сделай закат, фотореалистично"
></textarea>
<div class="field-foot">
<span>Опишите свет, настроение и нужные детали.</span>
<span id="prompt-counter">0 / 1000</span>
</div>
</div>
<div class="actions">
<button
id="edit-button"
class="button button-primary"
type="button"
disabled
>
<span>Редактировать</span>
<span class="button-arrow" aria-hidden="true"></span>
</button>
<button
id="upscale-button"
class="button button-secondary"
type="button"
disabled
>
<span class="spark" aria-hidden="true"></span>
<span>Увеличить разрешение</span>
</button>
</div>
<p class="privacy-note">
Фото передаётся только вашему локальному серверу ComfyUI.
</p>
</div>
</section>
<section
id="workspace"
class="workspace"
aria-labelledby="workspace-title"
>
<div class="workspace-head">
<div>
<p class="workspace-kicker">Предпросмотр</p>
<h2 id="workspace-title">До и после</h2>
</div>
<span class="local-badge">LAN · локально</span>
</div>
<div class="comparison">
<figure class="photo-frame">
<figcaption>Исходник</figcaption>
<div class="image-stage">
<img
id="source-image"
alt="Выбранная исходная фотография"
hidden
>
<div id="source-placeholder" class="placeholder">
<span class="placeholder-frame" aria-hidden="true"></span>
<strong>Здесь появится фотография</strong>
<span>Выберите файл слева, чтобы начать</span>
</div>
</div>
</figure>
<figure class="photo-frame result-frame">
<figcaption>
<span>Результат</span>
<span id="result-label" class="result-kind"></span>
</figcaption>
<div class="image-stage">
<img
id="result-image"
alt="Обработанная фотография"
hidden
>
<div id="result-placeholder" class="placeholder">
<span class="placeholder-spark" aria-hidden="true"></span>
<strong>Результат появится здесь</strong>
<span>Редактирование через Codex — 13 минуты, апскейл — несколько секунд</span>
</div>
</div>
</figure>
</div>
<div
id="job-status"
class="job-status"
role="status"
aria-live="polite"
hidden
>
<div class="status-row">
<span class="status-spinner" aria-hidden="true"></span>
<span id="job-status-text"></span>
</div>
<div class="progress-track" aria-hidden="true">
<span class="progress-bar"></span>
</div>
</div>
<div class="result-actions">
<a
id="download-link"
class="download-link"
href="#"
download
hidden
>
Скачать результат
<span aria-hidden="true"></span>
</a>
</div>
<section class="history-section" aria-labelledby="history-title">
<div class="history-head">
<h2 id="history-title">История версий</h2>
<span class="history-hint">
Нажмите на версию, чтобы вернуться к ней и продолжить правки
</span>
</div>
<div id="history-list" class="history-list">
<div id="history-empty" class="history-empty">
Пока пусто — загрузите фото, и все версии появятся здесь
</div>
</div>
</section>
</section>
</main>
<noscript>
<div class="noscript">
Для работы редактора необходимо включить JavaScript.
</div>
</noscript>
<script src="/app.js?v=7" defer></script>
</body>
</html>

955
public/style.css Normal file
View File

@@ -0,0 +1,955 @@
:root {
color-scheme: light;
--paper: #f3f0e8;
--panel: #fbfaf6;
--ink: #171817;
--muted: #666962;
--line: #d9d7ce;
--dark: #202321;
--accent: #6c5ce7;
--accent-dark: #5544d2;
--accent-soft: #e7e2ff;
--success: #20825c;
--warning: #b06e1f;
--danger: #b84343;
--shadow: 0 26px 70px rgba(28, 31, 29, 0.12);
--radius-large: 28px;
--radius-medium: 18px;
--radius-small: 12px;
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html {
min-width: 320px;
min-height: 100%;
background: var(--paper);
}
body {
min-height: 100vh;
margin: 0;
color: var(--ink);
background:
radial-gradient(circle at 85% 10%, rgba(108, 92, 231, 0.12), transparent 28rem),
linear-gradient(135deg, rgba(255, 255, 255, 0.28), transparent 55%),
var(--paper);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
button,
textarea,
input {
font: inherit;
}
button,
[role="button"],
a {
-webkit-tap-highlight-color: transparent;
}
.topbar {
display: flex;
min-height: 78px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 16px clamp(20px, 4vw, 64px);
border-bottom: 1px solid rgba(23, 24, 23, 0.1);
}
.brand {
display: inline-flex;
align-items: center;
gap: 11px;
color: inherit;
text-decoration: none;
}
.brand-mark {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border-radius: 11px;
color: white;
background: var(--dark);
font-size: 18px;
font-weight: 750;
transform: rotate(-3deg);
}
.brand-name {
font-size: 19px;
font-weight: 760;
letter-spacing: -0.03em;
}
.health-status {
display: inline-flex;
max-width: 440px;
align-items: center;
gap: 9px;
color: var(--muted);
font-size: 13px;
font-weight: 620;
text-align: right;
}
.health-dot {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 50%;
background: #a9aaa6;
box-shadow: 0 0 0 4px rgba(169, 170, 166, 0.15);
}
.health-loading .health-dot {
animation: health-pulse 1.4s ease-in-out infinite;
}
.health-ok .health-dot {
background: var(--success);
box-shadow: 0 0 0 4px rgba(32, 130, 92, 0.13);
}
.health-warning .health-dot {
background: var(--warning);
box-shadow: 0 0 0 4px rgba(176, 110, 31, 0.13);
}
.health-error .health-dot {
background: var(--danger);
box-shadow: 0 0 0 4px rgba(184, 67, 67, 0.13);
}
.editor-layout {
display: grid;
grid-template-columns: minmax(310px, 0.72fr) minmax(520px, 1.45fr);
gap: clamp(24px, 4vw, 58px);
width: min(1500px, 100%);
margin: 0 auto;
padding: clamp(28px, 5vw, 72px) clamp(20px, 4vw, 64px) 64px;
}
.control-panel {
align-self: start;
}
.intro {
margin-bottom: 36px;
}
.eyebrow,
.workspace-kicker {
margin: 0 0 10px;
color: var(--accent-dark);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
max-width: 520px;
margin-bottom: 18px;
font-size: clamp(40px, 5vw, 70px);
font-weight: 720;
line-height: 0.96;
letter-spacing: -0.065em;
}
.lead {
max-width: 520px;
margin-bottom: 0;
color: var(--muted);
font-size: clamp(15px, 1.5vw, 18px);
line-height: 1.65;
}
.control-stack {
display: grid;
gap: 23px;
}
.field-group {
display: grid;
gap: 10px;
}
.field-label {
font-size: 13px;
font-weight: 750;
}
.drop-zone {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 15px;
min-height: 104px;
padding: 17px;
border: 1.5px dashed #bdbbb1;
border-radius: var(--radius-medium);
background: rgba(251, 250, 246, 0.65);
cursor: pointer;
outline: none;
transition:
border-color 160ms ease,
background 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.drop-zone:hover,
.drop-zone:focus-visible,
.drop-zone.is-dragging {
border-color: var(--accent);
background: #faf8ff;
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1);
transform: translateY(-1px);
}
.drop-zone.has-file {
border-style: solid;
border-color: rgba(32, 130, 92, 0.45);
background: rgba(239, 250, 245, 0.72);
}
.upload-symbol {
display: grid;
width: 46px;
height: 46px;
flex: 0 0 auto;
place-items: center;
border-radius: 14px;
color: var(--accent-dark);
background: var(--accent-soft);
font-size: 27px;
font-weight: 300;
}
.has-file .upload-symbol {
color: var(--success);
background: rgba(32, 130, 92, 0.11);
}
.drop-copy {
display: grid;
min-width: 0;
gap: 4px;
}
.drop-copy strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.drop-copy span {
overflow: hidden;
color: var(--muted);
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.choose-chip {
padding: 8px 11px;
border-radius: 999px;
color: var(--dark);
background: #e8e7e1;
font-size: 11px;
font-weight: 750;
}
textarea {
width: 100%;
min-height: 120px;
resize: vertical;
padding: 16px 17px;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
outline: none;
color: var(--ink);
background: var(--panel);
line-height: 1.55;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset;
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
textarea::placeholder {
color: #9a9b96;
}
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1);
}
textarea:disabled {
cursor: wait;
opacity: 0.72;
}
.field-foot {
display: flex;
justify-content: space-between;
gap: 14px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.button {
min-height: 54px;
padding: 13px 16px;
border: 0;
border-radius: 15px;
cursor: pointer;
font-size: 13px;
font-weight: 760;
transition:
transform 150ms ease,
background 150ms ease,
box-shadow 150ms ease,
opacity 150ms ease;
}
.button:not(:disabled):hover {
transform: translateY(-2px);
}
.button:not(:disabled):active {
transform: translateY(0);
}
.button:focus-visible {
outline: 3px solid rgba(108, 92, 231, 0.28);
outline-offset: 2px;
}
.button:disabled {
cursor: not-allowed;
opacity: 0.43;
}
.button-primary {
display: flex;
align-items: center;
justify-content: space-between;
color: white;
background: var(--accent);
box-shadow: 0 10px 25px rgba(108, 92, 231, 0.24);
}
.button-primary:not(:disabled):hover {
background: var(--accent-dark);
box-shadow: 0 14px 28px rgba(108, 92, 231, 0.29);
}
.button-arrow {
font-size: 20px;
font-weight: 400;
}
.button-secondary {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
color: var(--ink);
background: #deddd6;
}
.button-secondary:not(:disabled):hover {
background: #d2d0c8;
}
.spark {
color: var(--accent-dark);
font-size: 15px;
}
.privacy-note {
margin: -4px 0 0;
color: var(--muted);
font-size: 11px;
line-height: 1.5;
}
.workspace {
min-width: 0;
padding: clamp(20px, 3vw, 32px);
border: 1px solid rgba(23, 24, 23, 0.08);
border-radius: var(--radius-large);
background: rgba(251, 250, 246, 0.84);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
.workspace-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
}
.workspace-kicker {
margin-bottom: 5px;
}
.workspace h2 {
margin-bottom: 0;
font-size: clamp(24px, 3vw, 34px);
letter-spacing: -0.045em;
}
.local-badge {
padding: 8px 11px;
border: 1px solid #dad8d0;
border-radius: 999px;
color: var(--muted);
background: rgba(255, 255, 255, 0.58);
font-size: 10px;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.comparison {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.photo-frame {
min-width: 0;
margin: 0;
}
.photo-frame figcaption {
display: flex;
min-height: 28px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 2px;
color: var(--muted);
font-size: 11px;
font-weight: 760;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.result-kind {
overflow: hidden;
max-width: 60%;
color: var(--accent-dark);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-stage {
position: relative;
display: grid;
min-height: clamp(360px, 54vw, 660px);
overflow: hidden;
place-items: center;
border-radius: 18px;
background:
linear-gradient(45deg, #252826 25%, transparent 25%),
linear-gradient(-45deg, #252826 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #252826 75%),
linear-gradient(-45deg, transparent 75%, #252826 75%),
#2b2e2c;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
background-size: 16px 16px;
}
.image-stage::after {
position: absolute;
inset: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: inherit;
content: "";
pointer-events: none;
}
.image-stage img {
display: block;
width: 100%;
height: 100%;
max-height: 660px;
object-fit: contain;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
padding: 30px;
color: rgba(255, 255, 255, 0.76);
text-align: center;
}
.placeholder strong {
font-size: 14px;
}
.placeholder > span:last-child {
color: rgba(255, 255, 255, 0.45);
font-size: 11px;
line-height: 1.5;
}
.placeholder-frame {
position: relative;
width: 58px;
height: 47px;
margin-bottom: 8px;
border: 1.5px solid rgba(255, 255, 255, 0.27);
border-radius: 9px;
}
.placeholder-frame::before {
position: absolute;
right: 9px;
bottom: 9px;
left: 9px;
height: 17px;
background:
linear-gradient(140deg, transparent 45%, rgba(255, 255, 255, 0.19) 46% 69%, transparent 70%),
linear-gradient(40deg, transparent 35%, rgba(255, 255, 255, 0.13) 36% 62%, transparent 63%);
content: "";
}
.placeholder-frame::after {
position: absolute;
top: 9px;
right: 10px;
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.28);
content: "";
}
.placeholder-spark {
display: grid;
width: 52px;
height: 52px;
margin-bottom: 7px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 50%;
color: #bcb2ff !important;
background: rgba(108, 92, 231, 0.17);
font-size: 20px !important;
}
.job-status {
margin-top: 16px;
padding: 14px 16px;
border: 1px solid #dedbd0;
border-radius: 14px;
background: #f2f0e8;
}
.status-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 19px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.status-spinner {
width: 14px;
height: 14px;
flex: 0 0 auto;
border: 2px solid rgba(108, 92, 231, 0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
.progress-track {
height: 3px;
overflow: hidden;
margin-top: 12px;
border-radius: 999px;
background: #dbd7ca;
}
.progress-bar {
display: block;
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--accent);
animation: progress-slide 1.7s ease-in-out infinite;
}
.job-status[data-kind="success"] {
border-color: rgba(32, 130, 92, 0.22);
background: rgba(32, 130, 92, 0.07);
}
.job-status[data-kind="success"] .status-row {
color: var(--success);
}
.job-status[data-kind="success"] .status-spinner {
border: 0;
animation: none;
}
.job-status[data-kind="success"] .status-spinner::after {
content: "✓";
font-weight: 800;
}
.job-status[data-kind="success"] .progress-track {
display: none;
}
.job-status[data-kind="error"] {
border-color: rgba(184, 67, 67, 0.22);
background: rgba(184, 67, 67, 0.07);
}
.job-status[data-kind="error"] .status-row {
color: var(--danger);
}
.job-status[data-kind="error"] .status-spinner {
border: 0;
animation: none;
}
.job-status[data-kind="error"] .status-spinner::after {
content: "!";
font-weight: 850;
}
.job-status[data-kind="error"] .progress-track {
display: none;
}
.result-actions {
display: flex;
justify-content: flex-end;
min-height: 0;
}
.download-link {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 16px;
padding: 11px 14px;
border-radius: 12px;
color: white;
background: var(--dark);
font-size: 12px;
font-weight: 740;
text-decoration: none;
transition:
transform 150ms ease,
background 150ms ease;
}
.download-link:hover {
background: #303531;
transform: translateY(-1px);
}
.download-link:focus-visible {
outline: 3px solid rgba(108, 92, 231, 0.28);
outline-offset: 2px;
}
.noscript {
margin: 20px;
padding: 16px;
border-radius: 12px;
color: #7a2424;
background: #ffe4e4;
text-align: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes health-pulse {
50% {
opacity: 0.4;
transform: scale(0.82);
}
}
@keyframes progress-slide {
0% {
transform: translateX(-115%);
}
50% {
transform: translateX(85%);
}
100% {
transform: translateX(245%);
}
}
@media (max-width: 1040px) {
.editor-layout {
grid-template-columns: 1fr;
}
.control-panel {
display: grid;
grid-template-columns: minmax(260px, 0.75fr) minmax(330px, 1fr);
gap: 36px;
}
.intro {
margin-bottom: 0;
}
h1 {
font-size: clamp(42px, 7vw, 68px);
}
.image-stage {
min-height: min(62vw, 600px);
}
}
@media (max-width: 720px) {
.topbar {
min-height: 68px;
padding: 14px 18px;
}
.health-status {
max-width: 58%;
font-size: 11px;
}
.editor-layout {
gap: 34px;
padding: 34px 16px 40px;
}
.control-panel {
display: block;
}
.intro {
margin-bottom: 30px;
}
h1 {
max-width: 450px;
font-size: clamp(40px, 13vw, 58px);
}
.actions {
grid-template-columns: 1fr;
}
.workspace {
padding: 18px 14px;
border-radius: 22px;
}
.comparison {
grid-template-columns: 1fr;
gap: 18px;
}
.image-stage {
min-height: min(105vw, 520px);
}
.field-foot span:first-child {
max-width: 70%;
}
}
@media (max-width: 460px) {
.brand-name {
display: none;
}
.health-status {
max-width: calc(100% - 56px);
}
.drop-zone {
grid-template-columns: auto minmax(0, 1fr);
}
.choose-chip {
display: none;
}
.workspace-head {
align-items: flex-start;
}
.local-badge {
padding: 7px 9px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* -- Èñòîðèÿ âåðñèé ------------------------------------------- */
.history-section {
margin-top: 30px;
}
.history-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.history-head h2 {
margin: 0;
font-size: 17px;
letter-spacing: -0.01em;
}
.history-hint {
font-size: 12.5px;
color: var(--muted);
}
.history-list {
display: flex;
gap: 12px;
overflow-x: auto;
padding: 4px 4px 10px;
}
.history-item {
display: flex;
flex-direction: column;
gap: 7px;
flex: 0 0 150px;
width: 150px;
padding: 9px;
border: 1.5px solid var(--line);
border-radius: var(--radius-small);
background: var(--panel);
cursor: pointer;
text-align: left;
font: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.history-item:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.history-item.is-current {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.history-item img {
width: 100%;
height: 92px;
object-fit: cover;
border-radius: 8px;
background: #ecece6;
}
.history-item-meta strong {
display: block;
font-size: 12.5px;
line-height: 1.3;
color: var(--ink);
word-break: break-word;
}
.history-item-meta span {
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--muted);
}
.history-item-badge {
align-self: flex-start;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-dark);
}
.history-empty {
padding: 10px 2px;
font-size: 13px;
color: var(--muted);
}