Files
filmcraft/crates/engine/src/image_sequence_tests.rs
T
PaulandCodex 9ed9650cad fix(media): preserve Unicode prefixes in image-sequence filenames
Compute the final ASCII frame-number boundary without slicing inside a UTF-8 character. Preserve Unicode prefixes and add parser and public image-sequence import regressions, including missing frames and timeline placement.

Fixes #1565

Co-authored-by: Codex <noreply@openai.com>
2026-10-14 17:43:05 -06:00

241 lines
11 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: File ▸ Import with Image Sequence, the Import image sequences setting,
//! missing frames, the indeterminate media timebase, timeline placement and project reopen.
use std::path::Path;
use filmcraft_media::{FrameRequest, MediaKind};
use filmcraft_project::{ClipId, ItemId};
use filmcraft_time::FrameRate;
use serde_json::{Value, json};
use crate::Session;
use crate::media_test_util::tmp_dir;
#[test]
fn import_accepts_uppercase_extensions() {
let dir = tmp_dir("import-uppercase");
let wav = dir.join("TONE.WAV");
std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap();
let mut s = Session::default();
let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 1);
assert!(s.project.items.values().any(|it| it.name == "TONE.WAV"));
}
#[test]
fn import_replace_starts_from_an_empty_project() {
let dir = tmp_dir("import-replace");
let wav = dir.join("tone.wav");
std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap();
let mut s = demo();
let demo_items = s.project.items.len();
assert!(demo_items > 1);
assert!(!s.state.open_sequences.is_empty());
s.execute("file.import", json!({"paths": [wav.to_string_lossy()], "replace": true})).unwrap();
assert!(s.state.open_sequences.is_empty());
assert_eq!(s.project.items.len(), 1);
assert!(s.project.items.values().any(|it| it.name == "tone.wav"));
let mut merge = demo();
merge.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap();
assert!(merge.project.items.len() > demo_items);
}
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
/// Frame `i` is a flat colour whose red channel is `10 * i`.
fn write_frames(dir: &Path, prefix: &str, numbers: &[u32], ext: &str) -> Vec<String> {
numbers
.iter()
.map(|&n| {
let p = dir.join(format!("{prefix}{n:04}.{ext}"));
image::RgbaImage::from_pixel(32, 18, image::Rgba([(10 * n) as u8, 99, 7, 255])).save(&p).unwrap();
p.to_string_lossy().to_string()
})
.collect()
}
fn red_at(s: &Session, item: ItemId, frame: i64) -> u8 {
let src = s.media.source_for(&s.project, item, &*s.services).unwrap();
let rate = src.info().frame_rate();
let f = src.video_frame(FrameRequest::full(rate.tick_of(frame))).unwrap();
match &f.data {
filmcraft_frame::PixelData::Rgba8(p) => p[0],
_ => panic!("rgba frames"),
}
}
fn item_of(r: &Value) -> ItemId {
ItemId(r["items"][0].as_u64().unwrap_or_else(|| panic!("{r}")))
}
#[test]
fn import_as_image_sequence_with_missing_frames() {
check_image_sequence_with_missing_frames("shot_");
}
#[test]
fn import_unicode_image_sequence_with_missing_frames() {
check_image_sequence_with_missing_frames("é");
}
fn check_image_sequence_with_missing_frames(prefix: &str) {
let dir = tmp_dir(&format!("imgseq-import-{prefix}"));
let mut s = demo();
s.execute("prefs.set", json!({"key": "media.indeterminateTimebase", "value": "25"})).unwrap();
// frames 1..=6 with 4 missing, plus an unrelated file
let paths = write_frames(&dir, prefix, &[1, 2, 3, 5, 6], "png");
write_frames(&dir, "other_", &[1], "png");
let r = s.execute("file.import", json!({"paths": [paths[0]], "imageSequence": true})).unwrap();
let id = item_of(&r);
assert_eq!(r["imageSequences"][0]["frames"], 6);
assert_eq!(r["imageSequences"][0]["missing"], json!([3]));
let m = s.project.item(id).unwrap().as_media().unwrap().clone();
assert_eq!(m.info.kind, MediaKind::ImageSequence);
assert_eq!(m.info.frame_rate(), FrameRate::FPS_25, "Indeterminate Media Timebase");
assert_eq!(m.info.duration, FrameRate::FPS_25.tick_of(6));
assert_eq!((m.info.video.as_ref().unwrap().width, m.info.video.as_ref().unwrap().height), (32, 18));
assert_eq!(red_at(&s, id, 0), 10);
assert_eq!(red_at(&s, id, 2), 30);
assert_eq!(red_at(&s, id, 3), 30, "missing frame 4 holds frame 3");
assert_eq!(red_at(&s, id, 5), 60);
// starting mid-sequence: from the chosen number on
let r = s.execute("file.importImageSequence", json!({"path": paths[2]})).unwrap();
let mid = item_of(&r);
assert_eq!(r["imageSequences"][0]["frames"], 4);
assert_eq!(red_at(&s, mid, 0), 30);
// onto the timeline: a clip of the sequence's length
let end = s.active_sequence().unwrap().duration();
let r = s.execute("timeline.place", json!({"item": id.0, "track": "V1", "time": end.0})).unwrap();
let clip = ClipId(r["clips"][0].as_u64().unwrap());
let rate = s.sequence_rate();
let dur = s.active_sequence().unwrap().find_item(clip).unwrap().1.duration;
assert_eq!(dur, rate.snap_nearest(FrameRate::FPS_25.tick_of(6)), "{dur:?}");
// a non-numbered file cannot start a sequence
let plain = dir.join("plain.png");
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).save(&plain).unwrap();
let e = s.execute("file.import", json!({"paths": [plain.to_string_lossy()], "imageSequence": true})).unwrap_err().to_string();
assert!(e.contains("numbered"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_image_sequences_setting_detects_a_numbered_still() {
let dir = tmp_dir("imgseq-auto");
let mut s = demo();
let paths = write_frames(&dir, "render.", &[10, 11, 12], "png");
// off (default): a still
let r = s.execute("file.import", json!({"paths": [paths[0]]})).unwrap();
assert_eq!(s.project.item(item_of(&r)).unwrap().as_media().unwrap().info.kind, MediaKind::Still);
// on: the sequence
s.execute("prefs.set", json!({"key": "media.importImageSequences", "value": true})).unwrap();
let r = s.execute("file.import", json!({"paths": [paths[0]]})).unwrap();
let id = item_of(&r);
let m = s.project.item(id).unwrap().as_media().unwrap();
assert_eq!(m.info.kind, MediaKind::ImageSequence);
assert_eq!(m.info.duration, m.info.frame_rate().tick_of(3));
// several files at once stay stills
let r = s.execute("file.import", json!({"paths": [paths[0], paths[1]]})).unwrap();
assert!(r.get("imageSequences").is_none());
assert_eq!(s.project.item(item_of(&r)).unwrap().as_media().unwrap().info.kind, MediaKind::Still);
// the last frame alone is no sequence
let r = s.execute("file.import", json!({"paths": [paths[2]]})).unwrap();
assert_eq!(s.project.item(item_of(&r)).unwrap().as_media().unwrap().info.kind, MediaKind::Still);
let _ = std::fs::remove_dir_all(&dir);
}
/// A CinemaDNG frame: 24 × 16 RGGB, 12-bit lossless JPEG behind a preview, a flat grey at `level`
/// (white balanced by the as-shot neutral), 23.976 fps, time code 01:00:00:00 + `frame`.
fn cinemadng_frame(level: f64, frame: u8) -> Vec<u8> {
use filmcraft_dng::tiff::tags;
use filmcraft_dng::writer::{Compression, Options, Value, write};
let (w, h) = (24, 16);
let neutral = [0.6, 1.0, 0.7];
let px: Vec<u16> =
(0..w * h).map(|i| (level * [neutral[0], neutral[1], neutral[1], neutral[2]][(i / w % 2) * 2 + i % 2] * 4000.0 + 95.0).round() as u16).collect();
let o = Options {
width: w,
height: h,
bits: 12,
preview: true,
compression: Compression::LosslessJpeg { predictor: 1, components: 2, restart_lines: 0, pt: 0 },
raw_tags: vec![(tags::BLACK_LEVEL, Value::Short(vec![95])), (tags::WHITE_LEVEL, Value::Short(vec![4095]))],
main_tags: vec![
(tags::AS_SHOT_NEUTRAL, Value::rationals(&neutral)),
(tags::FRAME_RATE, Value::SRational(vec![(24_000, 1001)])),
(tags::TIME_CODES, Value::Byte(vec![frame, 0, 0, 0x01, 0, 0, 0, 0])),
],
..Default::default()
};
write(&px, &o)
}
#[test]
fn import_cinemadng_folder_as_one_clip() {
let dir = tmp_dir("cinemadng-import");
let mut s = demo();
s.execute("prefs.set", json!({"key": "media.indeterminateTimebase", "value": "25"})).unwrap();
let paths: Vec<String> = (0..4u8)
.map(|i| {
let p = dir.join(format!("A001_C002_0101AB_{:06}.dng", i));
std::fs::write(&p, cinemadng_frame(0.1 + 0.2 * f64::from(i), i)).unwrap();
p.to_string_lossy().to_string()
})
.collect();
let r = s.execute("file.import", json!({"paths": [paths[0]], "imageSequence": true})).unwrap();
let id = item_of(&r);
assert_eq!(r["imageSequences"][0]["frames"], 4);
let m = s.project.item(id).unwrap().as_media().unwrap().clone();
assert_eq!(m.info.kind, MediaKind::ImageSequence);
assert_eq!(m.info.frame_rate(), FrameRate::FPS_23_976, "the CinemaDNG FrameRate tag, not the timebase setting");
assert_eq!(m.info.duration, FrameRate::FPS_23_976.tick_of(4));
assert_eq!(m.info.start_timecode, Some(3600 * 24), "01:00:00:00 at 24 nominal");
let v = m.info.video.as_ref().unwrap();
assert_eq!((v.width, v.height), (24, 16));
let src = s.media.source_for(&s.project, id, &*s.services).unwrap();
for i in 0..4 {
let f = src.video_frame(FrameRequest::full(FrameRate::FPS_23_976.tick_of(i))).unwrap();
let filmcraft_frame::PixelData::RgbaF32(px) = &f.data else { panic!("linear float frames") };
let want = 0.1 + 0.2 * i as f32;
for k in 0..3 {
assert!((px[(8 * 24 + 8) * 4 + k] - want).abs() < 2e-3, "frame {i} channel {k}: {}", px[(8 * 24 + 8) * 4 + k]);
}
}
// one frame on its own is a still
let r = s.execute("file.import", json!({"paths": [paths[3]]})).unwrap();
let still = s.project.item(item_of(&r)).unwrap().as_media().unwrap();
assert_eq!(still.info.kind, MediaKind::Still);
assert_eq!(still.info.video.as_ref().unwrap().codec, "DNG");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn image_sequence_survives_save_and_reopen() {
let dir = tmp_dir("imgseq-reopen");
let mut s = demo();
let paths = write_frames(&dir, "f", &[0, 1, 2, 3], "png");
let id = item_of(&s.execute("file.importFromMediaBrowser", json!({"paths": [paths[0]], "imageSequence": true})).unwrap());
let proj = dir.join("seq.fcproj");
s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap();
let mut t = Session::default();
t.execute("file.open", json!({"path": proj.to_string_lossy()})).unwrap();
let m = t.project.item(id).unwrap().as_media().unwrap();
assert_eq!(m.info.kind, MediaKind::ImageSequence);
assert_eq!(red_at(&t, id, 3), 30, "frames reopen through the media pool");
assert!(t.media.offline_status(id).is_none());
// a deleted frame is held over; deleting the first frame makes the item offline
std::fs::remove_file(&paths[2]).unwrap();
let mut u = Session::default();
u.execute("file.open", json!({"path": proj.to_string_lossy()})).unwrap();
assert_eq!(red_at(&u, id, 2), 10);
std::fs::remove_file(&paths[0]).unwrap();
let mut v = Session::default();
v.execute("file.open", json!({"path": proj.to_string_lossy()})).unwrap();
let _ = v.media.source_for(&v.project, id, &*v.services);
assert!(v.media.offline_status(id).is_some(), "missing first frame: offline");
let _ = std::fs::remove_dir_all(&dir);
}