diff --git a/Cargo.lock b/Cargo.lock index 6150092..2f0ebc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -383,6 +383,7 @@ dependencies = [ "regex", "serde", "serde_json", + "strfmt", "sys-locale", ] @@ -742,6 +743,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "strfmt" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8348af2d9fc3258c8733b8d9d8db2e56f54b2363a4b5b81585c7875ed65e65" + [[package]] name = "strsim" version = "0.11.0" diff --git a/Cargo.toml b/Cargo.toml index f54c843..3921cad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ exr = "1.73.0" jwalk = "0.8.1" lazy_static = "1.5.0" rayon = "1.10.0" +strfmt = "0.2.4" [dependencies.regex] version = "1.11.1" diff --git a/benches/mega.rs b/benches/mega.rs index 58f4b1d..9c895f0 100644 --- a/benches/mega.rs +++ b/benches/mega.rs @@ -4,6 +4,7 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; use framels::{ basic_listing, extended_listing, parse_dir, paths::{Paths, PathsPacked}, + FormatTemplate, }; fn generate_paths(n: u64) -> Paths { @@ -18,19 +19,19 @@ fn generate_paths(n: u64) -> Paths { fn parse_and_run() { let source = "./samples/big".to_string(); let paths: Paths = parse_dir(&source); - let _results: PathsPacked = basic_listing(paths, false); + let _results: PathsPacked = basic_listing(paths, false, FormatTemplate::default().format); } fn small_parse_and_run() { let source = "./samples/big".to_string(); let paths: Paths = parse_dir(&source); - let _results: PathsPacked = basic_listing(paths, false); + let _results: PathsPacked = basic_listing(paths, false, FormatTemplate::default().format); } fn exr_reading() { let source = "./samples/big/".to_string(); let paths: Paths = parse_dir(&source); - let _results: PathsPacked = extended_listing(source, paths, false); + let _results: PathsPacked = extended_listing(source, paths, false, FormatTemplate::default().format); } fn criterion_benchmark(c: &mut Criterion) { @@ -39,10 +40,10 @@ fn criterion_benchmark(c: &mut Criterion) { for i in [1u64, 10u64, 100u64, 1000u64, 10000u64].iter() { let data_set = generate_paths(*i); group.bench_with_input(BenchmarkId::new("Mono", i), i, |b, _i| { - b.iter(|| basic_listing(data_set.clone(), false)) + b.iter(|| basic_listing(data_set.clone(), false, FormatTemplate::default().format)) }); group.bench_with_input(BenchmarkId::new("Multi", i), i, |b, _i| { - b.iter(|| basic_listing(data_set.clone(), true)) + b.iter(|| basic_listing(data_set.clone(), true, FormatTemplate::default().format)) }); } group.finish(); diff --git a/samples/uncontinu/aaa.015.tif b/samples/uncontinu/aaa.015.tif new file mode 100644 index 0000000..e69de29 diff --git a/samples/uncontinu/aaa.016.tif b/samples/uncontinu/aaa.016.tif new file mode 100644 index 0000000..e69de29 diff --git a/samples/uncontinu/aaa.019.tif b/samples/uncontinu/aaa.019.tif new file mode 100644 index 0000000..e69de29 diff --git a/samples/uncontinu/aaa.020.tif b/samples/uncontinu/aaa.020.tif new file mode 100644 index 0000000..e69de29 diff --git a/src/lib.rs b/src/lib.rs index 486dec0..28da4dc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,7 +10,7 @@ //! //! The key concept of the library is to pack frames sequences in a new //! filename like `toto.***.jpg@158-179`. It use a regex to extract the frame -//! number and pack the frames. The regex is `(?x)(.*)(\.|_)(?P\d{2,9})\.(\w{2,5})$`. +//! number and pack the frames. The regex is `(?x)(?P.*)(?P\.|_)(?P\d{2,9})\.(?P\w{2,5})$`. //! It results in the limitations of only: //! //! - `.` or `_` as a separator between the filename and the frame number. @@ -81,20 +81,31 @@ //! similar to `rvls -l` //! It take a `Vec` of entries as an input //! - Pack the frames -//! - Print the metada if the sequence is an exr sequence +//! - Print the metadata if the sequence is an exr sequence //! - Return a Vector of path packed //! +//! ### Formating the output +//! +//! The output can be formated using the [FormatTemplate] struct. +//! +//! Three formats are available, with `toto.160.jpg` as an example: +//! - default : `{name}{sep}{padding}.{ext}@{first_frame}-{last_frame}` => `toto.***.jpg@158-179` +//! - buf : `{name}{sep}[{first_frame}:{last_frame}].{ext}` => `toto.[158:179].jpg` +//! - nuke : `{name}{sep}.{ext} {first_frame}-{last_frame}`=> `toto.jpg 158-179` +//! +//! //! ### Example //! //! ```rust -//! use framels::{basic_listing, extended_listing, parse_dir, paths::{Paths,Join}, recursive_dir}; +//! use framels::{basic_listing, extended_listing, parse_dir, paths::{Paths,Join}, recursive_dir, FormatTemplate}; //! //! fn main() { //! // Perform directory listing //! let in_paths: Paths = parse_dir("./samples/small"); //! //! // Generate results based on arguments -//! let results: String = basic_listing(in_paths, false).get_paths().join("\n"); +//! let results: String = basic_listing(in_paths, false, +//! FormatTemplate::default().format).get_paths().join("\n"); //! //! println!("{}", results) //! } @@ -110,6 +121,7 @@ use regex::{Captures, Regex}; use std::collections::HashMap; use std::fs; use std::{clone::Clone, path::PathBuf}; +use strfmt::strfmt; /// # parse_dir /// List files and directories in the targeted directory, take a `String` as @@ -141,52 +153,198 @@ pub fn recursive_dir(input_path: &str) -> Paths { } /// This function extract the matching group based on regex already compile to -/// a tuple of string. For exemple toto.458.jpg should return -/// (toto.***.jpg, 458) +/// a tuple of (file_padded:String, frame_number:String, hashmap) +/// For example toto.459.jpg should return: +/// (toto.***.jpg, 458, [("name", "toto"), ("sep", "."), ("frames", "458"), ("ext", "jpg")]) #[inline(always)] -fn extract_regex(x: &str) -> (String, String) { +fn extract_regex(x: &str) -> (String, Option>) { lazy_static! { - static ref RE_FLS: Regex = Regex::new(r"(?x)(.*)(\.|_)(?P\d{2,9})\.(\w{2,5})$") - .expect("Can't compile regex"); + static ref RE_FLS: Regex = + Regex::new(r"(?x)(?P.*)(?P\.|_)(?P\d{2,9})\.(?P\w{2,5})$") + .expect("Can't compile regex"); } let result_caps: Option = RE_FLS.captures(&x); match result_caps { - None => (x.to_string(), "None".to_string()), - caps_wrap => { - let caps = caps_wrap.unwrap(); + None => (x.to_string(), None), + Some(caps) => { + let hashmap: HashMap = RE_FLS + .capture_names() + .flatten() + .filter_map(|n| { + caps.name(n) + .map(|m| (n.to_string(), m.as_str().to_string())) + }) + .collect(); ( x.replace(&caps["frames"], &"*".repeat(caps["frames"].len())), - caps["frames"].to_string(), + Some(hashmap), ) } } } +/// A struct representing a collection of frames with associated information. +/// +/// # Fields +/// +/// * `name` - The name of the frame collection. +/// * `sep` - A separator string used in the frame collection. +/// * `frames` - A vector containing the frame data as integers. +/// * `ext` - The file extension associated with the frames. +/// * `padding` - The amount of padding to be applied to the frames. +#[derive(Debug)] +struct Frames { + name: String, + sep: String, + frames: Vec, + ext: String, + padding: usize, +} + +impl Frames { + /// Creates a `Frames` struct from a `HashMap`. + /// + /// # Parameters + /// + /// * `_hashmap` - A reference to a `HashMap` containing the frame details. + /// name, sep, frames, ext, padding + /// # Returns + /// + /// A `Frames` struct populated with the values from the `HashMap`. + fn from_hashmap(_hashmap: &HashMap) -> Self { + let name = _hashmap.get("name").unwrap_or(&"None".to_string()).clone(); + let sep = _hashmap.get("sep").unwrap_or(&"None".to_string()).clone(); + let frame = _hashmap + .get("frames") + .unwrap_or(&"None".to_string()) + .clone(); + let frames = vec![frame.parse().unwrap_or(0)]; + let ext = _hashmap.get("ext").unwrap_or(&"None".to_string()).clone(); + let padding = frame.len(); + + Frames { + name, + sep, + frames, + ext, + padding, + } + } + /// Returns the minimum value in `self.frames`. + /// + /// # Returns + /// + /// An `Option` containing the minimum value in `self.frames`, or `None` if `self.frames` is empty. + fn first_frame(&self) -> Option { + self.frames.iter().min().copied() + } + /// Returns the maximum value in `self.frames`. + /// + /// # Returns + /// + /// An `Option` containing the maximum value in `self.frames`, or `None` if `self.frames` is empty. + fn last_frame(&self) -> Option { + self.frames.iter().max().copied() + } + /// Returns a vector of strings representing the frame list. Used to print the missing frames. + /// + /// # Returns + /// + /// A `Vec` containing the frame list as a string. + fn frame_list(&self) -> Vec{ + let frames_as_isize: Vec = self.frames.iter() + .map(|frame| *frame as isize).collect(); + group_continuity(&frames_as_isize).iter().map(|ve| format!("{}:{}", ve[0], ve[ve.len()-1])).collect() + + } + /// Converts the `Frames` struct to a `HashMap` of string values. + /// + /// This was implemented to be used with `strfmt::strfmt` + /// + /// # Returns + /// + /// A `HashMap` containing the following key-value pairs: + /// + /// * `"name"` - The name of the frame. + /// * `"sep"` - The separator used in the frame. + /// * `"ext"` - The extension of the frame. + /// * `"first_frame"` - The first frame number as a string, or `"None"` if `self.frames` is empty. + /// * `"last_frame"` - The last frame number as a string, or `"None"` if `self.frames` is empty. + /// * `"padding"` - A string of asterisks (`'*'`) representing the padding length. + fn to_hashmap(&self) -> HashMap { + let mut map = HashMap::new(); + map.insert("name".to_string(), self.name.clone()); + map.insert("sep".to_string(), self.sep.clone()); + map.insert("ext".to_string(), self.ext.clone()); + map.insert( + "first_frame".to_string(), + self.first_frame() + .map_or("None".to_string(), |f| f.to_string()), + ); + map.insert( + "last_frame".to_string(), + self.last_frame() + .map_or("None".to_string(), |f| f.to_string()), + ); + map.insert( + "padding".to_string(), + std::iter::repeat('*').take(self.padding.into()).collect(), + ); + map.insert( + "frame_list".to_string(), + self.frame_list().join(","), + ); + map + } +} + +impl PartialEq for Frames { + /// Add a PartialEq implementation to the Frames struct + /// mainly for testing purpose. + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.sep == other.sep + && self.frames == other.frames + && self.ext == other.ext + && self.padding == other.padding + } +} + /// Parse the result of a vector of string. This function use HashMap to pack /// filename removed from the frame value. -fn parse_result(dir_scan: Paths, multithreaded: bool) -> HashMap> { +fn parse_result(dir_scan: Paths, multithreaded: bool) -> HashMap> { // Optimisation over PAR_THRESHOLD value, the parsing of the frame list // used rayon lib to paralelize the work. Result depends a lot from the // cpu number of thread may be put in a config file const PAR_THRESHOLD: usize = 100000; - let extracted: Vec<(String, String)> = if (dir_scan.len() > PAR_THRESHOLD) | multithreaded { - dir_scan - .par_iter() - .map(|path| extract_regex(path.to_str().unwrap())) - .collect() - } else { - dir_scan - .iter() - .map(|path| extract_regex(path.to_str().unwrap())) - .collect() - }; - let mut paths_dict: HashMap> = HashMap::with_capacity(extracted.len()); + let extracted: Vec<(String, Option>)> = + if (dir_scan.len() > PAR_THRESHOLD) | multithreaded { + dir_scan + .par_iter() + .map(|path| extract_regex(path.to_str().unwrap())) + .collect() + } else { + dir_scan + .iter() + .map(|path| extract_regex(path.to_str().unwrap())) + .collect() + }; + + let mut paths_dict: HashMap> = HashMap::with_capacity(extracted.len()); for extraction in extracted { - let vec1: Vec = vec![extraction.1.clone()]; paths_dict .entry(extraction.0) - .and_modify(|value| (*value).push(extraction.1)) - .or_insert(vec1); + .and_modify(|f| match f { + None => (), + Some(f) => match &extraction.1 { + None => (), + Some(t) => f.frames.push(t.get("frames").unwrap().parse().unwrap_or(0)), + }, + }) + .or_insert(match &extraction.1 { + None => None, + Some(t) => Some(Frames::from_hashmap(t)), + }); } paths_dict } @@ -208,29 +366,41 @@ fn group_continuity(data: &[isize]) -> Vec> { result.iter().map(|x| x.to_vec()).collect() } -/// Basic function to: -/// - convert vector of string into vector of isize -/// - analyse the continuity -/// - convert group of continuity into a concat string -fn create_frame_string(value: Vec) -> String { - let mut converted_vec_isize: Vec = value - .into_iter() - .map(|x| x.parse().expect("Failed to parse integer")) - .collect(); - converted_vec_isize.sort(); - let group_continuity: Vec> = group_continuity(&converted_vec_isize); - // Concatenation of continuity group in a string - group_continuity - .into_iter() - .map(|x| { - if x.len() == 1 { - x[0].to_string() - } else { - format!("{}-{}", x.first().unwrap(), x.last().unwrap()) - } - }) - .collect::>() - .join(",") +/// Structure to store output string format templates used in the library. +/// These templates are strings that can be used with the [strfmt()] crate to +/// model the output. +pub struct FormatTemplate { + pub format: &'static str, +} + +impl Default for FormatTemplate { + /// Default format template + /// + /// `toto.160.jpg` => `toto.***.jpg@158-179` + fn default() -> Self{ + FormatTemplate { + format: "{name}{sep}{padding}.{ext}@{first_frame}-{last_frame}" + } + } +} + +impl FormatTemplate { + /// Buff format template + /// + /// `toto.160.jpg` => `toto.[158:179].jpg` + pub fn buf_format () -> Self { + FormatTemplate { + format: "{name}{sep}[{frame_list}].{ext}" + } + } + /// Nuke format template + /// + /// `toto.160.jpg` => `toto.jpg 158-179` + pub fn nuke_format() -> Self { + FormatTemplate { + format: "{name}{sep}.{ext} {first_frame}-{last_frame}" + } + } } /// # basic_listing @@ -264,27 +434,32 @@ fn create_frame_string(value: Vec) -> String { /// ### Example as a library /// /// ```rust -/// use framels::{basic_listing, parse_dir, paths::{Paths,Join}}; +/// use framels::{basic_listing, parse_dir, paths::{Paths,Join}, FormatTemplate}; /// /// fn main() { /// // Perform directory listing /// let in_paths: Paths = parse_dir("./samples/small"); /// /// // Generate results based on arguments -/// let results: String = basic_listing(in_paths, false).get_paths().join("\n"); +/// let results: String = basic_listing(in_paths, false, FormatTemplate::default().format).get_paths().join("\n"); /// /// println!("{}", results) /// } /// ``` -pub fn basic_listing(frames: Paths, multithreaded: bool) -> PathsPacked { - let frames_dict: HashMap> = parse_result(frames, multithreaded); +pub fn basic_listing(frames: Paths, multithreaded: bool, format: &str) -> PathsPacked { + let frames_dict = parse_result(frames, multithreaded); let mut frames_list: Vec = frames_dict .into_par_iter() .map(|(key, value)| { - if value[0] == "None" && value.len() == 1 { - key - } else { - format!("{}@{}", key, create_frame_string(value)) + match value { + None => key, + Some(f) => match strfmt(format, &f.to_hashmap()) { + Ok(s) => s, + Err(e) => { + eprint!("Error formatting string: {}", e); + String::new() + } + }, } }) .collect(); @@ -322,7 +497,7 @@ fn get_exr_metada(root_path: &String, path: &String) -> String { /// /// It take a `Vec` of entries as an input /// - Pack the frames -/// - Print the metada if the sequence is an exr sequence +/// - Print the metadata if the sequence is an exr sequence /// - Return a Vector of path packed /// /// ## Example @@ -335,19 +510,29 @@ fn get_exr_metada(root_path: &String, path: &String) -> String { /// ```bash /// ./samples/small/foo.exr Not an exr /// ``` -pub fn extended_listing(root_path: String, frames: Paths, multithreaded: bool) -> PathsPacked { - let frames_dict: HashMap> = parse_result(frames, multithreaded); +pub fn extended_listing(root_path: String, frames: Paths, multithreaded: bool, format: &str) -> PathsPacked { + let frames_dict = parse_result(frames, multithreaded); let mut out_frames: PathsPacked = PathsPacked::new_empty(); for (key, value) in frames_dict { - if value[0] == "None" && value.len() == 1 { - out_frames.push_metadata(get_exr_metada(&root_path, &key)); - out_frames.push_paths(key.into()); - } else { - let to = value.first().unwrap(); - let from = String::from_utf8(vec![b'*'; to.len()]).unwrap(); - let new_path = &key.replace(&from, to); - out_frames.push_metadata(get_exr_metada(&root_path, &new_path)); - out_frames.push_paths(format!("{}@{}", key, create_frame_string(value)).into()); + match value { + None => { + out_frames.push_metadata(get_exr_metada(&root_path, &key)); + out_frames.push_paths(key.into()) + } + Some(f) => { + let to = f.frames.first().unwrap(); + let frame = format!("{:0zfill$}", to, zfill = f.padding); + let new_path = format!( + "{name}{sep}{frame}.{ext}", + name = f.name, + sep = f.sep, + ext = f.ext + ); + out_frames.push_metadata(get_exr_metada(&root_path, &new_path)); + out_frames.push_paths(PathBuf::from( + strfmt(&format, &f.to_hashmap()).unwrap(), + )); + } } } out_frames @@ -362,18 +547,28 @@ fn test_parse_dir() { #[test] fn test_handle_none() { let source: &str = "foobar.exr"; - let expected: (String, String) = (source.to_string(), "None".to_string()); - assert_eq!(expected, extract_regex(source)) + let expected: (String, Option) = (source.to_string(), None); + let extraction = extract_regex(source); + assert_eq!(expected.0, extraction.0); + assert!(extraction.1.is_none()) } #[test] fn test_regex_simple() { let source: &str = "RenderPass_Beauty_1_00000.exr"; - let expected: (String, String) = ( + let expected: (String, Option>) = ( "RenderPass_Beauty_1_*****.exr".to_string(), - "00000".to_string(), + Some(HashMap::from([ + ("name".to_string(), "RenderPass_Beauty_1".to_string()), + ("sep".to_string(), "_".to_string()), + ("frames".to_string(), "00000".to_string()), + ("ext".to_string(), "exr".to_string()), + ])), ); - assert_eq!(expected, extract_regex(source)) + let extraction = extract_regex(source); + assert_eq!(expected.0, extraction.0); + assert!(extraction.1.is_some()); + assert_eq!(expected.1, extraction.1) } #[test] fn test_parse_string() { @@ -383,11 +578,16 @@ fn test_parse_string() { "toto.003.tiff".into(), "foo.exr".into(), ]); - let vec_toto: Vec = vec!["001".to_string(), "002".to_string(), "003".to_string()]; - let vec_foo: Vec = vec!["None".to_string()]; - let expected: HashMap> = HashMap::from([ - ("toto.***.tiff".to_string(), vec_toto), - ("foo.exr".to_string(), vec_foo), + let frames_toto = Frames { + name: "toto".to_string(), + sep: ".".to_string(), + frames: vec![1, 2, 3], + ext: "tiff".to_string(), + padding: 3, + }; + let expected: HashMap> = HashMap::from([ + ("toto.***.tiff".to_string(), Some(frames_toto)), + ("foo.exr".to_string(), None), ]); assert_eq!(expected, parse_result(source, false)); } @@ -397,14 +597,3 @@ fn test_continuity() { let expected: Vec> = vec![vec![1, 2, 3], vec![5, 6, 7], vec![11, 12]]; assert_eq!(expected, group_continuity(&source)); } -#[test] -fn test_create_frame_string() { - let source: Vec = vec![ - "001".to_string(), - "005".to_string(), - "003".to_string(), - "002".to_string(), - ]; - let expected: String = "1-3,5".to_string(); - assert_eq!(expected, create_frame_string(source)); -} diff --git a/src/main.rs b/src/main.rs index 050a3ef..3d68f3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use framels::{ basic_listing, extended_listing, parse_dir, paths::{Join, Paths}, recursive_dir, + FormatTemplate, }; mod tree; use tree::run_tree; @@ -29,6 +30,10 @@ struct Args { #[arg(short, long)] tree: bool, + /// Select a format + #[arg(short, long, default_value_t = String::from("default"))] + format: String, + /// Force the use of multithreading #[arg(short, long, default_value_t = false)] multithread: bool, @@ -38,6 +43,7 @@ struct Args { root: String, } + fn main() { // Parse command-line arguments let args = Args::parse(); @@ -51,6 +57,12 @@ fn main() { parse_dir(&args.root) }; + let format = match args.format.as_str() { + "nuke" => FormatTemplate::nuke_format().format, + "buf" => FormatTemplate::buf_format().format, + _ => FormatTemplate::default().format, + }; + // Choose listing function based on arguments let results = if args.list { extended_listing( @@ -61,9 +73,10 @@ fn main() { }, in_paths, args.multithread, + format ) } else { - basic_listing(in_paths, args.multithread) + basic_listing(in_paths, args.multithread, format) }; // Display results based on arguments