Compare commits

...
4 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c736a97432 gpu: a device that cannot render the effect stage falls back to CPU effects instead of panicking
The GPU effect stage (#35) renders into Rgba32Float, which is not renderable on every device (the FreeBSD CI's software adapter rejects it). FxStage::supported only checks compute/storage limits, so GpuCompositor::new created an invalid pipeline and wgpu's default error handler panicked. Stage creation now runs inside a validation error scope; on an error the compositor runs without it and effects render on the CPU, as on devices without compute shaders. Found by the FreeBSD CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:23:57 -04:00
Brandon ThomasandClaude Opus 5.5 f2b36fe277 engine: camera-key test follows the per-platform default keymap
Off macOS, Ctrl+9 belongs to Toggle All Audio Targets (⌘9 there), so Cut to Camera 9 has no default key on Windows, Linux and FreeBSD; cameras 1–8 keep Ctrl+1–8 everywhere. Found by the FreeBSD CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:01:36 -04:00
Brandon ThomasandClaude Opus 5.5 bd944ee5df FreeBSD: shared workflow template; default shortcuts conflict-free off macOS
- .github/workflows/freebsd.yml follows the shared template: vmactions pinned
  by commit, cpu: 4, git installed, safe.directory for the synced checkout.
- Default shortcuts: the built-in tables are Premiere's macOS keyboard. Off
  macOS, Control and Command are one key, so Ctrl+9, Ctrl+Shift+M and Ctrl+T
  each held two commands on Windows, Linux and FreeBSD (shortcuts_tests::
  assign_reassigns_conflicts_and_undoes failed there; CI only ran on macOS).
  The Command binding now wins and the Mac-Control one is left out on those
  platforms; the Premiere audit checks keys on the platform it builds for.
  macOS is unchanged. New test: default keymaps conflict-free on every
  platform, and macOS keeps its keys.
- mpeg2v synth_tests::seed_sweep hashed seeds with a plain u64 multiply,
  which overflows in the dev profile (FreeBSD CI runs `cargo test` in dev);
  now wrapping_mul.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:29:06 -04:00
Brandon ThomasandClaude Opus 5.5 005d09503b FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:29:06 -04:00
6 changed files with 132 additions and 6 deletions

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+5 -1
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.
+33 -3
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)
}
+18
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");
}
}
+27 -1
View File
@@ -317,7 +317,23 @@ impl GpuCompositor {
view_formats: &[],
});
let dummy = dummy_tex.create_view(&Default::default());
let fx = fx::FxStage::supported(device).then(|| fx::FxStage::new(device, &shader, &layer_bgl));
// The effect stage's pipelines can still be invalid on a device that passes the limits
// check (Rgba32Float is not renderable everywhere, e.g. software Vulkan/GL). Validation
// errors would panic, so they are captured: without the stage, effects render on the CPU.
let fx = fx::FxStage::supported(device)
.then(|| {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let stage = fx::FxStage::new(device, &shader, &layer_bgl);
match poll_now(scope.pop()) {
Some(None) => Some(stage),
Some(Some(e)) => {
log::warn!("GPU effects unavailable on this device, rendering them on the CPU: {e}");
None
}
None => None,
}
})
.flatten();
Self {
device: device.clone(),
queue: queue.clone(),
@@ -846,6 +862,16 @@ struct SrcInfo {
color: filmcraft_color::ColorInfo,
}
/// Poll a future once: wgpu's native error scopes resolve immediately. `None` if it is still
/// pending (then the caller assumes the worst).
fn poll_now<F: std::future::Future>(f: F) -> Option<F::Output> {
let mut f = std::pin::pin!(f);
match f.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
std::task::Poll::Ready(v) => Some(v),
std::task::Poll::Pending => None,
}
}
/// A render pass drawing into the accumulator.
fn accum_pass<'e>(enc: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, load: wgpu::LoadOp<wgpu::Color>) -> wgpu::RenderPass<'e> {
enc.begin_render_pass(&wgpu::RenderPassDescriptor {
+1 -1
View File
@@ -758,7 +758,7 @@ fn seed_sweep() {
];
for seed in 1..=8u64 {
for c in cfgs {
check(Cfg { seed: (seed * 0x9E37_79B9_7F4A_7C15) ^ c.w as u64, ..c });
check(Cfg { seed: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ c.w as u64, ..c });
}
}
}