diff --git a/README.md b/README.md index d190015..f18d8fd 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ Codex CLI) и увеличивает разрешение через домаш `RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе отредактированной) версии. +Редактирование и апскейл выполняются в фоне: `POST /api/edit` и +`POST /api/upscale` отвечают мгновенно с идентификатором задания (`jobId`), +а тяжёлая работа продолжается на сервере. Фронтенд опрашивает +`GET /api/jobs/:id/status` каждые 3 секунды; страницу можно закрыть — +готовая версия появится в истории. + ## Требования - Node.js 20 или новее @@ -73,6 +79,45 @@ npm start `GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login` с JSON-телом `{"username": "...", "password": "..."}`. +## Логирование + +Все действия сервера пишутся в стандартный вывод контейнера — смотрите их в +**Portainer → Containers → kadr-photo-editor → Logs**. + +Что логируется: + +- каждый запрос к `/api/*` (метод, путь, HTTP-статус, время обработки); +- старт сервера (порт, логин, адрес ComfyUI); +- вход и выход пользователя; +- импорт фотографии; +- редактирование: запуск задания, успешное завершение или ошибка; +- апскейл: запуск задания, успешное завершение или ошибка; +- ошибки обработки запросов (с кодом статуса и текстом ошибки). + +Что **не** логируется: пароли, cookie `kadr_session`, содержимое фотографий. +Единственное исключение — баннер сгенерированного пароля при первом старте +(когда `APP_PASSWORD` не задан): пароль печатается в лог один раз, чтобы +владелец мог войти. + +Уровень логирования задаётся переменной окружения `LOG_LEVEL`: + +- `debug` — все сообщения, включая `GET /api/health` и опросы статуса заданий + `GET /api/jobs/:id/status`; +- `info` (по умолчанию) — основные события; +- `warn` — предупреждения; +- `error` — только ошибки. + +Примеры строк лога: + +``` +2026-08-26T06:00:00.000Z [INFO] Сервер запущен {"port":3000,"user":"admin","comfyUrl":"http://192.168.31.240:8188","passwordGenerated":false} +2026-08-26T06:00:01.000Z [INFO] GET /api/history -> 200 2ms +2026-08-26T06:00:05.000Z [INFO] Вход выполнен {"user":"admin"} +2026-08-26T06:00:10.000Z [INFO] Редактирование запущено {"jobId":"...","sourceId":"...","prompt":"..."} +2026-08-26T06:02:30.000Z [INFO] Редактирование завершено {"jobId":"...","ok":true,"exitCode":0,"timedOut":false,"durationMs":140000,"versionId":"...","lastMessage":"..."} +2026-08-26T06:02:31.000Z [WARN] Ошибка обработки запроса {"status":401,"message":"Требуется авторизация."} +``` + ## Docker Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI diff --git a/public/app.js b/public/app.js index 296ba7e..1642480 100644 --- a/public/app.js +++ b/public/app.js @@ -342,8 +342,8 @@ async function runJob(kind) { setJobStatus( "loading", isEdit - ? "Codex редактирует фотографию… Обычно 1–3 минуты." - : "Увеличиваем разрешение фотографии…" + ? "Codex редактирует фотографию… Обычно 1–3 минуты. Страницу можно закрыть — результат появится в истории." + : "Увеличиваем разрешение фотографии… Страницу можно закрыть — результат появится в истории." ); try { @@ -359,57 +359,118 @@ async function runJob(kind) { throw new Error(data.error || "Не удалось обработать фотографию."); } - const version = data.version; - - if (version) { - historyVersions.push(version); - currentVersionId = version.id; - updateDownloadCurrent(version); - renderHistory(); + if (!data.jobId) { + throw new Error("Сервер не вернул идентификатор задания."); } - const url = version?.url || data.result?.url; - - resultImage.src = url; - resultImage.hidden = false; - resultPlaceholder.hidden = true; - - resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale"; - - downloadLink.href = url; - downloadLink.download = - data.result?.filename || - (isEdit ? "edited-photo.jpg" : "upscaled-photo.png"); - downloadLink.hidden = false; - - const summary = isEdit && data.summary - ? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180) - : ""; - - setJobStatus( - "success", - isEdit - ? summary - ? `Готово — фотография отредактирована. Codex: ${summary}` - : "Готово — фотография отредактирована." - : `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.` - ); - - if (window.matchMedia("(max-width: 1040px)").matches) { - resultImage.scrollIntoView({ - behavior: "smooth", - block: "center" - }); - } + pollJob(data.jobId, isEdit); } catch (error) { + setBusy(false); setJobStatus( "error", error instanceof Error ? error.message : "Не удалось обработать фотографию." ); - } finally { - setBusy(false); + } +} + +let jobPollFailures = 0; + +async function pollJob(jobId, isEdit) { + try { + const response = await apiFetch(`/api/jobs/${jobId}/status`, { + cache: "no-store" + }); + const data = await readJsonResponse(response); + + if (!response.ok || !data.ok) { + throw new Error(data.error || "Не удалось получить статус задания."); + } + + jobPollFailures = 0; + + if (data.status === "running") { + setTimeout(() => pollJob(jobId, isEdit), 3000); + return; + } + + if (data.status === "done") { + renderJobSuccess(data, isEdit); + setBusy(false); + return; + } + + if (data.status === "error") { + throw new Error(data.error || "Задание завершилось с ошибкой."); + } + + throw new Error("Неизвестный статус задания."); + } catch (error) { + if (error instanceof Error && error.message === "Требуется авторизация.") { + setBusy(false); + return; + } + + jobPollFailures += 1; + + if (jobPollFailures >= 5) { + setBusy(false); + setJobStatus( + "error", + error instanceof Error + ? error.message + : "Ошибка связи с сервером. Проверьте результат в истории." + ); + return; + } + + setTimeout(() => pollJob(jobId, isEdit), 3000); + } +} + +function renderJobSuccess(data, isEdit) { + const version = data.version; + + if (version) { + historyVersions.push(version); + currentVersionId = version.id; + updateDownloadCurrent(version); + renderHistory(); + } + + const url = version?.url || data.result?.url; + + resultImage.src = url; + resultImage.hidden = false; + resultPlaceholder.hidden = true; + + resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale"; + + downloadLink.href = url; + downloadLink.download = + data.result?.filename || + (isEdit ? "edited-photo.jpg" : "upscaled-photo.png"); + downloadLink.hidden = false; + + const summary = isEdit && data.summary + ? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180) + : ""; + + setJobStatus( + "success", + isEdit + ? summary + ? `Готово — фотография отредактирована. Codex: ${summary}` + : "Готово — фотография отредактирована." + : `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.` + ); + + if (window.matchMedia("(max-width: 1040px)").matches) { + resultImage.scrollIntoView({ + behavior: "smooth", + block: "center" + }); } } diff --git a/public/index.html b/public/index.html index 417806b..4de1584 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,7 @@ rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E" > - +
@@ -261,6 +261,6 @@ - + diff --git a/server.js b/server.js index 686eabc..9f4b653 100644 --- a/server.js +++ b/server.js @@ -46,6 +46,38 @@ const REQUIRED_NODES = [ "ImageUpscaleWithModel" ]; +// --- Логирование ------------------------------------------------- +const LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; +const LOG_THRESHOLD = + LOG_LEVELS[String(process.env.LOG_LEVEL || "info").toLowerCase()] ?? + LOG_LEVELS.info; + +function log(level, message, fields) { + const numeric = LOG_LEVELS[level]; + if (numeric === undefined || numeric < LOG_THRESHOLD) { + return; + } + const line = + `${new Date().toISOString()} [${level.toUpperCase()}] ${message}` + + (fields === undefined ? "" : ` ${JSON.stringify(fields)}`); + if (numeric >= LOG_LEVELS.warn) { + console.error(line); + } else { + console.log(line); + } +} + +function singleLine(text) { + return String(text ?? "").replace(/[\r\n\t]+/g, " ").trim(); +} + +function truncate(text, maxLength) { + const clean = singleLine(text); + return clean.length > maxLength + ? `${clean.slice(0, maxLength)}…` + : clean; +} + const app = express(); app.disable("x-powered-by"); @@ -61,6 +93,19 @@ let codexEditInFlight = false; // JSON-тело нужно для POST /api/login; multipart обрабатывает multer. app.use(express.json({ limit: "16kb" })); +app.use((request, response, next) => { + const startedAt = Date.now(); + response.on("finish", () => { + const level = + request.originalUrl.startsWith("/api/health") || + request.originalUrl.startsWith("/api/jobs/") + ? "debug" + : "info"; + log(level, `${request.method} ${request.originalUrl} -> ${response.statusCode} ${Date.now() - startedAt}ms`); + }); + next(); +}); + class AppError extends Error { constructor(status, message, details = undefined) { super(message); @@ -95,6 +140,18 @@ if (!process.env.APP_PASSWORD) { // token -> { user, expiresAt } const sessions = new Map(); +// jobId -> { type, status, startedAt, finishedAt, payload, error, details } +const jobs = new Map(); + +setInterval(() => { + const now = Date.now(); + for (const [id, job] of jobs) { + if (job.finishedAt && now - job.finishedAt > 60 * 60 * 1000) { + jobs.delete(id); + } + } +}, 30 * 60 * 1000).unref(); + function constantTimeEqual(a, b) { const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest(); const digestB = crypto.createHash("sha256").update(String(b), "utf8").digest(); @@ -449,6 +506,154 @@ function runCodexEdit(jobDir, inputName, prompt) { }); } +async function runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceId, dimensions, jobStartedAt) { + try { + const result = await runCodexEdit(jobDir, inputName, promptText); + const durationMs = Date.now() - jobStartedAt; + + if (!result.outputPath) { + const details = result.lastMessage + ? `Ответ Codex: ${result.lastMessage.slice(0, 500)}` + : undefined; + log("error", "Редактирование не завершено", { + jobId, + ok: false, + exitCode: result.exitCode, + timedOut: result.timedOut, + durationMs, + lastMessage: truncate(result.lastMessage, 500) + }); + const job = jobs.get(jobId); + if (job) { + job.status = "error"; + job.error = "Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт."; + job.details = details; + job.finishedAt = Date.now(); + } + return; + } + + const version = makeHistoryVersion({ + kind: "edit", + label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`, + prompt, + parentId: sourceId || null, + buffer: fs.readFileSync(result.outputPath), + ext: "jpg", + width: dimensions?.width, + height: dimensions?.height + }); + + await appendHistoryVersion(version); + + log("info", "Редактирование завершено", { + jobId, + ok: true, + exitCode: result.exitCode, + timedOut: result.timedOut, + durationMs, + versionId: version.id, + lastMessage: truncate(result.lastMessage, 500) + }); + + const job = jobs.get(jobId); + if (job) { + job.status = "done"; + job.finishedAt = Date.now(); + job.payload = { + job: { type: "edit", engine: "codex", exitCode: result.exitCode, timedOut: result.timedOut }, + result: { filename: "edited-photo.jpg", url: version.url }, + input: dimensions, + summary: result.lastMessage, + version + }; + } + } catch (error) { + log("error", "Редактирование упало", { + jobId, + message: error instanceof Error ? error.message : String(error) + }); + const job = jobs.get(jobId); + if (job) { + job.status = "error"; + job.error = "Внутренняя ошибка при редактировании."; + job.details = error instanceof Error ? error.message : String(error); + job.finishedAt = Date.now(); + } + } finally { + codexEditInFlight = false; + } +} + +async function runUpscaleJob(jobId, sourceVersion, dimensions, scaledSize, runtime, fileBuffer, fileMimetype, startedAt) { + try { + const uploaded = await uploadToComfy({ + buffer: fileBuffer, + mimetype: fileMimetype, + originalname: "input.jpg" + }); + const built = buildUpscaleWorkflow(uploaded, runtime.selectedUpscaleModel, scaledSize); + const promptId = await submitWorkflow(built.workflow); + const image = await waitForResult(promptId, built.saveNodeId); + const imageBuffer = await downloadComfyImage(image); + + const resultExt = String(image.filename || "") + .toLowerCase() + .endsWith(".png") + ? "png" + : "jpg"; + + const version = makeHistoryVersion({ + kind: "upscale", + label: "4× upscale", + prompt: null, + parentId: sourceVersion?.id || null, + buffer: imageBuffer, + ext: resultExt, + width: null, + height: null + }); + + await appendHistoryVersion(version); + + log("info", "Апскейл завершён", { + jobId, + sourceId: sourceVersion?.id || null, + promptId, + model: runtime.selectedUpscaleModel, + durationMs: Date.now() - startedAt, + versionId: version.id + }); + + const job = jobs.get(jobId); + if (job) { + job.status = "done"; + job.finishedAt = Date.now(); + job.payload = { + job: { type: "upscale", promptId, model: runtime.selectedUpscaleModel }, + result: { filename: "upscaled-photo.png", url: version.url }, + input: dimensions, + processingInput: scaledSize || dimensions, + version + }; + } + } catch (error) { + log("error", "Апскейл упал", { + jobId, + message: error instanceof Error ? error.message : String(error) + }); + const job = jobs.get(jobId); + if (job) { + job.status = "error"; + job.error = error instanceof AppError + ? error.message + : "Не удалось увеличить разрешение фотографии."; + job.details = error instanceof Error ? error.message : String(error); + job.finishedAt = Date.now(); + } + } +} + function contentTypeForExt(ext) { const map = { jpg: "image/jpeg", @@ -502,7 +707,8 @@ async function appendHistoryVersion(version) { saveHistory(trimmed); }) .catch((error) => { - console.error("Ошибка записи истории:", error); + log("error", "Ошибка записи истории", { message: error instanceof Error ? error.message : String(error) }); + console.error(error); }); await historyWriteChain; @@ -1259,6 +1465,7 @@ app.post("/api/login", asyncRoute(async (request, response) => { if (!constantTimeEqual(APP_USER, username) || !constantTimeEqual(APP_PASSWORD, password)) { + log("warn", "Неудачный вход", { login: username }); throw new AppError(401, "Неверный логин или пароль."); } @@ -1266,6 +1473,7 @@ app.post("/api/login", asyncRoute(async (request, response) => { sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS }); response.cookie(SESSION_COOKIE, token, sessionCookieOptions()); response.json({ ok: true, user: APP_USER }); + log("info", "Вход выполнен", { user: APP_USER }); })); app.get("/api/me", (request, response, next) => { @@ -1284,6 +1492,7 @@ app.post("/api/logout", (request, response) => { if (token) sessions.delete(token); response.clearCookie(SESSION_COOKIE, { path: "/" }); response.json({ ok: true }); + log("info", "Выход из системы"); }); // Все эндпоинты ниже — только для авторизованных пользователей. @@ -1349,6 +1558,14 @@ app.post( await appendHistoryVersion(version); + log("info", "Импорт фотографии", { + versionId: version.id, + sizeBytes: request.file?.size ?? buffer.length, + ext, + width: version.width, + height: version.height + }); + response.json({ ok: true, version }); }) ); @@ -1410,63 +1627,40 @@ app.post( } codexEditInFlight = true; + const jobId = crypto.randomUUID(); + const jobDir = path.join(CODEX_JOBS_DIR, jobId); + const jobStartedAt = Date.now(); 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 + jobs.set(jobId, { + type: "edit", + status: "running", + startedAt: Date.now(), + finishedAt: null, + payload: null, + error: null, + details: null }); - 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 + log("info", "Редактирование запущено", { + jobId, + sourceId: sourceVersion?.id || null, + prompt: truncate(prompt, 200) }); - } finally { + + void runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt); + } catch (error) { codexEditInFlight = false; + throw error; } + + response.json({ ok: true, jobId, status: "running" }); }) ); @@ -1498,51 +1692,38 @@ app.post( ); } - const uploaded = await uploadToComfy(request.file); - const built = buildUpscaleWorkflow( - uploaded, - runtime.selectedUpscaleModel, - scaledSize + const jobId = crypto.randomUUID(); + const upscaleStartedAt = Date.now(); + + jobs.set(jobId, { + type: "upscale", + status: "running", + startedAt: Date.now(), + finishedAt: null, + payload: null, + error: null, + details: null + }); + + log("info", "Апскейл запущен", { + jobId, + sourceId: sourceVersion?.id || null, + model: runtime.selectedUpscaleModel, + scaledSize: scaledSize || null + }); + + void runUpscaleJob( + jobId, + sourceVersion, + dimensions, + scaledSize, + runtime, + request.file.buffer, + request.file.mimetype, + upscaleStartedAt ); - 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 - }); + response.json({ ok: true, jobId, status: "running" }); }) ); @@ -1615,6 +1796,35 @@ app.get("/api/jobs/:id/output.jpg", asyncRoute(async (request, response) => { fs.createReadStream(filePath).pipe(response); })); +app.get("/api/jobs/:id/status", asyncRoute(async (request, response) => { + const { id } = request.params; + + if (!JOB_ID_PATTERN.test(id)) { + throw new AppError(400, "Некорректный идентификатор задания."); + } + + const job = jobs.get(id); + + if (!job) { + throw new AppError(404, "Задание не найдено."); + } + + const body = { ok: true, status: job.status, type: job.type }; + + if (job.status === "done") { + Object.assign(body, job.payload); + } + + if (job.status === "error") { + body.error = job.error; + if (job.details !== undefined) { + body.details = job.details; + } + } + + response.json(body); +})); + app.use("/api", (_request, response) => { response.status(404).json({ ok: false, @@ -1624,6 +1834,11 @@ app.use("/api", (_request, response) => { app.use((error, _request, response, next) => { if (response.headersSent) { + log("error", "Ошибка после начала ответа", { + method: _request.method, + path: _request.originalUrl, + message: error instanceof Error ? error.message : String(error) + }); next(error); return; } @@ -1653,8 +1868,25 @@ app.use((error, _request, response, next) => { !(error instanceof multer.MulterError) && !(error && error.type === "entity.parse.failed") ) { + log("error", "Непредвиденная ошибка сервера", { + method: _request.method, + path: _request.originalUrl, + status, + message + }); console.error(error); message = "Внутренняя ошибка сервера."; + } else if (error instanceof AppError) { + const fields = { status, message }; + if (error.details !== undefined) { + fields.details = truncate( + JSON.stringify(normalizeErrorDetails(error.details)), + 200 + ); + } + log(status >= 500 ? "error" : "warn", "Ошибка обработки запроса", fields); + } else { + log("warn", "Ошибка обработки запроса", { status, message }); } response.status(status).json({ @@ -1689,13 +1921,11 @@ async function startServer() { try { await listenOnPort(port); - console.log(`Фоторедактор запущен: http://localhost:${port}`); - console.log(`Доступ в локальной сети: http://0.0.0.0:${port}`); - console.log(`ComfyUI: ${COMFY_URL}`); + log("info", "Сервер запущен", { port, user: APP_USER, comfyUrl: COMFY_URL, passwordGenerated: !process.env.APP_PASSWORD }); return; } catch (error) { if (error && error.code === "EADDRINUSE") { - console.warn(`Порт ${port} занят, пробую следующий.`); + log("warn", "Порт занят, пробую следующий", { port }); continue; } @@ -1709,6 +1939,7 @@ async function startServer() { } startServer().catch((error) => { - console.error("Ошибка запуска:", error); + log("error", "Ошибка запуска", { message: error instanceof Error ? error.message : String(error) }); + console.error(error); process.exitCode = 1; });