13 Commits

11 changed files with 494 additions and 319 deletions

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
# 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"
#Loging
RUST_LOG="info"
# 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"

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.DS_Store .DS_Store
*.xlsx *.xlsx
*.txt *.txt
makefile
# Файлы окружения # Файлы окружения
.env .env

14
Cargo.lock generated
View File

@@ -10,15 +10,17 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ae_anons" name = "ae_anons"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes",
"calamine", "calamine",
"chrono", "chrono",
"dotenv", "dotenv",
"env_logger", "env_logger",
"futures", "futures",
"log", "log",
"openssl",
"regex", "regex",
"reqwest", "reqwest",
"serde", "serde",
@@ -980,6 +982,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.0+3.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.113" version = "0.9.113"
@@ -988,6 +999,7 @@ checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]

View File

@@ -1,7 +1,15 @@
[package] [package]
name = "ae_anons" name = "ae_anons"
version = "0.1.0" version = "0.1.1"
edition = "2021" edition = "2021"
license = "MIT"
license-file = "LICENSE"
authors = ["Alexey Barabanov <a.barabanov@tvstart.ru>"]
description = "Automated Nexrender job generator from Synology Office spreadsheets"
repository = "https://git.tvstart.ru/lexx/AE_Anons"
readme = "README.md"
keywords = ["nexrender", "after-effects", "synology", "automation"]
categories = ["command-line-utilities", "multimedia"]
[dependencies] [dependencies]
reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } reqwest = { version = "0.12", features = ["json", "multipart", "stream"] }
@@ -18,6 +26,19 @@ futures = "0.3"
anyhow = "1.0" anyhow = "1.0"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
bytes = "1.9"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.0", features = ["full", "rt-multi-thread"] } tokio = { version = "1.0", features = ["full", "rt-multi-thread"] }
[target.x86_64-unknown-linux-gnu.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
[target.x86_64-unknown-linux-musl.dependencies]
openssl = { version = "0.10", features = ["vendored"] }
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
# MIT License
Copyright (c) 2026 [Your Name or Company]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

169
README.md
View File

@@ -1,10 +1,20 @@
# AE Anons - Автоматизированный генератор спортивных анонсов After Effects # AE Anons - Автоматизированный генератор спортивных анонсов в After Effects
<!-- markdownlint-disable MD033 -->
<p align="left">
<img src="assets/logo.png" alt="AE Anons Logo" width="250"/>
</p>
<!-- markdownlint-enable MD033 -->
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/Rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) [![Status](https://img.shields.io/badge/status-production-green.svg)](https://git.tvstart.ru/lexx/AE_Anons)
Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office. Автоматизированная система для создания спортивных анонсов с использованием шаблонов After Effects через Nexrender, с данными из электронных таблиц Synology Office.
## Обзор ## Обзор
AE Anons автоматизирует создание видеороликов спортивных анонсов путем: AE Anons — это CLI-утилита на Rust, которая выступает как **интеллектуальный генератор заданий**
для [Nexrender](https://github.com/inlife/nexrender) — опенсорсного оркестратора рендеринга
After Effects (лицензия MIT).
1. Подключения к NAS Synology для получения данных расписания из файлов офисных таблиц (.osheet) 1. Подключения к NAS Synology для получения данных расписания из файлов офисных таблиц (.osheet)
2. Парсинга Excel данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах 2. Парсинга Excel данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах
@@ -13,11 +23,11 @@ AE Anons автоматизирует создание видеороликов
## Особенности ## Особенности
- **Интеграция с Synology**: Плавная аутентификация и загрузка файлов с NAS Synology - **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel - **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков - **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса - **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе связей команд/спорта - **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе хэштегов `#` в имени команды
- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий - **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий
- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации - **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации
- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env` - **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env`
@@ -25,16 +35,16 @@ AE Anons автоматизирует создание видеороликов
## Предварительные требования ## Предварительные требования
- Rust 1.70 или выше - Rust 1.70 или выше
- Доступ к NAS Synology с включенными File Station и Office - Доступ к NAS Synology с установленными и включенными пакетами File Station и Office
- Экземпляр сервера Nexrender - Экземпляр _server_ и _worker_(не менее одного) Nexrender
- Шаблоны After Effects, настроенные на узлах рендеринга (формат Adobe After Effects 2024 .aepx) - Шаблоны After Effects, настроенные на _worker_ (формат Adobe After Effects 2024 .aepx)
## Установка ## Установка
### 1. Клонирование репозитория ### 1. Клонирование репозитория
```bash ```bash
git clone https://github.com/your-org/ae_anons.git git clone https://git.tvstart.ru/lexx/AE_Anons.git
cd ae_anons cd ae_anons
``` ```
@@ -48,15 +58,26 @@ cargo build --release
### 3. Настройка окружения ### 3. Настройка окружения
Создайте файл `.env` в корне проекта: Скопируйте пример конфигурации и заполните своими данными:
```bash
cp .env.example .env
```
Затем отредактируйте файл .env:
```env ```env
NAS_FQDN=https://your-synology-nas.example.com:5001 # Synology NAS
NAS_USER=service_account NAS_FQDN=https://your-nas.domain.com
NAS_PASS=secure_password NAS_USER=your_username
NAS_FILE=/Team Folder/Broadcast/Auto_Anons/schedule.osheet NAS_PASS=your_password
NEXRENDER_API_URL=http://nexrender-server:3000/api/v1/jobs NAS_FILE=/team-folders/path/to/your/file.osheet
OUTPUT_FOLDER=//file-server/edit/Auto_Anons
# Nexrender
NEXRENDER_API_URL=http://your-nexrender-server:3000/api/v1/jobs
OUTPUT_FOLDER=//your-storage/path/to/output
# Logging
RUST_LOG=info RUST_LOG=info
``` ```
@@ -92,15 +113,44 @@ RUST_LOG=debug cargo run
### Переменные окружения ### Переменные окружения
| Переменная | Обязательна | Описание | | Переменная | Обязательна | Описание |
|---------------------|-------------|------------------------------------------------------| |-------------------------|-------------|------------------------------------------------------|
| `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом | | `NAS_FQDN` | Да | URL NAS Synology с протоколом и портом |
| `NAS_USER` | Да | Имя пользователя учетной записи Synology | | `NAS_USER` | Да | Имя пользователя учетной записи Synology |
| `NAS_PASS` | Да | Пароль учетной записи Synology | | `NAS_PASS` | Да | Пароль учетной записи Synology |
| `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS | | `NAS_FILE` | Да | Полный путь к файлу .osheet на NAS |
| `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender | | `NEXRENDER_API_URL` | Да | Конечная точка API сервера Nexrender |
| `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео | | `OUTPUT_FOLDER` | Да | Сетевой путь для рендеренных видео |
| `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) | | `RUST_LOG` | Нет | Уровень детализации логирования (по умолчанию: info) |
| `TEMPLATE_DOUBLE_SRC` | Да | Путь к AEP-шаблону для двух команд |
| `TEMPLATE_SINGLE_SRC` | Да | Путь к AEP-шаблону для одной команды |
| `TEMPLATE_COMPOSITION` | Да | Имя композиции в проекте AE (например, `main`) |
| `TEMPLATE_OUTPUT_MODULE`| Да | Имя модуля вывода в AE (например, `h264`) |
| `TEMPLATE_OUTPUT_EXT` | Да | Расширение выходного файла (например, `mp4`) |
### Пример файла `.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"
#Loging
RUST_LOG="info"
# 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"
```
## Структура электронной таблицы ## Структура электронной таблицы
@@ -175,13 +225,6 @@ RUST_LOG=debug cargo run
## Выходные файлы ## Выходные файлы
### Экспорт JSON
Во время обработки сохраняются представления таблицы в формате JSON:
- `{filename}_workbook.json` - Полная структура книги
- `{filename}_{SheetName}.json` - Данные отдельного листа
### Рендеренные видео ### Рендеренные видео
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования: Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
@@ -250,7 +293,7 @@ YYYYMMDD_Sport_League_TeamA_TeamB_Channel[_Variant].mp4
- Проверьте сетевую связность с NAS - Проверьте сетевую связность с NAS
- Убедитесь, что сервисы File Station и Office включены - Убедитесь, что сервисы File Station и Office включены
#### Файл не найден** #### Файл не найден
- Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive - Убедитесь, что путь в `NAS_FILE` точно соответствует пути в Synology Drive
- Путь должен начинаться с `/Team Folder/` для рабочих папок - Путь должен начинаться с `/Team Folder/` для рабочих папок
@@ -296,20 +339,20 @@ RUST_LOG=debug ./target/release/ae_anons
### Структура кода ### Структура кода
```shell ```shell
src/ ae_anons/
├── main.rs # Точка входа и оркестрация приложения ├── Cargo.toml
├── config.rs # Управление конфигурацией ├── LICENSE
├── nexrender.rs # Генерация заданий Nexrender и структура данных ├── assets/
└── synology.rs # Клиент API Synology │ └── logo.png
├── README.md
├── .env.example
└── src/
├── main.rs # Точка входа и оркестрация приложения
├── config.rs # Управление конфигурацией
├── nexrender.rs # Генерация заданий Nexrender и структура данных
└── synology.rs # Клиент API Synology
``` ```
### Планируемые новые возможностеи
1. Расширь `JobData` в `nexrender.rs` для новых полей данных
2. Обновить логику парсинга листов при необходимости добавления новых колонок
3. Добавьть соответствующие слои After Effects в шаблоны
4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов
## Зависимости ## Зависимости
| Crate | Версия | Назначение | | Crate | Версия | Назначение |
@@ -324,9 +367,36 @@ src/
| thiserror | 2.0 | Определение типов ошибок | | thiserror | 2.0 | Определение типов ошибок |
| anyhow | 1.0 | Обработка ошибок | | anyhow | 1.0 | Обработка ошибок |
### Планируемые новые возможности
1. Расширить `JobData` в `nexrender.rs` для новых полей данных
2. Обновить логику парсинга листов при необходимости добавления новых колонок
3. Добавить соответствующие слои After Effects в шаблоны
4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов
## 🙏 Благодарности
Особая благодарность проекту **[Nexrender](https://github.com/inlife/nexrender)**
([@inlife](https://github.com/inlife) и контрибьюторам) за создание надёжной платформы
для автоматизации After Effects.
## Лицензия ## Лицензия
Ещё не выбранна **AE Anons** — [MIT License](LICENSE)
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
**Разрешается:**
- ✅ Использовать в коммерческих целях
- ✅ Изменять исходный код
- ✅ Распространять копии
- ✅ Использовать приватно
**Требуется:**
- Сохранять копирайт и текст лицензии
## Поддержка ## Поддержка
@@ -346,3 +416,12 @@ src/
- Создание множественных вариантов - Создание множественных вариантов
- Автоматические настройки размера шрифта и позиции - Автоматические настройки размера шрифта и позиции
- Умное масштабирование логотипов по целевому размеру - Умное масштабирование логотипов по целевому размеру
### v0.1.1
- Оптимизированна работат с памятью
- Убрана функция создания `.json`
---
Made with 🦀 Rust and ☕ coffee

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

View File

@@ -4,12 +4,20 @@ use std::env;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
// Synology
pub nas_fqdn: String, pub nas_fqdn: String,
pub nas_user: String, pub nas_user: String,
pub nas_pass: String, pub nas_pass: String,
pub nas_file: String, pub nas_file: String,
// Nexrender
pub nexrender_api_url: String, pub nexrender_api_url: String,
pub output_folder: String, pub output_folder: String,
// Templates (all required)
pub template_double_src: String,
pub template_single_src: String,
pub template_composition: String,
pub template_output_module: String,
pub template_output_ext: String,
} }
impl Config { impl Config {
@@ -21,8 +29,19 @@ impl Config {
nas_user: env::var("NAS_USER").context("NAS_USER not set")?, nas_user: env::var("NAS_USER").context("NAS_USER not set")?,
nas_pass: env::var("NAS_PASS").context("NAS_PASS not set")?, nas_pass: env::var("NAS_PASS").context("NAS_PASS not set")?,
nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?, nas_file: env::var("NAS_FILE").context("NAS_FILE not set")?,
nexrender_api_url: env::var("NEXRENDER_API_URL").context("NEXRENDER_API_URL not set")?, nexrender_api_url: env::var("NEXRENDER_API_URL")
.context("NEXRENDER_API_URL not set")?,
output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?, output_folder: env::var("OUTPUT_FOLDER").context("OUTPUT_FOLDER not set")?,
template_double_src: env::var("TEMPLATE_DOUBLE_SRC")
.context("TEMPLATE_DOUBLE_SRC not set")?,
template_single_src: env::var("TEMPLATE_SINGLE_SRC")
.context("TEMPLATE_SINGLE_SRC not set")?,
template_composition: env::var("TEMPLATE_COMPOSITION")
.context("TEMPLATE_COMPOSITION not set")?,
template_output_module: env::var("TEMPLATE_OUTPUT_MODULE")
.context("TEMPLATE_OUTPUT_MODULE not set")?,
template_output_ext: env::var("TEMPLATE_OUTPUT_EXT")
.context("TEMPLATE_OUTPUT_EXT not set")?,
}) })
} }
} }

View File

@@ -2,7 +2,7 @@ mod config;
mod nexrender; mod nexrender;
mod synology; mod synology;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Result};
use calamine::{Data, Reader, Xlsx}; use calamine::{Data, Reader, Xlsx};
use chrono::{Duration, NaiveDate}; use chrono::{Duration, NaiveDate};
use config::Config; use config::Config;
@@ -11,13 +11,13 @@ use nexrender::{JobData, LogoRegistry};
use reqwest::Client; use reqwest::Client;
use serde_json::Value; use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs::File; use std::io::Cursor;
use std::io::Write;
use std::path::Path; use std::path::Path;
use std::time::Duration as StdDuration; use std::time::Duration as StdDuration;
use synology::SynologyClient; use synology::SynologyClient;
use tokio::time::sleep; use tokio::time::sleep;
// Структуры для in-memory данных
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SheetData { pub struct SheetData {
pub name: String, pub name: String,
@@ -25,53 +25,18 @@ pub struct SheetData {
pub rows: Vec<HashMap<String, String>>, pub rows: Vec<HashMap<String, String>>,
} }
impl SheetData {
pub fn to_json(&self) -> Value {
let rows_json: Vec<Value> = self
.rows
.iter()
.map(|row| {
let map: serde_json::Map<String, Value> = row
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect();
Value::Object(map)
})
.collect();
serde_json::json!({
"name": self.name,
"headers": self.headers,
"rows": rows_json,
"row_count": self.rows.len(),
"column_count": self.headers.len()
})
}
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct ExcelWorkbook { pub struct ExcelWorkbook {
pub sheets: Vec<SheetData>, pub sheets: Vec<SheetData>,
} }
impl ExcelWorkbook {
pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
self.sheets.iter().find(|s| s.name == name)
}
pub fn to_json(&self) -> Value {
let sheets_json: Vec<Value> = self.sheets.iter().map(|s| s.to_json()).collect();
serde_json::json!(sheets_json)
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
info!("Starting AE Anons processor"); info!("Starting AE Anons processor v0.1.1 (in-memory mode)");
let config = Config::from_env()?; let config = Config::from_env()?;
debug!("Configuration loaded: {:?}", config); debug!("Configuration loaded");
let mut client = SynologyClient::new(&config.nas_fqdn); let mut client = SynologyClient::new(&config.nas_fqdn);
client.login(&config.nas_user, &config.nas_pass).await?; client.login(&config.nas_user, &config.nas_pass).await?;
@@ -80,9 +45,9 @@ async fn main() -> Result<()> {
let info = client.get_info().await?; let info = client.get_info().await?;
info!("Connected to NAS: {}", info.hostname); info!("Connected to NAS: {}", info.hostname);
let workbook = download_and_parse_excel(&mut client, &config).await?; // Скачиваем и парсим Excel в памяти
let workbook = download_and_parse_excel_in_memory(&mut client, &config).await?;
display_workbook_structure(&workbook); display_workbook_structure(&workbook);
save_workbook_json(&workbook, &config.nas_file)?;
process_nexrender_jobs(&workbook, &config).await?; process_nexrender_jobs(&workbook, &config).await?;
@@ -92,7 +57,7 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
async fn download_and_parse_excel( async fn download_and_parse_excel_in_memory(
client: &mut SynologyClient, client: &mut SynologyClient,
config: &Config, config: &Config,
) -> Result<ExcelWorkbook> { ) -> Result<ExcelWorkbook> {
@@ -145,16 +110,166 @@ async fn download_and_parse_excel(
.ok_or_else(|| anyhow!("Missing name"))?; .ok_or_else(|| anyhow!("Missing name"))?;
info!("Found file: {}", actual_file_name); info!("Found file: {}", actual_file_name);
info!("Exporting file from Synology Office to Excel format..."); info!("Exporting file from Synology Office to Excel format (in-memory)...");
let output_path = format!("./{}_export.xlsx", search_name); // Получаем бинарные данные Excel напрямую в память
let data = client.export_by_file_id(file_id, actual_file_name).await?; let excel_data = client.export_by_file_id(file_id, actual_file_name).await?;
let mut file = File::create(&output_path)?; info!("Exported {} bytes to memory", excel_data.len());
file.write_all(&data)?;
info!("Exported to: {} ({} bytes)", output_path, data.len());
info!("Parsing Excel workbook..."); info!("Parsing Excel workbook from memory...");
read_excel_workbook(&output_path) parse_excel_from_bytes(&excel_data)
}
fn parse_excel_from_bytes(data: &[u8]) -> Result<ExcelWorkbook> {
let cursor = Cursor::new(data);
let mut workbook: Xlsx<_> = calamine::open_workbook_from_rs(cursor)
.map_err(|e| anyhow!("Failed to open workbook from memory: {}", e))?;
let sheet_names = workbook.sheet_names().to_vec();
let mut excel_workbook = ExcelWorkbook::default();
for sheet_name in sheet_names {
debug!("Processing sheet: '{}'", sheet_name);
let range = workbook
.worksheet_range(&sheet_name)
.map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?;
let sheet_data = parse_sheet_dynamic_optimized(&sheet_name, &range)?;
excel_workbook.sheets.push(sheet_data);
}
Ok(excel_workbook)
}
fn parse_sheet_dynamic_optimized(
sheet_name: &str,
range: &calamine::Range<Data>,
) -> Result<SheetData> {
// Предварительно выделяем память для избежания реаллокаций
let (row_count, col_count) = range.get_size();
let mut data_matrix: Vec<Vec<String>> = Vec::with_capacity(row_count);
// Строим матрицу данных (без enumerate)
for row in range.rows() {
let mut row_data = Vec::with_capacity(col_count);
for cell in row {
row_data.push(cell_to_string_optimized(cell));
}
data_matrix.push(row_data);
}
if data_matrix.is_empty() {
return Ok(SheetData {
name: sheet_name.to_string(),
headers: Vec::new(),
rows: Vec::new(),
});
}
// Извлекаем заголовки с дедупликацией
let headers: Vec<String> = data_matrix[0]
.iter()
.enumerate()
.map(|(idx, header)| {
let h = header.trim().to_string();
if h.is_empty() {
format!("Column_{}", idx + 1)
} else {
h
}
})
.collect();
// Парсим строки данных
let rows_data: Vec<HashMap<String, String>> = data_matrix
.iter()
.skip(1)
.filter_map(|row_values| {
let mut row_map = HashMap::with_capacity(headers.len());
let mut has_data = false;
for (col_idx, value) in row_values.iter().enumerate() {
if col_idx < headers.len() && !value.is_empty() {
row_map.insert(headers[col_idx].clone(), value.clone());
has_data = true;
}
}
if has_data {
Some(row_map)
} else {
None
}
})
.take(10000) // Ограничение для безопасности
.collect();
Ok(SheetData {
name: sheet_name.to_string(),
headers,
rows: rows_data,
})
}
#[inline]
fn cell_to_string_optimized(cell: &Data) -> String {
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if is_excel_date(*f) {
excel_date_to_string(*f)
} else if f.fract() == 0.0 {
// Используем itoa для целых чисел (опционально)
format!("{:.0}", f)
} else {
// Используем ryu для float (опционально)
f.to_string()
}
}
Data::Int(i) => {
let f = *i as f64;
if is_excel_date(f) {
excel_date_to_string(f)
} else {
i.to_string()
}
}
Data::Bool(b) => {
if *b {
"TRUE".to_string()
} else {
"FALSE".to_string()
}
}
Data::DateTime(dt) => {
let serial = dt.as_f64();
if is_excel_date(serial) {
excel_date_to_string(serial)
} else {
dt.to_string()
}
}
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(e) => format!("{:?}", e),
}
}
#[inline]
fn is_excel_date(value: f64) -> bool {
(1.0..100000.0).contains(&value)
}
fn excel_date_to_string(serial: f64) -> String {
let days = serial as i64;
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
// Используем метод format напрямую - он публичный
date.format("%d.%m.%Y").to_string()
} else {
serial.to_string()
}
} }
fn display_workbook_structure(workbook: &ExcelWorkbook) { fn display_workbook_structure(workbook: &ExcelWorkbook) {
@@ -168,38 +283,9 @@ fn display_workbook_structure(workbook: &ExcelWorkbook) {
sheet.headers.len(), sheet.headers.len(),
sheet.rows.len() sheet.rows.len()
); );
if log::log_enabled!(log::Level::Debug) {
debug!(" Headers: {:?}", sheet.headers);
}
} }
} }
fn save_workbook_json(workbook: &ExcelWorkbook, nas_file: &str) -> Result<()> {
let search_name = Path::new(nas_file)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.replace(".osheet", "");
let json_output = format!("./{}_workbook.json", search_name);
let json_data = workbook.to_json();
std::fs::write(&json_output, serde_json::to_string_pretty(&json_data)?)?;
info!("Full workbook saved to: {}", json_output);
for sheet in &workbook.sheets {
let safe_name = sheet
.name
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
let sheet_json_path = format!("./{}_{}.json", search_name, safe_name);
std::fs::write(
&sheet_json_path,
serde_json::to_string_pretty(&sheet.to_json())?,
)?;
}
Ok(())
}
async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Result<()> { async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Result<()> {
info!("Preparing Nexrender jobs..."); info!("Preparing Nexrender jobs...");
@@ -216,6 +302,7 @@ async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Re
.get_sheet("CHANELL") .get_sheet("CHANELL")
.ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?; .ok_or_else(|| anyhow!("Sheet 'CHANELL' not found"))?;
// Используем предварительное выделение памяти
let packs: HashMap<String, String> = sport_sheet let packs: HashMap<String, String> = sport_sheet
.rows .rows
.iter() .iter()
@@ -223,7 +310,7 @@ async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Re
.collect(); .collect();
info!("Loaded {} sport packs", packs.len()); info!("Loaded {} sport packs", packs.len());
let mut logos = LogoRegistry::new(); let mut logos = LogoRegistry::with_capacity(teams_sheet.rows.len());
for row in &teams_sheet.rows { for row in &teams_sheet.rows {
if let (Some(team), Some(sport), Some(link)) = if let (Some(team), Some(sport), Some(link)) =
(row.get("TEAM"), row.get("SPORT"), row.get("LINK")) (row.get("TEAM"), row.get("SPORT"), row.get("LINK"))
@@ -240,12 +327,13 @@ async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Re
.collect(); .collect();
info!("Loaded {} channel logos", channels.len()); info!("Loaded {} channel logos", channels.len());
let mut jobs: Vec<JobData> = Vec::new(); // Предварительно выделяем память для jobs
let mut jobs: Vec<JobData> = Vec::with_capacity(start_sheet.rows.len() * 3);
for (idx, row) in start_sheet.rows.iter().enumerate() { for (idx, row) in start_sheet.rows.iter().enumerate() {
if let Some(state) = row.get("STATE") { if let Some(state) = row.get("STATE") {
if state == "FALSE" { if state == "FALSE" {
if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) { if let Some(job) = JobData::from_row(row, idx, &packs, &logos, &channels) {
debug!("Created job for row {}: {}", idx, job.outfile_name);
jobs.extend(job.create_variants()); jobs.extend(job.create_variants());
} }
} }
@@ -262,28 +350,52 @@ async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Re
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::new(); let mut submitted_jobs: Vec<(String, String)> = Vec::with_capacity(jobs.len());
for job in &jobs { // Отправляем jobs параллельно для ускорения
let nexrender_job = job.to_nexrender_job(&config.output_folder); let mut tasks = Vec::with_capacity(jobs.len());
info!("Submitting job: {}", job.outfile_name); for job in jobs {
let client = http_client.clone();
let api_url = config.nexrender_api_url.clone();
let config_clone = config.clone(); // ✅ Клонируем весь конфиг
let response = http_client tasks.push(tokio::spawn(async move {
.post(&config.nexrender_api_url) let nexrender_job = job.to_nexrender_job(&config_clone); // ✅ Передаём Config
.json(&nexrender_job) info!("Submitting job: {}", job.outfile_name);
.send()
.await?;
if response.status().is_success() { let response = client.post(&api_url).json(&nexrender_job).send().await?;
let result: Value = response.json().await?;
if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) { if response.status().is_success() {
submitted_jobs.push((uid.to_string(), job.outfile_name.clone())); let result: Value = response.json().await?;
info!("Job submitted successfully, UID: {}", uid); if let Some(uid) = result.get("uid").and_then(|u| u.as_str()) {
Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name)))
} else {
Ok(None)
}
} else {
let status = response.status();
let text = response.text().await.unwrap_or_default();
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);
} }
} else {
let status = response.status();
let text = response.text().await.unwrap_or_default();
error!("Failed to submit job ({}): {}", status, text);
} }
} }
@@ -300,145 +412,10 @@ async fn process_nexrender_jobs(workbook: &ExcelWorkbook, config: &Config) -> Re
Ok(()) Ok(())
} }
fn read_excel_workbook(file_path: &str) -> Result<ExcelWorkbook> { // ... остальные функции без изменений ...
let mut workbook: Xlsx<_> = calamine::open_workbook(file_path) impl ExcelWorkbook {
.map_err(|e| anyhow!("Failed to open workbook: {}", e))?; pub fn get_sheet(&self, name: &str) -> Option<&SheetData> {
self.sheets.iter().find(|s| s.name == name)
let sheet_names = workbook.sheet_names().to_vec();
let mut excel_workbook = ExcelWorkbook::default();
for sheet_name in sheet_names {
debug!("Processing sheet: '{}'", sheet_name);
let range = workbook
.worksheet_range(&sheet_name)
.map_err(|e| anyhow!("Failed to read sheet '{}': {}", sheet_name, e))?;
let sheet_data = parse_sheet_dynamic(&sheet_name, &range)?;
excel_workbook.sheets.push(sheet_data);
}
Ok(excel_workbook)
}
fn parse_sheet_dynamic(sheet_name: &str, range: &calamine::Range<Data>) -> Result<SheetData> {
let mut cell_map: HashMap<(usize, usize), String> = HashMap::new();
let mut max_row = 0usize;
let mut max_col = 0usize;
for (row, col, cell) in range.cells() {
let value = cell_to_string(cell);
if !value.is_empty() {
cell_map.insert((row, col), value);
max_row = max_row.max(row);
max_col = max_col.max(col);
}
}
if cell_map.is_empty() {
return Ok(SheetData {
name: sheet_name.to_string(),
headers: Vec::new(),
rows: Vec::new(),
});
}
let mut data_matrix: Vec<Vec<String>> = Vec::new();
for row in 0..=max_row {
let mut row_data = Vec::new();
for col in 0..=max_col {
row_data.push(cell_map.get(&(row, col)).cloned().unwrap_or_default());
}
data_matrix.push(row_data);
}
let headers: Vec<String> = if !data_matrix.is_empty() {
data_matrix[0]
.iter()
.enumerate()
.map(|(idx, header)| {
let h = header.trim().to_string();
if h.is_empty() {
format!("Column_{}", idx + 1)
} else {
h
}
})
.collect()
} else {
Vec::new()
};
let mut rows_data = Vec::new();
for row_values in data_matrix.iter().skip(1) {
let mut row_map = HashMap::new();
for (col_idx, value) in row_values.iter().enumerate() {
if col_idx < headers.len() && !value.is_empty() {
row_map.insert(headers[col_idx].clone(), value.clone());
}
}
if !row_map.is_empty() {
rows_data.push(row_map);
}
if rows_data.len() >= 10000 {
break;
}
}
Ok(SheetData {
name: sheet_name.to_string(),
headers,
rows: rows_data,
})
}
fn excel_date_to_string(serial: f64) -> String {
let days = serial as i64;
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
if let Some(date) = base.checked_add_signed(Duration::days(days)) {
date.format("%d.%m.%Y").to_string()
} else {
serial.to_string()
}
}
fn is_excel_date(value: f64) -> bool {
(1.0..100000.0).contains(&value)
}
fn cell_to_string(cell: &Data) -> String {
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if is_excel_date(*f) {
excel_date_to_string(*f)
} else if f.fract() == 0.0 {
format!("{:.0}", f)
} else {
f.to_string()
}
}
Data::Int(i) => {
let f = *i as f64;
if is_excel_date(f) {
excel_date_to_string(f)
} else {
i.to_string()
}
}
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => {
let serial = dt.as_f64();
if is_excel_date(serial) {
excel_date_to_string(serial)
} else {
dt.to_string()
}
}
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(e) => format!("{:?}", e),
} }
} }
@@ -454,10 +431,7 @@ async fn cleanup_finished_jobs(api_url: &str) -> Result<()> {
job.get("state").and_then(|s| s.as_str()), job.get("state").and_then(|s| s.as_str()),
) { ) {
if state == "finished" || state == "error" { if state == "finished" || state == "error" {
client let _ = client.delete(&format!("{}/{}", api_url, uid)).send().await;
.delete(&format!("{}/{}", api_url, uid))
.send()
.await?;
info!("Cleaned up completed job: {}", uid); info!("Cleaned up completed job: {}", uid);
} }
} }
@@ -474,8 +448,8 @@ async fn monitor_jobs(
while !pending.is_empty() { while !pending.is_empty() {
sleep(StdDuration::from_secs(25)).await; sleep(StdDuration::from_secs(25)).await;
let mut remaining = Vec::new(); let mut remaining = Vec::with_capacity(pending.len());
for (uid, outname) in &pending { for (uid, outname) in pending.drain(..) {
let response = client.get(&format!("{}/{}", api_url, uid)).send().await?; let response = client.get(&format!("{}/{}", api_url, uid)).send().await?;
if response.status().is_success() { if response.status().is_success() {
@@ -487,17 +461,13 @@ async fn monitor_jobs(
match state { match state {
"finished" => { "finished" => {
info!( info!("Job completed: {}", outname)
"Job completed: {} ({} remaining)",
outname,
pending.len() - 1
)
} }
"error" => error!("Job failed: {} ({} remaining)", outname, pending.len() - 1), "error" => error!("Job failed: {}", outname),
_ => remaining.push((uid.clone(), outname.clone())), _ => remaining.push((uid, outname)),
} }
} else { } else {
remaining.push((uid.clone(), outname.clone())); remaining.push((uid, outname));
} }
} }
pending = remaining; pending = remaining;

View File

@@ -1,3 +1,4 @@
#![allow(dead_code)]
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate};
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
@@ -89,22 +90,32 @@ pub struct Template {
pub output_ext: String, pub output_ext: String,
} }
impl Default for Template { impl Template {
fn default() -> Self { pub fn double(
src: String,
composition: String,
output_module: String,
output_ext: String,
) -> Self {
Self { Self {
src: "file:///c:/users/virtVmix-2/Downloads/PackShot_DOUBLE.aepx".to_string(), src,
composition: "pack".to_string(), composition,
output_module: "Start_h264".to_string(), output_module,
output_ext: "mp4".to_string(), output_ext,
} }
} }
}
impl Template { pub fn single(
pub fn single() -> Self { src: String,
composition: String,
output_module: String,
output_ext: String,
) -> Self {
Self { Self {
src: "file:///c:/users/virtVmix-2/Downloads/PackShot_SINGLE.aepx".to_string(), src,
..Default::default() composition,
output_module,
output_ext,
} }
} }
} }
@@ -291,17 +302,32 @@ impl JobData {
variants variants
} }
pub fn to_nexrender_job(&self, output_folder: &str) -> NexrenderJob { // ЕДИНСТВЕННАЯ реализация метода
pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob {
let template = if self.team_b.is_empty() { let template = if self.team_b.is_empty() {
Template::single() Template::single(
config.template_single_src.clone(),
config.template_composition.clone(),
config.template_output_module.clone(),
config.template_output_ext.clone(),
)
} else { } else {
Template::default() Template::double(
config.template_double_src.clone(),
config.template_composition.clone(),
config.template_output_module.clone(),
config.template_output_ext.clone(),
)
}; };
let output_path = format!("{}/{}.mp4", output_folder, self.outfile_name); let output_path = format!(
"{}/{}.{}",
config.output_folder, self.outfile_name, config.template_output_ext
);
let mut actions = Actions::default(); let mut actions = Actions::default();
actions.postrender.push(PostrenderAction::Copy { actions.postrender.push(PostrenderAction::Copy {
input: "encoded.mp4".to_string(), input: format!("encoded.{}", config.template_output_ext),
output: output_path, output: output_path,
}); });
@@ -502,6 +528,12 @@ impl LogoRegistry {
Self::default() Self::default()
} }
pub fn with_capacity(capacity: usize) -> Self {
Self {
logos: HashMap::with_capacity(capacity),
}
}
pub fn insert(&mut self, team: String, sport: String, link: String) { pub fn insert(&mut self, team: String, sport: String, link: String) {
self.logos.insert((team, sport), link); self.logos.insert((team, sport), link);
} }

View File

@@ -1,3 +1,4 @@
#![allow(dead_code)]
use log; use log;
use reqwest::{ use reqwest::{
multipart::{Form, Part}, multipart::{Form, Part},