Compare commits
5 Commits
bb371e1b71
...
v0.2.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2fe50eab | |||
| fbcb619544 | |||
| 0b2091b1e6 | |||
| 48f32e63c2 | |||
| fe13a5e3a7 |
@@ -4,16 +4,19 @@ NAS_USER="your_username"
|
|||||||
NAS_PASS="your_password"
|
NAS_PASS="your_password"
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
#Loging
|
# Logging (optional, defaults to info)
|
||||||
RUST_LOG="info"
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server (optional, defaults to 3000)
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
# Nexrender
|
# Nexrender
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
|
|
||||||
# After Effects Templates
|
# After Effects Templates
|
||||||
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aep"
|
||||||
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
||||||
TEMPLATE_COMPOSITION="main"
|
TEMPLATE_COMPOSITION="main"
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-file = "LICENSE"
|
license-file = "LICENSE"
|
||||||
|
|||||||
416
README.md
416
README.md
@@ -6,38 +6,56 @@
|
|||||||
</p>
|
</p>
|
||||||
<!-- markdownlint-enable MD033 -->
|
<!-- markdownlint-enable MD033 -->
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT) [](https://www.rust-lang.org/) [](https://git.tvstart.ru/lexx/AE_Anons)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
|
## Новое в версии 0.2.2
|
||||||
|
|
||||||
|
- **Веб-интерфейс**: Удобное управление заданиями через браузер
|
||||||
|
- **Мониторинг в реальном времени**: Отслеживание статуса всех заданий
|
||||||
|
- **Управление очередью**: Остановка, очистка и генерация заданий из веб-интерфейса
|
||||||
|
- **Тёмная/светлая тема**: Автоматическое определение системной темы
|
||||||
|
- **Сортировка и фильтрация**: Удобная работа с большим количеством заданий
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
AE Anons — это CLI-утилита на Rust, которая выступает как **интеллектуальный генератор заданий**
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
||||||
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
||||||
After Effects (лицензия MIT).
|
After Effects (лицензия MIT).
|
||||||
|
|
||||||
1. Подключения к NAS Synology для получения данных расписания из файлов офисных таблиц (.osheet)
|
### Режимы работы
|
||||||
2. Парсинга Excel данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах
|
|
||||||
3. Генерации заданий Nexrender с соответствующими шаблонами и ресурсами
|
1. **Однократная обработка** (`--once` или без флагов):
|
||||||
4. Мониторинга завершения рендеринга и управления выходными файлами
|
- Подключение к NAS Synology
|
||||||
|
- Парсинг Excel данных
|
||||||
|
- Генерация и отправка заданий Nexrender
|
||||||
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
|
2. **Веб-сервер** (`--web`):
|
||||||
|
- Запуск веб-интерфейса на порту `:3000`
|
||||||
|
- Управление заданиями через браузер
|
||||||
|
- Автообновление статуса каждую минуту
|
||||||
|
|
||||||
## Особенности
|
## Особенности
|
||||||
|
|
||||||
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе хэштегов `#` в имени команды
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
||||||
- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий
|
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
||||||
- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации
|
- **REST API**: Программный доступ к управлению заданиями
|
||||||
- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env`
|
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
||||||
|
|
||||||
## Предварительные требования
|
## Предварительные требования
|
||||||
|
|
||||||
- Rust 1.70 или выше
|
- Rust 1.70 или выше
|
||||||
- Доступ к NAS Synology с установленными и включенными пакетами File Station и Office
|
- Доступ к NAS Synology с пакетами File Station и Office
|
||||||
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
||||||
- Шаблоны After Effects, настроенные на _worker_ (формат Adobe After Effects 2024 .aepx)
|
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -54,8 +72,6 @@ cd ae_anons
|
|||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
Бинарный файл будет доступен по пути `target/release/ae_anons`
|
|
||||||
|
|
||||||
### 3. Настройка окружения
|
### 3. Настройка окружения
|
||||||
|
|
||||||
Скопируйте пример конфигурации и заполните своими данными:
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
@@ -66,35 +82,47 @@ cp .env.example .env
|
|||||||
|
|
||||||
Затем отредактируйте файл .env:
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
```env
|
|
||||||
# Synology NAS
|
|
||||||
NAS_FQDN=https://your-nas.domain.com
|
|
||||||
NAS_USER=your_username
|
|
||||||
NAS_PASS=your_password
|
|
||||||
NAS_FILE=/team-folders/path/to/your/file.osheet
|
|
||||||
|
|
||||||
# Nexrender
|
|
||||||
NEXRENDER_API_URL=http://your-nexrender-server:3000/api/v1/jobs
|
|
||||||
OUTPUT_FOLDER=//your-storage/path/to/output
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
RUST_LOG=info
|
|
||||||
```
|
|
||||||
|
|
||||||
См. раздел Конфигурация для подробной информации.
|
См. раздел Конфигурация для подробной информации.
|
||||||
|
|
||||||
## Использование
|
## Использование
|
||||||
|
|
||||||
### Базовое выполнение
|
### Однократная обработка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Запуск с настройками по умолчанию
|
# Запуск с настройками по умолчанию
|
||||||
./target/release/ae_anons
|
./target/release/ae_anons
|
||||||
|
|
||||||
# Запуск с пользовательским уровнем логирования
|
# Явно указать однократный режим
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
# С отладочным логированием
|
||||||
RUST_LOG=debug ./target/release/ae_anons
|
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`:
|
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
||||||
@@ -121,12 +149,13 @@ RUST_LOG=debug cargo run
|
|||||||
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
||||||
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
||||||
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
||||||
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
|
||||||
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
||||||
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
||||||
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
||||||
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
|
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
||||||
|
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
||||||
|
|
||||||
### Пример файла `.env`
|
### Пример файла `.env`
|
||||||
|
|
||||||
@@ -137,9 +166,12 @@ NAS_USER="your_username"
|
|||||||
NAS_PASS="your_password"
|
NAS_PASS="your_password"
|
||||||
NAS_FILE="/Team Folder/path/to/file.osheet"
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
#Loging
|
# Logging
|
||||||
RUST_LOG="info"
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
# Nexrender
|
# Nexrender
|
||||||
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
OUTPUT_FOLDER="/path/to/output"
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
@@ -183,7 +215,7 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
|
|
||||||
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
||||||
|
|
||||||
| CHANEL | LINK |
|
| CHANELL | LINK |
|
||||||
|---------|-----------------------------------------|
|
|---------|-----------------------------------------|
|
||||||
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
||||||
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
||||||
@@ -192,14 +224,17 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
|
|
||||||
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
||||||
|
|
||||||
Обязательные колонки: DATA, TIME, CHANEL, SPORT, LEAGUE, TEAM A, TEAM B
|
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
||||||
|
|
||||||
**Примечание**: Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
**Примечания:**
|
||||||
|
|
||||||
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANEL | 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 |
|
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
||||||
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол | Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki |
|
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
||||||
|
|
||||||
### Формат имен команд
|
### Формат имен команд
|
||||||
|
|
||||||
@@ -233,7 +268,8 @@ TEMPLATE_OUTPUT_EXT="mp4"
|
|||||||
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
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.mp4`
|
||||||
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
||||||
@@ -253,35 +289,174 @@ YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
|||||||
|
|
||||||
## Требования к шаблонам After Effects
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоев:
|
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
||||||
|
|
||||||
### Типы шаблонов
|
### Файлы шаблонов
|
||||||
|
|
||||||
| Файл шаблона | Назначение |
|
Настраиваются через переменные окружения:
|
||||||
|------------------------|---------------------------------|
|
|
||||||
| `PackShot_DOUBLE.aepx` | Сопоставление с двумя командами |
|
|
||||||
| `PackShot_SINGLE.aepx` | Анонсы для одной команды |
|
|
||||||
|
|
||||||
### Обязательные слои
|
| Переменная | Назначение | Пример значения |
|
||||||
|
|-------------------------|-------------------------------------|------------------------------------|
|
||||||
|
| `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` |
|
||||||
|
|
||||||
| Имя слоя | Тип | Описание |
|
### Обязательные слои в шаблонах
|
||||||
|------------------------|-------|-------------------------------------------------|
|
|
||||||
| DATA | Text | Отображение даты (автоматически подстраивается) |
|
#### Текстовые слои
|
||||||
| TIME / TIME_H / TIME_M | Text | Отображение времени |
|
|
||||||
| LEAGUE | Text | Название лиги |
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
| SPORT | Text | Категория спорта |
|
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
||||||
| TEAMS | Text | Скомбинированные имена команд |
|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
||||||
| TEAM_A_LOGO | Image | Логотип домашней команды |
|
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
||||||
| TEAM_B_LOGO | Image | Логотип гостевой команды |
|
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
||||||
| CHANEL | Image | Логотип канала |
|
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
||||||
| TOP | Video | Наложение видео оформления |
|
| `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` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
||||||
|
|
||||||
### Настройки композиции
|
### Настройки композиции
|
||||||
|
|
||||||
- Имя композиции: `pack`
|
| Параметр | Переменная окружения | Пример значения | Описание |
|
||||||
- Выходной модуль: `Start_h264`
|
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
||||||
- Формат вывода: `mp4`
|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
||||||
- Формат проекта: Adobe After Effects 2024 .aepx
|
| Выходной модуль | `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)
|
||||||
|
|
||||||
## Устранение неисправностей
|
## Устранение неисправностей
|
||||||
|
|
||||||
@@ -325,7 +500,6 @@ RUST_LOG=debug ./target/release/ae_anons
|
|||||||
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
||||||
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
||||||
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
||||||
- **Использование памяти**: Парсинг Excel сохраняет всю книгу в памяти
|
|
||||||
|
|
||||||
## Примечания по безопасности
|
## Примечания по безопасности
|
||||||
|
|
||||||
@@ -340,45 +514,60 @@ RUST_LOG=debug ./target/release/ae_anons
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
ae_anons/
|
ae_anons/
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
├── Cargo.toml
|
├── Cargo.toml
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
├── README.md
|
├── README.md
|
||||||
├── .env.example
|
├── assets/
|
||||||
├── src/
|
│ └── logo.png
|
||||||
│ ├── main.rs
|
└── src/
|
||||||
│ ├── config.rs
|
├── main.rs # Точка входа, CLI
|
||||||
│ ├── nexrender.rs
|
├── config.rs # Конфигурация из .env
|
||||||
│ ├── synology.rs
|
├── nexrender.rs # Модели заданий Nexrender
|
||||||
│ ├── processor.rs
|
├── synology.rs # Клиент Synology API
|
||||||
│ └── web.rs
|
├── processor.rs # Логика обработки
|
||||||
├── static/
|
├── web.rs # Веб-сервер и API
|
||||||
│ ├── index.html
|
└── static/
|
||||||
│ └── style.css
|
├── index.html # Веб-интерфейс
|
||||||
└── assets/
|
└── style.css # Стили
|
||||||
└── logo.png
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
|
### Основные зависимости
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
| Crate | Версия | Назначение |
|
||||||
|--------------------|--------|-----------------------------------------|
|
|--------------------|---------|-----------------------------------------|
|
||||||
| reqwest | 0.12 | HTTP клиент для коммуникации API |
|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
||||||
| serde / serde_json | 1.0 | Сериализация JSON |
|
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
||||||
| calamine | 0.26 | Парсинг файлов Excel |
|
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
||||||
| chrono | 0.4 | Обработка дат и времени |
|
| chrono | 0.4 | Обработка дат и времени |
|
||||||
| tokio | 1.0 | Асинхронная среда выполнения |
|
| tokio | 1.0 | Асинхронная среда выполнения |
|
||||||
| dotenv | 0.15 | Конфигурация через переменные окружения |
|
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
||||||
| log / env_logger | 0.11 | Инфраструктура логирования |
|
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
||||||
| thiserror | 2.0 | Определение типов ошибок |
|
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
||||||
| anyhow | 1.0 | Обработка ошибок |
|
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
||||||
|
| regex | 1.11 | Регулярные выражения |
|
||||||
|
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
||||||
|
| bytes | 1.9 | Работа с байтовыми данными |
|
||||||
|
| futures | 0.3 | Асинхронные примитивы |
|
||||||
|
|
||||||
### Планируемые новые возможности
|
### Веб-сервер и CLI
|
||||||
|
|
||||||
1. Расширить `JobData` в `nexrender.rs` для новых полей данных
|
| Crate | Версия | Назначение |
|
||||||
2. Обновить логику парсинга листов при необходимости добавления новых колонок
|
|--------------------|--------|-----------------------------------------|
|
||||||
3. Добавить соответствующие слои After Effects в шаблоны
|
| axum | 0.8 | Веб-фреймворк для REST API |
|
||||||
4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов
|
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
||||||
|
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
||||||
|
| askama | 0.15 | Шаблонизация (опционально) |
|
||||||
|
| clap | 4.5 | Парсинг аргументов командной строки |
|
||||||
|
|
||||||
|
### Платформозависимые зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Платформа | Назначение |
|
||||||
|
|--------------------|--------|-----------|----------------------------------|
|
||||||
|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
||||||
|
|
||||||
## 🙏 Благодарности
|
## 🙏 Благодарности
|
||||||
|
|
||||||
@@ -388,7 +577,7 @@ ae_anons/
|
|||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
**AE Anons** — [MIT License](LICENSE)
|
**AE Anons** — [License](LICENSE)
|
||||||
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
@@ -413,20 +602,43 @@ ae_anons/
|
|||||||
|
|
||||||
## История изменений
|
## История изменений
|
||||||
|
|
||||||
### v0.1.0
|
### v0.2.2 (текущая)
|
||||||
|
|
||||||
- Первый выпуск
|
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
||||||
- Интеграция с Synology Office
|
|
||||||
- Базовая генерация заданий Nexrender
|
- Улучшена цветовая схема
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
|
||||||
- Создание множественных вариантов
|
- Добавлена сортировка по всем колонкам
|
||||||
- Автоматические настройки размера шрифта и позиции
|
|
||||||
- Умное масштабирование логотипов по целевому размеру
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
|
||||||
|
- Реализован REST API
|
||||||
|
|
||||||
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
|
||||||
|
- Автообновление статуса заданий
|
||||||
|
|
||||||
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
### v0.1.1
|
### v0.1.1
|
||||||
|
|
||||||
- Оптимизированна работат с памятью
|
- Оптимизирована работа с памятью
|
||||||
- Убрана функция создания `.json`
|
|
||||||
|
- Убрана функция создания .json
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- Первый выпуск
|
||||||
|
|
||||||
|
- Интеграция с Synology Office
|
||||||
|
|
||||||
|
- Базовая генерация заданий Nexrender
|
||||||
|
|
||||||
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use dotenv::dotenv;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -18,12 +17,12 @@ pub struct Config {
|
|||||||
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
|
||||||
|
pub web_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn from_env() -> Result<Self> {
|
pub fn from_env() -> Result<Self> {
|
||||||
dotenv().ok();
|
|
||||||
|
|
||||||
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")?,
|
||||||
@@ -42,6 +41,10 @@ impl Config {
|
|||||||
.context("TEMPLATE_OUTPUT_MODULE not set")?,
|
.context("TEMPLATE_OUTPUT_MODULE not set")?,
|
||||||
template_output_ext: env::var("TEMPLATE_OUTPUT_EXT")
|
template_output_ext: env::var("TEMPLATE_OUTPUT_EXT")
|
||||||
.context("TEMPLATE_OUTPUT_EXT not set")?,
|
.context("TEMPLATE_OUTPUT_EXT not set")?,
|
||||||
|
web_port: env::var("WEB_PORT")
|
||||||
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse()
|
||||||
|
.context("Invalid WEB_PORT")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ mod web;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
|
use dotenv::dotenv;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
@@ -23,6 +24,10 @@ struct Cli {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
// Загружаем .env до инициализации логгера
|
||||||
|
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();
|
||||||
@@ -35,7 +40,6 @@ async fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
web::run_web_server(config).await?;
|
web::run_web_server(config).await?;
|
||||||
} else {
|
} else {
|
||||||
// По умолчанию или с флагом --once выполняем однократную обработку
|
|
||||||
info!(
|
info!(
|
||||||
"Starting AE Anons processor v{} (one-time mode)",
|
"Starting AE Anons processor v{} (one-time mode)",
|
||||||
env!("CARGO_PKG_VERSION")
|
env!("CARGO_PKG_VERSION")
|
||||||
|
|||||||
@@ -537,15 +537,23 @@ impl LogoRegistry {
|
|||||||
self.logos.insert((team, sport), link);
|
self.logos.insert((team, sport), link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// В impl LogoRegistry, функция find()
|
||||||
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
||||||
|
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
|
||||||
|
|
||||||
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
|
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
|
||||||
|
log::debug!("Found exact match: {}", link);
|
||||||
return Some(link.clone());
|
return Some(link.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
for ((t, s), link) in &self.logos {
|
for ((t, s), link) in &self.logos {
|
||||||
if s == sport && team.starts_with(t) {
|
if s == sport && team.starts_with(t) {
|
||||||
|
log::debug!("Found prefix match: t='{}', link='{}'", t, link);
|
||||||
return Some(link.clone());
|
return Some(link.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log::warn!("No logo found for team='{}', sport='{}'", team, sport);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -603,12 +611,26 @@ fn unc_to_uri(path: &str) -> String {
|
|||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Уже правильный URI
|
||||||
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
|
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
|
||||||
return path.to_string();
|
return path.to_string();
|
||||||
}
|
}
|
||||||
format!("file://{}", path.replace('\\', "/").trim_start_matches('/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Mac SMB путь: smb://server/share/path -> file://server/share/path
|
||||||
|
if path.starts_with("smb://") {
|
||||||
|
let without_smb = path.strip_prefix("smb:").unwrap_or(path);
|
||||||
|
return format!("file:{}", without_smb.replace('\\', "/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows UNC путь: \\server\share\path -> file://server/share/path
|
||||||
|
let normalized = path.replace('\\', "/");
|
||||||
|
|
||||||
|
// Убираем ведущие слеши (для UNC их быть не должно)
|
||||||
|
let trimmed = normalized.trim_start_matches('/');
|
||||||
|
|
||||||
|
format!("file://{}", trimmed)
|
||||||
|
}
|
||||||
fn logo_scale_expression(target_size: &str) -> String {
|
fn logo_scale_expression(target_size: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
||||||
|
|||||||
106
src/processor.rs
106
src/processor.rs
@@ -4,10 +4,10 @@ use crate::synology::SynologyClient;
|
|||||||
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};
|
||||||
use log::{debug, error, info};
|
use log::{debug, error, info, log_enabled, Level};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -211,10 +211,8 @@ fn cell_to_string_optimized(cell: &Data) -> String {
|
|||||||
if is_excel_date(*f) {
|
if is_excel_date(*f) {
|
||||||
excel_date_to_string(*f)
|
excel_date_to_string(*f)
|
||||||
} else if f.fract() == 0.0 {
|
} else if f.fract() == 0.0 {
|
||||||
// Используем itoa для целых чисел (опционально)
|
|
||||||
format!("{:.0}", f)
|
format!("{:.0}", f)
|
||||||
} else {
|
} else {
|
||||||
// Используем ryu для float (опционально)
|
|
||||||
f.to_string()
|
f.to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,7 +254,6 @@ fn excel_date_to_string(serial: f64) -> String {
|
|||||||
let days = serial as i64;
|
let days = serial as i64;
|
||||||
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
|
||||||
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
|
||||||
// Используем метод format напрямую - он публичный
|
|
||||||
date.format("%d.%m.%Y").to_string()
|
date.format("%d.%m.%Y").to_string()
|
||||||
} else {
|
} else {
|
||||||
serial.to_string()
|
serial.to_string()
|
||||||
@@ -296,6 +293,7 @@ async fn generate_and_submit_jobs(
|
|||||||
.get_sheet("CHANELL")
|
.get_sheet("CHANELL")
|
||||||
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
||||||
|
|
||||||
|
// Загружаем sport packs
|
||||||
let packs: HashMap<String, String> = sport_sheet
|
let packs: HashMap<String, String> = sport_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -303,16 +301,63 @@ async fn generate_and_submit_jobs(
|
|||||||
.collect();
|
.collect();
|
||||||
info!("Loaded {} sport packs", packs.len());
|
info!("Loaded {} sport packs", packs.len());
|
||||||
|
|
||||||
|
// Вспомогательная функция для поиска значения по части ключа
|
||||||
|
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
|
||||||
|
// Точное совпадение
|
||||||
|
if let Some(val) = row.get(key_part) {
|
||||||
|
if !val.is_empty() {
|
||||||
|
return Some(val.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Поиск по содержанию (без учёта регистра)
|
||||||
|
for (k, v) in row.iter() {
|
||||||
|
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
|
||||||
|
return Some(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем team logos
|
||||||
|
debug!("TEAMS headers: {:?}", teams_sheet.headers);
|
||||||
|
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 {
|
||||||
if let (Some(team), Some(sport), Some(link)) =
|
let team = get_cell_fuzzy(row, "TEAM");
|
||||||
(row.get("TEAM"), row.get("SPORT"), row.get("LINK"))
|
let sport = get_cell_fuzzy(row, "SPORT");
|
||||||
{
|
let link = get_cell_fuzzy(row, "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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info!("Loaded {} team logos", teams_sheet.rows.len());
|
info!("Loaded {} team logos", teams_sheet.rows.len());
|
||||||
|
|
||||||
|
// Дебаг: выводим статистику по TEAMS
|
||||||
|
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() {
|
||||||
|
let examples: Vec<_> = teams_sheet
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.get("SPORT") == Some(*sport))
|
||||||
|
.take(5)
|
||||||
|
.filter_map(|r| r.get("TEAM"))
|
||||||
|
.collect();
|
||||||
|
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем channel logos
|
||||||
let channels: HashMap<String, String> = channel_sheet
|
let channels: HashMap<String, String> = channel_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -320,11 +365,37 @@ 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) {
|
||||||
|
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
|
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
|
||||||
|
|
||||||
|
// Собираем статистику по ненайденным логотипам для дебага
|
||||||
|
let mut missing_teams: HashSet<String> = HashSet::new();
|
||||||
|
let mut missing_sports: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
for (idx, row) in start_sheet.rows.iter().enumerate() {
|
for (idx, row) in start_sheet.rows.iter().enumerate() {
|
||||||
if let Some(state) = row.get("STATE") {
|
if let Some(state) = row.get("STATE") {
|
||||||
if state == "FALSE" {
|
if state == "FALSE" {
|
||||||
|
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
||||||
|
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
||||||
|
let sport = row.get("SPORT").cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
@@ -332,6 +403,18 @@ async fn generate_and_submit_jobs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Дебаг: выводим сводку по отсутствующим логотипам
|
||||||
|
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());
|
info!("Generated {} total jobs (including variants)", jobs.len());
|
||||||
|
|
||||||
if jobs.is_empty() {
|
if jobs.is_empty() {
|
||||||
@@ -354,6 +437,13 @@ async fn generate_and_submit_jobs(
|
|||||||
let nexrender_job = job.to_nexrender_job(&config_clone);
|
let nexrender_job = job.to_nexrender_job(&config_clone);
|
||||||
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 = client.post(&api_url).json(&nexrender_job).send().await?;
|
let response = client.post(&api_url).json(&nexrender_job).send().await?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use serde::Serialize;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tower_http::trace::TraceLayer;
|
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -88,7 +88,9 @@ impl JobInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// web.rs - функция run_web_server
|
||||||
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 state = AppState {
|
let state = AppState {
|
||||||
config,
|
config,
|
||||||
last_generation: Arc::new(Mutex::new(None)),
|
last_generation: Arc::new(Mutex::new(None)),
|
||||||
@@ -106,7 +108,7 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
|||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr: SocketAddr = "0.0.0.0:3000".parse()?;
|
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
|
||||||
log::info!("Web server listening on http://{}", addr);
|
log::info!("Web server listening on http://{}", addr);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
@@ -114,7 +116,6 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остальные обработчики остаются без изменений...
|
|
||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user