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 (bd944ee and f2b36fe). The existing export fix from #282 remains in its original parent commit.

Assisted-by: Codex
This commit is contained in:
Cathy Shapiro 2026-10-08 18:28:32 -05:00
parent 2b843d3d81
commit 0b34c076ce
16 changed files with 113 additions and 17 deletions

View File

@ -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}");

View File

@ -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.

View File

@ -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();

View File

@ -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();

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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)
}

View File

@ -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");
}
}

View File

@ -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);

View File

@ -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");
}

View File

@ -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));
}

View File

@ -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 })],
}),

View File

@ -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");
}

View File

@ -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 };

View File

@ -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

View File

@ -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.