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

206
src/config.js Normal file
View File

@@ -0,0 +1,206 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { ConfigError } = require("./errors");
const REQUIRED_NODES = [
"VAELoader",
"UpscaleModelLoader",
"ImageUpscaleWithModel",
"ImageScale",
"SaveImage",
];
const VALID_LOG_LEVELS = ["debug", "info", "warn", "error"];
function resolveCodexExe(cliPathOverride) {
const candidates = [];
if (cliPathOverride) {
candidates.push(cliPathOverride);
}
try {
const binRoot = path.join(
process.env.LOCALAPPDATA || "",
"OpenAI",
"Codex",
"bin"
);
if (fs.existsSync(binRoot)) {
for (const entry of fs.readdirSync(binRoot)) {
const candidate = path.join(binRoot, entry, "codex.exe");
if (fs.existsSync(candidate)) {
candidates.push(candidate);
}
}
}
} catch {
// Skip if path error
}
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) {
return candidate;
}
}
return "codex";
}
function parseBoolean(val, defaultVal) {
if (val === undefined || val === null || val === "") return defaultVal;
if (typeof val === "boolean") return val;
const s = String(val).trim().toLowerCase();
if (s === "true" || s === "1" || s === "yes") return true;
if (s === "false" || s === "0" || s === "no") return false;
return defaultVal;
}
function parseNumber(val, defaultVal, name, issues, min = 0) {
if (val === undefined || val === null || val === "") return defaultVal;
const num = Number(val);
if (!Number.isFinite(num) || isNaN(num) || num < min) {
issues.push(`Переменная ${name} должна быть числом >= ${min}, получено: "${val}"`);
return defaultVal;
}
return num;
}
function loadConfig(env = process.env) {
const issues = [];
// PORT / PORT_CANDIDATES
let portCandidates = [3000, 8080, 8090];
let port = 3000;
if (env.PORT !== undefined && env.PORT !== "") {
const parsedPort = Number(env.PORT);
if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
issues.push(`PORT должен быть целым числом от 1 до 65535, получено: "${env.PORT}"`);
} else {
port = parsedPort;
portCandidates = [parsedPort];
}
}
// COMFY_URL
const comfyUrlStr = env.COMFY_URL || "http://192.168.31.240:8188";
try {
const parsedUrl = new URL(comfyUrlStr);
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
issues.push(`COMFY_URL должен использовать протокол http или https, получено: "${comfyUrlStr}"`);
}
} catch {
issues.push(`Некорректный COMFY_URL: "${comfyUrlStr}"`);
}
// APP_USER
const appUser = (env.APP_USER !== undefined && env.APP_USER !== "")
? String(env.APP_USER).trim()
: "admin";
// APP_PASSWORD
let isGeneratedPassword = false;
let appPassword = "";
if (env.APP_PASSWORD !== undefined && String(env.APP_PASSWORD).trim() !== "") {
appPassword = String(env.APP_PASSWORD);
} else {
appPassword = crypto.randomBytes(12).toString("base64url");
isGeneratedPassword = true;
}
// LOG_LEVEL
const logLevelRaw = (env.LOG_LEVEL || "info").toLowerCase();
if (!VALID_LOG_LEVELS.includes(logLevelRaw)) {
issues.push(`LOG_LEVEL должен быть одним из [${VALID_LOG_LEVELS.join(", ")}], получено: "${env.LOG_LEVEL}"`);
}
const logLevel = VALID_LOG_LEVELS.includes(logLevelRaw) ? logLevelRaw : "info";
// DATA_DIR & HISTORY_DIR
const dataDir = path.resolve(env.DATA_DIR || "./data");
const historyDir = path.resolve(env.HISTORY_DIR || path.join(dataDir, "history"));
// Numeric parameters
const historyLimit = parseNumber(env.HISTORY_LIMIT, 50, "HISTORY_LIMIT", issues, 1);
const maxUploadBytes = parseNumber(env.MAX_UPLOAD_BYTES, 25 * 1024 * 1024, "MAX_UPLOAD_BYTES", issues, 1024);
const editMaxDimension = parseNumber(env.EDIT_MAX_DIMENSION, 1024, "EDIT_MAX_DIMENSION", issues, 0);
const upscaleMaxDimension = parseNumber(env.UPSCALE_MAX_DIMENSION, 2048, "UPSCALE_MAX_DIMENSION", issues, 256);
const codexEditTimeoutMs = parseNumber(env.CODEX_EDIT_TIMEOUT_MS, 420000, "CODEX_EDIT_TIMEOUT_MS", issues, 1000);
const jobTimeoutMs = parseNumber(env.JOB_TIMEOUT_MS, 300000, "JOB_TIMEOUT_MS", issues, 1000);
const requestTimeoutMs = parseNumber(env.REQUEST_TIMEOUT_MS, 15000, "REQUEST_TIMEOUT_MS", issues, 500);
const pollIntervalMs = parseNumber(env.POLL_INTERVAL_MS, 1500, "POLL_INTERVAL_MS", issues, 100);
const sessionTtlMs = parseNumber(env.SESSION_TTL_MS, 7 * 24 * 60 * 60 * 1000, "SESSION_TTL_MS", issues, 1000);
const codexConcurrency = parseNumber(env.CODEX_CONCURRENCY, 1, "CODEX_CONCURRENCY", issues, 1);
const upscaleConcurrency = parseNumber(env.UPSCALE_CONCURRENCY, 2, "UPSCALE_CONCURRENCY", issues, 1);
const maxQueuedJobs = parseNumber(env.MAX_QUEUED_JOBS, 5, "MAX_QUEUED_JOBS", issues, 1);
// Booleans
const sessionPersist = parseBoolean(env.SESSION_PERSIST, true);
const sseEnabled = parseBoolean(env.SSE_ENABLED, true);
const trustProxy = parseBoolean(env.TRUST_PROXY, false);
// Rate limit
const rateLimitWindowMs = parseNumber(env.LOGIN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000, "LOGIN_RATE_LIMIT_WINDOW_MS", issues, 1000);
const rateLimitMax = parseNumber(env.LOGIN_RATE_LIMIT_MAX, 5, "LOGIN_RATE_LIMIT_MAX", issues, 1);
const loginRateLimit = { windowMs: rateLimitWindowMs, max: rateLimitMax };
// Codex CLI path
const codexCliPath = resolveCodexExe(env.CODEX_CLI_PATH);
const codexModel = env.CODEX_MODEL || null;
const codexReasoningEffort = env.CODEX_REASONING_EFFORT || null;
if (issues.length > 0) {
throw new ConfigError(`Ошибки в конфигурации:\n- ${issues.join("\n- ")}`, issues);
}
const configObj = {
PORT: port,
PORT_CANDIDATES: portCandidates,
COMFY_URL: comfyUrlStr,
CHECKPOINT: "sdxl_turbo.safetensors",
REQUIRED_NODES,
APP_USER: appUser,
APP_PASSWORD: appPassword,
isGeneratedPassword,
LOG_LEVEL: logLevel,
DATA_DIR: dataDir,
HISTORY_DIR: historyDir,
HISTORY_LIMIT: historyLimit,
MAX_UPLOAD_BYTES: maxUploadBytes,
EDIT_MAX_DIMENSION: editMaxDimension,
UPSCALE_MAX_DIMENSION: upscaleMaxDimension,
CODEX_CLI_PATH: codexCliPath,
CODEX_MODEL: codexModel,
CODEX_REASONING_EFFORT: codexReasoningEffort,
CODEX_EDIT_TIMEOUT_MS: codexEditTimeoutMs,
JOB_TIMEOUT_MS: jobTimeoutMs,
REQUEST_TIMEOUT_MS: requestTimeoutMs,
POLL_INTERVAL_MS: pollIntervalMs,
SESSION_TTL_MS: sessionTtlMs,
SESSION_PERSIST: sessionPersist,
LOGIN_RATE_LIMIT: loginRateLimit,
CODEX_CONCURRENCY: codexConcurrency,
UPSCALE_CONCURRENCY: upscaleConcurrency,
MAX_QUEUED_JOBS: maxQueuedJobs,
SSE_ENABLED: sseEnabled,
TRUST_PROXY: trustProxy,
getSanitized() {
const copy = { ...this };
delete copy.APP_PASSWORD;
copy.APP_PASSWORD_SET = Boolean(this.APP_PASSWORD);
return copy;
},
};
return Object.freeze(configObj);
}
module.exports = {
loadConfig,
REQUIRED_NODES,
resolveCodexExe,
};

114
src/core/auth/password.js Normal file
View File

@@ -0,0 +1,114 @@
"use strict";
const crypto = require("crypto");
const { promisify } = require("util");
const scryptAsync = promisify(crypto.scrypt);
const KEY_LEN = 64;
const SALT_LEN = 16;
const SCRYPT_OPTIONS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
};
/**
* Хеширует пароль с помощью scrypt и случайной соли.
* @param {string} password
* @returns {Promise<string>} Формат: "saltHex:derivedKeyHex"
*/
async function hashPassword(password) {
if (typeof password !== "string") {
throw new TypeError("Пароль должен быть строкой");
}
const salt = crypto.randomBytes(SALT_LEN);
const derivedKey = await scryptAsync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
}
/**
* Синхронное хеширование (для инициализации при старте).
* @param {string} password
* @returns {string}
*/
function hashPasswordSync(password) {
if (typeof password !== "string") {
throw new TypeError("Пароль должен быть строкой");
}
const salt = crypto.randomBytes(SALT_LEN);
const derivedKey = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return `${salt.toString("hex")}:${derivedKey.toString("hex")}`;
}
/**
* Проверяет пароль против сохранённого scrypt-хеша с защитой от тайминг-атак.
* @param {string} password
* @param {string} storedHash
* @returns {Promise<boolean>}
*/
async function verifyPassword(password, storedHash) {
if (typeof password !== "string" || typeof storedHash !== "string") {
return false;
}
const parts = storedHash.split(":");
if (parts.length !== 2) {
return false;
}
const [saltHex, keyHex] = parts;
try {
const salt = Buffer.from(saltHex, "hex");
const originalKey = Buffer.from(keyHex, "hex");
if (salt.length !== SALT_LEN || originalKey.length !== KEY_LEN) {
return false;
}
const derivedKey = await scryptAsync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return crypto.timingSafeEqual(originalKey, derivedKey);
} catch {
return false;
}
}
/**
* Синхронная проверка пароля.
* @param {string} password
* @param {string} storedHash
* @returns {boolean}
*/
function verifyPasswordSync(password, storedHash) {
if (typeof password !== "string" || typeof storedHash !== "string") {
return false;
}
const parts = storedHash.split(":");
if (parts.length !== 2) {
return false;
}
const [saltHex, keyHex] = parts;
try {
const salt = Buffer.from(saltHex, "hex");
const originalKey = Buffer.from(keyHex, "hex");
if (salt.length !== SALT_LEN || originalKey.length !== KEY_LEN) {
return false;
}
const derivedKey = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_OPTIONS);
return crypto.timingSafeEqual(originalKey, derivedKey);
} catch {
return false;
}
}
module.exports = {
hashPassword,
hashPasswordSync,
verifyPassword,
verifyPasswordSync,
};

View File

@@ -0,0 +1,190 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
function hashToken(token) {
return crypto.createHash("sha256").update(String(token)).digest("hex");
}
class SessionStore {
/**
* @param {Object} options
* @param {string} options.file - Путь к sessions.json
* @param {number} options.ttlMs - Время жизни сессии в мс
* @param {boolean} [options.persist=true] - Сохранять ли на диск
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ file, ttlMs = 7 * 24 * 60 * 60 * 1000, persist = true, logger = null }) {
this.file = file;
this.ttlMs = ttlMs;
this.persist = persist;
this.logger = logger;
/** @type {Map<string, { user: string, expiresAt: number, createdAt: number }>} */
this.sessions = new Map(); // key: hash(token)
this._persistTimer = null;
this._sweepInterval = null;
if (this.persist && this.file) {
this._load();
}
this._sweepInterval = setInterval(() => this.sweep(), 60 * 60 * 1000);
if (this._sweepInterval.unref) {
this._sweepInterval.unref();
}
}
_load() {
try {
if (!fs.existsSync(this.file)) return;
const raw = fs.readFileSync(this.file, "utf8");
if (!raw.trim()) return;
const data = JSON.parse(raw);
const now = Date.now();
let loaded = 0;
for (const [tokenHash, session] of Object.entries(data)) {
if (session && session.expiresAt && session.expiresAt > now && session.user) {
this.sessions.set(tokenHash, {
user: session.user,
expiresAt: session.expiresAt,
createdAt: session.createdAt || now,
});
loaded++;
}
}
if (this.logger) {
this.logger.debug("Сессии загружены с диска", { count: loaded });
}
} catch (err) {
if (this.logger) {
this.logger.warn("Ошибка при загрузке sessions.json, инициализация пустого хранилища", {
error: err.message,
});
}
}
}
create(user) {
const rawToken = crypto.randomBytes(32).toString("hex");
const tokenHash = hashToken(rawToken);
const now = Date.now();
const session = {
user: String(user || "admin"),
expiresAt: now + this.ttlMs,
createdAt: now,
};
this.sessions.set(tokenHash, session);
this._schedulePersist();
return rawToken;
}
get(token) {
if (!token) return null;
const tokenHash = hashToken(token);
const session = this.sessions.get(tokenHash);
if (!session) return null;
const now = Date.now();
if (session.expiresAt <= now) {
this.sessions.delete(tokenHash);
this._schedulePersist();
return null;
}
// Sliding TTL
session.expiresAt = now + this.ttlMs;
this._schedulePersist();
return {
user: session.user,
expiresAt: session.expiresAt,
createdAt: session.createdAt,
};
}
delete(token) {
if (!token) return;
const tokenHash = hashToken(token);
if (this.sessions.delete(tokenHash)) {
this._schedulePersist();
}
}
sweep() {
const now = Date.now();
let removed = 0;
for (const [tokenHash, session] of this.sessions) {
if (session.expiresAt <= now) {
this.sessions.delete(tokenHash);
removed++;
}
}
if (removed > 0) {
this._schedulePersist();
if (this.logger) {
this.logger.debug("Очищены устаревшие сессии", { count: removed });
}
}
}
_schedulePersist() {
if (!this.persist || !this.file) return;
if (this._persistTimer) return;
this._persistTimer = setTimeout(() => {
this._persistTimer = null;
this._flushPersist();
}, 500);
if (this._persistTimer.unref) {
this._persistTimer.unref();
}
}
_flushPersist() {
if (!this.persist || !this.file) return;
try {
const dir = path.dirname(this.file);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const obj = {};
for (const [tokenHash, session] of this.sessions) {
obj[tokenHash] = session;
}
const tmpFile = `${this.file}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
const fd = fs.openSync(tmpFile, "w");
try {
fs.writeFileSync(fd, JSON.stringify(obj, null, 2), "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmpFile, this.file);
} catch (err) {
if (this.logger) {
this.logger.error("Ошибка при сохранении sessions.json", { error: err.message });
}
}
}
close() {
if (this._sweepInterval) {
clearInterval(this._sweepInterval);
this._sweepInterval = null;
}
if (this._persistTimer) {
clearTimeout(this._persistTimer);
this._persistTimer = null;
}
this._flushPersist();
}
}
module.exports = {
SessionStore,
hashToken,
};

265
src/core/codex/cli.js Normal file
View File

@@ -0,0 +1,265 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
function codexExecArgs(jobDir, inputNames) {
const args = [
"exec",
"-C",
jobDir,
"--skip-git-repo-check",
"--ephemeral",
"--dangerously-bypass-approvals-and-sandbox",
];
for (const name of inputNames) {
args.push("-i", name);
}
args.push("-o", "last_message.txt", "-");
return args;
}
class CodexCli {
/**
* @param {Object} options
* @param {string} [options.codexExe='codex']
* @param {number} [options.defaultTimeoutMs=420000]
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ codexExe = "codex", defaultTimeoutMs = 420000, logger = null }) {
this.codexExe = codexExe;
this.defaultTimeoutMs = defaultTimeoutMs;
this.logger = logger;
}
/**
* Запускает Codex CLI для редактирования изображения.
* @param {Object} params
* @param {string} params.jobDir
* @param {string[]} params.inputNames
* @param {string} params.prompt
* @param {number} [params.timeoutMs]
* @returns {Promise<{ exitCode: number|null, timedOut: boolean, outputPath: string|null, lastMessage: string, durationMs: number }>}
*/
runEdit({ jobDir, inputNames, prompt, timeoutMs = this.defaultTimeoutMs }) {
const lastMessagePath = path.join(jobDir, "last_message.txt");
const outputPath = path.join(jobDir, "output.jpg");
return new Promise((resolve, reject) => {
let child;
try {
child = spawn(
this.codexExe,
codexExecArgs(jobDir, inputNames),
{
cwd: jobDir,
env: process.env,
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
}
);
} catch (error) {
reject(error);
return;
}
const startedAt = Date.now();
let exitCode = null;
let processExitedAt = null;
let outputSeenAt = null;
let timedOut = false;
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
clearInterval(deadlineTimer);
const lastMessage = fs.existsSync(lastMessagePath)
? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 2000)
: "";
const hasOutput =
fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0;
resolve({
exitCode,
timedOut,
outputPath: hasOutput ? outputPath : null,
lastMessage,
durationMs: Date.now() - startedAt,
});
};
const deadlineTimer = setInterval(() => {
if (Date.now() - startedAt > timeoutMs) {
timedOut = true;
try {
child.kill();
} catch {
// Process already gone
}
settle();
}
}, 1000);
child.on("error", (error) => {
if (settled) return;
settled = true;
clearInterval(deadlineTimer);
reject(error);
});
child.on("exit", (code) => {
exitCode = code;
processExitedAt = Date.now();
});
child.stdin.on("error", () => {});
child.stdin.end(prompt);
const poll = () => {
if (settled) return;
if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) {
if (outputSeenAt === null) {
outputSeenAt = Date.now();
}
// Дожидаемся итогового сообщения Codex, но не дольше 10 секунд
if (
!fs.existsSync(lastMessagePath) &&
Date.now() - outputSeenAt < 10000
) {
setTimeout(poll, 1000);
return;
}
settle();
return;
}
if (Date.now() - startedAt > timeoutMs) {
timedOut = true;
try {
child.kill();
} catch {
// Process already gone
}
settle();
return;
}
if (processExitedAt !== null && Date.now() - processExitedAt > 15000) {
settle();
return;
}
setTimeout(poll, 1000);
};
poll();
});
}
/**
* Запускает Codex CLI для генерации подсказок промптов.
* @param {Object} params
* @param {string} params.jobDir
* @param {string[]} params.inputNames
* @param {string} params.prompt
* @param {number} [params.timeoutMs]
* @returns {Promise<{ exitCode: number|null, timedOut: boolean, lastMessage: string, durationMs: number }>}
*/
runSuggest({ jobDir, inputNames, prompt, timeoutMs = this.defaultTimeoutMs }) {
const lastMessagePath = path.join(jobDir, "last_message.txt");
return new Promise((resolve, reject) => {
let child;
try {
child = spawn(
this.codexExe,
codexExecArgs(jobDir, inputNames),
{
cwd: jobDir,
env: process.env,
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
}
);
} catch (error) {
reject(error);
return;
}
const startedAt = Date.now();
let exitCode = null;
let settled = false;
let timedOut = false;
const settle = () => {
if (settled) return;
settled = true;
clearInterval(deadlineTimer);
const lastMessage = fs.existsSync(lastMessagePath)
? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 4000)
: "";
resolve({
exitCode,
timedOut,
lastMessage,
durationMs: Date.now() - startedAt,
});
};
const deadlineTimer = setInterval(() => {
if (Date.now() - startedAt > timeoutMs) {
timedOut = true;
try {
child.kill();
} catch {
// Process already gone
}
settle();
}
}, 1000);
child.on("error", (error) => {
if (settled) return;
settled = true;
clearInterval(deadlineTimer);
reject(error);
});
child.on("exit", (code) => {
exitCode = code;
const exitAt = Date.now();
const pollLastMessage = () => {
if (settled) return;
const text = fs.existsSync(lastMessagePath)
? fs.readFileSync(lastMessagePath, "utf8").trim()
: "";
if (text.length > 0 || Date.now() - exitAt > 5000) {
settle();
return;
}
setTimeout(pollLastMessage, 250);
};
setTimeout(pollLastMessage, 250);
});
child.stdin.on("error", () => {});
child.stdin.end(prompt);
});
}
}
module.exports = {
CodexCli,
codexExecArgs,
};

156
src/core/codex/prompts.js Normal file
View File

@@ -0,0 +1,156 @@
"use strict";
const { AppError } = require("../../errors");
/**
* Парсит и валидирует координаты прямоугольной области референса.
* @param {string|object} value
* @returns {{ x: number, y: number, w: number, h: number } | null}
*/
function parseRegion(value) {
if (value === undefined || value === null || String(value).trim() === "") {
return null;
}
let parsed = value;
if (typeof value === "string") {
try {
parsed = JSON.parse(value);
} catch {
throw new AppError(400, "Область референса передана в неверном формате.");
}
}
const nums = ["x", "y", "w", "h"].map((key) => Number(parsed?.[key]));
if (nums.some((num) => !Number.isFinite(num) || num < 0)) {
throw new AppError(
400,
"Область референса должна содержать неотрицательные числовые поля x, y, w, h."
);
}
const [x, y, w, h] = nums;
if (w <= 0 || h <= 0) {
throw new AppError(400, "Ширина и высота области должны быть больше 0.");
}
return {
x: Math.round(x),
y: Math.round(y),
w: Math.round(w),
h: Math.round(h),
};
}
/**
* Масштабирует координаты области при даунскейле референса.
* @param {{ x: number, y: number, w: number, h: number }} region
* @param {number} factor
* @returns {{ x: number, y: number, w: number, h: number }}
*/
function scaleRegion(region, factor = 1) {
if (!region || factor === 1 || !Number.isFinite(factor) || factor <= 0) {
return region;
}
return {
x: Math.max(0, Math.round(region.x * factor)),
y: Math.max(0, Math.round(region.y * factor)),
w: Math.max(1, Math.round(region.w * factor)),
h: Math.max(1, Math.round(region.h * factor)),
};
}
/**
* Собирает текст системного промпта для редактирования через Codex.
* @param {Object} options
* @param {string} options.prompt
* @param {{ width?: number, height?: number }|null} [options.dimensions]
* @param {string} [options.inputName='input.jpg']
* @param {string|null} [options.referenceName=null]
* @param {{ x: number, y: number, w: number, h: number }|null} [options.region=null]
* @param {boolean} [options.downscaled=false]
* @returns {string}
*/
function buildEditPrompt({
prompt,
dimensions = null,
inputName = "input.jpg",
referenceName = null,
region = null,
downscaled = false,
}) {
const size =
dimensions && dimensions.width && dimensions.height
? `${dimensions.width}×${dimensions.height}`
: "";
const lines = [
`Перед тобой фотография ${inputName}. Примени к ней следующее редактирование: «${prompt}».`,
];
if (referenceName) {
lines.push(
`Также дано вспомогательное изображение ${referenceName} — тот же сюжет и ракурс, что и ${inputName}. Красным прямоугольником на ${referenceName} выделена область, в которой нужно внести правку.`
);
}
if (referenceName && region) {
lines.push(
`Координаты области на ${referenceName}: x=${region.x}, y=${region.y}, ширина=${region.w}, высота=${region.h} (в пикселях).`
);
lines.push(
`Примени редактирование к ${inputName} именно в области, соответствующей прямоугольнику на ${referenceName}. Остальную часть ${inputName} не меняй.`
);
}
if (referenceName && !region) {
lines.push(
`Используй ${referenceName} как ориентир по сюжету и ракурсу при внесении правки в ${inputName}.`
);
}
if (downscaled) {
lines.push("Исходное фото уменьшено для скорости обработки.");
}
lines.push(
"Сохрани отредактированное фото в файл output.jpg в текущей директории.",
"Не перерисовывай фото с нуля и не заменяй его на полностью новое изображение — редактируй исходное фото, сохраняя его сюжет, объекты, лица и пропорции.",
size ? `Сохрани исходный размер ${size}.` : "Сохрани исходные пропорции.",
"Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал."
);
return lines.join(" ");
}
/**
* Собирает промпт для подсказок вариантов редактирования.
* @param {string} [inputName='input.jpg']
* @returns {string}
*/
function buildSuggestPrompt(inputName = "input.jpg") {
return `Перед тобой фотография ${inputName}. Предложи 3 коротких варианта промпта (по одному на строку, без нумерации) для её редактирования. Только варианты промптов, ничего больше.`;
}
/**
* Парсит предложения Codex в массив строк.
* @param {string} text
* @returns {string[]}
*/
function parseSuggestions(text) {
return String(text || "")
.split(/\r?\n/)
.map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim())
.filter((line) => line.length > 0 && line.length <= 200)
.slice(0, 3);
}
module.exports = {
parseRegion,
scaleRegion,
buildEditPrompt,
buildSuggestPrompt,
parseSuggestions,
};

View File

@@ -0,0 +1,224 @@
"use strict";
const crypto = require("crypto");
const path = require("path");
const { AppError } = require("../../errors");
function safeUploadFilename(originalname = "photo.png", mimetype = "image/png") {
const extensionByMime = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
};
const extension =
extensionByMime[String(mimetype).toLowerCase()] ||
path.extname(String(originalname)).toLowerCase() ||
".png";
const originalStem = path.parse(String(originalname || "photo")).name;
const cleanStem =
originalStem
.normalize("NFKC")
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "photo";
return (
[
"prompt-editor",
Date.now(),
crypto.randomBytes(4).toString("hex"),
cleanStem,
].join("-") + extension
);
}
class ComfyClient {
/**
* @param {Object} options
* @param {string} options.comfyUrl
* @param {number} [options.requestTimeoutMs=15000]
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ comfyUrl, requestTimeoutMs = 15000, logger = null }) {
this.comfyUrl = comfyUrl.replace(/\/+$/, "");
this.requestTimeoutMs = requestTimeoutMs;
this.logger = logger;
}
async request(route, options = {}, timeoutMs = this.requestTimeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const url = `${this.comfyUrl}${route}`;
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw new AppError(
502,
`ComfyUI вернул ошибку HTTP ${response.status}.`,
body.slice(0, 2000) || undefined
);
}
return response;
} catch (error) {
if (error instanceof AppError) {
throw error;
}
if (error && error.name === "AbortError") {
throw new AppError(
504,
"ComfyUI не ответил вовремя. Проверьте сервер обработки и повторите попытку."
);
}
const host = new URL(this.comfyUrl).host;
throw new AppError(
503,
`Не удалось подключиться к ComfyUI по адресу ${host}.`,
error instanceof Error ? error.message : String(error)
);
} finally {
clearTimeout(timer);
}
}
async json(route, options = {}, timeoutMs = this.requestTimeoutMs) {
const response = await this.request(route, options, timeoutMs);
try {
return await response.json();
} catch {
throw new AppError(502, "ComfyUI вернул некорректный JSON-ответ.");
}
}
async uploadImage(buffer, originalname = "photo.png", mimetype = "image/png") {
const form = new FormData();
const filename = safeUploadFilename(originalname, mimetype);
form.append(
"image",
new Blob([buffer], { type: mimetype }),
filename
);
form.append("type", "input");
form.append("overwrite", "true");
const uploaded = await this.json(
"/upload/image",
{
method: "POST",
body: form,
},
30000
);
if (!uploaded || typeof uploaded.name !== "string") {
throw new AppError(
502,
"ComfyUI не подтвердил загрузку исходной фотографии.",
uploaded
);
}
const subfolder =
typeof uploaded.subfolder === "string"
? uploaded.subfolder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
: "";
return {
name: uploaded.name,
subfolder,
type: uploaded.type || "input",
loadImageName: subfolder ? `${subfolder}/${uploaded.name}` : uploaded.name,
};
}
async submitPrompt(workflow) {
const response = await this.json(
"/prompt",
{
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
prompt: workflow,
client_id: crypto.randomUUID(),
}),
},
30000
);
const nodeErrors = response?.node_errors;
if (
nodeErrors &&
typeof nodeErrors === "object" &&
Object.keys(nodeErrors).length > 0
) {
throw new AppError(
422,
"ComfyUI отклонил схему обработки. Проверьте установленные модели и узлы.",
nodeErrors
);
}
if (!response || typeof response.prompt_id !== "string") {
throw new AppError(
502,
"ComfyUI не вернул идентификатор задачи.",
response
);
}
return response.prompt_id;
}
async getHistory(promptId) {
return this.json(`/history/${encodeURIComponent(promptId)}`);
}
async downloadImage({ filename, subfolder = "", type = "output" }) {
const parameters = new URLSearchParams({
filename,
subfolder: subfolder || "",
type: type || "output",
});
const upstream = await this.request(
`/view?${parameters.toString()}`,
{},
60000
);
if (!upstream.body) {
throw new AppError(502, "ComfyUI вернул пустое изображение.");
}
return Buffer.from(await upstream.arrayBuffer());
}
async viewStream({ filename, subfolder = "", type = "output" }) {
const parameters = new URLSearchParams({
filename,
subfolder: subfolder || "",
type: type || "output",
});
return this.request(`/view?${parameters.toString()}`, {}, 60000);
}
}
module.exports = {
ComfyClient,
safeUploadFilename,
};

View File

@@ -0,0 +1,195 @@
"use strict";
const { AppError } = require("../../errors");
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function extractChoiceOptions(specification) {
if (!Array.isArray(specification)) {
return [];
}
// Old format: [["model-a", "model-b"], {...}]
if (Array.isArray(specification[0])) {
return specification[0].filter((item) => typeof item === "string");
}
// New format: ["COMBO", { options: ["model-a"] }]
if (
specification[1] &&
typeof specification[1] === "object" &&
Array.isArray(specification[1].options)
) {
return specification[1].options.filter((item) => typeof item === "string");
}
if (specification.every((item) => typeof item === "string")) {
return specification;
}
return [];
}
function chooseUpscaleModel(models) {
const preferences = [
/4x[-_ ]?ultrasharp/i,
/realesrgan.*x4plus/i,
/4x.*plus/i,
/4x/i,
];
for (const pattern of preferences) {
const match = models.find((model) => pattern.test(model));
if (match) {
return match;
}
}
return models[0] || null;
}
function executionErrorFromHistory(record) {
const messages = record?.status?.messages;
if (!Array.isArray(messages)) {
return null;
}
for (const message of messages) {
if (!Array.isArray(message) || message[0] !== "execution_error") {
continue;
}
const payload = message[1];
if (payload?.exception_message) {
return payload.exception_message;
}
return "Внутренняя ошибка выполнения workflow.";
}
return null;
}
class ComfyRuntime {
/**
* @param {Object} options
* @param {import('./comfy-client').ComfyClient} options.client
* @param {string[]} [options.requiredNodes]
* @param {string} [options.checkpoint]
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({
client,
requiredNodes = [
"VAELoader",
"UpscaleModelLoader",
"ImageUpscaleWithModel",
"ImageScale",
"SaveImage",
],
checkpoint = "sdxl_turbo.safetensors",
logger = null,
}) {
this.client = client;
this.requiredNodes = requiredNodes;
this.checkpoint = checkpoint;
this.logger = logger;
this._cache = null;
this._cacheExpiresAt = 0;
}
async getRuntimeInfo(forceRefresh = false) {
if (!forceRefresh && this._cache && Date.now() < this._cacheExpiresAt) {
return this._cache;
}
const objectInfo = await this.client.json("/object_info");
const nodes = Object.fromEntries(
this.requiredNodes.map((nodeName) => [
nodeName,
Boolean(objectInfo[nodeName]),
])
);
const upscaleSpec = objectInfo.UpscaleModelLoader?.input?.required?.model_name;
const checkpointSpec = objectInfo.CheckpointLoaderSimple?.input?.required?.ckpt_name;
const upscaleModels = extractChoiceOptions(upscaleSpec);
const checkpoints = extractChoiceOptions(checkpointSpec);
this._cache = {
nodes,
upscaleModels,
selectedUpscaleModel: chooseUpscaleModel(upscaleModels),
checkpoints,
checkpointAvailable: checkpoints.includes(this.checkpoint),
};
this._cacheExpiresAt = Date.now() + 30000;
return this._cache;
}
requireNodes(runtime, nodeNames) {
const missing = nodeNames.filter((nodeName) => !runtime.nodes[nodeName]);
if (missing.length > 0) {
throw new AppError(
503,
`На сервере ComfyUI отсутствуют необходимые узлы: ${missing.join(", ")}.`,
{ missing }
);
}
}
async waitForResult(promptId, saveNodeId, timeoutMs = 300000, pollIntervalMs = 1500) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const history = await this.client.getHistory(promptId);
const record = history?.[promptId] || null;
const output = record?.outputs?.[saveNodeId];
const image = output?.images?.[0];
if (image && typeof image.filename === "string") {
return {
filename: image.filename,
subfolder: typeof image.subfolder === "string" ? image.subfolder : "",
type: image.type || "output",
};
}
const executionError = executionErrorFromHistory(record);
if (executionError) {
throw new AppError(
502,
`ComfyUI не смог обработать изображение: ${executionError}`
);
}
if (record?.status?.completed) {
throw new AppError(
502,
"ComfyUI завершил задачу, но не создал итоговое изображение.",
record.status.messages
);
}
await delay(pollIntervalMs);
}
throw new AppError(
504,
"Обработка заняла больше времени, чем положено, и была остановлена по таймауту."
);
}
}
module.exports = {
ComfyRuntime,
extractChoiceOptions,
chooseUpscaleModel,
executionErrorFromHistory,
};

View File

@@ -0,0 +1,75 @@
"use strict";
function imageScaleNode(imageSource, size) {
return {
class_type: "ImageScale",
inputs: {
image: imageSource,
upscale_method: "lanczos",
width: size.width,
height: size.height,
crop: "disabled",
},
};
}
function buildUpscaleWorkflow(uploaded, modelName, scaledSize) {
const imageSource = scaledSize ? ["2", 0] : ["1", 0];
const workflow = {
"1": {
class_type: "LoadImage",
inputs: {
image: uploaded.loadImageName,
},
},
"3": {
class_type: "UpscaleModelLoader",
inputs: {
model_name: modelName,
},
},
"4": {
class_type: "ImageUpscaleWithModel",
inputs: {
upscale_model: ["3", 0],
image: imageSource,
},
},
"5": {
class_type: "SaveImage",
inputs: {
images: ["4", 0],
filename_prefix: "prompt_editor/upscale",
},
},
};
if (scaledSize) {
workflow["2"] = imageScaleNode(["1", 0], scaledSize);
}
return {
workflow,
saveNodeId: "5",
};
}
function makePublicResult(image) {
const parameters = new URLSearchParams({
filename: image.filename,
subfolder: image.subfolder || "",
type: image.type || "output",
});
return {
...image,
url: `/api/result?${parameters.toString()}`,
};
}
module.exports = {
imageScaleNode,
buildUpscaleWorkflow,
makePublicResult,
};

View File

@@ -0,0 +1,261 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { downscaleImage } = require("../images/resize");
const { getImageDimensions } = require("../images/dimensions");
const { buildEditPrompt, buildSuggestPrompt, parseSuggestions, scaleRegion } = require("../codex/prompts");
const { truncate } = require("../../logger");
const { AppError } = require("../../errors");
/**
* Создаёт адаптер Codex Engine для edit и suggest.
* @param {import('./engine').EngineContext} ctx
*/
function createCodexEditEngine(ctx) {
const { config, logger, history, codexCli } = ctx;
return {
async run(job) {
const { payload } = job;
const {
sourceId,
inputBuffer,
inputMimetype,
prompt,
referenceId,
referenceBuffer,
referenceMimetype,
region,
} = payload || {};
if (!prompt || !String(prompt).trim()) {
throw new AppError(400, "Промпт обязателен для редактирования.");
}
// 1. Resolve input image buffer
let rawBuffer = null;
let inputExt = "jpg";
if (sourceId) {
const fileInfo = await history.getVersionFile(sourceId);
rawBuffer = fileInfo.buffer;
inputExt = fileInfo.version.ext || "jpg";
} else if (inputBuffer) {
rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer);
if (inputMimetype) {
inputExt = inputMimetype.includes("png") ? "png" : inputMimetype.includes("webp") ? "webp" : "jpg";
}
} else {
throw new AppError(400, "Исходная фотография не найдена.");
}
// 2. Downscale input image if needed
const maxDim = config.EDIT_MAX_DIMENSION || 0;
const scaledInput = await downscaleImage(rawBuffer, maxDim);
const inputBufferToUse = scaledInput.buffer;
const dimsToUse = { width: scaledInput.width, height: scaledInput.height };
const factor = scaledInput.factor || 1;
// 3. Prepare job directory
const jobsBaseDir = path.join(config.DATA_DIR, ".codex-jobs");
const jobDir = path.join(jobsBaseDir, job.id);
fs.mkdirSync(jobDir, { recursive: true });
const inputName = `input.${inputExt}`;
const inputFilePath = path.join(jobDir, inputName);
fs.writeFileSync(inputFilePath, inputBufferToUse);
const inputNames = [inputName];
// 4. Resolve reference if provided
let referenceName = null;
let scaledRegion = null;
let refRawBuffer = null;
let refExt = "jpg";
if (referenceId) {
try {
const refFileInfo = await history.getVersionFile(referenceId);
refRawBuffer = refFileInfo.buffer;
refExt = refFileInfo.version.ext || "jpg";
} catch (err) {
logger.warn("Не удалось прочитать референс по referenceId", { referenceId, error: err.message });
}
} else if (referenceBuffer) {
refRawBuffer = Buffer.isBuffer(referenceBuffer) ? referenceBuffer : Buffer.from(referenceBuffer);
if (referenceMimetype) {
refExt = referenceMimetype.includes("png") ? "png" : referenceMimetype.includes("webp") ? "webp" : "jpg";
}
}
if (refRawBuffer) {
referenceName = `reference.${refExt}`;
const refFilePath = path.join(jobDir, referenceName);
if (scaledInput.downscaled && factor < 1) {
const scaledRef = await downscaleImage(refRawBuffer, maxDim);
fs.writeFileSync(refFilePath, scaledRef.buffer);
if (region) {
scaledRegion = scaleRegion(region, factor);
}
} else {
fs.writeFileSync(refFilePath, refRawBuffer);
scaledRegion = region;
}
inputNames.push(referenceName);
}
// 5. Build prompt
const promptText = buildEditPrompt({
prompt,
dimensions: dimsToUse,
inputName,
referenceName,
region: scaledRegion,
downscaled: scaledInput.downscaled,
});
logger.info("Запуск Codex редактирования", {
jobId: job.id,
prompt: truncate(prompt, 100),
downscaled: scaledInput.downscaled,
width: dimsToUse.width,
height: dimsToUse.height,
});
// 6. Execute Codex
const result = await codexCli.runEdit({
jobDir,
inputNames,
prompt: promptText,
timeoutMs: config.CODEX_EDIT_TIMEOUT_MS,
});
// 7. Check result and create version
if (!result.outputPath || !fs.existsSync(result.outputPath)) {
const errorMsg = result.timedOut
? "Обработка заняла больше времени и была остановлена по таймауту."
: result.lastMessage
? `Codex не создал файл результата. Ответ: ${truncate(result.lastMessage, 300)}`
: "Codex завершил работу, но не создал файл output.jpg.";
return {
ok: false,
error: errorMsg,
details: {
exitCode: result.exitCode,
timedOut: result.timedOut,
lastMessage: result.lastMessage,
durationMs: result.durationMs,
},
};
}
const outputBuffer = fs.readFileSync(result.outputPath);
const outputDims = getImageDimensions(outputBuffer) || dimsToUse;
const label = `Правка: ${truncate(prompt, 30)}`;
const version = await history.createVersion({
kind: "edit",
label,
prompt,
parentId: sourceId || null,
referenceId: referenceId || null,
buffer: outputBuffer,
ext: "jpg",
width: outputDims.width,
height: outputDims.height,
});
return {
ok: true,
version,
extra: {
summary: result.lastMessage || undefined,
downscaledFrom: scaledInput.downscaled
? { width: scaledInput.originalWidth, height: scaledInput.originalHeight }
: null,
exitCode: result.exitCode,
timedOut: result.timedOut,
durationMs: result.durationMs,
},
};
},
};
}
function createCodexSuggestEngine(ctx) {
const { config, logger, history, codexCli } = ctx;
return {
async run(job) {
const { payload } = job;
const { sourceId, inputBuffer, inputMimetype } = payload || {};
let rawBuffer = null;
let inputExt = "jpg";
if (sourceId) {
const fileInfo = await history.getVersionFile(sourceId);
rawBuffer = fileInfo.buffer;
inputExt = fileInfo.version.ext || "jpg";
} else if (inputBuffer) {
rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer);
if (inputMimetype) {
inputExt = inputMimetype.includes("png") ? "png" : inputMimetype.includes("webp") ? "webp" : "jpg";
}
} else {
throw new AppError(400, "Исходная фотография не найдена.");
}
const jobsBaseDir = path.join(config.DATA_DIR, ".codex-jobs");
const jobDir = path.join(jobsBaseDir, job.id);
fs.mkdirSync(jobDir, { recursive: true });
const inputName = `input.${inputExt}`;
const inputFilePath = path.join(jobDir, inputName);
fs.writeFileSync(inputFilePath, rawBuffer);
const promptText = buildSuggestPrompt(inputName);
logger.info("Запуск генерации подсказок Codex", { jobId: job.id });
const result = await codexCli.runSuggest({
jobDir,
inputNames: [inputName],
prompt: promptText,
timeoutMs: config.CODEX_EDIT_TIMEOUT_MS,
});
const suggestions = parseSuggestions(result.lastMessage);
if (suggestions.length === 0) {
return {
ok: false,
error: "Codex не вернул вариантов редактирования.",
details: {
exitCode: result.exitCode,
timedOut: result.timedOut,
lastMessage: result.lastMessage,
},
};
}
return {
ok: true,
extra: {
suggestions,
summary: result.lastMessage,
},
};
},
};
}
module.exports = {
createCodexEditEngine,
createCodexSuggestEngine,
};

View File

@@ -0,0 +1,114 @@
"use strict";
const { getImageDimensions, scaleForLimit } = require("../images/dimensions");
const { buildUpscaleWorkflow, makePublicResult } = require("../comfy/workflows");
const { AppError } = require("../../errors");
/**
* Создаёт адаптер ComfyUI Upscale Engine.
* @param {import('./engine').EngineContext} ctx
*/
function createComfyUpscaleEngine(ctx) {
const { config, logger, history, comfyClient, comfyRuntime } = ctx;
return {
async run(job) {
const { payload } = job;
const { sourceId, inputBuffer, inputMimetype, inputOriginalname } = payload || {};
let rawBuffer = null;
let mimetype = inputMimetype || "image/png";
let originalname = inputOriginalname || "photo.png";
if (sourceId) {
const fileInfo = await history.getVersionFile(sourceId);
rawBuffer = fileInfo.buffer;
mimetype = fileInfo.contentType || "image/jpeg";
originalname = `${fileInfo.version.id}.${fileInfo.version.ext || "jpg"}`;
} else if (inputBuffer) {
rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer);
} else {
throw new AppError(400, "Исходная фотография не найдена.");
}
const inputDims = getImageDimensions(rawBuffer);
if (!inputDims) {
throw new AppError(400, "Не удалось определить размер исходного изображения.");
}
// Check ComfyUI runtime
const runtime = await comfyRuntime.getRuntimeInfo();
comfyRuntime.requireNodes(runtime, ["UpscaleModelLoader", "ImageUpscaleWithModel", "SaveImage"]);
const selectedModel = runtime.selectedUpscaleModel;
if (!selectedModel) {
throw new AppError(
503,
"На сервере ComfyUI не найдены подходящие модели апскейла (UpscaleModelLoader)."
);
}
const scaledSize = scaleForLimit(inputDims, config.UPSCALE_MAX_DIMENSION);
logger.info("Запуск апскейла ComfyUI", {
jobId: job.id,
model: selectedModel,
width: inputDims.width,
height: inputDims.height,
scaledSize,
});
// Upload image to ComfyUI
const uploaded = await comfyClient.uploadImage(rawBuffer, originalname, mimetype);
// Build workflow
const { workflow, saveNodeId } = buildUpscaleWorkflow(uploaded, selectedModel, scaledSize);
// Submit workflow
const promptId = await comfyClient.submitPrompt(workflow);
// Wait for output
const resultImage = await comfyRuntime.waitForResult(
promptId,
saveNodeId,
config.JOB_TIMEOUT_MS,
config.POLL_INTERVAL_MS
);
// Download output image
const outputBuffer = await comfyClient.downloadImage(resultImage);
const outputDims = getImageDimensions(outputBuffer) || {
width: inputDims.width * 4,
height: inputDims.height * 4,
};
const label = `Апскейл 4× (${selectedModel})`;
const version = await history.createVersion({
kind: "upscale",
label,
prompt: null,
parentId: sourceId || null,
referenceId: null,
buffer: outputBuffer,
ext: "png",
width: outputDims.width,
height: outputDims.height,
});
return {
ok: true,
version,
extra: {
promptId,
model: selectedModel,
scaledSize,
publicResult: makePublicResult(resultImage),
},
};
},
};
}
module.exports = {
createComfyUpscaleEngine,
};

View File

@@ -0,0 +1,54 @@
"use strict";
/**
* @typedef {Object} EngineContext
* @property {import('../../config').loadConfig} config
* @property {import('../../logger').Logger} logger
* @property {import('../history/history-service').HistoryService} history
* @property {import('../comfy/comfy-client').ComfyClient} comfyClient
* @property {import('../comfy/comfy-runtime').ComfyRuntime} comfyRuntime
* @property {import('../codex/cli').CodexCli} codexCli
*
* @typedef {Object} EngineResult
* @property {boolean} ok
* @property {import('../history/history-store').Version} [version]
* @property {object} [extra]
* @property {string} [error]
* @property {any} [details]
*/
class EngineRegistry {
constructor() {
this._factories = new Map();
}
/**
* Регистрирует фабрику движка для типа задания.
* @param {string} type - 'edit' | 'upscale' | 'suggest'
* @param {(ctx: EngineContext) => { run: (job: any) => Promise<EngineResult> }} factory
*/
register(type, factory) {
this._factories.set(type, factory);
}
/**
* Создаёт экземпляр движка для указанного типа задания.
* @param {string} type
* @param {EngineContext} ctx
*/
create(type, ctx) {
const factory = this._factories.get(type);
if (!factory) {
throw new Error(`Не найден движок для типа задания: ${type}`);
}
return factory(ctx);
}
has(type) {
return this._factories.has(type);
}
}
module.exports = {
EngineRegistry,
};

View File

@@ -0,0 +1,190 @@
"use strict";
function readUInt24LE(buffer, offset) {
return (
buffer[offset] |
(buffer[offset + 1] << 8) |
(buffer[offset + 2] << 16)
);
}
function getPngDimensions(buffer) {
const pngSignature = "89504e470d0a1a0a";
if (
buffer.length >= 24 &&
buffer.subarray(0, 8).toString("hex") === pngSignature
) {
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
}
return null;
}
function getJpegDimensions(buffer) {
if (
buffer.length < 4 ||
buffer[0] !== 0xff ||
buffer[1] !== 0xd8
) {
return null;
}
const startOfFrameMarkers = new Set([
0xc0, 0xc1, 0xc2, 0xc3,
0xc5, 0xc6, 0xc7,
0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
]);
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
while (offset < buffer.length && buffer[offset] === 0xff) {
offset += 1;
}
if (offset >= buffer.length) {
break;
}
const marker = buffer[offset];
offset += 1;
if (marker === 0xd8 || marker === 0x01) {
continue;
}
if (marker === 0xd9 || marker === 0xda) {
break;
}
if (offset + 1 >= buffer.length) {
break;
}
const segmentLength = buffer.readUInt16BE(offset);
if (segmentLength < 2 || offset + segmentLength > buffer.length) {
break;
}
if (startOfFrameMarkers.has(marker) && segmentLength >= 7) {
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
offset += segmentLength;
}
return null;
}
function getWebpDimensions(buffer) {
if (
buffer.length < 30 ||
buffer.toString("ascii", 0, 4) !== "RIFF" ||
buffer.toString("ascii", 8, 12) !== "WEBP"
) {
return null;
}
const chunkType = buffer.toString("ascii", 12, 16);
if (chunkType === "VP8X") {
return {
width: readUInt24LE(buffer, 24) + 1,
height: readUInt24LE(buffer, 27) + 1,
};
}
if (
chunkType === "VP8 " &&
buffer.length >= 30 &&
buffer[23] === 0x9d &&
buffer[24] === 0x01 &&
buffer[25] === 0x2a
) {
return {
width: buffer.readUInt16LE(26) & 0x3fff,
height: buffer.readUInt16LE(28) & 0x3fff,
};
}
if (chunkType === "VP8L" && buffer.length >= 25 && buffer[20] === 0x2f) {
const byte0 = buffer[21];
const byte1 = buffer[22];
const byte2 = buffer[23];
const byte3 = buffer[24];
return {
width: 1 + byte0 + ((byte1 & 0x3f) << 8),
height: 1 + ((byte1 & 0xc0) >> 6) + (byte2 << 2) + ((byte3 & 0x0f) << 10),
};
}
return null;
}
function getImageDimensions(buffer) {
if (!Buffer.isBuffer(buffer)) return null;
const dimensions =
getPngDimensions(buffer) ||
getJpegDimensions(buffer) ||
getWebpDimensions(buffer);
if (
!dimensions ||
!Number.isInteger(dimensions.width) ||
!Number.isInteger(dimensions.height) ||
dimensions.width <= 0 ||
dimensions.height <= 0
) {
return null;
}
return dimensions;
}
function scaleForLimit(dimensions, maximumDimension) {
const largestDimension = Math.max(
dimensions.width,
dimensions.height
);
if (largestDimension <= maximumDimension) {
return null;
}
const ratio = maximumDimension / largestDimension;
const width = Math.max(
8,
Math.floor((dimensions.width * ratio) / 8) * 8
);
const height = Math.max(
8,
Math.floor((dimensions.height * ratio) / 8) * 8
);
return { width, height };
}
module.exports = {
readUInt24LE,
getPngDimensions,
getJpegDimensions,
getWebpDimensions,
getImageDimensions,
scaleForLimit,
};

42
src/core/images/heic.js Normal file
View File

@@ -0,0 +1,42 @@
"use strict";
const path = require("path");
const convert = require("heic-convert");
const { AppError } = require("../../errors");
function isHeic(file) {
const mimetype = String(file?.mimetype || "").toLowerCase();
const extension = path.extname(
String(file?.originalname || "")
).toLowerCase();
return (
mimetype === "image/heic" ||
mimetype === "image/heif" ||
extension === ".heic" ||
extension === ".heif"
);
}
async function convertHeicToJpeg(buffer) {
try {
const output = await convert({
buffer,
format: "JPEG",
quality: 0.92,
});
return Buffer.from(output);
} catch (error) {
throw new AppError(
400,
"Не удалось преобразовать фотографию HEIC/HEIF в JPEG.",
error instanceof Error ? error.message : String(error)
);
}
}
module.exports = {
isHeic,
convertHeicToJpeg,
};

83
src/core/images/resize.js Normal file
View File

@@ -0,0 +1,83 @@
"use strict";
const sharp = require("sharp");
const { getImageDimensions } = require("./dimensions");
/**
* Уменьшает изображение до maxDim по наибольшей стороне с помощью sharp.
* @param {Buffer} buffer
* @param {number} maxDim - 0 для отключения
* @returns {Promise<{ buffer: Buffer, width: number, height: number, factor: number, downscaled: boolean, originalWidth: number, originalHeight: number }>}
*/
async function downscaleImage(buffer, maxDim = 1024) {
let origDimensions = getImageDimensions(buffer);
if (!origDimensions) {
try {
const meta = await sharp(buffer).metadata();
if (meta.width && meta.height) {
origDimensions = { width: meta.width, height: meta.height };
}
} catch {
// ignore
}
}
if (!origDimensions) {
return {
buffer,
width: null,
height: null,
factor: 1,
downscaled: false,
originalWidth: null,
originalHeight: null,
};
}
const { width: origWidth, height: origHeight } = origDimensions;
const largest = Math.max(origWidth, origHeight);
if (!maxDim || maxDim <= 0 || largest <= maxDim) {
return {
buffer,
width: origWidth,
height: origHeight,
factor: 1,
downscaled: false,
originalWidth: origWidth,
originalHeight: origHeight,
};
}
const factor = maxDim / largest;
const targetW = Math.max(1, Math.round(origWidth * factor));
const targetH = Math.max(1, Math.round(origHeight * factor));
// Resize using sharp, preserving format/quality if possible
const scaledBuffer = await sharp(buffer)
.resize(targetW, targetH, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
const finalDims = getImageDimensions(scaledBuffer) || {
width: targetW,
height: targetH,
};
return {
buffer: scaledBuffer,
width: finalDims.width,
height: finalDims.height,
factor,
downscaled: true,
originalWidth: origWidth,
originalHeight: origHeight,
};
}
module.exports = {
downscaleImage,
};

71
src/core/images/sniff.js Normal file
View File

@@ -0,0 +1,71 @@
"use strict";
const PNG_SIGNATURE = "89504e470d0a1a0a";
const HEIC_BRANDS = new Set([
"heic",
"heix",
"hevc",
"hevx",
"heim",
"heis",
"hevm",
"hevs",
"mif1",
"msf1",
"heif",
]);
/**
* Определяет тип изображения по сигнатуре первых байтов (magic bytes).
* @param {Buffer} buffer
* @returns {'jpeg' | 'png' | 'webp' | 'heic' | null}
*/
function sniffImageType(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 12) {
return null;
}
// JPEG: FF D8 FF
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "jpeg";
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (buffer.subarray(0, 8).toString("hex") === PNG_SIGNATURE) {
return "png";
}
// WebP: RIFF....WEBP
if (
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
) {
return "webp";
}
// HEIC / HEIF: ftyp box
if (buffer.toString("ascii", 4, 8) === "ftyp") {
const majorBrand = buffer.toString("ascii", 8, 12).toLowerCase();
if (HEIC_BRANDS.has(majorBrand)) {
return "heic";
}
// Check compatible brands
const boxLength = buffer.readUInt32BE(0);
const limit = Math.min(buffer.length, boxLength > 0 ? boxLength : 64);
for (let offset = 16; offset + 4 <= limit; offset += 4) {
const brand = buffer.toString("ascii", offset, offset + 4).toLowerCase();
if (HEIC_BRANDS.has(brand)) {
return "heic";
}
}
}
return null;
}
module.exports = {
sniffImageType,
HEIC_BRANDS,
};

View File

@@ -0,0 +1,122 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { EventEmitter } = require("events");
const { getGroupForType } = require("./queue");
const { sanitizePayloadForStore } = require("./job-store");
const { NotFoundError } = require("../../errors");
class JobService extends EventEmitter {
/**
* @param {Object} options
* @param {import('./job-store').JobStore} options.store
* @param {import('./queue').Queue} options.queue
* @param {string} [options.dataDir]
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ store, queue, dataDir = "./data", logger = null }) {
super();
this.store = store;
this.queue = queue;
this.dataDir = dataDir;
this.logger = logger;
}
async createJob({ type, payload = {}, requestId = null }) {
const id = crypto.randomUUID();
const engine = getGroupForType(type);
const job = {
id,
type,
status: "queued",
createdAt: new Date().toISOString(),
startedAt: null,
finishedAt: null,
payload,
result: null,
error: null,
details: null,
attempts: 0,
engine,
requestId,
};
this.store.saveJob(job);
const { queuePosition } = await this.queue.enqueue(job);
return {
ok: true,
jobId: job.id,
status: "queued",
queuePosition,
};
}
getJob(jobId) {
return this.queue.status(jobId);
}
getJobStatus(jobId) {
const job = this.queue.status(jobId);
if (!job) {
throw new NotFoundError("Задание не найдено.");
}
return {
ok: true,
status: job.status,
type: job.type,
queuePosition: job.queuePosition,
payload: sanitizePayloadForStore(job.payload),
result: job.result,
error: job.error,
details: job.details,
requestId: job.requestId,
createdAt: job.createdAt,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
};
}
getJobOutputFile(jobId) {
const job = this.store.getJob(jobId);
if (!job) {
throw new NotFoundError("Задание не найдено.");
}
const jobDir = path.join(this.dataDir, ".codex-jobs", jobId);
const outputPath = path.join(jobDir, "output.jpg");
if (!fs.existsSync(outputPath)) {
throw new NotFoundError("Итоговое изображение задания не найдено.");
}
return outputPath;
}
listActive() {
return this.queue.listActive();
}
listJobs() {
return this.store.listJobs().map((j) => {
const queuePos = j.status === "queued" ? this.queue.getQueuePosition(j.id) : null;
return {
id: j.id,
type: j.type,
status: j.status,
createdAt: j.createdAt,
startedAt: j.startedAt || null,
finishedAt: j.finishedAt || null,
queuePosition: queuePos !== null ? queuePos : undefined,
};
});
}
}
module.exports = {
JobService,
};

289
src/core/jobs/job-store.js Normal file
View File

@@ -0,0 +1,289 @@
"use strict";
const fs = require("fs");
const path = require("path");
/**
* @typedef {Object} JobCreatedEvent
* @property {number} seq
* @property {string} jobId
* @property {string} at
* @property {'created'} event
* @property {Object} job
*
* @typedef {Object} JobStatusEvent
* @property {number} seq
* @property {string} jobId
* @property {string} at
* @property {'status'} event
* @property {string} status
* @property {string} [startedAt]
* @property {string} [finishedAt]
* @property {any} [result]
* @property {string} [error]
* @property {any} [details]
* @property {number} [attempts]
*/
function sanitizePayloadForStore(payload) {
if (!payload || typeof payload !== "object") return payload;
const copy = { ...payload };
if (copy.inputBuffer) {
copy.inputBuffer = `[Buffer ${copy.inputBuffer.length || (Buffer.isBuffer(copy.inputBuffer) ? copy.inputBuffer.length : "raw")} bytes]`;
}
if (copy.referenceBuffer) {
copy.referenceBuffer = `[Buffer ${copy.referenceBuffer.length || (Buffer.isBuffer(copy.referenceBuffer) ? copy.referenceBuffer.length : "raw")} bytes]`;
}
return copy;
}
class JobStore {
/**
* @param {Object} options
* @param {string} options.file - Путь к jobs.jsonl
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ file, logger = null }) {
this.file = file;
this.logger = logger;
/** @type {Map<string, Object>} */
this.jobs = new Map();
this.seq = 0;
this._compactInterval = null;
if (this.file) {
this._load();
this._compactInterval = setInterval(() => this.compact(), 6 * 60 * 60 * 1000);
if (this._compactInterval.unref) {
this._compactInterval.unref();
}
}
}
_load() {
try {
if (!fs.existsSync(this.file)) {
return;
}
const content = fs.readFileSync(this.file, "utf8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
try {
const entry = JSON.parse(line);
if (entry.seq && entry.seq > this.seq) {
this.seq = entry.seq;
}
if (entry.event === "created" && entry.job && entry.job.id) {
this.jobs.set(entry.job.id, { ...entry.job });
} else if (entry.event === "status" && entry.jobId) {
const existing = this.jobs.get(entry.jobId);
if (existing) {
if (entry.status !== undefined) existing.status = entry.status;
if (entry.startedAt !== undefined) existing.startedAt = entry.startedAt;
if (entry.finishedAt !== undefined) existing.finishedAt = entry.finishedAt;
if (entry.result !== undefined) existing.result = entry.result;
if (entry.error !== undefined) existing.error = entry.error;
if (entry.details !== undefined) existing.details = entry.details;
if (entry.attempts !== undefined) existing.attempts = entry.attempts;
}
}
} catch (lineErr) {
if (this.logger) {
this.logger.warn("Невалидная строка в jobs.jsonl, пропуск", {
line: i + 1,
error: lineErr.message,
});
}
}
}
// Recovery after restart
let recovered = 0;
for (const [id, job] of this.jobs) {
if (job.status === "running") {
recovered++;
if (job.engine === "codex" || job.type === "edit" || job.type === "suggest") {
job.status = "interrupted";
job.attempts = (job.attempts || 0) + 1;
job.error = "Задание прервано перезапуском сервера. Запустите заново.";
job.finishedAt = new Date().toISOString();
this._appendStatusEvent(job);
} else if (job.engine === "comfy-upscale" || job.type === "upscale") {
if ((job.attempts || 0) < 1) {
job.status = "queued";
job.attempts = (job.attempts || 0) + 1;
job.startedAt = null;
this._appendStatusEvent(job);
} else {
job.status = "interrupted";
job.attempts = (job.attempts || 0) + 1;
job.error = "Задание прервано перезапуском сервера.";
job.finishedAt = new Date().toISOString();
this._appendStatusEvent(job);
}
}
}
}
if (this.logger && this.jobs.size > 0) {
this.logger.info("Журнал заданий загружен", {
totalJobs: this.jobs.size,
interruptedRecovered: recovered,
});
}
} catch (err) {
if (this.logger) {
this.logger.error("Ошибка при чтении jobs.jsonl", { error: err.message });
}
}
}
_appendLine(obj) {
if (!this.file) return;
try {
const dir = path.dirname(this.file);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.appendFileSync(this.file, JSON.stringify(obj) + "\n", "utf8");
} catch (err) {
if (this.logger) {
this.logger.error("Ошибка записи в jobs.jsonl", { error: err.message });
}
}
}
_appendStatusEvent(job) {
this.seq++;
const event = {
seq: this.seq,
jobId: job.id,
at: new Date().toISOString(),
event: "status",
status: job.status,
startedAt: job.startedAt || undefined,
finishedAt: job.finishedAt || undefined,
result: job.result || undefined,
error: job.error || undefined,
details: job.details || undefined,
attempts: job.attempts || undefined,
};
this._appendLine(event);
}
saveJob(job) {
this.jobs.set(job.id, job);
this.seq++;
const sanitizedJob = {
...job,
payload: sanitizePayloadForStore(job.payload),
};
const event = {
seq: this.seq,
jobId: job.id,
at: new Date().toISOString(),
event: "created",
job: sanitizedJob,
};
this._appendLine(event);
}
updateJobStatus(jobId, updates) {
const job = this.jobs.get(jobId);
if (!job) return null;
Object.assign(job, updates);
this._appendStatusEvent(job);
return job;
}
getJob(jobId) {
return this.jobs.get(jobId) || null;
}
listJobs() {
return Array.from(this.jobs.values());
}
compact() {
if (!this.file) return;
try {
const now = Date.now();
const ONE_HOUR = 60 * 60 * 1000;
// Keep active jobs and terminal jobs younger than 1 hour
const preserved = new Map();
for (const [id, job] of this.jobs) {
const isTerminal = ["done", "error", "interrupted"].includes(job.status);
if (!isTerminal) {
preserved.set(id, job);
} else {
const finishedTime = job.finishedAt ? new Date(job.finishedAt).getTime() : 0;
if (now - finishedTime < ONE_HOUR) {
preserved.set(id, job);
}
}
}
this.jobs = preserved;
const tmpFile = `${this.file}.${Date.now()}.compact.tmp`;
const fd = fs.openSync(tmpFile, "w");
try {
let newSeq = 0;
for (const job of preserved.values()) {
newSeq++;
const event = {
seq: newSeq,
jobId: job.id,
at: job.createdAt || new Date().toISOString(),
event: "created",
job: {
...job,
payload: sanitizePayloadForStore(job.payload),
},
};
fs.writeFileSync(fd, JSON.stringify(event) + "\n", "utf8");
}
fs.fsyncSync(fd);
this.seq = newSeq;
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmpFile, this.file);
if (this.logger) {
this.logger.debug("Компактизация jobs.jsonl завершена", {
remainingJobs: this.jobs.size,
});
}
} catch (err) {
if (this.logger) {
this.logger.error("Ошибка при компактизации jobs.jsonl", { error: err.message });
}
}
}
close() {
if (this._compactInterval) {
clearInterval(this._compactInterval);
this._compactInterval = null;
}
}
}
module.exports = {
JobStore,
sanitizePayloadForStore,
};

367
src/core/jobs/queue.js Normal file
View File

@@ -0,0 +1,367 @@
"use strict";
const { TooManyRequestsError } = require("../../errors");
function getGroupForType(type) {
if (type === "edit" || type === "suggest") {
return "codex";
}
if (type === "upscale") {
return "comfy-upscale";
}
return "codex";
}
class Queue {
/**
* @param {Object} options
* @param {Object} [options.concurrencyByGroup] - { codex: 1, 'comfy-upscale': 2 }
* @param {number} [options.maxQueuedJobs=5]
* @param {import('./job-store').JobStore} options.jobStore
* @param {import('../engines/engine').EngineRegistry} options.engineRegistry
* @param {import('../engines/engine').EngineContext} options.engineContext
* @param {import('events').EventEmitter} [options.events]
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({
concurrencyByGroup = { codex: 1, "comfy-upscale": 2 },
maxQueuedJobs = 5,
jobStore,
engineRegistry,
engineContext,
events = null,
logger = null,
}) {
this.concurrencyByGroup = concurrencyByGroup;
this.maxQueuedJobs = maxQueuedJobs;
this.jobStore = jobStore;
this.engineRegistry = engineRegistry;
this.engineContext = engineContext;
this.events = events;
this.logger = logger;
/** @type {Map<string, string[]>} group -> array of jobIds in FIFO queue */
this.queues = new Map([
["codex", []],
["comfy-upscale", []],
]);
/** @type {Map<string, Set<string>>} group -> set of running jobIds */
this.running = new Map([
["codex", new Set()],
["comfy-upscale", new Set()],
]);
this.isStarted = false;
this.isStopping = false;
}
start() {
if (this.isStarted) return;
this.isStarted = true;
// Load existing queued jobs from jobStore
const allJobs = this.jobStore.listJobs();
const queuedJobs = allJobs
.filter((j) => j.status === "queued")
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
for (const job of queuedJobs) {
const group = getGroupForType(job.type);
const queue = this.queues.get(group);
if (queue && !queue.includes(job.id)) {
queue.push(job.id);
}
}
if (this.logger) {
this.logger.info("Очередь заданий запущена", {
queuedCodex: this.queues.get("codex")?.length || 0,
queuedUpscale: this.queues.get("comfy-upscale")?.length || 0,
});
}
this._pump("codex");
this._pump("comfy-upscale");
}
async enqueue(job) {
if (this.isStopping) {
throw new Error("Очередь останавливается, новые задания не принимаются.");
}
const group = getGroupForType(job.type);
const queue = this.queues.get(group) || [];
if (queue.length >= this.maxQueuedJobs) {
throw new TooManyRequestsError(
"Слишком много заданий в очереди. Дождитесь завершения текущих."
);
}
queue.push(job.id);
this.queues.set(group, queue);
const queuePosition = queue.length;
if (this.logger) {
this.logger.info("Задание добавлено в очередь", {
jobId: job.id,
type: job.type,
group,
queuePosition,
});
}
if (this.events) {
this.events.emit("job:updated", {
jobId: job.id,
status: job.status,
type: job.type,
queuePosition,
});
}
if (this.isStarted) {
this._pump(group);
}
return { job, queuePosition };
}
getQueuePosition(jobId) {
for (const [, queue] of this.queues) {
const idx = queue.indexOf(jobId);
if (idx !== -1) {
return idx + 1;
}
}
return null;
}
status(jobId) {
const job = this.jobStore.getJob(jobId);
if (!job) return null;
const queuePosition = job.status === "queued" ? this.getQueuePosition(jobId) : null;
return {
...job,
queuePosition: queuePosition !== null ? queuePosition : undefined,
};
}
listActive() {
const all = this.jobStore.listJobs();
return all
.filter((j) => j.status === "queued" || j.status === "running")
.map((j) => {
const queuePosition = j.status === "queued" ? this.getQueuePosition(j.id) : null;
return {
id: j.id,
type: j.type,
status: j.status,
createdAt: j.createdAt,
startedAt: j.startedAt || null,
queuePosition: queuePosition !== null ? queuePosition : undefined,
};
});
}
_pump(group) {
if (!this.isStarted || this.isStopping) return;
const limit = this.concurrencyByGroup[group] || 1;
const runningSet = this.running.get(group) || new Set();
const queue = this.queues.get(group) || [];
while (runningSet.size < limit && queue.length > 0) {
const jobId = queue.shift();
const job = this.jobStore.getJob(jobId);
if (!job || job.status !== "queued") {
continue;
}
runningSet.add(jobId);
// Notify queue position shifts for remaining queued jobs in this group
this._notifyQueueShifts(group);
this._executeJob(job, group).finally(() => {
runningSet.delete(jobId);
this._pump(group);
});
}
}
_notifyQueueShifts(group) {
if (!this.events) return;
const queue = this.queues.get(group) || [];
queue.forEach((id, index) => {
const j = this.jobStore.getJob(id);
if (j && j.status === "queued") {
this.events.emit("job:updated", {
jobId: j.id,
status: j.status,
type: j.type,
queuePosition: index + 1,
});
}
});
}
async _executeJob(job, group) {
const startedAt = new Date().toISOString();
this.jobStore.updateJobStatus(job.id, {
status: "running",
startedAt,
});
if (this.logger) {
this.logger.info("Запуск обработки задания", {
jobId: job.id,
type: job.type,
group,
});
}
if (this.events) {
this.events.emit("job:updated", {
jobId: job.id,
status: "running",
type: job.type,
});
}
try {
const engine = this.engineRegistry.create(job.type, this.engineContext);
const result = await engine.run(job);
if (result && result.ok) {
const finishedAt = new Date().toISOString();
const jobResult = {
version: result.version || undefined,
...result.extra,
};
this.jobStore.updateJobStatus(job.id, {
status: "done",
finishedAt,
result: jobResult,
error: null,
details: null,
});
if (this.logger) {
this.logger.info("Задание успешно завершено", {
jobId: job.id,
type: job.type,
versionId: result.version?.id,
});
}
if (this.events) {
this.events.emit("job:updated", {
jobId: job.id,
status: "done",
type: job.type,
result: jobResult,
});
}
} else {
const finishedAt = new Date().toISOString();
const errorMsg = result?.error || "Ошибка выполнения задания";
this.jobStore.updateJobStatus(job.id, {
status: "error",
finishedAt,
error: errorMsg,
details: result?.details || null,
});
if (this.logger) {
this.logger.error("Задание завершилось с ошибкой", {
jobId: job.id,
type: job.type,
error: errorMsg,
});
}
if (this.events) {
this.events.emit("job:updated", {
jobId: job.id,
status: "error",
type: job.type,
error: errorMsg,
details: result?.details,
});
}
}
} catch (err) {
const finishedAt = new Date().toISOString();
const errorMsg = err.message || "Внутренняя ошибка при выполнении задания";
this.jobStore.updateJobStatus(job.id, {
status: "error",
finishedAt,
error: errorMsg,
details: err.details || null,
});
if (this.logger) {
this.logger.error("Исключение при выполнении задания", {
jobId: job.id,
type: job.type,
error: err.message,
stack: err.stack,
});
}
if (this.events) {
this.events.emit("job:updated", {
jobId: job.id,
status: "error",
type: job.type,
error: errorMsg,
details: err.details,
});
}
}
}
async stop() {
this.isStopping = true;
if (this.logger) {
this.logger.info("Остановка очереди заданий...");
}
// Wait for running tasks
const runningAll = [
...Array.from(this.running.get("codex") || []),
...Array.from(this.running.get("comfy-upscale") || []),
];
if (runningAll.length > 0 && this.logger) {
this.logger.info("Ожидание завершения выполняющихся заданий", {
count: runningAll.length,
});
}
while (
(this.running.get("codex")?.size || 0) > 0 ||
(this.running.get("comfy-upscale")?.size || 0) > 0
) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
this.isStarted = false;
if (this.logger) {
this.logger.info("Очередь заданий остановлена.");
}
}
}
module.exports = {
Queue,
getGroupForType,
};

63
src/errors.js Normal file
View File

@@ -0,0 +1,63 @@
"use strict";
class AppError extends Error {
constructor(status, message, details = null) {
super(message);
this.name = "AppError";
this.status = status;
this.details = details;
}
}
class ConfigError extends Error {
constructor(message, issues = []) {
super(message);
this.name = "ConfigError";
this.issues = issues;
}
}
class NotFoundError extends AppError {
constructor(message = "Ресурс не найден", details = null) {
super(404, message, details);
this.name = "NotFoundError";
}
}
class UnauthorizedError extends AppError {
constructor(message = "Требуется авторизация", details = null) {
super(401, message, details);
this.name = "UnauthorizedError";
}
}
class ForbiddenError extends AppError {
constructor(message = "Доступ запрещён", details = null) {
super(403, message, details);
this.name = "ForbiddenError";
}
}
class BadRequestError extends AppError {
constructor(message = "Некорректный запрос", details = null) {
super(400, message, details);
this.name = "BadRequestError";
}
}
class TooManyRequestsError extends AppError {
constructor(message = "Слишком много запросов. Подождите немного.", details = null) {
super(429, message, details);
this.name = "TooManyRequestsError";
}
}
module.exports = {
AppError,
ConfigError,
NotFoundError,
UnauthorizedError,
ForbiddenError,
BadRequestError,
TooManyRequestsError,
};

146
src/http/app.js Normal file
View File

@@ -0,0 +1,146 @@
"use strict";
const path = require("path");
const express = require("express");
const multer = require("multer");
const { requestContext } = require("./middleware/request-context");
const { originCheck } = require("./middleware/origin-check");
const { createAuthMiddleware } = require("./middleware/auth");
const { RateLimiter } = require("./middleware/rate-limit");
const { errorHandler } = require("./middleware/error-handler");
const { createHealthRoutes } = require("./routes/health.routes");
const { createAuthRoutes } = require("./routes/auth.routes");
const { createHistoryRoutes } = require("./routes/history.routes");
const { createPreviewRoutes } = require("./routes/preview.routes");
const { createEditRoutes } = require("./routes/edit.routes");
const { createUpscaleRoutes } = require("./routes/upscale.routes");
const { createSuggestRoutes } = require("./routes/suggest.routes");
const { createJobsRoutes } = require("./routes/jobs.routes");
const { createEventsRoutes } = require("./routes/events.routes");
const { createResultRoutes } = require("./routes/result.routes");
const { isHeic } = require("../core/images/heic");
const { BadRequestError, NotFoundError } = require("../errors");
/**
* Создаёт экземпляр Express приложения с внедрением зависимостей (DI).
* @param {Object} deps
* @param {import('../config').loadConfig} deps.config
* @param {import('../logger').Logger} deps.logger
* @param {import('../core/auth/session-store').SessionStore} deps.sessionStore
* @param {string} deps.passwordHash
* @param {import('../core/history/history-service').HistoryService} deps.historyService
* @param {import('../core/jobs/job-service').JobService} deps.jobService
* @param {import('../core/comfy/comfy-client').ComfyClient} deps.comfyClient
* @param {import('../core/comfy/comfy-runtime').ComfyRuntime} deps.comfyRuntime
* @param {import('events').EventEmitter} deps.events
* @returns {express.Application}
*/
function createApp(deps) {
const {
config,
logger,
sessionStore,
passwordHash,
historyService,
jobService,
comfyClient,
comfyRuntime,
events,
} = deps;
const app = express();
app.disable("x-powered-by");
if (config.TRUST_PROXY) {
app.set("trust proxy", 1);
}
// Static files from public/
const publicDir = path.join(__dirname, "../../public");
app.use(
express.static(publicDir, {
maxAge: "1h",
setHeaders: (res, filePath) => {
if (filePath.endsWith("index.html")) {
res.setHeader("cache-control", "no-cache");
}
},
})
);
// Request context & body parsing
app.use(express.json({ limit: "16kb" }));
app.use(requestContext(logger));
app.use("/api", originCheck());
// Multer instance for uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: config.MAX_UPLOAD_BYTES,
files: 2,
fields: 8,
},
fileFilter: (_request, file, callback) => {
const supported = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
if (supported.has(file.mimetype.toLowerCase()) || isHeic(file)) {
callback(null, true);
return;
}
callback(
new BadRequestError(
"Формат файла не поддерживается. Загрузите фотографию в формате JPEG, PNG, WebP или HEIC/HEIF."
)
);
},
});
// Auth & Rate Limiter
const authMiddleware = createAuthMiddleware(sessionStore);
const rateLimiter = new RateLimiter({
windowMs: config.LOGIN_RATE_LIMIT.windowMs,
max: config.LOGIN_RATE_LIMIT.max,
logger,
});
// 1. Public API routes
app.use("/api", createHealthRoutes({ comfyRuntime, jobService, historyService, config }));
app.use("/api", createAuthRoutes({ sessionStore, passwordHash, rateLimiter, config, logger }));
// 2. Protected API routes
app.use("/api", authMiddleware.requireAuth);
app.use("/api", createHistoryRoutes({ historyService, upload, logger }));
app.use("/api", createPreviewRoutes({ upload }));
app.use("/api", createEditRoutes({ jobService, historyService, upload, logger }));
app.use("/api", createUpscaleRoutes({ jobService, historyService, upload, logger }));
app.use("/api", createSuggestRoutes({ jobService, historyService, upload, logger }));
app.use("/api", createJobsRoutes({ jobService }));
app.use("/api", createEventsRoutes({ jobService, events, logger }));
app.use("/api", createResultRoutes({ comfyClient }));
// 404 handler for unmatched /api requests
app.use("/api", (_req, _res, next) => {
next(new NotFoundError("Эндпоинт не найден."));
});
// Global error handler
app.use(errorHandler(logger));
return app;
}
module.exports = {
createApp,
};

View File

@@ -0,0 +1,83 @@
"use strict";
const { UnauthorizedError } = require("../../errors");
const SESSION_COOKIE = "kadr_session";
function sessionCookieOptions(ttlMs = 7 * 24 * 60 * 60 * 1000) {
return {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: ttlMs,
};
}
function parseCookies(cookieHeader) {
const cookies = {};
if (!cookieHeader) return cookies;
const pairs = cookieHeader.split(";");
for (const pair of pairs) {
const idx = pair.indexOf("=");
if (idx < 0) continue;
const key = pair.slice(0, idx).trim();
const val = pair.slice(idx + 1).trim();
try {
cookies[key] = decodeURIComponent(val);
} catch {
cookies[key] = val;
}
}
return cookies;
}
function readSessionToken(req) {
const cookieHeader = req.headers.cookie;
if (!cookieHeader) return null;
const cookies = parseCookies(cookieHeader);
return cookies[SESSION_COOKIE] || null;
}
function createAuthMiddleware(sessionStore) {
return {
requireAuth(req, res, next) {
const token = readSessionToken(req);
if (!token) {
return next(new UnauthorizedError("Требуется авторизация."));
}
const session = sessionStore.get(token);
if (!session) {
return next(new UnauthorizedError("Сессия истекла или не найдена."));
}
req.user = session.user;
req.sessionToken = token;
// Sliding TTL: refresh cookie
res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs));
next();
},
optionalAuth(req, res, next) {
const token = readSessionToken(req);
if (token) {
const session = sessionStore.get(token);
if (session) {
req.user = session.user;
req.sessionToken = token;
res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs));
}
}
next();
},
};
}
module.exports = {
SESSION_COOKIE,
sessionCookieOptions,
readSessionToken,
createAuthMiddleware,
};

View File

@@ -0,0 +1,80 @@
"use strict";
const multer = require("multer");
const { AppError } = require("../../errors");
/**
* Единый middleware обработки ошибок приложения.
* @param {import('../../logger').Logger} logger
*/
function errorHandler(logger) {
// eslint-disable-next-line no-unused-vars
return (err, req, res, next) => {
const requestId = req.id || "req_unknown";
let status = 500;
let message = "Внутренняя ошибка сервера.";
let details = null;
if (err instanceof AppError) {
status = err.status;
message = err.message;
details = err.details || null;
} else if (err instanceof multer.MulterError) {
status = 400;
if (err.code === "LIMIT_FILE_SIZE") {
message = "Размер файла превышает допустимый лимит 25 МБ.";
} else if (err.code === "LIMIT_FILE_COUNT") {
message = "Превышено количество загружаемых файлов.";
} else {
message = `Ошибка загрузки файла: ${err.message}`;
}
details = { code: err.code, field: err.field };
} else if (err.type === "entity.too.large" || err.status === 413) {
status = 413;
message = "Размер переданных данных превышает лимит.";
} else if (err instanceof SyntaxError && err.status === 400 && "body" in err) {
status = 400;
message = "Некорректный JSON в теле запроса.";
} else if (err.status && typeof err.status === "number") {
status = err.status;
message = err.message || "Ошибка обработки запроса.";
}
const payload = {
ok: false,
error: message,
requestId,
};
if (details !== null && details !== undefined) {
payload.details = details;
}
if (status >= 500) {
logger.error("Необработанная ошибка сервера", {
requestId,
status,
error: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
});
} else {
logger.warn("Клиентская ошибка запроса", {
requestId,
status,
error: message,
details,
url: req.originalUrl,
method: req.method,
});
}
res.status(status).json(payload);
};
}
module.exports = {
errorHandler,
};

View File

@@ -0,0 +1,43 @@
"use strict";
const { ForbiddenError } = require("../../errors");
/**
* Middleware проверки Origin для защиты от CSRF на state-changing запросах.
*/
function originCheck() {
return (req, res, next) => {
// Only check state-changing methods
const method = req.method.toUpperCase();
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
return next();
}
const origin = req.headers.origin;
if (!origin) {
return next();
}
try {
const originUrl = new URL(origin);
const hostHeader = (req.headers.host || "").toLowerCase();
// Compare host (including port if present)
if (originUrl.host.toLowerCase() !== hostHeader) {
return next(
new ForbiddenError(
`Запрос с внешнего источника (${originUrl.host}) заблокирован.`
)
);
}
next();
} catch {
return next(new ForbiddenError("Некорректный заголовок Origin."));
}
};
}
module.exports = {
originCheck,
};

View File

@@ -0,0 +1,99 @@
"use strict";
const { TooManyRequestsError } = require("../../errors");
function getClientIp(req) {
return (
req.ip ||
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
req.socket?.remoteAddress ||
"127.0.0.1"
);
}
class RateLimiter {
/**
* @param {Object} options
* @param {number} [options.windowMs=900000] - 15 минут
* @param {number} [options.max=5] - Максимум попыток
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ windowMs = 15 * 60 * 1000, max = 5, logger = null }) {
this.windowMs = windowMs;
this.max = max;
this.logger = logger;
/** @type {Map<string, number[]>} IP -> array of failure timestamps */
this.failures = new Map();
this._cleanupInterval = setInterval(() => this.cleanup(), this.windowMs);
if (this._cleanupInterval.unref) {
this._cleanupInterval.unref();
}
}
middleware() {
return (req, res, next) => {
const ip = getClientIp(req);
const now = Date.now();
const timestamps = this.failures.get(ip) || [];
// Filter timestamps within current window
const recent = timestamps.filter((t) => now - t < this.windowMs);
this.failures.set(ip, recent);
if (recent.length >= this.max) {
if (this.logger) {
this.logger.warn("Превышен лимит попыток входа (rate limit)", {
ip,
attempts: recent.length,
});
}
return next(
new TooManyRequestsError(
"Слишком много неудачных попыток входа. Подождите 15 минут перед следующей попыткой."
)
);
}
next();
};
}
recordFailure(req) {
const ip = getClientIp(req);
const now = Date.now();
const timestamps = this.failures.get(ip) || [];
const recent = timestamps.filter((t) => now - t < this.windowMs);
recent.push(now);
this.failures.set(ip, recent);
}
recordSuccess(req) {
const ip = getClientIp(req);
this.failures.delete(ip);
}
cleanup() {
const now = Date.now();
for (const [ip, timestamps] of this.failures) {
const recent = timestamps.filter((t) => now - t < this.windowMs);
if (recent.length === 0) {
this.failures.delete(ip);
} else {
this.failures.set(ip, recent);
}
}
}
close() {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
this._cleanupInterval = null;
}
}
}
module.exports = {
RateLimiter,
getClientIp,
};

View File

@@ -0,0 +1,44 @@
"use strict";
const crypto = require("crypto");
/**
* Middleware для инициализации контекста запроса (requestId, тайминги, логирование).
* @param {import('../../logger').Logger} logger
*/
function requestContext(logger) {
return (req, res, next) => {
const reqId = "req_" + crypto.randomUUID().slice(0, 8);
req.id = reqId;
res.setHeader("x-request-id", reqId);
const startTime = Date.now();
res.on("finish", () => {
const durationMs = Date.now() - startTime;
if (req.originalUrl && req.originalUrl.startsWith("/api")) {
const fields = {
requestId: reqId,
method: req.method,
url: req.originalUrl,
status: res.statusCode,
durationMs,
};
if (res.statusCode >= 500) {
logger.error("HTTP ответ с ошибкой сервера", fields);
} else if (res.statusCode >= 400) {
logger.warn("HTTP клиентская ошибка", fields);
} else {
logger.debug("HTTP запрос завершён", fields);
}
}
});
next();
};
}
module.exports = {
requestContext,
};

View File

@@ -0,0 +1,87 @@
"use strict";
const express = require("express");
const { verifyPassword } = require("../../core/auth/password");
const { SESSION_COOKIE, sessionCookieOptions, readSessionToken } = require("../middleware/auth");
const { UnauthorizedError, BadRequestError } = require("../../errors");
/**
* @param {Object} deps
* @param {import('../../core/auth/session-store').SessionStore} deps.sessionStore
* @param {string} deps.passwordHash - scrypt-хеш пароля приложения
* @param {import('../middleware/rate-limit').RateLimiter} deps.rateLimiter
* @param {import('../../config').loadConfig} deps.config
* @param {import('../../logger').Logger} deps.logger
*/
function createAuthRoutes({ sessionStore, passwordHash, rateLimiter, config, logger }) {
const router = express.Router();
router.post("/login", rateLimiter.middleware(), async (req, res, next) => {
try {
const username = String(req.body?.username || "").trim();
const password = String(req.body?.password || "");
if (!username || !password) {
rateLimiter.recordFailure(req);
throw new BadRequestError("Укажите логин и пароль.");
}
const userMatches = username.toLowerCase() === config.APP_USER.toLowerCase();
const passwordMatches = await verifyPassword(password, passwordHash);
if (!userMatches || !passwordMatches) {
rateLimiter.recordFailure(req);
logger.warn("Неудачная попытка входа", {
user: username,
ip: req.ip || req.socket.remoteAddress,
});
throw new UnauthorizedError("Неверный логин или пароль.");
}
rateLimiter.recordSuccess(req);
const token = sessionStore.create(username);
res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs));
logger.audit("Успешный вход в систему", {
user: username,
ip: req.ip || req.socket.remoteAddress,
});
res.json({ ok: true, user: username });
} catch (err) {
next(err);
}
});
router.get("/me", (req, res) => {
const token = readSessionToken(req);
if (!token) {
return res.status(401).json({ ok: false, error: "Сессия истекла или не найдена." });
}
const session = sessionStore.get(token);
if (!session) {
return res.status(401).json({ ok: false, error: "Сессия истекла или не найдена." });
}
res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs));
res.json({ ok: true, user: session.user });
});
router.post("/logout", (req, res) => {
const token = readSessionToken(req);
if (token) {
sessionStore.delete(token);
}
res.clearCookie(SESSION_COOKIE, { path: "/" });
logger.audit("Выход из системы", { ip: req.ip || req.socket.remoteAddress });
res.json({ ok: true });
});
return router;
}
module.exports = {
createAuthRoutes,
};

View File

@@ -0,0 +1,149 @@
"use strict";
const express = require("express");
const { parseRegion } = require("../../core/codex/prompts");
const { isHeic, convertHeicToJpeg } = require("../../core/images/heic");
const { sniffImageType } = require("../../core/images/sniff");
const { BadRequestError } = require("../../errors");
const { JOB_ID_PATTERN } = require("./history.routes");
/**
* @param {Object} deps
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('multer').Multer} deps.upload
* @param {import('../../logger').Logger} deps.logger
*/
function createEditRoutes({ jobService, historyService, upload, logger }) {
const router = express.Router();
router.post(
"/edit",
upload.fields([
{ name: "photo", maxCount: 1 },
{ name: "reference", maxCount: 1 },
]),
async (req, res, next) => {
try {
const photoFile = req.files?.photo?.[0];
const referenceFile = req.files?.reference?.[0];
let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null;
if (sourceId) {
if (!JOB_ID_PATTERN.test(sourceId)) {
throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId).");
}
const version = historyService.getVersion(sourceId);
if (!version) {
throw new BadRequestError("Исходная версия не найдена в истории.");
}
} else if (!photoFile || !photoFile.buffer) {
throw new BadRequestError("Выберите фотографию или укажите sourceId из истории.");
}
const prompt = String(req.body?.prompt || "").trim();
if (!prompt) {
throw new BadRequestError("Введите промпт для редактирования фотографии.");
}
if (prompt.length > 1000) {
throw new BadRequestError("Промпт не должен превышать 1000 символов.");
}
let referenceId = req.body?.referenceId ? String(req.body.referenceId).trim() : null;
if (referenceId) {
if (!JOB_ID_PATTERN.test(referenceId)) {
throw new BadRequestError("Некорректный идентификатор референса (referenceId).");
}
const refVersion = historyService.getVersion(referenceId);
if (!refVersion) {
throw new BadRequestError("Референс не найден в истории.");
}
}
const region = parseRegion(req.body?.region);
if (region && !referenceFile && !referenceId) {
throw new BadRequestError("Область указана без изображения референса.");
}
// Process photo file buffer if uploaded
let inputBuffer = photoFile ? photoFile.buffer : undefined;
let inputMimetype = photoFile ? photoFile.mimetype : undefined;
let inputOriginalname = photoFile ? photoFile.originalname : undefined;
if (photoFile) {
if (isHeic(photoFile) || sniffImageType(inputBuffer) === "heic") {
inputBuffer = await convertHeicToJpeg(inputBuffer);
inputMimetype = "image/jpeg";
} else {
const sniffed = sniffImageType(inputBuffer);
if (!sniffed) {
throw new BadRequestError(
"Загруженная фотография не является поддерживаемым изображением."
);
}
}
}
// Process reference file buffer if uploaded
let referenceBuffer = referenceFile ? referenceFile.buffer : undefined;
let referenceMimetype = referenceFile ? referenceFile.mimetype : undefined;
let referenceOriginalname = referenceFile ? referenceFile.originalname : undefined;
if (referenceFile) {
if (isHeic(referenceFile) || sniffImageType(referenceBuffer) === "heic") {
referenceBuffer = await convertHeicToJpeg(referenceBuffer);
referenceMimetype = "image/jpeg";
} else {
const sniffed = sniffImageType(referenceBuffer);
if (!sniffed) {
throw new BadRequestError(
"Загруженный референс не является поддерживаемым изображением."
);
}
}
}
const payload = {
sourceId,
inputBuffer,
inputMimetype,
inputOriginalname,
prompt,
referenceId,
referenceBuffer,
referenceMimetype,
referenceOriginalname,
region,
};
const result = await jobService.createJob({
type: "edit",
payload,
requestId: req.id,
});
logger.info("Постановка задания edit в очередь", {
jobId: result.jobId,
sourceId,
prompt: prompt.slice(0, 80),
queuePosition: result.queuePosition,
});
res.json({
ok: true,
jobId: result.jobId,
status: result.status,
queuePosition: result.queuePosition,
});
} catch (err) {
next(err);
}
}
);
return router;
}
module.exports = {
createEditRoutes,
};

View File

@@ -0,0 +1,78 @@
"use strict";
const express = require("express");
/**
* @param {Object} deps
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
* @param {import('events').EventEmitter} deps.events
* @param {import('../../logger').Logger} deps.logger
*/
function createEventsRoutes({ jobService, events, logger }) {
const router = express.Router();
router.get("/events", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders?.();
// Send initial snapshot
const activeSnapshot = jobService.listActive();
res.write(`event: snapshot\ndata: ${JSON.stringify(activeSnapshot)}\n\n`);
const onJobUpdated = (data) => {
try {
const payload = {
jobId: data.jobId,
status: data.status,
type: data.type,
queuePosition: data.queuePosition,
error: data.error,
result: data.result ? { version: data.result.version } : undefined,
};
res.write(`event: job\ndata: ${JSON.stringify(payload)}\n\n`);
} catch (err) {
logger.debug("Ошибка при отправке SSE-события", { error: err.message });
}
};
const onJobRemoved = (data) => {
try {
res.write(`event: remove\ndata: ${JSON.stringify(data)}\n\n`);
} catch (err) {
logger.debug("Ошибка при отправке SSE remove-события", { error: err.message });
}
};
events.on("job:updated", onJobUpdated);
events.on("job:removed", onJobRemoved);
// Heartbeat ping every 15s
const pingInterval = setInterval(() => {
try {
res.write(": ping\n\n");
} catch {
// stream closed
}
}, 15000);
if (pingInterval.unref) {
pingInterval.unref();
}
req.on("close", () => {
clearInterval(pingInterval);
events.off("job:updated", onJobUpdated);
events.off("job:removed", onJobRemoved);
});
});
return router;
}
module.exports = {
createEventsRoutes,
};

View File

@@ -0,0 +1,63 @@
"use strict";
const express = require("express");
/**
* @param {Object} deps
* @param {import('../../core/comfy/comfy-runtime').ComfyRuntime} deps.comfyRuntime
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('../../config').loadConfig} deps.config
*/
function createHealthRoutes({ comfyRuntime, jobService, historyService, config }) {
const router = express.Router();
router.get("/health", async (req, res, next) => {
try {
let comfyAvailable = false;
let runtimeInfo = null;
let message = "ComfyUI готов к обработке.";
try {
runtimeInfo = await comfyRuntime.getRuntimeInfo(true);
comfyAvailable = true;
} catch (err) {
message = `ComfyUI недоступен: ${err.message}`;
}
const activeJobs = jobService.listActive();
const historyList = historyService.getHistory();
const missingNodes = runtimeInfo?.nodes
? Object.entries(runtimeInfo.nodes)
.filter(([, ok]) => !ok)
.map(([name]) => name)
: config.REQUIRED_NODES;
res.json({
ok: true,
version: "2.0.0",
comfyAvailable,
message,
selectedUpscaleModel: runtimeInfo?.selectedUpscaleModel || null,
upscaleModels: runtimeInfo?.upscaleModels || [],
checkpoint: config.CHECKPOINT,
checkpointAvailable: runtimeInfo?.checkpointAvailable || false,
nodes: runtimeInfo?.nodes || {},
missingNodes,
metrics: {
activeJobsCount: activeJobs.length,
historyCount: historyList.length,
},
});
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createHealthRoutes,
};

View File

@@ -0,0 +1,151 @@
"use strict";
const express = require("express");
const fs = require("fs");
const { sniffImageType } = require("../../core/images/sniff");
const { isHeic } = require("../../core/images/heic");
const { BadRequestError, NotFoundError } = require("../../errors");
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* @param {Object} deps
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('multer').Multer} deps.upload
* @param {import('../../logger').Logger} deps.logger
*/
function createHistoryRoutes({ historyService, upload, logger }) {
const router = express.Router();
router.get("/history", (req, res, next) => {
try {
res.json({ ok: true, versions: historyService.getHistory() });
} catch (err) {
next(err);
}
});
router.post("/history/import", upload.single("photo"), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
throw new BadRequestError("Выберите фотографию для импорта.");
}
const isHeicFile = isHeic(req.file) || sniffImageType(req.file.buffer) === "heic";
const sniffedType = sniffImageType(req.file.buffer);
if (!isHeicFile && !sniffedType) {
throw new BadRequestError(
"Файл не является поддерживаемым изображением (JPEG, PNG, WebP или HEIC)."
);
}
const version = await historyService.importPhoto({
buffer: req.file.buffer,
mimetype: req.file.mimetype,
originalname: req.file.originalname,
});
logger.info("Импорт фотографии", {
versionId: version.id,
ext: version.ext,
width: version.width,
height: version.height,
});
res.json({ ok: true, version });
} catch (err) {
next(err);
}
});
router.delete("/history/:id", async (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор версии.");
}
const existing = historyService.getVersion(id);
if (!existing) {
throw new NotFoundError("Версия не найдена в истории.");
}
const result = await historyService.removeCascade(id);
logger.audit("Удаление версии истории", {
versionId: id,
removedCount: result.removed.length,
});
res.json({ ok: true, versions: result.remaining });
} catch (err) {
next(err);
}
});
// Alias /image.jpg for backward compatibility -> 302 redirect to actual ext
router.get("/history/:id/image.jpg", (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор версии.");
}
const version = historyService.getVersion(id);
if (!version) {
throw new NotFoundError("Версия не найдена в истории.");
}
const targetExt = version.ext || "jpg";
if (targetExt !== "jpg") {
return res.redirect(302, `/api/history/${id}/image.${targetExt}`);
}
// If ext is jpg, serve it directly
const fileInfo = historyService.getVersionFile(id);
fileInfo
.then(({ filePath, contentType }) => {
res.setHeader("content-type", contentType);
res.setHeader("cache-control", "no-store");
res.setHeader(
"content-disposition",
`inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.jpg`)}`
);
fs.createReadStream(filePath).pipe(res);
})
.catch(next);
} catch (err) {
next(err);
}
});
// Direct route for /history/:id/image.:ext (e.g. image.png, image.webp, image.jpg)
router.get("/history/:id/image.:ext", async (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор версии.");
}
const { version, filePath, contentType } = await historyService.getVersionFile(id);
res.setHeader("content-type", contentType);
res.setHeader("cache-control", "no-store");
res.setHeader(
"content-disposition",
`inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.${version.ext || "jpg"}`)}`
);
fs.createReadStream(filePath).pipe(res);
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createHistoryRoutes,
JOB_ID_PATTERN,
};

View File

@@ -0,0 +1,65 @@
"use strict";
const express = require("express");
const fs = require("fs");
const { BadRequestError } = require("../../errors");
const { JOB_ID_PATTERN } = require("./history.routes");
/**
* @param {Object} deps
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
*/
function createJobsRoutes({ jobService }) {
const router = express.Router();
router.get("/jobs", (req, res, next) => {
try {
res.json({ ok: true, jobs: jobService.listJobs() });
} catch (err) {
next(err);
}
});
router.get("/jobs/:id/status", (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор задания.");
}
const status = jobService.getJobStatus(id);
res.json(status);
} catch (err) {
next(err);
}
});
router.get("/jobs/:id/output.jpg", (req, res, next) => {
try {
const { id } = req.params;
if (!JOB_ID_PATTERN.test(id)) {
throw new BadRequestError("Некорректный идентификатор задания.");
}
const outputPath = jobService.getJobOutputFile(id);
res.status(200);
res.setHeader("content-type", "image/jpeg");
res.setHeader("cache-control", "no-store");
res.setHeader(
"content-disposition",
`inline; filename*=UTF-8''${encodeURIComponent(`job-${id.slice(0, 8)}.jpg`)}`
);
fs.createReadStream(outputPath).pipe(res);
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createJobsRoutes,
};

View File

@@ -0,0 +1,51 @@
"use strict";
const express = require("express");
const { isHeic, convertHeicToJpeg } = require("../../core/images/heic");
const { sniffImageType } = require("../../core/images/sniff");
const { BadRequestError } = require("../../errors");
/**
* @param {Object} deps
* @param {import('multer').Multer} deps.upload
*/
function createPreviewRoutes({ upload }) {
const router = express.Router();
router.post("/preview", upload.single("photo"), async (req, res, next) => {
try {
if (!req.file || !req.file.buffer) {
throw new BadRequestError("Выберите фотографию для предпросмотра.");
}
let buffer = req.file.buffer;
let contentType = req.file.mimetype || "image/jpeg";
if (isHeic(req.file) || sniffImageType(buffer) === "heic") {
buffer = await convertHeicToJpeg(buffer);
contentType = "image/jpeg";
} else {
const sniffed = sniffImageType(buffer);
if (!sniffed) {
throw new BadRequestError(
"Файл не является поддерживаемым изображением (JPEG, PNG, WebP или HEIC)."
);
}
contentType = sniffed === "jpeg" ? "image/jpeg" : sniffed === "png" ? "image/png" : "image/webp";
}
res.status(200);
res.setHeader("content-type", contentType);
res.setHeader("cache-control", "no-store");
res.send(buffer);
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createPreviewRoutes,
};

View File

@@ -0,0 +1,58 @@
"use strict";
const express = require("express");
const path = require("path");
const { Readable } = require("stream");
const { pipeline } = require("stream/promises");
const { BadRequestError, AppError } = require("../../errors");
/**
* @param {Object} deps
* @param {import('../../core/comfy/comfy-client').ComfyClient} deps.comfyClient
*/
function createResultRoutes({ comfyClient }) {
const router = express.Router();
router.get("/result", async (req, res, next) => {
try {
const filename = String(req.query.filename || "");
const subfolder = String(req.query.subfolder || "");
const type = String(req.query.type || "output");
if (!filename) {
throw new BadRequestError("Не указано имя итогового изображения.");
}
if (type !== "output") {
throw new BadRequestError("Разрешена загрузка только итоговых изображений.");
}
const upstream = await comfyClient.viewStream({ filename, subfolder, type });
res.status(200);
res.setHeader(
"content-type",
upstream.headers.get("content-type") || "image/png"
);
res.setHeader("cache-control", "no-store");
res.setHeader(
"content-disposition",
`inline; filename*=UTF-8''${encodeURIComponent(path.basename(filename))}`
);
if (!upstream.body) {
throw new AppError(502, "ComfyUI вернул пустое изображение.");
}
await pipeline(Readable.fromWeb(upstream.body), res);
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createResultRoutes,
};

View File

@@ -0,0 +1,108 @@
"use strict";
const express = require("express");
const { isHeic, convertHeicToJpeg } = require("../../core/images/heic");
const { sniffImageType } = require("../../core/images/sniff");
const { BadRequestError } = require("../../errors");
const { JOB_ID_PATTERN } = require("./history.routes");
/**
* @param {Object} deps
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('multer').Multer} deps.upload
* @param {import('../../logger').Logger} deps.logger
*/
function createSuggestRoutes({ jobService, historyService, upload, logger }) {
const router = express.Router();
router.get("/suggest-prompts", (req, res, next) => {
try {
const history = historyService.getHistory();
const seen = new Set();
const prompts = [];
for (let i = history.length - 1; i >= 0 && prompts.length < 10; i--) {
const prompt = String(history[i]?.prompt || "").trim();
if (prompt && !seen.has(prompt)) {
seen.add(prompt);
prompts.push(prompt);
}
}
res.json({ ok: true, prompts });
} catch (err) {
next(err);
}
});
router.post("/suggest", upload.single("photo"), async (req, res, next) => {
try {
let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null;
if (sourceId) {
if (!JOB_ID_PATTERN.test(sourceId)) {
throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId).");
}
const version = historyService.getVersion(sourceId);
if (!version) {
throw new BadRequestError("Исходная версия не найдена в истории.");
}
} else if (!req.file || !req.file.buffer) {
throw new BadRequestError("Выберите фотографию или укажите sourceId из истории.");
}
let inputBuffer = req.file ? req.file.buffer : undefined;
let inputMimetype = req.file ? req.file.mimetype : undefined;
let inputOriginalname = req.file ? req.file.originalname : undefined;
if (req.file) {
if (isHeic(req.file) || sniffImageType(inputBuffer) === "heic") {
inputBuffer = await convertHeicToJpeg(inputBuffer);
inputMimetype = "image/jpeg";
} else {
const sniffed = sniffImageType(inputBuffer);
if (!sniffed) {
throw new BadRequestError(
"Загруженная фотография не является поддерживаемым изображением."
);
}
}
}
const payload = {
sourceId,
inputBuffer,
inputMimetype,
inputOriginalname,
};
const result = await jobService.createJob({
type: "suggest",
payload,
requestId: req.id,
});
logger.info("Постановка задания suggest в очередь", {
jobId: result.jobId,
sourceId,
queuePosition: result.queuePosition,
});
res.json({
ok: true,
jobId: result.jobId,
status: result.status,
queuePosition: result.queuePosition,
});
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createSuggestRoutes,
};

View File

@@ -0,0 +1,88 @@
"use strict";
const express = require("express");
const { isHeic, convertHeicToJpeg } = require("../../core/images/heic");
const { sniffImageType } = require("../../core/images/sniff");
const { BadRequestError } = require("../../errors");
const { JOB_ID_PATTERN } = require("./history.routes");
/**
* @param {Object} deps
* @param {import('../../core/jobs/job-service').JobService} deps.jobService
* @param {import('../../core/history/history-service').HistoryService} deps.historyService
* @param {import('multer').Multer} deps.upload
* @param {import('../../logger').Logger} deps.logger
*/
function createUpscaleRoutes({ jobService, historyService, upload, logger }) {
const router = express.Router();
router.post("/upscale", upload.single("photo"), async (req, res, next) => {
try {
let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null;
if (sourceId) {
if (!JOB_ID_PATTERN.test(sourceId)) {
throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId).");
}
const version = historyService.getVersion(sourceId);
if (!version) {
throw new BadRequestError("Исходная версия не найдена в истории.");
}
} else if (!req.file || !req.file.buffer) {
throw new BadRequestError("Выберите фотографию или укажите sourceId из истории.");
}
let inputBuffer = req.file ? req.file.buffer : undefined;
let inputMimetype = req.file ? req.file.mimetype : undefined;
let inputOriginalname = req.file ? req.file.originalname : undefined;
if (req.file) {
if (isHeic(req.file) || sniffImageType(inputBuffer) === "heic") {
inputBuffer = await convertHeicToJpeg(inputBuffer);
inputMimetype = "image/jpeg";
} else {
const sniffed = sniffImageType(inputBuffer);
if (!sniffed) {
throw new BadRequestError(
"Загруженная фотография не является поддерживаемым изображением."
);
}
}
}
const payload = {
sourceId,
inputBuffer,
inputMimetype,
inputOriginalname,
};
const result = await jobService.createJob({
type: "upscale",
payload,
requestId: req.id,
});
logger.info("Постановка задания upscale в очередь", {
jobId: result.jobId,
sourceId,
queuePosition: result.queuePosition,
});
res.json({
ok: true,
jobId: result.jobId,
status: result.status,
queuePosition: result.queuePosition,
});
} catch (err) {
next(err);
}
});
return router;
}
module.exports = {
createUpscaleRoutes,
};

119
src/logger.js Normal file
View File

@@ -0,0 +1,119 @@
"use strict";
const LOG_LEVELS = {
debug: 10,
info: 20,
audit: 25,
warn: 30,
error: 40,
};
function singleLine(text) {
return String(text || "")
.replace(/[\r\n\t]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function truncate(text, maxLength = 240) {
const normalized = singleLine(text);
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 1)}…`;
}
function sanitizeFields(fields) {
if (!fields || typeof fields !== "object") return {};
const cleaned = {};
for (const [key, value] of Object.entries(fields)) {
const lowerKey = key.toLowerCase();
if (
lowerKey.includes("password") ||
lowerKey.includes("token") ||
lowerKey.includes("cookie") ||
lowerKey.includes("secret") ||
lowerKey.includes("auth")
) {
cleaned[key] = "[REDACTED]";
} else if (value instanceof Error) {
cleaned[key] = {
message: value.message,
name: value.name,
stack: value.stack,
details: value.details,
};
} else {
cleaned[key] = value;
}
}
return cleaned;
}
class Logger {
constructor(options = {}) {
const levelName = (options.level || "info").toLowerCase();
this.threshold = LOG_LEVELS[levelName] !== undefined ? LOG_LEVELS[levelName] : LOG_LEVELS.info;
this.stream = options.stream || process.stdout;
this.defaultFields = options.defaultFields ? sanitizeFields(options.defaultFields) : {};
}
child(defaultFields = {}) {
return new Logger({
level: Object.keys(LOG_LEVELS).find((k) => LOG_LEVELS[k] === this.threshold) || "info",
stream: this.stream,
defaultFields: { ...this.defaultFields, ...sanitizeFields(defaultFields) },
});
}
_write(level, msg, fields = {}) {
if (LOG_LEVELS[level] < this.threshold) return;
const entry = {
ts: new Date().toISOString(),
level,
msg: singleLine(msg),
...this.defaultFields,
...sanitizeFields(fields),
};
try {
this.stream.write(JSON.stringify(entry) + "\n");
} catch {
// Fallback if write fails
console.log(JSON.stringify(entry));
}
}
debug(msg, fields) {
this._write("debug", msg, fields);
}
info(msg, fields) {
this._write("info", msg, fields);
}
audit(msg, fields) {
this._write("audit", msg, fields);
}
warn(msg, fields) {
this._write("warn", msg, fields);
}
error(msg, fields) {
this._write("error", msg, fields);
}
}
function createLogger(options = {}) {
return new Logger(options);
}
module.exports = {
LOG_LEVELS,
Logger,
createLogger,
singleLine,
truncate,
};

222
src/server.js Normal file
View File

@@ -0,0 +1,222 @@
"use strict";
const http = require("http");
const path = require("path");
const fs = require("fs");
const { EventEmitter } = require("events");
const { loadConfig } = require("./config");
const { createLogger } = require("./logger");
const { hashPasswordSync } = require("./core/auth/password");
const { SessionStore } = require("./core/auth/session-store");
const { HistoryStore } = require("./core/history/history-store");
const { HistoryService, migrateLegacyHistory } = require("./core/history/history-service");
const { JobStore } = require("./core/jobs/job-store");
const { Queue } = require("./core/jobs/queue");
const { JobService } = require("./core/jobs/job-service");
const { ComfyClient } = require("./core/comfy/comfy-client");
const { ComfyRuntime } = require("./core/comfy/comfy-runtime");
const { CodexCli } = require("./core/codex/cli");
const { EngineRegistry } = require("./core/engines/engine");
const { createCodexEditEngine, createCodexSuggestEngine } = require("./core/engines/codex-engine");
const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine");
const { createApp } = require("./http/app");
function printPasswordBanner(user, password) {
const line = "─".repeat(58);
console.log(`\n┌${line}┐`);
console.log(`│ Кадр: Пароль для входа (сгенерирован автоматически) │`);
console.log(`│ Логин : ${user.padEnd(48)}│`);
console.log(`│ Пароль: ${password.padEnd(48)}│`);
console.log(`│ Задайте APP_PASSWORD в окружении для фиксации. │`);
console.log(`└${line}┘\n`);
}
function listenOnPort(app, port) {
return new Promise((resolve, reject) => {
const server = http.createServer(app);
server.listen(port, () => resolve(server));
server.on("error", (error) => {
server.close();
reject(error);
});
});
}
async function startServer() {
const config = loadConfig(process.env);
const logger = createLogger({ level: config.LOG_LEVEL });
logger.info("Запуск веб-приложения «Кадр» v2...", config.getSanitized());
if (config.isGeneratedPassword) {
printPasswordBanner(config.APP_USER, config.APP_PASSWORD);
}
const passwordHash = hashPasswordSync(config.APP_PASSWORD);
// Ensure directories exist
fs.mkdirSync(config.DATA_DIR, { recursive: true });
fs.mkdirSync(config.HISTORY_DIR, { recursive: true });
fs.mkdirSync(path.join(config.DATA_DIR, ".codex-jobs"), { recursive: true });
// Migrate legacy history if present
migrateLegacyHistory(path.resolve("./history"), config.HISTORY_DIR, logger);
// Core stores and services
const historyStore = new HistoryStore({
dir: config.HISTORY_DIR,
limit: config.HISTORY_LIMIT,
logger,
});
const historyService = new HistoryService({ store: historyStore, logger });
const sessionStore = new SessionStore({
file: path.join(config.DATA_DIR, "sessions.json"),
ttlMs: config.SESSION_TTL_MS,
persist: config.SESSION_PERSIST,
logger,
});
const jobStore = new JobStore({
file: path.join(config.DATA_DIR, "jobs.jsonl"),
logger,
});
const comfyClient = new ComfyClient({
comfyUrl: config.COMFY_URL,
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
logger,
});
const comfyRuntime = new ComfyRuntime({
client: comfyClient,
requiredNodes: config.REQUIRED_NODES,
checkpoint: config.CHECKPOINT,
logger,
});
const codexCli = new CodexCli({
codexExe: config.CODEX_CLI_PATH,
defaultTimeoutMs: config.CODEX_EDIT_TIMEOUT_MS,
logger,
});
const events = new EventEmitter();
events.setMaxListeners(100);
// Engine Registry
const engineRegistry = new EngineRegistry();
const engineContext = {
config,
logger,
history: historyService,
comfyClient,
comfyRuntime,
codexCli,
};
engineRegistry.register("edit", (ctx) => createCodexEditEngine(ctx));
engineRegistry.register("suggest", (ctx) => createCodexSuggestEngine(ctx));
engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx));
// Queue and Job Service
const queue = new Queue({
concurrencyByGroup: {
codex: config.CODEX_CONCURRENCY,
"comfy-upscale": config.UPSCALE_CONCURRENCY,
},
maxQueuedJobs: config.MAX_QUEUED_JOBS,
jobStore,
engineRegistry,
engineContext,
events,
logger,
});
const jobService = new JobService({
store: jobStore,
queue,
dataDir: config.DATA_DIR,
logger,
});
// Start background queue workers
queue.start();
// Create Express App
const app = createApp({
config,
logger,
sessionStore,
passwordHash,
historyService,
jobService,
comfyClient,
comfyRuntime,
events,
});
// Bind to port
let server = null;
let boundPort = null;
for (const candidate of config.PORT_CANDIDATES) {
try {
server = await listenOnPort(app, candidate);
boundPort = candidate;
break;
} catch (err) {
if (err.code === "EADDRINUSE") {
logger.warn(`Порт ${candidate} занят, пробуем следующий...`);
continue;
}
throw err;
}
}
if (!server) {
throw new Error(
`Не удалось запустить сервер: все порты [${config.PORT_CANDIDATES.join(", ")}] заняты.`
);
}
logger.info(`Сервер «Кадр» слушает http://localhost:${boundPort}`, {
port: boundPort,
dataDir: config.DATA_DIR,
historyDir: config.HISTORY_DIR,
});
// Graceful shutdown
const shutdown = async (signal) => {
logger.info(`Получен сигнал ${signal}, корректное завершение работы...`);
try {
await queue.stop();
sessionStore.close();
jobStore.close();
server.close(() => {
logger.info("Сервер остановлен.");
process.exit(0);
});
} catch (err) {
logger.error("Ошибка при остановке сервера", { error: err.message });
process.exit(1);
}
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
return { app, server, boundPort, deps: engineContext };
}
if (require.main === module) {
startServer().catch((err) => {
console.error("Критическая ошибка при старте сервера:", err);
process.exit(1);
});
}
module.exports = {
startServer,
};