Compare commits
22 Commits
v0.2.3
...
ff0652b992
| Author | SHA1 | Date | |
|---|---|---|---|
| ff0652b992 | |||
| a3fc5e2940 | |||
| 6ec569f6e6 | |||
| 576e2b68fc | |||
| fde16de0c6 | |||
| 418f12ca4a | |||
| 7001aeb070 | |||
| b793571681 | |||
| 74412ad368 | |||
| 855bdcc3f9 | |||
| 4646dd2b70 | |||
| 6a642beb5c | |||
| 9b3d959ce3 | |||
| e06b55ad07 | |||
| c3056010eb | |||
| 9929dab66f | |||
| 6f13679dd9 | |||
| 70d3e6c6cd | |||
| e866cb32b7 | |||
| 11a8cb7246 | |||
| dc50413339 | |||
| 56f04804cc |
32
.env.example
32
.env.example
@@ -20,3 +20,35 @@ TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aep"
|
|||||||
TEMPLATE_COMPOSITION="main"
|
TEMPLATE_COMPOSITION="main"
|
||||||
TEMPLATE_OUTPUT_MODULE="h264"
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
TEMPLATE_OUTPUT_EXT="mp4"
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
|
||||||
|
# --- Источник готовых видео (где воркер Nexrender уже сохранил файлы) ---
|
||||||
|
# Тип доступа определяется тем, какая переменная задана:
|
||||||
|
# SOURCE_LOCAL_PATH -> локальная папка
|
||||||
|
# SOURCE_SMB_URL -> SMB-шара
|
||||||
|
# SOURCE_SYNOLOGY_PATH -> папка на Synology NAS (через FileStation API)
|
||||||
|
# Должна быть указана ровно одна из них.
|
||||||
|
|
||||||
|
# Пример локального источника:
|
||||||
|
# SOURCE_LOCAL_PATH="/mnt/rendered"
|
||||||
|
|
||||||
|
# Пример SMB-источника (если нужен – закомментируйте LOCAL и раскомментируйте SMB):
|
||||||
|
#SOURCE_SMB_URL="smb://192.168.1.10/share/output"
|
||||||
|
#SOURCE_SMB_USER="user"
|
||||||
|
#SOURCE_SMB_PASS="pass"
|
||||||
|
|
||||||
|
# Пример Synology-источника:
|
||||||
|
#SOURCE_SYNOLOGY_PATH="/Team Folder/output"
|
||||||
|
|
||||||
|
# --- Приёмник (куда будут перемещены проверенные видео) ---
|
||||||
|
# Аналогично, одна из трёх переменных обязательна.
|
||||||
|
|
||||||
|
# Пример локального приёмника:
|
||||||
|
# DESTINATION_LOCAL_PATH="/mnt/approved"
|
||||||
|
|
||||||
|
# Пример SMB-приёмника (раскомментировать при необходимости):
|
||||||
|
#DESTINATION_SMB_URL="smb://192.168.1.20/share/final"
|
||||||
|
#DESTINATION_SMB_USER="user"
|
||||||
|
#DESTINATION_SMB_PASS="pass"
|
||||||
|
|
||||||
|
# Пример Synology-приёмника:
|
||||||
|
#DESTINATION_SYNOLOGY_PATH="/Team Folder/final"
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,13 +1,12 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.xlsx
|
|
||||||
*.txt
|
|
||||||
makefile
|
|
||||||
# Файлы окружения
|
# Файлы окружения
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Логи
|
# Логи
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
out.txt
|
||||||
|
.build_timing
|
||||||
|
|
||||||
# Выходные файлы
|
# Выходные файлы
|
||||||
output/
|
output/
|
||||||
|
|||||||
1911
Cargo.lock
generated
1911
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.2.3"
|
version = "0.3.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-file = "LICENSE"
|
|
||||||
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
|
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
|
||||||
description = "Automated Nexrender job generator from Synology Office spreadsheets"
|
description = "Automated Nexrender job generator from Synology Office spreadsheets"
|
||||||
repository = "https://git.tvstart.ru/lexx/AE_Anons"
|
repository = "https://git.tvstart.ru/lexx/AE_Anons"
|
||||||
@@ -27,6 +26,9 @@ anyhow = "1.0"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
bytes = "1.9"
|
bytes = "1.9"
|
||||||
|
smb = { version = "0.11.2", features = ["async"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
url = "2"
|
||||||
|
|
||||||
# Web server (updated to latest stable versions)
|
# Web server (updated to latest stable versions)
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
@@ -49,3 +51,6 @@ opt-level = "z"
|
|||||||
lto = true
|
lto = true
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
winres = "0.1"
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,7 +1,5 @@
|
|||||||
# MIT License
|
# MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 [Your Name or Company]
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
|
|||||||
71
README.md
71
README.md
@@ -12,13 +12,15 @@
|
|||||||
|
|
||||||
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
## Новое в версии 0.2.2
|
## Новое в версии v0.2.6
|
||||||
|
|
||||||
- **Веб-интерфейс**: Удобное управление заданиями через браузер
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
- **Мониторинг в реальном времени**: Отслеживание статуса всех заданий
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
- **Управление очередью**: Остановка, очистка и генерация заданий из веб-интерфейса
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
- **Тёмная/светлая тема**: Автоматическое определение системной темы
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
- **Сортировка и фильтрация**: Удобная работа с большим количеством заданий
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
@@ -571,10 +573,21 @@ ae_anons/
|
|||||||
|
|
||||||
## 🙏 Благодарности
|
## 🙏 Благодарности
|
||||||
|
|
||||||
|
### Nexrender
|
||||||
|
|
||||||
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
||||||
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
||||||
для автоматизации After Effects.
|
для автоматизации After Effects.
|
||||||
|
|
||||||
|
### Font Awesome Free 6.4.0
|
||||||
|
|
||||||
|
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
||||||
|
|
||||||
|
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
|
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
||||||
|
|
||||||
|
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
**AE Anons** — [License](LICENSE)
|
**AE Anons** — [License](LICENSE)
|
||||||
@@ -602,42 +615,64 @@ ae_anons/
|
|||||||
|
|
||||||
## История изменений
|
## История изменений
|
||||||
|
|
||||||
### v0.2.2 (текущая)
|
### 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 файл
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
### v0.2.0
|
### v0.2.0
|
||||||
|
|
||||||
- Добавлен веб-интерфейс для управления заданиями
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
|
||||||
- Реализован REST API
|
- Реализован REST API
|
||||||
|
|
||||||
- Добавлена поддержка тёмной/светлой темы
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
|
||||||
- Автообновление статуса заданий
|
- Автообновление статуса заданий
|
||||||
|
|
||||||
- Возможность остановки всех активных заданий
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
### v0.1.1
|
### v0.1.1
|
||||||
|
|
||||||
- Оптимизирована работа с памятью
|
- Оптимизирована работа с памятью
|
||||||
|
|
||||||
- Убрана функция создания .json
|
- Убрана функция создания .json
|
||||||
|
|
||||||
### v0.1.0
|
### v0.1.0
|
||||||
|
|
||||||
- Первый выпуск
|
- Первый выпуск
|
||||||
|
|
||||||
- Интеграция с Synology Office
|
- Интеграция с Synology Office
|
||||||
|
|
||||||
- Базовая генерация заданий Nexrender
|
- Базовая генерация заданий Nexrender
|
||||||
|
|
||||||
- Парсинг Excel с динамическим обнаружением листов
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
BIN
ae_anons-v0.2.6-all.tar.gz
Normal file
BIN
ae_anons-v0.2.6-all.tar.gz
Normal file
Binary file not shown.
BIN
assets/logo.icns
Normal file
BIN
assets/logo.icns
Normal file
Binary file not shown.
BIN
assets/logo.ico
Normal file
BIN
assets/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
11
build.rs
Normal file
11
build.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Err(e) = winres::WindowsResource::new()
|
||||||
|
.set_icon("assets/logo.ico")
|
||||||
|
.compile()
|
||||||
|
{
|
||||||
|
eprintln!("warning: не удалось встроить иконку: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
311
makefile
Normal file
311
makefile
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
.PHONY: all clean build-mac build-windows build-linux build-linux-musl package help quick package-single icons
|
||||||
|
|
||||||
|
# Название проекта
|
||||||
|
PROJECT_NAME := ae_anons
|
||||||
|
RELEASE_DIR := target/release
|
||||||
|
BINARIES_DIR := releases
|
||||||
|
VERSION := $(shell grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||||
|
|
||||||
|
# Определение текущей ОС
|
||||||
|
UNAME_S := $(shell uname -s)
|
||||||
|
UNAME_M := $(shell uname -m)
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
RED := \033[0;31m
|
||||||
|
YELLOW := \033[1;33m
|
||||||
|
BLUE := \033[0;34m
|
||||||
|
CYAN := \033[0;36m
|
||||||
|
MAGENTA := \033[0;35m
|
||||||
|
BOLD := \033[1m
|
||||||
|
NC := \033[0m
|
||||||
|
|
||||||
|
# Файл для хранения времени сборки
|
||||||
|
TIMING_FILE := .build_timing
|
||||||
|
|
||||||
|
# Определение команды ImageMagick (v6 = convert, v7 = magick)
|
||||||
|
IMAGEMAGICK := $(shell command -v magick 2>/dev/null || command -v convert 2>/dev/null || echo "false")
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "$(BOLD)$(CYAN)AE Anons - Makefile команды$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Сборка под конкретную платформу:$(NC)"
|
||||||
|
@echo " make build-mac - Собрать для macOS (Apple Silicon)"
|
||||||
|
@echo " make build-windows - Собрать для Windows x86_64"
|
||||||
|
@echo " make build-linux - Собрать для Linux x86_64 (GNU)"
|
||||||
|
@echo " make build-linux-musl - Собрать для Linux x86_64 (musl static)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Быстрая сборка:$(NC)"
|
||||||
|
@echo " make quick - Собрать под текущую платформу с упаковкой"
|
||||||
|
@echo " make quick-release - Собрать под текущую платформу (release, без упаковки)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Полная сборка:$(NC)"
|
||||||
|
@echo " make build-all - Собрать для всех платформ"
|
||||||
|
@echo " make package - Создать пакеты для всех платформ"
|
||||||
|
@echo " make package-single - Создать пакет для текущей платформы"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Подготовка ресурсов:$(NC)"
|
||||||
|
@echo " make setup - Подготовить иконки (логотипы, favicon)"
|
||||||
|
@echo " make setup-fontawesome-manual - Скачать и исправить all.min.css (если удалён)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)Очистка:$(NC)"
|
||||||
|
@echo " make clean - Очистить все сборки"
|
||||||
|
@echo " make clean-timing - Очистить файл с временами сборки"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
define copy_common_files
|
||||||
|
@cp .env.example $(1)/ 2>/dev/null || true
|
||||||
|
@cp README.md $(1)/ 2>/dev/null || true
|
||||||
|
@if [ -f LICENSE ]; then cp LICENSE $(1)/; else echo "$(YELLOW)⚠️ LICENSE не найден, пропускаем$(NC)"; fi
|
||||||
|
endef
|
||||||
|
|
||||||
|
define measure_time
|
||||||
|
@start=$$(date +%s); \
|
||||||
|
$(2); \
|
||||||
|
end=$$(date +%s); \
|
||||||
|
duration=$$((end - start)); \
|
||||||
|
echo "$(1): $$duration сек" >> $(TIMING_FILE); \
|
||||||
|
if [ $$duration -ge 60 ]; then \
|
||||||
|
min=$$((duration / 60)); \
|
||||||
|
sec=$$((duration % 60)); \
|
||||||
|
echo "$(GREEN)✓ $(1) завершён за $${min}m $${sec}s$(NC)"; \
|
||||||
|
else \
|
||||||
|
echo "$(GREEN)✓ $(1) завершён за $${duration}s$(NC)"; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
|
||||||
|
define print_timing_summary
|
||||||
|
@echo ""
|
||||||
|
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
|
||||||
|
@echo "$(BOLD)$(CYAN) ВРЕМЯ СБОРКИ $(NC)"
|
||||||
|
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
|
||||||
|
@if [ -f $(TIMING_FILE) ]; then \
|
||||||
|
total=0; \
|
||||||
|
while IFS=':' read -r platform duration; do \
|
||||||
|
sec=$$(echo $$duration | sed 's/ сек//'); \
|
||||||
|
total=$$((total + sec)); \
|
||||||
|
printf "$(YELLOW)%-25s$(NC) : $(GREEN)%5d сек$(NC)\n" "$$platform" "$$sec"; \
|
||||||
|
done < $(TIMING_FILE); \
|
||||||
|
echo "$(BOLD)$(CYAN)───────────────────────────────────────────────────────────────$(NC)"; \
|
||||||
|
printf "$(BOLD)$(MAGENTA)%-25s$(NC) : $(BOLD)$(GREEN)%5d сек$(NC) (%.2f мин)\n" "ВСЕГО" "$$total" "$$(echo "scale=2; $$total/60" | bc 2>/dev/null || echo "$$total/60" | awk '{printf "%.2f", $$1/60}')"; \
|
||||||
|
else \
|
||||||
|
echo "$(YELLOW)Нет данных о времени сборки$(NC)"; \
|
||||||
|
fi
|
||||||
|
@echo "$(BOLD)$(CYAN)═══════════════════════════════════════════════════════════════$(NC)"
|
||||||
|
@rm -f $(TIMING_FILE)
|
||||||
|
endef
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ПОДГОТОВКА РЕСУРСОВ
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
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 || 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)"
|
||||||
|
|
||||||
|
# Восстановить 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
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ОПРЕДЕЛЕНИЕ ПЛАТФОРМЫ
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
detect-platform:
|
||||||
|
ifeq ($(UNAME_S),Darwin)
|
||||||
|
@echo "$(BLUE)🍎 macOS $(UNAME_M)$(NC)"
|
||||||
|
@$(MAKE) quick-mac
|
||||||
|
else ifeq ($(UNAME_S),Linux)
|
||||||
|
@echo "$(BLUE)🐧 Linux $(UNAME_M)$(NC)"
|
||||||
|
@$(MAKE) quick-linux
|
||||||
|
else ifeq ($(OS),Windows_NT)
|
||||||
|
@echo "$(BLUE)🪟 Windows$(NC)"
|
||||||
|
@$(MAKE) quick-windows
|
||||||
|
else
|
||||||
|
@echo "$(RED)❌ Неизвестная платформа: $(UNAME_S)$(NC)"
|
||||||
|
@exit 1
|
||||||
|
endif
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# БЫСТРАЯ СБОРКА (ТЕКУЩАЯ ПЛАТФОРМА)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
quick: detect-platform
|
||||||
|
|
||||||
|
quick-release:
|
||||||
|
ifeq ($(UNAME_S),Darwin)
|
||||||
|
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
|
||||||
|
@time cargo build --release
|
||||||
|
else ifeq ($(UNAME_S),Linux)
|
||||||
|
@echo "$(GREEN)🐧 Сборка для Linux (musl)...$(NC)"
|
||||||
|
@time cargo build --target x86_64-unknown-linux-musl --release
|
||||||
|
else ifeq ($(OS),Windows_NT)
|
||||||
|
@echo "$(GREEN)🪟 Сборка для Windows...$(NC)"
|
||||||
|
@time cargo build --release
|
||||||
|
else
|
||||||
|
@echo "$(RED)❌ Неизвестная платформа$(NC)"
|
||||||
|
@exit 1
|
||||||
|
endif
|
||||||
|
|
||||||
|
quick-mac:
|
||||||
|
@rm -f $(TIMING_FILE)
|
||||||
|
@$(call measure_time,"🍎 macOS $(UNAME_M)",cargo build --release)
|
||||||
|
@mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M)
|
||||||
|
@cp $(RELEASE_DIR)/$(PROJECT_NAME) $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M)/
|
||||||
|
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M))
|
||||||
|
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-macos-$(UNAME_M)/$(NC)"
|
||||||
|
@$(call print_timing_summary)
|
||||||
|
|
||||||
|
quick-linux:
|
||||||
|
@rm -f $(TIMING_FILE)
|
||||||
|
@$(call measure_time,"🐧 Linux $(UNAME_M)",cargo build --target x86_64-unknown-linux-musl --release)
|
||||||
|
@mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)
|
||||||
|
@cp target/x86_64-unknown-linux-musl/release/$(PROJECT_NAME) $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/
|
||||||
|
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M))
|
||||||
|
@echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-linux-$(UNAME_M)/$(NC)"
|
||||||
|
@$(call print_timing_summary)
|
||||||
|
|
||||||
|
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); \
|
||||||
|
mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64; \
|
||||||
|
cp target/x86_64-pc-windows-gnu/release/$(PROJECT_NAME).exe $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64/; \
|
||||||
|
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64); \
|
||||||
|
echo "$(GREEN)✅ Пакет: $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-windows-x64/$(NC)"; \
|
||||||
|
$(call print_timing_summary); \
|
||||||
|
else \
|
||||||
|
echo "$(RED)❌ Для кросс-компиляции Windows установите mingw-w64:$(NC)"; \
|
||||||
|
echo " brew install mingw-w64"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# СБОРКА ПОД КОНКРЕТНЫЕ ПЛАТФОРМЫ
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
build-mac:
|
||||||
|
@echo "$(GREEN)🍎 Сборка для macOS...$(NC)"
|
||||||
|
@time cargo build --release
|
||||||
|
|
||||||
|
build-windows: windows-icon
|
||||||
|
@echo "$(GREEN)🪟 Сборка для Windows x86_64...$(NC)"
|
||||||
|
@time cargo build --target x86_64-pc-windows-gnu --release
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
@echo "$(GREEN)🐧 Сборка для Linux x86_64 (GNU)...$(NC)"
|
||||||
|
@time cargo build --target x86_64-unknown-linux-gnu --release
|
||||||
|
|
||||||
|
build-linux-musl:
|
||||||
|
@echo "$(GREEN)🐧 Сборка для Linux x86_64 (musl static)...$(NC)"
|
||||||
|
@time cargo build --target x86_64-unknown-linux-musl --release
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# СБОРКА ВСЕХ ПЛАТФОРМ С ТАЙМИНГОМ
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
build-all:
|
||||||
|
@rm -f $(TIMING_FILE)
|
||||||
|
@echo "$(BOLD)$(CYAN)🚀 Сборка для всех платформ...$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@$(call measure_time,"🍎 macOS",cargo build --release)
|
||||||
|
@echo ""
|
||||||
|
@$(call measure_time,"🐧 Linux GNU",cargo build --target x86_64-unknown-linux-gnu --release)
|
||||||
|
@echo ""
|
||||||
|
@$(call measure_time,"🐧 Linux Musl",cargo build --target x86_64-unknown-linux-musl --release)
|
||||||
|
@echo ""
|
||||||
|
@-$(call measure_time,"🪟 Windows",cargo build --target x86_64-pc-windows-gnu --release)
|
||||||
|
@echo ""
|
||||||
|
@$(call print_timing_summary)
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# УПАКОВКА РЕЛИЗОВ
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
define create_release_package
|
||||||
|
@echo "$(YELLOW)📦 Упаковка для $(1)...$(NC)"
|
||||||
|
@mkdir -p $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-$(1)
|
||||||
|
@cp $(2) $(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-$(1)/$(3) 2>/dev/null || true
|
||||||
|
$(call copy_common_files,$(BINARIES_DIR)/$(PROJECT_NAME)-v$(VERSION)-$(1))
|
||||||
|
@echo "$(GREEN) ✓ $(PROJECT_NAME)-v$(VERSION)-$(1)$(NC)"
|
||||||
|
endef
|
||||||
|
|
||||||
|
package: build-all
|
||||||
|
@echo ""
|
||||||
|
@echo "$(BOLD)$(CYAN)📦 Создание пакетов...$(NC)"
|
||||||
|
@mkdir -p $(BINARIES_DIR)
|
||||||
|
|
||||||
|
@$(call create_release_package,macos-arm64,$(RELEASE_DIR)/$(PROJECT_NAME),$(PROJECT_NAME))
|
||||||
|
@$(call create_release_package,windows-x64,target/x86_64-pc-windows-gnu/release/$(PROJECT_NAME).exe,$(PROJECT_NAME).exe)
|
||||||
|
@$(call create_release_package,linux-gnu,target/x86_64-unknown-linux-gnu/release/$(PROJECT_NAME),$(PROJECT_NAME))
|
||||||
|
@$(call create_release_package,linux-musl,target/x86_64-unknown-linux-musl/release/$(PROJECT_NAME),$(PROJECT_NAME))
|
||||||
|
|
||||||
|
@echo ""
|
||||||
|
@echo "$(GREEN)🗜️ Создание общего архива...$(NC)"
|
||||||
|
@tar -czf $(PROJECT_NAME)-v$(VERSION)-all.tar.gz -C $(BINARIES_DIR) .
|
||||||
|
@echo "$(GREEN)✅ Архив: $(PROJECT_NAME)-v$(VERSION)-all.tar.gz$(NC)"
|
||||||
|
@echo ""
|
||||||
|
@echo "$(BOLD)$(GREEN)🎉 Релиз v$(VERSION) готов!$(NC)"
|
||||||
|
@echo "$(BOLD)📁 Папка: $(BINARIES_DIR)/$(NC)"
|
||||||
|
@ls -lh $(BINARIES_DIR)/ | grep "^d"
|
||||||
|
|
||||||
|
package-single: detect-platform
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# ОЧИСТКА
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo "$(RED)🧹 Очистка...$(NC)"
|
||||||
|
cargo clean
|
||||||
|
@rm -rf $(BINARIES_DIR)
|
||||||
|
@rm -f *.tar.gz
|
||||||
|
@rm -f $(TIMING_FILE)
|
||||||
|
@echo "$(GREEN)✅ Очищено$(NC)"
|
||||||
|
|
||||||
|
clean-timing:
|
||||||
|
@rm -f $(TIMING_FILE)
|
||||||
|
@echo "$(GREEN)✅ Файл с временами сборки удалён$(NC)"
|
||||||
15
out.txt
Normal file
15
out.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Compiling ae_anons v0.2.6 (/Users/Lexx/Code/RUST/AE_Anons)
|
||||||
|
warning: field `smb_domain` is never read
|
||||||
|
--> src/config.rs:18:9
|
||||||
|
|
|
||||||
|
12 | pub struct AccessConfig {
|
||||||
|
| ------------ field in this struct
|
||||||
|
...
|
||||||
|
18 | pub smb_domain: Option<String>,
|
||||||
|
| ^^^^^^^^^^
|
||||||
|
|
|
||||||
|
= note: `AccessConfig` has derived impls for the traits `Clone` and `Debug`, but these are intentionally ignored during dead code analysis
|
||||||
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
|
||||||
|
|
||||||
|
warning: `ae_anons` (bin "ae_anons") generated 1 warning
|
||||||
|
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.42s
|
||||||
22
releases/ae_anons-v0.2.6-linux-gnu/.env.example
Normal file
22
releases/ae_anons-v0.2.6-linux-gnu/.env.example
Normal 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"
|
||||||
19
releases/ae_anons-v0.2.6-linux-gnu/LICENSE
Normal file
19
releases/ae_anons-v0.2.6-linux-gnu/LICENSE
Normal 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.
|
||||||
680
releases/ae_anons-v0.2.6-linux-gnu/README.md
Normal file
680
releases/ae_anons-v0.2.6-linux-gnu/README.md
Normal 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 -->
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
|
## Новое в версии v0.2.6
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
||||||
|
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
||||||
|
After Effects (лицензия MIT).
|
||||||
|
|
||||||
|
### Режимы работы
|
||||||
|
|
||||||
|
1. **Однократная обработка** (`--once` или без флагов):
|
||||||
|
- Подключение к NAS Synology
|
||||||
|
- Парсинг Excel данных
|
||||||
|
- Генерация и отправка заданий Nexrender
|
||||||
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
|
2. **Веб-сервер** (`--web`):
|
||||||
|
- Запуск веб-интерфейса на порту `:3000`
|
||||||
|
- Управление заданиями через браузер
|
||||||
|
- Автообновление статуса каждую минуту
|
||||||
|
|
||||||
|
## Особенности
|
||||||
|
|
||||||
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
||||||
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
||||||
|
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
||||||
|
- **REST API**: Программный доступ к управлению заданиями
|
||||||
|
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
||||||
|
|
||||||
|
## Предварительные требования
|
||||||
|
|
||||||
|
- Rust 1.70 или выше
|
||||||
|
- Доступ к NAS Synology с пакетами File Station и Office
|
||||||
|
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
||||||
|
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
||||||
|
cd ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сборка проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка окружения
|
||||||
|
|
||||||
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
|
См. раздел Конфигурация для подробной информации.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Однократная обработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск с настройками по умолчанию
|
||||||
|
./target/release/ae_anons
|
||||||
|
|
||||||
|
# Явно указать однократный режим
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
# С отладочным логированием
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Веб-сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
||||||
|
./target/release/ae_anons --web
|
||||||
|
|
||||||
|
# Или с указанием другого порта через .env файл
|
||||||
|
# WEB_PORT=8080 ./target/release/ae_anons --web
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
После запуска веб-сервера откройте браузер:
|
||||||
|
|
||||||
|
- Главная страница: <http://localhost:3000>
|
||||||
|
|
||||||
|
- API эндпоинты:
|
||||||
|
- `GET /api/jobs` - список всех заданий
|
||||||
|
- `POST /api/generate` - запуск генерации
|
||||||
|
- `POST /api/cleanup` - очистка завершённых
|
||||||
|
- `POST /api/jobs/stop-all` - остановка активных
|
||||||
|
- `GET /api/status` - статус сервера
|
||||||
|
|
||||||
|
## Уровни логирования
|
||||||
|
|
||||||
|
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
||||||
|
|
||||||
|
- **error** - Только критические ошибки
|
||||||
|
- **warn** - Предупреждения и ошибки
|
||||||
|
- **info** - Общие операционные сообщения (по умолчанию)
|
||||||
|
- **debug** - Детальная информация о процессе обработки
|
||||||
|
- **trace** - Полное отладочное логирование с деталями API вызовов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|-------------------------|-------------|------------------------------------------------------|
|
||||||
|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
||||||
|
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
||||||
|
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
||||||
|
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
||||||
|
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
||||||
|
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
|
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
||||||
|
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
||||||
|
|
||||||
|
### Пример файла `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Synology NAS
|
||||||
|
NAS_FQDN="https://your-nas.example.com"
|
||||||
|
NAS_USER="your_username"
|
||||||
|
NAS_PASS="your_password"
|
||||||
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
|
# Nexrender
|
||||||
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
|
|
||||||
|
# After Effects Templates
|
||||||
|
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
||||||
|
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
||||||
|
TEMPLATE_COMPOSITION="main"
|
||||||
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура электронной таблицы
|
||||||
|
|
||||||
|
Файл Excel должен содержать следующие листы со специфичными структурами:
|
||||||
|
|
||||||
|
### Лист "SPORT"
|
||||||
|
|
||||||
|
Связывает названия видов спорта с соответствующими видео пакетами.
|
||||||
|
|
||||||
|
| SPORT | LINK |
|
||||||
|
|----------------|---------------------------------------------|
|
||||||
|
| Без оформления | \\server\share\path\to\null.mov |
|
||||||
|
| Футбол | \\server\share\path\to\football_pack.mov |
|
||||||
|
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
||||||
|
|
||||||
|
### Лист "TEAMS"
|
||||||
|
|
||||||
|
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
||||||
|
|
||||||
|
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
||||||
|
|
||||||
|
| TEAM | SPORT | LINK |
|
||||||
|
|-------------------|-----------|---------------------------------------------------|
|
||||||
|
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
||||||
|
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
||||||
|
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
||||||
|
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
||||||
|
|
||||||
|
### Лист "CHANELL"
|
||||||
|
|
||||||
|
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
||||||
|
|
||||||
|
| CHANELL | LINK |
|
||||||
|
|---------|-----------------------------------------|
|
||||||
|
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
||||||
|
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
||||||
|
|
||||||
|
### Лист "Start"
|
||||||
|
|
||||||
|
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
||||||
|
|
||||||
|
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
||||||
|
|
||||||
|
**Примечания:**
|
||||||
|
|
||||||
|
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
||||||
|
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
||||||
|
|
||||||
|
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
||||||
|
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
||||||
|
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
||||||
|
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
||||||
|
|
||||||
|
### Формат имен команд
|
||||||
|
|
||||||
|
Имена команд поддерживают два типа разделителей хэш #:
|
||||||
|
|
||||||
|
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
||||||
|
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
||||||
|
|
||||||
|
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
||||||
|
|
||||||
|
Формат: `DisplayName#UniqueID#TargetSize`
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Галатасарай#Turki` - только уникальный идентификатор
|
||||||
|
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
||||||
|
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
||||||
|
|
||||||
|
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
||||||
|
|
||||||
|
## Выходные файлы
|
||||||
|
|
||||||
|
### Рендеренные видео
|
||||||
|
|
||||||
|
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена файлов транслитерируются в латиницу.
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
||||||
|
|
||||||
|
## Рабочий процесс
|
||||||
|
|
||||||
|
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
||||||
|
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
||||||
|
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
||||||
|
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
||||||
|
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
||||||
|
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
||||||
|
7. **Отправка**: Отправка заданий на API Nexrender
|
||||||
|
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
||||||
|
9. **Завершение сеанса**: Закрытие сессии Synology
|
||||||
|
|
||||||
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
|
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
||||||
|
|
||||||
|
### Файлы шаблонов
|
||||||
|
|
||||||
|
Настраиваются через переменные окружения:
|
||||||
|
|
||||||
|
| Переменная | Назначение | Пример значения |
|
||||||
|
|-------------------------|-------------------------------------|------------------------------------|
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
||||||
|
|
||||||
|
### Обязательные слои в шаблонах
|
||||||
|
|
||||||
|
#### Текстовые слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
||||||
|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
||||||
|
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
||||||
|
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
||||||
|
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
||||||
|
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
||||||
|
| `SPORT` | ✅ | ✅ | Категория спорта |
|
||||||
|
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
||||||
|
|
||||||
|
#### Слои с изображениями
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|----------------|:-------------:|:-------------:|-----------------------------|
|
||||||
|
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
||||||
|
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
||||||
|
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
||||||
|
|
||||||
|
#### Видео слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|----------------------------|
|
||||||
|
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
||||||
|
|
||||||
|
### Особенности шаблонов
|
||||||
|
|
||||||
|
#### Шаблон DOUBLE (две команды)
|
||||||
|
|
||||||
|
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
||||||
|
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
||||||
|
|
||||||
|
#### Шаблон SINGLE (одна команда)
|
||||||
|
|
||||||
|
Используется когда заполнено только одно поле команды.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
||||||
|
- Только логотип `TEAM_A_LOGO`
|
||||||
|
|
||||||
|
### Автоматические корректировки
|
||||||
|
|
||||||
|
AE Anons автоматически применяет следующие настройки к слоям:
|
||||||
|
|
||||||
|
#### Слой DATA
|
||||||
|
|
||||||
|
В зависимости от отображаемого текста и типа шаблона:
|
||||||
|
|
||||||
|
| Текст | Шаблон | Font Size | Anchor Point |
|
||||||
|
|------------|----------|-----------|--------------|
|
||||||
|
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
||||||
|
| "завтра" | DOUBLE | 115 | [0, 25] |
|
||||||
|
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
||||||
|
|
||||||
|
#### Слои TIME_H, TIME_M, TIME
|
||||||
|
|
||||||
|
Корректировка Anchor Point в зависимости от длины текста:
|
||||||
|
|
||||||
|
| Условие | Anchor Point |
|
||||||
|
|-------------------|--------------|
|
||||||
|
| 1 символ | [60, 0] |
|
||||||
|
| 2 символа, <20 | [20, 0] |
|
||||||
|
|
||||||
|
#### Слой LEAGUE
|
||||||
|
|
||||||
|
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
||||||
|
|
||||||
|
#### Слой TEAMS
|
||||||
|
|
||||||
|
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
||||||
|
|
||||||
|
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
||||||
|
|
||||||
|
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (width > height) {
|
||||||
|
max_size = width;
|
||||||
|
} else {
|
||||||
|
max_size = height;
|
||||||
|
}
|
||||||
|
var real_size = 550 / max_size * 100;
|
||||||
|
[real_size, real_size]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример структуры слоёв в After Effects
|
||||||
|
|
||||||
|
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
||||||
|
|
||||||
|
```text
|
||||||
|
📁 main (композиция)
|
||||||
|
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
||||||
|
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
||||||
|
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
||||||
|
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
||||||
|
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
||||||
|
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
||||||
|
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
||||||
|
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
||||||
|
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
||||||
|
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
||||||
|
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
||||||
|
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат проекта и совместимость с After Effects
|
||||||
|
|
||||||
|
**Поддерживаемые форматы проектов:**
|
||||||
|
|
||||||
|
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
||||||
|
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
||||||
|
|
||||||
|
**Совместимость версий After Effects:**
|
||||||
|
|
||||||
|
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
||||||
|
|----------------------|------------------------|-------------------------------------------------|
|
||||||
|
| CS 5.5 | `.aep` | Базовая поддержка |
|
||||||
|
| CC / CC 2014 | `.aep` | Полная поддержка |
|
||||||
|
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
||||||
|
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
||||||
|
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
||||||
|
|
||||||
|
> **⚠️ Важно для After Effects 2023+:**
|
||||||
|
>
|
||||||
|
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
||||||
|
>
|
||||||
|
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
||||||
|
|
||||||
|
### Настройки композиции
|
||||||
|
|
||||||
|
| Параметр | Переменная окружения | Пример значения | Описание |
|
||||||
|
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
||||||
|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
||||||
|
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
||||||
|
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
||||||
|
|
||||||
|
**Рекомендации по выбору формата:**
|
||||||
|
|
||||||
|
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
||||||
|
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
||||||
|
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
||||||
|
|
||||||
|
**Источники:**
|
||||||
|
|
||||||
|
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
||||||
|
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
||||||
|
|
||||||
|
## Устранение неисправностей
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
#### Ошибка подключения к NAS
|
||||||
|
|
||||||
|
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
||||||
|
- Проверьте сетевую связность с NAS
|
||||||
|
- Убедитесь, что сервисы File Station и Office включены
|
||||||
|
|
||||||
|
#### Файл не найден
|
||||||
|
|
||||||
|
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
||||||
|
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
||||||
|
- Проверьте права доступа к файлу для учетной записи сервиса
|
||||||
|
|
||||||
|
#### Ошибка отправки задания Nexrender
|
||||||
|
|
||||||
|
- Подтвердите доступность сервера Nexrender
|
||||||
|
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
||||||
|
- Проверьте существование файлов шаблонов на узлах рендеринга
|
||||||
|
|
||||||
|
### Режим отладки
|
||||||
|
|
||||||
|
Включите подробное логирование для детального анализа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
Это выведет:
|
||||||
|
|
||||||
|
- Детали API запросов/ответов
|
||||||
|
- Информацию о парсинге листов
|
||||||
|
- Детали создания заданий
|
||||||
|
- Процесс разрешения ресурсов
|
||||||
|
|
||||||
|
## Возможные ограничения по производительности
|
||||||
|
|
||||||
|
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
||||||
|
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
||||||
|
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
||||||
|
|
||||||
|
## Примечания по безопасности
|
||||||
|
|
||||||
|
- Храните учетные данные только в файле `.env` (исключен из git)
|
||||||
|
- Используйте специальные аккаунты с минимально необходимыми правами
|
||||||
|
- Сессии Synology завершаются после выполнения
|
||||||
|
- HTTPS рекомендуется для подключений NAS в продакшене
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ae_anons/
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
|
├── Cargo.toml
|
||||||
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
|
├── assets/
|
||||||
|
│ └── logo.png
|
||||||
|
└── src/
|
||||||
|
├── main.rs # Точка входа, CLI
|
||||||
|
├── config.rs # Конфигурация из .env
|
||||||
|
├── nexrender.rs # Модели заданий Nexrender
|
||||||
|
├── synology.rs # Клиент Synology API
|
||||||
|
├── processor.rs # Логика обработки
|
||||||
|
├── web.rs # Веб-сервер и API
|
||||||
|
└── static/
|
||||||
|
├── index.html # Веб-интерфейс
|
||||||
|
└── style.css # Стили
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
### Основные зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|---------|-----------------------------------------|
|
||||||
|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
||||||
|
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
||||||
|
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
||||||
|
| chrono | 0.4 | Обработка дат и времени |
|
||||||
|
| tokio | 1.0 | Асинхронная среда выполнения |
|
||||||
|
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
||||||
|
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
||||||
|
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
||||||
|
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
||||||
|
| regex | 1.11 | Регулярные выражения |
|
||||||
|
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
||||||
|
| bytes | 1.9 | Работа с байтовыми данными |
|
||||||
|
| futures | 0.3 | Асинхронные примитивы |
|
||||||
|
|
||||||
|
### Веб-сервер и CLI
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|--------|-----------------------------------------|
|
||||||
|
| axum | 0.8 | Веб-фреймворк для REST API |
|
||||||
|
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
||||||
|
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
||||||
|
| askama | 0.15 | Шаблонизация (опционально) |
|
||||||
|
| clap | 4.5 | Парсинг аргументов командной строки |
|
||||||
|
|
||||||
|
### Платформозависимые зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Платформа | Назначение |
|
||||||
|
|--------------------|--------|-----------|----------------------------------|
|
||||||
|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
||||||
|
|
||||||
|
## 🙏 Благодарности
|
||||||
|
|
||||||
|
### Nexrender
|
||||||
|
|
||||||
|
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
||||||
|
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
||||||
|
для автоматизации After Effects.
|
||||||
|
|
||||||
|
### Font Awesome Free 6.4.0
|
||||||
|
|
||||||
|
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
||||||
|
|
||||||
|
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
|
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
||||||
|
|
||||||
|
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
**AE Anons** — [License](LICENSE)
|
||||||
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
|
|
||||||
|
**Разрешается:**
|
||||||
|
|
||||||
|
- ✅ Использовать в коммерческих целях
|
||||||
|
- ✅ Изменять исходный код
|
||||||
|
- ✅ Распространять копии
|
||||||
|
- ✅ Использовать приватно
|
||||||
|
|
||||||
|
**Требуется:**
|
||||||
|
|
||||||
|
- Сохранять копирайт и текст лицензии
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
Для вопросов и запросов функций обращайтесь:
|
||||||
|
|
||||||
|
- <a.barabanov@tvstart.ru>
|
||||||
|
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
### v0.2.6 (текущая)
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
### v0.2.5
|
||||||
|
|
||||||
|
- Оптимизация памяти: очистка неиспользуемых логотипов
|
||||||
|
- Групповая отправка заданий по 3 для сохранения порядка
|
||||||
|
- Добавлен UID в логи отправки
|
||||||
|
|
||||||
|
### v0.2.4
|
||||||
|
|
||||||
|
- Полностью автономный режим (встроенный Font Awesome)
|
||||||
|
- Добавлен favicon
|
||||||
|
- Все статические ресурсы встроены в бинарник
|
||||||
|
|
||||||
|
### v0.2.3
|
||||||
|
|
||||||
|
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
||||||
|
- Тёмная/светлая тема с автоопределением ОС
|
||||||
|
- Сортировка по всем колонкам в интерфейсе
|
||||||
|
- Фильтрация по имени файла/UID
|
||||||
|
- Остановка всех активных заданий
|
||||||
|
- Автообновление каждые 60 секунд
|
||||||
|
- Улучшенное debug-логирование
|
||||||
|
- Исправлена обработка UNC/SMB путей
|
||||||
|
|
||||||
|
### v0.2.2
|
||||||
|
|
||||||
|
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
||||||
|
- Улучшена цветовая схема
|
||||||
|
- Добавлена сортировка по всем колонкам
|
||||||
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
- Реализован REST API
|
||||||
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
- Автообновление статуса заданий
|
||||||
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
|
### v0.1.1
|
||||||
|
|
||||||
|
- Оптимизирована работа с памятью
|
||||||
|
- Убрана функция создания .json
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- Первый выпуск
|
||||||
|
- Интеграция с Synology Office
|
||||||
|
- Базовая генерация заданий Nexrender
|
||||||
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with 🦀 Rust and ☕ coffee
|
||||||
BIN
releases/ae_anons-v0.2.6-linux-gnu/ae_anons
Executable file
BIN
releases/ae_anons-v0.2.6-linux-gnu/ae_anons
Executable file
Binary file not shown.
22
releases/ae_anons-v0.2.6-linux-musl/.env.example
Normal file
22
releases/ae_anons-v0.2.6-linux-musl/.env.example
Normal 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"
|
||||||
19
releases/ae_anons-v0.2.6-linux-musl/LICENSE
Normal file
19
releases/ae_anons-v0.2.6-linux-musl/LICENSE
Normal 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.
|
||||||
680
releases/ae_anons-v0.2.6-linux-musl/README.md
Normal file
680
releases/ae_anons-v0.2.6-linux-musl/README.md
Normal 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 -->
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
|
## Новое в версии v0.2.6
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
||||||
|
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
||||||
|
After Effects (лицензия MIT).
|
||||||
|
|
||||||
|
### Режимы работы
|
||||||
|
|
||||||
|
1. **Однократная обработка** (`--once` или без флагов):
|
||||||
|
- Подключение к NAS Synology
|
||||||
|
- Парсинг Excel данных
|
||||||
|
- Генерация и отправка заданий Nexrender
|
||||||
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
|
2. **Веб-сервер** (`--web`):
|
||||||
|
- Запуск веб-интерфейса на порту `:3000`
|
||||||
|
- Управление заданиями через браузер
|
||||||
|
- Автообновление статуса каждую минуту
|
||||||
|
|
||||||
|
## Особенности
|
||||||
|
|
||||||
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
||||||
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
||||||
|
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
||||||
|
- **REST API**: Программный доступ к управлению заданиями
|
||||||
|
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
||||||
|
|
||||||
|
## Предварительные требования
|
||||||
|
|
||||||
|
- Rust 1.70 или выше
|
||||||
|
- Доступ к NAS Synology с пакетами File Station и Office
|
||||||
|
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
||||||
|
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
||||||
|
cd ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сборка проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка окружения
|
||||||
|
|
||||||
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
|
См. раздел Конфигурация для подробной информации.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Однократная обработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск с настройками по умолчанию
|
||||||
|
./target/release/ae_anons
|
||||||
|
|
||||||
|
# Явно указать однократный режим
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
# С отладочным логированием
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Веб-сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
||||||
|
./target/release/ae_anons --web
|
||||||
|
|
||||||
|
# Или с указанием другого порта через .env файл
|
||||||
|
# WEB_PORT=8080 ./target/release/ae_anons --web
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
После запуска веб-сервера откройте браузер:
|
||||||
|
|
||||||
|
- Главная страница: <http://localhost:3000>
|
||||||
|
|
||||||
|
- API эндпоинты:
|
||||||
|
- `GET /api/jobs` - список всех заданий
|
||||||
|
- `POST /api/generate` - запуск генерации
|
||||||
|
- `POST /api/cleanup` - очистка завершённых
|
||||||
|
- `POST /api/jobs/stop-all` - остановка активных
|
||||||
|
- `GET /api/status` - статус сервера
|
||||||
|
|
||||||
|
## Уровни логирования
|
||||||
|
|
||||||
|
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
||||||
|
|
||||||
|
- **error** - Только критические ошибки
|
||||||
|
- **warn** - Предупреждения и ошибки
|
||||||
|
- **info** - Общие операционные сообщения (по умолчанию)
|
||||||
|
- **debug** - Детальная информация о процессе обработки
|
||||||
|
- **trace** - Полное отладочное логирование с деталями API вызовов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|-------------------------|-------------|------------------------------------------------------|
|
||||||
|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
||||||
|
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
||||||
|
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
||||||
|
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
||||||
|
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
||||||
|
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
|
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
||||||
|
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
||||||
|
|
||||||
|
### Пример файла `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Synology NAS
|
||||||
|
NAS_FQDN="https://your-nas.example.com"
|
||||||
|
NAS_USER="your_username"
|
||||||
|
NAS_PASS="your_password"
|
||||||
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
|
# Nexrender
|
||||||
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
|
|
||||||
|
# After Effects Templates
|
||||||
|
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
||||||
|
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
||||||
|
TEMPLATE_COMPOSITION="main"
|
||||||
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура электронной таблицы
|
||||||
|
|
||||||
|
Файл Excel должен содержать следующие листы со специфичными структурами:
|
||||||
|
|
||||||
|
### Лист "SPORT"
|
||||||
|
|
||||||
|
Связывает названия видов спорта с соответствующими видео пакетами.
|
||||||
|
|
||||||
|
| SPORT | LINK |
|
||||||
|
|----------------|---------------------------------------------|
|
||||||
|
| Без оформления | \\server\share\path\to\null.mov |
|
||||||
|
| Футбол | \\server\share\path\to\football_pack.mov |
|
||||||
|
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
||||||
|
|
||||||
|
### Лист "TEAMS"
|
||||||
|
|
||||||
|
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
||||||
|
|
||||||
|
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
||||||
|
|
||||||
|
| TEAM | SPORT | LINK |
|
||||||
|
|-------------------|-----------|---------------------------------------------------|
|
||||||
|
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
||||||
|
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
||||||
|
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
||||||
|
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
||||||
|
|
||||||
|
### Лист "CHANELL"
|
||||||
|
|
||||||
|
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
||||||
|
|
||||||
|
| CHANELL | LINK |
|
||||||
|
|---------|-----------------------------------------|
|
||||||
|
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
||||||
|
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
||||||
|
|
||||||
|
### Лист "Start"
|
||||||
|
|
||||||
|
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
||||||
|
|
||||||
|
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
||||||
|
|
||||||
|
**Примечания:**
|
||||||
|
|
||||||
|
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
||||||
|
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
||||||
|
|
||||||
|
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
||||||
|
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
||||||
|
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
||||||
|
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
||||||
|
|
||||||
|
### Формат имен команд
|
||||||
|
|
||||||
|
Имена команд поддерживают два типа разделителей хэш #:
|
||||||
|
|
||||||
|
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
||||||
|
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
||||||
|
|
||||||
|
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
||||||
|
|
||||||
|
Формат: `DisplayName#UniqueID#TargetSize`
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Галатасарай#Turki` - только уникальный идентификатор
|
||||||
|
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
||||||
|
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
||||||
|
|
||||||
|
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
||||||
|
|
||||||
|
## Выходные файлы
|
||||||
|
|
||||||
|
### Рендеренные видео
|
||||||
|
|
||||||
|
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена файлов транслитерируются в латиницу.
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
||||||
|
|
||||||
|
## Рабочий процесс
|
||||||
|
|
||||||
|
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
||||||
|
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
||||||
|
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
||||||
|
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
||||||
|
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
||||||
|
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
||||||
|
7. **Отправка**: Отправка заданий на API Nexrender
|
||||||
|
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
||||||
|
9. **Завершение сеанса**: Закрытие сессии Synology
|
||||||
|
|
||||||
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
|
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
||||||
|
|
||||||
|
### Файлы шаблонов
|
||||||
|
|
||||||
|
Настраиваются через переменные окружения:
|
||||||
|
|
||||||
|
| Переменная | Назначение | Пример значения |
|
||||||
|
|-------------------------|-------------------------------------|------------------------------------|
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
||||||
|
|
||||||
|
### Обязательные слои в шаблонах
|
||||||
|
|
||||||
|
#### Текстовые слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
||||||
|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
||||||
|
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
||||||
|
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
||||||
|
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
||||||
|
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
||||||
|
| `SPORT` | ✅ | ✅ | Категория спорта |
|
||||||
|
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
||||||
|
|
||||||
|
#### Слои с изображениями
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|----------------|:-------------:|:-------------:|-----------------------------|
|
||||||
|
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
||||||
|
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
||||||
|
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
||||||
|
|
||||||
|
#### Видео слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|----------------------------|
|
||||||
|
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
||||||
|
|
||||||
|
### Особенности шаблонов
|
||||||
|
|
||||||
|
#### Шаблон DOUBLE (две команды)
|
||||||
|
|
||||||
|
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
||||||
|
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
||||||
|
|
||||||
|
#### Шаблон SINGLE (одна команда)
|
||||||
|
|
||||||
|
Используется когда заполнено только одно поле команды.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
||||||
|
- Только логотип `TEAM_A_LOGO`
|
||||||
|
|
||||||
|
### Автоматические корректировки
|
||||||
|
|
||||||
|
AE Anons автоматически применяет следующие настройки к слоям:
|
||||||
|
|
||||||
|
#### Слой DATA
|
||||||
|
|
||||||
|
В зависимости от отображаемого текста и типа шаблона:
|
||||||
|
|
||||||
|
| Текст | Шаблон | Font Size | Anchor Point |
|
||||||
|
|------------|----------|-----------|--------------|
|
||||||
|
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
||||||
|
| "завтра" | DOUBLE | 115 | [0, 25] |
|
||||||
|
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
||||||
|
|
||||||
|
#### Слои TIME_H, TIME_M, TIME
|
||||||
|
|
||||||
|
Корректировка Anchor Point в зависимости от длины текста:
|
||||||
|
|
||||||
|
| Условие | Anchor Point |
|
||||||
|
|-------------------|--------------|
|
||||||
|
| 1 символ | [60, 0] |
|
||||||
|
| 2 символа, <20 | [20, 0] |
|
||||||
|
|
||||||
|
#### Слой LEAGUE
|
||||||
|
|
||||||
|
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
||||||
|
|
||||||
|
#### Слой TEAMS
|
||||||
|
|
||||||
|
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
||||||
|
|
||||||
|
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
||||||
|
|
||||||
|
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (width > height) {
|
||||||
|
max_size = width;
|
||||||
|
} else {
|
||||||
|
max_size = height;
|
||||||
|
}
|
||||||
|
var real_size = 550 / max_size * 100;
|
||||||
|
[real_size, real_size]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример структуры слоёв в After Effects
|
||||||
|
|
||||||
|
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
||||||
|
|
||||||
|
```text
|
||||||
|
📁 main (композиция)
|
||||||
|
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
||||||
|
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
||||||
|
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
||||||
|
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
||||||
|
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
||||||
|
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
||||||
|
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
||||||
|
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
||||||
|
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
||||||
|
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
||||||
|
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
||||||
|
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат проекта и совместимость с After Effects
|
||||||
|
|
||||||
|
**Поддерживаемые форматы проектов:**
|
||||||
|
|
||||||
|
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
||||||
|
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
||||||
|
|
||||||
|
**Совместимость версий After Effects:**
|
||||||
|
|
||||||
|
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
||||||
|
|----------------------|------------------------|-------------------------------------------------|
|
||||||
|
| CS 5.5 | `.aep` | Базовая поддержка |
|
||||||
|
| CC / CC 2014 | `.aep` | Полная поддержка |
|
||||||
|
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
||||||
|
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
||||||
|
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
||||||
|
|
||||||
|
> **⚠️ Важно для After Effects 2023+:**
|
||||||
|
>
|
||||||
|
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
||||||
|
>
|
||||||
|
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
||||||
|
|
||||||
|
### Настройки композиции
|
||||||
|
|
||||||
|
| Параметр | Переменная окружения | Пример значения | Описание |
|
||||||
|
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
||||||
|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
||||||
|
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
||||||
|
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
||||||
|
|
||||||
|
**Рекомендации по выбору формата:**
|
||||||
|
|
||||||
|
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
||||||
|
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
||||||
|
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
||||||
|
|
||||||
|
**Источники:**
|
||||||
|
|
||||||
|
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
||||||
|
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
||||||
|
|
||||||
|
## Устранение неисправностей
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
#### Ошибка подключения к NAS
|
||||||
|
|
||||||
|
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
||||||
|
- Проверьте сетевую связность с NAS
|
||||||
|
- Убедитесь, что сервисы File Station и Office включены
|
||||||
|
|
||||||
|
#### Файл не найден
|
||||||
|
|
||||||
|
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
||||||
|
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
||||||
|
- Проверьте права доступа к файлу для учетной записи сервиса
|
||||||
|
|
||||||
|
#### Ошибка отправки задания Nexrender
|
||||||
|
|
||||||
|
- Подтвердите доступность сервера Nexrender
|
||||||
|
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
||||||
|
- Проверьте существование файлов шаблонов на узлах рендеринга
|
||||||
|
|
||||||
|
### Режим отладки
|
||||||
|
|
||||||
|
Включите подробное логирование для детального анализа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
Это выведет:
|
||||||
|
|
||||||
|
- Детали API запросов/ответов
|
||||||
|
- Информацию о парсинге листов
|
||||||
|
- Детали создания заданий
|
||||||
|
- Процесс разрешения ресурсов
|
||||||
|
|
||||||
|
## Возможные ограничения по производительности
|
||||||
|
|
||||||
|
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
||||||
|
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
||||||
|
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
||||||
|
|
||||||
|
## Примечания по безопасности
|
||||||
|
|
||||||
|
- Храните учетные данные только в файле `.env` (исключен из git)
|
||||||
|
- Используйте специальные аккаунты с минимально необходимыми правами
|
||||||
|
- Сессии Synology завершаются после выполнения
|
||||||
|
- HTTPS рекомендуется для подключений NAS в продакшене
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ae_anons/
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
|
├── Cargo.toml
|
||||||
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
|
├── assets/
|
||||||
|
│ └── logo.png
|
||||||
|
└── src/
|
||||||
|
├── main.rs # Точка входа, CLI
|
||||||
|
├── config.rs # Конфигурация из .env
|
||||||
|
├── nexrender.rs # Модели заданий Nexrender
|
||||||
|
├── synology.rs # Клиент Synology API
|
||||||
|
├── processor.rs # Логика обработки
|
||||||
|
├── web.rs # Веб-сервер и API
|
||||||
|
└── static/
|
||||||
|
├── index.html # Веб-интерфейс
|
||||||
|
└── style.css # Стили
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
### Основные зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|---------|-----------------------------------------|
|
||||||
|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
||||||
|
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
||||||
|
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
||||||
|
| chrono | 0.4 | Обработка дат и времени |
|
||||||
|
| tokio | 1.0 | Асинхронная среда выполнения |
|
||||||
|
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
||||||
|
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
||||||
|
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
||||||
|
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
||||||
|
| regex | 1.11 | Регулярные выражения |
|
||||||
|
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
||||||
|
| bytes | 1.9 | Работа с байтовыми данными |
|
||||||
|
| futures | 0.3 | Асинхронные примитивы |
|
||||||
|
|
||||||
|
### Веб-сервер и CLI
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|--------|-----------------------------------------|
|
||||||
|
| axum | 0.8 | Веб-фреймворк для REST API |
|
||||||
|
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
||||||
|
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
||||||
|
| askama | 0.15 | Шаблонизация (опционально) |
|
||||||
|
| clap | 4.5 | Парсинг аргументов командной строки |
|
||||||
|
|
||||||
|
### Платформозависимые зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Платформа | Назначение |
|
||||||
|
|--------------------|--------|-----------|----------------------------------|
|
||||||
|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
||||||
|
|
||||||
|
## 🙏 Благодарности
|
||||||
|
|
||||||
|
### Nexrender
|
||||||
|
|
||||||
|
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
||||||
|
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
||||||
|
для автоматизации After Effects.
|
||||||
|
|
||||||
|
### Font Awesome Free 6.4.0
|
||||||
|
|
||||||
|
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
||||||
|
|
||||||
|
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
|
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
||||||
|
|
||||||
|
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
**AE Anons** — [License](LICENSE)
|
||||||
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
|
|
||||||
|
**Разрешается:**
|
||||||
|
|
||||||
|
- ✅ Использовать в коммерческих целях
|
||||||
|
- ✅ Изменять исходный код
|
||||||
|
- ✅ Распространять копии
|
||||||
|
- ✅ Использовать приватно
|
||||||
|
|
||||||
|
**Требуется:**
|
||||||
|
|
||||||
|
- Сохранять копирайт и текст лицензии
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
Для вопросов и запросов функций обращайтесь:
|
||||||
|
|
||||||
|
- <a.barabanov@tvstart.ru>
|
||||||
|
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
### v0.2.6 (текущая)
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
### v0.2.5
|
||||||
|
|
||||||
|
- Оптимизация памяти: очистка неиспользуемых логотипов
|
||||||
|
- Групповая отправка заданий по 3 для сохранения порядка
|
||||||
|
- Добавлен UID в логи отправки
|
||||||
|
|
||||||
|
### v0.2.4
|
||||||
|
|
||||||
|
- Полностью автономный режим (встроенный Font Awesome)
|
||||||
|
- Добавлен favicon
|
||||||
|
- Все статические ресурсы встроены в бинарник
|
||||||
|
|
||||||
|
### v0.2.3
|
||||||
|
|
||||||
|
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
||||||
|
- Тёмная/светлая тема с автоопределением ОС
|
||||||
|
- Сортировка по всем колонкам в интерфейсе
|
||||||
|
- Фильтрация по имени файла/UID
|
||||||
|
- Остановка всех активных заданий
|
||||||
|
- Автообновление каждые 60 секунд
|
||||||
|
- Улучшенное debug-логирование
|
||||||
|
- Исправлена обработка UNC/SMB путей
|
||||||
|
|
||||||
|
### v0.2.2
|
||||||
|
|
||||||
|
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
||||||
|
- Улучшена цветовая схема
|
||||||
|
- Добавлена сортировка по всем колонкам
|
||||||
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
- Реализован REST API
|
||||||
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
- Автообновление статуса заданий
|
||||||
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
|
### v0.1.1
|
||||||
|
|
||||||
|
- Оптимизирована работа с памятью
|
||||||
|
- Убрана функция создания .json
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- Первый выпуск
|
||||||
|
- Интеграция с Synology Office
|
||||||
|
- Базовая генерация заданий Nexrender
|
||||||
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with 🦀 Rust and ☕ coffee
|
||||||
BIN
releases/ae_anons-v0.2.6-linux-musl/ae_anons
Executable file
BIN
releases/ae_anons-v0.2.6-linux-musl/ae_anons
Executable file
Binary file not shown.
22
releases/ae_anons-v0.2.6-macos-arm64/.env.example
Normal file
22
releases/ae_anons-v0.2.6-macos-arm64/.env.example
Normal 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"
|
||||||
19
releases/ae_anons-v0.2.6-macos-arm64/LICENSE
Normal file
19
releases/ae_anons-v0.2.6-macos-arm64/LICENSE
Normal 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.
|
||||||
680
releases/ae_anons-v0.2.6-macos-arm64/README.md
Normal file
680
releases/ae_anons-v0.2.6-macos-arm64/README.md
Normal 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 -->
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
|
## Новое в версии v0.2.6
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
||||||
|
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
||||||
|
After Effects (лицензия MIT).
|
||||||
|
|
||||||
|
### Режимы работы
|
||||||
|
|
||||||
|
1. **Однократная обработка** (`--once` или без флагов):
|
||||||
|
- Подключение к NAS Synology
|
||||||
|
- Парсинг Excel данных
|
||||||
|
- Генерация и отправка заданий Nexrender
|
||||||
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
|
2. **Веб-сервер** (`--web`):
|
||||||
|
- Запуск веб-интерфейса на порту `:3000`
|
||||||
|
- Управление заданиями через браузер
|
||||||
|
- Автообновление статуса каждую минуту
|
||||||
|
|
||||||
|
## Особенности
|
||||||
|
|
||||||
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
||||||
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
||||||
|
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
||||||
|
- **REST API**: Программный доступ к управлению заданиями
|
||||||
|
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
||||||
|
|
||||||
|
## Предварительные требования
|
||||||
|
|
||||||
|
- Rust 1.70 или выше
|
||||||
|
- Доступ к NAS Synology с пакетами File Station и Office
|
||||||
|
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
||||||
|
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
||||||
|
cd ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сборка проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка окружения
|
||||||
|
|
||||||
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
|
См. раздел Конфигурация для подробной информации.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Однократная обработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск с настройками по умолчанию
|
||||||
|
./target/release/ae_anons
|
||||||
|
|
||||||
|
# Явно указать однократный режим
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
# С отладочным логированием
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Веб-сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
||||||
|
./target/release/ae_anons --web
|
||||||
|
|
||||||
|
# Или с указанием другого порта через .env файл
|
||||||
|
# WEB_PORT=8080 ./target/release/ae_anons --web
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
После запуска веб-сервера откройте браузер:
|
||||||
|
|
||||||
|
- Главная страница: <http://localhost:3000>
|
||||||
|
|
||||||
|
- API эндпоинты:
|
||||||
|
- `GET /api/jobs` - список всех заданий
|
||||||
|
- `POST /api/generate` - запуск генерации
|
||||||
|
- `POST /api/cleanup` - очистка завершённых
|
||||||
|
- `POST /api/jobs/stop-all` - остановка активных
|
||||||
|
- `GET /api/status` - статус сервера
|
||||||
|
|
||||||
|
## Уровни логирования
|
||||||
|
|
||||||
|
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
||||||
|
|
||||||
|
- **error** - Только критические ошибки
|
||||||
|
- **warn** - Предупреждения и ошибки
|
||||||
|
- **info** - Общие операционные сообщения (по умолчанию)
|
||||||
|
- **debug** - Детальная информация о процессе обработки
|
||||||
|
- **trace** - Полное отладочное логирование с деталями API вызовов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|-------------------------|-------------|------------------------------------------------------|
|
||||||
|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
||||||
|
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
||||||
|
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
||||||
|
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
||||||
|
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
||||||
|
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
|
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
||||||
|
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
||||||
|
|
||||||
|
### Пример файла `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Synology NAS
|
||||||
|
NAS_FQDN="https://your-nas.example.com"
|
||||||
|
NAS_USER="your_username"
|
||||||
|
NAS_PASS="your_password"
|
||||||
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
|
# Nexrender
|
||||||
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
|
|
||||||
|
# After Effects Templates
|
||||||
|
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
||||||
|
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
||||||
|
TEMPLATE_COMPOSITION="main"
|
||||||
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура электронной таблицы
|
||||||
|
|
||||||
|
Файл Excel должен содержать следующие листы со специфичными структурами:
|
||||||
|
|
||||||
|
### Лист "SPORT"
|
||||||
|
|
||||||
|
Связывает названия видов спорта с соответствующими видео пакетами.
|
||||||
|
|
||||||
|
| SPORT | LINK |
|
||||||
|
|----------------|---------------------------------------------|
|
||||||
|
| Без оформления | \\server\share\path\to\null.mov |
|
||||||
|
| Футбол | \\server\share\path\to\football_pack.mov |
|
||||||
|
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
||||||
|
|
||||||
|
### Лист "TEAMS"
|
||||||
|
|
||||||
|
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
||||||
|
|
||||||
|
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
||||||
|
|
||||||
|
| TEAM | SPORT | LINK |
|
||||||
|
|-------------------|-----------|---------------------------------------------------|
|
||||||
|
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
||||||
|
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
||||||
|
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
||||||
|
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
||||||
|
|
||||||
|
### Лист "CHANELL"
|
||||||
|
|
||||||
|
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
||||||
|
|
||||||
|
| CHANELL | LINK |
|
||||||
|
|---------|-----------------------------------------|
|
||||||
|
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
||||||
|
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
||||||
|
|
||||||
|
### Лист "Start"
|
||||||
|
|
||||||
|
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
||||||
|
|
||||||
|
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
||||||
|
|
||||||
|
**Примечания:**
|
||||||
|
|
||||||
|
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
||||||
|
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
||||||
|
|
||||||
|
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
||||||
|
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
||||||
|
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
||||||
|
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
||||||
|
|
||||||
|
### Формат имен команд
|
||||||
|
|
||||||
|
Имена команд поддерживают два типа разделителей хэш #:
|
||||||
|
|
||||||
|
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
||||||
|
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
||||||
|
|
||||||
|
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
||||||
|
|
||||||
|
Формат: `DisplayName#UniqueID#TargetSize`
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Галатасарай#Turki` - только уникальный идентификатор
|
||||||
|
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
||||||
|
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
||||||
|
|
||||||
|
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
||||||
|
|
||||||
|
## Выходные файлы
|
||||||
|
|
||||||
|
### Рендеренные видео
|
||||||
|
|
||||||
|
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена файлов транслитерируются в латиницу.
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
||||||
|
|
||||||
|
## Рабочий процесс
|
||||||
|
|
||||||
|
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
||||||
|
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
||||||
|
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
||||||
|
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
||||||
|
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
||||||
|
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
||||||
|
7. **Отправка**: Отправка заданий на API Nexrender
|
||||||
|
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
||||||
|
9. **Завершение сеанса**: Закрытие сессии Synology
|
||||||
|
|
||||||
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
|
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
||||||
|
|
||||||
|
### Файлы шаблонов
|
||||||
|
|
||||||
|
Настраиваются через переменные окружения:
|
||||||
|
|
||||||
|
| Переменная | Назначение | Пример значения |
|
||||||
|
|-------------------------|-------------------------------------|------------------------------------|
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
||||||
|
|
||||||
|
### Обязательные слои в шаблонах
|
||||||
|
|
||||||
|
#### Текстовые слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
||||||
|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
||||||
|
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
||||||
|
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
||||||
|
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
||||||
|
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
||||||
|
| `SPORT` | ✅ | ✅ | Категория спорта |
|
||||||
|
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
||||||
|
|
||||||
|
#### Слои с изображениями
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|----------------|:-------------:|:-------------:|-----------------------------|
|
||||||
|
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
||||||
|
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
||||||
|
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
||||||
|
|
||||||
|
#### Видео слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|----------------------------|
|
||||||
|
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
||||||
|
|
||||||
|
### Особенности шаблонов
|
||||||
|
|
||||||
|
#### Шаблон DOUBLE (две команды)
|
||||||
|
|
||||||
|
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
||||||
|
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
||||||
|
|
||||||
|
#### Шаблон SINGLE (одна команда)
|
||||||
|
|
||||||
|
Используется когда заполнено только одно поле команды.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
||||||
|
- Только логотип `TEAM_A_LOGO`
|
||||||
|
|
||||||
|
### Автоматические корректировки
|
||||||
|
|
||||||
|
AE Anons автоматически применяет следующие настройки к слоям:
|
||||||
|
|
||||||
|
#### Слой DATA
|
||||||
|
|
||||||
|
В зависимости от отображаемого текста и типа шаблона:
|
||||||
|
|
||||||
|
| Текст | Шаблон | Font Size | Anchor Point |
|
||||||
|
|------------|----------|-----------|--------------|
|
||||||
|
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
||||||
|
| "завтра" | DOUBLE | 115 | [0, 25] |
|
||||||
|
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
||||||
|
|
||||||
|
#### Слои TIME_H, TIME_M, TIME
|
||||||
|
|
||||||
|
Корректировка Anchor Point в зависимости от длины текста:
|
||||||
|
|
||||||
|
| Условие | Anchor Point |
|
||||||
|
|-------------------|--------------|
|
||||||
|
| 1 символ | [60, 0] |
|
||||||
|
| 2 символа, <20 | [20, 0] |
|
||||||
|
|
||||||
|
#### Слой LEAGUE
|
||||||
|
|
||||||
|
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
||||||
|
|
||||||
|
#### Слой TEAMS
|
||||||
|
|
||||||
|
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
||||||
|
|
||||||
|
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
||||||
|
|
||||||
|
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (width > height) {
|
||||||
|
max_size = width;
|
||||||
|
} else {
|
||||||
|
max_size = height;
|
||||||
|
}
|
||||||
|
var real_size = 550 / max_size * 100;
|
||||||
|
[real_size, real_size]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример структуры слоёв в After Effects
|
||||||
|
|
||||||
|
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
||||||
|
|
||||||
|
```text
|
||||||
|
📁 main (композиция)
|
||||||
|
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
||||||
|
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
||||||
|
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
||||||
|
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
||||||
|
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
||||||
|
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
||||||
|
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
||||||
|
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
||||||
|
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
||||||
|
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
||||||
|
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
||||||
|
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат проекта и совместимость с After Effects
|
||||||
|
|
||||||
|
**Поддерживаемые форматы проектов:**
|
||||||
|
|
||||||
|
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
||||||
|
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
||||||
|
|
||||||
|
**Совместимость версий After Effects:**
|
||||||
|
|
||||||
|
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
||||||
|
|----------------------|------------------------|-------------------------------------------------|
|
||||||
|
| CS 5.5 | `.aep` | Базовая поддержка |
|
||||||
|
| CC / CC 2014 | `.aep` | Полная поддержка |
|
||||||
|
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
||||||
|
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
||||||
|
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
||||||
|
|
||||||
|
> **⚠️ Важно для After Effects 2023+:**
|
||||||
|
>
|
||||||
|
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
||||||
|
>
|
||||||
|
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
||||||
|
|
||||||
|
### Настройки композиции
|
||||||
|
|
||||||
|
| Параметр | Переменная окружения | Пример значения | Описание |
|
||||||
|
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
||||||
|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
||||||
|
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
||||||
|
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
||||||
|
|
||||||
|
**Рекомендации по выбору формата:**
|
||||||
|
|
||||||
|
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
||||||
|
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
||||||
|
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
||||||
|
|
||||||
|
**Источники:**
|
||||||
|
|
||||||
|
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
||||||
|
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
||||||
|
|
||||||
|
## Устранение неисправностей
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
#### Ошибка подключения к NAS
|
||||||
|
|
||||||
|
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
||||||
|
- Проверьте сетевую связность с NAS
|
||||||
|
- Убедитесь, что сервисы File Station и Office включены
|
||||||
|
|
||||||
|
#### Файл не найден
|
||||||
|
|
||||||
|
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
||||||
|
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
||||||
|
- Проверьте права доступа к файлу для учетной записи сервиса
|
||||||
|
|
||||||
|
#### Ошибка отправки задания Nexrender
|
||||||
|
|
||||||
|
- Подтвердите доступность сервера Nexrender
|
||||||
|
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
||||||
|
- Проверьте существование файлов шаблонов на узлах рендеринга
|
||||||
|
|
||||||
|
### Режим отладки
|
||||||
|
|
||||||
|
Включите подробное логирование для детального анализа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
Это выведет:
|
||||||
|
|
||||||
|
- Детали API запросов/ответов
|
||||||
|
- Информацию о парсинге листов
|
||||||
|
- Детали создания заданий
|
||||||
|
- Процесс разрешения ресурсов
|
||||||
|
|
||||||
|
## Возможные ограничения по производительности
|
||||||
|
|
||||||
|
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
||||||
|
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
||||||
|
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
||||||
|
|
||||||
|
## Примечания по безопасности
|
||||||
|
|
||||||
|
- Храните учетные данные только в файле `.env` (исключен из git)
|
||||||
|
- Используйте специальные аккаунты с минимально необходимыми правами
|
||||||
|
- Сессии Synology завершаются после выполнения
|
||||||
|
- HTTPS рекомендуется для подключений NAS в продакшене
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ae_anons/
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
|
├── Cargo.toml
|
||||||
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
|
├── assets/
|
||||||
|
│ └── logo.png
|
||||||
|
└── src/
|
||||||
|
├── main.rs # Точка входа, CLI
|
||||||
|
├── config.rs # Конфигурация из .env
|
||||||
|
├── nexrender.rs # Модели заданий Nexrender
|
||||||
|
├── synology.rs # Клиент Synology API
|
||||||
|
├── processor.rs # Логика обработки
|
||||||
|
├── web.rs # Веб-сервер и API
|
||||||
|
└── static/
|
||||||
|
├── index.html # Веб-интерфейс
|
||||||
|
└── style.css # Стили
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
### Основные зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|---------|-----------------------------------------|
|
||||||
|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
||||||
|
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
||||||
|
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
||||||
|
| chrono | 0.4 | Обработка дат и времени |
|
||||||
|
| tokio | 1.0 | Асинхронная среда выполнения |
|
||||||
|
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
||||||
|
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
||||||
|
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
||||||
|
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
||||||
|
| regex | 1.11 | Регулярные выражения |
|
||||||
|
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
||||||
|
| bytes | 1.9 | Работа с байтовыми данными |
|
||||||
|
| futures | 0.3 | Асинхронные примитивы |
|
||||||
|
|
||||||
|
### Веб-сервер и CLI
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|--------|-----------------------------------------|
|
||||||
|
| axum | 0.8 | Веб-фреймворк для REST API |
|
||||||
|
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
||||||
|
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
||||||
|
| askama | 0.15 | Шаблонизация (опционально) |
|
||||||
|
| clap | 4.5 | Парсинг аргументов командной строки |
|
||||||
|
|
||||||
|
### Платформозависимые зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Платформа | Назначение |
|
||||||
|
|--------------------|--------|-----------|----------------------------------|
|
||||||
|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
||||||
|
|
||||||
|
## 🙏 Благодарности
|
||||||
|
|
||||||
|
### Nexrender
|
||||||
|
|
||||||
|
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
||||||
|
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
||||||
|
для автоматизации After Effects.
|
||||||
|
|
||||||
|
### Font Awesome Free 6.4.0
|
||||||
|
|
||||||
|
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
||||||
|
|
||||||
|
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
|
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
||||||
|
|
||||||
|
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
**AE Anons** — [License](LICENSE)
|
||||||
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
|
|
||||||
|
**Разрешается:**
|
||||||
|
|
||||||
|
- ✅ Использовать в коммерческих целях
|
||||||
|
- ✅ Изменять исходный код
|
||||||
|
- ✅ Распространять копии
|
||||||
|
- ✅ Использовать приватно
|
||||||
|
|
||||||
|
**Требуется:**
|
||||||
|
|
||||||
|
- Сохранять копирайт и текст лицензии
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
Для вопросов и запросов функций обращайтесь:
|
||||||
|
|
||||||
|
- <a.barabanov@tvstart.ru>
|
||||||
|
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
### v0.2.6 (текущая)
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
### v0.2.5
|
||||||
|
|
||||||
|
- Оптимизация памяти: очистка неиспользуемых логотипов
|
||||||
|
- Групповая отправка заданий по 3 для сохранения порядка
|
||||||
|
- Добавлен UID в логи отправки
|
||||||
|
|
||||||
|
### v0.2.4
|
||||||
|
|
||||||
|
- Полностью автономный режим (встроенный Font Awesome)
|
||||||
|
- Добавлен favicon
|
||||||
|
- Все статические ресурсы встроены в бинарник
|
||||||
|
|
||||||
|
### v0.2.3
|
||||||
|
|
||||||
|
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
||||||
|
- Тёмная/светлая тема с автоопределением ОС
|
||||||
|
- Сортировка по всем колонкам в интерфейсе
|
||||||
|
- Фильтрация по имени файла/UID
|
||||||
|
- Остановка всех активных заданий
|
||||||
|
- Автообновление каждые 60 секунд
|
||||||
|
- Улучшенное debug-логирование
|
||||||
|
- Исправлена обработка UNC/SMB путей
|
||||||
|
|
||||||
|
### v0.2.2
|
||||||
|
|
||||||
|
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
||||||
|
- Улучшена цветовая схема
|
||||||
|
- Добавлена сортировка по всем колонкам
|
||||||
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
- Реализован REST API
|
||||||
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
- Автообновление статуса заданий
|
||||||
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
|
### v0.1.1
|
||||||
|
|
||||||
|
- Оптимизирована работа с памятью
|
||||||
|
- Убрана функция создания .json
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- Первый выпуск
|
||||||
|
- Интеграция с Synology Office
|
||||||
|
- Базовая генерация заданий Nexrender
|
||||||
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with 🦀 Rust and ☕ coffee
|
||||||
BIN
releases/ae_anons-v0.2.6-macos-arm64/ae_anons
Executable file
BIN
releases/ae_anons-v0.2.6-macos-arm64/ae_anons
Executable file
Binary file not shown.
22
releases/ae_anons-v0.2.6-windows-x64/.env.example
Normal file
22
releases/ae_anons-v0.2.6-windows-x64/.env.example
Normal 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"
|
||||||
19
releases/ae_anons-v0.2.6-windows-x64/LICENSE
Normal file
19
releases/ae_anons-v0.2.6-windows-x64/LICENSE
Normal 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.
|
||||||
680
releases/ae_anons-v0.2.6-windows-x64/README.md
Normal file
680
releases/ae_anons-v0.2.6-windows-x64/README.md
Normal 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 -->
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://www.rust-lang.org/)
|
||||||
|
[](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
|
||||||
|
|
||||||
|
## Новое в версии v0.2.6
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
AE Anons — это CLI-утилита и веб-сервер на Rust, которая выступает как **интеллектуальный генератор заданий**
|
||||||
|
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
|
||||||
|
After Effects (лицензия MIT).
|
||||||
|
|
||||||
|
### Режимы работы
|
||||||
|
|
||||||
|
1. **Однократная обработка** (`--once` или без флагов):
|
||||||
|
- Подключение к NAS Synology
|
||||||
|
- Парсинг Excel данных
|
||||||
|
- Генерация и отправка заданий Nexrender
|
||||||
|
- Мониторинг завершения и выход
|
||||||
|
|
||||||
|
2. **Веб-сервер** (`--web`):
|
||||||
|
- Запуск веб-интерфейса на порту `:3000`
|
||||||
|
- Управление заданиями через браузер
|
||||||
|
- Автообновление статуса каждую минуту
|
||||||
|
|
||||||
|
## Особенности
|
||||||
|
|
||||||
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий
|
||||||
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов
|
||||||
|
- **Веб-интерфейс**: Удобное управление и мониторинг заданий
|
||||||
|
- **REST API**: Программный доступ к управлению заданиями
|
||||||
|
- **Профессиональное логирование**: Структурированный журнал с настройкой уровня
|
||||||
|
|
||||||
|
## Предварительные требования
|
||||||
|
|
||||||
|
- Rust 1.70 или выше
|
||||||
|
- Доступ к NAS Synology с пакетами File Station и Office
|
||||||
|
- Экземпляр _server_ и _worker_(не менее одного) Nexrender
|
||||||
|
- Шаблоны After Effects (формат Adobe After Effects 2024 .aepx)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### 1. Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.tvstart.ru/lexx/AE_Anons.git
|
||||||
|
cd ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Сборка проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Настройка окружения
|
||||||
|
|
||||||
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
|
См. раздел Конфигурация для подробной информации.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Однократная обработка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск с настройками по умолчанию
|
||||||
|
./target/release/ae_anons
|
||||||
|
|
||||||
|
# Явно указать однократный режим
|
||||||
|
./target/release/ae_anons --once
|
||||||
|
|
||||||
|
# С отладочным логированием
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Веб-сервер
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск веб-интерфейса на порту по умолчанию (3000)
|
||||||
|
./target/release/ae_anons --web
|
||||||
|
|
||||||
|
# Или с указанием другого порта через .env файл
|
||||||
|
# WEB_PORT=8080 ./target/release/ae_anons --web
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Веб-интерфейс
|
||||||
|
|
||||||
|
После запуска веб-сервера откройте браузер:
|
||||||
|
|
||||||
|
- Главная страница: <http://localhost:3000>
|
||||||
|
|
||||||
|
- API эндпоинты:
|
||||||
|
- `GET /api/jobs` - список всех заданий
|
||||||
|
- `POST /api/generate` - запуск генерации
|
||||||
|
- `POST /api/cleanup` - очистка завершённых
|
||||||
|
- `POST /api/jobs/stop-all` - остановка активных
|
||||||
|
- `GET /api/status` - статус сервера
|
||||||
|
|
||||||
|
## Уровни логирования
|
||||||
|
|
||||||
|
Управляйте детализацией вывода через переменную окружения `RUST_LOG`:
|
||||||
|
|
||||||
|
- **error** - Только критические ошибки
|
||||||
|
- **warn** - Предупреждения и ошибки
|
||||||
|
- **info** - Общие операционные сообщения (по умолчанию)
|
||||||
|
- **debug** - Детальная информация о процессе обработки
|
||||||
|
- **trace** - Полное отладочное логирование с деталями API вызовов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Обязательна | Описание |
|
||||||
|
|-------------------------|-------------|------------------------------------------------------|
|
||||||
|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
|
||||||
|
| `NAS_USER` | Да | Имя пользователя учетной записи Synology |
|
||||||
|
| `NAS_PASS` | Да | Пароль учетной записи Synology |
|
||||||
|
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
|
||||||
|
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
|
||||||
|
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
|
||||||
|
| `WEB_PORT` | Нет | Порт для веб-сервера (по умолчанию: 3000) |
|
||||||
|
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
|
||||||
|
|
||||||
|
### Пример файла `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Synology NAS
|
||||||
|
NAS_FQDN="https://your-nas.example.com"
|
||||||
|
NAS_USER="your_username"
|
||||||
|
NAS_PASS="your_password"
|
||||||
|
NAS_FILE="/Team Folder/path/to/file.osheet"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
RUST_LOG="info"
|
||||||
|
|
||||||
|
# Web Server
|
||||||
|
WEB_PORT="3000"
|
||||||
|
|
||||||
|
# Nexrender
|
||||||
|
NEXRENDER_API_URL="http://nexrender-server:3050/api/v1/jobs"
|
||||||
|
OUTPUT_FOLDER="/path/to/output"
|
||||||
|
|
||||||
|
# After Effects Templates
|
||||||
|
TEMPLATE_DOUBLE_SRC="file:///path/to/double_team_template.aepx"
|
||||||
|
TEMPLATE_SINGLE_SRC="file:///path/to/single_team_template.aepx"
|
||||||
|
TEMPLATE_COMPOSITION="main"
|
||||||
|
TEMPLATE_OUTPUT_MODULE="h264"
|
||||||
|
TEMPLATE_OUTPUT_EXT="mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура электронной таблицы
|
||||||
|
|
||||||
|
Файл Excel должен содержать следующие листы со специфичными структурами:
|
||||||
|
|
||||||
|
### Лист "SPORT"
|
||||||
|
|
||||||
|
Связывает названия видов спорта с соответствующими видео пакетами.
|
||||||
|
|
||||||
|
| SPORT | LINK |
|
||||||
|
|----------------|---------------------------------------------|
|
||||||
|
| Без оформления | \\server\share\path\to\null.mov |
|
||||||
|
| Футбол | \\server\share\path\to\football_pack.mov |
|
||||||
|
| Волейбол | \\server\share\path\to\volleyball_pack.mov |
|
||||||
|
|
||||||
|
### Лист "TEAMS"
|
||||||
|
|
||||||
|
Сопоставляет имена команд с их видами спорта и логотипами. Колонка SPORT заполняется из выпадающего списка (заполнена из листа SPORT).
|
||||||
|
|
||||||
|
Если несколько команд в одном виде спорта имеют одинаковые названия, но разные логотипы, используется разделитель хеш # для уникальной идентификации.
|
||||||
|
|
||||||
|
| TEAM | SPORT | LINK |
|
||||||
|
|-------------------|-----------|---------------------------------------------------|
|
||||||
|
| Галатасарай#Turki | Футбол | \\server\share\path\to\galatasaray_football.png |
|
||||||
|
| Галатасарай | Волейбол | \\server\share\path\to\galatasaray_volleyball.png |
|
||||||
|
| Галатасарай#UCL | Волейбол | \\server\share\path\to\galatasaray_ucl.png |
|
||||||
|
| Сомбатей##400 | Футбол | \\server\share\path\to\szombathely.png |
|
||||||
|
|
||||||
|
### Лист "CHANELL"
|
||||||
|
|
||||||
|
Сопоставляет названия каналов с их логотипами. Поля заполняются вручную без выпадающих списков.
|
||||||
|
|
||||||
|
| CHANELL | LINK |
|
||||||
|
|---------|-----------------------------------------|
|
||||||
|
| КАНАЛ | \\server\share\path\to\channel_logo.png |
|
||||||
|
| TRIUMPH | \\server\share\path\to\triumph_logo.png |
|
||||||
|
|
||||||
|
### Лист "Start"
|
||||||
|
|
||||||
|
Основной источник данных для генерации анонсов. Большинство полей заполняются из выпадающих списков, основанных на других листах.
|
||||||
|
|
||||||
|
Обязательные колонки: DATA, TIME, CHANELL, SPORT, LEAGUE, TEAM A, TEAM B
|
||||||
|
|
||||||
|
**Примечания:**
|
||||||
|
|
||||||
|
- Колонка LEAGUE обязательна для заполнения, но не имеет выпадающего списка. Проверка орфографии и опечаток отсутствует!
|
||||||
|
- Колонка DATA должна содержать дату в формате `ДД.ММ.ГГГГ` (например, `28.2.2026`).
|
||||||
|
|
||||||
|
| STATE | TRIPPLE | DATA | TIME | SPORT | LEAGUE | CHANELL | TEAM A | TEAM B |
|
||||||
|
|-------|---------|-----------|-------|---------|---------------------------|---------|-----------------|------------------|
|
||||||
|
| TRUE | TRUE | 27.2.2026 | 23:40 | Футбол | Чемпионат Португалии | START | Спортинг#ll#550 | Эшторил#ll#550 |
|
||||||
|
| FALSE | TRUE | 28.2.2026 | 12:55 | Волейбол| Чемпионат Турции. Женщины | TRIUMPH | Бешикташ | Галатасарай#Turki|
|
||||||
|
|
||||||
|
### Формат имен команд
|
||||||
|
|
||||||
|
Имена команд поддерживают два типа разделителей хэш #:
|
||||||
|
|
||||||
|
1. **Первый хеш**: Уникальный идентификатор для команд с одинаковыми названиями в одном виде спорта, или одинаковым названием в разных видах спорта.
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Динамо#Футбол`, `Динамо#Волейбол` - Одно название разный вид спорта
|
||||||
|
- `Спартак#ЖенскийФ`, `Спартак#МужскойФ` Одно название _разный_ вид спорта
|
||||||
|
|
||||||
|
1. **Второй хеш**: Целевой размер в пикселях для масштабирования логотипа
|
||||||
|
|
||||||
|
Формат: `DisplayName#UniqueID#TargetSize`
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
- `Галатасарай#Turki` - только уникальный идентификатор
|
||||||
|
- `Сомбатей##400` - только целевой размер (обратите внимание на двойной хеш)
|
||||||
|
- `Спортинг#ll#550` - оба идентификатор и целевой размер
|
||||||
|
|
||||||
|
**Важно**: Обрабатывается **только второй хеш** для масштабирования логотипа. Если уникальный идентификатор не требуется, используйте двойной хеш ## перед целевым размером.
|
||||||
|
|
||||||
|
## Выходные файлы
|
||||||
|
|
||||||
|
### Рендеренные видео
|
||||||
|
|
||||||
|
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена файлов транслитерируются в латиницу.
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Today.mp4`
|
||||||
|
- `20260228_Volleyball_Championship-Turkey-Women_Beşiktaş_Galatasaray_TRIUMPH_Tomorrow.mp4`
|
||||||
|
|
||||||
|
## Рабочий процесс
|
||||||
|
|
||||||
|
1. **Аутентификация**: Подключение к NAS Synology с использованием предоставленных учетных данных
|
||||||
|
2. **Получение файла**: Поиск и экспорт указанного файла .osheet в Excel
|
||||||
|
3. **Парсинг данных**: Чтение всех листов и структурирование данных
|
||||||
|
4. **Разрешение ресурсов**: Сопоставление видов спорта, команд и каналов с визуальными элементами
|
||||||
|
5. **Генерация заданий**: Создание заданий Nexrender для каждой активной строки с вариантами
|
||||||
|
6. **Очистка**: Удаление завершенных/неудачных заданий из предыдущих запусков
|
||||||
|
7. **Отправка**: Отправка заданий на API Nexrender
|
||||||
|
8. **Мониторинг**: Слежение за прогрессом выполнения до завершения
|
||||||
|
9. **Завершение сеанса**: Закрытие сессии Synology
|
||||||
|
|
||||||
|
## Требования к шаблонам After Effects
|
||||||
|
|
||||||
|
Шаблоны должны быть предварительно настроены на узлах рендеринга со следующими именами слоёв.
|
||||||
|
|
||||||
|
### Файлы шаблонов
|
||||||
|
|
||||||
|
Настраиваются через переменные окружения:
|
||||||
|
|
||||||
|
| Переменная | Назначение | Пример значения |
|
||||||
|
|-------------------------|-------------------------------------|------------------------------------|
|
||||||
|
| `TEMPLATE_DOUBLE_SRC` | Шаблон для матчей с двумя командами | `file:///path/to/double.aepx` |
|
||||||
|
| `TEMPLATE_SINGLE_SRC` | Шаблон для анонсов с одной командой | `file:///path/to/single.aepx` |
|
||||||
|
| `TEMPLATE_COMPOSITION` | Имя главной композиции | `main` |
|
||||||
|
| `TEMPLATE_OUTPUT_MODULE`| Имя модуля вывода | `h264` |
|
||||||
|
| `TEMPLATE_OUTPUT_EXT` | Расширение выходного файла | `mp4` |
|
||||||
|
|
||||||
|
### Обязательные слои в шаблонах
|
||||||
|
|
||||||
|
#### Текстовые слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|-------------------------------------------------|
|
||||||
|
| `DATA` | ✅ | ✅ | Отображение даты (автоматически подстраивается) |
|
||||||
|
| `TIME_H` | ✅ | ❌ | Часы (отдельный слой) |
|
||||||
|
| `TIME_M` | ✅ | ❌ | Минуты (отдельный слой) |
|
||||||
|
| `TIME` | ❌ | ✅ | Полное время (единый слой) |
|
||||||
|
| `LEAGUE` | ✅ | ✅ | Название лиги/турнира |
|
||||||
|
| `SPORT` | ✅ | ✅ | Категория спорта |
|
||||||
|
| `TEAMS` | ✅ | ✅ | Скомбинированные имена команд |
|
||||||
|
|
||||||
|
#### Слои с изображениями
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|----------------|:-------------:|:-------------:|-----------------------------|
|
||||||
|
| `TEAM_A_LOGO` | ✅ | ✅ | Логотип команды A |
|
||||||
|
| `TEAM_B_LOGO` | ✅ | ❌ | Логотип команды B |
|
||||||
|
| `CHANELL` | ✅ | ✅ | Логотип канала (две буквы L)|
|
||||||
|
|
||||||
|
#### Видео слои
|
||||||
|
|
||||||
|
| Имя слоя | Шаблон DOUBLE | Шаблон SINGLE | Описание |
|
||||||
|
|-----------|:-------------:|:-------------:|----------------------------|
|
||||||
|
| `TOP` | ✅ | ✅ | Наложение видео оформления |
|
||||||
|
|
||||||
|
### Особенности шаблонов
|
||||||
|
|
||||||
|
#### Шаблон DOUBLE (две команды)
|
||||||
|
|
||||||
|
Используется когда в строке Excel заполнены оба поля `TEAM A` и `TEAM B`.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Все текстовые слои с `TIME_H` и `TIME_M` вместо `TIME`
|
||||||
|
- Оба логотипа команд: `TEAM_A_LOGO` и `TEAM_B_LOGO`
|
||||||
|
|
||||||
|
#### Шаблон SINGLE (одна команда)
|
||||||
|
|
||||||
|
Используется когда заполнено только одно поле команды.
|
||||||
|
|
||||||
|
Обязательные слои:
|
||||||
|
|
||||||
|
- Текстовый слой `TIME` вместо `TIME_H` и `TIME_M`
|
||||||
|
- Только логотип `TEAM_A_LOGO`
|
||||||
|
|
||||||
|
### Автоматические корректировки
|
||||||
|
|
||||||
|
AE Anons автоматически применяет следующие настройки к слоям:
|
||||||
|
|
||||||
|
#### Слой DATA
|
||||||
|
|
||||||
|
В зависимости от отображаемого текста и типа шаблона:
|
||||||
|
|
||||||
|
| Текст | Шаблон | Font Size | Anchor Point |
|
||||||
|
|------------|----------|-----------|--------------|
|
||||||
|
| "сегодня" | DOUBLE | 105 | [0, 5] |
|
||||||
|
| "завтра" | DOUBLE | 115 | [0, 25] |
|
||||||
|
| дата (<6) | DOUBLE | 120 | [0, 20] |
|
||||||
|
|
||||||
|
#### Слои TIME_H, TIME_M, TIME
|
||||||
|
|
||||||
|
Корректировка Anchor Point в зависимости от длины текста:
|
||||||
|
|
||||||
|
| Условие | Anchor Point |
|
||||||
|
|-------------------|--------------|
|
||||||
|
| 1 символ | [60, 0] |
|
||||||
|
| 2 символа, <20 | [20, 0] |
|
||||||
|
|
||||||
|
#### Слой LEAGUE
|
||||||
|
|
||||||
|
Если длина текста превышает 16 символов, размер шрифта уменьшается до 73.
|
||||||
|
|
||||||
|
#### Слой TEAMS
|
||||||
|
|
||||||
|
Если суммарная длина имён команд ≥ 32 символов, размер шрифта уменьшается до 55.
|
||||||
|
|
||||||
|
#### Логотипы (TEAM_A_LOGO, TEAM_B_LOGO)
|
||||||
|
|
||||||
|
Если в имени команды указан целевой размер через `#` (например, `Спортинг##550`), применяется выражение масштабирования:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (width > height) {
|
||||||
|
max_size = width;
|
||||||
|
} else {
|
||||||
|
max_size = height;
|
||||||
|
}
|
||||||
|
var real_size = 550 / max_size * 100;
|
||||||
|
[real_size, real_size]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример структуры слоёв в After Effects
|
||||||
|
|
||||||
|
Ниже представлен рекомендуемый порядок слоёв в композиции. Порядок важен для правильного наложения элементов.
|
||||||
|
|
||||||
|
```text
|
||||||
|
📁 main (композиция)
|
||||||
|
├── 🎬 TOP (видео слой) # Видео-оверлей (обязательный)
|
||||||
|
├── 📝 TEAMS (текстовый слой) # Имена команд (обязательный)
|
||||||
|
├── 📝 LEAGUE (текстовый слой) # Название лиги (обязательный)
|
||||||
|
├── 📝 SPORT (текстовый слой) # Вид спорта (обязательный)
|
||||||
|
├── 📝 DATA (текстовый слой) # Дата (обязательный)
|
||||||
|
├── 📝 TIME (текстовый слой) # Время (только SINGLE)
|
||||||
|
├── 📝 TIME_H (текстовый слой) # Часы (только DOUBLE)
|
||||||
|
├── 📝 TIME_M (текстовый слой) # Минуты (только DOUBLE)
|
||||||
|
├── 🖼️ CHANELL (слой изображения) # Логотип канала (обязательный)
|
||||||
|
├── 🖼️ TEAM_A_LOGO (слой изображения) # Логотип команды A (обязательный)
|
||||||
|
├── 🖼️ TEAM_B_LOGO (слой изображения) # Логотип команды B (только DOUBLE)
|
||||||
|
└── 🖼️ BOTTOM (слой изображения) # Фоновый слой (опционально)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат проекта и совместимость с After Effects
|
||||||
|
|
||||||
|
**Поддерживаемые форматы проектов:**
|
||||||
|
|
||||||
|
- `.aep` — стандартный бинарный формат After Effects (рекомендуется)
|
||||||
|
- `.aepx` — XML-формат проекта (поддерживается с AE CC 2015)
|
||||||
|
|
||||||
|
**Совместимость версий After Effects:**
|
||||||
|
|
||||||
|
| Версия After Effects | Поддерживаемые форматы | Особенности |
|
||||||
|
|----------------------|------------------------|-------------------------------------------------|
|
||||||
|
| CS 5.5 | `.aep` | Базовая поддержка |
|
||||||
|
| CC / CC 2014 | `.aep` | Полная поддержка |
|
||||||
|
| CC 2015 - CC 2019 | `.aep`, `.aepx` | Добавлена поддержка XML формата `.aepx` |
|
||||||
|
| CC 2020 - CC 2022 | `.aep`, `.aepx` | Рекомендуется использовать `.aep` |
|
||||||
|
| CC 2023 и новее | `.aep`, `.aepx` | **Требуется настройка Output Module** (см. ниже)|
|
||||||
|
|
||||||
|
> **⚠️ Важно для After Effects 2023+:**
|
||||||
|
>
|
||||||
|
> В версиях After Effects 2023 и новее критически важно настроить **Output Module** в шаблоне проекта. Бинарный файл рендеринга (`aerender`) не будет обрабатывать композицию без явно указанного модуля вывода, даже если в проекте используется модуль по умолчанию.
|
||||||
|
>
|
||||||
|
> AE Anons автоматически решает эту проблему, используя параметры `TEMPLATE_OUTPUT_MODULE` и `TEMPLATE_OUTPUT_EXT` из `.env` файла. Убедитесь, что эти значения соответствуют настройкам вашего шаблона.
|
||||||
|
|
||||||
|
### Настройки композиции
|
||||||
|
|
||||||
|
| Параметр | Переменная окружения | Пример значения | Описание |
|
||||||
|
|---------------------------|--------------------------|-----------------|---------------------------------------------|
|
||||||
|
| Имя композиции | `TEMPLATE_COMPOSITION` | `main` | Имя главной композиции в проекте AE |
|
||||||
|
| Выходной модуль | `TEMPLATE_OUTPUT_MODULE` | `h264` | Имя модуля вывода в AE |
|
||||||
|
| Расширение выходного файла| `TEMPLATE_OUTPUT_EXT` | `mp4` | Расширение выходного файла |
|
||||||
|
|
||||||
|
**Рекомендации по выбору формата:**
|
||||||
|
|
||||||
|
1. Используйте `.aep` для максимальной совместимости со всеми версиями AE
|
||||||
|
2. Версия After Effects на worker-машине должна быть не ниже версии, в которой создан проект
|
||||||
|
3. Для AE 2023+ убедитесь, что в шаблоне настроен Output Module с именем, указанным в `TEMPLATE_OUTPUT_MODULE`
|
||||||
|
|
||||||
|
**Источники:**
|
||||||
|
|
||||||
|
- [Nexrender - Tested with After Effects versions](https://github.com/inlife/nexrender#tested-with)
|
||||||
|
- [Adobe Aerender documentation](https://helpx.adobe.com/after-effects/using/automated-rendering-network-rendering.html)
|
||||||
|
|
||||||
|
## Устранение неисправностей
|
||||||
|
|
||||||
|
### Частые проблемы
|
||||||
|
|
||||||
|
#### Ошибка подключения к NAS
|
||||||
|
|
||||||
|
- Проверьте, что `NAS_FQDN` включает протокол и порт (например, `https://nas.example.com:5001`)
|
||||||
|
- Проверьте сетевую связность с NAS
|
||||||
|
- Убедитесь, что сервисы File Station и Office включены
|
||||||
|
|
||||||
|
#### Файл не найден
|
||||||
|
|
||||||
|
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
|
||||||
|
- Путь должен начинаться с `/Team Folder/` для рабочих папок
|
||||||
|
- Проверьте права доступа к файлу для учетной записи сервиса
|
||||||
|
|
||||||
|
#### Ошибка отправки задания Nexrender
|
||||||
|
|
||||||
|
- Подтвердите доступность сервера Nexrender
|
||||||
|
- Убедитесь, что `NEXRENDER_API_URL` правильный
|
||||||
|
- Проверьте существование файлов шаблонов на узлах рендеринга
|
||||||
|
|
||||||
|
### Режим отладки
|
||||||
|
|
||||||
|
Включите подробное логирование для детального анализа:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RUST_LOG=debug ./target/release/ae_anons
|
||||||
|
```
|
||||||
|
|
||||||
|
Это выведет:
|
||||||
|
|
||||||
|
- Детали API запросов/ответов
|
||||||
|
- Информацию о парсинге листов
|
||||||
|
- Детали создания заданий
|
||||||
|
- Процесс разрешения ресурсов
|
||||||
|
|
||||||
|
## Возможные ограничения по производительности
|
||||||
|
|
||||||
|
- **Большие таблицы**: Ограничение обработки до 10,000 строк на лист
|
||||||
|
- **Задержка сети**: Загрузка файлов с NAS может занимать время для больших файлов
|
||||||
|
- **Параллельные задания**: Nexrender управляет очередью заданий внутренне
|
||||||
|
|
||||||
|
## Примечания по безопасности
|
||||||
|
|
||||||
|
- Храните учетные данные только в файле `.env` (исключен из git)
|
||||||
|
- Используйте специальные аккаунты с минимально необходимыми правами
|
||||||
|
- Сессии Synology завершаются после выполнения
|
||||||
|
- HTTPS рекомендуется для подключений NAS в продакшене
|
||||||
|
|
||||||
|
## Разработка
|
||||||
|
|
||||||
|
### Структура кода
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ae_anons/
|
||||||
|
├── .gitignore
|
||||||
|
├── .env.example
|
||||||
|
├── Cargo.toml
|
||||||
|
├── LICENSE
|
||||||
|
├── README.md
|
||||||
|
├── assets/
|
||||||
|
│ └── logo.png
|
||||||
|
└── src/
|
||||||
|
├── main.rs # Точка входа, CLI
|
||||||
|
├── config.rs # Конфигурация из .env
|
||||||
|
├── nexrender.rs # Модели заданий Nexrender
|
||||||
|
├── synology.rs # Клиент Synology API
|
||||||
|
├── processor.rs # Логика обработки
|
||||||
|
├── web.rs # Веб-сервер и API
|
||||||
|
└── static/
|
||||||
|
├── index.html # Веб-интерфейс
|
||||||
|
└── style.css # Стили
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
### Основные зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|---------|-----------------------------------------|
|
||||||
|
| reqwest | 0.12 | HTTP клиент для коммуникации с API |
|
||||||
|
| serde / serde_json | 1.0 | Сериализация и десериализация JSON |
|
||||||
|
| calamine | 0.26 | Парсинг файлов Excel (.xlsx, .xls) |
|
||||||
|
| chrono | 0.4 | Обработка дат и времени |
|
||||||
|
| tokio | 1.0 | Асинхронная среда выполнения |
|
||||||
|
| dotenv | 0.15 | Загрузка конфигурации из .env файла |
|
||||||
|
| log / env_logger | 0.4/0.11| Система логирования с уровнями |
|
||||||
|
| thiserror | 2.0 | Эргономичные определения типов ошибок |
|
||||||
|
| anyhow | 1.0 | Упрощённая обработка ошибок |
|
||||||
|
| regex | 1.11 | Регулярные выражения |
|
||||||
|
| urlencoding | 2.1 | Кодирование URL для API запросов |
|
||||||
|
| bytes | 1.9 | Работа с байтовыми данными |
|
||||||
|
| futures | 0.3 | Асинхронные примитивы |
|
||||||
|
|
||||||
|
### Веб-сервер и CLI
|
||||||
|
|
||||||
|
| Crate | Версия | Назначение |
|
||||||
|
|--------------------|--------|-----------------------------------------|
|
||||||
|
| axum | 0.8 | Веб-фреймворк для REST API |
|
||||||
|
| tower | 0.5 | Промежуточное ПО для веб-сервера |
|
||||||
|
| tower-http | 0.6 |HTTP утилиты (CORS, статика, трассировка)|
|
||||||
|
| askama | 0.15 | Шаблонизация (опционально) |
|
||||||
|
| clap | 4.5 | Парсинг аргументов командной строки |
|
||||||
|
|
||||||
|
### Платформозависимые зависимости
|
||||||
|
|
||||||
|
| Crate | Версия | Платформа | Назначение |
|
||||||
|
|--------------------|--------|-----------|----------------------------------|
|
||||||
|
| openssl | 0.10 | Linux | Криптография для HTTPS (vendored)|
|
||||||
|
|
||||||
|
## 🙏 Благодарности
|
||||||
|
|
||||||
|
### Nexrender
|
||||||
|
|
||||||
|
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
|
||||||
|
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
|
||||||
|
для автоматизации After Effects.
|
||||||
|
|
||||||
|
### Font Awesome Free 6.4.0
|
||||||
|
|
||||||
|
Веб-интерфейс использует иконки и шрифты **Font Awesome Free**:
|
||||||
|
|
||||||
|
- **Иконки**: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||||
|
- **Шрифты**: [SIL OFL 1.1](https://scripts.sil.org/OFL)
|
||||||
|
|
||||||
|
© 2023 Fonticons, Inc. — <https://fontawesome.com>
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
**AE Anons** — [License](LICENSE)
|
||||||
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
|
|
||||||
|
**Разрешается:**
|
||||||
|
|
||||||
|
- ✅ Использовать в коммерческих целях
|
||||||
|
- ✅ Изменять исходный код
|
||||||
|
- ✅ Распространять копии
|
||||||
|
- ✅ Использовать приватно
|
||||||
|
|
||||||
|
**Требуется:**
|
||||||
|
|
||||||
|
- Сохранять копирайт и текст лицензии
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
Для вопросов и запросов функций обращайтесь:
|
||||||
|
|
||||||
|
- <a.barabanov@tvstart.ru>
|
||||||
|
- [README.md](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
|
## История изменений
|
||||||
|
|
||||||
|
### v0.2.6 (текущая)
|
||||||
|
|
||||||
|
- Сортировка заданий по дате и времени события (самые ранние — первыми)
|
||||||
|
- Сортировка по варианту (оригинал → today → tomorrow)
|
||||||
|
- Последовательная отправка для гарантированного порядка в очереди
|
||||||
|
- Исправлена нормализация дефисов (2 и более дефиса подряд → 1)
|
||||||
|
- Исправлен SINGLE шаблон (убран несуществующий слой TEAMS)
|
||||||
|
- Добавлена поддержка кеширования ассетов (cache: true/false)
|
||||||
|
- Логотипы команд не кешируются, логотипы каналов и видео-паки кешируются
|
||||||
|
|
||||||
|
### v0.2.5
|
||||||
|
|
||||||
|
- Оптимизация памяти: очистка неиспользуемых логотипов
|
||||||
|
- Групповая отправка заданий по 3 для сохранения порядка
|
||||||
|
- Добавлен UID в логи отправки
|
||||||
|
|
||||||
|
### v0.2.4
|
||||||
|
|
||||||
|
- Полностью автономный режим (встроенный Font Awesome)
|
||||||
|
- Добавлен favicon
|
||||||
|
- Все статические ресурсы встроены в бинарник
|
||||||
|
|
||||||
|
### v0.2.3
|
||||||
|
|
||||||
|
- Веб-интерфейс с настраиваемым портом (WEB_PORT)
|
||||||
|
- Тёмная/светлая тема с автоопределением ОС
|
||||||
|
- Сортировка по всем колонкам в интерфейсе
|
||||||
|
- Фильтрация по имени файла/UID
|
||||||
|
- Остановка всех активных заданий
|
||||||
|
- Автообновление каждые 60 секунд
|
||||||
|
- Улучшенное debug-логирование
|
||||||
|
- Исправлена обработка UNC/SMB путей
|
||||||
|
|
||||||
|
### v0.2.2
|
||||||
|
|
||||||
|
- Исправлено отображение имён выходных файлов в веб-интерфейсе
|
||||||
|
- Улучшена цветовая схема
|
||||||
|
- Добавлена сортировка по всем колонкам
|
||||||
|
- Вынесены стили в отдельный CSS файл
|
||||||
|
|
||||||
|
### v0.2.0
|
||||||
|
|
||||||
|
- Добавлен веб-интерфейс для управления заданиями
|
||||||
|
- Реализован REST API
|
||||||
|
- Добавлена поддержка тёмной/светлой темы
|
||||||
|
- Автообновление статуса заданий
|
||||||
|
- Возможность остановки всех активных заданий
|
||||||
|
|
||||||
|
### v0.1.1
|
||||||
|
|
||||||
|
- Оптимизирована работа с памятью
|
||||||
|
- Убрана функция создания .json
|
||||||
|
|
||||||
|
### v0.1.0
|
||||||
|
|
||||||
|
- Первый выпуск
|
||||||
|
- Интеграция с Synology Office
|
||||||
|
- Базовая генерация заданий Nexrender
|
||||||
|
- Парсинг Excel с динамическим обнаружением листов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with 🦀 Rust and ☕ coffee
|
||||||
BIN
releases/ae_anons-v0.2.6-windows-x64/ae_anons.exe
Executable file
BIN
releases/ae_anons-v0.2.6-windows-x64/ae_anons.exe
Executable file
Binary file not shown.
@@ -1,6 +1,57 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AccessType {
|
||||||
|
Local,
|
||||||
|
Smb,
|
||||||
|
Synology,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AccessConfig {
|
||||||
|
pub access_type: AccessType,
|
||||||
|
pub local_path: Option<String>,
|
||||||
|
pub smb_url: Option<String>,
|
||||||
|
pub smb_user: Option<String>,
|
||||||
|
pub smb_pass: Option<String>,
|
||||||
|
pub smb_domain: Option<String>,
|
||||||
|
pub synology_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessConfig {
|
||||||
|
fn from_env(prefix: &str) -> Result<Self> {
|
||||||
|
let local_path = env::var(format!("{}_LOCAL_PATH", prefix)).ok();
|
||||||
|
let smb_url = env::var(format!("{}_SMB_URL", prefix)).ok();
|
||||||
|
let synology_path = env::var(format!("{}_SYNOLOGY_PATH", prefix)).ok();
|
||||||
|
|
||||||
|
let access_type = if local_path.is_some() && smb_url.is_none() && synology_path.is_none() {
|
||||||
|
AccessType::Local
|
||||||
|
} else if smb_url.is_some() && local_path.is_none() && synology_path.is_none() {
|
||||||
|
AccessType::Smb
|
||||||
|
} else if synology_path.is_some() && local_path.is_none() && smb_url.is_none() {
|
||||||
|
AccessType::Synology
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Exactly one of {}_LOCAL_PATH, {}_SMB_URL, {}_SYNOLOGY_PATH must be set",
|
||||||
|
prefix,
|
||||||
|
prefix,
|
||||||
|
prefix
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AccessConfig {
|
||||||
|
access_type,
|
||||||
|
local_path,
|
||||||
|
smb_url,
|
||||||
|
smb_user: env::var(format!("{}_SMB_USER", prefix)).ok(),
|
||||||
|
smb_pass: env::var(format!("{}_SMB_PASS", prefix)).ok(),
|
||||||
|
smb_domain: env::var(format!("{}_SMB_DOMAIN", prefix)).ok(),
|
||||||
|
synology_path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
// Synology
|
// Synology
|
||||||
@@ -11,14 +62,18 @@ pub struct Config {
|
|||||||
// Nexrender
|
// Nexrender
|
||||||
pub nexrender_api_url: String,
|
pub nexrender_api_url: String,
|
||||||
pub output_folder: String,
|
pub output_folder: String,
|
||||||
// Templates (all required)
|
// Templates
|
||||||
pub template_double_src: String,
|
pub template_double_src: String,
|
||||||
pub template_single_src: String,
|
pub template_single_src: String,
|
||||||
pub template_composition: String,
|
pub template_composition: String,
|
||||||
pub template_output_module: String,
|
pub template_output_module: String,
|
||||||
pub template_output_ext: String,
|
pub template_output_ext: String,
|
||||||
//web_serwer
|
// Web server
|
||||||
pub web_port: u16,
|
pub web_port: u16,
|
||||||
|
// Source (where rendered videos appear)
|
||||||
|
pub source: AccessConfig,
|
||||||
|
// Destination (where to move approved videos)
|
||||||
|
pub destination: AccessConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -45,6 +100,8 @@ impl Config {
|
|||||||
.unwrap_or_else(|_| "3000".to_string())
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.context("Invalid WEB_PORT")?,
|
.context("Invalid WEB_PORT")?,
|
||||||
|
source: AccessConfig::from_env("SOURCE")?,
|
||||||
|
destination: AccessConfig::from_env("DESTINATION")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
287
src/file_access.rs
Normal file
287
src/file_access.rs
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use smb::{
|
||||||
|
resource::GetLen, Client, ClientConfig, CreateOptions, FileAccessMask, FileAttributes,
|
||||||
|
FileCreateArgs, ReadAt, UncPath, WriteAt,
|
||||||
|
};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// smb://server/share/path → \\server\share[\path...]
|
||||||
|
fn smb_url_to_unc(url_str: &str) -> Result<UncPath> {
|
||||||
|
let url = Url::parse(url_str).context("Invalid SMB URL")?;
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| anyhow!("No host in SMB URL"))?;
|
||||||
|
|
||||||
|
// Берём сырой путь
|
||||||
|
let raw_path = url.path().trim_start_matches('/');
|
||||||
|
// Декодируем каждый сегмент с помощью urlencoding (уже есть в зависимостях)
|
||||||
|
let decoded_segments: Vec<String> = raw_path
|
||||||
|
.split('/')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| {
|
||||||
|
urlencoding::decode(s)
|
||||||
|
.unwrap_or_else(|_| s.to_string().into())
|
||||||
|
.into_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if decoded_segments.is_empty() {
|
||||||
|
return Err(anyhow!("No path segments in SMB URL"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let share = &decoded_segments[0];
|
||||||
|
let base = UncPath::from_str(&format!("\\\\{}\\{}", host, share))
|
||||||
|
.map_err(|e| anyhow!("Invalid UNC: {}", e))?;
|
||||||
|
let mut full = base.clone();
|
||||||
|
for segment in decoded_segments.iter().skip(1) {
|
||||||
|
full = full.with_path(segment);
|
||||||
|
}
|
||||||
|
Ok(full)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Универсальный трейт ----------
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FileAccess: Send + Sync {
|
||||||
|
async fn list_files(&self) -> Result<Vec<String>>;
|
||||||
|
async fn read_file(&self, filename: &str) -> Result<Vec<u8>>;
|
||||||
|
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()>;
|
||||||
|
async fn delete_file(&self, filename: &str) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Локальная файловая система ----------
|
||||||
|
|
||||||
|
pub struct LocalFileAccess {
|
||||||
|
base_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalFileAccess {
|
||||||
|
pub fn new(base_path: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
base_path: base_path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_path(&self, filename: &str) -> String {
|
||||||
|
format!("{}/{}", self.base_path, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileAccess for LocalFileAccess {
|
||||||
|
async fn list_files(&self) -> Result<Vec<String>> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut entries = tokio::fs::read_dir(&self.base_path).await?;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
if entry.file_type().await?.is_file() {
|
||||||
|
if let Some(name) = entry.file_name().to_str() {
|
||||||
|
files.push(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
|
||||||
|
let path = self.full_path(filename);
|
||||||
|
log::debug!("LocalFileAccess reading file: {}", path);
|
||||||
|
tokio::fs::read(&path).await.map_err(|e| {
|
||||||
|
log::error!("Failed to read local file {}: {}", path, e);
|
||||||
|
anyhow::anyhow!(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
|
||||||
|
let path = self.full_path(filename);
|
||||||
|
tokio::fs::write(&path, data).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, filename: &str) -> Result<()> {
|
||||||
|
let path = self.full_path(filename);
|
||||||
|
tokio::fs::remove_file(&path).await.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SMB ----------
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SmbAccessConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub password: Option<String>,
|
||||||
|
pub domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SmbFileAccess {
|
||||||
|
config: SmbAccessConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmbFileAccess {
|
||||||
|
pub fn new(config: SmbAccessConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(&self) -> Result<(Client, UncPath)> {
|
||||||
|
let base_unc = smb_url_to_unc(&self.config.url)?;
|
||||||
|
let client = Client::new(ClientConfig::default());
|
||||||
|
|
||||||
|
let (user, pass) = match (&self.config.user, &self.config.password) {
|
||||||
|
(Some(u), Some(p)) => (u.clone(), p.clone()),
|
||||||
|
_ => {
|
||||||
|
client.share_connect(&base_unc, "", "".to_string()).await?;
|
||||||
|
return Ok((client, base_unc));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Если задан домен, добавляем его к имени пользователя: DOMAIN\username
|
||||||
|
let full_user = match &self.config.domain {
|
||||||
|
Some(d) if !d.is_empty() => format!("{}\\{}", d, user),
|
||||||
|
_ => user,
|
||||||
|
};
|
||||||
|
|
||||||
|
client.share_connect(&base_unc, &full_user, pass).await?;
|
||||||
|
Ok((client, base_unc))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Построить полный UNC путь к файлу, гарантируя один слеш между базой и именем
|
||||||
|
fn make_file_unc(base: &UncPath, filename: &str) -> Result<UncPath> {
|
||||||
|
let base_str = base.to_string();
|
||||||
|
let clean_base = base_str.trim_end_matches('\\');
|
||||||
|
// Заменяем все прямые слеши на обратные для UNC
|
||||||
|
let sanitized = filename.replace('/', "\\");
|
||||||
|
let clean_filename = sanitized.trim_start_matches('\\');
|
||||||
|
let full = format!("{}\\{}", clean_base, clean_filename);
|
||||||
|
UncPath::from_str(&full).map_err(|e| anyhow!("Invalid file UNC: {}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileAccess for SmbFileAccess {
|
||||||
|
async fn list_files(&self) -> Result<Vec<String>> {
|
||||||
|
// Заглушка
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
|
||||||
|
let (client, base_unc) = self.connect().await?;
|
||||||
|
let file_path = Self::make_file_unc(&base_unc, filename)?;
|
||||||
|
log::info!("SMB reading file: {:?}", file_path);
|
||||||
|
|
||||||
|
let args =
|
||||||
|
FileCreateArgs::make_open_existing(FileAccessMask::new().with_generic_read(true));
|
||||||
|
let resource = client.create_file(&file_path, &args).await.map_err(|e| {
|
||||||
|
log::error!("Failed to open SMB file {:?}: {}", file_path, e);
|
||||||
|
anyhow::anyhow!("Failed to open SMB file: {}", e)
|
||||||
|
})?;
|
||||||
|
let file = resource.unwrap_file();
|
||||||
|
|
||||||
|
let file_size = file.get_len().await? as usize;
|
||||||
|
let mut buf = vec![0u8; file_size];
|
||||||
|
let bytes_read = file.read_at(&mut buf, 0).await?;
|
||||||
|
buf.truncate(bytes_read);
|
||||||
|
|
||||||
|
file.close().await?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
|
||||||
|
let (client, base_unc) = self.connect().await?;
|
||||||
|
let file_path = Self::make_file_unc(&base_unc, filename)?;
|
||||||
|
log::info!("SMB writing file: {:?}", file_path);
|
||||||
|
|
||||||
|
let access_mask = FileAccessMask::new()
|
||||||
|
.with_generic_write(true)
|
||||||
|
.with_generic_read(true);
|
||||||
|
let mut args =
|
||||||
|
FileCreateArgs::make_overwrite(FileAttributes::default(), CreateOptions::default());
|
||||||
|
args.desired_access = access_mask;
|
||||||
|
|
||||||
|
let resource = client.create_file(&file_path, &args).await.map_err(|e| {
|
||||||
|
log::error!("Failed to create SMB file {:?}: {}", file_path, e);
|
||||||
|
anyhow::anyhow!("Failed to create SMB file: {}", e)
|
||||||
|
})?;
|
||||||
|
let remote_file = resource.unwrap_file();
|
||||||
|
|
||||||
|
remote_file.write_at(data, 0).await?;
|
||||||
|
remote_file.close().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, filename: &str) -> Result<()> {
|
||||||
|
log::warn!(
|
||||||
|
"SMB delete not supported – file left at source: {}",
|
||||||
|
filename
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Synology ----------
|
||||||
|
|
||||||
|
pub struct SynologyFileAccess {
|
||||||
|
client: crate::synology::SynologyClient,
|
||||||
|
base_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SynologyFileAccess {
|
||||||
|
pub fn new(client: crate::synology::SynologyClient, base_path: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
base_path: base_path.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_path(&self, filename: &str) -> String {
|
||||||
|
format!("{}/{}", self.base_path, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileAccess for SynologyFileAccess {
|
||||||
|
async fn list_files(&self) -> Result<Vec<String>> {
|
||||||
|
use crate::synology::ListFilesOptions;
|
||||||
|
let result = self
|
||||||
|
.client
|
||||||
|
.list_files(&self.base_path, ListFilesOptions::new())
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
let files = result
|
||||||
|
.files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| !f.isdir)
|
||||||
|
.map(|f| f.name)
|
||||||
|
.collect();
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_file(&self, filename: &str) -> Result<Vec<u8>> {
|
||||||
|
let path = self.full_path(filename);
|
||||||
|
self.client
|
||||||
|
.download(&path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_file(&self, filename: &str, data: &[u8]) -> Result<()> {
|
||||||
|
self.client
|
||||||
|
.upload(
|
||||||
|
&self.base_path,
|
||||||
|
filename,
|
||||||
|
data.to_vec(),
|
||||||
|
Some(false),
|
||||||
|
Some(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_file(&self, filename: &str) -> Result<()> {
|
||||||
|
let path = self.full_path(filename);
|
||||||
|
self.client
|
||||||
|
.delete(&[&path], None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod config;
|
mod config;
|
||||||
|
mod file_access;
|
||||||
mod nexrender;
|
mod nexrender;
|
||||||
mod processor;
|
mod processor;
|
||||||
mod synology;
|
mod synology;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use regex::Regex;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -154,6 +155,8 @@ pub enum Asset {
|
|||||||
asset_type: String,
|
asset_type: String,
|
||||||
#[serde(rename = "layerName")]
|
#[serde(rename = "layerName")]
|
||||||
layer_name: String,
|
layer_name: String,
|
||||||
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
cache: bool,
|
||||||
},
|
},
|
||||||
Video {
|
Video {
|
||||||
src: String,
|
src: String,
|
||||||
@@ -161,6 +164,8 @@ pub enum Asset {
|
|||||||
asset_type: String,
|
asset_type: String,
|
||||||
#[serde(rename = "layerName")]
|
#[serde(rename = "layerName")]
|
||||||
layer_name: String,
|
layer_name: String,
|
||||||
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
cache: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +212,10 @@ pub struct JobData {
|
|||||||
pub channel_logo: String,
|
pub channel_logo: String,
|
||||||
pub outfile_name: String,
|
pub outfile_name: String,
|
||||||
pub row_index: usize,
|
pub row_index: usize,
|
||||||
|
// Поля для сортировки
|
||||||
|
pub sort_date: NaiveDate,
|
||||||
|
pub sort_time: String,
|
||||||
|
pub variant_order: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JobData {
|
impl JobData {
|
||||||
@@ -232,6 +241,7 @@ impl JobData {
|
|||||||
|
|
||||||
let (date_obj, data_display) = parse_date(&data_str);
|
let (date_obj, data_display) = parse_date(&data_str);
|
||||||
let (time_h, time_m) = parse_time(&time);
|
let (time_h, time_m) = parse_time(&time);
|
||||||
|
let sort_time = format!("{}:{}", time_h, time_m);
|
||||||
|
|
||||||
let pack_path = packs.get(&sport).cloned().unwrap_or_default();
|
let pack_path = packs.get(&sport).cloned().unwrap_or_default();
|
||||||
let channel_logo = channels.get(&channel).cloned().unwrap_or_default();
|
let channel_logo = channels.get(&channel).cloned().unwrap_or_default();
|
||||||
@@ -263,10 +273,17 @@ impl JobData {
|
|||||||
fn_parts.push(channel.clone());
|
fn_parts.push(channel.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let outfile_name = transliterate(&fn_parts.join("_"), true)
|
let mut outfile_name = transliterate(&fn_parts.join("_"), true)
|
||||||
.replace(' ', "-")
|
.replace(' ', "-")
|
||||||
.replace('\'', "");
|
.replace('\'', "");
|
||||||
|
|
||||||
|
// Заменяем 2 и более дефисов подряд на один
|
||||||
|
let re = Regex::new(r"-{2,}").unwrap();
|
||||||
|
outfile_name = re.replace_all(&outfile_name, "-").to_string();
|
||||||
|
|
||||||
|
// Убираем дефисы в начале и конце
|
||||||
|
outfile_name = outfile_name.trim_matches('-').to_string();
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
date: data_str,
|
date: data_str,
|
||||||
time: time.clone(),
|
time: time.clone(),
|
||||||
@@ -286,19 +303,34 @@ impl JobData {
|
|||||||
channel_logo: unc_to_uri(&channel_logo),
|
channel_logo: unc_to_uri(&channel_logo),
|
||||||
outfile_name,
|
outfile_name,
|
||||||
row_index,
|
row_index,
|
||||||
|
sort_date: date_obj,
|
||||||
|
sort_time,
|
||||||
|
variant_order: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_variants(&self) -> Vec<Self> {
|
pub fn create_variants(&self) -> Vec<Self> {
|
||||||
let mut variants = vec![self.clone()];
|
let mut variants = Vec::new();
|
||||||
|
|
||||||
|
// Оригинал (0)
|
||||||
|
let mut original = self.clone();
|
||||||
|
original.variant_order = 0;
|
||||||
|
variants.push(original);
|
||||||
|
|
||||||
|
// Today (1)
|
||||||
let mut today = self.clone();
|
let mut today = self.clone();
|
||||||
today.data_display = "сегодня".to_string();
|
today.data_display = "сегодня".to_string();
|
||||||
today.outfile_name = format!("{}_Today", self.outfile_name);
|
today.outfile_name = format!("{}_Today", self.outfile_name);
|
||||||
|
today.variant_order = 1;
|
||||||
variants.push(today);
|
variants.push(today);
|
||||||
|
|
||||||
|
// Tomorrow (2)
|
||||||
let mut tomorrow = self.clone();
|
let mut tomorrow = self.clone();
|
||||||
tomorrow.data_display = "завтра".to_string();
|
tomorrow.data_display = "завтра".to_string();
|
||||||
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
|
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
|
||||||
|
tomorrow.variant_order = 2;
|
||||||
variants.push(tomorrow);
|
variants.push(tomorrow);
|
||||||
|
|
||||||
variants
|
variants
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +370,7 @@ impl JobData {
|
|||||||
src: self.pack_path.clone(),
|
src: self.pack_path.clone(),
|
||||||
asset_type: "video".to_string(),
|
asset_type: "video".to_string(),
|
||||||
layer_name: "TOP".to_string(),
|
layer_name: "TOP".to_string(),
|
||||||
|
cache: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,6 +382,7 @@ impl JobData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_text_assets(&self, assets: &mut Vec<Asset>) {
|
fn add_text_assets(&self, assets: &mut Vec<Asset>) {
|
||||||
|
// DATA — всегда
|
||||||
assets.push(Asset::Data {
|
assets.push(Asset::Data {
|
||||||
asset_type: "data".to_string(),
|
asset_type: "data".to_string(),
|
||||||
layer_name: "DATA".to_string(),
|
layer_name: "DATA".to_string(),
|
||||||
@@ -357,7 +391,9 @@ impl JobData {
|
|||||||
});
|
});
|
||||||
self.add_date_adjustments(assets);
|
self.add_date_adjustments(assets);
|
||||||
|
|
||||||
|
// TIME — зависит от шаблона
|
||||||
if !self.team_b.is_empty() {
|
if !self.team_b.is_empty() {
|
||||||
|
// DOUBLE: TIME_H и TIME_M
|
||||||
assets.push(Asset::Data {
|
assets.push(Asset::Data {
|
||||||
asset_type: "data".to_string(),
|
asset_type: "data".to_string(),
|
||||||
layer_name: "TIME_H".to_string(),
|
layer_name: "TIME_H".to_string(),
|
||||||
@@ -372,6 +408,7 @@ impl JobData {
|
|||||||
});
|
});
|
||||||
self.add_time_adjustments(assets);
|
self.add_time_adjustments(assets);
|
||||||
} else {
|
} else {
|
||||||
|
// SINGLE: TIME
|
||||||
assets.push(Asset::Data {
|
assets.push(Asset::Data {
|
||||||
asset_type: "data".to_string(),
|
asset_type: "data".to_string(),
|
||||||
layer_name: "TIME".to_string(),
|
layer_name: "TIME".to_string(),
|
||||||
@@ -380,6 +417,7 @@ impl JobData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LEAGUE — всегда
|
||||||
assets.push(Asset::Data {
|
assets.push(Asset::Data {
|
||||||
asset_type: "data".to_string(),
|
asset_type: "data".to_string(),
|
||||||
layer_name: "LEAGUE".to_string(),
|
layer_name: "LEAGUE".to_string(),
|
||||||
@@ -388,6 +426,7 @@ impl JobData {
|
|||||||
});
|
});
|
||||||
self.add_league_adjustments(assets);
|
self.add_league_adjustments(assets);
|
||||||
|
|
||||||
|
// SPORT — всегда
|
||||||
if !self.sport.is_empty() {
|
if !self.sport.is_empty() {
|
||||||
assets.push(Asset::Data {
|
assets.push(Asset::Data {
|
||||||
asset_type: "data".to_string(),
|
asset_type: "data".to_string(),
|
||||||
@@ -397,14 +436,9 @@ impl JobData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let teams_text = if !self.team_a.is_empty() && !self.team_b.is_empty() {
|
// TEAMS — ТОЛЬКО для DOUBLE шаблона
|
||||||
format!("{} - {}", self.team_a, self.team_b)
|
if !self.team_b.is_empty() {
|
||||||
} else if !self.team_a.is_empty() {
|
let teams_text = format!("{} - {}", self.team_a, self.team_b);
|
||||||
self.team_a.clone()
|
|
||||||
} else {
|
|
||||||
self.team_b.clone()
|
|
||||||
};
|
|
||||||
if !teams_text.is_empty() {
|
|
||||||
assets.push(Asset::Data {
|
assets.push(Asset::Data {
|
||||||
asset_type: "data".to_string(),
|
asset_type: "data".to_string(),
|
||||||
layer_name: "TEAMS".to_string(),
|
layer_name: "TEAMS".to_string(),
|
||||||
@@ -477,11 +511,13 @@ impl JobData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_image_assets(&self, assets: &mut Vec<Asset>) {
|
fn add_image_assets(&self, assets: &mut Vec<Asset>) {
|
||||||
|
// Логотипы команд — НЕ кешируем (часто меняются)
|
||||||
if !self.team_a_logo.is_empty() {
|
if !self.team_a_logo.is_empty() {
|
||||||
assets.push(Asset::Image {
|
assets.push(Asset::Image {
|
||||||
src: self.team_a_logo.clone(),
|
src: self.team_a_logo.clone(),
|
||||||
asset_type: "image".to_string(),
|
asset_type: "image".to_string(),
|
||||||
layer_name: "TEAM_A_LOGO".to_string(),
|
layer_name: "TEAM_A_LOGO".to_string(),
|
||||||
|
cache: false,
|
||||||
});
|
});
|
||||||
if let Some(res) = &self.team_a_logo_res {
|
if let Some(res) = &self.team_a_logo_res {
|
||||||
assets.push(Asset::DataExpression {
|
assets.push(Asset::DataExpression {
|
||||||
@@ -497,6 +533,7 @@ impl JobData {
|
|||||||
src: self.team_b_logo.clone(),
|
src: self.team_b_logo.clone(),
|
||||||
asset_type: "image".to_string(),
|
asset_type: "image".to_string(),
|
||||||
layer_name: "TEAM_B_LOGO".to_string(),
|
layer_name: "TEAM_B_LOGO".to_string(),
|
||||||
|
cache: false,
|
||||||
});
|
});
|
||||||
if let Some(res) = &self.team_b_logo_res {
|
if let Some(res) = &self.team_b_logo_res {
|
||||||
assets.push(Asset::DataExpression {
|
assets.push(Asset::DataExpression {
|
||||||
@@ -507,11 +544,13 @@ impl JobData {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Логотип канала — КЕШИРУЕМ (редко меняется)
|
||||||
if !self.channel_logo.is_empty() {
|
if !self.channel_logo.is_empty() {
|
||||||
assets.push(Asset::Image {
|
assets.push(Asset::Image {
|
||||||
src: self.channel_logo.clone(),
|
src: self.channel_logo.clone(),
|
||||||
asset_type: "image".to_string(),
|
asset_type: "image".to_string(),
|
||||||
layer_name: "CHANELL".to_string(),
|
layer_name: "CHANELL".to_string(),
|
||||||
|
cache: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,7 +576,6 @@ impl LogoRegistry {
|
|||||||
self.logos.insert((team, sport), link);
|
self.logos.insert((team, sport), link);
|
||||||
}
|
}
|
||||||
|
|
||||||
// В impl LogoRegistry, функция find()
|
|
||||||
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
||||||
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
|
log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport);
|
||||||
|
|
||||||
@@ -556,6 +594,19 @@ impl LogoRegistry {
|
|||||||
log::warn!("No logo found for team='{}', sport='{}'", team, sport);
|
log::warn!("No logo found for team='{}', sport='{}'", team, sport);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Оставляет только указанные команды, удаляя все остальные
|
||||||
|
pub fn retain<F>(&mut self, mut predicate: F)
|
||||||
|
where
|
||||||
|
F: FnMut(&str, &str) -> bool,
|
||||||
|
{
|
||||||
|
self.logos.retain(|(team, sport), _| predicate(team, sport));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает количество записей в реестре
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.logos.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_date(s: &str) -> (NaiveDate, String) {
|
fn parse_date(s: &str) -> (NaiveDate, String) {
|
||||||
@@ -631,6 +682,7 @@ fn unc_to_uri(path: &str) -> String {
|
|||||||
|
|
||||||
format!("file://{}", trimmed)
|
format!("file://{}", trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logo_scale_expression(target_size: &str) -> String {
|
fn logo_scale_expression(target_size: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
||||||
|
|||||||
189
src/processor.rs
189
src/processor.rs
@@ -274,55 +274,51 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn generate_and_submit_jobs(
|
// Вспомогательная функция для поиска значения по части ключа
|
||||||
workbook: &ExcelWorkbook,
|
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
|
||||||
config: &Config,
|
|
||||||
) -> Result<Vec<(String, String)>> {
|
|
||||||
info!("Preparing Nexrender jobs...");
|
|
||||||
|
|
||||||
let start_sheet = workbook
|
|
||||||
.get_sheet("Start")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'Start' not found"))?;
|
|
||||||
let sport_sheet = workbook
|
|
||||||
.get_sheet("SPORT")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'SPORT' not found"))?;
|
|
||||||
let teams_sheet = workbook
|
|
||||||
.get_sheet("TEAMS")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
|
|
||||||
let channel_sheet = workbook
|
|
||||||
.get_sheet("CHANELL")
|
|
||||||
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
|
||||||
|
|
||||||
// Загружаем sport packs
|
|
||||||
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());
|
|
||||||
|
|
||||||
// Вспомогательная функция для поиска значения по части ключа
|
|
||||||
fn get_cell_fuzzy(row: &HashMap<String, String>, key_part: &str) -> Option<String> {
|
|
||||||
// Точное совпадение
|
|
||||||
if let Some(val) = row.get(key_part) {
|
if let Some(val) = row.get(key_part) {
|
||||||
if !val.is_empty() {
|
if !val.is_empty() {
|
||||||
return Some(val.clone());
|
return Some(val.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Поиск по содержанию (без учёта регистра)
|
|
||||||
for (k, v) in row.iter() {
|
for (k, v) in row.iter() {
|
||||||
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
|
if k.to_lowercase().contains(&key_part.to_lowercase()) && !v.is_empty() {
|
||||||
return Some(v.clone());
|
return Some(v.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn generate_and_submit_jobs(
|
||||||
|
workbook: &ExcelWorkbook,
|
||||||
|
config: &Config,
|
||||||
|
) -> Result<Vec<(String, String)>> {
|
||||||
|
info!("Preparing Nexrender jobs...");
|
||||||
|
|
||||||
|
// ========== ЗАГРУЖАЕМ 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());
|
||||||
|
|
||||||
|
// ========== ЗАГРУЖАЕМ TEAM LOGOS ==========
|
||||||
|
let teams_sheet = workbook
|
||||||
|
.get_sheet("TEAMS")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'TEAMS' not found"))?;
|
||||||
|
|
||||||
// Загружаем team logos
|
|
||||||
debug!("TEAMS headers: {:?}", teams_sheet.headers);
|
debug!("TEAMS headers: {:?}", teams_sheet.headers);
|
||||||
|
if log_enabled!(Level::Debug) {
|
||||||
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
|
for (i, row) in teams_sheet.rows.iter().take(3).enumerate() {
|
||||||
debug!("TEAMS row {}: {:?}", i, row);
|
debug!("TEAMS row {}: {:?}", i, row);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
|
let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
|
||||||
for row in &teams_sheet.rows {
|
for row in &teams_sheet.rows {
|
||||||
@@ -334,7 +330,8 @@ async fn generate_and_submit_jobs(
|
|||||||
logos.insert(team.clone(), sport.clone(), link.clone());
|
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);
|
||||||
|
|
||||||
// Дебаг: выводим статистику по TEAMS
|
// Дебаг: выводим статистику по TEAMS
|
||||||
if log_enabled!(Level::Debug) {
|
if log_enabled!(Level::Debug) {
|
||||||
@@ -345,19 +342,23 @@ async fn generate_and_submit_jobs(
|
|||||||
.collect();
|
.collect();
|
||||||
debug!("Sports found in TEAMS sheet: {:?}", sports);
|
debug!("Sports found in TEAMS sheet: {:?}", sports);
|
||||||
|
|
||||||
for sport in sports.iter() {
|
for sport in sports.iter().take(5) {
|
||||||
let examples: Vec<_> = teams_sheet
|
let examples: Vec<_> = teams_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.get("SPORT") == Some(*sport))
|
.filter(|r| r.get("SPORT") == Some(*sport))
|
||||||
.take(5)
|
.take(3)
|
||||||
.filter_map(|r| r.get("TEAM"))
|
.filter_map(|r| r.get("TEAM"))
|
||||||
.collect();
|
.collect();
|
||||||
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
|
debug!(" TEAMS examples for '{}': {:?}", sport, examples);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем channel logos
|
// ========== ЗАГРУЖАЕМ CHANNEL LOGOS ==========
|
||||||
|
let channel_sheet = workbook
|
||||||
|
.get_sheet("CHANELL")
|
||||||
|
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
|
||||||
|
|
||||||
let channels: HashMap<String, String> = channel_sheet
|
let channels: HashMap<String, String> = channel_sheet
|
||||||
.rows
|
.rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -369,19 +370,48 @@ async fn generate_and_submit_jobs(
|
|||||||
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
|
debug!("Channels loaded: {:?}", channels.keys().collect::<Vec<_>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
|
// ========== ОБРАБАТЫВАЕМ 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(),
|
||||||
|
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_teams: HashSet<String> = HashSet::new();
|
||||||
let mut missing_sports: HashSet<String> = HashSet::new();
|
let mut missing_sports: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
for (idx, row) in start_sheet.rows.iter().enumerate() {
|
for (idx, row) in active_rows {
|
||||||
if let Some(state) = row.get("STATE") {
|
|
||||||
if state == "FALSE" {
|
|
||||||
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
||||||
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
||||||
let sport = row.get("SPORT").cloned().unwrap_or_default();
|
let sport = row.get("SPORT").cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
// Запоминаем использованные команды
|
||||||
|
if !team_a.is_empty() {
|
||||||
|
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) {
|
if log_enabled!(Level::Debug) {
|
||||||
let logo_a = logos.find(&team_a, &sport);
|
let logo_a = logos.find(&team_a, &sport);
|
||||||
let logo_b = logos.find(&team_b, &sport);
|
let logo_b = logos.find(&team_b, &sport);
|
||||||
@@ -400,8 +430,15 @@ async fn generate_and_submit_jobs(
|
|||||||
jobs.extend(job.create_variants());
|
jobs.extend(job.create_variants());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
// Очищаем 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()
|
||||||
|
);
|
||||||
|
|
||||||
// Дебаг: выводим сводку по отсутствующим логотипам
|
// Дебаг: выводим сводку по отсутствующим логотипам
|
||||||
if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
|
if log_enabled!(Level::Debug) && !missing_teams.is_empty() {
|
||||||
@@ -417,24 +454,43 @@ async fn generate_and_submit_jobs(
|
|||||||
|
|
||||||
info!("Generated {} total jobs (including variants)", jobs.len());
|
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),
|
||||||
|
other => other,
|
||||||
|
},
|
||||||
|
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() {
|
if jobs.is_empty() {
|
||||||
info!("No jobs with STATE='FALSE' found");
|
info!("No jobs with STATE='FALSE' found");
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== ОТПРАВКА ЗАДАНИЙ (ПОСЛЕДОВАТЕЛЬНО) ==========
|
||||||
cleanup_finished_jobs(&config.nexrender_api_url).await?;
|
cleanup_finished_jobs(&config.nexrender_api_url).await?;
|
||||||
|
|
||||||
let http_client = Client::new();
|
let http_client = Client::new();
|
||||||
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
|
let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
|
||||||
|
|
||||||
let mut tasks = Vec::with_capacity(jobs.len());
|
|
||||||
for job in jobs {
|
for job in jobs {
|
||||||
let client = http_client.clone();
|
let nexrender_job = job.to_nexrender_job(config);
|
||||||
let api_url = config.nexrender_api_url.clone();
|
|
||||||
let config_clone = config.clone();
|
|
||||||
|
|
||||||
tasks.push(tokio::spawn(async move {
|
|
||||||
let nexrender_job = job.to_nexrender_job(&config_clone);
|
|
||||||
info!("Submitting job: {}", job.outfile_name);
|
info!("Submitting job: {}", job.outfile_name);
|
||||||
|
|
||||||
if log_enabled!(Level::Debug) {
|
if log_enabled!(Level::Debug) {
|
||||||
@@ -444,39 +500,22 @@ async fn generate_and_submit_jobs(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = client.post(&api_url).json(&nexrender_job).send().await?;
|
let response = http_client
|
||||||
|
.post(&config.nexrender_api_url)
|
||||||
|
.json(&nexrender_job)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
let result: Value = response.json().await?;
|
let result: Value = response.json().await?;
|
||||||
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
|
||||||
Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name)))
|
info!("Job submitted: {} (UID: {})", job.outfile_name, uid);
|
||||||
} else {
|
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let text = response.text().await.unwrap_or_default();
|
let text = response.text().await.unwrap_or_default();
|
||||||
error!("Failed to submit job ({}): {}", status, text);
|
error!("Failed to submit job ({}): {}", status, text);
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
for task in tasks {
|
|
||||||
match task.await {
|
|
||||||
Ok(Ok(Some((uid, outfile_name)))) => {
|
|
||||||
info!("Job submitted: {}", outfile_name);
|
|
||||||
submitted_jobs.push((uid, outfile_name));
|
|
||||||
}
|
|
||||||
Ok(Ok(None)) => {
|
|
||||||
debug!("Job submission returned no UID");
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("Job submission error: {}", e);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("Task join error: {}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
src/static/favicon.ico
Normal file
BIN
src/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
8788
src/static/fontawesome/all.min.css
vendored
Normal file
8788
src/static/fontawesome/all.min.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/static/fontawesome/webfonts/fa-brands-400.woff2
Normal file
BIN
src/static/fontawesome/webfonts/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
src/static/fontawesome/webfonts/fa-regular-400.woff2
Normal file
BIN
src/static/fontawesome/webfonts/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
src/static/fontawesome/webfonts/fa-solid-900.woff2
Normal file
BIN
src/static/fontawesome/webfonts/fa-solid-900.woff2
Normal file
Binary file not shown.
@@ -4,8 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AE Anons - Nexrender Job Manager</title>
|
<title>AE Anons</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
<link rel="stylesheet" href="/static/fontawesome/all.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -15,13 +16,9 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo"
|
<img src="/assets/logo.png" alt="AE Anons Logo" class="logo" id="logo">
|
||||||
onerror="this.style.display='none'; document.getElementById('logoPlaceholder').style.display='flex';">
|
|
||||||
<div class="logo-placeholder" id="logoPlaceholder" style="display: none;">
|
|
||||||
<i class="fas fa-bolt"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<h1>AE Anons</h1>
|
||||||
<h1>AE Anons - Nexrender Job Manager</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<button class="theme-toggle" onclick="toggleTheme()">
|
<button class="theme-toggle" onclick="toggleTheme()">
|
||||||
@@ -88,11 +85,13 @@
|
|||||||
<th data-column="uid" onclick="sortTable('uid')">
|
<th data-column="uid" onclick="sortTable('uid')">
|
||||||
UID <i class="fas fa-sort"></i>
|
UID <i class="fas fa-sort"></i>
|
||||||
</th>
|
</th>
|
||||||
|
<th>Preview</th>
|
||||||
|
<th>Move</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="jobsTableBody">
|
<tbody id="jobsTableBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="7">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fas fa-spinner fa-spin"></i>
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
<p>Loading jobs...</p>
|
<p>Loading jobs...</p>
|
||||||
@@ -104,8 +103,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Player Modal -->
|
||||||
|
<div id="playerModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" onclick="closePlayer()">×</span>
|
||||||
|
<video id="videoPlayer" controls style="width:100%; max-height:70vh;"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// State
|
// ========== ОРИГИНАЛЬНЫЙ КОД (Задания) ==========
|
||||||
let allJobs = [];
|
let allJobs = [];
|
||||||
let filteredJobs = [];
|
let filteredJobs = [];
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
@@ -113,82 +120,47 @@
|
|||||||
let countdownTimer = null;
|
let countdownTimer = null;
|
||||||
let countdownValue = 60;
|
let countdownValue = 60;
|
||||||
let currentTheme = 'auto';
|
let currentTheme = 'auto';
|
||||||
|
|
||||||
// Sorting state
|
|
||||||
let currentSort = { column: 'state', direction: 'asc' };
|
let currentSort = { column: 'state', direction: 'asc' };
|
||||||
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
|
const stateOrder = { 'finished': 1, 'started': 2, 'processing': 3, 'queued': 4, 'pending': 5, 'error': 6 };
|
||||||
|
|
||||||
// Theme handling
|
|
||||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
function getOSPreference() { return darkModeMediaQuery.matches ? 'dark' : 'light'; }
|
||||||
function getOSPreference() {
|
|
||||||
return darkModeMediaQuery.matches ? 'dark' : 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme) {
|
function applyTheme(theme) {
|
||||||
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
|
const effectiveTheme = theme === 'auto' ? getOSPreference() : theme;
|
||||||
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
document.documentElement.setAttribute('data-theme', effectiveTheme);
|
||||||
updateThemeButton(theme);
|
updateThemeButton(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateThemeButton(theme) {
|
function updateThemeButton(theme) {
|
||||||
const icon = document.getElementById('themeIcon');
|
const icon = document.getElementById('themeIcon');
|
||||||
const text = document.getElementById('themeText');
|
const text = document.getElementById('themeText');
|
||||||
|
if (theme === 'auto') { icon.className = 'fas fa-circle-half-stroke'; text.textContent = 'Auto'; }
|
||||||
if (theme === 'auto') {
|
else if (theme === 'dark') { icon.className = 'fas fa-moon'; text.textContent = 'Dark'; }
|
||||||
icon.className = 'fas fa-circle-half-stroke';
|
else { icon.className = 'fas fa-sun'; text.textContent = 'Light'; }
|
||||||
text.textContent = 'Auto';
|
|
||||||
} else if (theme === 'dark') {
|
|
||||||
icon.className = 'fas fa-moon';
|
|
||||||
text.textContent = 'Dark';
|
|
||||||
} else {
|
|
||||||
icon.className = 'fas fa-sun';
|
|
||||||
text.textContent = 'Light';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
if (currentTheme === 'auto') {
|
if (currentTheme === 'auto') currentTheme = 'light';
|
||||||
currentTheme = 'light';
|
else if (currentTheme === 'light') currentTheme = 'dark';
|
||||||
} else if (currentTheme === 'light') {
|
else currentTheme = 'auto';
|
||||||
currentTheme = 'dark';
|
|
||||||
} else {
|
|
||||||
currentTheme = 'auto';
|
|
||||||
}
|
|
||||||
localStorage.setItem('theme', currentTheme);
|
localStorage.setItem('theme', currentTheme);
|
||||||
applyTheme(currentTheme);
|
applyTheme(currentTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
const savedTheme = localStorage.getItem('theme') || 'auto';
|
const savedTheme = localStorage.getItem('theme') || 'auto';
|
||||||
currentTheme = savedTheme;
|
currentTheme = savedTheme;
|
||||||
applyTheme(currentTheme);
|
applyTheme(currentTheme);
|
||||||
|
darkModeMediaQuery.addEventListener('change', () => { if (currentTheme === 'auto') applyTheme('auto'); });
|
||||||
darkModeMediaQuery.addEventListener('change', (e) => {
|
|
||||||
if (currentTheme === 'auto') {
|
|
||||||
applyTheme('auto');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
if (currentSort.column === column) {
|
if (currentSort.column === column) currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
else { currentSort.column = column; currentSort.direction = 'asc'; }
|
||||||
} else {
|
|
||||||
currentSort.column = column;
|
|
||||||
currentSort.direction = 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSortIcons();
|
updateSortIcons();
|
||||||
sortAndRender();
|
sortAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSortIcons() {
|
function updateSortIcons() {
|
||||||
document.querySelectorAll('th').forEach(th => {
|
document.querySelectorAll('th').forEach(th => {
|
||||||
const column = th.dataset.column;
|
const column = th.dataset.column;
|
||||||
th.classList.remove('sorted-asc', 'sorted-desc');
|
th.classList.remove('sorted-asc', 'sorted-desc');
|
||||||
|
|
||||||
const icon = th.querySelector('i');
|
const icon = th.querySelector('i');
|
||||||
if (column === currentSort.column) {
|
if (column === currentSort.column) {
|
||||||
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
th.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
||||||
@@ -198,58 +170,30 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortJobs(jobs) {
|
function sortJobs(jobs) {
|
||||||
const { column, direction } = currentSort;
|
const { column, direction } = currentSort;
|
||||||
const multiplier = direction === 'asc' ? 1 : -1;
|
const multiplier = direction === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
return [...jobs].sort((a, b) => {
|
return [...jobs].sort((a, b) => {
|
||||||
let aVal, bVal;
|
let aVal, bVal;
|
||||||
|
|
||||||
switch (column) {
|
switch (column) {
|
||||||
case 'filename':
|
case 'filename': aVal = a.outfile_name || ''; bVal = b.outfile_name || ''; return multiplier * aVal.localeCompare(bVal);
|
||||||
aVal = a.outfile_name || '';
|
case 'state': aVal = stateOrder[a.state] || 999; bVal = stateOrder[b.state] || 999; return multiplier * (aVal - bVal);
|
||||||
bVal = b.outfile_name || '';
|
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);
|
||||||
return multiplier * aVal.localeCompare(bVal);
|
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);
|
||||||
case 'state':
|
default: return 0;
|
||||||
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:
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortAndRender() {
|
function sortAndRender() {
|
||||||
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ?
|
const jobsToRender = filteredJobs.length > 0 || document.getElementById('filterInput').value ? filteredJobs : allJobs;
|
||||||
filteredJobs : allJobs;
|
|
||||||
const sorted = sortJobs(jobsToRender);
|
const sorted = sortJobs(jobsToRender);
|
||||||
renderJobs(sorted);
|
renderJobs(sorted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data fetching
|
|
||||||
async function refreshJobs() {
|
async function refreshJobs() {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus('loading', 'Loading jobs...');
|
setStatus('loading', 'Loading jobs...');
|
||||||
const response = await fetch('/api/jobs');
|
const response = await fetch('/api/jobs');
|
||||||
@@ -262,130 +206,73 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setStatus('error', 'Failed to load jobs');
|
setStatus('error', 'Failed to load jobs');
|
||||||
} finally {
|
} finally { isLoading = false; }
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobs(jobs) {
|
function renderJobs(jobs) {
|
||||||
const tbody = document.getElementById('jobsTableBody');
|
const tbody = document.getElementById('jobsTableBody');
|
||||||
|
|
||||||
if (jobs.length === 0) {
|
if (jobs.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><i class="fas fa-inbox"></i><p>No jobs found</p></div></td></tr>';
|
||||||
<tr>
|
|
||||||
<td colspan="5">
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-inbox"></i>
|
|
||||||
<p>No jobs found</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = jobs.map(job => {
|
tbody.innerHTML = jobs.map(job => {
|
||||||
const stateClass = getStateClass(job.state);
|
const stateClass = getStateClass(job.state);
|
||||||
const created = formatDateTime(job.created_at);
|
const created = formatDateTime(job.created_at);
|
||||||
const updated = formatDateTime(job.updated_at);
|
const updated = formatDateTime(job.updated_at);
|
||||||
|
const previewBtn = job.state === 'finished'
|
||||||
return `
|
? `<button class="btn btn-outline btn-sm" onclick="playVideo('${escapeHtml(job.outfile_name)}')"><i class="fas fa-play"></i></button>`
|
||||||
<tr>
|
: '';
|
||||||
<td>
|
// Заглушка‑иконка, обновится асинхронно
|
||||||
<div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div>
|
const moveIcon = job.state === 'finished'
|
||||||
</td>
|
? `<span class="move-icon" data-file="${escapeHtml(job.outfile_name)}"><i class="fas fa-spinner fa-spin"></i></span>`
|
||||||
|
: '';
|
||||||
|
return `<tr>
|
||||||
|
<td><div class="job-filename" title="${escapeHtml(job.outfile_name)}">${escapeHtml(job.outfile_name)}</div></td>
|
||||||
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
|
<td><span class="badge ${stateClass}">${escapeHtml(job.state)}</span></td>
|
||||||
<td class="datetime">${created}</td>
|
<td class="datetime">${created}</td>
|
||||||
<td class="datetime">${updated}</td>
|
<td class="datetime">${updated}</td>
|
||||||
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
|
<td class="uid" title="${escapeHtml(job.uid)}">${job.uid.substring(0, 10)}...</td>
|
||||||
</tr>
|
<td>${previewBtn}</td>
|
||||||
`;
|
<td>${moveIcon}</td>
|
||||||
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
// Запускаем проверку статусов после рендера
|
||||||
|
updateMoveStatuses();
|
||||||
}
|
}
|
||||||
|
function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStateClass(state) {
|
function getStateClass(state) {
|
||||||
const classes = {
|
const classes = { 'finished': 'badge-finished', 'started': 'badge-started', 'processing': 'badge-processing', 'queued': 'badge-queued', 'error': 'badge-error', 'pending': 'badge-pending' };
|
||||||
'finished': 'badge-finished',
|
|
||||||
'started': 'badge-started',
|
|
||||||
'processing': 'badge-processing',
|
|
||||||
'queued': 'badge-queued',
|
|
||||||
'error': 'badge-error',
|
|
||||||
'pending': 'badge-pending'
|
|
||||||
};
|
|
||||||
return classes[state] || 'badge-pending';
|
return classes[state] || 'badge-pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(dateStr) {
|
function formatDateTime(dateStr) {
|
||||||
if (!dateStr) return '-';
|
if (!dateStr) return '-';
|
||||||
try {
|
try { const date = new Date(dateStr); return `${date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}, ${date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })}`; }
|
||||||
const date = new Date(dateStr);
|
catch { return 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() {
|
function updateStats() {
|
||||||
const stats = {
|
const stats = { total: allJobs.length, finished: 0, started: 0, queued: 0, error: 0, other: 0 };
|
||||||
total: allJobs.length,
|
|
||||||
finished: 0,
|
|
||||||
started: 0,
|
|
||||||
queued: 0,
|
|
||||||
error: 0,
|
|
||||||
other: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
allJobs.forEach(job => {
|
allJobs.forEach(job => {
|
||||||
switch (job.state) {
|
switch (job.state) {
|
||||||
case 'finished': stats.finished++; break;
|
case 'finished': stats.finished++; break;
|
||||||
case 'started':
|
case 'started': case 'processing': stats.started++; break;
|
||||||
case 'processing': stats.started++; break;
|
|
||||||
case 'queued': stats.queued++; break;
|
case 'queued': stats.queued++; break;
|
||||||
case 'error': stats.error++; break;
|
case 'error': stats.error++; break;
|
||||||
default: stats.other++; break;
|
default: stats.other++; break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.getElementById('statsGrid').innerHTML = `
|
||||||
const grid = document.getElementById('statsGrid');
|
<div class="stat-card"><h3><i class="fas fa-tasks"></i> Total Jobs</h3><div class="value">${stats.total}</div></div>
|
||||||
grid.innerHTML = `
|
<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">
|
<div class="stat-card"><h3><i class="fas fa-play-circle"></i> Active</h3><div class="value">${stats.started + stats.queued}</div></div>
|
||||||
<h3><i class="fas fa-tasks"></i> Total Jobs</h3>
|
<div class="stat-card"><h3><i class="fas fa-exclamation-circle"></i> Errors</h3><div class="value">${stats.error}</div></div>`;
|
||||||
<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() {
|
function filterTable() {
|
||||||
const filter = document.getElementById('filterInput').value.toLowerCase();
|
const filter = document.getElementById('filterInput').value.toLowerCase();
|
||||||
filteredJobs = allJobs.filter(job =>
|
filteredJobs = allJobs.filter(job => job.outfile_name.toLowerCase().includes(filter) || job.uid.toLowerCase().includes(filter));
|
||||||
job.outfile_name.toLowerCase().includes(filter) ||
|
|
||||||
job.uid.toLowerCase().includes(filter)
|
|
||||||
);
|
|
||||||
sortAndRender();
|
sortAndRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
// Оригинальные реализации generateJobs, stopAllJobs, cleanupJobs, setStatus, startAutoRefresh, stopAutoRefresh, resetCountdown
|
||||||
async function generateJobs() {
|
async function generateJobs() {
|
||||||
setStatus('loading', 'Generating jobs...');
|
setStatus('loading', 'Generating jobs...');
|
||||||
try {
|
try {
|
||||||
@@ -404,7 +291,6 @@
|
|||||||
|
|
||||||
async function stopAllJobs() {
|
async function stopAllJobs() {
|
||||||
if (!confirm('Are you sure you want to stop all active jobs?')) return;
|
if (!confirm('Are you sure you want to stop all active jobs?')) return;
|
||||||
|
|
||||||
setStatus('loading', 'Stopping all jobs...');
|
setStatus('loading', 'Stopping all jobs...');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
|
const response = await fetch('/api/jobs/stop-all', { method: 'POST' });
|
||||||
@@ -446,21 +332,13 @@
|
|||||||
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
|
el.innerHTML = `${icons[type] || ''} <span>${message}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh
|
|
||||||
function startAutoRefresh() {
|
function startAutoRefresh() {
|
||||||
stopAutoRefresh();
|
stopAutoRefresh();
|
||||||
|
autoRefreshTimer = setInterval(() => { refreshJobs(); resetCountdown(); }, 60000);
|
||||||
autoRefreshTimer = setInterval(() => {
|
|
||||||
refreshJobs();
|
|
||||||
resetCountdown();
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
countdownTimer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
countdownValue--;
|
countdownValue--;
|
||||||
document.getElementById('refreshCountdown').textContent = countdownValue;
|
document.getElementById('refreshCountdown').textContent = countdownValue;
|
||||||
if (countdownValue <= 0) {
|
if (countdownValue <= 0) countdownValue = 60;
|
||||||
countdownValue = 60;
|
|
||||||
}
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,15 +352,56 @@
|
|||||||
document.getElementById('refreshCountdown').textContent = countdownValue;
|
document.getElementById('refreshCountdown').textContent = countdownValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize
|
// ========== НОВЫЙ КОД: Видео ==========
|
||||||
|
function playVideo(filename) {
|
||||||
|
const player = document.getElementById('videoPlayer');
|
||||||
|
player.src = '/api/videos/' + encodeURIComponent(filename);
|
||||||
|
document.getElementById('playerModal').classList.add('active');
|
||||||
|
}
|
||||||
|
function closePlayer() {
|
||||||
|
const player = document.getElementById('videoPlayer');
|
||||||
|
player.pause();
|
||||||
|
player.src = '';
|
||||||
|
document.getElementById('playerModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveVideo(filename) {
|
||||||
|
if (!confirm(`Переместить файл "${filename}" в целевую папку?`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/videos/' + encodeURIComponent(filename) + '/move?remove=true', { method: 'POST' });
|
||||||
|
if (!response.ok) throw new Error(await response.text());
|
||||||
|
alert('Файл успешно перемещён.');
|
||||||
|
refreshJobs(); // обновим список, возможно, файл исчезнет
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка перемещения: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMoveStatuses() {
|
||||||
|
const icons = document.querySelectorAll('.move-icon');
|
||||||
|
for (const icon of icons) {
|
||||||
|
const filename = icon.dataset.file;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/videos/' + encodeURIComponent(filename) + '/status');
|
||||||
|
const status = await resp.json();
|
||||||
|
const exists = status.exists_in_dest;
|
||||||
|
const subfolder = status.subfolder;
|
||||||
|
if (exists) {
|
||||||
|
icon.innerHTML = `<i class="fas fa-check-circle" style="color: var(--accent-success);" title="Already in ${subfolder}"></i>`;
|
||||||
|
} else {
|
||||||
|
icon.innerHTML = `<button class="btn btn-primary btn-sm" onclick="moveVideo('${escapeHtml(filename)}')"><i class="fas fa-share"></i></button>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
icon.innerHTML = `<i class="fas fa-question-circle" title="Status unknown"></i>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ИНИЦИАЛИЗАЦИЯ ==========
|
||||||
initTheme();
|
initTheme();
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
startAutoRefresh();
|
startAutoRefresh();
|
||||||
updateSortIcons();
|
updateSortIcons();
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
|
||||||
stopAutoRefresh();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -553,3 +553,47 @@ tr:hover {
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Модальное окно плеера */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 16px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки в таблице (уменьшенный размер) */
|
||||||
|
.btn-sm {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
287
src/web.rs
287
src/web.rs
@@ -1,8 +1,12 @@
|
|||||||
use crate::config::Config;
|
use crate::config::{AccessType, Config};
|
||||||
|
use crate::file_access::{
|
||||||
|
FileAccess, LocalFileAccess, SmbAccessConfig, SmbFileAccess, SynologyFileAccess,
|
||||||
|
};
|
||||||
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
|
use crate::processor::{cleanup_finished_jobs, fetch_all_jobs, process_spreadsheet};
|
||||||
|
use crate::synology::SynologyClient;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::{header, HeaderMap, StatusCode},
|
||||||
response::{Html, IntoResponse, Json},
|
response::{Html, IntoResponse, Json},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
@@ -11,13 +15,14 @@ use serde::Serialize;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
|
pub last_generation: Arc<Mutex<Option<chrono::DateTime<chrono::Local>>>>,
|
||||||
|
pub source_access: Arc<dyn FileAccess>,
|
||||||
|
pub dest_access: Arc<dyn FileAccess>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -34,43 +39,32 @@ impl JobInfo {
|
|||||||
let uid = job.get("uid")?.as_str()?.to_string();
|
let uid = job.get("uid")?.as_str()?.to_string();
|
||||||
let state = job.get("state")?.as_str()?.to_string();
|
let state = job.get("state")?.as_str()?.to_string();
|
||||||
|
|
||||||
// Извлекаем имя выходного файла из postrender actions
|
|
||||||
let outfile_name = job
|
let outfile_name = job
|
||||||
.get("actions")
|
.get("actions")
|
||||||
.and_then(|a| a.get("postrender"))
|
.and_then(|a| a.get("postrender"))
|
||||||
.and_then(|p| p.as_array())
|
.and_then(|p| p.as_array())
|
||||||
.and_then(|arr| {
|
.and_then(|arr| {
|
||||||
// Ищем действие copy (в нём финальный путь)
|
|
||||||
arr.iter()
|
arr.iter()
|
||||||
.find_map(|action| {
|
.find_map(|action| {
|
||||||
// Проверяем, что это действие copy
|
|
||||||
action
|
action
|
||||||
.get("module")
|
.get("module")
|
||||||
.and_then(|m| m.as_str())
|
.and_then(|m| m.as_str())
|
||||||
.filter(|&m| m == "@nexrender/action-copy")
|
.filter(|&m| m == "@nexrender/action-copy")
|
||||||
.and_then(|_| {
|
.and_then(|_| action.get("output").and_then(|o| o.as_str()))
|
||||||
// Извлекаем output из copy
|
|
||||||
action.get("output").and_then(|o| o.as_str())
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
// Если copy не найдено, пробуем encode
|
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
arr.iter()
|
arr.iter()
|
||||||
.find_map(|action| action.get("output").and_then(|o| o.as_str()))
|
.find_map(|action| action.get("output").and_then(|o| o.as_str()))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map(|path| {
|
.map(|path| {
|
||||||
// Извлекаем только имя файла из полного пути
|
|
||||||
std::path::Path::new(path)
|
std::path::Path::new(path)
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
.to_string()
|
.to_string()
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| format!("job_{}", uid));
|
||||||
// Если не удалось извлечь, используем UID
|
|
||||||
format!("job_{}", uid)
|
|
||||||
});
|
|
||||||
|
|
||||||
Some(JobInfo {
|
Some(JobInfo {
|
||||||
uid,
|
uid,
|
||||||
@@ -88,38 +82,124 @@ impl JobInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// web.rs - функция run_web_server
|
|
||||||
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
pub async fn run_web_server(config: Config) -> anyhow::Result<()> {
|
||||||
let web_port = config.web_port; // Берём порт из конфига
|
let source_access = create_file_access(&config.source, &config).await?;
|
||||||
|
let dest_access = create_file_access(&config.destination, &config).await?;
|
||||||
|
|
||||||
|
let web_port = config.web_port;
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
config,
|
config,
|
||||||
last_generation: Arc::new(Mutex::new(None)),
|
last_generation: Arc::new(Mutex::new(None)),
|
||||||
|
source_access,
|
||||||
|
dest_access,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(index_page))
|
.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/jobs", get(list_jobs))
|
||||||
.route("/api/generate", post(generate_jobs))
|
.route("/api/generate", post(generate_jobs))
|
||||||
.route("/api/cleanup", post(cleanup_jobs))
|
.route("/api/cleanup", post(cleanup_jobs))
|
||||||
.route("/api/status", get(get_status))
|
.route("/api/status", get(get_status))
|
||||||
.route("/api/jobs/stop-all", post(stop_all_jobs))
|
.route("/api/jobs/stop-all", post(stop_all_jobs))
|
||||||
.nest_service("/static", ServeDir::new("src/static"))
|
.route("/api/videos", get(list_videos))
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.route("/api/videos/{filename}", get(stream_video))
|
||||||
|
.route("/api/videos/{filename}/move", post(move_video))
|
||||||
|
.route("/api/videos/{filename}/status", get(get_video_status))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
|
let addr: SocketAddr = format!("0.0.0.0:{}", web_port).parse()?;
|
||||||
log::info!("Web server listening on http://{}", addr);
|
log::info!("Web server listening on http://{}", addr);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_file_access(
|
||||||
|
access_config: &crate::config::AccessConfig,
|
||||||
|
nas_config: &Config,
|
||||||
|
) -> anyhow::Result<Arc<dyn FileAccess>> {
|
||||||
|
match access_config.access_type {
|
||||||
|
AccessType::Local => {
|
||||||
|
let path = access_config.local_path.as_ref().unwrap();
|
||||||
|
Ok(Arc::new(LocalFileAccess::new(path)))
|
||||||
|
}
|
||||||
|
AccessType::Smb => {
|
||||||
|
let url = access_config.smb_url.as_ref().unwrap().clone();
|
||||||
|
let user = access_config.smb_user.clone();
|
||||||
|
let password = access_config.smb_pass.clone();
|
||||||
|
let domain = access_config.smb_domain.clone();
|
||||||
|
let smb_config = SmbAccessConfig {
|
||||||
|
url,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
domain,
|
||||||
|
};
|
||||||
|
let access: Arc<dyn FileAccess> = Arc::new(SmbFileAccess::new(smb_config));
|
||||||
|
Ok(access)
|
||||||
|
}
|
||||||
|
AccessType::Synology => {
|
||||||
|
let mut client = SynologyClient::new(&nas_config.nas_fqdn);
|
||||||
|
client
|
||||||
|
.login(&nas_config.nas_user, &nas_config.nas_pass)
|
||||||
|
.await?;
|
||||||
|
let base_path = access_config.synology_path.as_ref().unwrap().clone();
|
||||||
|
Ok(Arc::new(SynologyFileAccess::new(client, &base_path)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статические ресурсы
|
||||||
async fn index_page() -> Html<&'static str> {
|
async fn index_page() -> Html<&'static str> {
|
||||||
Html(include_str!("static/index.html"))
|
Html(include_str!("static/index.html"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn style_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/css")],
|
||||||
|
include_str!("static/style.css"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fontawesome_css() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "text/css")],
|
||||||
|
include_str!("static/fontawesome/all.min.css"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logo_png() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/png")],
|
||||||
|
include_bytes!("../assets/logo.png").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn favicon() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "image/x-icon")],
|
||||||
|
include_bytes!("static/favicon.ico").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Управление заданиями Nexrender
|
||||||
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
|
async fn list_jobs(State(state): State<AppState>) -> Result<Json<Vec<JobInfo>>, AppError> {
|
||||||
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
|
let jobs_json = fetch_all_jobs(&state.config.nexrender_api_url)
|
||||||
.await
|
.await
|
||||||
@@ -187,7 +267,6 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
|
|||||||
let client = Client::new();
|
let client = Client::new();
|
||||||
let api_url = &state.config.nexrender_api_url;
|
let api_url = &state.config.nexrender_api_url;
|
||||||
|
|
||||||
// Получаем все задания
|
|
||||||
let jobs = fetch_all_jobs(api_url)
|
let jobs = fetch_all_jobs(api_url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
@@ -197,7 +276,6 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
|
|||||||
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
|
let state = job.get("state").and_then(|s| s.as_str()).unwrap_or("");
|
||||||
if state == "queued" || state == "started" || state == "processing" {
|
if state == "queued" || state == "started" || state == "processing" {
|
||||||
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
|
if let Some(uid) = job.get("uid").and_then(|u| u.as_str()) {
|
||||||
// Отправляем DELETE запрос для остановки задания
|
|
||||||
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
|
||||||
stopped += 1;
|
stopped += 1;
|
||||||
log::info!("Stopped job: {}", uid);
|
log::info!("Stopped job: {}", uid);
|
||||||
@@ -209,7 +287,166 @@ async fn stop_all_jobs(State(state): State<AppState>) -> Result<impl IntoRespons
|
|||||||
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
|
Ok((StatusCode::OK, format!("Stopped {} jobs", stopped)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling
|
// Видео
|
||||||
|
async fn list_videos(State(state): State<AppState>) -> Result<Json<Vec<String>>, AppError> {
|
||||||
|
let files = state
|
||||||
|
.source_access
|
||||||
|
.list_files()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
let videos: Vec<String> = files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|name| {
|
||||||
|
let lower = name.to_lowercase();
|
||||||
|
lower.ends_with(".mp4") || lower.ends_with(".mov") || lower.ends_with(".avi")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Json(videos))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream_video(
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
log::info!("Stream video requested: {}", filename);
|
||||||
|
let file_data = state
|
||||||
|
.source_access
|
||||||
|
.read_file(&filename)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?;
|
||||||
|
|
||||||
|
let file_size = file_data.len() as u64;
|
||||||
|
let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
let (status, content_range, start, end) = if let Some(range_str) = range {
|
||||||
|
let range_str = range_str.trim_start_matches("bytes=");
|
||||||
|
let parts: Vec<&str> = range_str.split('-').collect();
|
||||||
|
let start: u64 = parts[0].parse().unwrap_or(0);
|
||||||
|
let end: u64 = if parts.len() > 1 && !parts[1].is_empty() {
|
||||||
|
parts[1].parse().unwrap_or(file_size - 1)
|
||||||
|
} else {
|
||||||
|
file_size - 1
|
||||||
|
};
|
||||||
|
(
|
||||||
|
StatusCode::PARTIAL_CONTENT,
|
||||||
|
Some(format!("bytes {}-{}/{}", start, end, file_size)),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(StatusCode::OK, None, 0, file_size - 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut resp_headers = HeaderMap::new();
|
||||||
|
resp_headers.insert(header::CONTENT_TYPE, "video/mp4".parse().unwrap());
|
||||||
|
if let Some(content_range) = content_range {
|
||||||
|
resp_headers.insert(header::CONTENT_RANGE, content_range.parse().unwrap());
|
||||||
|
}
|
||||||
|
let len = end - start + 1;
|
||||||
|
resp_headers.insert(header::CONTENT_LENGTH, len.to_string().parse().unwrap());
|
||||||
|
resp_headers.insert(header::ACCEPT_RANGES, "bytes".parse().unwrap());
|
||||||
|
|
||||||
|
let body = file_data[start as usize..=end as usize].to_vec();
|
||||||
|
Ok((status, resp_headers, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn move_video(
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Result<impl IntoResponse, AppError> {
|
||||||
|
let remove = params.get("remove").map(|s| s == "true").unwrap_or(false);
|
||||||
|
|
||||||
|
let data = state
|
||||||
|
.source_access
|
||||||
|
.read_file(&filename)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::NOT_FOUND, format!("File not found: {}", e)))?;
|
||||||
|
|
||||||
|
// Определяем целевую подпапку по имени файла
|
||||||
|
let subfolder = if filename.contains("_Today") {
|
||||||
|
"Today"
|
||||||
|
} else if filename.contains("_Tomorrow") {
|
||||||
|
"Tomorrow"
|
||||||
|
} else {
|
||||||
|
"Data"
|
||||||
|
};
|
||||||
|
let dest_filename = format!("{}/{}", subfolder, filename);
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже файл в целевой папке
|
||||||
|
if state.dest_access.read_file(&dest_filename).await.is_ok() {
|
||||||
|
return Ok((
|
||||||
|
StatusCode::OK,
|
||||||
|
format!("File {} already exists in {} folder.", filename, subfolder),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
state
|
||||||
|
.dest_access
|
||||||
|
.write_file(&dest_filename, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let mut message = format!(
|
||||||
|
"File {} moved successfully to {} folder.",
|
||||||
|
filename, subfolder
|
||||||
|
);
|
||||||
|
|
||||||
|
if remove {
|
||||||
|
state
|
||||||
|
.source_access
|
||||||
|
.delete_file(&filename)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
message.push_str(" Please delete the source file manually if needed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((StatusCode::OK, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шрифты Font Awesome
|
||||||
|
async fn fa_solid_woff2() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "font/woff2")],
|
||||||
|
include_bytes!("static/fontawesome/webfonts/fa-solid-900.woff2").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fa_regular_woff2() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "font/woff2")],
|
||||||
|
include_bytes!("static/fontawesome/webfonts/fa-regular-400.woff2").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fa_brands_woff2() -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[(header::CONTENT_TYPE, "font/woff2")],
|
||||||
|
include_bytes!("static/fontawesome/webfonts/fa-brands-400.woff2").as_slice(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_video_status(
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let subfolder = if filename.contains("_Today") {
|
||||||
|
"Today"
|
||||||
|
} else if filename.contains("_Tomorrow") {
|
||||||
|
"Tomorrow"
|
||||||
|
} else {
|
||||||
|
"Data"
|
||||||
|
};
|
||||||
|
let dest_filename = format!("{}/{}", subfolder, filename);
|
||||||
|
let exists = state.dest_access.read_file(&dest_filename).await.is_ok();
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"exists_in_dest": exists,
|
||||||
|
"subfolder": subfolder
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
struct AppError(StatusCode, String);
|
struct AppError(StatusCode, String);
|
||||||
|
|
||||||
impl IntoResponse for AppError {
|
impl IntoResponse for AppError {
|
||||||
|
|||||||
Reference in New Issue
Block a user