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:
@@ -775,6 +775,7 @@ async function getStackInfo(cacheStats) {
|
||||
dbVersion = r.rows[0] ? String(r.rows[0].server_version) : null;
|
||||
} catch {}
|
||||
const remote = storage.isRemote();
|
||||
const photoAi = await photoAiHealth(2000);
|
||||
return {
|
||||
app: {
|
||||
name: app.name,
|
||||
@@ -822,6 +823,26 @@ async function getStackInfo(cacheStats) {
|
||||
local_fallback: process.env.STORAGE_LOCAL_FALLBACK === '1',
|
||||
keep_local: process.env.STORAGE_KEEP_LOCAL === '1',
|
||||
},
|
||||
photo_ai: {
|
||||
engine: 'Real-ESRGAN + GFPGAN',
|
||||
host: hostOf(PHOTO_AI_URL),
|
||||
configured: !!photoAi.configured,
|
||||
reachable: !!photoAi.reachable,
|
||||
latency_ms: photoAi.latency_ms || 0,
|
||||
error: photoAi.error || null,
|
||||
ready: photoAi.ready ?? null,
|
||||
device: photoAi.device || null,
|
||||
device_name: photoAi.device_name || null,
|
||||
half: photoAi.half ?? null,
|
||||
tile: photoAi.tile ?? null,
|
||||
driver: photoAi.driver || null,
|
||||
cuda: photoAi.cuda || null,
|
||||
vram_total_mb: photoAi.vram_total_mb ?? null,
|
||||
vram_free_mb: photoAi.vram_free_mb ?? null,
|
||||
models: Array.isArray(photoAi.models) ? photoAi.models : [],
|
||||
face_models: Array.isArray(photoAi.face_models) ? photoAi.face_models : [],
|
||||
loaded: Array.isArray(photoAi.loaded) ? photoAi.loaded : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1253,6 +1274,9 @@ async function ensurePhotoJobsTable() {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_pending ON photo_jobs(id) WHERE status = 'pending'`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_photo_jobs_entry_id ON photo_jobs(entry_id)`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_worker_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_face_mode', 'off') ON CONFLICT (key) DO NOTHING`);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_face_model', $1) ON CONFLICT (key) DO NOTHING`, [PHOTO_AI_FACE_MODEL]);
|
||||
await pool.query(`INSERT INTO settings (key, value) VALUES ('photo_ai_device_pref', 'auto') ON CONFLICT (key) DO NOTHING`);
|
||||
}
|
||||
|
||||
async function ensureUserTables() {
|
||||
@@ -1660,8 +1684,8 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
|
||||
|
||||
app.get('/api/public-settings', apiLimiter, async (_, res) => {
|
||||
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
|
||||
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled'];
|
||||
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true' };
|
||||
const keys = ['system_name', 'system_logo', 'footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality', 'photo_enhance_engine', 'camera_enabled', 'photo_ai_face_mode', 'photo_ai_face_model', 'photo_ai_device_pref'];
|
||||
const defaults = { system_name: 'WhatIDo', system_logo: '', spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92', photo_enhance_engine: 'auto', camera_enabled: 'true', photo_ai_face_mode: PHOTO_AI_DEFAULT_FACE_MODE, photo_ai_face_model: PHOTO_AI_FACE_MODEL, photo_ai_device_pref: 'auto' };
|
||||
const result = {};
|
||||
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
|
||||
const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
|
||||
@@ -1710,6 +1734,15 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
|
||||
if (key === 'photo_enhance_engine' && !['auto', 'server', 'client'].includes(String(value))) {
|
||||
return res.status(400).json({ error: 'photo_enhance_engine должен быть auto, server или client' });
|
||||
}
|
||||
if (key === 'photo_ai_face_mode' && !PHOTO_AI_FACE_MODES.includes(String(value))) {
|
||||
return res.status(400).json({ error: `photo_ai_face_mode должен быть одним из: ${PHOTO_AI_FACE_MODES.join(', ')}` });
|
||||
}
|
||||
if (key === 'photo_ai_face_model' && !PHOTO_AI_FACE_MODELS.includes(String(value))) {
|
||||
return res.status(400).json({ error: `photo_ai_face_model должен быть одним из: ${PHOTO_AI_FACE_MODELS.join(', ')}` });
|
||||
}
|
||||
if (key === 'photo_ai_device_pref' && !PHOTO_AI_DEVICE_PREFS.includes(String(value))) {
|
||||
return res.status(400).json({ error: `photo_ai_device_pref должен быть одним из: ${PHOTO_AI_DEVICE_PREFS.join(', ')}` });
|
||||
}
|
||||
if (key === 'system_name' && String(value).trim().length > 60) {
|
||||
return res.status(400).json({ error: 'system_name не может быть длиннее 60 символов' });
|
||||
}
|
||||
@@ -1839,6 +1872,16 @@ const tar = require('tar');
|
||||
const os = require('os');
|
||||
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
||||
const PHOTO_AI_URL = process.env.PHOTO_AI_URL || '';
|
||||
const PHOTO_AI_FACE_MODEL = process.env.PHOTO_AI_FACE_MODEL || 'gfpgan';
|
||||
const PHOTO_AI_FACE_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PHOTO_AI_FACE_TIMEOUT_MS || '600000', 10) || 600000);
|
||||
const PHOTO_AI_DEFAULT_MODEL = 'x2plus';
|
||||
const PHOTO_AI_DEFAULT_FACE_MODE = process.env.PHOTO_AI_FACE_MODE || 'off';
|
||||
const PHOTO_AI_MODELS = ['x2plus', 'general-x4v3', 'animevideo-v3'];
|
||||
const PHOTO_AI_FACE_MODELS = ['gfpgan', 'codeformer'];
|
||||
const PHOTO_AI_FACE_MODES = ['off', 'face', 'all'];
|
||||
const PHOTO_AI_DEVICE_PREFS = ['auto', 'cuda', 'cpu'];
|
||||
const PHOTO_AI_DEFAULT_STRENGTH = 0.7;
|
||||
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
|
||||
const AI_MODEL = process.env.AI_MODEL || 'qwen2.5-1.5b-instruct-q4_k_m.gguf';
|
||||
const AI_DEFAULT_PROMPT = process.env.AI_PROMPT || 'Ты — редактор текстов. Исправь ТОЛЬКО грамматические, орфографические и пунктуационные ошибки в тексте. Приведи к правильному регистру буквы. НЕ меняй слова, структуру предложений, стиль или смысл текста. Верни ТОЛЬКО исправленный текст без пояснений.';
|
||||
let entryAutoChecker = null;
|
||||
@@ -2251,7 +2294,6 @@ function normalizeRestoreData(data) {
|
||||
for (const [k, v] of Object.entries(data.settings || {})) {
|
||||
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
|
||||
}
|
||||
const PHOTO_JOB_ACTIONS = new Set(['ai', 'enhance', 'restore', 'rollback']);
|
||||
const PHOTO_JOB_STATUSES = new Set(['pending', 'processing', 'done', 'error', 'rejected']);
|
||||
const photo_jobs = (data.photo_jobs || []).map(x => ({
|
||||
id: reqInt(x.id),
|
||||
@@ -5111,6 +5153,41 @@ app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
function parsePhotoAiRequest(body) {
|
||||
const src = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
||||
const pick = (v, def) => {
|
||||
if (v === undefined || v === null || v === '') return def;
|
||||
return String(v).trim().toLowerCase();
|
||||
};
|
||||
const model = pick(src.model, PHOTO_AI_DEFAULT_MODEL);
|
||||
if (!PHOTO_AI_MODELS.includes(model)) {
|
||||
return { error: `model должен быть одним из: ${PHOTO_AI_MODELS.join(', ')}` };
|
||||
}
|
||||
const face = pick(src.face, 'off');
|
||||
if (!PHOTO_AI_FACE_MODES.includes(face)) {
|
||||
return { error: `face должен быть одним из: ${PHOTO_AI_FACE_MODES.join(', ')}` };
|
||||
}
|
||||
const faceModel = pick(src.face_model, PHOTO_AI_FACE_MODEL);
|
||||
if (face !== 'off' && !PHOTO_AI_FACE_MODELS.includes(faceModel)) {
|
||||
return { error: `face_model должен быть одним из: ${PHOTO_AI_FACE_MODELS.join(', ')}` };
|
||||
}
|
||||
let strength = PHOTO_AI_DEFAULT_STRENGTH;
|
||||
if (src.strength !== undefined && src.strength !== null && src.strength !== '') {
|
||||
const n = Number(src.strength);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
||||
return { error: 'strength должен быть числом от 0 до 1' };
|
||||
}
|
||||
strength = n;
|
||||
}
|
||||
if (face !== 'off' && faceModel !== 'codeformer' && Math.abs(strength - PHOTO_AI_DEFAULT_STRENGTH) > 1e-9) {
|
||||
return { error: `strength применяется только к codeformer, для ${faceModel} оставьте ${PHOTO_AI_DEFAULT_STRENGTH}` };
|
||||
}
|
||||
return {
|
||||
action: face === 'off' ? 'ai' : 'ai_face',
|
||||
params: { model, face, face_model: face === 'off' ? null : faceModel, strength },
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
|
||||
if (!PHOTO_AI_URL) return res.status(503).json({ error: 'ИИ-обработка фото не настроена' });
|
||||
if (req.user.role !== 'admin') {
|
||||
@@ -5118,17 +5195,26 @@ app.post('/api/entries/:id/photo/enhance-ai', requireAuth, async (req, res) => {
|
||||
if (!acc.found) return res.status(404).json({ error: 'Not found' });
|
||||
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
|
||||
}
|
||||
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
||||
const hasParams = Object.keys(body).some((k) => body[k] !== undefined && body[k] !== null && body[k] !== '');
|
||||
const parsed = hasParams ? parsePhotoAiRequest(body) : { action: 'ai', params: null };
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
|
||||
if (!cur.length) return res.status(404).json({ error: 'Запись не найдена' });
|
||||
if (!cur[0].photo_path) return res.status(400).json({ error: 'У записи нет фото' });
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, 'ai', 'pending', NULL) RETURNING id`,
|
||||
[req.params.id]
|
||||
`INSERT INTO photo_jobs (entry_id, action, status, params) VALUES ($1, $2, 'pending', $3) RETURNING id`,
|
||||
[req.params.id, parsed.action, parsed.params ? JSON.stringify(parsed.params) : null]
|
||||
);
|
||||
if (photoWorker) photoWorker.notify();
|
||||
await logAudit(req, 'entry.photo.enhance-ai.queue', { entry_id: req.params.id, job_id: rows[0].id });
|
||||
res.json({ jobId: rows[0].id });
|
||||
await logAudit(req, 'entry.photo.enhance-ai.queue', {
|
||||
entry_id: req.params.id,
|
||||
job_id: rows[0].id,
|
||||
action: parsed.action,
|
||||
params: parsed.params,
|
||||
});
|
||||
res.json({ jobId: rows[0].id, action: parsed.action, params: parsed.params });
|
||||
} catch (e) {
|
||||
console.error('POST /api/entries/:id/photo/enhance-ai:', e);
|
||||
res.status(500).json({ error: 'Ошибка постановки задания в очередь' });
|
||||
@@ -5655,11 +5741,11 @@ async function aiHealthCheck() {
|
||||
}
|
||||
}
|
||||
|
||||
async function photoAiHealth() {
|
||||
async function photoAiHealth(timeoutMs = 5000) {
|
||||
if (!PHOTO_AI_URL) return { configured: false, reachable: false, latency_ms: 0, error: 'PHOTO_AI_URL не настроен' };
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const r = await fetch(`${PHOTO_AI_URL}/health`, { signal: controller.signal });
|
||||
const latency_ms = Date.now() - startedAt;
|
||||
@@ -5677,6 +5763,11 @@ async function photoAiHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/photo-ai/health', requireAdmin, async (_, res) => {
|
||||
const health = await photoAiHealth(5000);
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
app.get('/api/ai/status', requireAdmin, async (_, res) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ai_status, count(*)::int AS n FROM entries WHERE deleted_at IS NULL GROUP BY ai_status`
|
||||
@@ -6075,6 +6166,9 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
||||
storage,
|
||||
notifyEvent: notifyEntry,
|
||||
bus: createWorkerBus(PHOTO_WAKE_CHANNEL),
|
||||
faceTimeoutMs: PHOTO_AI_FACE_TIMEOUT_MS,
|
||||
defaultModel: PHOTO_AI_DEFAULT_MODEL,
|
||||
defaultFaceModel: PHOTO_AI_FACE_MODEL,
|
||||
});
|
||||
photoWorker.start();
|
||||
console.log('Photo enhance worker started');
|
||||
|
||||
Reference in New Issue
Block a user