Compare commits
11 Commits
0d4b81a34a
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f1c6d1ab7 | |||
| 23ca2dc7d1 | |||
| df26e4b1db | |||
| 91de200b91 | |||
| 1e1d9a9935 | |||
| 3cdd8395b3 | |||
| b3d33f4d35 | |||
| a9b8fc799a | |||
| 48de7d11ac | |||
| b4f358b12f | |||
| 8ec326cd21 |
19
.env.example
Normal file
19
.env.example
Normal 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
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.xlsx
|
*.xlsx
|
||||||
*.txt
|
*.txt
|
||||||
|
makefile
|
||||||
# Файлы окружения
|
# Файлы окружения
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
|||||||
14
Cargo.lock
generated
14
Cargo.lock
generated
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
17
Cargo.toml
17
Cargo.toml
@@ -1,10 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ae_anons"
|
name = "ae_anons"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
license-file = "LICENSE"
|
license-file = "LICENSE"
|
||||||
authors = ["Your Name <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"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -26,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
|
||||||
|
|||||||
127
README.md
127
README.md
@@ -1,4 +1,10 @@
|
|||||||
# 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 -->
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT) [](https://www.rust-lang.org/) [](https://git.tvstart.ru/lexx/AE_Anons)
|
[](https://opensource.org/licenses/MIT) [](https://www.rust-lang.org/) [](https://git.tvstart.ru/lexx/AE_Anons)
|
||||||
|
|
||||||
@@ -6,7 +12,9 @@
|
|||||||
|
|
||||||
## Обзор
|
## Обзор
|
||||||
|
|
||||||
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 данных, содержащих информацию о спортивных событиях, командах, каналах и временных интервалах
|
||||||
@@ -15,11 +23,11 @@ AE Anons автоматизирует создание видеороликов
|
|||||||
|
|
||||||
## Особенности
|
## Особенности
|
||||||
|
|
||||||
- **Интеграция с Synology**: Плавная аутентификация и загрузка файлов с NAS Synology
|
- **Интеграция с Synology**: Аутентификация и загрузка файлов с NAS Synology
|
||||||
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
- **Экспорт офисных таблиц**: Автоматическое преобразование файлов .osheet в формат Excel
|
||||||
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
- **Гибкий парсинг данных**: Динамический парсинг листов с обнаружением заголовков
|
||||||
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса
|
- **Множественная генерация вариантов**: Создание "Сегодня", "Завтра" и датированных версий для каждого анонса
|
||||||
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе связей команд/спорта
|
- **Умное управление логотипами**: Автоматическое разрешение и масштабирование логотипов на основе хэштегов `#` в имени команды
|
||||||
- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий
|
- **Оркестрация заданий Nexrender**: Автоматическая отправка, мониторинг и очистка заданий
|
||||||
- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации
|
- **Профессиональное логирование**: Структурированный журнал с возможностью настройки уровня детализации
|
||||||
- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env`
|
- **Конфигурация через переменные окружения**: Все параметры управляются через файл `.env`
|
||||||
@@ -27,9 +35,9 @@ 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)
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -50,7 +58,13 @@ cargo build --release
|
|||||||
|
|
||||||
### 3. Настройка окружения
|
### 3. Настройка окружения
|
||||||
|
|
||||||
Создайте файл `.env` в корне проекта:
|
Скопируйте пример конфигурации и заполните своими данными:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем отредактируйте файл .env:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Synology NAS
|
# Synology NAS
|
||||||
@@ -100,7 +114,7 @@ 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 |
|
||||||
@@ -108,6 +122,35 @@ RUST_LOG=debug cargo run
|
|||||||
| `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"
|
||||||
|
```
|
||||||
|
|
||||||
## Структура электронной таблицы
|
## Структура электронной таблицы
|
||||||
|
|
||||||
@@ -182,13 +225,6 @@ RUST_LOG=debug cargo run
|
|||||||
|
|
||||||
## Выходные файлы
|
## Выходные файлы
|
||||||
|
|
||||||
### Экспорт JSON
|
|
||||||
|
|
||||||
Во время обработки сохраняются представления таблицы в формате JSON:
|
|
||||||
|
|
||||||
- `{filename}_workbook.json` - Полная структура книги
|
|
||||||
- `{filename}_{SheetName}.json` - Данные отдельного листа
|
|
||||||
|
|
||||||
### Рендеренные видео
|
### Рендеренные видео
|
||||||
|
|
||||||
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
Рендерные видео сохраняются в `OUTPUT_FOLDER` по следующему шаблону именования:
|
||||||
@@ -257,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/` для рабочих папок
|
||||||
@@ -303,20 +339,20 @@ RUST_LOG=debug ./target/release/ae_anons
|
|||||||
### Структура кода
|
### Структура кода
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
src/
|
ae_anons/
|
||||||
|
├── Cargo.toml
|
||||||
|
├── LICENSE
|
||||||
|
├── assets/
|
||||||
|
│ └── logo.png
|
||||||
|
├── README.md
|
||||||
|
├── .env.example
|
||||||
|
└── src/
|
||||||
├── main.rs # Точка входа и оркестрация приложения
|
├── main.rs # Точка входа и оркестрация приложения
|
||||||
├── config.rs # Управление конфигурацией
|
├── config.rs # Управление конфигурацией
|
||||||
├── nexrender.rs # Генерация заданий Nexrender и структура данных
|
├── nexrender.rs # Генерация заданий Nexrender и структура данных
|
||||||
└── synology.rs # Клиент API Synology
|
└── synology.rs # Клиент API Synology
|
||||||
```
|
```
|
||||||
|
|
||||||
### Планируемые новые возможностеи
|
|
||||||
|
|
||||||
1. Расширь `JobData` в `nexrender.rs` для новых полей данных
|
|
||||||
2. Обновить логику парсинга листов при необходимости добавления новых колонок
|
|
||||||
3. Добавьть соответствующие слои After Effects в шаблоны
|
|
||||||
4. Обновите метод `to_nexrender_job()` с новыми сопоставлениями ресурсов
|
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
| Crate | Версия | Назначение |
|
| Crate | Версия | Назначение |
|
||||||
@@ -331,22 +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.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
|
**AE Anons** — [MIT License](LICENSE)
|
||||||
|
**Nexrender** — [MIT License](https://github.com/inlife/nexrender/blob/master/LICENSE)
|
||||||
|
|
||||||
### What does this mean?
|
Обе лицензии MIT обеспечивают полную свободу использования и модификации кода.
|
||||||
|
|
||||||
- ✅ **Commercial use** - You can use this in your company
|
**Разрешается:**
|
||||||
- ✅ **Modification** - You can change the code as you wish
|
|
||||||
- ✅ **Distribution** - You can share it with others
|
|
||||||
- ✅ **Private use** - You can use it privately
|
|
||||||
|
|
||||||
The only requirement is to keep the copyright notice and license text.
|
- ✅ Использовать в коммерческих целях
|
||||||
|
- ✅ Изменять исходный код
|
||||||
|
- ✅ Распространять копии
|
||||||
|
- ✅ Использовать приватно
|
||||||
|
|
||||||
---
|
**Требуется:**
|
||||||
|
|
||||||
Made with 🦀 Rust and ☕ coffee
|
- Сохранять копирайт и текст лицензии
|
||||||
|
|
||||||
## Поддержка
|
## Поддержка
|
||||||
|
|
||||||
@@ -366,3 +416,12 @@ Made with 🦀 Rust and ☕ coffee
|
|||||||
- Создание множественных вариантов
|
- Создание множественных вариантов
|
||||||
- Автоматические настройки размера шрифта и позиции
|
- Автоматические настройки размера шрифта и позиции
|
||||||
- Умное масштабирование логотипов по целевому размеру
|
- Умное масштабирование логотипов по целевому размеру
|
||||||
|
|
||||||
|
### v0.1.1
|
||||||
|
|
||||||
|
- Оптимизированна работат с памятью
|
||||||
|
- Убрана функция создания `.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made with 🦀 Rust and ☕ coffee
|
||||||
|
|||||||
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
@@ -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")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
460
src/main.rs
460
src/main.rs
@@ -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,37 +283,8 @@ 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());
|
||||||
|
for job in jobs {
|
||||||
|
let client = http_client.clone();
|
||||||
|
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); // ✅ Передаём Config
|
||||||
info!("Submitting job: {}", job.outfile_name);
|
info!("Submitting job: {}", job.outfile_name);
|
||||||
|
|
||||||
let response = http_client
|
let response = client.post(&api_url).json(&nexrender_job).send().await?;
|
||||||
.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()) {
|
||||||
submitted_jobs.push((uid.to_string(), job.outfile_name.clone()));
|
Ok::<_, anyhow::Error>(Some((uid.to_string(), job.outfile_name)))
|
||||||
info!("Job submitted successfully, UID: {}", uid);
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -90,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -503,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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user