5 Commits

Author SHA1 Message Date
0f2fe50eab chore: bump version to 0.2.3 2026-04-17 16:29:03 +03:00
fbcb619544 улучшено debug-логирование и обработка путей
- Добавлен детальный вывод при парсинге листа TEAMS
- Добавлен вывод поиска логотипов и сводка по отсутствующим
- Исправлен порядок инициализации RUST_LOG
- Добавлена поддержка smb:// путей в unc_to_uri
2026-04-17 16:00:46 +03:00
0b2091b1e6 Release v0.2.2 - Web Interface 2026-04-17 14:39:27 +03:00
48f32e63c2 feat: release v0.2.2 - web interface with configurable port 2026-04-17 14:38:38 +03:00
fe13a5e3a7 add WEB_PORT 2026-04-17 14:20:43 +03:00
8 changed files with 462 additions and 127 deletions

View File

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

View File

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

@@ -6,38 +6,56 @@
</p> </p>
<!-- markdownlint-enable MD033 --> <!-- markdownlint-enable MD033 -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) [![Status](https://img.shields.io/badge/status-production-green.svg)](https://git.tvstart.ru/lexx/AE_Anons) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/)
[![Status](https://img.shields.io/badge/status-production-green.svg)](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 с динамическим обнаружением листов
--- ---

View File

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

View File

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

View File

@@ -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]",

View File

@@ -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() {

View File

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