299 lines
19 KiB
Markdown
299 lines
19 KiB
Markdown
# Кадр — локальный AI-фоторедактор
|
||
|
||
Веб-приложение редактирует фотографии по текстовому промпту (через локальный
|
||
Codex CLI) и увеличивает разрешение через домашний сервер ComfyUI.
|
||
|
||
Поддерживаются фотографии JPEG, PNG, WebP и HEIC/HEIF. Файлы HEIC/HEIF
|
||
преобразуются на сервере в JPEG с помощью `heic-convert` до передачи в ComfyUI
|
||
или Codex.
|
||
|
||
## Как это работает
|
||
|
||
- **История версий**: каждая загрузка и каждый результат (редактирование или
|
||
апскейл) сохраняется в папке `history/` как отдельная версия. Можно вернуться
|
||
к любой версии кликом по ней в ленте «История версий» и продолжить правки
|
||
именно с неё — например, отредактировать фото, а затем увеличить разрешение
|
||
уже отредактированного варианта. История переживает перезапуск сервера и
|
||
обновление страницы; хранится последние 50 версий.
|
||
- **Редактирование**: сервер передаёт выбранную версию и промпт агенту Codex
|
||
CLI (`codex exec`). Агент редактирует изображение и сохраняет результат.
|
||
Обработка обычно занимает 1–3 минуты.
|
||
- **Увеличение разрешения**: 4× upscale через ComfyUI моделью
|
||
`RealESRGAN_x4plus.safetensors` — применяется к выбранной (в том числе
|
||
отредактированной) версии.
|
||
|
||
Редактирование и апскейл выполняются в фоне: `POST /api/edit` и
|
||
`POST /api/upscale` отвечают мгновенно с идентификатором задания (`jobId`),
|
||
а тяжёлая работа продолжается на сервере. Фронтенд опрашивает
|
||
`GET /api/jobs/:id/status` каждые 3 секунды; страницу можно закрыть —
|
||
готовая версия появится в истории.
|
||
|
||
- **Дерево истории**: вместо плоской ленты версии показаны деревом — каждая
|
||
исходная фотография образует группу со своей веткой (исходник → правки →
|
||
апскейлы), потомки выстраиваются по `parentId` с отступами, а сироты
|
||
(версии, чей родитель уже удалён или оттеснён лимитом) показываются как
|
||
отдельные корни. Ветку можно свернуть, под каждой edit-версией виден её
|
||
промпт (обрезка до двух строк, полный текст — в подсказке).
|
||
- **Удаление версий**: на узле (при наведении) есть кнопка «Удалить» —
|
||
версия удаляется вместе со всеми потомками и их файлами
|
||
(`DELETE /api/history/:id`, каскадно по `parentId`).
|
||
- **Мультизагрузка**: в поле «Фотография» можно выбрать сразу несколько
|
||
файлов — каждый становится отдельной исходной версией (своей группой),
|
||
импорт идёт последовательно с прогрессом «Загрузка N из M…».
|
||
- **Редактирование с референсом и областью**: перед правкой можно задать
|
||
референс — файл или версию из истории. На превью референса рисуется
|
||
прямоугольник области; сервер передаёт Codex обе картинки (повторяемый
|
||
флаг `-i`) и кладёт координаты области в промпт. Если область не выбрана,
|
||
референс всё равно передаётся как ориентир по сюжету и ракурсу.
|
||
- **Подсказки промптов**: кнопка «💡 Предложить промпт» — мгновенные
|
||
варианты из истории (`GET /api/suggest-prompts`), а «Спросить Codex» —
|
||
асинхронный джоб `POST /api/suggest` (тот же механизм опроса статуса, что
|
||
и у редактирования).
|
||
|
||
## Требования
|
||
|
||
- 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`)
|
||
|
||
## Запуск
|
||
|
||
```powershell
|
||
npm install
|
||
npm start
|
||
```
|
||
|
||
После запуска редактор закрыт cookie-сессией. По умолчанию логин — `admin`,
|
||
а пароль генерируется при старте сервера и печатается в консоль (баннер
|
||
`APP_PASSWORD не задан. Сгенерирован случайный пароль`). Чтобы зафиксировать
|
||
учётные данные, задайте переменные окружения `APP_USER` / `APP_PASSWORD`.
|
||
Подробнее — в разделе «Авторизация».
|
||
|
||
## Авторизация
|
||
|
||
Редактор «Кадр» закрыт cookie-сессией: все `/api/*` эндпоинты, кроме
|
||
`/api/health`, `/api/login`, `/api/me`, `/api/logout`, требуют валидную
|
||
сессию, иначе возвращают `401 {"ok":false,"error":"Требуется авторизация."}`.
|
||
На главной странице поверх редактора показывается форма входа, которая
|
||
блокирует работу, пока владелец не войдёт. Статические файлы и `/api/health`
|
||
остаются публичными.
|
||
|
||
Учётные данные задаются переменными окружения:
|
||
|
||
- `APP_USER` — логин владельца (по умолчанию `admin`).
|
||
- `APP_PASSWORD` — пароль. Если не задан или пуст, сервер генерирует
|
||
случайный 16-символьный пароль при старте и печатает его в консоль/лог
|
||
контейнера (баннер `APP_PASSWORD не задан. Сгенерированный пароль`).
|
||
|
||
Сессия — cookie `kadr_session`:
|
||
|
||
- срок жизни 7 суток;
|
||
- скользящая (sliding): продлевается при каждой активности;
|
||
- `HttpOnly`, `SameSite=Lax`, без `Secure` (приложение работает по HTTP
|
||
в локальной сети);
|
||
- сессии хранятся в памяти сервера — перезапуск сбрасывает все входы.
|
||
|
||
Публичные эндпоинты: `GET /api/health`, `POST /api/login`,
|
||
`GET /api/me`, `POST /api/logout`. Вход в систему — `POST /api/login`
|
||
с JSON-телом `{"username": "...", "password": "..."}`.
|
||
|
||
Остальные эндпоинты (все требуют авторизации): `GET /api/history`,
|
||
`POST /api/history/import`, `DELETE /api/history/:id`,
|
||
`GET /api/suggest-prompts`, `POST /api/suggest`, `POST /api/preview`,
|
||
`POST /api/edit`, `POST /api/upscale`, `GET /api/jobs/:id/status`.
|
||
|
||
## Логирование
|
||
|
||
Все действия сервера пишутся в стандартный вывод контейнера — смотрите их в
|
||
**Portainer → Containers → kadr-photo-editor → Logs**.
|
||
|
||
Что логируется:
|
||
|
||
- каждый запрос к `/api/*` (метод, путь, HTTP-статус, время обработки);
|
||
- старт сервера (порт, логин, адрес ComfyUI);
|
||
- вход и выход пользователя;
|
||
- импорт фотографии;
|
||
- редактирование: запуск задания, успешное завершение или ошибка;
|
||
- апскейл: запуск задания, успешное завершение или ошибка;
|
||
- ошибки обработки запросов (с кодом статуса и текстом ошибки).
|
||
|
||
Что **не** логируется: пароли, cookie `kadr_session`, содержимое фотографий.
|
||
Единственное исключение — баннер сгенерированного пароля при первом старте
|
||
(когда `APP_PASSWORD` не задан): пароль печатается в лог один раз, чтобы
|
||
владелец мог войти.
|
||
|
||
Уровень логирования задаётся переменной окружения `LOG_LEVEL`:
|
||
|
||
- `debug` — все сообщения, включая `GET /api/health` и опросы статуса заданий
|
||
`GET /api/jobs/:id/status`;
|
||
- `info` (по умолчанию) — основные события;
|
||
- `warn` — предупреждения;
|
||
- `error` — только ошибки.
|
||
|
||
Примеры строк лога:
|
||
|
||
```
|
||
2026-08-26T06:00:00.000Z [INFO] Сервер запущен {"port":3000,"user":"admin","comfyUrl":"http://192.168.31.240:8188","passwordGenerated":false}
|
||
2026-08-26T06:00:01.000Z [INFO] GET /api/history -> 200 2ms
|
||
2026-08-26T06:00:05.000Z [INFO] Вход выполнен {"user":"admin"}
|
||
2026-08-26T06:00:10.000Z [INFO] Редактирование запущено {"jobId":"...","sourceId":"...","prompt":"..."}
|
||
2026-08-26T06:02:30.000Z [INFO] Редактирование завершено {"jobId":"...","ok":true,"exitCode":0,"timedOut":false,"durationMs":140000,"versionId":"...","lastMessage":"..."}
|
||
2026-08-26T06:02:31.000Z [WARN] Ошибка обработки запроса {"status":401,"message":"Требуется авторизация."}
|
||
```
|
||
|
||
## Docker
|
||
|
||
Образ собирается на базе `node:22-bookworm-slim` (Debian, glibc) — Codex CLI
|
||
распространяется как бинарник под glibc и не работает на Alpine/musl.
|
||
|
||
```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 ^
|
||
-e APP_USER=admin ^
|
||
-e APP_PASSWORD=your-strong-password ^
|
||
kadr-photo-editor
|
||
```
|
||
|
||
Что важно знать:
|
||
|
||
- **Авторизация 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 ^
|
||
-e APP_USER=admin ^
|
||
-e APP_PASSWORD=your-strong-password ^
|
||
git.byte-mate.ru/coder/photo-editor:latest
|
||
```
|
||
|
||
Требования для работы кнопки:
|
||
|
||
1. **Actions включены** на инстансе (уже включены).
|
||
2. **Зарегистрирован runner** (`act_runner`) — уже работает
|
||
(«Runner-10C-32Gb»).
|
||
3. **Секрет `GIT_PASSWORD`** в репозитории (Settings → Actions → Secrets):
|
||
Personal Access Token с правами на пакеты. Имена `GITEA_TOKEN` /
|
||
`GITHUB_TOKEN` зарезервированы Gitea и не создаются.
|
||
4. Образ публикуется в `git.byte-mate.ru/coder/photo-editor` (теги `latest` и
|
||
имя ветки; путь в registry обязательно в нижнем регистре).
|
||
|
||
Если runner ещё не зарегистрирован:
|
||
|
||
```bash
|
||
# токен регистрации: Администрирование → Actions → Runners → Create new runner
|
||
docker run -d --name gitea-runner --restart always \
|
||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||
-v gitea-runner:/data \
|
||
-e GITEA_INSTANCE_URL=https://git.byte-mate.ru \
|
||
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token> \
|
||
gitea/act_runner:latest
|
||
```
|
||
|
||
3. После регистрации нажмите «Run workflow» — образ соберётся Buildx'ом и
|
||
уедет в registry.
|
||
|
||
## Развёртывание в Portainer (workflow deploy-portainer)
|
||
|
||
Workflow `.gitea/workflows/deploy.yml` разворачивает сервис на
|
||
[Portainer](https://ptr.byte-mate.ru): создаёт или обновляет стек
|
||
`photo-editor` из `deploy/docker-compose.yml` через API Portainer.
|
||
Запуск: **Actions → Run workflow** (workflow **deploy-portainer**).
|
||
|
||
Требования:
|
||
|
||
1. **Секрет `PORTAINER_API_KEY`** (Settings → Actions → Secrets) — API-ключ
|
||
Portainer: **Account → API keys → Add key**. Внимание: Portainer 2.39
|
||
принимает ключ только через заголовок `X-API-Key` (не Bearer) — workflow
|
||
это уже учитывает.
|
||
2. **Переменная `PORTAINER_ENDPOINT_ID`** (Settings → Actions → Variables) —
|
||
id окружения Docker в Portainer. Если не задана, берётся первый из
|
||
`/api/endpoints`. Сейчас endpoint — `server1`, id **3**.
|
||
3. **Registry в Portainer** (Registries → Add registry): URL
|
||
`git.byte-mate.ru`, username `Coder`, пароль — Personal Access Token.
|
||
Без этого хост не сможет стянуть образ (registry приватный, анонимный
|
||
pull возвращает 401).
|
||
4. На хосте Portainer должен существовать каталог `/root/.codex` с
|
||
аутентификацией Codex (auth.json + config.toml) — он монтируется в
|
||
контейнер. Альтернатива — переменная `OPENAI_API_KEY` в
|
||
`deploy/docker-compose.yml`.
|
||
5. На хосте должен работать прокси `http://192.168.31.240:4067`: прямой
|
||
выход сервера блокируется OpenAI (403, cf-ray HEL), поэтому Codex ходит
|
||
в ChatGPT API через прокси. Переменные `HTTP(S)_PROXY` и `NO_PROXY`
|
||
прописаны в `deploy/docker-compose.yml` (внутренние адреса — ComfyUI,
|
||
свой хост — идут напрямую).
|
||
6. **Авторизация владельца**: секреты `APP_USER` / `APP_PASSWORD`
|
||
(Settings → Actions → Secrets) передаются workflow deploy-portainer
|
||
в переменные окружения стека. Если секреты не заданы (пустые значения),
|
||
сервер использует дефолты: логин `admin` и сгенерированный при старте
|
||
пароль, напечатанный в лог контейнера. Альтернатива — задать переменные
|
||
вручную в Portainer UI: стек `photo-editor` → **Edit** → **Environment
|
||
variables** → `APP_USER` / `APP_PASSWORD`.
|
||
|
||
Поведение: если стека нет — создаёт; если есть — обновляет с
|
||
`RepullImageAndRedeploy=true` (всегда перетягивает свежий образ). Контейнер
|
||
`kadr-photo-editor` слушает порт **3000** на хосте Portainer; история версий
|
||
хранится в именованном томе `kadr-history` и переживает обновления.
|
||
|
||
## Тест скорости моделей Codex (ветка testing)
|
||
|
||
Сервер умеет переопределять модель и уровень рассуждений через переменные
|
||
окружения (без правки `~/.codex/config.toml`):
|
||
|
||
```powershell
|
||
$env:CODEX_MODEL = "gpt-5.6-luna"; $env:CODEX_REASONING_EFFORT = "low"
|
||
npm start
|
||
```
|
||
|
||
Проверено на ветке `testing`: одинаковое фото 512×512 и один промпт,
|
||
замер полного цикла редактирования:
|
||
|
||
| Конфигурация | Время | Токены |
|
||
|---|---|---|
|
||
| `gpt-5.6-sol` + effort high (как сейчас) | 73.9 s | 15 286 |
|
||
| `gpt-5.6-sol` + effort low | 61.7 s | 29 760 |
|
||
| `gpt-5.6-luna` (default medium) | 68.6 s | 52 005 |
|
||
| `gpt-5.4-mini` (default medium) | 82.1 s | 19 437 |
|
||
| `gpt-5.2-codex`, `gpt-5.2-codex-mini` | — | недоступны для ChatGPT-аккаунта (API 400) |
|
||
|
||
Выводы:
|
||
|
||
- **Выбор модели почти не влияет на время** (60–82 s): узкое место — вызов
|
||
image-модели при редактировании, а не агентная модель.
|
||
- Единственный заметный выигрыш — `CODEX_REASONING_EFFORT=low` (~17%).
|
||
Кстати, у `gpt-5.6-sol` low — это значение по умолчанию; в
|
||
`~/.codex/config.toml` оно переопределено на `high`, поэтому редактирование
|
||
такое долгое.
|
||
- Для радикального ускорения нужно уменьшать разрешение исходника для шага
|
||
редактирования (медленнее при 1200×800, чем при 512×512) — но тогда
|
||
результат будет меньшего размера.
|