Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))

> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (language table: German ships the menus, #630) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (file-manager drops; VA-API seeks; individual timeline selection) · **Target:** Adobe Premiere Pro 2026 (26.5.2)

Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
Expand Down Expand Up @@ -107,6 +107,9 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g

## Progress log

- **2026-10-14:** Alt box selection and boxes with Linked Selection off retain individual clips on release. `timeline.select` honors a per-call linked override; Clear/Delete preserves unselected linked partners while explicit deletion targets keep their existing link behavior. UI and engine regressions cover release, deletion, malformed overrides and undo/redo.
- **2026-10-14:** VA-API H.264 open-GOP seeks use decoded reference surfaces for concealment instead of uploaded placeholders lacking codec metadata. This fixes AMD/Mesa's rejection during the bit-exact parity test; per-seek and post-flush assertions now require hardware decoding, with a GPU-independent regression for reference metadata.
- **2026-10-14:** File-manager video drops open in Source for preview or place linked video/audio on Timeline; failed imports report errors. Synthetic MP4 tests cover preview, placement, matching sequence creation, undo/redo, stale cursor input and covered panels. Linux prefers usable X11/XWayland for file drops; native Wayland remains limited by winit 0.30.
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
Expand Down Expand Up @@ -166,6 +169,9 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g

| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | Individual Timeline box selections survive release and Clear/Delete preserves unselected linked partners; command override, UI and undo/redo regressions; readiness estimates unchanged |
| 2026-10-14 | minor | VA-API open-GOP seek reference metadata fixed for AMD/Mesa; stronger hardware parity checks and a GPU-independent regression; readiness estimates unchanged |
| 2026-10-14 | minor | File-manager drops into Source and Timeline, with import errors and a Linux X11/XWayland fallback; readiness estimates unchanged |
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
Expand Down
4 changes: 4 additions & 0 deletions apps/filmcraft/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ winit = { version = "0.30", default-features = false }
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
# portal file dialogs (rfd) and accesskit already use.
zbus = "5.19"
# winit 0.30 has file drops on X11, but not Wayland. Prefer a usable X11/XWayland server.
winit = { version = "0.30", default-features = false, features = ["x11", "wayland", "rwh_06"] }
# Safe Rust X11 connection: probe availability and query the pointer during a file drag.
x11rb = "0.13"

# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
[target.'cfg(windows)'.build-dependencies]
Expand Down
4 changes: 4 additions & 0 deletions apps/filmcraft/src/dev_icon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
out.push_str("StartupNotify=true\n");
out.push_str(&format!("StartupWMClass={app_id}\n"));
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
out.push_str(concat!(
"MimeType=application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;",
"video/webm;video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;\n"
));
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
out
}
Expand Down
111 changes: 111 additions & 0 deletions apps/filmcraft/src/linux_window.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! File-manager drops on Linux. winit 0.30's Wayland backend does not deliver file drops;
//! use X11 (including XWayland) when a connection succeeds. Native Wayland remains available
//! without X11 or explicitly via FILMCRAFT_WINDOW_BACKEND=wayland. No environment mutation.
//! X11 also omits cursor motion during a file drag, so query its position through safe x11rb.

use std::ffi::OsStr;
use std::sync::Arc;

use winit::raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::ConnectionExt;
use x11rb::rust_connection::RustConnection;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
X11,
Wayland,
}

fn requested_backend(value: Option<&OsStr>) -> Result<Option<Backend>, String> {
match value {
None => Ok(None),
Some(v) if v == "x11" => Ok(Some(Backend::X11)),
Some(v) if v == "wayland" => Ok(Some(Backend::Wayland)),
Some(v) => Err(format!("FILMCRAFT_WINDOW_BACKEND must be x11 or wayland, got {v:?}")),
}
}

pub fn backend() -> Result<Option<Backend>, String> {
let requested = std::env::var_os("FILMCRAFT_WINDOW_BACKEND");
if let Some(backend) = requested_backend(requested.as_deref())? {
return Ok(Some(backend));
}
if std::env::var_os("DISPLAY").is_some_and(|v| !v.is_empty()) {
match Cursor::connect() {
Ok(_) => {
log::info!("using X11/XWayland for file-manager drag and drop");
return Ok(Some(Backend::X11));
}
Err(e) => log::warn!("X11 unavailable; using the default window backend: {e}"),
}
}
Ok(None)
}

pub fn event_loop_hook(backend: Backend) -> eframe::EventLoopBuilderHook {
Box::new(move |builder| match backend {
Backend::X11 => {
use winit::platform::x11::EventLoopBuilderExtX11;
builder.with_x11();
}
Backend::Wayland => {
use winit::platform::wayland::EventLoopBuilderExtWayland;
builder.with_wayland();
}
})
}

struct Cursor {
connection: RustConnection,
root: u32,
}

impl Cursor {
fn connect() -> Result<Self, String> {
let (connection, screen) = x11rb::connect(None).map_err(|e| e.to_string())?;
let root = connection.setup().roots.get(screen).ok_or("X11 returned an invalid screen index")?.root;
Ok(Self { connection, root })
}

fn position(&self) -> Option<(i32, i32)> {
let reply = self.connection.query_pointer(self.root).ok()?.reply().ok()?;
reply.same_screen.then_some((i32::from(reply.root_x), i32::from(reply.root_y)))
}
}

pub fn cursor_hook(window: &winit::window::Window) -> Option<Box<dyn Fn() -> Option<(i32, i32)>>> {
// A native Wayland window must not use coordinates from another display's X11 root.
let display = window.display_handle().ok()?;
if !matches!(display.as_raw(), RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_)) {
return None;
}
match Cursor::connect() {
Ok(cursor) => {
let cursor = Arc::new(cursor);
Some(Box::new(move || cursor.position()))
}
Err(e) => {
log::warn!("file-drop cursor unavailable: {e}");
None
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn explicit_backend_is_validated_without_changing_the_environment() {
assert_eq!(requested_backend(None).unwrap(), None);
assert_eq!(requested_backend(Some(OsStr::new("x11"))).unwrap(), Some(Backend::X11));
assert_eq!(requested_backend(Some(OsStr::new("wayland"))).unwrap(), Some(Backend::Wayland));
for value in ["", "auto", "X11", "unknown", "wayland\0x11"] {
let error = requested_backend(Some(OsStr::new(value))).unwrap_err();
assert!(error.contains("FILMCRAFT_WINDOW_BACKEND"));
}
use std::os::unix::ffi::OsStrExt;
assert!(requested_backend(Some(OsStr::from_bytes(&[0xff]))).is_err());
}
}
12 changes: 12 additions & 0 deletions apps/filmcraft/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ mod control_server;
mod dev_icon;
mod file_filters;
mod graphics;
#[cfg(target_os = "linux")]
mod linux_window;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
Expand Down Expand Up @@ -117,6 +119,8 @@ fn main() -> eframe::Result {
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
filmcraft_ui_egui::crash::install(log_dir);
#[cfg(target_os = "linux")]
let window_backend = linux_window::backend().map_err(|e| eframe::Error::AppCreation(e.into()))?;
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
register_hardware_decoders();
Expand All @@ -132,6 +136,10 @@ fn main() -> eframe::Result {
}
let started = loop {
let mut options = native_options(launch.control_port.is_some());
#[cfg(target_os = "linux")]
{
options.event_loop_builder = window_backend.map(linux_window::event_loop_hook);
}
// Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs).
graphics::apply(&mut options, graphics.mode(), backend_override);
let app_created = Arc::new(AtomicBool::new(false));
Expand Down Expand Up @@ -312,6 +320,10 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
#[cfg(target_os = "linux")]
if let Some(window) = cc.winit_window() {
app.hooks.cursor_screen_position = linux_window::cursor_hook(window);
}
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
// compositor reports no theme to winit (no polling: see appearance.rs).
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2593,7 +2593,12 @@ fn build() -> Vec<CommandSpec> {
}
let clips: Vec<ClipId> =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect()).unwrap_or_default();
let clips = if bool_p(p, "linked").unwrap_or(true) { with_links(s, &clips) } else { clips };
let follow = match p.get("linked") {
None => true,
Some(Value::Bool(follow)) => *follow,
Some(_) => return Err(bad("timeline.select", "`linked` must be a boolean")),
};
let clips = if follow { with_links(s, &clips) } else { clips };
// selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive)
s.state.edit_points.clear();
s.state.trim_shift = Default::default();
Expand Down
73 changes: 73 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,79 @@ fn source_insert_three_point() {
s.active_sequence().unwrap().check().unwrap();
}

#[test]
fn timeline_selection_linked_override_preserves_add_toggle_and_the_global_setting() {
let mut s = demo();
let q = s.active_sequence().unwrap();
let video = q.video_tracks[0].items[0].clone();
let audio = q.audio_tracks.iter().flat_map(|tr| &tr.items).find(|i| i.link == video.link).unwrap().id;
let other = q.video_tracks[0].items[1].id;
for global in [true, false] {
s.execute("sequence.linkedSelection", json!({"on": global})).unwrap();
for linked in [None, Some(false), Some(true)] {
let mut params = json!({"clips": [video.id.0]});
if let Some(follow) = linked {
params["linked"] = json!(follow);
}
s.execute("timeline.select", params.clone()).unwrap();
assert!(s.state.selection.contains(&video.id));
assert_eq!(s.state.selection.contains(&audio), global && linked.unwrap_or(true));
params["toggle"] = json!(true);
s.execute("timeline.select", params.clone()).unwrap();
assert!(s.state.selection.is_empty(), "toggle removes exactly the selected sides");
s.state.selection = vec![other];
params["toggle"] = json!(false);
params["add"] = json!(true);
s.execute("timeline.select", params).unwrap();
assert!(s.state.selection.contains(&other) && s.state.selection.contains(&video.id));
assert_eq!(s.state.selection.contains(&audio), global && linked.unwrap_or(true));
assert_eq!(s.state.linked_selection, global, "the per-call override leaves the toggle alone");
}
}
for linked in [json!(null), json!("false"), json!(0), json!([]), json!({})] {
let before = serde_json::to_value(&s.state).unwrap();
let error = s.execute("timeline.select", json!({"clips": [video.id.0], "linked": linked})).unwrap_err();
assert!(error.to_string().contains("`linked` must be a boolean"));
assert_eq!(serde_json::to_value(&s.state).unwrap(), before, "invalid parameters leave selection state intact");
}
}

#[test]
fn clear_respects_individual_selection_and_keeps_explicit_targets_linked() {
for audio_only in [false, true] {
for mode in ["individual", "normal", "explicit"] {
let mut s = demo();
let q = s.active_sequence().unwrap();
let video = q.video_tracks[0].items[0].clone();
let audio = q.audio_tracks.iter().flat_map(|tr| &tr.items).find(|i| i.link == video.link).unwrap().clone();
let (target, partner) = if audio_only { (&audio, &video) } else { (&video, &audio) };
if mode != "explicit" {
let mut params = json!({"clips": [target.id.0]});
if mode == "individual" {
params["linked"] = json!(false);
}
s.execute("timeline.select", params).unwrap();
}
let before = s.project.clone();
let undo = s.history.undo.len();
s.execute("edit.clear", if mode == "explicit" { json!({"clips": [target.id.0]}) } else { json!({}) }).unwrap();
let q = s.active_sequence().unwrap();
assert!(q.find_item(target.id).is_none());
if mode == "individual" {
assert_eq!(q.find_item(partner.id).map(|(_, i)| i), Some(partner), "the unselected partner is untouched");
} else {
assert!(q.find_item(partner.id).is_none(), "normal selections and explicit targets include their partners");
}
assert_eq!(s.history.undo.len(), undo + 1);
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
}
}

#[test]
fn trim_linked_and_ripple_delete() {
let mut s = demo();
Expand Down
Loading