Реализация целевого дизайна (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/
59 KiB
Системный дизайн: «Кадр» v2 — целевая архитектура
Вердикт: READY
Артефакт: docs/architecture/target-design.md
Статус: 1.0, готов к планированию реализации
Язык: русский; идентификаторы кода — английские.
Этот документ — полный проект перепроектирования «Кадр» (локальный AI-фоторедактор). Он написан так, чтобы человек мог понять решение, а ИИ-агент — реализовать его без дополнительных уточнений: здесь зафиксированы границы, контракты, структура модулей, данные, поведение при сбоях, решения с альтернативами, порядок реализации и критерии приёмки каждой фазы.
Сопутствующие артефакты: docs/architecture/system-design-baseline.md (драйверы и ограничения, реестр D1–D17), 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 |
Пропадает редактирование на 1–3 мин при рестарте | Персистентный 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 |
| Правок в сутки | 0–20 (пик) | Оценка по history.json (12 версий за 2 дня активного тестирования) |
| Апскейлов в сутки | 0–20 | Аналогично |
| Параллельность | 1 edit/suggest + ≤ 2 upscale | Текущее узкое место — Codex (один поток); ComfyUI справляется с 1–2 воркфлоу |
| Размер загрузки | ≤ 25 МБ, типично 1–6 МБ | server.js:24, фактические файлы |
| История | ≤ 50 версий, манифест ~10 КБ | server.js:34 |
| Частота опроса статуса | 3 с (фронт) → 0 с при SSE | README.md |
| Очередь | ≤ 5 заданий в очереди | Лимит для защиты от «накликивания»; дешевле, чем бесконечная очередь |
Формула для оценки времени: t_edit ≈ 60–90 с × (maxDim/512)^1.3 (эмпирика из README: 512² ≈ 60–82 с, 1200×800 заметно дольше). Даунскейл до 1024 px сокращает среднее время до ~1–1.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 Правила зависимостей (обязательны для реализации)
- Слои:
routes → core/* → (engines | comfy | codex | images). Роуты не содержат бизнес-логики — только парсинг запроса и вызов сервиса. coreне импортируетexpress(кромеhttp/app.jsи middleware). Движки и сервисы получают зависимости через конструктор/DI.- Никто не читает
process.envнапрямую, кромеconfig.js. Все константы — изconfig. engines/*не знают про HTTP. ВозвращаютEngineResult(см. §6.3).queue.jsне знает про движки — получает фабрику по типу задания из реестра.- Файловая система — только через выделенные модули-хранилища (
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 выдерживает 1–2 воркфлоу; апскейлы быстрые |
Интерфейс:
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)
/**
* @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):
- По
job.payloadсобрать вход:input.<ext>из файла задания или из версии истории (history-serviceвыдаёт buffer). - Даунскейл: если
EDIT_MAX_DIMENSION > 0иmax(w,h) > EDIT_MAX_DIMENSION→images/resize.js(sharp), входной buffer уменьшается,scaledSizeсохраняется. Если есть референс с областью — референс тоже даунскейлится тем же фактором,region = scaleRegion(region, factor); промпт получает размер даунскейленного изображения. - Промпт:
buildEditPrompt(...)(см. §6.5). - Запуск
codex exec, ожиданиеoutput.jpg. - Создание версии
kind=edit,parentId=sourceId,referenceId, размеры = даунскейленные (если был даунскейл, вjob.result.extraпишемdownscaledFrom: {width,height}). 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:
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по первым байтам (JPEGFF D8 FF, PNG-сигнатура,RIFF....WEBP, HEICftypс брендомheic/heix/hevc/heif). Несовпадение → 400. - Промпт: trim, 1–1000 символов.
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 Ключевые изменения поведения
- Обновления статуса:
jobs.jsдержит одинEventSource; наevent: jobобновляет бейдж задания и завершает текущий job-цикл мгновенно (без 3-секундной задержки). Приonerror(сеть/прокси) — фолбэк: поллингGET /api/jobsкаждые 3 с. - Очередь: если ответ
POST /api/edit={ status:'queued', queuePosition }— UI показывает «В очереди, позиция N» и слушает событие доrunning/done(текущий кодrunJobпредполагает толькоrunning— доработать). - Ошибка
interrupted: после рестарта сервера задание приходит какinterrupted→ показать сообщение и предложить повторно запустить (кнопка повторяетPOST /api/editс теми же параметрами, которые UI хранит в памяти сессии). - Скачивание:
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. Миграция и совместимость
- Данные истории: при первом старте v2, если
./history/history.jsonсуществует, аdata/history/history.json— нет: скопировать каталогhistory/*→data/history/, оставить./historyнетронутым (не удалять), залогировать миграцию. Обратная совместимость формата версий — да (поля совпадают). - Старый URL изображений:
GET /api/history/:id/image.jpgостаётся как redirect 302 на/api/history/:id/image.<ext>— сохранённые вкладки/ссылки не ломаются. - 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/).
- 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/jobsMap). 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 сохранено | Фаза 0–1 ручной прогон всех 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. Открытые вопросы и остаточные риски
Вопросы, которые могут изменить планирование (на текущий момент решены дефолтами):
- Резкость даунскейла для Codex. Дефолт 1024 px; если владелец заметит потерю деталей на продуктах (этикетки, текст), поднять до 1536 или отключить (
EDIT_MAX_DIMENSION=0). Обратимо на уровне env. - Судьба старого тома
kadr-history. После подтверждённой миграции владелец может удалить том вручную; v2 не удаляет его сам. - Кониурентность апскейлов = 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.1–4.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, код не менялся.