Упрощено формирование row_data без лишних переменных. Удалены избыточные отладочные println!, уменьшив шум в консоли. Переписаны комментарии к блокам парсинга, убраны неиспользуемые выводы количества заголовков и строк. Объединено условие проверки Excel‑даты в компактный диапазон (1.0..100000.0). Удалены устаревшие комментарии о базовой дате; оставлено только вычисление даты. Добавлена асинхронная функция cleanup_finished_jobs для очистки завершённых/ошибочных задач Nexrender, с выводом информации о выполненных чистках. Реализована функция monitor_jobs, периодически проверяющая статус ожидающих заданий, выводящая прогресс и финальное сообщение о завершении всех работ. Эти изменения делают код чище, повышают читаемость и добавляют автоматизированное управление задачами Nexrender.
587 lines
18 KiB
Rust
587 lines
18 KiB
Rust
use chrono::{Datelike, NaiveDate};
|
||
use serde::Serialize;
|
||
use serde_json::json;
|
||
use std::collections::HashMap;
|
||
|
||
fn transliterate(text: &str, _reversed: bool) -> String {
|
||
let mapping = [
|
||
("а", "a"),
|
||
("б", "b"),
|
||
("в", "v"),
|
||
("г", "g"),
|
||
("д", "d"),
|
||
("е", "e"),
|
||
("ё", "yo"),
|
||
("ж", "zh"),
|
||
("з", "z"),
|
||
("и", "i"),
|
||
("й", "y"),
|
||
("к", "k"),
|
||
("л", "l"),
|
||
("м", "m"),
|
||
("н", "n"),
|
||
("о", "o"),
|
||
("п", "p"),
|
||
("р", "r"),
|
||
("с", "s"),
|
||
("т", "t"),
|
||
("у", "u"),
|
||
("ф", "f"),
|
||
("х", "h"),
|
||
("ц", "ts"),
|
||
("ч", "ch"),
|
||
("ш", "sh"),
|
||
("щ", "sch"),
|
||
("ъ", ""),
|
||
("ы", "y"),
|
||
("ь", ""),
|
||
("э", "e"),
|
||
("ю", "yu"),
|
||
("я", "ya"),
|
||
("А", "A"),
|
||
("Б", "B"),
|
||
("В", "V"),
|
||
("Г", "G"),
|
||
("Д", "D"),
|
||
("Е", "E"),
|
||
("Ё", "Yo"),
|
||
("Ж", "Zh"),
|
||
("З", "Z"),
|
||
("И", "I"),
|
||
("Й", "Y"),
|
||
("К", "K"),
|
||
("Л", "L"),
|
||
("М", "M"),
|
||
("Н", "N"),
|
||
("О", "O"),
|
||
("П", "P"),
|
||
("Р", "R"),
|
||
("С", "S"),
|
||
("Т", "T"),
|
||
("У", "U"),
|
||
("Ф", "F"),
|
||
("Х", "H"),
|
||
("Ц", "Ts"),
|
||
("Ч", "Ch"),
|
||
("Ш", "Sh"),
|
||
("Щ", "Sch"),
|
||
("Ъ", ""),
|
||
("Ы", "Y"),
|
||
("Ь", ""),
|
||
("Э", "E"),
|
||
("Ю", "Yu"),
|
||
("Я", "Ya"),
|
||
];
|
||
let mut result = text.to_string();
|
||
for (cyr, lat) in mapping {
|
||
result = result.replace(cyr, lat);
|
||
}
|
||
result
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Template {
|
||
pub src: String,
|
||
pub composition: String,
|
||
#[serde(rename = "outputModule")]
|
||
pub output_module: String,
|
||
#[serde(rename = "outputExt")]
|
||
pub output_ext: String,
|
||
}
|
||
|
||
impl Default for Template {
|
||
fn default() -> Self {
|
||
Self {
|
||
src: "file:///c:/users/virtVmix-2/Downloads/PackShot_DOUBLE.aepx".to_string(),
|
||
composition: "pack".to_string(),
|
||
output_module: "Start_h264".to_string(),
|
||
output_ext: "mp4".to_string(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Template {
|
||
pub fn single() -> Self {
|
||
Self {
|
||
src: "file:///c:/users/virtVmix-2/Downloads/PackShot_SINGLE.aepx".to_string(),
|
||
..Default::default()
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(tag = "module")]
|
||
pub enum PostrenderAction {
|
||
#[serde(rename = "@nexrender/action-encode")]
|
||
Encode { preset: String, output: String },
|
||
#[serde(rename = "@nexrender/action-copy")]
|
||
Copy { input: String, output: String },
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
#[serde(untagged)]
|
||
pub enum Asset {
|
||
Data {
|
||
#[serde(rename = "type")]
|
||
asset_type: String,
|
||
#[serde(rename = "layerName")]
|
||
layer_name: String,
|
||
property: String,
|
||
value: serde_json::Value,
|
||
},
|
||
DataExpression {
|
||
#[serde(rename = "type")]
|
||
asset_type: String,
|
||
#[serde(rename = "layerName")]
|
||
layer_name: String,
|
||
property: String,
|
||
expression: String,
|
||
},
|
||
Image {
|
||
src: String,
|
||
#[serde(rename = "type")]
|
||
asset_type: String,
|
||
#[serde(rename = "layerName")]
|
||
layer_name: String,
|
||
},
|
||
Video {
|
||
src: String,
|
||
#[serde(rename = "type")]
|
||
asset_type: String,
|
||
#[serde(rename = "layerName")]
|
||
layer_name: String,
|
||
},
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct NexrenderJob {
|
||
pub template: Template,
|
||
pub actions: Actions,
|
||
pub assets: Vec<Asset>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Actions {
|
||
pub postrender: Vec<PostrenderAction>,
|
||
}
|
||
|
||
impl Default for Actions {
|
||
fn default() -> Self {
|
||
Self {
|
||
postrender: vec![PostrenderAction::Encode {
|
||
preset: "mp4".to_string(),
|
||
output: "encoded.mp4".to_string(),
|
||
}],
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct JobData {
|
||
pub date: String,
|
||
pub time: String,
|
||
pub time_h: String,
|
||
pub time_m: String,
|
||
pub channel: String,
|
||
pub sport: String,
|
||
pub league: String,
|
||
pub team_a: String,
|
||
pub team_b: String,
|
||
pub data_display: String,
|
||
pub pack_path: String,
|
||
pub team_a_logo: String,
|
||
pub team_b_logo: String,
|
||
pub team_a_logo_res: Option<String>,
|
||
pub team_b_logo_res: Option<String>,
|
||
pub channel_logo: String,
|
||
pub outfile_name: String,
|
||
pub row_index: usize,
|
||
}
|
||
|
||
impl JobData {
|
||
pub fn from_row(
|
||
row: &HashMap<String, String>,
|
||
row_index: usize,
|
||
packs: &HashMap<String, String>,
|
||
logos: &LogoRegistry,
|
||
channels: &HashMap<String, String>,
|
||
) -> Option<Self> {
|
||
let state = row.get("STATE").map(|s| s.as_str()).unwrap_or("");
|
||
if state != "FALSE" {
|
||
return None;
|
||
}
|
||
|
||
let sport = row.get("SPORT")?.clone();
|
||
let league = row.get("LEAGUE")?.clone();
|
||
let team_a = row.get("TEAM A").cloned().unwrap_or_default();
|
||
let team_b = row.get("TEAM B").cloned().unwrap_or_default();
|
||
let channel = row.get("CHANEL").cloned().unwrap_or_default();
|
||
let time = row.get("TIME")?.clone();
|
||
let data_str = row.get("DATA")?.clone();
|
||
|
||
let (date_obj, data_display) = parse_date(&data_str);
|
||
let (time_h, time_m) = parse_time(&time);
|
||
|
||
let pack_path = packs.get(&sport).cloned().unwrap_or_default();
|
||
let channel_logo = channels.get(&channel).cloned().unwrap_or_default();
|
||
|
||
let (team_a_name, team_a_res) = parse_team_name(&team_a);
|
||
let (team_b_name, team_b_res) = parse_team_name(&team_b);
|
||
|
||
let team_a_logo = logos.find(&team_a, &sport).unwrap_or_default();
|
||
let team_b_logo = logos.find(&team_b, &sport).unwrap_or_default();
|
||
|
||
let mut fn_parts = Vec::new();
|
||
fn_parts.push(format!(
|
||
"{:04}{:02}{:02}",
|
||
date_obj.year(),
|
||
date_obj.month(),
|
||
date_obj.day()
|
||
));
|
||
if !sport.is_empty() && sport != "Без оформления" {
|
||
fn_parts.push(sport.clone());
|
||
}
|
||
fn_parts.push(league.trim_end_matches('.').to_string());
|
||
if !team_a_name.is_empty() {
|
||
fn_parts.push(team_a_name.clone());
|
||
}
|
||
if !team_b_name.is_empty() {
|
||
fn_parts.push(team_b_name.clone());
|
||
}
|
||
if !channel.is_empty() {
|
||
fn_parts.push(channel.clone());
|
||
}
|
||
|
||
let outfile_name = transliterate(&fn_parts.join("_"), true)
|
||
.replace(' ', "-")
|
||
.replace('\'', "");
|
||
|
||
Some(Self {
|
||
date: data_str,
|
||
time: time.clone(),
|
||
time_h,
|
||
time_m,
|
||
channel,
|
||
sport,
|
||
league,
|
||
team_a: team_a_name,
|
||
team_b: team_b_name,
|
||
data_display,
|
||
pack_path: unc_to_uri(&pack_path),
|
||
team_a_logo: unc_to_uri(&team_a_logo),
|
||
team_b_logo: unc_to_uri(&team_b_logo),
|
||
team_a_logo_res: team_a_res,
|
||
team_b_logo_res: team_b_res,
|
||
channel_logo: unc_to_uri(&channel_logo),
|
||
outfile_name,
|
||
row_index,
|
||
})
|
||
}
|
||
|
||
pub fn create_variants(&self) -> Vec<Self> {
|
||
let mut variants = vec![self.clone()];
|
||
let mut today = self.clone();
|
||
today.data_display = "сегодня".to_string();
|
||
today.outfile_name = format!("{}_Today", self.outfile_name);
|
||
variants.push(today);
|
||
let mut tomorrow = self.clone();
|
||
tomorrow.data_display = "завтра".to_string();
|
||
tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name);
|
||
variants.push(tomorrow);
|
||
variants
|
||
}
|
||
|
||
pub fn to_nexrender_job(&self, output_folder: &str) -> NexrenderJob {
|
||
let template = if self.team_b.is_empty() {
|
||
Template::single()
|
||
} else {
|
||
Template::default()
|
||
};
|
||
|
||
let output_path = format!("{}/{}.mp4", output_folder, self.outfile_name);
|
||
let mut actions = Actions::default();
|
||
actions.postrender.push(PostrenderAction::Copy {
|
||
input: "encoded.mp4".to_string(),
|
||
output: output_path,
|
||
});
|
||
|
||
let mut assets = Vec::new();
|
||
self.add_text_assets(&mut assets);
|
||
self.add_image_assets(&mut assets);
|
||
if !self.pack_path.is_empty() {
|
||
assets.push(Asset::Video {
|
||
src: self.pack_path.clone(),
|
||
asset_type: "video".to_string(),
|
||
layer_name: "TOP".to_string(),
|
||
});
|
||
}
|
||
|
||
NexrenderJob {
|
||
template,
|
||
actions,
|
||
assets,
|
||
}
|
||
}
|
||
|
||
fn add_text_assets(&self, assets: &mut Vec<Asset>) {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "DATA".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(self.data_display),
|
||
});
|
||
self.add_date_adjustments(assets);
|
||
|
||
if !self.team_b.is_empty() {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TIME_H".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(self.time_h),
|
||
});
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TIME_M".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(self.time_m),
|
||
});
|
||
self.add_time_adjustments(assets);
|
||
} else {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TIME".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(self.time),
|
||
});
|
||
}
|
||
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "LEAGUE".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(self.league),
|
||
});
|
||
self.add_league_adjustments(assets);
|
||
|
||
if !self.sport.is_empty() {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "SPORT".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(self.sport),
|
||
});
|
||
}
|
||
|
||
let teams_text = if !self.team_a.is_empty() && !self.team_b.is_empty() {
|
||
format!("{} - {}", self.team_a, self.team_b)
|
||
} else if !self.team_a.is_empty() {
|
||
self.team_a.clone()
|
||
} else {
|
||
self.team_b.clone()
|
||
};
|
||
if !teams_text.is_empty() {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TEAMS".to_string(),
|
||
property: "Source Text".to_string(),
|
||
value: json!(teams_text),
|
||
});
|
||
self.add_teams_adjustments(assets);
|
||
}
|
||
}
|
||
|
||
fn add_date_adjustments(&self, assets: &mut Vec<Asset>) {
|
||
let (font_size, anchor_point): (&str, Vec<i32>) = match self.data_display.as_str() {
|
||
"сегодня" if !self.team_b.is_empty() => ("105", vec![0, 5]),
|
||
"завтра" if !self.team_b.is_empty() => ("115", vec![0, 25]),
|
||
_ if self.data_display.len() < 6 && !self.team_b.is_empty() => ("120", vec![0, 20]),
|
||
_ => return,
|
||
};
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "DATA".to_string(),
|
||
property: "Source Text.fontSize".to_string(),
|
||
value: json!(font_size),
|
||
});
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "DATA".to_string(),
|
||
property: "transform.anchorPoint".to_string(),
|
||
value: json!(anchor_point),
|
||
});
|
||
}
|
||
|
||
fn add_time_adjustments(&self, assets: &mut Vec<Asset>) {
|
||
let anchor_point = if self.time_h.len() < 2 {
|
||
vec![60, 0]
|
||
} else if self.time_h.len() == 2 && self.time_h.parse::<i32>().unwrap_or(20) < 20 {
|
||
vec![20, 0]
|
||
} else {
|
||
return;
|
||
};
|
||
for layer in ["TIME_H", "TIME_M", "TIME"] {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: layer.to_string(),
|
||
property: "transform.anchorPoint".to_string(),
|
||
value: json!(anchor_point),
|
||
});
|
||
}
|
||
}
|
||
|
||
fn add_league_adjustments(&self, assets: &mut Vec<Asset>) {
|
||
if self.league.len() > 16 {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "LEAGUE".to_string(),
|
||
property: "Source Text.fontSize".to_string(),
|
||
value: json!("73"),
|
||
});
|
||
}
|
||
}
|
||
|
||
fn add_teams_adjustments(&self, assets: &mut Vec<Asset>) {
|
||
if (self.team_a.len() + self.team_b.len()) >= 32 {
|
||
assets.push(Asset::Data {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TEAMS".to_string(),
|
||
property: "Source Text.fontSize".to_string(),
|
||
value: json!("55"),
|
||
});
|
||
}
|
||
}
|
||
|
||
fn add_image_assets(&self, assets: &mut Vec<Asset>) {
|
||
if !self.team_a_logo.is_empty() {
|
||
assets.push(Asset::Image {
|
||
src: self.team_a_logo.clone(),
|
||
asset_type: "image".to_string(),
|
||
layer_name: "TEAM_A_LOGO".to_string(),
|
||
});
|
||
if let Some(res) = &self.team_a_logo_res {
|
||
assets.push(Asset::DataExpression {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TEAM_A_LOGO".to_string(),
|
||
property: "scale".to_string(),
|
||
expression: logo_scale_expression(res),
|
||
});
|
||
}
|
||
}
|
||
if !self.team_b_logo.is_empty() {
|
||
assets.push(Asset::Image {
|
||
src: self.team_b_logo.clone(),
|
||
asset_type: "image".to_string(),
|
||
layer_name: "TEAM_B_LOGO".to_string(),
|
||
});
|
||
if let Some(res) = &self.team_b_logo_res {
|
||
assets.push(Asset::DataExpression {
|
||
asset_type: "data".to_string(),
|
||
layer_name: "TEAM_B_LOGO".to_string(),
|
||
property: "scale".to_string(),
|
||
expression: logo_scale_expression(res),
|
||
});
|
||
}
|
||
}
|
||
if !self.channel_logo.is_empty() {
|
||
assets.push(Asset::Image {
|
||
src: self.channel_logo.clone(),
|
||
asset_type: "image".to_string(),
|
||
layer_name: "CHANELL".to_string(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct LogoRegistry {
|
||
logos: HashMap<(String, String), String>,
|
||
}
|
||
|
||
impl LogoRegistry {
|
||
pub fn new() -> Self {
|
||
Self::default()
|
||
}
|
||
|
||
pub fn insert(&mut self, team: String, sport: String, link: String) {
|
||
self.logos.insert((team, sport), link);
|
||
}
|
||
|
||
pub fn find(&self, team: &str, sport: &str) -> Option<String> {
|
||
if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) {
|
||
return Some(link.clone());
|
||
}
|
||
for ((t, s), link) in &self.logos {
|
||
if s == sport && team.starts_with(t) {
|
||
return Some(link.clone());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
fn parse_date(s: &str) -> (NaiveDate, String) {
|
||
let parts: Vec<&str> = s.split('.').collect();
|
||
if parts.len() == 3 {
|
||
if let (Ok(day), Ok(month), Ok(year)) = (
|
||
parts[0].parse::<u32>(),
|
||
parts[1].parse::<u32>(),
|
||
parts[2].parse::<i32>(),
|
||
) {
|
||
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) {
|
||
let months = [
|
||
"",
|
||
"января",
|
||
"февраля",
|
||
"марта",
|
||
"апреля",
|
||
"мая",
|
||
"июня",
|
||
"июля",
|
||
"августа",
|
||
"сентября",
|
||
"октября",
|
||
"ноября",
|
||
"декабря",
|
||
];
|
||
return (date, format!("{} {}", day, months[month as usize]));
|
||
}
|
||
}
|
||
}
|
||
(NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), s.to_string())
|
||
}
|
||
|
||
fn parse_time(s: &str) -> (String, String) {
|
||
let parts: Vec<&str> = s.split(':').collect();
|
||
if parts.len() >= 2 {
|
||
(parts[0].to_string(), parts[1].to_string())
|
||
} else {
|
||
(s.to_string(), "00".to_string())
|
||
}
|
||
}
|
||
|
||
fn parse_team_name(s: &str) -> (String, Option<String>) {
|
||
let parts: Vec<&str> = s.split('#').collect();
|
||
if parts.len() >= 3 {
|
||
(parts[0].to_string(), Some(parts[2].to_string()))
|
||
} else {
|
||
(parts[0].to_string(), None)
|
||
}
|
||
}
|
||
|
||
fn unc_to_uri(path: &str) -> String {
|
||
if path.is_empty() {
|
||
return String::new();
|
||
}
|
||
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") {
|
||
return path.to_string();
|
||
}
|
||
format!("file://{}", path.replace('\\', "/").trim_start_matches('/'))
|
||
}
|
||
|
||
fn logo_scale_expression(target_size: &str) -> String {
|
||
format!(
|
||
"if (width > height) {{max_size = width;}} else {{max_size = height;}} var real_size = {}/max_size*100;[real_size,real_size]",
|
||
target_size
|
||
)
|
||
}
|