Оптимизирована работа с данными листов и улучшена обработка Excel‑дат
Упрощено формирование row_data без лишних переменных. Удалены избыточные отладочные println!, уменьшив шум в консоли. Переписаны комментарии к блокам парсинга, убраны неиспользуемые выводы количества заголовков и строк. Объединено условие проверки Excel‑даты в компактный диапазон (1.0..100000.0). Удалены устаревшие комментарии о базовой дате; оставлено только вычисление даты. Добавлена асинхронная функция cleanup_finished_jobs для очистки завершённых/ошибочных задач Nexrender, с выводом информации о выполненных чистках. Реализована функция monitor_jobs, периодически проверяющая статус ожидающих заданий, выводящая прогресс и финальное сообщение о завершении всех работ. Эти изменения делают код чище, повышают читаемость и добавляют автоматизированное управление задачами Nexrender.
This commit is contained in:
586
src/nexrender.rs
Normal file
586
src/nexrender.rs
Normal file
@@ -0,0 +1,586 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user