Files
effectcraft/crates/engine/src/tests_sequence.rs
T
Claude 40e87709d5 Import: numbered stills import as stills unless the Sequence option is on; dropped folders make sequences (#431)
Cause: SequenceOptions::from_params defaulted `sequence` to true for
file.import, so any numbered still (IMG_0001.jpg) brought its whole numbered
run from its folder, and several picked or dropped stills with a common
prefix became one sequence. Dropped files never asked, and the Import
dialog's "<format> Sequence" checkbox started ticked.

Fix, matching After Effects: file.import takes picked and dropped files as
stills unless `sequence: true` (the Import dialog's Sequence checkbox, which
now starts unticked; Force Alphabetical Order implies it), while the numbered
stills inside a picked or dropped folder still import as sequences
(SequenceOptions::folders), and `sequence: false` (Alt-drag of a folder)
imports a folder's files one by one. Replace Footage, reload, proxies and
render outputs keep detecting their sequence (SequenceOptions::default and
from_params are unchanged), and mediaBrowser.import keeps bringing a numbered
still's run unless `sequence: false`. docs/footage.md and docs/agents.md
describe the new defaults.

Tests: sequence import_takes_stills_unless_asked_for_a_sequence and engine
numbered_stills_import_as_stills_by_default (one dropped still and two picked
IMG_ stills stay stills, a dropped folder makes one sequence, the checkbox
brings the run); the existing sequence tests pass `sequence: true`; ui
import_dialog_offers_the_image_sequence_option (unticked by default: a still;
ticked: the run) and a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation
(two dropped frames: two stills; the folder: one sequence).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
(cherry picked from commit aa3c31db9ab3a774ee2f8ba867e1bafc1fce7aba)
2026-10-10 19:48:25 +00:00

194 lines
10 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.
//! Image sequences (#297): File ▸ Import with the "<format> 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<Footage, String> {
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")], "sequence": true})).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<String> = RUN[..4].iter().map(|n| at(&dir, n)).collect();
let r = s.execute("file.import", json!({"paths": all, "sequence": true, "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")], "sequence": true})).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, "sequence": true, "frameRate": 0})).is_err());
}
/// #431: without the Sequence option, picked or dropped numbered stills import as stills (one
/// item each), as in After Effects; a dropped folder still makes its runs sequences.
#[test]
fn numbered_stills_import_as_stills_by_default() {
let dir = folder(&RUN);
let mut s = session();
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0002.png")], "drag": true})).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_0004.png")]})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
}
#[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<String> = 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<String> = 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")], "sequence": true})).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));
}