From 401969cfdb27c660f40ea4f54a6fce39d16cd269 Mon Sep 17 00:00:00 2001 From: Coder Date: Thu, 27 Aug 2026 12:52:27 +0700 Subject: [PATCH] =?UTF-8?q?v2:=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=BD=D0=BE=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=20=E2=80=94=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C,=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C,=20=D0=B1=D0=B5=D0=B7=D0=BE?= =?UTF-8?q?=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82=D1=8C,=20SSE,=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Реализация целевого дизайна (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/ --- .gitea/workflows/test.yml | 30 + .gitignore | 5 +- Dockerfile | 8 +- README.md | 360 +-- deploy/docker-compose.yml | 5 +- docker-compose.yml | 7 +- docs/architecture/system-design-baseline.md | 97 + docs/architecture/target-design.md | 676 ++++++ package-lock.json | 904 +++++++- package.json | 14 +- public/app.js | 1524 +----------- public/index.html | 2 +- public/js/api.js | 40 + public/js/auth.js | 111 + public/js/health.js | 49 + public/js/history-tree.js | 357 +++ public/js/jobs.js | 301 +++ public/js/main.js | 77 + public/js/reference.js | 322 +++ public/js/state.js | 74 + public/js/suggest.js | 187 ++ public/js/ui.js | 138 ++ public/js/upload.js | 123 + server.js | 2292 +------------------ src/config.js | 206 ++ src/core/auth/password.js | 114 + src/core/auth/session-store.js | 190 ++ src/core/codex/cli.js | 265 +++ src/core/codex/prompts.js | 156 ++ src/core/comfy/comfy-client.js | 224 ++ src/core/comfy/comfy-runtime.js | 195 ++ src/core/comfy/workflows.js | 75 + src/core/engines/codex-engine.js | 261 +++ src/core/engines/comfy-upscale-engine.js | 114 + src/core/engines/engine.js | 54 + src/core/images/dimensions.js | 190 ++ src/core/images/heic.js | 42 + src/core/images/resize.js | 83 + src/core/images/sniff.js | 71 + src/core/jobs/job-service.js | 122 + src/core/jobs/job-store.js | 289 +++ src/core/jobs/queue.js | 367 +++ src/errors.js | 63 + src/http/app.js | 146 ++ src/http/middleware/auth.js | 83 + src/http/middleware/error-handler.js | 80 + src/http/middleware/origin-check.js | 43 + src/http/middleware/rate-limit.js | 99 + src/http/middleware/request-context.js | 44 + src/http/routes/auth.routes.js | 87 + src/http/routes/edit.routes.js | 149 ++ src/http/routes/events.routes.js | 78 + src/http/routes/health.routes.js | 63 + src/http/routes/history.routes.js | 151 ++ src/http/routes/jobs.routes.js | 65 + src/http/routes/preview.routes.js | 51 + src/http/routes/result.routes.js | 58 + src/http/routes/suggest.routes.js | 108 + src/http/routes/upscale.routes.js | 88 + src/logger.js | 119 + src/server.js | 222 ++ test/api.test.js | 324 +++ test/config.test.js | 82 + test/history-store.test.js | 130 ++ test/images.test.js | 105 + test/job-queue.test.js | 161 ++ test/logger.test.js | 61 + test/prompts.test.js | 72 + 68 files changed, 9368 insertions(+), 4085 deletions(-) create mode 100644 .gitea/workflows/test.yml create mode 100644 docs/architecture/system-design-baseline.md create mode 100644 docs/architecture/target-design.md create mode 100644 public/js/api.js create mode 100644 public/js/auth.js create mode 100644 public/js/health.js create mode 100644 public/js/history-tree.js create mode 100644 public/js/jobs.js create mode 100644 public/js/main.js create mode 100644 public/js/reference.js create mode 100644 public/js/state.js create mode 100644 public/js/suggest.js create mode 100644 public/js/ui.js create mode 100644 public/js/upload.js create mode 100644 src/config.js create mode 100644 src/core/auth/password.js create mode 100644 src/core/auth/session-store.js create mode 100644 src/core/codex/cli.js create mode 100644 src/core/codex/prompts.js create mode 100644 src/core/comfy/comfy-client.js create mode 100644 src/core/comfy/comfy-runtime.js create mode 100644 src/core/comfy/workflows.js create mode 100644 src/core/engines/codex-engine.js create mode 100644 src/core/engines/comfy-upscale-engine.js create mode 100644 src/core/engines/engine.js create mode 100644 src/core/images/dimensions.js create mode 100644 src/core/images/heic.js create mode 100644 src/core/images/resize.js create mode 100644 src/core/images/sniff.js create mode 100644 src/core/jobs/job-service.js create mode 100644 src/core/jobs/job-store.js create mode 100644 src/core/jobs/queue.js create mode 100644 src/errors.js create mode 100644 src/http/app.js create mode 100644 src/http/middleware/auth.js create mode 100644 src/http/middleware/error-handler.js create mode 100644 src/http/middleware/origin-check.js create mode 100644 src/http/middleware/rate-limit.js create mode 100644 src/http/middleware/request-context.js create mode 100644 src/http/routes/auth.routes.js create mode 100644 src/http/routes/edit.routes.js create mode 100644 src/http/routes/events.routes.js create mode 100644 src/http/routes/health.routes.js create mode 100644 src/http/routes/history.routes.js create mode 100644 src/http/routes/jobs.routes.js create mode 100644 src/http/routes/preview.routes.js create mode 100644 src/http/routes/result.routes.js create mode 100644 src/http/routes/suggest.routes.js create mode 100644 src/http/routes/upscale.routes.js create mode 100644 src/logger.js create mode 100644 src/server.js create mode 100644 test/api.test.js create mode 100644 test/config.test.js create mode 100644 test/history-store.test.js create mode 100644 test/images.test.js create mode 100644 test/job-queue.test.js create mode 100644 test/logger.test.js create mode 100644 test/prompts.test.js diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..9175730 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,30 @@ +name: test + +# Автоматический запуск при push в main и ручной вызов +on: + workflow_dispatch: {} + push: + branches: [main] + +concurrency: + group: test + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore index 67b8653..11b0f4c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ # Зависимости node_modules/ +.npm-cache/ +.npmrc -# Данные приложения (история версий, временные рабочие каталоги Codex) +# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex) history/ +data/ .codex-jobs/ # Временные и тестовые файлы diff --git a/Dockerfile b/Dockerfile index 4fac58d..e268c29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# Кадр — локальный AI-фоторедактор +# Кадр — локальный AI-фоторедактор (v2) # Образ: Node.js 22 на Debian bookworm-slim. # Debian (glibc) обязателен: Codex CLI поставляется как бинарник под glibc, # на Alpine (musl) он не запускается. @@ -22,6 +22,7 @@ RUN npm ci --omit=dev # Код приложения COPY server.js ./ +COPY src ./src COPY public ./public # Конфиг Codex по умолчанию (используется, если ~/.codex не смонтирован) @@ -30,9 +31,8 @@ COPY codex/config.toml /root/.codex/config.toml ENV NODE_ENV=production ENV COMFY_URL=http://192.168.31.240:8188 -# История версий должна жить на томе — иначе версии пропадут -# при пересоздании контейнера. -VOLUME ["/app/history"] +# Хранилище данных и история версий на томах +VOLUME ["/app/history", "/app/data"] EXPOSE 3000 diff --git a/README.md b/README.md index 5be7904..7d2650a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Кадр — локальный AI-фоторедактор +# Кадр — локальный AI-фоторедактор (v2) Веб-приложение редактирует фотографии по текстовому промпту (через локальный Codex CLI) и увеличивает разрешение через домашний сервер ComfyUI. @@ -7,292 +7,130 @@ Codex CLI) и увеличивает разрешение через домаш преобразуются на сервере в JPEG с помощью `heic-convert` до передачи в ComfyUI или Codex. +--- + +## Архитектура v2 + +В версии v2 сервис переработан в **модульный монолит** (`src/`): + +1. **Устойчивость к перезапуску**: + - Персистентный append-only JSONL-журнал заданий (`data/jobs.jsonl`). При рестарте сервера незавершённые задания Codex получают статус `interrupted` с возможностью повтора в один клик, а задания ComfyUI автоматически возвращаются в очередь. + - Персистентное хранилище сессий (`data/sessions.json`, хранятся sha256-хеши токенов). Владелец остаётся авторизован после обновления или перезапуска контейнера. + - Атомарная запись манифеста истории (`history.json.tmp` + `fsync` + `rename`) защищает от повреждения данных при внезапном выключении. +2. **Очередь заданий (FIFO) и группы конкурентности**: + - Вместо блокировки single-flight (429) запросы на редактирование и апскейл ставятся в очередь. + - Лимит параллельности: Codex (edit, suggest) = 1 поток; ComfyUI (upscale) = 2 потока. + - Лимит очереди: до 5 заданий на группу (защита от накликивания). +3. **Безопасность**: + - Пароль защищён scrypt-хешированием (`crypto.scrypt`) с постоянным временем сверки (`timingSafeEqual`). + - Ограничение попыток входа (Rate Limiting): 5 попыток за 15 минут на IP. + - Проверка Origin на state-changing запросах (CSRF). + - Проверка сигнатур файлов (magic bytes) для защиты от поддельных MIME-типов. +4. **Ускорение редактирования (Downscale)**: + - Автоматический даунскейл `sharp` до `EDIT_MAX_DIMENSION` (по умолчанию 1024 px) перед передачей в Codex с пропорциональным масштабированием области референса (`scaleRegion`). Сокращает время обработки больших фото в 1.5–2 раза. +5. **Мгновенные обновления статуса (SSE)**: + - Server-Sent Events (`GET /api/events`) передают смену статуса очереди и прогресс мгновенно. + - Автоматический фолбэк на опрос (`/api/jobs`) при сбоях сети или неподдерживаемых прокси. +6. **Клиентская часть на Vanilla ES-модулях**: + - Модули в `public/js/` загружаются напрямую браузером (` + 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.` — сохранённые вкладки/ссылки не ломаются. +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`: ` + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..a8f03e4 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,40 @@ +/** + * api.js — Обёртка над fetch с обработкой ошибок и авторизации + */ + +import { showAuthOverlay } from "./auth.js"; + +export async function readJsonResponse(response) { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return { ok: false, error: text }; + } +} + +export async function apiFetch(url, options = {}) { + const response = await fetch(url, options); + + if (response.status === 401) { + showAuthOverlay(); + const data = await readJsonResponse(response); + const error = new Error(data?.error || "Требуется авторизация."); + error.status = 401; + throw error; + } + + const data = await readJsonResponse(response); + + if (!response.ok || (data && data.ok === false)) { + const errorMsg = data?.error || `Ошибка сервера (${response.status})`; + const error = new Error(errorMsg); + error.status = response.status; + error.details = data?.details; + error.requestId = data?.requestId; + throw error; + } + + return data; +} diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..8bd308e --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,111 @@ +/** + * auth.js — Управление авторизацией владельца + */ + +import { apiFetch, readJsonResponse } from "./api.js"; + +export function showAuthOverlay() { + const overlay = document.querySelector("#auth-overlay"); + const loginPassword = document.querySelector("#login-password"); + const loginError = document.querySelector("#login-error"); + + if (overlay) overlay.hidden = false; + if (loginError) { + loginError.hidden = true; + loginError.textContent = ""; + } + if (loginPassword) { + loginPassword.value = ""; + loginPassword.focus(); + } +} + +export function hideAuthOverlay() { + const overlay = document.querySelector("#auth-overlay"); + if (overlay) overlay.hidden = true; +} + +export async function checkAuth() { + try { + const res = await fetch("/api/me"); + if (res.ok) { + hideAuthOverlay(); + const logoutBtn = document.querySelector("#logout-button"); + if (logoutBtn) logoutBtn.hidden = false; + return true; + } + } catch { + // Network or server error + } + + showAuthOverlay(); + const logoutBtn = document.querySelector("#logout-button"); + if (logoutBtn) logoutBtn.hidden = true; + return false; +} + +export async function submitLogin(event) { + event.preventDefault(); + + const loginUsername = document.querySelector("#login-username"); + const loginPassword = document.querySelector("#login-password"); + const loginError = document.querySelector("#login-error"); + const loginSubmit = document.querySelector("#login-submit"); + + const username = String(loginUsername?.value || "").trim(); + const password = String(loginPassword?.value || ""); + + if (!username || !password) { + if (loginError) { + loginError.textContent = "Укажите логин и пароль."; + loginError.hidden = false; + } + return; + } + + if (loginSubmit) loginSubmit.disabled = true; + if (loginError) loginError.hidden = true; + + try { + const res = await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + const data = await readJsonResponse(res); + + if (!res.ok || !data || !data.ok) { + if (loginError) { + loginError.textContent = data?.error || "Неверный логин или пароль."; + loginError.hidden = false; + } + return; + } + + hideAuthOverlay(); + const logoutBtn = document.querySelector("#logout-button"); + if (logoutBtn) logoutBtn.hidden = false; + + // Reload page or refresh state + window.location.reload(); + } catch (err) { + if (loginError) { + loginError.textContent = err.message || "Не удалось подключиться к серверу."; + loginError.hidden = false; + } + } finally { + if (loginSubmit) loginSubmit.disabled = false; + } +} + +export async function logout() { + try { + await apiFetch("/api/logout", { method: "POST" }); + } catch { + // ignore + } + showAuthOverlay(); + const logoutBtn = document.querySelector("#logout-button"); + if (logoutBtn) logoutBtn.hidden = true; +} diff --git a/public/js/health.js b/public/js/health.js new file mode 100644 index 0000000..379c6d9 --- /dev/null +++ b/public/js/health.js @@ -0,0 +1,49 @@ +/** + * health.js — Проверка доступности сервера обработки ComfyUI + */ + +export function setHealthState(state, message, title = "") { + const healthStatus = document.querySelector("#health-status"); + const healthText = document.querySelector("#health-text"); + + if (!healthStatus || !healthText) return; + + healthStatus.className = `health-status health-${state}`; + healthStatus.title = title || message; + healthText.textContent = message; +} + +export async function checkHealth() { + try { + const response = await fetch("/api/health"); + if (!response.ok) { + setHealthState("error", "Сервер обработки недоступен"); + return; + } + + const data = await response.json(); + if (!data.comfyAvailable) { + setHealthState("error", data.message || "ComfyUI недоступен"); + return; + } + + const missing = data.missingNodes || []; + if (missing.length > 0) { + setHealthState( + "warn", + "Не хватает узлов ComfyUI", + `Отсутствуют узлы: ${missing.join(", ")}` + ); + return; + } + + setHealthState("ok", "Сервер обработки готов"); + } catch { + setHealthState("error", "Не удалось проверить сервер обработки"); + } +} + +export function startHealthPolling(intervalMs = 30000) { + checkHealth(); + return setInterval(checkHealth, intervalMs); +} diff --git a/public/js/history-tree.js b/public/js/history-tree.js new file mode 100644 index 0000000..1b3b2be --- /dev/null +++ b/public/js/history-tree.js @@ -0,0 +1,357 @@ +/** + * history-tree.js — Дерево версий, свёртка групп, выбор и удаление + */ + +import { apiFetch } from "./api.js"; +import { getState, setCurrentVersionId, setHistoryVersions } from "./state.js"; +import { + formatHistoryDate, + versionFileName, + resetResult, + updateDownloadCurrent, + showSourcePreview, + clearSourcePreview, + setJobStatus, +} from "./ui.js"; +import { rebuildReferenceSelect, clearReference, getReferenceState } from "./reference.js"; +import { updateSuggestCodexButton } from "./suggest.js"; + +function historyItemBadge(version) { + if (version.kind === "original") return "Исходник"; + if (version.kind === "upscale") return "Апскейл"; + return "Правка"; +} + +function buildChildrenMap(versions) { + const map = new Map(); + for (const version of versions) { + const key = version.parentId || ""; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(version); + } + return map; +} + +function countSubtreeSize(id, childrenMap) { + let count = 1; + const children = childrenMap.get(id); + if (children) { + for (const child of children) { + count += countSubtreeSize(child.id, childrenMap); + } + } + return count; +} + +function renderTreeNode(version, depth, childrenMap, container) { + const state = getState(); + const node = document.createElement("div"); + node.className = "history-node"; + node.style.setProperty("--depth", depth); + + if (version.id === state.currentVersionId) { + node.classList.add("is-current"); + } + + const main = document.createElement("button"); + main.type = "button"; + main.className = "history-node-main"; + main.setAttribute("aria-label", version.label || "Версия"); + main.addEventListener("click", () => selectHistoryVersion(version)); + + const thumb = document.createElement("img"); + thumb.className = "history-node-thumb"; + thumb.src = version.url; + thumb.alt = ""; + thumb.loading = "lazy"; + + const meta = document.createElement("span"); + meta.className = "history-node-meta"; + + const title = document.createElement("strong"); + title.className = "history-node-title"; + title.textContent = version.label || "Версия"; + + const badge = document.createElement("span"); + badge.className = "history-node-badge"; + badge.textContent = historyItemBadge(version); + + const date = document.createElement("span"); + date.className = "history-node-date"; + date.textContent = formatHistoryDate(version.createdAt); + + meta.append(title, badge, date); + + if (version.kind === "edit" && version.prompt) { + const prompt = document.createElement("div"); + prompt.className = "history-node-prompt"; + prompt.textContent = version.prompt; + prompt.title = version.prompt; + meta.appendChild(prompt); + } + + main.append(thumb, meta); + + const actions = document.createElement("div"); + actions.className = "history-node-actions"; + + const download = document.createElement("a"); + download.className = "history-node-action history-download"; + download.href = version.url; + download.download = versionFileName(version); + download.textContent = "Скачать"; + download.addEventListener("click", (event) => event.stopPropagation()); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "history-node-action history-delete"; + remove.textContent = "Удалить"; + remove.addEventListener("click", (event) => { + event.stopPropagation(); + deleteVersion(version); + }); + + actions.append(download, remove); + node.append(main, actions); + + if (childrenMap.has(version.id)) { + const body = document.createElement("div"); + body.className = "history-node-children"; + for (const child of childrenMap.get(version.id)) { + renderTreeNode(child, depth + 1, childrenMap, body); + } + node.appendChild(body); + } + + container.appendChild(node); +} + +export function renderHistory() { + const list = document.querySelector("#history-list"); + const empty = document.querySelector("#history-empty"); + if (!list || !empty) return; + + const state = getState(); + const versions = state.historyVersions; + + list.querySelectorAll(".history-group").forEach((group) => group.remove()); + empty.hidden = versions.length > 0; + + const childrenMap = buildChildrenMap(versions); + const roots = versions + .filter( + (version) => + version.kind === "original" || + !version.parentId || + !childrenMap.has(version.parentId) + ) + .sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || ""))); + + rebuildReferenceSelect(); + + for (const root of roots) { + const group = document.createElement("div"); + group.className = "history-group"; + group.dataset.group = root.id; + + const rootNode = document.createElement("div"); + rootNode.className = "history-node is-root"; + rootNode.style.setProperty("--depth", 0); + + if (root.id === state.currentVersionId) { + rootNode.classList.add("is-current"); + } + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "history-node-toggle"; + toggle.setAttribute("aria-label", "Свернуть ветку"); + const collapsed = state.collapsedGroups.has(root.id); + toggle.setAttribute("aria-expanded", String(!collapsed)); + toggle.textContent = collapsed ? "▸" : "▾"; + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + if (state.collapsedGroups.has(root.id)) { + state.collapsedGroups.delete(root.id); + } else { + state.collapsedGroups.set(root.id, true); + } + renderHistory(); + }); + + const rootMain = document.createElement("button"); + rootMain.type = "button"; + rootMain.className = "history-node-main"; + rootMain.setAttribute("aria-label", root.label || "Версия"); + rootMain.addEventListener("click", () => selectHistoryVersion(root)); + + const thumb = document.createElement("img"); + thumb.className = "history-node-thumb"; + thumb.src = root.url; + thumb.alt = ""; + thumb.loading = "lazy"; + + const meta = document.createElement("span"); + meta.className = "history-node-meta"; + + const title = document.createElement("strong"); + title.className = "history-node-title"; + title.textContent = root.label || "Версия"; + + const badge = document.createElement("span"); + badge.className = "history-node-badge"; + badge.textContent = historyItemBadge(root); + + const date = document.createElement("span"); + date.className = "history-node-date"; + date.textContent = formatHistoryDate(root.createdAt); + + meta.append(title, badge, date); + + if (root.kind === "edit" && root.prompt) { + const prompt = document.createElement("div"); + prompt.className = "history-node-prompt"; + prompt.textContent = root.prompt; + prompt.title = root.prompt; + meta.appendChild(prompt); + } + + rootMain.append(thumb, meta); + + const actions = document.createElement("div"); + actions.className = "history-node-actions"; + + const download = document.createElement("a"); + download.className = "history-node-action history-download"; + download.href = root.url; + download.download = versionFileName(root); + download.textContent = "Скачать"; + download.addEventListener("click", (event) => event.stopPropagation()); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "history-node-action history-delete"; + remove.textContent = "Удалить"; + remove.addEventListener("click", (event) => { + event.stopPropagation(); + deleteVersion(root); + }); + + actions.append(download, remove); + rootNode.append(toggle, rootMain, actions); + group.appendChild(rootNode); + + const count = document.createElement("span"); + count.className = "history-group-count"; + const subtreeSize = countSubtreeSize(root.id, childrenMap); + count.textContent = subtreeSize === 1 ? "1 версия" : `${subtreeSize} версий`; + group.appendChild(count); + + if (!collapsed) { + const body = document.createElement("div"); + body.className = "history-group-body"; + const children = childrenMap.get(root.id); + if (children) { + for (const child of children) { + renderTreeNode(child, 1, childrenMap, body); + } + } + group.appendChild(body); + } + + list.appendChild(group); + } +} + +export function selectHistoryVersion(version) { + const state = getState(); + if (state.isBusy) return; + + setCurrentVersionId(version.id); + + resetResult(); + updateDownloadCurrent(version); + showSourcePreview( + version.url, + version.label || "Версия", + version.width && version.height ? `${version.width}×${version.height}` : "Готово к обработке" + ); + renderHistory(); + updateSuggestCodexButton(); + setJobStatus("idle", ""); + + const editBtn = document.querySelector("#edit-button"); + const upscaleBtn = document.querySelector("#upscale-button"); + const promptInput = document.querySelector("#prompt"); + + if (upscaleBtn) upscaleBtn.disabled = false; + if (editBtn) { + editBtn.disabled = !promptInput?.value?.trim(); + } +} + +export async function refreshHistory(selectLatest = false) { + try { + const data = await apiFetch("/api/history", { cache: "no-store" }); + const versions = Array.isArray(data.versions) ? data.versions : []; + setHistoryVersions(versions); + + if (selectLatest && versions.length > 0) { + const latest = versions[versions.length - 1]; + selectHistoryVersion(latest); + } else { + renderHistory(); + updateSuggestCodexButton(); + } + } catch (err) { + console.error("Ошибка обновления истории:", err); + } +} + +export async function deleteVersion(version) { + if (!confirm("Удалить версию и все её ответвления?")) return; + + try { + const data = await apiFetch(`/api/history/${version.id}`, { + method: "DELETE", + cache: "no-store", + }); + + const versions = Array.isArray(data.versions) ? data.versions : []; + setHistoryVersions(versions); + + const state = getState(); + state.collapsedGroups.delete(version.id); + + const currentSurvives = versions.some((v) => v.id === state.currentVersionId); + if (!currentSurvives) { + const parent = version.parentId ? versions.find((v) => v.id === version.parentId) : null; + if (parent) { + selectHistoryVersion(parent); + } else if (versions.length > 0) { + setCurrentVersionId(null); + resetResult(); + updateDownloadCurrent(null); + clearSourcePreview(); + renderHistory(); + } else { + setCurrentVersionId(null); + resetResult(); + updateDownloadCurrent(null); + clearSourcePreview(); + renderHistory(); + } + } else { + renderHistory(); + } + + const refState = getReferenceState(); + if (refState?.source?.type === "history" && refState.source.versionId === version.id) { + clearReference(); + } + } catch (err) { + setJobStatus("error", err.message || "Не удалось удалить версию."); + } +} diff --git a/public/js/jobs.js b/public/js/jobs.js new file mode 100644 index 0000000..06b8555 --- /dev/null +++ b/public/js/jobs.js @@ -0,0 +1,301 @@ +/** + * jobs.js — Очередь заданий, SSE-подписка, фолбэк-поллинг и выполнение edit/upscale + */ + +import { apiFetch } from "./api.js"; +import { getState, setBusy, setCurrentVersionId } from "./state.js"; +import { setJobStatus, resetResult, updateDownloadCurrent, versionFileName } from "./ui.js"; +import { refreshHistory } from "./history-tree.js"; +import { getReferenceState } from "./reference.js"; + +let eventSource = null; +let currentTrackingJob = null; +let fallbackPollingTimer = null; + +export function initJobsEvents() { + if (window.EventSource) { + connectSSE(); + } else { + startFallbackPolling(); + } +} + +function connectSSE() { + if (eventSource) { + try { + eventSource.close(); + } catch { + // ignore + } + } + + eventSource = new EventSource("/api/events"); + + eventSource.addEventListener("snapshot", (event) => { + try { + const activeJobs = JSON.parse(event.data); + updateActiveBadge(activeJobs); + } catch { + // ignore + } + }); + + eventSource.addEventListener("job", (event) => { + try { + const jobData = JSON.parse(event.data); + handleJobEvent(jobData); + } catch { + // ignore + } + }); + + eventSource.onerror = () => { + if (eventSource) { + eventSource.close(); + eventSource = null; + } + startFallbackPolling(); + // Try reconnecting SSE after 15s + setTimeout(connectSSE, 15000); + }; +} + +function startFallbackPolling() { + if (fallbackPollingTimer) return; + fallbackPollingTimer = setInterval(async () => { + try { + const data = await apiFetch("/api/jobs"); + if (Array.isArray(data?.jobs)) { + updateActiveBadge(data.jobs); + } + } catch { + // ignore + } + }, 3000); +} + +function updateActiveBadge(jobs) { + const active = jobs.filter((j) => j.status === "queued" || j.status === "running"); + // Could update badge in topbar if needed +} + +function handleJobEvent(jobData) { + if (!currentTrackingJob || currentTrackingJob.id !== jobData.jobId) { + return; + } + + const { isEdit, onFinish } = currentTrackingJob; + + if (jobData.status === "queued") { + const pos = jobData.queuePosition ? ` (позиция ${jobData.queuePosition})` : ""; + setJobStatus("progress", `Задание ожидает в очереди${pos}…`); + } else if (jobData.status === "running") { + setJobStatus( + "progress", + isEdit + ? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты." + : "ComfyUI увеличивает разрешение… Это займёт несколько секунд." + ); + } else if (jobData.status === "done") { + currentTrackingJob = null; + onFinish(null, jobData); + } else if (jobData.status === "interrupted") { + currentTrackingJob = null; + const err = new Error( + jobData.error || "Задание прервано перезапуском сервера. Запустите заново." + ); + err.interrupted = true; + onFinish(err); + } else if (jobData.status === "error") { + currentTrackingJob = null; + onFinish(new Error(jobData.error || "Ошибка при выполнении задания.")); + } +} + +export async function runJob(kind) { + const state = getState(); + const isEdit = kind === "edit"; + + if (!state.currentVersionId || state.isBusy) { + return; + } + + const promptInput = document.querySelector("#prompt"); + const prompt = String(promptInput?.value || "").trim(); + + if (isEdit && !prompt) { + setJobStatus("error", "Введите текст промпта."); + return; + } + + setBusy(true); + resetResult(); + setJobStatus("progress", "Отправка задания на сервер…"); + + let lastJobParams = null; + + try { + let res; + if (isEdit) { + const formData = new FormData(); + formData.append("sourceId", state.currentVersionId); + formData.append("prompt", prompt); + + const refState = getReferenceState(); + if (refState?.source?.type === "file" && refState.source.file) { + formData.append("reference", refState.source.file); + } else if (refState?.source?.type === "history" && refState.source.versionId) { + formData.append("referenceId", refState.source.versionId); + } + + if (refState?.region) { + formData.append("region", JSON.stringify(refState.region)); + } + + lastJobParams = { kind, sourceId: state.currentVersionId, prompt, refState }; + + res = await apiFetch("/api/edit", { + method: "POST", + body: formData, + }); + } else { + lastJobParams = { kind, sourceId: state.currentVersionId }; + + res = await apiFetch("/api/upscale", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId: state.currentVersionId }), + }); + } + + const jobId = res.jobId; + + if (res.status === "queued") { + const pos = res.queuePosition ? ` (позиция ${res.queuePosition})` : ""; + setJobStatus("progress", `Задание добавлено в очередь${pos}…`); + } else { + setJobStatus( + "progress", + isEdit + ? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты." + : "ComfyUI увеличивает разрешение… Это займёт несколько секунд." + ); + } + + // Wait for completion via SSE or fallback polling + await new Promise((resolve, reject) => { + currentTrackingJob = { + id: jobId, + isEdit, + onFinish: (err, resultData) => { + if (err) reject(err); + else resolve(resultData); + }, + }; + + // Fallback polling loop in case SSE is blocked or slow + pollJobStatus(jobId, isEdit); + }); + + // Fetch full job status to get version and details + const finalJob = await apiFetch(`/api/jobs/${jobId}/status`); + renderJobSuccess(finalJob, isEdit); + } catch (err) { + if (err.interrupted) { + setJobStatus( + "error", + "Задание прервано перезапуском сервера. Нажмите кнопку, чтобы повторить попытку." + ); + } else { + setJobStatus("error", err.message || "Ошибка при выполнении задания."); + } + } finally { + setBusy(false); + } +} + +async function pollJobStatus(jobId, isEdit) { + let attempts = 0; + const poll = async () => { + if (!currentTrackingJob || currentTrackingJob.id !== jobId) { + return; + } + + try { + const data = await apiFetch(`/api/jobs/${jobId}/status`); + if (!currentTrackingJob || currentTrackingJob.id !== jobId) return; + + if (data.status === "queued") { + const pos = data.queuePosition ? ` (позиция ${data.queuePosition})` : ""; + setJobStatus("progress", `Задание ожидает в очереди${pos}…`); + setTimeout(poll, 2000); + } else if (data.status === "running") { + setJobStatus( + "progress", + isEdit + ? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты." + : "ComfyUI увеличивает разрешение… Это займёт несколько секунд." + ); + setTimeout(poll, 2000); + } else if (data.status === "done") { + const cb = currentTrackingJob?.onFinish; + currentTrackingJob = null; + if (cb) cb(null, data); + } else if (data.status === "interrupted") { + const cb = currentTrackingJob?.onFinish; + currentTrackingJob = null; + const err = new Error( + data.error || "Задание прервано перезапуском сервера. Запустите заново." + ); + err.interrupted = true; + if (cb) cb(err); + } else if (data.status === "error") { + const cb = currentTrackingJob?.onFinish; + currentTrackingJob = null; + if (cb) cb(new Error(data.error || "Ошибка при выполнении задания.")); + } else { + setTimeout(poll, 2000); + } + } catch { + attempts++; + if (attempts < 30) { + setTimeout(poll, 2500); + } + } + }; + + setTimeout(poll, 2000); +} + +export function renderJobSuccess(data, isEdit) { + const version = data.result?.version; + const resultImage = document.querySelector("#result-image"); + const resultPlaceholder = document.querySelector("#result-placeholder"); + const resultLabel = document.querySelector("#result-label"); + const downloadLink = document.querySelector("#download-link"); + + if (!version) { + setJobStatus("error", "Задание завершено, но версия не создана."); + return; + } + + setJobStatus("idle", ""); + + if (resultImage) { + resultImage.src = version.url; + resultImage.hidden = false; + } + if (resultPlaceholder) resultPlaceholder.hidden = true; + if (resultLabel) { + resultLabel.textContent = isEdit ? "Codex" : "4× Upscale"; + } + + if (downloadLink) { + downloadLink.href = version.url; + downloadLink.download = versionFileName(version); + downloadLink.hidden = false; + } + + // Refresh history tree and select newly created version + refreshHistory(true); +} diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..ff73aad --- /dev/null +++ b/public/js/main.js @@ -0,0 +1,77 @@ +/** + * main.js — Главная точка входа клиентского приложения «Кадр» v2 + */ + +import { checkAuth, submitLogin, logout } from "./auth.js"; +import { startHealthPolling } from "./health.js"; +import { initUpload } from "./upload.js"; +import { initReference } from "./reference.js"; +import { initSuggest } from "./suggest.js"; +import { initJobsEvents, runJob } from "./jobs.js"; +import { refreshHistory } from "./history-tree.js"; +import { subscribe, getState } from "./state.js"; + +function setupButtonsAndInputs() { + const loginForm = document.querySelector("#login-form"); + const logoutBtn = document.querySelector("#logout-button"); + const editBtn = document.querySelector("#edit-button"); + const upscaleBtn = document.querySelector("#upscale-button"); + const promptInput = document.querySelector("#prompt"); + const promptCounter = document.querySelector("#prompt-counter"); + + if (loginForm) { + loginForm.addEventListener("submit", submitLogin); + } + + if (logoutBtn) { + logoutBtn.addEventListener("click", logout); + } + + if (promptInput) { + promptInput.addEventListener("input", () => { + const val = promptInput.value; + if (promptCounter) { + promptCounter.textContent = `${val.length} / 1000`; + } + const state = getState(); + if (editBtn) { + editBtn.disabled = !state.currentVersionId || !val.trim() || state.isBusy; + } + }); + } + + if (editBtn) { + editBtn.addEventListener("click", () => runJob("edit")); + } + + if (upscaleBtn) { + upscaleBtn.addEventListener("click", () => runJob("upscale")); + } + + // Subscribe to state changes to update button states + subscribe((state, changeKey) => { + if (changeKey === "isBusy" || changeKey === "currentVersionId") { + const hasVersion = Boolean(state.currentVersionId); + const promptVal = promptInput?.value?.trim() || ""; + + if (upscaleBtn) upscaleBtn.disabled = !hasVersion || state.isBusy; + if (editBtn) editBtn.disabled = !hasVersion || !promptVal || state.isBusy; + } + }); +} + +async function init() { + setupButtonsAndInputs(); + initUpload(); + initReference(); + initSuggest(); + startHealthPolling(); + + const authenticated = await checkAuth(); + if (authenticated) { + initJobsEvents(); + await refreshHistory(true); + } +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/public/js/reference.js b/public/js/reference.js new file mode 100644 index 0000000..da57600 --- /dev/null +++ b/public/js/reference.js @@ -0,0 +1,322 @@ +/** + * reference.js — Выбор референса, канвас и выделение области + */ + +import { getState, setReference, clearReferenceState } from "./state.js"; +import { isHeicFile, setJobStatus, MAX_FILE_SIZE } from "./ui.js"; + +const REFERENCE_MAX_DIMENSION = 1600; + +let referenceState = null; +let regionDragStart = null; + +export function getReferenceState() { + return referenceState; +} + +export function clearReference() { + if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { + URL.revokeObjectURL(referenceState.source.objectUrl); + } + + referenceState = null; + clearReferenceState(); + + const preview = document.querySelector("#reference-preview"); + const canvas = document.querySelector("#reference-canvas"); + const note = document.querySelector("#reference-note"); + const regionInfo = document.querySelector("#reference-region-info"); + const historySelect = document.querySelector("#reference-history-select"); + + if (preview) preview.hidden = true; + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + if (note) note.hidden = true; + if (regionInfo) regionInfo.textContent = "Область не выбрана — потяните по превью"; + if (historySelect) historySelect.value = ""; +} + +function drawRegion(ctx) { + const region = referenceState?.region; + if (!region) return; + + ctx.strokeStyle = "rgba(220,38,38,0.95)"; + ctx.lineWidth = 3; + ctx.fillStyle = "rgba(220,38,38,0.18)"; + ctx.fillRect(region.x, region.y, region.w, region.h); + ctx.strokeRect(region.x, region.y, region.w, region.h); +} + +function drawReferenceToCanvas(image) { + const canvas = document.querySelector("#reference-canvas"); + if (!canvas) return; + + const naturalWidth = image.naturalWidth || image.width; + const naturalHeight = image.naturalHeight || image.height; + + if (!naturalWidth || !naturalHeight) return; + + const scale = Math.min( + 1, + REFERENCE_MAX_DIMENSION / Math.max(naturalWidth, naturalHeight) + ); + + canvas.width = Math.round(naturalWidth * scale); + canvas.height = Math.round(naturalHeight * scale); + + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + drawRegion(ctx); +} + +function redrawReferenceCanvas() { + const canvas = document.querySelector("#reference-canvas"); + const image = referenceState?.source?.image; + + if (!canvas || !image || !canvas.width) return; + + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + drawRegion(ctx); + updateReferenceNote(); +} + +function updateReferenceNote() { + const note = document.querySelector("#reference-note"); + const regionInfo = document.querySelector("#reference-region-info"); + + if (!referenceState) { + if (note) note.hidden = true; + if (regionInfo) regionInfo.textContent = "Область не выбрана — потяните по превью"; + return; + } + + if (referenceState.region) { + const r = referenceState.region; + if (note) note.hidden = true; + if (regionInfo) { + regionInfo.textContent = `Область: ${Math.round(r.x)},${Math.round(r.y)} ${Math.round(r.w)}×${Math.round(r.h)}`; + } + } else { + if (note) { + note.textContent = "Область не выбрана — Codex использует референс как ориентир"; + note.hidden = false; + } + if (regionInfo) regionInfo.textContent = "Область не выбрана — потяните по превью"; + } +} + +function canvasPointFromEvent(canvas, event) { + const rect = canvas.getBoundingClientRect(); + const x = (event.clientX - rect.left) * (canvas.width / rect.width); + const y = (event.clientY - rect.top) * (canvas.height / rect.height); + + return { + x: Math.min(Math.max(0, x), canvas.width), + y: Math.min(Math.max(0, y), canvas.height), + }; +} + +export function loadReferenceFromFile(file) { + if (!file) return; + + const note = document.querySelector("#reference-note"); + const preview = document.querySelector("#reference-preview"); + + const fileType = String(file.type || "").toLowerCase(); + + if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(fileType)) { + if (isHeicFile(file)) { + if (note) { + note.textContent = "HEIC не поддерживается как референс — выберите JPEG, PNG или WebP"; + note.hidden = false; + } + } else { + setJobStatus("error", "Референс должен быть JPEG, PNG или WebP."); + } + return; + } + + if (file.size > MAX_FILE_SIZE) { + setJobStatus("error", "Файл референса слишком большой. Максимальный размер — 25 МБ."); + return; + } + + if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { + URL.revokeObjectURL(referenceState.source.objectUrl); + } + + const objectUrl = URL.createObjectURL(file); + referenceState = { source: { type: "file", file, objectUrl }, region: null }; + + const image = new Image(); + image.onload = () => { + referenceState.source.image = image; + drawReferenceToCanvas(image); + if (preview) preview.hidden = false; + updateReferenceNote(); + setReference({ file, versionId: null, image, region: null }); + }; + image.onerror = () => { + URL.revokeObjectURL(objectUrl); + referenceState = null; + clearReferenceState(); + setJobStatus("error", "Не удалось прочитать изображение референса."); + }; + image.src = objectUrl; +} + +export function loadReferenceFromHistory(version) { + if (!version) return; + + if (referenceState?.source?.type === "file" && referenceState.source.objectUrl) { + URL.revokeObjectURL(referenceState.source.objectUrl); + } + + const preview = document.querySelector("#reference-preview"); + + referenceState = { + source: { type: "history", versionId: version.id, url: version.url }, + region: null, + }; + + const image = new Image(); + image.onload = () => { + referenceState.source.image = image; + drawReferenceToCanvas(image); + if (preview) preview.hidden = false; + updateReferenceNote(); + setReference({ file: null, versionId: version.id, image, region: null }); + }; + image.onerror = () => { + referenceState = null; + clearReferenceState(); + setJobStatus("error", "Не удалось загрузить референс из истории."); + }; + image.src = version.url; +} + +export function rebuildReferenceSelect() { + const historySelect = document.querySelector("#reference-history-select"); + if (!historySelect) return; + + const currentVal = historySelect.value; + historySelect.innerHTML = ''; + + const state = getState(); + for (const v of state.historyVersions) { + const opt = document.createElement("option"); + opt.value = v.id; + opt.textContent = `${v.label} (${v.id.slice(0, 8)})`; + historySelect.appendChild(opt); + } + + if (currentVal && state.historyVersions.some((v) => v.id === currentVal)) { + historySelect.value = currentVal; + } +} + +export function initReference() { + const uploadBtn = document.querySelector("#reference-upload-button"); + const fileInput = document.querySelector("#reference-file-input"); + const clearBtn = document.querySelector("#reference-clear-button"); + const historySelect = document.querySelector("#reference-history-select"); + const canvas = document.querySelector("#reference-canvas"); + const regionReset = document.querySelector("#reference-region-reset"); + + if (uploadBtn && fileInput) { + uploadBtn.addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", (e) => { + const file = e.target.files?.[0]; + if (file) { + if (historySelect) historySelect.value = ""; + loadReferenceFromFile(file); + } + fileInput.value = ""; + }); + } + + if (clearBtn) { + clearBtn.addEventListener("click", () => clearReference()); + } + + if (historySelect) { + historySelect.addEventListener("change", () => { + const vid = historySelect.value; + if (!vid) { + clearReference(); + return; + } + const state = getState(); + const version = state.historyVersions.find((v) => v.id === vid); + if (version) { + loadReferenceFromHistory(version); + } + }); + } + + if (regionReset) { + regionReset.addEventListener("click", () => { + if (referenceState) { + referenceState.region = null; + redrawReferenceCanvas(); + updateReferenceNote(); + setReference({ region: null }); + } + }); + } + + if (canvas) { + canvas.addEventListener("pointerdown", (event) => { + const state = getState(); + if (state.isBusy || !referenceState) return; + + event.preventDefault(); + canvas.setPointerCapture(event.pointerId); + + const pos = canvasPointFromEvent(canvas, event); + regionDragStart = pos; + referenceState.region = { x: pos.x, y: pos.y, w: 0, h: 0 }; + redrawReferenceCanvas(); + updateReferenceNote(); + }); + + canvas.addEventListener("pointermove", (event) => { + const state = getState(); + if (state.isBusy || !referenceState || !regionDragStart) return; + + const pos = canvasPointFromEvent(canvas, event); + const x = Math.min(regionDragStart.x, pos.x); + const y = Math.min(regionDragStart.y, pos.y); + const w = Math.max(regionDragStart.x, pos.x) - x; + const h = Math.max(regionDragStart.y, pos.y) - y; + + referenceState.region = { x, y, w, h }; + redrawReferenceCanvas(); + updateReferenceNote(); + }); + + canvas.addEventListener("pointerup", () => { + if (regionDragStart) { + regionDragStart = null; + if ( + referenceState?.region && + (referenceState.region.w < 3 || referenceState.region.h < 3) + ) { + referenceState.region = null; + redrawReferenceCanvas(); + updateReferenceNote(); + } + setReference({ region: referenceState?.region || null }); + } + }); + + canvas.addEventListener("pointercancel", () => { + regionDragStart = null; + }); + } +} diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 0000000..0a36e5c --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,74 @@ +/** + * state.js — Единое реактивное состояние редактора «Кадр» + */ + +const state = { + currentVersionId: null, + historyVersions: [], + collapsedGroups: new Map(), // groupId (id корня) -> true (свёрнута) + reference: { + file: null, + versionId: null, + image: null, + region: null, + }, + isBusy: false, + activeJobs: new Map(), + suggestRunning: false, +}; + +const listeners = new Set(); + +export function subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function notify(changeKey) { + for (const listener of listeners) { + try { + listener(state, changeKey); + } catch (err) { + console.error("State listener error:", err); + } + } +} + +export function getState() { + return state; +} + +export function setCurrentVersionId(id) { + state.currentVersionId = id; + notify("currentVersionId"); +} + +export function setHistoryVersions(versions) { + state.historyVersions = Array.isArray(versions) ? versions : []; + notify("historyVersions"); +} + +export function setBusy(busy) { + state.isBusy = Boolean(busy); + notify("isBusy"); +} + +export function setSuggestRunning(running) { + state.suggestRunning = Boolean(running); + notify("suggestRunning"); +} + +export function setReference(refData) { + state.reference = { ...state.reference, ...refData }; + notify("reference"); +} + +export function clearReferenceState() { + state.reference = { + file: null, + versionId: null, + image: null, + region: null, + }; + notify("reference"); +} diff --git a/public/js/suggest.js b/public/js/suggest.js new file mode 100644 index 0000000..ad07486 --- /dev/null +++ b/public/js/suggest.js @@ -0,0 +1,187 @@ +/** + * suggest.js — Подсказки промптов из истории и генерация через Codex + */ + +import { apiFetch } from "./api.js"; +import { getState, setSuggestRunning } from "./state.js"; + +function applyPrompt(promptText) { + const promptInput = document.querySelector("#prompt"); + const promptCounter = document.querySelector("#prompt-counter"); + const editButton = document.querySelector("#edit-button"); + + if (!promptInput) return; + + promptInput.value = promptText; + if (promptCounter) { + promptCounter.textContent = `${promptText.length} / 1000`; + } + + const state = getState(); + if (editButton) { + editButton.disabled = !state.currentVersionId || !promptText.trim() || state.isBusy; + } +} + +export function renderSuggestChips(container, items) { + if (!container) return; + container.innerHTML = ""; + + for (const item of items) { + const text = String(item || "").trim(); + if (!text) continue; + + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "suggest-chip"; + chip.textContent = text; + chip.addEventListener("click", () => applyPrompt(text)); + container.appendChild(chip); + } +} + +export async function loadSuggestHistoryChips() { + const container = document.querySelector("#suggest-history-chips"); + if (!container) return; + + try { + const res = await apiFetch("/api/suggest-prompts"); + const prompts = Array.isArray(res?.prompts) ? res.prompts : []; + if (prompts.length === 0) { + container.innerHTML = 'В истории пока нет промптов'; + return; + } + renderSuggestChips(container, prompts); + } catch { + container.innerHTML = 'Не удалось загрузить подсказки'; + } +} + +export function updateSuggestCodexButton() { + const suggestBtn = document.querySelector("#suggest-button"); + const codexBtn = document.querySelector("#suggest-codex-button"); + const state = getState(); + + const canSuggest = Boolean(state.currentVersionId) && !state.isBusy; + if (suggestBtn) { + suggestBtn.disabled = !canSuggest; + } + if (codexBtn) { + codexBtn.disabled = !canSuggest || state.suggestRunning; + } +} + +export async function runSuggest() { + const state = getState(); + if (!state.currentVersionId || state.suggestRunning || state.isBusy) { + return; + } + + const statusEl = document.querySelector("#suggest-codex-status"); + const chipsContainer = document.querySelector("#suggest-codex-chips"); + const codexBtn = document.querySelector("#suggest-codex-button"); + + setSuggestRunning(true); + updateSuggestCodexButton(); + + if (statusEl) statusEl.textContent = "Codex анализирует кадр…"; + if (chipsContainer) chipsContainer.innerHTML = ""; + + try { + const res = await apiFetch("/api/suggest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId: state.currentVersionId }), + }); + + const jobId = res.jobId; + await pollSuggestJob(jobId); + } catch (err) { + if (statusEl) statusEl.textContent = `Ошибка: ${err.message}`; + setSuggestRunning(false); + updateSuggestCodexButton(); + } +} + +async function pollSuggestJob(jobId) { + const statusEl = document.querySelector("#suggest-codex-status"); + const chipsContainer = document.querySelector("#suggest-codex-chips"); + + let failures = 0; + + const poll = async () => { + try { + const data = await apiFetch(`/api/jobs/${jobId}/status`); + failures = 0; + + if (data.status === "queued") { + if (statusEl) { + statusEl.textContent = data.queuePosition + ? `В очереди (позиция ${data.queuePosition})…` + : "В очереди…"; + } + setTimeout(poll, 1500); + return; + } + + if (data.status === "running") { + if (statusEl) statusEl.textContent = "Codex генерирует варианты…"; + setTimeout(poll, 1500); + return; + } + + if (data.status === "done") { + if (statusEl) statusEl.textContent = ""; + const suggestions = data.result?.suggestions || []; + if (suggestions.length > 0) { + renderSuggestChips(chipsContainer, suggestions); + } else { + if (statusEl) statusEl.textContent = "Codex не вернул вариантов."; + } + setSuggestRunning(false); + updateSuggestCodexButton(); + return; + } + + if (data.status === "error" || data.status === "interrupted") { + if (statusEl) statusEl.textContent = `Ошибка: ${data.error || "Не удалось получить подсказки."}`; + setSuggestRunning(false); + updateSuggestCodexButton(); + return; + } + + setTimeout(poll, 1500); + } catch (err) { + failures++; + if (failures >= 5) { + if (statusEl) statusEl.textContent = `Ошибка связи с сервером: ${err.message}`; + setSuggestRunning(false); + updateSuggestCodexButton(); + return; + } + setTimeout(poll, 2000); + } + }; + + poll(); +} + +export function initSuggest() { + const suggestBtn = document.querySelector("#suggest-button"); + const suggestPanel = document.querySelector("#suggest-panel"); + const codexBtn = document.querySelector("#suggest-codex-button"); + + if (suggestBtn && suggestPanel) { + suggestBtn.addEventListener("click", () => { + const isHidden = suggestPanel.hidden; + suggestPanel.hidden = !isHidden; + if (isHidden) { + loadSuggestHistoryChips(); + } + }); + } + + if (codexBtn) { + codexBtn.addEventListener("click", runSuggest); + } +} diff --git a/public/js/ui.js b/public/js/ui.js new file mode 100644 index 0000000..de81d92 --- /dev/null +++ b/public/js/ui.js @@ -0,0 +1,138 @@ +/** + * ui.js — Утилиты форматирования, отображения статусов и DOM-элементов + */ + +export const MAX_FILE_SIZE = 25 * 1024 * 1024; +export const ACCEPTED_TYPES = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/heic", + "image/heif", +]); + +export function isHeicFile(file) { + const type = String(file?.type || "").toLowerCase(); + const name = String(file?.name || "").toLowerCase(); + return ( + type === "image/heic" || + type === "image/heif" || + name.endsWith(".heic") || + name.endsWith(".heif") + ); +} + +export function formatFileSize(bytes) { + if (!bytes || bytes <= 0) return ""; + const units = ["Б", "КБ", "МБ", "ГБ"]; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; +} + +export function versionFileName(version) { + const dateStr = version?.createdAt + ? new Date(version.createdAt).toISOString().replace(/[:.]/g, "-").slice(0, 19) + : "photo"; + const ext = version?.ext || "jpg"; + const label = version?.label ? version.label.replace(/[^\p{L}\p{N}._-]+/gu, "-") : "version"; + return `kadr-${label}-${dateStr}.${ext}`; +} + +export function formatHistoryDate(iso) { + if (!iso) return ""; + try { + const d = new Date(iso); + const date = d.toLocaleDateString("ru-RU", { + day: "numeric", + month: "short", + }); + const time = d.toLocaleTimeString("ru-RU", { + hour: "2-digit", + minute: "2-digit", + }); + return `${date}, ${time}`; + } catch { + return iso; + } +} + +export function setJobStatus(kind, message) { + const jobStatus = document.querySelector("#job-status"); + const jobStatusText = document.querySelector("#job-status-text"); + + if (!jobStatus || !jobStatusText) return; + + if (kind === "idle") { + jobStatus.hidden = true; + jobStatus.classList.remove("status-error"); + jobStatusText.textContent = ""; + return; + } + + jobStatus.hidden = false; + jobStatus.classList.toggle("status-error", kind === "error"); + jobStatusText.textContent = message; +} + +export function resetResult() { + const resultImage = document.querySelector("#result-image"); + const resultPlaceholder = document.querySelector("#result-placeholder"); + const resultLabel = document.querySelector("#result-label"); + const downloadLink = document.querySelector("#download-link"); + + if (resultImage) { + resultImage.hidden = true; + resultImage.removeAttribute("src"); + } + if (resultPlaceholder) resultPlaceholder.hidden = false; + if (resultLabel) resultLabel.textContent = ""; + if (downloadLink) downloadLink.hidden = true; +} + +export function clearSourcePreview() { + const sourceImage = document.querySelector("#source-image"); + const sourcePlaceholder = document.querySelector("#source-placeholder"); + const downloadCurrent = document.querySelector("#download-current"); + + if (sourceImage) { + sourceImage.hidden = true; + sourceImage.removeAttribute("src"); + } + if (sourcePlaceholder) sourcePlaceholder.hidden = false; + if (downloadCurrent) downloadCurrent.hidden = true; +} + +export function showSourcePreview(url, title, meta) { + const sourceImage = document.querySelector("#source-image"); + const sourcePlaceholder = document.querySelector("#source-placeholder"); + const dropTitle = document.querySelector("#drop-title"); + const fileMeta = document.querySelector("#file-meta"); + + if (sourceImage) { + sourceImage.src = url; + sourceImage.hidden = false; + } + if (sourcePlaceholder) sourcePlaceholder.hidden = true; + if (dropTitle && title) dropTitle.textContent = title; + if (fileMeta && meta) fileMeta.textContent = meta; +} + +export function updateDownloadCurrent(version) { + const downloadCurrent = document.querySelector("#download-current"); + if (!downloadCurrent) return; + + if (!version || !version.url) { + downloadCurrent.hidden = true; + return; + } + + downloadCurrent.href = version.url; + downloadCurrent.download = versionFileName(version); + downloadCurrent.hidden = false; +} diff --git a/public/js/upload.js b/public/js/upload.js new file mode 100644 index 0000000..5357679 --- /dev/null +++ b/public/js/upload.js @@ -0,0 +1,123 @@ +/** + * upload.js — Загрузка и импорт фотографий через drag-and-drop или file picker + */ + +import { apiFetch } from "./api.js"; +import { getState, setBusy } from "./state.js"; +import { setJobStatus, isHeicFile, ACCEPTED_TYPES, MAX_FILE_SIZE } from "./ui.js"; +import { refreshHistory } from "./history-tree.js"; + +export async function importPhoto(file) { + const form = new FormData(); + form.append("photo", file, file.name); + + const data = await apiFetch("/api/history/import", { + method: "POST", + body: form, + cache: "no-store", + }); + + return data.version; +} + +export async function handleFiles(fileList) { + const files = Array.from(fileList || []).filter((file) => { + const fileType = String(file.type || "").toLowerCase(); + + if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) { + setJobStatus("error", "Поддерживаются только фотографии JPEG, PNG, WebP или HEIC."); + return false; + } + + if (file.size > MAX_FILE_SIZE) { + setJobStatus("error", "Файл слишком большой. Максимальный размер — 25 МБ."); + return false; + } + + return true; + }); + + if (files.length === 0) return; + + setBusy(true); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + setJobStatus( + "progress", + files.length > 1 + ? `Загрузка ${i + 1} из ${files.length}…` + : "Загружаем фотографию в историю…" + ); + + try { + await importPhoto(file); + } catch (error) { + setBusy(false); + setJobStatus( + "error", + error instanceof Error ? error.message : "Не удалось загрузить фотографию." + ); + return; + } + } + + await refreshHistory(true); + setJobStatus("idle", ""); + setBusy(false); +} + +export function initUpload() { + const dropZone = document.querySelector("#drop-zone"); + const photoInput = document.querySelector("#photo-input"); + + if (dropZone && photoInput) { + dropZone.addEventListener("click", () => { + const state = getState(); + if (!state.isBusy) { + photoInput.click(); + } + }); + + dropZone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + const state = getState(); + if (!state.isBusy) { + photoInput.click(); + } + } + }); + + photoInput.addEventListener("change", (event) => { + handleFiles(event.target.files); + photoInput.value = ""; + }); + + const preventDefaults = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + ["dragenter", "dragover", "dragleave", "drop"].forEach((evt) => { + dropZone.addEventListener(evt, preventDefaults, false); + document.body.addEventListener(evt, preventDefaults, false); + }); + + ["dragenter", "dragover"].forEach((evt) => { + dropZone.addEventListener(evt, () => dropZone.classList.add("dragover"), false); + }); + + ["dragleave", "drop"].forEach((evt) => { + dropZone.addEventListener(evt, () => dropZone.classList.remove("dragover"), false); + }); + + dropZone.addEventListener("drop", (event) => { + const state = getState(); + if (!state.isBusy && event.dataTransfer?.files) { + handleFiles(event.dataTransfer.files); + } + }); + } +} diff --git a/server.js b/server.js index b90b281..dbdb7c6 100644 --- a/server.js +++ b/server.js @@ -1,2288 +1,12 @@ "use strict"; -const crypto = require("crypto"); -const path = require("path"); -const { Readable } = require("stream"); -const { pipeline } = require("stream/promises"); +// Тонкий bootstrap: вся логика — в src/server.js. +// startServer() вызывается здесь явно, потому что внутри src/server.js +// условие require.main === module ложно при запуске этого файла +// (главным модулем процесса является именно server.js). +const { startServer } = require("./src/server"); -const fs = require("fs"); -const { spawn } = require("child_process"); - -const express = require("express"); -const multer = require("multer"); -const convert = require("heic-convert"); - -const COMFY_URL = process.env.COMFY_URL || "http://192.168.31.240:8188"; -const CHECKPOINT = "sdxl_turbo.safetensors"; -const PORT_CANDIDATES = process.env.PORT - ? [Number(process.env.PORT)] - : [3000, 8080, 8090]; - -const POLL_INTERVAL_MS = 1500; -const JOB_TIMEOUT_MS = 300000; -const REQUEST_TIMEOUT_MS = 15000; -const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; - -const UPSCALE_MAX_DIMENSION = 2048; - -const CODEX_JOBS_DIR = path.join(__dirname, ".codex-jobs"); -const CODEX_EDIT_TIMEOUT_MS = 420000; -const CODEX_EXE = resolveCodexExe(); - -const HISTORY_DIR = path.join(__dirname, "history"); -const HISTORY_MANIFEST = path.join(HISTORY_DIR, "history.json"); -const HISTORY_LIMIT = 50; - -const REQUIRED_NODES = [ - "CheckpointLoaderSimple", - "CLIPTextEncode", - "LoadImage", - "ImageScale", - "VAEEncode", - "KSampler", - "VAEDecode", - "SaveImage", - "UpscaleModelLoader", - "ImageUpscaleWithModel" -]; - -// --- Логирование ------------------------------------------------- -const LOG_LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }; -const LOG_THRESHOLD = - LOG_LEVELS[String(process.env.LOG_LEVEL || "info").toLowerCase()] ?? - LOG_LEVELS.info; - -function log(level, message, fields) { - const numeric = LOG_LEVELS[level]; - if (numeric === undefined || numeric < LOG_THRESHOLD) { - return; - } - const line = - `${new Date().toISOString()} [${level.toUpperCase()}] ${message}` + - (fields === undefined ? "" : ` ${JSON.stringify(fields)}`); - if (numeric >= LOG_LEVELS.warn) { - console.error(line); - } else { - console.log(line); - } -} - -function singleLine(text) { - return String(text ?? "").replace(/[\r\n\t]+/g, " ").trim(); -} - -function truncate(text, maxLength) { - const clean = singleLine(text); - return clean.length > maxLength - ? `${clean.slice(0, maxLength)}…` - : clean; -} - -const app = express(); - -app.disable("x-powered-by"); -// Статика без длинного кэша: index.html должен всегда получать свежие -// версии app.js/style.css (в HTML они подключены с ?v=...). -app.use(express.static(path.join(__dirname, "public"), { - extensions: ["html"], - maxAge: 0 -})); - -let codexEditInFlight = false; - -// JSON-тело нужно для POST /api/login; multipart обрабатывает multer. -app.use(express.json({ limit: "16kb" })); - -app.use((request, response, next) => { - const startedAt = Date.now(); - response.on("finish", () => { - const level = - request.originalUrl.startsWith("/api/health") || - request.originalUrl.startsWith("/api/jobs/") - ? "debug" - : "info"; - log(level, `${request.method} ${request.originalUrl} -> ${response.statusCode} ${Date.now() - startedAt}ms`); - }); - next(); -}); - -class AppError extends Error { - constructor(status, message, details = undefined) { - super(message); - this.name = "AppError"; - this.status = status; - this.details = details; - } -} - -// --- Авторизация ------------------------------------------------- -const SESSION_COOKIE = "kadr_session"; -const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 суток - -const APP_USER = String(process.env.APP_USER || "").trim() || "admin"; -const APP_PASSWORD = - process.env.APP_PASSWORD && String(process.env.APP_PASSWORD).length > 0 - ? String(process.env.APP_PASSWORD) - : crypto.randomBytes(12).toString("base64url"); // 16 символов - -if (!process.env.APP_PASSWORD) { - console.log(""); - console.log("========================================================"); - console.log(" APP_PASSWORD не задан. Сгенерирован случайный пароль:"); - console.log(` логин: ${APP_USER}`); - console.log(` пароль: ${APP_PASSWORD}`); - console.log(" Задайте переменные окружения APP_USER / APP_PASSWORD,"); - console.log(" чтобы зафиксировать пароль."); - console.log("========================================================"); - console.log(""); -} - -// token -> { user, expiresAt } -const sessions = new Map(); - -// jobId -> { type, status, startedAt, finishedAt, payload, error, details } -const jobs = new Map(); - -setInterval(() => { - const now = Date.now(); - for (const [id, job] of jobs) { - if (job.finishedAt && now - job.finishedAt > 60 * 60 * 1000) { - jobs.delete(id); - } - } -}, 30 * 60 * 1000).unref(); - -function constantTimeEqual(a, b) { - const digestA = crypto.createHash("sha256").update(String(a), "utf8").digest(); - const digestB = crypto.createHash("sha256").update(String(b), "utf8").digest(); - return crypto.timingSafeEqual(digestA, digestB); -} - -function makeSessionToken() { - return crypto.randomBytes(32).toString("hex"); -} - -function sessionCookieOptions() { - return { - httpOnly: true, - sameSite: "lax", - path: "/", - maxAge: SESSION_TTL_MS - // secure не задаём: приложение работает по HTTP в LAN - }; -} - -function readSessionToken(request) { - const header = String(request.headers.cookie || ""); - for (const part of header.split(";")) { - const eq = part.indexOf("="); - if (eq === -1) continue; - const name = part.slice(0, eq).trim(); - if (name === SESSION_COOKIE) { - const value = part.slice(eq + 1).trim(); - try { return decodeURIComponent(value); } catch { return value; } - } - } - return null; -} - -function getSession(request) { - const token = readSessionToken(request); - if (!token) return null; - const session = sessions.get(token); - if (!session) return null; - if (session.expiresAt < Date.now()) { - sessions.delete(token); - return null; - } - return { token, session }; -} - -function requireAuth(request, response, next) { - const found = getSession(request); - if (!found) { - next(new AppError(401, "Требуется авторизация.")); - return; - } - // Sliding: продлеваем сессию при каждой активности - found.session.expiresAt = Date.now() + SESSION_TTL_MS; - response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions()); - next(); -} - -setInterval(() => { - const now = Date.now(); - for (const [token, session] of sessions) { - if (session.expiresAt < now) sessions.delete(token); - } -}, 60 * 60 * 1000).unref(); - -function isHeic(file) { - const mimetype = String(file?.mimetype || "").toLowerCase(); - const extension = path.extname( - String(file?.originalname || "") - ).toLowerCase(); - - return ( - mimetype === "image/heic" || - mimetype === "image/heif" || - extension === ".heic" || - extension === ".heif" - ); -} - -const upload = multer({ - storage: multer.memoryStorage(), - limits: { - fileSize: MAX_UPLOAD_BYTES, - // /api/edit принимает два файла (photo + reference); остальные - // маршруты (preview, history/import, upscale, suggest) шлют по одному. - files: 2, - // /api/edit шлёт sourceId, prompt, region, referenceId. - fields: 8 - }, - fileFilter: (_request, file, callback) => { - const supported = new Set([ - "image/jpeg", - "image/jpg", - "image/png", - "image/webp", - "image/heic", - "image/heif" - ]); - - if ( - !supported.has(String(file.mimetype || "").toLowerCase()) && - !isHeic(file) - ) { - callback(new AppError( - 400, - "Поддерживаются фотографии в форматах JPEG, PNG, WebP или HEIC." - )); - return; - } - - callback(null, true); - } -}); - -function asyncRoute(handler) { - return (request, response, next) => { - Promise.resolve(handler(request, response, next)).catch(next); - }; -} - -function delay(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -function normalizeErrorDetails(value) { - if (value === undefined || value === null) { - return undefined; - } - - try { - return JSON.parse(JSON.stringify(value)); - } catch { - return String(value); - } -} - -async function convertHeicToJpeg(buffer) { - try { - const output = await convert({ - buffer, - format: "JPEG", - quality: 0.92 - }); - - return Buffer.from(output); - } catch (error) { - throw new AppError( - 400, - "Не удалось преобразовать фотографию HEIC/HEIF в JPEG.", - error instanceof Error ? error.message : String(error) - ); - } -} - -function resolveCodexExe() { - const candidates = []; - - if (process.env.CODEX_CLI_PATH) { - candidates.push(process.env.CODEX_CLI_PATH); - } - - try { - const binRoot = path.join( - process.env.LOCALAPPDATA || "", - "OpenAI", - "Codex", - "bin" - ); - - for (const entry of fs.readdirSync(binRoot)) { - const candidate = path.join(binRoot, entry, "codex.exe"); - - if (fs.existsSync(candidate)) { - candidates.push(candidate); - } - } - } catch { - // Папка может отсутствовать — используем fallback. - } - - for (const candidate of candidates) { - if (candidate && fs.existsSync(candidate)) { - return candidate; - } - } - - return "codex"; -} - -function extForMimetype(mimetype) { - const map = { - "image/jpeg": "jpg", - "image/jpg": "jpg", - "image/png": "png", - "image/webp": "webp" - }; - - return map[String(mimetype || "").toLowerCase()] || "jpg"; -} - -function buildCodexPrompt(prompt, dimensions, inputName, referenceName = null, region = null) { - const size = dimensions && dimensions.width - ? `${dimensions.width}×${dimensions.height}` - : ""; - - const lines = [ - `Перед тобой фотография ${inputName}. Примени к ней следующее редактирование: «${prompt}».` - ]; - - if (referenceName) { - lines.push( - `Также дано вспомогательное изображение ${referenceName} — тот же сюжет и ракурс, что и ${inputName}. Красным прямоугольником на ${referenceName} выделена область, в которой нужно внести правку.` - ); - } - - if (referenceName && region) { - lines.push( - `Координаты области на ${referenceName}: x=${region.x}, y=${region.y}, ширина=${region.w}, высота=${region.h} (в пикселях).` - ); - lines.push( - `Примени редактирование к ${inputName} именно в области, соответствующей прямоугольнику на ${referenceName}. Остальную часть ${inputName} не меняй.` - ); - } - - if (referenceName && !region) { - lines.push( - `Используй ${referenceName} как ориентир по сюжету и ракурсу при внесении правки в ${inputName}.` - ); - } - - lines.push( - "Сохрани отредактированное фото в файл output.jpg в текущей директории.", - "Не перерисовывай фото с нуля и не заменяй его на полностью новое изображение — редактируй исходное фото, сохраняя его сюжет, объекты, лица и пропорции.", - size - ? `Сохрани исходный размер ${size}.` - : "Сохрани исходные пропорции.", - "Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал." - ); - - return lines.join(" "); -} - -function codexExecArgs(jobDir, inputNames) { - const args = [ - "exec", "-C", jobDir, - "--skip-git-repo-check", - "--ephemeral", - "--dangerously-bypass-approvals-and-sandbox" - ]; - - for (const name of inputNames) { - args.push("-i", name); - } - - args.push("-o", "last_message.txt", "-"); - return args; -} - -function runCodexEdit(jobDir, inputNames, prompt) { - const lastMessagePath = path.join(jobDir, "last_message.txt"); - const outputPath = path.join(jobDir, "output.jpg"); - - return new Promise((resolve, reject) => { - let child; - - try { - child = spawn( - CODEX_EXE, - codexExecArgs(jobDir, inputNames), - { - cwd: jobDir, - env: process.env, - stdio: ["pipe", "ignore", "ignore"], - windowsHide: true - } - ); - } catch (error) { - reject(error); - return; - } - - const startedAt = Date.now(); - let exitCode = null; - let processExitedAt = null; - let outputSeenAt = null; - let timedOut = false; - let settled = false; - - const settle = () => { - if (settled) { - return; - } - - settled = true; - clearInterval(deadlineTimer); - - const lastMessage = fs.existsSync(lastMessagePath) - ? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 2000) - : ""; - const hasOutput = - fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0; - - resolve({ - exitCode, - timedOut, - outputPath: hasOutput ? outputPath : null, - lastMessage - }); - }; - - const deadlineTimer = setInterval(() => { - if (Date.now() - startedAt > CODEX_EDIT_TIMEOUT_MS) { - timedOut = true; - - try { - child.kill(); - } catch { - // Процесс уже завершён. - } - - settle(); - } - }, 1000); - - child.on("error", (error) => { - if (settled) { - return; - } - - settled = true; - clearInterval(deadlineTimer); - reject(error); - }); - - child.on("exit", (code) => { - exitCode = code; - processExitedAt = Date.now(); - }); - - child.stdin.on("error", () => {}); - child.stdin.end(prompt); - - const poll = () => { - if (settled) { - return; - } - - if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) { - if (outputSeenAt === null) { - outputSeenAt = Date.now(); - } - - // Дожидаемся итогового сообщения Codex, но не дольше 10 секунд. - if ( - !fs.existsSync(lastMessagePath) && - Date.now() - outputSeenAt < 10000 - ) { - setTimeout(poll, 1000); - return; - } - - settle(); - return; - } - - if (Date.now() - startedAt > CODEX_EDIT_TIMEOUT_MS) { - timedOut = true; - - try { - child.kill(); - } catch { - // Процесс уже завершён. - } - - settle(); - return; - } - - if (processExitedAt !== null && Date.now() - processExitedAt > 15000) { - settle(); - return; - } - - setTimeout(poll, 1000); - }; - - poll(); - }); -} - -async function runEditJob(jobId, jobDir, inputNames, promptText, prompt, sourceId, dimensions, jobStartedAt, referenceId = null) { - try { - const result = await runCodexEdit(jobDir, inputNames, promptText); - const durationMs = Date.now() - jobStartedAt; - - if (!result.outputPath) { - const details = result.lastMessage - ? `Ответ Codex: ${result.lastMessage.slice(0, 500)}` - : undefined; - log("error", "Редактирование не завершено", { - jobId, - ok: false, - exitCode: result.exitCode, - timedOut: result.timedOut, - durationMs, - lastMessage: truncate(result.lastMessage, 500) - }); - const job = jobs.get(jobId); - if (job) { - job.status = "error"; - job.error = "Codex не завершил редактирование. Попробуйте ещё раз или упростите промпт."; - job.details = details; - job.finishedAt = Date.now(); - } - return; - } - - const version = makeHistoryVersion({ - kind: "edit", - label: `Редактирование: «${prompt.slice(0, 60)}${prompt.length > 60 ? "…" : ""}»`, - prompt, - parentId: sourceId || null, - referenceId, - buffer: fs.readFileSync(result.outputPath), - ext: "jpg", - width: dimensions?.width, - height: dimensions?.height - }); - - await appendHistoryVersion(version); - - log("info", "Редактирование завершено", { - jobId, - ok: true, - exitCode: result.exitCode, - timedOut: result.timedOut, - durationMs, - versionId: version.id, - lastMessage: truncate(result.lastMessage, 500) - }); - - const job = jobs.get(jobId); - if (job) { - job.status = "done"; - job.finishedAt = Date.now(); - job.payload = { - job: { type: "edit", engine: "codex", exitCode: result.exitCode, timedOut: result.timedOut }, - result: { filename: "edited-photo.jpg", url: version.url }, - input: dimensions, - summary: result.lastMessage, - version - }; - } - } catch (error) { - log("error", "Редактирование упало", { - jobId, - message: error instanceof Error ? error.message : String(error) - }); - const job = jobs.get(jobId); - if (job) { - job.status = "error"; - job.error = "Внутренняя ошибка при редактировании."; - job.details = error instanceof Error ? error.message : String(error); - job.finishedAt = Date.now(); - } - } finally { - codexEditInFlight = false; - } -} - -async function runUpscaleJob(jobId, sourceVersion, dimensions, scaledSize, runtime, fileBuffer, fileMimetype, startedAt) { - try { - const uploaded = await uploadToComfy({ - buffer: fileBuffer, - mimetype: fileMimetype, - originalname: "input.jpg" - }); - const built = buildUpscaleWorkflow(uploaded, runtime.selectedUpscaleModel, scaledSize); - const promptId = await submitWorkflow(built.workflow); - const image = await waitForResult(promptId, built.saveNodeId); - const imageBuffer = await downloadComfyImage(image); - - const resultExt = String(image.filename || "") - .toLowerCase() - .endsWith(".png") - ? "png" - : "jpg"; - - const version = makeHistoryVersion({ - kind: "upscale", - label: "4× upscale", - prompt: null, - parentId: sourceVersion?.id || null, - buffer: imageBuffer, - ext: resultExt, - width: null, - height: null - }); - - await appendHistoryVersion(version); - - log("info", "Апскейл завершён", { - jobId, - sourceId: sourceVersion?.id || null, - promptId, - model: runtime.selectedUpscaleModel, - durationMs: Date.now() - startedAt, - versionId: version.id - }); - - const job = jobs.get(jobId); - if (job) { - job.status = "done"; - job.finishedAt = Date.now(); - job.payload = { - job: { type: "upscale", promptId, model: runtime.selectedUpscaleModel }, - result: { filename: "upscaled-photo.png", url: version.url }, - input: dimensions, - processingInput: scaledSize || dimensions, - version - }; - } - } catch (error) { - log("error", "Апскейл упал", { - jobId, - message: error instanceof Error ? error.message : String(error) - }); - const job = jobs.get(jobId); - if (job) { - job.status = "error"; - job.error = error instanceof AppError - ? error.message - : "Не удалось увеличить разрешение фотографии."; - job.details = error instanceof Error ? error.message : String(error); - job.finishedAt = Date.now(); - } - } -} - -function buildSuggestPrompt(inputName) { - return `Перед тобой фотография ${inputName}. Предложи 3 коротких варианта промпта (по одному на строку, без нумерации) для её редактирования. Только варианты промптов, ничего больше.`; -} - -function parseSuggestions(text) { - return String(text || "") - .split(/\r?\n/) - .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim()) - .filter((line) => line.length > 0 && line.length <= 200) - .slice(0, 3); -} - -function runCodexSuggest(jobDir, inputNames, prompt) { - const lastMessagePath = path.join(jobDir, "last_message.txt"); - return new Promise((resolve, reject) => { - let child; - try { - child = spawn(CODEX_EXE, codexExecArgs(jobDir, inputNames), { - cwd: jobDir, - env: process.env, - stdio: ["pipe", "ignore", "ignore"], - windowsHide: true - }); - } catch (error) { - reject(error); - return; - } - const startedAt = Date.now(); - let exitCode = null; - let settled = false; - let timedOut = false; - const settle = () => { - if (settled) return; - settled = true; - clearInterval(deadlineTimer); - const lastMessage = fs.existsSync(lastMessagePath) - ? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 4000) - : ""; - resolve({ exitCode, timedOut, lastMessage }); - }; - const deadlineTimer = setInterval(() => { - if (Date.now() - startedAt > CODEX_EDIT_TIMEOUT_MS) { - timedOut = true; - try { child.kill(); } catch { /* уже завершён */ } - settle(); - } - }, 1000); - child.on("error", (error) => { - if (settled) return; - settled = true; - clearInterval(deadlineTimer); - reject(error); - }); - child.on("exit", (code) => { - exitCode = code; - // Короткая пауза после выхода, чтобы Codex успел дописать - // last_message.txt, затем завершаем джоб (таймаут — только backstop). - const exitAt = Date.now(); - const pollLastMessage = () => { - if (settled) return; - const text = fs.existsSync(lastMessagePath) - ? fs.readFileSync(lastMessagePath, "utf8").trim() - : ""; - if (text.length > 0 || Date.now() - exitAt > 5000) { - settle(); - return; - } - setTimeout(pollLastMessage, 250); - }; - setTimeout(pollLastMessage, 250); - }); - child.stdin.on("error", () => {}); - child.stdin.end(prompt); - }); -} - -async function runSuggestJob(jobId, jobDir, inputName, jobStartedAt) { - try { - const result = await runCodexSuggest(jobDir, [inputName], buildSuggestPrompt(inputName)); - const suggestions = parseSuggestions(result.lastMessage); - const job = jobs.get(jobId); - if (!job) return; - if (suggestions.length === 0) { - job.status = "error"; - job.error = "Codex не вернул варианты промпта. Попробуйте ещё раз."; - job.details = result.lastMessage ? result.lastMessage.slice(0, 500) : undefined; - job.finishedAt = Date.now(); - log("error", "Подсказка промпта не удалась", { jobId, exitCode: result.exitCode, timedOut: result.timedOut }); - return; - } - job.status = "done"; - job.finishedAt = Date.now(); - job.payload = { job: { type: "suggest" }, suggestions }; - log("info", "Подсказка промпта готова", { - jobId, - count: suggestions.length, - durationMs: Date.now() - jobStartedAt - }); - } catch (error) { - log("error", "Подсказка промпта упала", { - jobId, - message: error instanceof Error ? error.message : String(error) - }); - const job = jobs.get(jobId); - if (job) { - job.status = "error"; - job.error = "Внутренняя ошибка при получении подсказки."; - job.details = error instanceof Error ? error.message : String(error); - job.finishedAt = Date.now(); - } - } finally { - codexEditInFlight = false; - } -} - -function contentTypeForExt(ext) { - const map = { - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - webp: "image/webp" - }; - - return map[String(ext || "").toLowerCase()] || "image/jpeg"; -} - -function loadHistory() { - try { - const parsed = JSON.parse(fs.readFileSync(HISTORY_MANIFEST, "utf8")); - - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -function saveHistory(history) { - fs.mkdirSync(HISTORY_DIR, { recursive: true }); - fs.writeFileSync(HISTORY_MANIFEST, JSON.stringify(history, null, 2), "utf8"); -} - -function historyFilePath(version) { - return path.join(HISTORY_DIR, `${version.id}.${version.ext || "jpg"}`); -} - -let historyWriteChain = Promise.resolve(); - -async function appendHistoryVersion(version) { - historyWriteChain = historyWriteChain - .then(() => { - const history = loadHistory(); - history.push(version); - const trimmed = history.slice(-HISTORY_LIMIT); - const keptIds = new Set(trimmed.map((entry) => entry.id)); - - for (const old of history) { - if (!keptIds.has(old.id)) { - try { - fs.rmSync(historyFilePath(old), { force: true }); - } catch { - // Файл мог уже отсутствовать. - } - } - } - - saveHistory(trimmed); - }) - .catch((error) => { - log("error", "Ошибка записи истории", { message: error instanceof Error ? error.message : String(error) }); - console.error(error); - }); - - await historyWriteChain; - return version; -} - -function makeHistoryVersion({ - kind, - label, - prompt, - parentId, - referenceId, - buffer, - ext, - width, - height -}) { - const id = crypto.randomUUID(); - const fileExt = ext || "jpg"; - - fs.mkdirSync(HISTORY_DIR, { recursive: true }); - fs.writeFileSync(path.join(HISTORY_DIR, `${id}.${fileExt}`), buffer); - - return { - id, - kind, - label, - prompt, - parentId: parentId || null, - referenceId: referenceId || null, - createdAt: new Date().toISOString(), - url: `/api/history/${id}/image.jpg`, - width: width || null, - height: height || null, - ext: fileExt - }; -} - -function parseRegion(value) { - if (value === undefined || value === null || String(value).trim() === "") { - return null; - } - - let parsed; - try { - parsed = JSON.parse(value); - } catch { - throw new AppError(400, "Область референса передана в неверном формате."); - } - - const nums = ["x", "y", "w", "h"].map((key) => Number(parsed?.[key])); - - if (nums.some((num) => !Number.isFinite(num) || num < 0)) { - throw new AppError(400, "Область референса должна содержать неотрицательные числовые поля x, y, w, h."); - } - - return { x: Math.round(nums[0]), y: Math.round(nums[1]), w: Math.round(nums[2]), h: Math.round(nums[3]) }; -} - -async function resolvePhotoInput(request) { - const sourceId = String(request.body?.sourceId || "").trim(); - - if (!sourceId) { - if (!request.file) { - throw new AppError(400, "Выберите фотографию для обработки."); - } - - return null; - } - - const version = loadHistory().find((entry) => entry.id === sourceId); - - if (!version) { - throw new AppError(404, "Исходная версия не найдена в истории."); - } - - const filePath = historyFilePath(version); - - if (!fs.existsSync(filePath)) { - throw new AppError(404, "Файл исходной версии не найден."); - } - - request.file = { - buffer: fs.readFileSync(filePath), - mimetype: contentTypeForExt(version.ext), - originalname: `source-${version.id.slice(0, 8)}.${version.ext || "jpg"}`, - size: fs.statSync(filePath).size - }; - - return version; -} - -async function downloadComfyImage(image) { - const parameters = new URLSearchParams({ - filename: image.filename, - subfolder: image.subfolder || "", - type: image.type || "output" - }); - - const upstream = await comfyRequest( - `/view?${parameters.toString()}`, - {}, - 60000 - ); - - if (!upstream.body) { - throw new AppError(502, "ComfyUI вернул пустое изображение."); - } - - return Buffer.from(await upstream.arrayBuffer()); -} - -async function comfyRequest(route, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - - try { - const response = await fetch(`${COMFY_URL}${route}`, { - ...options, - signal: controller.signal - }); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new AppError( - 502, - `ComfyUI вернул ошибку HTTP ${response.status}.`, - body.slice(0, 2000) || undefined - ); - } - - return response; - } catch (error) { - if (error instanceof AppError) { - throw error; - } - - if (error && error.name === "AbortError") { - throw new AppError( - 504, - "ComfyUI не ответил вовремя. Проверьте сервер обработки и повторите попытку." - ); - } - - throw new AppError( - 503, - "Не удалось подключиться к ComfyUI по адресу 192.168.31.240:8188.", - error instanceof Error ? error.message : String(error) - ); - } finally { - clearTimeout(timer); - } -} - -async function comfyJson(route, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) { - const response = await comfyRequest(route, options, timeoutMs); - - try { - return await response.json(); - } catch { - throw new AppError(502, "ComfyUI вернул некорректный JSON-ответ."); - } -} - -function extractChoiceOptions(specification) { - if (!Array.isArray(specification)) { - return []; - } - - // Старый формат ComfyUI: [["model-a", "model-b"], {...}] - if (Array.isArray(specification[0])) { - return specification[0].filter((item) => typeof item === "string"); - } - - // Новый формат ComfyUI: ["COMBO", { options: ["model-a"] }] - if ( - specification[1] && - typeof specification[1] === "object" && - Array.isArray(specification[1].options) - ) { - return specification[1].options.filter((item) => typeof item === "string"); - } - - if (specification.every((item) => typeof item === "string")) { - return specification; - } - - return []; -} - -function chooseUpscaleModel(models) { - const preferences = [ - /4x[-_ ]?ultrasharp/i, - /realesrgan.*x4plus/i, - /4x.*plus/i, - /4x/i - ]; - - for (const pattern of preferences) { - const match = models.find((model) => pattern.test(model)); - if (match) { - return match; - } - } - - return models[0] || null; -} - -let runtimeCache = null; -let runtimeCacheExpiresAt = 0; - -async function getRuntimeInfo(forceRefresh = false) { - if ( - !forceRefresh && - runtimeCache && - Date.now() < runtimeCacheExpiresAt - ) { - return runtimeCache; - } - - const objectInfo = await comfyJson("/object_info"); - - const nodes = Object.fromEntries( - REQUIRED_NODES.map((nodeName) => [ - nodeName, - Boolean(objectInfo[nodeName]) - ]) - ); - - const upscaleSpec = - objectInfo.UpscaleModelLoader?.input?.required?.model_name; - const checkpointSpec = - objectInfo.CheckpointLoaderSimple?.input?.required?.ckpt_name; - - const upscaleModels = extractChoiceOptions(upscaleSpec); - const checkpoints = extractChoiceOptions(checkpointSpec); - - runtimeCache = { - nodes, - upscaleModels, - selectedUpscaleModel: chooseUpscaleModel(upscaleModels), - checkpoints, - checkpointAvailable: checkpoints.includes(CHECKPOINT) - }; - - runtimeCacheExpiresAt = Date.now() + 30000; - return runtimeCache; -} - -function requireNodes(runtime, nodeNames) { - const missing = nodeNames.filter((nodeName) => !runtime.nodes[nodeName]); - - if (missing.length > 0) { - throw new AppError( - 503, - `На сервере ComfyUI отсутствуют необходимые узлы: ${missing.join(", ")}.` - ); - } -} - -function readUInt24LE(buffer, offset) { - return ( - buffer[offset] | - (buffer[offset + 1] << 8) | - (buffer[offset + 2] << 16) - ); -} - -function getPngDimensions(buffer) { - const pngSignature = "89504e470d0a1a0a"; - - if ( - buffer.length >= 24 && - buffer.subarray(0, 8).toString("hex") === pngSignature - ) { - return { - width: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20) - }; - } - - return null; -} - -function getJpegDimensions(buffer) { - if ( - buffer.length < 4 || - buffer[0] !== 0xff || - buffer[1] !== 0xd8 - ) { - return null; - } - - const startOfFrameMarkers = new Set([ - 0xc0, 0xc1, 0xc2, 0xc3, - 0xc5, 0xc6, 0xc7, - 0xc9, 0xca, 0xcb, - 0xcd, 0xce, 0xcf - ]); - - let offset = 2; - - while (offset + 9 < buffer.length) { - if (buffer[offset] !== 0xff) { - offset += 1; - continue; - } - - while (offset < buffer.length && buffer[offset] === 0xff) { - offset += 1; - } - - if (offset >= buffer.length) { - break; - } - - const marker = buffer[offset]; - offset += 1; - - if (marker === 0xd8 || marker === 0x01) { - continue; - } - - if (marker === 0xd9 || marker === 0xda) { - break; - } - - if (offset + 1 >= buffer.length) { - break; - } - - const segmentLength = buffer.readUInt16BE(offset); - - if (segmentLength < 2 || offset + segmentLength > buffer.length) { - break; - } - - if (startOfFrameMarkers.has(marker) && segmentLength >= 7) { - return { - height: buffer.readUInt16BE(offset + 3), - width: buffer.readUInt16BE(offset + 5) - }; - } - - offset += segmentLength; - } - - return null; -} - -function getWebpDimensions(buffer) { - if ( - buffer.length < 30 || - buffer.toString("ascii", 0, 4) !== "RIFF" || - buffer.toString("ascii", 8, 12) !== "WEBP" - ) { - return null; - } - - const chunkType = buffer.toString("ascii", 12, 16); - - if (chunkType === "VP8X") { - return { - width: readUInt24LE(buffer, 24) + 1, - height: readUInt24LE(buffer, 27) + 1 - }; - } - - if ( - chunkType === "VP8 " && - buffer.length >= 30 && - buffer[23] === 0x9d && - buffer[24] === 0x01 && - buffer[25] === 0x2a - ) { - return { - width: buffer.readUInt16LE(26) & 0x3fff, - height: buffer.readUInt16LE(28) & 0x3fff - }; - } - - if (chunkType === "VP8L" && buffer.length >= 25 && buffer[20] === 0x2f) { - const byte0 = buffer[21]; - const byte1 = buffer[22]; - const byte2 = buffer[23]; - const byte3 = buffer[24]; - - return { - width: 1 + byte0 + ((byte1 & 0x3f) << 8), - height: 1 + ((byte1 & 0xc0) >> 6) + (byte2 << 2) + ((byte3 & 0x0f) << 10) - }; - } - - return null; -} - -function getImageDimensions(buffer) { - const dimensions = - getPngDimensions(buffer) || - getJpegDimensions(buffer) || - getWebpDimensions(buffer); - - if ( - !dimensions || - !Number.isInteger(dimensions.width) || - !Number.isInteger(dimensions.height) || - dimensions.width <= 0 || - dimensions.height <= 0 - ) { - return null; - } - - return dimensions; -} - -function scaleForLimit(dimensions, maximumDimension) { - const largestDimension = Math.max( - dimensions.width, - dimensions.height - ); - - if (largestDimension <= maximumDimension) { - return null; - } - - const ratio = maximumDimension / largestDimension; - const width = Math.max( - 8, - Math.floor((dimensions.width * ratio) / 8) * 8 - ); - const height = Math.max( - 8, - Math.floor((dimensions.height * ratio) / 8) * 8 - ); - - return { width, height }; -} - -function safeUploadFilename(file) { - const extensionByMime = { - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/webp": ".webp" - }; - - const extension = - extensionByMime[file.mimetype.toLowerCase()] || - path.extname(file.originalname).toLowerCase() || - ".png"; - - const originalStem = path.parse(file.originalname).name; - const cleanStem = originalStem - .normalize("NFKC") - .replace(/[^\p{L}\p{N}._-]+/gu, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 60) || "photo"; - - return [ - "prompt-editor", - Date.now(), - crypto.randomBytes(4).toString("hex"), - cleanStem - ].join("-") + extension; -} - -async function uploadToComfy(file) { - const form = new FormData(); - - form.append( - "image", - new Blob([file.buffer], { type: file.mimetype }), - safeUploadFilename(file) - ); - form.append("type", "input"); - form.append("overwrite", "true"); - - const uploaded = await comfyJson("/upload/image", { - method: "POST", - body: form - }, 30000); - - if (!uploaded || typeof uploaded.name !== "string") { - throw new AppError( - 502, - "ComfyUI не подтвердил загрузку исходной фотографии.", - uploaded - ); - } - - const subfolder = - typeof uploaded.subfolder === "string" - ? uploaded.subfolder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") - : ""; - - return { - name: uploaded.name, - subfolder, - type: uploaded.type || "input", - loadImageName: subfolder - ? `${subfolder}/${uploaded.name}` - : uploaded.name - }; -} - -function makeSeed() { - return crypto.randomBytes(6).readUIntBE(0, 6); -} - -function imageScaleNode(imageSource, size) { - return { - class_type: "ImageScale", - inputs: { - image: imageSource, - upscale_method: "lanczos", - width: size.width, - height: size.height, - crop: "disabled" - } - }; -} - -function buildUpscaleWorkflow(uploaded, modelName, scaledSize) { - const imageSource = scaledSize ? ["2", 0] : ["1", 0]; - - const workflow = { - "1": { - class_type: "LoadImage", - inputs: { - image: uploaded.loadImageName - } - }, - "3": { - class_type: "UpscaleModelLoader", - inputs: { - model_name: modelName - } - }, - "4": { - class_type: "ImageUpscaleWithModel", - inputs: { - upscale_model: ["3", 0], - image: imageSource - } - }, - "5": { - class_type: "SaveImage", - inputs: { - images: ["4", 0], - filename_prefix: "prompt_editor/upscale" - } - } - }; - - if (scaledSize) { - workflow["2"] = imageScaleNode(["1", 0], scaledSize); - } - - return { - workflow, - saveNodeId: "5" - }; -} - -async function submitWorkflow(workflow) { - const response = await comfyJson("/prompt", { - method: "POST", - headers: { - "content-type": "application/json" - }, - body: JSON.stringify({ - prompt: workflow, - client_id: crypto.randomUUID() - }) - }, 30000); - - const nodeErrors = response?.node_errors; - - if ( - nodeErrors && - typeof nodeErrors === "object" && - Object.keys(nodeErrors).length > 0 - ) { - throw new AppError( - 422, - "ComfyUI отклонил схему обработки. Проверьте установленные модели и узлы.", - nodeErrors - ); - } - - if (!response || typeof response.prompt_id !== "string") { - throw new AppError( - 502, - "ComfyUI не вернул идентификатор задачи.", - response - ); - } - - return response.prompt_id; -} - -function executionErrorFromHistory(record) { - const messages = record?.status?.messages; - - if (!Array.isArray(messages)) { - return null; - } - - for (const message of messages) { - if (!Array.isArray(message) || message[0] !== "execution_error") { - continue; - } - - const payload = message[1]; - - if (payload?.exception_message) { - return payload.exception_message; - } - - return "Внутренняя ошибка выполнения workflow."; - } - - return null; -} - -async function waitForResult(promptId, saveNodeId) { - const startedAt = Date.now(); - - while (Date.now() - startedAt < JOB_TIMEOUT_MS) { - const history = await comfyJson( - `/history/${encodeURIComponent(promptId)}` - ); - - const record = history?.[promptId] || null; - const output = record?.outputs?.[saveNodeId]; - const image = output?.images?.[0]; - - if (image && typeof image.filename === "string") { - return { - filename: image.filename, - subfolder: - typeof image.subfolder === "string" ? image.subfolder : "", - type: image.type || "output" - }; - } - - const executionError = executionErrorFromHistory(record); - - if (executionError) { - throw new AppError( - 502, - `ComfyUI не смог обработать изображение: ${executionError}` - ); - } - - if (record?.status?.completed) { - throw new AppError( - 502, - "ComfyUI завершил задачу, но не создал итоговое изображение.", - record.status.messages - ); - } - - await delay(POLL_INTERVAL_MS); - } - - throw new AppError( - 504, - "Обработка заняла больше пяти минут и была остановлена по тайм-ауту." - ); -} - -function makePublicResult(image) { - const parameters = new URLSearchParams({ - filename: image.filename, - subfolder: image.subfolder || "", - type: image.type || "output" - }); - - return { - ...image, - url: `/api/result?${parameters.toString()}` - }; -} - -async function requirePhoto(request) { - if (!request.file) { - throw new AppError(400, "Выберите фотографию для обработки."); - } - - let dimensions; - - if (isHeic(request.file)) { - const buffer = await convertHeicToJpeg(request.file.buffer); - const originalStem = - path.parse(String(request.file.originalname || "")).name || "photo"; - - dimensions = getJpegDimensions(buffer); - - request.file = { - ...request.file, - buffer, - size: buffer.length, - mimetype: "image/jpeg", - originalname: `${originalStem}.jpg` - }; - } else { - dimensions = getImageDimensions(request.file.buffer); - } - - if ( - !dimensions || - !Number.isInteger(dimensions.width) || - !Number.isInteger(dimensions.height) || - dimensions.width <= 0 || - dimensions.height <= 0 - ) { - throw new AppError( - 400, - "Не удалось определить размеры фотографии. Используйте корректный JPEG, PNG, WebP или HEIC." - ); - } - - return dimensions; -} - -app.get("/api/health", asyncRoute(async (_request, response) => { - try { - const runtime = await getRuntimeInfo(true); - const missingNodes = REQUIRED_NODES.filter( - (nodeName) => !runtime.nodes[nodeName] - ); - - const ready = - missingNodes.length === 0 && - runtime.checkpointAvailable && - Boolean(runtime.selectedUpscaleModel); - - response.json({ - ok: ready, - comfyAvailable: true, - message: ready - ? "Сервер обработки: доступен" - : "Сервер доступен, но настроен не полностью", - selectedUpscaleModel: runtime.selectedUpscaleModel, - upscaleModels: runtime.upscaleModels, - checkpoint: CHECKPOINT, - checkpointAvailable: runtime.checkpointAvailable, - nodes: runtime.nodes, - missingNodes - }); - } catch (error) { - response.status(200).json({ - ok: false, - comfyAvailable: false, - message: error instanceof Error - ? error.message - : "Сервер обработки недоступен", - selectedUpscaleModel: null, - upscaleModels: [], - checkpoint: CHECKPOINT, - checkpointAvailable: false, - nodes: Object.fromEntries( - REQUIRED_NODES.map((nodeName) => [nodeName, false]) - ) - }); - } -})); - -app.post("/api/login", asyncRoute(async (request, response) => { - const username = String(request.body?.username || "").trim(); - const password = String(request.body?.password || ""); - - if (!username || !password) { - throw new AppError(400, "Введите логин и пароль."); - } - - if (!constantTimeEqual(APP_USER, username) || - !constantTimeEqual(APP_PASSWORD, password)) { - log("warn", "Неудачный вход", { login: username }); - throw new AppError(401, "Неверный логин или пароль."); - } - - const token = makeSessionToken(); - sessions.set(token, { user: APP_USER, expiresAt: Date.now() + SESSION_TTL_MS }); - response.cookie(SESSION_COOKIE, token, sessionCookieOptions()); - response.json({ ok: true, user: APP_USER }); - log("info", "Вход выполнен", { user: APP_USER }); -})); - -app.get("/api/me", (request, response, next) => { - const found = getSession(request); - if (!found) { - next(new AppError(401, "Требуется авторизация.")); - return; - } - found.session.expiresAt = Date.now() + SESSION_TTL_MS; - response.cookie(SESSION_COOKIE, found.token, sessionCookieOptions()); - response.json({ ok: true, user: found.session.user }); -}); - -app.post("/api/logout", (request, response) => { - const token = readSessionToken(request); - if (token) sessions.delete(token); - response.clearCookie(SESSION_COOKIE, { path: "/" }); - response.json({ ok: true }); - log("info", "Выход из системы"); -}); - -// Все эндпоинты ниже — только для авторизованных пользователей. -app.use("/api", requireAuth); - -app.post( - "/api/preview", - upload.single("photo"), - asyncRoute(async (request, response) => { - if (!request.file) { - throw new AppError(400, "Выберите фотографию для предпросмотра."); - } - - let buffer = request.file.buffer; - let contentType = request.file.mimetype; - - if (isHeic(request.file)) { - buffer = await convertHeicToJpeg(request.file.buffer); - contentType = "image/jpeg"; - } - - response.status(200); - response.setHeader("content-type", contentType); - response.setHeader("cache-control", "no-store"); - response.send(buffer); - }) -); - -app.get("/api/history", asyncRoute(async (_request, response) => { - response.json({ ok: true, versions: loadHistory() }); -})); - -app.get("/api/suggest-prompts", asyncRoute(async (_request, response) => { - const history = loadHistory(); - const seen = new Set(); - const prompts = []; - - for (let i = history.length - 1; i >= 0 && prompts.length < 10; i--) { - const prompt = String(history[i]?.prompt || "").trim(); - - if (prompt && !seen.has(prompt)) { - seen.add(prompt); - prompts.push(prompt); - } - } - - response.json({ ok: true, prompts }); -})); - -app.post( - "/api/history/import", - upload.single("photo"), - asyncRoute(async (request, response) => { - if (!request.file) { - throw new AppError(400, "Выберите фотографию."); - } - - let buffer = request.file.buffer; - let ext = extForMimetype(request.file.mimetype); - - if (isHeic(request.file)) { - buffer = await convertHeicToJpeg(buffer); - ext = "jpg"; - } - - const dimensions = ext === "jpg" - ? getJpegDimensions(buffer) - : getImageDimensions(buffer); - - const version = makeHistoryVersion({ - kind: "original", - label: "Исходник", - prompt: null, - parentId: null, - buffer, - ext, - width: dimensions?.width, - height: dimensions?.height - }); - - await appendHistoryVersion(version); - - log("info", "Импорт фотографии", { - versionId: version.id, - sizeBytes: request.file?.size ?? buffer.length, - ext, - width: version.width, - height: version.height - }); - - response.json({ ok: true, version }); - }) -); - -app.delete("/api/history/:id", asyncRoute(async (request, response, next) => { - const { id } = request.params; - - if (!JOB_ID_PATTERN.test(id)) { - throw new AppError(400, "Некорректный идентификатор версии."); - } - - // Удаление сериализуется через historyWriteChain вместе с - // appendHistoryVersion: saveHistory пишет весь список без триминга, - // поэтому файлы чужих версий не затрагиваются. - let remaining = null; - - await new Promise((resolve, reject) => { - historyWriteChain = historyWriteChain - .then(() => { - const history = loadHistory(); - const target = history.find((entry) => entry.id === id); - - if (!target) { - throw new AppError(404, "Версия не найдена в истории."); - } - - const toDelete = new Set([id]); - let grew = true; - - while (grew) { - grew = false; - - for (const entry of history) { - if (entry.parentId && toDelete.has(entry.parentId) && !toDelete.has(entry.id)) { - toDelete.add(entry.id); - grew = true; - } - } - } - - remaining = history.filter((entry) => !toDelete.has(entry.id)); - saveHistory(remaining); - - for (const entry of history) { - if (toDelete.has(entry.id)) { - try { - fs.rmSync(historyFilePath(entry), { force: true }); - } catch { - // Файл мог отсутствовать. - } - } - } - - log("info", "Удаление версии", { - versionId: id, - children: toDelete.size - 1, - removed: toDelete.size - }); - - resolve(); - }) - .catch(reject); - }); - - response.json({ ok: true, versions: remaining }); -})); - -app.get( - "/api/history/:id/image.jpg", - asyncRoute(async (request, response) => { - const { id } = request.params; - - if (!JOB_ID_PATTERN.test(id)) { - throw new AppError(400, "Некорректный идентификатор версии."); - } - - const version = loadHistory().find((entry) => entry.id === id); - - if (!version) { - throw new AppError(404, "Версия не найдена в истории."); - } - - const filePath = historyFilePath(version); - - if (!fs.existsSync(filePath)) { - throw new AppError(404, "Файл версии не найден."); - } - - response.status(200); - response.setHeader("content-type", contentTypeForExt(version.ext)); - response.setHeader("cache-control", "no-store"); - response.setHeader( - "content-disposition", - `inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.${version.ext || "jpg"}`)}` - ); - - fs.createReadStream(filePath).pipe(response); - }) -); - -app.post( - "/api/edit", - upload.fields([{ name: "photo", maxCount: 1 }, { name: "reference", maxCount: 1 }]), - asyncRoute(async (request, response) => { - const photoFile = request.files?.photo?.[0]; - if (photoFile) request.file = photoFile; - - const sourceVersion = await resolvePhotoInput(request); - const dimensions = await requirePhoto(request); - const prompt = String(request.body?.prompt || "").trim(); - - if (!prompt) { - throw new AppError(400, "Введите промпт для редактирования фотографии."); - } - - if (prompt.length > 1000) { - throw new AppError(400, "Промпт не должен превышать 1000 символов."); - } - - if (codexEditInFlight) { - throw new AppError( - 429, - "Обработка уже выполняется. Дождитесь завершения текущего задания." - ); - } - - const referenceFile = request.files?.reference?.[0]; - const region = parseRegion(request.body?.region); - - let referenceId = null; - if (request.body?.referenceId) { - const rid = String(request.body.referenceId).trim(); - - if (!JOB_ID_PATTERN.test(rid)) { - throw new AppError(400, "Некорректный идентификатор референса."); - } - - referenceId = rid; - } - - if (region && !referenceFile) { - throw new AppError(400, "Область указана без изображения референса."); - } - - codexEditInFlight = true; - const jobId = crypto.randomUUID(); - const jobDir = path.join(CODEX_JOBS_DIR, jobId); - const jobStartedAt = Date.now(); - - try { - fs.mkdirSync(jobDir, { recursive: true }); - const inputName = `input.${extForMimetype(request.file.mimetype)}`; - fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer); - - let referenceName = null; - if (referenceFile) { - let refBuffer = referenceFile.buffer; - let refExt = extForMimetype(referenceFile.mimetype); - - if (isHeic(referenceFile)) { - refBuffer = await convertHeicToJpeg(refBuffer); - refExt = "jpg"; - } - - referenceName = `reference_annotated.${refExt}`; - fs.writeFileSync(path.join(jobDir, referenceName), refBuffer); - } - - const inputNames = referenceName ? [inputName, referenceName] : [inputName]; - const promptText = buildCodexPrompt(prompt, dimensions, inputName, referenceName, region); - - jobs.set(jobId, { - type: "edit", - status: "running", - startedAt: Date.now(), - finishedAt: null, - payload: null, - error: null, - details: null - }); - - log("info", "Редактирование запущено", { - jobId, - sourceId: sourceVersion?.id || null, - prompt: truncate(prompt, 200), - hasReference: Boolean(referenceName), - region: region || null, - referenceId: referenceId || null - }); - - void runEditJob(jobId, jobDir, inputNames, promptText, prompt, sourceVersion?.id || null, dimensions, jobStartedAt, referenceId); - } catch (error) { - codexEditInFlight = false; - throw error; - } - - response.json({ ok: true, jobId, status: "running" }); - }) -); - -app.post( - "/api/upscale", - upload.single("photo"), - asyncRoute(async (request, response) => { - const sourceVersion = await resolvePhotoInput(request); - const dimensions = await requirePhoto(request); - const scaledSize = scaleForLimit( - dimensions, - UPSCALE_MAX_DIMENSION - ); - - const runtime = await getRuntimeInfo(); - - requireNodes(runtime, [ - "LoadImage", - "UpscaleModelLoader", - "ImageUpscaleWithModel", - "SaveImage", - ...(scaledSize ? ["ImageScale"] : []) - ]); - - if (!runtime.selectedUpscaleModel) { - throw new AppError( - 503, - "На сервере ComfyUI не установлена модель увеличения разрешения." - ); - } - - const jobId = crypto.randomUUID(); - const upscaleStartedAt = Date.now(); - - jobs.set(jobId, { - type: "upscale", - status: "running", - startedAt: Date.now(), - finishedAt: null, - payload: null, - error: null, - details: null - }); - - log("info", "Апскейл запущен", { - jobId, - sourceId: sourceVersion?.id || null, - model: runtime.selectedUpscaleModel, - scaledSize: scaledSize || null - }); - - void runUpscaleJob( - jobId, - sourceVersion, - dimensions, - scaledSize, - runtime, - request.file.buffer, - request.file.mimetype, - upscaleStartedAt - ); - - response.json({ ok: true, jobId, status: "running" }); - }) -); - -app.post( - "/api/suggest", - upload.single("photo"), - asyncRoute(async (request, response) => { - const sourceVersion = await resolvePhotoInput(request); - await requirePhoto(request); - - if (codexEditInFlight) { - throw new AppError( - 429, - "Обработка уже выполняется. Дождитесь завершения текущего задания." - ); - } - - codexEditInFlight = true; - const jobId = crypto.randomUUID(); - const jobDir = path.join(CODEX_JOBS_DIR, jobId); - const jobStartedAt = Date.now(); - - try { - fs.mkdirSync(jobDir, { recursive: true }); - const inputName = `input.${extForMimetype(request.file.mimetype)}`; - fs.writeFileSync(path.join(jobDir, inputName), request.file.buffer); - - jobs.set(jobId, { - type: "suggest", - status: "running", - startedAt: Date.now(), - finishedAt: null, - payload: null, - error: null, - details: null - }); - - log("info", "Подсказка промпта запрошена", { - jobId, - sourceId: sourceVersion?.id || null - }); - - void runSuggestJob(jobId, jobDir, inputName, jobStartedAt); - } catch (error) { - codexEditInFlight = false; - throw error; - } - - response.json({ ok: true, jobId, status: "running" }); - }) -); - -app.get("/api/result", asyncRoute(async (request, response) => { - const filename = String(request.query.filename || ""); - const subfolder = String(request.query.subfolder || ""); - const type = String(request.query.type || "output"); - - if (!filename) { - throw new AppError(400, "Не указано имя итогового изображения."); - } - - if (type !== "output") { - throw new AppError(400, "Разрешена загрузка только итоговых изображений."); - } - - const parameters = new URLSearchParams({ - filename, - subfolder, - type: "output" - }); - - const upstream = await comfyRequest( - `/view?${parameters.toString()}`, - {}, - 30000 - ); - - response.status(200); - response.setHeader( - "content-type", - upstream.headers.get("content-type") || "image/png" - ); - response.setHeader("cache-control", "no-store"); - response.setHeader( - "content-disposition", - `inline; filename*=UTF-8''${encodeURIComponent(path.basename(filename))}` - ); - - if (!upstream.body) { - throw new AppError(502, "ComfyUI вернул пустое изображение."); - } - - await pipeline(Readable.fromWeb(upstream.body), response); -})); - -const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -app.get("/api/jobs/:id/output.jpg", asyncRoute(async (request, response) => { - const { id } = request.params; - - if (!JOB_ID_PATTERN.test(id)) { - throw new AppError(400, "Некорректный идентификатор задания."); - } - - const filePath = path.join(CODEX_JOBS_DIR, id, "output.jpg"); - - if (!fs.existsSync(filePath)) { - throw new AppError(404, "Результат редактирования не найден."); - } - - response.status(200); - response.setHeader("content-type", "image/jpeg"); - response.setHeader("cache-control", "no-store"); - response.setHeader( - "content-disposition", - `inline; filename*=UTF-8''${encodeURIComponent("edited-photo.jpg")}` - ); - - fs.createReadStream(filePath).pipe(response); -})); - -app.get("/api/jobs/:id/status", asyncRoute(async (request, response) => { - const { id } = request.params; - - if (!JOB_ID_PATTERN.test(id)) { - throw new AppError(400, "Некорректный идентификатор задания."); - } - - const job = jobs.get(id); - - if (!job) { - throw new AppError(404, "Задание не найдено."); - } - - const body = { ok: true, status: job.status, type: job.type }; - - if (job.status === "done") { - Object.assign(body, job.payload); - } - - if (job.status === "error") { - body.error = job.error; - if (job.details !== undefined) { - body.details = job.details; - } - } - - response.json(body); -})); - -app.use("/api", (_request, response) => { - response.status(404).json({ - ok: false, - error: "API-метод не найден." - }); -}); - -app.use((error, _request, response, next) => { - if (response.headersSent) { - log("error", "Ошибка после начала ответа", { - method: _request.method, - path: _request.originalUrl, - message: error instanceof Error ? error.message : String(error) - }); - next(error); - return; - } - - let status = error instanceof AppError ? error.status : 500; - let message = error instanceof Error - ? error.message - : "Неизвестная ошибка сервера."; - - if (error instanceof multer.MulterError) { - status = 400; - - if (error.code === "LIMIT_FILE_SIZE") { - message = "Файл слишком большой. Максимальный размер — 25 МБ."; - } else { - message = `Ошибка загрузки файла: ${error.message}`; - } - } - - if (error && error.type === "entity.parse.failed") { - status = 400; - message = "Некорректный JSON в теле запроса."; - } - - if ( - !(error instanceof AppError) && - !(error instanceof multer.MulterError) && - !(error && error.type === "entity.parse.failed") - ) { - log("error", "Непредвиденная ошибка сервера", { - method: _request.method, - path: _request.originalUrl, - status, - message - }); - console.error(error); - message = "Внутренняя ошибка сервера."; - } else if (error instanceof AppError) { - const fields = { status, message }; - if (error.details !== undefined) { - fields.details = truncate( - JSON.stringify(normalizeErrorDetails(error.details)), - 200 - ); - } - log(status >= 500 ? "error" : "warn", "Ошибка обработки запроса", fields); - } else { - log("warn", "Ошибка обработки запроса", { status, message }); - } - - response.status(status).json({ - ok: false, - error: message, - details: normalizeErrorDetails(error.details) - }); -}); - -function listenOnPort(port) { - return new Promise((resolve, reject) => { - const server = app.listen(port, "0.0.0.0"); - - const onError = (error) => { - reject(error); - }; - - server.once("error", onError); - server.once("listening", () => { - server.off("error", onError); - resolve(server); - }); - }); -} - -async function startServer() { - fs.rmSync(CODEX_JOBS_DIR, { recursive: true, force: true }); - fs.mkdirSync(CODEX_JOBS_DIR, { recursive: true }); - fs.mkdirSync(HISTORY_DIR, { recursive: true }); - - for (const port of PORT_CANDIDATES) { - try { - await listenOnPort(port); - - log("info", "Сервер запущен", { port, user: APP_USER, comfyUrl: COMFY_URL, passwordGenerated: !process.env.APP_PASSWORD }); - return; - } catch (error) { - if (error && error.code === "EADDRINUSE") { - log("warn", "Порт занят, пробую следующий", { port }); - continue; - } - - throw error; - } - } - - throw new Error( - `Не удалось запустить сервер: порты ${PORT_CANDIDATES.join(", ")} заняты.` - ); -} - -startServer().catch((error) => { - log("error", "Ошибка запуска", { message: error instanceof Error ? error.message : String(error) }); - console.error(error); - process.exitCode = 1; +startServer().catch((err) => { + console.error("Критическая ошибка при старте сервера:", err); + process.exit(1); }); diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..4a654f7 --- /dev/null +++ b/src/config.js @@ -0,0 +1,206 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { ConfigError } = require("./errors"); + +const REQUIRED_NODES = [ + "VAELoader", + "UpscaleModelLoader", + "ImageUpscaleWithModel", + "ImageScale", + "SaveImage", +]; + +const VALID_LOG_LEVELS = ["debug", "info", "warn", "error"]; + +function resolveCodexExe(cliPathOverride) { + const candidates = []; + + if (cliPathOverride) { + candidates.push(cliPathOverride); + } + + try { + const binRoot = path.join( + process.env.LOCALAPPDATA || "", + "OpenAI", + "Codex", + "bin" + ); + + if (fs.existsSync(binRoot)) { + for (const entry of fs.readdirSync(binRoot)) { + const candidate = path.join(binRoot, entry, "codex.exe"); + if (fs.existsSync(candidate)) { + candidates.push(candidate); + } + } + } + } catch { + // Skip if path error + } + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + return "codex"; +} + +function parseBoolean(val, defaultVal) { + if (val === undefined || val === null || val === "") return defaultVal; + if (typeof val === "boolean") return val; + const s = String(val).trim().toLowerCase(); + if (s === "true" || s === "1" || s === "yes") return true; + if (s === "false" || s === "0" || s === "no") return false; + return defaultVal; +} + +function parseNumber(val, defaultVal, name, issues, min = 0) { + if (val === undefined || val === null || val === "") return defaultVal; + const num = Number(val); + if (!Number.isFinite(num) || isNaN(num) || num < min) { + issues.push(`Переменная ${name} должна быть числом >= ${min}, получено: "${val}"`); + return defaultVal; + } + return num; +} + +function loadConfig(env = process.env) { + const issues = []; + + // PORT / PORT_CANDIDATES + let portCandidates = [3000, 8080, 8090]; + let port = 3000; + if (env.PORT !== undefined && env.PORT !== "") { + const parsedPort = Number(env.PORT); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { + issues.push(`PORT должен быть целым числом от 1 до 65535, получено: "${env.PORT}"`); + } else { + port = parsedPort; + portCandidates = [parsedPort]; + } + } + + // COMFY_URL + const comfyUrlStr = env.COMFY_URL || "http://192.168.31.240:8188"; + try { + const parsedUrl = new URL(comfyUrlStr); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + issues.push(`COMFY_URL должен использовать протокол http или https, получено: "${comfyUrlStr}"`); + } + } catch { + issues.push(`Некорректный COMFY_URL: "${comfyUrlStr}"`); + } + + // APP_USER + const appUser = (env.APP_USER !== undefined && env.APP_USER !== "") + ? String(env.APP_USER).trim() + : "admin"; + + // APP_PASSWORD + let isGeneratedPassword = false; + let appPassword = ""; + if (env.APP_PASSWORD !== undefined && String(env.APP_PASSWORD).trim() !== "") { + appPassword = String(env.APP_PASSWORD); + } else { + appPassword = crypto.randomBytes(12).toString("base64url"); + isGeneratedPassword = true; + } + + // LOG_LEVEL + const logLevelRaw = (env.LOG_LEVEL || "info").toLowerCase(); + if (!VALID_LOG_LEVELS.includes(logLevelRaw)) { + issues.push(`LOG_LEVEL должен быть одним из [${VALID_LOG_LEVELS.join(", ")}], получено: "${env.LOG_LEVEL}"`); + } + const logLevel = VALID_LOG_LEVELS.includes(logLevelRaw) ? logLevelRaw : "info"; + + // DATA_DIR & HISTORY_DIR + const dataDir = path.resolve(env.DATA_DIR || "./data"); + const historyDir = path.resolve(env.HISTORY_DIR || path.join(dataDir, "history")); + + // Numeric parameters + const historyLimit = parseNumber(env.HISTORY_LIMIT, 50, "HISTORY_LIMIT", issues, 1); + const maxUploadBytes = parseNumber(env.MAX_UPLOAD_BYTES, 25 * 1024 * 1024, "MAX_UPLOAD_BYTES", issues, 1024); + const editMaxDimension = parseNumber(env.EDIT_MAX_DIMENSION, 1024, "EDIT_MAX_DIMENSION", issues, 0); + const upscaleMaxDimension = parseNumber(env.UPSCALE_MAX_DIMENSION, 2048, "UPSCALE_MAX_DIMENSION", issues, 256); + const codexEditTimeoutMs = parseNumber(env.CODEX_EDIT_TIMEOUT_MS, 420000, "CODEX_EDIT_TIMEOUT_MS", issues, 1000); + const jobTimeoutMs = parseNumber(env.JOB_TIMEOUT_MS, 300000, "JOB_TIMEOUT_MS", issues, 1000); + const requestTimeoutMs = parseNumber(env.REQUEST_TIMEOUT_MS, 15000, "REQUEST_TIMEOUT_MS", issues, 500); + const pollIntervalMs = parseNumber(env.POLL_INTERVAL_MS, 1500, "POLL_INTERVAL_MS", issues, 100); + const sessionTtlMs = parseNumber(env.SESSION_TTL_MS, 7 * 24 * 60 * 60 * 1000, "SESSION_TTL_MS", issues, 1000); + const codexConcurrency = parseNumber(env.CODEX_CONCURRENCY, 1, "CODEX_CONCURRENCY", issues, 1); + const upscaleConcurrency = parseNumber(env.UPSCALE_CONCURRENCY, 2, "UPSCALE_CONCURRENCY", issues, 1); + const maxQueuedJobs = parseNumber(env.MAX_QUEUED_JOBS, 5, "MAX_QUEUED_JOBS", issues, 1); + + // Booleans + const sessionPersist = parseBoolean(env.SESSION_PERSIST, true); + const sseEnabled = parseBoolean(env.SSE_ENABLED, true); + const trustProxy = parseBoolean(env.TRUST_PROXY, false); + + // Rate limit + const rateLimitWindowMs = parseNumber(env.LOGIN_RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000, "LOGIN_RATE_LIMIT_WINDOW_MS", issues, 1000); + const rateLimitMax = parseNumber(env.LOGIN_RATE_LIMIT_MAX, 5, "LOGIN_RATE_LIMIT_MAX", issues, 1); + const loginRateLimit = { windowMs: rateLimitWindowMs, max: rateLimitMax }; + + // Codex CLI path + const codexCliPath = resolveCodexExe(env.CODEX_CLI_PATH); + const codexModel = env.CODEX_MODEL || null; + const codexReasoningEffort = env.CODEX_REASONING_EFFORT || null; + + if (issues.length > 0) { + throw new ConfigError(`Ошибки в конфигурации:\n- ${issues.join("\n- ")}`, issues); + } + + const configObj = { + PORT: port, + PORT_CANDIDATES: portCandidates, + COMFY_URL: comfyUrlStr, + CHECKPOINT: "sdxl_turbo.safetensors", + REQUIRED_NODES, + APP_USER: appUser, + APP_PASSWORD: appPassword, + isGeneratedPassword, + LOG_LEVEL: logLevel, + DATA_DIR: dataDir, + HISTORY_DIR: historyDir, + HISTORY_LIMIT: historyLimit, + MAX_UPLOAD_BYTES: maxUploadBytes, + EDIT_MAX_DIMENSION: editMaxDimension, + UPSCALE_MAX_DIMENSION: upscaleMaxDimension, + CODEX_CLI_PATH: codexCliPath, + CODEX_MODEL: codexModel, + CODEX_REASONING_EFFORT: codexReasoningEffort, + CODEX_EDIT_TIMEOUT_MS: codexEditTimeoutMs, + JOB_TIMEOUT_MS: jobTimeoutMs, + REQUEST_TIMEOUT_MS: requestTimeoutMs, + POLL_INTERVAL_MS: pollIntervalMs, + SESSION_TTL_MS: sessionTtlMs, + SESSION_PERSIST: sessionPersist, + LOGIN_RATE_LIMIT: loginRateLimit, + CODEX_CONCURRENCY: codexConcurrency, + UPSCALE_CONCURRENCY: upscaleConcurrency, + MAX_QUEUED_JOBS: maxQueuedJobs, + SSE_ENABLED: sseEnabled, + TRUST_PROXY: trustProxy, + + getSanitized() { + const copy = { ...this }; + delete copy.APP_PASSWORD; + copy.APP_PASSWORD_SET = Boolean(this.APP_PASSWORD); + return copy; + }, + }; + + return Object.freeze(configObj); +} + +module.exports = { + loadConfig, + REQUIRED_NODES, + resolveCodexExe, +}; diff --git a/src/core/auth/password.js b/src/core/auth/password.js new file mode 100644 index 0000000..da6f21d --- /dev/null +++ b/src/core/auth/password.js @@ -0,0 +1,114 @@ +"use strict"; + +const crypto = require("crypto"); +const { promisify } = require("util"); + +const scryptAsync = promisify(crypto.scrypt); + +const KEY_LEN = 64; +const SALT_LEN = 16; +const SCRYPT_OPTIONS = { + N: 16384, + r: 8, + p: 1, + maxmem: 32 * 1024 * 1024, +}; + +/** + * Хеширует пароль с помощью scrypt и случайной соли. + * @param {string} password + * @returns {Promise} Формат: "saltHex:derivedKeyHex" + */ +async function hashPassword(password) { + if (typeof password !== "string") { + throw new TypeError("Пароль должен быть строкой"); + } + const salt = crypto.randomBytes(SALT_LEN); + const derivedKey = await scryptAsync(password, salt, KEY_LEN, SCRYPT_OPTIONS); + return `${salt.toString("hex")}:${derivedKey.toString("hex")}`; +} + +/** + * Синхронное хеширование (для инициализации при старте). + * @param {string} password + * @returns {string} + */ +function hashPasswordSync(password) { + if (typeof password !== "string") { + throw new TypeError("Пароль должен быть строкой"); + } + const salt = crypto.randomBytes(SALT_LEN); + const derivedKey = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_OPTIONS); + return `${salt.toString("hex")}:${derivedKey.toString("hex")}`; +} + +/** + * Проверяет пароль против сохранённого scrypt-хеша с защитой от тайминг-атак. + * @param {string} password + * @param {string} storedHash + * @returns {Promise} + */ +async function verifyPassword(password, storedHash) { + if (typeof password !== "string" || typeof storedHash !== "string") { + return false; + } + + const parts = storedHash.split(":"); + if (parts.length !== 2) { + return false; + } + + const [saltHex, keyHex] = parts; + try { + const salt = Buffer.from(saltHex, "hex"); + const originalKey = Buffer.from(keyHex, "hex"); + + if (salt.length !== SALT_LEN || originalKey.length !== KEY_LEN) { + return false; + } + + const derivedKey = await scryptAsync(password, salt, KEY_LEN, SCRYPT_OPTIONS); + return crypto.timingSafeEqual(originalKey, derivedKey); + } catch { + return false; + } +} + +/** + * Синхронная проверка пароля. + * @param {string} password + * @param {string} storedHash + * @returns {boolean} + */ +function verifyPasswordSync(password, storedHash) { + if (typeof password !== "string" || typeof storedHash !== "string") { + return false; + } + + const parts = storedHash.split(":"); + if (parts.length !== 2) { + return false; + } + + const [saltHex, keyHex] = parts; + try { + const salt = Buffer.from(saltHex, "hex"); + const originalKey = Buffer.from(keyHex, "hex"); + + if (salt.length !== SALT_LEN || originalKey.length !== KEY_LEN) { + return false; + } + + const derivedKey = crypto.scryptSync(password, salt, KEY_LEN, SCRYPT_OPTIONS); + return crypto.timingSafeEqual(originalKey, derivedKey); + } catch { + return false; + } +} + +module.exports = { + hashPassword, + hashPasswordSync, + verifyPassword, + verifyPasswordSync, +}; diff --git a/src/core/auth/session-store.js b/src/core/auth/session-store.js new file mode 100644 index 0000000..8feb9d7 --- /dev/null +++ b/src/core/auth/session-store.js @@ -0,0 +1,190 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +function hashToken(token) { + return crypto.createHash("sha256").update(String(token)).digest("hex"); +} + +class SessionStore { + /** + * @param {Object} options + * @param {string} options.file - Путь к sessions.json + * @param {number} options.ttlMs - Время жизни сессии в мс + * @param {boolean} [options.persist=true] - Сохранять ли на диск + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ file, ttlMs = 7 * 24 * 60 * 60 * 1000, persist = true, logger = null }) { + this.file = file; + this.ttlMs = ttlMs; + this.persist = persist; + this.logger = logger; + /** @type {Map} */ + this.sessions = new Map(); // key: hash(token) + this._persistTimer = null; + this._sweepInterval = null; + + if (this.persist && this.file) { + this._load(); + } + + this._sweepInterval = setInterval(() => this.sweep(), 60 * 60 * 1000); + if (this._sweepInterval.unref) { + this._sweepInterval.unref(); + } + } + + _load() { + try { + if (!fs.existsSync(this.file)) return; + const raw = fs.readFileSync(this.file, "utf8"); + if (!raw.trim()) return; + const data = JSON.parse(raw); + const now = Date.now(); + let loaded = 0; + for (const [tokenHash, session] of Object.entries(data)) { + if (session && session.expiresAt && session.expiresAt > now && session.user) { + this.sessions.set(tokenHash, { + user: session.user, + expiresAt: session.expiresAt, + createdAt: session.createdAt || now, + }); + loaded++; + } + } + if (this.logger) { + this.logger.debug("Сессии загружены с диска", { count: loaded }); + } + } catch (err) { + if (this.logger) { + this.logger.warn("Ошибка при загрузке sessions.json, инициализация пустого хранилища", { + error: err.message, + }); + } + } + } + + create(user) { + const rawToken = crypto.randomBytes(32).toString("hex"); + const tokenHash = hashToken(rawToken); + const now = Date.now(); + const session = { + user: String(user || "admin"), + expiresAt: now + this.ttlMs, + createdAt: now, + }; + this.sessions.set(tokenHash, session); + this._schedulePersist(); + return rawToken; + } + + get(token) { + if (!token) return null; + const tokenHash = hashToken(token); + const session = this.sessions.get(tokenHash); + if (!session) return null; + + const now = Date.now(); + if (session.expiresAt <= now) { + this.sessions.delete(tokenHash); + this._schedulePersist(); + return null; + } + + // Sliding TTL + session.expiresAt = now + this.ttlMs; + this._schedulePersist(); + return { + user: session.user, + expiresAt: session.expiresAt, + createdAt: session.createdAt, + }; + } + + delete(token) { + if (!token) return; + const tokenHash = hashToken(token); + if (this.sessions.delete(tokenHash)) { + this._schedulePersist(); + } + } + + sweep() { + const now = Date.now(); + let removed = 0; + for (const [tokenHash, session] of this.sessions) { + if (session.expiresAt <= now) { + this.sessions.delete(tokenHash); + removed++; + } + } + if (removed > 0) { + this._schedulePersist(); + if (this.logger) { + this.logger.debug("Очищены устаревшие сессии", { count: removed }); + } + } + } + + _schedulePersist() { + if (!this.persist || !this.file) return; + if (this._persistTimer) return; + + this._persistTimer = setTimeout(() => { + this._persistTimer = null; + this._flushPersist(); + }, 500); + + if (this._persistTimer.unref) { + this._persistTimer.unref(); + } + } + + _flushPersist() { + if (!this.persist || !this.file) return; + try { + const dir = path.dirname(this.file); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const obj = {}; + for (const [tokenHash, session] of this.sessions) { + obj[tokenHash] = session; + } + + const tmpFile = `${this.file}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; + const fd = fs.openSync(tmpFile, "w"); + try { + fs.writeFileSync(fd, JSON.stringify(obj, null, 2), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpFile, this.file); + } catch (err) { + if (this.logger) { + this.logger.error("Ошибка при сохранении sessions.json", { error: err.message }); + } + } + } + + close() { + if (this._sweepInterval) { + clearInterval(this._sweepInterval); + this._sweepInterval = null; + } + if (this._persistTimer) { + clearTimeout(this._persistTimer); + this._persistTimer = null; + } + this._flushPersist(); + } +} + +module.exports = { + SessionStore, + hashToken, +}; diff --git a/src/core/codex/cli.js b/src/core/codex/cli.js new file mode 100644 index 0000000..26d14e6 --- /dev/null +++ b/src/core/codex/cli.js @@ -0,0 +1,265 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); + +function codexExecArgs(jobDir, inputNames) { + const args = [ + "exec", + "-C", + jobDir, + "--skip-git-repo-check", + "--ephemeral", + "--dangerously-bypass-approvals-and-sandbox", + ]; + + for (const name of inputNames) { + args.push("-i", name); + } + + args.push("-o", "last_message.txt", "-"); + return args; +} + +class CodexCli { + /** + * @param {Object} options + * @param {string} [options.codexExe='codex'] + * @param {number} [options.defaultTimeoutMs=420000] + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ codexExe = "codex", defaultTimeoutMs = 420000, logger = null }) { + this.codexExe = codexExe; + this.defaultTimeoutMs = defaultTimeoutMs; + this.logger = logger; + } + + /** + * Запускает Codex CLI для редактирования изображения. + * @param {Object} params + * @param {string} params.jobDir + * @param {string[]} params.inputNames + * @param {string} params.prompt + * @param {number} [params.timeoutMs] + * @returns {Promise<{ exitCode: number|null, timedOut: boolean, outputPath: string|null, lastMessage: string, durationMs: number }>} + */ + runEdit({ jobDir, inputNames, prompt, timeoutMs = this.defaultTimeoutMs }) { + const lastMessagePath = path.join(jobDir, "last_message.txt"); + const outputPath = path.join(jobDir, "output.jpg"); + + return new Promise((resolve, reject) => { + let child; + + try { + child = spawn( + this.codexExe, + codexExecArgs(jobDir, inputNames), + { + cwd: jobDir, + env: process.env, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + } + ); + } catch (error) { + reject(error); + return; + } + + const startedAt = Date.now(); + let exitCode = null; + let processExitedAt = null; + let outputSeenAt = null; + let timedOut = false; + let settled = false; + + const settle = () => { + if (settled) return; + settled = true; + clearInterval(deadlineTimer); + + const lastMessage = fs.existsSync(lastMessagePath) + ? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 2000) + : ""; + const hasOutput = + fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0; + + resolve({ + exitCode, + timedOut, + outputPath: hasOutput ? outputPath : null, + lastMessage, + durationMs: Date.now() - startedAt, + }); + }; + + const deadlineTimer = setInterval(() => { + if (Date.now() - startedAt > timeoutMs) { + timedOut = true; + try { + child.kill(); + } catch { + // Process already gone + } + settle(); + } + }, 1000); + + child.on("error", (error) => { + if (settled) return; + settled = true; + clearInterval(deadlineTimer); + reject(error); + }); + + child.on("exit", (code) => { + exitCode = code; + processExitedAt = Date.now(); + }); + + child.stdin.on("error", () => {}); + child.stdin.end(prompt); + + const poll = () => { + if (settled) return; + + if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) { + if (outputSeenAt === null) { + outputSeenAt = Date.now(); + } + + // Дожидаемся итогового сообщения Codex, но не дольше 10 секунд + if ( + !fs.existsSync(lastMessagePath) && + Date.now() - outputSeenAt < 10000 + ) { + setTimeout(poll, 1000); + return; + } + + settle(); + return; + } + + if (Date.now() - startedAt > timeoutMs) { + timedOut = true; + try { + child.kill(); + } catch { + // Process already gone + } + settle(); + return; + } + + if (processExitedAt !== null && Date.now() - processExitedAt > 15000) { + settle(); + return; + } + + setTimeout(poll, 1000); + }; + + poll(); + }); + } + + /** + * Запускает Codex CLI для генерации подсказок промптов. + * @param {Object} params + * @param {string} params.jobDir + * @param {string[]} params.inputNames + * @param {string} params.prompt + * @param {number} [params.timeoutMs] + * @returns {Promise<{ exitCode: number|null, timedOut: boolean, lastMessage: string, durationMs: number }>} + */ + runSuggest({ jobDir, inputNames, prompt, timeoutMs = this.defaultTimeoutMs }) { + const lastMessagePath = path.join(jobDir, "last_message.txt"); + + return new Promise((resolve, reject) => { + let child; + try { + child = spawn( + this.codexExe, + codexExecArgs(jobDir, inputNames), + { + cwd: jobDir, + env: process.env, + stdio: ["pipe", "ignore", "ignore"], + windowsHide: true, + } + ); + } catch (error) { + reject(error); + return; + } + + const startedAt = Date.now(); + let exitCode = null; + let settled = false; + let timedOut = false; + + const settle = () => { + if (settled) return; + settled = true; + clearInterval(deadlineTimer); + + const lastMessage = fs.existsSync(lastMessagePath) + ? fs.readFileSync(lastMessagePath, "utf8").trim().slice(0, 4000) + : ""; + + resolve({ + exitCode, + timedOut, + lastMessage, + durationMs: Date.now() - startedAt, + }); + }; + + const deadlineTimer = setInterval(() => { + if (Date.now() - startedAt > timeoutMs) { + timedOut = true; + try { + child.kill(); + } catch { + // Process already gone + } + settle(); + } + }, 1000); + + child.on("error", (error) => { + if (settled) return; + settled = true; + clearInterval(deadlineTimer); + reject(error); + }); + + child.on("exit", (code) => { + exitCode = code; + const exitAt = Date.now(); + const pollLastMessage = () => { + if (settled) return; + const text = fs.existsSync(lastMessagePath) + ? fs.readFileSync(lastMessagePath, "utf8").trim() + : ""; + if (text.length > 0 || Date.now() - exitAt > 5000) { + settle(); + return; + } + setTimeout(pollLastMessage, 250); + }; + setTimeout(pollLastMessage, 250); + }); + + child.stdin.on("error", () => {}); + child.stdin.end(prompt); + }); + } +} + +module.exports = { + CodexCli, + codexExecArgs, +}; diff --git a/src/core/codex/prompts.js b/src/core/codex/prompts.js new file mode 100644 index 0000000..c6aa3a4 --- /dev/null +++ b/src/core/codex/prompts.js @@ -0,0 +1,156 @@ +"use strict"; + +const { AppError } = require("../../errors"); + +/** + * Парсит и валидирует координаты прямоугольной области референса. + * @param {string|object} value + * @returns {{ x: number, y: number, w: number, h: number } | null} + */ +function parseRegion(value) { + if (value === undefined || value === null || String(value).trim() === "") { + return null; + } + + let parsed = value; + if (typeof value === "string") { + try { + parsed = JSON.parse(value); + } catch { + throw new AppError(400, "Область референса передана в неверном формате."); + } + } + + const nums = ["x", "y", "w", "h"].map((key) => Number(parsed?.[key])); + + if (nums.some((num) => !Number.isFinite(num) || num < 0)) { + throw new AppError( + 400, + "Область референса должна содержать неотрицательные числовые поля x, y, w, h." + ); + } + + const [x, y, w, h] = nums; + if (w <= 0 || h <= 0) { + throw new AppError(400, "Ширина и высота области должны быть больше 0."); + } + + return { + x: Math.round(x), + y: Math.round(y), + w: Math.round(w), + h: Math.round(h), + }; +} + +/** + * Масштабирует координаты области при даунскейле референса. + * @param {{ x: number, y: number, w: number, h: number }} region + * @param {number} factor + * @returns {{ x: number, y: number, w: number, h: number }} + */ +function scaleRegion(region, factor = 1) { + if (!region || factor === 1 || !Number.isFinite(factor) || factor <= 0) { + return region; + } + + return { + x: Math.max(0, Math.round(region.x * factor)), + y: Math.max(0, Math.round(region.y * factor)), + w: Math.max(1, Math.round(region.w * factor)), + h: Math.max(1, Math.round(region.h * factor)), + }; +} + +/** + * Собирает текст системного промпта для редактирования через Codex. + * @param {Object} options + * @param {string} options.prompt + * @param {{ width?: number, height?: number }|null} [options.dimensions] + * @param {string} [options.inputName='input.jpg'] + * @param {string|null} [options.referenceName=null] + * @param {{ x: number, y: number, w: number, h: number }|null} [options.region=null] + * @param {boolean} [options.downscaled=false] + * @returns {string} + */ +function buildEditPrompt({ + prompt, + dimensions = null, + inputName = "input.jpg", + referenceName = null, + region = null, + downscaled = false, +}) { + const size = + dimensions && dimensions.width && dimensions.height + ? `${dimensions.width}×${dimensions.height}` + : ""; + + const lines = [ + `Перед тобой фотография ${inputName}. Примени к ней следующее редактирование: «${prompt}».`, + ]; + + if (referenceName) { + lines.push( + `Также дано вспомогательное изображение ${referenceName} — тот же сюжет и ракурс, что и ${inputName}. Красным прямоугольником на ${referenceName} выделена область, в которой нужно внести правку.` + ); + } + + if (referenceName && region) { + lines.push( + `Координаты области на ${referenceName}: x=${region.x}, y=${region.y}, ширина=${region.w}, высота=${region.h} (в пикселях).` + ); + lines.push( + `Примени редактирование к ${inputName} именно в области, соответствующей прямоугольнику на ${referenceName}. Остальную часть ${inputName} не меняй.` + ); + } + + if (referenceName && !region) { + lines.push( + `Используй ${referenceName} как ориентир по сюжету и ракурсу при внесении правки в ${inputName}.` + ); + } + + if (downscaled) { + lines.push("Исходное фото уменьшено для скорости обработки."); + } + + lines.push( + "Сохрани отредактированное фото в файл output.jpg в текущей директории.", + "Не перерисовывай фото с нуля и не заменяй его на полностью новое изображение — редактируй исходное фото, сохраняя его сюжет, объекты, лица и пропорции.", + size ? `Сохрани исходный размер ${size}.` : "Сохрани исходные пропорции.", + "Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал." + ); + + return lines.join(" "); +} + +/** + * Собирает промпт для подсказок вариантов редактирования. + * @param {string} [inputName='input.jpg'] + * @returns {string} + */ +function buildSuggestPrompt(inputName = "input.jpg") { + return `Перед тобой фотография ${inputName}. Предложи 3 коротких варианта промпта (по одному на строку, без нумерации) для её редактирования. Только варианты промптов, ничего больше.`; +} + +/** + * Парсит предложения Codex в массив строк. + * @param {string} text + * @returns {string[]} + */ +function parseSuggestions(text) { + return String(text || "") + .split(/\r?\n/) + .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, "").trim()) + .filter((line) => line.length > 0 && line.length <= 200) + .slice(0, 3); +} + +module.exports = { + parseRegion, + scaleRegion, + buildEditPrompt, + buildSuggestPrompt, + parseSuggestions, +}; diff --git a/src/core/comfy/comfy-client.js b/src/core/comfy/comfy-client.js new file mode 100644 index 0000000..0b2a878 --- /dev/null +++ b/src/core/comfy/comfy-client.js @@ -0,0 +1,224 @@ +"use strict"; + +const crypto = require("crypto"); +const path = require("path"); +const { AppError } = require("../../errors"); + +function safeUploadFilename(originalname = "photo.png", mimetype = "image/png") { + const extensionByMime = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + }; + + const extension = + extensionByMime[String(mimetype).toLowerCase()] || + path.extname(String(originalname)).toLowerCase() || + ".png"; + + const originalStem = path.parse(String(originalname || "photo")).name; + const cleanStem = + originalStem + .normalize("NFKC") + .replace(/[^\p{L}\p{N}._-]+/gu, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "photo"; + + return ( + [ + "prompt-editor", + Date.now(), + crypto.randomBytes(4).toString("hex"), + cleanStem, + ].join("-") + extension + ); +} + +class ComfyClient { + /** + * @param {Object} options + * @param {string} options.comfyUrl + * @param {number} [options.requestTimeoutMs=15000] + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ comfyUrl, requestTimeoutMs = 15000, logger = null }) { + this.comfyUrl = comfyUrl.replace(/\/+$/, ""); + this.requestTimeoutMs = requestTimeoutMs; + this.logger = logger; + } + + async request(route, options = {}, timeoutMs = this.requestTimeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const url = `${this.comfyUrl}${route}`; + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new AppError( + 502, + `ComfyUI вернул ошибку HTTP ${response.status}.`, + body.slice(0, 2000) || undefined + ); + } + + return response; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + if (error && error.name === "AbortError") { + throw new AppError( + 504, + "ComfyUI не ответил вовремя. Проверьте сервер обработки и повторите попытку." + ); + } + + const host = new URL(this.comfyUrl).host; + throw new AppError( + 503, + `Не удалось подключиться к ComfyUI по адресу ${host}.`, + error instanceof Error ? error.message : String(error) + ); + } finally { + clearTimeout(timer); + } + } + + async json(route, options = {}, timeoutMs = this.requestTimeoutMs) { + const response = await this.request(route, options, timeoutMs); + try { + return await response.json(); + } catch { + throw new AppError(502, "ComfyUI вернул некорректный JSON-ответ."); + } + } + + async uploadImage(buffer, originalname = "photo.png", mimetype = "image/png") { + const form = new FormData(); + const filename = safeUploadFilename(originalname, mimetype); + + form.append( + "image", + new Blob([buffer], { type: mimetype }), + filename + ); + form.append("type", "input"); + form.append("overwrite", "true"); + + const uploaded = await this.json( + "/upload/image", + { + method: "POST", + body: form, + }, + 30000 + ); + + if (!uploaded || typeof uploaded.name !== "string") { + throw new AppError( + 502, + "ComfyUI не подтвердил загрузку исходной фотографии.", + uploaded + ); + } + + const subfolder = + typeof uploaded.subfolder === "string" + ? uploaded.subfolder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") + : ""; + + return { + name: uploaded.name, + subfolder, + type: uploaded.type || "input", + loadImageName: subfolder ? `${subfolder}/${uploaded.name}` : uploaded.name, + }; + } + + async submitPrompt(workflow) { + const response = await this.json( + "/prompt", + { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + prompt: workflow, + client_id: crypto.randomUUID(), + }), + }, + 30000 + ); + + const nodeErrors = response?.node_errors; + if ( + nodeErrors && + typeof nodeErrors === "object" && + Object.keys(nodeErrors).length > 0 + ) { + throw new AppError( + 422, + "ComfyUI отклонил схему обработки. Проверьте установленные модели и узлы.", + nodeErrors + ); + } + + if (!response || typeof response.prompt_id !== "string") { + throw new AppError( + 502, + "ComfyUI не вернул идентификатор задачи.", + response + ); + } + + return response.prompt_id; + } + + async getHistory(promptId) { + return this.json(`/history/${encodeURIComponent(promptId)}`); + } + + async downloadImage({ filename, subfolder = "", type = "output" }) { + const parameters = new URLSearchParams({ + filename, + subfolder: subfolder || "", + type: type || "output", + }); + + const upstream = await this.request( + `/view?${parameters.toString()}`, + {}, + 60000 + ); + + if (!upstream.body) { + throw new AppError(502, "ComfyUI вернул пустое изображение."); + } + + return Buffer.from(await upstream.arrayBuffer()); + } + + async viewStream({ filename, subfolder = "", type = "output" }) { + const parameters = new URLSearchParams({ + filename, + subfolder: subfolder || "", + type: type || "output", + }); + + return this.request(`/view?${parameters.toString()}`, {}, 60000); + } +} + +module.exports = { + ComfyClient, + safeUploadFilename, +}; diff --git a/src/core/comfy/comfy-runtime.js b/src/core/comfy/comfy-runtime.js new file mode 100644 index 0000000..d340b41 --- /dev/null +++ b/src/core/comfy/comfy-runtime.js @@ -0,0 +1,195 @@ +"use strict"; + +const { AppError } = require("../../errors"); + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function extractChoiceOptions(specification) { + if (!Array.isArray(specification)) { + return []; + } + + // Old format: [["model-a", "model-b"], {...}] + if (Array.isArray(specification[0])) { + return specification[0].filter((item) => typeof item === "string"); + } + + // New format: ["COMBO", { options: ["model-a"] }] + if ( + specification[1] && + typeof specification[1] === "object" && + Array.isArray(specification[1].options) + ) { + return specification[1].options.filter((item) => typeof item === "string"); + } + + if (specification.every((item) => typeof item === "string")) { + return specification; + } + + return []; +} + +function chooseUpscaleModel(models) { + const preferences = [ + /4x[-_ ]?ultrasharp/i, + /realesrgan.*x4plus/i, + /4x.*plus/i, + /4x/i, + ]; + + for (const pattern of preferences) { + const match = models.find((model) => pattern.test(model)); + if (match) { + return match; + } + } + + return models[0] || null; +} + +function executionErrorFromHistory(record) { + const messages = record?.status?.messages; + + if (!Array.isArray(messages)) { + return null; + } + + for (const message of messages) { + if (!Array.isArray(message) || message[0] !== "execution_error") { + continue; + } + + const payload = message[1]; + if (payload?.exception_message) { + return payload.exception_message; + } + + return "Внутренняя ошибка выполнения workflow."; + } + + return null; +} + +class ComfyRuntime { + /** + * @param {Object} options + * @param {import('./comfy-client').ComfyClient} options.client + * @param {string[]} [options.requiredNodes] + * @param {string} [options.checkpoint] + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ + client, + requiredNodes = [ + "VAELoader", + "UpscaleModelLoader", + "ImageUpscaleWithModel", + "ImageScale", + "SaveImage", + ], + checkpoint = "sdxl_turbo.safetensors", + logger = null, + }) { + this.client = client; + this.requiredNodes = requiredNodes; + this.checkpoint = checkpoint; + this.logger = logger; + this._cache = null; + this._cacheExpiresAt = 0; + } + + async getRuntimeInfo(forceRefresh = false) { + if (!forceRefresh && this._cache && Date.now() < this._cacheExpiresAt) { + return this._cache; + } + + const objectInfo = await this.client.json("/object_info"); + + const nodes = Object.fromEntries( + this.requiredNodes.map((nodeName) => [ + nodeName, + Boolean(objectInfo[nodeName]), + ]) + ); + + const upscaleSpec = objectInfo.UpscaleModelLoader?.input?.required?.model_name; + const checkpointSpec = objectInfo.CheckpointLoaderSimple?.input?.required?.ckpt_name; + + const upscaleModels = extractChoiceOptions(upscaleSpec); + const checkpoints = extractChoiceOptions(checkpointSpec); + + this._cache = { + nodes, + upscaleModels, + selectedUpscaleModel: chooseUpscaleModel(upscaleModels), + checkpoints, + checkpointAvailable: checkpoints.includes(this.checkpoint), + }; + + this._cacheExpiresAt = Date.now() + 30000; + return this._cache; + } + + requireNodes(runtime, nodeNames) { + const missing = nodeNames.filter((nodeName) => !runtime.nodes[nodeName]); + if (missing.length > 0) { + throw new AppError( + 503, + `На сервере ComfyUI отсутствуют необходимые узлы: ${missing.join(", ")}.`, + { missing } + ); + } + } + + async waitForResult(promptId, saveNodeId, timeoutMs = 300000, pollIntervalMs = 1500) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const history = await this.client.getHistory(promptId); + const record = history?.[promptId] || null; + const output = record?.outputs?.[saveNodeId]; + const image = output?.images?.[0]; + + if (image && typeof image.filename === "string") { + return { + filename: image.filename, + subfolder: typeof image.subfolder === "string" ? image.subfolder : "", + type: image.type || "output", + }; + } + + const executionError = executionErrorFromHistory(record); + if (executionError) { + throw new AppError( + 502, + `ComfyUI не смог обработать изображение: ${executionError}` + ); + } + + if (record?.status?.completed) { + throw new AppError( + 502, + "ComfyUI завершил задачу, но не создал итоговое изображение.", + record.status.messages + ); + } + + await delay(pollIntervalMs); + } + + throw new AppError( + 504, + "Обработка заняла больше времени, чем положено, и была остановлена по таймауту." + ); + } +} + +module.exports = { + ComfyRuntime, + extractChoiceOptions, + chooseUpscaleModel, + executionErrorFromHistory, +}; diff --git a/src/core/comfy/workflows.js b/src/core/comfy/workflows.js new file mode 100644 index 0000000..bbda298 --- /dev/null +++ b/src/core/comfy/workflows.js @@ -0,0 +1,75 @@ +"use strict"; + +function imageScaleNode(imageSource, size) { + return { + class_type: "ImageScale", + inputs: { + image: imageSource, + upscale_method: "lanczos", + width: size.width, + height: size.height, + crop: "disabled", + }, + }; +} + +function buildUpscaleWorkflow(uploaded, modelName, scaledSize) { + const imageSource = scaledSize ? ["2", 0] : ["1", 0]; + + const workflow = { + "1": { + class_type: "LoadImage", + inputs: { + image: uploaded.loadImageName, + }, + }, + "3": { + class_type: "UpscaleModelLoader", + inputs: { + model_name: modelName, + }, + }, + "4": { + class_type: "ImageUpscaleWithModel", + inputs: { + upscale_model: ["3", 0], + image: imageSource, + }, + }, + "5": { + class_type: "SaveImage", + inputs: { + images: ["4", 0], + filename_prefix: "prompt_editor/upscale", + }, + }, + }; + + if (scaledSize) { + workflow["2"] = imageScaleNode(["1", 0], scaledSize); + } + + return { + workflow, + saveNodeId: "5", + }; +} + +function makePublicResult(image) { + const parameters = new URLSearchParams({ + filename: image.filename, + subfolder: image.subfolder || "", + type: image.type || "output", + }); + + return { + ...image, + url: `/api/result?${parameters.toString()}`, + }; +} + +module.exports = { + imageScaleNode, + buildUpscaleWorkflow, + makePublicResult, +}; diff --git a/src/core/engines/codex-engine.js b/src/core/engines/codex-engine.js new file mode 100644 index 0000000..51683e8 --- /dev/null +++ b/src/core/engines/codex-engine.js @@ -0,0 +1,261 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { downscaleImage } = require("../images/resize"); +const { getImageDimensions } = require("../images/dimensions"); +const { buildEditPrompt, buildSuggestPrompt, parseSuggestions, scaleRegion } = require("../codex/prompts"); +const { truncate } = require("../../logger"); +const { AppError } = require("../../errors"); + +/** + * Создаёт адаптер Codex Engine для edit и suggest. + * @param {import('./engine').EngineContext} ctx + */ +function createCodexEditEngine(ctx) { + const { config, logger, history, codexCli } = ctx; + + return { + async run(job) { + const { payload } = job; + const { + sourceId, + inputBuffer, + inputMimetype, + prompt, + referenceId, + referenceBuffer, + referenceMimetype, + region, + } = payload || {}; + + if (!prompt || !String(prompt).trim()) { + throw new AppError(400, "Промпт обязателен для редактирования."); + } + + // 1. Resolve input image buffer + let rawBuffer = null; + let inputExt = "jpg"; + + if (sourceId) { + const fileInfo = await history.getVersionFile(sourceId); + rawBuffer = fileInfo.buffer; + inputExt = fileInfo.version.ext || "jpg"; + } else if (inputBuffer) { + rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer); + if (inputMimetype) { + inputExt = inputMimetype.includes("png") ? "png" : inputMimetype.includes("webp") ? "webp" : "jpg"; + } + } else { + throw new AppError(400, "Исходная фотография не найдена."); + } + + // 2. Downscale input image if needed + const maxDim = config.EDIT_MAX_DIMENSION || 0; + const scaledInput = await downscaleImage(rawBuffer, maxDim); + const inputBufferToUse = scaledInput.buffer; + const dimsToUse = { width: scaledInput.width, height: scaledInput.height }; + const factor = scaledInput.factor || 1; + + // 3. Prepare job directory + const jobsBaseDir = path.join(config.DATA_DIR, ".codex-jobs"); + const jobDir = path.join(jobsBaseDir, job.id); + fs.mkdirSync(jobDir, { recursive: true }); + + const inputName = `input.${inputExt}`; + const inputFilePath = path.join(jobDir, inputName); + fs.writeFileSync(inputFilePath, inputBufferToUse); + + const inputNames = [inputName]; + + // 4. Resolve reference if provided + let referenceName = null; + let scaledRegion = null; + + let refRawBuffer = null; + let refExt = "jpg"; + + if (referenceId) { + try { + const refFileInfo = await history.getVersionFile(referenceId); + refRawBuffer = refFileInfo.buffer; + refExt = refFileInfo.version.ext || "jpg"; + } catch (err) { + logger.warn("Не удалось прочитать референс по referenceId", { referenceId, error: err.message }); + } + } else if (referenceBuffer) { + refRawBuffer = Buffer.isBuffer(referenceBuffer) ? referenceBuffer : Buffer.from(referenceBuffer); + if (referenceMimetype) { + refExt = referenceMimetype.includes("png") ? "png" : referenceMimetype.includes("webp") ? "webp" : "jpg"; + } + } + + if (refRawBuffer) { + referenceName = `reference.${refExt}`; + const refFilePath = path.join(jobDir, referenceName); + + if (scaledInput.downscaled && factor < 1) { + const scaledRef = await downscaleImage(refRawBuffer, maxDim); + fs.writeFileSync(refFilePath, scaledRef.buffer); + if (region) { + scaledRegion = scaleRegion(region, factor); + } + } else { + fs.writeFileSync(refFilePath, refRawBuffer); + scaledRegion = region; + } + + inputNames.push(referenceName); + } + + // 5. Build prompt + const promptText = buildEditPrompt({ + prompt, + dimensions: dimsToUse, + inputName, + referenceName, + region: scaledRegion, + downscaled: scaledInput.downscaled, + }); + + logger.info("Запуск Codex редактирования", { + jobId: job.id, + prompt: truncate(prompt, 100), + downscaled: scaledInput.downscaled, + width: dimsToUse.width, + height: dimsToUse.height, + }); + + // 6. Execute Codex + const result = await codexCli.runEdit({ + jobDir, + inputNames, + prompt: promptText, + timeoutMs: config.CODEX_EDIT_TIMEOUT_MS, + }); + + // 7. Check result and create version + if (!result.outputPath || !fs.existsSync(result.outputPath)) { + const errorMsg = result.timedOut + ? "Обработка заняла больше времени и была остановлена по таймауту." + : result.lastMessage + ? `Codex не создал файл результата. Ответ: ${truncate(result.lastMessage, 300)}` + : "Codex завершил работу, но не создал файл output.jpg."; + + return { + ok: false, + error: errorMsg, + details: { + exitCode: result.exitCode, + timedOut: result.timedOut, + lastMessage: result.lastMessage, + durationMs: result.durationMs, + }, + }; + } + + const outputBuffer = fs.readFileSync(result.outputPath); + const outputDims = getImageDimensions(outputBuffer) || dimsToUse; + + const label = `Правка: ${truncate(prompt, 30)}`; + const version = await history.createVersion({ + kind: "edit", + label, + prompt, + parentId: sourceId || null, + referenceId: referenceId || null, + buffer: outputBuffer, + ext: "jpg", + width: outputDims.width, + height: outputDims.height, + }); + + return { + ok: true, + version, + extra: { + summary: result.lastMessage || undefined, + downscaledFrom: scaledInput.downscaled + ? { width: scaledInput.originalWidth, height: scaledInput.originalHeight } + : null, + exitCode: result.exitCode, + timedOut: result.timedOut, + durationMs: result.durationMs, + }, + }; + }, + }; +} + +function createCodexSuggestEngine(ctx) { + const { config, logger, history, codexCli } = ctx; + + return { + async run(job) { + const { payload } = job; + const { sourceId, inputBuffer, inputMimetype } = payload || {}; + + let rawBuffer = null; + let inputExt = "jpg"; + + if (sourceId) { + const fileInfo = await history.getVersionFile(sourceId); + rawBuffer = fileInfo.buffer; + inputExt = fileInfo.version.ext || "jpg"; + } else if (inputBuffer) { + rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer); + if (inputMimetype) { + inputExt = inputMimetype.includes("png") ? "png" : inputMimetype.includes("webp") ? "webp" : "jpg"; + } + } else { + throw new AppError(400, "Исходная фотография не найдена."); + } + + const jobsBaseDir = path.join(config.DATA_DIR, ".codex-jobs"); + const jobDir = path.join(jobsBaseDir, job.id); + fs.mkdirSync(jobDir, { recursive: true }); + + const inputName = `input.${inputExt}`; + const inputFilePath = path.join(jobDir, inputName); + fs.writeFileSync(inputFilePath, rawBuffer); + + const promptText = buildSuggestPrompt(inputName); + + logger.info("Запуск генерации подсказок Codex", { jobId: job.id }); + + const result = await codexCli.runSuggest({ + jobDir, + inputNames: [inputName], + prompt: promptText, + timeoutMs: config.CODEX_EDIT_TIMEOUT_MS, + }); + + const suggestions = parseSuggestions(result.lastMessage); + + if (suggestions.length === 0) { + return { + ok: false, + error: "Codex не вернул вариантов редактирования.", + details: { + exitCode: result.exitCode, + timedOut: result.timedOut, + lastMessage: result.lastMessage, + }, + }; + } + + return { + ok: true, + extra: { + suggestions, + summary: result.lastMessage, + }, + }; + }, + }; +} + +module.exports = { + createCodexEditEngine, + createCodexSuggestEngine, +}; diff --git a/src/core/engines/comfy-upscale-engine.js b/src/core/engines/comfy-upscale-engine.js new file mode 100644 index 0000000..85a4e1b --- /dev/null +++ b/src/core/engines/comfy-upscale-engine.js @@ -0,0 +1,114 @@ +"use strict"; + +const { getImageDimensions, scaleForLimit } = require("../images/dimensions"); +const { buildUpscaleWorkflow, makePublicResult } = require("../comfy/workflows"); +const { AppError } = require("../../errors"); + +/** + * Создаёт адаптер ComfyUI Upscale Engine. + * @param {import('./engine').EngineContext} ctx + */ +function createComfyUpscaleEngine(ctx) { + const { config, logger, history, comfyClient, comfyRuntime } = ctx; + + return { + async run(job) { + const { payload } = job; + const { sourceId, inputBuffer, inputMimetype, inputOriginalname } = payload || {}; + + let rawBuffer = null; + let mimetype = inputMimetype || "image/png"; + let originalname = inputOriginalname || "photo.png"; + + if (sourceId) { + const fileInfo = await history.getVersionFile(sourceId); + rawBuffer = fileInfo.buffer; + mimetype = fileInfo.contentType || "image/jpeg"; + originalname = `${fileInfo.version.id}.${fileInfo.version.ext || "jpg"}`; + } else if (inputBuffer) { + rawBuffer = Buffer.isBuffer(inputBuffer) ? inputBuffer : Buffer.from(inputBuffer); + } else { + throw new AppError(400, "Исходная фотография не найдена."); + } + + const inputDims = getImageDimensions(rawBuffer); + if (!inputDims) { + throw new AppError(400, "Не удалось определить размер исходного изображения."); + } + + // Check ComfyUI runtime + const runtime = await comfyRuntime.getRuntimeInfo(); + comfyRuntime.requireNodes(runtime, ["UpscaleModelLoader", "ImageUpscaleWithModel", "SaveImage"]); + + const selectedModel = runtime.selectedUpscaleModel; + if (!selectedModel) { + throw new AppError( + 503, + "На сервере ComfyUI не найдены подходящие модели апскейла (UpscaleModelLoader)." + ); + } + + const scaledSize = scaleForLimit(inputDims, config.UPSCALE_MAX_DIMENSION); + + logger.info("Запуск апскейла ComfyUI", { + jobId: job.id, + model: selectedModel, + width: inputDims.width, + height: inputDims.height, + scaledSize, + }); + + // Upload image to ComfyUI + const uploaded = await comfyClient.uploadImage(rawBuffer, originalname, mimetype); + + // Build workflow + const { workflow, saveNodeId } = buildUpscaleWorkflow(uploaded, selectedModel, scaledSize); + + // Submit workflow + const promptId = await comfyClient.submitPrompt(workflow); + + // Wait for output + const resultImage = await comfyRuntime.waitForResult( + promptId, + saveNodeId, + config.JOB_TIMEOUT_MS, + config.POLL_INTERVAL_MS + ); + + // Download output image + const outputBuffer = await comfyClient.downloadImage(resultImage); + const outputDims = getImageDimensions(outputBuffer) || { + width: inputDims.width * 4, + height: inputDims.height * 4, + }; + + const label = `Апскейл 4× (${selectedModel})`; + const version = await history.createVersion({ + kind: "upscale", + label, + prompt: null, + parentId: sourceId || null, + referenceId: null, + buffer: outputBuffer, + ext: "png", + width: outputDims.width, + height: outputDims.height, + }); + + return { + ok: true, + version, + extra: { + promptId, + model: selectedModel, + scaledSize, + publicResult: makePublicResult(resultImage), + }, + }; + }, + }; +} + +module.exports = { + createComfyUpscaleEngine, +}; diff --git a/src/core/engines/engine.js b/src/core/engines/engine.js new file mode 100644 index 0000000..8d4a331 --- /dev/null +++ b/src/core/engines/engine.js @@ -0,0 +1,54 @@ +"use strict"; + +/** + * @typedef {Object} EngineContext + * @property {import('../../config').loadConfig} config + * @property {import('../../logger').Logger} logger + * @property {import('../history/history-service').HistoryService} history + * @property {import('../comfy/comfy-client').ComfyClient} comfyClient + * @property {import('../comfy/comfy-runtime').ComfyRuntime} comfyRuntime + * @property {import('../codex/cli').CodexCli} codexCli + * + * @typedef {Object} EngineResult + * @property {boolean} ok + * @property {import('../history/history-store').Version} [version] + * @property {object} [extra] + * @property {string} [error] + * @property {any} [details] + */ + +class EngineRegistry { + constructor() { + this._factories = new Map(); + } + + /** + * Регистрирует фабрику движка для типа задания. + * @param {string} type - 'edit' | 'upscale' | 'suggest' + * @param {(ctx: EngineContext) => { run: (job: any) => Promise }} factory + */ + register(type, factory) { + this._factories.set(type, factory); + } + + /** + * Создаёт экземпляр движка для указанного типа задания. + * @param {string} type + * @param {EngineContext} ctx + */ + create(type, ctx) { + const factory = this._factories.get(type); + if (!factory) { + throw new Error(`Не найден движок для типа задания: ${type}`); + } + return factory(ctx); + } + + has(type) { + return this._factories.has(type); + } +} + +module.exports = { + EngineRegistry, +}; diff --git a/src/core/images/dimensions.js b/src/core/images/dimensions.js new file mode 100644 index 0000000..67e1500 --- /dev/null +++ b/src/core/images/dimensions.js @@ -0,0 +1,190 @@ +"use strict"; + +function readUInt24LE(buffer, offset) { + return ( + buffer[offset] | + (buffer[offset + 1] << 8) | + (buffer[offset + 2] << 16) + ); +} + +function getPngDimensions(buffer) { + const pngSignature = "89504e470d0a1a0a"; + + if ( + buffer.length >= 24 && + buffer.subarray(0, 8).toString("hex") === pngSignature + ) { + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; + } + + return null; +} + +function getJpegDimensions(buffer) { + if ( + buffer.length < 4 || + buffer[0] !== 0xff || + buffer[1] !== 0xd8 + ) { + return null; + } + + const startOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, + 0xc5, 0xc6, 0xc7, + 0xc9, 0xca, 0xcb, + 0xcd, 0xce, 0xcf, + ]); + + let offset = 2; + + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1; + continue; + } + + while (offset < buffer.length && buffer[offset] === 0xff) { + offset += 1; + } + + if (offset >= buffer.length) { + break; + } + + const marker = buffer[offset]; + offset += 1; + + if (marker === 0xd8 || marker === 0x01) { + continue; + } + + if (marker === 0xd9 || marker === 0xda) { + break; + } + + if (offset + 1 >= buffer.length) { + break; + } + + const segmentLength = buffer.readUInt16BE(offset); + + if (segmentLength < 2 || offset + segmentLength > buffer.length) { + break; + } + + if (startOfFrameMarkers.has(marker) && segmentLength >= 7) { + return { + height: buffer.readUInt16BE(offset + 3), + width: buffer.readUInt16BE(offset + 5), + }; + } + + offset += segmentLength; + } + + return null; +} + +function getWebpDimensions(buffer) { + if ( + buffer.length < 30 || + buffer.toString("ascii", 0, 4) !== "RIFF" || + buffer.toString("ascii", 8, 12) !== "WEBP" + ) { + return null; + } + + const chunkType = buffer.toString("ascii", 12, 16); + + if (chunkType === "VP8X") { + return { + width: readUInt24LE(buffer, 24) + 1, + height: readUInt24LE(buffer, 27) + 1, + }; + } + + if ( + chunkType === "VP8 " && + buffer.length >= 30 && + buffer[23] === 0x9d && + buffer[24] === 0x01 && + buffer[25] === 0x2a + ) { + return { + width: buffer.readUInt16LE(26) & 0x3fff, + height: buffer.readUInt16LE(28) & 0x3fff, + }; + } + + if (chunkType === "VP8L" && buffer.length >= 25 && buffer[20] === 0x2f) { + const byte0 = buffer[21]; + const byte1 = buffer[22]; + const byte2 = buffer[23]; + const byte3 = buffer[24]; + + return { + width: 1 + byte0 + ((byte1 & 0x3f) << 8), + height: 1 + ((byte1 & 0xc0) >> 6) + (byte2 << 2) + ((byte3 & 0x0f) << 10), + }; + } + + return null; +} + +function getImageDimensions(buffer) { + if (!Buffer.isBuffer(buffer)) return null; + + const dimensions = + getPngDimensions(buffer) || + getJpegDimensions(buffer) || + getWebpDimensions(buffer); + + if ( + !dimensions || + !Number.isInteger(dimensions.width) || + !Number.isInteger(dimensions.height) || + dimensions.width <= 0 || + dimensions.height <= 0 + ) { + return null; + } + + return dimensions; +} + +function scaleForLimit(dimensions, maximumDimension) { + const largestDimension = Math.max( + dimensions.width, + dimensions.height + ); + + if (largestDimension <= maximumDimension) { + return null; + } + + const ratio = maximumDimension / largestDimension; + const width = Math.max( + 8, + Math.floor((dimensions.width * ratio) / 8) * 8 + ); + const height = Math.max( + 8, + Math.floor((dimensions.height * ratio) / 8) * 8 + ); + + return { width, height }; +} + +module.exports = { + readUInt24LE, + getPngDimensions, + getJpegDimensions, + getWebpDimensions, + getImageDimensions, + scaleForLimit, +}; diff --git a/src/core/images/heic.js b/src/core/images/heic.js new file mode 100644 index 0000000..2463918 --- /dev/null +++ b/src/core/images/heic.js @@ -0,0 +1,42 @@ +"use strict"; + +const path = require("path"); +const convert = require("heic-convert"); +const { AppError } = require("../../errors"); + +function isHeic(file) { + const mimetype = String(file?.mimetype || "").toLowerCase(); + const extension = path.extname( + String(file?.originalname || "") + ).toLowerCase(); + + return ( + mimetype === "image/heic" || + mimetype === "image/heif" || + extension === ".heic" || + extension === ".heif" + ); +} + +async function convertHeicToJpeg(buffer) { + try { + const output = await convert({ + buffer, + format: "JPEG", + quality: 0.92, + }); + + return Buffer.from(output); + } catch (error) { + throw new AppError( + 400, + "Не удалось преобразовать фотографию HEIC/HEIF в JPEG.", + error instanceof Error ? error.message : String(error) + ); + } +} + +module.exports = { + isHeic, + convertHeicToJpeg, +}; diff --git a/src/core/images/resize.js b/src/core/images/resize.js new file mode 100644 index 0000000..bfed693 --- /dev/null +++ b/src/core/images/resize.js @@ -0,0 +1,83 @@ +"use strict"; + +const sharp = require("sharp"); +const { getImageDimensions } = require("./dimensions"); + +/** + * Уменьшает изображение до maxDim по наибольшей стороне с помощью sharp. + * @param {Buffer} buffer + * @param {number} maxDim - 0 для отключения + * @returns {Promise<{ buffer: Buffer, width: number, height: number, factor: number, downscaled: boolean, originalWidth: number, originalHeight: number }>} + */ +async function downscaleImage(buffer, maxDim = 1024) { + let origDimensions = getImageDimensions(buffer); + + if (!origDimensions) { + try { + const meta = await sharp(buffer).metadata(); + if (meta.width && meta.height) { + origDimensions = { width: meta.width, height: meta.height }; + } + } catch { + // ignore + } + } + + if (!origDimensions) { + return { + buffer, + width: null, + height: null, + factor: 1, + downscaled: false, + originalWidth: null, + originalHeight: null, + }; + } + + const { width: origWidth, height: origHeight } = origDimensions; + const largest = Math.max(origWidth, origHeight); + + if (!maxDim || maxDim <= 0 || largest <= maxDim) { + return { + buffer, + width: origWidth, + height: origHeight, + factor: 1, + downscaled: false, + originalWidth: origWidth, + originalHeight: origHeight, + }; + } + + const factor = maxDim / largest; + const targetW = Math.max(1, Math.round(origWidth * factor)); + const targetH = Math.max(1, Math.round(origHeight * factor)); + + // Resize using sharp, preserving format/quality if possible + const scaledBuffer = await sharp(buffer) + .resize(targetW, targetH, { + fit: "inside", + withoutEnlargement: true, + }) + .toBuffer(); + + const finalDims = getImageDimensions(scaledBuffer) || { + width: targetW, + height: targetH, + }; + + return { + buffer: scaledBuffer, + width: finalDims.width, + height: finalDims.height, + factor, + downscaled: true, + originalWidth: origWidth, + originalHeight: origHeight, + }; +} + +module.exports = { + downscaleImage, +}; diff --git a/src/core/images/sniff.js b/src/core/images/sniff.js new file mode 100644 index 0000000..746dae0 --- /dev/null +++ b/src/core/images/sniff.js @@ -0,0 +1,71 @@ +"use strict"; + +const PNG_SIGNATURE = "89504e470d0a1a0a"; + +const HEIC_BRANDS = new Set([ + "heic", + "heix", + "hevc", + "hevx", + "heim", + "heis", + "hevm", + "hevs", + "mif1", + "msf1", + "heif", +]); + +/** + * Определяет тип изображения по сигнатуре первых байтов (magic bytes). + * @param {Buffer} buffer + * @returns {'jpeg' | 'png' | 'webp' | 'heic' | null} + */ +function sniffImageType(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) { + return null; + } + + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "jpeg"; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (buffer.subarray(0, 8).toString("hex") === PNG_SIGNATURE) { + return "png"; + } + + // WebP: RIFF....WEBP + if ( + buffer.toString("ascii", 0, 4) === "RIFF" && + buffer.toString("ascii", 8, 12) === "WEBP" + ) { + return "webp"; + } + + // HEIC / HEIF: ftyp box + if (buffer.toString("ascii", 4, 8) === "ftyp") { + const majorBrand = buffer.toString("ascii", 8, 12).toLowerCase(); + if (HEIC_BRANDS.has(majorBrand)) { + return "heic"; + } + + // Check compatible brands + const boxLength = buffer.readUInt32BE(0); + const limit = Math.min(buffer.length, boxLength > 0 ? boxLength : 64); + for (let offset = 16; offset + 4 <= limit; offset += 4) { + const brand = buffer.toString("ascii", offset, offset + 4).toLowerCase(); + if (HEIC_BRANDS.has(brand)) { + return "heic"; + } + } + } + + return null; +} + +module.exports = { + sniffImageType, + HEIC_BRANDS, +}; diff --git a/src/core/jobs/job-service.js b/src/core/jobs/job-service.js new file mode 100644 index 0000000..edb9f56 --- /dev/null +++ b/src/core/jobs/job-service.js @@ -0,0 +1,122 @@ +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { EventEmitter } = require("events"); +const { getGroupForType } = require("./queue"); +const { sanitizePayloadForStore } = require("./job-store"); +const { NotFoundError } = require("../../errors"); + +class JobService extends EventEmitter { + /** + * @param {Object} options + * @param {import('./job-store').JobStore} options.store + * @param {import('./queue').Queue} options.queue + * @param {string} [options.dataDir] + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ store, queue, dataDir = "./data", logger = null }) { + super(); + this.store = store; + this.queue = queue; + this.dataDir = dataDir; + this.logger = logger; + } + + async createJob({ type, payload = {}, requestId = null }) { + const id = crypto.randomUUID(); + const engine = getGroupForType(type); + + const job = { + id, + type, + status: "queued", + createdAt: new Date().toISOString(), + startedAt: null, + finishedAt: null, + payload, + result: null, + error: null, + details: null, + attempts: 0, + engine, + requestId, + }; + + this.store.saveJob(job); + const { queuePosition } = await this.queue.enqueue(job); + + return { + ok: true, + jobId: job.id, + status: "queued", + queuePosition, + }; + } + + getJob(jobId) { + return this.queue.status(jobId); + } + + getJobStatus(jobId) { + const job = this.queue.status(jobId); + if (!job) { + throw new NotFoundError("Задание не найдено."); + } + + return { + ok: true, + status: job.status, + type: job.type, + queuePosition: job.queuePosition, + payload: sanitizePayloadForStore(job.payload), + result: job.result, + error: job.error, + details: job.details, + requestId: job.requestId, + createdAt: job.createdAt, + startedAt: job.startedAt, + finishedAt: job.finishedAt, + }; + } + + getJobOutputFile(jobId) { + const job = this.store.getJob(jobId); + if (!job) { + throw new NotFoundError("Задание не найдено."); + } + + const jobDir = path.join(this.dataDir, ".codex-jobs", jobId); + const outputPath = path.join(jobDir, "output.jpg"); + + if (!fs.existsSync(outputPath)) { + throw new NotFoundError("Итоговое изображение задания не найдено."); + } + + return outputPath; + } + + listActive() { + return this.queue.listActive(); + } + + listJobs() { + return this.store.listJobs().map((j) => { + const queuePos = j.status === "queued" ? this.queue.getQueuePosition(j.id) : null; + return { + id: j.id, + type: j.type, + status: j.status, + createdAt: j.createdAt, + startedAt: j.startedAt || null, + finishedAt: j.finishedAt || null, + queuePosition: queuePos !== null ? queuePos : undefined, + }; + }); + } +} + +module.exports = { + JobService, +}; diff --git a/src/core/jobs/job-store.js b/src/core/jobs/job-store.js new file mode 100644 index 0000000..5280826 --- /dev/null +++ b/src/core/jobs/job-store.js @@ -0,0 +1,289 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +/** + * @typedef {Object} JobCreatedEvent + * @property {number} seq + * @property {string} jobId + * @property {string} at + * @property {'created'} event + * @property {Object} job + * + * @typedef {Object} JobStatusEvent + * @property {number} seq + * @property {string} jobId + * @property {string} at + * @property {'status'} event + * @property {string} status + * @property {string} [startedAt] + * @property {string} [finishedAt] + * @property {any} [result] + * @property {string} [error] + * @property {any} [details] + * @property {number} [attempts] + */ + +function sanitizePayloadForStore(payload) { + if (!payload || typeof payload !== "object") return payload; + const copy = { ...payload }; + if (copy.inputBuffer) { + copy.inputBuffer = `[Buffer ${copy.inputBuffer.length || (Buffer.isBuffer(copy.inputBuffer) ? copy.inputBuffer.length : "raw")} bytes]`; + } + if (copy.referenceBuffer) { + copy.referenceBuffer = `[Buffer ${copy.referenceBuffer.length || (Buffer.isBuffer(copy.referenceBuffer) ? copy.referenceBuffer.length : "raw")} bytes]`; + } + return copy; +} + +class JobStore { + /** + * @param {Object} options + * @param {string} options.file - Путь к jobs.jsonl + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ file, logger = null }) { + this.file = file; + this.logger = logger; + /** @type {Map} */ + this.jobs = new Map(); + this.seq = 0; + this._compactInterval = null; + + if (this.file) { + this._load(); + this._compactInterval = setInterval(() => this.compact(), 6 * 60 * 60 * 1000); + if (this._compactInterval.unref) { + this._compactInterval.unref(); + } + } + } + + _load() { + try { + if (!fs.existsSync(this.file)) { + return; + } + + const content = fs.readFileSync(this.file, "utf8"); + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + try { + const entry = JSON.parse(line); + if (entry.seq && entry.seq > this.seq) { + this.seq = entry.seq; + } + + if (entry.event === "created" && entry.job && entry.job.id) { + this.jobs.set(entry.job.id, { ...entry.job }); + } else if (entry.event === "status" && entry.jobId) { + const existing = this.jobs.get(entry.jobId); + if (existing) { + if (entry.status !== undefined) existing.status = entry.status; + if (entry.startedAt !== undefined) existing.startedAt = entry.startedAt; + if (entry.finishedAt !== undefined) existing.finishedAt = entry.finishedAt; + if (entry.result !== undefined) existing.result = entry.result; + if (entry.error !== undefined) existing.error = entry.error; + if (entry.details !== undefined) existing.details = entry.details; + if (entry.attempts !== undefined) existing.attempts = entry.attempts; + } + } + } catch (lineErr) { + if (this.logger) { + this.logger.warn("Невалидная строка в jobs.jsonl, пропуск", { + line: i + 1, + error: lineErr.message, + }); + } + } + } + + // Recovery after restart + let recovered = 0; + for (const [id, job] of this.jobs) { + if (job.status === "running") { + recovered++; + if (job.engine === "codex" || job.type === "edit" || job.type === "suggest") { + job.status = "interrupted"; + job.attempts = (job.attempts || 0) + 1; + job.error = "Задание прервано перезапуском сервера. Запустите заново."; + job.finishedAt = new Date().toISOString(); + this._appendStatusEvent(job); + } else if (job.engine === "comfy-upscale" || job.type === "upscale") { + if ((job.attempts || 0) < 1) { + job.status = "queued"; + job.attempts = (job.attempts || 0) + 1; + job.startedAt = null; + this._appendStatusEvent(job); + } else { + job.status = "interrupted"; + job.attempts = (job.attempts || 0) + 1; + job.error = "Задание прервано перезапуском сервера."; + job.finishedAt = new Date().toISOString(); + this._appendStatusEvent(job); + } + } + } + } + + if (this.logger && this.jobs.size > 0) { + this.logger.info("Журнал заданий загружен", { + totalJobs: this.jobs.size, + interruptedRecovered: recovered, + }); + } + } catch (err) { + if (this.logger) { + this.logger.error("Ошибка при чтении jobs.jsonl", { error: err.message }); + } + } + } + + _appendLine(obj) { + if (!this.file) return; + try { + const dir = path.dirname(this.file); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.appendFileSync(this.file, JSON.stringify(obj) + "\n", "utf8"); + } catch (err) { + if (this.logger) { + this.logger.error("Ошибка записи в jobs.jsonl", { error: err.message }); + } + } + } + + _appendStatusEvent(job) { + this.seq++; + const event = { + seq: this.seq, + jobId: job.id, + at: new Date().toISOString(), + event: "status", + status: job.status, + startedAt: job.startedAt || undefined, + finishedAt: job.finishedAt || undefined, + result: job.result || undefined, + error: job.error || undefined, + details: job.details || undefined, + attempts: job.attempts || undefined, + }; + this._appendLine(event); + } + + saveJob(job) { + this.jobs.set(job.id, job); + this.seq++; + + const sanitizedJob = { + ...job, + payload: sanitizePayloadForStore(job.payload), + }; + + const event = { + seq: this.seq, + jobId: job.id, + at: new Date().toISOString(), + event: "created", + job: sanitizedJob, + }; + + this._appendLine(event); + } + + updateJobStatus(jobId, updates) { + const job = this.jobs.get(jobId); + if (!job) return null; + + Object.assign(job, updates); + this._appendStatusEvent(job); + return job; + } + + getJob(jobId) { + return this.jobs.get(jobId) || null; + } + + listJobs() { + return Array.from(this.jobs.values()); + } + + compact() { + if (!this.file) return; + + try { + const now = Date.now(); + const ONE_HOUR = 60 * 60 * 1000; + + // Keep active jobs and terminal jobs younger than 1 hour + const preserved = new Map(); + for (const [id, job] of this.jobs) { + const isTerminal = ["done", "error", "interrupted"].includes(job.status); + if (!isTerminal) { + preserved.set(id, job); + } else { + const finishedTime = job.finishedAt ? new Date(job.finishedAt).getTime() : 0; + if (now - finishedTime < ONE_HOUR) { + preserved.set(id, job); + } + } + } + + this.jobs = preserved; + + const tmpFile = `${this.file}.${Date.now()}.compact.tmp`; + const fd = fs.openSync(tmpFile, "w"); + try { + let newSeq = 0; + for (const job of preserved.values()) { + newSeq++; + const event = { + seq: newSeq, + jobId: job.id, + at: job.createdAt || new Date().toISOString(), + event: "created", + job: { + ...job, + payload: sanitizePayloadForStore(job.payload), + }, + }; + fs.writeFileSync(fd, JSON.stringify(event) + "\n", "utf8"); + } + fs.fsyncSync(fd); + this.seq = newSeq; + } finally { + fs.closeSync(fd); + } + + fs.renameSync(tmpFile, this.file); + + if (this.logger) { + this.logger.debug("Компактизация jobs.jsonl завершена", { + remainingJobs: this.jobs.size, + }); + } + } catch (err) { + if (this.logger) { + this.logger.error("Ошибка при компактизации jobs.jsonl", { error: err.message }); + } + } + } + + close() { + if (this._compactInterval) { + clearInterval(this._compactInterval); + this._compactInterval = null; + } + } +} + +module.exports = { + JobStore, + sanitizePayloadForStore, +}; diff --git a/src/core/jobs/queue.js b/src/core/jobs/queue.js new file mode 100644 index 0000000..4e8bd26 --- /dev/null +++ b/src/core/jobs/queue.js @@ -0,0 +1,367 @@ +"use strict"; + +const { TooManyRequestsError } = require("../../errors"); + +function getGroupForType(type) { + if (type === "edit" || type === "suggest") { + return "codex"; + } + if (type === "upscale") { + return "comfy-upscale"; + } + return "codex"; +} + +class Queue { + /** + * @param {Object} options + * @param {Object} [options.concurrencyByGroup] - { codex: 1, 'comfy-upscale': 2 } + * @param {number} [options.maxQueuedJobs=5] + * @param {import('./job-store').JobStore} options.jobStore + * @param {import('../engines/engine').EngineRegistry} options.engineRegistry + * @param {import('../engines/engine').EngineContext} options.engineContext + * @param {import('events').EventEmitter} [options.events] + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ + concurrencyByGroup = { codex: 1, "comfy-upscale": 2 }, + maxQueuedJobs = 5, + jobStore, + engineRegistry, + engineContext, + events = null, + logger = null, + }) { + this.concurrencyByGroup = concurrencyByGroup; + this.maxQueuedJobs = maxQueuedJobs; + this.jobStore = jobStore; + this.engineRegistry = engineRegistry; + this.engineContext = engineContext; + this.events = events; + this.logger = logger; + + /** @type {Map} group -> array of jobIds in FIFO queue */ + this.queues = new Map([ + ["codex", []], + ["comfy-upscale", []], + ]); + + /** @type {Map>} group -> set of running jobIds */ + this.running = new Map([ + ["codex", new Set()], + ["comfy-upscale", new Set()], + ]); + + this.isStarted = false; + this.isStopping = false; + } + + start() { + if (this.isStarted) return; + this.isStarted = true; + + // Load existing queued jobs from jobStore + const allJobs = this.jobStore.listJobs(); + const queuedJobs = allJobs + .filter((j) => j.status === "queued") + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + + for (const job of queuedJobs) { + const group = getGroupForType(job.type); + const queue = this.queues.get(group); + if (queue && !queue.includes(job.id)) { + queue.push(job.id); + } + } + + if (this.logger) { + this.logger.info("Очередь заданий запущена", { + queuedCodex: this.queues.get("codex")?.length || 0, + queuedUpscale: this.queues.get("comfy-upscale")?.length || 0, + }); + } + + this._pump("codex"); + this._pump("comfy-upscale"); + } + + async enqueue(job) { + if (this.isStopping) { + throw new Error("Очередь останавливается, новые задания не принимаются."); + } + + const group = getGroupForType(job.type); + const queue = this.queues.get(group) || []; + + if (queue.length >= this.maxQueuedJobs) { + throw new TooManyRequestsError( + "Слишком много заданий в очереди. Дождитесь завершения текущих." + ); + } + + queue.push(job.id); + this.queues.set(group, queue); + + const queuePosition = queue.length; + + if (this.logger) { + this.logger.info("Задание добавлено в очередь", { + jobId: job.id, + type: job.type, + group, + queuePosition, + }); + } + + if (this.events) { + this.events.emit("job:updated", { + jobId: job.id, + status: job.status, + type: job.type, + queuePosition, + }); + } + + if (this.isStarted) { + this._pump(group); + } + + return { job, queuePosition }; + } + + getQueuePosition(jobId) { + for (const [, queue] of this.queues) { + const idx = queue.indexOf(jobId); + if (idx !== -1) { + return idx + 1; + } + } + return null; + } + + status(jobId) { + const job = this.jobStore.getJob(jobId); + if (!job) return null; + + const queuePosition = job.status === "queued" ? this.getQueuePosition(jobId) : null; + return { + ...job, + queuePosition: queuePosition !== null ? queuePosition : undefined, + }; + } + + listActive() { + const all = this.jobStore.listJobs(); + return all + .filter((j) => j.status === "queued" || j.status === "running") + .map((j) => { + const queuePosition = j.status === "queued" ? this.getQueuePosition(j.id) : null; + return { + id: j.id, + type: j.type, + status: j.status, + createdAt: j.createdAt, + startedAt: j.startedAt || null, + queuePosition: queuePosition !== null ? queuePosition : undefined, + }; + }); + } + + _pump(group) { + if (!this.isStarted || this.isStopping) return; + + const limit = this.concurrencyByGroup[group] || 1; + const runningSet = this.running.get(group) || new Set(); + const queue = this.queues.get(group) || []; + + while (runningSet.size < limit && queue.length > 0) { + const jobId = queue.shift(); + const job = this.jobStore.getJob(jobId); + + if (!job || job.status !== "queued") { + continue; + } + + runningSet.add(jobId); + + // Notify queue position shifts for remaining queued jobs in this group + this._notifyQueueShifts(group); + + this._executeJob(job, group).finally(() => { + runningSet.delete(jobId); + this._pump(group); + }); + } + } + + _notifyQueueShifts(group) { + if (!this.events) return; + const queue = this.queues.get(group) || []; + queue.forEach((id, index) => { + const j = this.jobStore.getJob(id); + if (j && j.status === "queued") { + this.events.emit("job:updated", { + jobId: j.id, + status: j.status, + type: j.type, + queuePosition: index + 1, + }); + } + }); + } + + async _executeJob(job, group) { + const startedAt = new Date().toISOString(); + this.jobStore.updateJobStatus(job.id, { + status: "running", + startedAt, + }); + + if (this.logger) { + this.logger.info("Запуск обработки задания", { + jobId: job.id, + type: job.type, + group, + }); + } + + if (this.events) { + this.events.emit("job:updated", { + jobId: job.id, + status: "running", + type: job.type, + }); + } + + try { + const engine = this.engineRegistry.create(job.type, this.engineContext); + const result = await engine.run(job); + + if (result && result.ok) { + const finishedAt = new Date().toISOString(); + const jobResult = { + version: result.version || undefined, + ...result.extra, + }; + + this.jobStore.updateJobStatus(job.id, { + status: "done", + finishedAt, + result: jobResult, + error: null, + details: null, + }); + + if (this.logger) { + this.logger.info("Задание успешно завершено", { + jobId: job.id, + type: job.type, + versionId: result.version?.id, + }); + } + + if (this.events) { + this.events.emit("job:updated", { + jobId: job.id, + status: "done", + type: job.type, + result: jobResult, + }); + } + } else { + const finishedAt = new Date().toISOString(); + const errorMsg = result?.error || "Ошибка выполнения задания"; + + this.jobStore.updateJobStatus(job.id, { + status: "error", + finishedAt, + error: errorMsg, + details: result?.details || null, + }); + + if (this.logger) { + this.logger.error("Задание завершилось с ошибкой", { + jobId: job.id, + type: job.type, + error: errorMsg, + }); + } + + if (this.events) { + this.events.emit("job:updated", { + jobId: job.id, + status: "error", + type: job.type, + error: errorMsg, + details: result?.details, + }); + } + } + } catch (err) { + const finishedAt = new Date().toISOString(); + const errorMsg = err.message || "Внутренняя ошибка при выполнении задания"; + + this.jobStore.updateJobStatus(job.id, { + status: "error", + finishedAt, + error: errorMsg, + details: err.details || null, + }); + + if (this.logger) { + this.logger.error("Исключение при выполнении задания", { + jobId: job.id, + type: job.type, + error: err.message, + stack: err.stack, + }); + } + + if (this.events) { + this.events.emit("job:updated", { + jobId: job.id, + status: "error", + type: job.type, + error: errorMsg, + details: err.details, + }); + } + } + } + + async stop() { + this.isStopping = true; + if (this.logger) { + this.logger.info("Остановка очереди заданий..."); + } + + // Wait for running tasks + const runningAll = [ + ...Array.from(this.running.get("codex") || []), + ...Array.from(this.running.get("comfy-upscale") || []), + ]; + + if (runningAll.length > 0 && this.logger) { + this.logger.info("Ожидание завершения выполняющихся заданий", { + count: runningAll.length, + }); + } + + while ( + (this.running.get("codex")?.size || 0) > 0 || + (this.running.get("comfy-upscale")?.size || 0) > 0 + ) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + + this.isStarted = false; + if (this.logger) { + this.logger.info("Очередь заданий остановлена."); + } + } +} + +module.exports = { + Queue, + getGroupForType, +}; diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..e34f199 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,63 @@ +"use strict"; + +class AppError extends Error { + constructor(status, message, details = null) { + super(message); + this.name = "AppError"; + this.status = status; + this.details = details; + } +} + +class ConfigError extends Error { + constructor(message, issues = []) { + super(message); + this.name = "ConfigError"; + this.issues = issues; + } +} + +class NotFoundError extends AppError { + constructor(message = "Ресурс не найден", details = null) { + super(404, message, details); + this.name = "NotFoundError"; + } +} + +class UnauthorizedError extends AppError { + constructor(message = "Требуется авторизация", details = null) { + super(401, message, details); + this.name = "UnauthorizedError"; + } +} + +class ForbiddenError extends AppError { + constructor(message = "Доступ запрещён", details = null) { + super(403, message, details); + this.name = "ForbiddenError"; + } +} + +class BadRequestError extends AppError { + constructor(message = "Некорректный запрос", details = null) { + super(400, message, details); + this.name = "BadRequestError"; + } +} + +class TooManyRequestsError extends AppError { + constructor(message = "Слишком много запросов. Подождите немного.", details = null) { + super(429, message, details); + this.name = "TooManyRequestsError"; + } +} + +module.exports = { + AppError, + ConfigError, + NotFoundError, + UnauthorizedError, + ForbiddenError, + BadRequestError, + TooManyRequestsError, +}; diff --git a/src/http/app.js b/src/http/app.js new file mode 100644 index 0000000..a9dfcf2 --- /dev/null +++ b/src/http/app.js @@ -0,0 +1,146 @@ +"use strict"; + +const path = require("path"); +const express = require("express"); +const multer = require("multer"); + +const { requestContext } = require("./middleware/request-context"); +const { originCheck } = require("./middleware/origin-check"); +const { createAuthMiddleware } = require("./middleware/auth"); +const { RateLimiter } = require("./middleware/rate-limit"); +const { errorHandler } = require("./middleware/error-handler"); + +const { createHealthRoutes } = require("./routes/health.routes"); +const { createAuthRoutes } = require("./routes/auth.routes"); +const { createHistoryRoutes } = require("./routes/history.routes"); +const { createPreviewRoutes } = require("./routes/preview.routes"); +const { createEditRoutes } = require("./routes/edit.routes"); +const { createUpscaleRoutes } = require("./routes/upscale.routes"); +const { createSuggestRoutes } = require("./routes/suggest.routes"); +const { createJobsRoutes } = require("./routes/jobs.routes"); +const { createEventsRoutes } = require("./routes/events.routes"); +const { createResultRoutes } = require("./routes/result.routes"); + +const { isHeic } = require("../core/images/heic"); +const { BadRequestError, NotFoundError } = require("../errors"); + +/** + * Создаёт экземпляр Express приложения с внедрением зависимостей (DI). + * @param {Object} deps + * @param {import('../config').loadConfig} deps.config + * @param {import('../logger').Logger} deps.logger + * @param {import('../core/auth/session-store').SessionStore} deps.sessionStore + * @param {string} deps.passwordHash + * @param {import('../core/history/history-service').HistoryService} deps.historyService + * @param {import('../core/jobs/job-service').JobService} deps.jobService + * @param {import('../core/comfy/comfy-client').ComfyClient} deps.comfyClient + * @param {import('../core/comfy/comfy-runtime').ComfyRuntime} deps.comfyRuntime + * @param {import('events').EventEmitter} deps.events + * @returns {express.Application} + */ +function createApp(deps) { + const { + config, + logger, + sessionStore, + passwordHash, + historyService, + jobService, + comfyClient, + comfyRuntime, + events, + } = deps; + + const app = express(); + app.disable("x-powered-by"); + + if (config.TRUST_PROXY) { + app.set("trust proxy", 1); + } + + // Static files from public/ + const publicDir = path.join(__dirname, "../../public"); + app.use( + express.static(publicDir, { + maxAge: "1h", + setHeaders: (res, filePath) => { + if (filePath.endsWith("index.html")) { + res.setHeader("cache-control", "no-cache"); + } + }, + }) + ); + + // Request context & body parsing + app.use(express.json({ limit: "16kb" })); + app.use(requestContext(logger)); + app.use("/api", originCheck()); + + // Multer instance for uploads + const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: config.MAX_UPLOAD_BYTES, + files: 2, + fields: 8, + }, + fileFilter: (_request, file, callback) => { + const supported = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/webp", + "image/heic", + "image/heif", + ]); + + if (supported.has(file.mimetype.toLowerCase()) || isHeic(file)) { + callback(null, true); + return; + } + + callback( + new BadRequestError( + "Формат файла не поддерживается. Загрузите фотографию в формате JPEG, PNG, WebP или HEIC/HEIF." + ) + ); + }, + }); + + // Auth & Rate Limiter + const authMiddleware = createAuthMiddleware(sessionStore); + const rateLimiter = new RateLimiter({ + windowMs: config.LOGIN_RATE_LIMIT.windowMs, + max: config.LOGIN_RATE_LIMIT.max, + logger, + }); + + // 1. Public API routes + app.use("/api", createHealthRoutes({ comfyRuntime, jobService, historyService, config })); + app.use("/api", createAuthRoutes({ sessionStore, passwordHash, rateLimiter, config, logger })); + + // 2. Protected API routes + app.use("/api", authMiddleware.requireAuth); + app.use("/api", createHistoryRoutes({ historyService, upload, logger })); + app.use("/api", createPreviewRoutes({ upload })); + app.use("/api", createEditRoutes({ jobService, historyService, upload, logger })); + app.use("/api", createUpscaleRoutes({ jobService, historyService, upload, logger })); + app.use("/api", createSuggestRoutes({ jobService, historyService, upload, logger })); + app.use("/api", createJobsRoutes({ jobService })); + app.use("/api", createEventsRoutes({ jobService, events, logger })); + app.use("/api", createResultRoutes({ comfyClient })); + + // 404 handler for unmatched /api requests + app.use("/api", (_req, _res, next) => { + next(new NotFoundError("Эндпоинт не найден.")); + }); + + // Global error handler + app.use(errorHandler(logger)); + + return app; +} + +module.exports = { + createApp, +}; diff --git a/src/http/middleware/auth.js b/src/http/middleware/auth.js new file mode 100644 index 0000000..5bc08d6 --- /dev/null +++ b/src/http/middleware/auth.js @@ -0,0 +1,83 @@ +"use strict"; + +const { UnauthorizedError } = require("../../errors"); + +const SESSION_COOKIE = "kadr_session"; + +function sessionCookieOptions(ttlMs = 7 * 24 * 60 * 60 * 1000) { + return { + httpOnly: true, + sameSite: "lax", + path: "/", + maxAge: ttlMs, + }; +} + +function parseCookies(cookieHeader) { + const cookies = {}; + if (!cookieHeader) return cookies; + + const pairs = cookieHeader.split(";"); + for (const pair of pairs) { + const idx = pair.indexOf("="); + if (idx < 0) continue; + const key = pair.slice(0, idx).trim(); + const val = pair.slice(idx + 1).trim(); + try { + cookies[key] = decodeURIComponent(val); + } catch { + cookies[key] = val; + } + } + return cookies; +} + +function readSessionToken(req) { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) return null; + const cookies = parseCookies(cookieHeader); + return cookies[SESSION_COOKIE] || null; +} + +function createAuthMiddleware(sessionStore) { + return { + requireAuth(req, res, next) { + const token = readSessionToken(req); + if (!token) { + return next(new UnauthorizedError("Требуется авторизация.")); + } + + const session = sessionStore.get(token); + if (!session) { + return next(new UnauthorizedError("Сессия истекла или не найдена.")); + } + + req.user = session.user; + req.sessionToken = token; + + // Sliding TTL: refresh cookie + res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs)); + next(); + }, + + optionalAuth(req, res, next) { + const token = readSessionToken(req); + if (token) { + const session = sessionStore.get(token); + if (session) { + req.user = session.user; + req.sessionToken = token; + res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs)); + } + } + next(); + }, + }; +} + +module.exports = { + SESSION_COOKIE, + sessionCookieOptions, + readSessionToken, + createAuthMiddleware, +}; diff --git a/src/http/middleware/error-handler.js b/src/http/middleware/error-handler.js new file mode 100644 index 0000000..e9a2f56 --- /dev/null +++ b/src/http/middleware/error-handler.js @@ -0,0 +1,80 @@ +"use strict"; + +const multer = require("multer"); +const { AppError } = require("../../errors"); + +/** + * Единый middleware обработки ошибок приложения. + * @param {import('../../logger').Logger} logger + */ +function errorHandler(logger) { + // eslint-disable-next-line no-unused-vars + return (err, req, res, next) => { + const requestId = req.id || "req_unknown"; + + let status = 500; + let message = "Внутренняя ошибка сервера."; + let details = null; + + if (err instanceof AppError) { + status = err.status; + message = err.message; + details = err.details || null; + } else if (err instanceof multer.MulterError) { + status = 400; + if (err.code === "LIMIT_FILE_SIZE") { + message = "Размер файла превышает допустимый лимит 25 МБ."; + } else if (err.code === "LIMIT_FILE_COUNT") { + message = "Превышено количество загружаемых файлов."; + } else { + message = `Ошибка загрузки файла: ${err.message}`; + } + details = { code: err.code, field: err.field }; + } else if (err.type === "entity.too.large" || err.status === 413) { + status = 413; + message = "Размер переданных данных превышает лимит."; + } else if (err instanceof SyntaxError && err.status === 400 && "body" in err) { + status = 400; + message = "Некорректный JSON в теле запроса."; + } else if (err.status && typeof err.status === "number") { + status = err.status; + message = err.message || "Ошибка обработки запроса."; + } + + const payload = { + ok: false, + error: message, + requestId, + }; + + if (details !== null && details !== undefined) { + payload.details = details; + } + + if (status >= 500) { + logger.error("Необработанная ошибка сервера", { + requestId, + status, + error: err.message, + stack: err.stack, + url: req.originalUrl, + method: req.method, + }); + } else { + logger.warn("Клиентская ошибка запроса", { + requestId, + status, + error: message, + details, + url: req.originalUrl, + method: req.method, + }); + } + + res.status(status).json(payload); + }; +} + +module.exports = { + errorHandler, +}; diff --git a/src/http/middleware/origin-check.js b/src/http/middleware/origin-check.js new file mode 100644 index 0000000..a0e3bdd --- /dev/null +++ b/src/http/middleware/origin-check.js @@ -0,0 +1,43 @@ +"use strict"; + +const { ForbiddenError } = require("../../errors"); + +/** + * Middleware проверки Origin для защиты от CSRF на state-changing запросах. + */ +function originCheck() { + return (req, res, next) => { + // Only check state-changing methods + const method = req.method.toUpperCase(); + if (["GET", "HEAD", "OPTIONS"].includes(method)) { + return next(); + } + + const origin = req.headers.origin; + if (!origin) { + return next(); + } + + try { + const originUrl = new URL(origin); + const hostHeader = (req.headers.host || "").toLowerCase(); + + // Compare host (including port if present) + if (originUrl.host.toLowerCase() !== hostHeader) { + return next( + new ForbiddenError( + `Запрос с внешнего источника (${originUrl.host}) заблокирован.` + ) + ); + } + + next(); + } catch { + return next(new ForbiddenError("Некорректный заголовок Origin.")); + } + }; +} + +module.exports = { + originCheck, +}; diff --git a/src/http/middleware/rate-limit.js b/src/http/middleware/rate-limit.js new file mode 100644 index 0000000..515f7b1 --- /dev/null +++ b/src/http/middleware/rate-limit.js @@ -0,0 +1,99 @@ +"use strict"; + +const { TooManyRequestsError } = require("../../errors"); + +function getClientIp(req) { + return ( + req.ip || + req.headers["x-forwarded-for"]?.split(",")[0]?.trim() || + req.socket?.remoteAddress || + "127.0.0.1" + ); +} + +class RateLimiter { + /** + * @param {Object} options + * @param {number} [options.windowMs=900000] - 15 минут + * @param {number} [options.max=5] - Максимум попыток + * @param {import('../../logger').Logger} [options.logger] + */ + constructor({ windowMs = 15 * 60 * 1000, max = 5, logger = null }) { + this.windowMs = windowMs; + this.max = max; + this.logger = logger; + /** @type {Map} IP -> array of failure timestamps */ + this.failures = new Map(); + + this._cleanupInterval = setInterval(() => this.cleanup(), this.windowMs); + if (this._cleanupInterval.unref) { + this._cleanupInterval.unref(); + } + } + + middleware() { + return (req, res, next) => { + const ip = getClientIp(req); + const now = Date.now(); + const timestamps = this.failures.get(ip) || []; + + // Filter timestamps within current window + const recent = timestamps.filter((t) => now - t < this.windowMs); + this.failures.set(ip, recent); + + if (recent.length >= this.max) { + if (this.logger) { + this.logger.warn("Превышен лимит попыток входа (rate limit)", { + ip, + attempts: recent.length, + }); + } + return next( + new TooManyRequestsError( + "Слишком много неудачных попыток входа. Подождите 15 минут перед следующей попыткой." + ) + ); + } + + next(); + }; + } + + recordFailure(req) { + const ip = getClientIp(req); + const now = Date.now(); + const timestamps = this.failures.get(ip) || []; + const recent = timestamps.filter((t) => now - t < this.windowMs); + recent.push(now); + this.failures.set(ip, recent); + } + + recordSuccess(req) { + const ip = getClientIp(req); + this.failures.delete(ip); + } + + cleanup() { + const now = Date.now(); + for (const [ip, timestamps] of this.failures) { + const recent = timestamps.filter((t) => now - t < this.windowMs); + if (recent.length === 0) { + this.failures.delete(ip); + } else { + this.failures.set(ip, recent); + } + } + } + + close() { + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + this._cleanupInterval = null; + } + } +} + +module.exports = { + RateLimiter, + getClientIp, +}; diff --git a/src/http/middleware/request-context.js b/src/http/middleware/request-context.js new file mode 100644 index 0000000..a0a86d8 --- /dev/null +++ b/src/http/middleware/request-context.js @@ -0,0 +1,44 @@ +"use strict"; + +const crypto = require("crypto"); + +/** + * Middleware для инициализации контекста запроса (requestId, тайминги, логирование). + * @param {import('../../logger').Logger} logger + */ +function requestContext(logger) { + return (req, res, next) => { + const reqId = "req_" + crypto.randomUUID().slice(0, 8); + req.id = reqId; + res.setHeader("x-request-id", reqId); + + const startTime = Date.now(); + + res.on("finish", () => { + const durationMs = Date.now() - startTime; + if (req.originalUrl && req.originalUrl.startsWith("/api")) { + const fields = { + requestId: reqId, + method: req.method, + url: req.originalUrl, + status: res.statusCode, + durationMs, + }; + + if (res.statusCode >= 500) { + logger.error("HTTP ответ с ошибкой сервера", fields); + } else if (res.statusCode >= 400) { + logger.warn("HTTP клиентская ошибка", fields); + } else { + logger.debug("HTTP запрос завершён", fields); + } + } + }); + + next(); + }; +} + +module.exports = { + requestContext, +}; diff --git a/src/http/routes/auth.routes.js b/src/http/routes/auth.routes.js new file mode 100644 index 0000000..b31b422 --- /dev/null +++ b/src/http/routes/auth.routes.js @@ -0,0 +1,87 @@ +"use strict"; + +const express = require("express"); +const { verifyPassword } = require("../../core/auth/password"); +const { SESSION_COOKIE, sessionCookieOptions, readSessionToken } = require("../middleware/auth"); +const { UnauthorizedError, BadRequestError } = require("../../errors"); + +/** + * @param {Object} deps + * @param {import('../../core/auth/session-store').SessionStore} deps.sessionStore + * @param {string} deps.passwordHash - scrypt-хеш пароля приложения + * @param {import('../middleware/rate-limit').RateLimiter} deps.rateLimiter + * @param {import('../../config').loadConfig} deps.config + * @param {import('../../logger').Logger} deps.logger + */ +function createAuthRoutes({ sessionStore, passwordHash, rateLimiter, config, logger }) { + const router = express.Router(); + + router.post("/login", rateLimiter.middleware(), async (req, res, next) => { + try { + const username = String(req.body?.username || "").trim(); + const password = String(req.body?.password || ""); + + if (!username || !password) { + rateLimiter.recordFailure(req); + throw new BadRequestError("Укажите логин и пароль."); + } + + const userMatches = username.toLowerCase() === config.APP_USER.toLowerCase(); + const passwordMatches = await verifyPassword(password, passwordHash); + + if (!userMatches || !passwordMatches) { + rateLimiter.recordFailure(req); + logger.warn("Неудачная попытка входа", { + user: username, + ip: req.ip || req.socket.remoteAddress, + }); + throw new UnauthorizedError("Неверный логин или пароль."); + } + + rateLimiter.recordSuccess(req); + const token = sessionStore.create(username); + res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs)); + + logger.audit("Успешный вход в систему", { + user: username, + ip: req.ip || req.socket.remoteAddress, + }); + + res.json({ ok: true, user: username }); + } catch (err) { + next(err); + } + }); + + router.get("/me", (req, res) => { + const token = readSessionToken(req); + if (!token) { + return res.status(401).json({ ok: false, error: "Сессия истекла или не найдена." }); + } + + const session = sessionStore.get(token); + if (!session) { + return res.status(401).json({ ok: false, error: "Сессия истекла или не найдена." }); + } + + res.cookie(SESSION_COOKIE, token, sessionCookieOptions(sessionStore.ttlMs)); + res.json({ ok: true, user: session.user }); + }); + + router.post("/logout", (req, res) => { + const token = readSessionToken(req); + if (token) { + sessionStore.delete(token); + } + + res.clearCookie(SESSION_COOKIE, { path: "/" }); + logger.audit("Выход из системы", { ip: req.ip || req.socket.remoteAddress }); + res.json({ ok: true }); + }); + + return router; +} + +module.exports = { + createAuthRoutes, +}; diff --git a/src/http/routes/edit.routes.js b/src/http/routes/edit.routes.js new file mode 100644 index 0000000..39d202c --- /dev/null +++ b/src/http/routes/edit.routes.js @@ -0,0 +1,149 @@ +"use strict"; + +const express = require("express"); +const { parseRegion } = require("../../core/codex/prompts"); +const { isHeic, convertHeicToJpeg } = require("../../core/images/heic"); +const { sniffImageType } = require("../../core/images/sniff"); +const { BadRequestError } = require("../../errors"); +const { JOB_ID_PATTERN } = require("./history.routes"); + +/** + * @param {Object} deps + * @param {import('../../core/jobs/job-service').JobService} deps.jobService + * @param {import('../../core/history/history-service').HistoryService} deps.historyService + * @param {import('multer').Multer} deps.upload + * @param {import('../../logger').Logger} deps.logger + */ +function createEditRoutes({ jobService, historyService, upload, logger }) { + const router = express.Router(); + + router.post( + "/edit", + upload.fields([ + { name: "photo", maxCount: 1 }, + { name: "reference", maxCount: 1 }, + ]), + async (req, res, next) => { + try { + const photoFile = req.files?.photo?.[0]; + const referenceFile = req.files?.reference?.[0]; + + let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null; + if (sourceId) { + if (!JOB_ID_PATTERN.test(sourceId)) { + throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId)."); + } + const version = historyService.getVersion(sourceId); + if (!version) { + throw new BadRequestError("Исходная версия не найдена в истории."); + } + } else if (!photoFile || !photoFile.buffer) { + throw new BadRequestError("Выберите фотографию или укажите sourceId из истории."); + } + + const prompt = String(req.body?.prompt || "").trim(); + if (!prompt) { + throw new BadRequestError("Введите промпт для редактирования фотографии."); + } + if (prompt.length > 1000) { + throw new BadRequestError("Промпт не должен превышать 1000 символов."); + } + + let referenceId = req.body?.referenceId ? String(req.body.referenceId).trim() : null; + if (referenceId) { + if (!JOB_ID_PATTERN.test(referenceId)) { + throw new BadRequestError("Некорректный идентификатор референса (referenceId)."); + } + const refVersion = historyService.getVersion(referenceId); + if (!refVersion) { + throw new BadRequestError("Референс не найден в истории."); + } + } + + const region = parseRegion(req.body?.region); + if (region && !referenceFile && !referenceId) { + throw new BadRequestError("Область указана без изображения референса."); + } + + // Process photo file buffer if uploaded + let inputBuffer = photoFile ? photoFile.buffer : undefined; + let inputMimetype = photoFile ? photoFile.mimetype : undefined; + let inputOriginalname = photoFile ? photoFile.originalname : undefined; + + if (photoFile) { + if (isHeic(photoFile) || sniffImageType(inputBuffer) === "heic") { + inputBuffer = await convertHeicToJpeg(inputBuffer); + inputMimetype = "image/jpeg"; + } else { + const sniffed = sniffImageType(inputBuffer); + if (!sniffed) { + throw new BadRequestError( + "Загруженная фотография не является поддерживаемым изображением." + ); + } + } + } + + // Process reference file buffer if uploaded + let referenceBuffer = referenceFile ? referenceFile.buffer : undefined; + let referenceMimetype = referenceFile ? referenceFile.mimetype : undefined; + let referenceOriginalname = referenceFile ? referenceFile.originalname : undefined; + + if (referenceFile) { + if (isHeic(referenceFile) || sniffImageType(referenceBuffer) === "heic") { + referenceBuffer = await convertHeicToJpeg(referenceBuffer); + referenceMimetype = "image/jpeg"; + } else { + const sniffed = sniffImageType(referenceBuffer); + if (!sniffed) { + throw new BadRequestError( + "Загруженный референс не является поддерживаемым изображением." + ); + } + } + } + + const payload = { + sourceId, + inputBuffer, + inputMimetype, + inputOriginalname, + prompt, + referenceId, + referenceBuffer, + referenceMimetype, + referenceOriginalname, + region, + }; + + const result = await jobService.createJob({ + type: "edit", + payload, + requestId: req.id, + }); + + logger.info("Постановка задания edit в очередь", { + jobId: result.jobId, + sourceId, + prompt: prompt.slice(0, 80), + queuePosition: result.queuePosition, + }); + + res.json({ + ok: true, + jobId: result.jobId, + status: result.status, + queuePosition: result.queuePosition, + }); + } catch (err) { + next(err); + } + } + ); + + return router; +} + +module.exports = { + createEditRoutes, +}; diff --git a/src/http/routes/events.routes.js b/src/http/routes/events.routes.js new file mode 100644 index 0000000..00a99f1 --- /dev/null +++ b/src/http/routes/events.routes.js @@ -0,0 +1,78 @@ +"use strict"; + +const express = require("express"); + +/** + * @param {Object} deps + * @param {import('../../core/jobs/job-service').JobService} deps.jobService + * @param {import('events').EventEmitter} deps.events + * @param {import('../../logger').Logger} deps.logger + */ +function createEventsRoutes({ jobService, events, logger }) { + const router = express.Router(); + + router.get("/events", (req, res) => { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + + res.flushHeaders?.(); + + // Send initial snapshot + const activeSnapshot = jobService.listActive(); + res.write(`event: snapshot\ndata: ${JSON.stringify(activeSnapshot)}\n\n`); + + const onJobUpdated = (data) => { + try { + const payload = { + jobId: data.jobId, + status: data.status, + type: data.type, + queuePosition: data.queuePosition, + error: data.error, + result: data.result ? { version: data.result.version } : undefined, + }; + res.write(`event: job\ndata: ${JSON.stringify(payload)}\n\n`); + } catch (err) { + logger.debug("Ошибка при отправке SSE-события", { error: err.message }); + } + }; + + const onJobRemoved = (data) => { + try { + res.write(`event: remove\ndata: ${JSON.stringify(data)}\n\n`); + } catch (err) { + logger.debug("Ошибка при отправке SSE remove-события", { error: err.message }); + } + }; + + events.on("job:updated", onJobUpdated); + events.on("job:removed", onJobRemoved); + + // Heartbeat ping every 15s + const pingInterval = setInterval(() => { + try { + res.write(": ping\n\n"); + } catch { + // stream closed + } + }, 15000); + + if (pingInterval.unref) { + pingInterval.unref(); + } + + req.on("close", () => { + clearInterval(pingInterval); + events.off("job:updated", onJobUpdated); + events.off("job:removed", onJobRemoved); + }); + }); + + return router; +} + +module.exports = { + createEventsRoutes, +}; diff --git a/src/http/routes/health.routes.js b/src/http/routes/health.routes.js new file mode 100644 index 0000000..ff9ccc9 --- /dev/null +++ b/src/http/routes/health.routes.js @@ -0,0 +1,63 @@ +"use strict"; + +const express = require("express"); + +/** + * @param {Object} deps + * @param {import('../../core/comfy/comfy-runtime').ComfyRuntime} deps.comfyRuntime + * @param {import('../../core/jobs/job-service').JobService} deps.jobService + * @param {import('../../core/history/history-service').HistoryService} deps.historyService + * @param {import('../../config').loadConfig} deps.config + */ +function createHealthRoutes({ comfyRuntime, jobService, historyService, config }) { + const router = express.Router(); + + router.get("/health", async (req, res, next) => { + try { + let comfyAvailable = false; + let runtimeInfo = null; + let message = "ComfyUI готов к обработке."; + + try { + runtimeInfo = await comfyRuntime.getRuntimeInfo(true); + comfyAvailable = true; + } catch (err) { + message = `ComfyUI недоступен: ${err.message}`; + } + + const activeJobs = jobService.listActive(); + const historyList = historyService.getHistory(); + + const missingNodes = runtimeInfo?.nodes + ? Object.entries(runtimeInfo.nodes) + .filter(([, ok]) => !ok) + .map(([name]) => name) + : config.REQUIRED_NODES; + + res.json({ + ok: true, + version: "2.0.0", + comfyAvailable, + message, + selectedUpscaleModel: runtimeInfo?.selectedUpscaleModel || null, + upscaleModels: runtimeInfo?.upscaleModels || [], + checkpoint: config.CHECKPOINT, + checkpointAvailable: runtimeInfo?.checkpointAvailable || false, + nodes: runtimeInfo?.nodes || {}, + missingNodes, + metrics: { + activeJobsCount: activeJobs.length, + historyCount: historyList.length, + }, + }); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createHealthRoutes, +}; diff --git a/src/http/routes/history.routes.js b/src/http/routes/history.routes.js new file mode 100644 index 0000000..a9afa85 --- /dev/null +++ b/src/http/routes/history.routes.js @@ -0,0 +1,151 @@ +"use strict"; + +const express = require("express"); +const fs = require("fs"); +const { sniffImageType } = require("../../core/images/sniff"); +const { isHeic } = require("../../core/images/heic"); +const { BadRequestError, NotFoundError } = require("../../errors"); + +const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * @param {Object} deps + * @param {import('../../core/history/history-service').HistoryService} deps.historyService + * @param {import('multer').Multer} deps.upload + * @param {import('../../logger').Logger} deps.logger + */ +function createHistoryRoutes({ historyService, upload, logger }) { + const router = express.Router(); + + router.get("/history", (req, res, next) => { + try { + res.json({ ok: true, versions: historyService.getHistory() }); + } catch (err) { + next(err); + } + }); + + router.post("/history/import", upload.single("photo"), async (req, res, next) => { + try { + if (!req.file || !req.file.buffer) { + throw new BadRequestError("Выберите фотографию для импорта."); + } + + const isHeicFile = isHeic(req.file) || sniffImageType(req.file.buffer) === "heic"; + const sniffedType = sniffImageType(req.file.buffer); + + if (!isHeicFile && !sniffedType) { + throw new BadRequestError( + "Файл не является поддерживаемым изображением (JPEG, PNG, WebP или HEIC)." + ); + } + + const version = await historyService.importPhoto({ + buffer: req.file.buffer, + mimetype: req.file.mimetype, + originalname: req.file.originalname, + }); + + logger.info("Импорт фотографии", { + versionId: version.id, + ext: version.ext, + width: version.width, + height: version.height, + }); + + res.json({ ok: true, version }); + } catch (err) { + next(err); + } + }); + + router.delete("/history/:id", async (req, res, next) => { + try { + const { id } = req.params; + if (!JOB_ID_PATTERN.test(id)) { + throw new BadRequestError("Некорректный идентификатор версии."); + } + + const existing = historyService.getVersion(id); + if (!existing) { + throw new NotFoundError("Версия не найдена в истории."); + } + + const result = await historyService.removeCascade(id); + logger.audit("Удаление версии истории", { + versionId: id, + removedCount: result.removed.length, + }); + + res.json({ ok: true, versions: result.remaining }); + } catch (err) { + next(err); + } + }); + + // Alias /image.jpg for backward compatibility -> 302 redirect to actual ext + router.get("/history/:id/image.jpg", (req, res, next) => { + try { + const { id } = req.params; + if (!JOB_ID_PATTERN.test(id)) { + throw new BadRequestError("Некорректный идентификатор версии."); + } + + const version = historyService.getVersion(id); + if (!version) { + throw new NotFoundError("Версия не найдена в истории."); + } + + const targetExt = version.ext || "jpg"; + if (targetExt !== "jpg") { + return res.redirect(302, `/api/history/${id}/image.${targetExt}`); + } + + // If ext is jpg, serve it directly + const fileInfo = historyService.getVersionFile(id); + fileInfo + .then(({ filePath, contentType }) => { + res.setHeader("content-type", contentType); + res.setHeader("cache-control", "no-store"); + res.setHeader( + "content-disposition", + `inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.jpg`)}` + ); + fs.createReadStream(filePath).pipe(res); + }) + .catch(next); + } catch (err) { + next(err); + } + }); + + // Direct route for /history/:id/image.:ext (e.g. image.png, image.webp, image.jpg) + router.get("/history/:id/image.:ext", async (req, res, next) => { + try { + const { id } = req.params; + if (!JOB_ID_PATTERN.test(id)) { + throw new BadRequestError("Некорректный идентификатор версии."); + } + + const { version, filePath, contentType } = await historyService.getVersionFile(id); + + res.setHeader("content-type", contentType); + res.setHeader("cache-control", "no-store"); + res.setHeader( + "content-disposition", + `inline; filename*=UTF-8''${encodeURIComponent(`version-${id.slice(0, 8)}.${version.ext || "jpg"}`)}` + ); + + fs.createReadStream(filePath).pipe(res); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createHistoryRoutes, + JOB_ID_PATTERN, +}; diff --git a/src/http/routes/jobs.routes.js b/src/http/routes/jobs.routes.js new file mode 100644 index 0000000..940469f --- /dev/null +++ b/src/http/routes/jobs.routes.js @@ -0,0 +1,65 @@ +"use strict"; + +const express = require("express"); +const fs = require("fs"); +const { BadRequestError } = require("../../errors"); +const { JOB_ID_PATTERN } = require("./history.routes"); + +/** + * @param {Object} deps + * @param {import('../../core/jobs/job-service').JobService} deps.jobService + */ +function createJobsRoutes({ jobService }) { + const router = express.Router(); + + router.get("/jobs", (req, res, next) => { + try { + res.json({ ok: true, jobs: jobService.listJobs() }); + } catch (err) { + next(err); + } + }); + + router.get("/jobs/:id/status", (req, res, next) => { + try { + const { id } = req.params; + if (!JOB_ID_PATTERN.test(id)) { + throw new BadRequestError("Некорректный идентификатор задания."); + } + + const status = jobService.getJobStatus(id); + res.json(status); + } catch (err) { + next(err); + } + }); + + router.get("/jobs/:id/output.jpg", (req, res, next) => { + try { + const { id } = req.params; + if (!JOB_ID_PATTERN.test(id)) { + throw new BadRequestError("Некорректный идентификатор задания."); + } + + const outputPath = jobService.getJobOutputFile(id); + + res.status(200); + res.setHeader("content-type", "image/jpeg"); + res.setHeader("cache-control", "no-store"); + res.setHeader( + "content-disposition", + `inline; filename*=UTF-8''${encodeURIComponent(`job-${id.slice(0, 8)}.jpg`)}` + ); + + fs.createReadStream(outputPath).pipe(res); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createJobsRoutes, +}; diff --git a/src/http/routes/preview.routes.js b/src/http/routes/preview.routes.js new file mode 100644 index 0000000..27e58c5 --- /dev/null +++ b/src/http/routes/preview.routes.js @@ -0,0 +1,51 @@ +"use strict"; + +const express = require("express"); +const { isHeic, convertHeicToJpeg } = require("../../core/images/heic"); +const { sniffImageType } = require("../../core/images/sniff"); +const { BadRequestError } = require("../../errors"); + +/** + * @param {Object} deps + * @param {import('multer').Multer} deps.upload + */ +function createPreviewRoutes({ upload }) { + const router = express.Router(); + + router.post("/preview", upload.single("photo"), async (req, res, next) => { + try { + if (!req.file || !req.file.buffer) { + throw new BadRequestError("Выберите фотографию для предпросмотра."); + } + + let buffer = req.file.buffer; + let contentType = req.file.mimetype || "image/jpeg"; + + if (isHeic(req.file) || sniffImageType(buffer) === "heic") { + buffer = await convertHeicToJpeg(buffer); + contentType = "image/jpeg"; + } else { + const sniffed = sniffImageType(buffer); + if (!sniffed) { + throw new BadRequestError( + "Файл не является поддерживаемым изображением (JPEG, PNG, WebP или HEIC)." + ); + } + contentType = sniffed === "jpeg" ? "image/jpeg" : sniffed === "png" ? "image/png" : "image/webp"; + } + + res.status(200); + res.setHeader("content-type", contentType); + res.setHeader("cache-control", "no-store"); + res.send(buffer); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createPreviewRoutes, +}; diff --git a/src/http/routes/result.routes.js b/src/http/routes/result.routes.js new file mode 100644 index 0000000..a8697f3 --- /dev/null +++ b/src/http/routes/result.routes.js @@ -0,0 +1,58 @@ +"use strict"; + +const express = require("express"); +const path = require("path"); +const { Readable } = require("stream"); +const { pipeline } = require("stream/promises"); +const { BadRequestError, AppError } = require("../../errors"); + +/** + * @param {Object} deps + * @param {import('../../core/comfy/comfy-client').ComfyClient} deps.comfyClient + */ +function createResultRoutes({ comfyClient }) { + const router = express.Router(); + + router.get("/result", async (req, res, next) => { + try { + const filename = String(req.query.filename || ""); + const subfolder = String(req.query.subfolder || ""); + const type = String(req.query.type || "output"); + + if (!filename) { + throw new BadRequestError("Не указано имя итогового изображения."); + } + + if (type !== "output") { + throw new BadRequestError("Разрешена загрузка только итоговых изображений."); + } + + const upstream = await comfyClient.viewStream({ filename, subfolder, type }); + + res.status(200); + res.setHeader( + "content-type", + upstream.headers.get("content-type") || "image/png" + ); + res.setHeader("cache-control", "no-store"); + res.setHeader( + "content-disposition", + `inline; filename*=UTF-8''${encodeURIComponent(path.basename(filename))}` + ); + + if (!upstream.body) { + throw new AppError(502, "ComfyUI вернул пустое изображение."); + } + + await pipeline(Readable.fromWeb(upstream.body), res); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createResultRoutes, +}; diff --git a/src/http/routes/suggest.routes.js b/src/http/routes/suggest.routes.js new file mode 100644 index 0000000..c943139 --- /dev/null +++ b/src/http/routes/suggest.routes.js @@ -0,0 +1,108 @@ +"use strict"; + +const express = require("express"); +const { isHeic, convertHeicToJpeg } = require("../../core/images/heic"); +const { sniffImageType } = require("../../core/images/sniff"); +const { BadRequestError } = require("../../errors"); +const { JOB_ID_PATTERN } = require("./history.routes"); + +/** + * @param {Object} deps + * @param {import('../../core/jobs/job-service').JobService} deps.jobService + * @param {import('../../core/history/history-service').HistoryService} deps.historyService + * @param {import('multer').Multer} deps.upload + * @param {import('../../logger').Logger} deps.logger + */ +function createSuggestRoutes({ jobService, historyService, upload, logger }) { + const router = express.Router(); + + router.get("/suggest-prompts", (req, res, next) => { + try { + const history = historyService.getHistory(); + const seen = new Set(); + const prompts = []; + + for (let i = history.length - 1; i >= 0 && prompts.length < 10; i--) { + const prompt = String(history[i]?.prompt || "").trim(); + if (prompt && !seen.has(prompt)) { + seen.add(prompt); + prompts.push(prompt); + } + } + + res.json({ ok: true, prompts }); + } catch (err) { + next(err); + } + }); + + router.post("/suggest", upload.single("photo"), async (req, res, next) => { + try { + let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null; + + if (sourceId) { + if (!JOB_ID_PATTERN.test(sourceId)) { + throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId)."); + } + const version = historyService.getVersion(sourceId); + if (!version) { + throw new BadRequestError("Исходная версия не найдена в истории."); + } + } else if (!req.file || !req.file.buffer) { + throw new BadRequestError("Выберите фотографию или укажите sourceId из истории."); + } + + let inputBuffer = req.file ? req.file.buffer : undefined; + let inputMimetype = req.file ? req.file.mimetype : undefined; + let inputOriginalname = req.file ? req.file.originalname : undefined; + + if (req.file) { + if (isHeic(req.file) || sniffImageType(inputBuffer) === "heic") { + inputBuffer = await convertHeicToJpeg(inputBuffer); + inputMimetype = "image/jpeg"; + } else { + const sniffed = sniffImageType(inputBuffer); + if (!sniffed) { + throw new BadRequestError( + "Загруженная фотография не является поддерживаемым изображением." + ); + } + } + } + + const payload = { + sourceId, + inputBuffer, + inputMimetype, + inputOriginalname, + }; + + const result = await jobService.createJob({ + type: "suggest", + payload, + requestId: req.id, + }); + + logger.info("Постановка задания suggest в очередь", { + jobId: result.jobId, + sourceId, + queuePosition: result.queuePosition, + }); + + res.json({ + ok: true, + jobId: result.jobId, + status: result.status, + queuePosition: result.queuePosition, + }); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createSuggestRoutes, +}; diff --git a/src/http/routes/upscale.routes.js b/src/http/routes/upscale.routes.js new file mode 100644 index 0000000..6fa6434 --- /dev/null +++ b/src/http/routes/upscale.routes.js @@ -0,0 +1,88 @@ +"use strict"; + +const express = require("express"); +const { isHeic, convertHeicToJpeg } = require("../../core/images/heic"); +const { sniffImageType } = require("../../core/images/sniff"); +const { BadRequestError } = require("../../errors"); +const { JOB_ID_PATTERN } = require("./history.routes"); + +/** + * @param {Object} deps + * @param {import('../../core/jobs/job-service').JobService} deps.jobService + * @param {import('../../core/history/history-service').HistoryService} deps.historyService + * @param {import('multer').Multer} deps.upload + * @param {import('../../logger').Logger} deps.logger + */ +function createUpscaleRoutes({ jobService, historyService, upload, logger }) { + const router = express.Router(); + + router.post("/upscale", upload.single("photo"), async (req, res, next) => { + try { + let sourceId = req.body?.sourceId ? String(req.body.sourceId).trim() : null; + + if (sourceId) { + if (!JOB_ID_PATTERN.test(sourceId)) { + throw new BadRequestError("Некорректный идентификатор исходной версии (sourceId)."); + } + const version = historyService.getVersion(sourceId); + if (!version) { + throw new BadRequestError("Исходная версия не найдена в истории."); + } + } else if (!req.file || !req.file.buffer) { + throw new BadRequestError("Выберите фотографию или укажите sourceId из истории."); + } + + let inputBuffer = req.file ? req.file.buffer : undefined; + let inputMimetype = req.file ? req.file.mimetype : undefined; + let inputOriginalname = req.file ? req.file.originalname : undefined; + + if (req.file) { + if (isHeic(req.file) || sniffImageType(inputBuffer) === "heic") { + inputBuffer = await convertHeicToJpeg(inputBuffer); + inputMimetype = "image/jpeg"; + } else { + const sniffed = sniffImageType(inputBuffer); + if (!sniffed) { + throw new BadRequestError( + "Загруженная фотография не является поддерживаемым изображением." + ); + } + } + } + + const payload = { + sourceId, + inputBuffer, + inputMimetype, + inputOriginalname, + }; + + const result = await jobService.createJob({ + type: "upscale", + payload, + requestId: req.id, + }); + + logger.info("Постановка задания upscale в очередь", { + jobId: result.jobId, + sourceId, + queuePosition: result.queuePosition, + }); + + res.json({ + ok: true, + jobId: result.jobId, + status: result.status, + queuePosition: result.queuePosition, + }); + } catch (err) { + next(err); + } + }); + + return router; +} + +module.exports = { + createUpscaleRoutes, +}; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..0f02976 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,119 @@ +"use strict"; + +const LOG_LEVELS = { + debug: 10, + info: 20, + audit: 25, + warn: 30, + error: 40, +}; + +function singleLine(text) { + return String(text || "") + .replace(/[\r\n\t]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function truncate(text, maxLength = 240) { + const normalized = singleLine(text); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 1)}…`; +} + +function sanitizeFields(fields) { + if (!fields || typeof fields !== "object") return {}; + const cleaned = {}; + for (const [key, value] of Object.entries(fields)) { + const lowerKey = key.toLowerCase(); + if ( + lowerKey.includes("password") || + lowerKey.includes("token") || + lowerKey.includes("cookie") || + lowerKey.includes("secret") || + lowerKey.includes("auth") + ) { + cleaned[key] = "[REDACTED]"; + } else if (value instanceof Error) { + cleaned[key] = { + message: value.message, + name: value.name, + stack: value.stack, + details: value.details, + }; + } else { + cleaned[key] = value; + } + } + return cleaned; +} + +class Logger { + constructor(options = {}) { + const levelName = (options.level || "info").toLowerCase(); + this.threshold = LOG_LEVELS[levelName] !== undefined ? LOG_LEVELS[levelName] : LOG_LEVELS.info; + this.stream = options.stream || process.stdout; + this.defaultFields = options.defaultFields ? sanitizeFields(options.defaultFields) : {}; + } + + child(defaultFields = {}) { + return new Logger({ + level: Object.keys(LOG_LEVELS).find((k) => LOG_LEVELS[k] === this.threshold) || "info", + stream: this.stream, + defaultFields: { ...this.defaultFields, ...sanitizeFields(defaultFields) }, + }); + } + + _write(level, msg, fields = {}) { + if (LOG_LEVELS[level] < this.threshold) return; + + const entry = { + ts: new Date().toISOString(), + level, + msg: singleLine(msg), + ...this.defaultFields, + ...sanitizeFields(fields), + }; + + try { + this.stream.write(JSON.stringify(entry) + "\n"); + } catch { + // Fallback if write fails + console.log(JSON.stringify(entry)); + } + } + + debug(msg, fields) { + this._write("debug", msg, fields); + } + + info(msg, fields) { + this._write("info", msg, fields); + } + + audit(msg, fields) { + this._write("audit", msg, fields); + } + + warn(msg, fields) { + this._write("warn", msg, fields); + } + + error(msg, fields) { + this._write("error", msg, fields); + } +} + +function createLogger(options = {}) { + return new Logger(options); +} + +module.exports = { + LOG_LEVELS, + Logger, + createLogger, + singleLine, + truncate, +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..a48d62b --- /dev/null +++ b/src/server.js @@ -0,0 +1,222 @@ +"use strict"; + +const http = require("http"); +const path = require("path"); +const fs = require("fs"); +const { EventEmitter } = require("events"); + +const { loadConfig } = require("./config"); +const { createLogger } = require("./logger"); +const { hashPasswordSync } = require("./core/auth/password"); +const { SessionStore } = require("./core/auth/session-store"); +const { HistoryStore } = require("./core/history/history-store"); +const { HistoryService, migrateLegacyHistory } = require("./core/history/history-service"); +const { JobStore } = require("./core/jobs/job-store"); +const { Queue } = require("./core/jobs/queue"); +const { JobService } = require("./core/jobs/job-service"); +const { ComfyClient } = require("./core/comfy/comfy-client"); +const { ComfyRuntime } = require("./core/comfy/comfy-runtime"); +const { CodexCli } = require("./core/codex/cli"); +const { EngineRegistry } = require("./core/engines/engine"); +const { createCodexEditEngine, createCodexSuggestEngine } = require("./core/engines/codex-engine"); +const { createComfyUpscaleEngine } = require("./core/engines/comfy-upscale-engine"); +const { createApp } = require("./http/app"); + +function printPasswordBanner(user, password) { + const line = "─".repeat(58); + console.log(`\n┌${line}┐`); + console.log(`│ Кадр: Пароль для входа (сгенерирован автоматически) │`); + console.log(`│ Логин : ${user.padEnd(48)}│`); + console.log(`│ Пароль: ${password.padEnd(48)}│`); + console.log(`│ Задайте APP_PASSWORD в окружении для фиксации. │`); + console.log(`└${line}┘\n`); +} + +function listenOnPort(app, port) { + return new Promise((resolve, reject) => { + const server = http.createServer(app); + server.listen(port, () => resolve(server)); + server.on("error", (error) => { + server.close(); + reject(error); + }); + }); +} + +async function startServer() { + const config = loadConfig(process.env); + const logger = createLogger({ level: config.LOG_LEVEL }); + + logger.info("Запуск веб-приложения «Кадр» v2...", config.getSanitized()); + + if (config.isGeneratedPassword) { + printPasswordBanner(config.APP_USER, config.APP_PASSWORD); + } + + const passwordHash = hashPasswordSync(config.APP_PASSWORD); + + // Ensure directories exist + fs.mkdirSync(config.DATA_DIR, { recursive: true }); + fs.mkdirSync(config.HISTORY_DIR, { recursive: true }); + fs.mkdirSync(path.join(config.DATA_DIR, ".codex-jobs"), { recursive: true }); + + // Migrate legacy history if present + migrateLegacyHistory(path.resolve("./history"), config.HISTORY_DIR, logger); + + // Core stores and services + const historyStore = new HistoryStore({ + dir: config.HISTORY_DIR, + limit: config.HISTORY_LIMIT, + logger, + }); + const historyService = new HistoryService({ store: historyStore, logger }); + + const sessionStore = new SessionStore({ + file: path.join(config.DATA_DIR, "sessions.json"), + ttlMs: config.SESSION_TTL_MS, + persist: config.SESSION_PERSIST, + logger, + }); + + const jobStore = new JobStore({ + file: path.join(config.DATA_DIR, "jobs.jsonl"), + logger, + }); + + const comfyClient = new ComfyClient({ + comfyUrl: config.COMFY_URL, + requestTimeoutMs: config.REQUEST_TIMEOUT_MS, + logger, + }); + + const comfyRuntime = new ComfyRuntime({ + client: comfyClient, + requiredNodes: config.REQUIRED_NODES, + checkpoint: config.CHECKPOINT, + logger, + }); + + const codexCli = new CodexCli({ + codexExe: config.CODEX_CLI_PATH, + defaultTimeoutMs: config.CODEX_EDIT_TIMEOUT_MS, + logger, + }); + + const events = new EventEmitter(); + events.setMaxListeners(100); + + // Engine Registry + const engineRegistry = new EngineRegistry(); + const engineContext = { + config, + logger, + history: historyService, + comfyClient, + comfyRuntime, + codexCli, + }; + + engineRegistry.register("edit", (ctx) => createCodexEditEngine(ctx)); + engineRegistry.register("suggest", (ctx) => createCodexSuggestEngine(ctx)); + engineRegistry.register("upscale", (ctx) => createComfyUpscaleEngine(ctx)); + + // Queue and Job Service + const queue = new Queue({ + concurrencyByGroup: { + codex: config.CODEX_CONCURRENCY, + "comfy-upscale": config.UPSCALE_CONCURRENCY, + }, + maxQueuedJobs: config.MAX_QUEUED_JOBS, + jobStore, + engineRegistry, + engineContext, + events, + logger, + }); + + const jobService = new JobService({ + store: jobStore, + queue, + dataDir: config.DATA_DIR, + logger, + }); + + // Start background queue workers + queue.start(); + + // Create Express App + const app = createApp({ + config, + logger, + sessionStore, + passwordHash, + historyService, + jobService, + comfyClient, + comfyRuntime, + events, + }); + + // Bind to port + let server = null; + let boundPort = null; + + for (const candidate of config.PORT_CANDIDATES) { + try { + server = await listenOnPort(app, candidate); + boundPort = candidate; + break; + } catch (err) { + if (err.code === "EADDRINUSE") { + logger.warn(`Порт ${candidate} занят, пробуем следующий...`); + continue; + } + throw err; + } + } + + if (!server) { + throw new Error( + `Не удалось запустить сервер: все порты [${config.PORT_CANDIDATES.join(", ")}] заняты.` + ); + } + + logger.info(`Сервер «Кадр» слушает http://localhost:${boundPort}`, { + port: boundPort, + dataDir: config.DATA_DIR, + historyDir: config.HISTORY_DIR, + }); + + // Graceful shutdown + const shutdown = async (signal) => { + logger.info(`Получен сигнал ${signal}, корректное завершение работы...`); + try { + await queue.stop(); + sessionStore.close(); + jobStore.close(); + server.close(() => { + logger.info("Сервер остановлен."); + process.exit(0); + }); + } catch (err) { + logger.error("Ошибка при остановке сервера", { error: err.message }); + process.exit(1); + } + }; + + process.on("SIGINT", () => shutdown("SIGINT")); + process.on("SIGTERM", () => shutdown("SIGTERM")); + + return { app, server, boundPort, deps: engineContext }; +} + +if (require.main === module) { + startServer().catch((err) => { + console.error("Критическая ошибка при старте сервера:", err); + process.exit(1); + }); +} + +module.exports = { + startServer, +}; diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..9b7e937 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,324 @@ +"use strict"; + +const { test, describe, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const request = require("supertest"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const sharp = require("sharp"); +const { EventEmitter } = require("events"); + +const { createApp } = require("../src/http/app"); +const { loadConfig } = require("../src/config"); +const { createLogger } = require("../src/logger"); +const { hashPasswordSync } = require("../src/core/auth/password"); +const { SessionStore } = require("../src/core/auth/session-store"); +const { HistoryStore } = require("../src/core/history/history-store"); +const { HistoryService } = require("../src/core/history/history-service"); +const { JobStore } = require("../src/core/jobs/job-store"); +const { Queue } = require("../src/core/jobs/queue"); +const { JobService } = require("../src/core/jobs/job-service"); +const { EngineRegistry } = require("../src/core/engines/engine"); + +describe("API integration tests", () => { + let tempDir; + let app; + let historyService; + let jobService; + let queue; + let sessionStore; + let password = "testpassword123"; + let passwordHash; + let samplePngBuffer; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kadr-api-test-")); + const historyDir = path.join(tempDir, "history"); + const dataDir = tempDir; + + fs.mkdirSync(historyDir, { recursive: true }); + + samplePngBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }).png().toBuffer(); + + const config = loadConfig({ + DATA_DIR: dataDir, + HISTORY_DIR: historyDir, + APP_USER: "admin", + APP_PASSWORD: password, + LOGIN_RATE_LIMIT_MAX: "3", + LOGIN_RATE_LIMIT_WINDOW_MS: "10000", + }); + + const logger = createLogger({ level: "error", stream: { write: () => {} } }); + passwordHash = hashPasswordSync(password); + + const historyStore = new HistoryStore({ dir: historyDir, limit: 10, logger }); + historyService = new HistoryService({ store: historyStore, logger }); + + sessionStore = new SessionStore({ + file: path.join(dataDir, "sessions.json"), + ttlMs: 3600000, + persist: false, + logger, + }); + + const jobStore = new JobStore({ + file: path.join(dataDir, "jobs.jsonl"), + logger, + }); + + const events = new EventEmitter(); + const engineRegistry = new EngineRegistry(); + + // Stub engines + engineRegistry.register("edit", () => ({ + async run(job) { + const v = await historyService.createVersion({ + kind: "edit", + label: "Stub edit", + prompt: job.payload?.prompt, + parentId: job.payload?.sourceId, + buffer: samplePngBuffer, + ext: "png", + width: 100, + height: 100, + }); + return { ok: true, version: v }; + }, + })); + + engineRegistry.register("upscale", () => ({ + async run(job) { + const v = await historyService.createVersion({ + kind: "upscale", + label: "Stub upscale", + parentId: job.payload?.sourceId, + buffer: samplePngBuffer, + ext: "png", + width: 400, + height: 400, + }); + return { ok: true, version: v }; + }, + })); + + engineRegistry.register("suggest", () => ({ + async run() { + return { ok: true, extra: { suggestions: ["Вариант 1", "Вариант 2"] } }; + }, + })); + + queue = new Queue({ + concurrencyByGroup: { codex: 1, "comfy-upscale": 2 }, + maxQueuedJobs: 5, + jobStore, + engineRegistry, + engineContext: {}, + events, + logger, + }); + + jobService = new JobService({ + store: jobStore, + queue, + dataDir, + logger, + }); + + queue.start(); + + const comfyClient = { + viewStream: async () => { + throw new Error("ComfyClient not mocked for stream"); + }, + }; + + const comfyRuntime = { + getRuntimeInfo: async () => ({ + nodes: { + VAELoader: true, + UpscaleModelLoader: true, + ImageUpscaleWithModel: true, + ImageScale: true, + SaveImage: true, + }, + upscaleModels: ["4x_UltraSharp.pth"], + selectedUpscaleModel: "4x_UltraSharp.pth", + checkpoints: ["sdxl_turbo.safetensors"], + checkpointAvailable: true, + }), + }; + + app = createApp({ + config, + logger, + sessionStore, + passwordHash, + historyService, + jobService, + comfyClient, + comfyRuntime, + events, + }); + }); + + afterEach(async () => { + if (queue) await queue.stop(); + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + test("GET /api/health is public and returns 200 with version and status", async () => { + const res = await request(app).get("/api/health"); + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.version, "2.0.0"); + assert.equal(res.body.comfyAvailable, true); + }); + + test("Protected endpoints require authentication", async () => { + const res = await request(app).get("/api/history"); + assert.equal(res.status, 401); + assert.equal(res.body.ok, false); + assert.ok(res.body.requestId); + }); + + test("Login, session cookie, and /api/me flow", async () => { + // Bad login + const badRes = await request(app) + .post("/api/login") + .send({ username: "admin", password: "wrongpassword" }); + assert.equal(badRes.status, 401); + assert.equal(badRes.body.ok, false); + + // Good login + const loginRes = await request(app) + .post("/api/login") + .send({ username: "admin", password: "testpassword123" }); + assert.equal(loginRes.status, 200); + assert.equal(loginRes.body.ok, true); + assert.equal(loginRes.body.user, "admin"); + + const cookie = loginRes.headers["set-cookie"]; + assert.ok(cookie); + + // /api/me with session cookie + const meRes = await request(app) + .get("/api/me") + .set("Cookie", cookie); + assert.equal(meRes.status, 200); + assert.equal(meRes.body.ok, true); + assert.equal(meRes.body.user, "admin"); + + // Rate limit test: max is 3 failures, so after 3 failures the 4th attempt is rejected with 429 + await request(app).post("/api/login").send({ username: "admin", password: "bad" }); + await request(app).post("/api/login").send({ username: "admin", password: "bad" }); + await request(app).post("/api/login").send({ username: "admin", password: "bad" }); + const rateLimitedRes = await request(app).post("/api/login").send({ username: "admin", password: "bad" }); + assert.equal(rateLimitedRes.status, 429); + }); + + test("History import, retrieval, image streaming and deletion", async () => { + // Login to get cookie + const loginRes = await request(app) + .post("/api/login") + .send({ username: "admin", password: "testpassword123" }); + const cookie = loginRes.headers["set-cookie"]; + + // 1. Import photo + const importRes = await request(app) + .post("/api/history/import") + .set("Cookie", cookie) + .attach("photo", samplePngBuffer, "photo.png"); + + assert.equal(importRes.status, 200); + assert.equal(importRes.body.ok, true); + const version = importRes.body.version; + assert.ok(version.id); + assert.equal(version.kind, "original"); + assert.equal(version.ext, "png"); + assert.equal(version.url, `/api/history/${version.id}/image.png`); + + // 2. Reject non-image import + const badImport = await request(app) + .post("/api/history/import") + .set("Cookie", cookie) + .attach("photo", Buffer.from("not an image at all"), "fake.png"); + assert.equal(badImport.status, 400); + + // 3. GET /api/history + const historyRes = await request(app) + .get("/api/history") + .set("Cookie", cookie); + assert.equal(historyRes.status, 200); + assert.equal(historyRes.body.versions.length, 1); + + // 4. GET /api/history/:id/image.png + const imageRes = await request(app) + .get(`/api/history/${version.id}/image.png`) + .set("Cookie", cookie); + assert.equal(imageRes.status, 200); + assert.equal(imageRes.headers["content-type"], "image/png"); + + // 5. GET /api/history/:id/image.jpg alias redirects 302 to actual .png + const redirectRes = await request(app) + .get(`/api/history/${version.id}/image.jpg`) + .set("Cookie", cookie); + assert.equal(redirectRes.status, 302); + assert.equal(redirectRes.headers["location"], `/api/history/${version.id}/image.png`); + + // 6. Delete version + const deleteRes = await request(app) + .delete(`/api/history/${version.id}`) + .set("Cookie", cookie); + assert.equal(deleteRes.status, 200); + assert.equal(deleteRes.body.ok, true); + assert.equal(deleteRes.body.versions.length, 0); + }); + + test("Edit and upscale job submission and status", async () => { + const loginRes = await request(app) + .post("/api/login") + .send({ username: "admin", password: "testpassword123" }); + const cookie = loginRes.headers["set-cookie"]; + + // Import initial version + const importRes = await request(app) + .post("/api/history/import") + .set("Cookie", cookie) + .attach("photo", samplePngBuffer, "photo.png"); + const version = importRes.body.version; + + // POST /api/edit + const editRes = await request(app) + .post("/api/edit") + .set("Cookie", cookie) + .field("sourceId", version.id) + .field("prompt", "Сделай красивый закат"); + + assert.equal(editRes.status, 200); + assert.equal(editRes.body.ok, true); + assert.ok(editRes.body.jobId); + assert.equal(editRes.body.status, "queued"); + + const jobId = editRes.body.jobId; + + // Poll until done (stub engine completes immediately) + while (jobService.getJob(jobId)?.status !== "done") { + await new Promise((r) => setTimeout(r, 20)); + } + + const statusRes = await request(app) + .get(`/api/jobs/${jobId}/status`) + .set("Cookie", cookie); + assert.equal(statusRes.status, 200); + assert.equal(statusRes.body.status, "done"); + assert.ok(statusRes.body.result?.version); + }); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..5532211 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,82 @@ +"use strict"; + +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { loadConfig } = require("../src/config"); +const { ConfigError } = require("../src/errors"); + +describe("config", () => { + test("loads default configuration with generated password", () => { + const config = loadConfig({}); + assert.equal(config.PORT, 3000); + assert.equal(config.APP_USER, "admin"); + assert.equal(typeof config.APP_PASSWORD, "string"); + assert.ok(config.APP_PASSWORD.length > 8); + assert.equal(config.isGeneratedPassword, true); + assert.equal(config.LOG_LEVEL, "info"); + assert.equal(config.EDIT_MAX_DIMENSION, 1024); + assert.equal(config.UPSCALE_MAX_DIMENSION, 2048); + assert.equal(config.CODEX_CONCURRENCY, 1); + assert.equal(config.UPSCALE_CONCURRENCY, 2); + assert.equal(config.MAX_QUEUED_JOBS, 5); + assert.equal(config.SSE_ENABLED, true); + assert.equal(config.SESSION_PERSIST, true); + }); + + test("applies environment variable overrides", () => { + const env = { + PORT: "8080", + COMFY_URL: "http://10.0.0.5:8188", + APP_USER: "customuser", + APP_PASSWORD: "secretpassword123", + LOG_LEVEL: "debug", + EDIT_MAX_DIMENSION: "512", + CODEX_CONCURRENCY: "2", + UPSCALE_CONCURRENCY: "4", + MAX_QUEUED_JOBS: "10", + SSE_ENABLED: "false", + SESSION_PERSIST: "0", + TRUST_PROXY: "true", + }; + + const config = loadConfig(env); + assert.equal(config.PORT, 8080); + assert.equal(config.COMFY_URL, "http://10.0.0.5:8188"); + assert.equal(config.APP_USER, "customuser"); + assert.equal(config.APP_PASSWORD, "secretpassword123"); + assert.equal(config.isGeneratedPassword, false); + assert.equal(config.LOG_LEVEL, "debug"); + assert.equal(config.EDIT_MAX_DIMENSION, 512); + assert.equal(config.CODEX_CONCURRENCY, 2); + assert.equal(config.UPSCALE_CONCURRENCY, 4); + assert.equal(config.MAX_QUEUED_JOBS, 10); + assert.equal(config.SSE_ENABLED, false); + assert.equal(config.SESSION_PERSIST, false); + assert.equal(config.TRUST_PROXY, true); + }); + + test("throws ConfigError on invalid values", () => { + assert.throws( + () => loadConfig({ PORT: "99999" }), + (err) => err instanceof ConfigError && err.issues.length > 0 + ); + + assert.throws( + () => loadConfig({ COMFY_URL: "not-a-url" }), + (err) => err instanceof ConfigError + ); + + assert.throws( + () => loadConfig({ LOG_LEVEL: "invalid_level" }), + (err) => err instanceof ConfigError + ); + }); + + test("getSanitized() masks APP_PASSWORD", () => { + const config = loadConfig({ APP_PASSWORD: "supersecretpass" }); + const sanitized = config.getSanitized(); + assert.equal(sanitized.APP_PASSWORD, undefined); + assert.equal(sanitized.APP_PASSWORD_SET, true); + assert.equal(sanitized.APP_USER, "admin"); + }); +}); diff --git a/test/history-store.test.js b/test/history-store.test.js new file mode 100644 index 0000000..e4c6e5e --- /dev/null +++ b/test/history-store.test.js @@ -0,0 +1,130 @@ +"use strict"; + +const { test, describe, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { HistoryStore } = require("../src/core/history/history-store"); +const { HistoryService, migrateLegacyHistory } = require("../src/core/history/history-service"); + +describe("history-store", () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kadr-history-test-")); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + test("appends versions atomically and handles pruning", async () => { + const store = new HistoryStore({ dir: tempDir, limit: 3 }); + const service = new HistoryService({ store }); + + const dummyImg = Buffer.from("dummy image content"); + + const v1 = await service.createVersion({ + kind: "original", + label: "V1", + buffer: dummyImg, + ext: "jpg", + width: 100, + height: 100, + }); + const v2 = await service.createVersion({ + kind: "edit", + label: "V2", + parentId: v1.id, + buffer: dummyImg, + ext: "jpg", + width: 100, + height: 100, + }); + const v3 = await service.createVersion({ + kind: "edit", + label: "V3", + parentId: v2.id, + buffer: dummyImg, + ext: "jpg", + width: 100, + height: 100, + }); + + assert.equal(store.load().length, 3); + assert.ok(fs.existsSync(store.filePath(v1))); + assert.ok(fs.existsSync(store.filePath(v2))); + assert.ok(fs.existsSync(store.filePath(v3))); + + // Appending 4th version should prune v1 + const v4 = await service.createVersion({ + kind: "upscale", + label: "V4", + parentId: v3.id, + buffer: dummyImg, + ext: "png", + width: 400, + height: 400, + }); + + const history = store.load(); + assert.equal(history.length, 3); + assert.deepEqual(history.map((v) => v.id), [v2.id, v3.id, v4.id]); + + // v1 file should be deleted on disk + assert.equal(fs.existsSync(store.filePath(v1)), false); + assert.ok(fs.existsSync(store.filePath(v4))); + + // Check no leftover .tmp files + const tmpFiles = fs.readdirSync(tempDir).filter((f) => f.endsWith(".tmp")); + assert.equal(tmpFiles.length, 0); + }); + + test("cascade deletion removes version and all its children", async () => { + const store = new HistoryStore({ dir: tempDir, limit: 10 }); + const service = new HistoryService({ store }); + const dummyImg = Buffer.from("dummy image content"); + + const root = await service.createVersion({ kind: "original", label: "Root", buffer: dummyImg }); + const child1 = await service.createVersion({ kind: "edit", label: "Child 1", parentId: root.id, buffer: dummyImg }); + const child2 = await service.createVersion({ kind: "upscale", label: "Child 2", parentId: child1.id, buffer: dummyImg }); + const independent = await service.createVersion({ kind: "original", label: "Independent", buffer: dummyImg }); + + assert.equal(store.load().length, 4); + + // Delete child1 -> should delete child1 and child2, but keep root and independent + const result = await service.removeCascade(child1.id); + assert.equal(result.removed.length, 2); + assert.equal(result.remaining.length, 2); + assert.deepEqual(result.remaining.map((v) => v.id), [root.id, independent.id]); + + // Verify deleted files are gone from disk + assert.ok(fs.existsSync(store.filePath(root))); + assert.ok(fs.existsSync(store.filePath(independent))); + assert.equal(fs.existsSync(store.filePath(child1)), false); + assert.equal(fs.existsSync(store.filePath(child2)), false); + }); + + test("migrateLegacyHistory copies files without deleting original", () => { + const legacyDir = path.join(tempDir, "legacy"); + const targetDir = path.join(tempDir, "target"); + + fs.mkdirSync(legacyDir, { recursive: true }); + const mockManifest = [{ id: "v-legacy-1", kind: "original", label: "Legacy" }]; + fs.writeFileSync(path.join(legacyDir, "history.json"), JSON.stringify(mockManifest)); + fs.writeFileSync(path.join(legacyDir, "v-legacy-1.jpg"), "fake jpg content"); + + migrateLegacyHistory(legacyDir, targetDir); + + assert.ok(fs.existsSync(path.join(targetDir, "history.json"))); + assert.ok(fs.existsSync(path.join(targetDir, "v-legacy-1.jpg"))); + // Legacy files still exist + assert.ok(fs.existsSync(path.join(legacyDir, "history.json"))); + assert.ok(fs.existsSync(path.join(legacyDir, "v-legacy-1.jpg"))); + }); +}); diff --git a/test/images.test.js b/test/images.test.js new file mode 100644 index 0000000..ebdb8f5 --- /dev/null +++ b/test/images.test.js @@ -0,0 +1,105 @@ +"use strict"; + +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const sharp = require("sharp"); +const { getImageDimensions, scaleForLimit } = require("../src/core/images/dimensions"); +const { sniffImageType } = require("../src/core/images/sniff"); +const { scaleRegion } = require("../src/core/codex/prompts"); +const { downscaleImage } = require("../src/core/images/resize"); + +describe("images", () => { + test("sniffImageType recognizes JPEG, PNG, WebP and rejects invalid data", async () => { + // Generate valid test images via sharp + const pngBuffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }).png().toBuffer(); + + const jpegBuffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 255, b: 0 } }, + }).jpeg().toBuffer(); + + const webpBuffer = await sharp({ + create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }).webp().toBuffer(); + + assert.equal(sniffImageType(pngBuffer), "png"); + assert.equal(sniffImageType(jpegBuffer), "jpeg"); + assert.equal(sniffImageType(webpBuffer), "webp"); + + // HEIC ftyp box mock + const heicMock = Buffer.alloc(32); + heicMock.writeUInt32BE(32, 0); + heicMock.write("ftyp", 4, "ascii"); + heicMock.write("heic", 8, "ascii"); + assert.equal(sniffImageType(heicMock), "heic"); + + // Non-image garbage + assert.equal(sniffImageType(Buffer.from("not an image at all")), null); + assert.equal(sniffImageType(Buffer.alloc(4)), null); + }); + + test("getImageDimensions parses PNG, JPEG and WebP dimensions", async () => { + const pngBuffer = await sharp({ + create: { width: 320, height: 240, channels: 3, background: "white" }, + }).png().toBuffer(); + + const jpegBuffer = await sharp({ + create: { width: 640, height: 480, channels: 3, background: "white" }, + }).jpeg().toBuffer(); + + const webpBuffer = await sharp({ + create: { width: 128, height: 96, channels: 3, background: "white" }, + }).webp().toBuffer(); + + assert.deepEqual(getImageDimensions(pngBuffer), { width: 320, height: 240 }); + assert.deepEqual(getImageDimensions(jpegBuffer), { width: 640, height: 480 }); + assert.deepEqual(getImageDimensions(webpBuffer), { width: 128, height: 96 }); + }); + + test("scaleRegion scales coordinates and preserves bounds", () => { + const region = { x: 100, y: 50, w: 200, h: 150 }; + + // factor = 1 -> identical + assert.deepEqual(scaleRegion(region, 1), region); + + // factor = 0.5 + assert.deepEqual(scaleRegion(region, 0.5), { x: 50, y: 25, w: 100, h: 75 }); + + // factor = 0.333 + const scaled = scaleRegion(region, 0.333333); + assert.equal(scaled.x, 33); + assert.equal(scaled.y, 17); + assert.equal(scaled.w, 67); + assert.equal(scaled.h, 50); + + // null region + assert.equal(scaleRegion(null, 0.5), null); + }); + + test("downscaleImage resizes images exceeding EDIT_MAX_DIMENSION", async () => { + const largeBuffer = await sharp({ + create: { width: 2000, height: 1500, channels: 3, background: "red" }, + }).jpeg().toBuffer(); + + const result = await downscaleImage(largeBuffer, 1024); + assert.equal(result.downscaled, true); + assert.equal(result.originalWidth, 2000); + assert.equal(result.originalHeight, 1500); + assert.equal(result.width, 1024); + assert.equal(result.height, 768); + assert.ok(result.factor < 1); + assert.ok(result.buffer.length < largeBuffer.length); + + // Small image is NOT downscaled + const smallBuffer = await sharp({ + create: { width: 800, height: 600, channels: 3, background: "blue" }, + }).jpeg().toBuffer(); + + const smallResult = await downscaleImage(smallBuffer, 1024); + assert.equal(smallResult.downscaled, false); + assert.equal(smallResult.width, 800); + assert.equal(smallResult.height, 600); + assert.equal(smallResult.factor, 1); + }); +}); diff --git a/test/job-queue.test.js b/test/job-queue.test.js new file mode 100644 index 0000000..f9b7e2d --- /dev/null +++ b/test/job-queue.test.js @@ -0,0 +1,161 @@ +"use strict"; + +const { test, describe, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { EventEmitter } = require("events"); + +const { JobStore } = require("../src/core/jobs/job-store"); +const { Queue } = require("../src/core/jobs/queue"); +const { EngineRegistry } = require("../src/core/engines/engine"); +const { TooManyRequestsError } = require("../src/errors"); + +describe("job-queue", () => { + let tempDir; + let jobFile; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "kadr-job-test-")); + jobFile = path.join(tempDir, "jobs.jsonl"); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + test("processes jobs in FIFO order respecting group concurrency", async () => { + const jobStore = new JobStore({ file: jobFile }); + const engineRegistry = new EngineRegistry(); + const events = new EventEmitter(); + + const executed = []; + + // Fake edit engine with delay + engineRegistry.register("edit", () => ({ + async run(job) { + executed.push(`start:${job.id}`); + await new Promise((r) => setTimeout(r, 50)); + executed.push(`done:${job.id}`); + return { ok: true }; + }, + })); + + const queue = new Queue({ + concurrencyByGroup: { codex: 1, "comfy-upscale": 2 }, + maxQueuedJobs: 5, + jobStore, + engineRegistry, + engineContext: {}, + events, + }); + + const job1 = { id: "job-1", type: "edit", status: "queued", payload: {} }; + const job2 = { id: "job-2", type: "edit", status: "queued", payload: {} }; + + jobStore.saveJob(job1); + jobStore.saveJob(job2); + + await queue.enqueue(job1); + const { queuePosition } = await queue.enqueue(job2); + + assert.equal(queuePosition, 2); + + queue.start(); + + // Wait until both jobs finish + while (jobStore.getJob("job-2")?.status !== "done") { + await new Promise((r) => setTimeout(r, 20)); + } + + // Because codex concurrency = 1, job-1 should complete before job-2 starts + assert.deepEqual(executed, [ + "start:job-1", + "done:job-1", + "start:job-2", + "done:job-2", + ]); + + await queue.stop(); + }); + + test("rejects with 429 when MAX_QUEUED_JOBS limit is exceeded", async () => { + const jobStore = new JobStore({ file: jobFile }); + const engineRegistry = new EngineRegistry(); + engineRegistry.register("edit", () => ({ + run: async () => new Promise((r) => setTimeout(r, 500)), + })); + + const queue = new Queue({ + concurrencyByGroup: { codex: 1 }, + maxQueuedJobs: 2, + jobStore, + engineRegistry, + engineContext: {}, + }); + + const j1 = { id: "j1", type: "edit", status: "queued", payload: {} }; + const j2 = { id: "j2", type: "edit", status: "queued", payload: {} }; + const j3 = { id: "j3", type: "edit", status: "queued", payload: {} }; + + jobStore.saveJob(j1); + jobStore.saveJob(j2); + jobStore.saveJob(j3); + + await queue.enqueue(j1); + await queue.enqueue(j2); + + // 3rd job exceeds limit of 2 + await assert.rejects( + async () => queue.enqueue(j3), + (err) => err instanceof TooManyRequestsError + ); + }); + + test("recovers jobs from journal after server restart", () => { + // Write pre-existing jobs into jobs.jsonl + const lines = [ + JSON.stringify({ + seq: 1, + jobId: "j-codex-running", + at: new Date().toISOString(), + event: "created", + job: { id: "j-codex-running", type: "edit", status: "running", engine: "codex", attempts: 0 }, + }), + JSON.stringify({ + seq: 2, + jobId: "j-upscale-running", + at: new Date().toISOString(), + event: "created", + job: { id: "j-upscale-running", type: "upscale", status: "running", engine: "comfy-upscale", attempts: 0 }, + }), + JSON.stringify({ + seq: 3, + jobId: "j-queued", + at: new Date().toISOString(), + event: "created", + job: { id: "j-queued", type: "edit", status: "queued", engine: "codex", attempts: 0 }, + }), + ]; + fs.writeFileSync(jobFile, lines.join("\n") + "\n", "utf8"); + + // Load JobStore -> recovery happens in constructor + const jobStore = new JobStore({ file: jobFile }); + + const codexJob = jobStore.getJob("j-codex-running"); + assert.equal(codexJob.status, "interrupted"); + assert.equal(codexJob.attempts, 1); + + const upscaleJob = jobStore.getJob("j-upscale-running"); + assert.equal(upscaleJob.status, "queued"); + assert.equal(upscaleJob.attempts, 1); + + const queuedJob = jobStore.getJob("j-queued"); + assert.equal(queuedJob.status, "queued"); + }); +}); diff --git a/test/logger.test.js b/test/logger.test.js new file mode 100644 index 0000000..13fd9ff --- /dev/null +++ b/test/logger.test.js @@ -0,0 +1,61 @@ +"use strict"; + +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { createLogger, singleLine, truncate } = require("../src/logger"); + +describe("logger", () => { + test("formats output as JSON-lines with timestamp, level and message", () => { + const lines = []; + const fakeStream = { + write: (str) => lines.push(str), + }; + + const logger = createLogger({ level: "debug", stream: fakeStream }); + logger.info("Тестовое сообщение", { foo: "bar" }); + + assert.equal(lines.length, 1); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.level, "info"); + assert.equal(parsed.msg, "Тестовое сообщение"); + assert.equal(parsed.foo, "bar"); + assert.ok(parsed.ts); + }); + + test("respects threshold and suppresses lower levels", () => { + const lines = []; + const fakeStream = { write: (str) => lines.push(str) }; + + const logger = createLogger({ level: "warn", stream: fakeStream }); + logger.debug("Debug msg"); + logger.info("Info msg"); + assert.equal(lines.length, 0); + + logger.warn("Warn msg"); + logger.error("Error msg"); + assert.equal(lines.length, 2); + }); + + test("sanitizes password, token and auth fields", () => { + const lines = []; + const fakeStream = { write: (str) => lines.push(str) }; + + const logger = createLogger({ level: "info", stream: fakeStream }); + logger.info("Вход", { + password: "secretpassword", + sessionToken: "12345678abcdef", + cookieHeader: "kadr_session=xyz", + }); + + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.password, "[REDACTED]"); + assert.equal(parsed.sessionToken, "[REDACTED]"); + assert.equal(parsed.cookieHeader, "[REDACTED]"); + }); + + test("singleLine and truncate helpers work properly", () => { + assert.equal(singleLine(" hello \n\r\t world "), "hello world"); + assert.equal(truncate("short", 10), "short"); + assert.equal(truncate("123456789012345", 10), "123456789…"); + }); +}); diff --git a/test/prompts.test.js b/test/prompts.test.js new file mode 100644 index 0000000..1acdcd6 --- /dev/null +++ b/test/prompts.test.js @@ -0,0 +1,72 @@ +"use strict"; + +const { test, describe } = require("node:test"); +const assert = require("node:assert/strict"); +const { + buildEditPrompt, + buildSuggestPrompt, + parseSuggestions, + parseRegion, + scaleRegion, +} = require("../src/core/codex/prompts"); +const { AppError } = require("../src/errors"); + +describe("prompts", () => { + test("buildEditPrompt builds basic edit prompt", () => { + const prompt = buildEditPrompt({ + prompt: "Сделай закатный свет", + dimensions: { width: 1024, height: 768 }, + inputName: "input.jpg", + }); + + assert.ok(prompt.includes("Перед тобой фотография input.jpg. Примени к ней следующее редактирование: «Сделай закатный свет».")); + assert.ok(prompt.includes("Сохрани отредактированное фото в файл output.jpg")); + assert.ok(prompt.includes("Сохрани исходный размер 1024×768.")); + }); + + test("buildEditPrompt includes reference, region and downscaled notes", () => { + const prompt = buildEditPrompt({ + prompt: "Замени фон", + dimensions: { width: 800, height: 600 }, + inputName: "input.jpg", + referenceName: "reference.jpg", + region: { x: 10, y: 20, w: 100, h: 200 }, + downscaled: true, + }); + + assert.ok(prompt.includes("вспомогательное изображение reference.jpg")); + assert.ok(prompt.includes("Координаты области на reference.jpg: x=10, y=20, ширина=100, высота=200")); + assert.ok(prompt.includes("Исходное фото уменьшено для скорости обработки.")); + assert.ok(prompt.includes("Сохрани исходный размер 800×600.")); + }); + + test("parseSuggestions extracts up to 3 clean suggestions", () => { + const raw = ` +1. Сделай теплый закатный свет +2. Добавь легкий туман на заднем плане +* Стилизуй под винтажное фото +4. Четвертый вариант, который должен быть отброшен +`; + const parsed = parseSuggestions(raw); + assert.equal(parsed.length, 3); + assert.equal(parsed[0], "Сделай теплый закатный свет"); + assert.equal(parsed[1], "Добавь легкий туман на заднем плане"); + assert.equal(parsed[2], "Стилизуй под винтажное фото"); + }); + + test("parseRegion validates and parses region format", () => { + assert.equal(parseRegion(null), null); + assert.equal(parseRegion(""), null); + + const fromObj = parseRegion({ x: 10.4, y: 20.8, w: 50, h: 60 }); + assert.deepEqual(fromObj, { x: 10, y: 21, w: 50, h: 60 }); + + const fromStr = parseRegion(JSON.stringify({ x: 0, y: 0, w: 100, h: 100 })); + assert.deepEqual(fromStr, { x: 0, y: 0, w: 100, h: 100 }); + + // Invalid coordinates + assert.throws(() => parseRegion({ x: -5, y: 0, w: 10, h: 10 }), (err) => err instanceof AppError); + assert.throws(() => parseRegion({ x: 0, y: 0, w: 0, h: 10 }), (err) => err instanceof AppError); + assert.throws(() => parseRegion("invalid-json"), (err) => err instanceof AppError); + }); +});