v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
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:
1524
public/app.js
1524
public/app.js
File diff suppressed because it is too large
Load Diff
@@ -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
40
public/js/api.js
Normal 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
111
public/js/auth.js
Normal 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
49
public/js/health.js
Normal 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
357
public/js/history-tree.js
Normal 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
301
public/js/jobs.js
Normal 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 редактирует фотографию… Это обычно занимает 1–3 минуты."
|
||||
: "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 редактирует фотографию… Это обычно занимает 1–3 минуты."
|
||||
: "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 редактирует фотографию… Это обычно занимает 1–3 минуты."
|
||||
: "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
77
public/js/main.js
Normal 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
322
public/js/reference.js
Normal 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
74
public/js/state.js
Normal 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
187
public/js/suggest.js
Normal 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
138
public/js/ui.js
Normal 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
123
public/js/upload.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user