mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
LINUX.VALIDATION: honor preset shortcuts and retain MPEG oracle frames
Resolve inherited physical key collisions when loading built-in presets, while preserving established explicit winners and deliberate custom conflicts. Read every ffprobe display-frame timestamp and register the requested credit. Verify exact frame-plane reuse without depending on concurrent global metrics. Co-authored-by: Codex <[email protected]>
This commit is contained in:
1 parent
5231852443
commit
0e0f0485da
7 files changed
+234
-21
No files matched your search
Generated
+1
@@ -1816,6 +1816,7 @@ dependencies = [
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
|
||||
@@ -21,3 +21,7 @@
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
sync_github_name = false
|
||||
@@ -41,3 +41,4 @@ symphonia = { version = "0.5.5", default-features = false, features = ["aac", "m
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -177,15 +177,64 @@ fn open(p: &Path) -> MpegSource {
|
||||
MpegSource::open(p.file_name().unwrap().to_str().unwrap(), bytes(p)).unwrap()
|
||||
}
|
||||
|
||||
/// ffprobe's video frames in display order: pts (90 kHz).
|
||||
/// ffprobe's video frames in display order: PTS (90 kHz). A program-stream picture
|
||||
/// need not carry its own PTS; use the decoder's presentation timestamp for that picture.
|
||||
fn ffprobe_frame_pts(file: &Path) -> Option<Vec<i64>> {
|
||||
let fp = filmcraft_testkit::ffprobe()?;
|
||||
let fp = filmcraft_testkit::ffprobe_or_skip("MPEG video frame presentation timestamps")?;
|
||||
let o = std::process::Command::new(fp)
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts", "-of", "csv=p=0"])
|
||||
.args(["-v", "error", "-select_streams", "v:0", "-show_entries", "frame=pts,best_effort_timestamp", "-of", "json"])
|
||||
.arg(file)
|
||||
.output()
|
||||
.ok()?;
|
||||
Some(String::from_utf8_lossy(&o.stdout).lines().filter_map(|l| l.trim().trim_end_matches(',').parse().ok()).collect())
|
||||
.unwrap_or_else(|error| panic!("{}: cannot run ffprobe: {error}", file.display()));
|
||||
assert!(o.status.success(), "{}: ffprobe failed: {}", file.display(), String::from_utf8_lossy(&o.stderr));
|
||||
Some(parse_ffprobe_frame_pts(&o.stdout).unwrap_or_else(|error| panic!("{}: {error}", file.display())))
|
||||
}
|
||||
|
||||
fn parse_ffprobe_frame_pts(bytes: &[u8]) -> Result<Vec<i64>, String> {
|
||||
let data: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| format!("invalid ffprobe JSON: {error}"))?;
|
||||
let frames = data.get("frames").and_then(serde_json::Value::as_array).ok_or("ffprobe output is missing its frames array")?;
|
||||
if frames.is_empty() {
|
||||
return Err("ffprobe returned no video frames".into());
|
||||
}
|
||||
frames
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, frame)| {
|
||||
let field = if frame.get("pts").is_some() { "pts" } else { "best_effort_timestamp" };
|
||||
frame.get(field).and_then(serde_json::Value::as_i64).ok_or_else(|| format!("ffprobe video frame {index} is missing an integer {field}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffprobe_pts_preserve_missing_raw_timestamps_and_side_data() {
|
||||
let output = br#"{"frames":[
|
||||
{"pts":131400,"best_effort_timestamp":131401,"side_data_list":[{},{}]},
|
||||
{"best_effort_timestamp":135000,"side_data_list":[{}]},
|
||||
{"pts":138600,"best_effort_timestamp":138600}
|
||||
]}"#;
|
||||
assert_eq!(parse_ffprobe_frame_pts(output).unwrap(), [131400, 135000, 138600], "keep every display frame, with raw PTS preferred when present");
|
||||
assert_eq!(parse_ffprobe_frame_pts(br#"{"frames":[{"pts":-3600},{"pts":0},{"pts":9223372036854775807}]}"#).unwrap(), [-3600, 0, i64::MAX]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ffprobe_pts_fail_instead_of_dropping_frames() {
|
||||
for output in [
|
||||
"not JSON",
|
||||
"{}",
|
||||
r#"{"frames":{}}"#,
|
||||
r#"{"frames":[]}"#,
|
||||
r#"{"frames":[{}]}"#,
|
||||
r#"{"frames":[null]}"#,
|
||||
r#"{"frames":[{"pts":"N/A","best_effort_timestamp":135000}]}"#,
|
||||
r#"{"frames":[{"pts":null,"best_effort_timestamp":135000}]}"#,
|
||||
r#"{"frames":[{"pts":1.5}]}"#,
|
||||
r#"{"frames":[{"pts":9223372036854775808}]}"#,
|
||||
r#"{"frames":[{"best_effort_timestamp":"135000"}]}"#,
|
||||
r#"{"frames":[{"pts":131400},{"best_effort_timestamp":null},{"pts":138600}]}"#,
|
||||
] {
|
||||
assert!(parse_ffprobe_frame_pts(output.as_bytes()).is_err(), "malformed oracle output must fail: {output}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode every frame in order and `seeks` random frames; compare with ffmpeg within `tol`.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
//! Commands the frontend owns (tools, transport, panels, workspaces) are registered with
|
||||
//! [`Shortcuts::register_external`] so they can be listed, bound and resolved here too.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -593,22 +593,34 @@ impl Shortcuts {
|
||||
_ => return None,
|
||||
};
|
||||
self.apply_table(&mut b, &table);
|
||||
// Canonical chords can become the same physical key on this platform. Keep the first
|
||||
// binding, matching resolve(), when generating a built-in preset.
|
||||
let platform = Platform::current();
|
||||
let mut seen = BTreeSet::new();
|
||||
b.retain(|binding| binding.chord().is_none_or(|chord| seen.insert((binding.context().to_string(), chord.effective(platform)))));
|
||||
Some(b)
|
||||
}
|
||||
|
||||
/// Apply a preset table: listed commands get exactly the listed keys, and a listed key is taken
|
||||
/// away from whatever else had it in that context.
|
||||
/// Apply a preset table: listed commands get the listed keys, and explicit physical keys take
|
||||
/// precedence over inherited bindings in the same context. Within the table, keep canonical
|
||||
/// replacement order so folded aliases retain their established first-match winner.
|
||||
fn apply_table(&self, b: &mut Vec<Binding>, table: &[Entry]) {
|
||||
let listed: Vec<&str> = table.iter().map(|e| e.0).filter(|c| self.known(c)).collect();
|
||||
b.retain(|x| !listed.contains(&x.command.as_str()));
|
||||
let platform = Platform::current();
|
||||
let mut explicit: Vec<Binding> = Vec::new();
|
||||
for (c, k, p) in table {
|
||||
if !self.known(c) {
|
||||
continue;
|
||||
}
|
||||
let nb = Binding::new(c, k, panel_opt(p));
|
||||
b.retain(|o| !(o.context() == nb.context() && o.chord() == nb.chord()));
|
||||
b.push(nb);
|
||||
let chord = nb.chord();
|
||||
let physical = chord.as_ref().map(|chord| chord.effective(platform));
|
||||
b.retain(|o| !(o.context() == nb.context() && o.chord().map(|chord| chord.effective(platform)) == physical));
|
||||
explicit.retain(|o| !(o.context() == nb.context() && o.chord() == chord));
|
||||
explicit.push(nb);
|
||||
}
|
||||
b.extend(explicit);
|
||||
}
|
||||
|
||||
fn presets_dir(&self) -> Option<PathBuf> {
|
||||
|
||||
@@ -218,3 +218,133 @@ 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}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn engine_defaults_keep_the_existing_linux_winners_without_folded_conflicts() {
|
||||
let s = Session::default();
|
||||
assert!(s.shortcuts.conflicts(Platform::Linux).is_empty());
|
||||
for (keys, command) in [("Cmd+9", "multicam.cutToCamera9"), ("Cmd+Shift+M", "markers.addRange"), ("Cmd+T", "trim.toggleType")] {
|
||||
let chord = Chord::parse(keys).unwrap();
|
||||
assert_eq!(s.shortcuts.resolve(&chord, None, Platform::Linux).unwrap().command, command, "{keys} keeps its existing first-match action");
|
||||
let ctrl = keys.replacen("Cmd", "Ctrl", 1);
|
||||
assert_eq!(s.shortcuts.resolve(&Chord::parse(&ctrl).unwrap(), None, Platform::Linux).unwrap().command, command);
|
||||
}
|
||||
assert!(!s.shortcuts.modified);
|
||||
assert!(!s.shortcuts.can_undo());
|
||||
assert!(!s.shortcuts.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_presets_are_unambiguous_with_engine_and_frontend_commands() {
|
||||
let platform = Platform::current();
|
||||
for frontend in [false, true] {
|
||||
for name in crate::shortcuts::BUILTIN_PRESETS {
|
||||
let mut s = Session::default();
|
||||
if frontend {
|
||||
with_ui_commands(&mut s);
|
||||
}
|
||||
s.execute("shortcuts.loadPreset", json!({"name": name})).unwrap();
|
||||
assert!(s.shortcuts.conflicts(platform).is_empty(), "{name}, frontend={frontend}: {:?}", s.shortcuts.conflicts(platform));
|
||||
let backspace = Chord::parse("Backspace").unwrap();
|
||||
assert_eq!(s.shortcuts.resolve(&backspace, Some("Project"), platform).unwrap().command, "project.delete", "panel overrides remain independent");
|
||||
s.execute("shortcuts.undo", json!({})).unwrap();
|
||||
assert_eq!(s.shortcuts.preset, crate::shortcuts::DEFAULT_PRESET);
|
||||
assert!(s.shortcuts.conflicts(platform).is_empty());
|
||||
s.execute("shortcuts.redo", json!({})).unwrap();
|
||||
assert_eq!(s.shortcuts.preset, name);
|
||||
assert!(s.shortcuts.conflicts(platform).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intentional_conflicts_survive_registration_persistence_and_shortcut_undo() {
|
||||
let dir = temp_dir("intentional-conflicts");
|
||||
let platform = Platform::current();
|
||||
let mut s = Session::default();
|
||||
s.shortcuts.set_dir(&dir);
|
||||
let defaults = s.shortcuts.bindings.clone();
|
||||
let result = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap();
|
||||
assert_eq!(result["conflicts"].as_array().unwrap().len(), 1);
|
||||
let edited = s.shortcuts.bindings.clone();
|
||||
with_ui_commands(&mut s);
|
||||
assert_eq!(s.shortcuts.bindings, edited, "registering the UI preserves an intentionally modified set");
|
||||
let mut restored = Session::default();
|
||||
restored.shortcuts.set_dir(&dir);
|
||||
with_ui_commands(&mut restored);
|
||||
assert_eq!(restored.shortcuts.bindings, edited, "a modified built-in set survives restart");
|
||||
assert!(restored.shortcuts.modified);
|
||||
assert_eq!(restored.shortcuts.conflicts(platform).len(), 1);
|
||||
s.execute("shortcuts.undo", json!({})).unwrap();
|
||||
assert_eq!(s.shortcuts.bindings, defaults);
|
||||
assert!(!s.shortcuts.modified);
|
||||
assert!(s.shortcuts.conflicts(platform).is_empty());
|
||||
s.execute("shortcuts.redo", json!({})).unwrap();
|
||||
assert_eq!(s.shortcuts.bindings, edited);
|
||||
assert_eq!(s.shortcuts.conflicts(platform).len(), 1);
|
||||
s.execute("shortcuts.savePreset", json!({"name": "Kept conflicts"})).unwrap();
|
||||
assert!(!s.shortcuts.modified);
|
||||
with_ui_commands(&mut s);
|
||||
assert_eq!(s.shortcuts.bindings, edited, "an unmodified custom preset keeps its deliberate conflicts");
|
||||
let mut custom = Session::default();
|
||||
custom.shortcuts.set_dir(&dir);
|
||||
with_ui_commands(&mut custom);
|
||||
assert_eq!(custom.shortcuts.preset, "Kept conflicts");
|
||||
assert_eq!(custom.shortcuts.bindings, edited);
|
||||
assert_eq!(custom.shortcuts.conflicts(platform).len(), 1);
|
||||
custom.execute("shortcuts.loadPreset", json!({"name": crate::shortcuts::DEFAULT_PRESET})).unwrap();
|
||||
assert!(custom.shortcuts.conflicts(platform).is_empty());
|
||||
custom.execute("shortcuts.loadPreset", json!({"name": "Kept conflicts"})).unwrap();
|
||||
assert_eq!(custom.shortcuts.bindings, edited);
|
||||
assert_eq!(custom.shortcuts.conflicts(platform).len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_explicit_final_cut_preset_binding_overrides_inherited_linux_physical_keys() {
|
||||
for frontend in [false, true] {
|
||||
let mut session = Session::default();
|
||||
if frontend {
|
||||
with_ui_commands(&mut session);
|
||||
}
|
||||
session.execute("shortcuts.loadPreset", json!({"name": crate::shortcuts::FCP_PRESET})).unwrap();
|
||||
for keys in ["Cmd+T", "Ctrl+T"] {
|
||||
let resolved = session.execute("shortcuts.resolve", json!({"keys": keys})).unwrap();
|
||||
assert_eq!(
|
||||
resolved["command"], "sequence.applyVideoTransition",
|
||||
"the explicit Final Cut Pro table replaces inherited Trim Toggle Type: {resolved}"
|
||||
);
|
||||
}
|
||||
assert!(session.shortcuts.conflicts(Platform::Linux).is_empty());
|
||||
session.execute("shortcuts.undo", json!({})).unwrap();
|
||||
let resolved = session.execute("shortcuts.resolve", json!({"keys": "Cmd+T"})).unwrap();
|
||||
assert_eq!(resolved["command"], "trim.toggleType", "the FilmCraft default keeps its established Linux winner");
|
||||
session.execute("shortcuts.redo", json!({})).unwrap();
|
||||
let resolved = session.execute("shortcuts.resolve", json!({"keys": "Ctrl+T"})).unwrap();
|
||||
assert_eq!(resolved["command"], "sequence.applyVideoTransition");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn compatible_presets_keep_the_existing_first_explicit_linux_winners() {
|
||||
let mut session = Session::default();
|
||||
// Export is frontend-owned; use the same definition registered by the desktop menu.
|
||||
session.shortcuts.register_external(vec![CommandInfo::new("mode.export", "Export", &["File", "Export"], Some("Cmd+M"))]);
|
||||
for (preset, keys, command) in [
|
||||
(crate::shortcuts::FCP_PRESET, "Cmd+R", "clip.speedDuration"),
|
||||
(crate::shortcuts::PREMIERE_PRESET, "Cmd+M", "mode.export"),
|
||||
(crate::shortcuts::PREMIERE_PRESET, "Cmd+Shift+M", "markers.goPrev"),
|
||||
(crate::shortcuts::PREMIERE_PRESET, "Cmd+T", "trim.toggleType"),
|
||||
(crate::shortcuts::PREMIERE_PRESET, "Cmd+9", "multicam.cutToCamera9"),
|
||||
] {
|
||||
session.execute("shortcuts.loadPreset", json!({"name": preset})).unwrap();
|
||||
for spelling in [keys.to_string(), keys.replacen("Cmd", "Ctrl", 1)] {
|
||||
let resolved = session.execute("shortcuts.resolve", json!({"keys": spelling})).unwrap();
|
||||
assert_eq!(resolved["command"], command, "{preset}: {spelling} keeps its existing first explicit action: {resolved}");
|
||||
}
|
||||
assert!(session.shortcuts.conflicts(Platform::Linux).is_empty());
|
||||
}
|
||||
}
|
||||
+27
-11
@@ -226,21 +226,37 @@ mod tests {
|
||||
pts: filmcraft_time::Tick::ZERO,
|
||||
})
|
||||
};
|
||||
let before = stats().reused;
|
||||
// a frame someone else still holds is left alone
|
||||
let plane_ptrs = |frame: &VideoFrame| match &frame.data {
|
||||
PixelData::Yuv8 { planes, .. } => planes.each_ref().map(|plane| plane.as_ptr() as usize),
|
||||
_ => panic!("fixture has three byte planes"),
|
||||
};
|
||||
// A frame someone else still holds is left alone. The reuse statistic is global:
|
||||
// concurrent float-buffer tests can change it without touching these planes.
|
||||
let shared = yuv(1);
|
||||
let held = shared.clone();
|
||||
let held_ptrs = plane_ptrs(&held);
|
||||
recycle(shared);
|
||||
assert!(take_u8(n).capacity() >= n);
|
||||
assert_eq!(stats().reused, before);
|
||||
let fresh = take_u8(n);
|
||||
assert!(fresh.capacity() >= n);
|
||||
assert!(!held_ptrs.contains(&(fresh.as_ptr() as usize)), "a shared plane cannot be handed out");
|
||||
assert_eq!(held.byte_size(), n + 2 * c);
|
||||
// an unshared frame's planes come back, empty
|
||||
recycle(yuv(2));
|
||||
// An unshared frame's exact allocations come back, empty. Equal-size chroma
|
||||
// planes may be taken in either order.
|
||||
let unshared = yuv(2);
|
||||
let returned_ptrs = plane_ptrs(&unshared);
|
||||
recycle(unshared);
|
||||
let (y, u, v) = (take_u8(n), take_u8(c), take_u8(c));
|
||||
assert!(y.is_empty() && y.capacity() >= n && u.capacity() >= c && v.capacity() >= c);
|
||||
assert_eq!(stats().reused, before + 3);
|
||||
// and are not handed out twice
|
||||
take_u8(n);
|
||||
assert_eq!(stats().reused, before + 3);
|
||||
assert!(y.is_empty() && u.is_empty() && v.is_empty());
|
||||
assert!(y.capacity() >= n && u.capacity() >= c && v.capacity() >= c);
|
||||
assert_eq!(y.as_ptr() as usize, returned_ptrs[0]);
|
||||
let mut expected_chroma = [returned_ptrs[1], returned_ptrs[2]];
|
||||
let mut actual_chroma = [u.as_ptr() as usize, v.as_ptr() as usize];
|
||||
expected_chroma.sort_unstable();
|
||||
actual_chroma.sort_unstable();
|
||||
assert_eq!(actual_chroma, expected_chroma);
|
||||
// The returned allocations remain owned by y/u/v and cannot be handed out twice.
|
||||
let another = take_u8(n);
|
||||
assert!(another.capacity() >= n);
|
||||
assert!(!returned_ptrs.contains(&(another.as_ptr() as usize)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user