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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user