//! Image sequences (#297): File ▸ Import with the " Sequence" option, picked ranges, //! folders, Force Alphabetical Order, the import frame rate, Replace Footage, missing frames //! (placeholders, as in After Effects) and Interpret Footage ▸ Start Timecode. use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use crate::Session; /// Probes every file as a 64×32 still (the media layer's single-file probe). struct Stills; impl crate::Importer for Stills { fn probe(&self, path: &str) -> Result { if !std::path::Path::new(path).is_file() { return Err(format!("{path}: no such file")); } Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() }) } } /// A fresh folder with empty files of these names; returns its path. fn folder(names: &[&str]) -> String { use std::sync::atomic::{AtomicU32, Ordering}; static N: AtomicU32 = AtomicU32::new(0); let d = std::env::temp_dir().join(format!("ec-seq-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); for n in names { std::fs::write(d.join(n), b"").unwrap(); } d.to_string_lossy().to_string() } fn at(dir: &str, name: &str) -> String { std::path::Path::new(dir).join(name).to_string_lossy().to_string() } fn session() -> Session { let mut s = Session { importer: Some(std::sync::Arc::new(Stills)), ..Default::default() }; s.prefs.import.sequence_fps = 30.0; s } fn footage(s: &Session, item: &Value) -> (String, Footage) { let it = s.project.item(ItemId(item.as_u64().unwrap())).unwrap(); let ItemKind::Footage(f) = &it.kind else { panic!("footage") }; (it.name.clone(), f.clone()) } const RUN: [&str; 5] = ["shot_0001.png", "shot_0002.png", "shot_0004.png", "shot_0005.png", "notes.txt"]; #[test] fn one_picked_numbered_file_imports_its_run_as_one_sequence() { let dir = folder(&RUN); let mut s = session(); let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")]})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (name, f) = footage(&s, &r["items"][0]); assert_eq!(name, "shot_[0001-0005].png"); assert_eq!((f.kind, f.sequence.len(), f.alphabetical), (FootageKind::Sequence, 4, false)); assert_eq!(f.path, at(&dir, "shot_0001.png")); // Frames 1–5 at Settings ▸ Import ▸ Sequence Footage (30 fps); frame 3 is a placeholder. assert_eq!(f.duration, FrameRate::FPS_30.tick_of(5)); assert_eq!(f.sequence_file(2), None); // Settings ▸ Import ▸ Report Missing Frames. let w = r["warnings"][0].as_str().unwrap(); assert!(w.contains("1 missing frame (3)"), "{w}"); } /// Picking every frame of a sequence used to make one item per frame (each a whole sequence on /// the desktop, a still in the browser). #[test] fn picking_every_frame_imports_one_sequence() { let dir = folder(&RUN); let mut s = session(); let all: Vec = RUN[..4].iter().map(|n| at(&dir, n)).collect(); let r = s.execute("file.import", json!({"paths": all, "frameRate": 24})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 1, "{r}"); let (_, f) = footage(&s, &r["items"][0]); assert_eq!(f.sequence.len(), 4); // `frameRate` sets the sequence's rate on import. assert_eq!((f.frame_rate, f.duration), (FrameRate::FPS_24, FrameRate::FPS_24.tick_of(5))); // A picked range is just that range. let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0004.png"), at(&dir, "shot_0005.png")]})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0004-0005].png", 2)); assert!(s.execute("file.import", json!({"paths": all, "frameRate": 0})).is_err()); } #[test] fn the_sequence_option_off_imports_stills() { let dir = folder(&RUN); let mut s = session(); let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "sequence": false})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); assert_eq!((name.as_str(), f.kind), ("shot_0002.png", FootageKind::Still)); let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png"), at(&dir, "shot_0002.png")], "sequence": false})).unwrap(); assert_eq!(r["items"].as_array().unwrap().len(), 2); } #[test] fn a_folder_imports_its_files_with_runs_as_sequences() { let dir = folder(&["a_01.png", "a_02.png", "b.png", "readme.txt"]); let mut s = session(); let r = s.execute("file.import", json!({"paths": [dir], "background": true})).unwrap(); s.execute("jobs.wait", json!({})).unwrap(); assert!(r["job"].is_u64() || r["job"].is_string(), "{r}"); let names: Vec = s.project.items.values().map(|i| i.name.clone()).collect(); assert_eq!(names, ["a_[01-02].png", "b.png"], "the text file is skipped"); } #[test] fn force_alphabetical_order_takes_every_image_of_the_type() { let dir = folder(&["take_b.png", "take_a.png", "other_7.png", "c.jpg"]); let mut s = session(); let r = s.execute("file.import", json!({"paths": [at(&dir, "take_a.png")], "alphabetical": true})).unwrap(); let (name, f) = footage(&s, &r["items"][0]); let files: Vec = f.sequence.iter().map(|p| crate::sequence::file_name(p)).collect(); assert_eq!(files, ["other_7.png", "take_a.png", "take_b.png"]); // Its numbering doesn't count: the files play one after another. assert_eq!((name.as_str(), f.alphabetical, f.sequence_frames()), ("other_7.png", true, 3)); assert!(r["warnings"].as_array().is_none_or(Vec::is_empty), "{r}"); } #[test] fn interpret_footage_start_timecode_relabels_source_time() { let dir = folder(&RUN); let mut s = session(); let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")]})).unwrap()["items"][0].clone(); s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 10})).unwrap(); let layer = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); let out = |s: &Session| s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().out_point; assert_eq!(out(&s), FrameRate::FPS_30.tick_of(5)); // Override Start changes the timecode the Footage panel shows, not the frames (After Effects). let r = s.execute("file.interpretFootage", json!({"items": [item], "overrideStart": true, "startTimecode": "0:00:01:10"})).unwrap(); assert_eq!((r["items"][0]["startTimecode"].as_str(), r["items"][0]["frames"].as_i64()), (Some("0:00:01:10"), Some(5))); assert_eq!((footage(&s, &item).1.start_timecode, out(&s)), (Some(40), FrameRate::FPS_30.tick_of(5))); s.execute("footage.open", json!({"item": item})).unwrap(); s.execute("footage.setTime", json!({"frame": 2})).unwrap(); assert_eq!(s.execute("footage.info", json!({})).unwrap()["timecode"], "0:00:01:12"); // A frame number works too; Use Source File Timecode goes back to 0. s.execute("file.interpretFootage", json!({"items": [item], "startTimecode": 90})).unwrap(); assert_eq!(footage(&s, &item).1.start_timecode, Some(90)); let r = s.execute("file.interpretFootage", json!({"items": [item], "overrideStart": false, "startTimecode": "0:00:09:00"})).unwrap(); assert_eq!((r["items"][0]["startTimecode"].as_str(), footage(&s, &item).1.start_timecode), (Some("0:00:00:00"), None)); // Timecode is read at the footage's frame rate. s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 10, "startTimecode": "0:00:01:05"})).unwrap(); let f = footage(&s, &item).1; assert_eq!((f.duration, f.start_timecode), (Tick::from_seconds_f64(0.5), Some(15))); for bad in [json!({"startTimecode": "x"}), json!({"startTimecode": 1e300}), json!({"startTimecode": -5}), json!({"startTimecode": "-1:00"})] { let mut p = bad.clone(); p["items"] = json!([item]); assert!(s.execute("file.interpretFootage", p).is_err(), "{bad}"); } // Undo puts the interpretation back. s.execute("edit.undo", json!({})).unwrap(); assert_eq!((footage(&s, &item).1.frame_rate, footage(&s, &item).1.start_timecode), (FrameRate::FPS_30, None)); } #[test] fn replace_footage_and_reload_keep_sequences() { let dir = folder(&RUN); let mut s = session(); let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone(); s.state.project_selection = vec![ItemId(item.as_u64().unwrap())]; s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap(); let (name, f) = footage(&s, &item); assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4)); // Reload finds a new frame and keeps the interpretation. s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap(); std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap(); s.execute("file.reloadFootage", json!({"items": [item]})).unwrap(); let (_, f) = footage(&s, &item); assert_eq!((f.sequence.len(), f.frame_rate, f.start_timecode), (5, FrameRate::from_f64(12.0), Some(2))); assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5)); }