2289 lines
61 KiB
JavaScript
2289 lines
61 KiB
JavaScript
"use strict";
|
||
|
||
const crypto = require("crypto");
|
||
const path = require("path");
|
||
const { Readable } = require("stream");
|
||
const { pipeline } = require("stream/promises");
|
||
|
||
const fs = require("fs");
|
||
const { spawn } = require("child_process");
|
||
|
||
const express = require("express");
|
||
const multer = require("multer");
|
||
const convert = require("heic-convert");
|
||
|
||
const COMFY_URL = process.env.COMFY_URL || "http://192.168.31.240:8188";
|
||
const CHECKPOINT = "sdxl_turbo.safetensors";
|
||
const PORT_CANDIDATES = process.env.PORT
|
||
? [Number(process.env.PORT)]
|
||
: [3000, 8080, 8090];
|
||
|
||
const POLL_INTERVAL_MS = 1500;
|
||
const JOB_TIMEOUT_MS = 300000;
|
||
const REQUEST_TIMEOUT_MS = 15000;
|
||
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||
|
||
const UPSCALE_MAX_DIMENSION = 2048;
|
||
|
||
const CODEX_JOBS_DIR = path.join(__dirname, ".codex-jobs");
|
||
const CODEX_EDIT_TIMEOUT_MS = 420000;
|
||
const CODEX_EXE = resolveCodexExe();
|
||
|
||
const HISTORY_DIR = path.join(__dirname, "history");
|
||
const HISTORY_MANIFEST = path.join(HISTORY_DIR, "history.json");
|
||
const HISTORY_LIMIT = 50;
|
||
|
||
const REQUIRED_NODES = [
|
||
"CheckpointLoaderSimple",
|
||
"CLIPTextEncode",
|
||
"LoadImage",
|
||
"ImageScale",
|
||
"VAEEncode",
|
||
"KSampler",
|
||
"VAEDecode",
|
||
"SaveImage",
|
||
"UpscaleModelLoader",
|
||
"ImageUpscaleWithModel"
|
||
];
|
||
|
||
// --- Логирование -------------------------------------------------
|
||
const LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
|
||
const LOG_THRESHOLD =
|
||
LOG_LEVELS[String(process.env.LOG_LEVEL || "info").toLowerCase()] ??
|
||
LOG_LEVELS.info;
|
||
|
||
function log(level, message, fields) {
|
||
const numeric = LOG_LEVELS[level];
|
||
if (numeric === undefined || numeric < LOG_THRESHOLD) {
|
||
return;
|
||
}
|
||
const line =
|
||
`${new Date().toISOString()} [${level.toUpperCase()}] ${message}` +
|
||
(fields === undefined ? "" : ` ${JSON.stringify(fields)}`);
|
||
if (numeric >= LOG_LEVELS.warn) {
|
||
console.error(line);
|
||
} else {
|
||
console.log(line);
|
||
}
|
||
}
|
||
|
||
function singleLine(text) {
|
||
return String(text ?? "").replace(/[\r\n\t]+/g, " ").trim();
|
||
}
|
||
|
||
function truncate(text, maxLength) {
|
||
const clean = singleLine(text);
|
||
return clean.length > maxLength
|
||
? `${clean.slice(0, maxLength)}…`
|
||
: clean;
|
||
}
|
||
|
||
const app = express();
|
||
|
||
app.disable("x-powered-by");
|
||
// Статика без длинного кэша: index.html должен всегда получать свежие
|
||
// версии app.js/style.css (в HTML они подключены с ?v=...).
|
||
app.use(express.static(path.join(__dirname, "public"), {
|
||
extensions: ["html"],
|
||
maxAge: 0
|
||
}));
|
||
|
||
let codexEditInFlight = false;
|
||
|
||
// JSON-тело нужно для POST /api/login; multipart обрабатывает multer.
|
||
app.use(express.json({ limit: "16kb" }));
|
||
|
||
app.use((request, response, next) => {
|
||
const startedAt = Date.now();
|
||
response.on("finish", () => {
|
||
const level =
|
||
request.originalUrl.startsWith("/api/health") ||
|
||
request.originalUrl.startsWith("/api/jobs/")
|
||
? "debug"
|
||
: "info";
|
||
log(level, `${request.method} ${request.originalUrl} -> ${response.statusCode} ${Date.now() - startedAt}ms`);
|
||
});
|
||
next();
|
||
});
|
||
|
||
class AppError extends Error {
|
||
constructor(status, message, details = undefined) {
|
||
super(message);
|
||
this.name = "AppError";
|
||
this.status = status;
|
||
this.details = details;
|
||
}
|
||
}
|
||
|
||
// --- Авторизация -------------------------------------------------
|
||
const SESSION_COOKIE = "kadr_session";
|
||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 суток
|
||
|
||
const APP_USER = String(process.env.APP_USER || "").trim() || "admin";
|
||
const APP_PASSWORD =
|
||
process.env.APP_PASSWORD && String(process.env.APP_PASSWORD).length > 0
|
||
? String(process.env.APP_PASSWORD)
|
||
: crypto.randomBytes(12).toString("base64url"); // 16 символов
|
||
|
||
if (!process.env.APP_PASSWORD) {
|
||
console.log("");
|
||
console.log("========================================================");
|
||
console.log(" APP_PASSWORD не задан. Сгенерирован случайный пароль:");
|
||
console.log(` логин: ${APP_USER}`);
|
||
console.log(` пароль: ${APP_PASSWORD}`);
|
||
console.log(" Задайте переменные окружения APP_USER / APP_PASSWORD,");
|
||
console.log(" чтобы зафиксировать пароль.");
|
||
console.log("========================================================");
|
||
console.log("");
|
||
}
|
||
|
||
// token -> { user, expiresAt }
|
||
const sessions = new Map();
|
||
|
||
// jobId -> { type, status, startedAt, finishedAt, payload, error, details }
|
||
const jobs = new Map();
|
||
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [id, job] of jobs) {
|
||
if (job.finishedAt && now - job.finishedAt > 60 * 60 * 1000) {
|
||
jobs.delete(id);
|
||
}
|
||
}
|
||
}, 30 * 60 * 1000).unref();
|
||
|
||
function constantTimeEqual(a, b) {
|
||
const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest();
|
||
const digestB = crypto.createHash("sha256").update(String(b), "utf8").digest();
|
||
return crypto.timingSafeEqual(digestA, digestB);
|
||
}
|
||
|
||
function makeSessionToken() {
|
||
return crypto.randomBytes(32).toString("hex");
|
||
}
|
||
|
||
function sessionCookieOptions() {
|
||
return {
|
||
httpOnly: true,
|
||
sameSite: "lax",
|
||
path: "/",
|
||
maxAge: SESSION_TTL_MS
|
||
// secure не задаём: приложение работает по HTTP в LAN
|
||
};
|
||
}
|
||
|
||
function readSessionToken(request) {
|
||
const header = String(request.headers.cookie || "");
|
||
for (const part of header.split(";")) {
|
||
const eq = part.indexOf("=");
|
||
if (eq === -1) continue;
|
||
const name = part.slice(0, eq).trim();
|
||
if (name === SESSION_COOKIE) {
|
||
const value = part.slice(eq + 1).trim();
|
||
try { return decodeURIComponent(value); } catch { return value; }
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getSession(request) {
|
||
const token = readSessionToken(request);
|
||
if (!token) return null;
|
||
const session = sessions.get(token);
|
||
if (!session) return null;
|
||
if (session.expiresAt < Date.now()) {
|
||
sessions.delete(token);
|
||
return null;
|
||
}
|
||
return { token, session };
|
||
}
|
||
|
||
function requireAuth(request, response, next) {
|
||
const found = getSession(request);
|
||
if (!found) {
|
||
next(new AppError(401, "Требуется авторизация."));
|
||
return;
|
||
}
|
||
// Sliding: продлеваем сессию при каждой активности
|
||
found.session.expiresAt = Date.now() + SESSION_TTL_MS;
|
||
response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions());
|
||
next();
|
||
}
|
||
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [token, session] of sessions) {
|
||
if (session.expiresAt < now) sessions.delete(token);
|
||
}
|
||
}, 60 * 60 * 1000).unref();
|
||
|
||
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"
|
||
);
|
||
}
|
||
|
||
const upload = multer({
|
||
storage: multer.memoryStorage(),
|
||
limits: {
|
||
fileSize: MAX_UPLOAD_BYTES,
|
||
// /api/edit принимает два файла (photo + reference); остальные
|
||
// маршруты (preview, history/import, upscale, suggest) шлют по одному.
|
||
files: 2,
|
||
// /api/edit шлёт sourceId, prompt, region, referenceId.
|
||
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(String(file.mimetype || "").toLowerCase()) &&
|
||
!isHeic(file)
|
||
) {
|
||
callback(new AppError(
|
||
400,
|
||
"Поддерживаются фотографии в форматах JPEG, PNG, WebP или HEIC."
|
||
));
|
||
return;
|
||
}
|
||
|
||
callback(null, true);
|
||
}
|
||
});
|
||
|
||
function asyncRoute(handler) {
|
||
return (request, response, next) => {
|
||
Promise.resolve(handler(request, response, next)).catch(next);
|
||
};
|
||
}
|
||
|
||
function delay(milliseconds) {
|
||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||
}
|
||
|
||
function normalizeErrorDetails(value) {
|
||
if (value === undefined || value === null) {
|
||
return undefined;
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(JSON.stringify(value));
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
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)
|
||
);
|
||
}
|
||
}
|
||
|
||
function resolveCodexExe() {
|
||
const candidates = [];
|
||
|
||
if (process.env.CODEX_CLI_PATH) {
|
||
candidates.push(process.env.CODEX_CLI_PATH);
|
||
}
|
||
|
||
try {
|
||
const binRoot = path.join(
|
||
process.env.LOCALAPPDATA || "",
|
||
"OpenAI",
|
||
"Codex",
|
||
"bin"
|
||
);
|
||
|
||
for (const entry of fs.readdirSync(binRoot)) {
|
||
const candidate = path.join(binRoot, entry, "codex.exe");
|
||
|
||
if (fs.existsSync(candidate)) {
|
||
candidates.push(candidate);
|
||
}
|
||
}
|
||
} catch {
|
||
// Папка может отсутствовать — используем fallback.
|
||
}
|
||
|
||
for (const candidate of candidates) {
|
||
if (candidate && fs.existsSync(candidate)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return "codex";
|
||
}
|
||
|
||
function extForMimetype(mimetype) {
|
||
const map = {
|
||
"image/jpeg": "jpg",
|
||
"image/jpg": "jpg",
|
||
"image/png": "png",
|
||
"image/webp": "webp"
|
||
};
|
||
|
||
return map[String(mimetype || "").toLowerCase()] || "jpg";
|
||
}
|
||
|
||
function buildCodexPrompt(prompt, dimensions, inputName, referenceName = null, region = null) {
|
||
const size = dimensions && dimensions.width
|
||
? `${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}.`
|
||
);
|
||
}
|
||
|
||
lines.push(
|
||
"Сохрани отредактированное фото в файл output.jpg в текущей директории.",
|
||
"Не перерисовывай фото с нуля и не заменяй его на полностью новое изображение — редактируй исходное фото, сохраняя его сюжет, объекты, лица и пропорции.",
|
||
size
|
||
? `Сохрани исходный размер ${size}.`
|
||
: "Сохрани исходные пропорции.",
|
||
"Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал."
|
||
);
|
||
|
||
return lines.join(" ");
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
function runCodexEdit(jobDir, inputNames, prompt) {
|
||
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(
|
||
CODEX_EXE,
|
||
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
|
||
});
|
||
};
|
||
|
||
const deadlineTimer = setInterval(() => {
|
||
if (Date.now() - startedAt > CODEX_EDIT_TIMEOUT_MS) {
|
||
timedOut = true;
|
||
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// Процесс уже завершён.
|
||
}
|
||
|
||
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 > CODEX_EDIT_TIMEOUT_MS) {
|
||
timedOut = true;
|
||
|
||
try {
|
||
child.kill();
|
||
} catch {
|
||
// Процесс уже завершён.
|
||
}
|
||
|
||
settle();
|
||
return;
|
||
}
|
||
|
||
if (processExitedAt !== null && Date.now() - processExitedAt > 15000) {
|
||
settle();
|
||
return;
|
||
}
|
||
|
||
setTimeout(poll, 1000);
|
||
};
|
||
|
||
poll();
|
||
});
|
||
}
|
||
|
||
async function runEditJob(jobId, jobDir, inputNames, promptText, prompt, sourceId, dimensions, jobStartedAt, referenceId = null) {
|
||
try {
|
||
const result = await runCodexEdit(jobDir, inputNames, promptText);
|
||
const durationMs = Date.now() - jobStartedAt;
|
||
|
||
if (!result.outputPath) {
|
||
const details = result.lastMessage
|
||
? `Ответ Codex: ${result.lastMessage.slice(0, 500)}`
|
||
: undefined;
|
||
log("error", "Редактирование не завершено", {
|
||
jobId,
|
||
ok: false,
|
||
exitCode: result.exitCode,
|
||
timedOut: result.timedOut,
|
||
durationMs,
|
||
lastMessage: truncate(result.lastMessage, 500)
|
||
});
|
||
const job = jobs.get(jobId);
|
||
if (job) {
|
||
job.status = "error";
|
||
job.error = "Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт.";
|
||
job.details = details;
|
||
job.finishedAt = Date.now();
|
||
}
|
||
return;
|
||
}
|
||
|
||
const version = makeHistoryVersion({
|
||
kind: "edit",
|
||
label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`,
|
||
prompt,
|
||
parentId: sourceId || null,
|
||
referenceId,
|
||
buffer: fs.readFileSync(result.outputPath),
|
||
ext: "jpg",
|
||
width: dimensions?.width,
|
||
height: dimensions?.height
|
||
});
|
||
|
||
await appendHistoryVersion(version);
|
||
|
||
log("info", "Редактирование завершено", {
|
||
jobId,
|
||
ok: true,
|
||
exitCode: result.exitCode,
|
||
timedOut: result.timedOut,
|
||
durationMs,
|
||
versionId: version.id,
|
||
lastMessage: truncate(result.lastMessage, 500)
|
||
});
|
||
|
||
const job = jobs.get(jobId);
|
||
if (job) {
|
||
job.status = "done";
|
||
job.finishedAt = Date.now();
|
||
job.payload = {
|
||
job: { type: "edit", engine: "codex", exitCode: result.exitCode, timedOut: result.timedOut },
|
||
result: { filename: "edited-photo.jpg", url: version.url },
|
||
input: dimensions,
|
||
summary: result.lastMessage,
|
||
version
|
||
};
|
||
}
|
||
} catch (error) {
|
||
log("error", "Редактирование упало", {
|
||
jobId,
|
||
message: error instanceof Error ? error.message : String(error)
|
||
});
|
||
const job = jobs.get(jobId);
|
||
if (job) {
|
||
job.status = "error";
|
||
job.error = "Внутренняя ошибка при редактировании.";
|
||
job.details = error instanceof Error ? error.message : String(error);
|
||
job.finishedAt = Date.now();
|
||
}
|
||
} finally {
|
||
codexEditInFlight = false;
|
||
}
|
||
}
|
||
|
||
async function runUpscaleJob(jobId, sourceVersion, dimensions, scaledSize, runtime, fileBuffer, fileMimetype, startedAt) {
|
||
try {
|
||
const uploaded = await uploadToComfy({
|
||
buffer: fileBuffer,
|
||
mimetype: fileMimetype,
|
||
originalname: "input.jpg"
|
||
});
|
||
const built = buildUpscaleWorkflow(uploaded, runtime.selectedUpscaleModel, scaledSize);
|
||
const promptId = await submitWorkflow(built.workflow);
|
||
const image = await waitForResult(promptId, built.saveNodeId);
|
||
const imageBuffer = await downloadComfyImage(image);
|
||
|
||
const resultExt = String(image.filename || "")
|
||
.toLowerCase()
|
||
.endsWith(".png")
|
||
? "png"
|
||
: "jpg";
|
||
|
||
const version = makeHistoryVersion({
|
||
kind: "upscale",
|
||
label: "4× upscale",
|
||
prompt: null,
|
||
parentId: sourceVersion?.id || null,
|
||
buffer: imageBuffer,
|
||
ext: resultExt,
|
||
width: null,
|
||
height: null
|
||
});
|
||
|
||
await appendHistoryVersion(version);
|
||
|
||
log("info", "Апскейл завершён", {
|
||
jobId,
|
||
sourceId: sourceVersion?.id || null,
|
||
promptId,
|
||
model: runtime.selectedUpscaleModel,
|
||
durationMs: Date.now() - startedAt,
|
||
versionId: version.id
|
||
});
|
||
|
||
const job = jobs.get(jobId);
|
||
if (job) {
|
||
job.status = "done";
|
||
job.finishedAt = Date.now();
|
||
job.payload = {
|
||
job: { type: "upscale", promptId, model: runtime.selectedUpscaleModel },
|
||
result: { filename: "upscaled-photo.png", url: version.url },
|
||
input: dimensions,
|
||
processingInput: scaledSize || dimensions,
|
||
version
|
||
};
|
||
}
|
||
} catch (error) {
|
||
log("error", "Апскейл упал", {
|
||
jobId,
|
||
message: error instanceof Error ? error.message : String(error)
|
||
});
|
||
const job = jobs.get(jobId);
|
||
if (job) {
|
||
job.status = "error";
|
||
job.error = error instanceof AppError
|
||
? error.message
|
||
: "Не удалось увеличить разрешение фотографии.";
|
||
job.details = error instanceof Error ? error.message : String(error);
|
||
job.finishedAt = Date.now();
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildSuggestPrompt(inputName) {
|
||
return `Перед тобой фотография ${inputName}. Предложи 3 коротких варианта промпта (по одному на строку, без нумерации) для её редактирования. Только варианты промптов, ничего больше.`;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function runCodexSuggest(jobDir, inputNames, prompt) {
|
||
const lastMessagePath = path.join(jobDir, "last_message.txt");
|
||
return new Promise((resolve, reject) => {
|
||
let child;
|
||
try {
|
||
child = spawn(CODEX_EXE, 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 });
|
||
};
|
||
const deadlineTimer = setInterval(() => {
|
||
if (Date.now() - startedAt > CODEX_EDIT_TIMEOUT_MS) {
|
||
timedOut = true;
|
||
try { child.kill(); } catch { /* уже завершён */ }
|
||
settle();
|
||
}
|
||
}, 1000);
|
||
child.on("error", (error) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearInterval(deadlineTimer);
|
||
reject(error);
|
||
});
|
||
child.on("exit", (code) => {
|
||
exitCode = code;
|
||
// Короткая пауза после выхода, чтобы Codex успел дописать
|
||
// last_message.txt, затем завершаем джоб (таймаут — только backstop).
|
||
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);
|
||
});
|
||
}
|
||
|
||
async function runSuggestJob(jobId, jobDir, inputName, jobStartedAt) {
|
||
try {
|
||
const result = await runCodexSuggest(jobDir, [inputName], buildSuggestPrompt(inputName));
|
||
const suggestions = parseSuggestions(result.lastMessage);
|
||
const job = jobs.get(jobId);
|
||
if (!job) return;
|
||
if (suggestions.length === 0) {
|
||
job.status = "error";
|
||
job.error = "Codex не вернул варианты промпта. Попробуйте ещё раз.";
|
||
job.details = result.lastMessage ? result.lastMessage.slice(0, 500) : undefined;
|
||
job.finishedAt = Date.now();
|
||
log("error", "Подсказка промпта не удалась", { jobId, exitCode: result.exitCode, timedOut: result.timedOut });
|
||
return;
|
||
}
|
||
job.status = "done";
|
||
job.finishedAt = Date.now();
|
||
job.payload = { job: { type: "suggest" }, suggestions };
|
||
log("info", "Подсказка промпта готова", {
|
||
jobId,
|
||
count: suggestions.length,
|
||
durationMs: Date.now() - jobStartedAt
|
||
});
|
||
} catch (error) {
|
||
log("error", "Подсказка промпта упала", {
|
||
jobId,
|
||
message: error instanceof Error ? error.message : String(error)
|
||
});
|
||
const job = jobs.get(jobId);
|
||
if (job) {
|
||
job.status = "error";
|
||
job.error = "Внутренняя ошибка при получении подсказки.";
|
||
job.details = error instanceof Error ? error.message : String(error);
|
||
job.finishedAt = Date.now();
|
||
}
|
||
} finally {
|
||
codexEditInFlight = false;
|
||
}
|
||
}
|
||
|
||
function contentTypeForExt(ext) {
|
||
const map = {
|
||
jpg: "image/jpeg",
|
||
jpeg: "image/jpeg",
|
||
png: "image/png",
|
||
webp: "image/webp"
|
||
};
|
||
|
||
return map[String(ext || "").toLowerCase()] || "image/jpeg";
|
||
}
|
||
|
||
function loadHistory() {
|
||
try {
|
||
const parsed = JSON.parse(fs.readFileSync(HISTORY_MANIFEST, "utf8"));
|
||
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveHistory(history) {
|
||
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
||
fs.writeFileSync(HISTORY_MANIFEST, JSON.stringify(history, null, 2), "utf8");
|
||
}
|
||
|
||
function historyFilePath(version) {
|
||
return path.join(HISTORY_DIR, `${version.id}.${version.ext || "jpg"}`);
|
||
}
|
||
|
||
let historyWriteChain = Promise.resolve();
|
||
|
||
async function appendHistoryVersion(version) {
|
||
historyWriteChain = historyWriteChain
|
||
.then(() => {
|
||
const history = loadHistory();
|
||
history.push(version);
|
||
const trimmed = history.slice(-HISTORY_LIMIT);
|
||
const keptIds = new Set(trimmed.map((entry) => entry.id));
|
||
|
||
for (const old of history) {
|
||
if (!keptIds.has(old.id)) {
|
||
try {
|
||
fs.rmSync(historyFilePath(old), { force: true });
|
||
} catch {
|
||
// Файл мог уже отсутствовать.
|
||
}
|
||
}
|
||
}
|
||
|
||
saveHistory(trimmed);
|
||
})
|
||
.catch((error) => {
|
||
log("error", "Ошибка записи истории", { message: error instanceof Error ? error.message : String(error) });
|
||
console.error(error);
|
||
});
|
||
|
||
await historyWriteChain;
|
||
return version;
|
||
}
|
||
|
||
function makeHistoryVersion({
|
||
kind,
|
||
label,
|
||
prompt,
|
||
parentId,
|
||
referenceId,
|
||
buffer,
|
||
ext,
|
||
width,
|
||
height
|
||
}) {
|
||
const id = crypto.randomUUID();
|
||
const fileExt = ext || "jpg";
|
||
|
||
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
||
fs.writeFileSync(path.join(HISTORY_DIR, `${id}.${fileExt}`), buffer);
|
||
|
||
return {
|
||
id,
|
||
kind,
|
||
label,
|
||
prompt,
|
||
parentId: parentId || null,
|
||
referenceId: referenceId || null,
|
||
createdAt: new Date().toISOString(),
|
||
url: `/api/history/${id}/image.jpg`,
|
||
width: width || null,
|
||
height: height || null,
|
||
ext: fileExt
|
||
};
|
||
}
|
||
|
||
function parseRegion(value) {
|
||
if (value === undefined || value === null || String(value).trim() === "") {
|
||
return null;
|
||
}
|
||
|
||
let parsed;
|
||
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.");
|
||
}
|
||
|
||
return { x: Math.round(nums[0]), y: Math.round(nums[1]), w: Math.round(nums[2]), h: Math.round(nums[3]) };
|
||
}
|
||
|
||
async function resolvePhotoInput(request) {
|
||
const sourceId = String(request.body?.sourceId || "").trim();
|
||
|
||
if (!sourceId) {
|
||
if (!request.file) {
|
||
throw new AppError(400, "Выберите фотографию для обработки.");
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const version = loadHistory().find((entry) => entry.id === sourceId);
|
||
|
||
if (!version) {
|
||
throw new AppError(404, "Исходная версия не найдена в истории.");
|
||
}
|
||
|
||
const filePath = historyFilePath(version);
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new AppError(404, "Файл исходной версии не найден.");
|
||
}
|
||
|
||
request.file = {
|
||
buffer: fs.readFileSync(filePath),
|
||
mimetype: contentTypeForExt(version.ext),
|
||
originalname: `source-${version.id.slice(0, 8)}.${version.ext || "jpg"}`,
|
||
size: fs.statSync(filePath).size
|
||
};
|
||
|
||
return version;
|
||
}
|
||
|
||
async function downloadComfyImage(image) {
|
||
const parameters = new URLSearchParams({
|
||
filename: image.filename,
|
||
subfolder: image.subfolder || "",
|
||
type: image.type || "output"
|
||
});
|
||
|
||
const upstream = await comfyRequest(
|
||
`/view?${parameters.toString()}`,
|
||
{},
|
||
60000
|
||
);
|
||
|
||
if (!upstream.body) {
|
||
throw new AppError(502, "ComfyUI вернул пустое изображение.");
|
||
}
|
||
|
||
return Buffer.from(await upstream.arrayBuffer());
|
||
}
|
||
|
||
async function comfyRequest(route, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
|
||
try {
|
||
const response = await fetch(`${COMFY_URL}${route}`, {
|
||
...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 не ответил вовремя. Проверьте сервер обработки и повторите попытку."
|
||
);
|
||
}
|
||
|
||
throw new AppError(
|
||
503,
|
||
"Не удалось подключиться к ComfyUI по адресу 192.168.31.240:8188.",
|
||
error instanceof Error ? error.message : String(error)
|
||
);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function comfyJson(route, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||
const response = await comfyRequest(route, options, timeoutMs);
|
||
|
||
try {
|
||
return await response.json();
|
||
} catch {
|
||
throw new AppError(502, "ComfyUI вернул некорректный JSON-ответ.");
|
||
}
|
||
}
|
||
|
||
function extractChoiceOptions(specification) {
|
||
if (!Array.isArray(specification)) {
|
||
return [];
|
||
}
|
||
|
||
// Старый формат ComfyUI: [["model-a", "model-b"], {...}]
|
||
if (Array.isArray(specification[0])) {
|
||
return specification[0].filter((item) => typeof item === "string");
|
||
}
|
||
|
||
// Новый формат ComfyUI: ["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;
|
||
}
|
||
|
||
let runtimeCache = null;
|
||
let runtimeCacheExpiresAt = 0;
|
||
|
||
async function getRuntimeInfo(forceRefresh = false) {
|
||
if (
|
||
!forceRefresh &&
|
||
runtimeCache &&
|
||
Date.now() < runtimeCacheExpiresAt
|
||
) {
|
||
return runtimeCache;
|
||
}
|
||
|
||
const objectInfo = await comfyJson("/object_info");
|
||
|
||
const nodes = Object.fromEntries(
|
||
REQUIRED_NODES.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);
|
||
|
||
runtimeCache = {
|
||
nodes,
|
||
upscaleModels,
|
||
selectedUpscaleModel: chooseUpscaleModel(upscaleModels),
|
||
checkpoints,
|
||
checkpointAvailable: checkpoints.includes(CHECKPOINT)
|
||
};
|
||
|
||
runtimeCacheExpiresAt = Date.now() + 30000;
|
||
return runtimeCache;
|
||
}
|
||
|
||
function requireNodes(runtime, nodeNames) {
|
||
const missing = nodeNames.filter((nodeName) => !runtime.nodes[nodeName]);
|
||
|
||
if (missing.length > 0) {
|
||
throw new AppError(
|
||
503,
|
||
`На сервере ComfyUI отсутствуют необходимые узлы: ${missing.join(", ")}.`
|
||
);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
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 };
|
||
}
|
||
|
||
function safeUploadFilename(file) {
|
||
const extensionByMime = {
|
||
"image/jpeg": ".jpg",
|
||
"image/jpg": ".jpg",
|
||
"image/png": ".png",
|
||
"image/webp": ".webp"
|
||
};
|
||
|
||
const extension =
|
||
extensionByMime[file.mimetype.toLowerCase()] ||
|
||
path.extname(file.originalname).toLowerCase() ||
|
||
".png";
|
||
|
||
const originalStem = path.parse(file.originalname).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;
|
||
}
|
||
|
||
async function uploadToComfy(file) {
|
||
const form = new FormData();
|
||
|
||
form.append(
|
||
"image",
|
||
new Blob([file.buffer], { type: file.mimetype }),
|
||
safeUploadFilename(file)
|
||
);
|
||
form.append("type", "input");
|
||
form.append("overwrite", "true");
|
||
|
||
const uploaded = await comfyJson("/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
|
||
};
|
||
}
|
||
|
||
function makeSeed() {
|
||
return crypto.randomBytes(6).readUIntBE(0, 6);
|
||
}
|
||
|
||
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"
|
||
};
|
||
}
|
||
|
||
async function submitWorkflow(workflow) {
|
||
const response = await comfyJson("/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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
async function waitForResult(promptId, saveNodeId) {
|
||
const startedAt = Date.now();
|
||
|
||
while (Date.now() - startedAt < JOB_TIMEOUT_MS) {
|
||
const history = await comfyJson(
|
||
`/history/${encodeURIComponent(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(POLL_INTERVAL_MS);
|
||
}
|
||
|
||
throw new AppError(
|
||
504,
|
||
"Обработка заняла больше пяти минут и была остановлена по тайм-ауту."
|
||
);
|
||
}
|
||
|
||
function makePublicResult(image) {
|
||
const parameters = new URLSearchParams({
|
||
filename: image.filename,
|
||
subfolder: image.subfolder || "",
|
||
type: image.type || "output"
|
||
});
|
||
|
||
return {
|
||
...image,
|
||
url: `/api/result?${parameters.toString()}`
|
||
};
|
||
}
|
||
|
||
async function requirePhoto(request) {
|
||
if (!request.file) {
|
||
throw new AppError(400, "Выберите фотографию для обработки.");
|
||
}
|
||
|
||
let dimensions;
|
||
|
||
if (isHeic(request.file)) {
|
||
const buffer = await convertHeicToJpeg(request.file.buffer);
|
||
const originalStem =
|
||
path.parse(String(request.file.originalname || "")).name || "photo";
|
||
|
||
dimensions = getJpegDimensions(buffer);
|
||
|
||
request.file = {
|
||
...request.file,
|
||
buffer,
|
||
size: buffer.length,
|
||
mimetype: "image/jpeg",
|
||
originalname: `${originalStem}.jpg`
|
||
};
|
||
} else {
|
||
dimensions = getImageDimensions(request.file.buffer);
|
||
}
|
||
|
||
if (
|
||
!dimensions ||
|
||
!Number.isInteger(dimensions.width) ||
|
||
!Number.isInteger(dimensions.height) ||
|
||
dimensions.width <= 0 ||
|
||
dimensions.height <= 0
|
||
) {
|
||
throw new AppError(
|
||
400,
|
||
"Не удалось определить размеры фотографии. Используйте корректный JPEG, PNG, WebP или HEIC."
|
||
);
|
||
}
|
||
|
||
return dimensions;
|
||
}
|
||
|
||
app.get("/api/health", asyncRoute(async (_request, response) => {
|
||
try {
|
||
const runtime = await getRuntimeInfo(true);
|
||
const missingNodes = REQUIRED_NODES.filter(
|
||
(nodeName) => !runtime.nodes[nodeName]
|
||
);
|
||
|
||
const ready =
|
||
missingNodes.length === 0 &&
|
||
runtime.checkpointAvailable &&
|
||
Boolean(runtime.selectedUpscaleModel);
|
||
|
||
response.json({
|
||
ok: ready,
|
||
comfyAvailable: true,
|
||
message: ready
|
||
? "Сервер обработки: доступен"
|
||
: "Сервер доступен, но настроен не полностью",
|
||
selectedUpscaleModel: runtime.selectedUpscaleModel,
|
||
upscaleModels: runtime.upscaleModels,
|
||
checkpoint: CHECKPOINT,
|
||
checkpointAvailable: runtime.checkpointAvailable,
|
||
nodes: runtime.nodes,
|
||
missingNodes
|
||
});
|
||
} catch (error) {
|
||
response.status(200).json({
|
||
ok: false,
|
||
comfyAvailable: false,
|
||
message: error instanceof Error
|
||
? error.message
|
||
: "Сервер обработки недоступен",
|
||
selectedUpscaleModel: null,
|
||
upscaleModels: [],
|
||
checkpoint: CHECKPOINT,
|
||
checkpointAvailable: false,
|
||
nodes: Object.fromEntries(
|
||
REQUIRED_NODES.map((nodeName) => [nodeName, false])
|
||
)
|
||
});
|
||
}
|
||
}));
|
||
|
||
app.post("/api/login", asyncRoute(async (request, response) => {
|
||
const username = String(request.body?.username || "").trim();
|
||
const password = String(request.body?.password || "");
|
||
|
||
if (!username || !password) {
|
||
throw new AppError(400, "Введите логин и пароль.");
|
||
}
|
||
|
||
if (!constantTimeEqual(APP_USER, username) ||
|
||
!constantTimeEqual(APP_PASSWORD, password)) {
|
||
log("warn", "Неудачный вход", { login: username });
|
||
throw new AppError(401, "Неверный логин или пароль.");
|
||
}
|
||
|
||
const token = makeSessionToken();
|
||
sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS });
|
||
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
|
||
response.json({ ok: true, user: APP_USER });
|
||
log("info", "Вход выполнен", { user: APP_USER });
|
||
}));
|
||
|
||
app.get("/api/me", (request, response, next) => {
|
||
const found = getSession(request);
|
||
if (!found) {
|
||
next(new AppError(401, "Требуется авторизация."));
|
||
return;
|
||
}
|
||
found.session.expiresAt = Date.now() + SESSION_TTL_MS;
|
||
response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions());
|
||
response.json({ ok: true, user: found.session.user });
|
||
});
|
||
|
||
app.post("/api/logout", (request, response) => {
|
||
const token = readSessionToken(request);
|
||
if (token) sessions.delete(token);
|
||
response.clearCookie(SESSION_COOKIE, { path: "/" });
|
||
response.json({ ok: true });
|
||
log("info", "Выход из системы");
|
||
});
|
||
|
||
// Все эндпоинты ниже — только для авторизованных пользователей.
|
||
app.use("/api", requireAuth);
|
||
|
||
app.post(
|
||
"/api/preview",
|
||
upload.single("photo"),
|
||
asyncRoute(async (request, response) => {
|
||
if (!request.file) {
|
||
throw new AppError(400, "Выберите фотографию для предпросмотра.");
|
||
}
|
||
|
||
let buffer = request.file.buffer;
|
||
let contentType = request.file.mimetype;
|
||
|
||
if (isHeic(request.file)) {
|
||
buffer = await convertHeicToJpeg(request.file.buffer);
|
||
contentType = "image/jpeg";
|
||
}
|
||
|
||
response.status(200);
|
||
response.setHeader("content-type", contentType);
|
||
response.setHeader("cache-control", "no-store");
|
||
response.send(buffer);
|
||
})
|
||
);
|
||
|
||
app.get("/api/history", asyncRoute(async (_request, response) => {
|
||
response.json({ ok: true, versions: loadHistory() });
|
||
}));
|
||
|
||
app.get("/api/suggest-prompts", asyncRoute(async (_request, response) => {
|
||
const history = loadHistory();
|
||
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);
|
||
}
|
||
}
|
||
|
||
response.json({ ok: true, prompts });
|
||
}));
|
||
|
||
app.post(
|
||
"/api/history/import",
|
||
upload.single("photo"),
|
||
asyncRoute(async (request, response) => {
|
||
if (!request.file) {
|
||
throw new AppError(400, "Выберите фотографию.");
|
||
}
|
||
|
||
let buffer = request.file.buffer;
|
||
let ext = extForMimetype(request.file.mimetype);
|
||
|
||
if (isHeic(request.file)) {
|
||
buffer = await convertHeicToJpeg(buffer);
|
||
ext = "jpg";
|
||
}
|
||
|
||
const dimensions = ext === "jpg"
|
||
? getJpegDimensions(buffer)
|
||
: getImageDimensions(buffer);
|
||
|
||
const version = makeHistoryVersion({
|
||
kind: "original",
|
||
label: "Исходник",
|
||
prompt: null,
|
||
parentId: null,
|
||
buffer,
|
||
ext,
|
||
width: dimensions?.width,
|
||
height: dimensions?.height
|
||
});
|
||
|
||
await appendHistoryVersion(version);
|
||
|
||
log("info", "Импорт фотографии", {
|
||
versionId: version.id,
|
||
sizeBytes: request.file?.size ?? buffer.length,
|
||
ext,
|
||
width: version.width,
|
||
height: version.height
|
||
});
|
||
|
||
response.json({ ok: true, version });
|
||
})
|
||
);
|
||
|
||
app.delete("/api/history/:id", asyncRoute(async (request, response, next) => {
|
||
const { id } = request.params;
|
||
|
||
if (!JOB_ID_PATTERN.test(id)) {
|
||
throw new AppError(400, "Некорректный идентификатор версии.");
|
||
}
|
||
|
||
// Удаление сериализуется через historyWriteChain вместе с
|
||
// appendHistoryVersion: saveHistory пишет весь список без триминга,
|
||
// поэтому файлы чужих версий не затрагиваются.
|
||
let remaining = null;
|
||
|
||
await new Promise((resolve, reject) => {
|
||
historyWriteChain = historyWriteChain
|
||
.then(() => {
|
||
const history = loadHistory();
|
||
const target = history.find((entry) => entry.id === id);
|
||
|
||
if (!target) {
|
||
throw new AppError(404, "Версия не найдена в истории.");
|
||
}
|
||
|
||
const toDelete = new Set([id]);
|
||
let grew = true;
|
||
|
||
while (grew) {
|
||
grew = false;
|
||
|
||
for (const entry of history) {
|
||
if (entry.parentId && toDelete.has(entry.parentId) && !toDelete.has(entry.id)) {
|
||
toDelete.add(entry.id);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
remaining = history.filter((entry) => !toDelete.has(entry.id));
|
||
saveHistory(remaining);
|
||
|
||
for (const entry of history) {
|
||
if (toDelete.has(entry.id)) {
|
||
try {
|
||
fs.rmSync(historyFilePath(entry), { force: true });
|
||
} catch {
|
||
// Файл мог отсутствовать.
|
||
}
|
||
}
|
||
}
|
||
|
||
log("info", "Удаление версии", {
|
||
versionId: id,
|
||
children: toDelete.size - 1,
|
||
removed: toDelete.size
|
||
});
|
||
|
||
resolve();
|
||
})
|
||
.catch(reject);
|
||
});
|
||
|
||
response.json({ ok: true, versions: remaining });
|
||
}));
|
||
|
||
app.get(
|
||
"/api/history/:id/image.jpg",
|
||
asyncRoute(async (request, response) => {
|
||
const { id } = request.params;
|
||
|
||
if (!JOB_ID_PATTERN.test(id)) {
|
||
throw new AppError(400, "Некорректный идентификатор версии.");
|
||
}
|
||
|
||
const version = loadHistory().find((entry) => entry.id === id);
|
||
|
||
if (!version) {
|
||
throw new AppError(404, "Версия не найдена в истории.");
|
||
}
|
||
|
||
const filePath = historyFilePath(version);
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new AppError(404, "Файл версии не найден.");
|
||
}
|
||
|
||
response.status(200);
|
||
response.setHeader("content-type", contentTypeForExt(version.ext));
|
||
response.setHeader("cache-control", "no-store");
|
||
response.setHeader(
|
||
"content-disposition",
|
||
`inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.${version.ext || "jpg"}`)}`
|
||
);
|
||
|
||
fs.createReadStream(filePath).pipe(response);
|
||
})
|
||
);
|
||
|
||
app.post(
|
||
"/api/edit",
|
||
upload.fields([{ name: "photo", maxCount: 1 }, { name: "reference", maxCount: 1 }]),
|
||
asyncRoute(async (request, response) => {
|
||
const photoFile = request.files?.photo?.[0];
|
||
if (photoFile) request.file = photoFile;
|
||
|
||
const sourceVersion = await resolvePhotoInput(request);
|
||
const dimensions = await requirePhoto(request);
|
||
const prompt = String(request.body?.prompt || "").trim();
|
||
|
||
if (!prompt) {
|
||
throw new AppError(400, "Введите промпт для редактирования фотографии.");
|
||
}
|
||
|
||
if (prompt.length > 1000) {
|
||
throw new AppError(400, "Промпт не должен превышать 1000 символов.");
|
||
}
|
||
|
||
if (codexEditInFlight) {
|
||
throw new AppError(
|
||
429,
|
||
"Обработка уже выполняется. Дождитесь завершения текущего задания."
|
||
);
|
||
}
|
||
|
||
const referenceFile = request.files?.reference?.[0];
|
||
const region = parseRegion(request.body?.region);
|
||
|
||
let referenceId = null;
|
||
if (request.body?.referenceId) {
|
||
const rid = String(request.body.referenceId).trim();
|
||
|
||
if (!JOB_ID_PATTERN.test(rid)) {
|
||
throw new AppError(400, "Некорректный идентификатор референса.");
|
||
}
|
||
|
||
referenceId = rid;
|
||
}
|
||
|
||
if (region && !referenceFile) {
|
||
throw new AppError(400, "Область указана без изображения референса.");
|
||
}
|
||
|
||
codexEditInFlight = true;
|
||
const jobId = crypto.randomUUID();
|
||
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
|
||
const jobStartedAt = Date.now();
|
||
|
||
try {
|
||
fs.mkdirSync(jobDir, { recursive: true });
|
||
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
|
||
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
|
||
|
||
let referenceName = null;
|
||
if (referenceFile) {
|
||
let refBuffer = referenceFile.buffer;
|
||
let refExt = extForMimetype(referenceFile.mimetype);
|
||
|
||
if (isHeic(referenceFile)) {
|
||
refBuffer = await convertHeicToJpeg(refBuffer);
|
||
refExt = "jpg";
|
||
}
|
||
|
||
referenceName = `reference_annotated.${refExt}`;
|
||
fs.writeFileSync(path.join(jobDir, referenceName), refBuffer);
|
||
}
|
||
|
||
const inputNames = referenceName ? [inputName, referenceName] : [inputName];
|
||
const promptText = buildCodexPrompt(prompt, dimensions, inputName, referenceName, region);
|
||
|
||
jobs.set(jobId, {
|
||
type: "edit",
|
||
status: "running",
|
||
startedAt: Date.now(),
|
||
finishedAt: null,
|
||
payload: null,
|
||
error: null,
|
||
details: null
|
||
});
|
||
|
||
log("info", "Редактирование запущено", {
|
||
jobId,
|
||
sourceId: sourceVersion?.id || null,
|
||
prompt: truncate(prompt, 200),
|
||
hasReference: Boolean(referenceName),
|
||
region: region || null,
|
||
referenceId: referenceId || null
|
||
});
|
||
|
||
void runEditJob(jobId, jobDir, inputNames, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt, referenceId);
|
||
} catch (error) {
|
||
codexEditInFlight = false;
|
||
throw error;
|
||
}
|
||
|
||
response.json({ ok: true, jobId, status: "running" });
|
||
})
|
||
);
|
||
|
||
app.post(
|
||
"/api/upscale",
|
||
upload.single("photo"),
|
||
asyncRoute(async (request, response) => {
|
||
const sourceVersion = await resolvePhotoInput(request);
|
||
const dimensions = await requirePhoto(request);
|
||
const scaledSize = scaleForLimit(
|
||
dimensions,
|
||
UPSCALE_MAX_DIMENSION
|
||
);
|
||
|
||
const runtime = await getRuntimeInfo();
|
||
|
||
requireNodes(runtime, [
|
||
"LoadImage",
|
||
"UpscaleModelLoader",
|
||
"ImageUpscaleWithModel",
|
||
"SaveImage",
|
||
...(scaledSize ? ["ImageScale"] : [])
|
||
]);
|
||
|
||
if (!runtime.selectedUpscaleModel) {
|
||
throw new AppError(
|
||
503,
|
||
"На сервере ComfyUI не установлена модель увеличения разрешения."
|
||
);
|
||
}
|
||
|
||
const jobId = crypto.randomUUID();
|
||
const upscaleStartedAt = Date.now();
|
||
|
||
jobs.set(jobId, {
|
||
type: "upscale",
|
||
status: "running",
|
||
startedAt: Date.now(),
|
||
finishedAt: null,
|
||
payload: null,
|
||
error: null,
|
||
details: null
|
||
});
|
||
|
||
log("info", "Апскейл запущен", {
|
||
jobId,
|
||
sourceId: sourceVersion?.id || null,
|
||
model: runtime.selectedUpscaleModel,
|
||
scaledSize: scaledSize || null
|
||
});
|
||
|
||
void runUpscaleJob(
|
||
jobId,
|
||
sourceVersion,
|
||
dimensions,
|
||
scaledSize,
|
||
runtime,
|
||
request.file.buffer,
|
||
request.file.mimetype,
|
||
upscaleStartedAt
|
||
);
|
||
|
||
response.json({ ok: true, jobId, status: "running" });
|
||
})
|
||
);
|
||
|
||
app.post(
|
||
"/api/suggest",
|
||
upload.single("photo"),
|
||
asyncRoute(async (request, response) => {
|
||
const sourceVersion = await resolvePhotoInput(request);
|
||
await requirePhoto(request);
|
||
|
||
if (codexEditInFlight) {
|
||
throw new AppError(
|
||
429,
|
||
"Обработка уже выполняется. Дождитесь завершения текущего задания."
|
||
);
|
||
}
|
||
|
||
codexEditInFlight = true;
|
||
const jobId = crypto.randomUUID();
|
||
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
|
||
const jobStartedAt = Date.now();
|
||
|
||
try {
|
||
fs.mkdirSync(jobDir, { recursive: true });
|
||
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
|
||
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
|
||
|
||
jobs.set(jobId, {
|
||
type: "suggest",
|
||
status: "running",
|
||
startedAt: Date.now(),
|
||
finishedAt: null,
|
||
payload: null,
|
||
error: null,
|
||
details: null
|
||
});
|
||
|
||
log("info", "Подсказка промпта запрошена", {
|
||
jobId,
|
||
sourceId: sourceVersion?.id || null
|
||
});
|
||
|
||
void runSuggestJob(jobId, jobDir, inputName, jobStartedAt);
|
||
} catch (error) {
|
||
codexEditInFlight = false;
|
||
throw error;
|
||
}
|
||
|
||
response.json({ ok: true, jobId, status: "running" });
|
||
})
|
||
);
|
||
|
||
app.get("/api/result", asyncRoute(async (request, response) => {
|
||
const filename = String(request.query.filename || "");
|
||
const subfolder = String(request.query.subfolder || "");
|
||
const type = String(request.query.type || "output");
|
||
|
||
if (!filename) {
|
||
throw new AppError(400, "Не указано имя итогового изображения.");
|
||
}
|
||
|
||
if (type !== "output") {
|
||
throw new AppError(400, "Разрешена загрузка только итоговых изображений.");
|
||
}
|
||
|
||
const parameters = new URLSearchParams({
|
||
filename,
|
||
subfolder,
|
||
type: "output"
|
||
});
|
||
|
||
const upstream = await comfyRequest(
|
||
`/view?${parameters.toString()}`,
|
||
{},
|
||
30000
|
||
);
|
||
|
||
response.status(200);
|
||
response.setHeader(
|
||
"content-type",
|
||
upstream.headers.get("content-type") || "image/png"
|
||
);
|
||
response.setHeader("cache-control", "no-store");
|
||
response.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), response);
|
||
}));
|
||
|
||
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||
|
||
app.get("/api/jobs/:id/output.jpg", asyncRoute(async (request, response) => {
|
||
const { id } = request.params;
|
||
|
||
if (!JOB_ID_PATTERN.test(id)) {
|
||
throw new AppError(400, "Некорректный идентификатор задания.");
|
||
}
|
||
|
||
const filePath = path.join(CODEX_JOBS_DIR, id, "output.jpg");
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
throw new AppError(404, "Результат редактирования не найден.");
|
||
}
|
||
|
||
response.status(200);
|
||
response.setHeader("content-type", "image/jpeg");
|
||
response.setHeader("cache-control", "no-store");
|
||
response.setHeader(
|
||
"content-disposition",
|
||
`inline; filename*=UTF-8''${encodeURIComponent("edited-photo.jpg")}`
|
||
);
|
||
|
||
fs.createReadStream(filePath).pipe(response);
|
||
}));
|
||
|
||
app.get("/api/jobs/:id/status", asyncRoute(async (request, response) => {
|
||
const { id } = request.params;
|
||
|
||
if (!JOB_ID_PATTERN.test(id)) {
|
||
throw new AppError(400, "Некорректный идентификатор задания.");
|
||
}
|
||
|
||
const job = jobs.get(id);
|
||
|
||
if (!job) {
|
||
throw new AppError(404, "Задание не найдено.");
|
||
}
|
||
|
||
const body = { ok: true, status: job.status, type: job.type };
|
||
|
||
if (job.status === "done") {
|
||
Object.assign(body, job.payload);
|
||
}
|
||
|
||
if (job.status === "error") {
|
||
body.error = job.error;
|
||
if (job.details !== undefined) {
|
||
body.details = job.details;
|
||
}
|
||
}
|
||
|
||
response.json(body);
|
||
}));
|
||
|
||
app.use("/api", (_request, response) => {
|
||
response.status(404).json({
|
||
ok: false,
|
||
error: "API-метод не найден."
|
||
});
|
||
});
|
||
|
||
app.use((error, _request, response, next) => {
|
||
if (response.headersSent) {
|
||
log("error", "Ошибка после начала ответа", {
|
||
method: _request.method,
|
||
path: _request.originalUrl,
|
||
message: error instanceof Error ? error.message : String(error)
|
||
});
|
||
next(error);
|
||
return;
|
||
}
|
||
|
||
let status = error instanceof AppError ? error.status : 500;
|
||
let message = error instanceof Error
|
||
? error.message
|
||
: "Неизвестная ошибка сервера.";
|
||
|
||
if (error instanceof multer.MulterError) {
|
||
status = 400;
|
||
|
||
if (error.code === "LIMIT_FILE_SIZE") {
|
||
message = "Файл слишком большой. Максимальный размер — 25 МБ.";
|
||
} else {
|
||
message = `Ошибка загрузки файла: ${error.message}`;
|
||
}
|
||
}
|
||
|
||
if (error && error.type === "entity.parse.failed") {
|
||
status = 400;
|
||
message = "Некорректный JSON в теле запроса.";
|
||
}
|
||
|
||
if (
|
||
!(error instanceof AppError) &&
|
||
!(error instanceof multer.MulterError) &&
|
||
!(error && error.type === "entity.parse.failed")
|
||
) {
|
||
log("error", "Непредвиденная ошибка сервера", {
|
||
method: _request.method,
|
||
path: _request.originalUrl,
|
||
status,
|
||
message
|
||
});
|
||
console.error(error);
|
||
message = "Внутренняя ошибка сервера.";
|
||
} else if (error instanceof AppError) {
|
||
const fields = { status, message };
|
||
if (error.details !== undefined) {
|
||
fields.details = truncate(
|
||
JSON.stringify(normalizeErrorDetails(error.details)),
|
||
200
|
||
);
|
||
}
|
||
log(status >= 500 ? "error" : "warn", "Ошибка обработки запроса", fields);
|
||
} else {
|
||
log("warn", "Ошибка обработки запроса", { status, message });
|
||
}
|
||
|
||
response.status(status).json({
|
||
ok: false,
|
||
error: message,
|
||
details: normalizeErrorDetails(error.details)
|
||
});
|
||
});
|
||
|
||
function listenOnPort(port) {
|
||
return new Promise((resolve, reject) => {
|
||
const server = app.listen(port, "0.0.0.0");
|
||
|
||
const onError = (error) => {
|
||
reject(error);
|
||
};
|
||
|
||
server.once("error", onError);
|
||
server.once("listening", () => {
|
||
server.off("error", onError);
|
||
resolve(server);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function startServer() {
|
||
fs.rmSync(CODEX_JOBS_DIR, { recursive: true, force: true });
|
||
fs.mkdirSync(CODEX_JOBS_DIR, { recursive: true });
|
||
fs.mkdirSync(HISTORY_DIR, { recursive: true });
|
||
|
||
for (const port of PORT_CANDIDATES) {
|
||
try {
|
||
await listenOnPort(port);
|
||
|
||
log("info", "Сервер запущен", { port, user: APP_USER, comfyUrl: COMFY_URL, passwordGenerated: !process.env.APP_PASSWORD });
|
||
return;
|
||
} catch (error) {
|
||
if (error && error.code === "EADDRINUSE") {
|
||
log("warn", "Порт занят, пробую следующий", { port });
|
||
continue;
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`Не удалось запустить сервер: порты ${PORT_CANDIDATES.join(", ")} заняты.`
|
||
);
|
||
}
|
||
|
||
startServer().catch((error) => {
|
||
log("error", "Ошибка запуска", { message: error instanceof Error ? error.message : String(error) });
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|