Files
photo-editor/docs/architecture/target-design.md
Coder 401969cfdb
Some checks failed
test / test (push) Failing after 24s
v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
2026-08-27 12:52:27 +07:00

677 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Системный дизайн: «Кадр» v2 — целевая архитектура
**Вердикт:** READY
**Артефакт:** `docs/architecture/target-design.md`
**Статус:** 1.0, готов к планированию реализации
**Язык:** русский; идентификаторы кода — английские.
Этот документ — полный проект перепроектирования «Кадр» (локальный AI-фоторедактор). Он написан так, чтобы **человек** мог понять решение, а **ИИ-агент** — реализовать его без дополнительных уточнений: здесь зафиксированы границы, контракты, структура модулей, данные, поведение при сбоях, решения с альтернативами, порядок реализации и критерии приёмки каждой фазы.
Сопутствующие артефакты: `docs/architecture/system-design-baseline.md` (драйверы и ограничения, реестр D1D17), `README.md` (текущая документация).
---
## 1. Контекст и цель перепроектирования
«Кадр» — веб-приложение одного владельца в LAN: редактирование фотографий текстовым промптом (Codex CLI) и 4× апскейл (ComfyUI), с деревом версий, референсом и областью, подсказками промптов. Развёрнуто одним контейнером Docker на Portainer.
Текущая реализация — рабочий прототип: один файл `server.js` на ~2290 строк и `public/app.js` на ~1523 строки. Она выполняет все функции, но имеет структурные дефекты (см. §2), которые делают её хрупкой, трудной для доработки и для безопасного изменения ИИ-агентом.
**Цель перепроектирования:** превратить прототип в **модульный монолит** с чёткими границами, устойчивый к перезапуску, безопасный, тестируемый, с теми же внешними зависимостями (Codex CLI, ComfyUI) и той же моделью развёртывания. Никаких новых сервисов, микросервисов и внешних хранилищ.
**Не-цели:** многопользовательность, интернет-доступ, смена стека фронтенда на сборку (React/Vue + bundler), SQLite, генеративные функции «с нуля». Обоснование — в §8 «Решения».
---
## 2. Текущее состояние: что ломается и что сохраняем
Оценка основана на коде (`server.js`, `public/app.js`, `history/history.json`, `Dockerfile`, `deploy/docker-compose.yml`, `.gitea/workflows/*.yml`).
### 2.1 Дефекты, которые устраняет v2
| # | Проблема | Где | Последствие | Решение в v2 |
|---|---|---|---|---|
| P1 | Монолит в одном файле: auth, история, задания, Codex, ComfyUI, парсеры изображений, роуты | `server.js` (2288 стр.) | Тяжело менять, тестировать, навигировать | Модульная структура `src/` (§4) |
| P2 | Задания в памяти: рестарт убивает `running` и теряет `queued`; `.codex-jobs` стирается при старте | `server.js:141-153, 2259` | Пропадает редактирование на 13 мин при рестарте | Персистентный job-store + журнал (§6.2), восстановление при старте |
| P3 | Глобальный single-flight `codexEditInFlight` → 429, а не очередь; suggest делит флаг с edit | `server.js:91, 1880, 2031` | Нельзя поставить в очередь две правки; UX «дождитесь завершения» | Очередь с персистентностью и кониурентностью по группам (§6.1) |
| P4 | Сессии в памяти; пароль сравнивается с открытым env-значением; нет rate-limit на вход | `server.js:141, 155-159, 1641` | Разлогин при рестарте; открытый пароль в сравнении; перебор пароля | scrypt-хеш в памяти + persistent session store + rate-limit (§7) |
| P5 | Валидация аплоада только по MIME/расширению | `server.js:244-267` | Поддельный MIME проходит | Проверка сигнатуры (magic bytes) (§7.3) |
| P6 | `history.json` переписывается целиком; запись без tmp+rename (частичная запись при крахе) | `server.js:830-868` | Риск порчи манифеста при обрыве | Атомарная запись (tmp+rename+fsync) в history-store (§5.1) |
| P7 | URL версии жёстко `/image.jpg` независимо от реального расширения | `server.js:895` | Ложный URL для PNG | URL формируется из фактического `ext` (§5.1) |
| P8 | Фронтенд: один файл, глобальные переменные, опрос каждые 3 с | `public/app.js` (1523 стр.) | Трудно поддерживать; задержка обновлений до 3 с | ES-модули (§9) + SSE с фолбэком на опрос (§6.4) |
| P9 | Нет тестов, нет CI-проверок; `package.json` только `start` | `package.json` | Регрессии незаметны | `node:test` + supertest + Gitea workflow `test` (§10) |
| P10 | Магические константы и env разбросаны по файлу | `server.js:15-47` | Ошибки конфигурации | Единый `config.js` с валидацией (§4.3) |
| P11 | Нет requestId/корреляции в логах; нет метрик | `server.js:50-79` | Трудно разбирать инциденты | requestId + структурированные логи + счётчики (§11) |
| P12 | Нет предварительного даунскейла для Codex (замер: 1200×800 медленнее 512×512) | `README.md` разд. «Тест скорости» | Дорогие и медленные правки больших фото | Даунскейл `sharp` до `EDIT_MAX_DIMENSION` (§6.5) |
### 2.2 Что сохраняем без изменений (проверено, работает)
- Внешние зависимости: Codex CLI (`codex exec`, флаги `-i/-o/--ephemeral/--skip-git-repo-check/--dangerously-bypass-approvals-and-sandbox`), ComfyUI REST (`/object_info`, `/upload/image`, `/prompt`, `/history/{id}`, `/view`), модели `sdxl_turbo.safetensors`, `RealESRGAN_x4plus.safetensors`.
- Модель развёртывания: Docker (`node:22-bookworm-slim`), Portainer stack, Gitea Actions build/deploy, прокси `:4067` для egress к OpenAI, тома.
- HTTP-контракт публичных эндпоинтов `/api/health`, `/api/login`, `/api/me`, `/api/logout` и семантика основных бизнес-эндпоинтов (см. §7.1 — изменения только аддитивные).
- Формат версии истории (поля `id, kind, label, prompt, parentId, referenceId, createdAt, url, width, height, ext`) — обратная совместимость с существующим `history/history.json` и файлами.
- UI/UX: русский интерфейс, дерево истории, референс с областью, подсказки, «до/после».
- Парсинг размеров PNG/JPEG/WebP из заголовков (без библиотек) — переносится в модуль `images/dimensions.js` как есть.
---
## 3. Оценка нагрузки (до выбора компонентов)
Расчёт для одного владельца в LAN. Никакие компоненты не выбираются «на вырост»; механизмы масштабирования перечислены как отложенные (§8, триггеры).
| Величина | Значение | Обоснование |
|---|---|---|
| Пользователей | 1 | D1 |
| Правок в сутки | 020 (пик) | Оценка по `history.json` (12 версий за 2 дня активного тестирования) |
| Апскейлов в сутки | 020 | Аналогично |
| Параллельность | 1 edit/suggest + ≤ 2 upscale | Текущее узкое место — Codex (один поток); ComfyUI справляется с 12 воркфлоу |
| Размер загрузки | ≤ 25 МБ, типично 16 МБ | `server.js:24`, фактические файлы |
| История | ≤ 50 версий, манифест ~10 КБ | `server.js:34` |
| Частота опроса статуса | 3 с (фронт) → 0 с при SSE | `README.md` |
| Очередь | ≤ 5 заданий в очереди | Лимит для защиты от «накликивания»; дешевле, чем бесконечная очередь |
**Формула для оценки времени:** `t_edit ≈ 6090 с × (maxDim/512)^1.3` (эмпирика из README: 512² ≈ 6082 с, 1200×800 заметно дольше). Даунскейл до 1024 px сокращает среднее время до ~11.5 мин на типичных фото 4000×3000.
**Первый вероятный бутылочный горлышек:** одиночный поток Codex (один `codex exec` на машине). Отложенный механизм: кониурентность > 1 для Codex при появлении второго ключа/аккаунта — не требуется сейчас.
---
## 4. Целевая структура: модульный монолит
### 4.1 Дерево сервера
```
server.js # Тонкий bootstrap-загрузчик: `require("./src/server")` (совместимость с Docker CMD и `npm start`)
src/
server.js # Реальная точка входа: loadConfig → createApp → listen (см. §13, Фаза 0)
config.js # Единый источник конфигурации (env → объект, валидация)
logger.js # Структурированный лог (JSON-lines), уровни, requestId
errors.js # Иерархия ошибок: AppError(status, message, details)
http/
app.js # createApp(deps) -> express app (DI для тестов)
middleware/
request-context.js # requestId, тайминги, лог запросов
auth.js # requireAuth (cookie-сессия, sliding TTL)
origin-check.js # CSRF-защита: Origin против Host на state-changing запросах
rate-limit.js # Окно попыток входа на IP
error-handler.js # Единый формат ошибок { ok:false, error, details, requestId }
routes/
auth.routes.js # POST /api/login, GET /api/me, POST /api/logout
health.routes.js # GET /api/health
history.routes.js # GET /api/history, POST /api/history/import, DELETE /api/history/:id, GET /api/history/:id/image.{ext}
preview.routes.js # POST /api/preview
edit.routes.js # POST /api/edit
upscale.routes.js # POST /api/upscale
suggest.routes.js # GET /api/suggest-prompts, POST /api/suggest
jobs.routes.js # GET /api/jobs, GET /api/jobs/:id/status, GET /api/jobs/:id/output.jpg
events.routes.js # GET /api/events (SSE)
result.routes.js # GET /api/result (прокси к ComfyUI /view)
core/
auth/
password.js # scrypt hash/verify (timing-safe)
session-store.js # Персистентный store сессий (JSON-файл, атомарная запись, TTL-sweep)
history/
history-store.js # Манифест + файлы изображений; атомарные операции
history-service.js # Бизнес-операции: импорт, каскадное удаление, прунинг, чтение файла
jobs/
job-store.js # Персистентный журнал заданий (JSONL) + индекс в памяти
queue.js # FIFO-очередь с кониурентностью по группам, восстановлением
job-service.js # Создание задания, статусы, эмиссия событий
engines/
engine.js # Контракт движка + реестр (тип -> фабрика)
codex-engine.js # Редактирование и подсказки через Codex CLI
comfy-upscale-engine.js # Апскейл через ComfyUI
comfy/
comfy-client.js # REST-клиент: upload, submit, poll history, download view
comfy-runtime.js # Кэш /object_info (30 с), выбор моделей, requireNodes
workflows.js # buildUpscaleWorkflow (перенос), будущие воркфлоу
codex/
cli.js # Обёртка spawn `codex exec`: запуск, ожидание output.jpg/last_message.txt, таймаут
prompts.js # buildEditPrompt, buildSuggestPrompt, parseSuggestions, scaleRegion
images/
dimensions.js # getImageDimensions (PNG/JPEG/WebP) — перенос без изменений
heic.js # convertHeicToJpeg (перенос)
resize.js # Даунскейл через sharp (только для edit-пути)
data/ # Runtime-данные (gitignored, Docker volume /app/data)
history.json # Манифест версий
sessions.json # Сессии (если SESSION_PERSIST=true)
jobs.jsonl # Журнал заданий
test/ # node:test + supertest
config.test.js logger.test.js images.test.js
history-store.test.js job-queue.test.js prompts.test.js
api.test.js
```
### 4.2 Правила зависимостей (обязательны для реализации)
1. **Слои:** `routes → core/* → (engines | comfy | codex | images)`. Роуты не содержат бизнес-логики — только парсинг запроса и вызов сервиса.
2. `core` не импортирует `express` (кроме `http/app.js` и middleware). Движки и сервисы получают зависимости через конструктор/DI.
3. Никто не читает `process.env` напрямую, кроме `config.js`. Все константы — из `config`.
4. `engines/*` не знают про HTTP. Возвращают `EngineResult` (см. §6.3).
5. `queue.js` не знает про движки — получает фабрику по типу задания из реестра.
6. Файловая система — только через выделенные модули-хранилища (`history-store`, `session-store`, `job-store`, `codex/cli`); случайные `fs.*` в роутах запрещены.
### 4.3 Конфигурация (`src/config.js`)
Функция `loadConfig(env = process.env)` → замороженный объект; бросает `ConfigError` со списком проблем при старте. Все значения — с дефолтами (совместимы с текущими):
| Ключ env | Тип | Дефолт | Комментарий |
|---|---|---|---|
| `PORT` | number | 3000 | Единый порт (убрать каскад 8080/8090? — оставить как `PORT_CANDIDATES` массив `[3000, 8080, 8090]` для совместимости, управляется `PORT` при задании) |
| `COMFY_URL` | url | `http://192.168.31.240:8188` | Валидируется как URL |
| `APP_USER` | string | `admin` | |
| `APP_PASSWORD` | string | генерируется при старте (баннер в лог, как сейчас) | Никогда не логируется |
| `LOG_LEVEL` | debug/info/warn/error | info | |
| `DATA_DIR` | path | `./data` | Хранилища сессий и заданий |
| `HISTORY_LIMIT` | number | 50 | |
| `MAX_UPLOAD_BYTES` | number | 25 МБ | |
| `EDIT_MAX_DIMENSION` | number | 1024 | Даунскейл перед Codex; `0` = отключить |
| `UPSCALE_MAX_DIMENSION` | number | 2048 | Существующий предел |
| `CODEX_CLI_PATH` | string | авто-поиск (`resolveCodexExe` переносится) | |
| `CODEX_MODEL`, `CODEX_REASONING_EFFORT` | string | из `~/.codex/config.toml` | Передаются в `codex exec` как временный config или аргументы (как сегодня) |
| `CODEX_EDIT_TIMEOUT_MS` | number | 420000 | |
| `JOB_TIMEOUT_MS` | number | 300000 | Таймаут ComfyUI |
| `REQUEST_TIMEOUT_MS` | number | 15000 | |
| `SESSION_TTL_MS` | number | 7 суток | |
| `SESSION_PERSIST` | bool | true | Переживание входа через рестарт |
| `LOGIN_RATE_LIMIT` | object | `{ windowMs: 15*60*1000, max: 5 }` | |
| `CODEX_CONCURRENCY` | number | 1 | Потоков Codex |
| `UPSCALE_CONCURRENCY` | number | 2 | Потоков ComfyUI-апскейла |
| `MAX_QUEUED_JOBS` | number | 5 | Предел очереди на тип |
| `SSE_ENABLED` | bool | true | |
| `TRUST_PROXY` | bool | false | Для rate-limit по IP за прокси |
При старте в лог пишется **редактируемый** снимок конфигурации (без `APP_PASSWORD`, без путей к токенам).
---
## 5. Данные и хранилища
### 5.1 История версий (`core/history`)
Формат манифеста — **без изменений** (обратная совместимость с текущим `history/history.json`): массив объектов
```js
{
id: string, // uuid v4
kind: 'original'|'edit'|'upscale',
label: string,
prompt: string|null,
parentId: string|null,
referenceId: string|null,
createdAt: ISO string,
url: `/api/history/${id}/image.${ext}`, // P7: фактическое расширение
width: number|null,
height: number|null,
ext: 'jpg'|'png'|'webp'
}
```
Файлы изображений: `data/history/<id>.<ext>`**важно**: каталог переносится из `history/` в `data/history/` (том `kadr-data` вместо `kadr-history`; миграция описана в §12). Пока не мигрирован — `HISTORY_DIR` конфигурируется и по умолчанию остаётся `./history`; v2 пишет в `DATA_DIR/history`. Выбираем: **`HISTORY_DIR = path.join(DATA_DIR, 'history')`**, миграция при старте: если существует старый `./history/history.json`, а нового нет — перенести файлы (см. §12).
Интерфейс `history-store`:
```js
class HistoryStore {
constructor({ dir, limit, logger })
load(): Version[] // чтение манифеста (пусто при отсутствии)
async append(version): Promise<void> // атомарно: tmp+rename+fsync; прунинг > limit (удаление файлов вытесненных)
async removeCascade(id): Promise<{ removed: Version[] }> // BFS по parentId, удаление файлов
get(id): Version | null
filePath(version): string
}
```
Все мутации сериализуются цепочкой (как текущий `historyWriteChain`) — **сохраняем** этот паттерн, вынося его в store. Атомарность: писать в `<manifest>.tmp`, `fsync`, `rename`, `fsync` директории.
### 5.2 Сессии (`core/auth/session-store`)
`SessionStore` — Map + персистентный JSON-файл `data/sessions.json`:
```js
class SessionStore {
constructor({ file, ttlMs, persist, logger })
create(user): token
get(token): { user, expiresAt } | null // sliding: обновляет expiresAt
delete(token): void
sweep(): void // периодическая чистка TTL
_persist(): void // атомарная запись (debounce 500 мс)
}
```
Токен — 32 байта hex (как сейчас). В файле хранятся хеши токенов (`sha256(token)`), чтобы кража файла не давала прямых сессий. При `SESSION_PERSIST=false` — чисто in-memory (поведение v1).
### 5.3 Задания (`core/jobs/job-store`)
Персистентность: **append-only JSONL-журнал** `data/jobs.jsonl` + индекс в памяти. Каждая строка — событие перехода:
```jsonl
{"seq":1,"jobId":"...","at":"2026-08-26T06:00:00.000Z","event":"created","job":{...полный объект...}}
{"seq":2,"jobId":"...","at":"...","event":"status","status":"running","startedAt":"..."}
{"seq":3,"jobId":"...","at":"...","event":"status","status":"done","result":{...},"finishedAt":"..."}
```
- При старте: журнал читается, для каждого `jobId` восстанавливается последнее состояние (события применяются по порядку). Схемы строк журнала фиксируются JSDoc-типами (`JobCreatedEvent`, `JobStatusEvent`) в `core/jobs/job-store.js`, чтобы валидация журнала при старте была тривиальной (невалидная строка → warn + пропуск).
- `status=running` на момент старта → `interrupted` (см. §6.1 восстановление).
- Периодическая компактизация: каждые 6 ч журнал переписывается, оставляя только задания моложе 1 ч после `finishedAt` (терминальные задания живут в индексе 1 ч, как сейчас `server.js:146-153`).
- Формат задания:
```js
{
id: string, // uuid
type: 'edit'|'upscale'|'suggest',
status: 'queued'|'running'|'done'|'error'|'interrupted',
createdAt: ISO, startedAt: ISO|null, finishedAt: ISO|null,
payload: object, // зависит от типа (§7.1)
result: object|null, // { version?, summary?, ... }
error: string|null, details: any|null,
attempts: number, // для восстановления
engine: 'codex'|'comfy-upscale' // производная от type
}
```
### 5.4 Что не храним
- Исходные файлы заданий в `.codex-jobs` **не персистим как данные** — это рабочий каталог движка; очистка: при старте удалять каталоги старше 24 ч (а не все, как сейчас).
---
## 6. Очередь, жизненный цикл заданий, восстановление, события
### 6.1 Очередь (`core/jobs/queue.js`)
FIFO по `createdAt`; кониурентность по группам движков:
| Группа | Типы заданий | Кониурентность | Обоснование |
|---|---|---|---|
| `codex` | edit, suggest | `CODEX_CONCURRENCY` (1) | Один Codex-процесс на машине; узкое место — image-модель |
| `comfy-upscale` | upscale | `UPSCALE_CONCURRENCY` (2) | ComfyUI выдерживает 12 воркфлоу; апскейлы быстрые |
Интерфейс:
```js
class Queue {
constructor({ concurrencyByGroup, jobStore, engineRegistry, logger, events })
async enqueue(job): Promise<{ job, queuePosition }> // 429, если MAX_QUEUED_JOBS достигнут
start(): void // запуск воркеров (при старте сервера)
stop(): void // graceful shutdown: дождаться running, queued остаются
status(jobId): Job | null
listActive(): Job[] // для UI-бейджа и SSE-снапшота
}
```
Жизненный цикл (state machine):
```
queued ──worker взял──▶ running ──успех──▶ done
├──ошибка──▶ error
└──рестарт──▶ interrupted (attempts+1; если attempts < 1 — снова queued, иначе error «прервано перезапуском»)
```
**Политика восстановления после рестарта (решение):**
- `queued` → остаётся в очереди, выполняется после старта (входные файлы целы, движок ещё не запускался).
- `running``interrupted`; **автоматически НЕ перезапускаем** задания Codex (риск двойного списания токенов): пользователь видит ошибку «Задание прервано перезапуском сервера. Запустите заново.» с кнопкой повтора в UI. Исключение: `upscale` — безопасно перезапустить (идемпотентно), `attempts < 1` → requeue.
- Входные файлы движка: каталог `.codex-jobs/<jobId>/` сохраняется до завершения задания; при `interrupted` кодекс-задания файлы не нужны (повтор не авто), поэтому каталог можно чистить сразу.
### 6.2 Контракт движка (`core/engines/engine.js`)
```js
/**
* @typedef {Object} EngineContext
* @property {Config} config
* @property {Logger} logger
* @property {HistoryService} history
* @property {ComfyClient} comfy
* @property {CodexCli} codex
*
* @typedef {Object} EngineResult
* @property {boolean} ok
* @property {Version} [version] // созданная версия (edit/upscale)
* @property {object} [extra] // summary, promptId, model и т.п. → в job.result
* @property {string} [error] // сообщение для пользователя
* @property {any} [details] // технические детали (в лог)
*
* Реестр: engineRegistry.register('edit', (ctx) => ({ run(job) {...} }))
*/
```
Типы движков: `codex-engine` (edit, suggest — два метода одного адаптера), `comfy-upscale-engine` (upscale).
### 6.3 Кодекс-движок (`core/engines/codex-engine.js` + `core/codex/*`)
`codex/cli.js` — перенос `runCodexEdit`/`runCodexSuggest` (spawn, ожидание `output.jpg`/`last_message.txt`, таймаут `CODEX_EDIT_TIMEOUT_MS`, обработка `exit/error`), с одним изменением: функция принимает `{ jobDir, inputNames, prompt, timeoutMs }` и возвращает `{ exitCode, timedOut, outputPath, lastMessage }`.
`codex/prompts.js` — перенос `buildCodexPrompt`/`buildSuggestPrompt`/`parseSuggestions` + **новая** функция:
```js
// Масштабирование координат области референса после даунскейла референса.
// region — {x,y,w,h} в пикселях ОРИГИНАЛА референса; factor = targetDim / max(origW, origH)
function scaleRegion(region, factor): { x,y,w,h } // Math.round каждого, минимум 1px
```
`codex-engine.js` (edit):
1. По `job.payload` собрать вход: `input.<ext>` из файла задания или из версии истории (`history-service` выдаёт buffer).
2. Даунскейл: если `EDIT_MAX_DIMENSION > 0` и `max(w,h) > EDIT_MAX_DIMENSION``images/resize.js` (`sharp`), входной buffer уменьшается, `scaledSize` сохраняется. Если есть референс с областью — референс тоже даунскейлится тем же фактором, `region = scaleRegion(region, factor)`; промпт получает размер **даунскейленного** изображения.
3. Промпт: `buildEditPrompt(...)` (см. §6.5).
4. Запуск `codex exec`, ожидание `output.jpg`.
5. Создание версии `kind=edit`, `parentId=sourceId`, `referenceId`, размеры = даунскейленные (если был даунскейл, в `job.result.extra` пишем `downscaledFrom: {width,height}`).
6. `EngineResult { ok, version, extra }`.
`suggest`: без даунскейла (текстовый вывод), перенос как есть.
### 6.4 Апскейл-движок (`core/engines/comfy-upscale-engine.js` + `core/comfy/*`)
Перенос без изменения логики: `uploadToComfy``buildUpscaleWorkflow``submitWorkflow``waitForResult``downloadComfyImage` → версия `kind=upscale`. Компоненты: `comfy-client.js` (HTTP-слой с таймаутами и AppError-обёрткой), `comfy-runtime.js` (кэш `/object_info` 30 с, `chooseUpscaleModel`, `requireNodes`), `workflows.js` (построитель графа). `GET /api/result` (прокси к `/view`) остаётся в роуте, используя `comfy-client`.
### 6.5 Промпт редактирования (обновлённый `buildEditPrompt`)
Структура совпадает с текущей (`server.js:355-395`), плюс одна строка при даунскейле:
```
Перед тобой фотография input.jpg. Примени к ней следующее редактирование: «{prompt}».
[Если референс:] Также дано вспомогательное изображение reference.jpg — тот же сюжет... Координаты области: x=..., y=..., ширина=..., высота=... (в пикселях). Примени правку именно в области...
[Если даунскейл:] Исходное фото уменьшено для скорости обработки.
Сохрани отредактированное фото в файл output.jpg в текущей директории.
Не перерисовывай фото с нуля... Сохрани исходный размер {W}×{H}. // W,H — размер фактически поданного файла
Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал.
```
### 6.6 События и SSE (`core/jobs/events` + `routes/events.routes.js`)
`job-service` эмитит события через простой `EventEmitter`:
```js
events.emit('job:updated', { jobId, status, type, payload?, error?, queuePosition? })
events.emit('job:removed', { jobId })
```
`GET /api/events` (SSE, авторизованный):
- Заголовки: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`.
- При подключении — снапшот активных заданий (`event: snapshot`), далее `event: job` на каждое обновление; heartbeat `: ping` каждые 15 с.
- При `close` — отписка; таймаут простоя 10 мин → закрытие.
- Формат данных: `data: {"jobId":"...","status":"running","type":"edit"}` — только разрешённые поля, без внутренних объектов.
Фронтенд: `EventSource('/api/events')`; при ошибке/`onerror` — фолбэк на текущий опрос `GET /api/jobs/:id/status` каждые 3 с (поведение v1). Это делает SSE аддитивным и безопасным.
---
## 7. HTTP-контракт (целевой)
### 7.1 Эндпоинты
Все эндпоинты, кроме публичных, требуют сессию (`requireAuth`). Единый формат ошибок:
```json
{ "ok": false, "error": "Текст для пользователя", "details": {}, "requestId": "req_..." }
```
| Метод и путь | Публичный | Тело/параметры | Ответ (успех) | Изменения vs v1 |
|---|---|---|---|---|
| `GET /api/health` | да | — | `{ ok, comfyAvailable, message, selectedUpscaleModel, upscaleModels, checkpoint, checkpointAvailable, nodes, missingNodes, version }` | + `version` |
| `POST /api/login` | да | `{ username, password }` | `{ ok, user }` + cookie | + rate-limit; сравнение по scrypt-хешу |
| `GET /api/me` | да | — | `{ ok, user }` | как есть |
| `POST /api/logout` | да | — | `{ ok }` | как есть |
| `GET /api/history` | нет | — | `{ ok, versions: Version[] }` | как есть |
| `POST /api/history/import` | нет | multipart `photo` | `{ ok, version }` | + magic-byte проверка |
| `DELETE /api/history/:id` | нет | — | `{ ok, versions }` | как есть (каскад) |
| `GET /api/history/:id/image.{ext}` | нет | — | изображение | URL с фактическим ext (P7); старый `/image.jpg` оставить алиасом 302 → совместимость с сохранёнными ссылками |
| `POST /api/preview` | нет | multipart `photo` | изображение | как есть |
| `GET /api/suggest-prompts` | нет | — | `{ ok, prompts }` | как есть |
| `POST /api/suggest` | нет | multipart `photo` (или `sourceId`) | `{ ok, jobId, status:'queued' }` | очередь вместо 429 |
| `POST /api/edit` | нет | multipart: `photo`\|`sourceId`, `prompt`, `reference`, `referenceId`, `region` | `{ ok, jobId, status:'queued', queuePosition }` | очередь вместо 429; даунскейл |
| `POST /api/upscale` | нет | multipart `photo`\|`sourceId` | `{ ok, jobId, status:'queued', queuePosition }` | очередь |
| `GET /api/jobs` | нет | — | `{ ok, jobs: JobSummary[] }` | **новый** (активные + последние терминальные) |
| `GET /api/jobs/:id/status` | нет | — | `{ ok, status, type, queuePosition?, payload?, error?, details?, requestId }` | + `queuePosition` |
| `GET /api/jobs/:id/output.jpg` | нет | — | JPEG | как есть |
| `GET /api/result` | нет | `filename, subfolder, type=output` | изображение (прокси ComfyUI) | как есть |
| `GET /api/events` | нет | — | SSE-поток | **новый** |
`JobSummary = { id, type, status, createdAt, startedAt?, finishedAt?, queuePosition? }` — без payload/result (для бейджа очереди).
### 7.2 Payload'ы заданий
```js
edit: { sourceId: string|null, inputBuffer?: Buffer, inputMimetype?: string,
prompt: string, referenceId?: string|null, region?: {x,y,w,h}|null }
upscale: { sourceId: string|null, inputBuffer?: Buffer, inputMimetype?: string }
suggest: { sourceId: string|null, inputBuffer?: Buffer, inputMimetype?: string }
```
(`sourceId` предпочтителен; если его нет — в payload кладётся buffer загруженного файла; buffer НЕ пишется в журнал — только в рабочий каталог движка.)
`result`:
```js
edit: { version: Version, summary?: string, extra?: { downscaledFrom?: {width,height}, exitCode?, timedOut? } }
upscale: { version: Version, extra?: { promptId, model, scaledSize? } }
suggest: { suggestions: string[] }
```
### 7.3 Валидация входов
- **Загрузка файлов:** multer (лимиты как сейчас); `fileFilter` — по MIME **и** по сигнатуре: `sniffImageType(buffer)` определяет `jpeg|png|webp|heic` по первым байтам (JPEG `FF D8 FF`, PNG-сигнатура, `RIFF....WEBP`, HEIC `ftyp` с брендом `heic/heix/hevc/heif`). Несовпадение → 400.
- **Промпт:** trim, 11000 символов.
- **`region`:** как сейчас (`parseRegion`), дополнительно: целые ≥ 0; не больше размеров референса.
- **UUID:** `JOB_ID_PATTERN` остаётся для всех `:id` параметров.
- **Origin-проверка (CSRF):** middleware для не-GET `/api/*`: если заголовок `Origin` присутствует и не равен `Host` (с учётом схемы) → 403. Плюс `SameSite=Lax` (уже есть). Для LAN-инструмента этого достаточно (прокси не используется, кроме `/api/result` к ComfyUI).
---
## 8. Решения и альтернативы
| # | Решение | Выбрано | Альтернативы (рассмотрены) | Почему | Триггер пересмотра |
|---|---|---|---|---|---|
| R1 | Гранулярность | Модульный монолит | Микросервисы; один файл | Один владелец, один хост; микросервисы = жизненный цикл без выгоды | Второй хост/сервис, командная разработка |
| R2 | Хранилище истории | JSON-манифест + файлы, атомарная запись | SQLite (better-sqlite3) | ≤ 50 версий; совместимость с существующими данными; ноль нативных зависимостей | > 500 версий, конкурентные писатели, сложные запросы дерева |
| R3 | Хранилище заданий | JSONL-журнал + индекс в памяти | SQLite; in-memory (v1) | Транзиентные данные (1 ч); простота восстановления; нет нативных зависимостей | Нужна аналитика по истории заданий |
| R4 | Сессии | Персистентный файл (хеши токенов), TTL | In-memory (v1) | «Переживает рестарт» — явная ценность владельца; дёшево | Мультипользователь, внешний доступ |
| R5 | Пароль | scrypt (встроенный `crypto`) | bcrypt/argon2 (native); открытый compare (v1) | Ноль зависимостей, timing-safe, NIST-приемлемо | Требования комплаенса |
| R6 | Даунскейл перед Codex | `sharp` | `jimp` (чистый JS); без даунскейла (v1) | Замеры README показывают выигрыш; sharp — стандарт, есть prebuilds для bookworm-slim | Проблемы сборки native → заменить на jimp |
| R7 | Обновления статуса | SSE + фолбэк на polling | Только polling (v1); WebSocket | SSE — нулевые зависимости, работает через любой прокси; фолбэк сохраняет v1-поведение | Нужны двусторонние каналы (отмена заданий) |
| R8 | Фронтенд | Vanilla ES-модули, без сборки | React/Vue + Vite | Ноль build-шага в Docker/CI; текущий HTML/CSS сохраняется; сложность UI невелика | Рост сложности UI (много интерактивных панелей) |
| R9 | Очередь | FIFO + кониурентность по группам | Только single-flight (v1); полноценный worker pool | Покрывает реальный сценарий «поставить правку в очередь» без оверкилла | Параллельные правки |
| R10 | Восстановление заданий | `queued` выполняются; `running``interrupted` (upscale — requeue 1 раз) | Автоперезапуск всего | Избегаем двойного списания токенов Codex; честный UX | Появление идемпотентных движков |
| R11 | Тесты | `node:test` + supertest | Jest/Vitest | Встроено в Node 20, ноль зависимостей | Миграция на TS |
| R12 | TypeScript | Нет (JS + JSDoc) | TS с сборкой | Текущий стек без сборки; JSDoc даёт контракты для ИИ-агента | Кодовой базы > ~10k строк |
---
## 9. Фронтенд (target)
### 9.1 Структура
```
public/
index.html // почти без изменений; подключение: <script type="module" src="/js/main.js"></script>
style.css // без изменений (v1); допускается мелкая правка под новые блоки
js/
main.js // bootstrap: чтение DOM-элементов, инициализация модулей, подписки
state.js // единое состояние { currentVersionId, historyVersions, collapsed, reference, busy, activeJobs }
api.js // apiFetch(url, opts): JSON-ошибки, 401 → auth.show(), requestId
auth.js // showAuthOverlay/hide, checkAuth, submitLogin, logout
health.js // опрос /api/health (как сейчас, 30 с)
upload.js // drop-zone, мультизагрузка (последовательный импорт, прогресс)
reference.js // выбор референса (файл/история), канвас, область (перенос из app.js:1072-1290)
suggest.js // chips из истории, «Спросить Codex», подписка на job (перенос)
jobs.js // EventSource + фолбэк-поллинг; renderJobSuccess, ошибки
history-tree.js // рендер дерева, свёртка групп, выбор версии, удаление (перенос из app.js:532-852)
ui.js // мелкие утилиты: formatFileSize, versionFileName, toast
```
### 9.2 Ключевые изменения поведения
1. **Обновления статуса:** `jobs.js` держит один `EventSource`; на `event: job` обновляет бейдж задания и завершает текущий job-цикл мгновенно (без 3-секундной задержки). При `onerror` (сеть/прокси) — фолбэк: поллинг `GET /api/jobs` каждые 3 с.
2. **Очередь:** если ответ `POST /api/edit` = `{ status:'queued', queuePosition }` — UI показывает «В очереди, позиция N» и слушает событие до `running`/`done` (текущий код `runJob` предполагает только `running` — доработать).
3. **Ошибка `interrupted`:** после рестарта сервера задание приходит как `interrupted` → показать сообщение и предложить повторно запустить (кнопка повторяет `POST /api/edit` с теми же параметрами, которые UI хранит в памяти сессии).
4. **Скачивание:** `download-current` и `download-link` используют `version.url` (теперь с фактическим ext).
### 9.3 Состояние
`state.js` — простой объект + `subscribe(fn)`; модули не трогают DOM друг друга напрямую. Это минимально достаточное улучшение без библиотек.
---
## 10. Тестирование и CI
### 10.1 Модульные (node:test, `test/*.test.js`)
| Файл | Что проверяет |
|---|---|
| `config.test.js` | дефолты, валидация, ошибки при неверных значениях, редактирование лог-снимка |
| `logger.test.js` | уровни, формат JSON-lines, requestId-корреляция, отсутствие пароля |
| `images.test.js` | `getImageDimensions` (PNG/JPEG/WebP фикстуры), `sniffImageType` (magic bytes, включая HEIC `ftyp`), `scaleRegion` (округление, factor=1) |
| `history-store.test.js` | append + прунинг + удаление файлов; атомарность (нет tmp-файлов после успеха); каскадное удаление; переживание перезаписи |
| `job-queue.test.js` | FIFO-порядок, кониурентность по группам (fake-движок с задержками), `MAX_QUEUED_JOBS` → 429, восстановление из журнала (queued остаётся, running → interrupted, upscale requeue) |
| `prompts.test.js` | сборка промпта (с референсом/областью/даунскейлом), `parseSuggestions` |
| `api.test.js` (supertest) | login (успех/ошибка/rate-limit), auth-защита, import/история, edit/upscale/suggest с **stub-движками** (журнал), SSE-снапшот, ошибки `{ok:false,error,requestId}` |
Все тесты работают с временным `DATA_DIR` (`fs.mkdtemp`) — реальные `data/` и `history/` не трогаются.
### 10.2 Приёмочные проверки каждой фазы — в §13.
### 10.3 CI (Gitea Actions)
Новый workflow `.gitea/workflows/test.yml` (workflow_dispatch + push на `main`):
```yaml
name: test
on: { workflow_dispatch: {}, push: { branches: [main] } }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm test
```
`package.json`: добавить `"test": "node --test test/"` и devDependencies `supertest`.
---
## 11. Наблюдаемость
- **Логи:** JSON-lines, поля `{ ts, level, msg, requestId, jobId?, versionId?, type?, durationMs? }`. `requestId` генерируется в `request-context.js` (`req_` + uuid8) и прокидывается в контекст задания при его создании (журналируем `requestId` в job-событиях).
- **Метрики (лёгкие, in-memory):** счётчики `jobs.created{type}`, `jobs.done{type}`, `jobs.error{type}`, гистограмма `job.duration{type}` (бакеты 5/15/30/60/120/300/600 с), `history.size`. Экспонируются в `GET /api/health` (поле `metrics`) — отдельный `/api/metrics` не нужен для одного владельца.
- **Аудит:** отдельный уровень `audit` для входа/выхода/удаления версий (в stdout).
- **Health:** как сейчас (ComfyUI `/object_info`), плюс проверка, что очередь не заблокирована (если `running` дольше 10 мин — поле `warning`).
---
## 12. Миграция и совместимость
1. **Данные истории:** при первом старте v2, если `./history/history.json` существует, а `data/history/history.json` — нет: скопировать каталог `history/*``data/history/`, оставить `./history` нетронутым (не удалять), залогировать миграцию. Обратная совместимость формата версий — да (поля совпадают).
2. **Старый URL изображений:** `GET /api/history/:id/image.jpg` остаётся как redirect 302 на `/api/history/:id/image.<ext>` — сохранённые вкладки/ссылки не ломаются.
3. **Docker/deploy:**
- `Dockerfile`: `COPY server.js ./`, `COPY src ./src`, `COPY public ./public`; `VOLUME ["/app/history", "/app/data"]`.
- `deploy/docker-compose.yml`: заменить `kadr-history:/app/history` на `kadr-data:/app/data` (или добавить `kadr-data` рядом — при первом старте сработает миграция из старого тома только если смонтирован и старый; **рекомендация:** смонтировать оба тома на переходный период: `kadr-history:/app/history:ro`? Нет — проще: оставить `kadr-history:/app/history` и добавить `kadr-data:/app/data`; v2 пишет в `data/history`, миграция читает `./history`).
- `.gitignore`: добавить `data/` (уже есть `history/`).
4. **README:** обновить разделы «Запуск», «Авторизация» (rate-limit, scrypt), «Логирование» (requestId), добавить раздел «Архитектура» со ссылкой на эти документы и список env-переменных.
---
## 13. План реализации для ИИ-агента (по фазам)
Порядок фаз сохраняет работоспособность сервиса на каждом шаге (каждая фаза заканчивается зелёными проверками и рабочей страницей). Реализатор должен следовать правилам зависимостей §4.2 и контрактам §5§7.
### Фаза 0 — Каркас (без изменения поведения)
- Создать `src/config.js`, `src/logger.js`, `src/errors.js`; перенести существующие константы и `log()` из `server.js`.
- Создать `src/http/app.js` (`createApp(deps)`) и `src/server.js` (bootstrap: `loadConfig → createApp → listen`). Корневой `server.js` становится тонким загрузчиком (`require("./src/server")`) — Docker CMD и `npm start` не меняются.
- Перенести все роуты из `server.js` в `src/http/routes/*` **без изменения логики** (механический перенос, временно оставив в одном месте `sessions/jobs` Map).
- `package.json`: `"start": "node server.js"`, `"start:dev": "node --watch src/server.js"`.
- **Приёмка:** `npm start` поднимает сервер; все сценарии README работают как раньше (ручная проверка); `npm test` (пустой набор) зелёный.
### Фаза 1 — Хранилища данных
- `core/history/history-store.js` + `history-service.js`: атомарная запись, прунинг, каскадное удаление (перенос из `server.js:820-900, 1765-1826`), URL с фактическим ext + алиас `/image.jpg` (302).
- `core/auth/password.js` + `session-store.js`: scrypt-хеш, персистентные сессии с хешами токенов.
- Миграция `history/``data/history/` (§12.1).
- Тесты: `history-store.test.js` (модульный), `api.test.js` (импорт/удаление/история).
- **Приёмка:** `npm test` зелёный; загрузка/удаление версий работает; перезапуск сервера сохраняет историю и вход (с `SESSION_PERSIST=true`).
### Фаза 2 — Очередь и движки
- `core/jobs/job-store.js`, `queue.js`, `job-service.js`; контракт `engines/engine.js`.
- Перенос Codex-логики в `core/codex/cli.js` + `prompts.js` + `core/engines/codex-engine.js`; ComfyUI-логики в `core/comfy/*` + `comfy-upscale-engine.js`.
- Роуты edit/upscale/suggest переключаются на `job-service.enqueue` (вместо `codexEditInFlight`); `GET /api/jobs`, `queuePosition`, восстановление при старте.
- Тесты: `job-queue.test.js` (fake-движки), `prompts.test.js`, обновить `api.test.js` (stub-движки).
- **Приёмка:** две правки подряд встают в очередь; рестарт во время `running``interrupted` с понятной ошибкой в UI; `queued` переживает рестарт; апскейл работает.
### Фаза 3 — Безопасность и валидация
- `rate-limit.js` (логин), `origin-check.js`, `sniffImageType` в фильтре аплоада, валидация `region`.
- Тесты: login rate-limit, Origin-проверка, поддельный MIME отклоняется.
- **Приёмка:** 6 неудачных входов за 15 мин блокируют; curl с чужим Origin на `POST` → 403; файл с переименованным расширением отклоняется.
### Фаза 4 — Даунскейл и SSE
- `images/resize.js` (sharp), даунскейл в `codex-engine` (edit), `scaleRegion`.
- `events.routes.js` (SSE) + эмиттер в `job-service`; фронтенд: `js/jobs.js` (EventSource + фолбэк).
- Тесты: `images.test.js` (scaleRegion), SSE-снапшот в `api.test.js`.
- **Приёмка:** правка фото 4000×3000 уходит в Codex как ≤ 1024 px (проверка лога), результат корректен; статусы приходят мгновенно без поллинга (DevTools Network); при отключённом SSE работает фолбэк.
### Фаза 5 — Фронтенд-модули
- Разбить `public/app.js` на `js/*.js` (ES-модули) по §9.1 без изменения вёрстки/стилей; перенести логику 1-в-1, заменить глобальные переменные на `state.js`.
- `index.html`: `<script type="module" src="/js/main.js">`.
- **Приёмка:** все сценарии README работают; `npm start` отдаёт модули без build-шага; консоль браузера чистая.
### Фаза 6 — CI, наблюдаемость, документация
- `.gitea/workflows/test.yml`; метрики в `/api/health`; requestId в логах и ошибках; обновление README и Dockerfile/deploy (том `data`).
- **Приёмка:** Gitea Actions `test` зелёный; `docker compose up -d --build` собирается; после рестарта контейнера данные и входы на месте.
---
## 14. Валидация целевой архитектуры (приёмочные доказательства)
| Требование | Доказательство |
|---|---|
| Поведение v1 сохранено | Фаза 01 ручной прогон всех README-сценариев; `api.test.js` покрывает контракты |
| Переживание рестарта | Тест `job-queue.test.js` (восстановление из журнала) + ручной: рестарт во время edit |
| Безопасность | Тесты rate-limit/Origin/magic-bytes; в логах нет паролей и токенов |
| Скорость | Замер времени edit до/после даунскейла (лог `durationMs`, сравнение с README-таблицей) |
| Наблюдаемость | Лог с requestId по всем API; `GET /api/health` содержит metrics |
| Реализуемость | Каждая фаза имеет автотесты и ручную приёмку; контракты в §5§7 однозначны |
---
## 15. Открытые вопросы и остаточные риски
Вопросы, которые **могут** изменить планирование (на текущий момент решены дефолтами):
1. **Резкость даунскейла для Codex.** Дефолт 1024 px; если владелец заметит потерю деталей на продуктах (этикетки, текст), поднять до 1536 или отключить (`EDIT_MAX_DIMENSION=0`). Обратимо на уровне env.
2. **Судьба старого тома `kadr-history`.** После подтверждённой миграции владелец может удалить том вручную; v2 не удаляет его сам.
3. **Кониурентность апскейлов = 2.** Если ComfyUI (2× AMD Radeon Pro VII) начнёт ставить в очередь — снизить до 1; env-переменная уже предусмотрена.
Риски и их снижение:
- **sharp в node:22-bookworm-slim:** prebuilt binaries распространяются для linux-x64 glibc — ожидаемо работает; фолбэк `jimp` (R6).
- **Изменения API Codex/ComfyUI:** клиенты изолированы в `core/codex` и `core/comfy`; изменения затрагивают один модуль + тесты.
- **SSE через прокси:** в LAN прокси нет (кроме egress-прокси для OpenAI, который не стоит на пути браузера); фолбэк-поллинг покрывает остальные случаи.
- **Рост `data/history` при даунскейле:** файлы исходников хранятся как есть; лимит 50 версий действует (D3).
---
## 16. Реестр решений — сводка для быстрой навигации
| Решение | Секция |
|---|---|
| Модульный монолит, правила зависимостей | §4.14.2 |
| Единый config с валидацией | §4.3 |
| JSON-манифест истории + атомарная запись | §5.1 |
| Персистентные сессии (хеши токенов) | §5.2 |
| JSONL-журнал заданий + восстановление | §5.3, §6.1 |
| Очередь FIFO, кониурентность по группам, MAX_QUEUED_JOBS | §6.1 |
| Контракт движка EngineResult | §6.2 |
| Даунскейл sharp + scaleRegion | §6.5, Фаза 4 |
| SSE + фолбэк-поллинг | §6.6 |
| API: аддитивные изменения, requestId | §7 |
| scrypt, rate-limit, Origin-check, magic bytes | §7.3 |
| Vanilla ES-модули без сборки | §9 |
| node:test + supertest + Gitea test workflow | §10 |
| requestId-логи, лёгкие метрики | §11 |
| Миграция data/ + алиас /image.jpg | §12 |
| План по фазам с приёмкой | §13 |
---
# System Design Proposal
**Checklist: 6/6 complete**<br>
**Incomplete: None** — обрамление (§1), оценка (§3), домены/данные/контракты (§4§7), HLD/LLD (§4§6, §9§11), решения (§8), написание/валидация (§13§15) выполнены; изменены только `docs/architecture/*.md`, код не менялся.