5 Commits

32 changed files with 4673 additions and 3757 deletions

View File

@@ -1,32 +1,22 @@
# Synology NAS
NAS_FQDN="https://nas.company.local:5001"
NAS_USER="DOMAIN\\service_account"
NAS_PASS="strongpassword"
NAS_FILE="/Team Folder/Anonsy/sport.osheet"
NAS_FQDN="https://your-nas.example.com"
NAS_USER="your_username"
NAS_PASS="your_password"
NAS_FILE="/Team Folder/path/to/file.osheet"
# Logging
# Logging (optional, defaults to info)
RUST_LOG="info"
# Web Server
# Web Server (optional, defaults to 3000)
WEB_PORT="3000"
# Nexrender
NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
OUTPUT_FOLDER="/mnt/nexrender/output"
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
OUTPUT_FOLDER="/path/to/output"
# After Effects Templates
TEMPLATE_DOUBLE_SRC="file:///mnt/templates/double_team.aepx"
TEMPLATE_SINGLE_SRC="file:///mnt/templates/single_team.aepx"
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"
# 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"

2638
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "ae_anons"
version = "0.3.0"
version = "0.2.6"
edition = "2021"
license = "MIT"
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
@@ -11,41 +11,31 @@ keywords = ["nexrender", "after-effects", "synology", "automation"]
categories = ["command-line-utilities", "multimedia"]
[dependencies]
reqwest = { version = "0.12.5", features = ["json", "multipart", "stream"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.140"
thiserror = "2.0.12"
urlencoding = "2.1.3"
tokio = { version = "1.52.1", features = ["full", "sync"] }
dotenv = "0.15.0"
calamine = "0.34"
chrono = { version = "0.4.41", features = ["serde"] }
regex = "1.11.1"
futures = "0.3.31"
anyhow = "1.0.98"
log = "0.4.27"
env_logger = "0.11.8"
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"
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
urlencoding = "2.1"
tokio = { version = "1.0", features = ["full"] }
dotenv = "0.15"
calamine = "0.26"
chrono = "0.4"
regex = "1.11"
futures = "0.3"
anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
bytes = "1.9"
# Web server (with WebSocket support)
axum = { version = "0.8.9", features = ["ws"] }
tower = "0.5.2"
tower-http = { version = "0.6.4", features = ["fs", "trace"] }
askama = "0.15.4"
clap = { version = "4.5.38", features = ["derive"] }
# Web server (updated to latest stable versions)
axum = "0.8.9"
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "trace"] }
askama = "0.15"
clap = { version = "4.5", features = ["derive"] }
[dev-dependencies]
tokio = { version = "1.52.1", features = ["full", "rt-multi-thread"] }
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
[target.x86_64-unknown-linux-gnu.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
@@ -59,8 +49,5 @@ lto = true
codegen-units = 1
strip = true
[target.'cfg(windows)'.dependencies]
winres = "0.1"
[build-dependencies]
winres = "0.1"

View File

@@ -1,7 +1,5 @@
# 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

675
README.md
View File

@@ -1,4 +1,4 @@
# AE Anons - Автономный генератор спортивных анонсов в After Effects
# AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
<!-- markdownlint-disable MD033 -->
<p align="left">
@@ -10,22 +10,23 @@
[![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)
**AE Anons** — полностью автономная (standalone) система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender. Все данные берутся из электронных таблиц Synology Office, рендеринг выполняется локально, управление заданиями происходит через встроенный веб-интерфейс с мгновенными обновлениями. Система не требует доступа в интернет — все ресурсы (иконки, стили, шрифты) встроены в бинарный файл.
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Новое в версии v0.3.0
## Новое в версии v0.2.6
- **WebSocket** — мгновенные обновления списка заданий (без устаревшего polling каждые 60 секунд)
- **SQLite** — хранение истории заданий и статуса апрува
- **Просмотр видео** прямо в веб-интерфейсе (по ссылке на готовый файл)
- **Апрув заданий** с копированием видео на SMB-шару (без монтирования, с поддержкой Active Directory)
- **Уведомления в реальном времени** о старте/завершении генерации, ошибках
- **Улучшенный веб-интерфейс** с кнопками апрува и иконками превью
- **Оптимизация памяти** — строки таблицы обрабатываются потоково, неиспользуемые логотипы и ассеты удаляются после генерации. Пиковое потребление памяти достигается только в момент загрузки и парсинга Excel, после чего память освобождается. Это позволяет работать с таблицами большого объёма (десятки тысяч строк) и обеспечивает стабильную работу в режиме долгоживущего сервиса.
- Полная автономность — все статические ресурсы (HTML, CSS, Font Awesome, логотип, favicon) встроены в бинарный файл.
- Сортировка заданий по дате и времени события (самые ранние — первыми)
- Сортировка по варианту (оригинал → today → tomorrow)
- Последовательная отправка для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
- Добавлена поддержка кеширования ассетов (cache: true/false)
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
## Обзор
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий** для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга After Effects.
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
After Effects (лицензия MIT).
### Режимы работы
@@ -36,147 +37,154 @@ AE Anons — это CLI-утилита и веб-сервер на Rust, кот
- Мониторинг завершения и выход
2. **Веб-сервер** (`--web`):
- Запуск веб-интерфейса на порту `:3000` (настраивается)
- Управление заданиями через браузер с WebSocket-обновлениями
- Просмотр видео и апрув готовых роликов
- Запуск веб-интерфейса на порту `:3000`
- Управление заданиями через браузер
- Автообновление статуса каждую минуту
## Особенности
- **Полная автономность** — не требует выхода в интернет, все ресурсы встроены в бинарник
- **Интеграция с Synology** — аутентификация и загрузка файлов с NAS Synology (локальные и AD-учётные записи)
- **Экспорт офисных таблиц** — автоматическое преобразование `.osheet` в Excel
- **Гибкий парсинг данных** — динамический парсинг листов с обнаружением заголовков
- **Множественная генерация вариантов** — "Сегодня", "Завтра" и датированные версии
- **Умное управление логотипами** — автоматическое разрешение и масштабирование
- **Веб-интерфейс с WebSocket** — мгновенные обновления, просмотр видео, апрув
- **REST API** — программное управление заданиями
- **SQLite** — хранение истории и статуса апрува
- **Поддержка SMB** — копирование утверждённых видео на сетевую шару (с AD-аутентификацией)
- **Профессиональное логирование** — структурированный журнал с настройкой уровня
- **Эффективная работа с памятью** — потоковая обработка строк, своевременное удаление неиспользуемых данных (логотипы, ассеты) позволяет обрабатывать таблицы с десятками тысяч строк без утечек памяти
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
- **REST API**: Программный доступ к управлению заданиями
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
## Предварительные требования
- Rust 1.70 или выше
- Доступ к NAS Synology с пакетами File Station и Office (локальный или доменный пользователь)
- Экземпляр _server_ и _worker_ (не менее одного) Nexrender
- Шаблоны After Effects (формат .aep или .aepx, совместимые с версией AE на worker)
- (Для апрува) Доступ к SMB-шаре с правами на запись
- Доступ к NAS Synology с пакетами File Station и Office
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
## Установка
git clone https://git.example.com/team/ae_anons.git
cd ae_anons
cargo build --release
cp .env.example .env
# отредактируйте .env согласно вашей инфраструктуре
### 1. Клонирование репозитория
## Конфигурация
```bash
git clone https://git.tvstart.ru/lexx/AE_Anons.git
cd ae_anons
```
Все настройки задаются через переменные окружения в файле `.env`.
### 2. Сборка проекта
### Основные переменные
```bash
cargo build --release
```
| Переменная | Обязательна | Описание |
|-------------------------|-------------|---------------------------------------------------------------------------------|
| `NAS_FQDN` | Да | URL Synology NAS (*) |
| `NAS_USER` | Да | Имя пользователя (локального или доменного, например `DOMAIN\username`) |
| `NAS_PASS` | Да | Пароль |
| `NAS_FILE` | Да | Путь к `.osheet` файлу на NAS (например, `/Team Folder/schedule.osheet`) |
| `NEXRENDER_API_URL` | Да | API Nexrender (например, `http://nexrender:3050/api/v1/jobs`) |
| `OUTPUT_FOLDER` | Да | Папка, куда Nexrender сохраняет готовые видео (локальный путь) |
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд (например, `file:///templates/double.aepx`) |
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE |
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE |
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
| `WEB_PORT` | Нет | Порт веб-сервера (по умолчанию 3000) |
| `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`) |
### 3. Настройка окружения
> (*) — для `NAS_FQDN` можно указывать протокол `http://` или `https://`. Безопаснее `https`, хотя для локальной сети допустим и `http`. Если NAS доступен по стандартному HTTPS-порту 443, протокол можно опустить (например, `nas.company.local`). Иначе указывайте полный URL с портом: `https://nas.company.local:5001` или `http://nas.local:5000`.
>
> (**) — обязательно, если используется функция апрува (веб-режим). В режиме `--once` апрув не требуется.
Скопируйте пример конфигурации и заполните своими данными:
### Пример файла `.env`
```bash
cp .env.example .env
```
# Synology NAS
NAS_FQDN="https://nas.company.local:5001"
NAS_USER="DOMAIN\\service_account"
NAS_PASS="strongpassword"
NAS_FILE="/Team Folder/Anonsy/sport.osheet"
Затем отредактируйте файл .env:
# Nexrender
NEXRENDER_API_URL="http://render-01:3050/api/v1/jobs"
OUTPUT_FOLDER="/mnt/nexrender/output"
# After Effects Templates
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"
# Web server
WEB_PORT="3000"
RUST_LOG="info"
# SMB для апрува (AD-совместимо)
SMB_UPLOAD_URL="smb://storage.company.local/approved_videos"
SMB_USERNAME="DOMAIN\\service_account"
SMB_PASSWORD="strongpassword"
# 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
```bash
# Запуск с настройками по умолчанию
./target/release/ae_anons
### Веб-сервер (интерактивный режим)
# Явно указать однократный режим
./target/release/ae_anons --once
./target/release/ae_anons --web
# С отладочным логированием
RUST_LOG=debug ./target/release/ae_anons
```
После запуска откройте браузер: `http://localhost:3000`
### Веб-сервер
```bash
# Запуск веб-интерфейса на порту по умолчанию (3000)
./target/release/ae_anons --web
# Или с указанием другого порта через .env файл
# WEB_PORT=8080 ./target/release/ae_anons --web
```
## Веб-интерфейс
- **Главная страница** — таблица всех заданий с полями: UID, имя файла, статус, превью (иконка видео), кнопка апрува, дата создания.
- **Обновления в реальном времени** через WebSocket — статусы заданий меняются мгновенно без перезагрузки страницы.
- **Кнопка «Generate»** — запускает парсинг таблицы Synology и создание новых заданий. Во время генерации кнопка блокируется, при завершении приходит уведомление.
- **Кнопка «Cleanup»** — удаляет из Nexrender задания со статусом finished/error.
- **Кнопка «Stop all»** — останавливает все активные (queued/started/processing) задания.
- **Просмотр видео** — клик по иконке видео открывает готовый файл в новой вкладке (поддерживается любой браузерный просмотр MP4).
- **Апрув** — клик по ✅ копирует видео на SMB-шару (путь из `SMB_UPLOAD_URL`) и помечает задание как approved. Повторный апрув невозможен.
После запуска веб-сервера откройте браузер:
### API эндпоинты
- Главная страница: <http://localhost:3000>
- `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`)
- API эндпоинты:
- `GET /api/jobs` - список всех заданий
- `POST /api/generate` - запуск генерации
- `POST /api/cleanup` - очистка завершённых
- `POST /api/jobs/stop-all` - остановка активных
- `GET /api/status` - статус сервера
## Уровни логирования
`RUST_LOG=debug ./ae_anons --web`
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
- **error** — только критические ошибки
- **warn** — предупреждения
- **info** — стандартная информация (по умолчанию)
- **debug** — детали API-вызовов, парсинга, SMB-операций
- **trace** — максимальная детализация
- **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"
```
## Структура электронной таблицы
@@ -235,7 +243,12 @@ AE Anons — это CLI-утилита и веб-сервер на Rust, кот
Имена команд поддерживают два типа разделителей хэш #:
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
2. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
Примеры:
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
Формат: `DisplayName#UniqueID#TargetSize`
@@ -245,70 +258,422 @@ AE Anons — это CLI-утилита и веб-сервер на Rust, кот
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
- `Спортинг#ll#550` - оба идентификатор и целевой размер
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
## Выходные файлы
Рендеренные видео сохраняются в `OUTPUT_FOLDER` по шаблону:
`YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4`
### Рендеренные видео
При апруве файл копируется в SMB-шару с тем же именем.
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
## Рабочий процесс (веб-режим)
```shell
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
```
1. Пользователь заполняет таблицу Synology Office.
2. Нажимает «Generate» в веб-интерфейсе.
3. Сервер аутентифицируется на NAS, скачивает и парсит Excel.
4. Генерирует задания Nexrender, сохраняет их в SQLite и отправляет в Nexrender.
5. WebSocket уведомляет клиент о новых заданиях.
6. Nexrender рендерит видео, статус задания обновляется (периодический опрос или webhook).
7. Когда видео готово, пользователь видит иконку превью, может посмотреть видео и нажать апрув.
8. При апруве видео копируется на SMB-шару, задание помечается как approved.
Имена файлов транслитерируются в латиницу.
**Примеры:**
- `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
Не изменились — все слои `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 не работает
### Частые проблемы
- Проверьте, что браузер поддерживает WebSocket (все современные поддерживают).
- При использовании прокси (nginx) необходимо настроить Upgrade заголовки.
#### Ошибка подключения к NAS
### Ошибка аутентификации на NAS для доменного пользователя
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
- Проверьте сетевую связность с NAS
- Убедитесь, что сервисы File Station и Office включены
- Убедитесь, что в `.env` указано `DOMAIN\\username` (двойной обратный слеш).
- Проверьте, что NAS настроен на приём доменных учётных записей (DSM → Домен/LDAP).
#### Файл не найден
### Ошибка SMB подключения
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
- Путь должен начинаться с `/Team Folder/` для рабочих папок
- Проверьте права доступа к файлу для учетной записи сервиса
- Проверьте доступность шары: `smbclient -U DOMAIN/username -L //storage/`
- Убедитесь, что в URL используется `smb://` протокол, путь без лишних слешей.
- Время на сервере и клиенте должно быть синхронизировано (SMB требует этого).
#### Ошибка отправки задания Nexrender
### БД SQLite блокирована
- Подтвердите доступность сервера Nexrender
- Убедитесь, что `NEXRENDER_API_URL` правильный
- Проверьте существование файлов шаблонов на узлах рендеринга
- При конкурентных запросах SQLite может выдавать `database is locked`. Для веб-сервера с одним процессом это маловероятно. Если возникает — используйте `sqlite://:memory:` для тестов или настройте WAL-режим.
### Режим отладки
### Высокое потребление памяти
Включите подробное логирование для детального анализа:
- Система спроектирована так, чтобы минимизировать удержание данных. Если вы наблюдаете рост памяти, убедитесь, что вы используете последнюю версию. При работе с очень большими таблицами (сотни тысяч строк) может потребоваться увеличить лимит строк в `processor.rs` (константа 10000).
```bash
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/`, но при сборке они компилируются внутрь исполняемого файла.
### Структура кода
### Сборка для production
```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 # Стили
```
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**MIT
**Nexrender** — MIT
**Font Awesome Free** — CC BY 4.0 (иконки) и SIL OFL 1.1 (шрифты)
**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 с динамическим обнаружением листов
---

BIN
ae_anons-v0.2.6-all.tar.gz Normal file

Binary file not shown.

BIN
assets/logo.icns Normal file

Binary file not shown.

View File

@@ -1,9 +1,11 @@
// build.rs
fn main() {
#[cfg(windows)]
{
let mut res = winres::WindowsResource::new();
res.set_icon("assets/logo.ico");
res.compile().unwrap();
if let Err(e) = winres::WindowsResource::new()
.set_icon("assets/logo.ico")
.compile()
{
eprintln!("warning: не удалось встроить иконку: {e}");
}
}
}

View File

@@ -1,4 +1,4 @@
.PHONY: all clean build-mac build-windows build-linux build-linux-musl package help quick package-single icons favicon
.PHONY: all clean build-mac build-windows build-linux build-linux-musl package help quick package-single icons
# Название проекта
PROJECT_NAME := ae_anons
@@ -44,9 +44,9 @@ help:
@echo " make package - Создать пакеты для всех платформ"
@echo " make package-single - Создать пакет для текущей платформы"
@echo ""
@echo "$(GREEN)Иконки:$(NC)"
@echo " make icons - Создать все иконки (logo.ico, logo.icns, favicon.ico)"
@echo " make favicon - Создать только favicon.ico"
@echo "$(GREEN)Подготовка ресурсов:$(NC)"
@echo " make setup - Подготовить иконки (логотипы, favicon)"
@echo " make setup-fontawesome-manual - Скачать и исправить all.min.css (если удалён)"
@echo ""
@echo "$(GREEN)Очистка:$(NC)"
@echo " make clean - Очистить все сборки"
@@ -67,15 +67,14 @@ define measure_time
$(2); \
end=$$(date +%s); \
duration=$$((end - start)); \
echo "$(1): $$duration сек" >> $(TIMING_FILE); \
if [ $$duration -ge 60 ]; then \
min=$$((duration / 60)); \
sec=$$((duration % 60)); \
time_str="$${min}m $${sec}s"; \
echo "$(GREEN)$(1) завершён за $${min}m $${sec}s$(NC)"; \
else \
time_str="$${duration}s"; \
fi; \
echo "$(1): $$time_str" >> $(TIMING_FILE); \
echo "$(GREEN)$(1) завершён за $$time_str$(NC)"
echo "$(GREEN)$(1) завершён за $${duration}s$(NC)"; \
fi
endef
define print_timing_summary
@@ -100,41 +99,56 @@ define print_timing_summary
endef
# ============================================
# ИКОНКИ
# ПОДГОТОВКА РЕСУРСОВ
# ============================================
icons: favicon
@echo "$(GREEN)Создание иконок для Windows и macOS...$(NC)"
setup: icons
@echo "$(GREEN)Все статические ресурсы подготовлены$(NC)"
# Генерация иконок (favicon, .ico, .icns) из логотипа
icons:
@echo "$(GREEN)🎨 Генерация иконок...$(NC)"
@if [ "$(IMAGEMAGICK)" = "false" ]; then \
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \
exit 1; \
fi
# favicon.ico
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=48,32,16 src/static/favicon.ico
@echo "$(GREEN) ✓ src/static/favicon.ico$(NC)"
# Windows .ico
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=256,128,64,48,32,16 assets/logo.ico
@echo "$(GREEN) ✓ assets/logo.ico$(NC)"
# macOS .icns
@mkdir -p assets/icon.iconset
@sips -z 16 16 assets/logo.png --out assets/icon.iconset/icon_16x16.png 2>/dev/null
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_16x16@2x.png 2>/dev/null
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_32x32.png 2>/dev/null
@sips -z 64 64 assets/logo.png --out assets/icon.iconset/icon_32x32@2x.png 2>/dev/null
@sips -z 128 128 assets/logo.png --out assets/icon.iconset/icon_128x128.png 2>/dev/null
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_128x128@2x.png 2>/dev/null
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_256x256.png 2>/dev/null
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_256x256@2x.png 2>/dev/null
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_512x512.png 2>/dev/null
@iconutil -c icns assets/icon.iconset -o assets/logo.icns 2>/dev/null
@sips -z 16 16 assets/logo.png --out assets/icon.iconset/icon_16x16.png 2>/dev/null || true
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_16x16@2x.png 2>/dev/null || true
@sips -z 32 32 assets/logo.png --out assets/icon.iconset/icon_32x32.png 2>/dev/null || true
@sips -z 64 64 assets/logo.png --out assets/icon.iconset/icon_32x32@2x.png 2>/dev/null || true
@sips -z 128 128 assets/logo.png --out assets/icon.iconset/icon_128x128.png 2>/dev/null || true
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_128x128@2x.png 2>/dev/null || true
@sips -z 256 256 assets/logo.png --out assets/icon.iconset/icon_256x256.png 2>/dev/null || true
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_256x256@2x.png 2>/dev/null || true
@sips -z 512 512 assets/logo.png --out assets/icon.iconset/icon_512x512.png 2>/dev/null || true
@iconutil -c icns assets/icon.iconset -o assets/logo.icns 2>/dev/null || true
@rm -rf assets/icon.iconset
@echo "$(GREEN) ✓ assets/logo.icns$(NC)"
favicon:
@echo "$(GREEN)Создание favicon.ico...$(NC)"
@if [ "$(IMAGEMAGICK)" = "false" ]; then \
echo "$(RED)❌ ImageMagick не установлен. Установите: brew install imagemagick$(NC)"; \
exit 1; \
# Восстановить CSS Font Awesome вручную (если файл удалён)
setup-fontawesome-manual:
@echo "$(GREEN)📥 Скачивание Font Awesome CSS...$(NC)"
@curl -sL -o src/static/fontawesome/all.min.css $(FONTAWESOME_CSS_URL)
@sed -i '' -E 's|(\.\./)*webfonts/|/static/fontawesome/webfonts/|g' \
src/static/fontawesome/all.min.css
@echo "$(GREEN)✓ CSS восстановлен и пути исправлены$(NC)"
# ============================================
# ИКОНКА ДЛЯ WINDOWS .EXE
# ============================================
windows-icon:
@if [ ! -f assets/logo.ico ]; then \
echo "$(YELLOW)logo.ico не найден, запускаю генерацию иконок...$(NC)"; \
$(MAKE) icons; \
fi
@$(IMAGEMAGICK) assets/logo.png -define icon:auto-resize=48,32,16 src/static/favicon.ico
@echo "$(GREEN) ✓ src/static/favicon.ico$(NC)"
# ============================================
# ОПРЕДЕЛЕНИЕ ПЛАТФОРМЫ
@@ -194,7 +208,7 @@ quick-linux:
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/$(NC)"
@$(call print_timing_summary)
quick-windows:
quick-windows: windows-icon
@rm -f $(TIMING_FILE)
@if command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then \
$(call measure_time,"🪟 Windows x64",cargo build --target x86_64-pc-windows-gnu --release); \
@@ -217,7 +231,7 @@ build-mac:
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
@time cargo build --release
build-windows:
build-windows: windows-icon
@echo "$(GREEN)🪟 Сборка для Windows x86_64...$(NC)"
@time cargo build --target x86_64-pc-windows-gnu --release

View File

@@ -0,0 +1,22 @@
# 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"

View File

@@ -0,0 +1,19 @@
# MIT License
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.

View File

@@ -0,0 +1,680 @@
# 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 -->
[![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.
## Новое в версии 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.

View File

@@ -0,0 +1,22 @@
# 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"

View File

@@ -0,0 +1,19 @@
# MIT License
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.

View File

@@ -0,0 +1,680 @@
# 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 -->
[![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.
## Новое в версии 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.

View File

@@ -0,0 +1,22 @@
# 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"

View File

@@ -0,0 +1,19 @@
# MIT License
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.

View File

@@ -0,0 +1,680 @@
# 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 -->
[![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.
## Новое в версии 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.

View File

@@ -0,0 +1,22 @@
# 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"

View File

@@ -0,0 +1,19 @@
# MIT License
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.

View File

@@ -0,0 +1,680 @@
# 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 -->
[![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.
## Новое в версии 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.

View File

@@ -11,30 +11,18 @@ pub struct Config {
// Nexrender
pub nexrender_api_url: String,
pub output_folder: String,
// Templates
// Templates (all required)
pub template_double_src: String,
pub template_single_src: String,
pub template_composition: String,
pub template_output_module: String,
pub template_output_ext: String,
// Web
//web_serwer
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 {
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 {
nas_fqdn: env::var("NAS_FQDN").context("NAS_FQDN not set")?,
nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
@@ -42,7 +30,7 @@ impl Config {
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
nexrender_api_url: env::var("NEXRENDER_API_URL")
.context("NEXRENDER_API_URL not set")?,
output_folder,
output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?,
template_double_src: env::var("TEMPLATE_DOUBLE_SRC")
.context("TEMPLATE_DOUBLE_SRC not set")?,
template_single_src: env::var("TEMPLATE_SINGLE_SRC")
@@ -57,12 +45,6 @@ impl Config {
.unwrap_or_else(|_| "3000".to_string())
.parse()
.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()),
})
}
}

View File

@@ -1,7 +1,6 @@
mod config;
mod nexrender;
mod processor;
mod storage;
mod synology;
mod web;
@@ -14,29 +13,38 @@ use log::info;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Run web server instead of one-time processing
#[arg(short, long)]
web: bool,
/// Run one-time processing (default if no flags)
#[arg(short, long)]
once: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
// Загружаем .env до инициализации логгера
dotenv().ok();
// Инициализируем логгер
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cli = Cli::parse();
let config = Config::from_env()?;
if cli.web {
info!("Starting AE Anons web server v{}", env!("CARGO_PKG_VERSION"));
info!(
"Starting AE Anons web server v{}",
env!("CARGO_PKG_VERSION")
);
web::run_web_server(config).await?;
} else {
info!("Starting AE Anons processor v{} (one-time mode)", env!("CARGO_PKG_VERSION"));
// For one-time mode we don't need SMB, so we create dummy db and channel
let (dummy_ws_tx, _) = tokio::sync::broadcast::channel(1);
let dummy_db = sqlx::SqlitePool::connect("sqlite::memory:").await?;
let submitted = processor::process_spreadsheet(&config, &dummy_db, dummy_ws_tx).await?;
info!(
"Starting AE Anons processor v{} (one-time mode)",
env!("CARGO_PKG_VERSION")
);
let submitted = processor::process_spreadsheet(&config).await?;
info!("Submitted {} jobs. Exiting.", submitted.len());
}

View File

@@ -1,18 +1,15 @@
use crate::config::Config;
use crate::nexrender::{JobData, LogoRegistry};
use crate::synology::SynologyClient;
use crate::storage::{self, JobRecord};
use crate::web::WsEvent;
use anyhow::{anyhow, Result};
use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate, Utc};
use log::{debug, error, info};
use chrono::{Duration, NaiveDate};
use log::{debug, error, info, log_enabled, Level};
use reqwest::Client;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::Path;
use tokio::sync::broadcast;
#[derive(Debug, Clone)]
pub struct SheetData {
@@ -32,7 +29,8 @@ impl ExcelWorkbook {
}
}
pub async fn process_spreadsheet(config: &Config, db: &sqlx::SqlitePool, ws_tx: broadcast::Sender<WsEvent>) -> Result<Vec<(String, String)>> {
/// Основная функция обработки: скачивает Excel, генерирует задания, отправляет в Nexrender
pub async fn process_spreadsheet(config: &Config) -> Result<Vec<(String, String)>> {
let mut client = SynologyClient::new(&config.nas_fqdn);
client.login(&config.nas_user, &config.nas_pass).await?;
info!("Successfully authenticated with Synology NAS");
@@ -40,10 +38,11 @@ pub async fn process_spreadsheet(config: &Config, db: &sqlx::SqlitePool, ws_tx:
let info = client.get_info().await?;
info!("Connected to NAS: {}", info.hostname);
// Скачиваем и парсим Excel в памяти
let workbook = download_and_parse_excel_in_memory(&mut client, config).await?;
display_workbook_structure(&workbook);
let submitted = generate_and_submit_jobs(&workbook, config, db, ws_tx).await?;
let submitted = generate_and_submit_jobs(&workbook, config).await?;
client.logout().await?;
info!("Session terminated successfully");
@@ -106,6 +105,7 @@ async fn download_and_parse_excel_in_memory(
info!("Found file: {}", actual_file_name);
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?;
info!("Exported {} bytes to memory", excel_data.len());
@@ -158,6 +158,7 @@ fn parse_sheet_dynamic_optimized(
});
}
// Извлекаем заголовки с дедупликацией
let headers: Vec<String> = data_matrix[0]
.iter()
.enumerate()
@@ -191,7 +192,7 @@ fn parse_sheet_dynamic_optimized(
None
}
})
.take(10000)
.take(10000) // Ограничение для безопасности
.collect();
Ok(SheetData {
@@ -273,6 +274,7 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
}
}
// Вспомогательная функция для поиска значения по части ключа
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() {
@@ -290,41 +292,73 @@ fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<Strin
async fn generate_and_submit_jobs(
workbook: &ExcelWorkbook,
config: &Config,
db: &sqlx::SqlitePool,
ws_tx: broadcast::Sender<WsEvent>,
) -> Result<Vec<(String, String)>> {
info!("Preparing Nexrender jobs...");
// Load SPORT packs
// ========== ЗАГРУЖАЕМ SPORT PACKS ==========
let sport_sheet = workbook
.get_sheet("SPORT")
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
let packs: HashMap<String, String> = sport_sheet
.rows
.iter()
.filter_map(|row| Some((row.get("SPORT")?.clone(), row.get("LINK")?.clone())))
.collect();
info!("Loaded {} sport packs", packs.len());
debug!("Processed SPORT sheet ({} rows)", sport_sheet.rows.len());
// Load TEAM logos
// ========== ЗАГРУЖАЕМ TEAM LOGOS ==========
let teams_sheet = workbook
.get_sheet("TEAMS")
.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());
for row in &teams_sheet.rows {
let team = get_cell_fuzzy(row, "TEAM");
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());
}
}
info!("Loaded {} team logos", teams_sheet.rows.len());
let total_teams = teams_sheet.rows.len();
info!("Loaded {} team logos", total_teams);
// Load CHANNEL logos
// Дебаг: выводим статистику по 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().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
.get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
let channels: HashMap<String, String> = channel_sheet
.rows
.iter()
@@ -332,42 +366,95 @@ async fn generate_and_submit_jobs(
.collect();
info!("Loaded {} channel logos", channels.len());
// Process START sheet
if log_enabled!(Level::Debug) {
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
}
// ========== ОБРАБАТЫВАЕМ START (только активные строки) ==========
let start_sheet = workbook
.get_sheet("Start")
.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
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.get("STATE").map(|s| s.as_str()) == Some("FALSE"))
.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 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 {
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 !team_a.is_empty() {
used_teams.insert((team_a.clone(), sport.clone()));
}
if !team_b.is_empty() {
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) {
jobs.extend(job.create_variants());
}
}
// Cleanup unused logos
// Очищаем logos от неиспользуемых команд
let logos_before = logos.len();
logos.retain(|team, sport| used_teams.contains(&(team.to_string(), sport.to_string())));
info!("Retained {} used team logos (cleaned up {} unused)", logos.len(), logos_before - logos.len());
info!(
"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) {
std::cmp::Ordering::Equal => match a.sort_time.cmp(&b.sort_time) {
std::cmp::Ordering::Equal => a.variant_order.cmp(&b.variant_order),
@@ -375,20 +462,44 @@ async fn generate_and_submit_jobs(
},
other => other,
});
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() {
info!("No jobs with STATE='FALSE' found");
return Ok(Vec::new());
}
// ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ==========
cleanup_finished_jobs(&config.nexrender_api_url).await?;
let http_client = Client::new();
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
for job in jobs {
let nexrender_job = job.to_nexrender_job(config);
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
.post(&config.nexrender_api_url)
.json(&nexrender_job)
@@ -400,22 +511,6 @@ async fn generate_and_submit_jobs(
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
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 {
let status = response.status();
@@ -424,13 +519,18 @@ async fn generate_and_submit_jobs(
}
}
info!("Successfully submitted {} jobs to Nexrender", submitted_jobs.len());
info!(
"Successfully submitted {} jobs to Nexrender",
submitted_jobs.len()
);
Ok(submitted_jobs)
}
pub async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
let client = Client::new();
let response = client.get(api_url).send().await?;
if response.status().is_success() {
let jobs: Vec<Value> = response.json().await?;
for job in jobs {

View File

@@ -2,56 +2,108 @@
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AE Anons — Управление заданиями</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
<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">
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="logo-area">
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo">
<div class="header-left">
<div class="logo-container">
<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>
</div>
<div class="theme-toggle">
<button id="themeToggleBtn" class="theme-toggle-btn" title="Сменить тему">
<i class="fas fa-moon"></i>
<div class="header-controls">
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-circle-half-stroke" id="themeIcon"></i>
<span id="themeText">Auto</span>
</button>
</div>
</div>
<div class="controls">
<button id="generateBtn" class="btn btn-primary">
<i class="fas fa-sync-alt"></i> Generate
<!-- Action Bar -->
<div class="action-bar">
<button class="btn btn-primary" onclick="generateJobs()">
<i class="fas fa-play"></i> Generate New Jobs
</button>
<button id="cleanupBtn" class="btn btn-secondary">
<i class="fas fa-trash-alt"></i> Cleanup
<button class="btn btn-warning" onclick="stopAllJobs()">
<i class="fas fa-stop"></i> Stop All Jobs
</button>
<button id="stopAllBtn" class="btn btn-danger">
<i class="fas fa-stop-circle"></i> Stop all
<button class="btn btn-danger" onclick="cleanupJobs()">
<i class="fas fa-trash-alt"></i> Cleanup Finished
</button>
<span id="statusMsg" class="status-msg"></span>
<button class="btn btn-outline" onclick="refreshJobs()">
<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 class="table-wrapper">
<!-- Stats Grid -->
<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">
<thead>
<tr>
<th data-sort="uid">UID <i class="fas fa-sort"></i></th>
<th data-sort="filename">Файл <i class="fas fa-sort"></i></th>
<th data-sort="status">Статус <i class="fas fa-sort"></i></th>
<th>Превью</th>
<th>Апрув</th>
<th data-sort="created_at">Создан <i class="fas fa-sort"></i></th>
<th data-column="filename" onclick="sortTable('filename')">
Output File <i class="fas fa-sort"></i>
</th>
<th data-column="state" onclick="sortTable('state')" class="sorted-asc">
State <i class="fas fa-sort-up"></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>
</thead>
<tbody id="jobsBody">
<tbody id="jobsTableBody">
<tr>
<td colspan="6" class="loading">Загрузка заданий...</td>
<td colspan="5">
<div class="empty-state">
<i class="fas fa-spinner fa-spin"></i>
<p>Loading jobs...</p>
</div>
</td>
</tr>
</tbody>
</table>
@@ -59,243 +111,384 @@
</div>
<script>
// --- Тема (dark/light) с автоопределением ОС и кнопкой переключения ---
(function initTheme() {
const STORAGE_KEY = 'ae_anons_theme';
const btn = document.getElementById('themeToggleBtn');
const icon = btn.querySelector('i');
// State
let allJobs = [];
let filteredJobs = [];
let isLoading = false;
let autoRefreshTimer = null;
let countdownTimer = null;
let countdownValue = 60;
let currentTheme = 'auto';
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
if (theme === 'dark') {
icon.classList.remove('fa-sun');
icon.classList.add('fa-moon');
// Sorting state
let currentSort = { column: 'state', direction: 'asc' };
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
// Theme handling
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
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 {
icon.classList.remove('fa-moon');
icon.classList.add('fa-sun');
icon.className = 'fas fa-sun';
text.textContent = 'Light';
}
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
function toggleTheme() {
if (currentTheme === 'auto') {
currentTheme = 'light';
} else if (currentTheme === 'light') {
currentTheme = 'dark';
} else {
currentTheme = 'auto';
}
localStorage.setItem('theme', currentTheme);
applyTheme(currentTheme);
}
function getSavedTheme() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return null;
}
function initTheme() {
const savedTheme = localStorage.getItem('theme') || 'auto';
currentTheme = savedTheme;
applyTheme(currentTheme);
const saved = getSavedTheme();
const initial = saved !== null ? saved : getSystemTheme();
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');
darkModeMediaQuery.addEventListener('change', (e) => {
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
})();
// --- Основная логика приложения (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);
}
function handleWsEvent(event) {
switch (event.type) {
case 'JobUpdated':
updateJobInList(event.job);
break;
case 'GenerationStarted':
showStatus('Генерация заданий началась...', 'info');
break;
case 'GenerationFinished':
showStatus(`Генерация завершена: создано ${event.count} заданий`, 'success');
loadJobs();
break;
case 'Error':
showStatus(`Ошибка: ${event.message}`, 'error');
break;
// Sorting
function sortTable(column) {
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
}
updateSortIcons();
sortAndRender();
}
function updateSortIcons() {
document.querySelectorAll('th').forEach(th => {
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:
console.log('Unknown event:', event);
return 0;
}
});
}
function updateJobInList(job) {
const idx = currentJobs.findIndex(j => j.uid === job.uid);
if (idx !== -1) {
currentJobs[idx] = job;
} else {
currentJobs.unshift(job);
}
renderJobsTable();
function sortAndRender() {
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
filteredJobs : allJobs;
const sorted = sortJobs(jobsToRender);
renderJobs(sorted);
}
async function loadJobs() {
// Data fetching
async function refreshJobs() {
if (isLoading) return;
isLoading = true;
try {
setStatus('loading', 'Loading jobs...');
const response = await fetch('/api/jobs');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
currentJobs = await response.json();
renderJobsTable();
allJobs = await response.json();
filteredJobs = [];
document.getElementById('filterInput').value = '';
sortAndRender();
updateStats();
setStatus('success', `Loaded ${allJobs.length} jobs`);
} catch (err) {
console.error('Failed to load jobs:', err);
document.getElementById('jobsBody').innerHTML = '<tr><td colspan="6">Ошибка загрузки заданий</td></tr>';
console.error(err);
setStatus('error', 'Failed to load jobs');
} finally {
isLoading = false;
}
}
function renderJobsTable() {
const tbody = document.getElementById('jobsBody');
if (!currentJobs.length) {
tbody.innerHTML = '<tr><td colspan="6">Нет заданий</td></tr>';
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody');
if (jobs.length === 0) {
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;
}
tbody.innerHTML = currentJobs.map(job => `
<tr data-uid="${job.uid}">
<td class="uid">${escapeHtml(job.uid)}</td>
<td class="filename">${escapeHtml(job.filename)}</td>
<td class="status">${escapeHtml(job.status)}</td>
<td class="preview">
<a href="/api/video/${job.uid}" target="_blank" title="Просмотр видео">
<i class="fas fa-video"></i>
</a>
tbody.innerHTML = jobs.map(job => {
const stateClass = getStateClass(job.state);
const created = formatDateTime(job.created_at);
const updated = formatDateTime(job.updated_at);
return `
<tr>
<td>
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
</td>
<td class="approve">
<button class="approve-btn" data-uid="${job.uid}" ${job.approved ? 'disabled' : ''}>
${job.approved ? '✅' : '✅'}
</button>
</td>
<td class="created">${formatDate(job.created_at)}</td>
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
<td class="datetime">${created}</td>
<td class="datetime">${updated}</td>
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
</tr>
`).join('');
// привязать обработчики апрува
document.querySelectorAll('.approve-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const uid = btn.getAttribute('data-uid');
approveJob(uid, btn);
});
});
`;
}).join('');
}
async function approveJob(uid, btn) {
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
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 response = await fetch(`/api/approve/${uid}`, { method: 'POST' });
const result = await response.json();
if (response.ok) {
showStatus(`Файл скопирован: ${result.destination || 'approved'}`, 'success');
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');
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() {
const filter = document.getElementById('filterInput').value.toLowerCase();
filteredJobs = allJobs.filter(job =>
job.outfile_name.toLowerCase().includes(filter) ||
job.uid.toLowerCase().includes(filter)
);
sortAndRender();
}
// Actions
async function generateJobs() {
const btn = document.getElementById('generateBtn');
btn.disabled = true;
showStatus('Запуск генерации...', 'info');
setStatus('loading', 'Generating jobs...');
try {
const response = await fetch('/api/generate', { method: 'POST' });
if (response.ok) {
showStatus('Генерация запущена', 'success');
setStatus('success', 'Job generation started');
setTimeout(() => refreshJobs(), 5000);
} else {
const text = await response.text();
showStatus(`Ошибка: ${text}`, 'error');
setStatus('error', `Error: ${text}`);
}
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
} finally {
setTimeout(() => { btn.disabled = false; }, 2000);
}
}
async function cleanupJobs() {
showStatus('Очистка завершённых заданий...', 'info');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
showStatus('Очистка выполнена', 'success');
loadJobs();
} else {
showStatus('Ошибка при очистке', 'error');
}
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
setStatus('error', `Error: ${err}`);
}
}
async function stopAllJobs() {
if (!confirm('Остановить все активные задания?')) return;
showStatus('Остановка заданий...', 'info');
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();
showStatus(text, 'success');
loadJobs();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
showStatus(`Ошибка: ${err.message}`, 'error');
setStatus('error', `Error: ${err}`);
}
}
function showStatus(msg, type) {
const el = document.getElementById('statusMsg');
el.textContent = msg;
el.className = `status-msg ${type}`;
setTimeout(() => {
if (el.textContent === msg) el.textContent = '';
}, 5000);
async function cleanupJobs() {
setStatus('loading', 'Cleaning up finished jobs...');
try {
const response = await fetch('/api/cleanup', { method: 'POST' });
if (response.ok) {
setStatus('success', 'Cleanup completed');
await refreshJobs();
} else {
const text = await response.text();
setStatus('error', `Error: ${text}`);
}
} catch (err) {
setStatus('error', `Error: ${err}`);
}
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString();
function setStatus(type, message) {
const el = document.getElementById('statusMessage');
const icons = {
loading: '<span class="spinner"></span>',
success: '<i class="fas fa-check-circle" style="color: var(--accent-success);"></i>',
error: '<i class="fas fa-times-circle" style="color: var(--accent-danger);"></i>'
};
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function (m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
// Auto-refresh
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = setInterval(() => {
refreshJobs();
resetCountdown();
}, 60000);
countdownTimer = setInterval(() => {
countdownValue--;
document.getElementById('refreshCountdown').textContent = countdownValue;
if (countdownValue <= 0) {
countdownValue = 60;
}
}, 1000);
}
function stopAutoRefresh() {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (countdownTimer) clearInterval(countdownTimer);
}
function resetCountdown() {
countdownValue = 60;
document.getElementById('refreshCountdown').textContent = countdownValue;
}
// Initialize
initTheme();
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>
</body>

View File

@@ -1,273 +1,555 @@
/* ========================================
AE Anons - Nexrender Job Manager Styles
======================================== */
/* CSS Variables - Light Theme (default) */
:root {
--bg-body: #f5f7fa;
--bg-container: #ffffff;
--text-primary: #1e2a3a;
--text-secondary: #4a5568;
--border-color: #e2e8f0;
--table-header-bg: #f8fafc;
--btn-primary-bg: #3b82f6;
--btn-primary-hover: #2563eb;
--btn-secondary-bg: #64748b;
--btn-secondary-hover: #475569;
--btn-danger-bg: #ef4444;
--btn-danger-hover: #dc2626;
--status-info: #3b82f6;
--status-success: #10b981;
--status-error: #ef4444;
--logo-brightness: 1;
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f3f0f7;
--text-primary: #1a1a1a;
--text-secondary: #6b4f7c;
--border-color: #e0d4e8;
--accent-primary: #7c3aed;
--accent-secondary: #c2410c;
--accent-success: #10b981;
--accent-warning: #ea580c;
--accent-danger: #ef4444;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
--header-bg: #7c3aed;
--header-text: #ffffff;
}
/* Dark Theme */
[data-theme="dark"] {
--bg-body: #0f172a;
--bg-container: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--border-color: #334155;
--table-header-bg: #0f172a;
--btn-primary-bg: #2563eb;
--btn-primary-hover: #3b82f6;
--btn-secondary-bg: #475569;
--btn-secondary-hover: #64748b;
--btn-danger-bg: #dc2626;
--btn-danger-hover: #ef4444;
--logo-brightness: 0.9;
--bg-primary: #1a1025;
--bg-secondary: #251a30;
--bg-tertiary: #352545;
--text-primary: #f0e6ff;
--text-secondary: #c4a6d9;
--border-color: #4a3560;
--accent-primary: #a78bfa;
--accent-secondary: #f59e0b;
--accent-success: #34d399;
--accent-warning: #fbbf24;
--accent-danger: #f87171;
--header-bg: #a78bfa;
--header-text: #1a1025;
}
/* Reset & Base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
}
body {
margin: 0;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-body);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s, color 0.2s;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 1400px;
max-width: 100%;
margin: 0 auto;
background-color: var(--bg-container);
border-radius: 16px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
overflow: hidden;
padding: 24px;
}
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
justify-content: space-between;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.logo-area {
.header-left {
display: flex;
align-items: center;
gap: 1rem;
gap: 16px;
}
.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 {
width: 150px;
/* уменьшенный размер */
height: auto;
filter: brightness(var(--logo-brightness));
width: 100%;
height: 100%;
object-fit: cover;
}
.logo-area h1 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
}
.theme-toggle-btn {
background: none;
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;
.logo-placeholder {
width: 100%;
height: 100%;
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
border-bottom: 1px solid var(--border-color);
justify-content: center;
color: white;
font-size: 24px;
font-weight: bold;
}
.header h1 {
font-size: 28px;
font-weight: 600;
color: var(--accent-primary);
letter-spacing: -0.5px;
}
.header-controls {
display: flex;
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);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 8px;
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 {
font-size: 1rem;
font-size: 14px;
}
/* Generate - Purple */
.btn-primary {
background-color: var(--btn-primary-bg);
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%);
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 {
background-color: var(--btn-primary-hover);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4);
}
.btn-secondary {
background-color: var(--btn-secondary-bg);
/* Stop - Orange */
.btn-warning {
background: linear-gradient(135deg, #ea580c 0%, #f97316 100%);
color: white;
border: none;
box-shadow: 0 2px 8px rgba(234, 88, 12, 0.3);
}
.btn-secondary:hover {
background-color: var(--btn-secondary-hover);
[data-theme="dark"] .btn-warning {
background: linear-gradient(135deg, #f97316 0%, #fb923c 100%);
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 {
background-color: var(--btn-danger-bg);
background: linear-gradient(135deg, #9d174d 0%, #be185d 100%);
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 {
background-color: var(--btn-danger-hover);
box-shadow: 0 4px 12px rgba(157, 23, 77, 0.4);
}
.btn:active {
transform: scale(0.97);
/* Refresh - Outline */
.btn-outline {
background: transparent;
border: 2px solid #7c3aed;
color: #7c3aed;
box-shadow: none;
}
.status-msg {
[data-theme="dark"] .btn-outline {
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;
font-size: 0.85rem;
padding: 0.25rem 0.75rem;
padding: 8px 16px;
background: var(--bg-tertiary);
border-radius: 20px;
font-size: 14px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.status-msg.info {
background-color: var(--status-info);
color: white;
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.status-msg.success {
background-color: var(--status-success);
color: white;
.stat-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-color);
}
.status-msg.error {
background-color: var(--status-error);
color: white;
.stat-card h3 {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 8px;
font-weight: 500;
}
.table-wrapper {
.stat-card .value {
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;
padding: 0 2rem 2rem 2rem;
border: 1px solid var(--border-color);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
table-layout: auto;
min-width: 800px;
}
th {
background-color: var(--table-header-bg);
text-align: left;
padding: 16px;
background: var(--header-bg);
color: var(--header-text);
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
cursor: pointer;
user-select: none;
position: relative;
}
th:hover {
opacity: 0.9;
}
th i {
margin-left: 0.5rem;
opacity: 0.5;
}
tr:hover {
background-color: rgba(0, 0, 0, 0.02);
}
[data-theme="dark"] tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.approve-btn {
background: none;
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;
cursor: default;
}
.preview a {
color: var(--text-primary);
text-decoration: none;
font-size: 1.2rem;
}
.preview a:hover {
margin-left: 8px;
font-size: 12px;
opacity: 0.7;
}
@media (max-width: 768px) {
th.sorted-asc i,
th.sorted-desc i {
opacity: 1;
}
.header,
.controls,
.table-wrapper {
padding-left: 1rem;
padding-right: 1rem;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
vertical-align: middle;
}
.logo {
width: 100px;
}
tr:last-child td {
border-bottom: none;
}
.logo-area h1 {
font-size: 1.2rem;
}
tr:hover {
background: var(--bg-tertiary);
}
th,
td {
padding: 0.5rem;
/* Job Filename */
.job-filename {
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 */
.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;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
color: var(--accent-primary);
}
/* Loading Spinner */
.spinner {
width: 20px;
height: 20px;
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 {
to {
transform: rotate(360deg);
}
}
/* Responsive */
@media (max-width: 768px) {
.container {
padding: 16px;
}
.header {
flex-direction: column;
align-items: flex-start;
}
.header-left {
flex-wrap: wrap;
}
.header h1 {
font-size: 20px;
}
.action-bar {
flex-direction: column;
align-items: stretch;
}
.status-message {
margin-left: 0;
}
}

View File

@@ -1,141 +0,0 @@
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)
}

View File

@@ -1,77 +1,113 @@
use crate::config::Config;
use crate::processor::{fetch_all_jobs, process_spreadsheet};
use crate::storage::{self, JobRecord};
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
use axum::{
extract::{Path, State, WebSocketUpgrade},
extract::State,
http::{header::CONTENT_TYPE, StatusCode},
response::{Html, IntoResponse, Json, Response},
response::{Html, IntoResponse, Json},
routing::{get, post},
Router,
};
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::str::FromStr;
use std::sync::Arc;
use tokio::sync::broadcast;
use tokio_util::io::ReaderStream;
use tokio::sync::Mutex;
use tower_http::trace::TraceLayer;
use url::Url;
use chrono::Utc;
#[derive(Clone)]
pub struct AppState {
pub config: Config,
pub db: SqlitePool,
pub ws_tx: broadcast::Sender<WsEvent>,
pub last_generation: Arc<tokio::sync::Mutex<Option<chrono::DateTime<chrono::Local>>>>,
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
}
#[derive(Serialize, Clone)]
#[serde(tag = "type")]
pub enum WsEvent {
JobUpdated(JobRecord),
GenerationStarted,
GenerationFinished { count: usize },
Error { message: String },
#[derive(Serialize)]
pub struct JobInfo {
pub uid: String,
pub outfile_name: String,
pub state: String,
pub created_at: Option<String>,
pub updated_at: Option<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<()> {
let web_port = config.web_port;
let db = storage::init_db(&config.database_url).await?;
let (ws_tx, _) = broadcast::channel(100);
let state = AppState {
config,
db,
ws_tx,
last_generation: Arc::new(tokio::sync::Mutex::new(None)),
last_generation: Arc::new(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()
.route("/", get(index_page))
.route("/favicon.ico", get(favicon))
.route("/static/style.css", get(style_css))
.route("/static/fontawesome/all.min.css", get(fontawesome_css))
.route(
"/static/fontawesome/webfonts/fa-solid-900.woff2",
get(fa_solid_woff2),
)
.route(
"/static/fontawesome/webfonts/fa-regular-400.woff2",
get(fa_regular_woff2),
)
.route(
"/static/fontawesome/webfonts/fa-brands-400.woff2",
get(fa_brands_woff2),
)
.route("/assets/logo.png", get(logo_png))
.route("/api/jobs", get(list_jobs))
.route("/api/generate", post(generate_jobs))
.route("/api/cleanup", post(cleanup_jobs))
.route("/api/status", get(get_status))
.route("/api/jobs/stop-all", post(stop_all_jobs))
.route("/api/video/{uid}", get(serve_video))
.route("/api/approve/{uid}", post(approve_job))
.route("/ws", get(ws_handler))
.layer(TraceLayer::new_for_http())
.with_state(state);
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
log::info!("Web server listening on http://{}", addr);
@@ -80,53 +116,6 @@ pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
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> {
Html(include_str!("static/index.html"))
}
@@ -159,10 +148,16 @@ async fn favicon() -> impl IntoResponse {
)
}
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobRecord>>, AppError> {
let jobs = storage::get_all_jobs(&state.db)
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
.await
.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))
}
@@ -180,64 +175,34 @@ async fn generate_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
*last_gen = Some(chrono::Local::now());
drop(last_gen);
let state_clone = state.clone();
let config = state.config.clone();
let last_gen_clone = state.last_generation.clone();
tokio::spawn(async move {
let _ = state_clone.ws_tx.send(WsEvent::GenerationStarted);
match process_spreadsheet(
&state_clone.config,
&state_clone.db,
state_clone.ws_tx.clone(),
)
.await
{
match process_spreadsheet(&config).await {
Ok(submitted) => {
log::info!("Generation completed, {} jobs submitted", submitted.len());
let _ = state_clone.ws_tx.send(WsEvent::GenerationFinished {
count: submitted.len(),
});
}
Err(e) => {
log::error!("Generation failed: {}", e);
let _ = state_clone.ws_tx.send(WsEvent::Error {
message: e.to_string(),
});
}
}
*state_clone.last_generation.lock().await = None;
*last_gen_clone.lock().await = None;
});
Ok((StatusCode::ACCEPTED, "Job generation started"))
}
async fn cleanup_jobs(State(state): State<AppState>) -> Result<impl IntoResponse, AppError> {
let client = reqwest::Client::new();
let api_url = &state.config.nexrender_api_url;
let jobs = fetch_all_jobs(api_url)
cleanup_finished_jobs(&state.config.nexrender_api_url)
.await
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
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)))
Ok((StatusCode::OK, "Cleanup completed"))
}
async fn get_status(State(state): State<AppState>) -> Result<Json<serde_json::Value>, AppError> {
let last_gen = *state.last_generation.lock().await;
let status = json!({
let status = serde_json::json!({
"last_generation": last_gen.map(|dt| dt.to_rfc3339()),
"nexrender_api": state.config.nexrender_api_url,
});
@@ -256,8 +221,8 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
let mut stopped = 0;
for job in jobs {
let state_str = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
if state_str == "queued" || state_str == "started" || state_str == "processing" {
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
if state == "queued" || state == "started" || state == "processing" {
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
stopped += 1;
@@ -270,220 +235,31 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
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 fa_solid_woff2() -> impl IntoResponse {
(
[(CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-solid-900.woff2").as_slice(),
)
}
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(),
async fn fa_regular_woff2() -> impl IntoResponse {
(
[(CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-regular-400.woff2").as_slice(),
)
})?;
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,
})
}
async fn fa_brands_woff2() -> impl IntoResponse {
(
[(CONTENT_TYPE, "font/woff2")],
include_bytes!("static/fontawesome/webfonts/fa-brands-400.woff2").as_slice(),
)
}
struct AppError(StatusCode, String);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
fn into_response(self) -> axum::response::Response {
(self.0, self.1).into_response()
}
}