Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4ea8df840 | |||
| 6f611dc697 | |||
| 6d5b8d5f05 | |||
| 401969cfdb | |||
| 1c36a21e0c | |||
| f91aa94336 | |||
| 27a2ac304c | |||
| d9eccfb4b7 | |||
| 5dcc4ce1ad | |||
| 1dec7e5178 | |||
| 839ed50ef4 | |||
| 4bf31a310e | |||
| cfa919a435 | |||
| 5b0f8a8f73 | |||
| 29037e1720 | |||
| f80ed97e98 | |||
| e59b3645f6 | |||
| ee952df2a5 | |||
| bedd2e0fef | |||
| 1e1fdb7425 | |||
| c1998b9397 | |||
| 5a920c7fc6 |
@@ -18,6 +18,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Сброс docker-конфига раннера: битый auth для docker.io
|
||||
# ломает даже анонимные pull базового образа.
|
||||
- name: Clean docker config
|
||||
run: rm -f "$HOME/.docker/config.json"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -25,27 +30,28 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Логин во встроенный registry Gitea.
|
||||
# GITHUB_TOKEN выдаётся автоматически; если понадобится PAT —
|
||||
# создайте секрет GITEA_TOKEN и замените password на него.
|
||||
- name: Login to Gitea container registry
|
||||
# Пароль — существующий секрет GIT_PASSWORD (PAT для аккаунта с 2FA).
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.byte-mate.ru
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
password: ${{ secrets.GIT_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
# Путь в registry обязателен в нижнем регистре
|
||||
# (github.repository = Coder/photo-editor не подойдёт)
|
||||
tags: |
|
||||
git.byte-mate.ru/${{ github.repository }}:latest
|
||||
git.byte-mate.ru/${{ github.repository }}:${{ github.ref_name }}
|
||||
git.byte-mate.ru/coder/photo-editor:latest
|
||||
git.byte-mate.ru/coder/photo-editor:${{ github.ref_name }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "## Образ собран и опубликован" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Теги: \`git.byte-mate.ru/${{ github.repository }}:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Теги: \`git.byte-mate.ru/coder/photo-editor:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
77
.gitea/workflows/deploy.yml
Normal file
77
.gitea/workflows/deploy.yml
Normal 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
30
.gitea/workflows/test.yml
Normal 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
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,14 +1,20 @@
|
||||
# Зависимости
|
||||
node_modules/
|
||||
.npm-cache/
|
||||
.npmrc
|
||||
|
||||
# Данные приложения (история версий, временные рабочие каталоги Codex)
|
||||
history/
|
||||
# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
|
||||
# Паттерны привязаны к корню репозитория: иначе "history/" игнорирует
|
||||
# и служебный модуль src/core/history/ (сборка/CI на Linux падали бы).
|
||||
/history/
|
||||
/data/
|
||||
.codex-jobs/
|
||||
|
||||
# Временные и тестовые файлы
|
||||
_*
|
||||
*.tmp
|
||||
*.log
|
||||
.bench/
|
||||
|
||||
# ОС / редакторы
|
||||
.DS_Store
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,12 +1,12 @@
|
||||
# Кадр — локальный AI-фоторедактор
|
||||
# Кадр — локальный AI-фоторедактор (v2)
|
||||
# Образ: Node.js 22 на Debian bookworm-slim.
|
||||
# Debian (glibc) обязателен: Codex CLI поставляется как бинарник под glibc,
|
||||
# на Alpine (musl) он не запускается.
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
# Утилиты для healthcheck
|
||||
# Утилиты для healthcheck и песочницы Codex (bubblewrap)
|
||||
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/*
|
||||
|
||||
# Codex CLI — движок редактирования фотографий по текстовому промпту.
|
||||
@@ -22,6 +22,7 @@ RUN npm ci --omit=dev
|
||||
|
||||
# Код приложения
|
||||
COPY server.js ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
|
||||
# Конфиг Codex по умолчанию (используется, если ~/.codex не смонтирован)
|
||||
@@ -30,9 +31,8 @@ COPY codex/config.toml /root/.codex/config.toml
|
||||
ENV NODE_ENV=production
|
||||
ENV COMFY_URL=http://192.168.31.240:8188
|
||||
|
||||
# История версий должна жить на томе — иначе версии пропадут
|
||||
# при пересоздании контейнера.
|
||||
VOLUME ["/app/history"]
|
||||
# Хранилище данных и история версий на томах
|
||||
VOLUME ["/app/history", "/app/data"]
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
190
README.md
190
README.md
@@ -1,4 +1,4 @@
|
||||
# Кадр — локальный AI-фоторедактор
|
||||
# Кадр — локальный AI-фоторедактор (v2)
|
||||
|
||||
Веб-приложение редактирует фотографии по текстовому промпту (через локальный
|
||||
Codex CLI) и увеличивает разрешение через домашний сервер ComfyUI.
|
||||
@@ -7,100 +7,130 @@ Codex CLI) и увеличивает разрешение через домаш
|
||||
преобразуются на сервере в JPEG с помощью `heic-convert` до передачи в ComfyUI
|
||||
или Codex.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура v2
|
||||
|
||||
В версии v2 сервис переработан в **модульный монолит** (`src/`):
|
||||
|
||||
1. **Устойчивость к перезапуску**:
|
||||
- Персистентный append-only JSONL-журнал заданий (`data/jobs.jsonl`). При рестарте сервера незавершённые задания Codex получают статус `interrupted` с возможностью повтора в один клик, а задания ComfyUI автоматически возвращаются в очередь.
|
||||
- Персистентное хранилище сессий (`data/sessions.json`, хранятся sha256-хеши токенов). Владелец остаётся авторизован после обновления или перезапуска контейнера.
|
||||
- Атомарная запись манифеста истории (`history.json.tmp` + `fsync` + `rename`) защищает от повреждения данных при внезапном выключении.
|
||||
2. **Очередь заданий (FIFO) и группы конкурентности**:
|
||||
- Вместо блокировки single-flight (429) запросы на редактирование и апскейл ставятся в очередь.
|
||||
- Лимит параллельности: Codex (edit, suggest) = 1 поток; ComfyUI (upscale) = 2 потока.
|
||||
- Лимит очереди: до 5 заданий на группу (защита от накликивания).
|
||||
3. **Безопасность**:
|
||||
- Пароль защищён scrypt-хешированием (`crypto.scrypt`) с постоянным временем сверки (`timingSafeEqual`).
|
||||
- Ограничение попыток входа (Rate Limiting): 5 попыток за 15 минут на IP.
|
||||
- Проверка Origin на state-changing запросах (CSRF).
|
||||
- Проверка сигнатур файлов (magic bytes) для защиты от поддельных MIME-типов.
|
||||
4. **Ускорение редактирования (Downscale)**:
|
||||
- Автоматический даунскейл `sharp` до `EDIT_MAX_DIMENSION` (по умолчанию 1024 px) перед передачей в Codex с пропорциональным масштабированием области референса (`scaleRegion`). Сокращает время обработки больших фото в 1.5–2 раза.
|
||||
5. **Мгновенные обновления статуса (SSE)**:
|
||||
- Server-Sent Events (`GET /api/events`) передают смену статуса очереди и прогресс мгновенно.
|
||||
- Автоматический фолбэк на опрос (`/api/jobs`) при сбоях сети или неподдерживаемых прокси.
|
||||
6. **Клиентская часть на Vanilla ES-модулях**:
|
||||
- Модули в `public/js/` загружаются напрямую браузером (`<script type="module">`) без этапа сборки.
|
||||
|
||||
---
|
||||
|
||||
## Как это работает
|
||||
|
||||
- **История версий**: каждая загрузка и каждый результат (редактирование или
|
||||
апскейл) сохраняется в папке `history/` как отдельная версия. Можно вернуться
|
||||
к любой версии кликом по ней в ленте «История версий» и продолжить правки
|
||||
именно с неё — например, отредактировать фото, а затем увеличить разрешение
|
||||
уже отредактированного варианта. История переживает перезапуск сервера и
|
||||
обновление страницы; хранится последние 50 версий.
|
||||
- **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex
|
||||
CLI (`codex exec`). Агент редактирует изображение и сохраняет результат.
|
||||
Обработка обычно занимает 1–3 минуты.
|
||||
- **Увеличение разрешения**: 4× upscale через ComfyUI моделью
|
||||
`RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе
|
||||
отредактированной) версии.
|
||||
- **История версий**: каждая загрузка и результат (редактирование или апскейл) сохраняются в `data/history/` как отдельная версия. Доступно интерактивное дерево версий с ветвлением от исходника, каскадным удалением и свёрткой групп. Хранятся последние 50 версий (настраивается через `HISTORY_LIMIT`).
|
||||
- **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex CLI (`codex exec`). Агент редактирует изображение и сохраняет результат в JPEG.
|
||||
- **Увеличение разрешения**: 4× upscale через ComfyUI моделью `RealESRGAN_x4plus.safetensors` (или `4x_UltraSharp`).
|
||||
- **Редактирование с референсом и областью**: можно задать референс — файл или версию из истории. На превью референса выделяется прямоугольник области; координаты передаются в промпт Codex.
|
||||
- **Подсказки промптов**: мгновенные варианты из недавней истории и генерация новых вариантов через Codex.
|
||||
|
||||
## Требования
|
||||
---
|
||||
|
||||
- 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
|
||||
# Установка зависимостей
|
||||
npm install
|
||||
|
||||
# Запуск тестов
|
||||
npm test
|
||||
|
||||
# Запуск приложения
|
||||
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
|
||||
# сборка и запуск
|
||||
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`
|
||||
(auth + config + навыки). Альтернатива — переменная окружения
|
||||
`OPENAI_API_KEY`.
|
||||
- **История версий**: каталог `history/` смонтирован томом, версии переживают
|
||||
пересоздание контейнера.
|
||||
- **ComfyUI**: задаётся через `COMFY_URL` (по умолчанию
|
||||
`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`). Сейчас раннеров нет — без него
|
||||
задания будут висеть в очереди. Регистрация на машине с Docker:
|
||||
|
||||
```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.
|
||||
1. Workflow `.gitea/workflows/test.yml` автоматически запускает автотесты при push в `main`.
|
||||
2. Workflow `.gitea/workflows/build.yml` собирает образ на базе `node:22-bookworm-slim` и пушит в Gitea Registry.
|
||||
3. Workflow `.gitea/workflows/deploy.yml` обновляет стек в Portainer с томами:
|
||||
- `kadr-data:/app/data` — данные v2 (сессии, журнал, история);
|
||||
- `kadr-history:/app/history` — старый том истории (автоматически мигрируется при первом старте v2);
|
||||
- `/root/.codex:/root/.codex` — конфигурация и авторизация Codex.
|
||||
|
||||
41
deploy/docker-compose.yml
Normal file
41
deploy/docker-compose.yml
Normal 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:
|
||||
@@ -5,15 +5,12 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# История версий на хосте — переживает пересоздание контейнера
|
||||
# Хранилище данных v2 (сессии, задания, история)
|
||||
- ./data:/app/data
|
||||
- ./history:/app/history
|
||||
# Аутентификация и конфиг Codex из домашней папки пользователя
|
||||
# (auth.json, config.toml, навыки). Если не монтировать — задайте
|
||||
# OPENAI_API_KEY в environment.
|
||||
- ~/.codex:/root/.codex
|
||||
environment:
|
||||
# Адрес ComfyUI (доступен из контейнера как исходящий адрес LAN)
|
||||
- COMFY_URL=http://192.168.31.240:8188
|
||||
# Альтернатива монтированию ~/.codex (раскомментируйте при необходимости):
|
||||
# - OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
restart: unless-stopped
|
||||
|
||||
97
docs/architecture/system-design-baseline.md
Normal file
97
docs/architecture/system-design-baseline.md
Normal 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. Владелец загружает фото → видит превью → редактирует промптом (1–3 мин, фон) → результат в истории.
|
||||
2. Владелец увеличивает разрешение версии (несколько секунд) → результат в истории.
|
||||
3. Владелец возвращается к любой версии истории (дерево), продолжает правки с неё.
|
||||
4. Владелец удаляет версию каскадно (с потомками).
|
||||
5. Владелец получает подсказки промптов (из истории / от Codex).
|
||||
|
||||
Горизонт решений: **один пользователь, LAN, один хост, один контейнер**, горизонт планирования 6–12 месяцев. Выход за эти рамки — триггер пересмотра (см. реестр).
|
||||
|
||||
---
|
||||
|
||||
## 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 | Обработка 60–82 с на 512×512, 1–3 мин на больших; узкое место — вызов 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 МБ исходник; реальные файлы 1–6 МБ | `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), редактирование ~15–52k токенов/операция | `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`, код не трогался.
|
||||
676
docs/architecture/target-design.md
Normal file
676
docs/architecture/target-design.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# Системный дизайн: «Кадр» v2 — целевая архитектура
|
||||
|
||||
**Вердикт:** READY
|
||||
**Артефакт:** `docs/architecture/target-design.md`
|
||||
**Статус:** 1.0, готов к планированию реализации
|
||||
**Язык:** русский; идентификаторы кода — английские.
|
||||
|
||||
Этот документ — полный проект перепроектирования «Кадр» (локальный AI-фоторедактор). Он написан так, чтобы **человек** мог понять решение, а **ИИ-агент** — реализовать его без дополнительных уточнений: здесь зафиксированы границы, контракты, структура модулей, данные, поведение при сбоях, решения с альтернативами, порядок реализации и критерии приёмки каждой фазы.
|
||||
|
||||
Сопутствующие артефакты: `docs/architecture/system-design-baseline.md` (драйверы и ограничения, реестр D1–D17), `README.md` (текущая документация).
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и цель перепроектирования
|
||||
|
||||
«Кадр» — веб-приложение одного владельца в LAN: редактирование фотографий текстовым промптом (Codex CLI) и 4× апскейл (ComfyUI), с деревом версий, референсом и областью, подсказками промптов. Развёрнуто одним контейнером Docker на Portainer.
|
||||
|
||||
Текущая реализация — рабочий прототип: один файл `server.js` на ~2290 строк и `public/app.js` на ~1523 строки. Она выполняет все функции, но имеет структурные дефекты (см. §2), которые делают её хрупкой, трудной для доработки и для безопасного изменения ИИ-агентом.
|
||||
|
||||
**Цель перепроектирования:** превратить прототип в **модульный монолит** с чёткими границами, устойчивый к перезапуску, безопасный, тестируемый, с теми же внешними зависимостями (Codex CLI, ComfyUI) и той же моделью развёртывания. Никаких новых сервисов, микросервисов и внешних хранилищ.
|
||||
|
||||
**Не-цели:** многопользовательность, интернет-доступ, смена стека фронтенда на сборку (React/Vue + bundler), SQLite, генеративные функции «с нуля». Обоснование — в §8 «Решения».
|
||||
|
||||
---
|
||||
|
||||
## 2. Текущее состояние: что ломается и что сохраняем
|
||||
|
||||
Оценка основана на коде (`server.js`, `public/app.js`, `history/history.json`, `Dockerfile`, `deploy/docker-compose.yml`, `.gitea/workflows/*.yml`).
|
||||
|
||||
### 2.1 Дефекты, которые устраняет v2
|
||||
|
||||
| # | Проблема | Где | Последствие | Решение в v2 |
|
||||
|---|---|---|---|---|
|
||||
| P1 | Монолит в одном файле: auth, история, задания, Codex, ComfyUI, парсеры изображений, роуты | `server.js` (2288 стр.) | Тяжело менять, тестировать, навигировать | Модульная структура `src/` (§4) |
|
||||
| P2 | Задания в памяти: рестарт убивает `running` и теряет `queued`; `.codex-jobs` стирается при старте | `server.js:141-153, 2259` | Пропадает редактирование на 1–3 мин при рестарте | Персистентный job-store + журнал (§6.2), восстановление при старте |
|
||||
| P3 | Глобальный single-flight `codexEditInFlight` → 429, а не очередь; suggest делит флаг с edit | `server.js:91, 1880, 2031` | Нельзя поставить в очередь две правки; UX «дождитесь завершения» | Очередь с персистентностью и кониурентностью по группам (§6.1) |
|
||||
| P4 | Сессии в памяти; пароль сравнивается с открытым env-значением; нет rate-limit на вход | `server.js:141, 155-159, 1641` | Разлогин при рестарте; открытый пароль в сравнении; перебор пароля | scrypt-хеш в памяти + persistent session store + rate-limit (§7) |
|
||||
| P5 | Валидация аплоада только по MIME/расширению | `server.js:244-267` | Поддельный MIME проходит | Проверка сигнатуры (magic bytes) (§7.3) |
|
||||
| P6 | `history.json` переписывается целиком; запись без tmp+rename (частичная запись при крахе) | `server.js:830-868` | Риск порчи манифеста при обрыве | Атомарная запись (tmp+rename+fsync) в history-store (§5.1) |
|
||||
| P7 | URL версии жёстко `/image.jpg` независимо от реального расширения | `server.js:895` | Ложный URL для PNG | URL формируется из фактического `ext` (§5.1) |
|
||||
| P8 | Фронтенд: один файл, глобальные переменные, опрос каждые 3 с | `public/app.js` (1523 стр.) | Трудно поддерживать; задержка обновлений до 3 с | ES-модули (§9) + SSE с фолбэком на опрос (§6.4) |
|
||||
| P9 | Нет тестов, нет CI-проверок; `package.json` только `start` | `package.json` | Регрессии незаметны | `node:test` + supertest + Gitea workflow `test` (§10) |
|
||||
| P10 | Магические константы и env разбросаны по файлу | `server.js:15-47` | Ошибки конфигурации | Единый `config.js` с валидацией (§4.3) |
|
||||
| P11 | Нет requestId/корреляции в логах; нет метрик | `server.js:50-79` | Трудно разбирать инциденты | requestId + структурированные логи + счётчики (§11) |
|
||||
| P12 | Нет предварительного даунскейла для Codex (замер: 1200×800 медленнее 512×512) | `README.md` разд. «Тест скорости» | Дорогие и медленные правки больших фото | Даунскейл `sharp` до `EDIT_MAX_DIMENSION` (§6.5) |
|
||||
|
||||
### 2.2 Что сохраняем без изменений (проверено, работает)
|
||||
|
||||
- Внешние зависимости: Codex CLI (`codex exec`, флаги `-i/-o/--ephemeral/--skip-git-repo-check/--dangerously-bypass-approvals-and-sandbox`), ComfyUI REST (`/object_info`, `/upload/image`, `/prompt`, `/history/{id}`, `/view`), модели `sdxl_turbo.safetensors`, `RealESRGAN_x4plus.safetensors`.
|
||||
- Модель развёртывания: Docker (`node:22-bookworm-slim`), Portainer stack, Gitea Actions build/deploy, прокси `:4067` для egress к OpenAI, тома.
|
||||
- HTTP-контракт публичных эндпоинтов `/api/health`, `/api/login`, `/api/me`, `/api/logout` и семантика основных бизнес-эндпоинтов (см. §7.1 — изменения только аддитивные).
|
||||
- Формат версии истории (поля `id, kind, label, prompt, parentId, referenceId, createdAt, url, width, height, ext`) — обратная совместимость с существующим `history/history.json` и файлами.
|
||||
- UI/UX: русский интерфейс, дерево истории, референс с областью, подсказки, «до/после».
|
||||
- Парсинг размеров PNG/JPEG/WebP из заголовков (без библиотек) — переносится в модуль `images/dimensions.js` как есть.
|
||||
|
||||
---
|
||||
|
||||
## 3. Оценка нагрузки (до выбора компонентов)
|
||||
|
||||
Расчёт для одного владельца в LAN. Никакие компоненты не выбираются «на вырост»; механизмы масштабирования перечислены как отложенные (§8, триггеры).
|
||||
|
||||
| Величина | Значение | Обоснование |
|
||||
|---|---|---|
|
||||
| Пользователей | 1 | D1 |
|
||||
| Правок в сутки | 0–20 (пик) | Оценка по `history.json` (12 версий за 2 дня активного тестирования) |
|
||||
| Апскейлов в сутки | 0–20 | Аналогично |
|
||||
| Параллельность | 1 edit/suggest + ≤ 2 upscale | Текущее узкое место — Codex (один поток); ComfyUI справляется с 1–2 воркфлоу |
|
||||
| Размер загрузки | ≤ 25 МБ, типично 1–6 МБ | `server.js:24`, фактические файлы |
|
||||
| История | ≤ 50 версий, манифест ~10 КБ | `server.js:34` |
|
||||
| Частота опроса статуса | 3 с (фронт) → 0 с при SSE | `README.md` |
|
||||
| Очередь | ≤ 5 заданий в очереди | Лимит для защиты от «накликивания»; дешевле, чем бесконечная очередь |
|
||||
|
||||
**Формула для оценки времени:** `t_edit ≈ 60–90 с × (maxDim/512)^1.3` (эмпирика из README: 512² ≈ 60–82 с, 1200×800 заметно дольше). Даунскейл до 1024 px сокращает среднее время до ~1–1.5 мин на типичных фото 4000×3000.
|
||||
|
||||
**Первый вероятный бутылочный горлышек:** одиночный поток Codex (один `codex exec` на машине). Отложенный механизм: кониурентность > 1 для Codex при появлении второго ключа/аккаунта — не требуется сейчас.
|
||||
|
||||
---
|
||||
|
||||
## 4. Целевая структура: модульный монолит
|
||||
|
||||
### 4.1 Дерево сервера
|
||||
|
||||
```
|
||||
server.js # Тонкий bootstrap-загрузчик: `require("./src/server")` (совместимость с Docker CMD и `npm start`)
|
||||
src/
|
||||
server.js # Реальная точка входа: loadConfig → createApp → listen (см. §13, Фаза 0)
|
||||
config.js # Единый источник конфигурации (env → объект, валидация)
|
||||
logger.js # Структурированный лог (JSON-lines), уровни, requestId
|
||||
errors.js # Иерархия ошибок: AppError(status, message, details)
|
||||
http/
|
||||
app.js # createApp(deps) -> express app (DI для тестов)
|
||||
middleware/
|
||||
request-context.js # requestId, тайминги, лог запросов
|
||||
auth.js # requireAuth (cookie-сессия, sliding TTL)
|
||||
origin-check.js # CSRF-защита: Origin против Host на state-changing запросах
|
||||
rate-limit.js # Окно попыток входа на IP
|
||||
error-handler.js # Единый формат ошибок { ok:false, error, details, requestId }
|
||||
routes/
|
||||
auth.routes.js # POST /api/login, GET /api/me, POST /api/logout
|
||||
health.routes.js # GET /api/health
|
||||
history.routes.js # GET /api/history, POST /api/history/import, DELETE /api/history/:id, GET /api/history/:id/image.{ext}
|
||||
preview.routes.js # POST /api/preview
|
||||
edit.routes.js # POST /api/edit
|
||||
upscale.routes.js # POST /api/upscale
|
||||
suggest.routes.js # GET /api/suggest-prompts, POST /api/suggest
|
||||
jobs.routes.js # GET /api/jobs, GET /api/jobs/:id/status, GET /api/jobs/:id/output.jpg
|
||||
events.routes.js # GET /api/events (SSE)
|
||||
result.routes.js # GET /api/result (прокси к ComfyUI /view)
|
||||
core/
|
||||
auth/
|
||||
password.js # scrypt hash/verify (timing-safe)
|
||||
session-store.js # Персистентный store сессий (JSON-файл, атомарная запись, TTL-sweep)
|
||||
history/
|
||||
history-store.js # Манифест + файлы изображений; атомарные операции
|
||||
history-service.js # Бизнес-операции: импорт, каскадное удаление, прунинг, чтение файла
|
||||
jobs/
|
||||
job-store.js # Персистентный журнал заданий (JSONL) + индекс в памяти
|
||||
queue.js # FIFO-очередь с кониурентностью по группам, восстановлением
|
||||
job-service.js # Создание задания, статусы, эмиссия событий
|
||||
engines/
|
||||
engine.js # Контракт движка + реестр (тип -> фабрика)
|
||||
codex-engine.js # Редактирование и подсказки через Codex CLI
|
||||
comfy-upscale-engine.js # Апскейл через ComfyUI
|
||||
comfy/
|
||||
comfy-client.js # REST-клиент: upload, submit, poll history, download view
|
||||
comfy-runtime.js # Кэш /object_info (30 с), выбор моделей, requireNodes
|
||||
workflows.js # buildUpscaleWorkflow (перенос), будущие воркфлоу
|
||||
codex/
|
||||
cli.js # Обёртка spawn `codex exec`: запуск, ожидание output.jpg/last_message.txt, таймаут
|
||||
prompts.js # buildEditPrompt, buildSuggestPrompt, parseSuggestions, scaleRegion
|
||||
images/
|
||||
dimensions.js # getImageDimensions (PNG/JPEG/WebP) — перенос без изменений
|
||||
heic.js # convertHeicToJpeg (перенос)
|
||||
resize.js # Даунскейл через sharp (только для edit-пути)
|
||||
data/ # Runtime-данные (gitignored, Docker volume /app/data)
|
||||
history.json # Манифест версий
|
||||
sessions.json # Сессии (если SESSION_PERSIST=true)
|
||||
jobs.jsonl # Журнал заданий
|
||||
test/ # node:test + supertest
|
||||
config.test.js logger.test.js images.test.js
|
||||
history-store.test.js job-queue.test.js prompts.test.js
|
||||
api.test.js
|
||||
```
|
||||
|
||||
### 4.2 Правила зависимостей (обязательны для реализации)
|
||||
|
||||
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 выдерживает 1–2 воркфлоу; апскейлы быстрые |
|
||||
|
||||
Интерфейс:
|
||||
|
||||
```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, 1–1000 символов.
|
||||
- **`region`:** как сейчас (`parseRegion`), дополнительно: целые ≥ 0; не больше размеров референса.
|
||||
- **UUID:** `JOB_ID_PATTERN` остаётся для всех `:id` параметров.
|
||||
- **Origin-проверка (CSRF):** middleware для не-GET `/api/*`: если заголовок `Origin` присутствует и не равен `Host` (с учётом схемы) → 403. Плюс `SameSite=Lax` (уже есть). Для LAN-инструмента этого достаточно (прокси не используется, кроме `/api/result` к ComfyUI).
|
||||
|
||||
---
|
||||
|
||||
## 8. Решения и альтернативы
|
||||
|
||||
| # | Решение | Выбрано | Альтернативы (рассмотрены) | Почему | Триггер пересмотра |
|
||||
|---|---|---|---|---|---|
|
||||
| R1 | Гранулярность | Модульный монолит | Микросервисы; один файл | Один владелец, один хост; микросервисы = жизненный цикл без выгоды | Второй хост/сервис, командная разработка |
|
||||
| R2 | Хранилище истории | JSON-манифест + файлы, атомарная запись | SQLite (better-sqlite3) | ≤ 50 версий; совместимость с существующими данными; ноль нативных зависимостей | > 500 версий, конкурентные писатели, сложные запросы дерева |
|
||||
| R3 | Хранилище заданий | JSONL-журнал + индекс в памяти | SQLite; in-memory (v1) | Транзиентные данные (1 ч); простота восстановления; нет нативных зависимостей | Нужна аналитика по истории заданий |
|
||||
| R4 | Сессии | Персистентный файл (хеши токенов), TTL | In-memory (v1) | «Переживает рестарт» — явная ценность владельца; дёшево | Мультипользователь, внешний доступ |
|
||||
| R5 | Пароль | scrypt (встроенный `crypto`) | bcrypt/argon2 (native); открытый compare (v1) | Ноль зависимостей, timing-safe, NIST-приемлемо | Требования комплаенса |
|
||||
| R6 | Даунскейл перед Codex | `sharp` | `jimp` (чистый JS); без даунскейла (v1) | Замеры README показывают выигрыш; sharp — стандарт, есть prebuilds для bookworm-slim | Проблемы сборки native → заменить на jimp |
|
||||
| R7 | Обновления статуса | SSE + фолбэк на polling | Только polling (v1); WebSocket | SSE — нулевые зависимости, работает через любой прокси; фолбэк сохраняет v1-поведение | Нужны двусторонние каналы (отмена заданий) |
|
||||
| R8 | Фронтенд | Vanilla ES-модули, без сборки | React/Vue + Vite | Ноль build-шага в Docker/CI; текущий HTML/CSS сохраняется; сложность UI невелика | Рост сложности UI (много интерактивных панелей) |
|
||||
| R9 | Очередь | FIFO + кониурентность по группам | Только single-flight (v1); полноценный worker pool | Покрывает реальный сценарий «поставить правку в очередь» без оверкилла | Параллельные правки |
|
||||
| R10 | Восстановление заданий | `queued` выполняются; `running` → `interrupted` (upscale — requeue 1 раз) | Автоперезапуск всего | Избегаем двойного списания токенов Codex; честный UX | Появление идемпотентных движков |
|
||||
| R11 | Тесты | `node:test` + supertest | Jest/Vitest | Встроено в Node 20, ноль зависимостей | Миграция на TS |
|
||||
| R12 | TypeScript | Нет (JS + JSDoc) | TS с сборкой | Текущий стек без сборки; JSDoc даёт контракты для ИИ-агента | Кодовой базы > ~10k строк |
|
||||
|
||||
---
|
||||
|
||||
## 9. Фронтенд (target)
|
||||
|
||||
### 9.1 Структура
|
||||
|
||||
```
|
||||
public/
|
||||
index.html // почти без изменений; подключение: <script type="module" src="/js/main.js"></script>
|
||||
style.css // без изменений (v1); допускается мелкая правка под новые блоки
|
||||
js/
|
||||
main.js // bootstrap: чтение DOM-элементов, инициализация модулей, подписки
|
||||
state.js // единое состояние { currentVersionId, historyVersions, collapsed, reference, busy, activeJobs }
|
||||
api.js // apiFetch(url, opts): JSON-ошибки, 401 → auth.show(), requestId
|
||||
auth.js // showAuthOverlay/hide, checkAuth, submitLogin, logout
|
||||
health.js // опрос /api/health (как сейчас, 30 с)
|
||||
upload.js // drop-zone, мультизагрузка (последовательный импорт, прогресс)
|
||||
reference.js // выбор референса (файл/история), канвас, область (перенос из app.js:1072-1290)
|
||||
suggest.js // chips из истории, «Спросить Codex», подписка на job (перенос)
|
||||
jobs.js // EventSource + фолбэк-поллинг; renderJobSuccess, ошибки
|
||||
history-tree.js // рендер дерева, свёртка групп, выбор версии, удаление (перенос из app.js:532-852)
|
||||
ui.js // мелкие утилиты: formatFileSize, versionFileName, toast
|
||||
```
|
||||
|
||||
### 9.2 Ключевые изменения поведения
|
||||
|
||||
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 сохранено | Фаза 0–1 ручной прогон всех README-сценариев; `api.test.js` покрывает контракты |
|
||||
| Переживание рестарта | Тест `job-queue.test.js` (восстановление из журнала) + ручной: рестарт во время edit |
|
||||
| Безопасность | Тесты rate-limit/Origin/magic-bytes; в логах нет паролей и токенов |
|
||||
| Скорость | Замер времени edit до/после даунскейла (лог `durationMs`, сравнение с README-таблицей) |
|
||||
| Наблюдаемость | Лог с requestId по всем API; `GET /api/health` содержит metrics |
|
||||
| Реализуемость | Каждая фаза имеет автотесты и ручную приёмку; контракты в §5–§7 однозначны |
|
||||
|
||||
---
|
||||
|
||||
## 15. Открытые вопросы и остаточные риски
|
||||
|
||||
Вопросы, которые **могут** изменить планирование (на текущий момент решены дефолтами):
|
||||
|
||||
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.1–4.2 |
|
||||
| Единый config с валидацией | §4.3 |
|
||||
| JSON-манифест истории + атомарная запись | §5.1 |
|
||||
| Персистентные сессии (хеши токенов) | §5.2 |
|
||||
| JSONL-журнал заданий + восстановление | §5.3, §6.1 |
|
||||
| Очередь FIFO, кониурентность по группам, MAX_QUEUED_JOBS | §6.1 |
|
||||
| Контракт движка EngineResult | §6.2 |
|
||||
| Даунскейл sharp + scaleRegion | §6.5, Фаза 4 |
|
||||
| SSE + фолбэк-поллинг | §6.6 |
|
||||
| API: аддитивные изменения, requestId | §7 |
|
||||
| scrypt, rate-limit, Origin-check, magic bytes | §7.3 |
|
||||
| Vanilla ES-модули без сборки | §9 |
|
||||
| node:test + supertest + Gitea test workflow | §10 |
|
||||
| requestId-логи, лёгкие метрики | §11 |
|
||||
| Миграция data/ + алиас /image.jpg | §12 |
|
||||
| План по фазам с приёмкой | §13 |
|
||||
|
||||
---
|
||||
|
||||
# System Design Proposal
|
||||
|
||||
**Checklist: 6/6 complete**<br>
|
||||
**Incomplete: None** — обрамление (§1), оценка (§3), домены/данные/контракты (§4–§7), HLD/LLD (§4–§6, §9–§11), решения (§8), написание/валидация (§13–§15) выполнены; изменены только `docs/architecture/*.md`, код не менялся.
|
||||
904
package-lock.json
generated
904
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,11 +1,13 @@
|
||||
{
|
||||
"name": "prompt-photo-editor",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Локальный веб-редактор фотографий на базе ComfyUI",
|
||||
"description": "Локальный веб-редактор фотографий на базе Codex и ComfyUI (v2)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"start": "node server.js",
|
||||
"start:dev": "node --watch src/server.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -13,6 +15,10 @@
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
576
public/app.js
576
public/app.js
@@ -1,575 +1 @@
|
||||
"use strict";
|
||||
|
||||
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 редактирует фотографию… Обычно 1–3 минуты."
|
||||
: "Увеличиваем разрешение фотографии…"
|
||||
);
|
||||
|
||||
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();
|
||||
// migrated to /js/main.js (ES-modules in public/js/)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
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"
|
||||
>
|
||||
<link rel="stylesheet" href="/style.css?v=7">
|
||||
<link rel="stylesheet" href="/style.css?v=14">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -24,6 +24,7 @@
|
||||
<span class="brand-name">Кадр</span>
|
||||
</a>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div
|
||||
id="health-status"
|
||||
class="health-status health-loading"
|
||||
@@ -33,6 +34,10 @@
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main class="editor-layout">
|
||||
@@ -61,6 +66,7 @@
|
||||
id="photo-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,.heic,.heif"
|
||||
multiple
|
||||
hidden
|
||||
>
|
||||
|
||||
@@ -77,6 +83,26 @@
|
||||
</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">
|
||||
<label class="field-label" for="prompt">Промпт</label>
|
||||
<textarea
|
||||
@@ -91,6 +117,18 @@
|
||||
</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">
|
||||
<button
|
||||
id="edit-button"
|
||||
@@ -129,8 +167,20 @@
|
||||
<p class="workspace-kicker">Предпросмотр</p>
|
||||
<h2 id="workspace-title">До и после</h2>
|
||||
</div>
|
||||
<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 class="comparison">
|
||||
<figure class="photo-frame">
|
||||
@@ -218,12 +268,32 @@
|
||||
</section>
|
||||
</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>
|
||||
<div class="noscript">
|
||||
Для работы редактора необходимо включить JavaScript.
|
||||
</div>
|
||||
</noscript>
|
||||
|
||||
<script src="/app.js?v=7" defer></script>
|
||||
<script type="module" src="/js/main.js?v=13"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
40
public/js/api.js
Normal file
40
public/js/api.js
Normal 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
111
public/js/auth.js
Normal 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
49
public/js/health.js
Normal 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
357
public/js/history-tree.js
Normal 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
301
public/js/jobs.js
Normal 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 редактирует фотографию… Это обычно занимает 1–3 минуты."
|
||||
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
|
||||
);
|
||||
} else if (jobData.status === "done") {
|
||||
currentTrackingJob = null;
|
||||
onFinish(null, jobData);
|
||||
} else if (jobData.status === "interrupted") {
|
||||
currentTrackingJob = null;
|
||||
const err = new Error(
|
||||
jobData.error || "Задание прервано перезапуском сервера. Запустите заново."
|
||||
);
|
||||
err.interrupted = true;
|
||||
onFinish(err);
|
||||
} else if (jobData.status === "error") {
|
||||
currentTrackingJob = null;
|
||||
onFinish(new Error(jobData.error || "Ошибка при выполнении задания."));
|
||||
}
|
||||
}
|
||||
|
||||
export async function runJob(kind) {
|
||||
const state = getState();
|
||||
const isEdit = kind === "edit";
|
||||
|
||||
if (!state.currentVersionId || state.isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promptInput = document.querySelector("#prompt");
|
||||
const prompt = String(promptInput?.value || "").trim();
|
||||
|
||||
if (isEdit && !prompt) {
|
||||
setJobStatus("error", "Введите текст промпта.");
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
resetResult();
|
||||
setJobStatus("progress", "Отправка задания на сервер…");
|
||||
|
||||
let lastJobParams = null;
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (isEdit) {
|
||||
const formData = new FormData();
|
||||
formData.append("sourceId", state.currentVersionId);
|
||||
formData.append("prompt", prompt);
|
||||
|
||||
const refState = getReferenceState();
|
||||
if (refState?.source?.type === "file" && refState.source.file) {
|
||||
formData.append("reference", refState.source.file);
|
||||
} else if (refState?.source?.type === "history" && refState.source.versionId) {
|
||||
formData.append("referenceId", refState.source.versionId);
|
||||
}
|
||||
|
||||
if (refState?.region) {
|
||||
formData.append("region", JSON.stringify(refState.region));
|
||||
}
|
||||
|
||||
lastJobParams = { kind, sourceId: state.currentVersionId, prompt, refState };
|
||||
|
||||
res = await apiFetch("/api/edit", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
} else {
|
||||
lastJobParams = { kind, sourceId: state.currentVersionId };
|
||||
|
||||
res = await apiFetch("/api/upscale", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: state.currentVersionId }),
|
||||
});
|
||||
}
|
||||
|
||||
const jobId = res.jobId;
|
||||
|
||||
if (res.status === "queued") {
|
||||
const pos = res.queuePosition ? ` (позиция ${res.queuePosition})` : "";
|
||||
setJobStatus("progress", `Задание добавлено в очередь${pos}…`);
|
||||
} else {
|
||||
setJobStatus(
|
||||
"progress",
|
||||
isEdit
|
||||
? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты."
|
||||
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for completion via SSE or fallback polling
|
||||
await new Promise((resolve, reject) => {
|
||||
currentTrackingJob = {
|
||||
id: jobId,
|
||||
isEdit,
|
||||
onFinish: (err, resultData) => {
|
||||
if (err) reject(err);
|
||||
else resolve(resultData);
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback polling loop in case SSE is blocked or slow
|
||||
pollJobStatus(jobId, isEdit);
|
||||
});
|
||||
|
||||
// Fetch full job status to get version and details
|
||||
const finalJob = await apiFetch(`/api/jobs/${jobId}/status`);
|
||||
renderJobSuccess(finalJob, isEdit);
|
||||
} catch (err) {
|
||||
if (err.interrupted) {
|
||||
setJobStatus(
|
||||
"error",
|
||||
"Задание прервано перезапуском сервера. Нажмите кнопку, чтобы повторить попытку."
|
||||
);
|
||||
} else {
|
||||
setJobStatus("error", err.message || "Ошибка при выполнении задания.");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJobStatus(jobId, isEdit) {
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
if (!currentTrackingJob || currentTrackingJob.id !== jobId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch(`/api/jobs/${jobId}/status`);
|
||||
if (!currentTrackingJob || currentTrackingJob.id !== jobId) return;
|
||||
|
||||
if (data.status === "queued") {
|
||||
const pos = data.queuePosition ? ` (позиция ${data.queuePosition})` : "";
|
||||
setJobStatus("progress", `Задание ожидает в очереди${pos}…`);
|
||||
setTimeout(poll, 2000);
|
||||
} else if (data.status === "running") {
|
||||
setJobStatus(
|
||||
"progress",
|
||||
isEdit
|
||||
? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты."
|
||||
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
|
||||
);
|
||||
setTimeout(poll, 2000);
|
||||
} else if (data.status === "done") {
|
||||
const cb = currentTrackingJob?.onFinish;
|
||||
currentTrackingJob = null;
|
||||
if (cb) cb(null, data);
|
||||
} else if (data.status === "interrupted") {
|
||||
const cb = currentTrackingJob?.onFinish;
|
||||
currentTrackingJob = null;
|
||||
const err = new Error(
|
||||
data.error || "Задание прервано перезапуском сервера. Запустите заново."
|
||||
);
|
||||
err.interrupted = true;
|
||||
if (cb) cb(err);
|
||||
} else if (data.status === "error") {
|
||||
const cb = currentTrackingJob?.onFinish;
|
||||
currentTrackingJob = null;
|
||||
if (cb) cb(new Error(data.error || "Ошибка при выполнении задания."));
|
||||
} else {
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
} catch {
|
||||
attempts++;
|
||||
if (attempts < 30) {
|
||||
setTimeout(poll, 2500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(poll, 2000);
|
||||
}
|
||||
|
||||
export function renderJobSuccess(data, isEdit) {
|
||||
const version = data.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
77
public/js/main.js
Normal 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
322
public/js/reference.js
Normal 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
74
public/js/state.js
Normal 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
187
public/js/suggest.js
Normal 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
138
public/js/ui.js
Normal 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
123
public/js/upload.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
546
public/style.css
546
public/style.css
@@ -186,6 +186,7 @@ h1 {
|
||||
|
||||
.control-stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 23px;
|
||||
}
|
||||
|
||||
@@ -445,6 +446,41 @@ textarea:disabled {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -841,6 +877,20 @@ textarea:disabled {
|
||||
.local-badge {
|
||||
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) {
|
||||
@@ -854,7 +904,7 @@ textarea:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Èñòîðèÿ âåðñèé ------------------------------------------- */
|
||||
/* -- История версий ------------------------------------------------- */
|
||||
|
||||
.history-section {
|
||||
margin-top: 30px;
|
||||
@@ -881,71 +931,202 @@ textarea:disabled {
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
overflow: hidden auto;
|
||||
padding: 4px 4px 10px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
flex: 0 0 150px;
|
||||
width: 150px;
|
||||
padding: 9px;
|
||||
border: 1.5px solid var(--line);
|
||||
.history-group {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-small);
|
||||
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 {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
.history-group-count {
|
||||
display: block;
|
||||
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);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.history-item img {
|
||||
width: 100%;
|
||||
height: 92px;
|
||||
.history-node-toggle {
|
||||
flex: 0 0 auto;
|
||||
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;
|
||||
border-radius: 8px;
|
||||
background: #ecece6;
|
||||
}
|
||||
|
||||
.history-item-meta strong {
|
||||
display: block;
|
||||
.history-node-meta {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.history-node-title {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
font-weight: 750;
|
||||
color: var(--ink);
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.history-item-meta span {
|
||||
display: block;
|
||||
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;
|
||||
.history-node-badge {
|
||||
justify-self: start;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
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 {
|
||||
@@ -953,3 +1134,290 @@ textarea:disabled {
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
206
src/config.js
Normal file
206
src/config.js
Normal 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
114
src/core/auth/password.js
Normal 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,
|
||||
};
|
||||
190
src/core/auth/session-store.js
Normal file
190
src/core/auth/session-store.js
Normal 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
265
src/core/codex/cli.js
Normal 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
156
src/core/codex/prompts.js
Normal 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,
|
||||
};
|
||||
224
src/core/comfy/comfy-client.js
Normal file
224
src/core/comfy/comfy-client.js
Normal 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,
|
||||
};
|
||||
195
src/core/comfy/comfy-runtime.js
Normal file
195
src/core/comfy/comfy-runtime.js
Normal 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,
|
||||
};
|
||||
75
src/core/comfy/workflows.js
Normal file
75
src/core/comfy/workflows.js
Normal 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,
|
||||
};
|
||||
261
src/core/engines/codex-engine.js
Normal file
261
src/core/engines/codex-engine.js
Normal 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,
|
||||
};
|
||||
114
src/core/engines/comfy-upscale-engine.js
Normal file
114
src/core/engines/comfy-upscale-engine.js
Normal 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,
|
||||
};
|
||||
54
src/core/engines/engine.js
Normal file
54
src/core/engines/engine.js
Normal 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,
|
||||
};
|
||||
225
src/core/history/history-service.js
Normal file
225
src/core/history/history-service.js
Normal 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,
|
||||
};
|
||||
163
src/core/history/history-store.js
Normal file
163
src/core/history/history-store.js
Normal 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,
|
||||
};
|
||||
190
src/core/images/dimensions.js
Normal file
190
src/core/images/dimensions.js
Normal 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
42
src/core/images/heic.js
Normal 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
83
src/core/images/resize.js
Normal 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
71
src/core/images/sniff.js
Normal 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,
|
||||
};
|
||||
122
src/core/jobs/job-service.js
Normal file
122
src/core/jobs/job-service.js
Normal 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
289
src/core/jobs/job-store.js
Normal 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
367
src/core/jobs/queue.js
Normal 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
63
src/errors.js
Normal 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
146
src/http/app.js
Normal 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,
|
||||
};
|
||||
83
src/http/middleware/auth.js
Normal file
83
src/http/middleware/auth.js
Normal 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,
|
||||
};
|
||||
80
src/http/middleware/error-handler.js
Normal file
80
src/http/middleware/error-handler.js
Normal 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,
|
||||
};
|
||||
43
src/http/middleware/origin-check.js
Normal file
43
src/http/middleware/origin-check.js
Normal 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,
|
||||
};
|
||||
99
src/http/middleware/rate-limit.js
Normal file
99
src/http/middleware/rate-limit.js
Normal 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,
|
||||
};
|
||||
44
src/http/middleware/request-context.js
Normal file
44
src/http/middleware/request-context.js
Normal 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,
|
||||
};
|
||||
87
src/http/routes/auth.routes.js
Normal file
87
src/http/routes/auth.routes.js
Normal 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,
|
||||
};
|
||||
149
src/http/routes/edit.routes.js
Normal file
149
src/http/routes/edit.routes.js
Normal 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,
|
||||
};
|
||||
78
src/http/routes/events.routes.js
Normal file
78
src/http/routes/events.routes.js
Normal 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,
|
||||
};
|
||||
63
src/http/routes/health.routes.js
Normal file
63
src/http/routes/health.routes.js
Normal 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,
|
||||
};
|
||||
151
src/http/routes/history.routes.js
Normal file
151
src/http/routes/history.routes.js
Normal 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,
|
||||
};
|
||||
67
src/http/routes/jobs.routes.js
Normal file
67
src/http/routes/jobs.routes.js
Normal 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,
|
||||
};
|
||||
51
src/http/routes/preview.routes.js
Normal file
51
src/http/routes/preview.routes.js
Normal 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,
|
||||
};
|
||||
58
src/http/routes/result.routes.js
Normal file
58
src/http/routes/result.routes.js
Normal 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,
|
||||
};
|
||||
108
src/http/routes/suggest.routes.js
Normal file
108
src/http/routes/suggest.routes.js
Normal 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,
|
||||
};
|
||||
88
src/http/routes/upscale.routes.js
Normal file
88
src/http/routes/upscale.routes.js
Normal 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
119
src/logger.js
Normal 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
222
src/server.js
Normal 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
324
test/api.test.js
Normal 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
82
test/config.test.js
Normal 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
130
test/history-store.test.js
Normal 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
105
test/images.test.js
Normal 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
161
test/job-queue.test.js
Normal 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
61
test/logger.test.js
Normal 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
72
test/prompts.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user