feat(photo-ai): Stage 4 — face-режим, апскейл-модели и метаданные в аудите

Воркер и сервер принимают параметры ИИ-обработки фото: модель апскейла
(x2plus / general-x4v3 / animevideo-v3), режим лиц (off / face / all),
модель лиц (gfpgan / codeformer) и strength. Пустое тело запроса ведёт себя
как раньше: action='ai', params=NULL (инвариант I2).

worker.js:
- таймаут выбирается по params.face: PHOTO_AI_TIMEOUT_MS для апскейла,
  PHOTO_AI_FACE_TIMEOUT_MS (600000) для face-режима
- runAiEnhance шлёт model/face/face_model/strength и понимает оба
  контракта: JSON с image_base64 и сырой image/jpeg старого сервиса
- тело не-2xx ответа больше не выбрасывается: readErrorBody() добавляет
  причину к сообщению, иначе оператор видит «ИИ-сервис ответил 400» без
  объяснения
- applyResult пишет в аудит model/face/face_model/device/faces_found/
  elapsed_ms/warnings и выбирает текст уведомления по факту режима;
  warnings видны оператору, если лица не нашлись
- CONFIG: + face_timeout_ms, default_model, face_model

server.js:
- POST /api/entries/:id/photo/enhance-ai принимает и валидирует тело до
  запроса записи — невалидный вход даёт 400, а не 404/500
- PHOTO_JOB_ACTIONS вынесен на уровень модуля, + ai_face и ai_upscale
- GET /api/photo-ai/health (requireAdmin) — прямой прокси /health
- photoAiHealth(timeoutMs), в «Статусе стека» вызывается с 2000 мс
- getStackInfo(): блок photo_ai (engine, host, device, vram, модели)
- настройки photo_ai_face_mode / photo_ai_face_model / photo_ai_device_pref
  с валидацией в PUT /api/settings, дефолты в init.sql, migration.sql,
  public-settings и ensurePhotoJobsTable()

Приёмка (живой стек, CPU + отдельно CUDA) — в TODO_PHOTO_FACE_AI.md,
журнал раздела 4: I1 байт-в-байт 5/5 и совпадение sha256 с raw-путём,
I2, I3 при пустом PHOTO_AI_URL, I5 на обрыве и на 503 с Retry-After,
7 невалидных тел → 400, api.smoketest.js 57 PASS.
This commit is contained in:
dev
2026-09-29 15:08:46 +03:00
parent 4c63a46d24
commit 884188e9ec
5 changed files with 393 additions and 51 deletions
+160 -17
View File
@@ -6,7 +6,12 @@ const MIN_TEXT_CHARS = 4;
const crypto = require('crypto');
const { textDiff, FIELD_LABELS } = require('./diff');
const PHOTO_MAX_ATTEMPTS = 3;
const PHOTO_AI_TIMEOUT_MS = 300000;
const PHOTO_AI_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_TIMEOUT_MS || '300000', 10) || 300000);
const PHOTO_AI_FACE_TIMEOUT_MS = Math.max(PHOTO_AI_TIMEOUT_MS, parseInt(process.env.PHOTO_AI_FACE_TIMEOUT_MS || '600000', 10) || 600000);
const PHOTO_AI_DEFAULT_MODEL = 'x2plus';
const PHOTO_AI_DEFAULT_FACE_MODEL = String(process.env.PHOTO_AI_FACE_MODEL || 'gfpgan').trim() || 'gfpgan';
const PHOTO_AI_DEFAULT_STRENGTH = 0.7;
const PHOTO_AI_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale']);
const PHOTO_SOFT_MAX_RETRIES = Math.max(1, parseInt(process.env.PHOTO_AI_SOFT_MAX_RETRIES || '60', 10) || 60);
const PHOTO_SOFT_BACKOFF_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MS || '10000', 10) || 10000);
const PHOTO_SOFT_BACKOFF_MAX_MS = Math.max(PHOTO_SOFT_BACKOFF_MS, parseInt(process.env.PHOTO_AI_SOFT_BACKOFF_MAX_MS || '300000', 10) || 300000);
@@ -38,10 +43,14 @@ function softFailure(message, e) {
return err;
}
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent }) {
function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntries, sharp, photoAiUrl, storage, bus, notifyEvent, faceTimeoutMs, defaultModel, defaultFaceModel }) {
const AI_URL = photoAiUrl || process.env.PHOTO_AI_URL || '';
const IDLE_MIN = 2000;
const IDLE_MAX = 30000;
const AI_TIMEOUT_MS = PHOTO_AI_TIMEOUT_MS;
const AI_FACE_TIMEOUT_MS = Math.max(AI_TIMEOUT_MS, parseInt(faceTimeoutMs, 10) || PHOTO_AI_FACE_TIMEOUT_MS);
const DEFAULT_MODEL = String(defaultModel || PHOTO_AI_DEFAULT_MODEL).trim() || PHOTO_AI_DEFAULT_MODEL;
const DEFAULT_FACE_MODEL = String(defaultFaceModel || PHOTO_AI_DEFAULT_FACE_MODEL).trim() || PHOTO_AI_DEFAULT_FACE_MODEL;
async function sendNotification(entryId, payload) {
if (!notifyEvent) return;
@@ -64,12 +73,15 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
const CONFIG = {
idle_min_ms: IDLE_MIN,
idle_max_ms: IDLE_MAX,
ai_timeout_ms: PHOTO_AI_TIMEOUT_MS,
ai_timeout_ms: AI_TIMEOUT_MS,
face_timeout_ms: AI_FACE_TIMEOUT_MS,
max_attempts: PHOTO_MAX_ATTEMPTS,
soft_max_retries: PHOTO_SOFT_MAX_RETRIES,
soft_backoff_ms: PHOTO_SOFT_BACKOFF_MS,
soft_backoff_max_ms: PHOTO_SOFT_BACKOFF_MAX_MS,
ai_url: AI_URL,
default_model: DEFAULT_MODEL,
face_model: DEFAULT_FACE_MODEL,
};
function sleep(ms) {
@@ -150,48 +162,177 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
return `/uploads/${newName}`;
}
async function runAiEnhance(srcKey) {
function aiParams(job) {
const p = job && job.params && typeof job.params === 'object' ? job.params : {};
const face = String(p.face || (job && job.action === 'ai_face' ? 'face' : 'off')).trim().toLowerCase() || 'off';
const strength = Number(p.strength);
return {
model: String(p.model || DEFAULT_MODEL).trim().toLowerCase() || DEFAULT_MODEL,
face,
face_model: String(p.face_model || DEFAULT_FACE_MODEL).trim().toLowerCase() || DEFAULT_FACE_MODEL,
strength: Number.isFinite(strength) ? strength : PHOTO_AI_DEFAULT_STRENGTH,
timeout_ms: face === 'off' ? AI_TIMEOUT_MS : AI_FACE_TIMEOUT_MS,
};
}
function aiMeta(source) {
const o = source && typeof source === 'object' ? source : {};
const num = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
return {
model: o.model ? String(o.model) : null,
face: o.face ? String(o.face) : null,
face_model: o.face_model ? String(o.face_model) : null,
faces_found: num(o.faces_found),
device: o.device ? String(o.device) : null,
elapsed_ms: num(o.elapsed_ms),
warnings: Array.isArray(o.warnings) ? o.warnings.map((w) => String(w).slice(0, 300)).slice(0, 5) : [],
};
}
async function readErrorBody(resp) {
try {
const text = (await resp.text()).trim();
if (!text) return null;
let data = null;
try { data = JSON.parse(text); } catch (e) {}
if (data && typeof data === 'object' && !Array.isArray(data)) {
return String(data.error || data.detail || '').slice(0, 400) || null;
}
return text.slice(0, 400);
} catch (e) {
return null;
}
}
async function runAiEnhance(srcKey, job) {
if (!AI_URL) throw new Error('PHOTO_AI_URL не настроен');
const opt = aiParams(job);
const buf = await storage.getBuffer(srcKey);
if (!buf) throw new Error('Файл фото не найден');
const fd = new FormData();
fd.append('image', new Blob([buf], { type: 'image/jpeg' }), 'photo.jpg');
fd.append('scale', '2');
fd.append('model', opt.model);
fd.append('face', opt.face);
fd.append('face_model', opt.face_model);
fd.append('strength', String(opt.strength));
let resp;
try {
resp = await fetch(AI_URL.replace(/\/+$/, '') + '/enhance', {
method: 'POST',
headers: { Accept: 'application/json' },
body: fd,
signal: AbortSignal.timeout(PHOTO_AI_TIMEOUT_MS),
signal: AbortSignal.timeout(opt.timeout_ms),
});
} catch (e) {
if (isTimeoutFailure(e)) {
throw new Error(`ИИ-сервис не ответил за ${Math.round(PHOTO_AI_TIMEOUT_MS / 1000)} с`);
throw new Error(`ИИ-сервис не ответил за ${Math.round(opt.timeout_ms / 1000)} с`);
}
throw softFailure('ИИ-сервис недоступен', e);
}
if (!resp.ok) {
const retryAfter = resp.headers.get('retry-after');
const detail = `ИИ-сервис ответил ${resp.status}${retryAfter ? ` (Retry-After: ${retryAfter})` : ''}`;
if (isSoftStatus(resp.status)) throw softFailure(detail);
throw new Error(detail);
const reason = await readErrorBody(resp);
const message = reason ? `${detail}: ${reason}` : detail;
if (isSoftStatus(resp.status)) throw softFailure(message);
throw new Error(message);
}
const out = Buffer.from(await resp.arrayBuffer());
const contentType = resp.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const out = Buffer.from(await resp.arrayBuffer());
return {
out,
meta: aiMeta({
model: resp.headers.get('x-photo-ai-model') || opt.model,
face: resp.headers.get('x-photo-ai-face') || opt.face,
face_model: opt.face === 'off' ? null : opt.face_model,
device: resp.headers.get('x-photo-ai-device'),
elapsed_ms: resp.headers.get('x-photo-ai-elapsed-ms'),
}),
};
}
let data;
try {
data = await resp.json();
} catch (e) {
throw new Error('ИИ-сервис вернул нечитаемый JSON');
}
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('ИИ-сервис вернул неожиданный JSON');
if (data.ok === false) throw new Error(String(data.error || 'ИИ-сервис вернул ошибку').slice(0, 500));
if (typeof data.image_base64 !== 'string' || !data.image_base64) throw new Error('ИИ-сервис не вернул изображение');
let out;
try {
out = Buffer.from(data.image_base64, 'base64');
} catch (e) {
throw new Error('ИИ-сервис вернул изображение в нечитаемом base64');
}
if (!out.length) throw new Error('ИИ-сервис вернул пустое изображение');
return {
out,
meta: aiMeta({
model: data.model || opt.model,
face: data.face || opt.face,
face_model: data.face_model || (opt.face === 'off' ? null : opt.face_model),
faces_found: data.faces_found,
device: data.device,
elapsed_ms: data.elapsed_ms,
warnings: data.warnings,
}),
};
}
async function enhanceWithAi(srcKey, job) {
const { out, meta } = await runAiEnhance(srcKey, job);
const newName = crypto.randomBytes(12).toString('hex') + '.jpg';
await storage.put(newName, out);
return `/uploads/${newName}`;
return { path: `/uploads/${newName}`, meta };
}
async function applyResult(job, newPath) {
async function applyResult(job, newPath, meta) {
const m = meta || {};
await pool.query(
`UPDATE photo_jobs SET status = 'done', after_path = $1, error = NULL, finished_at = now() WHERE id = $2`,
[newPath, job.id]
);
if (logAudit) await logAudit(null, 'photo.job.preview', { entry_id: job.entry_id, job_id: job.id, action: job.action, after_path: newPath });
if (logAudit) {
await logAudit(null, 'photo.job.preview', {
entry_id: job.entry_id,
job_id: job.id,
action: job.action,
after_path: newPath,
model: m.model,
face: m.face,
face_model: m.face_model,
device: m.device,
faces_found: m.faces_found,
elapsed_ms: m.elapsed_ms,
warnings: m.warnings && m.warnings.length ? m.warnings : null,
});
}
const title = job.action === 'ai_face'
? 'Фото обработано нейросетью с восстановлением лиц: {student}'
: job.action === 'ai_upscale'
? 'Фото обработано нейросетью (апскейл): {student}'
: job.action === 'ai'
? 'Фото обработано нейросетью: {student}'
: 'Фото улучшено на сервере: {student}';
const warn = m.warnings && m.warnings.length ? ' · ' + m.warnings.join('; ').slice(0, 300) : '';
await sendNotification(job.entry_id, {
type: 'photo.job.done',
title: job.action === 'ai' ? 'Фото обработано нейросетью: {student}' : 'Фото улучшено на сервере: {student}',
body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения`,
target: { job_id: job.id, action: job.action, after_path: newPath },
title,
body: `Группа {group} · запись #${job.entry_id} · результат ждёт применения${warn}`,
target: {
job_id: job.id,
action: job.action,
after_path: newPath,
model: m.model,
face: m.face,
face_model: m.face_model,
device: m.device,
faces_found: m.faces_found,
elapsed_ms: m.elapsed_ms,
},
});
}
async function softRetry(job, message) {
@@ -253,8 +394,10 @@ function createPhotoEnhanceWorker({ pool, getSetting, logAudit, invalidateEntrie
}
const photoPath = rows[0].photo_path;
try {
const newPath = job.action === 'ai' ? await runAiEnhance(photoPath) : await enhanceWithSharp(photoPath, job.params);
await applyResult(job, newPath);
const result = PHOTO_AI_ACTIONS.has(job.action)
? await enhanceWithAi(photoPath, job)
: { path: await enhanceWithSharp(photoPath, job.params), meta: null };
await applyResult(job, result.path, result.meta);
softTries.delete(job.id);
stats.done++;
stats.last_at = new Date().toISOString();