"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; });