Compare commits

22 Commits

Author SHA1 Message Date
b4ea8df840 fix: результат редактирования не появлялся в окне «Результат появится здесь» — корень: браузер кэшировал ответ /api/jobs/:id/status со статусом «running» (не было Cache-Control), и последний финальный запрос получал stale-ответ с result=null
All checks were successful
test / test (push) Successful in 10s
2026-08-28 01:19:17 +07:00
6f611dc697 fix: исправление наложения кнопок в дереве истории версий
All checks were successful
test / test (push) Successful in 9s
- Переведён .history-node с flex на grid (main + actions в колонках)
- Убрано абсолютное позиционирование .history-node-actions
- Починена вертикальность поддерева (grid-column: 1 / -1)
- Инкрементальная индентация через padding-left на контейнерах
- Адаптивность: на ≤460px действия переносятся под кнопку
- overflow-x: hidden на списке истории для предотвращения скролла
- Бамп стиля v=13 → v=14 (сброс кэша)
2026-08-27 22:45:07 +07:00
6d5b8d5f05 fix: .gitignore игнорировал src/core/history (паттерн history/ без якоря) — CI падал с MODULE_NOT_FOUND
All checks were successful
test / test (push) Successful in 11s
Паттерн 'history/' в .gitignore матчил любой каталог с таким именем, включая служебный модуль src/core/history/. Привязаны /history/ и /data/ к корню репозитория; модуль истории добавлен в индекс.
2026-08-27 13:04:46 +07:00
401969cfdb v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s
Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
2026-08-27 12:52:27 +07:00
1c36a21e0c История: сортировка групп по дате — новые фотографии вверху 2026-08-26 19:57:55 +07:00
f91aa94336 Дерево истории, удаление версий, мультизагрузка, редактирование с референсом и областью, подсказки промптов 2026-08-26 19:17:36 +07:00
27a2ac304c Логирование в stdout (видно в Portainer) + асинхронные задания edit/upscale с опросом статуса (фикс 504/499 при длинных запросах) 2026-08-26 14:10:45 +07:00
d9eccfb4b7 Авторизация: форма входа, cookie-сессия, защита /api/* (кроме health/login/me/logout) 2026-08-26 00:38:23 +07:00
5dcc4ce1ad Деплой: Codex через прокси http://192.168.31.240:4067 (прямой egress блокируется OpenAI, 403) 2026-08-25 23:55:15 +07:00
1dec7e5178 Кнопка «Скачать» текущей версии фото (доступна при навигации по истории) 2026-08-25 23:34:47 +07:00
839ed50ef4 Upscale: таймаут ожидания ComfyUI 2 → 5 минут (JOB_TIMEOUT_MS=300000) 2026-08-25 23:06:40 +07:00
4bf31a310e Dockerfile: установка bubblewrap (песочница Codex без предупреждений) 2026-08-25 22:52:37 +07:00
cfa919a435 README: раздел про развёртывание в Portainer (deploy-portainer) 2026-08-25 22:30:07 +07:00
5b0f8a8f73 Portainer 2.39: create через /stacks/create/standalone/file, update через PUT JSON StackFileContent 2026-08-25 22:28:12 +07:00
29037e1720 Portainer: авторизация через X-API-Key (Portainer 2.39 не принимает Bearer для API-ключей) 2026-08-25 22:24:00 +07:00
f80ed97e98 Workflow деплоя в Portainer (создание/обновление стека photo-editor через API) 2026-08-25 22:20:15 +07:00
e59b3645f6 README: требования Gitea Actions (секрет GIT_PASSWORD, lowercase-теги); gitignore: .bench 2026-08-25 22:09:51 +07:00
ee952df2a5 Workflow: теги registry в нижнем регистре (docker требует lowercase) 2026-08-25 22:06:36 +07:00
bedd2e0fef Workflow: логин через существующий секрет GIT_PASSWORD 2026-08-25 22:03:56 +07:00
1e1fdb7425 Workflow: секрет KADR_TOKEN (GITEA_TOKEN/GITHUB_TOKEN зарезервированы Gitea) 2026-08-25 22:02:31 +07:00
c1998b9397 Workflow: фикс логина в Gitea registry (жёсткий хост + PAT GITEA_TOKEN, сброс docker-конфига) 2026-08-25 22:00:30 +07:00
5a920c7fc6 Обновить .gitea/workflows/build.yml 2026-08-25 21:56:28 +07:00
72 changed files with 10475 additions and 2345 deletions

View File

@@ -0,0 +1,77 @@
name: deploy-portainer
# Ручной запуск: вкладка Actions → Run workflow.
# Создаёт или обновляет стек photo-editor на Portainer
# (https://ptr.byte-mate.ru) из deploy/docker-compose.yml.
on:
workflow_dispatch:
concurrency:
group: deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack to Portainer
env:
PORTAINER_URL: https://ptr.byte-mate.ru
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
# Секреты Gitea: Settings → Actions → Secrets → APP_USER / APP_PASSWORD
APP_USER: ${{ secrets.APP_USER }}
APP_PASSWORD: ${{ secrets.APP_PASSWORD }}
STACK_NAME: photo-editor
# Если не задано — берётся первый endpoint из /api/endpoints
ENDPOINT_ID: ${{ vars.PORTAINER_ENDPOINT_ID }}
run: |
set -euo pipefail
if [ -z "${PORTAINER_API_KEY:-}" ]; then
echo "::error::Секрет PORTAINER_API_KEY не задан"
echo "::error::Создайте API-ключ в Portainer (Account → API keys) и добавьте его в секреты репозитория."
exit 1
fi
AUTH="X-API-Key: ${PORTAINER_API_KEY}"
# Параметры стека: пустые значения означают «дефолты на сервере»
ENV_JSON=$(jq -nc \
--arg u "${APP_USER:-}" \
--arg p "${APP_PASSWORD:-}" \
'[{name:"APP_USER",value:$u},{name:"APP_PASSWORD",value:$p}]')
# Определяем endpoint (окружение Docker), если не задан вручную
if [ -z "${ENDPOINT_ID:-}" ]; then
ENDPOINT_ID=$(curl -fsSk -H "$AUTH" "$PORTAINER_URL/api/endpoints" | jq -r '.[0].Id')
echo "Endpoint id автоопределён: ${ENDPOINT_ID}"
fi
echo "Endpoint id: ${ENDPOINT_ID}"
# Ищем существующий стек
STACK_ID=$(curl -fsSk -H "$AUTH" \
"$PORTAINER_URL/api/stacks?filters=%7B%22EndpointId%22%3A${ENDPOINT_ID}%7D" \
| jq -r ".[] | select(.Name==\"${STACK_NAME}\") | .Id" | head -1 || true)
if [ -n "${STACK_ID}" ]; then
echo "Обновляю стек ${STACK_NAME} (id=${STACK_ID})"
PAYLOAD=$(jq -nc \
--rawfile content deploy/docker-compose.yml \
--argjson env "$ENV_JSON" \
'{StackFileContent:$content, Env:$env, RepullImageAndRedeploy:true}')
curl -fsSk -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
-d "$PAYLOAD" \
"$PORTAINER_URL/api/stacks/${STACK_ID}?endpointId=${ENDPOINT_ID}" >/dev/null
else
echo "Создаю стек ${STACK_NAME}"
curl -fsSk -X POST -H "$AUTH" \
-F "Name=${STACK_NAME}" \
-F "file=@deploy/docker-compose.yml" \
-F "Env=${ENV_JSON}" \
"$PORTAINER_URL/api/stacks/create/standalone/file?endpointId=${ENDPOINT_ID}" >/dev/null
fi
echo "✅ Стек ${STACK_NAME} развёрнут на ${PORTAINER_URL}"

30
.gitea/workflows/test.yml Normal file
View File

@@ -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

9
.gitignore vendored
View File

@@ -1,8 +1,13 @@
# Зависимости # Зависимости
node_modules/ node_modules/
.npm-cache/
.npmrc
# Данные приложения (история версий, временные рабочие каталоги Codex) # Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
history/ # Паттерны привязаны к корню репозитория: иначе "history/" игнорирует
# и служебный модуль src/core/history/ (сборка/CI на Linux падали бы).
/history/
/data/
.codex-jobs/ .codex-jobs/
# Временные и тестовые файлы # Временные и тестовые файлы

View File

@@ -1,12 +1,12 @@
# Кадр — локальный AI-фоторедактор # Кадр — локальный AI-фоторедактор (v2)
# Образ: Node.js 22 на Debian bookworm-slim. # Образ: Node.js 22 на Debian bookworm-slim.
# Debian (glibc) обязателен: Codex CLI поставляется как бинарник под glibc, # Debian (glibc) обязателен: Codex CLI поставляется как бинарник под glibc,
# на Alpine (musl) он не запускается. # на Alpine (musl) он не запускается.
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
# Утилиты для healthcheck # Утилиты для healthcheck и песочницы Codex (bubblewrap)
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends wget ca-certificates \ && apt-get install -y --no-install-recommends wget ca-certificates bubblewrap \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Codex CLI — движок редактирования фотографий по текстовому промпту. # Codex CLI — движок редактирования фотографий по текстовому промпту.
@@ -22,6 +22,7 @@ RUN npm ci --omit=dev
# Код приложения # Код приложения
COPY server.js ./ COPY server.js ./
COPY src ./src
COPY public ./public COPY public ./public
# Конфиг Codex по умолчанию (используется, если ~/.codex не смонтирован) # Конфиг Codex по умолчанию (используется, если ~/.codex не смонтирован)
@@ -30,9 +31,8 @@ COPY codex/config.toml /root/.codex/config.toml
ENV NODE_ENV=production ENV NODE_ENV=production
ENV COMFY_URL=http://192.168.31.240:8188 ENV COMFY_URL=http://192.168.31.240:8188
# История версий должна жить на томе — иначе версии пропадут # Хранилище данных и история версий на томах
# при пересоздании контейнера. VOLUME ["/app/history", "/app/data"]
VOLUME ["/app/history"]
EXPOSE 3000 EXPOSE 3000

230
README.md
View File

@@ -1,4 +1,4 @@
# Кадр — локальный AI-фоторедактор # Кадр — локальный AI-фоторедактор (v2)
Веб-приложение редактирует фотографии по текстовому промпту (через локальный Веб-приложение редактирует фотографии по текстовому промпту (через локальный
Codex CLI) и увеличивает разрешение через домашний сервер ComfyUI. Codex CLI) и увеличивает разрешение через домашний сервер ComfyUI.
@@ -7,140 +7,130 @@ Codex CLI) и увеличивает разрешение через домаш
преобразуются на сервере в JPEG с помощью `heic-convert` до передачи в ComfyUI преобразуются на сервере в JPEG с помощью `heic-convert` до передачи в ComfyUI
или Codex. или 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.52 раза.
5. **Мгновенные обновления статуса (SSE)**:
- Server-Sent Events (`GET /api/events`) передают смену статуса очереди и прогресс мгновенно.
- Автоматический фолбэк на опрос (`/api/jobs`) при сбоях сети или неподдерживаемых прокси.
6. **Клиентская часть на Vanilla ES-модулях**:
- Модули в `public/js/` загружаются напрямую браузером (`<script type="module">`) без этапа сборки.
---
## Как это работает ## Как это работает
- **История версий**: каждая загрузка и каждый результат (редактирование или - **История версий**: каждая загрузка и результат (редактирование или апскейл) сохраняются в `data/history/` как отдельная версия. Доступно интерактивное дерево версий с ветвлением от исходника, каскадным удалением и свёрткой групп. Хранятся последние 50 версий (настраивается через `HISTORY_LIMIT`).
апскейл) сохраняется в папке `history/` как отдельная версия. Можно вернуться - **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex CLI (`codex exec`). Агент редактирует изображение и сохраняет результат в JPEG.
к любой версии кликом по ней в ленте «История версий» и продолжить правки - **Увеличение разрешения**: 4× upscale через ComfyUI моделью `RealESRGAN_x4plus.safetensors` (или `4x_UltraSharp`).
именно с неё — например, отредактировать фото, а затем увеличить разрешение - **Редактирование с референсом и областью**: можно задать референс — файл или версию из истории. На превью референса выделяется прямоугольник области; координаты передаются в промпт Codex.
уже отредактированного варианта. История переживает перезапуск сервера и - **Подсказки промптов**: мгновенные варианты из недавней истории и генерация новых вариантов через Codex.
обновление страницы; хранится последние 50 версий.
- **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex
CLI (`codex exec`). Агент редактирует изображение и сохраняет результат.
Обработка обычно занимает 13 минуты.
- **Увеличение разрешения**: 4× upscale через ComfyUI моделью
`RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе
отредактированной) версии.
## Требования ---
- Node.js 20 или новее ## Переменные окружения
- Локальный Codex CLI (`codex`) с выполненным входом в аккаунт OpenAI
- Доступ к ComfyUI: `http://192.168.31.240:8188`
- Checkpoint: `sdxl_turbo.safetensors`
- Upscale-модель: `RealESRGAN_x4plus.safetensors`
- Установленные зависимости проекта, включая `heic-convert` (ставится
командой `npm install`)
## Запуск | Переменная | Тип | Дефолт | Описание |
|---|---|---|---|
| `PORT` | number | `3000` | Порт HTTP-сервера (кандидаты по умолчанию: 3000, 8080, 8090) |
| `COMFY_URL` | url | `http://192.168.31.240:8188` | Адрес домашнего сервера ComfyUI |
| `APP_USER` | string | `admin` | Логин владельца |
| `APP_PASSWORD` | string | *автогенерация* | Пароль владельца (если не задан — печатается в лог при старте) |
| `LOG_LEVEL` | string | `info` | Уровень логов (`debug`, `info`, `warn`, `error`) |
| `DATA_DIR` | path | `./data` | Каталог данных (сессии, журнал заданий, история) |
| `HISTORY_LIMIT` | number | `50` | Максимальное количество версий в истории |
| `MAX_UPLOAD_BYTES` | number | `26214400` (25 МБ) | Максимальный размер загружаемого файла |
| `EDIT_MAX_DIMENSION` | number | `1024` | Максимальный размер стороны при даунскейле перед Codex (0 — отключить) |
| `UPSCALE_MAX_DIMENSION`| number | `2048` | Предел входного разрешения перед 4× апскейлом |
| `CODEX_CLI_PATH` | string | `codex` / авто-поиск | Путь к бинарнику Codex CLI |
| `CODEX_EDIT_TIMEOUT_MS`| number | `420000` (7 мин) | Таймаут выполнения операции в Codex |
| `JOB_TIMEOUT_MS` | number | `300000` (5 мин) | Таймаут задачи в ComfyUI |
| `REQUEST_TIMEOUT_MS` | number | `15000` (15 с) | Таймаут сетевых HTTP-запросов |
| `SESSION_TTL_MS` | number | `604800000` (7 дней) | Срок жизни cookie-сессии (скользящий) |
| `SESSION_PERSIST` | bool | `true` | Сохранение сессий между перезапусками сервера |
| `CODEX_CONCURRENCY` | number | `1` | Число параллельных потоков Codex |
| `UPSCALE_CONCURRENCY` | number | `2` | Число параллельных потоков ComfyUI |
| `MAX_QUEUED_JOBS` | number | `5` | Максимальный размер очереди на группу |
| `SSE_ENABLED` | bool | `true` | Включение Server-Sent Events |
| `TRUST_PROXY` | bool | `false` | Доверие заголовкам прокси (`X-Forwarded-For` для rate-limiting) |
---
## Запуск и разработка
### Локальный запуск
```powershell ```powershell
# Установка зависимостей
npm install npm install
# Запуск тестов
npm test
# Запуск приложения
npm start npm start
# Запуск в режиме разработки с автоперезагрузкой
npm run start:dev
``` ```
## Docker При первом запуске без `APP_PASSWORD` в консоль выводится баннер со случайным сгенерированным паролем.
Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI ---
распространяется как бинарник под glibc и не работает на Alpine/musl.
## Авторизация и безопасность
Редактор защищён cookie-сессией `kadr_session` (`HttpOnly`, `SameSite=Lax`):
- Публичные маршруты: `GET /api/health`, `POST /api/login`, `GET /api/me`, `POST /api/logout`.
- Все остальные маршруты `/api/*` требуют авторизации (`401 Unauthorized`).
- Защита от перебора: после 5 неудачных попыток входа IP блокируется на 15 минут.
- Пароль проверяется функцией scrypt (`crypto.scrypt`) с защитой от атак по времени (`timingSafeEqual`).
---
## Логирование и диагностика
Все логи формируются в структурированном формате JSON-lines с обязательным полем `ts`, `level`, `msg` и `requestId`:
```json
{"ts":"2026-08-27T12:00:00.000Z","level":"info","msg":"HTTP запрос завершён","requestId":"req_a1b2c3d4","method":"GET","url":"/api/history","status":200,"durationMs":3}
```
Пароли, сессионные токены и персональные секреты автоматически маскируются (`[REDACTED]`).
---
## Docker и развёртывание
### Локальный Docker
```powershell ```powershell
# сборка и запуск
docker compose up -d --build docker compose up -d --build
# или вручную:
docker build -t kadr-photo-editor .
docker run -d --name kadr-photo-editor -p 3000:3000 ^
-v "$env:USERPROFILE\.codex:/root/.codex" ^
-v "${PWD}\history:/app/history" ^
-e COMFY_URL=http://192.168.31.240:8188 ^
kadr-photo-editor
``` ```
Что важно знать: ### Развёртывание в Portainer через Gitea Actions
- **Авторизация Codex**: контейнер монтирует `~/.codex` хоста в `/root/.codex` 1. Workflow `.gitea/workflows/test.yml` автоматически запускает автотесты при push в `main`.
(auth + config + навыки). Альтернатива — переменная окружения 2. Workflow `.gitea/workflows/build.yml` собирает образ на базе `node:22-bookworm-slim` и пушит в Gitea Registry.
`OPENAI_API_KEY`. 3. Workflow `.gitea/workflows/deploy.yml` обновляет стек в Portainer с томами:
- **История версий**: каталог `history/` смонтирован томом, версии переживают - `kadr-data:/app/data` — данные v2 (сессии, журнал, история);
пересоздание контейнера. - `kadr-history:/app/history` — старый том истории (автоматически мигрируется при первом старте v2);
- **ComfyUI**: задаётся через `COMFY_URL` (по умолчанию - `/root/.codex:/root/.codex` — конфигурация и авторизация Codex.
`http://192.168.31.240:8188`); из контейнера адрес доступен как исходящий
адрес локальной сети.
- **Порт**: по умолчанию 3000, меняется через `PORT`.
## Сборка образа в Gitea (Actions)
Репозиторий: `https://git.byte-mate.ru/Coder/photo-editor` (ветка `main`).
Workflow лежит в `.gitea/workflows/build.yml` и запускается **вручную**:
вкладка **Actions****Run workflow** → выбрать ветку → нажать кнопку.
После успешного прогона образ публикуется во встроенный registry Gitea:
```powershell
docker pull git.byte-mate.ru/coder/photo-editor:latest
docker run -d --name kadr-photo-editor -p 3000:3000 ^
-v "$env:USERPROFILE\.codex:/root/.codex" ^
-v "${PWD}\history:/app/history" ^
-e COMFY_URL=http://192.168.31.240:8188 ^
git.byte-mate.ru/coder/photo-editor:latest
```
Требования для работы кнопки:
1. **Actions включены** на инстансе (уже включены).
2. **Зарегистрирован runner** (`act_runner`) — уже работает
(«Runner-10C-32Gb»).
3. **Секрет `GIT_PASSWORD`** в репозитории (Settings → Actions → Secrets):
Personal Access Token с правами на пакеты. Имена `GITEA_TOKEN` /
`GITHUB_TOKEN` зарезервированы Gitea и не создаются.
4. Образ публикуется в `git.byte-mate.ru/coder/photo-editor` (теги `latest` и
имя ветки; путь в registry обязательно в нижнем регистре).
Если runner ещё не зарегистрирован:
```bash
# токен регистрации: Администрирование → Actions → Runners → Create new runner
docker run -d --name gitea-runner --restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v gitea-runner:/data \
-e GITEA_INSTANCE_URL=https://git.byte-mate.ru \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \
gitea/act_runner:latest
```
3. После регистрации нажмите «Run workflow» — образ соберётся Buildx'ом и
уедет в registry.
## Тест скорости моделей Codex (ветка testing)
Сервер умеет переопределять модель и уровень рассуждений через переменные
окружения (без правки `~/.codex/config.toml`):
```powershell
$env:CODEX_MODEL = "gpt-5.6-luna"; $env:CODEX_REASONING_EFFORT = "low"
npm start
```
Проверено на ветке `testing`: одинаковое фото 512×512 и один промпт,
замер полного цикла редактирования:
| Конфигурация | Время | Токены |
|---|---|---|
| `gpt-5.6-sol` + effort high (как сейчас) | 73.9 s | 15 286 |
| `gpt-5.6-sol` + effort low | 61.7 s | 29 760 |
| `gpt-5.6-luna` (default medium) | 68.6 s | 52 005 |
| `gpt-5.4-mini` (default medium) | 82.1 s | 19 437 |
| `gpt-5.2-codex`, `gpt-5.2-codex-mini` | — | недоступны для ChatGPT-аккаунта (API 400) |
Выводы:
- **Выбор модели почти не влияет на время** (6082 s): узкое место — вызов
image-модели при редактировании, а не агентная модель.
- Единственный заметный выигрыш — `CODEX_REASONING_EFFORT=low` (~17%).
Кстати, у `gpt-5.6-sol` low — это значение по умолчанию; в
`~/.codex/config.toml` оно переопределено на `high`, поэтому редактирование
такое долгое.
- Для радикального ускорения нужно уменьшать разрешение исходника для шага
редактирования (медленнее при 1200×800, чем при 512×512) — но тогда
результат будет меньшего размера.

41
deploy/docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
# Файл развёртывания на хосте Portainer.
# Используется workflow .gitea/workflows/deploy.yml (публикует образ,
# уже собранный workflow build-container).
services:
photo-editor:
image: git.byte-mate.ru/coder/photo-editor:latest
pull_policy: always
container_name: kadr-photo-editor
ports:
- "3000:3000"
volumes:
# Основной том данных v2 (сессии, задания, история)
- kadr-data:/app/data
# Старый том истории для автоматической миграции при первом старте
- kadr-history:/app/history
# Аутентификация и конфиг Codex с хоста Portainer.
# Альтернатива — переменная OPENAI_API_KEY ниже.
- /root/.codex:/root/.codex
environment:
- COMFY_URL=http://192.168.31.240:8188
# Codex (ChatGPT API) через локальный прокси: прямой egress сервера
# блокируется OpenAI (403, cf-ray HEL). Прокси слушает на хосте :4067.
- HTTP_PROXY=http://192.168.31.240:4067
- HTTPS_PROXY=http://192.168.31.240:4067
- http_proxy=http://192.168.31.240:4067
- https_proxy=http://192.168.31.240:4067
# Внутренние адреса ходят напрямую (ComfyUI и свой же хост)
- NO_PROXY=localhost,127.0.0.1,192.168.31.240,git.byte-mate.ru,ptr.byte-mate.ru
- no_proxy=localhost,127.0.0.1,192.168.31.240,git.byte-mate.ru,ptr.byte-mate.ru
# Авторизация владельца. Передаются workflow deploy.yml из секретов
# APP_USER / APP_PASSWORD; если не заданы/пусты — сервер использует
# дефолты: логин admin и сгенерированный при старте пароль
# (печатается в лог контейнера).
- APP_USER=${APP_USER}
- APP_PASSWORD=${APP_PASSWORD}
# - OPENAI_API_KEY=${OPENAI_API_KEY}
restart: unless-stopped
volumes:
kadr-data:
kadr-history:

View File

@@ -5,15 +5,12 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
# История версий на хосте — переживает пересоздание контейнера # Хранилище данных v2 (сессии, задания, история)
- ./data:/app/data
- ./history:/app/history - ./history:/app/history
# Аутентификация и конфиг Codex из домашней папки пользователя # Аутентификация и конфиг Codex из домашней папки пользователя
# (auth.json, config.toml, навыки). Если не монтировать — задайте
# OPENAI_API_KEY в environment.
- ~/.codex:/root/.codex - ~/.codex:/root/.codex
environment: environment:
# Адрес ComfyUI (доступен из контейнера как исходящий адрес LAN) # Адрес ComfyUI (доступен из контейнера как исходящий адрес LAN)
- COMFY_URL=http://192.168.31.240:8188 - COMFY_URL=http://192.168.31.240:8188
# Альтернатива монтированию ~/.codex (раскомментируйте при необходимости):
# - OPENAI_API_KEY=${OPENAI_API_KEY}
restart: unless-stopped restart: unless-stopped

View File

@@ -0,0 +1,97 @@
# System Design Baseline
**Вердикт:** READY
**Артефакт:** `docs/architecture/system-design-baseline.md`
**Статус:** 1.0, подтверждён по рабочей копии `main@1c36a21` (рабочее дерево чистое), дата наблюдения 2026-08-26.
**Язык:** русский (проект и владелец русскоязычные).
Этот документ — источник истины по **архитектуро-формирующим требованиям и ограничениям** проекта «Кадр». Он не описывает решение (см. `docs/architecture/target-design.md`). Каждый материал параметр имеет владельца, источник, дату и триггер пересмотра.
---
## 1. Идентичность и бизнес-контекст
| Параметр | Значение | Источник |
|---|---|---|
| Продукт | «Кадр» — локальный AI-фоторедактор: редактирование фотографии по текстовому промпту (Codex CLI) + 4× апскейл (ComfyUI RealESRGAN) | `README.md` |
| Владелец/оператор | Один человек («владелец»), доступ только ему | `README.md` разд. «Авторизация» |
| Развёртывание | Один контейнер Docker на хосте Portainer (`kadr-photo-editor`, порт 3000), LAN | `deploy/docker-compose.yml`, `README.md` |
| Окружение | Node.js 20+, Express 4, Codex CLI (OpenAI), ComfyUI `http://192.168.31.240:8188`, прокси `http://192.168.31.240:4067` для исходящего к OpenAI | `package.json`, `server.js`, `deploy/docker-compose.yml` |
| Читатели документа | Владелец; ИИ-агент, реализующий целевую архитектуру | — |
Критические сценарии (джорни):
1. Владелец загружает фото → видит превью → редактирует промптом (13 мин, фон) → результат в истории.
2. Владелец увеличивает разрешение версии (несколько секунд) → результат в истории.
3. Владелец возвращается к любой версии истории (дерево), продолжает правки с неё.
4. Владелец удаляет версию каскадно (с потомками).
5. Владелец получает подсказки промптов (из истории / от Codex).
Горизонт решений: **один пользователь, LAN, один хост, один контейнер**, горизонт планирования 612 месяцев. Выход за эти рамки — триггер пересмотра (см. реестр).
---
## 2. Область и не-цели
**В области:** редактирование по промпту, апскейл 4×, история версий с деревом и каскадным удалением, авторизация одного владельца, референс с областью, подсказки промптов, мультизагрузка, деплой Docker/Portainer, CI на Gitea Actions.
**Вне области (не-цели):** многопользовательность; публичный интернет-доступ; мобильные приложения; пакетная обработка; генерация изображений «с нуля»; онлайн-редактор с кистями/слоями; платёжные интеграции; облачный хостинг. Всё перечисленное — кандидаты на отдельное решение, не для текущего горизонта.
---
## 3. Драйверы и ограничения (реестр)
Легенда: **Крит.** — DRIVER / SUPPORTING / INFORMATIONAL; **Статус** — CONFIRMED (из кода/документов), ASSUMED, UNKNOWN.
| # | Тема | Параметр | Крит. | Статус | Значение/мера | Источник и владелец | Триггер пересмотра |
|---|---|---|---|---|---|---|---|
| D1 | Бизнес | Один владелец, закрытый доступ | DRIVER | CONFIRMED | Ровно один логин (`APP_USER`/`APP_PASSWORD`), cookie-сессия `kadr_session`, 7 суток, sliding | `server.js:119-218`, `README.md` | Появление второго пользователя |
| D2 | Бизнес | Редактирование по промпту — ядро продукта | DRIVER | CONFIRMED | Обработка 6082 с на 512×512, 13 мин на больших; узкое место — вызов image-модели | `README.md` разд. «Тест скорости» | Смена движка редактирования |
| D3 | Бизнес | История версий — непрерывная ветка правок | DRIVER | CONFIRMED | Дерево по `parentId`, до 50 версий, каскадное удаление, переживает перезапуск | `server.js:820-900, 1765-1826`, `README.md` | Рост лимита истории |
| D4 | Demand | Нагрузка | SUPPORTING | CONFIRMED | Один пользователь, 1 редактирование + 1 апскейл одновременно; опрос статуса каждые 3 с; лимит аплоада 25 МБ, промпт ≤ 1000 симв. | `server.js:24, 1876`, `README.md` | Параллельные правки разных фотографий |
| D5 | Demand | Объём данных | SUPPORTING | CONFIRMED | ≤ 50 версий × ≤ 25 МБ исходник; реальные файлы 16 МБ | `history/history.json`, `server.js:34` | Лимит 50 перестаёт устраивать |
| D6 | Качество | Время ответа API | SUPPORTING | CONFIRMED | `POST /api/edit`, `/api/upscale`, `/api/suggest` отвечают мгновенно (< 1 с) с `jobId`; тяжёлая работа фоном | `README.md` стр. 25-29 | — |
| D7 | Качество | Завершение редактирования | DRIVER | CONFIRMED | Codex: таймаут 420 с; ComfyUI: таймаут 300 с; сбои отображаются в UI, задание помечается `error` | `server.js:22, 29, 465-541, 1489-1534` | — |
| D8 | Данные | Переживание перезапуска | DRIVER | CONFIRMED | История переживает перезапуск; **сессии и задания — нет** (память, `sessions`/`jobs` Map); `.codex-jobs` очищается при старте | `server.js:141-153, 2259`, `README.md` стр. 98 | Цель перепроектирования: переживание заданий |
| D9 | Данные | Согласованность истории | DRIVER | CONFIRMED | Один писатель (`historyWriteChain`), полная перезапись `history.json` при каждом изменении, файлы `history/<uuid>.<ext>` | `server.js:830-868` | Переход на SQLite при > 500 версий |
| D10 | Безопасность | Аутентификация | DRIVER | CONFIRMED | Сравнение пароля — `constantTimeEqual` по **открытому** значению env-переменной; без хеширования, без rate-limit на вход | `server.js:155-159, 1641` | Редизайн: хешировать (scrypt) |
| D11 | Безопасность | Уязвимости | SUPPORTING | CONFIRMED | Только UUID в путях (`JOB_ID_PATTERN`); MIME+расширение проверяются, но без проверки сигнатуры файла; SameSite=Lax; без Secure (HTTP LAN); без Origin-проверки | `server.js:234-267, 2116` | Появление внешнего доступа |
| D12 | Операции | Деплой | SUPPORTING | CONFIRMED | Образ `node:22-bookworm-slim` (glibc обязателен для Codex); том `kadr-history`; секреты `APP_USER`/`APP_PASSWORD` из Gitea; Codex через прокси `:4067` | `Dockerfile`, `deploy/docker-compose.yml`, `.gitea/workflows/*.yml` | Смена хостинга |
| D13 | Операции | Наблюдаемость | SUPPORTING | CONFIRMED | Только лог в stdout (уровни debug/info/warn/error), без requestId, без метрик | `server.js:50-79`, `README.md` разд. «Логирование» | — |
| D14 | Экономика | Стоимость | INFORMATIONAL | CONFIRMED | Стоимость = токены Codex (ChatGPT API), редактирование ~1552k токенов/операция | `README.md` разд. «Тест скорости» | Смена тарифа |
| D15 | Эволюция | Скорость редактирования | SUPPORTING | CONFIRMED | Время растёт с разрешением исходника (1200×800 медленнее 512×512); замерено: `CODEX_REASONING_EFFORT=low` даёт ~17% | `README.md` разд. «Тест скорости» | Внедрение предварительного даунскейла |
| D16 | Ограничение | Внешние сервисы | DRIVER | CONFIRMED | ComfyUI API (upload/workflow/history/view), модель `sdxl_turbo.safetensors`, upscale `RealESRGAN_x4plus.safetensors`; Codex CLI `codex exec` с флагами `-i`, `-o last_message.txt`, `--ephemeral`, bypass sandbox | `server.js:15-17, 397-411`, `README.md` | Обновление ComfyUI/Codex |
| D17 | Ограничение | Исходящий egress | DRIVER | CONFIRMED | Прямой выход к OpenAI блокируется (403 cf-ray HEL); обязателен прокси `http://192.168.31.240:4067`, `NO_PROXY` для внутренних адресов | `deploy/docker-compose.yml:20-27`, `README.md` п.5 разд. Portainer | Смена сети/провайдера |
---
## 4. Ключевые сценарии (source/stimulus/environment/artifact/response/measure)
1. **Редактирование с сохранением в историю.** Источник: владелец. Стимул: `POST /api/edit` с фото/`sourceId` + промпт. Окружение: один контейнер, Codex доступен. Артефакт: сервис `/api/edit`. Реакция: ответ < 1 с с `jobId`; ≤ 420 с — новая версия `kind=edit` в истории; мера: версия видна в `GET /api/history` и по `job.status=done`.
2. **Апскейл.** Стимул: `POST /api/upscale`. Реакция: ≤ 300 с — версия `kind=upscale` (PNG), исходник масштабируется до ≤ 2048 px до подачи в ComfyUI. Мера: `job.status=done`.
3. **Переживание перезапуска (цель).** Стимул: перезапуск контейнера во время `running`-задания. Реакция (целевая): задание помечается прерванным с понятной ошибкой; очередь-задания и сессии переживают рестарт; история не теряется. Мера: после рестарта `GET /api/jobs/:id/status` возвращает `interrupted`/`error`, а не 404; сессия владельца жива.
4. **Безопасный вход.** Стимул: 10 неудачных попыток логина за 15 мин с одного IP. Реакция: блокировка с `Retry-After`; пароль не хранится в открытом виде. Мера: rate-limit срабатывает, в логах нет паролей.
---
## 5. Допущения и неизвестные
- **ASSUMED:** ComfyUI и Codex доступны из сети владельца (рабочее состояние подтверждается историей и `.bench/`).
- **ASSUMED:** целевой читатель (ИИ-агент) реализует сервер на Node.js/Express и фронтенд без сборки (vanilla ES-модули) — см. решения в target-design.md.
- **UNKNOWN:** точные объёмы будущего использования (нет метрик) — не влияет: горизонтальный запас огромен для одного пользователя.
- **UNKNOWN:** SLA внешних сервисов (Codex/ComfyUI) — обрабатываются таймаутами и понятными ошибками.
## 6. Триггеры пересмотра baseline
- Второй пользователь или внешний доступ (D1, D11).
- Лимит истории 50 или объём > 500 версий (D3, D5, D9).
- Параллельные правки разных фотографий (D4).
- Смена движка редактирования (D2, D16).
- Смена хостинга/сети (D12, D17).
- Переход на SQLite или другое хранилище (D9).
---
# System Design Baseline
**Checklist: 5/5 complete**<br>
**Incomplete: None** — все пункты чек-листа (объём и назначение; реестр доказательств; драйверы; написание; валидация) выполнены; изменены только документы `docs/architecture/*.md`, код не трогался.

View File

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

904
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,13 @@
{ {
"name": "prompt-photo-editor", "name": "prompt-photo-editor",
"version": "1.0.0", "version": "2.0.0",
"private": true, "private": true,
"description": "Локальный веб-редактор фотографий на базе ComfyUI", "description": "Локальный веб-редактор фотографий на базе Codex и ComfyUI (v2)",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"start:dev": "node --watch src/server.js",
"test": "node --test"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -13,6 +15,10 @@
"dependencies": { "dependencies": {
"express": "^4.19.2", "express": "^4.19.2",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1",
"sharp": "^0.35.4"
},
"devDependencies": {
"supertest": "^7.2.2"
} }
} }

View File

@@ -1,575 +1 @@
"use strict"; // migrated to /js/main.js (ES-modules in public/js/)
const MAX_FILE_SIZE = 25 * 1024 * 1024;
const ACCEPTED_TYPES = new Set([
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/heic",
"image/heif"
]);
const healthStatus = document.querySelector("#health-status");
const healthText = document.querySelector("#health-text");
const dropZone = document.querySelector("#drop-zone");
const photoInput = document.querySelector("#photo-input");
const dropTitle = document.querySelector("#drop-title");
const fileMeta = document.querySelector("#file-meta");
const promptInput = document.querySelector("#prompt");
const promptCounter = document.querySelector("#prompt-counter");
const editButton = document.querySelector("#edit-button");
const upscaleButton = document.querySelector("#upscale-button");
const workspace = document.querySelector("#workspace");
const sourceImage = document.querySelector("#source-image");
const sourcePlaceholder = document.querySelector("#source-placeholder");
const resultImage = document.querySelector("#result-image");
const resultPlaceholder = document.querySelector("#result-placeholder");
const resultLabel = document.querySelector("#result-label");
const jobStatus = document.querySelector("#job-status");
const jobStatusText = document.querySelector("#job-status-text");
const downloadLink = document.querySelector("#download-link");
let currentVersionId = null;
let historyVersions = [];
let sourceObjectUrl = null;
let isBusy = false;
let fileSelectionSequence = 0;
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")
);
}
function formatFileSize(bytes) {
if (bytes < 1024 * 1024) {
return `${Math.max(1, Math.round(bytes / 1024))} КБ`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
function setHealthState(state, message, title = "") {
healthStatus.className = `health-status health-${state}`;
healthText.textContent = message;
healthStatus.title = title;
}
function setJobStatus(kind, message) {
if (!message) {
jobStatus.hidden = true;
jobStatus.removeAttribute("data-kind");
jobStatusText.textContent = "";
return;
}
jobStatus.hidden = false;
jobStatus.dataset.kind = kind;
jobStatusText.textContent = message;
}
function setBusy(busy) {
isBusy = busy;
workspace.setAttribute("aria-busy", String(busy));
editButton.disabled = busy || !currentVersionId;
upscaleButton.disabled = busy || !currentVersionId;
promptInput.disabled = busy;
photoInput.disabled = busy;
dropZone.setAttribute("aria-disabled", String(busy));
}
function resetResult() {
resultImage.hidden = true;
resultImage.removeAttribute("src");
resultPlaceholder.hidden = false;
resultLabel.textContent = "";
downloadLink.hidden = true;
downloadLink.removeAttribute("href");
downloadLink.removeAttribute("download");
}
function clearSourcePreview() {
if (sourceObjectUrl) {
URL.revokeObjectURL(sourceObjectUrl);
sourceObjectUrl = null;
}
sourceImage.hidden = true;
sourceImage.removeAttribute("src");
sourcePlaceholder.hidden = false;
}
function showSourcePreview(url) {
if (sourceObjectUrl) {
URL.revokeObjectURL(sourceObjectUrl);
sourceObjectUrl = null;
}
sourceImage.src = url;
sourceImage.hidden = false;
sourcePlaceholder.hidden = true;
}
function showSourcePreviewFromUrl(url, title, meta) {
showSourcePreview(url);
dropZone.classList.add("has-file");
dropTitle.textContent = title || "Текущая фотография";
fileMeta.textContent = meta || "Готово к обработке";
}
async function selectFile(file) {
const selectionToken = ++fileSelectionSequence;
if (!file) {
return;
}
const fileType = String(file.type || "").toLowerCase();
if (!ACCEPTED_TYPES.has(fileType) && !isHeicFile(file)) {
setJobStatus(
"error",
"Поддерживаются только фотографии JPEG, PNG, WebP или HEIC."
);
return;
}
if (file.size > MAX_FILE_SIZE) {
setJobStatus(
"error",
"Файл слишком большой. Максимальный размер — 25 МБ."
);
return;
}
setBusy(true);
setJobStatus("loading", "Загружаем фотографию в историю…");
const form = new FormData();
form.append("photo", file, file.name);
try {
const response = await fetch("/api/history/import", {
method: "POST",
body: form,
cache: "no-store"
});
if (!response.ok) {
let message = `Не удалось загрузить фотографию. HTTP ${response.status}.`;
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const data = await response.json().catch(() => null);
if (data?.error) {
message = data.error;
}
}
throw new Error(message);
}
const data = await response.json();
if (selectionToken !== fileSelectionSequence) {
return;
}
historyVersions.push(data.version);
currentVersionId = data.version.id;
renderHistory();
resetResult();
showSourcePreviewFromUrl(
data.version.url,
file.name,
`${formatFileSize(file.size)} · готово к обработке`
);
setJobStatus("", "");
setBusy(false);
} catch (error) {
if (selectionToken !== fileSelectionSequence) {
return;
}
setBusy(false);
setJobStatus(
"error",
error instanceof Error
? error.message
: "Не удалось загрузить фотографию."
);
}
}
function openFilePicker() {
if (!isBusy) {
photoInput.click();
}
}
function preventDragDefaults(event) {
event.preventDefault();
event.stopPropagation();
}
async function readJsonResponse(response) {
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json")) {
throw new Error(
`Сервер вернул неожиданный ответ HTTP ${response.status}.`
);
}
return response.json();
}
async function runJob(kind) {
if (isBusy) {
return;
}
if (!currentVersionId) {
setJobStatus("error", "Сначала выберите фотографию.");
return;
}
const prompt = promptInput.value.trim();
if (kind === "edit" && !prompt) {
setJobStatus(
"error",
"Введите промпт — опишите, как нужно изменить фотографию."
);
promptInput.focus();
return;
}
const isEdit = kind === "edit";
const endpoint = isEdit ? "/api/edit" : "/api/upscale";
const form = new FormData();
form.append("sourceId", currentVersionId);
if (isEdit) {
form.append("prompt", prompt);
}
resetResult();
setBusy(true);
setJobStatus(
"loading",
isEdit
? "Codex редактирует фотографию… Обычно 13 минуты."
: "Увеличиваем разрешение фотографии…"
);
try {
const response = await fetch(endpoint, {
method: "POST",
body: form,
cache: "no-store"
});
const data = await readJsonResponse(response);
if (!response.ok || !data.ok) {
throw new Error(data.error || "Не удалось обработать фотографию.");
}
const version = data.version;
if (version) {
historyVersions.push(version);
currentVersionId = version.id;
renderHistory();
}
const url = version?.url || data.result?.url;
resultImage.src = url;
resultImage.hidden = false;
resultPlaceholder.hidden = true;
resultLabel.textContent = isEdit ? "Редактирование · Codex" : "4× upscale";
downloadLink.href = url;
downloadLink.download =
data.result?.filename ||
(isEdit ? "edited-photo.jpg" : "upscaled-photo.png");
downloadLink.hidden = false;
const summary = isEdit && data.summary
? String(data.summary).replace(/\s+/g, " ").trim().slice(0, 180)
: "";
setJobStatus(
"success",
isEdit
? summary
? `Готово — фотография отредактирована. Codex: ${summary}`
: "Готово — фотография отредактирована."
: `Готово — разрешение увеличено с помощью ${data.job?.model || "upscale-модели"}.`
);
if (window.matchMedia("(max-width: 1040px)").matches) {
resultImage.scrollIntoView({
behavior: "smooth",
block: "center"
});
}
} catch (error) {
setJobStatus(
"error",
error instanceof Error
? error.message
: "Не удалось обработать фотографию."
);
} finally {
setBusy(false);
}
}
function formatHistoryDate(iso) {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
} catch {
return "";
}
}
function historyItemBadge(version) {
if (version.kind === "original") {
return "загрузка";
}
if (version.kind === "upscale") {
return "4× upscale";
}
return "Codex";
}
function renderHistory() {
const list = document.querySelector("#history-list");
const empty = document.querySelector("#history-empty");
list.querySelectorAll(".history-item").forEach((item) => item.remove());
empty.hidden = historyVersions.length > 0;
for (const version of historyVersions) {
const item = document.createElement("button");
item.type = "button";
item.className = "history-item";
item.dataset.id = version.id;
item.setAttribute("aria-label", version.label || "Версия");
if (version.id === currentVersionId) {
item.classList.add("is-current");
}
const thumb = document.createElement("img");
thumb.src = version.url;
thumb.alt = "";
thumb.loading = "lazy";
const meta = document.createElement("span");
meta.className = "history-item-meta";
const title = document.createElement("strong");
title.textContent = version.label || "Версия";
const sub = document.createElement("span");
const size =
version.width && version.height
? ` · ${version.width}×${version.height}`
: "";
sub.textContent = `${formatHistoryDate(version.createdAt)}${size}`;
meta.append(title, sub);
const badge = document.createElement("span");
badge.className = "history-item-badge";
badge.textContent = historyItemBadge(version);
item.append(thumb, meta, badge);
item.addEventListener("click", () => selectHistoryVersion(version));
list.appendChild(item);
}
}
function selectHistoryVersion(version) {
if (isBusy) {
return;
}
currentVersionId = version.id;
resetResult();
showSourcePreviewFromUrl(
version.url,
version.label || "Версия",
version.width && version.height
? `${version.width}×${version.height}`
: "Готово к обработке"
);
renderHistory();
setJobStatus("", "");
}
async function loadHistoryOnStart() {
try {
const response = await fetch("/api/history", {
cache: "no-store"
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
historyVersions = Array.isArray(data.versions) ? data.versions : [];
if (historyVersions.length > 0) {
const latest = historyVersions[historyVersions.length - 1];
currentVersionId = latest.id;
showSourcePreviewFromUrl(
latest.url,
latest.label || "Текущая фотография",
latest.width && latest.height
? `${latest.width}×${latest.height}`
: "Готово к обработке"
);
}
} catch {
historyVersions = [];
} finally {
renderHistory();
setBusy(false);
}
}
async function checkHealth() {
setHealthState("loading", "Проверяем сервер обработки…");
try {
const response = await fetch("/api/health", {
cache: "no-store"
});
const data = await readJsonResponse(response);
if (data.ok) {
const modelTitle = data.selectedUpscaleModel
? `Upscale: ${data.selectedUpscaleModel}`
: "";
setHealthState(
"ok",
"Сервер обработки: доступен",
modelTitle
);
return;
}
if (data.comfyAvailable) {
const missing = Array.isArray(data.missingNodes)
? data.missingNodes.join(", ")
: "";
setHealthState(
"warning",
data.message || "Сервер настроен не полностью",
missing ? `Отсутствуют узлы: ${missing}` : ""
);
return;
}
setHealthState(
"error",
"Сервер обработки: недоступен",
data.message || ""
);
} catch (error) {
setHealthState(
"error",
"Сервер обработки: недоступен",
error instanceof Error ? error.message : ""
);
}
}
dropZone.addEventListener("click", openFilePicker);
dropZone.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openFilePicker();
}
});
photoInput.addEventListener("change", () => {
selectFile(photoInput.files?.[0]);
photoInput.value = "";
});
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, preventDragDefaults);
});
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, () => {
if (!isBusy) {
dropZone.classList.add("is-dragging");
}
});
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove("is-dragging");
});
});
dropZone.addEventListener("drop", (event) => {
if (!isBusy) {
selectFile(event.dataTransfer?.files?.[0]);
}
});
promptInput.addEventListener("input", () => {
promptCounter.textContent = `${promptInput.value.length} / 1000`;
});
editButton.addEventListener("click", () => runJob("edit"));
upscaleButton.addEventListener("click", () => runJob("upscale"));
window.addEventListener("beforeunload", () => {
if (sourceObjectUrl) {
URL.revokeObjectURL(sourceObjectUrl);
}
});
checkHealth();
loadHistoryOnStart();

View File

@@ -15,7 +15,7 @@
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%23202321'/%3E%3Ctext x='32' y='45' font-size='38' font-weight='bold' text-anchor='middle' fill='%23ffffff' font-family='sans-serif'%3E%D0%9A%3C/text%3E%3C/svg%3E"
> >
<link rel="stylesheet" href="/style.css?v=7"> <link rel="stylesheet" href="/style.css?v=14">
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@@ -24,14 +24,19 @@
<span class="brand-name">Кадр</span> <span class="brand-name">Кадр</span>
</a> </a>
<div <div class="topbar-right">
id="health-status" <div
class="health-status health-loading" id="health-status"
role="status" class="health-status health-loading"
aria-live="polite" role="status"
> aria-live="polite"
<span class="health-dot" aria-hidden="true"></span> >
<span id="health-text">Проверяем сервер обработки…</span> <span class="health-dot" aria-hidden="true"></span>
<span id="health-text">Проверяем сервер обработки…</span>
</div>
<button id="logout-button" class="logout-button" type="button" hidden>
Выйти
</button>
</div> </div>
</header> </header>
@@ -61,6 +66,7 @@
id="photo-input" id="photo-input"
type="file" type="file"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif" accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
multiple
hidden hidden
> >
@@ -77,6 +83,26 @@
</div> </div>
</div> </div>
<div class="field-group">
<span class="field-label">Референс <span class="field-label-hint">(необязательно)</span></span>
<div class="reference-actions">
<button id="reference-upload-button" class="button button-secondary button-small" type="button">Загрузить файл</button>
<select id="reference-history-select" class="reference-select" aria-label="Выбрать референс из истории">
<option value="">— из истории —</option>
</select>
<button id="reference-clear-button" class="button button-secondary button-small" type="button">Очистить</button>
</div>
<input id="reference-file-input" type="file" accept="image/jpeg,image/png,image/webp" hidden>
<div id="reference-preview" class="reference-preview" hidden>
<canvas id="reference-canvas" class="reference-canvas"></canvas>
<div class="reference-preview-foot">
<span id="reference-region-info">Область не выбрана — потяните по превью</span>
<button id="reference-region-reset" class="button button-secondary button-small" type="button">Сбросить область</button>
</div>
</div>
<p id="reference-note" class="reference-note" hidden></p>
</div>
<div class="field-group"> <div class="field-group">
<label class="field-label" for="prompt">Промпт</label> <label class="field-label" for="prompt">Промпт</label>
<textarea <textarea
@@ -91,6 +117,18 @@
</div> </div>
</div> </div>
<button id="suggest-button" class="button button-secondary button-suggest" type="button" disabled>💡 Предложить промпт</button>
<div id="suggest-panel" class="suggest-panel" hidden>
<p class="suggest-section-title">Из истории</p>
<div id="suggest-history-chips" class="suggest-chips"></div>
<div class="suggest-actions">
<button id="suggest-codex-button" class="button button-secondary button-small" type="button" disabled>Спросить Codex</button>
<span id="suggest-codex-status" class="suggest-status" role="status" aria-live="polite"></span>
</div>
<p class="suggest-section-title">Варианты от Codex</p>
<div id="suggest-codex-chips" class="suggest-chips"></div>
</div>
<div class="actions"> <div class="actions">
<button <button
id="edit-button" id="edit-button"
@@ -129,7 +167,19 @@
<p class="workspace-kicker">Предпросмотр</p> <p class="workspace-kicker">Предпросмотр</p>
<h2 id="workspace-title">До и после</h2> <h2 id="workspace-title">До и после</h2>
</div> </div>
<span class="local-badge">LAN · локально</span> <div class="workspace-head-actions">
<a
id="download-current"
class="download-current"
href="#"
download
hidden
>
Скачать
<span aria-hidden="true"></span>
</a>
<span class="local-badge">LAN · локально</span>
</div>
</div> </div>
<div class="comparison"> <div class="comparison">
@@ -218,12 +268,32 @@
</section> </section>
</main> </main>
<div id="auth-overlay" class="auth-overlay" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<form id="login-form" class="auth-card" novalidate>
<p class="auth-eyebrow">Закрытый доступ</p>
<h1 id="auth-title">Вход в редактор</h1>
<p class="auth-lead">Редактор доступен только владельцу. Войдите, чтобы продолжить.</p>
<div class="auth-field">
<label class="field-label" for="login-username">Логин</label>
<input id="login-username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="auth-field">
<label class="field-label" for="login-password">Пароль</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
</div>
<p id="login-error" class="auth-error" hidden></p>
<button id="login-submit" class="button button-primary auth-submit" type="submit">
<span>Войти</span>
</button>
</form>
</div>
<noscript> <noscript>
<div class="noscript"> <div class="noscript">
Для работы редактора необходимо включить JavaScript. Для работы редактора необходимо включить JavaScript.
</div> </div>
</noscript> </noscript>
<script src="/app.js?v=7" defer></script> <script type="module" src="/js/main.js?v=13"></script>
</body> </body>
</html> </html>

40
public/js/api.js Normal file
View File

@@ -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;
}

111
public/js/auth.js Normal file
View File

@@ -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;
}

49
public/js/health.js Normal file
View File

@@ -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);
}

357
public/js/history-tree.js Normal file
View File

@@ -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 || "Не удалось удалить версию.");
}
}

301
public/js/jobs.js Normal file
View File

@@ -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 редактирует фотографию… Это обычно занимает 13 минуты."
: "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 редактирует фотографию… Это обычно занимает 13 минуты."
: "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 редактирует фотографию… Это обычно занимает 13 минуты."
: "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.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);
}

77
public/js/main.js Normal file
View File

@@ -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);

322
public/js/reference.js Normal file
View File

@@ -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 = '<option value="">— из истории —</option>';
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;
});
}
}

74
public/js/state.js Normal file
View File

@@ -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");
}

187
public/js/suggest.js Normal file
View File

@@ -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 = '<span class="suggest-empty">В истории пока нет промптов</span>';
return;
}
renderSuggestChips(container, prompts);
} catch {
container.innerHTML = '<span class="suggest-empty">Не удалось загрузить подсказки</span>';
}
}
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);
}
}

138
public/js/ui.js Normal file
View File

@@ -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;
}

123
public/js/upload.js Normal file
View File

@@ -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);
}
});
}
}

View File

@@ -186,6 +186,7 @@ h1 {
.control-stack { .control-stack {
display: grid; display: grid;
min-width: 0;
gap: 23px; gap: 23px;
} }
@@ -445,6 +446,41 @@ textarea:disabled {
text-transform: uppercase; text-transform: uppercase;
} }
.workspace-head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.download-current {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid var(--dark);
border-radius: 999px;
color: var(--dark);
background: transparent;
font-size: 12px;
font-weight: 740;
text-decoration: none;
transition:
transform 150ms ease,
background 150ms ease,
color 150ms ease;
}
.download-current:hover {
background: var(--dark);
color: white;
transform: translateY(-1px);
}
.download-current:focus-visible {
outline: 3px solid rgba(108, 92, 231, 0.28);
outline-offset: 2px;
}
.comparison { .comparison {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -841,6 +877,20 @@ textarea:disabled {
.local-badge { .local-badge {
padding: 7px 9px; padding: 7px 9px;
} }
.history-node {
grid-template-columns: minmax(0, 1fr);
overflow: hidden !important;
}
.history-node.is-root {
grid-template-columns: auto minmax(0, 1fr);
}
.history-node-actions {
grid-column: 1 / -1;
justify-self: end;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -854,7 +904,7 @@ textarea:disabled {
} }
} }
/* -- Èñòîðèÿ âåðñèé ------------------------------------------- */ /* -- История версий ------------------------------------------------- */
.history-section { .history-section {
margin-top: 30px; margin-top: 30px;
@@ -881,71 +931,202 @@ textarea:disabled {
} }
.history-list { .history-list {
display: flex; display: grid;
gap: 12px; gap: 14px;
overflow-x: auto; overflow: hidden auto;
padding: 4px 4px 10px; padding: 4px 4px 10px;
} }
.history-item { .history-group {
display: flex; padding: 10px;
flex-direction: column; border: 1px solid var(--line);
gap: 7px;
flex: 0 0 150px;
width: 150px;
padding: 9px;
border: 1.5px solid var(--line);
border-radius: var(--radius-small); border-radius: var(--radius-small);
background: var(--panel); background: var(--panel);
cursor: pointer;
text-align: left;
font: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
} }
.history-item:hover { .history-group-count {
border-color: var(--accent); display: block;
transform: translateY(-1px); margin: 7px 2px 2px;
color: var(--muted);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
} }
.history-item.is-current { .history-group-body {
padding-left: 18px;
}
.history-node {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 6px;
padding: 5px 6px;
border: 1.5px solid transparent;
border-radius: var(--radius-small);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background 0.15s ease;
min-width: 0;
}
.history-node.is-root {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.history-node:hover {
background: rgba(108, 92, 231, 0.05);
}
.history-node.is-current {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 3px var(--accent-soft);
} }
.history-item img { .history-node-toggle {
width: 100%; flex: 0 0 auto;
height: 92px; width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: 7px;
color: var(--muted);
background: transparent;
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.history-node-toggle:hover {
color: var(--ink);
background: #ecebe5;
}
.history-node-main {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 2px;
border: 0;
border-radius: 8px;
color: inherit;
background: transparent;
cursor: pointer;
text-align: left;
font: inherit;
}
.history-node-main:hover {
background: rgba(108, 92, 231, 0.07);
}
.history-node-thumb {
width: 52px;
height: 52px;
object-fit: cover; object-fit: cover;
border-radius: 8px; border-radius: 8px;
background: #ecece6; background: #ecece6;
} }
.history-item-meta strong { .history-node-meta {
display: block; display: grid;
min-width: 0;
gap: 2px;
}
.history-node-title {
font-size: 12.5px; font-size: 12.5px;
line-height: 1.3; font-weight: 750;
color: var(--ink); color: var(--ink);
line-height: 1.3;
word-break: break-word; word-break: break-word;
} }
.history-item-meta span { .history-node-badge {
display: block; justify-self: start;
margin-top: 2px;
font-size: 11px;
color: var(--muted);
}
.history-item-badge {
align-self: flex-start;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
padding: 2px 7px; padding: 2px 7px;
border-radius: 999px; border-radius: 999px;
background: var(--accent-soft); background: var(--accent-soft);
color: var(--accent-dark); color: var(--accent-dark);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.history-node-date {
font-size: 11px;
color: var(--muted);
}
.history-node-prompt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 11.5px;
color: var(--muted);
line-height: 1.45;
}
.history-node-children {
grid-column: 1 / -1;
display: grid;
min-width: 0;
gap: 6px;
padding-left: 18px;
}
.history-node-actions {
display: flex;
align-items: center;
gap: 6px;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
.history-node:hover .history-node-actions,
.history-node:focus-within .history-node-actions {
opacity: 1;
visibility: visible;
}
.history-node-action {
padding: 5px 9px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: var(--panel);
cursor: pointer;
font-size: 10.5px;
font-weight: 700;
text-decoration: none;
transition:
border-color 0.15s ease,
color 0.15s ease,
background 0.15s ease;
}
.history-node-action:hover {
border-color: var(--accent);
color: var(--accent-dark);
background: var(--accent-soft);
}
.history-node-action.history-delete {
color: var(--danger);
}
.history-node-action.history-delete:hover {
border-color: var(--danger);
color: var(--danger);
background: rgba(184, 67, 67, 0.07);
} }
.history-empty { .history-empty {
@@ -953,3 +1134,290 @@ textarea:disabled {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
} }
/* -- Референс ---------------------------------------------------- */
.reference-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.button-small {
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
background: var(--panel);
cursor: pointer;
font-size: 12px;
font-weight: 720;
transition:
border-color 150ms ease,
background 150ms ease,
transform 150ms ease,
opacity 150ms ease;
}
.button-small:not(:disabled):hover {
border-color: var(--accent);
background: var(--accent-soft);
transform: translateY(-1px);
}
.button-small:disabled {
cursor: not-allowed;
opacity: 0.43;
}
.reference-select {
min-height: 36px;
min-width: 0;
max-width: 100%;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 12px;
color: var(--ink);
background: var(--panel);
font-size: 12px;
outline: none;
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
.reference-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.12);
}
.reference-select:disabled {
cursor: not-allowed;
opacity: 0.43;
}
.reference-preview {
margin-top: 10px;
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--paper);
}
.reference-canvas {
display: block;
width: 100%;
max-height: 260px;
object-fit: contain;
border-radius: 8px;
background: #ecece6;
cursor: crosshair;
touch-action: none;
}
.reference-preview-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 8px;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.reference-note {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
/* -- Подсказки промптов ------------------------------------------- */
.button-suggest {
width: 100%;
}
.suggest-panel {
margin-top: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: rgba(251, 250, 246, 0.7);
}
.suggest-section-title {
margin: 0 0 8px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.suggest-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.suggest-chip {
max-width: 100%;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink);
background: var(--panel);
cursor: pointer;
font-size: 11.5px;
line-height: 1.4;
text-align: left;
transition:
border-color 150ms ease,
background 150ms ease,
color 150ms ease;
}
.suggest-chip:hover {
border-color: var(--accent);
color: var(--accent-dark);
background: var(--accent-soft);
}
.suggest-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
}
.suggest-status {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
/* -- Авторизация -------------------------------------------------- */
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.logout-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid var(--dark);
border-radius: 999px;
color: var(--dark);
background: transparent;
font-size: 12px;
font-weight: 740;
text-decoration: none;
cursor: pointer;
transition:
transform 150ms ease,
background 150ms ease,
color 150ms ease;
}
.logout-button:hover {
background: var(--dark);
color: white;
transform: translateY(-1px);
}
.logout-button:focus-visible {
outline: 3px solid rgba(108, 92, 231, 0.28);
outline-offset: 2px;
}
.auth-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 16px;
background: rgba(243, 240, 232, 0.82);
backdrop-filter: blur(10px);
}
.auth-card {
width: min(400px, 100%);
padding: 34px 32px;
border: 1px solid rgba(23, 24, 23, 0.08);
border-radius: var(--radius-large);
background: var(--panel);
box-shadow: var(--shadow);
display: grid;
gap: 16px;
}
.auth-eyebrow {
margin: 0;
color: var(--accent-dark);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.auth-card h1 {
margin: 0;
font-size: clamp(26px, 4vw, 34px);
letter-spacing: -0.045em;
}
.auth-lead {
margin: 0;
color: var(--muted);
font-size: 13.5px;
line-height: 1.55;
}
.auth-field {
display: grid;
gap: 8px;
}
.auth-field input {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: var(--radius-medium);
background: var(--paper);
color: var(--ink);
outline: none;
transition:
border-color 160ms ease,
box-shadow 160ms ease;
}
.auth-field input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.1);
}
.auth-error {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-small);
color: var(--danger);
background: rgba(184, 67, 67, 0.07);
font-size: 13px;
}
.auth-submit {
width: 100%;
justify-content: center;
min-height: 52px;
}

1590
server.js

File diff suppressed because it is too large Load Diff

206
src/config.js Normal file
View File

@@ -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,
};

114
src/core/auth/password.js Normal file
View File

@@ -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<string>} Формат: "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<boolean>}
*/
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,
};

View File

@@ -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<string, { user: string, expiresAt: number, createdAt: number }>} */
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,
};

265
src/core/codex/cli.js Normal file
View File

@@ -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,
};

156
src/core/codex/prompts.js Normal file
View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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<EngineResult> }} 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,
};

View File

@@ -0,0 +1,225 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const { getImageDimensions } = require("../images/dimensions");
const { isHeic, convertHeicToJpeg } = require("../images/heic");
const { sniffImageType } = require("../images/sniff");
const { AppError, NotFoundError } = require("../../errors");
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 extForMimetype(mimetype) {
const map = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "jpg",
"image/heif": "jpg",
};
return map[String(mimetype || "").toLowerCase()] || "jpg";
}
class HistoryService {
/**
* @param {Object} options
* @param {import('./history-store').HistoryStore} options.store
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ store, logger = null }) {
this.store = store;
this.logger = logger;
}
getHistory() {
return this.store.load();
}
getVersion(id) {
return this.store.get(id);
}
async getVersionFile(id) {
const version = this.store.get(id);
if (!version) {
throw new NotFoundError("Версия не найдена в истории.");
}
const filePath = this.store.filePath(version);
if (!fs.existsSync(filePath)) {
throw new NotFoundError("Файл изображения не найден на диске.");
}
const buffer = fs.readFileSync(filePath);
const contentType = contentTypeForExt(version.ext);
return { version, filePath, buffer, contentType };
}
async createVersion({
kind,
label,
prompt = null,
parentId = null,
referenceId = null,
buffer,
ext = "jpg",
width = null,
height = null,
}) {
if (!Buffer.isBuffer(buffer)) {
throw new AppError(400, "Буфер изображения обязателен для создания версии.");
}
let finalWidth = width;
let finalHeight = height;
if (!finalWidth || !finalHeight) {
const dims = getImageDimensions(buffer);
if (dims) {
finalWidth = dims.width;
finalHeight = dims.height;
}
}
const id = crypto.randomUUID();
const fileExt = ext ? ext.toLowerCase().replace(/^\./, "") : "jpg";
const version = {
id,
kind: kind || "original",
label: String(label || "Версия").trim(),
prompt: prompt ? String(prompt).trim() : null,
parentId: parentId || null,
referenceId: referenceId || null,
createdAt: new Date().toISOString(),
url: `/api/history/${id}/image.${fileExt}`,
width: finalWidth || null,
height: finalHeight || null,
ext: fileExt,
};
await this.store.append(version, buffer);
if (this.logger) {
this.logger.info("Создана версия истории", {
versionId: version.id,
kind: version.kind,
ext: version.ext,
width: version.width,
height: version.height,
});
}
return version;
}
async importPhoto({ buffer, mimetype, originalname }) {
if (!Buffer.isBuffer(buffer)) {
throw new AppError(400, "Файл не передан или повреждён.");
}
let processedBuffer = buffer;
let ext = extForMimetype(mimetype);
// Handle HEIC
const isHeicFile = isHeic({ mimetype, originalname }) || sniffImageType(buffer) === "heic";
if (isHeicFile) {
processedBuffer = await convertHeicToJpeg(buffer);
ext = "jpg";
} else {
const sniffed = sniffImageType(buffer);
if (sniffed) {
ext = sniffed === "jpeg" ? "jpg" : sniffed;
}
}
const dims = getImageDimensions(processedBuffer);
const label = originalname ? path.basename(originalname, path.extname(originalname)) : "Оригинал";
return this.createVersion({
kind: "original",
label: label || "Оригинал",
prompt: null,
parentId: null,
referenceId: null,
buffer: processedBuffer,
ext,
width: dims?.width || null,
height: dims?.height || null,
});
}
async removeCascade(id) {
return this.store.removeCascade(id);
}
}
/**
* Миграция истории из старого каталога ./history в data/history при первом старте v2.
* @param {string} legacyDir - e.g. './history'
* @param {string} targetDir - e.g. './data/history'
* @param {import('../../logger').Logger} [logger]
*/
function migrateLegacyHistory(legacyDir, targetDir, logger = null) {
const legacyManifest = path.join(legacyDir, "history.json");
const targetManifest = path.join(targetDir, "history.json");
// If source does not exist, nothing to migrate
if (!fs.existsSync(legacyManifest)) {
return;
}
// If target already has manifest, migration was already done
if (fs.existsSync(targetManifest)) {
return;
}
try {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const files = fs.readdirSync(legacyDir);
let copied = 0;
for (const file of files) {
const srcFile = path.join(legacyDir, file);
const dstFile = path.join(targetDir, file);
const stat = fs.statSync(srcFile);
if (stat.isFile()) {
fs.copyFileSync(srcFile, dstFile);
copied++;
}
}
if (logger) {
logger.info("Миграция истории из ./history в data/history завершена", {
filesCount: copied,
from: legacyDir,
to: targetDir,
});
}
} catch (err) {
if (logger) {
logger.error("Ошибка при миграции истории версий", { error: err.message });
}
}
}
module.exports = {
HistoryService,
migrateLegacyHistory,
contentTypeForExt,
extForMimetype,
};

View File

@@ -0,0 +1,163 @@
"use strict";
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
class HistoryStore {
/**
* @param {Object} options
* @param {string} options.dir - Путь к каталогу с историей (data/history)
* @param {number} [options.limit=50] - Максимальное число хранимых версий
* @param {import('../../logger').Logger} [options.logger]
*/
constructor({ dir, limit = 50, logger = null }) {
this.dir = dir;
this.limit = limit;
this.logger = logger;
this.manifestPath = path.join(this.dir, "history.json");
this._writeChain = Promise.resolve();
// Ensure dir exists
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
}
}
load() {
try {
if (!fs.existsSync(this.manifestPath)) {
return [];
}
const raw = fs.readFileSync(this.manifestPath, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (err) {
if (this.logger) {
this.logger.warn("Не удалось прочитать history.json, возврат пустого списка", {
error: err.message,
});
}
return [];
}
}
_saveManifestAtomic(versions) {
if (!fs.existsSync(this.dir)) {
fs.mkdirSync(this.dir, { recursive: true });
}
const tmpFile = path.join(
this.dir,
`history.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
);
const fd = fs.openSync(tmpFile, "w");
try {
fs.writeFileSync(fd, JSON.stringify(versions, null, 2), "utf8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmpFile, this.manifestPath);
}
filePath(version) {
const ext = version.ext || "jpg";
return path.join(this.dir, `${version.id}.${ext}`);
}
get(id) {
if (!id) return null;
const list = this.load();
return list.find((v) => v.id === id) || null;
}
async append(version, buffer = null) {
return new Promise((resolve, reject) => {
this._writeChain = this._writeChain
.then(() => {
if (buffer) {
const fPath = this.filePath(version);
fs.writeFileSync(fPath, buffer);
}
const history = this.load();
history.push(version);
const trimmed = history.slice(-this.limit);
const keptIds = new Set(trimmed.map((entry) => entry.id));
for (const old of history) {
if (!keptIds.has(old.id)) {
try {
fs.rmSync(this.filePath(old), { force: true });
} catch {
// File might not exist
}
}
}
this._saveManifestAtomic(trimmed);
resolve(version);
})
.catch(reject);
});
}
async removeCascade(id) {
return new Promise((resolve, reject) => {
this._writeChain = this._writeChain
.then(() => {
const history = this.load();
const target = history.find((entry) => entry.id === id);
if (!target) {
resolve({ removed: [], remaining: history });
return;
}
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;
}
}
}
const removed = history.filter((entry) => toDelete.has(entry.id));
const remaining = history.filter((entry) => !toDelete.has(entry.id));
this._saveManifestAtomic(remaining);
for (const entry of removed) {
try {
fs.rmSync(this.filePath(entry), { force: true });
} catch {
// Ignore
}
}
if (this.logger) {
this.logger.info("Удаление версии из истории", {
versionId: id,
removedCount: removed.length,
});
}
resolve({ removed, remaining });
})
.catch(reject);
});
}
}
module.exports = {
HistoryStore,
};

View File

@@ -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,
};

42
src/core/images/heic.js Normal file
View File

@@ -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,
};

83
src/core/images/resize.js Normal file
View File

@@ -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,
};

71
src/core/images/sniff.js Normal file
View File

@@ -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,
};

View File

@@ -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,
};

289
src/core/jobs/job-store.js Normal file
View File

@@ -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<string, Object>} */
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,
};

367
src/core/jobs/queue.js Normal file
View File

@@ -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<string, string[]>} group -> array of jobIds in FIFO queue */
this.queues = new Map([
["codex", []],
["comfy-upscale", []],
]);
/** @type {Map<string, Set<string>>} 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,
};

63
src/errors.js Normal file
View File

@@ -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,
};

146
src/http/app.js Normal file
View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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<string, number[]>} 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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -0,0 +1,67 @@
"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.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.json({ ok: true, jobs: jobService.listJobs() });
} catch (err) {
next(err);
}
});
router.get("/jobs/:id/status", (req, res, next) => {
try {
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

119
src/logger.js Normal file
View File

@@ -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,
};

222
src/server.js Normal file
View File

@@ -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,
};

324
test/api.test.js Normal file
View File

@@ -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);
});
});

82
test/config.test.js Normal file
View File

@@ -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");
});
});

130
test/history-store.test.js Normal file
View File

@@ -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")));
});
});

105
test/images.test.js Normal file
View File

@@ -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);
});
});

161
test/job-queue.test.js Normal file
View File

@@ -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");
});
});

61
test/logger.test.js Normal file
View File

@@ -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…");
});
});

72
test/prompts.test.js Normal file
View File

@@ -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);
});
});