v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
Some checks failed
test / test (push) Failing after 24s
Реализация целевого дизайна (docs/architecture/target-design.md): - src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images) - Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue) - Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов - Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history - Даунскейл sharp до 1024px перед Codex + масштабирование области референса - SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/) - Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data - Документация: README, docs/architecture/
This commit is contained in:
206
src/config.js
Normal file
206
src/config.js
Normal 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
114
src/core/auth/password.js
Normal 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,
|
||||
};
|
||||
190
src/core/auth/session-store.js
Normal file
190
src/core/auth/session-store.js
Normal 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
265
src/core/codex/cli.js
Normal 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
156
src/core/codex/prompts.js
Normal 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,
|
||||
};
|
||||
224
src/core/comfy/comfy-client.js
Normal file
224
src/core/comfy/comfy-client.js
Normal 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,
|
||||
};
|
||||
195
src/core/comfy/comfy-runtime.js
Normal file
195
src/core/comfy/comfy-runtime.js
Normal 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,
|
||||
};
|
||||
75
src/core/comfy/workflows.js
Normal file
75
src/core/comfy/workflows.js
Normal 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,
|
||||
};
|
||||
261
src/core/engines/codex-engine.js
Normal file
261
src/core/engines/codex-engine.js
Normal 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,
|
||||
};
|
||||
114
src/core/engines/comfy-upscale-engine.js
Normal file
114
src/core/engines/comfy-upscale-engine.js
Normal 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,
|
||||
};
|
||||
54
src/core/engines/engine.js
Normal file
54
src/core/engines/engine.js
Normal 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,
|
||||
};
|
||||
190
src/core/images/dimensions.js
Normal file
190
src/core/images/dimensions.js
Normal 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
42
src/core/images/heic.js
Normal 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
83
src/core/images/resize.js
Normal 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
71
src/core/images/sniff.js
Normal 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,
|
||||
};
|
||||
122
src/core/jobs/job-service.js
Normal file
122
src/core/jobs/job-service.js
Normal 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
289
src/core/jobs/job-store.js
Normal 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
367
src/core/jobs/queue.js
Normal 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
63
src/errors.js
Normal 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
146
src/http/app.js
Normal 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,
|
||||
};
|
||||
83
src/http/middleware/auth.js
Normal file
83
src/http/middleware/auth.js
Normal 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,
|
||||
};
|
||||
80
src/http/middleware/error-handler.js
Normal file
80
src/http/middleware/error-handler.js
Normal 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,
|
||||
};
|
||||
43
src/http/middleware/origin-check.js
Normal file
43
src/http/middleware/origin-check.js
Normal 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,
|
||||
};
|
||||
99
src/http/middleware/rate-limit.js
Normal file
99
src/http/middleware/rate-limit.js
Normal 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,
|
||||
};
|
||||
44
src/http/middleware/request-context.js
Normal file
44
src/http/middleware/request-context.js
Normal 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,
|
||||
};
|
||||
87
src/http/routes/auth.routes.js
Normal file
87
src/http/routes/auth.routes.js
Normal 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,
|
||||
};
|
||||
149
src/http/routes/edit.routes.js
Normal file
149
src/http/routes/edit.routes.js
Normal 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,
|
||||
};
|
||||
78
src/http/routes/events.routes.js
Normal file
78
src/http/routes/events.routes.js
Normal 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,
|
||||
};
|
||||
63
src/http/routes/health.routes.js
Normal file
63
src/http/routes/health.routes.js
Normal 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,
|
||||
};
|
||||
151
src/http/routes/history.routes.js
Normal file
151
src/http/routes/history.routes.js
Normal 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,
|
||||
};
|
||||
65
src/http/routes/jobs.routes.js
Normal file
65
src/http/routes/jobs.routes.js
Normal 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,
|
||||
};
|
||||
51
src/http/routes/preview.routes.js
Normal file
51
src/http/routes/preview.routes.js
Normal 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,
|
||||
};
|
||||
58
src/http/routes/result.routes.js
Normal file
58
src/http/routes/result.routes.js
Normal 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,
|
||||
};
|
||||
108
src/http/routes/suggest.routes.js
Normal file
108
src/http/routes/suggest.routes.js
Normal 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,
|
||||
};
|
||||
88
src/http/routes/upscale.routes.js
Normal file
88
src/http/routes/upscale.routes.js
Normal 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
119
src/logger.js
Normal 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
222
src/server.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user