1587 lines
38 KiB
JavaScript
1587 lines
38 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 = 125000;
|
||
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 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;
|
||
|
||
class AppError extends Error {
|
||
constructor(status, message, details = undefined) {
|
||
super(message);
|
||
this.name = "AppError";
|
||
this.status = status;
|
||
this.details = details;
|
||
}
|
||
}
|
||
|
||
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,
|
||
files: 1,
|
||
fields: 4
|
||
},
|
||
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) {
|
||
const size = dimensions && dimensions.width
|
||
? `${dimensions.width}×${dimensions.height}`
|
||
: "";
|
||
|
||
return [
|
||
`Перед тобой фотография ${inputName}. Примени к ней следующее редактирование: «${prompt}».`,
|
||
"Сохрани отредактированное фото в файл output.jpg в текущей директории.",
|
||
"Не перерисовывай фото с нуля и не заменяй его на полностью новое изображение — редактируй исходное фото, сохраняя его сюжет, объекты, лица и пропорции.",
|
||
size
|
||
? `Сохрани исходный размер ${size}.`
|
||
: "Сохрани исходные пропорции.",
|
||
"Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал."
|
||
].join(" ");
|
||
}
|
||
|
||
function runCodexEdit(jobDir, inputName, prompt) {
|
||
const lastMessagePath = path.join(jobDir, "last_message.txt");
|
||
const outputPath = path.join(jobDir, "output.jpg");
|
||
|
||
const args = [
|
||
"exec",
|
||
"-C", jobDir,
|
||
"--skip-git-repo-check",
|
||
"--ephemeral",
|
||
"--dangerously-bypass-approvals-and-sandbox"
|
||
];
|
||
|
||
// Переопределение модели и уровня рассуждений (для тестов скорости):
|
||
// CODEX_MODEL=gpt-5.2-codex-mini CODEX_REASONING_EFFORT=low node server.js
|
||
if (process.env.CODEX_MODEL) {
|
||
args.push("-m", process.env.CODEX_MODEL);
|
||
}
|
||
|
||
if (process.env.CODEX_REASONING_EFFORT) {
|
||
args.push(
|
||
"-c",
|
||
`model_reasoning_effort=${process.env.CODEX_REASONING_EFFORT}`
|
||
);
|
||
}
|
||
|
||
args.push("-i", inputName, "-o", "last_message.txt", "-");
|
||
|
||
return new Promise((resolve, reject) => {
|
||
let child;
|
||
|
||
try {
|
||
child = spawn(
|
||
CODEX_EXE,
|
||
args,
|
||
{
|
||
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();
|
||
});
|
||
}
|
||
|
||
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) => {
|
||
console.error("Ошибка записи истории:", error);
|
||
});
|
||
|
||
await historyWriteChain;
|
||
return version;
|
||
}
|
||
|
||
function makeHistoryVersion({
|
||
kind,
|
||
label,
|
||
prompt,
|
||
parentId,
|
||
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,
|
||
createdAt: new Date().toISOString(),
|
||
url: `/api/history/${id}/image.jpg`,
|
||
width: width || null,
|
||
height: height || null,
|
||
ext: fileExt
|
||
};
|
||
}
|
||
|
||
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/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.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);
|
||
|
||
response.json({ ok: true, version });
|
||
})
|
||
);
|
||
|
||
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.single("photo"),
|
||
asyncRoute(async (request, response) => {
|
||
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,
|
||
"Редактирование уже выполняется. Дождитесь завершения текущего задания."
|
||
);
|
||
}
|
||
|
||
codexEditInFlight = true;
|
||
|
||
try {
|
||
const jobId = crypto.randomUUID();
|
||
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
|
||
|
||
fs.mkdirSync(jobDir, { recursive: true });
|
||
|
||
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
|
||
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
|
||
|
||
const promptText = buildCodexPrompt(prompt, dimensions, inputName);
|
||
const result = await runCodexEdit(jobDir, inputName, promptText);
|
||
|
||
if (!result.outputPath) {
|
||
const details = result.lastMessage
|
||
? `Ответ Codex: ${result.lastMessage.slice(0, 500)}`
|
||
: undefined;
|
||
|
||
throw new AppError(
|
||
504,
|
||
"Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт.",
|
||
details
|
||
);
|
||
}
|
||
|
||
const version = makeHistoryVersion({
|
||
kind: "edit",
|
||
label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`,
|
||
prompt,
|
||
parentId: sourceVersion?.id || null,
|
||
buffer: fs.readFileSync(result.outputPath),
|
||
ext: "jpg",
|
||
width: dimensions?.width,
|
||
height: dimensions?.height
|
||
});
|
||
|
||
await appendHistoryVersion(version);
|
||
|
||
response.json({
|
||
ok: true,
|
||
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
|
||
});
|
||
} finally {
|
||
codexEditInFlight = false;
|
||
}
|
||
})
|
||
);
|
||
|
||
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 uploaded = await uploadToComfy(request.file);
|
||
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);
|
||
|
||
response.json({
|
||
ok: true,
|
||
job: {
|
||
type: "upscale",
|
||
promptId,
|
||
model: runtime.selectedUpscaleModel
|
||
},
|
||
result: {
|
||
filename: "upscaled-photo.png",
|
||
url: version.url
|
||
},
|
||
input: dimensions,
|
||
processingInput: scaledSize || dimensions,
|
||
version
|
||
});
|
||
})
|
||
);
|
||
|
||
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.use("/api", (_request, response) => {
|
||
response.status(404).json({
|
||
ok: false,
|
||
error: "API-метод не найден."
|
||
});
|
||
});
|
||
|
||
app.use((error, _request, response, next) => {
|
||
if (response.headersSent) {
|
||
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 instanceof AppError) && !(error instanceof multer.MulterError)) {
|
||
console.error(error);
|
||
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);
|
||
|
||
console.log(`Фоторедактор запущен: http://localhost:${port}`);
|
||
console.log(`Доступ в локальной сети: http://0.0.0.0:${port}`);
|
||
console.log(`ComfyUI: ${COMFY_URL}`);
|
||
return;
|
||
} catch (error) {
|
||
if (error && error.code === "EADDRINUSE") {
|
||
console.warn(`Порт ${port} занят, пробую следующий.`);
|
||
continue;
|
||
}
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`Не удалось запустить сервер: порты ${PORT_CANDIDATES.join(", ")} заняты.`
|
||
);
|
||
}
|
||
|
||
startServer().catch((error) => {
|
||
console.error("Ошибка запуска:", error);
|
||
process.exitCode = 1;
|
||
});
|