#![allow(dead_code)] use chrono::{Datelike, NaiveDate}; use regex::Regex; 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 Template { pub fn double( src: String, composition: String, output_module: String, output_ext: String, ) -> Self { Self { src, composition, output_module, output_ext, } } pub fn single( src: String, composition: String, output_module: String, output_ext: String, ) -> Self { Self { src, composition, output_module, output_ext, } } } #[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, #[serde(skip_serializing_if = "std::ops::Not::not")] cache: bool, }, Video { src: String, #[serde(rename = "type")] asset_type: String, #[serde(rename = "layerName")] layer_name: String, #[serde(skip_serializing_if = "std::ops::Not::not")] cache: bool, }, } #[derive(Debug, Clone, Serialize)] pub struct NexrenderJob { pub template: Template, pub actions: Actions, pub assets: Vec, } #[derive(Debug, Clone, Serialize)] pub struct Actions { pub postrender: Vec, } 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, pub team_b_logo_res: Option, pub channel_logo: String, pub outfile_name: String, pub row_index: usize, // Поля для сортировки pub sort_date: NaiveDate, pub sort_time: String, pub variant_order: u8, } impl JobData { pub fn from_row( row: &HashMap, row_index: usize, packs: &HashMap, logos: &LogoRegistry, channels: &HashMap, ) -> Option { 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 sort_time = format!("{}:{}", time_h, time_m); 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 mut outfile_name = transliterate(&fn_parts.join("_"), true) .replace(' ', "-") .replace('\'', ""); // Заменяем 2 и более дефисов подряд на один let re = Regex::new(r"-{2,}").unwrap(); outfile_name = re.replace_all(&outfile_name, "-").to_string(); // Убираем дефисы в начале и конце outfile_name = outfile_name.trim_matches('-').to_string(); Some(Self { 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, sort_date: date_obj, sort_time, variant_order: 0, }) } pub fn create_variants(&self) -> Vec { let mut variants = Vec::new(); // Оригинал (0) let mut original = self.clone(); original.variant_order = 0; variants.push(original); // Today (1) let mut today = self.clone(); today.data_display = "сегодня".to_string(); today.outfile_name = format!("{}_Today", self.outfile_name); today.variant_order = 1; variants.push(today); // Tomorrow (2) let mut tomorrow = self.clone(); tomorrow.data_display = "завтра".to_string(); tomorrow.outfile_name = format!("{}_Tomorrow", self.outfile_name); tomorrow.variant_order = 2; variants.push(tomorrow); variants } pub fn to_nexrender_job(&self, config: &crate::config::Config) -> NexrenderJob { let template = if self.team_b.is_empty() { Template::single( config.template_single_src.clone(), config.template_composition.clone(), config.template_output_module.clone(), config.template_output_ext.clone(), ) } else { 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!( "{}/{}.{}", config.output_folder, self.outfile_name, config.template_output_ext ); let mut actions = Actions::default(); actions.postrender.push(PostrenderAction::Copy { input: format!("encoded.{}", config.template_output_ext), 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(), cache: true, }); } NexrenderJob { template, actions, assets, } } fn add_text_assets(&self, assets: &mut Vec) { // DATA — всегда 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); // TIME — зависит от шаблона if !self.team_b.is_empty() { // DOUBLE: TIME_H и TIME_M 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 { // SINGLE: TIME assets.push(Asset::Data { asset_type: "data".to_string(), layer_name: "TIME".to_string(), property: "Source Text".to_string(), value: json!(self.time), }); } // LEAGUE — всегда 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); // SPORT — всегда 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), }); } // TEAMS — ТОЛЬКО для DOUBLE шаблона if !self.team_b.is_empty() { let teams_text = format!("{} - {}", self.team_a, self.team_b); 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) { let (font_size, anchor_point): (&str, Vec) = 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) { let anchor_point = if self.time_h.len() < 2 { vec![60, 0] } else if self.time_h.len() == 2 && self.time_h.parse::().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) { 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) { 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) { // Логотипы команд — НЕ кешируем (часто меняются) 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(), cache: false, }); 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(), cache: false, }); 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(), cache: true, }); } } } #[derive(Debug, Clone, Default)] pub struct LogoRegistry { logos: HashMap<(String, String), String>, } impl LogoRegistry { pub fn new() -> Self { 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) { self.logos.insert((team, sport), link); } pub fn find(&self, team: &str, sport: &str) -> Option { log::debug!("LogoRegistry::find: team='{}', sport='{}'", team, sport); if let Some(link) = self.logos.get(&(team.to_string(), sport.to_string())) { log::debug!("Found exact match: {}", link); return Some(link.clone()); } for ((t, s), link) in &self.logos { if s == sport && team.starts_with(t) { log::debug!("Found prefix match: t='{}', link='{}'", t, link); return Some(link.clone()); } } log::warn!("No logo found for team='{}', sport='{}'", team, sport); None } /// Оставляет только указанные команды, удаляя все остальные pub fn retain(&mut self, mut predicate: F) where F: FnMut(&str, &str) -> bool, { self.logos.retain(|(team, sport), _| predicate(team, sport)); } /// Возвращает количество записей в реестре pub fn len(&self) -> usize { self.logos.len() } } fn parse_date(s: &str) -> (NaiveDate, String) { let parts: Vec<&str> = s.split('.').collect(); if parts.len() == 3 { if let (Ok(day), Ok(month), Ok(year)) = ( parts[0].parse::(), parts[1].parse::(), parts[2].parse::(), ) { 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) { 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(); } // Уже правильный URI if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("file://") { return path.to_string(); } // Mac SMB путь: smb://server/share/path -> file://server/share/path if path.starts_with("smb://") { let without_smb = path.strip_prefix("smb:").unwrap_or(path); return format!("file:{}", without_smb.replace('\\', "/")); } // Windows UNC путь: \\server\share\path -> file://server/share/path let normalized = path.replace('\\', "/"); // Убираем ведущие слеши (для UNC их быть не должно) let trimmed = normalized.trim_start_matches('/'); format!("file://{}", trimmed) } 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 ) }