Files
photo-editor/server.js

1587 lines
38 KiB
JavaScript
Raw Permalink 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 = 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;
});