Логирование в 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` — применяется к выбранной (в том числе
|
||||
отредактированной) версии.
|
||||
|
||||
Редактирование и апскейл выполняются в фоне: `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
|
||||
|
||||
149
public/app.js
149
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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<link rel="stylesheet" href="/style.css?v=9">
|
||||
<link rel="stylesheet" href="/style.css?v=10">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -261,6 +261,6 @@
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<script src="/app.js?v=9" defer></script>
|
||||
<script src="/app.js?v=10" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
417
server.js
417
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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user