Files
photo-editor/server.js

2289 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
});