v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -294,6 +294,6 @@
</div>
</noscript>
<script src="/app.js?v=12" defer></script>
<script type="module" src="/js/main.js"></script>
</body>
</html>

40
public/js/api.js Normal file
View File

@@ -0,0 +1,40 @@
/**
* api.js — Обёртка над fetch с обработкой ошибок и авторизации
*/
import { showAuthOverlay } from "./auth.js";
export async function readJsonResponse(response) {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return { ok: false, error: text };
}
}
export async function apiFetch(url, options = {}) {
const response = await fetch(url, options);
if (response.status === 401) {
showAuthOverlay();
const data = await readJsonResponse(response);
const error = new Error(data?.error || "Требуется авторизация.");
error.status = 401;
throw error;
}
const data = await readJsonResponse(response);
if (!response.ok || (data && data.ok === false)) {
const errorMsg = data?.error || `Ошибка сервера (${response.status})`;
const error = new Error(errorMsg);
error.status = response.status;
error.details = data?.details;
error.requestId = data?.requestId;
throw error;
}
return data;
}

111
public/js/auth.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* auth.js — Управление авторизацией владельца
*/
import { apiFetch, readJsonResponse } from "./api.js";
export function showAuthOverlay() {
const overlay = document.querySelector("#auth-overlay");
const loginPassword = document.querySelector("#login-password");
const loginError = document.querySelector("#login-error");
if (overlay) overlay.hidden = false;
if (loginError) {
loginError.hidden = true;
loginError.textContent = "";
}
if (loginPassword) {
loginPassword.value = "";
loginPassword.focus();
}
}
export function hideAuthOverlay() {
const overlay = document.querySelector("#auth-overlay");
if (overlay) overlay.hidden = true;
}
export async function checkAuth() {
try {
const res = await fetch("/api/me");
if (res.ok) {
hideAuthOverlay();
const logoutBtn = document.querySelector("#logout-button");
if (logoutBtn) logoutBtn.hidden = false;
return true;
}
} catch {
// Network or server error
}
showAuthOverlay();
const logoutBtn = document.querySelector("#logout-button");
if (logoutBtn) logoutBtn.hidden = true;
return false;
}
export async function submitLogin(event) {
event.preventDefault();
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 username = String(loginUsername?.value || "").trim();
const password = String(loginPassword?.value || "");
if (!username || !password) {
if (loginError) {
loginError.textContent = "Укажите логин и пароль.";
loginError.hidden = false;
}
return;
}
if (loginSubmit) loginSubmit.disabled = true;
if (loginError) loginError.hidden = true;
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await readJsonResponse(res);
if (!res.ok || !data || !data.ok) {
if (loginError) {
loginError.textContent = data?.error || "Неверный логин или пароль.";
loginError.hidden = false;
}
return;
}
hideAuthOverlay();
const logoutBtn = document.querySelector("#logout-button");
if (logoutBtn) logoutBtn.hidden = false;
// Reload page or refresh state
window.location.reload();
} catch (err) {
if (loginError) {
loginError.textContent = err.message || "Не удалось подключиться к серверу.";
loginError.hidden = false;
}
} finally {
if (loginSubmit) loginSubmit.disabled = false;
}
}
export async function logout() {
try {
await apiFetch("/api/logout", { method: "POST" });
} catch {
// ignore
}
showAuthOverlay();
const logoutBtn = document.querySelector("#logout-button");
if (logoutBtn) logoutBtn.hidden = true;
}

49
public/js/health.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* health.js — Проверка доступности сервера обработки ComfyUI
*/
export function setHealthState(state, message, title = "") {
const healthStatus = document.querySelector("#health-status");
const healthText = document.querySelector("#health-text");
if (!healthStatus || !healthText) return;
healthStatus.className = `health-status health-${state}`;
healthStatus.title = title || message;
healthText.textContent = message;
}
export async function checkHealth() {
try {
const response = await fetch("/api/health");
if (!response.ok) {
setHealthState("error", "Сервер обработки недоступен");
return;
}
const data = await response.json();
if (!data.comfyAvailable) {
setHealthState("error", data.message || "ComfyUI недоступен");
return;
}
const missing = data.missingNodes || [];
if (missing.length > 0) {
setHealthState(
"warn",
"Не хватает узлов ComfyUI",
`Отсутствуют узлы: ${missing.join(", ")}`
);
return;
}
setHealthState("ok", "Сервер обработки готов");
} catch {
setHealthState("error", "Не удалось проверить сервер обработки");
}
}
export function startHealthPolling(intervalMs = 30000) {
checkHealth();
return setInterval(checkHealth, intervalMs);
}

357
public/js/history-tree.js Normal file
View File

@@ -0,0 +1,357 @@
/**
* history-tree.js — Дерево версий, свёртка групп, выбор и удаление
*/
import { apiFetch } from "./api.js";
import { getState, setCurrentVersionId, setHistoryVersions } from "./state.js";
import {
formatHistoryDate,
versionFileName,
resetResult,
updateDownloadCurrent,
showSourcePreview,
clearSourcePreview,
setJobStatus,
} from "./ui.js";
import { rebuildReferenceSelect, clearReference, getReferenceState } from "./reference.js";
import { updateSuggestCodexButton } from "./suggest.js";
function historyItemBadge(version) {
if (version.kind === "original") return "Исходник";
if (version.kind === "upscale") return "Апскейл";
return "Правка";
}
function buildChildrenMap(versions) {
const map = new Map();
for (const version of versions) {
const key = version.parentId || "";
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(version);
}
return map;
}
function countSubtreeSize(id, childrenMap) {
let count = 1;
const children = childrenMap.get(id);
if (children) {
for (const child of children) {
count += countSubtreeSize(child.id, childrenMap);
}
}
return count;
}
function renderTreeNode(version, depth, childrenMap, container) {
const state = getState();
const node = document.createElement("div");
node.className = "history-node";
node.style.setProperty("--depth", depth);
if (version.id === state.currentVersionId) {
node.classList.add("is-current");
}
const main = document.createElement("button");
main.type = "button";
main.className = "history-node-main";
main.setAttribute("aria-label", version.label || "Версия");
main.addEventListener("click", () => selectHistoryVersion(version));
const thumb = document.createElement("img");
thumb.className = "history-node-thumb";
thumb.src = version.url;
thumb.alt = "";
thumb.loading = "lazy";
const meta = document.createElement("span");
meta.className = "history-node-meta";
const title = document.createElement("strong");
title.className = "history-node-title";
title.textContent = version.label || "Версия";
const badge = document.createElement("span");
badge.className = "history-node-badge";
badge.textContent = historyItemBadge(version);
const date = document.createElement("span");
date.className = "history-node-date";
date.textContent = formatHistoryDate(version.createdAt);
meta.append(title, badge, date);
if (version.kind === "edit" && version.prompt) {
const prompt = document.createElement("div");
prompt.className = "history-node-prompt";
prompt.textContent = version.prompt;
prompt.title = version.prompt;
meta.appendChild(prompt);
}
main.append(thumb, meta);
const actions = document.createElement("div");
actions.className = "history-node-actions";
const download = document.createElement("a");
download.className = "history-node-action history-download";
download.href = version.url;
download.download = versionFileName(version);
download.textContent = "Скачать";
download.addEventListener("click", (event) => event.stopPropagation());
const remove = document.createElement("button");
remove.type = "button";
remove.className = "history-node-action history-delete";
remove.textContent = "Удалить";
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteVersion(version);
});
actions.append(download, remove);
node.append(main, actions);
if (childrenMap.has(version.id)) {
const body = document.createElement("div");
body.className = "history-node-children";
for (const child of childrenMap.get(version.id)) {
renderTreeNode(child, depth + 1, childrenMap, body);
}
node.appendChild(body);
}
container.appendChild(node);
}
export function renderHistory() {
const list = document.querySelector("#history-list");
const empty = document.querySelector("#history-empty");
if (!list || !empty) return;
const state = getState();
const versions = state.historyVersions;
list.querySelectorAll(".history-group").forEach((group) => group.remove());
empty.hidden = versions.length > 0;
const childrenMap = buildChildrenMap(versions);
const roots = versions
.filter(
(version) =>
version.kind === "original" ||
!version.parentId ||
!childrenMap.has(version.parentId)
)
.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")));
rebuildReferenceSelect();
for (const root of roots) {
const group = document.createElement("div");
group.className = "history-group";
group.dataset.group = root.id;
const rootNode = document.createElement("div");
rootNode.className = "history-node is-root";
rootNode.style.setProperty("--depth", 0);
if (root.id === state.currentVersionId) {
rootNode.classList.add("is-current");
}
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "history-node-toggle";
toggle.setAttribute("aria-label", "Свернуть ветку");
const collapsed = state.collapsedGroups.has(root.id);
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.textContent = collapsed ? "▸" : "▾";
toggle.addEventListener("click", (event) => {
event.stopPropagation();
if (state.collapsedGroups.has(root.id)) {
state.collapsedGroups.delete(root.id);
} else {
state.collapsedGroups.set(root.id, true);
}
renderHistory();
});
const rootMain = document.createElement("button");
rootMain.type = "button";
rootMain.className = "history-node-main";
rootMain.setAttribute("aria-label", root.label || "Версия");
rootMain.addEventListener("click", () => selectHistoryVersion(root));
const thumb = document.createElement("img");
thumb.className = "history-node-thumb";
thumb.src = root.url;
thumb.alt = "";
thumb.loading = "lazy";
const meta = document.createElement("span");
meta.className = "history-node-meta";
const title = document.createElement("strong");
title.className = "history-node-title";
title.textContent = root.label || "Версия";
const badge = document.createElement("span");
badge.className = "history-node-badge";
badge.textContent = historyItemBadge(root);
const date = document.createElement("span");
date.className = "history-node-date";
date.textContent = formatHistoryDate(root.createdAt);
meta.append(title, badge, date);
if (root.kind === "edit" && root.prompt) {
const prompt = document.createElement("div");
prompt.className = "history-node-prompt";
prompt.textContent = root.prompt;
prompt.title = root.prompt;
meta.appendChild(prompt);
}
rootMain.append(thumb, meta);
const actions = document.createElement("div");
actions.className = "history-node-actions";
const download = document.createElement("a");
download.className = "history-node-action history-download";
download.href = root.url;
download.download = versionFileName(root);
download.textContent = "Скачать";
download.addEventListener("click", (event) => event.stopPropagation());
const remove = document.createElement("button");
remove.type = "button";
remove.className = "history-node-action history-delete";
remove.textContent = "Удалить";
remove.addEventListener("click", (event) => {
event.stopPropagation();
deleteVersion(root);
});
actions.append(download, remove);
rootNode.append(toggle, rootMain, actions);
group.appendChild(rootNode);
const count = document.createElement("span");
count.className = "history-group-count";
const subtreeSize = countSubtreeSize(root.id, childrenMap);
count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`;
group.appendChild(count);
if (!collapsed) {
const body = document.createElement("div");
body.className = "history-group-body";
const children = childrenMap.get(root.id);
if (children) {
for (const child of children) {
renderTreeNode(child, 1, childrenMap, body);
}
}
group.appendChild(body);
}
list.appendChild(group);
}
}
export function selectHistoryVersion(version) {
const state = getState();
if (state.isBusy) return;
setCurrentVersionId(version.id);
resetResult();
updateDownloadCurrent(version);
showSourcePreview(
version.url,
version.label || "Версия",
version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке"
);
renderHistory();
updateSuggestCodexButton();
setJobStatus("idle", "");
const editBtn = document.querySelector("#edit-button");
const upscaleBtn = document.querySelector("#upscale-button");
const promptInput = document.querySelector("#prompt");
if (upscaleBtn) upscaleBtn.disabled = false;
if (editBtn) {
editBtn.disabled = !promptInput?.value?.trim();
}
}
export async function refreshHistory(selectLatest = false) {
try {
const data = await apiFetch("/api/history", { cache: "no-store" });
const versions = Array.isArray(data.versions) ? data.versions : [];
setHistoryVersions(versions);
if (selectLatest && versions.length > 0) {
const latest = versions[versions.length - 1];
selectHistoryVersion(latest);
} else {
renderHistory();
updateSuggestCodexButton();
}
} catch (err) {
console.error("Ошибка обновления истории:", err);
}
}
export async function deleteVersion(version) {
if (!confirm("Удалить версию и все её ответвления?")) return;
try {
const data = await apiFetch(`/api/history/${version.id}`, {
method: "DELETE",
cache: "no-store",
});
const versions = Array.isArray(data.versions) ? data.versions : [];
setHistoryVersions(versions);
const state = getState();
state.collapsedGroups.delete(version.id);
const currentSurvives = versions.some((v) => v.id === state.currentVersionId);
if (!currentSurvives) {
const parent = version.parentId ? versions.find((v) => v.id === version.parentId) : null;
if (parent) {
selectHistoryVersion(parent);
} else if (versions.length > 0) {
setCurrentVersionId(null);
resetResult();
updateDownloadCurrent(null);
clearSourcePreview();
renderHistory();
} else {
setCurrentVersionId(null);
resetResult();
updateDownloadCurrent(null);
clearSourcePreview();
renderHistory();
}
} else {
renderHistory();
}
const refState = getReferenceState();
if (refState?.source?.type === "history" && refState.source.versionId === version.id) {
clearReference();
}
} catch (err) {
setJobStatus("error", err.message || "Не удалось удалить версию.");
}
}

301
public/js/jobs.js Normal file
View File

@@ -0,0 +1,301 @@
/**
* jobs.js — Очередь заданий, SSE-подписка, фолбэк-поллинг и выполнение edit/upscale
*/
import { apiFetch } from "./api.js";
import { getState, setBusy, setCurrentVersionId } from "./state.js";
import { setJobStatus, resetResult, updateDownloadCurrent, versionFileName } from "./ui.js";
import { refreshHistory } from "./history-tree.js";
import { getReferenceState } from "./reference.js";
let eventSource = null;
let currentTrackingJob = null;
let fallbackPollingTimer = null;
export function initJobsEvents() {
if (window.EventSource) {
connectSSE();
} else {
startFallbackPolling();
}
}
function connectSSE() {
if (eventSource) {
try {
eventSource.close();
} catch {
// ignore
}
}
eventSource = new EventSource("/api/events");
eventSource.addEventListener("snapshot", (event) => {
try {
const activeJobs = JSON.parse(event.data);
updateActiveBadge(activeJobs);
} catch {
// ignore
}
});
eventSource.addEventListener("job", (event) => {
try {
const jobData = JSON.parse(event.data);
handleJobEvent(jobData);
} catch {
// ignore
}
});
eventSource.onerror = () => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
startFallbackPolling();
// Try reconnecting SSE after 15s
setTimeout(connectSSE, 15000);
};
}
function startFallbackPolling() {
if (fallbackPollingTimer) return;
fallbackPollingTimer = setInterval(async () => {
try {
const data = await apiFetch("/api/jobs");
if (Array.isArray(data?.jobs)) {
updateActiveBadge(data.jobs);
}
} catch {
// ignore
}
}, 3000);
}
function updateActiveBadge(jobs) {
const active = jobs.filter((j) => j.status === "queued" || j.status === "running");
// Could update badge in topbar if needed
}
function handleJobEvent(jobData) {
if (!currentTrackingJob || currentTrackingJob.id !== jobData.jobId) {
return;
}
const { isEdit, onFinish } = currentTrackingJob;
if (jobData.status === "queued") {
const pos = jobData.queuePosition ? ` (позиция ${jobData.queuePosition})` : "";
setJobStatus("progress", `Задание ожидает в очереди${pos}`);
} else if (jobData.status === "running") {
setJobStatus(
"progress",
isEdit
? "Codex редактирует фотографию… Это обычно занимает 13 минуты."
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
);
} else if (jobData.status === "done") {
currentTrackingJob = null;
onFinish(null, jobData);
} else if (jobData.status === "interrupted") {
currentTrackingJob = null;
const err = new Error(
jobData.error || "Задание прервано перезапуском сервера. Запустите заново."
);
err.interrupted = true;
onFinish(err);
} else if (jobData.status === "error") {
currentTrackingJob = null;
onFinish(new Error(jobData.error || "Ошибка при выполнении задания."));
}
}
export async function runJob(kind) {
const state = getState();
const isEdit = kind === "edit";
if (!state.currentVersionId || state.isBusy) {
return;
}
const promptInput = document.querySelector("#prompt");
const prompt = String(promptInput?.value || "").trim();
if (isEdit && !prompt) {
setJobStatus("error", "Введите текст промпта.");
return;
}
setBusy(true);
resetResult();
setJobStatus("progress", "Отправка задания на сервер…");
let lastJobParams = null;
try {
let res;
if (isEdit) {
const formData = new FormData();
formData.append("sourceId", state.currentVersionId);
formData.append("prompt", prompt);
const refState = getReferenceState();
if (refState?.source?.type === "file" && refState.source.file) {
formData.append("reference", refState.source.file);
} else if (refState?.source?.type === "history" && refState.source.versionId) {
formData.append("referenceId", refState.source.versionId);
}
if (refState?.region) {
formData.append("region", JSON.stringify(refState.region));
}
lastJobParams = { kind, sourceId: state.currentVersionId, prompt, refState };
res = await apiFetch("/api/edit", {
method: "POST",
body: formData,
});
} else {
lastJobParams = { kind, sourceId: state.currentVersionId };
res = await apiFetch("/api/upscale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: state.currentVersionId }),
});
}
const jobId = res.jobId;
if (res.status === "queued") {
const pos = res.queuePosition ? ` (позиция ${res.queuePosition})` : "";
setJobStatus("progress", `Задание добавлено в очередь${pos}`);
} else {
setJobStatus(
"progress",
isEdit
? "Codex редактирует фотографию… Это обычно занимает 13 минуты."
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
);
}
// Wait for completion via SSE or fallback polling
await new Promise((resolve, reject) => {
currentTrackingJob = {
id: jobId,
isEdit,
onFinish: (err, resultData) => {
if (err) reject(err);
else resolve(resultData);
},
};
// Fallback polling loop in case SSE is blocked or slow
pollJobStatus(jobId, isEdit);
});
// Fetch full job status to get version and details
const finalJob = await apiFetch(`/api/jobs/${jobId}/status`);
renderJobSuccess(finalJob, isEdit);
} catch (err) {
if (err.interrupted) {
setJobStatus(
"error",
"Задание прервано перезапуском сервера. Нажмите кнопку, чтобы повторить попытку."
);
} else {
setJobStatus("error", err.message || "Ошибка при выполнении задания.");
}
} finally {
setBusy(false);
}
}
async function pollJobStatus(jobId, isEdit) {
let attempts = 0;
const poll = async () => {
if (!currentTrackingJob || currentTrackingJob.id !== jobId) {
return;
}
try {
const data = await apiFetch(`/api/jobs/${jobId}/status`);
if (!currentTrackingJob || currentTrackingJob.id !== jobId) return;
if (data.status === "queued") {
const pos = data.queuePosition ? ` (позиция ${data.queuePosition})` : "";
setJobStatus("progress", `Задание ожидает в очереди${pos}`);
setTimeout(poll, 2000);
} else if (data.status === "running") {
setJobStatus(
"progress",
isEdit
? "Codex редактирует фотографию… Это обычно занимает 13 минуты."
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
);
setTimeout(poll, 2000);
} else if (data.status === "done") {
const cb = currentTrackingJob?.onFinish;
currentTrackingJob = null;
if (cb) cb(null, data);
} else if (data.status === "interrupted") {
const cb = currentTrackingJob?.onFinish;
currentTrackingJob = null;
const err = new Error(
data.error || "Задание прервано перезапуском сервера. Запустите заново."
);
err.interrupted = true;
if (cb) cb(err);
} else if (data.status === "error") {
const cb = currentTrackingJob?.onFinish;
currentTrackingJob = null;
if (cb) cb(new Error(data.error || "Ошибка при выполнении задания."));
} else {
setTimeout(poll, 2000);
}
} catch {
attempts++;
if (attempts < 30) {
setTimeout(poll, 2500);
}
}
};
setTimeout(poll, 2000);
}
export function renderJobSuccess(data, isEdit) {
const version = data.result?.version;
const resultImage = document.querySelector("#result-image");
const resultPlaceholder = document.querySelector("#result-placeholder");
const resultLabel = document.querySelector("#result-label");
const downloadLink = document.querySelector("#download-link");
if (!version) {
setJobStatus("error", "Задание завершено, но версия не создана.");
return;
}
setJobStatus("idle", "");
if (resultImage) {
resultImage.src = version.url;
resultImage.hidden = false;
}
if (resultPlaceholder) resultPlaceholder.hidden = true;
if (resultLabel) {
resultLabel.textContent = isEdit ? "Codex" : "4× Upscale";
}
if (downloadLink) {
downloadLink.href = version.url;
downloadLink.download = versionFileName(version);
downloadLink.hidden = false;
}
// Refresh history tree and select newly created version
refreshHistory(true);
}

77
public/js/main.js Normal file
View File

@@ -0,0 +1,77 @@
/**
* main.js — Главная точка входа клиентского приложения «Кадр» v2
*/
import { checkAuth, submitLogin, logout } from "./auth.js";
import { startHealthPolling } from "./health.js";
import { initUpload } from "./upload.js";
import { initReference } from "./reference.js";
import { initSuggest } from "./suggest.js";
import { initJobsEvents, runJob } from "./jobs.js";
import { refreshHistory } from "./history-tree.js";
import { subscribe, getState } from "./state.js";
function setupButtonsAndInputs() {
const loginForm = document.querySelector("#login-form");
const logoutBtn = document.querySelector("#logout-button");
const editBtn = document.querySelector("#edit-button");
const upscaleBtn = document.querySelector("#upscale-button");
const promptInput = document.querySelector("#prompt");
const promptCounter = document.querySelector("#prompt-counter");
if (loginForm) {
loginForm.addEventListener("submit", submitLogin);
}
if (logoutBtn) {
logoutBtn.addEventListener("click", logout);
}
if (promptInput) {
promptInput.addEventListener("input", () => {
const val = promptInput.value;
if (promptCounter) {
promptCounter.textContent = `${val.length} / 1000`;
}
const state = getState();
if (editBtn) {
editBtn.disabled = !state.currentVersionId || !val.trim() || state.isBusy;
}
});
}
if (editBtn) {
editBtn.addEventListener("click", () => runJob("edit"));
}
if (upscaleBtn) {
upscaleBtn.addEventListener("click", () => runJob("upscale"));
}
// Subscribe to state changes to update button states
subscribe((state, changeKey) => {
if (changeKey === "isBusy" || changeKey === "currentVersionId") {
const hasVersion = Boolean(state.currentVersionId);
const promptVal = promptInput?.value?.trim() || "";
if (upscaleBtn) upscaleBtn.disabled = !hasVersion || state.isBusy;
if (editBtn) editBtn.disabled = !hasVersion || !promptVal || state.isBusy;
}
});
}
async function init() {
setupButtonsAndInputs();
initUpload();
initReference();
initSuggest();
startHealthPolling();
const authenticated = await checkAuth();
if (authenticated) {
initJobsEvents();
await refreshHistory(true);
}
}
document.addEventListener("DOMContentLoaded", init);

322
public/js/reference.js Normal file
View File

@@ -0,0 +1,322 @@
/**
* reference.js — Выбор референса, канвас и выделение области
*/
import { getState, setReference, clearReferenceState } from "./state.js";
import { isHeicFile, setJobStatus, MAX_FILE_SIZE } from "./ui.js";
const REFERENCE_MAX_DIMENSION = 1600;
let referenceState = null;
let regionDragStart = null;
export function getReferenceState() {
return referenceState;
}
export function clearReference() {
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
URL.revokeObjectURL(referenceState.source.objectUrl);
}
referenceState = null;
clearReferenceState();
const preview = document.querySelector("#reference-preview");
const canvas = document.querySelector("#reference-canvas");
const note = document.querySelector("#reference-note");
const regionInfo = document.querySelector("#reference-region-info");
const historySelect = document.querySelector("#reference-history-select");
if (preview) preview.hidden = true;
if (canvas) {
canvas.width = 0;
canvas.height = 0;
}
if (note) note.hidden = true;
if (regionInfo) regionInfo.textContent = "Область не выбрана — потяните по превью";
if (historySelect) historySelect.value = "";
}
function drawRegion(ctx) {
const region = referenceState?.region;
if (!region) return;
ctx.strokeStyle = "rgba(220,38,38,0.95)";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(220,38,38,0.18)";
ctx.fillRect(region.x, region.y, region.w, region.h);
ctx.strokeRect(region.x, region.y, region.w, region.h);
}
function drawReferenceToCanvas(image) {
const canvas = document.querySelector("#reference-canvas");
if (!canvas) return;
const naturalWidth = image.naturalWidth || image.width;
const naturalHeight = image.naturalHeight || image.height;
if (!naturalWidth || !naturalHeight) return;
const scale = Math.min(
1,
REFERENCE_MAX_DIMENSION / Math.max(naturalWidth, naturalHeight)
);
canvas.width = Math.round(naturalWidth * scale);
canvas.height = Math.round(naturalHeight * scale);
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
drawRegion(ctx);
}
function redrawReferenceCanvas() {
const canvas = document.querySelector("#reference-canvas");
const image = referenceState?.source?.image;
if (!canvas || !image || !canvas.width) return;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
drawRegion(ctx);
updateReferenceNote();
}
function updateReferenceNote() {
const note = document.querySelector("#reference-note");
const regionInfo = document.querySelector("#reference-region-info");
if (!referenceState) {
if (note) note.hidden = true;
if (regionInfo) regionInfo.textContent = "Область не выбрана — потяните по превью";
return;
}
if (referenceState.region) {
const r = referenceState.region;
if (note) note.hidden = true;
if (regionInfo) {
regionInfo.textContent = `Область: ${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}×${Math.round(r.h)}`;
}
} else {
if (note) {
note.textContent = "Область не выбрана — Codex использует референс как ориентир";
note.hidden = false;
}
if (regionInfo) regionInfo.textContent = "Область не выбрана — потяните по превью";
}
}
function canvasPointFromEvent(canvas, event) {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) * (canvas.width / rect.width);
const y = (event.clientY - rect.top) * (canvas.height / rect.height);
return {
x: Math.min(Math.max(0, x), canvas.width),
y: Math.min(Math.max(0, y), canvas.height),
};
}
export function loadReferenceFromFile(file) {
if (!file) return;
const note = document.querySelector("#reference-note");
const preview = document.querySelector("#reference-preview");
const fileType = String(file.type || "").toLowerCase();
if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(fileType)) {
if (isHeicFile(file)) {
if (note) {
note.textContent = "HEIC не поддерживается как референс — выберите JPEG, PNG или WebP";
note.hidden = false;
}
} else {
setJobStatus("error", "Референс должен быть JPEG, PNG или WebP.");
}
return;
}
if (file.size > MAX_FILE_SIZE) {
setJobStatus("error", "Файл референса слишком большой. Максимальный размер — 25 МБ.");
return;
}
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
URL.revokeObjectURL(referenceState.source.objectUrl);
}
const objectUrl = URL.createObjectURL(file);
referenceState = { source: { type: "file", file, objectUrl }, region: null };
const image = new Image();
image.onload = () => {
referenceState.source.image = image;
drawReferenceToCanvas(image);
if (preview) preview.hidden = false;
updateReferenceNote();
setReference({ file, versionId: null, image, region: null });
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
referenceState = null;
clearReferenceState();
setJobStatus("error", "Не удалось прочитать изображение референса.");
};
image.src = objectUrl;
}
export function loadReferenceFromHistory(version) {
if (!version) return;
if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) {
URL.revokeObjectURL(referenceState.source.objectUrl);
}
const preview = document.querySelector("#reference-preview");
referenceState = {
source: { type: "history", versionId: version.id, url: version.url },
region: null,
};
const image = new Image();
image.onload = () => {
referenceState.source.image = image;
drawReferenceToCanvas(image);
if (preview) preview.hidden = false;
updateReferenceNote();
setReference({ file: null, versionId: version.id, image, region: null });
};
image.onerror = () => {
referenceState = null;
clearReferenceState();
setJobStatus("error", "Не удалось загрузить референс из истории.");
};
image.src = version.url;
}
export function rebuildReferenceSelect() {
const historySelect = document.querySelector("#reference-history-select");
if (!historySelect) return;
const currentVal = historySelect.value;
historySelect.innerHTML = '<option value="">— из истории —</option>';
const state = getState();
for (const v of state.historyVersions) {
const opt = document.createElement("option");
opt.value = v.id;
opt.textContent = `${v.label} (${v.id.slice(0, 8)})`;
historySelect.appendChild(opt);
}
if (currentVal && state.historyVersions.some((v) => v.id === currentVal)) {
historySelect.value = currentVal;
}
}
export function initReference() {
const uploadBtn = document.querySelector("#reference-upload-button");
const fileInput = document.querySelector("#reference-file-input");
const clearBtn = document.querySelector("#reference-clear-button");
const historySelect = document.querySelector("#reference-history-select");
const canvas = document.querySelector("#reference-canvas");
const regionReset = document.querySelector("#reference-region-reset");
if (uploadBtn && fileInput) {
uploadBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => {
const file = e.target.files?.[0];
if (file) {
if (historySelect) historySelect.value = "";
loadReferenceFromFile(file);
}
fileInput.value = "";
});
}
if (clearBtn) {
clearBtn.addEventListener("click", () => clearReference());
}
if (historySelect) {
historySelect.addEventListener("change", () => {
const vid = historySelect.value;
if (!vid) {
clearReference();
return;
}
const state = getState();
const version = state.historyVersions.find((v) => v.id === vid);
if (version) {
loadReferenceFromHistory(version);
}
});
}
if (regionReset) {
regionReset.addEventListener("click", () => {
if (referenceState) {
referenceState.region = null;
redrawReferenceCanvas();
updateReferenceNote();
setReference({ region: null });
}
});
}
if (canvas) {
canvas.addEventListener("pointerdown", (event) => {
const state = getState();
if (state.isBusy || !referenceState) return;
event.preventDefault();
canvas.setPointerCapture(event.pointerId);
const pos = canvasPointFromEvent(canvas, event);
regionDragStart = pos;
referenceState.region = { x: pos.x, y: pos.y, w: 0, h: 0 };
redrawReferenceCanvas();
updateReferenceNote();
});
canvas.addEventListener("pointermove", (event) => {
const state = getState();
if (state.isBusy || !referenceState || !regionDragStart) return;
const pos = canvasPointFromEvent(canvas, event);
const x = Math.min(regionDragStart.x, pos.x);
const y = Math.min(regionDragStart.y, pos.y);
const w = Math.max(regionDragStart.x, pos.x) - x;
const h = Math.max(regionDragStart.y, pos.y) - y;
referenceState.region = { x, y, w, h };
redrawReferenceCanvas();
updateReferenceNote();
});
canvas.addEventListener("pointerup", () => {
if (regionDragStart) {
regionDragStart = null;
if (
referenceState?.region &&
(referenceState.region.w < 3 || referenceState.region.h < 3)
) {
referenceState.region = null;
redrawReferenceCanvas();
updateReferenceNote();
}
setReference({ region: referenceState?.region || null });
}
});
canvas.addEventListener("pointercancel", () => {
regionDragStart = null;
});
}
}

74
public/js/state.js Normal file
View File

@@ -0,0 +1,74 @@
/**
* state.js — Единое реактивное состояние редактора «Кадр»
*/
const state = {
currentVersionId: null,
historyVersions: [],
collapsedGroups: new Map(), // groupId (id корня) -> true (свёрнута)
reference: {
file: null,
versionId: null,
image: null,
region: null,
},
isBusy: false,
activeJobs: new Map(),
suggestRunning: false,
};
const listeners = new Set();
export function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function notify(changeKey) {
for (const listener of listeners) {
try {
listener(state, changeKey);
} catch (err) {
console.error("State listener error:", err);
}
}
}
export function getState() {
return state;
}
export function setCurrentVersionId(id) {
state.currentVersionId = id;
notify("currentVersionId");
}
export function setHistoryVersions(versions) {
state.historyVersions = Array.isArray(versions) ? versions : [];
notify("historyVersions");
}
export function setBusy(busy) {
state.isBusy = Boolean(busy);
notify("isBusy");
}
export function setSuggestRunning(running) {
state.suggestRunning = Boolean(running);
notify("suggestRunning");
}
export function setReference(refData) {
state.reference = { ...state.reference, ...refData };
notify("reference");
}
export function clearReferenceState() {
state.reference = {
file: null,
versionId: null,
image: null,
region: null,
};
notify("reference");
}

187
public/js/suggest.js Normal file
View File

@@ -0,0 +1,187 @@
/**
* suggest.js — Подсказки промптов из истории и генерация через Codex
*/
import { apiFetch } from "./api.js";
import { getState, setSuggestRunning } from "./state.js";
function applyPrompt(promptText) {
const promptInput = document.querySelector("#prompt");
const promptCounter = document.querySelector("#prompt-counter");
const editButton = document.querySelector("#edit-button");
if (!promptInput) return;
promptInput.value = promptText;
if (promptCounter) {
promptCounter.textContent = `${promptText.length} / 1000`;
}
const state = getState();
if (editButton) {
editButton.disabled = !state.currentVersionId || !promptText.trim() || state.isBusy;
}
}
export function renderSuggestChips(container, items) {
if (!container) return;
container.innerHTML = "";
for (const item of items) {
const text = String(item || "").trim();
if (!text) continue;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "suggest-chip";
chip.textContent = text;
chip.addEventListener("click", () => applyPrompt(text));
container.appendChild(chip);
}
}
export async function loadSuggestHistoryChips() {
const container = document.querySelector("#suggest-history-chips");
if (!container) return;
try {
const res = await apiFetch("/api/suggest-prompts");
const prompts = Array.isArray(res?.prompts) ? res.prompts : [];
if (prompts.length === 0) {
container.innerHTML = '<span class="suggest-empty">В истории пока нет промптов</span>';
return;
}
renderSuggestChips(container, prompts);
} catch {
container.innerHTML = '<span class="suggest-empty">Не удалось загрузить подсказки</span>';
}
}
export function updateSuggestCodexButton() {
const suggestBtn = document.querySelector("#suggest-button");
const codexBtn = document.querySelector("#suggest-codex-button");
const state = getState();
const canSuggest = Boolean(state.currentVersionId) && !state.isBusy;
if (suggestBtn) {
suggestBtn.disabled = !canSuggest;
}
if (codexBtn) {
codexBtn.disabled = !canSuggest || state.suggestRunning;
}
}
export async function runSuggest() {
const state = getState();
if (!state.currentVersionId || state.suggestRunning || state.isBusy) {
return;
}
const statusEl = document.querySelector("#suggest-codex-status");
const chipsContainer = document.querySelector("#suggest-codex-chips");
const codexBtn = document.querySelector("#suggest-codex-button");
setSuggestRunning(true);
updateSuggestCodexButton();
if (statusEl) statusEl.textContent = "Codex анализирует кадр…";
if (chipsContainer) chipsContainer.innerHTML = "";
try {
const res = await apiFetch("/api/suggest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: state.currentVersionId }),
});
const jobId = res.jobId;
await pollSuggestJob(jobId);
} catch (err) {
if (statusEl) statusEl.textContent = `Ошибка: ${err.message}`;
setSuggestRunning(false);
updateSuggestCodexButton();
}
}
async function pollSuggestJob(jobId) {
const statusEl = document.querySelector("#suggest-codex-status");
const chipsContainer = document.querySelector("#suggest-codex-chips");
let failures = 0;
const poll = async () => {
try {
const data = await apiFetch(`/api/jobs/${jobId}/status`);
failures = 0;
if (data.status === "queued") {
if (statusEl) {
statusEl.textContent = data.queuePosition
? `В очереди (позиция ${data.queuePosition})…`
: "В очереди…";
}
setTimeout(poll, 1500);
return;
}
if (data.status === "running") {
if (statusEl) statusEl.textContent = "Codex генерирует варианты…";
setTimeout(poll, 1500);
return;
}
if (data.status === "done") {
if (statusEl) statusEl.textContent = "";
const suggestions = data.result?.suggestions || [];
if (suggestions.length > 0) {
renderSuggestChips(chipsContainer, suggestions);
} else {
if (statusEl) statusEl.textContent = "Codex не вернул вариантов.";
}
setSuggestRunning(false);
updateSuggestCodexButton();
return;
}
if (data.status === "error" || data.status === "interrupted") {
if (statusEl) statusEl.textContent = `Ошибка: ${data.error || "Не удалось получить подсказки."}`;
setSuggestRunning(false);
updateSuggestCodexButton();
return;
}
setTimeout(poll, 1500);
} catch (err) {
failures++;
if (failures >= 5) {
if (statusEl) statusEl.textContent = `Ошибка связи с сервером: ${err.message}`;
setSuggestRunning(false);
updateSuggestCodexButton();
return;
}
setTimeout(poll, 2000);
}
};
poll();
}
export function initSuggest() {
const suggestBtn = document.querySelector("#suggest-button");
const suggestPanel = document.querySelector("#suggest-panel");
const codexBtn = document.querySelector("#suggest-codex-button");
if (suggestBtn && suggestPanel) {
suggestBtn.addEventListener("click", () => {
const isHidden = suggestPanel.hidden;
suggestPanel.hidden = !isHidden;
if (isHidden) {
loadSuggestHistoryChips();
}
});
}
if (codexBtn) {
codexBtn.addEventListener("click", runSuggest);
}
}

138
public/js/ui.js Normal file
View File

@@ -0,0 +1,138 @@
/**
* ui.js — Утилиты форматирования, отображения статусов и DOM-элементов
*/
export const MAX_FILE_SIZE = 25 * 1024 * 1024;
export const ACCEPTED_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
export 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")
);
}
export function formatFileSize(bytes) {
if (!bytes || bytes <= 0) return "";
const units = ["Б", "КБ", "МБ", "ГБ"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export function versionFileName(version) {
const dateStr = version?.createdAt
? new Date(version.createdAt).toISOString().replace(/[:.]/g, "-").slice(0, 19)
: "photo";
const ext = version?.ext || "jpg";
const label = version?.label ? version.label.replace(/[^\p{L}\p{N}._-]+/gu, "-") : "version";
return `kadr-${label}-${dateStr}.${ext}`;
}
export function formatHistoryDate(iso) {
if (!iso) return "";
try {
const d = new Date(iso);
const date = d.toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
});
const time = d.toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
});
return `${date}, ${time}`;
} catch {
return iso;
}
}
export function setJobStatus(kind, message) {
const jobStatus = document.querySelector("#job-status");
const jobStatusText = document.querySelector("#job-status-text");
if (!jobStatus || !jobStatusText) return;
if (kind === "idle") {
jobStatus.hidden = true;
jobStatus.classList.remove("status-error");
jobStatusText.textContent = "";
return;
}
jobStatus.hidden = false;
jobStatus.classList.toggle("status-error", kind === "error");
jobStatusText.textContent = message;
}
export function resetResult() {
const resultImage = document.querySelector("#result-image");
const resultPlaceholder = document.querySelector("#result-placeholder");
const resultLabel = document.querySelector("#result-label");
const downloadLink = document.querySelector("#download-link");
if (resultImage) {
resultImage.hidden = true;
resultImage.removeAttribute("src");
}
if (resultPlaceholder) resultPlaceholder.hidden = false;
if (resultLabel) resultLabel.textContent = "";
if (downloadLink) downloadLink.hidden = true;
}
export function clearSourcePreview() {
const sourceImage = document.querySelector("#source-image");
const sourcePlaceholder = document.querySelector("#source-placeholder");
const downloadCurrent = document.querySelector("#download-current");
if (sourceImage) {
sourceImage.hidden = true;
sourceImage.removeAttribute("src");
}
if (sourcePlaceholder) sourcePlaceholder.hidden = false;
if (downloadCurrent) downloadCurrent.hidden = true;
}
export function showSourcePreview(url, title, meta) {
const sourceImage = document.querySelector("#source-image");
const sourcePlaceholder = document.querySelector("#source-placeholder");
const dropTitle = document.querySelector("#drop-title");
const fileMeta = document.querySelector("#file-meta");
if (sourceImage) {
sourceImage.src = url;
sourceImage.hidden = false;
}
if (sourcePlaceholder) sourcePlaceholder.hidden = true;
if (dropTitle && title) dropTitle.textContent = title;
if (fileMeta && meta) fileMeta.textContent = meta;
}
export function updateDownloadCurrent(version) {
const downloadCurrent = document.querySelector("#download-current");
if (!downloadCurrent) return;
if (!version || !version.url) {
downloadCurrent.hidden = true;
return;
}
downloadCurrent.href = version.url;
downloadCurrent.download = versionFileName(version);
downloadCurrent.hidden = false;
}

123
public/js/upload.js Normal file
View File

@@ -0,0 +1,123 @@
/**
* upload.js — Загрузка и импорт фотографий через drag-and-drop или file picker
*/
import { apiFetch } from "./api.js";
import { getState, setBusy } from "./state.js";
import { setJobStatus, isHeicFile, ACCEPTED_TYPES, MAX_FILE_SIZE } from "./ui.js";
import { refreshHistory } from "./history-tree.js";
export async function importPhoto(file) {
const form = new FormData();
form.append("photo", file, file.name);
const data = await apiFetch("/api/history/import", {
method: "POST",
body: form,
cache: "no-store",
});
return data.version;
}
export async function handleFiles(fileList) {
const files = Array.from(fileList || []).filter((file) => {
const fileType = String(file.type || "").toLowerCase();
if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) {
setJobStatus("error", "Поддерживаются только фотографии JPEG, PNG, WebP или HEIC.");
return false;
}
if (file.size > MAX_FILE_SIZE) {
setJobStatus("error", "Файл слишком большой. Максимальный размер — 25 МБ.");
return false;
}
return true;
});
if (files.length === 0) return;
setBusy(true);
for (let i = 0; i < files.length; i++) {
const file = files[i];
setJobStatus(
"progress",
files.length > 1
? `Загрузка ${i + 1} из ${files.length}`
: "Загружаем фотографию в историю…"
);
try {
await importPhoto(file);
} catch (error) {
setBusy(false);
setJobStatus(
"error",
error instanceof Error ? error.message : "Не удалось загрузить фотографию."
);
return;
}
}
await refreshHistory(true);
setJobStatus("idle", "");
setBusy(false);
}
export function initUpload() {
const dropZone = document.querySelector("#drop-zone");
const photoInput = document.querySelector("#photo-input");
if (dropZone && photoInput) {
dropZone.addEventListener("click", () => {
const state = getState();
if (!state.isBusy) {
photoInput.click();
}
});
dropZone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const state = getState();
if (!state.isBusy) {
photoInput.click();
}
}
});
photoInput.addEventListener("change", (event) => {
handleFiles(event.target.files);
photoInput.value = "";
});
const preventDefaults = (e) => {
e.preventDefault();
e.stopPropagation();
};
["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => {
dropZone.addEventListener(evt, preventDefaults, false);
document.body.addEventListener(evt, preventDefaults, false);
});
["dragenter", "dragover"].forEach((evt) => {
dropZone.addEventListener(evt, () => dropZone.classList.add("dragover"), false);
});
["dragleave", "drop"].forEach((evt) => {
dropZone.addEventListener(evt, () => dropZone.classList.remove("dragover"), false);
});
dropZone.addEventListener("drop", (event) => {
const state = getState();
if (!state.isBusy && event.dataTransfer?.files) {
handleFiles(event.dataTransfer.files);
}
});
}
}