Files
AE_Anons/src/nexrender.rs
Алексей Барабанов 9b3d959ce3 feat: сортировка заданий по дате и времени, последовательная отправка
- Добавлена сортировка заданий по дате и времени события (самые ранние — первыми)
- Добавлена сортировка по варианту (оригинал -> today -> tomorrow)
- Изменена отправка на последовательную для гарантированного порядка в очереди
- Исправлена нормализация дефисов (2+ дефиса -> 1)
- Исправлен SINGLE шаблон (убран слой TEAMS)
- Добавлена поддержка cache: true/false для ассетов
- Логотипы команд: cache: false (часто меняются)
- Логотип канала: cache: true (редко меняется)
- Видео-пак: cache: true (редко меняется)
- Версия 0.2.6
2026-04-17 20:00:06 +03:00

692 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![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<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,
// Поля для сортировки
pub sort_date: NaiveDate,
pub sort_time: String,
pub variant_order: u8,
}
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 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<Self> {
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<Asset>) {
// 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<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(),
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<String> {
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<F>(&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::<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();
}
// Уже правильный 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
)
}