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

59 KiB
Raw Permalink Blame History

Системный дизайн: «Кадр» 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): массив объектов

{
  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:

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:

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 + индекс в памяти. Каждая строка — событие перехода:

{"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).
  • Формат задания:
{
  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 воркфлоу; апскейлы быстрые

Интерфейс:

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 → остаётся в очереди, выполняется после старта (входные файлы целы, движок ещё не запускался).
  • runninginterrupted; автоматически НЕ перезапускаем задания Codex (риск двойного списания токенов): пользователь видит ошибку «Задание прервано перезапуском сервера. Запустите заново.» с кнопкой повтора в UI. Исключение: upscale — безопасно перезапустить (идемпотентно), attempts < 1 → requeue.
  • Входные файлы движка: каталог .codex-jobs/<jobId>/ сохраняется до завершения задания; при interrupted кодекс-задания файлы не нужны (повтор не авто), поэтому каталог можно чистить сразу.

6.2 Контракт движка (core/engines/engine.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 + новая функция:

// Масштабирование координат области референса после даунскейла референса.
// 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_DIMENSIONimages/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/*)

Перенос без изменения логики: uploadToComfybuildUpscaleWorkflowsubmitWorkflowwaitForResultdownloadComfyImage → версия 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:

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). Единый формат ошибок:

{ "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'ы заданий

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:

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 выполняются; runninginterrupted (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):

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-движки).
  • Приёмка: две правки подряд встают в очередь; рестарт во время runninginterrupted с понятной ошибкой в 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
Incomplete: None — обрамление (§1), оценка (§3), домены/данные/контракты (§4§7), HLD/LLD (§4§6, §9§11), решения (§8), написание/валидация (§13§15) выполнены; изменены только docs/architecture/*.md, код не менялся.