Логирование в stdout (видно в Portainer) + асинхронные задания edit/upscale с опросом статуса (фикс 504/499 при длинных запросах)
This commit is contained in:
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