Логирование в stdout (видно в Portainer) + асинхронные задания edit/upscale с опросом статуса (фикс 504/499 при длинных запросах)
This commit is contained in:
45
README.md
45
README.md
@@ -22,6 +22,12 @@ Codex CLI) и увеличивает разрешение через домаш
|
|||||||
`RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе
|
`RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе
|
||||||
отредактированной) версии.
|
отредактированной) версии.
|
||||||
|
|
||||||
|
Редактирование и апскейл выполняются в фоне: `POST /api/edit` и
|
||||||
|
`POST /api/upscale` отвечают мгновенно с идентификатором задания (`jobId`),
|
||||||
|
а тяжёлая работа продолжается на сервере. Фронтенд опрашивает
|
||||||
|
`GET /api/jobs/:id/status` каждые 3 секунды; страницу можно закрыть —
|
||||||
|
готовая версия появится в истории.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Node.js 20 или новее
|
- Node.js 20 или новее
|
||||||
@@ -73,6 +79,45 @@ npm start
|
|||||||
`GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login`
|
`GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login`
|
||||||
с JSON-телом `{"username": "...", "password": "..."}`.
|
с 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
|
## Docker
|
||||||
|
|
||||||
Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI
|
Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI
|
||||||
|
|||||||
149
public/app.js
149
public/app.js
@@ -342,8 +342,8 @@ async function runJob(kind) {
|
|||||||
setJobStatus(
|
setJobStatus(
|
||||||
"loading",
|
"loading",
|
||||||
isEdit
|
isEdit
|
||||||
? "Codex редактирует фотографию… Обычно 1–3 минуты."
|
? "Codex редактирует фотографию… Обычно 1–3 минуты. Страницу можно закрыть — результат появится в истории."
|
||||||
: "Увеличиваем разрешение фотографии…"
|
: "Увеличиваем разрешение фотографии… Страницу можно закрыть — результат появится в истории."
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -359,57 +359,118 @@ async function runJob(kind) {
|
|||||||
throw new Error(data.error || "Не удалось обработать фотографию.");
|
throw new Error(data.error || "Не удалось обработать фотографию.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = data.version;
|
if (!data.jobId) {
|
||||||
|
throw new Error("Сервер не вернул идентификатор задания.");
|
||||||
if (version) {
|
|
||||||
historyVersions.push(version);
|
|
||||||
currentVersionId = version.id;
|
|
||||||
updateDownloadCurrent(version);
|
|
||||||
renderHistory();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = version?.url || data.result?.url;
|
pollJob(data.jobId, isEdit);
|
||||||
|
|
||||||
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"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setBusy(false);
|
||||||
setJobStatus(
|
setJobStatus(
|
||||||
"error",
|
"error",
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? 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"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
rel="icon"
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<link rel="stylesheet" href="/style.css?v=9">
|
<link rel="stylesheet" href="/style.css?v=10">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
@@ -261,6 +261,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<script src="/app.js?v=9" defer></script>
|
<script src="/app.js?v=10" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
417
server.js
417
server.js
@@ -46,6 +46,38 @@ const REQUIRED_NODES = [
|
|||||||
"ImageUpscaleWithModel"
|
"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();
|
const app = express();
|
||||||
|
|
||||||
app.disable("x-powered-by");
|
app.disable("x-powered-by");
|
||||||
@@ -61,6 +93,19 @@ let codexEditInFlight = false;
|
|||||||
// JSON-тело нужно для POST /api/login; multipart обрабатывает multer.
|
// JSON-тело нужно для POST /api/login; multipart обрабатывает multer.
|
||||||
app.use(express.json({ limit: "16kb" }));
|
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 {
|
class AppError extends Error {
|
||||||
constructor(status, message, details = undefined) {
|
constructor(status, message, details = undefined) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -95,6 +140,18 @@ if (!process.env.APP_PASSWORD) {
|
|||||||
// token -> { user, expiresAt }
|
// token -> { user, expiresAt }
|
||||||
const sessions = new Map();
|
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) {
|
function constantTimeEqual(a, b) {
|
||||||
const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest();
|
const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest();
|
||||||
const digestB = crypto.createHash("sha256").update(String(b), "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) {
|
function contentTypeForExt(ext) {
|
||||||
const map = {
|
const map = {
|
||||||
jpg: "image/jpeg",
|
jpg: "image/jpeg",
|
||||||
@@ -502,7 +707,8 @@ async function appendHistoryVersion(version) {
|
|||||||
saveHistory(trimmed);
|
saveHistory(trimmed);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Ошибка записи истории:", error);
|
log("error", "Ошибка записи истории", { message: error instanceof Error ? error.message : String(error) });
|
||||||
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
await historyWriteChain;
|
await historyWriteChain;
|
||||||
@@ -1259,6 +1465,7 @@ app.post("/api/login", asyncRoute(async (request, response) => {
|
|||||||
|
|
||||||
if (!constantTimeEqual(APP_USER, username) ||
|
if (!constantTimeEqual(APP_USER, username) ||
|
||||||
!constantTimeEqual(APP_PASSWORD, password)) {
|
!constantTimeEqual(APP_PASSWORD, password)) {
|
||||||
|
log("warn", "Неудачный вход", { login: username });
|
||||||
throw new AppError(401, "Неверный логин или пароль.");
|
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 });
|
sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS });
|
||||||
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
|
response.cookie(SESSION_COOKIE, token, sessionCookieOptions());
|
||||||
response.json({ ok: true, user: APP_USER });
|
response.json({ ok: true, user: APP_USER });
|
||||||
|
log("info", "Вход выполнен", { user: APP_USER });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/api/me", (request, response, next) => {
|
app.get("/api/me", (request, response, next) => {
|
||||||
@@ -1284,6 +1492,7 @@ app.post("/api/logout", (request, response) => {
|
|||||||
if (token) sessions.delete(token);
|
if (token) sessions.delete(token);
|
||||||
response.clearCookie(SESSION_COOKIE, { path: "/" });
|
response.clearCookie(SESSION_COOKIE, { path: "/" });
|
||||||
response.json({ ok: true });
|
response.json({ ok: true });
|
||||||
|
log("info", "Выход из системы");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Все эндпоинты ниже — только для авторизованных пользователей.
|
// Все эндпоинты ниже — только для авторизованных пользователей.
|
||||||
@@ -1349,6 +1558,14 @@ app.post(
|
|||||||
|
|
||||||
await appendHistoryVersion(version);
|
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 });
|
response.json({ ok: true, version });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -1410,63 +1627,40 @@ app.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
codexEditInFlight = true;
|
codexEditInFlight = true;
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
|
||||||
|
const jobStartedAt = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jobId = crypto.randomUUID();
|
|
||||||
const jobDir = path.join(CODEX_JOBS_DIR, jobId);
|
|
||||||
|
|
||||||
fs.mkdirSync(jobDir, { recursive: true });
|
fs.mkdirSync(jobDir, { recursive: true });
|
||||||
|
|
||||||
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
|
const inputName = `input.${extForMimetype(request.file.mimetype)}`;
|
||||||
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
|
fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer);
|
||||||
|
|
||||||
const promptText = buildCodexPrompt(prompt, dimensions, inputName);
|
const promptText = buildCodexPrompt(prompt, dimensions, inputName);
|
||||||
const result = await runCodexEdit(jobDir, inputName, promptText);
|
|
||||||
|
|
||||||
if (!result.outputPath) {
|
jobs.set(jobId, {
|
||||||
const details = result.lastMessage
|
type: "edit",
|
||||||
? `Ответ Codex: ${result.lastMessage.slice(0, 500)}`
|
status: "running",
|
||||||
: undefined;
|
startedAt: Date.now(),
|
||||||
|
finishedAt: null,
|
||||||
throw new AppError(
|
payload: null,
|
||||||
504,
|
error: null,
|
||||||
"Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт.",
|
details: null
|
||||||
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);
|
log("info", "Редактирование запущено", {
|
||||||
|
jobId,
|
||||||
response.json({
|
sourceId: sourceVersion?.id || null,
|
||||||
ok: true,
|
prompt: truncate(prompt, 200)
|
||||||
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 {
|
|
||||||
|
void runEditJob(jobId, jobDir, inputName, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt);
|
||||||
|
} catch (error) {
|
||||||
codexEditInFlight = false;
|
codexEditInFlight = false;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response.json({ ok: true, jobId, status: "running" });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1498,51 +1692,38 @@ app.post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploaded = await uploadToComfy(request.file);
|
const jobId = crypto.randomUUID();
|
||||||
const built = buildUpscaleWorkflow(
|
const upscaleStartedAt = Date.now();
|
||||||
uploaded,
|
|
||||||
runtime.selectedUpscaleModel,
|
jobs.set(jobId, {
|
||||||
scaledSize
|
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);
|
response.json({ ok: true, jobId, status: "running" });
|
||||||
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
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1615,6 +1796,35 @@ app.get("/api/jobs/:id/output.jpg", asyncRoute(async (request, response) => {
|
|||||||
fs.createReadStream(filePath).pipe(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) => {
|
app.use("/api", (_request, response) => {
|
||||||
response.status(404).json({
|
response.status(404).json({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1624,6 +1834,11 @@ app.use("/api", (_request, response) => {
|
|||||||
|
|
||||||
app.use((error, _request, response, next) => {
|
app.use((error, _request, response, next) => {
|
||||||
if (response.headersSent) {
|
if (response.headersSent) {
|
||||||
|
log("error", "Ошибка после начала ответа", {
|
||||||
|
method: _request.method,
|
||||||
|
path: _request.originalUrl,
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
next(error);
|
next(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1653,8 +1868,25 @@ app.use((error, _request, response, next) => {
|
|||||||
!(error instanceof multer.MulterError) &&
|
!(error instanceof multer.MulterError) &&
|
||||||
!(error && error.type === "entity.parse.failed")
|
!(error && error.type === "entity.parse.failed")
|
||||||
) {
|
) {
|
||||||
|
log("error", "Непредвиденная ошибка сервера", {
|
||||||
|
method: _request.method,
|
||||||
|
path: _request.originalUrl,
|
||||||
|
status,
|
||||||
|
message
|
||||||
|
});
|
||||||
console.error(error);
|
console.error(error);
|
||||||
message = "Внутренняя ошибка сервера.";
|
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({
|
response.status(status).json({
|
||||||
@@ -1689,13 +1921,11 @@ async function startServer() {
|
|||||||
try {
|
try {
|
||||||
await listenOnPort(port);
|
await listenOnPort(port);
|
||||||
|
|
||||||
console.log(`Фоторедактор запущен: http://localhost:${port}`);
|
log("info", "Сервер запущен", { port, user: APP_USER, comfyUrl: COMFY_URL, passwordGenerated: !process.env.APP_PASSWORD });
|
||||||
console.log(`Доступ в локальной сети: http://0.0.0.0:${port}`);
|
|
||||||
console.log(`ComfyUI: ${COMFY_URL}`);
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.code === "EADDRINUSE") {
|
if (error && error.code === "EADDRINUSE") {
|
||||||
console.warn(`Порт ${port} занят, пробую следующий.`);
|
log("warn", "Порт занят, пробую следующий", { port });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1709,6 +1939,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startServer().catch((error) => {
|
startServer().catch((error) => {
|
||||||
console.error("Ошибка запуска:", error);
|
log("error", "Ошибка запуска", { message: error instanceof Error ? error.message : String(error) });
|
||||||
|
console.error(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user