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:
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user