WINCI: Windows path handling and portable validation
Normalize explicit relink prefixes, compare native paths in portable tests, close fixture sessions before directory moves, and preserve exact GPU effect-source pixel centres. Keep the hardware-dependent mixer benchmark opt-in with its 4x target unchanged. Shortcut portability changes reused from storytold/filmcraft#37 (bd944eeandf2b36fe). The existing export fix from #282 remains in its original parent commit. Assisted-by: Codex
This commit is contained in:
parent
2b843d3d81
commit
0b34c076ce
|
|
@ -69,7 +69,7 @@ fn aaf_embedded_trimmed_audio_round_trips_sample_exact() {
|
|||
assert_eq!(a, b, "{kind:?}");
|
||||
}
|
||||
// video links to the original movies, audio to WAV files extracted next to the AAF
|
||||
assert_eq!(path_of(&s, q.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, q.video_tracks[0].items[0].item)), std::path::Path::new(&path_of(&s, items[0])));
|
||||
for (c, oc) in q.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
|
||||
let p = path_of(&s, c.item);
|
||||
assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
|
||||
|
|
@ -138,7 +138,7 @@ fn aaf_separate_aiff_breakout_and_linked() {
|
|||
let n = imported_seq(&r);
|
||||
let q = s.project.sequence(n).unwrap();
|
||||
let a = &q.audio_tracks[0].items[0];
|
||||
assert_eq!(path_of(&s, a.item), path_of(&s, items[0]));
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, a.item)), std::path::Path::new(&path_of(&s, items[0])));
|
||||
assert_eq!(a.source_in, FrameRate::FPS_24.tick_of(12));
|
||||
assert!(!s.project.item(a.item).unwrap().as_media().unwrap().offline);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
|
@ -159,7 +159,7 @@ fn aaf_video_mixdown() {
|
|||
assert_eq!(q.video_tracks.iter().map(|t| t.items.len()).sum::<usize>(), 1);
|
||||
let v = &q.video_tracks[0].items[0];
|
||||
assert_eq!(v.duration, FrameRate::FPS_24.tick_of(48));
|
||||
assert_eq!(path_of(&s, v.item), mov);
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, v.item)), std::path::Path::new(mov));
|
||||
let m = s.project.item(v.item).unwrap().as_media().unwrap();
|
||||
assert!(!m.offline);
|
||||
assert_eq!(m.info.video.as_ref().map(|v| (v.width, v.height)), Some((64, 36)));
|
||||
|
|
@ -284,7 +284,7 @@ fn aaf_keeps_a_nested_sequence_as_a_sequence() {
|
|||
let want: Vec<_> = orig.tracks(kind)[0].items.iter().map(|c| (c.start, c.duration)).collect();
|
||||
assert_eq!(got, want, "{kind:?}");
|
||||
}
|
||||
assert_eq!(path_of(&s, n.video_tracks[0].items[0].item), path_of(&s, items[0]));
|
||||
assert_eq!(std::path::Path::new(&path_of(&s, n.video_tracks[0].items[0].item)), std::path::Path::new(&path_of(&s, items[0])));
|
||||
for (c, oc) in n.audio_tracks[0].items.iter().zip(&orig.audio_tracks[0].items) {
|
||||
let p = path_of(&s, c.item);
|
||||
assert!(p.ends_with(".wav") && std::path::Path::new(&p).exists(), "{p}");
|
||||
|
|
|
|||
|
|
@ -609,7 +609,11 @@ fn camera_keys_cut_and_select() {
|
|||
let keys: Vec<(String, String)> =
|
||||
s.shortcuts.bindings.iter().filter(|b| b.command.starts_with("multicam.")).map(|b| (b.command.clone(), b.keys.clone())).collect();
|
||||
assert!(keys.contains(&("multicam.selectCamera1".into(), "1".into())), "{keys:?}");
|
||||
assert!(keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into())), "{keys:?}");
|
||||
assert!(keys.contains(&("multicam.cutToCamera2".into(), "Ctrl+2".into())), "{keys:?}");
|
||||
// ⌃9 is Premiere's macOS key; off macOS Ctrl+9 is Toggle All Audio Targets (⌘9 there), which
|
||||
// wins, so Cut to Camera 9 has no default key on Windows, Linux and FreeBSD
|
||||
let nine = keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into()));
|
||||
assert_eq!(nine, cfg!(target_os = "macos"), "{keys:?}");
|
||||
}
|
||||
|
||||
/// Colour of camera `k` (0-based) in the many-angle tests: distinct reds and greens.
|
||||
|
|
|
|||
|
|
@ -59,11 +59,12 @@ fn collect_files_copies_used_media_and_opens() {
|
|||
let jobs = s.execute("jobs.list", json!({})).unwrap();
|
||||
assert!(jobs[0]["result"].get("error").is_none(), "{jobs}");
|
||||
// the copy opens with its media next to it, and plays the same frames
|
||||
drop(s); // Windows does not allow renaming a directory while decoder files are open.
|
||||
std::fs::rename(root.join("Media"), root.join("Media-gone")).unwrap();
|
||||
let mut t = opened(r["project"].as_str().unwrap());
|
||||
for it in t.project.items.values().filter_map(|i| i.as_media()) {
|
||||
let MediaRef::File { path } = &it.media else { panic!() };
|
||||
assert!(path.starts_with(&*dest.to_string_lossy()), "{path}");
|
||||
assert!(std::path::Path::new(path).starts_with(&dest), "{path}");
|
||||
assert!(it.identity.is_some());
|
||||
}
|
||||
let after: Vec<Vec<u8>> = (0..36).step_by(5).map(|f| frame_rgba(&mut t, f, 1.0).2).collect();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ fn create_attach_toggle_renders_the_same_picture() {
|
|||
let full_half = frame_rgba(&mut s, 5, 0.5);
|
||||
let r = s.execute("media.createProxies", json!({"items": [items[0].0], "preset": "prores_proxy_half", "wait": true})).unwrap();
|
||||
let out = r["outputs"][0]["path"].as_str().unwrap().to_string();
|
||||
assert!(out.ends_with("Proxies/a_Proxy.mov"), "{out}");
|
||||
assert!(std::path::Path::new(&out).ends_with(std::path::Path::new("Proxies").join("a_Proxy.mov")), "{out}");
|
||||
let job = s.execute("jobs.list", json!({})).unwrap();
|
||||
assert_eq!(job[0]["finished"], json!(true), "{job}");
|
||||
let m = s.project.item(items[0]).unwrap().as_media().unwrap().clone();
|
||||
|
|
|
|||
|
|
@ -303,8 +303,9 @@ pub fn derive_remap(old: &str, new: &str) -> Option<(String, String)> {
|
|||
/// Apply a remap to a path (None when it doesn't start with the old prefix).
|
||||
pub fn apply_remap(path: &str, from: &str, to: &str) -> Option<String> {
|
||||
let p = path.replace('\\', "/");
|
||||
let from = from.trim_end_matches('/');
|
||||
let rest = p.strip_prefix(from)?;
|
||||
let from = from.replace('\\', "/");
|
||||
let to = to.replace('\\', "/");
|
||||
let rest = p.strip_prefix(from.trim_end_matches('/'))?;
|
||||
if !(rest.is_empty() || rest.starts_with('/')) {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ fn moved_media_opens_offline_and_relinks_others_by_folder() {
|
|||
assert!(!s.offline.prompt);
|
||||
assert_eq!(frame_rgba(&mut s, 3, 1.0).2, online, "relinked media renders as before the move");
|
||||
let b_path = s.project.item(items[1]).unwrap().as_media().unwrap().media.clone();
|
||||
assert_eq!(b_path, filmcraft_project::MediaRef::File { path: moved.join("b.mov").to_string_lossy().into_owned() });
|
||||
let filmcraft_project::MediaRef::File { path: b_path } = b_path else { panic!("relinked media must remain file-based") };
|
||||
assert_eq!(std::path::Path::new(&b_path), moved.join("b.mov"));
|
||||
// undo brings the old (missing) paths back, and the slate with them
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(psnr(&frame_rgba(&mut s, 3, 1.0).2, &slate) > 40.0);
|
||||
|
|
@ -177,3 +178,10 @@ fn reimport_clears_offline_list_after_path_resolves() {
|
|||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_folder_remaps_normalize_both_prefixes_and_respect_boundaries() {
|
||||
assert_eq!(apply_remap(r"C:\shoot\Media\a.mov", r"C:\shoot\Media\", r"D:\archive\Media\"), Some("D:/archive/Media/a.mov".into()));
|
||||
assert_eq!(apply_remap("C:/shoot/Media/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), Some("D:/archive/Media/a.mov".into()));
|
||||
assert_eq!(apply_remap("C:/shoot/Media2/a.mov", r"C:\shoot\Media", r"D:\archive\Media"), None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -363,6 +363,22 @@ pub struct Binding {
|
|||
pub panel: Option<String>,
|
||||
}
|
||||
|
||||
/// Off macOS the Control and Command keys are the same key. The built-in tables are written for
|
||||
/// macOS, so a binding on the Mac Control key (`⌃T`) can land on the same key as a Command binding
|
||||
/// (`⌘T`) in the same context: the Command binding wins and the Control one is left out there
|
||||
/// (that command has no default key on that platform). macOS keeps both.
|
||||
fn drop_control_collisions(b: &mut Vec<Binding>, p: Platform) {
|
||||
if p.is_mac() {
|
||||
return;
|
||||
}
|
||||
let primary: Vec<(String, Option<Chord>)> =
|
||||
b.iter().filter(|x| x.chord().is_some_and(|c| !c.mods.ctrl)).map(|x| (x.context().to_string(), x.chord().map(|c| c.effective(p)))).collect();
|
||||
b.retain(|x| {
|
||||
let Some(c) = x.chord() else { return true };
|
||||
!(c.mods.ctrl && primary.iter().any(|(ctx, k)| ctx == x.context() && *k == Some(c.effective(p))))
|
||||
});
|
||||
}
|
||||
|
||||
impl Binding {
|
||||
fn new(command: &str, keys: &str, panel: Option<&str>) -> Self {
|
||||
Binding { command: command.to_string(), keys: normalize(keys).unwrap_or_else(|_| keys.to_string()), panel: panel.map(str::to_string) }
|
||||
|
|
@ -564,8 +580,15 @@ impl Shortcuts {
|
|||
}
|
||||
|
||||
/// Premiere-default entries FilmCraft Default adopts: commands with no shortcut of their own
|
||||
/// whose Premiere key is free in that context.
|
||||
/// whose Premiere key is free in that context on this platform.
|
||||
pub fn audit(&self) -> Vec<Binding> {
|
||||
self.audit_for(Platform::current())
|
||||
}
|
||||
|
||||
/// [`Self::audit`] for platform `p`. The Premiere table is Premiere's macOS keyboard: off macOS
|
||||
/// the Control and Command keys are the same key, so an entry whose key would collide there
|
||||
/// with one already taken (`⌃T` vs `⌘T`) is left out, and the command keeps no default key.
|
||||
pub(crate) fn audit_for(&self, platform: Platform) -> Vec<Binding> {
|
||||
let base = self.base_defaults();
|
||||
let mut added: Vec<Binding> = Vec::new();
|
||||
for (c, k, p) in presets::PREMIERE {
|
||||
|
|
@ -573,7 +596,8 @@ impl Shortcuts {
|
|||
continue;
|
||||
}
|
||||
let b = Binding::new(c, k, panel_opt(p));
|
||||
let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord() == b.chord());
|
||||
let key = b.chord().map(|c| c.effective(platform));
|
||||
let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord().map(|c| c.effective(platform)) == key);
|
||||
if !taken {
|
||||
added.push(b);
|
||||
}
|
||||
|
|
@ -583,8 +607,13 @@ impl Shortcuts {
|
|||
|
||||
/// The bindings of a built-in preset.
|
||||
pub fn builtin(&self, name: &str) -> Option<Vec<Binding>> {
|
||||
self.builtin_for(name, Platform::current())
|
||||
}
|
||||
|
||||
/// [`Self::builtin`] as it is built on platform `p`.
|
||||
pub(crate) fn builtin_for(&self, name: &str, p: Platform) -> Option<Vec<Binding>> {
|
||||
let mut b = self.base_defaults();
|
||||
b.extend(self.audit());
|
||||
b.extend(self.audit_for(p));
|
||||
let table: Vec<Entry> = match name {
|
||||
DEFAULT_PRESET => Vec::new(),
|
||||
PREMIERE_PRESET => presets::premiere(),
|
||||
|
|
@ -593,6 +622,7 @@ impl Shortcuts {
|
|||
_ => return None,
|
||||
};
|
||||
self.apply_table(&mut b, &table);
|
||||
drop_control_collisions(&mut b, p);
|
||||
Some(b)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,3 +218,21 @@ fn list_searches_labels_and_keys() {
|
|||
let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap();
|
||||
assert_eq!(v.as_array().unwrap().len(), 2, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keymaps_have_no_conflicts_on_any_platform() {
|
||||
// The Premiere audit used to check keys on macOS only, so off macOS `⌃9`/`⌘9`, `⌃⇧M`/`⌘⇧M` and
|
||||
// `⌃T`/`⌘T` ended up on the same Ctrl key; CI only ran on macOS, so nobody saw it.
|
||||
use crate::shortcuts::Platform;
|
||||
let s = Session::default();
|
||||
for p in [Platform::Mac, Platform::Windows, Platform::Linux] {
|
||||
let mut sc = s.shortcuts.clone();
|
||||
sc.bindings = sc.builtin_for(crate::shortcuts::DEFAULT_PRESET, p).unwrap();
|
||||
assert!(sc.conflicts(p).is_empty(), "{p:?}: {:?}", sc.conflicts(p));
|
||||
}
|
||||
// macOS keeps every Premiere key it had
|
||||
let mac = s.shortcuts.builtin_for(crate::shortcuts::DEFAULT_PRESET, Platform::Mac).unwrap();
|
||||
for (cmd, keys) in [("multicam.cutToCamera9", "Ctrl+9"), ("markers.addRange", "Ctrl+Shift+M"), ("graphics.newText", "Cmd+T")] {
|
||||
assert!(mac.iter().any(|b| b.command == cmd && b.keys == keys), "{cmd} {keys} on macOS");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,7 +176,10 @@ fn mxf_formats_and_presets() {
|
|||
assert!(s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).format.contains("MXF OP1a (Apple ProRes)"));
|
||||
let names: Vec<String> = builtin_presets().into_iter().filter(|p| p.settings.format.is_mxf()).map(|p| p.name).collect();
|
||||
assert_eq!(names.len(), 4, "{names:?}");
|
||||
assert_eq!(opatom_audio_paths("/a/b/clip.mxf", 2), vec!["/a/b/clip_A1.mxf".to_string(), "/a/b/clip_A2.mxf".to_string()]);
|
||||
assert_eq!(
|
||||
opatom_audio_paths("/a/b/clip.mxf", 2).iter().map(std::path::PathBuf::from).collect::<Vec<_>>(),
|
||||
vec![std::path::PathBuf::from("/a/b/clip_A1.mxf"), std::path::PathBuf::from("/a/b/clip_A2.mxf")]
|
||||
);
|
||||
}
|
||||
|
||||
/// ffprobe identifies our files (operational pattern, codec, frame count, duration, timecode);
|
||||
|
|
|
|||
|
|
@ -117,8 +117,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn sequence_names() {
|
||||
assert_eq!(image_sequence_path("/a/Sequence 01.png", 0, 24), "/a/Sequence 01000.png");
|
||||
assert_eq!(image_sequence_path("/a/x.tif", 1234, 2000), "/a/x1234.tif");
|
||||
use std::path::Path;
|
||||
assert_eq!(Path::new(&image_sequence_path("/a/Sequence 01.png", 0, 24)), Path::new("/a/Sequence 01000.png"));
|
||||
assert_eq!(Path::new(&image_sequence_path("/a/x.tif", 1234, 2000)), Path::new("/a/x1234.tif"));
|
||||
assert_eq!(image_sequence_path("shot.bmp", 7, 10), "shot007.bmp");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -311,3 +311,11 @@ fn fs_full(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
|||
let hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055;
|
||||
return vec4(select(hi, lo, c <= vec3(0.0031308)), 1.0);
|
||||
}
|
||||
|
||||
// Effect inputs use only positive uniform decimation, without rotation or translation.
|
||||
// Reconstruct source coordinates from exact fragment centres: rasterizer interpolation of
|
||||
// `sp` can introduce tiny neighbours, amplified by gamma and alpha inversion at black/zero alpha.
|
||||
@fragment
|
||||
fn fs_fx_source(in: VOut) -> @location(0) vec4<f32> {
|
||||
return layer_color(in.pos.xy / vec2(u.m0.x, u.m0.w));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ impl FxStage {
|
|||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: composite,
|
||||
entry_point: Some("fs"),
|
||||
entry_point: Some("fs_fx_source"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: FX_FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -489,3 +489,15 @@ fn layers_with_effects_fall_back_to_the_cpu_without_a_stage() {
|
|||
assert!(p99 <= 6 && mean < 1.5, "stage {stage}: p99 {p99}, mean {mean}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_source_identity_preserves_black_and_transparent_pixels() {
|
||||
let Some((dev, q)) = device() else { return };
|
||||
let mut c = GpuCompositor::new(&dev, &q);
|
||||
let (w, h) = (67, 41);
|
||||
let px = picture(w, h);
|
||||
let frame = VideoFrame::rgba_f32(w, h, px.clone());
|
||||
let fx = LayerFx { size: (w, h), decimation: 1, ops: vec![] };
|
||||
let (_, _, got) = c.effect_image(&frame, &fx).expect("working image");
|
||||
assert_eq!(got, px, "identity import must not sample neighbouring transparent or black pixels");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ fn live_override_holds_and_ramps_back() {
|
|||
|
||||
/// 24 tracks × 3 inserts (EQ, Dynamics, Studio Reverb) + a compressed submix at 48 kHz, one core.
|
||||
#[test]
|
||||
#[ignore = "hardware-dependent throughput benchmark; run explicitly on the benchmark host"]
|
||||
fn perf_24_tracks_3_effects_realtime_factor() {
|
||||
let r = busy_rig(24);
|
||||
let secs = if cfg!(debug_assertions) { 2.0 } else { 10.0 };
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ fn moved_project(root: &Path) -> String {
|
|||
s.project = Arc::new(p);
|
||||
let path = root.join("p.fcproj").to_string_lossy().into_owned();
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
drop(s); // Close decoder files before moving their directory on Windows.
|
||||
std::fs::create_dir_all(root.join("Moved")).unwrap();
|
||||
std::fs::rename(&media, root.join("Moved").join("Media")).unwrap();
|
||||
path
|
||||
|
|
|
|||
|
|
@ -325,3 +325,11 @@ At load ~25–60 the same final build plays h264-1080, stack3 and h264-2160 at F
|
|||
in 3 of 3 runs (192/0) and render previews 192/0. The 4K fixture needs ~4 cores of decode per
|
||||
real-time second (≈160 ms CPU per frame at 170 Mbit/s); the demo project's procedural footage
|
||||
~190 ms per Full-resolution frame.
|
||||
|
||||
### Audio mixer throughput
|
||||
|
||||
`cargo test --release -p filmcraft-render --lib perf_24_tracks_3_effects_realtime_factor -- --ignored --nocapture`
|
||||
runs the 24-track, 72-insert throughput benchmark. It retains the 4x realtime release target, but
|
||||
is opt-in like the other hardware benchmarks: portable functional CI must not depend on a host's
|
||||
single-core throughput or scheduling. The mixer correctness tests remain enabled. On non-Unix
|
||||
hosts its existing timer falls back to wall time; record that limitation with the machine and result.
|
||||
|
|
|
|||
Loading…
Reference in New Issue