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

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;
});
}
}