Compare commits
2 Commits
v0.3.0
...
6fcc518438
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fcc518438 | |||
| 02fd7a65a7 |
30
.env.example
30
.env.example
@@ -1,22 +1,32 @@
|
|||||||
# Synology NAS
|
# Synology NAS
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
NAS_FQDN="https://nas.company.local:5001"
|
||||||
NAS_USER="your_username"
|
NAS_USER="DOMAIN\\service_account"
|
||||||
NAS_PASS="your_password"
|
NAS_PASS="strongpassword"
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
NAS_FILE="/Team Folder/Anonsy/sport.osheet"
|
||||||
|
|
||||||
# Logging (optional, defaults to info)
|
# Logging
|
||||||
RUST_LOG="info"
|
RUST_LOG="info"
|
||||||
|
|
||||||
# Web Server (optional, defaults to 3000)
|
# Web Server
|
||||||
WEB_PORT="3000"
|
WEB_PORT="3000"
|
||||||
|
|
||||||
# Nexrender
|
# Nexrender
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
OUTPUT_FOLDER="/mnt/nexrender/output"
|
||||||
|
|
||||||
# After Effects Templates
|
# After Effects Templates
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
TEMPLATE_SINGLE_SRC="file:///mnt/templates/single_team.aepx"
|
||||||
TEMPLATE_COMPOSITION="main"
|
TEMPLATE_COMPOSITION="main"
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
|
||||||
|
# SMB Upload for approved videos (optional, only needed for web mode)
|
||||||
|
# Format: smb://server/share/optional/folder
|
||||||
|
SMB_UPLOAD_URL="smb://storage.company.local/approved_videos"
|
||||||
|
SMB_USERNAME="DOMAIN\\service_account"
|
||||||
|
SMB_PASSWORD="strongpassword"
|
||||||
|
# Optional: SMB_WORKGROUP="DOMAIN"
|
||||||
|
|
||||||
|
# Database (optional, default sqlite:ae_anons.db)
|
||||||
|
DATABASE_URL="sqlite:ae_anons.db"
|
||||||
2640
Cargo.lock
generated
2640
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
56
Cargo.toml
56
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.2.6"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
|
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
|
||||||
@@ -11,31 +11,41 @@ keywords = ["nexrender", "after-effects", "synology", "automation"]
|
|||||||
categories = ["command-line-utilities", "multimedia"]
|
categories = ["command-line-utilities", "multimedia"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
|
reqwest = { version = "0.12.5", features = ["json", "multipart", "stream"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0.140"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0.12"
|
||||||
urlencoding = "2.1"
|
urlencoding = "2.1.3"
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.52.1", features = ["full", "sync"] }
|
||||||
dotenv = "0.15"
|
dotenv = "0.15.0"
|
||||||
calamine = "0.26"
|
calamine = "0.34"
|
||||||
chrono = "0.4"
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
regex = "1.11"
|
regex = "1.11.1"
|
||||||
futures = "0.3"
|
futures = "0.3.31"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0.98"
|
||||||
log = "0.4"
|
log = "0.4.27"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11.8"
|
||||||
bytes = "1.9"
|
bytes = "1.10.1"
|
||||||
|
sqlx = { version = "0.8.5", features = [
|
||||||
|
"runtime-tokio-native-tls",
|
||||||
|
"sqlite",
|
||||||
|
"chrono",
|
||||||
|
] }
|
||||||
|
tokio-stream = "0.1.17"
|
||||||
|
mime_guess = "2.0.5"
|
||||||
|
tokio-util = "0.7.14"
|
||||||
|
url = "2.5.0"
|
||||||
|
smb = "0.11.1"
|
||||||
|
|
||||||
# Web server (updated to latest stable versions)
|
# Web server (with WebSocket support)
|
||||||
axum = "0.8.9"
|
axum = { version = "0.8.9", features = ["ws"] }
|
||||||
tower = "0.5"
|
tower = "0.5.2"
|
||||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
tower-http = { version = "0.6.4", features = ["fs", "trace"] }
|
||||||
askama = "0.15"
|
askama = "0.15.4"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5.38", features = ["derive"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
|
tokio = { version = "1.52.1", features = ["full", "rt-multi-thread"] }
|
||||||
|
|
||||||
[target.x86_64-unknown-linux-gnu.dependencies]
|
[target.x86_64-unknown-linux-gnu.dependencies]
|
||||||
openssl = { version = "0.10", features = ["vendored"] }
|
openssl = { version = "0.10", features = ["vendored"] }
|
||||||
|
|||||||
695
README.md
695
README.md
@@ -1,4 +1,4 @@
|
|||||||
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
|
# AE Anons - Автономный генератор спортивных анонсов в After Effects
|
||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
<!-- markdownlint-disable MD033 -->
|
||||||
<p align="left">
|
<p align="left">
|
||||||
@@ -10,23 +10,22 @@
|
|||||||
[](https://www.rust-lang.org/)
|
[](https://www.rust-lang.org/)
|
||||||
[](https://git.tvstart.ru/lexx/AE_Anons)
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
**AE Anons** — полностью автономная (standalone) система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender. Все данные берутся из электронных таблиц Synology Office, рендеринг выполняется локально, управление заданиями происходит через встроенный веб-интерфейс с мгновенными обновлениями. Система не требует доступа в интернет — все ресурсы (иконки, стили, шрифты) встроены в бинарный файл.
|
||||||
|
|
||||||
## Новое в версии v0.2.6
|
## Новое в версии v0.3.0
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
- **WebSocket** — мгновенные обновления списка заданий (без устаревшего polling каждые 60 секунд)
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
- **SQLite** — хранение истории заданий и статуса апрува
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
- **Просмотр видео** прямо в веб-интерфейсе (по ссылке на готовый файл)
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
- **Апрув заданий** с копированием видео на SMB-шару (без монтирования, с поддержкой Active Directory)
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
- **Уведомления в реальном времени** о старте/завершении генерации, ошибках
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
- **Улучшенный веб-интерфейс** с кнопками апрува и иконками превью
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
- **Оптимизация памяти** — строки таблицы обрабатываются потоково, неиспользуемые логотипы и ассеты удаляются после генерации. Пиковое потребление памяти достигается только в момент загрузки и парсинга Excel, после чего память освобождается. Это позволяет работать с таблицами большого объёма (десятки тысяч строк) и обеспечивает стабильную работу в режиме долгоживущего сервиса.
|
||||||
|
- Полная автономность — все статические ресурсы (HTML, CSS, Font Awesome, логотип, favicon) встроены в бинарный файл.
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий** для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга After Effects.
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
|
||||||
After Effects (лицензия MIT).
|
|
||||||
|
|
||||||
### Режимы работы
|
### Режимы работы
|
||||||
|
|
||||||
@@ -37,154 +36,147 @@ After Effects (лицензия MIT).
|
|||||||
- Мониторинг завершения и выход
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
2. **Веб-сервер** (`--web`):
|
2. **Веб-сервер** (`--web`):
|
||||||
- Запуск веб-интерфейса на порту `:3000`
|
- Запуск веб-интерфейса на порту `:3000` (настраивается)
|
||||||
- Управление заданиями через браузер
|
- Управление заданиями через браузер с WebSocket-обновлениями
|
||||||
- Автообновление статуса каждую минуту
|
- Просмотр видео и апрув готовых роликов
|
||||||
|
|
||||||
## Особенности
|
## Особенности
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
- **Полная автономность** — не требует выхода в интернет, все ресурсы встроены в бинарник
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
- **Интеграция с Synology** — аутентификация и загрузка файлов с NAS Synology (локальные и AD-учётные записи)
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
- **Экспорт офисных таблиц** — автоматическое преобразование `.osheet` в Excel
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
- **Гибкий парсинг данных** — динамический парсинг листов с обнаружением заголовков
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
- **Множественная генерация вариантов** — "Сегодня", "Завтра" и датированные версии
|
||||||
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
- **Умное управление логотипами** — автоматическое разрешение и масштабирование
|
||||||
- **REST API**: Программный доступ к управлению заданиями
|
- **Веб-интерфейс с WebSocket** — мгновенные обновления, просмотр видео, апрув
|
||||||
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
- **REST API** — программное управление заданиями
|
||||||
|
- **SQLite** — хранение истории и статуса апрува
|
||||||
|
- **Поддержка SMB** — копирование утверждённых видео на сетевую шару (с AD-аутентификацией)
|
||||||
|
- **Профессиональное логирование** — структурированный журнал с настройкой уровня
|
||||||
|
- **Эффективная работа с памятью** — потоковая обработка строк, своевременное удаление неиспользуемых данных (логотипы, ассеты) позволяет обрабатывать таблицы с десятками тысяч строк без утечек памяти
|
||||||
|
|
||||||
## Предварительные требования
|
## Предварительные требования
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
- Rust 1.70 или выше
|
||||||
- Доступ к NAS Synology с пакетами File Station и Office
|
- Доступ к NAS Synology с пакетами File Station и Office (локальный или доменный пользователь)
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
- Экземпляр _server_ и _worker_ (не менее одного) Nexrender
|
||||||
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
- Шаблоны After Effects (формат .aep или .aepx, совместимые с версией AE на worker)
|
||||||
|
- (Для апрува) Доступ к SMB-шаре с правами на запись
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
git clone https://git.example.com/team/ae_anons.git
|
||||||
|
cd ae_anons
|
||||||
```bash
|
cargo build --release
|
||||||
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
cp .env.example .env
|
||||||
cd ae_anons
|
# отредактируйте .env согласно вашей инфраструктуре
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сборка проекта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем отредактируйте файл .env:
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Однократная обработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск с настройками по умолчанию
|
|
||||||
./target/release/ae_anons
|
|
||||||
|
|
||||||
# Явно указать однократный режим
|
|
||||||
./target/release/ae_anons --once
|
|
||||||
|
|
||||||
# С отладочным логированием
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### Веб-сервер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
|
||||||
./target/release/ae_anons --web
|
|
||||||
|
|
||||||
# Или с указанием другого порта через .env файл
|
|
||||||
# WEB_PORT=8080 ./target/release/ae_anons --web
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Веб-интерфейс
|
|
||||||
|
|
||||||
После запуска веб-сервера откройте браузер:
|
|
||||||
|
|
||||||
- Главная страница: <http://localhost:3000>
|
|
||||||
|
|
||||||
- API эндпоинты:
|
|
||||||
- `GET /api/jobs` - список всех заданий
|
|
||||||
- `POST /api/generate` - запуск генерации
|
|
||||||
- `POST /api/cleanup` - очистка завершённых
|
|
||||||
- `POST /api/jobs/stop-all` - остановка активных
|
|
||||||
- `GET /api/status` - статус сервера
|
|
||||||
|
|
||||||
## Уровни логирования
|
|
||||||
|
|
||||||
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
|
||||||
|
|
||||||
- **error** - Только критические ошибки
|
|
||||||
- **warn** - Предупреждения и ошибки
|
|
||||||
- **info** - Общие операционные сообщения (по умолчанию)
|
|
||||||
- **debug** - Детальная информация о процессе обработки
|
|
||||||
- **trace** - Полное отладочное логирование с деталями API вызовов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
|
|
||||||
### Переменные окружения
|
Все настройки задаются через переменные окружения в файле `.env`.
|
||||||
|
|
||||||
|
### Основные переменные
|
||||||
|
|
||||||
| Переменная | Обязательна | Описание |
|
| Переменная | Обязательна | Описание |
|
||||||
|-------------------------|-------------|------------------------------------------------------|
|
|-------------------------|-------------|---------------------------------------------------------------------------------|
|
||||||
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
| `NAS_FQDN` | Да | URL Synology NAS (*) |
|
||||||
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
| `NAS_USER` | Да | Имя пользователя (локального или доменного, например `DOMAIN\username`) |
|
||||||
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
| `NAS_PASS` | Да | Пароль |
|
||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
| `NAS_FILE` | Да | Путь к `.osheet` файлу на NAS (например, `/Team Folder/schedule.osheet`) |
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
| `NEXRENDER_API_URL` | Да | API Nexrender (например, `http://nexrender:3050/api/v1/jobs`) |
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
| `OUTPUT_FOLDER` | Да | Папка, куда Nexrender сохраняет готовые видео (локальный путь) |
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд (например, `file:///templates/double.aepx`) |
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE |
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE |
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
| `WEB_PORT` | Нет | Порт веб-сервера (по умолчанию 3000) |
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
| `RUST_LOG` | Нет | Уровень логирования (info, debug, trace) |
|
||||||
|
| `SMB_UPLOAD_URL` | Да(**) | SMB-URL для апрува (например, `smb://storage/approved`) |
|
||||||
|
| `SMB_USERNAME` | Да(**) | Имя пользователя для SMB (локальный или `DOMAIN\user`) |
|
||||||
|
| `SMB_PASSWORD` | Да(**) | Пароль для SMB |
|
||||||
|
| `DATABASE_URL` | Нет | Путь к SQLite БД (по умолчанию `sqlite:ae_anons.db`) |
|
||||||
|
|
||||||
|
> (*) — для `NAS_FQDN` можно указывать протокол `http://` или `https://`. Безопаснее `https`, хотя для локальной сети допустим и `http`. Если NAS доступен по стандартному HTTPS-порту 443, протокол можно опустить (например, `nas.company.local`). Иначе указывайте полный URL с портом: `https://nas.company.local:5001` или `http://nas.local:5000`.
|
||||||
|
>
|
||||||
|
> (**) — обязательно, если используется функция апрува (веб-режим). В режиме `--once` апрув не требуется.
|
||||||
|
|
||||||
### Пример файла `.env`
|
### Пример файла `.env`
|
||||||
|
|
||||||
```env
|
# Synology NAS
|
||||||
# Synology NAS
|
NAS_FQDN="https://nas.company.local:5001"
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
NAS_USER="DOMAIN\\service_account"
|
||||||
NAS_USER="your_username"
|
NAS_PASS="strongpassword"
|
||||||
NAS_PASS="your_password"
|
NAS_FILE="/Team Folder/Anonsy/sport.osheet"
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging
|
# Nexrender
|
||||||
RUST_LOG="info"
|
NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
|
||||||
|
OUTPUT_FOLDER="/mnt/nexrender/output"
|
||||||
|
|
||||||
# Web Server
|
# After Effects Templates
|
||||||
WEB_PORT="3000"
|
TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
|
||||||
|
TEMPLATE_SINGLE_SRC="file:///mnt/templates/single_team.aepx"
|
||||||
|
TEMPLATE_COMPOSITION="main"
|
||||||
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
|
||||||
# Nexrender
|
# Web server
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
WEB_PORT="3000"
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
RUST_LOG="info"
|
||||||
|
|
||||||
# After Effects Templates
|
# SMB для апрува (AD-совместимо)
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
SMB_UPLOAD_URL="smb://storage.company.local/approved_videos"
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
SMB_USERNAME="DOMAIN\\service_account"
|
||||||
TEMPLATE_COMPOSITION="main"
|
SMB_PASSWORD="strongpassword"
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
# Database
|
||||||
```
|
DATABASE_URL="sqlite:/var/lib/ae_anons/ae_anons.db"
|
||||||
|
|
||||||
|
> **Примечание об аутентификации:**
|
||||||
|
> Synology API и SMB-библиотека `smb` поддерживают как локальных пользователей NAS, так и доменных (Active Directory). Для доменных пользователей используйте формат `DOMAIN\username` (обратный слеш необходимо экранировать в `.env` как `\\`). Проверено на Synology DSM 7.x и Samba AD.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Однократная обработка (cron или ручной запуск)
|
||||||
|
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
### Веб-сервер (интерактивный режим)
|
||||||
|
|
||||||
|
./target/release/ae_anons --web
|
||||||
|
|
||||||
|
После запуска откройте браузер: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
- **Главная страница** — таблица всех заданий с полями: UID, имя файла, статус, превью (иконка видео), кнопка апрува, дата создания.
|
||||||
|
- **Обновления в реальном времени** через WebSocket — статусы заданий меняются мгновенно без перезагрузки страницы.
|
||||||
|
- **Кнопка «Generate»** — запускает парсинг таблицы Synology и создание новых заданий. Во время генерации кнопка блокируется, при завершении приходит уведомление.
|
||||||
|
- **Кнопка «Cleanup»** — удаляет из Nexrender задания со статусом finished/error.
|
||||||
|
- **Кнопка «Stop all»** — останавливает все активные (queued/started/processing) задания.
|
||||||
|
- **Просмотр видео** — клик по иконке видео открывает готовый файл в новой вкладке (поддерживается любой браузерный просмотр MP4).
|
||||||
|
- **Апрув** — клик по ✅ копирует видео на SMB-шару (путь из `SMB_UPLOAD_URL`) и помечает задание как approved. Повторный апрув невозможен.
|
||||||
|
|
||||||
|
### API эндпоинты
|
||||||
|
|
||||||
|
- `GET /api/jobs` — список всех заданий (из SQLite)
|
||||||
|
- `POST /api/generate` — запуск генерации
|
||||||
|
- `POST /api/cleanup` — очистка завершённых
|
||||||
|
- `POST /api/jobs/stop-all` — остановка активных
|
||||||
|
- `GET /api/status` — статус сервера
|
||||||
|
- `GET /api/video/:uid` — просмотр видеофайла
|
||||||
|
- `POST /api/approve/:uid` — апрув (копирование на SMB)
|
||||||
|
- `WS /ws` — WebSocket для получения событий (`JobUpdated`, `GenerationStarted`, `GenerationFinished`, `Error`)
|
||||||
|
|
||||||
|
## Уровни логирования
|
||||||
|
|
||||||
|
`RUST_LOG=debug ./ae_anons --web`
|
||||||
|
|
||||||
|
- **error** — только критические ошибки
|
||||||
|
- **warn** — предупреждения
|
||||||
|
- **info** — стандартная информация (по умолчанию)
|
||||||
|
- **debug** — детали API-вызовов, парсинга, SMB-операций
|
||||||
|
- **trace** — максимальная детализация
|
||||||
|
|
||||||
## Структура электронной таблицы
|
## Структура электронной таблицы
|
||||||
|
|
||||||
@@ -243,12 +235,7 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
Имена команд поддерживают два типа разделителей хэш #:
|
Имена команд поддерживают два типа разделителей хэш #:
|
||||||
|
|
||||||
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
||||||
Примеры:
|
2. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
||||||
|
|
||||||
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
|
||||||
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
|
||||||
|
|
||||||
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
|
||||||
|
|
||||||
Формат: `DisplayName#UniqueID#TargetSize`
|
Формат: `DisplayName#UniqueID#TargetSize`
|
||||||
|
|
||||||
@@ -258,422 +245,70 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
||||||
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
||||||
|
|
||||||
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
|
||||||
|
|
||||||
## Выходные файлы
|
## Выходные файлы
|
||||||
|
|
||||||
### Рендеренные видео
|
Рендеренные видео сохраняются в `OUTPUT_FOLDER` по шаблону:
|
||||||
|
`YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4`
|
||||||
|
|
||||||
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
При апруве файл копируется в SMB-шару с тем же именем.
|
||||||
|
|
||||||
```shell
|
## Рабочий процесс (веб-режим)
|
||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена файлов транслитерируются в латиницу.
|
1. Пользователь заполняет таблицу Synology Office.
|
||||||
**Примеры:**
|
2. Нажимает «Generate» в веб-интерфейсе.
|
||||||
|
3. Сервер аутентифицируется на NAS, скачивает и парсит Excel.
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
4. Генерирует задания Nexrender, сохраняет их в SQLite и отправляет в Nexrender.
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
5. WebSocket уведомляет клиент о новых заданиях.
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
6. Nexrender рендерит видео, статус задания обновляется (периодический опрос или webhook).
|
||||||
|
7. Когда видео готово, пользователь видит иконку превью, может посмотреть видео и нажать апрув.
|
||||||
## Рабочий процесс
|
8. При апруве видео копируется на SMB-шару, задание помечается как approved.
|
||||||
|
|
||||||
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
|
||||||
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
|
||||||
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
|
||||||
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
|
||||||
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
|
||||||
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
|
||||||
7. **Отправка**: Отправка заданий на API Nexrender
|
|
||||||
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
|
||||||
9. **Завершение сеанса**: Закрытие сессии Synology
|
|
||||||
|
|
||||||
## Требования к шаблонам After Effects
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
Не изменились — все слои `DATA`, `TIME_H`, `TIME_M`, `LEAGUE`, `SPORT`, `TEAMS`, `TEAM_A_LOGO`, `TEAM_B_LOGO`, `CHANELL`, `TOP` должны присутствовать согласно типу шаблона (DOUBLE/SINGLE). Поддержка масштабирования логотипов через выражения After Effects — осталась.
|
||||||
|
|
||||||
### Файлы шаблонов
|
|
||||||
|
|
||||||
Настраиваются через переменные окружения:
|
|
||||||
|
|
||||||
| Переменная | Назначение | Пример значения |
|
|
||||||
|-------------------------|-------------------------------------|------------------------------------|
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
|
||||||
|
|
||||||
### Обязательные слои в шаблонах
|
|
||||||
|
|
||||||
#### Текстовые слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
|
||||||
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
|
||||||
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
|
||||||
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
|
||||||
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
|
||||||
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
|
||||||
| `SPORT` | ✅ | ✅ | Категория спорта |
|
|
||||||
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
|
||||||
|
|
||||||
#### Слои с изображениями
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|----------------|:-------------:|:-------------:|-----------------------------|
|
|
||||||
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
|
||||||
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
|
||||||
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
|
||||||
|
|
||||||
#### Видео слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|----------------------------|
|
|
||||||
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
|
||||||
|
|
||||||
### Особенности шаблонов
|
|
||||||
|
|
||||||
#### Шаблон DOUBLE (две команды)
|
|
||||||
|
|
||||||
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
|
||||||
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
|
||||||
|
|
||||||
#### Шаблон SINGLE (одна команда)
|
|
||||||
|
|
||||||
Используется когда заполнено только одно поле команды.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
|
||||||
- Только логотип `TEAM_A_LOGO`
|
|
||||||
|
|
||||||
### Автоматические корректировки
|
|
||||||
|
|
||||||
AE Anons автоматически применяет следующие настройки к слоям:
|
|
||||||
|
|
||||||
#### Слой DATA
|
|
||||||
|
|
||||||
В зависимости от отображаемого текста и типа шаблона:
|
|
||||||
|
|
||||||
| Текст | Шаблон | Font Size | Anchor Point |
|
|
||||||
|------------|----------|-----------|--------------|
|
|
||||||
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
|
||||||
| "завтра" | DOUBLE | 115 | [0, 25] |
|
|
||||||
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
|
||||||
|
|
||||||
#### Слои TIME_H, TIME_M, TIME
|
|
||||||
|
|
||||||
Корректировка Anchor Point в зависимости от длины текста:
|
|
||||||
|
|
||||||
| Условие | Anchor Point |
|
|
||||||
|-------------------|--------------|
|
|
||||||
| 1 символ | [60, 0] |
|
|
||||||
| 2 символа, <20 | [20, 0] |
|
|
||||||
|
|
||||||
#### Слой LEAGUE
|
|
||||||
|
|
||||||
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
|
||||||
|
|
||||||
#### Слой TEAMS
|
|
||||||
|
|
||||||
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
|
||||||
|
|
||||||
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
|
||||||
|
|
||||||
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (width > height) {
|
|
||||||
max_size = width;
|
|
||||||
} else {
|
|
||||||
max_size = height;
|
|
||||||
}
|
|
||||||
var real_size = 550 / max_size * 100;
|
|
||||||
[real_size, real_size]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример структуры слоёв в After Effects
|
|
||||||
|
|
||||||
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
📁 main (композиция)
|
|
||||||
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
|
||||||
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
|
||||||
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
|
||||||
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
|
||||||
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
|
||||||
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
|
||||||
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
|
||||||
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
|
||||||
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
|
||||||
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
|
||||||
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
|
||||||
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Формат проекта и совместимость с After Effects
|
|
||||||
|
|
||||||
**Поддерживаемые форматы проектов:**
|
|
||||||
|
|
||||||
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
|
||||||
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
|
||||||
|
|
||||||
**Совместимость версий After Effects:**
|
|
||||||
|
|
||||||
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
|
||||||
|----------------------|------------------------|-------------------------------------------------|
|
|
||||||
| CS 5.5 | `.aep` | Базовая поддержка |
|
|
||||||
| CC / CC 2014 | `.aep` | Полная поддержка |
|
|
||||||
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
|
||||||
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
|
||||||
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
|
||||||
|
|
||||||
> **⚠️ Важно для After Effects 2023+:**
|
|
||||||
>
|
|
||||||
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
|
||||||
>
|
|
||||||
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
|
||||||
|
|
||||||
### Настройки композиции
|
|
||||||
|
|
||||||
| Параметр | Переменная окружения | Пример значения | Описание |
|
|
||||||
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
|
||||||
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
|
||||||
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
|
||||||
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
|
||||||
|
|
||||||
**Рекомендации по выбору формата:**
|
|
||||||
|
|
||||||
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
|
||||||
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
|
||||||
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
|
||||||
|
|
||||||
**Источники:**
|
|
||||||
|
|
||||||
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
|
||||||
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
|
||||||
|
|
||||||
## Устранение неисправностей
|
## Устранение неисправностей
|
||||||
|
|
||||||
### Частые проблемы
|
### WebSocket не работает
|
||||||
|
|
||||||
#### Ошибка подключения к NAS
|
- Проверьте, что браузер поддерживает WebSocket (все современные поддерживают).
|
||||||
|
- При использовании прокси (nginx) необходимо настроить Upgrade заголовки.
|
||||||
|
|
||||||
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
### Ошибка аутентификации на NAS для доменного пользователя
|
||||||
- Проверьте сетевую связность с NAS
|
|
||||||
- Убедитесь, что сервисы File Station и Office включены
|
|
||||||
|
|
||||||
#### Файл не найден
|
- Убедитесь, что в `.env` указано `DOMAIN\\username` (двойной обратный слеш).
|
||||||
|
- Проверьте, что NAS настроен на приём доменных учётных записей (DSM → Домен/LDAP).
|
||||||
|
|
||||||
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
### Ошибка SMB подключения
|
||||||
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
|
||||||
- Проверьте права доступа к файлу для учетной записи сервиса
|
|
||||||
|
|
||||||
#### Ошибка отправки задания Nexrender
|
- Проверьте доступность шары: `smbclient -U DOMAIN/username -L //storage/`
|
||||||
|
- Убедитесь, что в URL используется `smb://` протокол, путь без лишних слешей.
|
||||||
|
- Время на сервере и клиенте должно быть синхронизировано (SMB требует этого).
|
||||||
|
|
||||||
- Подтвердите доступность сервера Nexrender
|
### БД SQLite блокирована
|
||||||
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
|
||||||
- Проверьте существование файлов шаблонов на узлах рендеринга
|
|
||||||
|
|
||||||
### Режим отладки
|
- При конкурентных запросах SQLite может выдавать `database is locked`. Для веб-сервера с одним процессом это маловероятно. Если возникает — используйте `sqlite://:memory:` для тестов или настройте WAL-режим.
|
||||||
|
|
||||||
Включите подробное логирование для детального анализа:
|
### Высокое потребление памяти
|
||||||
|
|
||||||
```bash
|
- Система спроектирована так, чтобы минимизировать удержание данных. Если вы наблюдаете рост памяти, убедитесь, что вы используете последнюю версию. При работе с очень большими таблицами (сотни тысяч строк) может потребоваться увеличить лимит строк в `processor.rs` (константа 10000).
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
Это выведет:
|
|
||||||
|
|
||||||
- Детали API запросов/ответов
|
|
||||||
- Информацию о парсинге листов
|
|
||||||
- Детали создания заданий
|
|
||||||
- Процесс разрешения ресурсов
|
|
||||||
|
|
||||||
## Возможные ограничения по производительности
|
|
||||||
|
|
||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
|
||||||
|
|
||||||
- Храните учетные данные только в файле `.env` (исключен из git)
|
|
||||||
- Используйте специальные аккаунты с минимально необходимыми правами
|
|
||||||
- Сессии Synology завершаются после выполнения
|
|
||||||
- HTTPS рекомендуется для подключений NAS в продакшене
|
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
### Структура кода
|
Все статические ресурсы (HTML, CSS, шрифты Font Awesome, логотип, favicon) встроены в бинарник с помощью `include_str!` и `include_bytes!`. Для разработки можно редактировать файлы в `src/static/`, но при сборке они компилируются внутрь исполняемого файла.
|
||||||
|
|
||||||
```shell
|
### Сборка для production
|
||||||
ae_anons/
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
|
||||||
├── Cargo.toml
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
├── assets/
|
|
||||||
│ └── logo.png
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Точка входа, CLI
|
|
||||||
├── config.rs # Конфигурация из .env
|
|
||||||
├── nexrender.rs # Модели заданий Nexrender
|
|
||||||
├── synology.rs # Клиент Synology API
|
|
||||||
├── processor.rs # Логика обработки
|
|
||||||
├── web.rs # Веб-сервер и API
|
|
||||||
└── static/
|
|
||||||
├── index.html # Веб-интерфейс
|
|
||||||
└── style.css # Стили
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
cargo build --release
|
||||||
|
strip target/release/ae_anons # уменьшает размер
|
||||||
|
|
||||||
### Основные зависимости
|
Итоговый бинарник можно переносить на любой Linux-сервер без дополнительных зависимостей (кроме libc и openssl, если не используется статическая сборка).
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|---------|-----------------------------------------|
|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
|
||||||
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
|
||||||
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
|
||||||
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
|
||||||
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
|
||||||
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
|
||||||
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
|
||||||
| regex | 1.11 | Регулярные выражения |
|
|
||||||
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
|
||||||
| bytes | 1.9 | Работа с байтовыми данными |
|
|
||||||
| futures | 0.3 | Асинхронные примитивы |
|
|
||||||
|
|
||||||
### Веб-сервер и CLI
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|--------|-----------------------------------------|
|
|
||||||
| axum | 0.8 | Веб-фреймворк для REST API |
|
|
||||||
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
|
||||||
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
|
||||||
| askama | 0.15 | Шаблонизация (опционально) |
|
|
||||||
| clap | 4.5 | Парсинг аргументов командной строки |
|
|
||||||
|
|
||||||
### Платформозависимые зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Платформа | Назначение |
|
|
||||||
|--------------------|--------|-----------|----------------------------------|
|
|
||||||
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
|
||||||
|
|
||||||
### Nexrender
|
|
||||||
|
|
||||||
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
|
||||||
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
|
||||||
для автоматизации After Effects.
|
|
||||||
|
|
||||||
### Font Awesome Free 6.4.0
|
|
||||||
|
|
||||||
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
|
||||||
|
|
||||||
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
|
||||||
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
|
||||||
|
|
||||||
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
**AE Anons** — [License](LICENSE)
|
**AE Anons** — MIT
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
**Nexrender** — MIT
|
||||||
|
**Font Awesome Free** — CC BY 4.0 (иконки) и SIL OFL 1.1 (шрифты)
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
|
||||||
|
|
||||||
**Разрешается:**
|
|
||||||
|
|
||||||
- ✅ Использовать в коммерческих целях
|
|
||||||
- ✅ Изменять исходный код
|
|
||||||
- ✅ Распространять копии
|
|
||||||
- ✅ Использовать приватно
|
|
||||||
|
|
||||||
**Требуется:**
|
|
||||||
|
|
||||||
- Сохранять копирайт и текст лицензии
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для вопросов и запросов функций обращайтесь:
|
|
||||||
|
|
||||||
- <a.barabanov@tvstart.ru>
|
|
||||||
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
## История изменений
|
|
||||||
|
|
||||||
### v0.2.6 (текущая)
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
### v0.2.5
|
|
||||||
|
|
||||||
- Оптимизация памяти: очистка неиспользуемых логотипов
|
|
||||||
- Групповая отправка заданий по 3 для сохранения порядка
|
|
||||||
- Добавлен UID в логи отправки
|
|
||||||
|
|
||||||
### v0.2.4
|
|
||||||
|
|
||||||
- Полностью автономный режим (встроенный Font Awesome)
|
|
||||||
- Добавлен favicon
|
|
||||||
- Все статические ресурсы встроены в бинарник
|
|
||||||
|
|
||||||
### v0.2.3
|
|
||||||
|
|
||||||
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
|
||||||
- Тёмная/светлая тема с автоопределением ОС
|
|
||||||
- Сортировка по всем колонкам в интерфейсе
|
|
||||||
- Фильтрация по имени файла/UID
|
|
||||||
- Остановка всех активных заданий
|
|
||||||
- Автообновление каждые 60 секунд
|
|
||||||
- Улучшенное debug-логирование
|
|
||||||
- Исправлена обработка UNC/SMB путей
|
|
||||||
|
|
||||||
### v0.2.2
|
|
||||||
|
|
||||||
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
|
||||||
- Улучшена цветовая схема
|
|
||||||
- Добавлена сортировка по всем колонкам
|
|
||||||
- Вынесены стили в отдельный CSS файл
|
|
||||||
|
|
||||||
### v0.2.0
|
|
||||||
|
|
||||||
- Добавлен веб-интерфейс для управления заданиями
|
|
||||||
- Реализован REST API
|
|
||||||
- Добавлена поддержка тёмной/светлой темы
|
|
||||||
- Автообновление статуса заданий
|
|
||||||
- Возможность остановки всех активных заданий
|
|
||||||
|
|
||||||
### v0.1.1
|
|
||||||
|
|
||||||
- Оптимизирована работа с памятью
|
|
||||||
- Убрана функция создания .json
|
|
||||||
|
|
||||||
### v0.1.0
|
|
||||||
|
|
||||||
- Первый выпуск
|
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
|||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging (optional, defaults to info)
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server (optional, defaults to 3000)
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 [Your Name or Company]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
|
|
||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
|
||||||
<p align="left">
|
|
||||||
<img src="assets/logo.png" alt="AE Anons Logo" width="250"/>
|
|
||||||
</p>
|
|
||||||
<!-- markdownlint-enable MD033 -->
|
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
[](https://www.rust-lang.org/)
|
|
||||||
[](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
|
||||||
|
|
||||||
## Новое в версии v0.2.6
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
|
||||||
After Effects (лицензия MIT).
|
|
||||||
|
|
||||||
### Режимы работы
|
|
||||||
|
|
||||||
1. **Однократная обработка** (`--once` или без флагов):
|
|
||||||
- Подключение к NAS Synology
|
|
||||||
- Парсинг Excel данных
|
|
||||||
- Генерация и отправка заданий Nexrender
|
|
||||||
- Мониторинг завершения и выход
|
|
||||||
|
|
||||||
2. **Веб-сервер** (`--web`):
|
|
||||||
- Запуск веб-интерфейса на порту `:3000`
|
|
||||||
- Управление заданиями через браузер
|
|
||||||
- Автообновление статуса каждую минуту
|
|
||||||
|
|
||||||
## Особенности
|
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
|
||||||
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
|
||||||
- **REST API**: Программный доступ к управлению заданиями
|
|
||||||
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
|
||||||
|
|
||||||
## Предварительные требования
|
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
|
||||||
- Доступ к NAS Synology с пакетами File Station и Office
|
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
|
||||||
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
|
||||||
cd ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сборка проекта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем отредактируйте файл .env:
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Однократная обработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск с настройками по умолчанию
|
|
||||||
./target/release/ae_anons
|
|
||||||
|
|
||||||
# Явно указать однократный режим
|
|
||||||
./target/release/ae_anons --once
|
|
||||||
|
|
||||||
# С отладочным логированием
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### Веб-сервер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
|
||||||
./target/release/ae_anons --web
|
|
||||||
|
|
||||||
# Или с указанием другого порта через .env файл
|
|
||||||
# WEB_PORT=8080 ./target/release/ae_anons --web
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Веб-интерфейс
|
|
||||||
|
|
||||||
После запуска веб-сервера откройте браузер:
|
|
||||||
|
|
||||||
- Главная страница: <http://localhost:3000>
|
|
||||||
|
|
||||||
- API эндпоинты:
|
|
||||||
- `GET /api/jobs` - список всех заданий
|
|
||||||
- `POST /api/generate` - запуск генерации
|
|
||||||
- `POST /api/cleanup` - очистка завершённых
|
|
||||||
- `POST /api/jobs/stop-all` - остановка активных
|
|
||||||
- `GET /api/status` - статус сервера
|
|
||||||
|
|
||||||
## Уровни логирования
|
|
||||||
|
|
||||||
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
|
||||||
|
|
||||||
- **error** - Только критические ошибки
|
|
||||||
- **warn** - Предупреждения и ошибки
|
|
||||||
- **info** - Общие операционные сообщения (по умолчанию)
|
|
||||||
- **debug** - Детальная информация о процессе обработки
|
|
||||||
- **trace** - Полное отладочное логирование с деталями API вызовов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | Обязательна | Описание |
|
|
||||||
|-------------------------|-------------|------------------------------------------------------|
|
|
||||||
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
|
||||||
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
|
||||||
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
|
||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
|
||||||
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
|
||||||
|
|
||||||
### Пример файла `.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура электронной таблицы
|
|
||||||
|
|
||||||
Файл Excel должен содержать следующие листы со специфичными структурами:
|
|
||||||
|
|
||||||
### Лист "SPORT"
|
|
||||||
|
|
||||||
Связывает названия видов спорта с соответствующими видео пакетами.
|
|
||||||
|
|
||||||
| SPORT | LINK |
|
|
||||||
|----------------|---------------------------------------------|
|
|
||||||
| Без оформления | \\server\share\path\to\null.mov |
|
|
||||||
| Футбол | \\server\share\path\to\football_pack.mov |
|
|
||||||
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
|
||||||
|
|
||||||
### Лист "TEAMS"
|
|
||||||
|
|
||||||
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
|
||||||
|
|
||||||
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
|
||||||
|
|
||||||
| TEAM | SPORT | LINK |
|
|
||||||
|-------------------|-----------|---------------------------------------------------|
|
|
||||||
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
|
||||||
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
|
||||||
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
|
||||||
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
|
||||||
|
|
||||||
### Лист "CHANELL"
|
|
||||||
|
|
||||||
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
|
||||||
|
|
||||||
| CHANELL | LINK |
|
|
||||||
|---------|-----------------------------------------|
|
|
||||||
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
|
||||||
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
|
||||||
|
|
||||||
### Лист "Start"
|
|
||||||
|
|
||||||
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
|
||||||
|
|
||||||
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
|
||||||
|
|
||||||
**Примечания:**
|
|
||||||
|
|
||||||
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
|
||||||
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
|
||||||
|
|
||||||
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
|
||||||
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
|
||||||
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
|
||||||
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
|
||||||
|
|
||||||
### Формат имен команд
|
|
||||||
|
|
||||||
Имена команд поддерживают два типа разделителей хэш #:
|
|
||||||
|
|
||||||
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
|
||||||
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
|
||||||
|
|
||||||
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
|
||||||
|
|
||||||
Формат: `DisplayName#UniqueID#TargetSize`
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Галатасарай#Turki` - только уникальный идентификатор
|
|
||||||
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
|
||||||
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
|
||||||
|
|
||||||
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
|
||||||
|
|
||||||
## Выходные файлы
|
|
||||||
|
|
||||||
### Рендеренные видео
|
|
||||||
|
|
||||||
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена файлов транслитерируются в латиницу.
|
|
||||||
**Примеры:**
|
|
||||||
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
|
||||||
|
|
||||||
## Рабочий процесс
|
|
||||||
|
|
||||||
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
|
||||||
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
|
||||||
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
|
||||||
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
|
||||||
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
|
||||||
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
|
||||||
7. **Отправка**: Отправка заданий на API Nexrender
|
|
||||||
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
|
||||||
9. **Завершение сеанса**: Закрытие сессии Synology
|
|
||||||
|
|
||||||
## Требования к шаблонам After Effects
|
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
|
||||||
|
|
||||||
### Файлы шаблонов
|
|
||||||
|
|
||||||
Настраиваются через переменные окружения:
|
|
||||||
|
|
||||||
| Переменная | Назначение | Пример значения |
|
|
||||||
|-------------------------|-------------------------------------|------------------------------------|
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
|
||||||
|
|
||||||
### Обязательные слои в шаблонах
|
|
||||||
|
|
||||||
#### Текстовые слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
|
||||||
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
|
||||||
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
|
||||||
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
|
||||||
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
|
||||||
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
|
||||||
| `SPORT` | ✅ | ✅ | Категория спорта |
|
|
||||||
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
|
||||||
|
|
||||||
#### Слои с изображениями
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|----------------|:-------------:|:-------------:|-----------------------------|
|
|
||||||
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
|
||||||
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
|
||||||
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
|
||||||
|
|
||||||
#### Видео слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|----------------------------|
|
|
||||||
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
|
||||||
|
|
||||||
### Особенности шаблонов
|
|
||||||
|
|
||||||
#### Шаблон DOUBLE (две команды)
|
|
||||||
|
|
||||||
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
|
||||||
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
|
||||||
|
|
||||||
#### Шаблон SINGLE (одна команда)
|
|
||||||
|
|
||||||
Используется когда заполнено только одно поле команды.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
|
||||||
- Только логотип `TEAM_A_LOGO`
|
|
||||||
|
|
||||||
### Автоматические корректировки
|
|
||||||
|
|
||||||
AE Anons автоматически применяет следующие настройки к слоям:
|
|
||||||
|
|
||||||
#### Слой DATA
|
|
||||||
|
|
||||||
В зависимости от отображаемого текста и типа шаблона:
|
|
||||||
|
|
||||||
| Текст | Шаблон | Font Size | Anchor Point |
|
|
||||||
|------------|----------|-----------|--------------|
|
|
||||||
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
|
||||||
| "завтра" | DOUBLE | 115 | [0, 25] |
|
|
||||||
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
|
||||||
|
|
||||||
#### Слои TIME_H, TIME_M, TIME
|
|
||||||
|
|
||||||
Корректировка Anchor Point в зависимости от длины текста:
|
|
||||||
|
|
||||||
| Условие | Anchor Point |
|
|
||||||
|-------------------|--------------|
|
|
||||||
| 1 символ | [60, 0] |
|
|
||||||
| 2 символа, <20 | [20, 0] |
|
|
||||||
|
|
||||||
#### Слой LEAGUE
|
|
||||||
|
|
||||||
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
|
||||||
|
|
||||||
#### Слой TEAMS
|
|
||||||
|
|
||||||
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
|
||||||
|
|
||||||
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
|
||||||
|
|
||||||
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (width > height) {
|
|
||||||
max_size = width;
|
|
||||||
} else {
|
|
||||||
max_size = height;
|
|
||||||
}
|
|
||||||
var real_size = 550 / max_size * 100;
|
|
||||||
[real_size, real_size]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример структуры слоёв в After Effects
|
|
||||||
|
|
||||||
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
📁 main (композиция)
|
|
||||||
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
|
||||||
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
|
||||||
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
|
||||||
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
|
||||||
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
|
||||||
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
|
||||||
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
|
||||||
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
|
||||||
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
|
||||||
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
|
||||||
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
|
||||||
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Формат проекта и совместимость с After Effects
|
|
||||||
|
|
||||||
**Поддерживаемые форматы проектов:**
|
|
||||||
|
|
||||||
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
|
||||||
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
|
||||||
|
|
||||||
**Совместимость версий After Effects:**
|
|
||||||
|
|
||||||
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
|
||||||
|----------------------|------------------------|-------------------------------------------------|
|
|
||||||
| CS 5.5 | `.aep` | Базовая поддержка |
|
|
||||||
| CC / CC 2014 | `.aep` | Полная поддержка |
|
|
||||||
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
|
||||||
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
|
||||||
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
|
||||||
|
|
||||||
> **⚠️ Важно для After Effects 2023+:**
|
|
||||||
>
|
|
||||||
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
|
||||||
>
|
|
||||||
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
|
||||||
|
|
||||||
### Настройки композиции
|
|
||||||
|
|
||||||
| Параметр | Переменная окружения | Пример значения | Описание |
|
|
||||||
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
|
||||||
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
|
||||||
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
|
||||||
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
|
||||||
|
|
||||||
**Рекомендации по выбору формата:**
|
|
||||||
|
|
||||||
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
|
||||||
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
|
||||||
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
|
||||||
|
|
||||||
**Источники:**
|
|
||||||
|
|
||||||
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
|
||||||
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
|
||||||
|
|
||||||
## Устранение неисправностей
|
|
||||||
|
|
||||||
### Частые проблемы
|
|
||||||
|
|
||||||
#### Ошибка подключения к NAS
|
|
||||||
|
|
||||||
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
|
||||||
- Проверьте сетевую связность с NAS
|
|
||||||
- Убедитесь, что сервисы File Station и Office включены
|
|
||||||
|
|
||||||
#### Файл не найден
|
|
||||||
|
|
||||||
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
|
||||||
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
|
||||||
- Проверьте права доступа к файлу для учетной записи сервиса
|
|
||||||
|
|
||||||
#### Ошибка отправки задания Nexrender
|
|
||||||
|
|
||||||
- Подтвердите доступность сервера Nexrender
|
|
||||||
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
|
||||||
- Проверьте существование файлов шаблонов на узлах рендеринга
|
|
||||||
|
|
||||||
### Режим отладки
|
|
||||||
|
|
||||||
Включите подробное логирование для детального анализа:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
Это выведет:
|
|
||||||
|
|
||||||
- Детали API запросов/ответов
|
|
||||||
- Информацию о парсинге листов
|
|
||||||
- Детали создания заданий
|
|
||||||
- Процесс разрешения ресурсов
|
|
||||||
|
|
||||||
## Возможные ограничения по производительности
|
|
||||||
|
|
||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
|
||||||
|
|
||||||
- Храните учетные данные только в файле `.env` (исключен из git)
|
|
||||||
- Используйте специальные аккаунты с минимально необходимыми правами
|
|
||||||
- Сессии Synology завершаются после выполнения
|
|
||||||
- HTTPS рекомендуется для подключений NAS в продакшене
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
### Структура кода
|
|
||||||
|
|
||||||
```shell
|
|
||||||
ae_anons/
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
|
||||||
├── Cargo.toml
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
├── assets/
|
|
||||||
│ └── logo.png
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Точка входа, CLI
|
|
||||||
├── config.rs # Конфигурация из .env
|
|
||||||
├── nexrender.rs # Модели заданий Nexrender
|
|
||||||
├── synology.rs # Клиент Synology API
|
|
||||||
├── processor.rs # Логика обработки
|
|
||||||
├── web.rs # Веб-сервер и API
|
|
||||||
└── static/
|
|
||||||
├── index.html # Веб-интерфейс
|
|
||||||
└── style.css # Стили
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
### Основные зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|---------|-----------------------------------------|
|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
|
||||||
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
|
||||||
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
|
||||||
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
|
||||||
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
|
||||||
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
|
||||||
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
|
||||||
| regex | 1.11 | Регулярные выражения |
|
|
||||||
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
|
||||||
| bytes | 1.9 | Работа с байтовыми данными |
|
|
||||||
| futures | 0.3 | Асинхронные примитивы |
|
|
||||||
|
|
||||||
### Веб-сервер и CLI
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|--------|-----------------------------------------|
|
|
||||||
| axum | 0.8 | Веб-фреймворк для REST API |
|
|
||||||
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
|
||||||
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
|
||||||
| askama | 0.15 | Шаблонизация (опционально) |
|
|
||||||
| clap | 4.5 | Парсинг аргументов командной строки |
|
|
||||||
|
|
||||||
### Платформозависимые зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Платформа | Назначение |
|
|
||||||
|--------------------|--------|-----------|----------------------------------|
|
|
||||||
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
|
||||||
|
|
||||||
### Nexrender
|
|
||||||
|
|
||||||
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
|
||||||
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
|
||||||
для автоматизации After Effects.
|
|
||||||
|
|
||||||
### Font Awesome Free 6.4.0
|
|
||||||
|
|
||||||
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
|
||||||
|
|
||||||
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
|
||||||
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
|
||||||
|
|
||||||
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
**AE Anons** — [License](LICENSE)
|
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
|
||||||
|
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
|
||||||
|
|
||||||
**Разрешается:**
|
|
||||||
|
|
||||||
- ✅ Использовать в коммерческих целях
|
|
||||||
- ✅ Изменять исходный код
|
|
||||||
- ✅ Распространять копии
|
|
||||||
- ✅ Использовать приватно
|
|
||||||
|
|
||||||
**Требуется:**
|
|
||||||
|
|
||||||
- Сохранять копирайт и текст лицензии
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для вопросов и запросов функций обращайтесь:
|
|
||||||
|
|
||||||
- <a.barabanov@tvstart.ru>
|
|
||||||
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
## История изменений
|
|
||||||
|
|
||||||
### v0.2.6 (текущая)
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
### v0.2.5
|
|
||||||
|
|
||||||
- Оптимизация памяти: очистка неиспользуемых логотипов
|
|
||||||
- Групповая отправка заданий по 3 для сохранения порядка
|
|
||||||
- Добавлен UID в логи отправки
|
|
||||||
|
|
||||||
### v0.2.4
|
|
||||||
|
|
||||||
- Полностью автономный режим (встроенный Font Awesome)
|
|
||||||
- Добавлен favicon
|
|
||||||
- Все статические ресурсы встроены в бинарник
|
|
||||||
|
|
||||||
### v0.2.3
|
|
||||||
|
|
||||||
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
|
||||||
- Тёмная/светлая тема с автоопределением ОС
|
|
||||||
- Сортировка по всем колонкам в интерфейсе
|
|
||||||
- Фильтрация по имени файла/UID
|
|
||||||
- Остановка всех активных заданий
|
|
||||||
- Автообновление каждые 60 секунд
|
|
||||||
- Улучшенное debug-логирование
|
|
||||||
- Исправлена обработка UNC/SMB путей
|
|
||||||
|
|
||||||
### v0.2.2
|
|
||||||
|
|
||||||
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
|
||||||
- Улучшена цветовая схема
|
|
||||||
- Добавлена сортировка по всем колонкам
|
|
||||||
- Вынесены стили в отдельный CSS файл
|
|
||||||
|
|
||||||
### v0.2.0
|
|
||||||
|
|
||||||
- Добавлен веб-интерфейс для управления заданиями
|
|
||||||
- Реализован REST API
|
|
||||||
- Добавлена поддержка тёмной/светлой темы
|
|
||||||
- Автообновление статуса заданий
|
|
||||||
- Возможность остановки всех активных заданий
|
|
||||||
|
|
||||||
### v0.1.1
|
|
||||||
|
|
||||||
- Оптимизирована работа с памятью
|
|
||||||
- Убрана функция создания .json
|
|
||||||
|
|
||||||
### v0.1.0
|
|
||||||
|
|
||||||
- Первый выпуск
|
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Made with 🦀 Rust and ☕ coffee
|
|
||||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
|||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging (optional, defaults to info)
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server (optional, defaults to 3000)
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 [Your Name or Company]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
|
|
||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
|
||||||
<p align="left">
|
|
||||||
<img src="assets/logo.png" alt="AE Anons Logo" width="250"/>
|
|
||||||
</p>
|
|
||||||
<!-- markdownlint-enable MD033 -->
|
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
[](https://www.rust-lang.org/)
|
|
||||||
[](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
|
||||||
|
|
||||||
## Новое в версии v0.2.6
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
|
||||||
After Effects (лицензия MIT).
|
|
||||||
|
|
||||||
### Режимы работы
|
|
||||||
|
|
||||||
1. **Однократная обработка** (`--once` или без флагов):
|
|
||||||
- Подключение к NAS Synology
|
|
||||||
- Парсинг Excel данных
|
|
||||||
- Генерация и отправка заданий Nexrender
|
|
||||||
- Мониторинг завершения и выход
|
|
||||||
|
|
||||||
2. **Веб-сервер** (`--web`):
|
|
||||||
- Запуск веб-интерфейса на порту `:3000`
|
|
||||||
- Управление заданиями через браузер
|
|
||||||
- Автообновление статуса каждую минуту
|
|
||||||
|
|
||||||
## Особенности
|
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
|
||||||
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
|
||||||
- **REST API**: Программный доступ к управлению заданиями
|
|
||||||
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
|
||||||
|
|
||||||
## Предварительные требования
|
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
|
||||||
- Доступ к NAS Synology с пакетами File Station и Office
|
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
|
||||||
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
|
||||||
cd ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сборка проекта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем отредактируйте файл .env:
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Однократная обработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск с настройками по умолчанию
|
|
||||||
./target/release/ae_anons
|
|
||||||
|
|
||||||
# Явно указать однократный режим
|
|
||||||
./target/release/ae_anons --once
|
|
||||||
|
|
||||||
# С отладочным логированием
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### Веб-сервер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
|
||||||
./target/release/ae_anons --web
|
|
||||||
|
|
||||||
# Или с указанием другого порта через .env файл
|
|
||||||
# WEB_PORT=8080 ./target/release/ae_anons --web
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Веб-интерфейс
|
|
||||||
|
|
||||||
После запуска веб-сервера откройте браузер:
|
|
||||||
|
|
||||||
- Главная страница: <http://localhost:3000>
|
|
||||||
|
|
||||||
- API эндпоинты:
|
|
||||||
- `GET /api/jobs` - список всех заданий
|
|
||||||
- `POST /api/generate` - запуск генерации
|
|
||||||
- `POST /api/cleanup` - очистка завершённых
|
|
||||||
- `POST /api/jobs/stop-all` - остановка активных
|
|
||||||
- `GET /api/status` - статус сервера
|
|
||||||
|
|
||||||
## Уровни логирования
|
|
||||||
|
|
||||||
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
|
||||||
|
|
||||||
- **error** - Только критические ошибки
|
|
||||||
- **warn** - Предупреждения и ошибки
|
|
||||||
- **info** - Общие операционные сообщения (по умолчанию)
|
|
||||||
- **debug** - Детальная информация о процессе обработки
|
|
||||||
- **trace** - Полное отладочное логирование с деталями API вызовов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | Обязательна | Описание |
|
|
||||||
|-------------------------|-------------|------------------------------------------------------|
|
|
||||||
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
|
||||||
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
|
||||||
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
|
||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
|
||||||
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
|
||||||
|
|
||||||
### Пример файла `.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура электронной таблицы
|
|
||||||
|
|
||||||
Файл Excel должен содержать следующие листы со специфичными структурами:
|
|
||||||
|
|
||||||
### Лист "SPORT"
|
|
||||||
|
|
||||||
Связывает названия видов спорта с соответствующими видео пакетами.
|
|
||||||
|
|
||||||
| SPORT | LINK |
|
|
||||||
|----------------|---------------------------------------------|
|
|
||||||
| Без оформления | \\server\share\path\to\null.mov |
|
|
||||||
| Футбол | \\server\share\path\to\football_pack.mov |
|
|
||||||
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
|
||||||
|
|
||||||
### Лист "TEAMS"
|
|
||||||
|
|
||||||
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
|
||||||
|
|
||||||
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
|
||||||
|
|
||||||
| TEAM | SPORT | LINK |
|
|
||||||
|-------------------|-----------|---------------------------------------------------|
|
|
||||||
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
|
||||||
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
|
||||||
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
|
||||||
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
|
||||||
|
|
||||||
### Лист "CHANELL"
|
|
||||||
|
|
||||||
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
|
||||||
|
|
||||||
| CHANELL | LINK |
|
|
||||||
|---------|-----------------------------------------|
|
|
||||||
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
|
||||||
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
|
||||||
|
|
||||||
### Лист "Start"
|
|
||||||
|
|
||||||
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
|
||||||
|
|
||||||
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
|
||||||
|
|
||||||
**Примечания:**
|
|
||||||
|
|
||||||
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
|
||||||
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
|
||||||
|
|
||||||
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
|
||||||
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
|
||||||
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
|
||||||
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
|
||||||
|
|
||||||
### Формат имен команд
|
|
||||||
|
|
||||||
Имена команд поддерживают два типа разделителей хэш #:
|
|
||||||
|
|
||||||
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
|
||||||
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
|
||||||
|
|
||||||
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
|
||||||
|
|
||||||
Формат: `DisplayName#UniqueID#TargetSize`
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Галатасарай#Turki` - только уникальный идентификатор
|
|
||||||
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
|
||||||
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
|
||||||
|
|
||||||
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
|
||||||
|
|
||||||
## Выходные файлы
|
|
||||||
|
|
||||||
### Рендеренные видео
|
|
||||||
|
|
||||||
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена файлов транслитерируются в латиницу.
|
|
||||||
**Примеры:**
|
|
||||||
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
|
||||||
|
|
||||||
## Рабочий процесс
|
|
||||||
|
|
||||||
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
|
||||||
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
|
||||||
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
|
||||||
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
|
||||||
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
|
||||||
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
|
||||||
7. **Отправка**: Отправка заданий на API Nexrender
|
|
||||||
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
|
||||||
9. **Завершение сеанса**: Закрытие сессии Synology
|
|
||||||
|
|
||||||
## Требования к шаблонам After Effects
|
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
|
||||||
|
|
||||||
### Файлы шаблонов
|
|
||||||
|
|
||||||
Настраиваются через переменные окружения:
|
|
||||||
|
|
||||||
| Переменная | Назначение | Пример значения |
|
|
||||||
|-------------------------|-------------------------------------|------------------------------------|
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
|
||||||
|
|
||||||
### Обязательные слои в шаблонах
|
|
||||||
|
|
||||||
#### Текстовые слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
|
||||||
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
|
||||||
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
|
||||||
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
|
||||||
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
|
||||||
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
|
||||||
| `SPORT` | ✅ | ✅ | Категория спорта |
|
|
||||||
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
|
||||||
|
|
||||||
#### Слои с изображениями
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|----------------|:-------------:|:-------------:|-----------------------------|
|
|
||||||
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
|
||||||
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
|
||||||
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
|
||||||
|
|
||||||
#### Видео слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|----------------------------|
|
|
||||||
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
|
||||||
|
|
||||||
### Особенности шаблонов
|
|
||||||
|
|
||||||
#### Шаблон DOUBLE (две команды)
|
|
||||||
|
|
||||||
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
|
||||||
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
|
||||||
|
|
||||||
#### Шаблон SINGLE (одна команда)
|
|
||||||
|
|
||||||
Используется когда заполнено только одно поле команды.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
|
||||||
- Только логотип `TEAM_A_LOGO`
|
|
||||||
|
|
||||||
### Автоматические корректировки
|
|
||||||
|
|
||||||
AE Anons автоматически применяет следующие настройки к слоям:
|
|
||||||
|
|
||||||
#### Слой DATA
|
|
||||||
|
|
||||||
В зависимости от отображаемого текста и типа шаблона:
|
|
||||||
|
|
||||||
| Текст | Шаблон | Font Size | Anchor Point |
|
|
||||||
|------------|----------|-----------|--------------|
|
|
||||||
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
|
||||||
| "завтра" | DOUBLE | 115 | [0, 25] |
|
|
||||||
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
|
||||||
|
|
||||||
#### Слои TIME_H, TIME_M, TIME
|
|
||||||
|
|
||||||
Корректировка Anchor Point в зависимости от длины текста:
|
|
||||||
|
|
||||||
| Условие | Anchor Point |
|
|
||||||
|-------------------|--------------|
|
|
||||||
| 1 символ | [60, 0] |
|
|
||||||
| 2 символа, <20 | [20, 0] |
|
|
||||||
|
|
||||||
#### Слой LEAGUE
|
|
||||||
|
|
||||||
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
|
||||||
|
|
||||||
#### Слой TEAMS
|
|
||||||
|
|
||||||
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
|
||||||
|
|
||||||
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
|
||||||
|
|
||||||
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (width > height) {
|
|
||||||
max_size = width;
|
|
||||||
} else {
|
|
||||||
max_size = height;
|
|
||||||
}
|
|
||||||
var real_size = 550 / max_size * 100;
|
|
||||||
[real_size, real_size]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример структуры слоёв в After Effects
|
|
||||||
|
|
||||||
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
📁 main (композиция)
|
|
||||||
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
|
||||||
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
|
||||||
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
|
||||||
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
|
||||||
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
|
||||||
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
|
||||||
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
|
||||||
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
|
||||||
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
|
||||||
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
|
||||||
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
|
||||||
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Формат проекта и совместимость с After Effects
|
|
||||||
|
|
||||||
**Поддерживаемые форматы проектов:**
|
|
||||||
|
|
||||||
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
|
||||||
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
|
||||||
|
|
||||||
**Совместимость версий After Effects:**
|
|
||||||
|
|
||||||
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
|
||||||
|----------------------|------------------------|-------------------------------------------------|
|
|
||||||
| CS 5.5 | `.aep` | Базовая поддержка |
|
|
||||||
| CC / CC 2014 | `.aep` | Полная поддержка |
|
|
||||||
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
|
||||||
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
|
||||||
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
|
||||||
|
|
||||||
> **⚠️ Важно для After Effects 2023+:**
|
|
||||||
>
|
|
||||||
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
|
||||||
>
|
|
||||||
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
|
||||||
|
|
||||||
### Настройки композиции
|
|
||||||
|
|
||||||
| Параметр | Переменная окружения | Пример значения | Описание |
|
|
||||||
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
|
||||||
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
|
||||||
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
|
||||||
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
|
||||||
|
|
||||||
**Рекомендации по выбору формата:**
|
|
||||||
|
|
||||||
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
|
||||||
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
|
||||||
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
|
||||||
|
|
||||||
**Источники:**
|
|
||||||
|
|
||||||
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
|
||||||
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
|
||||||
|
|
||||||
## Устранение неисправностей
|
|
||||||
|
|
||||||
### Частые проблемы
|
|
||||||
|
|
||||||
#### Ошибка подключения к NAS
|
|
||||||
|
|
||||||
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
|
||||||
- Проверьте сетевую связность с NAS
|
|
||||||
- Убедитесь, что сервисы File Station и Office включены
|
|
||||||
|
|
||||||
#### Файл не найден
|
|
||||||
|
|
||||||
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
|
||||||
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
|
||||||
- Проверьте права доступа к файлу для учетной записи сервиса
|
|
||||||
|
|
||||||
#### Ошибка отправки задания Nexrender
|
|
||||||
|
|
||||||
- Подтвердите доступность сервера Nexrender
|
|
||||||
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
|
||||||
- Проверьте существование файлов шаблонов на узлах рендеринга
|
|
||||||
|
|
||||||
### Режим отладки
|
|
||||||
|
|
||||||
Включите подробное логирование для детального анализа:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
Это выведет:
|
|
||||||
|
|
||||||
- Детали API запросов/ответов
|
|
||||||
- Информацию о парсинге листов
|
|
||||||
- Детали создания заданий
|
|
||||||
- Процесс разрешения ресурсов
|
|
||||||
|
|
||||||
## Возможные ограничения по производительности
|
|
||||||
|
|
||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
|
||||||
|
|
||||||
- Храните учетные данные только в файле `.env` (исключен из git)
|
|
||||||
- Используйте специальные аккаунты с минимально необходимыми правами
|
|
||||||
- Сессии Synology завершаются после выполнения
|
|
||||||
- HTTPS рекомендуется для подключений NAS в продакшене
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
### Структура кода
|
|
||||||
|
|
||||||
```shell
|
|
||||||
ae_anons/
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
|
||||||
├── Cargo.toml
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
├── assets/
|
|
||||||
│ └── logo.png
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Точка входа, CLI
|
|
||||||
├── config.rs # Конфигурация из .env
|
|
||||||
├── nexrender.rs # Модели заданий Nexrender
|
|
||||||
├── synology.rs # Клиент Synology API
|
|
||||||
├── processor.rs # Логика обработки
|
|
||||||
├── web.rs # Веб-сервер и API
|
|
||||||
└── static/
|
|
||||||
├── index.html # Веб-интерфейс
|
|
||||||
└── style.css # Стили
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
### Основные зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|---------|-----------------------------------------|
|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
|
||||||
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
|
||||||
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
|
||||||
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
|
||||||
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
|
||||||
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
|
||||||
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
|
||||||
| regex | 1.11 | Регулярные выражения |
|
|
||||||
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
|
||||||
| bytes | 1.9 | Работа с байтовыми данными |
|
|
||||||
| futures | 0.3 | Асинхронные примитивы |
|
|
||||||
|
|
||||||
### Веб-сервер и CLI
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|--------|-----------------------------------------|
|
|
||||||
| axum | 0.8 | Веб-фреймворк для REST API |
|
|
||||||
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
|
||||||
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
|
||||||
| askama | 0.15 | Шаблонизация (опционально) |
|
|
||||||
| clap | 4.5 | Парсинг аргументов командной строки |
|
|
||||||
|
|
||||||
### Платформозависимые зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Платформа | Назначение |
|
|
||||||
|--------------------|--------|-----------|----------------------------------|
|
|
||||||
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
|
||||||
|
|
||||||
### Nexrender
|
|
||||||
|
|
||||||
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
|
||||||
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
|
||||||
для автоматизации After Effects.
|
|
||||||
|
|
||||||
### Font Awesome Free 6.4.0
|
|
||||||
|
|
||||||
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
|
||||||
|
|
||||||
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
|
||||||
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
|
||||||
|
|
||||||
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
**AE Anons** — [License](LICENSE)
|
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
|
||||||
|
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
|
||||||
|
|
||||||
**Разрешается:**
|
|
||||||
|
|
||||||
- ✅ Использовать в коммерческих целях
|
|
||||||
- ✅ Изменять исходный код
|
|
||||||
- ✅ Распространять копии
|
|
||||||
- ✅ Использовать приватно
|
|
||||||
|
|
||||||
**Требуется:**
|
|
||||||
|
|
||||||
- Сохранять копирайт и текст лицензии
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для вопросов и запросов функций обращайтесь:
|
|
||||||
|
|
||||||
- <a.barabanov@tvstart.ru>
|
|
||||||
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
## История изменений
|
|
||||||
|
|
||||||
### v0.2.6 (текущая)
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
### v0.2.5
|
|
||||||
|
|
||||||
- Оптимизация памяти: очистка неиспользуемых логотипов
|
|
||||||
- Групповая отправка заданий по 3 для сохранения порядка
|
|
||||||
- Добавлен UID в логи отправки
|
|
||||||
|
|
||||||
### v0.2.4
|
|
||||||
|
|
||||||
- Полностью автономный режим (встроенный Font Awesome)
|
|
||||||
- Добавлен favicon
|
|
||||||
- Все статические ресурсы встроены в бинарник
|
|
||||||
|
|
||||||
### v0.2.3
|
|
||||||
|
|
||||||
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
|
||||||
- Тёмная/светлая тема с автоопределением ОС
|
|
||||||
- Сортировка по всем колонкам в интерфейсе
|
|
||||||
- Фильтрация по имени файла/UID
|
|
||||||
- Остановка всех активных заданий
|
|
||||||
- Автообновление каждые 60 секунд
|
|
||||||
- Улучшенное debug-логирование
|
|
||||||
- Исправлена обработка UNC/SMB путей
|
|
||||||
|
|
||||||
### v0.2.2
|
|
||||||
|
|
||||||
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
|
||||||
- Улучшена цветовая схема
|
|
||||||
- Добавлена сортировка по всем колонкам
|
|
||||||
- Вынесены стили в отдельный CSS файл
|
|
||||||
|
|
||||||
### v0.2.0
|
|
||||||
|
|
||||||
- Добавлен веб-интерфейс для управления заданиями
|
|
||||||
- Реализован REST API
|
|
||||||
- Добавлена поддержка тёмной/светлой темы
|
|
||||||
- Автообновление статуса заданий
|
|
||||||
- Возможность остановки всех активных заданий
|
|
||||||
|
|
||||||
### v0.1.1
|
|
||||||
|
|
||||||
- Оптимизирована работа с памятью
|
|
||||||
- Убрана функция создания .json
|
|
||||||
|
|
||||||
### v0.1.0
|
|
||||||
|
|
||||||
- Первый выпуск
|
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Made with 🦀 Rust and ☕ coffee
|
|
||||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
|||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging (optional, defaults to info)
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server (optional, defaults to 3000)
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 [Your Name or Company]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
|
|
||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
|
||||||
<p align="left">
|
|
||||||
<img src="assets/logo.png" alt="AE Anons Logo" width="250"/>
|
|
||||||
</p>
|
|
||||||
<!-- markdownlint-enable MD033 -->
|
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
[](https://www.rust-lang.org/)
|
|
||||||
[](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
|
||||||
|
|
||||||
## Новое в версии v0.2.6
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
|
||||||
After Effects (лицензия MIT).
|
|
||||||
|
|
||||||
### Режимы работы
|
|
||||||
|
|
||||||
1. **Однократная обработка** (`--once` или без флагов):
|
|
||||||
- Подключение к NAS Synology
|
|
||||||
- Парсинг Excel данных
|
|
||||||
- Генерация и отправка заданий Nexrender
|
|
||||||
- Мониторинг завершения и выход
|
|
||||||
|
|
||||||
2. **Веб-сервер** (`--web`):
|
|
||||||
- Запуск веб-интерфейса на порту `:3000`
|
|
||||||
- Управление заданиями через браузер
|
|
||||||
- Автообновление статуса каждую минуту
|
|
||||||
|
|
||||||
## Особенности
|
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
|
||||||
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
|
||||||
- **REST API**: Программный доступ к управлению заданиями
|
|
||||||
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
|
||||||
|
|
||||||
## Предварительные требования
|
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
|
||||||
- Доступ к NAS Synology с пакетами File Station и Office
|
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
|
||||||
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
|
||||||
cd ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сборка проекта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем отредактируйте файл .env:
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Однократная обработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск с настройками по умолчанию
|
|
||||||
./target/release/ae_anons
|
|
||||||
|
|
||||||
# Явно указать однократный режим
|
|
||||||
./target/release/ae_anons --once
|
|
||||||
|
|
||||||
# С отладочным логированием
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### Веб-сервер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
|
||||||
./target/release/ae_anons --web
|
|
||||||
|
|
||||||
# Или с указанием другого порта через .env файл
|
|
||||||
# WEB_PORT=8080 ./target/release/ae_anons --web
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Веб-интерфейс
|
|
||||||
|
|
||||||
После запуска веб-сервера откройте браузер:
|
|
||||||
|
|
||||||
- Главная страница: <http://localhost:3000>
|
|
||||||
|
|
||||||
- API эндпоинты:
|
|
||||||
- `GET /api/jobs` - список всех заданий
|
|
||||||
- `POST /api/generate` - запуск генерации
|
|
||||||
- `POST /api/cleanup` - очистка завершённых
|
|
||||||
- `POST /api/jobs/stop-all` - остановка активных
|
|
||||||
- `GET /api/status` - статус сервера
|
|
||||||
|
|
||||||
## Уровни логирования
|
|
||||||
|
|
||||||
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
|
||||||
|
|
||||||
- **error** - Только критические ошибки
|
|
||||||
- **warn** - Предупреждения и ошибки
|
|
||||||
- **info** - Общие операционные сообщения (по умолчанию)
|
|
||||||
- **debug** - Детальная информация о процессе обработки
|
|
||||||
- **trace** - Полное отладочное логирование с деталями API вызовов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | Обязательна | Описание |
|
|
||||||
|-------------------------|-------------|------------------------------------------------------|
|
|
||||||
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
|
||||||
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
|
||||||
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
|
||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
|
||||||
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
|
||||||
|
|
||||||
### Пример файла `.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура электронной таблицы
|
|
||||||
|
|
||||||
Файл Excel должен содержать следующие листы со специфичными структурами:
|
|
||||||
|
|
||||||
### Лист "SPORT"
|
|
||||||
|
|
||||||
Связывает названия видов спорта с соответствующими видео пакетами.
|
|
||||||
|
|
||||||
| SPORT | LINK |
|
|
||||||
|----------------|---------------------------------------------|
|
|
||||||
| Без оформления | \\server\share\path\to\null.mov |
|
|
||||||
| Футбол | \\server\share\path\to\football_pack.mov |
|
|
||||||
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
|
||||||
|
|
||||||
### Лист "TEAMS"
|
|
||||||
|
|
||||||
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
|
||||||
|
|
||||||
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
|
||||||
|
|
||||||
| TEAM | SPORT | LINK |
|
|
||||||
|-------------------|-----------|---------------------------------------------------|
|
|
||||||
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
|
||||||
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
|
||||||
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
|
||||||
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
|
||||||
|
|
||||||
### Лист "CHANELL"
|
|
||||||
|
|
||||||
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
|
||||||
|
|
||||||
| CHANELL | LINK |
|
|
||||||
|---------|-----------------------------------------|
|
|
||||||
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
|
||||||
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
|
||||||
|
|
||||||
### Лист "Start"
|
|
||||||
|
|
||||||
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
|
||||||
|
|
||||||
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
|
||||||
|
|
||||||
**Примечания:**
|
|
||||||
|
|
||||||
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
|
||||||
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
|
||||||
|
|
||||||
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
|
||||||
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
|
||||||
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
|
||||||
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
|
||||||
|
|
||||||
### Формат имен команд
|
|
||||||
|
|
||||||
Имена команд поддерживают два типа разделителей хэш #:
|
|
||||||
|
|
||||||
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
|
||||||
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
|
||||||
|
|
||||||
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
|
||||||
|
|
||||||
Формат: `DisplayName#UniqueID#TargetSize`
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Галатасарай#Turki` - только уникальный идентификатор
|
|
||||||
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
|
||||||
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
|
||||||
|
|
||||||
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
|
||||||
|
|
||||||
## Выходные файлы
|
|
||||||
|
|
||||||
### Рендеренные видео
|
|
||||||
|
|
||||||
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена файлов транслитерируются в латиницу.
|
|
||||||
**Примеры:**
|
|
||||||
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
|
||||||
|
|
||||||
## Рабочий процесс
|
|
||||||
|
|
||||||
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
|
||||||
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
|
||||||
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
|
||||||
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
|
||||||
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
|
||||||
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
|
||||||
7. **Отправка**: Отправка заданий на API Nexrender
|
|
||||||
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
|
||||||
9. **Завершение сеанса**: Закрытие сессии Synology
|
|
||||||
|
|
||||||
## Требования к шаблонам After Effects
|
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
|
||||||
|
|
||||||
### Файлы шаблонов
|
|
||||||
|
|
||||||
Настраиваются через переменные окружения:
|
|
||||||
|
|
||||||
| Переменная | Назначение | Пример значения |
|
|
||||||
|-------------------------|-------------------------------------|------------------------------------|
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
|
||||||
|
|
||||||
### Обязательные слои в шаблонах
|
|
||||||
|
|
||||||
#### Текстовые слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
|
||||||
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
|
||||||
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
|
||||||
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
|
||||||
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
|
||||||
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
|
||||||
| `SPORT` | ✅ | ✅ | Категория спорта |
|
|
||||||
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
|
||||||
|
|
||||||
#### Слои с изображениями
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|----------------|:-------------:|:-------------:|-----------------------------|
|
|
||||||
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
|
||||||
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
|
||||||
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
|
||||||
|
|
||||||
#### Видео слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|----------------------------|
|
|
||||||
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
|
||||||
|
|
||||||
### Особенности шаблонов
|
|
||||||
|
|
||||||
#### Шаблон DOUBLE (две команды)
|
|
||||||
|
|
||||||
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
|
||||||
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
|
||||||
|
|
||||||
#### Шаблон SINGLE (одна команда)
|
|
||||||
|
|
||||||
Используется когда заполнено только одно поле команды.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
|
||||||
- Только логотип `TEAM_A_LOGO`
|
|
||||||
|
|
||||||
### Автоматические корректировки
|
|
||||||
|
|
||||||
AE Anons автоматически применяет следующие настройки к слоям:
|
|
||||||
|
|
||||||
#### Слой DATA
|
|
||||||
|
|
||||||
В зависимости от отображаемого текста и типа шаблона:
|
|
||||||
|
|
||||||
| Текст | Шаблон | Font Size | Anchor Point |
|
|
||||||
|------------|----------|-----------|--------------|
|
|
||||||
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
|
||||||
| "завтра" | DOUBLE | 115 | [0, 25] |
|
|
||||||
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
|
||||||
|
|
||||||
#### Слои TIME_H, TIME_M, TIME
|
|
||||||
|
|
||||||
Корректировка Anchor Point в зависимости от длины текста:
|
|
||||||
|
|
||||||
| Условие | Anchor Point |
|
|
||||||
|-------------------|--------------|
|
|
||||||
| 1 символ | [60, 0] |
|
|
||||||
| 2 символа, <20 | [20, 0] |
|
|
||||||
|
|
||||||
#### Слой LEAGUE
|
|
||||||
|
|
||||||
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
|
||||||
|
|
||||||
#### Слой TEAMS
|
|
||||||
|
|
||||||
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
|
||||||
|
|
||||||
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
|
||||||
|
|
||||||
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (width > height) {
|
|
||||||
max_size = width;
|
|
||||||
} else {
|
|
||||||
max_size = height;
|
|
||||||
}
|
|
||||||
var real_size = 550 / max_size * 100;
|
|
||||||
[real_size, real_size]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример структуры слоёв в After Effects
|
|
||||||
|
|
||||||
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
📁 main (композиция)
|
|
||||||
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
|
||||||
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
|
||||||
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
|
||||||
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
|
||||||
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
|
||||||
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
|
||||||
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
|
||||||
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
|
||||||
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
|
||||||
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
|
||||||
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
|
||||||
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Формат проекта и совместимость с After Effects
|
|
||||||
|
|
||||||
**Поддерживаемые форматы проектов:**
|
|
||||||
|
|
||||||
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
|
||||||
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
|
||||||
|
|
||||||
**Совместимость версий After Effects:**
|
|
||||||
|
|
||||||
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
|
||||||
|----------------------|------------------------|-------------------------------------------------|
|
|
||||||
| CS 5.5 | `.aep` | Базовая поддержка |
|
|
||||||
| CC / CC 2014 | `.aep` | Полная поддержка |
|
|
||||||
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
|
||||||
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
|
||||||
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
|
||||||
|
|
||||||
> **⚠️ Важно для After Effects 2023+:**
|
|
||||||
>
|
|
||||||
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
|
||||||
>
|
|
||||||
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
|
||||||
|
|
||||||
### Настройки композиции
|
|
||||||
|
|
||||||
| Параметр | Переменная окружения | Пример значения | Описание |
|
|
||||||
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
|
||||||
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
|
||||||
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
|
||||||
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
|
||||||
|
|
||||||
**Рекомендации по выбору формата:**
|
|
||||||
|
|
||||||
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
|
||||||
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
|
||||||
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
|
||||||
|
|
||||||
**Источники:**
|
|
||||||
|
|
||||||
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
|
||||||
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
|
||||||
|
|
||||||
## Устранение неисправностей
|
|
||||||
|
|
||||||
### Частые проблемы
|
|
||||||
|
|
||||||
#### Ошибка подключения к NAS
|
|
||||||
|
|
||||||
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
|
||||||
- Проверьте сетевую связность с NAS
|
|
||||||
- Убедитесь, что сервисы File Station и Office включены
|
|
||||||
|
|
||||||
#### Файл не найден
|
|
||||||
|
|
||||||
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
|
||||||
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
|
||||||
- Проверьте права доступа к файлу для учетной записи сервиса
|
|
||||||
|
|
||||||
#### Ошибка отправки задания Nexrender
|
|
||||||
|
|
||||||
- Подтвердите доступность сервера Nexrender
|
|
||||||
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
|
||||||
- Проверьте существование файлов шаблонов на узлах рендеринга
|
|
||||||
|
|
||||||
### Режим отладки
|
|
||||||
|
|
||||||
Включите подробное логирование для детального анализа:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
Это выведет:
|
|
||||||
|
|
||||||
- Детали API запросов/ответов
|
|
||||||
- Информацию о парсинге листов
|
|
||||||
- Детали создания заданий
|
|
||||||
- Процесс разрешения ресурсов
|
|
||||||
|
|
||||||
## Возможные ограничения по производительности
|
|
||||||
|
|
||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
|
||||||
|
|
||||||
- Храните учетные данные только в файле `.env` (исключен из git)
|
|
||||||
- Используйте специальные аккаунты с минимально необходимыми правами
|
|
||||||
- Сессии Synology завершаются после выполнения
|
|
||||||
- HTTPS рекомендуется для подключений NAS в продакшене
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
### Структура кода
|
|
||||||
|
|
||||||
```shell
|
|
||||||
ae_anons/
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
|
||||||
├── Cargo.toml
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
├── assets/
|
|
||||||
│ └── logo.png
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Точка входа, CLI
|
|
||||||
├── config.rs # Конфигурация из .env
|
|
||||||
├── nexrender.rs # Модели заданий Nexrender
|
|
||||||
├── synology.rs # Клиент Synology API
|
|
||||||
├── processor.rs # Логика обработки
|
|
||||||
├── web.rs # Веб-сервер и API
|
|
||||||
└── static/
|
|
||||||
├── index.html # Веб-интерфейс
|
|
||||||
└── style.css # Стили
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
### Основные зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|---------|-----------------------------------------|
|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
|
||||||
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
|
||||||
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
|
||||||
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
|
||||||
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
|
||||||
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
|
||||||
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
|
||||||
| regex | 1.11 | Регулярные выражения |
|
|
||||||
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
|
||||||
| bytes | 1.9 | Работа с байтовыми данными |
|
|
||||||
| futures | 0.3 | Асинхронные примитивы |
|
|
||||||
|
|
||||||
### Веб-сервер и CLI
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|--------|-----------------------------------------|
|
|
||||||
| axum | 0.8 | Веб-фреймворк для REST API |
|
|
||||||
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
|
||||||
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
|
||||||
| askama | 0.15 | Шаблонизация (опционально) |
|
|
||||||
| clap | 4.5 | Парсинг аргументов командной строки |
|
|
||||||
|
|
||||||
### Платформозависимые зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Платформа | Назначение |
|
|
||||||
|--------------------|--------|-----------|----------------------------------|
|
|
||||||
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
|
||||||
|
|
||||||
### Nexrender
|
|
||||||
|
|
||||||
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
|
||||||
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
|
||||||
для автоматизации After Effects.
|
|
||||||
|
|
||||||
### Font Awesome Free 6.4.0
|
|
||||||
|
|
||||||
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
|
||||||
|
|
||||||
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
|
||||||
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
|
||||||
|
|
||||||
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
**AE Anons** — [License](LICENSE)
|
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
|
||||||
|
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
|
||||||
|
|
||||||
**Разрешается:**
|
|
||||||
|
|
||||||
- ✅ Использовать в коммерческих целях
|
|
||||||
- ✅ Изменять исходный код
|
|
||||||
- ✅ Распространять копии
|
|
||||||
- ✅ Использовать приватно
|
|
||||||
|
|
||||||
**Требуется:**
|
|
||||||
|
|
||||||
- Сохранять копирайт и текст лицензии
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для вопросов и запросов функций обращайтесь:
|
|
||||||
|
|
||||||
- <a.barabanov@tvstart.ru>
|
|
||||||
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
## История изменений
|
|
||||||
|
|
||||||
### v0.2.6 (текущая)
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
### v0.2.5
|
|
||||||
|
|
||||||
- Оптимизация памяти: очистка неиспользуемых логотипов
|
|
||||||
- Групповая отправка заданий по 3 для сохранения порядка
|
|
||||||
- Добавлен UID в логи отправки
|
|
||||||
|
|
||||||
### v0.2.4
|
|
||||||
|
|
||||||
- Полностью автономный режим (встроенный Font Awesome)
|
|
||||||
- Добавлен favicon
|
|
||||||
- Все статические ресурсы встроены в бинарник
|
|
||||||
|
|
||||||
### v0.2.3
|
|
||||||
|
|
||||||
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
|
||||||
- Тёмная/светлая тема с автоопределением ОС
|
|
||||||
- Сортировка по всем колонкам в интерфейсе
|
|
||||||
- Фильтрация по имени файла/UID
|
|
||||||
- Остановка всех активных заданий
|
|
||||||
- Автообновление каждые 60 секунд
|
|
||||||
- Улучшенное debug-логирование
|
|
||||||
- Исправлена обработка UNC/SMB путей
|
|
||||||
|
|
||||||
### v0.2.2
|
|
||||||
|
|
||||||
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
|
||||||
- Улучшена цветовая схема
|
|
||||||
- Добавлена сортировка по всем колонкам
|
|
||||||
- Вынесены стили в отдельный CSS файл
|
|
||||||
|
|
||||||
### v0.2.0
|
|
||||||
|
|
||||||
- Добавлен веб-интерфейс для управления заданиями
|
|
||||||
- Реализован REST API
|
|
||||||
- Добавлена поддержка тёмной/светлой темы
|
|
||||||
- Автообновление статуса заданий
|
|
||||||
- Возможность остановки всех активных заданий
|
|
||||||
|
|
||||||
### v0.1.1
|
|
||||||
|
|
||||||
- Оптимизирована работа с памятью
|
|
||||||
- Убрана функция создания .json
|
|
||||||
|
|
||||||
### v0.1.0
|
|
||||||
|
|
||||||
- Первый выпуск
|
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Made with 🦀 Rust and ☕ coffee
|
|
||||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
|||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging (optional, defaults to info)
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server (optional, defaults to 3000)
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 [Your Name or Company]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
|
|
||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
|
||||||
<p align="left">
|
|
||||||
<img src="assets/logo.png" alt="AE Anons Logo" width="250"/>
|
|
||||||
</p>
|
|
||||||
<!-- markdownlint-enable MD033 -->
|
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
|
||||||
[](https://www.rust-lang.org/)
|
|
||||||
[](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
|
||||||
|
|
||||||
## Новое в версии v0.2.6
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
|
||||||
After Effects (лицензия MIT).
|
|
||||||
|
|
||||||
### Режимы работы
|
|
||||||
|
|
||||||
1. **Однократная обработка** (`--once` или без флагов):
|
|
||||||
- Подключение к NAS Synology
|
|
||||||
- Парсинг Excel данных
|
|
||||||
- Генерация и отправка заданий Nexrender
|
|
||||||
- Мониторинг завершения и выход
|
|
||||||
|
|
||||||
2. **Веб-сервер** (`--web`):
|
|
||||||
- Запуск веб-интерфейса на порту `:3000`
|
|
||||||
- Управление заданиями через браузер
|
|
||||||
- Автообновление статуса каждую минуту
|
|
||||||
|
|
||||||
## Особенности
|
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
|
||||||
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
|
||||||
- **REST API**: Программный доступ к управлению заданиями
|
|
||||||
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
|
||||||
|
|
||||||
## Предварительные требования
|
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
|
||||||
- Доступ к NAS Synology с пакетами File Station и Office
|
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
|
||||||
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
### 1. Клонирование репозитория
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
|
||||||
cd ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Сборка проекта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем отредактируйте файл .env:
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
|
||||||
|
|
||||||
## Использование
|
|
||||||
|
|
||||||
### Однократная обработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск с настройками по умолчанию
|
|
||||||
./target/release/ae_anons
|
|
||||||
|
|
||||||
# Явно указать однократный режим
|
|
||||||
./target/release/ae_anons --once
|
|
||||||
|
|
||||||
# С отладочным логированием
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
### Веб-сервер
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
|
||||||
./target/release/ae_anons --web
|
|
||||||
|
|
||||||
# Или с указанием другого порта через .env файл
|
|
||||||
# WEB_PORT=8080 ./target/release/ae_anons --web
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Веб-интерфейс
|
|
||||||
|
|
||||||
После запуска веб-сервера откройте браузер:
|
|
||||||
|
|
||||||
- Главная страница: <http://localhost:3000>
|
|
||||||
|
|
||||||
- API эндпоинты:
|
|
||||||
- `GET /api/jobs` - список всех заданий
|
|
||||||
- `POST /api/generate` - запуск генерации
|
|
||||||
- `POST /api/cleanup` - очистка завершённых
|
|
||||||
- `POST /api/jobs/stop-all` - остановка активных
|
|
||||||
- `GET /api/status` - статус сервера
|
|
||||||
|
|
||||||
## Уровни логирования
|
|
||||||
|
|
||||||
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
|
||||||
|
|
||||||
- **error** - Только критические ошибки
|
|
||||||
- **warn** - Предупреждения и ошибки
|
|
||||||
- **info** - Общие операционные сообщения (по умолчанию)
|
|
||||||
- **debug** - Детальная информация о процессе обработки
|
|
||||||
- **trace** - Полное отладочное логирование с деталями API вызовов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug cargo run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Конфигурация
|
|
||||||
|
|
||||||
### Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | Обязательна | Описание |
|
|
||||||
|-------------------------|-------------|------------------------------------------------------|
|
|
||||||
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
|
||||||
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
|
||||||
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
|
||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
|
||||||
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
|
||||||
|
|
||||||
### Пример файла `.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Synology NAS
|
|
||||||
NAS_FQDN="https://your-nas.example.com"
|
|
||||||
NAS_USER="your_username"
|
|
||||||
NAS_PASS="your_password"
|
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
RUST_LOG="info"
|
|
||||||
|
|
||||||
# Web Server
|
|
||||||
WEB_PORT="3000"
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
|
||||||
|
|
||||||
# After Effects Templates
|
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
|
||||||
TEMPLATE_COMPOSITION="main"
|
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура электронной таблицы
|
|
||||||
|
|
||||||
Файл Excel должен содержать следующие листы со специфичными структурами:
|
|
||||||
|
|
||||||
### Лист "SPORT"
|
|
||||||
|
|
||||||
Связывает названия видов спорта с соответствующими видео пакетами.
|
|
||||||
|
|
||||||
| SPORT | LINK |
|
|
||||||
|----------------|---------------------------------------------|
|
|
||||||
| Без оформления | \\server\share\path\to\null.mov |
|
|
||||||
| Футбол | \\server\share\path\to\football_pack.mov |
|
|
||||||
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
|
||||||
|
|
||||||
### Лист "TEAMS"
|
|
||||||
|
|
||||||
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
|
||||||
|
|
||||||
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
|
||||||
|
|
||||||
| TEAM | SPORT | LINK |
|
|
||||||
|-------------------|-----------|---------------------------------------------------|
|
|
||||||
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
|
||||||
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
|
||||||
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
|
||||||
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
|
||||||
|
|
||||||
### Лист "CHANELL"
|
|
||||||
|
|
||||||
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
|
||||||
|
|
||||||
| CHANELL | LINK |
|
|
||||||
|---------|-----------------------------------------|
|
|
||||||
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
|
||||||
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
|
||||||
|
|
||||||
### Лист "Start"
|
|
||||||
|
|
||||||
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
|
||||||
|
|
||||||
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
|
||||||
|
|
||||||
**Примечания:**
|
|
||||||
|
|
||||||
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
|
||||||
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
|
||||||
|
|
||||||
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
|
||||||
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
|
||||||
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
|
||||||
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
|
||||||
|
|
||||||
### Формат имен команд
|
|
||||||
|
|
||||||
Имена команд поддерживают два типа разделителей хэш #:
|
|
||||||
|
|
||||||
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
|
||||||
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
|
||||||
|
|
||||||
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
|
||||||
|
|
||||||
Формат: `DisplayName#UniqueID#TargetSize`
|
|
||||||
|
|
||||||
Примеры:
|
|
||||||
|
|
||||||
- `Галатасарай#Turki` - только уникальный идентификатор
|
|
||||||
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
|
||||||
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
|
||||||
|
|
||||||
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
|
||||||
|
|
||||||
## Выходные файлы
|
|
||||||
|
|
||||||
### Рендеренные видео
|
|
||||||
|
|
||||||
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|
||||||
```
|
|
||||||
|
|
||||||
Имена файлов транслитерируются в латиницу.
|
|
||||||
**Примеры:**
|
|
||||||
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
|
||||||
|
|
||||||
## Рабочий процесс
|
|
||||||
|
|
||||||
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
|
||||||
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
|
||||||
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
|
||||||
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
|
||||||
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
|
||||||
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
|
||||||
7. **Отправка**: Отправка заданий на API Nexrender
|
|
||||||
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
|
||||||
9. **Завершение сеанса**: Закрытие сессии Synology
|
|
||||||
|
|
||||||
## Требования к шаблонам After Effects
|
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
|
||||||
|
|
||||||
### Файлы шаблонов
|
|
||||||
|
|
||||||
Настраиваются через переменные окружения:
|
|
||||||
|
|
||||||
| Переменная | Назначение | Пример значения |
|
|
||||||
|-------------------------|-------------------------------------|------------------------------------|
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
|
||||||
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
|
||||||
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
|
||||||
|
|
||||||
### Обязательные слои в шаблонах
|
|
||||||
|
|
||||||
#### Текстовые слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
|
||||||
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
|
||||||
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
|
||||||
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
|
||||||
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
|
||||||
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
|
||||||
| `SPORT` | ✅ | ✅ | Категория спорта |
|
|
||||||
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
|
||||||
|
|
||||||
#### Слои с изображениями
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|----------------|:-------------:|:-------------:|-----------------------------|
|
|
||||||
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
|
||||||
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
|
||||||
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
|
||||||
|
|
||||||
#### Видео слои
|
|
||||||
|
|
||||||
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
|
||||||
|-----------|:-------------:|:-------------:|----------------------------|
|
|
||||||
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
|
||||||
|
|
||||||
### Особенности шаблонов
|
|
||||||
|
|
||||||
#### Шаблон DOUBLE (две команды)
|
|
||||||
|
|
||||||
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
|
||||||
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
|
||||||
|
|
||||||
#### Шаблон SINGLE (одна команда)
|
|
||||||
|
|
||||||
Используется когда заполнено только одно поле команды.
|
|
||||||
|
|
||||||
Обязательные слои:
|
|
||||||
|
|
||||||
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
|
||||||
- Только логотип `TEAM_A_LOGO`
|
|
||||||
|
|
||||||
### Автоматические корректировки
|
|
||||||
|
|
||||||
AE Anons автоматически применяет следующие настройки к слоям:
|
|
||||||
|
|
||||||
#### Слой DATA
|
|
||||||
|
|
||||||
В зависимости от отображаемого текста и типа шаблона:
|
|
||||||
|
|
||||||
| Текст | Шаблон | Font Size | Anchor Point |
|
|
||||||
|------------|----------|-----------|--------------|
|
|
||||||
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
|
||||||
| "завтра" | DOUBLE | 115 | [0, 25] |
|
|
||||||
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
|
||||||
|
|
||||||
#### Слои TIME_H, TIME_M, TIME
|
|
||||||
|
|
||||||
Корректировка Anchor Point в зависимости от длины текста:
|
|
||||||
|
|
||||||
| Условие | Anchor Point |
|
|
||||||
|-------------------|--------------|
|
|
||||||
| 1 символ | [60, 0] |
|
|
||||||
| 2 символа, <20 | [20, 0] |
|
|
||||||
|
|
||||||
#### Слой LEAGUE
|
|
||||||
|
|
||||||
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
|
||||||
|
|
||||||
#### Слой TEAMS
|
|
||||||
|
|
||||||
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
|
||||||
|
|
||||||
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
|
||||||
|
|
||||||
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (width > height) {
|
|
||||||
max_size = width;
|
|
||||||
} else {
|
|
||||||
max_size = height;
|
|
||||||
}
|
|
||||||
var real_size = 550 / max_size * 100;
|
|
||||||
[real_size, real_size]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Пример структуры слоёв в After Effects
|
|
||||||
|
|
||||||
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
|
||||||
|
|
||||||
```text
|
|
||||||
📁 main (композиция)
|
|
||||||
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
|
||||||
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
|
||||||
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
|
||||||
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
|
||||||
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
|
||||||
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
|
||||||
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
|
||||||
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
|
||||||
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
|
||||||
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
|
||||||
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
|
||||||
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Формат проекта и совместимость с After Effects
|
|
||||||
|
|
||||||
**Поддерживаемые форматы проектов:**
|
|
||||||
|
|
||||||
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
|
||||||
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
|
||||||
|
|
||||||
**Совместимость версий After Effects:**
|
|
||||||
|
|
||||||
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
|
||||||
|----------------------|------------------------|-------------------------------------------------|
|
|
||||||
| CS 5.5 | `.aep` | Базовая поддержка |
|
|
||||||
| CC / CC 2014 | `.aep` | Полная поддержка |
|
|
||||||
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
|
||||||
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
|
||||||
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
|
||||||
|
|
||||||
> **⚠️ Важно для After Effects 2023+:**
|
|
||||||
>
|
|
||||||
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
|
||||||
>
|
|
||||||
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
|
||||||
|
|
||||||
### Настройки композиции
|
|
||||||
|
|
||||||
| Параметр | Переменная окружения | Пример значения | Описание |
|
|
||||||
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
|
||||||
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
|
||||||
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
|
||||||
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
|
||||||
|
|
||||||
**Рекомендации по выбору формата:**
|
|
||||||
|
|
||||||
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
|
||||||
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
|
||||||
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
|
||||||
|
|
||||||
**Источники:**
|
|
||||||
|
|
||||||
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
|
||||||
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
|
||||||
|
|
||||||
## Устранение неисправностей
|
|
||||||
|
|
||||||
### Частые проблемы
|
|
||||||
|
|
||||||
#### Ошибка подключения к NAS
|
|
||||||
|
|
||||||
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
|
||||||
- Проверьте сетевую связность с NAS
|
|
||||||
- Убедитесь, что сервисы File Station и Office включены
|
|
||||||
|
|
||||||
#### Файл не найден
|
|
||||||
|
|
||||||
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
|
||||||
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
|
||||||
- Проверьте права доступа к файлу для учетной записи сервиса
|
|
||||||
|
|
||||||
#### Ошибка отправки задания Nexrender
|
|
||||||
|
|
||||||
- Подтвердите доступность сервера Nexrender
|
|
||||||
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
|
||||||
- Проверьте существование файлов шаблонов на узлах рендеринга
|
|
||||||
|
|
||||||
### Режим отладки
|
|
||||||
|
|
||||||
Включите подробное логирование для детального анализа:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
|
||||||
```
|
|
||||||
|
|
||||||
Это выведет:
|
|
||||||
|
|
||||||
- Детали API запросов/ответов
|
|
||||||
- Информацию о парсинге листов
|
|
||||||
- Детали создания заданий
|
|
||||||
- Процесс разрешения ресурсов
|
|
||||||
|
|
||||||
## Возможные ограничения по производительности
|
|
||||||
|
|
||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
|
||||||
|
|
||||||
- Храните учетные данные только в файле `.env` (исключен из git)
|
|
||||||
- Используйте специальные аккаунты с минимально необходимыми правами
|
|
||||||
- Сессии Synology завершаются после выполнения
|
|
||||||
- HTTPS рекомендуется для подключений NAS в продакшене
|
|
||||||
|
|
||||||
## Разработка
|
|
||||||
|
|
||||||
### Структура кода
|
|
||||||
|
|
||||||
```shell
|
|
||||||
ae_anons/
|
|
||||||
├── .gitignore
|
|
||||||
├── .env.example
|
|
||||||
├── Cargo.toml
|
|
||||||
├── LICENSE
|
|
||||||
├── README.md
|
|
||||||
├── assets/
|
|
||||||
│ └── logo.png
|
|
||||||
└── src/
|
|
||||||
├── main.rs # Точка входа, CLI
|
|
||||||
├── config.rs # Конфигурация из .env
|
|
||||||
├── nexrender.rs # Модели заданий Nexrender
|
|
||||||
├── synology.rs # Клиент Synology API
|
|
||||||
├── processor.rs # Логика обработки
|
|
||||||
├── web.rs # Веб-сервер и API
|
|
||||||
└── static/
|
|
||||||
├── index.html # Веб-интерфейс
|
|
||||||
└── style.css # Стили
|
|
||||||
```
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
### Основные зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|---------|-----------------------------------------|
|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
|
||||||
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
|
||||||
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
|
||||||
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
|
||||||
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
|
||||||
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
|
||||||
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
|
||||||
| regex | 1.11 | Регулярные выражения |
|
|
||||||
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
|
||||||
| bytes | 1.9 | Работа с байтовыми данными |
|
|
||||||
| futures | 0.3 | Асинхронные примитивы |
|
|
||||||
|
|
||||||
### Веб-сервер и CLI
|
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
|
||||||
|--------------------|--------|-----------------------------------------|
|
|
||||||
| axum | 0.8 | Веб-фреймворк для REST API |
|
|
||||||
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
|
||||||
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
|
||||||
| askama | 0.15 | Шаблонизация (опционально) |
|
|
||||||
| clap | 4.5 | Парсинг аргументов командной строки |
|
|
||||||
|
|
||||||
### Платформозависимые зависимости
|
|
||||||
|
|
||||||
| Crate | Версия | Платформа | Назначение |
|
|
||||||
|--------------------|--------|-----------|----------------------------------|
|
|
||||||
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
|
||||||
|
|
||||||
### Nexrender
|
|
||||||
|
|
||||||
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
|
||||||
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
|
||||||
для автоматизации After Effects.
|
|
||||||
|
|
||||||
### Font Awesome Free 6.4.0
|
|
||||||
|
|
||||||
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
|
||||||
|
|
||||||
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
|
||||||
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
|
||||||
|
|
||||||
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
**AE Anons** — [License](LICENSE)
|
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
|
||||||
|
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
|
||||||
|
|
||||||
**Разрешается:**
|
|
||||||
|
|
||||||
- ✅ Использовать в коммерческих целях
|
|
||||||
- ✅ Изменять исходный код
|
|
||||||
- ✅ Распространять копии
|
|
||||||
- ✅ Использовать приватно
|
|
||||||
|
|
||||||
**Требуется:**
|
|
||||||
|
|
||||||
- Сохранять копирайт и текст лицензии
|
|
||||||
|
|
||||||
## Поддержка
|
|
||||||
|
|
||||||
Для вопросов и запросов функций обращайтесь:
|
|
||||||
|
|
||||||
- <a.barabanov@tvstart.ru>
|
|
||||||
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
|
||||||
|
|
||||||
## История изменений
|
|
||||||
|
|
||||||
### v0.2.6 (текущая)
|
|
||||||
|
|
||||||
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
|
||||||
- Сортировка по варианту (оригинал → today → tomorrow)
|
|
||||||
- Последовательная отправка для гарантированного порядка в очереди
|
|
||||||
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
|
||||||
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
|
||||||
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
|
||||||
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
|
||||||
|
|
||||||
### v0.2.5
|
|
||||||
|
|
||||||
- Оптимизация памяти: очистка неиспользуемых логотипов
|
|
||||||
- Групповая отправка заданий по 3 для сохранения порядка
|
|
||||||
- Добавлен UID в логи отправки
|
|
||||||
|
|
||||||
### v0.2.4
|
|
||||||
|
|
||||||
- Полностью автономный режим (встроенный Font Awesome)
|
|
||||||
- Добавлен favicon
|
|
||||||
- Все статические ресурсы встроены в бинарник
|
|
||||||
|
|
||||||
### v0.2.3
|
|
||||||
|
|
||||||
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
|
||||||
- Тёмная/светлая тема с автоопределением ОС
|
|
||||||
- Сортировка по всем колонкам в интерфейсе
|
|
||||||
- Фильтрация по имени файла/UID
|
|
||||||
- Остановка всех активных заданий
|
|
||||||
- Автообновление каждые 60 секунд
|
|
||||||
- Улучшенное debug-логирование
|
|
||||||
- Исправлена обработка UNC/SMB путей
|
|
||||||
|
|
||||||
### v0.2.2
|
|
||||||
|
|
||||||
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
|
||||||
- Улучшена цветовая схема
|
|
||||||
- Добавлена сортировка по всем колонкам
|
|
||||||
- Вынесены стили в отдельный CSS файл
|
|
||||||
|
|
||||||
### v0.2.0
|
|
||||||
|
|
||||||
- Добавлен веб-интерфейс для управления заданиями
|
|
||||||
- Реализован REST API
|
|
||||||
- Добавлена поддержка тёмной/светлой темы
|
|
||||||
- Автообновление статуса заданий
|
|
||||||
- Возможность остановки всех активных заданий
|
|
||||||
|
|
||||||
### v0.1.1
|
|
||||||
|
|
||||||
- Оптимизирована работа с памятью
|
|
||||||
- Убрана функция создания .json
|
|
||||||
|
|
||||||
### v0.1.0
|
|
||||||
|
|
||||||
- Первый выпуск
|
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Made with 🦀 Rust and ☕ coffee
|
|
||||||
Binary file not shown.
@@ -11,18 +11,30 @@ pub struct Config {
|
|||||||
// Nexrender
|
// Nexrender
|
||||||
pub nexrender_api_url: String,
|
pub nexrender_api_url: String,
|
||||||
pub output_folder: String,
|
pub output_folder: String,
|
||||||
// Templates (all required)
|
// Templates
|
||||||
pub template_double_src: String,
|
pub template_double_src: String,
|
||||||
pub template_single_src: String,
|
pub template_single_src: String,
|
||||||
pub template_composition: String,
|
pub template_composition: String,
|
||||||
pub template_output_module: String,
|
pub template_output_module: String,
|
||||||
pub template_output_ext: String,
|
pub template_output_ext: String,
|
||||||
//web_serwer
|
// Web
|
||||||
pub web_port: u16,
|
pub web_port: u16,
|
||||||
|
// SMB for approved videos (optional)
|
||||||
|
pub smb_upload_url: Option<String>,
|
||||||
|
pub smb_username: Option<String>,
|
||||||
|
pub smb_password: Option<String>,
|
||||||
|
// SMB source folder (where videos are stored locally before upload)
|
||||||
|
pub smb_source_folder: String,
|
||||||
|
// Database
|
||||||
|
pub database_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
|
let output_folder = env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?;
|
||||||
|
let smb_source_folder =
|
||||||
|
env::var("SMB_SOURCE_FOLDER").unwrap_or_else(|_| output_folder.clone());
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
|
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
|
||||||
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
|
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
|
||||||
@@ -30,7 +42,7 @@ impl Config {
|
|||||||
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
|
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
|
||||||
nexrender_api_url: env::var("NEXRENDER_API_URL")
|
nexrender_api_url: env::var("NEXRENDER_API_URL")
|
||||||
.context("NEXRENDER_API_URL not set")?,
|
.context("NEXRENDER_API_URL not set")?,
|
||||||
output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?,
|
output_folder,
|
||||||
template_double_src: env::var("TEMPLATE_DOUBLE_SRC")
|
template_double_src: env::var("TEMPLATE_DOUBLE_SRC")
|
||||||
.context("TEMPLATE_DOUBLE_SRC not set")?,
|
.context("TEMPLATE_DOUBLE_SRC not set")?,
|
||||||
template_single_src: env::var("TEMPLATE_SINGLE_SRC")
|
template_single_src: env::var("TEMPLATE_SINGLE_SRC")
|
||||||
@@ -45,6 +57,12 @@ impl Config {
|
|||||||
.unwrap_or_else(|_| "3000".to_string())
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.context("Invalid WEB_PORT")?,
|
.context("Invalid WEB_PORT")?,
|
||||||
|
smb_upload_url: env::var("SMB_UPLOAD_URL").ok(),
|
||||||
|
smb_username: env::var("SMB_USERNAME").ok(),
|
||||||
|
smb_password: env::var("SMB_PASSWORD").ok(),
|
||||||
|
smb_source_folder,
|
||||||
|
database_url: env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite:ae_anons.db".to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
22
src/main.rs
22
src/main.rs
@@ -1,6 +1,7 @@
|
|||||||
mod config;
|
mod config;
|
||||||
mod nexrender;
|
mod nexrender;
|
||||||
mod processor;
|
mod processor;
|
||||||
|
mod storage;
|
||||||
mod synology;
|
mod synology;
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
@@ -13,38 +14,29 @@ use log::info;
|
|||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Run web server instead of one-time processing
|
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
web: bool,
|
web: bool,
|
||||||
|
|
||||||
/// Run one-time processing (default if no flags)
|
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
once: bool,
|
once: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Загружаем .env до инициализации логгера
|
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
// Инициализируем логгер
|
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let config = Config::from_env()?;
|
let config = Config::from_env()?;
|
||||||
|
|
||||||
if cli.web {
|
if cli.web {
|
||||||
info!(
|
info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION"));
|
||||||
"Starting AE Anons web server v{}",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
);
|
|
||||||
web::run_web_server(config).await?;
|
web::run_web_server(config).await?;
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION"));
|
||||||
"Starting AE Anons processor v{} (one-time mode)",
|
// For one-time mode we don't need SMB, so we create dummy db and channel
|
||||||
env!("CARGO_PKG_VERSION")
|
let (dummy_ws_tx, _) = tokio::sync::broadcast::channel(1);
|
||||||
);
|
let dummy_db = sqlx::SqlitePool::connect("sqlite::memory:").await?;
|
||||||
let submitted = processor::process_spreadsheet(&config).await?;
|
let submitted = processor::process_spreadsheet(&config, &dummy_db, dummy_ws_tx).await?;
|
||||||
info!("Submitted {} jobs. Exiting.", submitted.len());
|
info!("Submitted {} jobs. Exiting.", submitted.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
172
src/processor.rs
172
src/processor.rs
@@ -1,15 +1,18 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::nexrender::{JobData, LogoRegistry};
|
use crate::nexrender::{JobData, LogoRegistry};
|
||||||
use crate::synology::SynologyClient;
|
use crate::synology::SynologyClient;
|
||||||
|
use crate::storage::{self, JobRecord};
|
||||||
|
use crate::web::WsEvent;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use calamine::{Data, Reader, Xlsx};
|
use calamine::{Data, Reader, Xlsx};
|
||||||
use chrono::{Duration, NaiveDate};
|
use chrono::{Duration, NaiveDate, Utc};
|
||||||
use log::{debug, error, info, log_enabled, Level};
|
use log::{debug, error, info};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SheetData {
|
pub struct SheetData {
|
||||||
@@ -29,8 +32,7 @@ impl ExcelWorkbook {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Основная функция обработки: скачивает Excel, генерирует задания, отправляет в Nexrender
|
pub async fn process_spreadsheet(config: &Config, db: &sqlx::SqlitePool, ws_tx: broadcast::Sender<WsEvent>) -> Result<Vec<(String, String)>> {
|
||||||
pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)>> {
|
|
||||||
let mut client = SynologyClient::new(&config.nas_fqdn);
|
let mut client = SynologyClient::new(&config.nas_fqdn);
|
||||||
client.login(&config.nas_user, &config.nas_pass).await?;
|
client.login(&config.nas_user, &config.nas_pass).await?;
|
||||||
info!("Successfully authenticated with Synology NAS");
|
info!("Successfully authenticated with Synology NAS");
|
||||||
@@ -38,11 +40,10 @@ pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)
|
|||||||
let info = client.get_info().await?;
|
let info = client.get_info().await?;
|
||||||
info!("Connected to NAS: {}", info.hostname);
|
info!("Connected to NAS: {}", info.hostname);
|
||||||
|
|
||||||
// Скачиваем и парсим Excel в памяти
|
|
||||||
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
|
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
|
||||||
display_workbook_structure(&workbook);
|
display_workbook_structure(&workbook);
|
||||||
|
|
||||||
let submitted = generate_and_submit_jobs(&workbook, config).await?;
|
let submitted = generate_and_submit_jobs(&workbook, config, db, ws_tx).await?;
|
||||||
|
|
||||||
client.logout().await?;
|
client.logout().await?;
|
||||||
info!("Session terminated successfully");
|
info!("Session terminated successfully");
|
||||||
@@ -105,7 +106,6 @@ async fn download_and_parse_excel_in_memory(
|
|||||||
info!("Found file: {}", actual_file_name);
|
info!("Found file: {}", actual_file_name);
|
||||||
info!("Exporting file from Synology Office to Excel format (in-memory)...");
|
info!("Exporting file from Synology Office to Excel format (in-memory)...");
|
||||||
|
|
||||||
// Получаем бинарные данные Excel напрямую в память
|
|
||||||
let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
|
let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
|
||||||
info!("Exported {} bytes to memory", excel_data.len());
|
info!("Exported {} bytes to memory", excel_data.len());
|
||||||
|
|
||||||
@@ -158,7 +158,6 @@ fn parse_sheet_dynamic_optimized(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Извлекаем заголовки с дедупликацией
|
|
||||||
let headers: Vec<String> = data_matrix[0]
|
let headers: Vec<String> = data_matrix[0]
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
@@ -192,7 +191,7 @@ fn parse_sheet_dynamic_optimized(
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.take(10000) // Ограничение для безопасности
|
.take(10000)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(SheetData {
|
Ok(SheetData {
|
||||||
@@ -274,7 +273,6 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательная функция для поиска значения по части ключа
|
|
||||||
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
|
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
|
||||||
if let Some(val) = row.get(key_part) {
|
if let Some(val) = row.get(key_part) {
|
||||||
if !val.is_empty() {
|
if !val.is_empty() {
|
||||||
@@ -292,73 +290,41 @@ fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<Strin
|
|||||||
async fn generate_and_submit_jobs(
|
async fn generate_and_submit_jobs(
|
||||||
workbook: &ExcelWorkbook,
|
workbook: &ExcelWorkbook,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
db: &sqlx::SqlitePool,
|
||||||
|
ws_tx: broadcast::Sender<WsEvent>,
|
||||||
) -> Result<Vec<(String, String)>> {
|
) -> Result<Vec<(String, String)>> {
|
||||||
info!("Preparing Nexrender jobs...");
|
info!("Preparing Nexrender jobs...");
|
||||||
|
|
||||||
// ========== ЗАГРУЖАЕМ SPORT PACKS ==========
|
// Load SPORT packs
|
||||||
let sport_sheet = workbook
|
let sport_sheet = workbook
|
||||||
.get_sheet("SPORT")
|
.get_sheet("SPORT")
|
||||||
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
|
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
|
||||||
|
|
||||||
let packs: HashMap<String, String> = sport_sheet
|
let packs: HashMap<String, String> = sport_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
|
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
|
||||||
.collect();
|
.collect();
|
||||||
info!("Loaded {} sport packs", packs.len());
|
info!("Loaded {} sport packs", packs.len());
|
||||||
debug!("Processed SPORT sheet ({} rows)", sport_sheet.rows.len());
|
|
||||||
|
|
||||||
// ========== ЗАГРУЖАЕМ TEAM LOGOS ==========
|
// Load TEAM logos
|
||||||
let teams_sheet = workbook
|
let teams_sheet = workbook
|
||||||
.get_sheet("TEAMS")
|
.get_sheet("TEAMS")
|
||||||
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
|
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
|
||||||
|
|
||||||
debug!("TEAMS headers: {:?}", teams_sheet.headers);
|
|
||||||
if log_enabled!(Level::Debug) {
|
|
||||||
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
|
|
||||||
debug!("TEAMS row {}: {:?}", i, row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
|
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
|
||||||
for row in &teams_sheet.rows {
|
for row in &teams_sheet.rows {
|
||||||
let team = get_cell_fuzzy(row, "TEAM");
|
let team = get_cell_fuzzy(row, "TEAM");
|
||||||
let sport = get_cell_fuzzy(row, "SPORT");
|
let sport = get_cell_fuzzy(row, "SPORT");
|
||||||
let link = get_cell_fuzzy(row, "LINK");
|
let link = get_cell_fuzzy(row, "LINK");
|
||||||
|
|
||||||
if let (Some(team), Some(sport), Some(link)) = (team, sport, link) {
|
if let (Some(team), Some(sport), Some(link)) = (team, sport, link) {
|
||||||
logos.insert(team.clone(), sport.clone(), link.clone());
|
logos.insert(team.clone(), sport.clone(), link.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let total_teams = teams_sheet.rows.len();
|
info!("Loaded {} team logos", teams_sheet.rows.len());
|
||||||
info!("Loaded {} team logos", total_teams);
|
|
||||||
|
|
||||||
// Дебаг: выводим статистику по TEAMS
|
// Load CHANNEL logos
|
||||||
if log_enabled!(Level::Debug) {
|
|
||||||
let sports: HashSet<_> = teams_sheet
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.filter_map(|r| r.get("SPORT"))
|
|
||||||
.collect();
|
|
||||||
debug!("Sports found in TEAMS sheet: {:?}", sports);
|
|
||||||
|
|
||||||
for sport in sports.iter().take(5) {
|
|
||||||
let examples: Vec<_> = teams_sheet
|
|
||||||
.rows
|
|
||||||
.iter()
|
|
||||||
.filter(|r| r.get("SPORT") == Some(*sport))
|
|
||||||
.take(3)
|
|
||||||
.filter_map(|r| r.get("TEAM"))
|
|
||||||
.collect();
|
|
||||||
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== ЗАГРУЖАЕМ CHANNEL LOGOS ==========
|
|
||||||
let channel_sheet = workbook
|
let channel_sheet = workbook
|
||||||
.get_sheet("CHANELL")
|
.get_sheet("CHANELL")
|
||||||
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
||||||
|
|
||||||
let channels: HashMap<String, String> = channel_sheet
|
let channels: HashMap<String, String> = channel_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -366,95 +332,42 @@ async fn generate_and_submit_jobs(
|
|||||||
.collect();
|
.collect();
|
||||||
info!("Loaded {} channel logos", channels.len());
|
info!("Loaded {} channel logos", channels.len());
|
||||||
|
|
||||||
if log_enabled!(Level::Debug) {
|
// Process START sheet
|
||||||
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== ОБРАБАТЫВАЕМ START (только активные строки) ==========
|
|
||||||
let start_sheet = workbook
|
let start_sheet = workbook
|
||||||
.get_sheet("Start")
|
.get_sheet("Start")
|
||||||
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
|
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
|
||||||
|
|
||||||
let total_start_rows = start_sheet.rows.len();
|
|
||||||
|
|
||||||
// Сразу фильтруем только строки с STATE = "FALSE"
|
|
||||||
let active_rows: Vec<(usize, &HashMap<String, String>)> = start_sheet
|
let active_rows: Vec<(usize, &HashMap<String, String>)> = start_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, row)| row.get("STATE").map(|s| s.as_str()) == Some("FALSE"))
|
.filter(|(_, row)| row.get("STATE").map(|s| s.as_str()) == Some("FALSE"))
|
||||||
.collect();
|
.collect();
|
||||||
|
info!("Found {} active rows (STATE='FALSE') out of {} total", active_rows.len(), start_sheet.rows.len());
|
||||||
|
|
||||||
info!(
|
|
||||||
"Found {} active rows (STATE='FALSE') out of {} total",
|
|
||||||
active_rows.len(),
|
|
||||||
total_start_rows
|
|
||||||
);
|
|
||||||
|
|
||||||
// Собираем использованные команды для очистки logos
|
|
||||||
let mut used_teams: HashSet<(String, String)> = HashSet::new();
|
let mut used_teams: HashSet<(String, String)> = HashSet::new();
|
||||||
let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
|
let mut jobs: Vec<JobData> = Vec::with_capacity(active_rows.len() * 3);
|
||||||
|
|
||||||
// Для дебага
|
|
||||||
let mut missing_teams: HashSet<String> = HashSet::new();
|
|
||||||
let mut missing_sports: HashSet<String> = HashSet::new();
|
|
||||||
|
|
||||||
for (idx, row) in active_rows {
|
for (idx, row) in active_rows {
|
||||||
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
||||||
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
||||||
let sport = row.get("SPORT").cloned().unwrap_or_default();
|
let sport = row.get("SPORT").cloned().unwrap_or_default();
|
||||||
|
|
||||||
// Запоминаем использованные команды
|
|
||||||
if !team_a.is_empty() {
|
if !team_a.is_empty() {
|
||||||
used_teams.insert((team_a.clone(), sport.clone()));
|
used_teams.insert((team_a.clone(), sport.clone()));
|
||||||
}
|
}
|
||||||
if !team_b.is_empty() {
|
if !team_b.is_empty() {
|
||||||
used_teams.insert((team_b.clone(), sport.clone()));
|
used_teams.insert((team_b.clone(), sport.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if log_enabled!(Level::Debug) {
|
|
||||||
let logo_a = logos.find(&team_a, &sport);
|
|
||||||
let logo_b = logos.find(&team_b, &sport);
|
|
||||||
|
|
||||||
if !team_a.is_empty() && logo_a.is_none() {
|
|
||||||
missing_teams.insert(team_a.clone());
|
|
||||||
missing_sports.insert(sport.clone());
|
|
||||||
}
|
|
||||||
if !team_b.is_empty() && logo_b.is_none() {
|
|
||||||
missing_teams.insert(team_b.clone());
|
|
||||||
missing_sports.insert(sport.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
|
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
|
||||||
jobs.extend(job.create_variants());
|
jobs.extend(job.create_variants());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очищаем logos от неиспользуемых команд
|
// Cleanup unused logos
|
||||||
let logos_before = logos.len();
|
let logos_before = logos.len();
|
||||||
logos.retain(|team, sport| used_teams.contains(&(team.to_string(), sport.to_string())));
|
logos.retain(|team, sport| used_teams.contains(&(team.to_string(), sport.to_string())));
|
||||||
info!(
|
info!("Retained {} used team logos (cleaned up {} unused)", logos.len(), logos_before - logos.len());
|
||||||
"Retained {} used team logos (cleaned up {} unused)",
|
|
||||||
logos.len(),
|
|
||||||
logos_before - logos.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Дебаг: выводим сводку по отсутствующим логотипам
|
// Sort jobs by date and time
|
||||||
if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
|
|
||||||
debug!("=== MISSING LOGOS SUMMARY ===");
|
|
||||||
debug!("Total unique teams missing logos: {}", missing_teams.len());
|
|
||||||
debug!("Sports involved: {:?}", missing_sports);
|
|
||||||
debug!(
|
|
||||||
"Missing teams (first 20): {:?}",
|
|
||||||
missing_teams.iter().take(20).collect::<Vec<_>>()
|
|
||||||
);
|
|
||||||
debug!("==============================");
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Generated {} total jobs (including variants)", jobs.len());
|
|
||||||
|
|
||||||
// ========== СОРТИРОВКА ПО ДАТЕ И ВРЕМЕНИ ==========
|
|
||||||
jobs.sort_by(|a, b| match a.sort_date.cmp(&b.sort_date) {
|
jobs.sort_by(|a, b| match a.sort_date.cmp(&b.sort_date) {
|
||||||
std::cmp::Ordering::Equal => match a.sort_time.cmp(&b.sort_time) {
|
std::cmp::Ordering::Equal => match a.sort_time.cmp(&b.sort_time) {
|
||||||
std::cmp::Ordering::Equal => a.variant_order.cmp(&b.variant_order),
|
std::cmp::Ordering::Equal => a.variant_order.cmp(&b.variant_order),
|
||||||
@@ -462,44 +375,20 @@ async fn generate_and_submit_jobs(
|
|||||||
},
|
},
|
||||||
other => other,
|
other => other,
|
||||||
});
|
});
|
||||||
|
|
||||||
info!("Jobs sorted by date and time (earliest first)");
|
info!("Jobs sorted by date and time (earliest first)");
|
||||||
|
|
||||||
if log_enabled!(Level::Debug) {
|
|
||||||
for (i, job) in jobs.iter().take(10).enumerate() {
|
|
||||||
debug!(
|
|
||||||
" {}: {} {} - {} (variant: {})",
|
|
||||||
i + 1,
|
|
||||||
job.sort_date,
|
|
||||||
job.sort_time,
|
|
||||||
job.outfile_name,
|
|
||||||
job.variant_order
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
info!("No jobs with STATE='FALSE' found");
|
info!("No jobs with STATE='FALSE' found");
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ==========
|
|
||||||
cleanup_finished_jobs(&config.nexrender_api_url).await?;
|
cleanup_finished_jobs(&config.nexrender_api_url).await?;
|
||||||
|
|
||||||
let http_client = Client::new();
|
let http_client = Client::new();
|
||||||
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
|
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
|
||||||
|
|
||||||
for job in jobs {
|
for job in jobs {
|
||||||
let nexrender_job = job.to_nexrender_job(config);
|
let nexrender_job = job.to_nexrender_job(config);
|
||||||
info!("Submitting job: {}", job.outfile_name);
|
info!("Submitting job: {}", job.outfile_name);
|
||||||
|
|
||||||
if log_enabled!(Level::Debug) {
|
|
||||||
debug!(
|
|
||||||
"Job details: sport='{}', league='{}', channel='{}', team_a='{}', team_b='{}'",
|
|
||||||
job.sport, job.league, job.channel, job.team_a, job.team_b
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = http_client
|
let response = http_client
|
||||||
.post(&config.nexrender_api_url)
|
.post(&config.nexrender_api_url)
|
||||||
.json(&nexrender_job)
|
.json(&nexrender_job)
|
||||||
@@ -511,6 +400,22 @@ async fn generate_and_submit_jobs(
|
|||||||
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
||||||
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
|
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
|
||||||
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
|
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
let record = JobRecord {
|
||||||
|
uid: uid.to_string(),
|
||||||
|
filename: job.outfile_name.clone(),
|
||||||
|
status: "queued".to_string(),
|
||||||
|
output_path: format!("{}/{}.{}", config.output_folder, job.outfile_name, config.template_output_ext),
|
||||||
|
approved: false,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
if let Err(e) = storage::upsert_job(db, &record).await {
|
||||||
|
error!("Failed to save job to DB: {}", e);
|
||||||
|
} else {
|
||||||
|
let _ = ws_tx.send(WsEvent::JobUpdated(record));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
@@ -519,18 +424,13 @@ async fn generate_and_submit_jobs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!("Successfully submitted {} jobs to Nexrender", submitted_jobs.len());
|
||||||
"Successfully submitted {} jobs to Nexrender",
|
|
||||||
submitted_jobs.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(submitted_jobs)
|
Ok(submitted_jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
|
pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
|
||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let response = client.get(api_url).send().await?;
|
let response = client.get(api_url).send().await?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let jobs: Vec<Value> = response.json().await?;
|
let jobs: Vec<Value> = response.json().await?;
|
||||||
for job in jobs {
|
for job in jobs {
|
||||||
|
|||||||
@@ -2,108 +2,56 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AE Anons</title>
|
<title>AE Anons — Управление заданиями</title>
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
<link rel="apple-touch-icon" href="/assets/logo.png">
|
|
||||||
<!-- CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Header -->
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-left">
|
<div class="logo-area">
|
||||||
<div class="logo-container">
|
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo">
|
||||||
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo"
|
|
||||||
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
|
|
||||||
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
|
|
||||||
<i class="fas fa-bolt"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1>AE Anons</h1>
|
<h1>AE Anons</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-controls">
|
<div class="theme-toggle">
|
||||||
<button class="theme-toggle" onclick="toggleTheme()">
|
<button id="themeToggleBtn" class="theme-toggle-btn" title="Сменить тему">
|
||||||
<i class="fas fa-circle-half-stroke" id="themeIcon"></i>
|
<i class="fas fa-moon"></i>
|
||||||
<span id="themeText">Auto</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Bar -->
|
<div class="controls">
|
||||||
<div class="action-bar">
|
<button id="generateBtn" class="btn btn-primary">
|
||||||
<button class="btn btn-primary" onclick="generateJobs()">
|
<i class="fas fa-sync-alt"></i> Generate
|
||||||
<i class="fas fa-play"></i> Generate New Jobs
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-warning" onclick="stopAllJobs()">
|
<button id="cleanupBtn" class="btn btn-secondary">
|
||||||
<i class="fas fa-stop"></i> Stop All Jobs
|
<i class="fas fa-trash-alt"></i> Cleanup
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick="cleanupJobs()">
|
<button id="stopAllBtn" class="btn btn-danger">
|
||||||
<i class="fas fa-trash-alt"></i> Cleanup Finished
|
<i class="fas fa-stop-circle"></i> Stop all
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline" onclick="refreshJobs()">
|
<span id="statusMsg" class="status-msg"></span>
|
||||||
<i class="fas fa-sync-alt"></i> Refresh
|
|
||||||
</button>
|
|
||||||
<div class="status-message" id="statusMessage">
|
|
||||||
<i class="fas fa-circle" style="color: var(--accent-success); font-size: 8px;"></i>
|
|
||||||
<span>Ready</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
<div class="table-wrapper">
|
||||||
<div class="stats-grid" id="statsGrid"></div>
|
|
||||||
|
|
||||||
<!-- Filter Bar -->
|
|
||||||
<div class="filter-bar">
|
|
||||||
<div class="search-wrapper">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<input type="text" class="search-input" id="filterInput" placeholder="Filter by filename or UID..."
|
|
||||||
onkeyup="filterTable()">
|
|
||||||
</div>
|
|
||||||
<div class="auto-refresh-badge">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
<span>Auto-refresh: 60s</span>
|
|
||||||
<span id="refreshCountdown">60</span>s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div class="table-container">
|
|
||||||
<table id="jobsTable">
|
<table id="jobsTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-column="filename" onclick="sortTable('filename')">
|
<th data-sort="uid">UID <i class="fas fa-sort"></i></th>
|
||||||
Output File <i class="fas fa-sort"></i>
|
<th data-sort="filename">Файл <i class="fas fa-sort"></i></th>
|
||||||
</th>
|
<th data-sort="status">Статус <i class="fas fa-sort"></i></th>
|
||||||
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
|
<th>Превью</th>
|
||||||
State <i class="fas fa-sort-up"></i>
|
<th>Апрув</th>
|
||||||
</th>
|
<th data-sort="created_at">Создан <i class="fas fa-sort"></i></th>
|
||||||
<th data-column="created" onclick="sortTable('created')">
|
|
||||||
Created <i class="fas fa-sort"></i>
|
|
||||||
</th>
|
|
||||||
<th data-column="updated" onclick="sortTable('updated')">
|
|
||||||
Updated <i class="fas fa-sort"></i>
|
|
||||||
</th>
|
|
||||||
<th data-column="uid" onclick="sortTable('uid')">
|
|
||||||
UID <i class="fas fa-sort"></i>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="jobsTableBody">
|
<tbody id="jobsBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="6" class="loading">Загрузка заданий...</td>
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
|
||||||
<p>Loading jobs...</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -111,384 +59,243 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// State
|
// --- Тема (dark/light) с автоопределением ОС и кнопкой переключения ---
|
||||||
let allJobs = [];
|
(function initTheme() {
|
||||||
let filteredJobs = [];
|
const STORAGE_KEY = 'ae_anons_theme';
|
||||||
let isLoading = false;
|
const btn = document.getElementById('themeToggleBtn');
|
||||||
let autoRefreshTimer = null;
|
const icon = btn.querySelector('i');
|
||||||
let countdownTimer = null;
|
|
||||||
let countdownValue = 60;
|
|
||||||
let currentTheme = 'auto';
|
|
||||||
|
|
||||||
// Sorting state
|
function setTheme(theme) {
|
||||||
let currentSort = { column: 'state', direction: 'asc' };
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
if (theme === 'dark') {
|
||||||
// Theme handling
|
icon.classList.remove('fa-sun');
|
||||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
icon.classList.add('fa-moon');
|
||||||
|
|
||||||
function getOSPreference() {
|
|
||||||
return darkModeMediaQuery.matches ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme) {
|
|
||||||
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
|
|
||||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
|
||||||
updateThemeButton(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateThemeButton(theme) {
|
|
||||||
const icon = document.getElementById('themeIcon');
|
|
||||||
const text = document.getElementById('themeText');
|
|
||||||
|
|
||||||
if (theme === 'auto') {
|
|
||||||
icon.className = 'fas fa-circle-half-stroke';
|
|
||||||
text.textContent = 'Auto';
|
|
||||||
} else if (theme === 'dark') {
|
|
||||||
icon.className = 'fas fa-moon';
|
|
||||||
text.textContent = 'Dark';
|
|
||||||
} else {
|
} else {
|
||||||
icon.className = 'fas fa-sun';
|
icon.classList.remove('fa-moon');
|
||||||
text.textContent = 'Light';
|
icon.classList.add('fa-sun');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function getSystemTheme() {
|
||||||
if (currentTheme === 'auto') {
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
currentTheme = 'light';
|
|
||||||
} else if (currentTheme === 'light') {
|
|
||||||
currentTheme = 'dark';
|
|
||||||
} else {
|
|
||||||
currentTheme = 'auto';
|
|
||||||
}
|
|
||||||
localStorage.setItem('theme', currentTheme);
|
|
||||||
applyTheme(currentTheme);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function getSavedTheme() {
|
||||||
const savedTheme = localStorage.getItem('theme') || 'auto';
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
currentTheme = savedTheme;
|
if (saved === 'dark' || saved === 'light') return saved;
|
||||||
applyTheme(currentTheme);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
darkModeMediaQuery.addEventListener('change', (e) => {
|
const saved = getSavedTheme();
|
||||||
if (currentTheme === 'auto') {
|
const initial = saved !== null ? saved : getSystemTheme();
|
||||||
applyTheme('auto');
|
setTheme(initial);
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const newTheme = current === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(newTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Следим за изменением системной темы (если нет сохранённой)
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
if (localStorage.getItem(STORAGE_KEY) === null) {
|
||||||
|
setTheme(e.matches ? 'dark' : 'light');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
// --- Основная логика приложения (WebSocket, таблица, кнопки) ---
|
||||||
|
let ws = null;
|
||||||
|
let currentJobs = [];
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||||
|
ws.onopen = () => console.log('WebSocket connected');
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleWsEvent(data);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket disconnected, reconnecting in 3s...');
|
||||||
|
setTimeout(connectWebSocket, 3000);
|
||||||
|
};
|
||||||
|
ws.onerror = (err) => console.error('WebSocket error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting
|
function handleWsEvent(event) {
|
||||||
function sortTable(column) {
|
switch (event.type) {
|
||||||
if (currentSort.column === column) {
|
case 'JobUpdated':
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
updateJobInList(event.job);
|
||||||
} else {
|
break;
|
||||||
currentSort.column = column;
|
case 'GenerationStarted':
|
||||||
currentSort.direction = 'asc';
|
showStatus('Генерация заданий началась...', 'info');
|
||||||
}
|
break;
|
||||||
|
case 'GenerationFinished':
|
||||||
updateSortIcons();
|
showStatus(`Генерация завершена: создано ${event.count} заданий`, 'success');
|
||||||
sortAndRender();
|
loadJobs();
|
||||||
}
|
break;
|
||||||
|
case 'Error':
|
||||||
function updateSortIcons() {
|
showStatus(`Ошибка: ${event.message}`, 'error');
|
||||||
document.querySelectorAll('th').forEach(th => {
|
break;
|
||||||
const column = th.dataset.column;
|
|
||||||
th.classList.remove('sorted-asc', 'sorted-desc');
|
|
||||||
|
|
||||||
const icon = th.querySelector('i');
|
|
||||||
if (column === currentSort.column) {
|
|
||||||
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
|
||||||
icon.className = currentSort.direction === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
|
|
||||||
} else {
|
|
||||||
icon.className = 'fas fa-sort';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortJobs(jobs) {
|
|
||||||
const { column, direction } = currentSort;
|
|
||||||
const multiplier = direction === 'asc' ? 1 : -1;
|
|
||||||
|
|
||||||
return [...jobs].sort((a, b) => {
|
|
||||||
let aVal, bVal;
|
|
||||||
|
|
||||||
switch (column) {
|
|
||||||
case 'filename':
|
|
||||||
aVal = a.outfile_name || '';
|
|
||||||
bVal = b.outfile_name || '';
|
|
||||||
return multiplier * aVal.localeCompare(bVal);
|
|
||||||
|
|
||||||
case 'state':
|
|
||||||
aVal = stateOrder[a.state] || 999;
|
|
||||||
bVal = stateOrder[b.state] || 999;
|
|
||||||
return multiplier * (aVal - bVal);
|
|
||||||
|
|
||||||
case 'created':
|
|
||||||
aVal = a.created_at ? new Date(a.created_at).getTime() : 0;
|
|
||||||
bVal = b.created_at ? new Date(b.created_at).getTime() : 0;
|
|
||||||
return multiplier * (bVal - aVal);
|
|
||||||
|
|
||||||
case 'updated':
|
|
||||||
aVal = a.updated_at ? new Date(a.updated_at).getTime() : 0;
|
|
||||||
bVal = b.updated_at ? new Date(b.updated_at).getTime() : 0;
|
|
||||||
return multiplier * (bVal - aVal);
|
|
||||||
|
|
||||||
case 'uid':
|
|
||||||
aVal = a.uid || '';
|
|
||||||
bVal = b.uid || '';
|
|
||||||
return multiplier * aVal.localeCompare(bVal);
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
console.log('Unknown event:', event);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortAndRender() {
|
function updateJobInList(job) {
|
||||||
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
|
const idx = currentJobs.findIndex(j => j.uid === job.uid);
|
||||||
filteredJobs : allJobs;
|
if (idx !== -1) {
|
||||||
const sorted = sortJobs(jobsToRender);
|
currentJobs[idx] = job;
|
||||||
renderJobs(sorted);
|
} else {
|
||||||
|
currentJobs.unshift(job);
|
||||||
|
}
|
||||||
|
renderJobsTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data fetching
|
async function loadJobs() {
|
||||||
async function refreshJobs() {
|
|
||||||
if (isLoading) return;
|
|
||||||
isLoading = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus('loading', 'Loading jobs...');
|
|
||||||
const response = await fetch('/api/jobs');
|
const response = await fetch('/api/jobs');
|
||||||
allJobs = await response.json();
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
filteredJobs = [];
|
currentJobs = await response.json();
|
||||||
document.getElementById('filterInput').value = '';
|
renderJobsTable();
|
||||||
sortAndRender();
|
|
||||||
updateStats();
|
|
||||||
setStatus('success', `Loaded ${allJobs.length} jobs`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error('Failed to load jobs:', err);
|
||||||
setStatus('error', 'Failed to load jobs');
|
document.getElementById('jobsBody').innerHTML = '<tr><td colspan="6">Ошибка загрузки заданий</td></tr>';
|
||||||
} finally {
|
|
||||||
isLoading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobs(jobs) {
|
function renderJobsTable() {
|
||||||
const tbody = document.getElementById('jobsTableBody');
|
const tbody = document.getElementById('jobsBody');
|
||||||
|
if (!currentJobs.length) {
|
||||||
if (jobs.length === 0) {
|
tbody.innerHTML = '<tr><td colspan="6">Нет заданий</td></tr>';
|
||||||
tbody.innerHTML = `
|
|
||||||
<tr>
|
|
||||||
<td colspan="5">
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-inbox"></i>
|
|
||||||
<p>No jobs found</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
tbody.innerHTML = currentJobs.map(job => `
|
||||||
tbody.innerHTML = jobs.map(job => {
|
<tr data-uid="${job.uid}">
|
||||||
const stateClass = getStateClass(job.state);
|
<td class="uid">${escapeHtml(job.uid)}</td>
|
||||||
const created = formatDateTime(job.created_at);
|
<td class="filename">${escapeHtml(job.filename)}</td>
|
||||||
const updated = formatDateTime(job.updated_at);
|
<td class="status">${escapeHtml(job.status)}</td>
|
||||||
|
<td class="preview">
|
||||||
return `
|
<a href="/api/video/${job.uid}" target="_blank" title="Просмотр видео">
|
||||||
<tr>
|
<i class="fas fa-video"></i>
|
||||||
<td>
|
</a>
|
||||||
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
|
<td class="approve">
|
||||||
<td class="datetime">${created}</td>
|
<button class="approve-btn" data-uid="${job.uid}" ${job.approved ? 'disabled' : ''}>
|
||||||
<td class="datetime">${updated}</td>
|
${job.approved ? '✅' : '✅'}
|
||||||
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="created">${formatDate(job.created_at)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`).join('');
|
||||||
}).join('');
|
// привязать обработчики апрува
|
||||||
}
|
document.querySelectorAll('.approve-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
function escapeHtml(text) {
|
e.stopPropagation();
|
||||||
if (!text) return '';
|
const uid = btn.getAttribute('data-uid');
|
||||||
const div = document.createElement('div');
|
approveJob(uid, btn);
|
||||||
div.textContent = text;
|
});
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateClass(state) {
|
|
||||||
const classes = {
|
|
||||||
'finished': 'badge-finished',
|
|
||||||
'started': 'badge-started',
|
|
||||||
'processing': 'badge-processing',
|
|
||||||
'queued': 'badge-queued',
|
|
||||||
'error': 'badge-error',
|
|
||||||
'pending': 'badge-pending'
|
|
||||||
};
|
|
||||||
return classes[state] || 'badge-pending';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateStr) {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
try {
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
const dayMonth = date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' });
|
|
||||||
return `${time}, ${dayMonth}`;
|
|
||||||
} catch {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStats() {
|
|
||||||
const stats = {
|
|
||||||
total: allJobs.length,
|
|
||||||
finished: 0,
|
|
||||||
started: 0,
|
|
||||||
queued: 0,
|
|
||||||
error: 0,
|
|
||||||
other: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
allJobs.forEach(job => {
|
|
||||||
switch (job.state) {
|
|
||||||
case 'finished': stats.finished++; break;
|
|
||||||
case 'started':
|
|
||||||
case 'processing': stats.started++; break;
|
|
||||||
case 'queued': stats.queued++; break;
|
|
||||||
case 'error': stats.error++; break;
|
|
||||||
default: stats.other++; break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const grid = document.getElementById('statsGrid');
|
|
||||||
grid.innerHTML = `
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3><i class="fas fa-tasks"></i> Total Jobs</h3>
|
|
||||||
<div class="value">${stats.total}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3><i class="fas fa-check-circle"></i> Completed</h3>
|
|
||||||
<div class="value">${stats.finished}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3><i class="fas fa-play-circle"></i> Active</h3>
|
|
||||||
<div class="value">${stats.started + stats.queued}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3><i class="fas fa-exclamation-circle"></i> Errors</h3>
|
|
||||||
<div class="value">${stats.error}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterTable() {
|
async function approveJob(uid, btn) {
|
||||||
const filter = document.getElementById('filterInput').value.toLowerCase();
|
try {
|
||||||
filteredJobs = allJobs.filter(job =>
|
const response = await fetch(`/api/approve/${uid}`, { method: 'POST' });
|
||||||
job.outfile_name.toLowerCase().includes(filter) ||
|
const result = await response.json();
|
||||||
job.uid.toLowerCase().includes(filter)
|
if (response.ok) {
|
||||||
);
|
showStatus(`Файл скопирован: ${result.destination || 'approved'}`, 'success');
|
||||||
sortAndRender();
|
btn.disabled = true;
|
||||||
|
// обновить задание в списке
|
||||||
|
const job = currentJobs.find(j => j.uid === uid);
|
||||||
|
if (job) { job.approved = true; renderJobsTable(); }
|
||||||
|
} else {
|
||||||
|
showStatus(`Ошибка: ${result.message || result.status}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`Ошибка при апруве: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
|
||||||
async function generateJobs() {
|
async function generateJobs() {
|
||||||
setStatus('loading', 'Generating jobs...');
|
const btn = document.getElementById('generateBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
showStatus('Запуск генерации...', 'info');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/generate', { method: 'POST' });
|
const response = await fetch('/api/generate', { method: 'POST' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setStatus('success', 'Job generation started');
|
showStatus('Генерация запущена', 'success');
|
||||||
setTimeout(() => refreshJobs(), 5000);
|
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
setStatus('error', `Error: ${text}`);
|
showStatus(`Ошибка: ${text}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error', `Error: ${err}`);
|
showStatus(`Ошибка: ${err.message}`, 'error');
|
||||||
}
|
} finally {
|
||||||
}
|
setTimeout(() => { btn.disabled = false; }, 2000);
|
||||||
|
|
||||||
async function stopAllJobs() {
|
|
||||||
if (!confirm('Are you sure you want to stop all active jobs?')) return;
|
|
||||||
|
|
||||||
setStatus('loading', 'Stopping all jobs...');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
|
|
||||||
if (response.ok) {
|
|
||||||
setStatus('success', 'All jobs stopped');
|
|
||||||
refreshJobs();
|
|
||||||
} else {
|
|
||||||
const text = await response.text();
|
|
||||||
setStatus('error', `Error: ${text}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setStatus('error', `Error: ${err}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupJobs() {
|
async function cleanupJobs() {
|
||||||
setStatus('loading', 'Cleaning up finished jobs...');
|
showStatus('Очистка завершённых заданий...', 'info');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/cleanup', { method: 'POST' });
|
const response = await fetch('/api/cleanup', { method: 'POST' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setStatus('success', 'Cleanup completed');
|
showStatus('Очистка выполнена', 'success');
|
||||||
await refreshJobs();
|
loadJobs();
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
showStatus('Ошибка при очистке', 'error');
|
||||||
setStatus('error', `Error: ${text}`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('error', `Error: ${err}`);
|
showStatus(`Ошибка: ${err.message}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(type, message) {
|
async function stopAllJobs() {
|
||||||
const el = document.getElementById('statusMessage');
|
if (!confirm('Остановить все активные задания?')) return;
|
||||||
const icons = {
|
showStatus('Остановка заданий...', 'info');
|
||||||
loading: '<span class="spinner"></span>',
|
try {
|
||||||
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
|
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
|
||||||
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
|
const text = await response.text();
|
||||||
};
|
showStatus(text, 'success');
|
||||||
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
|
loadJobs();
|
||||||
|
} catch (err) {
|
||||||
|
showStatus(`Ошибка: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh
|
function showStatus(msg, type) {
|
||||||
function startAutoRefresh() {
|
const el = document.getElementById('statusMsg');
|
||||||
stopAutoRefresh();
|
el.textContent = msg;
|
||||||
|
el.className = `status-msg ${type}`;
|
||||||
autoRefreshTimer = setInterval(() => {
|
setTimeout(() => {
|
||||||
refreshJobs();
|
if (el.textContent === msg) el.textContent = '';
|
||||||
resetCountdown();
|
}, 5000);
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
countdownTimer = setInterval(() => {
|
|
||||||
countdownValue--;
|
|
||||||
document.getElementById('refreshCountdown').textContent = countdownValue;
|
|
||||||
if (countdownValue <= 0) {
|
|
||||||
countdownValue = 60;
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopAutoRefresh() {
|
function formatDate(iso) {
|
||||||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
if (!iso) return '';
|
||||||
if (countdownTimer) clearInterval(countdownTimer);
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetCountdown() {
|
function escapeHtml(str) {
|
||||||
countdownValue = 60;
|
if (!str) return '';
|
||||||
document.getElementById('refreshCountdown').textContent = countdownValue;
|
return str.replace(/[&<>]/g, function (m) {
|
||||||
}
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
// Initialize
|
if (m === '>') return '>';
|
||||||
initTheme();
|
return m;
|
||||||
refreshJobs();
|
|
||||||
startAutoRefresh();
|
|
||||||
updateSortIcons();
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
stopAutoRefresh();
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
connectWebSocket();
|
||||||
|
loadJobs();
|
||||||
|
document.getElementById('generateBtn').addEventListener('click', generateJobs);
|
||||||
|
document.getElementById('cleanupBtn').addEventListener('click', cleanupJobs);
|
||||||
|
document.getElementById('stopAllBtn').addEventListener('click', stopAllJobs);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -1,555 +1,273 @@
|
|||||||
/* ========================================
|
|
||||||
AE Anons - Nexrender Job Manager Styles
|
|
||||||
======================================== */
|
|
||||||
|
|
||||||
/* CSS Variables - Light Theme (default) */
|
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #ffffff;
|
--bg-body: #f5f7fa;
|
||||||
--bg-secondary: #fafafa;
|
--bg-container: #ffffff;
|
||||||
--bg-tertiary: #f3f0f7;
|
--text-primary: #1e2a3a;
|
||||||
--text-primary: #1a1a1a;
|
--text-secondary: #4a5568;
|
||||||
--text-secondary: #6b4f7c;
|
--border-color: #e2e8f0;
|
||||||
--border-color: #e0d4e8;
|
--table-header-bg: #f8fafc;
|
||||||
--accent-primary: #7c3aed;
|
--btn-primary-bg: #3b82f6;
|
||||||
--accent-secondary: #c2410c;
|
--btn-primary-hover: #2563eb;
|
||||||
--accent-success: #10b981;
|
--btn-secondary-bg: #64748b;
|
||||||
--accent-warning: #ea580c;
|
--btn-secondary-hover: #475569;
|
||||||
--accent-danger: #ef4444;
|
--btn-danger-bg: #ef4444;
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--btn-danger-hover: #dc2626;
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
--status-info: #3b82f6;
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
--status-success: #10b981;
|
||||||
--header-bg: #7c3aed;
|
--status-error: #ef4444;
|
||||||
--header-text: #ffffff;
|
--logo-brightness: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark Theme */
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--bg-primary: #1a1025;
|
--bg-body: #0f172a;
|
||||||
--bg-secondary: #251a30;
|
--bg-container: #1e293b;
|
||||||
--bg-tertiary: #352545;
|
--text-primary: #f1f5f9;
|
||||||
--text-primary: #f0e6ff;
|
--text-secondary: #cbd5e1;
|
||||||
--text-secondary: #c4a6d9;
|
--border-color: #334155;
|
||||||
--border-color: #4a3560;
|
--table-header-bg: #0f172a;
|
||||||
--accent-primary: #a78bfa;
|
--btn-primary-bg: #2563eb;
|
||||||
--accent-secondary: #f59e0b;
|
--btn-primary-hover: #3b82f6;
|
||||||
--accent-success: #34d399;
|
--btn-secondary-bg: #475569;
|
||||||
--accent-warning: #fbbf24;
|
--btn-secondary-hover: #64748b;
|
||||||
--accent-danger: #f87171;
|
--btn-danger-bg: #dc2626;
|
||||||
--header-bg: #a78bfa;
|
--btn-danger-hover: #ef4444;
|
||||||
--header-text: #1a1025;
|
--logo-brightness: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reset & Base */
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
margin: 0;
|
||||||
background: var(--bg-primary);
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: var(--bg-body);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
transition: background-color 0.3s, color 0.2s;
|
||||||
min-height: 100vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 100%;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
background-color: var(--bg-container);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 24px;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
padding: 1rem 2rem;
|
||||||
gap: 16px;
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.logo-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--accent-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
width: 100%;
|
width: 150px;
|
||||||
height: 100%;
|
/* уменьшенный размер */
|
||||||
object-fit: cover;
|
height: auto;
|
||||||
|
filter: brightness(var(--logo-brightness));
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-placeholder {
|
.logo-area h1 {
|
||||||
width: 100%;
|
margin: 0;
|
||||||
height: 100%;
|
font-size: 1.8rem;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent-primary);
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls {
|
.theme-toggle-btn {
|
||||||
display: flex;
|
background: none;
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 2px solid var(--accent-primary);
|
|
||||||
border-radius: 24px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Action Bar */
|
|
||||||
.action-bar {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle-btn:hover {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 20px;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 14px;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, transform 0.1s;
|
||||||
|
font-weight: 500;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 0.5rem;
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn i {
|
.btn i {
|
||||||
font-size: 14px;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Generate - Purple */
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
|
background-color: var(--btn-primary-bg);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
|
||||||
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .btn-primary {
|
|
||||||
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
|
||||||
color: #1a1025;
|
|
||||||
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
|
background-color: var(--btn-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stop - Orange */
|
.btn-secondary {
|
||||||
.btn-warning {
|
background-color: var(--btn-secondary-bg);
|
||||||
background: linear-gradient(135deg, #ea580c 0%, #f97316 100%);
|
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
|
||||||
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .btn-warning {
|
.btn-secondary:hover {
|
||||||
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
|
background-color: var(--btn-secondary-hover);
|
||||||
color: #1a1025;
|
|
||||||
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-warning:hover {
|
|
||||||
box-shadow: 0 4px 12px rgba(234, 88, 12, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cleanup - Plum */
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: linear-gradient(135deg, #9d174d 0%, #be185d 100%);
|
background-color: var(--btn-danger-bg);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
|
||||||
box-shadow: 0 2px 8px rgba(157, 23, 77, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .btn-danger {
|
|
||||||
background: linear-gradient(135deg, #be185d 0%, #db2777 100%);
|
|
||||||
color: #f0e6ff;
|
|
||||||
box-shadow: 0 2px 8px rgba(190, 24, 93, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
box-shadow: 0 4px 12px rgba(157, 23, 77, 0.4);
|
background-color: var(--btn-danger-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Refresh - Outline */
|
.btn:active {
|
||||||
.btn-outline {
|
transform: scale(0.97);
|
||||||
background: transparent;
|
|
||||||
border: 2px solid #7c3aed;
|
|
||||||
color: #7c3aed;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .btn-outline {
|
.status-msg {
|
||||||
border-color: #a78bfa;
|
|
||||||
color: #a78bfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background: #7c3aed;
|
|
||||||
color: white;
|
|
||||||
border-color: #7c3aed;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .btn-outline:hover {
|
|
||||||
background: #a78bfa;
|
|
||||||
color: #1a1025;
|
|
||||||
border-color: #a78bfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Message */
|
|
||||||
.status-message {
|
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 8px 16px;
|
font-size: 0.85rem;
|
||||||
background: var(--bg-tertiary);
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats Cards */
|
.status-msg.info {
|
||||||
.stats-grid {
|
background-color: var(--status-info);
|
||||||
display: grid;
|
color: white;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.status-msg.success {
|
||||||
background: var(--bg-secondary);
|
background-color: var(--status-success);
|
||||||
border-radius: 12px;
|
color: white;
|
||||||
padding: 20px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card h3 {
|
.status-msg.error {
|
||||||
font-size: 14px;
|
background-color: var(--status-error);
|
||||||
color: var(--text-secondary);
|
color: white;
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card .value {
|
.table-wrapper {
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter Bar */
|
|
||||||
.filter-bar {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-wrapper i {
|
|
||||||
position: absolute;
|
|
||||||
left: 16px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px 12px 44px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 2px solid var(--border-color);
|
|
||||||
border-radius: 24px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.auto-refresh-badge {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table */
|
|
||||||
.table-container {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border-color);
|
padding: 0 2rem 2rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: auto;
|
font-size: 0.9rem;
|
||||||
min-width: 800px;
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
text-align: left;
|
background-color: var(--table-header-bg);
|
||||||
padding: 16px;
|
|
||||||
background: var(--header-bg);
|
|
||||||
color: var(--header-text);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
th:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
th i {
|
th i {
|
||||||
margin-left: 8px;
|
margin-left: 0.5rem;
|
||||||
font-size: 12px;
|
opacity: 0.5;
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
th.sorted-asc i,
|
|
||||||
th.sorted-desc i {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tr:hover {
|
tr:hover {
|
||||||
background: var(--bg-tertiary);
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Job Filename */
|
[data-theme="dark"] tr:hover {
|
||||||
.job-filename {
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
font-family: 'Monaco', 'Menlo', 'Cascadia Code', 'Consolas', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.5;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: normal;
|
|
||||||
max-width: 100%;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badges */
|
.loading {
|
||||||
.badge {
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-finished {
|
|
||||||
background: rgba(16, 185, 129, 0.15);
|
|
||||||
color: var(--accent-success);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-started,
|
|
||||||
.badge-processing {
|
|
||||||
background: rgba(124, 58, 237, 0.15);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
border: 1px solid rgba(124, 58, 237, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-queued {
|
|
||||||
background: rgba(234, 88, 12, 0.15);
|
|
||||||
color: var(--accent-warning);
|
|
||||||
border: 1px solid rgba(234, 88, 12, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-error {
|
|
||||||
background: rgba(239, 68, 68, 0.15);
|
|
||||||
color: var(--accent-danger);
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-pending {
|
|
||||||
background: rgba(245, 158, 11, 0.15);
|
|
||||||
color: var(--accent-secondary);
|
|
||||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Date & Time */
|
|
||||||
.datetime {
|
|
||||||
font-family: 'Monaco', 'Menlo', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.uid {
|
|
||||||
font-family: 'Monaco', 'Menlo', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 2rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state i {
|
.approve-btn {
|
||||||
font-size: 48px;
|
background: none;
|
||||||
margin-bottom: 16px;
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-btn:not(:disabled):hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
color: var(--accent-primary);
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading Spinner */
|
.preview a {
|
||||||
.spinner {
|
color: var(--text-primary);
|
||||||
width: 20px;
|
text-decoration: none;
|
||||||
height: 20px;
|
font-size: 1.2rem;
|
||||||
border: 3px solid var(--border-color);
|
|
||||||
border-top-color: var(--accent-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.preview a:hover {
|
||||||
to {
|
opacity: 0.7;
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
|
||||||
padding: 16px;
|
.header,
|
||||||
|
.controls,
|
||||||
|
.table-wrapper {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.logo {
|
||||||
flex-direction: column;
|
width: 100px;
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.logo-area h1 {
|
||||||
flex-wrap: wrap;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
th,
|
||||||
font-size: 20px;
|
td {
|
||||||
}
|
padding: 0.5rem;
|
||||||
|
|
||||||
.action-bar {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-message {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
141
src/storage.rs
Normal file
141
src/storage.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::Serialize;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct JobRecord {
|
||||||
|
pub uid: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub status: String,
|
||||||
|
pub output_path: String,
|
||||||
|
pub approved: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init_db(database_url: &str) -> Result<SqlitePool> {
|
||||||
|
// Для in-memory БД (тестирование)
|
||||||
|
if database_url == "sqlite::memory:" {
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:").await?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
output_path TEXT NOT NULL,
|
||||||
|
approved BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
return Ok(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для файловой БД
|
||||||
|
let path = database_url.strip_prefix("sqlite:").unwrap_or(database_url);
|
||||||
|
// Создаём родительскую директорию, если её нет
|
||||||
|
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||||
|
if !parent.exists() {
|
||||||
|
tokio::fs::create_dir_all(parent).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to create database directory '{}': {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Подключаемся (если файла нет, SQLite создаст его автоматически)
|
||||||
|
let pool = SqlitePool::connect(database_url).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to open database '{}': {}", database_url, e))?;
|
||||||
|
// Создаём таблицу, если её нет
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
uid TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
output_path TEXT NOT NULL,
|
||||||
|
approved BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub async fn upsert_job(pool: &SqlitePool, record: &JobRecord) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO jobs (uid, filename, status, output_path, approved, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(uid) DO UPDATE SET
|
||||||
|
status = excluded.status,
|
||||||
|
output_path = excluded.output_path,
|
||||||
|
approved = excluded.approved,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&record.uid)
|
||||||
|
.bind(&record.filename)
|
||||||
|
.bind(&record.status)
|
||||||
|
.bind(&record.output_path)
|
||||||
|
.bind(record.approved)
|
||||||
|
.bind(record.created_at)
|
||||||
|
.bind(record.updated_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_jobs(pool: &SqlitePool) -> Result<Vec<JobRecord>> {
|
||||||
|
let rows = sqlx::query("SELECT * FROM jobs ORDER BY created_at DESC")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
let mut jobs = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
jobs.push(JobRecord {
|
||||||
|
uid: row.get("uid"),
|
||||||
|
filename: row.get("filename"),
|
||||||
|
status: row.get("status"),
|
||||||
|
output_path: row.get("output_path"),
|
||||||
|
approved: row.get("approved"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_job(pool: &SqlitePool, uid: &str) -> Result<Option<JobRecord>> {
|
||||||
|
let row = sqlx::query("SELECT * FROM jobs WHERE uid = ?")
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
if let Some(row) = row {
|
||||||
|
Ok(Some(JobRecord {
|
||||||
|
uid: row.get("uid"),
|
||||||
|
filename: row.get("filename"),
|
||||||
|
status: row.get("status"),
|
||||||
|
output_path: row.get("output_path"),
|
||||||
|
approved: row.get("approved"),
|
||||||
|
created_at: row.get("created_at"),
|
||||||
|
updated_at: row.get("updated_at"),
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn approve_job(pool: &SqlitePool, uid: &str) -> Result<bool> {
|
||||||
|
let result = sqlx::query("UPDATE jobs SET approved = 1, updated_at = ? WHERE uid = ?")
|
||||||
|
.bind(Utc::now())
|
||||||
|
.bind(uid)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
423
src/web.rs
423
src/web.rs
@@ -1,89 +1,60 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
|
use crate::processor::{fetch_all_jobs, process_spreadsheet};
|
||||||
|
use crate::storage::{self, JobRecord};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, State, WebSocketUpgrade},
|
||||||
http::{header::CONTENT_TYPE, StatusCode},
|
http::{header::CONTENT_TYPE, StatusCode},
|
||||||
response::{Html, IntoResponse, Json},
|
response::{Html, IntoResponse, Json, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use smb::{Client, ClientConfig, CreateOptions, FileAttributes, FileCreateArgs, UncPath, WriteAt};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::broadcast;
|
||||||
use tower_http::services::ServeDir;
|
use tokio_util::io::ReaderStream;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
use url::Url;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
|
pub db: SqlitePool,
|
||||||
|
pub ws_tx: broadcast::Sender<WsEvent>,
|
||||||
|
pub last_generation: Arc<tokio::sync::Mutex<Option<chrono::DateTime<chrono::Local>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct JobInfo {
|
#[serde(tag = "type")]
|
||||||
pub uid: String,
|
pub enum WsEvent {
|
||||||
pub outfile_name: String,
|
JobUpdated(JobRecord),
|
||||||
pub state: String,
|
GenerationStarted,
|
||||||
pub created_at: Option<String>,
|
GenerationFinished { count: usize },
|
||||||
pub updated_at: Option<String>,
|
Error { message: String },
|
||||||
}
|
|
||||||
|
|
||||||
impl JobInfo {
|
|
||||||
fn from_nexrender_json(job: &serde_json::Value) -> Option<Self> {
|
|
||||||
let uid = job.get("uid")?.as_str()?.to_string();
|
|
||||||
let state = job.get("state")?.as_str()?.to_string();
|
|
||||||
|
|
||||||
let outfile_name = job
|
|
||||||
.get("actions")
|
|
||||||
.and_then(|a| a.get("postrender"))
|
|
||||||
.and_then(|p| p.as_array())
|
|
||||||
.and_then(|arr| {
|
|
||||||
arr.iter()
|
|
||||||
.find_map(|action| {
|
|
||||||
action
|
|
||||||
.get("module")
|
|
||||||
.and_then(|m| m.as_str())
|
|
||||||
.filter(|&m| m == "@nexrender/action-copy")
|
|
||||||
.and_then(|_| action.get("output").and_then(|o| o.as_str()))
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
arr.iter()
|
|
||||||
.find_map(|action| action.get("output").and_then(|o| o.as_str()))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map(|path| {
|
|
||||||
std::path::Path::new(path)
|
|
||||||
.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.unwrap_or(path)
|
|
||||||
.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| format!("job_{}", uid));
|
|
||||||
|
|
||||||
Some(JobInfo {
|
|
||||||
uid,
|
|
||||||
outfile_name,
|
|
||||||
state,
|
|
||||||
created_at: job
|
|
||||||
.get("createdAt")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
updated_at: job
|
|
||||||
.get("updatedAt")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
||||||
let web_port = config.web_port;
|
let web_port = config.web_port;
|
||||||
|
let db = storage::init_db(&config.database_url).await?;
|
||||||
|
let (ws_tx, _) = broadcast::channel(100);
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config,
|
config,
|
||||||
last_generation: Arc::new(Mutex::new(None)),
|
db,
|
||||||
|
ws_tx,
|
||||||
|
last_generation: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let state_clone_for_sync = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
sync_jobs_status(state_clone_for_sync).await;
|
||||||
|
});
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index_page))
|
.route("/", get(index_page))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
@@ -95,10 +66,9 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
|||||||
.route("/api/cleanup", post(cleanup_jobs))
|
.route("/api/cleanup", post(cleanup_jobs))
|
||||||
.route("/api/status", get(get_status))
|
.route("/api/status", get(get_status))
|
||||||
.route("/api/jobs/stop-all", post(stop_all_jobs))
|
.route("/api/jobs/stop-all", post(stop_all_jobs))
|
||||||
.nest_service(
|
.route("/api/video/{uid}", get(serve_video))
|
||||||
"/static/fontawesome/webfonts",
|
.route("/api/approve/{uid}", post(approve_job))
|
||||||
ServeDir::new("src/static/fontawesome/webfonts"),
|
.route("/ws", get(ws_handler))
|
||||||
)
|
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
@@ -110,6 +80,53 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn sync_jobs_status(state: AppState) {
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
log::debug!("🔄 Syncing jobs from Nexrender...");
|
||||||
|
match fetch_all_jobs(&state.config.nexrender_api_url).await {
|
||||||
|
Ok(jobs_json) => {
|
||||||
|
log::info!("📊 Fetched {} jobs from Nexrender", jobs_json.len());
|
||||||
|
for job_json in jobs_json {
|
||||||
|
if let Some(job) = JobInfo::from_nexrender_json(&job_json) {
|
||||||
|
log::debug!("🔍 Job from Nexrender: uid={}, state={}", job.uid, job.state);
|
||||||
|
match storage::get_job(&state.db, &job.uid).await {
|
||||||
|
Ok(Some(record)) => {
|
||||||
|
if record.status != job.state {
|
||||||
|
log::info!("🔄 Status changed for job {}: {} -> {}", job.uid, record.status, job.state);
|
||||||
|
let mut updated_record = record;
|
||||||
|
updated_record.status = job.state.clone();
|
||||||
|
updated_record.updated_at = Utc::now();
|
||||||
|
if let Err(e) = storage::upsert_job(&state.db, &updated_record).await {
|
||||||
|
log::error!("❌ Failed to update job status in DB: {}", e);
|
||||||
|
} else {
|
||||||
|
let _ = state.ws_tx.send(WsEvent::JobUpdated(updated_record));
|
||||||
|
log::debug!("📨 Sent WebSocket event for job {}", job.uid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::debug!("✅ No change for job {}", job.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::warn!("⚠️ Job {} not found in DB, skipping", job.uid);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("❌ DB error for job {}: {}", job.uid, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::warn!("⚠️ Failed to parse job from JSON: {:?}", job_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("❌ Failed to fetch jobs from Nexrender: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn index_page() -> Html<&'static str> {
|
async fn index_page() -> Html<&'static str> {
|
||||||
Html(include_str!("static/index.html"))
|
Html(include_str!("static/index.html"))
|
||||||
}
|
}
|
||||||
@@ -142,16 +159,10 @@ async fn favicon() -> impl IntoResponse {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
|
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobRecord>>, AppError> {
|
||||||
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
|
let jobs = storage::get_all_jobs(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let jobs: Vec<JobInfo> = jobs_json
|
|
||||||
.iter()
|
|
||||||
.filter_map(JobInfo::from_nexrender_json)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(jobs))
|
Ok(Json(jobs))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,34 +180,64 @@ async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
|
|||||||
*last_gen = Some(chrono::Local::now());
|
*last_gen = Some(chrono::Local::now());
|
||||||
drop(last_gen);
|
drop(last_gen);
|
||||||
|
|
||||||
let config = state.config.clone();
|
let state_clone = state.clone();
|
||||||
let last_gen_clone = state.last_generation.clone();
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
match process_spreadsheet(&config).await {
|
let _ = state_clone.ws_tx.send(WsEvent::GenerationStarted);
|
||||||
|
match process_spreadsheet(
|
||||||
|
&state_clone.config,
|
||||||
|
&state_clone.db,
|
||||||
|
state_clone.ws_tx.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(submitted) => {
|
Ok(submitted) => {
|
||||||
log::info!("Generation completed, {} jobs submitted", submitted.len());
|
log::info!("Generation completed, {} jobs submitted", submitted.len());
|
||||||
|
let _ = state_clone.ws_tx.send(WsEvent::GenerationFinished {
|
||||||
|
count: submitted.len(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Generation failed: {}", e);
|
log::error!("Generation failed: {}", e);
|
||||||
|
let _ = state_clone.ws_tx.send(WsEvent::Error {
|
||||||
|
message: e.to_string(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*last_gen_clone.lock().await = None;
|
*state_clone.last_generation.lock().await = None;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok((StatusCode::ACCEPTED, "Job generation started"))
|
Ok((StatusCode::ACCEPTED, "Job generation started"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||||
cleanup_finished_jobs(&state.config.nexrender_api_url)
|
let client = reqwest::Client::new();
|
||||||
|
let api_url = &state.config.nexrender_api_url;
|
||||||
|
let jobs = fetch_all_jobs(api_url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
Ok((StatusCode::OK, "Cleanup completed"))
|
let mut removed = 0;
|
||||||
|
for job in jobs {
|
||||||
|
if let (Some(uid), Some(status)) = (
|
||||||
|
job.get("uid").and_then(|u| u.as_str()),
|
||||||
|
job.get("state").and_then(|s| s.as_str()),
|
||||||
|
) {
|
||||||
|
if status == "finished" || status == "error" {
|
||||||
|
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
||||||
|
// Also delete from local DB
|
||||||
|
let _ = sqlx::query("DELETE FROM jobs WHERE uid = ?")
|
||||||
|
.bind(uid)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
removed += 1;
|
||||||
|
log::info!("Cleaned up job: {}", uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((StatusCode::OK, format!("Cleaned up {} jobs", removed)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
|
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
let last_gen = *state.last_generation.lock().await;
|
let last_gen = *state.last_generation.lock().await;
|
||||||
let status = serde_json::json!({
|
let status = json!({
|
||||||
"last_generation": last_gen.map(|dt| dt.to_rfc3339()),
|
"last_generation": last_gen.map(|dt| dt.to_rfc3339()),
|
||||||
"nexrender_api": state.config.nexrender_api_url,
|
"nexrender_api": state.config.nexrender_api_url,
|
||||||
});
|
});
|
||||||
@@ -215,8 +256,8 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
|
|||||||
|
|
||||||
let mut stopped = 0;
|
let mut stopped = 0;
|
||||||
for job in jobs {
|
for job in jobs {
|
||||||
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
|
let state_str = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
|
||||||
if state == "queued" || state == "started" || state == "processing" {
|
if state_str == "queued" || state_str == "started" || state_str == "processing" {
|
||||||
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
|
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
|
||||||
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
||||||
stopped += 1;
|
stopped += 1;
|
||||||
@@ -229,10 +270,220 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
|
|||||||
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
|
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn serve_video(
|
||||||
|
Path(uid): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let record = storage::get_job(&state.db, &uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or_else(|| AppError(StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||||
|
|
||||||
|
let path = std::path::Path::new(&record.output_path);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(AppError(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
"File not found".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||||
|
let file = tokio::fs::File::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(StatusCode::NOT_FOUND, "Cannot open file".to_string()))?;
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = axum::body::Body::from_stream(stream);
|
||||||
|
Ok(Response::builder()
|
||||||
|
.header(CONTENT_TYPE, mime.as_ref())
|
||||||
|
.body(body)
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_job(
|
||||||
|
Path(uid): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let mut record = storage::get_job(&state.db, &uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||||
|
.ok_or_else(|| AppError(StatusCode::NOT_FOUND, "Job not found".to_string()))?;
|
||||||
|
|
||||||
|
if record.approved {
|
||||||
|
return Ok(Json(json!({"status": "already_approved"})));
|
||||||
|
}
|
||||||
|
|
||||||
|
let smb_url = state.config.smb_upload_url.as_ref().ok_or_else(|| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"SMB_UPLOAD_URL not configured".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let smb_user = state.config.smb_username.as_ref().ok_or_else(|| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"SMB_USERNAME not configured".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let smb_pass = state.config.smb_password.as_ref().ok_or_else(|| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"SMB_PASSWORD not configured".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// *** ИСПРАВЛЕНИЕ ЗДЕСЬ ***
|
||||||
|
let src_path = std::path::Path::new(&state.config.smb_source_folder).join(&record.filename);
|
||||||
|
if !src_path.exists() {
|
||||||
|
return Err(AppError(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
format!("Source file not found: {}", src_path.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправление для SMB URL обработки
|
||||||
|
let url = Url::parse(smb_url)
|
||||||
|
.map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("Invalid SMB URL: {}", e)))?;
|
||||||
|
|
||||||
|
let server = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| AppError(StatusCode::BAD_REQUEST, "No host in SMB URL".to_string()))?;
|
||||||
|
|
||||||
|
// Исправленный способ извлечения share и path
|
||||||
|
let path_parts: Vec<&str> = url.path().trim_matches('/').split('/').collect();
|
||||||
|
if path_parts.is_empty() {
|
||||||
|
return Err(AppError(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"No share in SMB URL".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Первый элемент пути - это имя шары
|
||||||
|
let share = path_parts[0];
|
||||||
|
|
||||||
|
// Остальная часть пути будет использоваться как путь к файлу (если есть)
|
||||||
|
let remote_path = if path_parts.len() > 1 {
|
||||||
|
path_parts.iter().skip(1).map(|&s| s.to_string()).collect::<Vec<_>>().join("/")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Исправленное подключение к SMB
|
||||||
|
let client = Client::new(ClientConfig::default());
|
||||||
|
|
||||||
|
// Подключаемся к шаре
|
||||||
|
let target_path = UncPath::from_str(&format!("\\\\{}\\{}", server, share))
|
||||||
|
.map_err(|e| AppError(StatusCode::BAD_REQUEST, format!("Invalid UNC path: {}", e)))?;
|
||||||
|
|
||||||
|
client
|
||||||
|
.share_connect(&target_path, smb_user, smb_pass.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("SMB connect failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Читаем содержимое файла
|
||||||
|
let data = tokio::fs::read(&src_path).await.map_err(|e| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Read source failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Определяем путь для сохранения на SMB
|
||||||
|
let remote_filename = if !remote_path.is_empty() {
|
||||||
|
format!("{}/{}", remote_path, record.filename)
|
||||||
|
} else {
|
||||||
|
record.filename.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Создаем полный UNC путь к файлу в шаре
|
||||||
|
let file_to_open = target_path.with_path(&remote_filename);
|
||||||
|
let file_open_args =
|
||||||
|
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
|
||||||
|
let resource = client
|
||||||
|
.create_file(&file_to_open, &file_open_args)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Create file failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let remote_file = resource.unwrap_file();
|
||||||
|
|
||||||
|
// Записываем данные на SMB
|
||||||
|
remote_file.write_at(&data, 0).await.map_err(|e| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Write to SMB failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
remote_file.close().await.map_err(|e| {
|
||||||
|
AppError(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Close file failed: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Обновляем статус в БД
|
||||||
|
storage::approve_job(&state.db, &uid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
record.approved = true;
|
||||||
|
|
||||||
|
// Отправляем обновление через WebSocket
|
||||||
|
let _ = state.ws_tx.send(WsEvent::JobUpdated(record.clone()));
|
||||||
|
|
||||||
|
Ok(Json(json!({"status": "approved"})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
|
||||||
|
ws.on_upgrade(|socket| handle_socket(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket(mut socket: axum::extract::ws::WebSocket, state: AppState) {
|
||||||
|
let mut rx = state.ws_tx.subscribe();
|
||||||
|
while let Ok(event) = rx.recv().await {
|
||||||
|
if let Ok(text) = serde_json::to_string(&event) {
|
||||||
|
if socket
|
||||||
|
.send(axum::extract::ws::Message::Text(text.into()))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct JobInfo {
|
||||||
|
uid: String,
|
||||||
|
state: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobInfo {
|
||||||
|
fn from_nexrender_json(job: &serde_json::Value) -> Option<Self> {
|
||||||
|
let uid = job.get("uid")?.as_str()?.to_string();
|
||||||
|
let state = job.get("state")?.as_str()?.to_string();
|
||||||
|
|
||||||
|
Some(JobInfo {
|
||||||
|
uid,
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AppError(StatusCode, String);
|
struct AppError(StatusCode, String);
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
fn into_response(self) -> axum::response::Response {
|
fn into_response(self) -> Response {
|
||||||
(self.0, self.1).into_response()
|
(self.0, self.1).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user