From 0bc4795083d72123353eb8cc9dbdfa8af8fc3339 Mon Sep 17 00:00:00 2001 From: KitsuneSemCalda Date: Fri, 9 Oct 2026 22:50:29 -0300 Subject: [PATCH 001/157] Add OmaStore manifest --- omastore.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 omastore.toml diff --git a/omastore.toml b/omastore.toml new file mode 100644 index 000000000..8d1ee53ae --- /dev/null +++ b/omastore.toml @@ -0,0 +1,11 @@ +kind = "app" +categories = ["AudioVideo", "Video", "AudioVideoEditing"] +icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" + +[linux.x86_64] +asset = "filmcraft-{version}-linux-x86_64.tar.gz" +exec = "filmcraft-{version}-linux-x86_64/usr/bin/filmcraft" + +[linux.aarch64] +asset = "filmcraft-{version}-linux-aarch64.tar.gz" +exec = "filmcraft-{version}-linux-aarch64/usr/bin/filmcraft" From 059376af1c3ad91f20695c55a535feeed88951a5 Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Sat, 10 Oct 2026 11:52:07 +0200 Subject: [PATCH 002/157] #260: file dialogs on Linux no longer stop the app Native file dialogs are requested with a continuation (FilmcraftApp::pick for commands, pick_ui for panel buttons). On Linux the desktop host shows the rfd dialog on a worker thread (HostHooks::run_dialog) and the result is applied when it arrives, so the frame loop, playback and the control channel keep running and GNOME no longer offers to force-quit the window. One dialog at a time; a path for a dialog closed meanwhile goes nowhere. macOS / Windows keep their synchronous pickers. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/main.rs | 61 +++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/lib.rs | 119 ++++++---- crates/ui-egui/src/native_dialogs.rs | 181 ++++++++++++++ crates/ui-egui/src/panels/export_mode.rs | 40 ++-- crates/ui-egui/src/panels/frame_export.rs | 21 +- .../ui-egui/src/panels/graphics_templates.rs | 12 +- .../ui-egui/src/panels/interchange_export.rs | 15 +- crates/ui-egui/src/panels/lumetri.rs | 11 +- crates/ui-egui/src/panels/media_dialogs.rs | 84 +++---- crates/ui-egui/src/panels/menu_dialogs.rs | 43 ++-- crates/ui-egui/src/panels/presets.rs | 20 +- crates/ui-egui/src/panels/settings.rs | 12 +- crates/ui-egui/src/panels/shortcuts_dialog.rs | 11 +- crates/ui-egui/tests/native_dialogs_ui.rs | 221 ++++++++++++++++++ 15 files changed, 698 insertions(+), 154 deletions(-) create mode 100644 crates/ui-egui/src/native_dialogs.rs create mode 100644 crates/ui-egui/tests/native_dialogs_ui.rs diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 18fb4d7b0..2b1898a08 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -226,6 +226,13 @@ fn main() -> eframe::Result { app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); app.hooks.pick_folder_at = Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); + // Linux: the XDG portal call blocks until the user chooses, which would stop the frame + // loop (GNOME offers to force-quit the window after about five seconds, #260). Show the + // dialog on its own thread instead; the app keeps running and acts on the answer. + #[cfg(target_os = "linux")] + { + app.hooks.run_dialog = Some(Box::new(run_dialog_off_thread)); + } app.hooks.open_path = Some(Box::new(open_path)); // Settings ▸ General ▸ Interface Language ▸ System Language (#218). app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); @@ -321,6 +328,60 @@ fn agent_event_loop(agent: bool) -> Option { None } +/// Show `dialog` with rfd on a worker thread and answer on the returned channel (#260). Filter +/// names are translated here, on the UI thread (the interface language is per thread). A dialog +/// thread that can't start or panics drops the sender: the app takes that as cancelled. +#[cfg(target_os = "linux")] +fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> std::sync::mpsc::Receiver> { + use filmcraft_ui_egui::native_dialogs::FileDialog; + let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string(); + let media = t("Media"); + let project = t("FilmCraft Project"); + let dialog = match dialog { + FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts }, + FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts, name }, + other => other, + }; + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || { + let shown = std::panic::catch_unwind(move || { + let path = |p: std::path::PathBuf| p.to_string_lossy().into_owned(); + match dialog { + FileDialog::OpenFiles { exts } => { + rfd::FileDialog::new().add_filter(&media, &exts).pick_files().unwrap_or_default().into_iter().map(path).collect() + } + FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(), + FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(), + FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &["fcproj"]).pick_file().map(path).into_iter().collect(), + FileDialog::SaveProject { name } => { + rfd::FileDialog::new().add_filter(&project, &["fcproj"]).set_file_name(name).save_file().map(path).into_iter().collect() + } + FileDialog::SaveAs { filter, exts, name } => { + rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect() + } + FileDialog::Folder { at } => { + let d = rfd::FileDialog::new(); + let d = match at { + Some(dir) if !dir.is_empty() => d.set_directory(dir), + _ => d, + }; + d.pick_folder().map(path).into_iter().collect::>() + } + } + }); + match shown { + Ok(paths) => { + let _ = tx.send(paths); + } + Err(_) => log::warn!("the file dialog thread panicked; taken as cancelled"), + } + }); + if let Err(e) = spawned { + log::warn!("could not start the file dialog thread: {e}"); + } + rx +} + #[cfg(test)] mod tests { /// Start-up registers the hardware decoders whether or not anything is logged (no logger is diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a39dc82ea..0ab5f90ba 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -801,6 +801,7 @@ Relink others automatically Volver a vincular los demás automáticamente Search in: Buscar en: Browse… Examinar… + A file dialog is already open Ya hay un cuadro de diálogo de archivos abierto Exact name matches only Solo coincidencias exactas de nombre No candidates yet — Search a folder or Locate the file. Aún no hay candidatos: busca en una carpeta o localiza el archivo. ✔ same file ✔ mismo archivo diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83d18261e..d1ec22566 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,6 +42,7 @@ pub mod i18n; pub mod icons; pub mod links; pub mod menus; +pub mod native_dialogs; pub mod panels; pub mod perf; #[cfg(not(target_arch = "wasm32"))] @@ -63,6 +64,7 @@ use serde_json::{Value, json}; pub use control::ControlRequest; use dock::PanelKind; use frames::{FrameKey, FrameServer, Target}; +use native_dialogs::FileDialog; use state::UiState; use theme::{ThemeKind, Tokens}; @@ -131,6 +133,11 @@ pub struct HostHooks { /// Open a file in its default application, or (`true`) reveal it in the file manager (Edit ▸ /// Edit Original, Help ▸ Reveal Log Files). pub open_path: Option Result<(), String>>>, + /// Show a file dialog without blocking the UI thread and answer on the returned channel with + /// the chosen paths (none: cancelled). With it, [`FilmcraftApp::pick`] keeps the frame loop + /// running while the dialog is open (the desktop app on Linux, #260); without it the + /// synchronous pickers above are used. + pub run_dialog: Option Receiver>>>, } /// The command a [`HostHooks::pick_file_for_relink`] caller runs with the chosen file, for hosts @@ -226,6 +233,8 @@ pub struct FilmcraftApp { pub hooks: HostHooks, pub dialog: Option, pub file_dialogs: panels::file_dialogs::FileDialogState, + /// The native file dialog the host is showing ([`Self::pick`]). + native_dialog: Option, pub auto: automation::Registry, /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, @@ -430,6 +439,7 @@ impl FilmcraftApp { // Unsaved changes left by a session that died are offered first thing. dialog: recovery.then_some(Dialog::Recovery), file_dialogs: Default::default(), + native_dialog: None, auto: Default::default(), textures: HashMap::new(), control_rx: None, @@ -1032,65 +1042,69 @@ impl FilmcraftApp { .chain(&["srt", "vtt", "scc", "edl", "xml", "fcpxml", "otio", "aaf", "omf"]) .copied() .collect(); - let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default(); - if paths.is_empty() { - return Ok(Value::Null); - } - let bin = params.get("bin").cloned().unwrap_or_else(|| json!(self.import_bin().0)); - let r = self.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string()); - if let Ok(v) = &r - && let Some(errs) = v.get("errors").and_then(Value::as_array) - && !errs.is_empty() - { - self.ui.status = errs.iter().filter_map(Value::as_str).collect::>().join("; "); - } - r + let bin = params.get("bin").cloned(); + self.pick(FileDialog::open_files(&exts), move |app, paths| { + if paths.is_empty() { + return Ok(Value::Null); + } + let bin = bin.unwrap_or_else(|| json!(app.import_bin().0)); + let r = app.session.execute("file.import", json!({"paths": paths, "bin": bin})).map_err(|e| e.to_string()); + if let Ok(v) = &r + && let Some(errs) = v.get("errors").and_then(Value::as_array) + && !errs.is_empty() + { + app.ui.status = errs.iter().filter_map(Value::as_str).collect::>().join("; "); + } + r + }) } // File ▸ Import with Image Sequence: choose the first numbered still - "file.importImageSequence" => { - let paths = self.hooks.pick_files.as_mut().map(|f| f(filmcraft_media::STILL_EXTENSIONS)).unwrap_or_default(); + "file.importImageSequence" => self.pick(FileDialog::open_files(filmcraft_media::STILL_EXTENSIONS), |app, paths| { let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - let r = self.session.execute("file.importImageSequence", json!({"path": path})).map_err(|e| e.to_string()); + let r = app.session.execute("file.importImageSequence", json!({"path": path})).map_err(|e| e.to_string()); if let Err(e) = &r { - self.ui.status = e.clone(); + app.ui.status = e.clone(); } r - } + }), "file.saveAs" | "file.save" | "file.saveCopy" => { let suggested = if id == "file.saveCopy" { format!("{} copy.fcproj", self.session.project.name) } else { format!("{}.fcproj", self.session.project.name) }; - let Some(path) = self.hooks.pick_save.as_mut().and_then(|f| f(&suggested)) else { return Ok(Value::Null) }; let cmd = if id == "file.saveCopy" { "file.saveCopy" } else { "file.saveAs" }; - self.session.execute(cmd, json!({"path": path})).map_err(|e| e.to_string()) - } - "file.open" => { - let Some(path) = self.hooks.pick_open_project.as_mut().and_then(|f| f()) else { return Ok(Value::Null) }; - self.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string()) + self.pick(FileDialog::SaveProject { name: suggested }, move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute(cmd, json!({"path": path})).map_err(|e| e.to_string()) + }) } + "file.open" => self.pick(FileDialog::OpenProject, |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute("file.open", json!({"path": path})).map_err(|e| e.to_string()) + }), "graphics.newFromFile" => { let exts: Vec<&str> = filmcraft_media::STILL_EXTENSIONS.iter().chain(filmcraft_media::VIDEO_EXTENSIONS).copied().collect(); - let paths = self.hooks.pick_files.as_mut().map(|f| f(&exts)).unwrap_or_default(); - let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - self.session.execute("graphics.newFromFile", json!({"path": path})).map_err(|e| e.to_string()) + self.pick(FileDialog::open_files(&exts), |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute("graphics.newFromFile", json!({"path": path})).map_err(|e| e.to_string()) + }) } - "captions.import" => { - let paths = self.hooks.pick_files.as_mut().map(|f| f(&["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp", "xml"])).unwrap_or_default(); + "captions.import" => self.pick(FileDialog::open_files(&["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp", "xml"]), |app, paths| { let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; - self.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) - } + app.session.execute("captions.import", json!({"path": path})).map_err(|e| e.to_string()) + }), "captions.export" => { let name = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()).unwrap_or_default(); let suggested = format!("{}.srt", name.replace(' ', "_")); - let Some(path) = - self.hooks.pick_save_as.as_mut().and_then(|f| { - f("Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], &suggested) - }) - else { - return Ok(Value::Null); - }; + let dialog = FileDialog::save_as( + "Captions (SRT, WebVTT, SCC, MCC, EBU STL, TTML, DFXP)", + &["srt", "vtt", "scc", "mcc", "stl", "ttml", "dfxp"], + &suggested, + ); let mut p = params.clone(); - p["path"] = json!(path); - self.session.execute("captions.export", p).map_err(|e| e.to_string()) + self.pick(dialog, move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + p["path"] = json!(path); + app.session.execute("captions.export", p).map_err(|e| e.to_string()) + }) } _ => match EXPORT_SAVE_DIALOGS.iter().find(|(c, ..)| *c == id) { Some(&(_, filter, ext)) => self.export_save_dialog(id, filter, ext, params), @@ -1103,9 +1117,9 @@ impl FilmcraftApp { /// run the command with that `path` and the rest of `params`. fn export_save_dialog(&mut self, id: &str, filter: &str, ext: &str, params: &Value) -> Result { filmcraft_engine::find_command(id).map_or(Ok(()), |c| (c.enabled)(&self.session))?; - let Some(pick) = self.hooks.pick_save_as.as_mut() else { + if !FileDialog::save_as(filter, &[ext], "").available(&self.hooks) { return Err("no save dialog available: run the command with a `path`".into()); - }; + } // Timelines are named after the sequence; a selection must not suggest the open project's file. let sequence = self.session.state.active_sequence.and_then(|s| self.session.project.item(s)).map(|i| i.name.clone()); let stem = match id { @@ -1115,15 +1129,18 @@ impl FilmcraftApp { }; let stem: String = stem.chars().map(|c| if matches!(c, '/' | '\\') || c.is_control() { '-' } else { c }).collect(); let stem = if stem.trim().is_empty() { "Untitled".to_string() } else { stem }; - let Some(path) = pick(filter, &[ext], &format!("{stem}.{ext}")) else { return Ok(Value::Null) }; let mut p = params.as_object().cloned().unwrap_or_default(); - p.insert("path".into(), json!(path)); - let r = self.session.execute(id, Value::Object(p)).map_err(|e| e.to_string()); - self.ui.status = match &r { - Ok(_) => tlf!("Exported {path}", path), - Err(e) => e.clone(), - }; - r + let id = id.to_string(); + self.pick(FileDialog::save_as(filter, &[ext], &format!("{stem}.{ext}")), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + p.insert("path".into(), json!(path)); + let r = app.session.execute(&id, Value::Object(p)).map_err(|e| e.to_string()); + app.ui.status = match &r { + Ok(_) => tlf!("Exported {path}", path), + Err(e) => e.clone(), + }; + r + }) } /// Import dropped files. @@ -1608,6 +1625,8 @@ impl eframe::App for FilmcraftApp { } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // a native file dialog's answer first, so the panels draw what it chose (#260) + self.poll_native_dialog(ctx); if !self.styled { theme::install(ctx, &self.tokens); // theme::install replaces the fonts: add the system Japanese font back (or fall back to diff --git a/crates/ui-egui/src/native_dialogs.rs b/crates/ui-egui/src/native_dialogs.rs new file mode 100644 index 000000000..a18ae8bf8 --- /dev/null +++ b/crates/ui-egui/src/native_dialogs.rs @@ -0,0 +1,181 @@ +//! Native file dialogs that don't stop the frame loop (#260). +//! +//! A dialog is asked for with [`FilmcraftApp::pick`]: what to show ([`FileDialog`]) and what to do +//! with the chosen paths. A host that can show dialogs without blocking ([`HostHooks::run_dialog`]: +//! the desktop app on Linux, where the XDG portal call would otherwise hold the UI thread until the +//! user chooses, and GNOME offers to force-quit the window after about five seconds) gets the +//! dialog and answers on a channel; the frame loop keeps running, and the continuation runs on the +//! frame the answer arrives ([`FilmcraftApp::poll_native_dialog`]). Without it the host's +//! synchronous pickers (`pick_files`, `pick_save_as`, …) are called and the continuation runs at +//! once, as before: headless tests, the control channel's stand-ins and other hosts are unchanged. +//! +//! Two entry points: +//! - [`FilmcraftApp::pick`] for menu and control-channel commands: with the synchronous pickers +//! the continuation runs at once and its result is the command's (agents and tests see what +//! they saw before); with the runner the command answers `{"dialog": "open"}`. +//! - [`FilmcraftApp::pick_ui`] for a panel's Browse… / Import… / Export… button: the continuation +//! always runs at the start of a later frame, before the panels draw, since a panel works on a +//! copy of its state that it writes back at the end of the frame. It writes into the dialog's +//! stored state (`app.ui.…`, an egui temporary); when the user has closed that dialog meanwhile +//! there is nothing to write into and the path is dropped. +//! +//! One dialog is open at a time; asking for another one meanwhile is refused. + +use std::sync::mpsc::{Receiver, TryRecvError}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::{FilmcraftApp, HostHooks, RelinkHint}; + +/// A native file dialog. Filters are a name and extensions without dots. +#[derive(Clone, Debug)] +pub enum FileDialog { + /// Choose one or more files (File ▸ Import; one file where only the first is used). + OpenFiles { exts: Vec }, + /// Choose one file. + OpenFile { filter: String, exts: Vec }, + /// Choose a FilmCraft project to open. + OpenProject, + /// Save a FilmCraft project, suggesting `name`. + SaveProject { name: String }, + /// Save a file, suggesting `name`. + SaveAs { filter: String, exts: Vec, name: String }, + /// Choose a folder, starting at `at` when there is one. + Folder { at: Option }, + /// Choose one file to relink to (Link Media ▸ Locate…, Attach Proxies, Reconnect Full + /// Resolution): the host's relink picker, which may take over the command (the web). + Relink { exts: Vec, hint: RelinkHint }, +} + +/// What to do with the chosen paths (none: cancelled). Its result is the request's (control +/// channel, menu) when the dialog answers at once; otherwise an error goes to the status line. +pub(crate) type Continuation = Box) -> Result>; + +/// The dialog a host is showing, and what to do with its answer. +pub(crate) struct PendingDialog { + answer: Receiver>, + then: Continuation, +} + +fn strings(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_string()).collect() +} + +impl FileDialog { + pub fn open_files(exts: &[&str]) -> Self { + Self::OpenFiles { exts: strings(exts) } + } + pub fn open_file(filter: &str, exts: &[&str]) -> Self { + Self::OpenFile { filter: filter.to_string(), exts: strings(exts) } + } + pub fn save_as(filter: &str, exts: &[&str], name: &str) -> Self { + Self::SaveAs { filter: filter.to_string(), exts: strings(exts), name: name.to_string() } + } + + /// Whether `hooks` can show this dialog at all (a host without the picker leaves it out). + pub fn available(&self, hooks: &HostHooks) -> bool { + hooks.run_dialog.is_some() + || match self { + Self::OpenFiles { .. } => hooks.pick_files.is_some(), + Self::OpenFile { .. } => hooks.pick_open_file.is_some(), + Self::OpenProject => hooks.pick_open_project.is_some(), + Self::SaveProject { .. } => hooks.pick_save.is_some(), + Self::SaveAs { .. } => hooks.pick_save_as.is_some(), + Self::Folder { at } => hooks.pick_folder.is_some() || (at.is_some() && hooks.pick_folder_at.is_some()), + Self::Relink { .. } => hooks.pick_file_for_relink.is_some() || hooks.pick_files.is_some(), + } + } + + /// Show the dialog with the host's synchronous pickers, as before #260. + fn run_now(self, hooks: &mut HostHooks) -> Vec { + let one = |p: Option| p.into_iter().collect::>(); + match self { + Self::OpenFiles { exts } => hooks.pick_files.as_mut().map(|f| f(&refs(&exts))).unwrap_or_default(), + Self::OpenFile { filter, exts } => one(hooks.pick_open_file.as_mut().and_then(|f| f(&filter, &refs(&exts)))), + Self::OpenProject => one(hooks.pick_open_project.as_mut().and_then(|f| f())), + Self::SaveProject { name } => one(hooks.pick_save.as_mut().and_then(|f| f(&name))), + Self::SaveAs { filter, exts, name } => one(hooks.pick_save_as.as_mut().and_then(|f| f(&filter, &refs(&exts), &name))), + Self::Folder { at } => one(match (at, hooks.pick_folder_at.as_mut()) { + (Some(dir), Some(f)) => f(&dir), + _ => hooks.pick_folder.as_mut().and_then(|f| f()), + }), + Self::Relink { exts, hint } => match hooks.pick_file_for_relink.as_mut() { + Some(f) => one(f(&refs(&exts), Some(hint))), + None => hooks.pick_files.as_mut().map(|f| f(&refs(&exts))).unwrap_or_default().into_iter().take(1).collect(), + }, + } + } +} + +fn refs(v: &[String]) -> Vec<&str> { + v.iter().map(String::as_str).collect() +} + +impl FilmcraftApp { + /// Show `dialog`, then call `then` with the chosen paths (empty when cancelled). With a host + /// that shows dialogs without blocking, this returns `{"dialog": "open"}` and `then` runs on a + /// later frame; otherwise `then` runs now and its result is returned. While a dialog is open + /// another one is refused. + pub fn pick(&mut self, dialog: FileDialog, then: impl FnOnce(&mut FilmcraftApp, Vec) -> Result + 'static) -> Result { + if self.native_dialog.is_some() { + return Err(tl!("A file dialog is already open").into()); + } + if let Some(run) = self.hooks.run_dialog.as_mut() { + self.native_dialog = Some(PendingDialog { answer: run(dialog), then: Box::new(then) }); + return Ok(json!({"dialog": "open"})); + } + let paths = dialog.run_now(&mut self.hooks); + then(self, paths) + } + + /// [`Self::pick`] for a panel's button: `then` returns nothing, and a refusal (another dialog is + /// open) goes to the status line. `then` always runs at the start of a later frame, also with + /// the host's synchronous pickers: a panel works on a copy of its state and writes it back at + /// the end of the frame, which would overwrite a path written in the middle of it. + pub fn pick_ui(&mut self, dialog: FileDialog, then: impl FnOnce(&mut FilmcraftApp, Vec) + 'static) { + if self.native_dialog.is_some() { + self.ui.status = tl!("A file dialog is already open").into(); + return; + } + let answer = match self.hooks.run_dialog.as_mut() { + Some(run) => run(dialog), + None => { + let (tx, rx) = std::sync::mpsc::channel(); + // the receiver is alive (held here): sending can't fail + let _ = tx.send(dialog.run_now(&mut self.hooks)); + rx + } + }; + let then: Continuation = Box::new(move |app, paths| { + then(app, paths); + Ok(Value::Null) + }); + self.native_dialog = Some(PendingDialog { answer, then }); + } + + /// Whether a file dialog is open (one the host shows without blocking). + pub fn native_dialog_open(&self) -> bool { + self.native_dialog.is_some() + } + + /// Run the open dialog's continuation once it has answered (called first thing every frame). + /// While it is open the frame loop keeps polling at a low rate. + pub(crate) fn poll_native_dialog(&mut self, ctx: &egui::Context) { + let Some(pending) = &self.native_dialog else { return }; + let paths = match pending.answer.try_recv() { + Ok(paths) => paths, + Err(TryRecvError::Empty) => { + ctx.request_repaint_after(Duration::from_millis(100)); + return; + } + // the host's dialog ended without an answer (its thread failed): as if cancelled + Err(TryRecvError::Disconnected) => Vec::new(), + }; + let Some(pending) = self.native_dialog.take() else { return }; + if let Err(e) = (pending.then)(self, paths) { + self.ui.status = e; + } + ctx.request_repaint(); + } +} diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 4838ce5ff..46570ec86 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -31,6 +31,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::frames::{FrameKey, Target}; +use crate::native_dialogs::FileDialog; use crate::theme::Tokens; /// Export mode state. @@ -543,12 +544,20 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if open_manager { app.ui.export.manager = Some(PresetManager { selected: app.ui.export.preset.clone(), ..Default::default() }); } - if pick_folder && let Some(d) = app.hooks.pick_folder.as_mut().and_then(|f| f()) { - app.ui.export.location = d; + if pick_folder { + app.pick_ui(FileDialog::Folder { at: None }, |app, paths| { + if let Some(d) = paths.into_iter().next() { + app.ui.export.location = d; + } + }); } - if pick_overlay && let Some(f) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Image"), &["png", "jpg", "jpeg"])) { - app.ui.export.settings.effects.image_overlay.path = f; - app.ui.export.settings.effects.image_overlay.enabled = true; + if pick_overlay { + app.pick_ui(FileDialog::open_file(tl!("Image"), &["png", "jpg", "jpeg"]), |app, paths| { + if let Some(f) = paths.into_iter().next() { + app.ui.export.settings.effects.image_overlay.path = f; + app.ui.export.settings.effects.image_overlay.enabled = true; + } + }); } reg.flush(app); } @@ -1160,22 +1169,25 @@ fn preset_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { app.ui.status = e.to_string(); } match pick { - Some("import") => match app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Export presets"), &["json"])) { + Some("import") => app.pick_ui(FileDialog::open_file(tl!("Export presets"), &["json"]), |app, paths| match paths.into_iter().next() { Some(path) => { if let Err(e) = app.session.execute("export.presets.import", json!({"path": path})) { app.ui.status = e.to_string(); } } None => app.ui.status = tl!("Import: no file chosen (agents: export.presets.import {path})").into(), - }, - Some(_) => match app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Export presets"), &["json"], &format!("{}.json", m.selected))) { - Some(path) => { - if let Err(e) = app.session.execute("export.presets.export", json!({"path": path, "names": [m.selected]})) { - app.ui.status = e.to_string(); + }), + Some(_) => { + let selected = m.selected.clone(); + app.pick_ui(FileDialog::save_as(tl!("Export presets"), &["json"], &format!("{selected}.json")), move |app, paths| match paths.into_iter().next() { + Some(path) => { + if let Err(e) = app.session.execute("export.presets.export", json!({"path": path, "names": [selected]})) { + app.ui.status = e.to_string(); + } } - } - None => app.ui.status = tl!("Export: no file chosen (agents: export.presets.export {path, names})").into(), - }, + None => app.ui.status = tl!("Export: no file chosen (agents: export.presets.export {path, names})").into(), + }) + } None => {} } if let Some(name) = apply diff --git a/crates/ui-egui/src/panels/frame_export.rs b/crates/ui-egui/src/panels/frame_export.rs index 4790c440a..b3a8f8dd2 100644 --- a/crates/ui-egui/src/panels/frame_export.rs +++ b/crates/ui-egui/src/panels/frame_export.rs @@ -1,4 +1,5 @@ //! Monitor Export Frame settings, with captured target/time and a destination folder. +use crate::native_dialogs::FileDialog; use crate::{ FilmcraftApp, icons::{self, Icon}, @@ -152,7 +153,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let r = ui.checkbox(&mut d.import, "Import into project"); elems.push(("exportFrame.import".into(), r.rect, d.import.to_string())); ui.allocate_ui_with_layout(egui::vec2(ui.available_width(), 26.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { - let r = ui.add_enabled(app.hooks.pick_folder_at.is_some() || app.hooks.pick_folder.is_some(), egui::Button::new("Browse…")); + let r = ui.add_enabled(FileDialog::Folder { at: Some(String::new()) }.available(&app.hooks), egui::Button::new("Browse…")); browse = r.clicked(); elems.push(("exportFrame.browse".into(), r.rect, "Browse".into())); }); @@ -211,14 +212,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { close |= ctx.input(|i| i.key_pressed(egui::Key::Escape)); if browse && !close { let folder = filmcraft_engine::export_tools::expand_home(d.folder.trim()); - let picked = match app.hooks.pick_folder_at.as_mut() { - Some(pick) => pick(&folder), - None => app.hooks.pick_folder.as_mut().and_then(|pick| pick()), - }; - if let Some(path) = picked { - d.folder = path; - d.replace = None; - } + let ctx = ctx.clone(); + // into the dialog's stored draft, if it is still open when the folder comes back + app.pick_ui(FileDialog::Folder { at: Some(folder) }, move |_, paths| { + let Some(path) = paths.into_iter().next() else { return }; + if let Some(Some(mut d)) = ctx.data(|m| m.get_temp::>(draft_id())) { + d.folder = path; + d.replace = None; + ctx.data_mut(|m| m.insert_temp(draft_id(), Some(d))); + } + }); } if (apply || replace) && !close { let mut stem = clean(&d.name); diff --git a/crates/ui-egui/src/panels/graphics_templates.rs b/crates/ui-egui/src/panels/graphics_templates.rs index d66c346a8..39d29baf8 100644 --- a/crates/ui-egui/src/panels/graphics_templates.rs +++ b/crates/ui-egui/src/panels/graphics_templates.rs @@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::FilmcraftApp; +use crate::native_dialogs::FileDialog; use crate::theme::Tokens; /// One candidate property in the Export As Motion Graphics Template dialog. @@ -589,10 +590,13 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val let empty = params.as_object().is_none_or(|m| m.is_empty()); match id { "graphics.template.install" if params.get("path").is_none() => { - let path = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("FilmCraft Graphics Template"), &["fcgt"]))?; - let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); - invalidate(ctx); - Some(r) + let (id, ctx) = (id.to_string(), ctx.clone()); + Some(app.pick(FileDialog::open_file(tl!("FilmCraft Graphics Template"), &["fcgt"]), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + let r = app.session.execute(&id, json!({"path": path})).map_err(|e| e.to_string()); + invalidate(&ctx); + r + })) } "graphics.template.export" | "file.exportGraphicsTemplate" if empty => { let clip = filmcraft_engine::graphics::target_clip(&app.session, &Value::Null); diff --git a/crates/ui-egui/src/panels/interchange_export.rs b/crates/ui-egui/src/panels/interchange_export.rs index 205cdafa9..2426674c6 100644 --- a/crates/ui-egui/src/panels/interchange_export.rs +++ b/crates/ui-egui/src/panels/interchange_export.rs @@ -10,6 +10,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; +use crate::native_dialogs::FileDialog; #[derive(Clone, Debug, PartialEq)] struct Draft { @@ -176,8 +177,12 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let (ext, filter) = if d.omf { ("omf", "OMF") } else { ("aaf", "AAF") }; let stem = if d.title.is_empty() { "Sequence".to_string() } else { d.title.replace(['/', '\\', ':'], "_") }; let suggested = format!("{stem}.{ext}"); - match app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &suggested)) { - Some(path) => { + let dialog = FileDialog::save_as(filter, &[ext], &suggested); + if dialog.available(&app.hooks) { + // the dialog closes now: the export takes its settings as they are + let d = d.clone(); + app.pick_ui(dialog, move |app, paths| { + let Some(path) = paths.into_iter().next() else { return }; let mut params = json!({ "path": path, "breakoutToMono": d.breakout, @@ -199,9 +204,9 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { Ok(v) => app.ui.status = tlf!("Exported {path}", path = v["path"].as_str().unwrap_or_default()), Err(e) => app.ui.status = e.to_string(), } - } - None if app.hooks.pick_save_as.is_none() => app.ui.status = "no save dialog available: run the command with a `path`".into(), - None => {} + }); + } else { + app.ui.status = "no save dialog available: run the command with a `path`".into(); } } ctx.data_mut(|m| m.insert_temp(draft_id(), if close { None } else { Some(d) })); diff --git a/crates/ui-egui/src/panels/lumetri.rs b/crates/ui-egui/src/panels/lumetri.rs index ff35ec199..b1ec1c6c3 100644 --- a/crates/ui-egui/src/panels/lumetri.rs +++ b/crates/ui-egui/src/panels/lumetri.rs @@ -15,6 +15,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::icons::{self, Icon}; +use crate::native_dialogs::FileDialog; use crate::theme::Tokens; type Actions = Vec; @@ -255,8 +256,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { continue; } if p.get("browse").is_some() { - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("LUT"), &["cube", "3dl"])) else { continue }; - p = json!({"path": path}); + let (cmd, clip) = (cmd.to_string(), clip.0); + app.pick_ui(FileDialog::open_file(tl!("LUT"), &["cube", "3dl"]), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return }; + if let Err(err) = app.session.execute(&cmd, json!({"path": path, "clip": clip})) { + app.ui.status = err.to_string(); + } + }); + continue; } p["clip"] = json!(clip.0); if let Err(err) = app.session.execute(cmd, p) { diff --git a/crates/ui-egui/src/panels/media_dialogs.rs b/crates/ui-egui/src/panels/media_dialogs.rs index e4cd2e93e..5c59fef2f 100644 --- a/crates/ui-egui/src/panels/media_dialogs.rs +++ b/crates/ui-egui/src/panels/media_dialogs.rs @@ -17,6 +17,7 @@ use egui::{Color32, RichText}; use serde_json::{Value, json}; +use crate::native_dialogs::FileDialog; use crate::state::{LinkMediaDraft, ProjectManagerDraft, ProxyDraft}; use crate::{FilmcraftApp, RelinkHint}; @@ -82,15 +83,11 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option = filmcraft_media::VIDEO_EXTENSIONS.to_vec(); let hint = RelinkHint { command: id.to_string(), params: json!({"item": item.0}) }; - let picked = if let Some(picker) = app.hooks.pick_file_for_relink.as_mut() { - picker(&exts, Some(hint)) - } else if let Some(picker) = app.hooks.pick_files.as_mut() { - picker(&exts).into_iter().next() - } else { - None - }; - let Some(picked) = picked else { return Some(Ok(Value::Null)) }; - Some(app.session.execute(id, json!({"item": item.0, "path": picked})).map_err(|e| e.to_string())) + let id = id.to_string(); + Some(app.pick(FileDialog::Relink { exts: exts.iter().map(|e| e.to_string()).collect(), hint }, move |app, paths| { + let Some(picked) = paths.into_iter().next() else { return Ok(Value::Null) }; + app.session.execute(&id, json!({"item": item.0, "path": picked})).map_err(|e| e.to_string()) + })) } "file.projectManager" => Some(enabled(app, id).map(|_| { let seqs = app.session.state.active_sequence.map(|s| vec![s.0]).unwrap_or_default(); @@ -292,26 +289,13 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { action = Some("cancel"); } let (item, ..) = rows[d.row].clone(); - let relink = |app: &mut FilmcraftApp, d: &mut LinkMediaDraft, path: String| { - let mut p = match_params(d); - p["item"] = json!(item); - p["path"] = json!(path); - match app.session.execute("media.relink", p) { - Ok(v) => { - d.message.clear(); - d.candidates.clear(); - d.candidate = None; - app.ui.status = tlf!("Linked {n} clip(s)", n = v["relinked"].as_array().map_or(0, Vec::len)); - } - Err(e) => d.message = e.to_string(), - } - }; + let relink = |app: &mut FilmcraftApp, d: &mut LinkMediaDraft, path: String| relink_to(app, d, &json!(item), path); match action { - Some("browse") => { - if let Some(f) = app.hooks.pick_folder.as_mut().and_then(|f| f()) { + Some("browse") => app.pick_ui(FileDialog::Folder { at: None }, |app, paths| { + if let (Some(f), Some(d)) = (paths.into_iter().next(), app.ui.link_media.as_mut()) { d.folder = f; } - } + }), Some("search") => { let mut p = match_params(&d); p["folder"] = json!(d.folder); @@ -343,16 +327,12 @@ fn link_media(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut params = match_params(&d); params["item"] = json!(item); let hint = RelinkHint { command: "media.relink".into(), params }; - let picked = if let Some(picker) = app.hooks.pick_file_for_relink.as_mut() { - picker(&exts, Some(hint)) - } else if let Some(picker) = app.hooks.pick_files.as_mut() { - picker(&exts).into_iter().next() - } else { - None - }; - if let Some(path) = picked { - relink(app, &mut d, path); - } + let item = json!(item); + app.pick_ui(FileDialog::Relink { exts: exts.iter().map(|e| e.to_string()).collect(), hint }, move |app, paths| { + let (Some(path), Some(mut d)) = (paths.into_iter().next(), app.ui.link_media.clone()) else { return }; + relink_to(app, &mut d, &item, path); + app.ui.link_media = Some(d); + }); } Some("link") => { if let Some(path) = d.candidate.and_then(|c| d.candidates.get(c)).map(|c| c.0.clone()) { @@ -462,8 +442,12 @@ fn create_proxies(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if browse && let Some(f) = app.hooks.pick_folder.as_mut().and_then(|f| f()) { - d.destination = f; + if browse { + app.pick_ui(FileDialog::Folder { at: None }, |app, paths| { + if let (Some(f), Some(d)) = (paths.into_iter().next(), app.ui.create_proxies.as_mut()) { + d.destination = f; + } + }); } if ok { let p = json!({"items": d.items, "preset": d.preset, "destination": if d.destination.is_empty() { Value::Null } else { json!(d.destination) }}); @@ -591,8 +575,12 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if browse && let Some(f) = app.hooks.pick_folder.as_mut().and_then(|f| f()) { - d.destination = f; + if browse { + app.pick_ui(FileDialog::Folder { at: None }, |app, paths| { + if let (Some(f), Some(d)) = (paths.into_iter().next(), app.ui.project_manager.as_mut()) { + d.destination = f; + } + }); } // settings changed: the estimate is stale if (d.mode != before.mode @@ -625,3 +613,19 @@ fn project_manager(app: &mut FilmcraftApp, ctx: &egui::Context) { } app.ui.project_manager = (keep && !ctx.input(|i| i.key_pressed(egui::Key::Escape))).then_some(d); } + +/// Link Media: relink `item` to `path` with the dialog's matching options; the result goes into `d`. +fn relink_to(app: &mut FilmcraftApp, d: &mut LinkMediaDraft, item: &Value, path: String) { + let mut p = match_params(d); + p["item"] = item.clone(); + p["path"] = json!(path); + match app.session.execute("media.relink", p) { + Ok(v) => { + d.message.clear(); + d.candidates.clear(); + d.candidate = None; + app.ui.status = tlf!("Linked {n} clip(s)", n = v["relinked"].as_array().map_or(0, Vec::len)); + } + Err(e) => d.message = e.to_string(), + } +} diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index e3431398a..5f4dfe0d7 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -23,6 +23,7 @@ use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::dock::PanelKind; +use crate::native_dialogs::FileDialog; use crate::state::ClipDialogDraft; /// Frontend state of the M3.11 menu items (`UiState::extras`). @@ -213,26 +214,30 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val } else { (tl!("FilmCraft Project"), "fcproj", tlf!("{name} Selection.fcproj", name = app.session.project.name)) }; - let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(filter, &[ext], &name)) else { - return Some(Ok(Value::Null)); - }; let mut p = params.clone(); if !p.is_object() { p = json!({}); } - p["path"] = json!(path); - return Some(app.session.execute(id, p).map_err(|e| e.to_string())); + let id = id.to_string(); + return Some(app.pick(FileDialog::save_as(filter, &[ext], &name), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + p["path"] = json!(path); + app.session.execute(&id, p).map_err(|e| e.to_string()) + })); } "file.mediaPropertiesFile" if params.get("path").is_none() => { let exts: Vec<&str> = filmcraft_media::VIDEO_EXTENSIONS.iter().chain(filmcraft_media::AUDIO_EXTENSIONS).chain(filmcraft_media::STILL_EXTENSIONS).copied().collect(); - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Media"), &exts)) else { return Some(Ok(Value::Null)) }; - let r = app.session.execute(id, json!({"path": path})).map_err(|e| e.to_string()); - if let Ok(v) = &r { - app.ui.extras.dialog = - Some(ClipDialogDraft { command: "file.mediaProperties".into(), params: json!({}), info: json!([v]), error: String::new() }); - } - return Some(r); + let id = id.to_string(); + return Some(app.pick(FileDialog::open_file(tl!("Media"), &exts), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + let r = app.session.execute(&id, json!({"path": path})).map_err(|e| e.to_string()); + if let Ok(v) = &r { + app.ui.extras.dialog = + Some(ClipDialogDraft { command: "file.mediaProperties".into(), params: json!({}), info: json!([v]), error: String::new() }); + } + r + })); } _ => {} } @@ -822,10 +827,16 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { for (id, r, l) in elems { app.auto.add(&id, r, &l); } - if let Some(k) = browse - && let Some(dir) = app.hooks.pick_folder.as_mut().and_then(|f| f()) - { - d.params[k.as_str()] = json!(dir); + if let Some(k) = browse { + // into the dialog's parameters, if the same dialog is still open when the folder comes back + let command = d.command.clone(); + app.pick_ui(FileDialog::Folder { at: None }, move |app, paths| { + if let (Some(dir), Some(d)) = (paths.into_iter().next(), app.ui.extras.dialog.as_mut()) + && d.command == command + { + d.params[k.as_str()] = json!(dir); + } + }); } if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { action = Some("cancel"); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 61e6bb18f..b6639bda3 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -10,6 +10,7 @@ use serde_json::json; use crate::FilmcraftApp; use crate::icons::{self, Icon}; +use crate::native_dialogs::FileDialog; use crate::state::SavePresetDraft; use crate::theme::Tokens; @@ -66,12 +67,23 @@ pub fn run(app: &mut FilmcraftApp, cmd: &str, name: &str) { "presets.apply" => app.session.execute(cmd, json!({"preset": name})), "presets.delete" => app.session.execute(cmd, json!({"name": name})), "presets.export" => { - let Some(path) = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Effect presets"), &["json"], &format!("{name}.json"))) else { return }; - app.session.execute(cmd, json!({"path": path, "names": [name]})) + let name = name.to_string(); + app.pick_ui(FileDialog::save_as(tl!("Effect presets"), &["json"], &format!("{name}.json")), move |app, paths| { + let Some(path) = paths.into_iter().next() else { return }; + if let Err(e) = app.session.execute("presets.export", json!({"path": path, "names": [name]})) { + app.ui.status = e.to_string(); + } + }); + return; } "presets.import" => { - let Some(path) = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Effect presets"), &["json"])) else { return }; - app.session.execute(cmd, json!({"path": path})) + app.pick_ui(FileDialog::open_file(tl!("Effect presets"), &["json"]), |app, paths| { + let Some(path) = paths.into_iter().next() else { return }; + if let Err(e) = app.session.execute("presets.import", json!({"path": path})) { + app.ui.status = e.to_string(); + } + }); + return; } _ => return, }; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 241c59755..6beb00b10 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -17,6 +17,7 @@ use filmcraft_project::Label; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; +use crate::native_dialogs::FileDialog; use crate::theme::Tokens; use crate::{AudioDevices, Dialog, FilmcraftApp}; @@ -323,10 +324,13 @@ fn draw_field(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, f: &Fi if matches!(f.kind, Kind::Path) { let b = ui.button(tl!("Browse…")); app.auto.add(&format!("settings.{}.browse", f.key), b.rect, "Browse…"); - if b.clicked() - && let Some(dir) = app.hooks.pick_folder.as_mut().and_then(|p| p()) - { - put(&mut d.values, f.key, json!(dir)); + if b.clicked() { + let key = f.key; + app.pick_ui(FileDialog::Folder { at: None }, move |app, paths| { + if let (Some(dir), Some(d)) = (paths.into_iter().next(), app.ui.settings.as_mut()) { + put(&mut d.values, key, json!(dir)); + } + }); } } } diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs index 0e6845a88..f5464f5b6 100644 --- a/crates/ui-egui/src/panels/shortcuts_dialog.rs +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -19,6 +19,7 @@ use serde::Serialize; use serde_json::{Value, json}; use crate::FilmcraftApp; +use crate::native_dialogs::FileDialog; use crate::theme::Tokens; /// Key colours (our own palette): application-wide, panel-specific, unassigned, held modifier. @@ -234,26 +235,24 @@ fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { app.shortcut_editor.message = tlf!("Deleted preset “{active}”", active); } if small_button(app, ui, "shortcuts.export", tl!("Export…"), true) { - let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f(tl!("Keyboard Shortcuts"), &["json"], &format!("{active}.json"))); - match picked { + app.pick_ui(FileDialog::save_as(tl!("Keyboard Shortcuts"), &["json"], &format!("{active}.json")), |app, paths| match paths.into_iter().next() { Some(path) => { if exec(app, "shortcuts.export", json!({"path": path})).is_some() { app.shortcut_editor.message = tlf!("Exported to {path}", path); } } None => app.shortcut_editor.message = tl!("Export: no file chosen (agents: shortcuts.export {path})").into(), - } + }); } if small_button(app, ui, "shortcuts.import", tl!("Import…"), true) { - let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f(tl!("Keyboard Shortcuts"), &["json"])); - match picked { + app.pick_ui(FileDialog::open_file(tl!("Keyboard Shortcuts"), &["json"]), |app, paths| match paths.into_iter().next() { Some(path) => { if let Some(r) = exec(app, "shortcuts.import", json!({"path": path})) { app.shortcut_editor.message = tlf!("Imported “{name}”", name = r["name"].as_str().unwrap_or("")); } } None => app.shortcut_editor.message = tl!("Import: no file chosen (agents: shortcuts.import {path})").into(), - } + }); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(RichText::new(if cfg!(target_os = "macos") { tl!("Keyboard: US (macOS)") } else { tl!("Keyboard: US") }).size(12.0).color(t.text_dim)); diff --git a/crates/ui-egui/tests/native_dialogs_ui.rs b/crates/ui-egui/tests/native_dialogs_ui.rs new file mode 100644 index 000000000..9bbad64a8 --- /dev/null +++ b/crates/ui-egui/tests/native_dialogs_ui.rs @@ -0,0 +1,221 @@ +//! File dialogs that don't stop the frame loop (#260), headless with a stand-in host runner +//! (`HostHooks::run_dialog`) the test answers by hand: while a dialog is open the app keeps +//! answering requests and drawing, and the chosen path is used when the answer arrives (a menu +//! command, a panel's field), cancelled or lost dialogs do nothing, one dialog is open at a time, +//! and a path for a dialog the user has closed meanwhile goes nowhere. Also: with the synchronous +//! pickers (no runner) a panel's Browse… still fills its field. + +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use filmcraft_ui_egui::native_dialogs::FileDialog; +use serde_json::{Value, json}; + +/// Dialogs the app asked for, with the sender that answers each. +type Asked = Arc>)>>>; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + asked: Asked, +} + +impl Driver { + /// The demo project; `runner`: dialogs go to the stand-in host runner (else synchronous hooks). + fn new(runner: bool) -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let asked: Asked = Arc::default(); + if runner { + let a = asked.clone(); + app.hooks.run_dialog = Some(Box::new(move |dialog: FileDialog| -> Receiver> { + let (tx, rx) = channel(); + a.lock().unwrap().push((dialog, tx)); + rx + })); + } + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, asked }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + + fn click(&mut self, id: &str) { + self.ok("ui.click", json!({"id": id})); + self.frames(3); + } + + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } + + /// The one dialog asked for so far (taken), with its answering sender. + fn asked(&mut self) -> (FileDialog, Sender>) { + let mut a = self.asked.lock().unwrap(); + assert_eq!(a.len(), 1, "one dialog asked for: {:?}", a.iter().map(|x| &x.0).collect::>()); + a.pop().unwrap() + } + + fn items(&mut self) -> usize { + self.app().session.project.items.len() + } +} + +/// A one-second 8 kHz mono WAV in Cargo's temporary folder for integration tests. +fn wav() -> PathBuf { + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("native-dialogs"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tone.wav"); + let (rate, n) = (8000u32, 8000u32); + let mut b = Vec::new(); + b.extend_from_slice(b"RIFF"); + b.extend_from_slice(&(36 + n * 2).to_le_bytes()); + b.extend_from_slice(b"WAVEfmt "); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&rate.to_le_bytes()); + b.extend_from_slice(&(rate * 2).to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(n * 2).to_le_bytes()); + for i in 0..n { + b.extend_from_slice(&(((i as f32 * 0.2).sin() * 8000.0) as i16).to_le_bytes()); + } + std::fs::write(&path, b).unwrap(); + path +} + +#[test] +fn the_app_keeps_running_while_the_import_dialog_is_open_and_imports_when_it_answers() { + let mut d = Driver::new(true); + let before = d.items(); + // File ▸ Import answers at once: the dialog is open, nothing is imported yet + assert_eq!(d.ok("ui.menu.invoke", json!({"id": "file.import"})), json!({"dialog": "open"})); + assert!(d.app().native_dialog_open()); + let (dialog, answer) = d.asked(); + assert!(matches!(&dialog, FileDialog::OpenFiles { exts } if exts.iter().any(|e| e == "wav")), "{dialog:?}"); + // the frame loop runs and requests are answered while the dialog is open + for _ in 0..5 { + d.ok("ui.inspect", json!({})); + } + d.frames(20); + assert_eq!(d.items(), before); + // the user chooses a file: it is imported on the next frame + answer.send(vec![wav().to_string_lossy().into_owned()]).unwrap(); + d.frames(2); + assert!(!d.app().native_dialog_open()); + assert_eq!(d.items(), before + 1); + assert!(d.app().session.project.items.values().any(|i| i.name == "tone.wav")); +} + +#[test] +fn cancelled_or_lost_dialogs_do_nothing_and_one_dialog_is_open_at_a_time() { + let mut d = Driver::new(true); + let before = d.items(); + d.ok("ui.menu.invoke", json!({"id": "file.import"})); + // a second dialog while one is open is refused + let r = d.call("ui.menu.invoke", json!({"id": "file.open"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r.to_string().contains("already open"), "{r}"); + let (_, answer) = d.asked(); + // cancelled: no path + answer.send(vec![]).unwrap(); + d.frames(2); + assert!(!d.app().native_dialog_open()); + assert_eq!(d.items(), before); + // the host's dialog went away without an answer (its thread failed): as if cancelled + d.ok("ui.menu.invoke", json!({"id": "file.import"})); + let (_, answer) = d.asked(); + drop(answer); + d.frames(2); + assert!(!d.app().native_dialog_open()); + assert_eq!(d.items(), before); + // and the next dialog opens normally + assert_eq!(d.ok("ui.menu.invoke", json!({"id": "file.open"})), json!({"dialog": "open"})); + assert!(matches!(d.asked().0, FileDialog::OpenProject)); +} + +#[test] +fn a_panel_folder_arrives_in_its_open_dialog_and_nowhere_once_it_is_closed() { + let mut d = Driver::new(true); + // Settings ▸ Media Cache ▸ Location ▸ Browse… + d.ok("ui.menu.invoke", json!({"id": "app.settings.mediaCache"})); + d.frames(3); + d.click("settings.mediaCache.location.browse"); + let (dialog, answer) = d.asked(); + assert!(matches!(dialog, FileDialog::Folder { .. }), "{dialog:?}"); + d.frames(10); + answer.send(vec!["/tmp/filmcraft-cache-test".into()]).unwrap(); + d.frames(3); + let loc = d.app().ui.settings.as_ref().map(|s| s.values.pointer("/mediaCache/location").cloned()); + assert_eq!(loc, Some(Some(json!("/tmp/filmcraft-cache-test")))); + // Browse… again, then Cancel the Settings dialog before the folder comes back: nothing to write into + d.click("settings.mediaCache.location.browse"); + let (_, answer) = d.asked(); + d.click("settings.cancel"); + assert!(d.app().ui.settings.is_none()); + answer.send(vec!["/tmp/elsewhere".into()]).unwrap(); + d.frames(3); + assert!(d.app().ui.settings.is_none()); + assert!(!d.app().native_dialog_open()); + assert_ne!(d.app().session.prefs.to_value().pointer("/mediaCache/location"), Some(&json!("/tmp/elsewhere"))); +} + +#[test] +fn export_location_browse_works_with_the_runner_and_with_synchronous_pickers() { + // runner: the folder arrives later + let mut d = Driver::new(true); + d.ok("ui.set", json!({"mode": "export"})); + d.frames(3); + d.click("export.location.browse"); + let (_, answer) = d.asked(); + answer.send(vec!["/tmp/exports-a".into()]).unwrap(); + d.frames(2); + assert_eq!(d.app().ui.export.location, "/tmp/exports-a"); + + // synchronous picker (no runner, as on macOS / Windows / other hosts): still filled in + let mut d = Driver::new(false); + d.app().hooks.pick_folder = Some(Box::new(|| Some("/tmp/exports-b".into()))); + d.ok("ui.set", json!({"mode": "export"})); + d.frames(3); + d.click("export.location.browse"); + assert_eq!(d.app().ui.export.location, "/tmp/exports-b"); + assert!(!d.app().native_dialog_open()); +} From f91b86dd48e180161fd67e5b4537cfbb18f7820c Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:26:28 +0700 Subject: [PATCH 003/157] codecs: import FLAC audio from MP4/MOV (pass STREAMINFO from dfLa) The ISO-BMFF path created the FLAC decoder with no extra data, so every FLAC track in an MP4 or MOV failed with "flac: missing extra data". The decoder now gets the STREAMINFO body that `dfLa` carries after its 4-byte block header, as the Matroska path already did. Regression test `mp4_flac_audio` (an ffmpeg-made MP4 with FLAC audio) failed before this change. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio.rs | 3 ++- crates/codecs/src/tests.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 5f7c02f4a..0a24e6103 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -139,7 +139,8 @@ impl PacketDecoder { C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None), C::Ac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), - C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), + // the decoder needs the STREAMINFO body: `dfLa` holds it after the 4-byte block header + C::Flac(f) => Self::new(CODEC_TYPE_FLAC, rate, f.metadata_blocks.get(4..38).map(<[u8]>::to_vec)), C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), other => Err(CodecError::Unsupported(format!("{} audio", other.name()))), } diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 3f1f5047d..05bfcf0dc 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -224,6 +224,18 @@ fn matroska_hevc_flac() { check_mkv("blue_hevc_flac.mkv", &["-c:v", "libx265", "-x265-params", "log-level=error", "-pix_fmt", "yuv420p"], &["-c:a", "flac"]); } +/// FLAC in MP4 (`fLaC` + `dfLa`): the decoder gets the STREAMINFO from `dfLa` (it was given +/// nothing, and every FLAC track in an MP4 failed with "missing extra data"). +#[test] +fn mp4_flac_audio() { + let args = ["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=2", "-c:a", "flac", "-strict", "-2"]; + let Some(b) = fixture("tone_flac.mp4", &args) else { return }; + let src = crate::open_bytes("tone_flac.mp4", b).expect("open"); + let a = src.audio(24_000, 4800, 48_000).expect("audio"); + let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}"); +} + #[test] fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); From 055e95ab1763c21771c234befcb145c1c9c6af83 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:26:28 +0700 Subject: [PATCH 004/157] Export: FLAC (clean-room encoder; .flac files and FLAC audio in MP4/MOV) New L0 crate filmcraft-flac, a FLAC encoder written from RFC 9639: constant / verbatim / fixed / LPC subframes (Levinson-Durbin on Tukey- windowed autocorrelation, up to order 12), partitioned Rice residuals, left/side, side/right and mid/side stereo, CRC-8/CRC-16, STREAMINFO with MD5 (RFC 1321, own implementation), levels 0-8, all lossless. Export: - Format::Flac, an audio-only .flac (16/24-bit), streamed one second at a time like WAV/AIFF; STREAMINFO is rewritten at the end with frame sizes, total samples and MD5. Preset "FLAC 48 kHz 24-bit". - AudioCodec::Flac in MP4 and QuickTime (fLaC + dfLa), one frame per sample with the short last frame timed exactly; the dfLa STREAMINFO is replaced before the moov is written (Mp4Writer::set_sample_entry). MXF stays PCM. - AudioSettings::flac_level (default 5); Export panel: FLAC (lossless) audio format and Compression Level; file.exportMedia `format: "flac"`, `audioCodec`, `flacLevel`. Spanish and Japanese strings. ffmpeg oracle: every stream decodes bit-exactly with the STREAMINFO MD5 equal to ffmpeg's hash; .flac and FLAC-in-MP4/MOV exports decode to the same samples as a WAV export of the range. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 4 + crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 3 +- crates/engine/src/export_tools.rs | 6 + crates/export/Cargo.toml | 1 + crates/export/src/job.rs | 10 +- crates/export/src/lib.rs | 116 +++++++- crates/export/src/pcm.rs | 2 +- crates/export/src/presets.rs | 7 +- crates/export/src/settings.rs | 33 ++- crates/export/src/settings_tests.rs | 122 ++++++++- crates/flac/Cargo.toml | 17 ++ crates/flac/README.md | 62 +++++ crates/flac/src/crc.rs | 57 ++++ crates/flac/src/frame.rs | 203 ++++++++++++++ crates/flac/src/lib.rs | 247 +++++++++++++++++ crates/flac/src/md5.rs | 118 ++++++++ crates/flac/src/predict.rs | 327 +++++++++++++++++++++++ crates/flac/tests/ffmpeg_oracle.rs | 181 +++++++++++++ crates/isobmff/src/codec.rs | 10 + crates/isobmff/src/mux.rs | 8 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/panels/export_mode.rs | 23 +- xtask/src/main.rs | 1 + 26 files changed, 1553 insertions(+), 29 deletions(-) create mode 100644 crates/flac/Cargo.toml create mode 100644 crates/flac/README.md create mode 100644 crates/flac/src/crc.rs create mode 100644 crates/flac/src/frame.rs create mode 100644 crates/flac/src/lib.rs create mode 100644 crates/flac/src/md5.rs create mode 100644 crates/flac/src/predict.rs create mode 100644 crates/flac/tests/ffmpeg_oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 4803e4b18..9a4b2aee9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1895,6 +1895,7 @@ dependencies = [ "filmcraft-codecs", "filmcraft-color", "filmcraft-dnx", + "filmcraft-flac", "filmcraft-frame", "filmcraft-geom", "filmcraft-h264enc", @@ -1915,6 +1916,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "filmcraft-flac" +version = "0.5.0" +dependencies = [ + "filmcraft-bitstream", + "filmcraft-testkit", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-format" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 58a216420..4fb6e3a99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ filmcraft-dnx = { path = "crates/dnx" } filmcraft-apv = { path = "crates/apv" } filmcraft-av1 = { path = "crates/av1" } filmcraft-aac = { path = "crates/aac" } +filmcraft-flac = { path = "crates/flac" } filmcraft-opus = { path = "crates/opus" } filmcraft-h264enc = { path = "crates/h264enc" } filmcraft-audio-dsp = { path = "crates/audio-dsp" } @@ -111,6 +112,9 @@ opt-level = 3 [profile.dev.package.filmcraft-aac] opt-level = 3 +[profile.dev.package.filmcraft-flac] +opt-level = 3 + # Audio effects: realtime-factor tests and long convolution / spectral runs. [profile.dev.package.filmcraft-audio-dsp] opt-level = 3 diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 70cdeb65a..3bf960529 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1154,7 +1154,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|flac"?,"audioCodec":"auto|aac|pcm|flac"?,"flacLevel":0..8=5,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba4..2ca258972 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -144,10 +144,11 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let Some(j) = probe(&out) else { continue }; let fmt_name = j["format"]["format_name"].as_str().unwrap(); match fmt.as_str() { - "wav" | "aiff" => { + "wav" | "aiff" | "flac" => { let a = stream(&j, "audio").unwrap(); let bits = settings["audio"]["bits"].as_u64().unwrap(); let codec = match (fmt.as_str(), bits) { + ("flac", _) => "flac", ("wav", 24) => "pcm_s24le", ("wav", _) => "pcm_s16le", (_, 24) => "pcm_s24be", diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index a2ef4ab41..e92c9ef3a 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -242,6 +242,12 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option if let Some(v) = str_p(p, "apvProfile") { settings.apv_profile = v.to_string(); } + if let Some(v) = str_p(p, "audioCodec") { + settings.audio.codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "audioCodec: auto | aac | pcm | flac"))?; + } + if let Some(v) = u64_p(p, "flacLevel") { + settings.audio.flac_level = v.min(8) as u8; + } if let Some(v) = str_p(p, "mxfVideoCodec") { settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?; } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index d058e4e60..3732c7fb3 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -25,6 +25,7 @@ filmcraft-dnx = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-mxf = { workspace = true } filmcraft-aac = { workspace = true } +filmcraft-flac = { workspace = true } filmcraft-h264enc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 88864eaf3..e5e6007c4 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -257,6 +257,7 @@ impl Exporter { audio_factories().read().unwrap_or_else(|e| e.into_inner()).iter().find_map(|f| f(settings.format, a.sr, a.channels as u32, &settings)); Some(found.ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??) } + (Some(a), AudioCodec::Flac) => Some(Box::new(crate::FlacAudio::new(a.sr, a.channels as u32, &settings)?)), _ => None, }; let measured = !settings.effects.loudness.enabled || audio.is_none(); @@ -484,11 +485,12 @@ impl Exporter { self.write_video(tail)?; self.write_audio(mixed)?; if let (Some(at), Some(a)) = (self.at, self.aenc.as_mut()) { - let fs = a.frame_size(); let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?; - for au in a.flush()? { - mux.write_sample(at, WriteSample { data: &au, duration: fs, composition_offset: 0, is_sync: true }) - .map_err(|e| ExportError::Io(e.to_string()))?; + for (au, duration) in a.flush_timed()? { + mux.write_sample(at, WriteSample { data: &au, duration, composition_offset: 0, is_sync: true }).map_err(|e| ExportError::Io(e.to_string()))?; + } + if let Some(entry) = a.final_sample_entry() { + mux.set_sample_entry(at, entry).map_err(|e| ExportError::Io(e.to_string()))?; } } let (bytes, extra_files) = match self.mxf.take() { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 2821b57b3..29e76d6bb 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -98,6 +98,9 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// FLAC (RFC 9639): lossless compressed audio. + #[serde(rename = "flac", alias = "Flac")] + Flac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -124,6 +127,7 @@ impl Format { "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, + "flac" | "fla" => Format::Flac, _ => return None, }) } @@ -142,6 +146,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::Flac => "flac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -156,6 +161,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::Flac => "flac", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -173,10 +179,15 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::Flac => "FLAC", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } } + /// Audio only: WAV, AIFF or FLAC. + pub fn is_audio_only(self) -> bool { + matches!(self, Format::Wav | Format::Aiff | Format::Flac) + } /// An MXF container format. pub fn is_mxf(self) -> bool { matches!(self, Format::MxfOp1a | Format::MxfOpAtom) @@ -190,7 +201,7 @@ impl Format { pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) } - pub const ALL: [Format; 14] = [ + pub const ALL: [Format; 15] = [ Format::H264, Format::Hevc, Format::ProRes, @@ -203,6 +214,7 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::Flac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -319,6 +331,12 @@ impl std::fmt::Debug for OutputSink { } } +/// How an audio-only export codes its samples. +enum AudioWriter { + Pcm(pcm::PcmContainer), + Flac(Box), +} + /// An export's output file: on disk, or in memory for an [`OutputSink`]. enum Out { File(std::io::BufWriter), @@ -623,6 +641,17 @@ pub trait AudioEncoder: Send { fn priming(&self) -> u32; /// Samples per access unit (1024 for AAC). fn frame_size(&self) -> u32; + /// [`AudioEncoder::flush`] with each access unit's duration, for codecs whose last unit is + /// shorter than [`AudioEncoder::frame_size`] (FLAC); by default every unit is a full frame. + fn flush_timed(&mut self) -> Result, u32)>> { + let n = self.frame_size(); + Ok(self.flush()?.into_iter().map(|au| (au, n)).collect()) + } + /// The sample entry once everything is encoded, when it differs from the first one (the FLAC + /// STREAMINFO learns its frame sizes and MD5 at the end). + fn final_sample_entry(&self) -> Option { + None + } } pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option>>; @@ -872,6 +901,53 @@ impl AudioEncoder for AacEncoder { } } +/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit. +pub(crate) struct FlacAudio { + enc: filmcraft_flac::Encoder, + bits: u16, + /// Samples of the frame `flush` returns (shorter than a full block). + tail: u32, +} + +impl FlacAudio { + pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result { + let bits = if s.audio.bits >= 24 { 24 } else { 16 }; + let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?; + let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8); + cfg.level = s.audio.flac_level.min(8); + let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?; + Ok(FlacAudio { enc, bits, tail: 0 }) + } +} + +impl AudioEncoder for FlacAudio { + fn sample_entry(&self) -> SampleEntry { + SampleEntry::flac(&self.enc.streaminfo()) + } + fn encode(&mut self, planar: &[Vec]) -> Result>> { + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect(); + let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); + self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}"))) + } + fn flush(&mut self) -> Result>> { + Ok(self.flush_timed()?.into_iter().map(|(au, _)| au).collect()) + } + fn flush_timed(&mut self) -> Result, u32)>> { + self.tail = self.enc.pending_samples() as u32; + let last = self.enc.finish(); + Ok(if last.is_empty() { Vec::new() } else { vec![(last, self.tail)] }) + } + fn priming(&self) -> u32 { + 0 + } + fn frame_size(&self) -> u32 { + 4096 + } + fn final_sample_entry(&self) -> Option { + Some(SampleEntry::flac(&self.enc.streaminfo())) + } +} + fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option>> { // AAC caps a frame at 6144 bits per channel (ISO/IEC 14496-3 §4.5.3.2): 6 bits per sample, so // 264.6 kbps for stereo at 22.05 kHz; a higher setting is capped instead of refused @@ -1473,13 +1549,24 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { - Format::Wav | Format::Aiff => { + Format::Wav | Format::Aiff | Format::Flac => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); - let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff }; let frames = a.remaining(); // the sizes are known up front: refuse an AIFF that cannot hold them before measuring - let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?; + let mut writer = match settings.format { + Format::Flac => { + let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 }); + cfg.level = settings.audio.flac_level.min(8); + AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?)) + } + Format::Wav => AudioWriter::Pcm(pcm::PcmContainer::Wav), + _ => AudioWriter::Pcm(pcm::PcmContainer::Aiff), + }; + let head = match &writer { + AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?, + AudioWriter::Flac(enc) => enc.header(), + }; // one second per chunk: memory stays flat however long the range is let chunk = i64::from(sr.max(1)); if !settings.part_of_batch { @@ -1497,13 +1584,30 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so return Err(ExportError::Cancelled); } buf.clear(); - pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf); + match &mut writer { + AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf), + AudioWriter::Flac(enc) => { + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect(); + let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); + buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?; + } + } out.write_all(&buf).map_err(io)?; if !settings.part_of_batch { progress.done.fetch_add(1, Ordering::Relaxed); } } - out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?; + match &mut writer { + AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?, + AudioWriter::Flac(enc) => { + out.write_all(&enc.finish()).map_err(io)?; + // the frame sizes and MD5 are known now: fill them into STREAMINFO + use std::io::{Seek, SeekFrom}; + out.seek(SeekFrom::Start(filmcraft_flac::STREAMINFO_OFFSET)).map_err(io)?; + out.write_all(&enc.streaminfo()).map_err(io)?; + out.seek(SeekFrom::End(0)).map_err(io)?; + } + } let n = out.finish(settings)?; if settings.part_of_batch { progress.done.store(1, Ordering::Relaxed); diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 03b88a74d..7601886f0 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -13,7 +13,7 @@ pub enum PcmContainer { } /// Quantise one sample to a signed integer of `bits` (16 or 24). -fn quantise(s: f32, bits: u16) -> i32 { +pub(crate) fn quantise(s: f32, bits: u16) -> i32 { let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 }; (s.clamp(-1.0, 1.0) * max).round() as i32 } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 3aa37d056..12ee53ad6 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -41,7 +41,7 @@ fn h264(size: Option<(u32, u32)>, kbps: u32, max_kbps: u32) -> ExportSettings { bitrate_kbps: kbps, max_bitrate_kbps: Some(max_kbps), bitrate_mode: BitrateMode::Vbr1Pass, - audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16, ..Default::default() }, ..Default::default() } } @@ -53,7 +53,7 @@ fn adaptive(bpp: f32) -> ExportSettings { fn mov(format: Format, profile: &str) -> ExportSettings { let mut s = ExportSettings { format, - audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24 }, + audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24, ..Default::default() }, render_at_max_depth: true, ..Default::default() }; @@ -82,7 +82,7 @@ fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings { fn audio_only(format: Format, bits: u16) -> ExportSettings { ExportSettings { format, - audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits }, + audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits, ..Default::default() }, ..Default::default() } } @@ -153,6 +153,7 @@ pub fn builtin_presets() -> Vec { p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)), p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)), p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)), + p("FLAC 48 kHz 24-bit", "Audio Only", "FLAC, stereo 48 kHz 24-bit, lossless", audio_only(Format::Flac, 24)), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index dad3b0e45..e841f645a 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -140,7 +140,8 @@ impl MxfVideoCodec { } } -/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF, FLAC in FLAC. MP4 and +/// QuickTime can also carry FLAC; MXF is always PCM. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -148,6 +149,8 @@ pub enum AudioCodec { Auto, Aac, Pcm, + /// Lossless compression (the FLAC format). + Flac, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -160,13 +163,15 @@ pub struct AudioSettings { pub channels: u32, /// AAC bitrate. pub bitrate_kbps: u32, - /// PCM sample size: 16 or 24. + /// PCM and FLAC sample size: 16 or 24. pub bits: u16, + /// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless. + pub flac_level: u8, } impl Default for AudioSettings { fn default() -> Self { - AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16 } + AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 } } } @@ -405,7 +410,10 @@ impl ExportSettings { /// The audio codec actually used. pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { + (Format::Flac, _) => AudioCodec::Flac, (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, + // MP4 and QuickTime both carry FLAC (`fLaC` + `dfLa`) + (_, AudioCodec::Flac) => AudioCodec::Flac, (f, _) if f.is_h26x() && self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_h26x() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -420,7 +428,7 @@ impl ExportSettings { /// Whether the format carries video. pub fn has_video(&self) -> bool { - !matches!(self.format, Format::Wav | Format::Aiff) + !self.format.is_audio_only() } /// File extension of the output. @@ -460,12 +468,15 @@ impl ExportSettings { Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, - Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, + Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; - let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio_bps = if self.has_audio() || self.format.is_audio_only() { + let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }; match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, - _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, + // typical of music and dialogue; noise compresses less, silence far more + AudioCodec::Flac => pcm * 0.6, + _ => pcm, } } else { 0.0 @@ -534,7 +545,7 @@ impl ExportSettings { } v }; - let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio = if self.has_audio() || self.format.is_audio_only() { let ch = match r.channels { 1 => "Mono", 6 => "5.1", @@ -542,6 +553,12 @@ impl ExportSettings { }; match self.audio_codec() { AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate), + AudioCodec::Flac => format!( + "FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", + if self.audio.bits >= 24 { 24 } else { 16 }, + self.audio.flac_level.min(8), + r.sample_rate + ), _ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate), } } else { diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index f08414847..4e86346a2 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -167,6 +167,126 @@ fn wav_and_aiff_audio_only() { } } +#[test] +fn flac_is_lossless_against_wav() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("flac"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2))); + let audio = AudioSettings { channels: 2, bits: 24, ..Default::default() }; + let (wav, flac) = (dir.path("a.wav"), dir.path("a.flac")); + let w = ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }; + export(&p, seq, &w, &m, &Progress::default()).unwrap(); + let f = ExportSettings { format: Format::Flac, path: flac.clone(), range, audio, ..Default::default() }; + let prog = Progress::default(); + let r = export(&p, seq, &f, &m, &prog).unwrap(); + assert_eq!(r.frames, 72_000, "1.5 s at 48 kHz"); + // one progress step per second of audio + assert_eq!((prog.total.load(Ordering::Relaxed), prog.done.load(Ordering::Relaxed)), (2, 2)); + let (wb, fb) = (std::fs::read(&wav).unwrap(), std::fs::read(&flac).unwrap()); + assert_eq!(&fb[..4], b"fLaC"); + assert!(fb.len() * 2 < wb.len(), "a tone compresses: FLAC {} bytes, WAV {}", fb.len(), wb.len()); + // STREAMINFO was filled in at the end: total samples + let si = &fb[8..42]; + assert_eq!((u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes(si[14..18].try_into().unwrap())), 72_000); + + // the same samples through FilmCraft's own readers + let decode = |name: &str, bytes: Vec| filmcraft_codecs::open_bytes(name, bytes.into()).unwrap().audio(0, 72_000, 48_000).unwrap(); + let (a, b) = (decode("a.wav", wb), decode("a.flac", fb)); + assert_eq!(a.channels.len(), 2); + for (ca, cb) in a.channels.iter().zip(&b.channels) { + assert_eq!(ca.len(), cb.len()); + let worst = ca.iter().zip(cb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "differs by {worst}"); + assert!(ca.iter().any(|v| v.abs() > 0.1), "the tone is there"); + } + // and bit for bit through ffmpeg + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac export") { + let raw = |path: &str| { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-i", path, "-f", "s32le", "-c:a", "pcm_s32le", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr)); + out.stdout + }; + let (a, b) = (raw(&wav), raw(&flac)); + assert_eq!(a.len(), 72_000 * 2 * 4); + assert!(a == b, "FLAC must decode to the WAV's samples exactly"); + } + if let Some(j) = ffprobe_json(&["-show_streams"], &flac) { + let st = &j["streams"][0]; + assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["bits_per_raw_sample"].as_str()), (Some("flac"), Some("48000"), Some("24"))); + } +} + +#[test] +fn flac_audio_in_mp4_and_mov() { + let (p, seq, m) = matte([0.2, 0.4, 0.6, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("flac-in"); + // 1.5 s at 48 kHz: 17 full FLAC frames of 4096 samples and a last one of 2368 + let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2))); + let audio = AudioSettings { codec: AudioCodec::Flac, bits: 24, ..Default::default() }; + let wav = dir.path("ref.wav"); + export(&p, seq, &ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }, &m, &Progress::default()) + .unwrap(); + for (format, mux, name) in + [(Format::H264, Multiplexer::Mp4, "a.mp4"), (Format::H264, Multiplexer::Mov, "b.mov"), (Format::ProRes, Multiplexer::Mp4, "c.mov")] + { + let path = dir.path(name); + let s = ExportSettings { format, multiplexer: mux, path: path.clone(), range, audio: audio.clone(), ..Default::default() }; + assert_eq!(s.audio_codec(), AudioCodec::Flac, "{name}"); + assert!(s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio.starts_with("FLAC (lossless) 24-bit"), "{name}"); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + // `dfLa` was rewritten at the end: STREAMINFO with frame sizes, total samples and the MD5 + let at = bytes.windows(4).position(|w| w == b"dfLa").unwrap() + 4 + 4 + 4; + let si = &bytes[at..at + 34]; + assert_eq!(bytes[at - 4], 0x80, "{name}: STREAMINFO is the last metadata block"); + assert!(si[4..10].iter().any(|&b| b != 0), "{name}: frame sizes"); + assert_eq!(u32::from_be_bytes(si[14..18].try_into().unwrap()), 72_000, "{name}: total samples"); + assert!(si[18..34].iter().any(|&b| b != 0), "{name}: MD5"); + // FilmCraft's own importer reads the FLAC track back, sample-accurately long + let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap(); + let a = src.audio(0, 72_000, 48_000).unwrap(); + assert!(a.channels.iter().all(|c| c.len() == 72_000) && a.channels[0].iter().any(|v| v.abs() > 0.1), "{name}"); + if let Some(j) = ffprobe_json(&["-show_streams", "-count_packets"], &path) { + let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone(); + assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["channels"].as_u64()), (Some("flac"), Some("48000"), Some(2)), "{name}"); + assert_eq!(st["nb_read_packets"].as_str(), Some("18"), "{name}: one FLAC frame per packet"); + assert_eq!(st["duration_ts"].as_u64(), Some(72_000), "{name}: the short last frame is timed exactly"); + } + // the audio decodes to the WAV export's samples bit for bit + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac in containers") { + let raw = |path: &str| { + let out = std::process::Command::new(&ffmpeg) + .args(["-v", "error", "-i", path, "-map", "0:a", "-f", "s32le", "-c:a", "pcm_s32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout + }; + assert!(raw(&path) == raw(&wav), "{name}: FLAC must decode to the WAV's samples exactly"); + } + } + // MXF stays PCM whatever is asked for + let mxf = ExportSettings { format: Format::MxfOp1a, audio: audio.clone(), ..Default::default() }; + assert_eq!(mxf.audio_codec(), AudioCodec::Pcm); +} + +#[test] +fn flac_settings_round_trip_and_summary() { + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "flac"})).unwrap(); + assert_eq!((s.format, s.audio_codec(), s.audio.flac_level, s.has_video()), (Format::Flac, AudioCodec::Flac, 5, false)); + assert_eq!((s.extension(), Format::from_name("FLAC"), Format::Flac.id()), ("flac", Some(Format::Flac), "flac")); + // settings saved before FLAC read the default level + let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap(); + assert_eq!(old.flac_level, 5); + let sum = ExportSettings { format: Format::Flac, ..Default::default() }.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + assert!(sum.audio.starts_with("FLAC (lossless) 16-bit, level 5, 48000 Hz"), "{}", sum.audio); + assert_eq!(sum.video, "No video"); + // estimated below PCM + let pcm = ExportSettings { format: Format::Wav, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + let flac = ExportSettings { format: Format::Flac, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + assert!(flac < pcm, "{flac} < {pcm}"); +} + #[test] fn invalid_export_allocations_are_rejected_before_rendering() { let (project, sequence, _) = matte([1.0, 0.0, 0.0, 1.0], 64, 36, None); @@ -496,7 +616,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 14); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/flac/Cargo.toml b/crates/flac/Cargo.toml new file mode 100644 index 000000000..8d6d05095 --- /dev/null +++ b/crates/flac/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filmcraft-flac" +description = "Clean-room FLAC encoder (RFC 9639)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/flac/README.md b/crates/flac/README.md new file mode 100644 index 000000000..3909ab793 --- /dev/null +++ b/crates/flac/README.md @@ -0,0 +1,62 @@ +# filmcraft-flac + +A clean-room FLAC encoder (Free Lossless Audio Codec, [RFC 9639](https://www.rfc-editor.org/rfc/rfc9639)), +written from the RFC. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`, has no +`unsafe`, and builds for `wasm32-unknown-unknown`. + +No libFLAC, FFmpeg or other FLAC implementation was read or ported. ffmpeg is used only as an +external **test oracle** (decoding our streams). Decoding FLAC in FilmCraft is still done by the +symphonia bootstrap decoder in `filmcraft-codecs`. + +## API + +```rust +use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; + +let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24) +cfg.level = 8; // 0 fastest … 8 smallest; 5 by default +let mut enc = Encoder::new(cfg)?; +let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor) +file.extend(enc.encode(&[&left, &right])?); // planar i32 samples, any chunk size → whole frames +file.extend(enc.finish()); // the last, shorter frame +// frame sizes, total samples and MD5 are only known now: patch STREAMINFO (or seek back in a file) +let at = STREAMINFO_OFFSET as usize; +file[at..at + 34].copy_from_slice(&enc.streaminfo()); + +// MP4 / QuickTime (`fLaC` + `dfLa`): one frame per sample, the STREAMINFO body in `dfLa` +let frames: Vec> = enc.encode_frames(&[&left, &right])?; // each a full block +let last_len = enc.pending_samples(); // duration of the frame finish() writes +``` + +## What it writes + +| Part | | +|---|---| +| Metadata | STREAMINFO (block size, frame sizes, rate, channels, sample size, total samples, MD5 of the audio) and a VORBIS_COMMENT with the vendor string | +| Frames | fixed block size (4096 by default), frame numbers, sample rate and size codes, CRC-8 header and CRC-16 frame checksums | +| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest | +| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback | +| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe | +| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest | + +Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples, +seek tables. + +| Level | LPC order | Rice partition order | Stereo decorrelation | +|---|---|---|---| +| 0 | fixed only | ≤ 3 | no | +| 1–2 | fixed only | ≤ 3 | yes | +| 3 | ≤ 6 | ≤ 4 | no | +| 4 | ≤ 8 | ≤ 4 | yes | +| 5 (default) | ≤ 8 | ≤ 5 | yes | +| 6 | ≤ 8 | ≤ 6 | yes | +| 7–8 | ≤ 12 | ≤ 6 | yes | + +## Tests + +`cargo test -p filmcraft-flac`: CRC check values, the RFC 1321 MD5 suite, frame-number coding, +predictor round trips, and `tests/ffmpeg_oracle.rs`, which decodes every stream with ffmpeg and +requires every sample back exactly, no decoder errors, and the STREAMINFO MD5 equal to ffmpeg's +hash of the decoded audio. It covers music-like, sweep, noise, full-scale square, silence and click +signals; 8/16/20/24-bit; mono, stereo and 5.1; common and odd sample rates; block sizes 16 to 65535; +and every level. Skipped (with a `SKIPPED` line) without ffmpeg. diff --git a/crates/flac/src/crc.rs b/crates/flac/src/crc.rs new file mode 100644 index 000000000..0cc4869b5 --- /dev/null +++ b/crates/flac/src/crc.rs @@ -0,0 +1,57 @@ +//! The two frame checksums of RFC 9639 §9.1.8 and §9.3: CRC-8 (polynomial x⁸+x²+x+1) over the +//! frame header and CRC-16 (x¹⁶+x¹⁵+x²+1) over the whole frame, both MSB-first with a zero start. + +const fn table8() -> [u8; 256] { + let mut t = [0u8; 256]; + let mut i = 0; + while i < 256 { + let mut c = i as u8; + let mut b = 0; + while b < 8 { + c = if c & 0x80 != 0 { (c << 1) ^ 0x07 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +const fn table16() -> [u16; 256] { + let mut t = [0u16; 256]; + let mut i = 0; + while i < 256 { + let mut c = (i as u16) << 8; + let mut b = 0; + while b < 8 { + c = if c & 0x8000 != 0 { (c << 1) ^ 0x8005 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +static T8: [u8; 256] = table8(); +static T16: [u16; 256] = table16(); + +pub fn crc8(data: &[u8]) -> u8 { + data.iter().fold(0u8, |c, &b| T8[usize::from(c ^ b)]) +} + +pub fn crc16(data: &[u8]) -> u16 { + data.iter().fold(0u16, |c, &b| (c << 8) ^ T16[usize::from((c >> 8) as u8 ^ b)]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_values() { + // the standard check value of each polynomial over "123456789" + assert_eq!(crc8(b"123456789"), 0xF4); + assert_eq!(crc16(b"123456789"), 0xFEE8); + } +} diff --git a/crates/flac/src/frame.rs b/crates/flac/src/frame.rs new file mode 100644 index 000000000..6ccd8a50f --- /dev/null +++ b/crates/flac/src/frame.rs @@ -0,0 +1,203 @@ +//! Writing one frame (RFC 9639 §9): header with CRC-8, one subframe per channel, zero padding to a +//! byte, CRC-16. + +use filmcraft_bitstream::BitWriter; + +use crate::crc::{crc8, crc16}; +use crate::predict::{Residual, Subframe}; + +/// Inter-channel decorrelation of a stereo frame (§9.1.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stereo { + Independent, + /// Left, then side (left − right). + LeftSide, + /// Side, then right. + SideRight, + /// Mid ((left + right) >> 1), then side. + MidSide, +} + +/// Frame header block size bits: 4096 has its own code, others are stored after the header. +fn block_size_code(n: u16) -> (u8, Option<(u32, u32)>) { + match n { + 4096 => (0b1100, None), + 1..=256 => (0b0110, Some((u32::from(n - 1), 8))), + _ => (0b0111, Some((u32::from(n.saturating_sub(1)), 16))), + } +} + +/// Frame header sample rate code; 0 refers to STREAMINFO. +fn sample_rate_code(rate: u32) -> u8 { + match rate { + 88_200 => 1, + 176_400 => 2, + 192_000 => 3, + 8_000 => 4, + 16_000 => 5, + 22_050 => 6, + 24_000 => 7, + 32_000 => 8, + 44_100 => 9, + 48_000 => 10, + 96_000 => 11, + _ => 0, + } +} + +/// Frame header sample size code; 0 refers to STREAMINFO. +fn sample_size_code(bps: u8) -> u8 { + match bps { + 8 => 1, + 12 => 2, + 16 => 4, + 20 => 5, + 24 => 6, + _ => 0, + } +} + +/// The frame number in the UTF-8-like coding of §9.1.5 (up to 36 bits). +fn coded_number(v: u64, out: &mut Vec) { + if v < 0x80 { + out.push(v as u8); + return; + } + let tail = match v { + 0..0x800 => 1, + 0x800..0x1_0000 => 2, + 0x1_0000..0x20_0000 => 3, + 0x20_0000..0x400_0000 => 4, + 0x400_0000..0x8000_0000 => 5, + _ => 6, + }; + let lead_mask: u8 = !(0xFFu8 >> (tail + 1)); + out.push(lead_mask | (v >> (6 * tail)) as u8 & (0x7F >> (tail + 1))); + for i in (0..tail).rev() { + out.push(0x80 | ((v >> (6 * i)) & 0x3F) as u8); + } +} + +pub struct FrameSpec<'a> { + pub number: u64, + pub sample_rate: u32, + pub bps: u8, + pub stereo: Option, + /// The signals as coded (mid/side already formed), with their subframes. + pub channels: &'a [(&'a [i32], Subframe, u8)], +} + +fn write_residual(w: &mut BitWriter, residual: &Residual, r: &[i64], block: usize, order: usize) { + let wide = residual.wide(); + w.write_bits(u32::from(wide), 2); + w.write_bits(u32::from(residual.partition_order), 4); + let parts = 1usize << residual.partition_order; + let len = block / parts; + let mut start = 0usize; + for (p, &k) in residual.params.iter().enumerate() { + w.write_bits(u32::from(k), if wide { 5 } else { 4 }); + let n = if p == 0 { len.saturating_sub(order) } else { len }; + for &v in r.get(start..start + n).unwrap_or_default() { + let u = if v >= 0 { (v as u64) << 1 } else { ((-v - 1) as u64) << 1 | 1 }; + let mut q = u >> k; + while q >= 32 { + w.write_bits(0, 32); + q -= 32; + } + w.write_bits(1, q as u32 + 1); + w.write_bits((u & ((1u64 << k) - 1)) as u32, u32::from(k)); + } + start += n; + } +} + +fn write_subframe(w: &mut BitWriter, x: &[i32], sub: &Subframe, bps: u8) { + let b = u32::from(bps); + let sample = |w: &mut BitWriter, v: i32| w.write_bits(v as u32, b); + w.write_bit(false); + match sub { + Subframe::Constant(v) => { + w.write_bits(0, 6); + w.write_bit(false); + sample(w, *v); + } + Subframe::Verbatim => { + w.write_bits(1, 6); + w.write_bit(false); + x.iter().for_each(|&v| sample(w, v)); + } + Subframe::Fixed { order, residual } => { + w.write_bits(0b001000 | u32::from(*order), 6); + w.write_bit(false); + let o = usize::from(*order); + x.iter().take(o).for_each(|&v| sample(w, v)); + let r = crate::predict::fixed_residual(x, o).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + Subframe::Lpc { coefs, precision, shift, residual } => { + let o = coefs.len(); + w.write_bits(0b100000 | (o as u32 - 1), 6); + w.write_bit(false); + x.iter().take(o).for_each(|&v| sample(w, v)); + w.write_bits(u32::from(*precision) - 1, 4); + w.write_bits(u32::from(*shift), 5); + coefs.iter().for_each(|&c| w.write_bits(c as u32, u32::from(*precision))); + let r = crate::predict::lpc_residual(x, coefs, *shift).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + } +} + +/// One complete frame. +pub fn write(spec: &FrameSpec) -> Vec { + let block = spec.channels.first().map_or(0, |c| c.0.len()); + let (bs_code, bs_extra) = block_size_code(u16::try_from(block).unwrap_or(u16::MAX)); + let channel_code = match spec.stereo { + Some(Stereo::LeftSide) => 8, + Some(Stereo::SideRight) => 9, + Some(Stereo::MidSide) => 10, + _ => spec.channels.len().saturating_sub(1) as u8, + }; + let mut h = vec![0xFF, 0xF8, bs_code << 4 | sample_rate_code(spec.sample_rate), channel_code << 4 | sample_size_code(spec.bps) << 1]; + coded_number(spec.number, &mut h); + if let Some((v, n)) = bs_extra { + if n == 8 { + h.push(v as u8); + } else { + h.extend_from_slice(&(v as u16).to_be_bytes()); + } + } + h.push(crc8(&h)); + let mut w = BitWriter::new(); + w.write_bytes(&h); + for (x, sub, bps) in spec.channels { + write_subframe(&mut w, x, sub, *bps); + } + let mut out = w.finish(); + let crc = crc16(&out); + out.extend_from_slice(&crc.to_be_bytes()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coded_numbers() { + let code = |v| { + let mut o = Vec::new(); + coded_number(v, &mut o); + o + }; + assert_eq!(code(0), [0]); + assert_eq!(code(0x7F), [0x7F]); + assert_eq!(code(0x80), [0xC2, 0x80]); + assert_eq!(code(0x7FF), [0xDF, 0xBF]); + assert_eq!(code(0x800), [0xE0, 0xA0, 0x80]); + assert_eq!(code(0xFFFF), [0xEF, 0xBF, 0xBF]); + assert_eq!(code(0x1_0000), [0xF0, 0x90, 0x80, 0x80]); + assert_eq!(code(0x7FFF_FFFF), [0xFD, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + assert_eq!(code(0xF_FFFF_FFFF), [0xFE, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + } +} diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs new file mode 100644 index 000000000..5e278365b --- /dev/null +++ b/crates/flac/src/lib.rs @@ -0,0 +1,247 @@ +//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec). +//! +//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the +//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of +//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed +//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes; +//! stereo frames also try left/side, side/right and mid/side. +//! - The STREAMINFO sizes and MD5 are only known at the end: write the header first, then overwrite +//! [`STREAMINFO_OFFSET`] with [`Encoder::streaminfo`] once [`Encoder::finish`] has run (or keep +//! the placeholder, whose zeros mean "unknown" and decode fine). +//! +//! Lossless at every compression level; the level (0–8) only trades encoding time for size. +//! +//! Layer L0: depends only on `filmcraft-bitstream` (+ `thiserror`); no `unsafe`; builds for +//! `wasm32-unknown-unknown`. +//! +//! ``` +//! use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; +//! +//! let left: Vec = (0..48_000).map(|i| ((i as f64 * 0.05).sin() * 20_000.0) as i32).collect(); +//! let right = left.clone(); +//! let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16))?; +//! let mut file = enc.header(); +//! file.extend(enc.encode(&[&left, &right])?); +//! file.extend(enc.finish()); +//! let at = STREAMINFO_OFFSET as usize; +//! file[at..at + 34].copy_from_slice(&enc.streaminfo()); +//! assert_eq!(&file[..4], b"fLaC"); +//! assert!(file.len() < 48_000 * 2 * 2 / 2); // well under half of 16-bit PCM +//! # Ok::<(), filmcraft_flac::Error>(()) +//! ``` + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod crc; +mod frame; +mod md5; +mod predict; + +use frame::{FrameSpec, Stereo}; +use predict::{Effort, Subframe}; + +/// Byte offset of the 34-byte STREAMINFO body in [`Encoder::header`]. +pub const STREAMINFO_OFFSET: u64 = 8; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid configuration: {0}")] + InvalidConfig(&'static str), + #[error("expected {expected} channels, got {got}")] + Channels { expected: usize, got: usize }, + #[error("channels of unequal length")] + UnequalChannels, + #[error("sample {0} does not fit the configured sample size")] + SampleRange(i32), +} + +#[derive(Clone, Debug)] +pub struct EncoderConfig { + /// 1 to 1 048 575 Hz. + pub sample_rate: u32, + /// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1). + pub channels: u8, + /// 8–24. + pub bits_per_sample: u8, + /// Samples per channel per frame, 16–65535 (4096 by default). + pub block_size: u16, + /// 0 (fastest) to 8 (smallest); 5 by default. + pub level: u8, +} + +impl EncoderConfig { + pub fn new(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Self { + EncoderConfig { sample_rate, channels, bits_per_sample, block_size: 4096, level: 5 } + } +} + +pub struct Encoder { + cfg: EncoderConfig, + effort: Effort, + /// Samples waiting for a full block, per channel. + pending: Vec>, + frame_number: u64, + total_samples: u64, + min_frame: u32, + max_frame: u32, + md5: md5::Md5, +} + +impl Encoder { + pub fn new(cfg: EncoderConfig) -> Result { + if !(1..=8).contains(&cfg.channels) { + return Err(Error::InvalidConfig("1 to 8 channels")); + } + if !(8..=24).contains(&cfg.bits_per_sample) { + return Err(Error::InvalidConfig("8 to 24 bits per sample")); + } + if !(1..=1_048_575).contains(&cfg.sample_rate) { + return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz")); + } + if cfg.block_size < 16 { + return Err(Error::InvalidConfig("block size 16 to 65535")); + } + let effort = Effort::level(cfg.level); + Ok(Encoder { + pending: vec![Vec::new(); usize::from(cfg.channels)], + cfg, + effort, + frame_number: 0, + total_samples: 0, + min_frame: 0, + max_frame: 0, + md5: md5::Md5::default(), + }) + } + + /// `fLaC`, the STREAMINFO block (sizes and MD5 unknown yet: see [`Encoder::streaminfo`]) and a + /// VORBIS_COMMENT block with the vendor string. + pub fn header(&self) -> Vec { + let mut v = b"fLaC".to_vec(); + v.extend_from_slice(&[0, 0, 0, 34]); + v.extend_from_slice(&self.streaminfo_with(0, 0, 0, [0; 16])); + let vendor = concat!("filmcraft-flac ", env!("CARGO_PKG_VERSION")); + let len = 4 + vendor.len() + 4; + // last metadata block, type 4 (VORBIS_COMMENT), little-endian lengths inside + v.push(0x84); + v.extend_from_slice(&(len as u32).to_be_bytes()[1..]); + v.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + v.extend_from_slice(vendor.as_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v + } + + /// The final STREAMINFO body: frame sizes, total samples and MD5 of what has been encoded. + pub fn streaminfo(&self) -> [u8; 34] { + self.streaminfo_with(self.min_frame, self.max_frame, self.total_samples, self.md5.digest()) + } + + fn streaminfo_with(&self, min_frame: u32, max_frame: u32, total: u64, md5: [u8; 16]) -> [u8; 34] { + let mut w = filmcraft_bitstream::BitWriter::new(); + let bs = u32::from(self.cfg.block_size); + w.write_bits(bs, 16); + w.write_bits(bs, 16); + w.write_bits(min_frame.min(0xFF_FFFF), 24); + w.write_bits(max_frame.min(0xFF_FFFF), 24); + w.write_bits(self.cfg.sample_rate, 20); + w.write_bits(u32::from(self.cfg.channels) - 1, 3); + w.write_bits(u32::from(self.cfg.bits_per_sample) - 1, 5); + // 36 bits; 0 means unknown, and a count that does not fit is left unknown + let total = if total >> 36 == 0 { total } else { 0 }; + w.write_bits((total >> 32) as u32, 4); + w.write_bits(total as u32, 32); + w.write_bytes(&md5); + let mut out = [0u8; 34]; + out.copy_from_slice(w.bytes().get(..34).unwrap_or(&[0; 34])); + out + } + + /// Add samples (one slice per channel, equal lengths); returns the frames completed. + pub fn encode(&mut self, planar: &[&[i32]]) -> Result, Error> { + Ok(self.encode_frames(planar)?.concat()) + } + + /// [`Encoder::encode`] with each completed frame on its own (one MP4 / QuickTime sample each). + pub fn encode_frames(&mut self, planar: &[&[i32]]) -> Result>, Error> { + if planar.len() != self.pending.len() { + return Err(Error::Channels { expected: self.pending.len(), got: planar.len() }); + } + let n = planar.first().map_or(0, |c| c.len()); + if planar.iter().any(|c| c.len() != n) { + return Err(Error::UnequalChannels); + } + let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1; + if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) { + return Err(Error::SampleRange(bad)); + } + for (p, c) in self.pending.iter_mut().zip(planar) { + p.extend_from_slice(c); + } + let bs = usize::from(self.cfg.block_size); + let mut out = Vec::new(); + while self.pending.first().is_some_and(|p| p.len() >= bs) { + let block: Vec> = self.pending.iter_mut().map(|p| p.drain(..bs).collect()).collect(); + out.push(self.frame(&block)); + } + Ok(out) + } + + /// Samples per channel waiting for a full block: the length of the frame [`Encoder::finish`] + /// would write. + pub fn pending_samples(&self) -> usize { + self.pending.first().map_or(0, Vec::len) + } + + /// Encode what is left as a final, shorter frame. + pub fn finish(&mut self) -> Vec { + if self.pending.first().is_none_or(Vec::is_empty) { + return Vec::new(); + } + let block: Vec> = self.pending.iter_mut().map(std::mem::take).collect(); + self.frame(&block) + } + + fn frame(&mut self, block: &[Vec]) -> Vec { + let n = block.first().map_or(0, Vec::len); + let bps = self.cfg.bits_per_sample; + // the MD5 of the unencoded audio: interleaved, little-endian, whole bytes per sample + let bytes = usize::from(bps.div_ceil(8)); + let mut raw = Vec::with_capacity(n * block.len() * bytes); + for i in 0..n { + for c in block { + raw.extend_from_slice(c.get(i).copied().unwrap_or(0).to_le_bytes().get(..bytes).unwrap_or_default()); + } + } + self.md5.update(&raw); + + let effort = self.effort; + let code = |x: &[i32], b: u8| predict::choose(x, b, effort); + let data = match block { + [l, r] if effort.stereo => { + let side: Vec = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); + let mid: Vec = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect(); + let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps)); + let options = + [(Stereo::Independent, cl.1 + cr.1), (Stereo::LeftSide, cl.1 + cs.1), (Stereo::SideRight, cs.1 + cr.1), (Stereo::MidSide, cm.1 + cs.1)]; + let best = options.iter().min_by_key(|o| o.1).map_or(Stereo::Independent, |o| o.0); + let channels: Vec<(&[i32], Subframe, u8)> = match best { + Stereo::Independent => vec![(l, cl.0, bps), (r, cr.0, bps)], + Stereo::LeftSide => vec![(l, cl.0, bps), (&side, cs.0, bps + 1)], + Stereo::SideRight => vec![(&side, cs.0, bps + 1), (r, cr.0, bps)], + Stereo::MidSide => vec![(&mid, cm.0, bps), (&side, cs.0, bps + 1)], + }; + frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: Some(best), channels: &channels }) + } + _ => { + let channels: Vec<(&[i32], Subframe, u8)> = block.iter().map(|c| (c.as_slice(), code(c, bps).0, bps)).collect(); + frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: None, channels: &channels }) + } + }; + self.frame_number += 1; + self.total_samples += n as u64; + let size = u32::try_from(data.len()).unwrap_or(u32::MAX); + self.min_frame = if self.min_frame == 0 { size } else { self.min_frame.min(size) }; + self.max_frame = self.max_frame.max(size); + data + } +} diff --git a/crates/flac/src/md5.rs b/crates/flac/src/md5.rs new file mode 100644 index 000000000..ca99c9e32 --- /dev/null +++ b/crates/flac/src/md5.rs @@ -0,0 +1,118 @@ +//! MD5 (RFC 1321), for the STREAMINFO signature of the unencoded audio (RFC 9639 §8.2): every +//! sample, interleaved, little-endian, in the fewest whole bytes that hold the sample size. + +const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, + 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +/// `floor(abs(sin(i + 1)) × 2³²)`, RFC 1321 §3.4. +const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, + 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, + 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, + 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, + 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, +]; + +#[derive(Clone)] +pub struct Md5 { + state: [u32; 4], + block: [u8; 64], + filled: usize, + len: u64, +} + +impl Default for Md5 { + fn default() -> Self { + Md5 { state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476], block: [0; 64], filled: 0, len: 0 } + } +} + +impl Md5 { + pub fn update(&mut self, mut data: &[u8]) { + self.len = self.len.wrapping_add(data.len() as u64); + while !data.is_empty() { + let take = (64 - self.filled).min(data.len()); + let (head, rest) = data.split_at(take); + if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) { + dst.copy_from_slice(head); + } + self.filled += take; + data = rest; + if self.filled == 64 { + let block = self.block; + self.compress(&block); + self.filled = 0; + } + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut m = [0u32; 16]; + for (w, c) in m.iter_mut().zip(block.as_chunks::<4>().0) { + *w = u32::from_le_bytes(*c); + } + let [mut a, mut b, mut c, mut d] = self.state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + for (s, v) in self.state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + + /// The digest so far (the state is not consumed: more data may follow). + pub fn digest(&self) -> [u8; 16] { + let mut h = self.clone(); + let bits = h.len.wrapping_mul(8); + h.update(&[0x80]); + while h.filled != 56 { + h.update(&[0]); + } + h.update(&bits.to_le_bytes()); + let mut out = [0u8; 16]; + for (o, s) in out.as_chunks_mut::<4>().0.iter_mut().zip(h.state) { + o.copy_from_slice(&s.to_le_bytes()); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(d: [u8; 16]) -> String { + d.iter().map(|b| format!("{b:02x}")).collect() + } + + #[test] + fn rfc_1321_test_suite() { + let md5 = |s: &[u8]| { + let mut m = Md5::default(); + m.update(s); + hex(m.digest()) + }; + assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!(md5(b"message digest"), "f96b697d7cb7938d525a2f31aaf161d0"); + assert_eq!(md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "57edf4a22be3c955ac49da2e2107b67a"); + // fed in pieces across block boundaries + let mut m = Md5::default(); + for c in b"12345678901234567890123456789012345678901234567890123456789012345678901234567890".chunks(7) { + m.update(c); + } + assert_eq!(hex(m.digest()), "57edf4a22be3c955ac49da2e2107b67a"); + } +} diff --git a/crates/flac/src/predict.rs b/crates/flac/src/predict.rs new file mode 100644 index 000000000..f5c681dee --- /dev/null +++ b/crates/flac/src/predict.rs @@ -0,0 +1,327 @@ +//! Subframe prediction (RFC 9639 §9.2.5–9.2.6) and residual coding (§9.2.7): the fixed polynomial +//! predictors, linear prediction from the windowed autocorrelation (Levinson–Durbin), and +//! partitioned Rice codes. Everything here only measures and chooses; [`crate::frame`] writes. + +/// Residuals larger than this are not Rice coded (a 5-bit parameter tops out at 30). +const MAX_RESIDUAL: i64 = (1 << 30) - 1; + +/// A residual coded with partitioned Rice codes. +#[derive(Clone, Debug, PartialEq)] +pub struct Residual { + /// Rice parameter per partition; `1 << order` partitions. + pub params: Vec, + pub partition_order: u8, + /// Total bits of the residual section, coding method included. + pub bits: u64, +} + +impl Residual { + /// The 5-bit parameter method is needed when any parameter passes 14 (15 is the escape code). + pub fn wide(&self) -> bool { + self.params.iter().any(|&k| k > 14) + } +} + +/// How one channel of one block is coded. +#[derive(Clone, Debug, PartialEq)] +pub enum Subframe { + Constant(i32), + Verbatim, + Fixed { order: u8, residual: Residual }, + Lpc { coefs: Vec, precision: u8, shift: u8, residual: Residual }, +} + +/// Folded residual: 0, -1, 1, -2, 2 … → 0, 1, 2, 3, 4 …. +fn fold(r: i64) -> u64 { + if r >= 0 { (r as u64) << 1 } else { ((-r - 1) as u64) << 1 | 1 } +} + +/// Bits of `n` folded values summing to `sum` with Rice parameter `k`: unary quotient, stop bit, +/// `k` low bits. The quotient total is approximated by `sum >> k`, exact up to the per-value +/// carries; [`rice_bits_exact`] gives the true cost for the chosen parameter. +fn rice_estimate(sum: u64, n: u64, k: u32) -> u64 { + (sum >> k) + n * (u64::from(k) + 1) +} + +fn rice_bits_exact(u: &[u64], k: u32) -> u64 { + u.iter().map(|&v| (v >> k) + u64::from(k) + 1).sum() +} + +/// The best parameter for a partition of folded values. +fn best_param(u: &[u64]) -> (u8, u64) { + let n = u.len() as u64; + let sum: u64 = u.iter().sum(); + if n == 0 { + return (0, 0); + } + let mean = sum / n; + let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() }; + let mut best = (0u32, u64::MAX); + for k in guess.saturating_sub(1)..=(guess + 1).min(30) { + let b = rice_estimate(sum, n, k); + if b < best.1 { + best = (k, b); + } + } + (best.0 as u8, rice_bits_exact(u, best.0)) +} + +/// Choose the partition order and parameters for the residual of a block of `block` samples whose +/// first `order` samples are warm-up (`u` holds the folded residual after them). +fn code_residual(u: &[u64], block: usize, order: usize, max_partition_order: u8) -> Residual { + let mut best: Option = None; + for po in 0..=max_partition_order { + let parts = 1usize << po; + if !block.is_multiple_of(parts) { + break; + } + let len = block / parts; + if len <= order { + break; + } + let mut params = Vec::with_capacity(parts); + let mut bits = 0u64; + let mut start = 0usize; + for p in 0..parts { + let n = if p == 0 { len - order } else { len }; + let Some(chunk) = u.get(start..start + n) else { break }; + let (k, b) = best_param(chunk); + params.push(k); + bits += b; + start += n; + } + let wide = params.iter().any(|&k| k > 14); + bits += 2 + 4 + parts as u64 * if wide { 5 } else { 4 }; + if best.as_ref().is_none_or(|b| bits < b.bits) { + best = Some(Residual { params, partition_order: po, bits }); + } + } + best.unwrap_or(Residual { params: vec![0], partition_order: 0, bits: u64::MAX }) +} + +/// The residual of fixed predictor `order` (0–4), or None when it does not fit. +pub fn fixed_residual(x: &[i32], order: usize) -> Option> { + let x: Vec = x.iter().map(|&v| i64::from(v)).collect(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let s = |j: usize| x.get(i - j).copied().unwrap_or(0); + let r = match order { + 0 => s(0), + 1 => s(0) - s(1), + 2 => s(0) - 2 * s(1) + s(2), + 3 => s(0) - 3 * s(1) + 3 * s(2) - s(3), + _ => s(0) - 4 * s(1) + 6 * s(2) - 4 * s(3) + s(4), + }; + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// The residual of quantised linear prediction, or None when it does not fit. +pub fn lpc_residual(x: &[i32], coefs: &[i32], shift: u8) -> Option> { + let order = coefs.len(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let hist = x.get(i - order..i)?; + // coefs[0] weighs the previous sample + let pred: i64 = coefs.iter().zip(hist.iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + let r = i64::from(*x.get(i)?) - (pred >> shift); + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// Tukey(0.5) window: flat in the middle half, raised-cosine tapers. +fn window(n: usize) -> Vec { + let taper = (n / 4).max(1); + (0..n) + .map(|i| { + let edge = i.min(n - 1 - i); + if edge >= taper { 1.0 } else { 0.5 * (1.0 - (std::f64::consts::PI * edge as f64 / taper as f64).cos()) } + }) + .collect() +} + +/// Linear prediction coefficients of every order 1..=`max` (Levinson–Durbin on the windowed +/// autocorrelation); empty for a silent or degenerate block. +fn lpc_orders(x: &[i32], max: usize) -> Vec> { + let n = x.len(); + if n <= max { + return Vec::new(); + } + let w = window(n); + let s: Vec = x.iter().zip(&w).map(|(&v, &w)| f64::from(v) * w).collect(); + let r: Vec = (0..=max).map(|lag| s.iter().zip(s.iter().skip(lag)).map(|(a, b)| a * b).sum()).collect(); + let Some(&r0) = r.first() else { return Vec::new() }; + if r0 <= 0.0 { + return Vec::new(); + } + let mut err = r0 * (1.0 + 1e-9); + let mut a: Vec = Vec::new(); + let mut all = Vec::with_capacity(max); + for i in 0..max { + let ri = r.get(i + 1).copied().unwrap_or(0.0); + let acc: f64 = a.iter().enumerate().map(|(j, &aj)| aj * r.get(i - j).copied().unwrap_or(0.0)).sum(); + let k = (ri - acc) / err; + if !k.is_finite() { + break; + } + let prev = a.clone(); + a.push(k); + for j in 0..i { + if let (Some(aj), Some(&p)) = (a.get_mut(j), prev.get(i - 1 - j)) { + *aj -= k * p; + } + } + err *= 1.0 - k * k; + all.push(a.clone()); + if err <= 0.0 { + break; + } + } + all +} + +/// Quantise coefficients to `precision`-bit integers with a right shift (0–15), carrying the +/// rounding error forward so the quantised filter stays close to the real one. +fn quantise(coefs: &[f64], precision: u8) -> Option<(Vec, u8)> { + let cmax = coefs.iter().fold(0.0f64, |m, c| m.max(c.abs())); + if !(cmax.is_finite() && cmax > 0.0) { + return None; + } + let lim = (1i64 << (precision - 1)) - 1; + // the largest shift that keeps the biggest coefficient inside the precision + let log = cmax.log2().floor() as i32 + 1; + let shift = (i32::from(precision) - 1 - log).clamp(0, 15); + let scale = f64::from(1u32 << shift); + let mut carry = 0.0; + let mut q = Vec::with_capacity(coefs.len()); + for &c in coefs { + carry += c * scale; + let v = (carry.round() as i64).clamp(-lim - 1, lim); + carry -= v as f64; + q.push(v as i32); + } + Some((q, shift as u8)) +} + +/// Encoding effort, from the compression level. +#[derive(Clone, Copy, Debug)] +pub struct Effort { + pub max_lpc_order: usize, + pub max_partition_order: u8, + pub stereo: bool, +} + +impl Effort { + pub fn level(level: u8) -> Effort { + let (max_lpc_order, max_partition_order, stereo) = match level { + 0 => (0, 3, false), + 1 | 2 => (0, 3, true), + 3 => (6, 4, false), + 4 => (8, 4, true), + 5 => (8, 5, true), + 6 => (8, 6, true), + _ => (12, 6, true), + }; + Effort { max_lpc_order, max_partition_order, stereo } + } +} + +/// The cheapest coding of one channel of `bps`-bit samples, and its size in bits. +pub fn choose(x: &[i32], bps: u8, effort: Effort) -> (Subframe, u64) { + let n = x.len(); + let b = u64::from(bps); + let head = 8; // zero bit, type, wasted-bits flag + let first = x.first().copied().unwrap_or(0); + if x.iter().all(|&v| v == first) { + return (Subframe::Constant(first), head + b); + } + let mut best = (Subframe::Verbatim, head + n as u64 * b); + let mut consider = |sub: Subframe, bits: u64| { + if bits < best.1 { + best = (sub, bits); + } + }; + let folded = |r: &[i64]| r.iter().map(|&v| fold(v)).collect::>(); + for order in 0..=4usize.min(n.saturating_sub(1)) { + let Some(r) = fixed_residual(x, order) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * b + residual.bits; + consider(Subframe::Fixed { order: order as u8, residual }, bits); + } + // 15-bit coefficients, fewer for short blocks (their side information weighs more) + let precision: u8 = if n <= 1152 { 12 } else { 15 }; + for coefs in lpc_orders(x, effort.max_lpc_order) { + let Some((q, shift)) = quantise(&coefs, precision) else { continue }; + let order = q.len(); + let Some(r) = lpc_residual(x, &q, shift) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * (b + u64::from(precision)) + 4 + 5 + residual.bits; + consider(Subframe::Lpc { coefs: q, precision, shift, residual }, bits); + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folding() { + assert_eq!([0, -1, 1, -2, 2].map(fold), [0, 1, 2, 3, 4]); + } + + #[test] + fn predictable_signals_beat_verbatim() { + // a ramp is exactly predicted by the order-2 fixed predictor + let ramp: Vec = (0..4096).map(|i| i * 3 - 6000).collect(); + let (sub, bits) = choose(&ramp, 16, Effort::level(5)); + assert!(matches!(sub, Subframe::Fixed { order: 2, .. } | Subframe::Lpc { .. }), "{sub:?}"); + assert!(bits < 4096 * 2, "{bits}"); + // a sine is well predicted by LPC + let sine: Vec = (0..4096).map(|i| ((i as f64 * 0.05).sin() * 20000.0) as i32).collect(); + let (_, bits) = choose(&sine, 16, Effort::level(5)); + assert!(bits < 4096 * 6, "{bits}"); + assert_eq!(choose(&[7; 100], 16, Effort::level(5)), (Subframe::Constant(7), 24)); + } + + #[test] + fn lpc_residual_inverts() { + let x: Vec = (0..500).map(|i| ((i as f64 * 0.1).sin() * 1000.0) as i32).collect(); + let coefs = lpc_orders(&x, 4).pop().unwrap(); + let (q, shift) = quantise(&coefs, 15).unwrap(); + let r = lpc_residual(&x, &q, shift).unwrap(); + // rebuild the signal from the warm-up and the residual, as a decoder would + let mut y: Vec = x[..q.len()].to_vec(); + for (i, &res) in r.iter().enumerate() { + let p: i64 = q.iter().zip(y[i..i + q.len()].iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + y.push((res + (p >> shift)) as i32); + } + assert_eq!(y, x); + } + + #[test] + fn noise_and_extremes_do_not_panic() { + let mut seed = 1u32; + let noise: Vec = (0..4096) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as i32 - (1 << 23) + }) + .collect(); + let (_, bits) = choose(&noise, 24, Effort::level(8)); + assert!(bits <= 8 + 4096 * 24); + let square: Vec = (0..4096).map(|i| if i % 2 == 0 { (1 << 23) - 1 } else { -(1 << 23) }).collect(); + choose(&square, 24, Effort::level(8)); + choose(&[], 16, Effort::level(5)); + choose(&[1], 16, Effort::level(5)); + choose(&[1, 2], 16, Effort::level(8)); + } +} diff --git a/crates/flac/tests/ffmpeg_oracle.rs b/crates/flac/tests/ffmpeg_oracle.rs new file mode 100644 index 000000000..80a0c7538 --- /dev/null +++ b/crates/flac/tests/ffmpeg_oracle.rs @@ -0,0 +1,181 @@ +//! ffmpeg as an external decoder oracle (skipped when absent): every stream must decode without an +//! error to exactly the samples that went in, with the STREAMINFO MD5 matching ffmpeg's hash of the +//! decoded audio. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use filmcraft_flac::{Encoder, EncoderConfig, Error, STREAMINFO_OFFSET}; + +fn ffmpeg() -> Option { + filmcraft_testkit::ffmpeg_or_skip("flac oracle") +} + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise, +/// a full-scale square, silence and a lone click. +fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec { + let max = f64::from((1i32 << (bps - 1)) - 1); + let mut r = Rng(seed | 1); + (0..n) + .map(|i| { + let t = i as f64 / 48_000.0; + let v = match kind { + "music" => 0.5 * (2.0 * std::f64::consts::PI * 220.0 * t).sin() + 0.3 * (2.0 * std::f64::consts::PI * 1375.0 * t).sin() + 0.05 * r.next(), + "sweep" => (2.0 * std::f64::consts::PI * (40.0 + 4000.0 * t) * t).sin() * 0.9, + "noise" => r.next(), + "square" => { + if (i / 37) % 2 == 0 { + 1.0 + } else { + -1.0 + } + } + "click" => f64::from(u8::from(i == n / 2)), + _ => 0.0, + }; + ((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32) + }) + .collect() +} + +fn encode(planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Result, Error> { + let mut enc = Encoder::new(cfg)?; + let mut file = enc.header(); + let n = planar.first().map_or(0, Vec::len); + let mut at = 0; + while at < n { + let end = (at + chunk).min(n); + let parts: Vec<&[i32]> = planar.iter().map(|c| &c[at..end]).collect(); + file.extend(enc.encode(&parts)?); + at = end; + } + file.extend(enc.finish()); + let o = STREAMINFO_OFFSET as usize; + file[o..o + 34].copy_from_slice(&enc.streaminfo()); + Ok(file) +} + +fn run(ffmpeg: &Path, args: &[&str], input: &Path) -> (Vec, String) { + let out = Command::new(ffmpeg).args(["-v", "error", "-i"]).arg(input).args(args).output().unwrap(); + assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr)); + (out.stdout, String::from_utf8_lossy(&out.stderr).into_owned()) +} + +/// Encode, decode with ffmpeg, compare every sample and the MD5; returns the file size. +fn round_trip(name: &str, planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Option { + let ffmpeg = ffmpeg()?; + let bps = cfg.bits_per_sample; + let rate = cfg.sample_rate; + let file = encode(planar, cfg, chunk).unwrap(); + let path = filmcraft_testkit::fixtures_dir("flac").join(format!("{name}.flac")); + std::fs::write(&path, &file).unwrap(); + + // samples: s32 holds each one shifted to the top bits + let (raw, err) = run(&ffmpeg, &["-f", "s32le", "-c:a", "pcm_s32le", "-"], &path); + assert!(err.is_empty(), "{name}: decoder errors: {err}"); + let decoded: Vec = raw.as_chunks::<4>().0.iter().map(|b| i32::from_le_bytes(*b) >> (32 - bps)).collect(); + let n = planar[0].len(); + assert_eq!(decoded.len(), n * planar.len(), "{name}: sample count"); + for (i, frame) in decoded.chunks_exact(planar.len()).enumerate() { + for (c, &v) in frame.iter().enumerate() { + assert_eq!(v, planar[c][i], "{name}: channel {c} sample {i}"); + } + } + + // STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC + // hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16 + // and 24 bit compare + let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34]; + let pcm = match bps { + 8 => Some("s8"), + 16 => Some("s16le"), + 24 => Some("s24le"), + _ => None, + }; + if let Some(pcm) = pcm { + let (hash, _) = run(&ffmpeg, &["-c:a", &format!("pcm_{pcm}"), "-f", "hash", "-hash", "md5", "-"], &path); + let hash = String::from_utf8_lossy(&hash); + let ours: String = si[18..34].iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hash.trim().strip_prefix("MD5=").unwrap_or(""), ours, "{name}: MD5"); + } + let total = (u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes([si[14], si[15], si[16], si[17]])); + assert_eq!(total, n as u64, "{name}: total samples"); + let si_rate = (u32::from(si[10]) << 12) | (u32::from(si[11]) << 4) | (u32::from(si[12]) >> 4); + assert_eq!(si_rate, rate); + Some(file.len()) +} + +#[test] +fn every_signal_round_trips_exactly() { + for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] { + for bps in [16u8, 24] { + let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)]; + if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() { + return; + } + } + } +} + +#[test] +fn channel_layouts_rates_and_sizes() { + let cases: [(&str, u8, u8, u32, usize); 6] = [ + ("mono-8bit", 1, 8, 8_000, 3_001), + ("mono-24-44k1", 1, 24, 44_100, 9_999), + ("stereo-20bit", 2, 20, 96_000, 10_000), + ("5.1-24", 6, 24, 48_000, 12_345), + ("odd-rate", 2, 16, 37_800, 5_000), + ("tiny", 2, 16, 48_000, 1), + ]; + for (name, ch, bps, rate, n) in cases { + let planar: Vec> = (0..ch).map(|c| signal(if c == 3 { "silence" } else { "music" }, n, bps, u64::from(c) + 7)).collect(); + if round_trip(name, &planar, EncoderConfig::new(rate, ch, bps), 1000).is_none() { + return; + } + } +} + +#[test] +fn levels_and_block_sizes_trade_time_for_size() { + let planar = vec![signal("music", 48_000, 16, 11), signal("sweep", 48_000, 16, 12)]; + let mut sizes = Vec::new(); + for level in 0..=8 { + let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 16) }; + let Some(size) = round_trip(&format!("level{level}"), &planar, cfg, 48_000) else { return }; + sizes.push(size); + } + // the effortful levels are never larger than the fastest, and all beat PCM + assert!(sizes[8] <= sizes[0] && sizes[5] <= sizes[0], "{sizes:?}"); + assert!(sizes.iter().all(|&s| s < 48_000 * 4), "{sizes:?}"); + for bs in [16u16, 192, 1152, 4608, 65_535] { + let cfg = EncoderConfig { block_size: bs, ..EncoderConfig::new(48_000, 2, 16) }; + round_trip(&format!("block{bs}"), &planar, cfg, 777); + } +} + +#[test] +fn bad_input_is_an_error_not_a_panic() { + assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err()); + assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err()); + assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err()); + let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap(); + assert_eq!(enc.encode(&[&[0, 1]]), Err(Error::Channels { expected: 2, got: 1 })); + assert_eq!(enc.encode(&[&[0, 1], &[0]]), Err(Error::UnequalChannels)); + assert_eq!(enc.encode(&[&[40_000], &[0]]), Err(Error::SampleRange(40_000))); + assert_eq!(enc.encode(&[&[-32_769], &[0]]), Err(Error::SampleRange(-32_769))); + // nothing encoded: no frames, and a header that still says "unknown" + assert!(enc.finish().is_empty()); + assert_eq!(&enc.header()[..4], b"fLaC"); +} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index ee926cb5d..f5c39b5f8 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1310,6 +1310,16 @@ impl SampleEntry { pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) } + /// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block. + pub fn flac(streaminfo: &[u8; 34]) -> Self { + let mut blocks = vec![0x80, 0, 0, 34]; + blocks.extend_from_slice(streaminfo); + let mut full = vec![0; 4]; + full.extend_from_slice(&blocks); + let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() }); + let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample)); + Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits) + } /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). pub fn pcm(cfg: PcmConfig) -> Self { Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs index 0ea814779..880d49a2c 100644 --- a/crates/isobmff/src/mux.rs +++ b/crates/isobmff/src/mux.rs @@ -541,6 +541,14 @@ impl Mp4Writer { Ok(self.tracks.len() - 1) } + /// Replace a track's sample entry before the file is finished (the `moov` is written last), for + /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. + pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { + let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; + t.cfg.entry = entry; + Ok(()) + } + /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start /// frame number) is written at finish, spanning the video track's duration. pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aa1568317..21a88bbd5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2962,6 +2962,12 @@ AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24 bits + FLAC, stereo 48 kHz 24-bit, lossless FLAC, estéreo, 48 kHz y 24 bits, sin pérdida + FLAC (lossless) FLAC (sin pérdida) + Compression Level Nivel de compresión + 0 fastest, 8 smallest 0 más rápido, 8 más pequeño APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 14a5c02d0..ce9bd1cbe 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2956,6 +2956,12 @@ AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24-bit + FLAC, stereo 48 kHz 24-bit, lossless FLAC、ステレオ 48 kHz 24-bit、ロスレス + FLAC (lossless) FLAC (ロスレス) + Compression Level 圧縮レベル + 0 fastest, 8 smallest 0 が最速、8 が最小 APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 4838ce5ff..c961a94be 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -750,17 +750,22 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } row(ui, t, tl!("Audio Format"), |ui| { - let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); + let fixed = audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", + AudioCodec::Flac => tl!("FLAC (lossless)"), _ => tl!("Uncompressed (PCM)"), }; - let labels = - vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))]; + // MP4 takes AAC or FLAC; QuickTime any of the three + let labels = vec![ + ("AAC".to_string(), !audio_only), + (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4)), + (tl!("FLAC (lossless)").to_string(), true), + ]; if fixed { ui.label(cur); } else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) { - s.audio.codec = if i == 0 { AudioCodec::Aac } else { AudioCodec::Pcm }; + s.audio.codec = [AudioCodec::Aac, AudioCodec::Pcm, AudioCodec::Flac][i.min(2)]; } }); row(ui, t, tl!("Sample Rate"), |ui| { @@ -799,6 +804,16 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } + if s.audio_codec() == AudioCodec::Flac { + // every level is lossless: higher levels take longer for smaller files + row(ui, t, tl!("Compression Level"), |ui| { + let mut level = f64::from(s.audio.flac_level.min(8)); + if drag(ui, reg, "export.audio.flacLevel", &mut level, 0.0..=8.0, 0.05, "", 0) { + s.audio.flac_level = level.round() as u8; + } + ui.label(egui::RichText::new(tl!("0 fastest, 8 smallest")).size(12.0)); + }); + } } fn placement_combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, p: &mut Placement) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fb5419463..8ae75434a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -47,6 +47,7 @@ const LAYERS: &[(&str, u8)] = &[ ("apv", 0), ("av1", 0), ("aac", 0), + ("flac", 0), ("opus", 0), ("frame", 1), ("media", 1), From 2f4b59329e534fb6033e4ce2abb9f298c8176eac Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:30 +0700 Subject: [PATCH 005/157] #598: filmcraft-avi, a clean-room AVI / OpenDML demuxer New L0 crate written from the AVI RIFF file reference and OpenDML 1.02: avih and per-stream strh / strf (BITMAPINFOHEADER, WAVEFORMATEX(TENSIBLE)) / strn, and chunk tables from the best index a file has: the OpenDML indx super index and ix## standard indexes (field indexes too), else idx1 (offsets relative to movi or absolute) plus a scan of RIFF AVIX extensions it cannot see, else a scan of the movi lists (key frames then unknown). Frames are read on demand through a ByteSource. Sizes are clamped to the file, so a cut-off recording opens with what is there; rec lists nest at most 3 deep and tables are bounded by what the file could hold. Tests: hand-built files for every index path, truncation, and every single-byte mutation and truncation (no panic); ffmpeg-made files against ffprobe; an ignored test on a 1.16 GB OpenDML file with AVIX extensions. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 + Cargo.toml | 1 + crates/avi/Cargo.toml | 15 + crates/avi/README.md | 43 +++ crates/avi/src/lib.rs | 542 ++++++++++++++++++++++++++++++ crates/avi/src/source.rs | 59 ++++ crates/avi/src/tests.rs | 269 +++++++++++++++ crates/avi/tests/ffmpeg_oracle.rs | 151 +++++++++ xtask/src/main.rs | 1 + 9 files changed, 1089 insertions(+) create mode 100644 crates/avi/Cargo.toml create mode 100644 crates/avi/README.md create mode 100644 crates/avi/src/lib.rs create mode 100644 crates/avi/src/source.rs create mode 100644 crates/avi/src/tests.rs create mode 100644 crates/avi/tests/ffmpeg_oracle.rs diff --git a/Cargo.lock b/Cargo.lock index d94a44573..ed05e601d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1755,6 +1755,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-avi" +version = "0.5.0" +dependencies = [ + "filmcraft-testkit", +] + [[package]] name = "filmcraft-bitstream" version = "0.5.0" @@ -1808,6 +1815,7 @@ dependencies = [ "filmcraft-ac3", "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", diff --git a/Cargo.toml b/Cargo.toml index 1d306cffa..d69ecd83b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ filmcraft-geom = { path = "crates/geom" } filmcraft-color = { path = "crates/color" } filmcraft-bitstream = { path = "crates/bitstream" } filmcraft-isobmff = { path = "crates/isobmff" } +filmcraft-avi = { path = "crates/avi" } filmcraft-matroska = { path = "crates/matroska" } filmcraft-mxf = { path = "crates/mxf" } filmcraft-cfb = { path = "crates/cfb" } diff --git a/crates/avi/Cargo.toml b/crates/avi/Cargo.toml new file mode 100644 index 000000000..519c098dc --- /dev/null +++ b/crates/avi/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "filmcraft-avi" +description = "Clean-room AVI (RIFF) and OpenDML demuxer" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/avi/README.md b/crates/avi/README.md new file mode 100644 index 000000000..d10ba02f2 --- /dev/null +++ b/crates/avi/README.md @@ -0,0 +1,43 @@ +# filmcraft-avi + +A clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +OpenDML AVI File Format Extensions 1.02. Layer L0: no dependencies beyond `std`, no `unsafe`, builds +for `wasm32-unknown-unknown`. No FFmpeg, libavformat or other AVI implementation was read; ffmpeg +and ffprobe are used only as external **test oracles**. + +## API + +```rust +use filmcraft_avi::{open, StreamKind}; + +let file = open(&bytes)?; // any ByteSource: a slice, a file, a web Blob… +for (i, s) in file.streams.iter().enumerate() { + match s.kind { + StreamKind::Video => println!("{:?} {} fps", s.video.as_ref().map(|b| b.compression), s.rate as f64 / s.scale as f64), + StreamKind::Audio => println!("format 0x{:04x}", s.audio.as_ref().map_or(0, |w| w.format_tag)), + _ => {} + } + let first = file.read_chunk(&bytes, i, 0)?; // frame data, on demand +} +``` + +## What it reads + +| Part | | +|---|---| +| Headers | `avih`; per stream `strh`, `strf` (`BITMAPINFOHEADER`, `WAVEFORMATEX` / `WAVEFORMATEXTENSIBLE`), `strn` | +| Index | OpenDML `indx` super index → `ix##` standard indexes (field indexes too), else `idx1` (offsets relative to `movi` or absolute), else a scan of the `movi` lists | +| Large files | `RIFF AVIX` extensions: through the OpenDML index, or scanned after `idx1`, which only covers the first RIFF | +| Key frames | from the index; a scan marks every chunk and says so (`keyframes_known`) | +| Damage | sizes clamped to the file (a cut-off recording opens with what is there), `rec ` lists nested at most 3 deep, tables bounded by what the file could hold | + +`filmcraft-codecs` maps the streams onto decoders (`AviSource`): Motion JPEG, H.264, HEVC and +uncompressed RGB / YUV video; PCM, MP3, MP2 and AC-3 audio. + +## Tests + +`cargo test -p filmcraft-avi`: hand-built files for every index path (`idx1` relative and absolute, +`rec ` lists, OpenDML standard indexes, a scan), truncation, and every single-byte mutation and +truncation of them (never a panic). `tests/ffmpeg_oracle.rs` checks ffmpeg-made files (Motion JPEG, +H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb` +checks a 1.16 GB OpenDML file with `AVIX` extensions. diff --git a/crates/avi/src/lib.rs b/crates/avi/src/lib.rs new file mode 100644 index 000000000..0294fdfea --- /dev/null +++ b/crates/avi/src/lib.rs @@ -0,0 +1,542 @@ +//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +//! OpenDML AVI File Format Extensions 1.02. +//! +//! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` / +//! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index +//! the file has: the OpenDML super index (`indx` → `ix##` standard indexes, which also cover the +//! `RIFF AVIX` extensions of files past 1 GB), else the AVI 1.0 `idx1` (plus a scan of any `AVIX` +//! extension it cannot see), else a scan of the `movi` lists. Frame data is read on demand with +//! [`AviFile::read_chunk`]. Truncated files (a recording cut off) open with what is there. +//! +//! Every size and count comes from the file and is treated as hostile: reads are bounded by the +//! file length, tables by the number of chunks the file could hold, and nesting by a fixed depth. +//! +//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod source; +#[cfg(test)] +mod tests; + +pub use source::ByteSource; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Not an AVI file (no `RIFF` … `AVI ` header). + NotAvi, + /// A read failed. + Io(String), + /// Structurally invalid data. + Invalid(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotAvi => f.write_str("not an AVI file"), + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::Invalid(e) => write!(f, "invalid AVI: {e}"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +/// Whether `head` starts like an AVI file: `RIFF` `AVI `. +pub fn sniff(head: &[u8]) -> bool { + head.get(..4) == Some(b"RIFF") && head.get(8..12) == Some(b"AVI ") +} + +/// `avih`: the main header (frame period, flags, frame count, size). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MainHeader { + pub micro_sec_per_frame: u32, + pub flags: u32, + pub total_frames: u32, + pub streams: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamKind { + Video, + Audio, + /// Text, MIDI or anything else (`fccType`). + Other([u8; 4]), +} + +/// `strf` of a video stream: a `BITMAPINFOHEADER`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BitmapInfo { + pub width: i32, + /// Positive: rows stored bottom-up (uncompressed RGB); negative: top-down. + pub height: i32, + pub bit_count: u16, + /// `biCompression` as four bytes: a FourCC (`MJPG`, `H264`, `YUY2`…) or 0 / 3 (`BI_RGB`, + /// `BI_BITFIELDS`) in little-endian. + pub compression: [u8; 4], + pub size_image: u32, + /// Bytes after the 40-byte header (codec setup, palette). + pub extra: Vec, +} + +impl BitmapInfo { + /// Uncompressed RGB (`BI_RGB` / `BI_BITFIELDS`). + pub fn is_rgb(&self) -> bool { + matches!(u32::from_le_bytes(self.compression), 0 | 3) + } +} + +/// `strf` of an audio stream: a `WAVEFORMATEX` (`WAVEFORMATEXTENSIBLE` is resolved to its sub-format). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WaveFormat { + /// 1 PCM, 3 IEEE float, 0x50 MPEG audio, 0x55 MP3, 0x2000 AC-3, 0xFF AAC… + pub format_tag: u16, + pub channels: u16, + pub sample_rate: u32, + pub avg_bytes_per_sec: u32, + pub block_align: u16, + pub bits_per_sample: u16, + /// `cbSize` bytes after the 18-byte header. + pub extra: Vec, +} + +/// One stored frame or audio chunk. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Chunk { + /// Offset of the data (after the 8-byte chunk header). + pub offset: u64, + pub size: u32, + /// A key frame (always true for audio and when the index does not say). + pub key: bool, +} + +/// Where the chunk tables came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IndexSource { + /// OpenDML `indx` super index and `ix##` standard indexes. + OpenDml, + /// The AVI 1.0 `idx1` (plus a scan of the `AVIX` extensions). + Idx1, + /// No usable index: the `movi` lists were scanned (key frames unknown). + Scan, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stream { + pub kind: StreamKind, + /// `fccHandler`. + pub handler: [u8; 4], + /// Time base: `rate / scale` units per second (frames per second for video). + pub scale: u32, + pub rate: u32, + /// Start time in `scale / rate` units. + pub start: u32, + /// Length in `scale / rate` units. + pub length: u32, + /// Bytes per sample for fixed-size samples (PCM block align), 0 when samples vary. + pub sample_size: u32, + pub video: Option, + pub audio: Option, + /// `strn`. + pub name: Option, + pub chunks: Vec, + /// Whether [`Chunk::key`] comes from an index (a scan cannot tell). + pub keyframes_known: bool, + /// The OpenDML super index entries (offsets of the `ix##` chunks). + super_index: Vec, +} + +impl Stream { + /// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters. + fn number(id: &[u8; 4]) -> Option { + let d = |b: u8| (b as char).to_digit(16).map(|v| v as usize); + Some(d(id[0])? * 16 + d(id[1])?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AviFile { + pub header: MainHeader, + pub streams: Vec, + pub index: IndexSource, +} + +/// Options for [`open_with`] (tests reach the fallback indexes with them). +#[derive(Clone, Copy, Debug, Default)] +pub struct OpenOptions { + pub ignore_odml: bool, + pub ignore_idx1: bool, +} + +/// Headers larger than this are refused (a real `hdrl` is a few kilobytes). +const MAX_HEADER: u64 = 16 << 20; +/// Streams beyond this are ignored. +const MAX_STREAMS: usize = 64; +/// `LIST rec ` inside `movi` nests once; deeper is refused. +const MAX_DEPTH: u32 = 3; + +fn io(e: std::io::Error) -> Error { + Error::Io(e.to_string()) +} + +fn read(src: &S, at: u64, n: u64) -> Result> { + let end = at.checked_add(n).filter(|e| *e <= src.len()).ok_or_else(|| Error::Invalid(format!("{n} bytes at {at} run past the end")))?; + let len = usize::try_from(end - at).map_err(|_| Error::Invalid("chunk too large".into()))?; + let mut buf = vec![0; len]; + src.read_at(at, &mut buf).map_err(io)?; + Ok(buf) +} + +fn fourcc(b: &[u8], at: usize) -> [u8; 4] { + let mut f = [0; 4]; + if let Some(s) = b.get(at..at + 4) { + f.copy_from_slice(s); + } + f +} + +fn le16(b: &[u8], at: usize) -> u16 { + b.get(at..at + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> u32 { + b.get(at..at + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn le64(b: &[u8], at: usize) -> u64 { + u64::from(le32(b, at)) | (u64::from(le32(b, at + 4)) << 32) +} + +/// A chunk header at `at`: (id, size, data offset). The size is clamped to the file (a truncated +/// recording keeps what is there). +fn header_at(src: &S, at: u64) -> Result<([u8; 4], u64, u64)> { + let h = read(src, at, 8)?; + let data = at + 8; + let size = u64::from(le32(&h, 4)).min(src.len().saturating_sub(data)); + Ok((fourcc(&h, 0), size, data)) +} + +/// Iterate the chunks of `[from, to)`: (id, size, data offset). Stops at the first unreadable +/// header; every step moves forward, so it ends. +fn children(src: &S, from: u64, to: u64) -> Vec<([u8; 4], u64, u64)> { + let mut out = Vec::new(); + let mut at = from; + while at.checked_add(8).is_some_and(|e| e <= to) { + let Ok((id, size, data)) = header_at(src, at) else { break }; + out.push((id, size, data)); + // chunks are padded to an even size + let Some(next) = data.checked_add(size).and_then(|e| e.checked_add(size & 1)) else { break }; + if next <= at { + break; + } + at = next; + } + out +} + +/// Open an AVI file. +pub fn open(src: &S) -> Result { + open_with(src, OpenOptions::default()) +} + +pub fn open_with(src: &S, opts: OpenOptions) -> Result { + let head = read(src, 0, 12.min(src.len())).map_err(|_| Error::NotAvi)?; + if !sniff(&head) { + return Err(Error::NotAvi); + } + // the RIFF forms: the first `AVI `, then any `AVIX` extensions (OpenDML) + let mut hdrl = None; + let mut movis: Vec<(u64, u64, u64)> = Vec::new(); // (position of the `movi` id, data start, data end) + let mut idx1 = None; + for (id, size, data) in children(src, 0, src.len()) { + if id != *b"RIFF" || size < 4 { + continue; + } + let end = data + size; + let form = fourcc(&read(src, data, 4)?, 0); + if form != *b"AVI " && form != *b"AVIX" { + continue; + } + for (cid, csize, cdata) in children(src, data + 4, end) { + match &cid { + b"LIST" if csize >= 4 => { + let ltype = fourcc(&read(src, cdata, 4)?, 0); + if ltype == *b"hdrl" && hdrl.is_none() && form == *b"AVI " { + hdrl = Some((cdata + 4, cdata + csize)); + } else if ltype == *b"movi" { + movis.push((cdata, cdata + 4, cdata + csize)); + } + } + b"idx1" if form == *b"AVI " => idx1 = Some((cdata, csize)), + _ => {} + } + } + } + let (hfrom, hto) = hdrl.ok_or_else(|| Error::Invalid("no hdrl header list".into()))?; + if hto - hfrom > MAX_HEADER { + return Err(Error::Invalid("header list too large".into())); + } + let (header, mut streams) = parse_hdrl(src, hfrom, hto)?; + if streams.is_empty() { + return Err(Error::Invalid("no streams".into())); + } + // chunk tables from the best index + let odml = !opts.ignore_odml && streams.iter().any(|s| !s.super_index.is_empty()); + let index = if odml && read_odml(src, &mut streams) { + IndexSource::OpenDml + } else if let (false, Some((at, size)), Some(first)) = (opts.ignore_idx1, idx1, movis.first()) { + clear(&mut streams); + read_idx1(src, &mut streams, at, size, first.0); + // idx1 only covers the first RIFF: the AVIX extensions are scanned + for &(_, from, to) in movis.iter().skip(1) { + scan(src, &mut streams, from, to, 0); + } + if streams.iter().all(|s| s.chunks.is_empty()) { scan_all(src, &mut streams, &movis) } else { IndexSource::Idx1 } + } else { + scan_all(src, &mut streams, &movis) + }; + Ok(AviFile { header, streams, index }) +} + +fn clear(streams: &mut [Stream]) { + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + } +} + +fn scan_all(src: &S, streams: &mut [Stream], movis: &[(u64, u64, u64)]) -> IndexSource { + clear(streams); + for &(_, from, to) in movis { + scan(src, streams, from, to, 0); + } + for s in streams.iter_mut() { + s.keyframes_known = false; + } + IndexSource::Scan +} + +fn parse_hdrl(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec)> { + let mut header = MainHeader::default(); + let mut streams = Vec::new(); + for (id, size, data) in children(src, from, to) { + match &id { + b"avih" => { + let b = read(src, data, size.min(64))?; + header = MainHeader { + micro_sec_per_frame: le32(&b, 0), + flags: le32(&b, 12), + total_frames: le32(&b, 16), + streams: le32(&b, 24), + width: le32(&b, 32), + height: le32(&b, 36), + }; + } + b"LIST" if size >= 4 && streams.len() < MAX_STREAMS && fourcc(&read(src, data, 4)?, 0) == *b"strl" => { + streams.push(parse_strl(src, data + 4, data + size)?); + } + _ => {} + } + } + Ok((header, streams)) +} + +fn parse_strl(src: &S, from: u64, to: u64) -> Result { + let mut s = Stream { + kind: StreamKind::Other([0; 4]), + handler: [0; 4], + scale: 1, + rate: 1, + start: 0, + length: 0, + sample_size: 0, + video: None, + audio: None, + name: None, + chunks: Vec::new(), + keyframes_known: true, + super_index: Vec::new(), + }; + let mut strf = None; + for (id, size, data) in children(src, from, to) { + match &id { + b"strh" => { + let b = read(src, data, size.min(64))?; + s.kind = match &fourcc(&b, 0) { + b"vids" => StreamKind::Video, + b"auds" => StreamKind::Audio, + other => StreamKind::Other(*other), + }; + s.handler = fourcc(&b, 4); + s.scale = le32(&b, 20); + s.rate = le32(&b, 24); + s.start = le32(&b, 28); + s.length = le32(&b, 32); + s.sample_size = le32(&b, 44); + } + b"strf" => strf = Some(read(src, data, size.min(1 << 20))?), + b"strn" => { + let b = read(src, data, size.min(4096))?; + let name = String::from_utf8_lossy(b.split(|&c| c == 0).next().unwrap_or_default()).trim().to_string(); + s.name = (!name.is_empty()).then_some(name); + } + b"indx" => s.super_index = super_index(&read(src, data, size.min(MAX_HEADER))?), + _ => {} + } + } + if let Some(b) = strf { + match s.kind { + StreamKind::Video => { + s.video = Some(BitmapInfo { + width: le32(&b, 4) as i32, + height: le32(&b, 8) as i32, + bit_count: le16(&b, 14), + compression: fourcc(&b, 16), + size_image: le32(&b, 20), + extra: b.get(40.min(b.len())..).unwrap_or_default().to_vec(), + }); + } + StreamKind::Audio => { + let cb = usize::from(le16(&b, 16)); + let extra = b.get(18.min(b.len())..(18 + cb).min(b.len())).unwrap_or_default().to_vec(); + let mut tag = le16(&b, 0); + // WAVE_FORMAT_EXTENSIBLE: the sub-format GUID starts with the real tag + if tag == 0xFFFE && extra.len() >= 22 { + tag = le16(&extra, 6); + } + s.audio = Some(WaveFormat { + format_tag: tag, + channels: le16(&b, 2), + sample_rate: le32(&b, 4), + avg_bytes_per_sec: le32(&b, 8), + block_align: le16(&b, 12), + bits_per_sample: le16(&b, 14), + extra, + }); + } + StreamKind::Other(_) => {} + } + } + if s.scale == 0 || s.rate == 0 { + (s.scale, s.rate) = (1, 1); + } + Ok(s) +} + +/// The `ix##` chunk offsets of an `indx` super index (`AVI_INDEX_OF_INDEXES`, 4 longs per entry). +fn super_index(b: &[u8]) -> Vec { + let longs = usize::from(le16(b, 0)); + let (subtype, itype, n) = (b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0xFF), le32(b, 4) as usize); + if itype != 0 || subtype != 0 || longs != 4 { + return Vec::new(); + } + // header 24 bytes, entries of 16: qwOffset, dwSize, dwDuration + let n = n.min(b.len().saturating_sub(24) / 16); + (0..n).map(|k| le64(b, 24 + k * 16)).filter(|&o| o > 0).collect() +} + +/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream +/// that has data lacks one or none could be read. +fn read_odml(src: &S, streams: &mut [Stream]) -> bool { + let limit = src.len() / 8; + let mut total = 0u64; + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + for &at in &s.super_index { + let Ok((id, size, data)) = header_at(src, at) else { continue }; + if &id[..2] != b"ix" { + continue; + } + let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue }; + let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)); + // AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD) + if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) { + continue; + } + let base = le64(&b, 12); + let step = longs * 4; + let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step); + for k in 0..n { + let e = 24 + k * step; + let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4)); + let size = raw & 0x7FFF_FFFF; + let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue }; + total += 1; + if total > limit { + return false; + } + s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 }); + } + } + } + let ok = streams.iter().any(|s| !s.chunks.is_empty()) && streams.iter().all(|s| !s.super_index.is_empty() || matches!(s.kind, StreamKind::Other(_))); + if !ok { + clear(streams); + } + ok +} + +/// Fill the chunk tables from `idx1` (16-byte entries: id, flags, offset, size). Offsets are +/// relative to the `movi` list's type id, or (some writers) absolute: the first entry decides. +fn read_idx1(src: &S, streams: &mut [Stream], at: u64, size: u64, movi: u64) { + let Ok(b) = read(src, at, size.min(src.len() / 2)) else { return }; + let entries: Vec<&[u8]> = b.as_chunks::<16>().0.iter().map(|e| e.as_slice()).collect(); + let id_at = |pos: u64| read(src, pos, 4).ok().map(|v| fourcc(&v, 0)); + let first = entries.iter().find(|e| le32(e, 4) & 1 == 0); + let relative = match first { + Some(e) => { + let (id, off) = (fourcc(e, 0), u64::from(le32(e, 8))); + id_at(movi + off) == Some(id) || id_at(off) != Some(id) + } + None => true, + }; + let base = if relative { movi } else { 0 }; + for e in entries { + let (id, flags, off, len) = (fourcc(e, 0), le32(e, 4), u64::from(le32(e, 8)), le32(e, 12)); + // AVIIF_LIST entries (`rec ` lists) and palette changes carry no samples + if flags & 1 != 0 || &id[2..] == b"pc" { + continue; + } + let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) else { continue }; + let Some(offset) = base.checked_add(off).and_then(|o| o.checked_add(8)).filter(|o| o.checked_add(u64::from(len)).is_some_and(|end| end <= src.len())) + else { + continue; + }; + s.chunks.push(Chunk { offset, size: len, key: flags & 0x10 != 0 || s.kind != StreamKind::Video }); + } +} + +/// Append the data chunks of a `movi` list (`[from, to)`) to the streams' tables, in file order. +fn scan(src: &S, streams: &mut [Stream], from: u64, to: u64, depth: u32) { + if depth >= MAX_DEPTH { + return; + } + for (id, size, data) in children(src, from, to) { + if id == *b"LIST" { + if size >= 4 { + scan(src, streams, data + 4, data + size, depth + 1); + } + continue; + } + if &id[..2] == b"ix" || &id[2..] == b"pc" { + continue; + } + if let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) { + s.chunks.push(Chunk { offset: data, size: u32::try_from(size).unwrap_or(u32::MAX), key: true }); + } + } +} + +impl AviFile { + /// The bytes of chunk `i` of stream `stream`. + pub fn read_chunk(&self, src: &S, stream: usize, i: usize) -> Result> { + let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?; + read(src, c.offset, u64::from(c.size)) + } +} diff --git a/crates/avi/src/source.rs b/crates/avi/src/source.rs new file mode 100644 index 000000000..783cc1905 --- /dev/null +++ b/crates/avi/src/source.rs @@ -0,0 +1,59 @@ +//! Random-access byte sources for the demuxer (same shape as the other container crates', so one +//! adapter serves them all). + +use std::io; + +/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). +pub trait ByteSource { + /// Total length in bytes. + fn len(&self) -> u64; + /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + /// True if the source holds no bytes. + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn eof() -> io::Error { + io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") +} + +impl ByteSource for [u8] { + fn len(&self) -> u64 { + <[u8]>::len(self) as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let start = usize::try_from(offset).map_err(|_| eof())?; + let end = start.checked_add(buf.len()).ok_or_else(eof)?; + buf.copy_from_slice(self.get(start..end).ok_or_else(eof)?); + Ok(()) + } +} + +impl ByteSource for Vec { + fn len(&self) -> u64 { + self.as_slice().len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.as_slice().read_at(offset, buf) + } +} + +impl ByteSource for &T { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for std::sync::Arc { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} diff --git a/crates/avi/src/tests.rs b/crates/avi/src/tests.rs new file mode 100644 index 000000000..248364d6f --- /dev/null +++ b/crates/avi/src/tests.rs @@ -0,0 +1,269 @@ +use super::*; + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend_from_slice(&(data.len() as u32).to_le_bytes()); + v.extend_from_slice(data); + if data.len() % 2 == 1 { + v.push(0); + } + v +} + +fn list(ltype: &[u8; 4], body: &[u8]) -> Vec { + let mut d = ltype.to_vec(); + d.extend_from_slice(body); + chunk(b"LIST", &d) +} + +fn strl(video: bool) -> Vec { + let mut strh = vec![0u8; 56]; + strh[..4].copy_from_slice(if video { b"vids" } else { b"auds" }); + strh[4..8].copy_from_slice(if video { b"MJPG" } else { &[0; 4] }); + let (scale, rate, ss) = if video { (1u32, 25u32, 0u32) } else { (4, 192_000, 4) }; + strh[20..24].copy_from_slice(&scale.to_le_bytes()); + strh[24..28].copy_from_slice(&rate.to_le_bytes()); + strh[44..48].copy_from_slice(&ss.to_le_bytes()); + let strf = if video { + let mut b = vec![0u8; 40]; + b[..4].copy_from_slice(&40u32.to_le_bytes()); + b[4..8].copy_from_slice(&64i32.to_le_bytes()); + b[8..12].copy_from_slice(&48i32.to_le_bytes()); + b[14..16].copy_from_slice(&24u16.to_le_bytes()); + b[16..20].copy_from_slice(b"MJPG"); + b + } else { + let mut b = vec![0u8; 18]; + b[..2].copy_from_slice(&1u16.to_le_bytes()); + b[2..4].copy_from_slice(&2u16.to_le_bytes()); + b[4..8].copy_from_slice(&48_000u32.to_le_bytes()); + b[8..12].copy_from_slice(&192_000u32.to_le_bytes()); + b[12..14].copy_from_slice(&4u16.to_le_bytes()); + b[14..16].copy_from_slice(&16u16.to_le_bytes()); + b + }; + let mut body = chunk(b"strh", &strh); + body.extend(chunk(b"strf", &strf)); + body.extend(chunk(b"strn", if video { b"Camera\0" } else { b"Mic\0" })); + list(b"strl", &body) +} + +/// A small AVI: 3 video frames (the second a delta, the third odd-sized) and 2 audio chunks, +/// optionally inside a `rec ` list, with an `idx1` whose offsets are relative to `movi` or absolute. +fn avi(idx1: bool, absolute: bool, rec: bool) -> Vec { + let mut avih = vec![0u8; 56]; + avih[0..4].copy_from_slice(&40_000u32.to_le_bytes()); + avih[12..16].copy_from_slice(&0x10u32.to_le_bytes()); + avih[16..20].copy_from_slice(&3u32.to_le_bytes()); + avih[24..28].copy_from_slice(&2u32.to_le_bytes()); + avih[32..36].copy_from_slice(&64u32.to_le_bytes()); + avih[36..40].copy_from_slice(&48u32.to_le_bytes()); + let mut hdrl = chunk(b"avih", &avih); + hdrl.extend(strl(true)); + hdrl.extend(strl(false)); + let hdrl = list(b"hdrl", &hdrl); + let samples: [(&[u8; 4], Vec, bool); 5] = + [(b"00dc", vec![1; 10], true), (b"01wb", vec![2; 16], true), (b"00dc", vec![3; 6], false), (b"01wb", vec![4; 8], true), (b"00dc", vec![5; 7], true)]; + let mut body = Vec::new(); + let mut entries = Vec::new(); + for (id, data, key) in &samples { + entries.push((**id, body.len() as u32 + 4, data.len() as u32, *key)); + body.extend(chunk(id, data)); + } + let movi_body = if rec { + let r = list(b"rec ", &body); + // the samples moved 12 bytes in (the rec list's header and type) + for e in &mut entries { + e.1 += 12; + } + r + } else { + body + }; + let movi = list(b"movi", &movi_body); + let mut riff = b"AVI ".to_vec(); + riff.extend(hdrl); + // position of the `movi` type id: after `RIFF` , what `riff` holds so far, `LIST` + let movi_at = (8 + riff.len() + 8) as u32; + riff.extend(movi); + if idx1 { + let mut ix = Vec::new(); + for (id, off, len, key) in entries { + ix.extend_from_slice(&id); + ix.extend_from_slice(&(if key { 0x10u32 } else { 0 }).to_le_bytes()); + ix.extend_from_slice(&(if absolute { off + movi_at } else { off }).to_le_bytes()); + ix.extend_from_slice(&len.to_le_bytes()); + } + riff.extend(chunk(b"idx1", &ix)); + } + chunk(b"RIFF", &riff) +} + +fn table(f: &AviFile, s: usize) -> Vec<(u32, bool)> { + f.streams[s].chunks.iter().map(|c| (c.size, c.key)).collect() +} + +#[test] +fn headers_and_every_index_path_agree() { + for (idx1, absolute, rec, want) in [ + (true, false, false, IndexSource::Idx1), + (true, true, false, IndexSource::Idx1), + (true, false, true, IndexSource::Idx1), + (false, false, false, IndexSource::Scan), + (false, false, true, IndexSource::Scan), + ] { + let file = avi(idx1, absolute, rec); + let f = open(&file).unwrap(); + let what = format!("idx1 {idx1} absolute {absolute} rec {rec}"); + assert_eq!(f.index, want, "{what}"); + assert_eq!((f.header.width, f.header.height, f.header.total_frames, f.header.micro_sec_per_frame), (64, 48, 3, 40_000)); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, v.handler, v.scale, v.rate, v.name.as_deref()), (StreamKind::Video, *b"MJPG", 1, 25, Some("Camera"))); + assert_eq!(v.video.as_ref().map(|b| (b.width, b.height, b.bit_count, b.compression)), Some((64, 48, 24, *b"MJPG"))); + let w = a.audio.as_ref().unwrap(); + assert_eq!((w.format_tag, w.channels, w.sample_rate, w.block_align, w.bits_per_sample, a.sample_size), (1, 2, 48_000, 4, 16, 4)); + // the index knows the delta frame; a scan cannot + let keys = if idx1 { [true, false, true] } else { [true; 3] }; + assert_eq!(table(&f, 0), vec![(10, keys[0]), (6, keys[1]), (7, keys[2])], "{what}"); + assert_eq!(f.streams[0].keyframes_known, idx1); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7], "{what}"); + assert_eq!(f.read_chunk(&file, 1, 1).unwrap(), vec![4; 8], "{what}"); + assert!(f.read_chunk(&file, 0, 3).is_err() && f.read_chunk(&file, 5, 0).is_err()); + } + // the idx1 path can be skipped + let f = open_with(&avi(true, false, false), OpenOptions { ignore_idx1: true, ..Default::default() }).unwrap(); + assert_eq!(f.index, IndexSource::Scan); +} + +#[test] +fn not_avi_and_truncated_recordings() { + assert_eq!(open(&b"RIFF\0\0\0\0WAVEfmt ".to_vec()), Err(Error::NotAvi)); + assert_eq!(open(&Vec::new()), Err(Error::NotAvi)); + assert!(sniff(&avi(true, false, false)) && !sniff(b"RIFF")); + // a recording cut off in its last frame: the headers and the whole frames are there + let full = avi(false, false, false); + let cut = full[..full.len() - 4].to_vec(); + let f = open(&cut).unwrap(); + assert_eq!(f.streams[0].chunks.len(), 3); + assert!(f.read_chunk(&cut, 0, 0).is_ok()); +} + +/// Every single-byte change and every truncation of a valid file opens or fails, never panics, and +/// whatever opens reads its chunks without panicking. +#[test] +fn mutations_never_panic() { + for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false)] { + for cut in 0..base.len() { + let b = base[..cut].to_vec(); + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + for at in 0..base.len() { + for v in [0x00u8, 0xFF, 0x7F, 0x80] { + let mut b = base.clone(); + b[at] = v; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + } + } +} + +/// An OpenDML file: each stream's `indx` super index points at an `ix##` standard index (base +/// offset + offsets to the data, bit 31 of the size marking delta frames). Built twice: the second +/// pass knows where the `ix##` chunks landed. +fn avi_odml(ix_at: [u64; 2]) -> (Vec, [u64; 2]) { + let indx = |stream: u8, at: u64| { + let mut b = Vec::new(); + b.extend_from_slice(&4u16.to_le_bytes()); // wLongsPerEntry + b.extend_from_slice(&[0, 0]); // subtype, AVI_INDEX_OF_INDEXES + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&[b'0', b'0' + stream, if stream == 0 { b'd' } else { b'w' }, if stream == 0 { b'c' } else { b'b' }]); + b.extend_from_slice(&[0; 12]); + b.extend_from_slice(&at.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + chunk(b"indx", &b) + }; + let mut hdrl = chunk(b"avih", &[0; 56]); + for (s, at) in ix_at.iter().enumerate() { + let l = strl(s == 0); + // strl = LIST strl …: append indx inside it + let mut body = l[12..].to_vec(); + body.extend(indx(s as u8, *at)); + hdrl.extend(list(b"strl", &body)); + } + let mut riff = b"AVI ".to_vec(); + riff.extend(list(b"hdrl", &hdrl)); + // movi: video 10 (key) 6 (delta) 7 (key), audio 16 8; then ix00, ix01 + let movi_data_at = 8 + riff.len() as u64 + 12; // RIFF hdr, so far, LIST hdr + `movi` + let mut body = Vec::new(); + let mut at: [Vec<(u64, u32, bool)>; 2] = [Vec::new(), Vec::new()]; + for (s, data, key) in [(0usize, vec![1u8; 10], true), (1, vec![2; 16], true), (0, vec![3; 6], false), (1, vec![4; 8], true), (0, vec![5; 7], true)] { + let id = if s == 0 { b"00dc" } else { b"01wb" }; + at[s].push((movi_data_at + body.len() as u64 + 8, data.len() as u32, key)); + body.extend(chunk(id, &data)); + } + let mut where_ix = [0u64; 2]; + for (s, entries) in at.iter().enumerate() { + let base = 100u64; // offsets are relative to qwBaseOffset + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&[0, 1]); // subtype 0, AVI_INDEX_OF_CHUNKS + b.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + b.extend_from_slice(if s == 0 { b"00dc" } else { b"01wb" }); + b.extend_from_slice(&base.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + for &(off, size, key) in entries { + b.extend_from_slice(&((off - base) as u32).to_le_bytes()); + b.extend_from_slice(&(size | if key { 0 } else { 0x8000_0000 }).to_le_bytes()); + } + where_ix[s] = movi_data_at + body.len() as u64; + body.extend(chunk(if s == 0 { b"ix00" } else { b"ix01" }, &b)); + } + riff.extend(list(b"movi", &body)); + (chunk(b"RIFF", &riff), where_ix) +} + +#[test] +fn opendml_standard_indexes() { + let (_, at) = avi_odml([0, 0]); + let (file, again) = avi_odml(at); + assert_eq!(at, again, "the layout does not depend on the offsets"); + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7]); + // ignoring the OpenDML index, the scan finds the same chunks (and skips the ix## chunks) + let s = open_with(&file, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(s.index, IndexSource::Scan); + assert_eq!(s.streams[0].chunks.iter().map(|c| c.offset).collect::>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::>()); + // a broken super index (pointing nowhere) falls back to a scan + let (bad, _) = avi_odml([3, 5]); + assert_eq!(open(&bad).unwrap().index, IndexSource::Scan); + // and mutations of the OpenDML file never panic + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0xFF; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for k in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, k); + } + } + } + } +} diff --git a/crates/avi/tests/ffmpeg_oracle.rs b/crates/avi/tests/ffmpeg_oracle.rs new file mode 100644 index 000000000..7f1b1662f --- /dev/null +++ b/crates/avi/tests/ffmpeg_oracle.rs @@ -0,0 +1,151 @@ +//! ffmpeg-made AVI files (skipped without ffmpeg): the chunk tables match ffprobe's packet counts, +//! and every index the file has (`idx1`; OpenDML once a file passes 1 GB) and a `movi` scan describe +//! the same chunks. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use filmcraft_avi::{IndexSource, OpenOptions, StreamKind, open, open_with}; + +fn fixture(name: &str, args: &[&str]) -> Option { + let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("avi oracle")?; + let out = filmcraft_testkit::fixtures_dir("avi").join(name); + filmcraft_testkit::fixtures::generate(&out, |tmp: &Path| { + let mut c = Command::new(&ffmpeg); + c.args(["-v", "error", "-y"]).args(args).args(["-f", "avi"]).arg(tmp); + c.status().is_ok_and(|s| s.success()) + }) +} + +fn packets(path: &Path, stream: usize) -> usize { + let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0) +} + +fn packet_bytes(path: &Path, stream: usize) -> u64 { + let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-show_entries", "packet=size", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.trim().parse::().ok()).sum() +} + +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn cases() -> Vec<(&'static str, Vec<&'static str>, [u8; 4])> { + let pic = vec!["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; + let with = |v: &[&'static str], a: &[&'static str]| -> Vec<&'static str> { + let mut x = pic.clone(); + x.extend_from_slice(&TONE); + x.extend_from_slice(v); + x.extend_from_slice(a); + x + }; + vec![ + ("mjpeg_pcm.avi", with(&["-c:v", "mjpeg", "-q:v", "4", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]), *b"MJPG"), + ("h264_mp3.avi", with(&["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]), *b"H264"), + ("bgr24_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "bgr24"], &["-c:a", "pcm_s16le"]), [0; 4]), + ("yuyv_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "yuyv422"], &["-c:a", "pcm_s16le"]), *b"YUY2"), + ] +} + +#[test] +fn ffmpeg_files_match_ffprobe_on_every_index_path() { + for (name, args, fourcc) in cases() { + let Some(path) = fixture(name, &args) else { return }; + let bytes = std::fs::read(&path).unwrap(); + let f = open(&bytes).unwrap(); + // ffmpeg reserves room for the OpenDML index but writes only idx1 under 1 GB + assert_eq!(f.index, IndexSource::Idx1, "{name}"); + assert_eq!(f.streams.len(), 2, "{name}"); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, a.kind), (StreamKind::Video, StreamKind::Audio), "{name}"); + let bmp = v.video.as_ref().unwrap(); + assert_eq!((bmp.width, bmp.height.abs(), bmp.compression), (160, 120, fourcc), "{name}"); + assert_eq!((v.rate as f64 / v.scale as f64).round(), 25.0, "{name}"); + assert_eq!(a.audio.as_ref().map(|w| (w.sample_rate, w.channels)), Some((48_000, 1)), "{name}"); + // the tables hold what ffprobe reads + assert_eq!(v.chunks.len(), packets(&path, 0), "{name}: video"); + // PCM chunks are packets; ffprobe re-splits MP3 into its frames, which AVI chunks need not + // follow, so compressed audio compares by bytes + if a.audio.as_ref().is_some_and(|w| w.format_tag == 1) { + assert_eq!(a.chunks.len(), packets(&path, 1), "{name}: audio"); + } else { + let ours: u64 = a.chunks.iter().map(|c| u64::from(c.size)).sum(); + assert_eq!(ours, packet_bytes(&path, 1), "{name}: audio bytes"); + } + assert!(v.chunks[0].key, "{name}: the first frame is a key frame"); + if fourcc == *b"H264" { + assert!(v.chunks.iter().any(|c| !c.key), "{name}: the index marks the delta frames"); + } + // a scan finds the same chunks (it cannot know key frames) + let scanned = open_with(&bytes, OpenOptions { ignore_odml: true, ignore_idx1: true }).unwrap(); + assert_eq!(scanned.index, IndexSource::Scan, "{name}"); + for s in 0..2 { + assert_eq!(sizes(&scanned, s), sizes(&f, s), "{name}: scan stream {s}"); + } + } +} + +fn sizes(f: &filmcraft_avi::AviFile, s: usize) -> Vec<(u64, u32)> { + f.streams[s].chunks.iter().map(|c| (c.offset, c.size)).collect() +} + +/// A file past 1 GB, as long recordings are: ffmpeg writes `RIFF AVIX` extensions and the +/// OpenDML index. Needs ffmpeg and 1.2 GB of disk: `cargo test -p filmcraft-avi --test +/// ffmpeg_oracle -- --ignored`. +#[test] +#[ignore] +fn opendml_file_past_1_gb() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=640x480:r=25:d=50", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:d=50", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + "-c:a", + "pcm_s16le", + ]; + let Some(path) = fixture("large_opendml.avi", &args) else { return }; + let file = std::fs::File::open(&path).unwrap(); + let src = FileSource(file); + let f = open(&src).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(f.streams[0].chunks.len(), packets(&path, 0)); + assert_eq!(f.streams[1].chunks.len(), packets(&path, 1)); + assert_eq!(f.streams[0].chunks.len(), 1250); + assert!(f.streams[0].chunks.last().unwrap().offset > 1 << 30, "the last frames are in an AVIX extension"); + // idx1 covers the first RIFF only; the AVIX extensions are scanned: the same chunks + let idx1 = open_with(&src, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(idx1.index, IndexSource::Idx1); + for s in 0..2 { + assert_eq!(sizes(&idx1, s), sizes(&f, s), "stream {s}"); + } + assert_eq!(f.read_chunk(&src, 0, 1249).unwrap().len(), 640 * 480 * 3); +} + +/// A file on disk as a byte source, read where asked. +struct FileSource(std::fs::File); + +impl filmcraft_avi::ByteSource for FileSource { + fn len(&self) -> u64 { + self.0.metadata().map(|m| m.len()).unwrap_or(0) + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset) + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 631678d84..e44a843e5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -29,6 +29,7 @@ const LAYERS: &[(&str, u8)] = &[ ("color", 0), ("bitstream", 0), ("isobmff", 0), + ("avi", 0), ("matroska", 0), ("mxf", 0), ("cfb", 0), From 0797ab4ed9d076b56a212a809076ef6d9ce659d9 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:30 +0700 Subject: [PATCH 006/157] #598: import AVI files (Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3) AviSource maps AVI streams onto the decoders, opened from bytes or a streaming reader (Media Browser probe): - Video: Motion JPEG, H.264 and HEVC (Annex B, our decoders) and uncompressed BGR24 / BGRX32 / YUY2 / UYVY / I420 / YV12 / NV12 / grey (new RawVideoDecoder). Empty "drop frame" chunks hold the picture before. AVI has no timestamps: H.264 with B-frames is stored in decode order, so the presentation order comes from the pictures' order counts (mxf::avc_presentation_order), keeping the slots the pictures occupy. A writer that kept a fine time base (ffmpeg copying an MP4's 1/600) fills the slots between pictures with empty chunks; the reported frame rate is the one the pictures play at. DivX / Xvid and DV are named and report that they have no decoder; their sound plays. - Audio: PCM (integer and float) from the chunks; MPEG audio and AC-3 frames, which AVI chunks need not follow, found again across chunk boundaries with the new filmcraft_mpegts::frame_bytes. - Motion JPEG frames without Huffman tables (the AVI1 convention) decode with the standard ITU-T T.81 Annex K tables (also for MOV). Tests against ffmpeg's decode of the same files: H.264 with B-frames bit-exact under random access, also copied from a 1/600 MP4; uncompressed video and PCM bit-exact; JPEG and MP3 / MP2 / AC-3 within decoder tolerance; drop frames; DivX; JPEG without tables equal to the same frame with them. Engine: an AVI imports, makes a sequence, plays picture and sound, and probes without reading it whole. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- crates/codecs/Cargo.toml | 1 + crates/codecs/src/avi.rs | 585 ++++++++++++++++++++++++++ crates/codecs/src/avi_tests.rs | 232 ++++++++++ crates/codecs/src/lib.rs | 29 +- crates/codecs/src/tests.rs | 8 +- crates/codecs/src/video.rs | 220 +++++++++- crates/engine/src/avi_import_tests.rs | 58 +++ crates/engine/src/lib.rs | 2 + crates/engine/src/media_pool.rs | 2 +- crates/mpegts/src/es.rs | 14 + crates/mpegts/src/lib.rs | 2 +- 12 files changed, 1146 insertions(+), 10 deletions(-) create mode 100644 crates/codecs/src/avi.rs create mode 100644 crates/codecs/src/avi_tests.rs create mode 100644 crates/engine/src/avi_import_tests.rs diff --git a/README.md b/README.md index fc1acfebe..80b79b0be 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own | | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | +| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). @@ -301,7 +302,7 @@ A layered Cargo workspace: | Layer | Crates | |---|---| -| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `bitstream` | +| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` | | Foundations | `time`, `geom`, `color`, `frame`, `media`, `text`, `project`, `audio-dsp` | | Editing and interchange | `edit`, `codecs`, `captions`, `format` (project files), `interchange` | | Rendering and output | `render`, `gpu`, `export` | diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c4..3faf97daa 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -14,6 +14,7 @@ filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-avi = { workspace = true } filmcraft-isobmff = { workspace = true } filmcraft-matroska = { workspace = true } filmcraft-mxf = { workspace = true } diff --git a/crates/codecs/src/avi.rs b/crates/codecs/src/avi.rs new file mode 100644 index 000000000..659524427 --- /dev/null +++ b/crates/codecs/src/avi.rs @@ -0,0 +1,585 @@ +//! AVI media source (`filmcraft-avi` demux, GOP-aware video via [`crate::gop`]). +//! +//! Video: Motion JPEG (frames without their own Huffman tables get the standard ones), H.264 and +//! HEVC (Annex B byte streams, our decoders), and uncompressed RGB / YUV ([`RawFormat`]). A frame +//! slot with an empty chunk (a "drop frame") repeats the picture before it. MPEG-4 Part 2 +//! (DivX / Xvid) and DV have no decoder yet: their streams are listed and report the codec. +//! +//! Audio: PCM (integer and float) straight from the chunks; MPEG audio (MP3, MP2) and AC-3 frames, +//! which AVI chunks need not follow, are found again across chunk boundaries +//! ([`filmcraft_mpegts::frame_bytes`]) and decoded as packets. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use filmcraft_avi::{AviFile, BitmapInfo, StreamKind, WaveFormat}; +use filmcraft_frame::{AudioBuffer, VideoFrame}; +use filmcraft_isobmff::{PcmConfig, SampleEntry}; +use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; +use filmcraft_time::{FrameRate, Tick}; + +use crate::audio::{PacketDecoder, decode_pcm}; +use crate::gop::{GopCache, VideoSamples}; +use crate::video::{RawFormat, RawVideoDecoder, VideoDecoder}; +use crate::{CodecError, make_video_decoder}; + +pub fn sniff(b: &[u8]) -> bool { + filmcraft_avi::sniff(b) +} + +#[derive(Clone, Debug, PartialEq)] +enum VCodec { + Jpeg, + H264, + Hevc, + Raw(RawFormat), + /// No decoder: the codec's name. + Unsupported(String), +} + +fn fourcc_text(f: [u8; 4]) -> String { + let t: String = f.iter().map(|&c| if c.is_ascii_graphic() { c as char } else { '?' }).collect(); + t.trim_end_matches('?').to_string() +} + +/// The decoder for a video stream and its label. +fn video_codec(b: &BitmapInfo) -> (VCodec, String) { + if let Some(raw) = RawFormat::from_avi(b.compression, b.bit_count) { + return (VCodec::Raw(raw), raw.label().into()); + } + let mut f = b.compression; + f.make_ascii_uppercase(); + match &f { + b"MJPG" | b"AVRN" | b"LJPG" | b"JPGL" | b"DMB1" | b"JPEG" | b"MJPA" => (VCodec::Jpeg, "Motion JPEG".into()), + b"H264" | b"X264" | b"AVC1" | b"DAVC" | b"VSSH" => (VCodec::H264, "H.264".into()), + b"HEVC" | b"H265" | b"HEV1" | b"HVC1" | b"X265" => (VCodec::Hevc, "HEVC".into()), + b"DIVX" | b"DX50" | b"XVID" | b"FMP4" | b"MP4V" | b"M4S2" | b"3IV2" | b"DIV3" | b"MP43" | b"MP42" => { + (VCodec::Unsupported("MPEG-4 Part 2 (DivX / Xvid)".into()), "MPEG-4 Part 2 (DivX / Xvid)".into()) + } + b"DVSD" | b"DV25" | b"DV50" | b"DVHD" | b"DVSL" | b"CDVC" | b"DVCP" | b"DVPP" => (VCodec::Unsupported("DV".into()), "DV".into()), + _ => { + let name = fourcc_text(b.compression); + (VCodec::Unsupported(format!("\"{name}\"")), name) + } + } +} + +/// One picture of the video stream: its frame slot (in `scale / rate` units) and chunk. +#[derive(Clone, Copy, Debug)] +struct VFrame { + pts: i64, + chunk: usize, + key: bool, +} + +#[derive(Clone, Debug)] +enum ACodec { + Pcm(PcmConfig), + /// MPEG audio (layer 1-3) or AC-3 frames, found again across chunks. + Framed { + ts: filmcraft_mpegts::Codec, + layer: u8, + }, + Unsupported(String), +} + +fn audio_codec(w: &WaveFormat) -> (ACodec, String) { + let pcm = |float: bool| { + let bits = w.bits_per_sample; + let cfg = PcmConfig { bits, float, big_endian: false, signed: bits > 8, channels: u32::from(w.channels.max(1)), sample_rate: f64::from(w.sample_rate) }; + (ACodec::Pcm(cfg), format!("PCM {bits}-bit{}", if float { " float" } else { "" })) + }; + match w.format_tag { + 1 if matches!(w.bits_per_sample, 8 | 16 | 24 | 32) => pcm(false), + 3 if matches!(w.bits_per_sample, 32 | 64) => pcm(true), + 0x55 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::MpegAudio, layer: 3 }, "MP3".into()), + 0x50 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::MpegAudio, layer: 2 }, "MPEG Audio".into()), + 0x2000 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::Ac3, layer: 0 }, "AC-3".into()), + tag => { + let name = format!("audio format 0x{tag:04X}"); + (ACodec::Unsupported(name.clone()), name) + } + } +} + +/// A slice of a chunk: (chunk, offset in it, length). +type Piece = (usize, usize, usize); + +struct AudioState { + decoder: Option, + packets: HashMap>>>, + order: Vec, + last_decoded: Option, +} + +struct AviAudio { + stream: usize, + codec: ACodec, + /// The pieces of packet `k` are `pieces[first[k]..first[k + 1]]`. + pieces: Vec, + first: Vec, + /// Packet start positions in sample frames. + starts: Vec, + state: Mutex, +} + +pub struct AviSource { + info: MediaInfo, + bytes: crate::Src, + file: AviFile, + vstream: Option, + vcodec: VCodec, + /// Pictures in stored (decode) order. + vframes: Vec, + /// `vframes` indices in presentation order (H.264 with B-frames is stored in decode order), + /// and their presentation times. + vorder: Vec, + vorder_pts: Vec, + /// Frame slots of the video stream (where the last picture ends). + vslots: i64, + rate: FrameRate, + bottom_up: bool, + video: GopCache, + audios: Vec, +} + +/// Whether an Annex B access unit holds an H.264 IDR or HEVC IRAP picture. +fn random_access(data: &[u8], hevc: bool) -> bool { + filmcraft_bitstream::annexb_nals(data).iter().any(|n| match n.first() { + Some(h) if hevc => (16..=23).contains(&((h >> 1) & 0x3F)), + Some(h) => h & 0x1F == 5, + None => false, + }) +} + +/// The packets of a framed audio stream: every frame header found in the stream of chunks, and +/// the samples each frame decodes to. Bytes that start no frame are skipped (resynchronisation). +fn split_frames( + file: &AviFile, + bytes: &crate::Src, + s: usize, + ts: &filmcraft_mpegts::Codec, +) -> (Vec, Vec, Vec, Option) { + let Some(st) = file.streams.get(s) else { return Default::default() }; + // chunk start positions in the concatenated stream + let mut at = Vec::with_capacity(st.chunks.len()); + let mut total = 0usize; + for c in &st.chunks { + at.push(total); + total = total.saturating_add(c.size as usize); + } + let locate = |pos: usize| -> Option<(usize, usize)> { + let k = at.partition_point(|&a| a <= pos).checked_sub(1)?; + Some((k, pos - at.get(k)?)) + }; + let (mut pieces, mut first, mut samples) = (Vec::new(), Vec::new(), Vec::new()); + let mut info = None; + // a sliding window over the chunks: `win` holds the stream from `base` + let (mut win, mut base, mut next) = (Vec::::new(), 0usize, 0usize); + let mut pos = 0usize; + while pos < total { + // keep at least a header's worth (and the frame, when we know its length) in the window + while win.len() < (pos - base) + 4096 && next < st.chunks.len() { + win.extend(file.read_chunk(&bytes, s, next).unwrap_or_default()); + next += 1; + } + let Some(here) = win.get(pos - base..) else { break }; + match filmcraft_mpegts::frame_bytes(ts, here) { + Some(n) if pos + n <= total => { + if info.is_none() { + info = filmcraft_mpegts::frame_info(ts, here); + } + let k = filmcraft_mpegts::frame_info(ts, here).map_or(0, |f| f.samples); + first.push(pieces.len()); + let mut p = pos; + while p < pos + n { + let Some((c, off)) = locate(p) else { break }; + let len = (st.chunks.get(c).map_or(0, |ch| ch.size as usize) - off).min(pos + n - p); + if len == 0 { + break; + } + pieces.push((c, off, len)); + p += len; + } + samples.push(k); + pos += n; + } + _ if here.len() < 8 && next >= st.chunks.len() => break, + _ => pos += 1, + } + // drop what is behind + if pos - base > 1 << 16 { + let cut = pos - base; + win.drain(..cut.min(win.len())); + base += cut; + } + } + (pieces, first, samples, info) +} + +impl AviSource { + pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result { + Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes))) + } + + /// Open from a random-access reader: the index now, frames and audio on demand. + pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { + let bytes = crate::Src(reader); + let file = filmcraft_avi::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; + let vstream = file.streams.iter().position(|s| s.kind == StreamKind::Video && s.video.is_some() && !s.chunks.is_empty()); + let astreams: Vec = file + .streams + .iter() + .enumerate() + .filter(|(_, s)| s.kind == StreamKind::Audio && s.audio.is_some() && !s.chunks.is_empty()) + .map(|(i, _)| i) + .take(filmcraft_media::MAX_AUDIO_STREAMS) + .collect(); + if vstream.is_none() && astreams.is_empty() { + return Err(CodecError::Unsupported("no playable streams".into())); + } + // video: frame slots, pictures and key frames + let (mut vcodec, mut vframes, mut vslots, mut rate, mut bottom_up, mut video) = + (VCodec::Unsupported(String::new()), Vec::new(), 0i64, FrameRate::new(25, 1), false, None); + if let Some(vs) = vstream.and_then(|i| file.streams.get(i)) { + let b = vs.video.clone().unwrap_or_default(); + let (codec, label) = video_codec(&b); + rate = FrameRate::new(i64::from(vs.rate), i64::from(vs.scale)); + bottom_up = b.height > 0; + let (w, h) = (b.width.unsigned_abs(), b.height.unsigned_abs()); + for (k, c) in vs.chunks.iter().enumerate() { + if c.size == 0 { + continue; + } + let key = match &codec { + VCodec::H264 | VCodec::Hevc if !vs.keyframes_known => { + file.read_chunk(&bytes, vstream.unwrap_or(0), k).is_ok_and(|d| random_access(&d, codec == VCodec::Hevc)) + } + VCodec::H264 | VCodec::Hevc => c.key, + _ => true, + }; + vframes.push(VFrame { pts: i64::from(vs.start) + k as i64, chunk: k, key }); + } + vslots = i64::from(vs.start) + vs.chunks.len() as i64; + // AVI has no timestamps: H.264 with B-frames is stored in decode order, and the + // presentation order comes from the pictures' order counts. A first look at the start + // of the stream decides whether it reorders at all (reading every frame's header is the + // cost of a reordering stream only). + if codec == VCodec::H264 + && let Some(si) = vstream + { + let heads = |n: usize| -> Vec> { + vframes + .iter() + .take(n) + .map(|f| { + file.streams + .get(si) + .and_then(|s| s.chunks.get(f.chunk)) + .and_then(|c| filmcraft_media::reader::read_range(&*bytes.0, c.offset, (c.size as usize).min(8 << 10)).ok()) + .unwrap_or_default() + }) + .collect() + }; + let reorders = |p: &[i64]| p.windows(2).any(|w| w[1] < w[0]); + if crate::mxf::avc_presentation_order(&heads(300)).is_some_and(|p| reorders(&p)) + && let Some(ranks) = crate::mxf::avc_presentation_order(&heads(vframes.len())) + { + // the pictures keep the slots they occupy (empty drop-frame chunks between them + // stay), shown in their presentation order + let mut slots: Vec = vframes.iter().map(|f| f.pts).collect(); + slots.sort_unstable(); + for (f, r) in vframes.iter_mut().zip(ranks) { + if let Some(&p) = usize::try_from(r).ok().and_then(|r| slots.get(r)) { + f.pts = p; + } + } + } + } + let secs = vslots as f64 / rate.as_f64().max(1e-9); + // The rate the pictures play at: a writer that kept a fine time base (ffmpeg copying an + // MP4's 1/600) fills the slots between pictures with empty chunks, every 20th slot + // holding a picture at 30 fps. A regular spacing makes the rate; slots stay the units. + let shown = if vframes.len() > 1 { + let mut gaps: Vec = vframes.windows(2).map(|w| w[1].chunk as i64 - w[0].chunk as i64).filter(|g| *g > 0).take(1000).collect(); + gaps.sort_unstable(); + let gap = gaps.get(gaps.len() / 2).copied().unwrap_or(1).max(1); + let regular = gaps.iter().filter(|g| **g == gap).count() * 10 >= gaps.len() * 9; + if gap > 1 && regular { FrameRate::new(i64::from(vs.rate), i64::from(vs.scale).saturating_mul(gap)) } else { rate } + } else { + rate + }; + let bitrate = (secs > 0.0).then(|| (vs.chunks.iter().map(|c| u64::from(c.size)).sum::() as f64 * 8.0 / secs) as u64); + video = Some(VideoStreamInfo { + width: w, + height: h, + frame_rate: shown, + par: (1, 1), + codec: label, + pixel_format: String::new(), + color: crate::stream_color::resolve(w, h, &[]), + has_alpha: false, + bitrate, + hdr: None, + }); + vcodec = codec; + } + // audio: packets and their start positions + let mut audios = Vec::new(); + let mut audio_streams = Vec::new(); + for &s in &astreams { + let Some(st) = file.streams.get(s) else { continue }; + let w = st.audio.clone().unwrap_or_default(); + let (codec, label) = audio_codec(&w); + let mut sample_rate = w.sample_rate.max(1); + let mut channels = u32::from(w.channels.max(1)); + let (pieces, first, starts) = match &codec { + ACodec::Pcm(cfg) => { + let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize); + let mut t = 0i64; + let mut starts = Vec::with_capacity(st.chunks.len()); + for c in &st.chunks { + starts.push(t); + t = t.saturating_add((c.size as usize / block) as i64); + } + let pieces: Vec = st.chunks.iter().enumerate().map(|(k, c)| (k, 0, c.size as usize)).collect(); + let first = (0..pieces.len()).collect(); + (pieces, first, starts) + } + ACodec::Framed { ts, .. } => { + let (pieces, first, samples, info) = split_frames(&file, &bytes, s, ts); + if let Some(i) = info { + sample_rate = i.sample_rate.max(1); + channels = i.channels.max(1); + } + let mut t = 0i64; + let starts = samples + .iter() + .map(|&k| { + let s = t; + t = t.saturating_add(i64::from(k)); + s + }) + .collect(); + (pieces, first, starts) + } + ACodec::Unsupported(_) => (Vec::new(), Vec::new(), Vec::new()), + }; + // a stream that starts late (dwStart, in scale / rate units) + let delay = (i128::from(st.start) * i128::from(st.scale) * i128::from(sample_rate) / i128::from(st.rate.max(1))) as i64; + let starts: Vec = starts.into_iter().map(|s: i64| s.saturating_add(delay)).collect(); + audio_streams.push(AudioStreamInfo { + sample_rate, + channels, + codec: label, + bits_per_sample: (w.bits_per_sample > 0).then_some(u32::from(w.bits_per_sample)), + }); + let state = Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }); + audios.push(AviAudio { stream: s, codec, pieces, first, starts, state }); + } + // the longer of the picture and the sound + let vdur = if vslots > 0 { rate.tick_of(vslots) } else { Tick::ZERO }; + let adur = audios + .iter() + .zip(&audio_streams) + .filter_map(|(a, i)| { + let last = a.starts.last()?; + let tail = a.starts.len().checked_sub(2).and_then(|k| a.starts.get(k)).map_or(0, |p| last - p); + Some(Tick::from_rational(last + tail, 1, i64::from(i.sample_rate))) + }) + .max() + .unwrap_or_default(); + let info = MediaInfo { + name: name.to_string(), + kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, + duration: vdur.max(adur), + video, + audio_streams, + container: "AVI".into(), + start_timecode: None, + file_size: Some(bytes.0.len()), + }; + let mut vorder: Vec = (0..vframes.len()).collect(); + vorder.sort_by_key(|&i| vframes.get(i).map_or(0, |f| f.pts)); + let vorder_pts = vorder.iter().map(|&i| vframes.get(i).map_or(0, |f| f.pts)).collect(); + Ok(Self { info, bytes, file, vstream, vcodec, vframes, vorder, vorder_pts, vslots, rate, bottom_up, video: GopCache::new(None), audios }) + } + + fn read(&self, stream: usize, chunk: usize) -> crate::Result> { + self.file.read_chunk(&self.bytes, stream, chunk).map_err(|e| CodecError::Container(e.to_string())) + } + + /// The bytes of audio packet `k`. + fn packet(&self, a: &AviAudio, k: usize) -> crate::Result> { + let (from, to) = ( + a.first.get(k).copied().ok_or_else(|| CodecError::Container("audio packet out of range".into()))?, + a.first.get(k + 1).copied().unwrap_or(a.pieces.len()), + ); + let mut out = Vec::new(); + for &(c, off, len) in a.pieces.get(from..to).unwrap_or_default() { + let chunk = self.read(a.stream, c)?; + out.extend_from_slice(chunk.get(off..off + len).ok_or_else(|| CodecError::Container("audio chunk shorter than its index".into()))?); + } + Ok(out) + } + + fn audio_packet(&self, a: &AviAudio, st: &mut AudioState, ainfo: &AudioStreamInfo, i: usize) -> crate::Result>>> { + if let Some(p) = st.packets.get(&i) { + return Ok(p.clone()); + } + let data = self.packet(a, i)?; + let decoded = match &a.codec { + ACodec::Pcm(cfg) => decode_pcm(&data, cfg), + ACodec::Unsupported(name) => return Err(CodecError::Unsupported(name.clone())), + ACodec::Framed { ts, layer } => { + if st.decoder.is_none() { + st.decoder = Some(match ts { + filmcraft_mpegts::Codec::Ac3 => PacketDecoder::ac3()?, + _ => PacketDecoder::mpeg_audio(*layer, ainfo.sample_rate)?, + }); + } + let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?; + // non-sequential access: reset and prime with the packet before (the bit reservoir) + if st.last_decoded.is_none_or(|l| l + 1 != i) { + d.reset(); + if let Some(prev) = i.checked_sub(1).and_then(|j| self.packet(a, j).ok()) { + let _ = d.decode(&prev, 0); + } + } + let start = a.starts.get(i).copied().unwrap_or(0).max(0) as u64; + let r = d.decode(&data, start); + st.last_decoded = Some(i); + r.unwrap_or_default() + } + }; + let p = Arc::new(decoded); + st.packets.insert(i, p.clone()); + st.order.push(i); + if st.order.len() > 4096 { + let old = st.order.remove(0); + st.packets.remove(&old); + } + Ok(p) + } +} + +/// The video stream as a [`VideoSamples`] table. +struct AviVideo<'a> { + src: &'a AviSource, + stream: usize, +} + +impl VideoSamples for AviVideo<'_> { + fn count(&self) -> usize { + self.src.vframes.len() + } + fn pts(&self, i: usize) -> i64 { + self.src.vframes.get(i).map_or(0, |f| f.pts) + } + fn sync_before(&self, i: usize) -> usize { + let i = i.min(self.src.vframes.len().saturating_sub(1)); + self.src.vframes.get(..=i).and_then(|f| f.iter().rposition(|f| f.key)).unwrap_or(0) + } + fn sample_at(&self, t: i64) -> Option { + // the picture shown in slot `t`: the last one at or before it (empty chunks repeat it) + if t >= self.src.vslots { + return None; + } + let k = self.src.vorder_pts.partition_point(|&p| p <= t).checked_sub(1)?; + self.src.vorder.get(k).copied() + } + fn read(&self, i: usize) -> crate::Result> { + let f = self.src.vframes.get(i).ok_or_else(|| CodecError::Container("frame out of range".into()))?; + self.src.read(self.stream, f.chunk) + } + fn make_decoder(&self) -> crate::Result> { + let v = self.src.info.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video stream".into()))?; + let (w, h) = (v.width.min(u32::from(u16::MAX)) as u16, v.height.min(u32::from(u16::MAX)) as u16); + match &self.src.vcodec { + VCodec::Jpeg => make_video_decoder(&SampleEntry::jpeg(w, h)), + VCodec::H264 => Ok(Box::new(crate::video::H264Decoder::annexb())), + VCodec::Hevc => Ok(Box::new(crate::video::HevcDecoder::annexb())), + VCodec::Raw(f) => Ok(Box::new(RawVideoDecoder::new(*f, v.width, v.height, self.src.bottom_up)?)), + VCodec::Unsupported(name) => Err(CodecError::Unsupported(format!("no decoder for {name} video"))), + } + } +} + +impl MediaSource for AviSource { + fn info(&self) -> &MediaInfo { + &self.info + } + + fn video_frame(&self, req: FrameRequest) -> Result, MediaError> { + let s = self.vstream.ok_or(MediaError::NoStream("video"))?; + let t = req.time.max(Tick::ZERO); + let slot = |t: Tick| self.rate.frame_at(t); + let late = filmcraft_media::cancel::catch_up().map(|m| slot(t - m)); + Ok(self.video.frame_late(&AviVideo { src: self, stream: s }, slot(t), late)?) + } + + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { + self.audio_stream(0, start, frames, sample_rate) + } + + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?; + let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; + if let ACodec::Unsupported(name) = &a.codec { + return Err(MediaError::Unsupported(format!("{name} audio"))); + } + let ch = ainfo.channels.max(1) as usize; + let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1)); + let s0 = (start as f64 * ratio).floor() as i64; + let need = (frames as f64 * ratio).ceil() as i64 + 2; + let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); + let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); + while let Some(&pk_start) = a.starts.get(i) { + if pk_start >= s0 + need { + break; + } + let pk = self.audio_packet(a, &mut st, ainfo, i)?; + for (c, dst) in src.iter_mut().enumerate() { + let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue }; + for (k, v) in chan.iter().enumerate() { + let pos = pk_start + k as i64 - s0; + if let Some(d) = usize::try_from(pos).ok().and_then(|p| dst.get_mut(p)) { + *d = *v; + } + } + } + i += 1; + } + drop(st); + let frac = s0 as f64 - start as f64 * ratio; + let mut out = AudioBuffer::silence(sample_rate, ch, frames); + for (c, o) in out.channels.iter_mut().enumerate() { + let Some(s) = src.get(c) else { continue }; + for (k, d) in o.iter_mut().enumerate() { + let pos = k as f64 * ratio - frac; + let i0 = pos.floor().max(0.0) as usize; + let f = (pos - i0 as f64) as f32; + let a = s.get(i0).copied().unwrap_or(0.0); + let b = s.get(i0 + 1).copied().unwrap_or(a); + *d = a + (b - a) * f; + } + } + Ok(out) + } +} + +pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option> { + if !sniff(&bytes) { + return None; + } + Some(AviSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} + +/// [`filmcraft_media::ReaderOpener`] for AVI. +pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option> { + if !sniff(head) { + return None; + } + Some(AviSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} diff --git a/crates/codecs/src/avi_tests.rs b/crates/codecs/src/avi_tests.rs new file mode 100644 index 000000000..714eda190 --- /dev/null +++ b/crates/codecs/src/avi_tests.rs @@ -0,0 +1,232 @@ +//! AVI files made by ffmpeg (skipped without it), checked against ffmpeg's decode of the same file: +//! bit-exact where the decoder is (H.264, uncompressed video, PCM), within decoder tolerance where +//! it differs (JPEG, MP3 / MP2 / AC-3). + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use filmcraft_media::FrameRequest; + +use crate::tests::{ffmpeg, fixture_path, reference_yuv, yuv_bytes}; + +const PIC: [&str; 4] = ["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn avi(name: &str, video: &[&str], audio: &[&str]) -> Option<(std::path::PathBuf, filmcraft_media::SharedSource)> { + let mut args: Vec<&str> = PIC.to_vec(); + args.extend_from_slice(&TONE); + args.extend_from_slice(video); + args.extend_from_slice(audio); + let path = fixture_path(name, &args)?; + let bytes: Arc<[u8]> = std::fs::read(&path).ok()?.into(); + Some((path.clone(), crate::open_bytes(name, bytes).unwrap_or_else(|e| panic!("{name}: {e}")))) +} + +/// ffmpeg's decode of the first audio stream as interleaved f32 at its own rate. +fn reference_audio(path: &Path) -> Vec { + let out = Command::new(ffmpeg().unwrap()) + .args(["-v", "error", "-i"]) + .arg(path) + .args(["-map", "0:a:0", "-f", "f32le", "-c:a", "pcm_f32le", "-"]) + .output() + .unwrap(); + out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// The first `n` mono samples of stream 0 as FilmCraft decodes them. +fn ours(src: &filmcraft_media::SharedSource, n: usize) -> Vec { + src.audio(0, n, 48_000).unwrap().channels[0].clone() +} + +fn check_info(name: &str, src: &filmcraft_media::SharedSource, vcodec: &str, acodec: &str) { + let info = src.info(); + assert_eq!(info.container, "AVI", "{name}"); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (160, 120, vcodec), "{name}"); + assert_eq!(v.frame_rate.as_f64(), 25.0, "{name}"); + let a = &info.audio_streams[0]; + assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (48_000, 1, acodec), "{name}"); + let secs = info.duration.seconds(); + assert!((secs - 2.0).abs() < 0.1, "{name}: duration {secs}"); +} + +#[test] +fn h264_frames_are_bit_exact_with_random_access_and_mp3_plays() { + let Some((path, src)) = + avi("avi_h264_mp3.avi", &["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]) + else { + return; + }; + check_info("h264", &src, "H.264", "MP3"); + let reference = reference_yuv(&path, "yuv420p").unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [37, 3, 24, 49, 0, 13, 12, 11, 30, 31, 32] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame..(k + 1) * frame], "H.264 frame {k} differs from ffmpeg"); + } + // MP3 (a different decoder): the tone, in time with ffmpeg's decode + let r = reference_audio(&path); + let a = ours(&src, 48_000); + let (rms_ref, rms_diff) = rms_and_diff(&r, &a, 4_800..43_200); + // ffmpeg's test tone is at 1/8 of full scale + assert!(rms_ref > 0.05 && rms_diff < rms_ref * 0.05, "MP3: tone {rms_ref}, difference {rms_diff}"); + // random access into the middle matches sequential decoding + let mid = src.audio(24_000, 4_800, 48_000).unwrap().channels[0].clone(); + let worst = mid.iter().zip(&a[24_000..28_800]).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(worst < 0.01, "MP3 seek: {worst}"); +} + +fn rms_and_diff(reference: &[f32], ours: &[f32], range: std::ops::Range) -> (f32, f32) { + let n = range.len() as f32; + let rms = (range.clone().map(|i| reference[i].powi(2)).sum::() / n).sqrt(); + let diff = (range.map(|i| (reference[i] - ours[i]).powi(2)).sum::() / n).sqrt(); + (rms, diff) +} + +#[test] +fn mjpeg_and_pcm() { + let Some((path, src)) = avi("avi_mjpeg_pcm.avi", &["-c:v", "mjpeg", "-q:v", "3", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]) else { return }; + check_info("mjpeg", &src, "Motion JPEG", "PCM 16-bit"); + let reference = reference_yuv(&path, "rgba").unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [10, 0, 49] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap().to_rgba8(); + let r = &reference[k * frame..(k + 1) * frame]; + let mean = f.iter().zip(r).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::() as f64 / f.len() as f64; + // another JPEG decoder and chroma upsampling than ffmpeg's: close, not identical + assert!(mean < 6.0, "JPEG frame {k}: mean difference {mean}"); + } + // PCM: exactly ffmpeg's samples + let r = reference_audio(&path); + assert_eq!(ours(&src, 48_000), r[..48_000].to_vec()); +} + +#[test] +fn uncompressed_video_is_bit_exact() { + for (name, pix, codec, reference_fmt) in [ + ("avi_bgr24.avi", "bgr24", "Uncompressed RGB 24-bit", "rgba"), + ("avi_yuyv.avi", "yuyv422", "Uncompressed YUV 4:2:2", "yuv422p"), + ("avi_i420.avi", "yuv420p", "Uncompressed YUV 4:2:0", "yuv420p"), + ("avi_nv12.avi", "nv12", "Uncompressed YUV 4:2:0", "yuv420p"), + ] { + let Some((path, src)) = avi(name, &["-c:v", "rawvideo", "-pix_fmt", pix], &["-c:a", "pcm_s16le"]) else { return }; + check_info(name, &src, codec, "PCM 16-bit"); + let reference = reference_yuv(&path, reference_fmt).unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [7, 0, 49] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + let got = if reference_fmt == "rgba" { f.to_rgba8() } else { yuv_bytes(&f) }; + assert!(got == reference[k * frame..(k + 1) * frame], "{name}: frame {k} differs from ffmpeg"); + } + } +} + +#[test] +fn mp2_and_ac3_audio() { + for (name, codec, label) in [("avi_mp2.avi", "mp2", "MPEG Audio"), ("avi_ac3.avi", "ac3", "AC-3")] { + let Some((path, src)) = avi(name, &["-c:v", "mjpeg"], &["-c:a", codec, "-b:a", "192k"]) else { return }; + assert_eq!(src.info().audio_streams[0].codec, label, "{name}"); + let r = reference_audio(&path); + let a = ours(&src, 48_000); + let (rms_ref, rms_diff) = rms_and_diff(&r, &a, 4_800..43_200); + assert!(rms_ref > 0.05 && rms_diff < rms_ref * 0.05, "{name}: tone {rms_ref}, difference {rms_diff}"); + } +} + +/// Dropped frames are stored as empty chunks: their slot shows the picture before. +#[test] +fn drop_frames_hold_the_previous_picture() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=160x120:r=25:d=2", + "-vf", + "select='not(eq(n\\,10))'", + "-fps_mode", + "passthrough", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + ]; + let Some(path) = fixture_path("avi_drop.avi", &args) else { return }; + let src = crate::open_bytes("avi_drop.avi", std::fs::read(&path).unwrap().into()).unwrap(); + let rate = src.info().frame_rate(); + let at = |k: i64| src.video_frame(FrameRequest::full(rate.tick_of(k))).unwrap().to_rgba8(); + assert!(at(10) == at(9), "the dropped slot holds frame 9"); + assert!(at(11) != at(9)); +} + +/// DivX / Xvid has no decoder yet: the file opens, the codec is named, the picture reports it, and +/// the sound plays. +#[test] +fn mpeg4_part2_is_named_and_its_audio_plays() { + let Some((_, src)) = avi("avi_xvid.avi", &["-c:v", "mpeg4", "-vtag", "XVID"], &["-c:a", "pcm_s16le"]) else { return }; + assert_eq!(src.info().video.as_ref().unwrap().codec, "MPEG-4 Part 2 (DivX / Xvid)"); + let e = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap_err().to_string(); + assert!(e.contains("MPEG-4 Part 2"), "{e}"); + assert!(ours(&src, 4_800).iter().any(|s| s.abs() > 0.1)); +} + +/// A Motion JPEG frame without Huffman tables (the AVI1 convention) decodes with the standard ones: +/// exactly as the same frame with them. +#[test] +fn jpeg_without_huffman_tables_uses_the_standard_ones() { + let Some(path) = fixture_path( + "default_huffman.jpg", + &["-f", "lavfi", "-i", "testsrc2=s=160x120", "-frames:v", "1", "-c:v", "mjpeg", "-huffman", "default", "-q:v", "3"], + ) else { + return; + }; + let jpeg = std::fs::read(&path).unwrap(); + // remove every DHT segment + let mut stripped = jpeg[..2].to_vec(); + let mut at = 2; + while at + 4 <= jpeg.len() && jpeg[at] == 0xFF && jpeg[at + 1] != 0xDA { + let len = u16::from_be_bytes([jpeg[at + 2], jpeg[at + 3]]) as usize; + if jpeg[at + 1] != 0xC4 { + stripped.extend_from_slice(&jpeg[at..at + 2 + len]); + } + at += 2 + len; + } + stripped.extend_from_slice(&jpeg[at..]); + assert!(stripped.len() < jpeg.len() && !stripped.windows(2).any(|w| w == [0xFF, 0xC4])); + use crate::video::VideoDecoder; + let mut d = crate::video::MjpegDecoder; + let a = d.decode(&jpeg, 0).unwrap().remove(0).frame.to_rgba8(); + let b = d.decode(&stripped, 0).unwrap().remove(0).frame.to_rgba8(); + assert!(a == b); + // the inserted segment is 418 bytes and well-formed: 4 tables of 12, 12, 162, 162 codes + let with = crate::video::with_default_huffman(&stripped); + assert_eq!(with.len(), stripped.len() + 420); +} + +/// H.264 with B-frames copied from an MP4 whose time base is 1/600 (ffmpeg keeps it): 600 frame +/// slots a second, a picture in every 24th at 25 fps, empty drop chunks between. The pictures stay +/// at their times in presentation order, and the stream reports the rate they play at. +#[test] +fn copied_h264_keeps_its_timing_with_a_fine_time_base() { + let mp4 = + ["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2", "-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p", "-video_track_timescale", "600"]; + let Some(src_mp4) = fixture_path("avi_copy_source.mp4", &mp4) else { return }; + let src_path = src_mp4.to_string_lossy().into_owned(); + let Some(path) = fixture_path("avi_copy_600.avi", &["-i", &src_path, "-c:v", "copy", "-bsf:v", "h264_mp4toannexb", "-f", "avi"]) else { return }; + let src = crate::open_bytes("avi_copy_600.avi", std::fs::read(&path).unwrap().into()).unwrap(); + let v = src.info().video.clone().unwrap(); + assert_eq!(v.frame_rate.as_f64(), 25.0, "the rate the pictures play at, not the 600 slots"); + let reference = reference_yuv(&path, "yuv420p").unwrap(); + let frame = reference.len() / 50; + for k in [37usize, 3, 24, 49, 0, 13, 12, 11, 30] { + let at = filmcraft_time::Tick::from_rational(k as i64, 1, 25); + let f = src.video_frame(FrameRequest::full(at)).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame..(k + 1) * frame], "picture {k} at {:.2} s differs from ffmpeg", k as f64 / 25.0); + } + // between pictures, the one before holds + let mid = src.video_frame(FrameRequest::full(filmcraft_time::Tick::from_rational(1, 1, 50))).unwrap(); + assert!(yuv_bytes(&mid) == reference[0..frame]); +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index a31da3a7e..b728820f4 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -13,6 +13,8 @@ //! - [`MpegSource`]: MPEG-2 transport streams (`.ts`, `.m2ts`, `.mts`), program streams (`.mpg`, //! `.vob`, `.mod`) and MPEG-1/2 video elementary streams: MPEG-1/2, H.264 and HEVC video; MPEG //! audio, AAC (ADTS / LATM), AC-3 and LPCM. +//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM, +//! MP3 / MP2 and AC-3 audio. //! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …). //! - [`openers`]: the openers to register with the engine's media pool. @@ -20,6 +22,7 @@ pub mod apv; pub mod audio; +pub mod avi; pub mod gop; pub mod hw; mod hw_frame; @@ -35,6 +38,7 @@ use std::sync::{Arc, RwLock}; pub use apv::ApvSource; pub use audio::AudioFileSource; +pub use avi::AviSource; pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders}; pub use mkv::MkvSource; pub use mp4::Mp4Source; @@ -124,12 +128,22 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result< /// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and /// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio). pub fn openers() -> Vec { - vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, audio::opener] + vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener] } fn reader_registry() -> &'static RwLock> { static R: std::sync::OnceLock>> = std::sync::OnceLock::new(); - R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener, apv::reader_opener])) + R.get_or_init(|| { + RwLock::new(vec![ + mp4::reader_opener, + mkv::reader_opener, + mxf::reader_opener, + ogg::reader_opener, + mpeg::reader_opener, + apv::reader_opener, + avi::reader_opener, + ]) + }) } /// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on @@ -160,6 +174,15 @@ impl filmcraft_isobmff::ByteSource for Src { } } +impl filmcraft_avi::ByteSource for Src { + fn len(&self) -> u64 { + self.0.len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.0.read_at(offset, buf) + } +} + impl filmcraft_matroska::ByteSource for Src { fn len(&self) -> u64 { self.0.len() @@ -177,6 +200,8 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>) -> std::result::Result Option<&'static str> { +pub(crate) fn ffmpeg() -> Option<&'static str> { ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].into_iter().find(|p| std::path::Path::new(p).exists()) } @@ -231,13 +231,13 @@ fn matroska_prores_pcm() { /// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing /// (`None` when ffmpeg or the encoder is unavailable). -fn fixture_path(name: &str, args: &[&str]) -> Option { +pub(crate) fn fixture_path(name: &str, args: &[&str]) -> Option { fixture(name, args)?; Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs").join(name)) } /// ffmpeg's decode of `src` as raw planar video (the oracle). -fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { +pub(crate) fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { let name = format!("{}.{pix_fmt}.yuv", src.file_name()?.to_str()?); let path = src.to_str()?; let b = fixture(&name, &["-i", path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt])?; @@ -245,7 +245,7 @@ fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { } /// Planes of a decoded YUV frame as little-endian bytes (ffmpeg rawvideo layout). -fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { +pub(crate) fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { match &f.data { filmcraft_frame::PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(), filmcraft_frame::PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(), diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 86b21b7d9..8b9c31964 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -178,13 +178,69 @@ pub fn hvcc_length_size_and_tid(hvcc: &[u8]) -> (usize, Option) { /// A factory returns `None` when it does not handle the entry. pub type VideoDecoderFactory = fn(&SampleEntry) -> Option>>; +/// The typical Huffman tables of ITU-T T.81 Annex K.3 as one DHT segment (luminance and +/// chrominance, DC and AC): what a Motion JPEG frame without its own tables is coded with (AVI's +/// "MJPG", the AVI1 convention of capture cards and cameras). +const DEFAULT_DHT: &[u8] = &[ + 0xFF, 0xC4, 0x01, 0xA2, // + 0x00, 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // + 0x01, 0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // + 0x10, 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7D, // + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, // + 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, // + 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, // + 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, // + 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, // + 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, // + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, // + 0x11, 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77, // + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, // + 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, // + 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, // + 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, // + 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, // + 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, // + 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, +]; + +/// A JPEG with the [`DEFAULT_DHT`] inserted before its first scan when it defines no Huffman +/// table of its own (else unchanged, borrowed). +pub fn with_default_huffman(jpeg: &[u8]) -> std::borrow::Cow<'_, [u8]> { + use std::borrow::Cow; + if jpeg.get(..2) != Some(&[0xFF, 0xD8]) { + return Cow::Borrowed(jpeg); + } + // walk the marker segments up to the start of scan + let mut at = 2usize; + while let (Some(&0xFF), Some(&m)) = (jpeg.get(at), jpeg.get(at + 1)) { + match m { + 0xFF => at += 1, // fill byte + 0xC4 => return Cow::Borrowed(jpeg), + 0xDA => { + let mut out = Vec::with_capacity(jpeg.len() + DEFAULT_DHT.len()); + out.extend_from_slice(jpeg.get(..at).unwrap_or_default()); + out.extend_from_slice(DEFAULT_DHT); + out.extend_from_slice(jpeg.get(at..).unwrap_or_default()); + return Cow::Owned(out); + } + 0xD0..=0xD9 | 0x01 => at += 2, + _ => { + let Some(len) = jpeg.get(at + 2..at + 4).map(|b| usize::from(u16::from_be_bytes([b[0], b[1]]))) else { break }; + at += 2 + len; + } + } + } + Cow::Borrowed(jpeg) +} + /// Motion-JPEG / Photo-JPEG (each sample is a complete JPEG). pub struct MjpegDecoder; impl VideoDecoder for MjpegDecoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let sample = with_default_huffman(sample); // mjpa samples may contain two fields; decode the first image (field-merging lands with interlace support). - let img = image::load_from_memory_with_format(sample, image::ImageFormat::Jpeg).map_err(|e| CodecError::Decode(e.to_string()))?; + let img = image::load_from_memory_with_format(&sample, image::ImageFormat::Jpeg).map_err(|e| CodecError::Decode(e.to_string()))?; let rgba = img.to_rgba8(); let (w, h) = rgba.dimensions(); Ok(vec![DecodedFrame { pts, frame: VideoFrame::rgba8(w, h, rgba.into_raw()), draft: false }]) @@ -1017,3 +1073,165 @@ mod disposable_tests { assert!(!hevc_disposable(&sample(&[&nal(0, 1)]), 4, None)); } } + +/// Uncompressed video layouts (AVI `BI_RGB` and YUV FourCCs). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RawFormat { + /// 24-bit B, G, R; rows padded to 4 bytes. + Bgr24, + /// 32-bit B, G, R, X. + Bgrx32, + /// Packed 4:2:2: Y0 U Y1 V (`YUY2`, `YUYV`). + Yuy2, + /// Packed 4:2:2: U Y0 V Y1 (`UYVY`, `2vuy`). + Uyvy, + /// Planar 4:2:0: Y, U, V (`I420`, `IYUV`). + I420, + /// Planar 4:2:0: Y, V, U (`YV12`). + Yv12, + /// Y plane, interleaved U V at half resolution (`NV12`). + Nv12, + /// Luma only (`Y800`, `GREY`, `Y8 `). + Gray8, +} + +impl RawFormat { + /// The layout of an AVI video stream: `BI_RGB` with its bit count, or a YUV FourCC. + pub fn from_avi(compression: [u8; 4], bit_count: u16) -> Option { + if matches!(u32::from_le_bytes(compression), 0 | 3) { + return match bit_count { + 24 => Some(RawFormat::Bgr24), + 32 => Some(RawFormat::Bgrx32), + _ => None, + }; + } + let mut upper = compression; + upper.make_ascii_uppercase(); + Some(match &upper { + b"YUY2" | b"YUYV" | b"YUNV" | b"V422" => RawFormat::Yuy2, + b"UYVY" | b"2VUY" | b"UYNV" | b"HDYC" => RawFormat::Uyvy, + b"I420" | b"IYUV" => RawFormat::I420, + b"YV12" => RawFormat::Yv12, + b"NV12" => RawFormat::Nv12, + b"Y800" | b"GREY" | b"GRAY" | b"Y8 " => RawFormat::Gray8, + _ => return None, + }) + } + + pub fn label(self) -> &'static str { + match self { + RawFormat::Bgr24 => "Uncompressed RGB 24-bit", + RawFormat::Bgrx32 => "Uncompressed RGB 32-bit", + RawFormat::Yuy2 | RawFormat::Uyvy => "Uncompressed YUV 4:2:2", + RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 => "Uncompressed YUV 4:2:0", + RawFormat::Gray8 => "Uncompressed greyscale", + } + } +} + +/// Uncompressed frames (each sample one picture of a [`RawFormat`]). +pub struct RawVideoDecoder { + format: RawFormat, + width: u32, + height: u32, + /// RGB rows are stored bottom-up (a positive `BITMAPINFOHEADER` height). + bottom_up: bool, +} + +impl RawVideoDecoder { + pub fn new(format: RawFormat, width: u32, height: u32, bottom_up: bool) -> Result { + if width == 0 || height == 0 || width > 16_384 || height > 16_384 { + return Err(CodecError::Unsupported(format!("{width}x{height} uncompressed video"))); + } + Ok(Self { format, width, height, bottom_up }) + } + + fn yuv(&self, planes: [Vec; 3], chroma: filmcraft_frame::Chroma) -> VideoFrame { + use std::sync::Arc; + let [y, u, v] = planes; + VideoFrame { + width: self.width, + height: self.height, + data: filmcraft_frame::PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha: None }, + color: vui_color(self.width, self.height, 2, 2, false), + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + } + } + + fn picture(&self, s: &[u8]) -> Result { + let (w, h) = (self.width as usize, self.height as usize); + let short = || CodecError::Decode(format!("{} frame of {} bytes is too short for {w}x{h}", self.format.label(), s.len())); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + Ok(match self.format { + RawFormat::Bgr24 | RawFormat::Bgrx32 => { + let bpp = if self.format == RawFormat::Bgr24 { 3 } else { 4 }; + let stride = (w * bpp).div_ceil(4) * 4; + if s.len() < stride * h { + return Err(short()); + } + let mut rgba = Vec::with_capacity(w * h * 4); + for row in 0..h { + let src = if self.bottom_up { h - 1 - row } else { row }; + for px in s.get(src * stride..src * stride + w * bpp).unwrap_or_default().chunks_exact(bpp) { + rgba.extend_from_slice(&[px[2], px[1], px[0], 255]); + } + } + VideoFrame::rgba8(self.width, self.height, rgba) + } + RawFormat::Yuy2 | RawFormat::Uyvy => { + let stride = cw * 4; + if s.len() < stride * h { + return Err(short()); + } + let (yo, uo, vo, y2) = if self.format == RawFormat::Yuy2 { (0, 1, 3, 2) } else { (1, 0, 2, 3) }; + let (mut y, mut u, mut v) = (Vec::with_capacity(w * h), Vec::with_capacity(cw * h), Vec::with_capacity(cw * h)); + for row in s.chunks_exact(stride).take(h) { + for (k, q) in row.as_chunks::<4>().0.iter().enumerate() { + y.push(q[yo]); + if 2 * k + 1 < w { + y.push(q[y2]); + } + u.push(q[uo]); + v.push(q[vo]); + } + } + self.yuv([y, u, v], filmcraft_frame::Chroma::C422) + } + RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 | RawFormat::Gray8 => { + let luma = w * h; + let need = if self.format == RawFormat::Gray8 { luma } else { luma + 2 * cw * ch }; + if s.len() < need { + return Err(short()); + } + let y = s.get(..luma).unwrap_or_default().to_vec(); + let (u, v) = match self.format { + RawFormat::Gray8 => (vec![128; cw * ch], vec![128; cw * ch]), + RawFormat::Nv12 => s.get(luma..luma + 2 * cw * ch).unwrap_or_default().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).unzip(), + _ => { + let a = s.get(luma..luma + cw * ch).unwrap_or_default().to_vec(); + let b = s.get(luma + cw * ch..luma + 2 * cw * ch).unwrap_or_default().to_vec(); + if self.format == RawFormat::Yv12 { (b, a) } else { (a, b) } + } + }; + self.yuv([y, u, v], filmcraft_frame::Chroma::C420) + } + }) + } +} + +impl VideoDecoder for RawVideoDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + Ok(vec![DecodedFrame { pts, frame: self.picture(sample)?, draft: false }]) + } + fn flush(&mut self) -> Vec { + Vec::new() + } + fn reset(&mut self) {} + fn name(&self) -> &str { + self.format.label() + } + fn intra_only(&self) -> bool { + true + } +} diff --git a/crates/engine/src/avi_import_tests.rs b/crates/engine/src/avi_import_tests.rs new file mode 100644 index 000000000..43d8a3018 --- /dev/null +++ b/crates/engine/src/avi_import_tests.rs @@ -0,0 +1,58 @@ +//! #598: AVI files import (ffmpeg-made, skipped without it): the Project item describes the +//! streams, a sequence made from it plays picture and sound, and the Media Browser's streaming +//! probe opens it without reading it whole. + +use serde_json::json; + +use crate::Session; +use filmcraft_project::ItemId; + +fn fixture() -> Option { + let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("avi import")?; + let path = filmcraft_testkit::fixtures_dir("engine/avi").join("h264_mp3.avi"); + filmcraft_testkit::fixtures::generate(&path, |tmp| { + std::process::Command::new(&ffmpeg) + .args([ + "-y", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=160x120:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + ]) + .args(["-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p", "-c:a", "libmp3lame", "-f", "avi"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +#[test] +fn avi_imports_plays_and_probes() { + let Some(path) = fixture() else { return }; + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(r["errors"].as_array().is_none_or(|e| e.is_empty()), "{r}"); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let info = s.project.item(item).unwrap().as_media().unwrap().info.clone(); + assert_eq!(info.container, "AVI"); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (160, 120, "H.264")); + assert_eq!(info.audio_streams.first().map(|a| a.codec.as_str()), Some("MP3")); + assert!((info.duration.seconds() - 2.0).abs() < 0.1, "{}", info.duration.seconds()); + // a sequence from the clip has its picture and sound + s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap(); + let q = s.active_sequence().unwrap().clone(); + assert!(!q.video_tracks[0].items.is_empty() && !q.audio_tracks[0].items.is_empty()); + let src = s.source(item).unwrap(); + assert!(src.video_frame(filmcraft_media::FrameRequest::full(filmcraft_time::Tick(filmcraft_time::TICKS_PER_SECOND))).is_ok()); + assert!(src.audio(48_000, 4_800, 48_000).unwrap().channels[0].iter().any(|v| v.abs() > 0.05)); + // the Media Browser's probe streams it (index now, frames on demand) + let probed = s.media.probe_file(&path.to_string_lossy(), &*s.services).unwrap(); + assert_eq!(probed.info().container, "AVI"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32a..96ae97618 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1046,6 +1046,8 @@ mod audio_placement_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] +mod avi_import_tests; +#[cfg(test)] mod clip_ops_tests; #[cfg(test)] mod color_tests; diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index f82aa78bd..06147b640 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -54,7 +54,7 @@ impl Default for MediaPool { } /// Largest file [`MediaPool::probe_file`] reads whole when no streaming reader takes it (WAV, -/// MP3 and other byte-opened formats, or an unsupported container such as AVI). +/// MP3 and other byte-opened formats, or an unsupported container). pub const PROBE_WHOLE_FILE_MAX: u64 = 64 * 1024 * 1024; /// Cache key of a media clip's full-resolution reference ("" matches anything: sources inserted diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 7d5679692..97b42d964 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -175,6 +175,20 @@ pub struct FrameInfo { const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350]; /// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3). +/// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or +/// `None` without a valid header there. For containers that store frames in chunks that need not +/// follow them (AVI): the frames are found again with it. +pub fn frame_bytes(codec: &Codec, b: &[u8]) -> Option { + let sync = match codec { + Codec::MpegAudio => AudioSync::Mpa, + Codec::AacAdts => AudioSync::Adts, + Codec::AacLatm => AudioSync::Loas, + Codec::Ac3 | Codec::Eac3 => AudioSync::Ac3, + _ => return None, + }; + frame_len(sync, b) +} + pub fn frame_info(codec: &Codec, b: &[u8]) -> Option { match codec { Codec::MpegAudio => { diff --git a/crates/mpegts/src/lib.rs b/crates/mpegts/src/lib.rs index 074d39512..4330ebcb5 100644 --- a/crates/mpegts/src/lib.rs +++ b/crates/mpegts/src/lib.rs @@ -21,7 +21,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] mod es; -pub use es::{FrameInfo, frame_info}; +pub use es::{FrameInfo, frame_bytes, frame_info}; mod pes; mod ps; mod ts; From cbd06b6c9a3fe2309ade2522baefd7643f41b2f6 Mon Sep 17 00:00:00 2001 From: awe <156385337+aw4e@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:22:02 +0700 Subject: [PATCH 007/157] Project panel: dragging one of several selected items places them all, back to back, in one undo step timeline.place takes items: [...] and file.newSequence takes fromItems: [...]; both run as one undo step through the new Session::edit_group, which rolls back if any item fails. Files dropped from the OS use the same command instead of one timeline.place per file, so they are one undo step too. --- crates/engine/src/commands.rs | 195 +++++++++++++++-------- crates/engine/src/lib.rs | 35 ++++ crates/engine/src/place_items_tests.rs | 109 +++++++++++++ crates/ui-egui/src/panels/mod.rs | 11 +- crates/ui-egui/src/panels/timeline.rs | 89 +++-------- crates/ui-egui/tests/project_panel_ui.rs | 37 +++++ crates/ui-egui/tests/timeline_drop_ui.rs | 24 +++ 7 files changed, 366 insertions(+), 134 deletions(-) create mode 100644 crates/engine/src/place_items_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a6..05144a41c 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -577,6 +577,110 @@ pub(crate) fn ensure_audio_tracks(p: &mut Project, seq_id: ItemId, first: usize, Ok(p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default()) } +/// `timeline.place` for one item (`item`). +fn place_one(s: &mut Session, p: &Value) -> Result { + let stream_flag = |key: &str| match p.get(key) { + None => Ok(true), + Some(Value::Bool(v)) => Ok(*v), + _ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))), + }; + let (video, audio) = (stream_flag("video")?, stream_flag("audio")?); + if !video && !audio { + return Err(bad("timeline.place", "enable video, audio, or both")); + } + let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; + let at = time_p(s, p, "").unwrap_or(s.playhead()); + let tg = s.targeting(); + let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; + let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); + // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that + // names an audio track as `track` asks for the sound alone, on that track. + let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) { + (Some(t), Some(_)) if is_audio(t) => { + return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); + } + (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), + (Some(t), None) if is_audio(t) && !pi.has_audio() => { + return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); + } + (Some(t), None) if is_audio(t) => (None, Some(t)), + (Some(_), None) if !pi.has_video() => { + return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); + } + (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), + }; + let v = v.filter(|_| video); + let a = a.filter(|_| audio); + if p.get("video").is_some() && video && pi.has_video() && v.is_none() { + return Err(bad("timeline.place", "no video destination: add or enable a video track")); + } + if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { + return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); + } + let full = match &pi.kind { + // Settings ▸ Timeline ▸ Still Image Default Duration + ItemKind::Media(m) + if matches!(m.info.kind, filmcraft_media::MediaKind::Still) + || (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) => + { + s.prefs.timeline.still_duration(s.sequence_rate()) + } + _ => pi.duration(), + }; + let (mi, mo) = match &pi.kind { + ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), + ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), + _ => (None, None), + }; + let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default(); + let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin); + let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?; + Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) +} + +/// Most items one `timeline.place` with `items` takes: a larger list is refused, not cut. +const MAX_PLACE_ITEMS: usize = 10_000; + +/// `timeline.place` for `items`: the first at the given time, each next one where the previous one +/// ends, all as one undo step. `track`, `audioTrack`, `insert`, `video` and `audio` apply to every +/// item; `sourceIn` and `duration` name one item's range, so they need `item`. +fn place_items(s: &mut Session, p: &Value, items: &Value) -> Result { + let ids: Vec = items + .as_array() + .ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))? + .iter() + .map(|v| v.as_u64().map(ItemId).ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))) + .collect::>()?; + if p.get("item").is_some() || p.get("sourceIn").is_some() || p.get("duration").is_some() { + return Err(bad("timeline.place", "give `item` or `items`, and no `sourceIn` or `duration` with `items`")); + } + if ids.is_empty() || ids.len() > MAX_PLACE_ITEMS { + return Err(bad("timeline.place", format!("`items` needs 1 to {MAX_PLACE_ITEMS} item ids"))); + } + let mut at = time_p(s, p, "").unwrap_or(s.playhead()); + s.edit_group("Place Clips", |s| { + let mut placed = Vec::new(); + for item in ids { + let mut one = p.clone(); + if let Some(o) = one.as_object_mut() { + o.remove("items"); + for k in ["time", "frame", "seconds"] { + o.remove(k); + } + o.insert("item".into(), json!(item.0)); + o.insert("time".into(), json!(at.0)); + } + let r = place_one(s, &one)?; + let clips: Vec = r["clips"].as_array().map(|a| a.iter().filter_map(|c| c.as_u64().map(ClipId)).collect()).unwrap_or_default(); + if let Some(end) = s.active_sequence().and_then(|q| clips.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()) { + at = end; + } + placed.push(json!(clips.iter().map(|c| c.0).collect::>())); + } + Ok(json!({"clips": placed.iter().flat_map(|c| c.as_array().cloned().unwrap_or_default()).collect::>(), "placed": placed})) + }) +} + /// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source). pub(crate) fn place_item( s: &mut Session, @@ -774,11 +878,22 @@ fn build() -> Vec { "Sequence…", ["File", "New"], Some("Cmd+N"), - r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#, + r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?,"fromItems":[itemId]?}"#, always, - |s, p| { + |s, p| s.edit_group("New Sequence", |s| { + // `fromItems` is `fromItem` for a list: the first sets the sequence, the rest follow it back to back + let mut from_ids: Vec = match p.get("fromItems") { + None => Vec::new(), + Some(l) => l + .as_array() + .and_then(|a| a.iter().map(|v| v.as_u64().map(ItemId)).collect()) + .ok_or_else(|| bad("file.newSequence", "`fromItems` must be a list of item ids"))?, + }; + if let Some(one) = item_p(p, "fromItem") { + from_ids.insert(0, one); + } let mut st = SequenceSettings::default(); - if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) { + if let Some(from) = from_ids.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media()) { st = default_seq_settings_for(from); } if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? { @@ -828,14 +943,19 @@ fn build() -> Vec { } Ok(id) })?; - if let Some(from) = item_p(p, "fromItem") { + if let Some((&from, rest)) = from_ids.split_first() { let dur = s.project.item(from).map(|i| i.duration()).unwrap_or_default(); let tg = s.targeting(); - place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + let ids = + place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + if !rest.is_empty() { + let end = s.active_sequence().and_then(|q| ids.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()).unwrap_or_default(); + place_items(s, &json!({"time": end.0}), &json!(rest.iter().map(|i| i.0).collect::>()))?; + } } s.events.push(crate::Event::OpenSequence(id)); Ok(json!({"sequence": id.0})) - } + }) ), cmd!("file.newBin", "Bin", ["File", "New"], Some("Cmd+B"), r#"{"name":str,"parent":binId?}"#, always, |s, p| { let n = str_p(p, "name").unwrap_or("New Bin").to_string(); @@ -2074,66 +2194,11 @@ fn build() -> Vec { "Place Clip", [], None, - r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, + r#"{"item":id|"items":[id] (back to back, one undo step),"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, has_seq, - |s, p| { - let stream_flag = |key: &str| match p.get(key) { - None => Ok(true), - Some(Value::Bool(v)) => Ok(*v), - _ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))), - }; - let (video, audio) = (stream_flag("video")?, stream_flag("audio")?); - if !video && !audio { - return Err(bad("timeline.place", "enable video, audio, or both")); - } - let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; - let at = time_p(s, p, "").unwrap_or(s.playhead()); - let tg = s.targeting(); - let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; - let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); - // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that - // names an audio track as `track` asks for the sound alone, on that track. - let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) { - (Some(t), Some(_)) if is_audio(t) => { - return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); - } - (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), - (Some(t), None) if is_audio(t) && !pi.has_audio() => { - return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); - } - (Some(t), None) if is_audio(t) => (None, Some(t)), - (Some(_), None) if !pi.has_video() => { - return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); - } - (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), - }; - let v = v.filter(|_| video); - let a = a.filter(|_| audio); - if p.get("video").is_some() && video && pi.has_video() && v.is_none() { - return Err(bad("timeline.place", "no video destination: add or enable a video track")); - } - if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { - return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); - } - let full = match &pi.kind { - // Settings ▸ Timeline ▸ Still Image Default Duration - ItemKind::Media(m) - if matches!(m.info.kind, filmcraft_media::MediaKind::Still) - || (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) => - { - s.prefs.timeline.still_duration(s.sequence_rate()) - } - _ => pi.duration(), - }; - let (mi, mo) = match &pi.kind { - ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), - ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), - _ => (None, None), - }; - let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default(); - let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin); - let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?; - Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) + |s, p| match p.get("items") { + Some(items) => place_items(s, p, items), + None => place_one(s, p), } ), cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32a..303102e56 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -799,6 +799,39 @@ impl Session { Ok(r) } + /// Run several undoable edits as one undo step labelled `label`. If `f` fails, every edit it made + /// is rolled back and the history is as before. + pub fn edit_group(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let (project, state, undo, redo, limit) = + (self.project.clone(), self.state.clone(), self.history.undo.len(), self.history.redo.clone(), self.history.limit); + // keep every step the group makes, however many, so the first one can be found afterwards + self.history.limit = usize::MAX; + let r = f(self); + self.history.limit = limit; + let first = self.history.undo.get(undo).map(|e| e.1.clone()); + match (r, first) { + (Ok(v), Some(first)) => { + self.history.undo.truncate(undo); + self.history.undo.push((label.to_string(), first)); + if self.history.undo.len() > limit { + self.history.undo.remove(0); + } + self.history.merge_key = None; + Ok(v) + } + (Ok(v), None) => Ok(v), + (Err(e), _) => { + self.history.undo.truncate(undo); + self.history.redo = redo; + self.history.merge_key = None; + self.project = project; + self.state = state; + self.bump(); + Err(e) + } + } + } + /// Edit the active sequence with the edit-algebra context. pub fn edit_sequence(&mut self, label: &str, f: impl FnOnce(&mut Sequence, &mut EditCtx, &mut EditorState) -> Result) -> Result { self.edit_sequence_as(label, None, f) @@ -1088,6 +1121,8 @@ mod panels_tests; #[cfg(test)] mod par_tests; #[cfg(test)] +mod place_items_tests; +#[cfg(test)] mod presets_tests; #[cfg(test)] mod previews_tests; diff --git a/crates/engine/src/place_items_tests.rs b/crates/engine/src/place_items_tests.rs new file mode 100644 index 000000000..ac0088cbd --- /dev/null +++ b/crates/engine/src/place_items_tests.rs @@ -0,0 +1,109 @@ +//! `timeline.place` with `items` and `file.newSequence` with `fromItems`: several items back to +//! back, as one undo step. + +use filmcraft_project::ItemId; +use serde_json::json; + +use crate::Session; + +/// A session on an empty sequence, and the ids of three media items with picture and sound. +fn three() -> (Session, Vec) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let items: Vec = s.project.items.values().filter(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).take(3).collect(); + assert!(items.len() >= 2, "the demo project has media to place"); + s.execute("file.newSequence", json!({"name": "Back to back", "video": 2, "audio": 2})).unwrap(); + (s, items) +} + +fn ends(s: &Session) -> Vec<(i64, i64)> { + let q = s.active_sequence().unwrap(); + q.video_tracks[0].items.iter().map(|i| (i.start.0, i.end().0)).collect() +} + +/// Dragging several selected items placed them one by one, each one an undo step. +#[test] +fn items_land_back_to_back_in_one_undo_step() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"items": items.iter().map(|i| i.0).collect::>(), "time": 0})).unwrap(); + assert_eq!(r["placed"].as_array().unwrap().len(), items.len()); + let spans = ends(&s); + assert_eq!(spans.len(), items.len()); + assert_eq!(spans[0].0, 0); + for w in spans.windows(2) { + assert_eq!(w[0].1, w[1].0, "each item starts where the one before ends"); + } + assert_eq!(s.history.undo.len(), steps + 1, "one undo step for all of them"); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Place Clips")); + s.undo(); + assert!(ends(&s).is_empty(), "one undo takes them all off"); + s.redo(); + assert_eq!(ends(&s), spans); +} + +/// One bad item in the list leaves the sequence and the history as they were. +#[test] +fn a_failing_item_places_nothing() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let list = vec![items[0].0, u64::MAX - 1]; + assert!(s.execute("timeline.place", json!({"items": list, "time": 0})).is_err()); + assert!(ends(&s).is_empty()); + assert_eq!(s.history.undo.len(), steps); +} + +/// `items` is checked: not a list, empty, mixed with `item`, or with one item's range. +#[test] +fn items_are_checked() { + let (mut s, items) = three(); + let one = items[0].0; + for p in [ + json!({"items": one}), + json!({"items": []}), + json!({"items": ["x"]}), + json!({"items": [one], "item": one}), + json!({"items": [one], "duration": 5}), + json!({"items": [one], "sourceIn": 5}), + ] { + assert!(s.execute("timeline.place", p.clone()).is_err(), "{p}"); + } + assert!(ends(&s).is_empty()); +} + +/// A new sequence from several items is one undo step: the sequence and its clips go together. +#[test] +fn new_sequence_from_items_is_one_undo_step() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let sequences = s.project.sequences().count(); + s.execute("file.newSequence", json!({"fromItems": items.iter().map(|i| i.0).collect::>()})).unwrap(); + assert_eq!(ends(&s).len(), items.len()); + assert_eq!(s.history.undo.len(), steps + 1); + s.undo(); + assert_eq!(s.project.sequences().count(), sequences); + assert!(s.execute("file.newSequence", json!({"fromItems": 3})).is_err()); +} + +/// Times at the edge of the number range are refused or placed, never a panic, and a refusal +/// leaves the sequence and the history as they were. +#[test] +fn hostile_times_never_panic_and_leave_nothing_half_placed() { + let (mut s, items) = three(); + let ids: Vec = items.iter().map(|i| i.0).collect(); + for t in [i64::MAX, i64::MAX - 5, i64::MIN, -1] { + let steps = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"items": ids, "time": t})); + if r.is_err() { + assert_eq!(s.history.undo.len(), steps, "{t}"); + assert!(ends(&s).is_empty(), "{t}"); + } else { + assert_eq!(s.history.undo.len(), steps + 1, "{t}"); + s.undo(); + } + } + // zero-length and many items: capped, not unbounded + let many = vec![items[0].0; 10_001]; + assert!(s.execute("timeline.place", json!({"items": many, "time": 0})).is_err()); + assert!(ends(&s).is_empty()); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fbb..9da3f04b3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -158,6 +158,12 @@ pub fn dragged_project_item(ui: &egui::Ui) -> Option { _ => None, } } +/// The items a drag from the Project panel carries: the whole selection when the dragged item is +/// one of several selected, otherwise just the dragged item. +pub fn dragged_selection(app: &FilmcraftApp, item: ItemId) -> Vec { + let sel = &app.session.state.project_selection; + if sel.len() > 1 && sel.contains(&item) { sel.clone() } else { vec![item] } +} pub fn dragged_effect(ui: &egui::Ui) -> Option { match payload(ui) { Some(DragPayload::Effect(e)) => Some(e), @@ -174,7 +180,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { let ctx = ui.ctx(); if let Some(p) = ctx.pointer_hover_pos() { let label = match &pl { - DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + DragPayload::Item(i) => match dragged_selection(app, *i).len() { + 1 => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + n => format!("{n} items"), + }, DragPayload::Source(s) => { let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source"); let mode = match (s.video, s.audio) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..3c34aa8d3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -568,7 +568,11 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { && ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.any_released()) { - let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); + // a multi-selection makes the sequence from its first item and follows it, back to back, in one undo step + let items: Vec = crate::panels::dragged_selection(app, item).iter().map(|i| i.0).collect(); + if let Err(e) = app.session.execute("file.newSequence", json!({"fromItems": items})) { + app.ui.status = e.to_string(); + } } } @@ -2315,15 +2319,23 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), }; - let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); + // dragging one of several selected project items places them all, back to back, as one edit + let items = match source { + None => crate::panels::dragged_selection(app, item), + Some(_) => vec![item], + }; + let mut params = json!({"track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); + match items.as_slice() { + [one] => params["item"] = json!(one.0), + many => params["items"] = json!(many.iter().map(|i| i.0).collect::>()), + } if let Some(source) = source { params["sourceIn"] = json!(source.range.start.0); params["duration"] = json!(source.range.duration.0); params["video"] = json!(source.video); params["audio"] = json!(source.audio); } - let r = app.session.execute("timeline.place", params); - if let Err(e) = r { + if let Err(e) = app.session.execute("timeline.place", params) { app.ui.status = e.to_string(); } crate::panels::clear_drag(ui); @@ -2416,20 +2428,12 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), }; - let still = app.session.prefs.timeline.still_duration(rate); - let durations: Vec = items - .iter() - .map(|&item| { - let is_still = app.session.project.item(item).and_then(|i| i.as_media()).is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Still); - app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still) - }) - .collect(); let start = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]); - for (item, t) in items.iter().zip(chain_starts(start, &durations)) { - let params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": false}); - if let Err(e) = app.session.execute("timeline.place", params) { - app.ui.status = e.to_string(); - } + // one command, one undo step: the files go end to end from the drop point + let ids: Vec = items.iter().map(|i| i.0).collect(); + let params = json!({"items": ids, "track": vt, "audioTrack": at, "time": start.0, "insert": false}); + if let Err(e) = app.session.execute("timeline.place", params) { + app.ui.status = e.to_string(); } } } @@ -2451,19 +2455,6 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & app.auto.add("timeline.tracks", layout.content, "tracks"); } -/// Start tick of each clip when the clips are laid end to end from `start`. Saturates rather than overflows. -pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec { - let mut t = start; - durations - .iter() - .map(|d| { - let at = t; - t = Tick(t.0.saturating_add(d.0.max(0))); - at - }) - .collect() -} - #[cfg(test)] mod waveform_tests { use super::waveform_display_gain; @@ -2486,41 +2477,3 @@ mod waveform_tests { assert!((db(waveform_display_gain(1.0, -6.0)) - (-6.0)).abs() < 0.1); } } - -#[cfg(test)] -mod chain_starts_tests { - use super::chain_starts; - use filmcraft_time::Tick; - - #[test] - fn empty_list_has_no_starts() { - assert!(chain_starts(Tick(100), &[]).is_empty()); - } - - #[test] - fn one_clip_starts_at_the_drop_point() { - assert_eq!(chain_starts(Tick(7), &[Tick(30)]), vec![Tick(7)]); - } - - #[test] - fn several_clips_are_laid_end_to_end() { - let durations = [Tick(10), Tick(25), Tick(5)]; - assert_eq!(chain_starts(Tick(100), &durations), vec![Tick(100), Tick(110), Tick(135)]); - } - - #[test] - fn start_near_i64_max_saturates_without_panicking() { - let starts = chain_starts(Tick(i64::MAX - 5), &[Tick(10), Tick(10), Tick(10)]); - assert_eq!(starts, vec![Tick(i64::MAX - 5), Tick(i64::MAX), Tick(i64::MAX)]); - } - - #[test] - fn zero_length_clips_share_one_start() { - assert_eq!(chain_starts(Tick(42), &[Tick(0), Tick(0), Tick(9)]), vec![Tick(42), Tick(42), Tick(42)]); - } - - #[test] - fn negative_durations_count_as_zero() { - assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]); - } -} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32f..af5d6c04c 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -626,3 +626,40 @@ fn media_browser_drag_imports_into_the_project_panel() { assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// Dragging one of several selected Project items onto the Timeline places all of them, back to +/// back, as one undo step; an item outside the selection goes alone. +#[test] +fn dragging_a_selection_to_the_timeline_places_every_item() { + let mut d = Driver::demo(); + let (bin, items) = d.footage(); + assert!(items.len() >= 3); + d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2})); + d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}})); + let card = |i: u64| format!("project.item.{i}"); + d.wait_for(&card(items[0])); + let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::()).unwrap_or(0); + let steps = d.app().session.history.undo.len(); + let drop = json!({"id": "timeline.tracks", "fx": 0.3, "fy": 0.1}); + + // an item that is not selected: one clip + d.exec("project.select", json!({"items": [items[2]]})); + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(clips(&mut d), 1, "an unselected item goes alone"); + d.exec("edit.undo", json!({})); + + // two of the selection: both, back to back, in one undo step + d.exec("project.select", json!({"items": [items[0], items[1]]})); + d.frames(2); + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(clips(&mut d), 2, "the whole selection is placed"); + let mut v1: Vec<(i64, i64)> = + d.app().session.active_sequence().unwrap().video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect(); + v1.sort(); + assert_eq!(v1[0].1, v1[1].0, "back to back"); + assert_eq!(d.app().session.history.undo.len(), steps + 1, "the drag is one undo step"); + d.exec("edit.undo", json!({})); + assert_eq!(clips(&mut d), 0); +} diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9fa..b166962d9 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -152,3 +152,27 @@ fn with_drop_import_off_the_file_is_only_imported() { assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin"); assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status); } + +/// Several files dropped at once land end to end, and one undo takes all of them off the Timeline. +#[test] +fn several_files_dropped_together_are_placed_in_one_undo_step() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + pointer_to(&mut h, at); + for name in ["drop_many_a.png", "drop_many_b.png", "drop_many_c.png"] { + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still(name)))); + } + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (3, 0), "status: {}", h.state().ui.status); + let spans: Vec<(i64, i64)> = { + let q = h.state().session.active_sequence().unwrap(); + let mut v: Vec<_> = q.video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect(); + v.sort(); + v + }; + assert!(spans.windows(2).all(|w| w[0].1 == w[1].0), "end to end: {spans:?}"); + h.state_mut().session.execute("edit.undo", serde_json::json!({})).unwrap(); + assert_eq!(clip_counts(&h), (0, 0), "one undo removes every placed clip"); +} From baadac2cf81f57b3b980cd260e6c1b48b6869cdc Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 14:59:52 -0600 Subject: [PATCH 008/157] UI.9: integrate shared docking and preserve workspace behavior --- Cargo.lock | 10 + Cargo.toml | 2 + ROADMAP.md | 3 + apps/filmcraft/src/args.rs | 24 +- apps/filmcraft/src/main.rs | 4 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/README.md | 25 ++ crates/ui-egui/src/automation.rs | 29 +- crates/ui-egui/src/control.rs | 52 ++- crates/ui-egui/src/dock.rs | 331 +++++++++----- crates/ui-egui/src/dock_shared.rs | 113 +++++ crates/ui-egui/src/dock_ui.rs | 394 +++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + crates/ui-egui/src/lib.rs | 139 +++--- crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/keyboard.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 106 +++-- crates/ui-egui/src/panels/monitor.rs | 169 +++++-- crates/ui-egui/src/panels/timeline.rs | 7 + crates/ui-egui/src/panels/tools.rs | 13 +- crates/ui-egui/src/panels/workspaces.rs | 9 +- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/tests/monitor_geometry.rs | 185 ++++++++ crates/ui-egui/tests/project_panel_ui.rs | 19 +- crates/ui-egui/tests/shared_docking_ui.rs | 515 ++++++++++++++++++++++ crates/ui-egui/tests/timecode_ui.rs | 34 ++ crates/ui-egui/tests/timeline_drop_ui.rs | 114 +++++ crates/ui-egui/tests/ui_control_input.rs | 277 ++++++++++++ crates/ui-egui/tests/ui_dock_resize.rs | 95 ++++ crates/ui-egui/tests/ui_keyboard_focus.rs | 107 +++++ docs/control-protocol.md | 43 +- xtask/src/main.rs | 2 +- 33 files changed, 2548 insertions(+), 301 deletions(-) create mode 100644 crates/ui-egui/src/dock_shared.rs create mode 100644 crates/ui-egui/src/dock_ui.rs create mode 100644 crates/ui-egui/tests/monitor_geometry.rs create mode 100644 crates/ui-egui/tests/shared_docking_ui.rs create mode 100644 crates/ui-egui/tests/ui_control_input.rs create mode 100644 crates/ui-egui/tests/ui_dock_resize.rs create mode 100644 crates/ui-egui/tests/ui_keyboard_focus.rs diff --git a/Cargo.lock b/Cargo.lock index d94a44573..66008b52b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -2262,6 +2271,7 @@ name = "filmcraft-ui-egui" version = "0.5.0" dependencies = [ "cpu-time", + "craft-ui", "eframe", "egui", "egui_kittest", diff --git a/Cargo.toml b/Cargo.toml index 1d306cffa..cfe2ae9b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ proptest = "1" roxmltree = "0.21" # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/ROADMAP.md b/ROADMAP.md index e22211dbd..b604e069e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,8 @@ # FilmCraft Roadmap +- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. +- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. + Progress toward parity with Adobe Premiere Pro, with estimates. Updated as milestones land. **Last updated:** 2026-10-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a9684..db2080121 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -7,6 +7,8 @@ Usage: filmcraft [options] [project.fcproj | media files...] Options: --control start the localhost JSON-lines control server (also: FILMCRAFT_CONTROL_PORT) + --control-input-only accept widget input only from the control server + (requires --control or FILMCRAFT_CONTROL_PORT) --demo open the demo project --empty start with an empty project --recover recover the newest unsaved changes without asking @@ -29,6 +31,7 @@ pub enum Cli { #[derive(Debug, PartialEq)] pub struct Launch { pub control_port: Option, + pub control_input_only: bool, /// A project and / or media files to open. pub files: Vec, pub demo: bool, @@ -46,7 +49,7 @@ pub struct Launch { /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { - let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; + let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -58,6 +61,7 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator (l.demo, l.startup_flag) = (true, true), + "--control-input-only" => l.control_input_only = true, "--empty" => (l.demo, l.startup_flag) = (false, true), "--recover" => l.recover = Some(true), "--no-recover" => l.recover = Some(false), @@ -81,6 +85,9 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -267,7 +267,7 @@ fn main() -> eframe::Result { } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); + app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) }; } Ok(Box::new(app)) }), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4a99b17b2..950c22acd 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-ui = { workspace = true } filmcraft-time = { workspace = true } filmcraft-geom = { workspace = true } filmcraft-color = { workspace = true } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 699a95d49..0e9583e35 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -57,3 +57,28 @@ range validation and undo; the UI owns the drag gesture. See `source_drag_ui` an Source range handles use transient gesture previews and the engine's integer-frame `source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing keeps Source navigation, markers and marked-range playback separate from Program. + +## Pane layout controls + +Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and +keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved +dock tree and panel identities to the shared model. Panel tabs or their header grip can move +between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl. Floating +panels move by their empty header and resize from the bottom-right corner. Timeline sequence +tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip +moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout +JSON, and workspaces without floating data still load. Automation IDs and workspace commands +remain the integration boundary. + +Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support +Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused +widgets in a background window. The optional `--control-input-only` native launch mode isolates +automation from native widget/menu input; `ui.inspect` reports native and effective focus +separately. Focus and input-ownership regressions cover document/playhead preservation. + +Compact monitor layouts separate the current and duration timecodes from zoom, resolution and +settings. Source video/audio drag choices retain their existing placement behavior. Transport +buttons use `craft-ui::layout::fit_toolbar`; commands beyond the available width remain available +through More, including their selected state and Source/Program routing. A scrolling fallback +retains all commands if even More cannot fit. `monitor_geometry` checks the painted readouts, +control bounds, overflow actions and Source drag payloads across monitor widths and themes. diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 9b9ae3bb9..850fcb8dd 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -10,6 +10,15 @@ pub struct Element { pub id: String, pub label: String, pub rect: [f32; 4], + /// Present only when this entry represents a real egui widget, rather than a click alias. + #[serde(rename = "widgetId", skip_serializing_if = "Option::is_none")] + pub widget_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub focusable: Option, + #[serde(skip)] + pub(crate) response_id: Option, } #[derive(Default)] @@ -21,7 +30,25 @@ pub struct Registry { impl Registry { pub fn add(&mut self, id: &str, rect: Rect, label: &str) { - self.elements.push(Element { id: id.to_string(), label: label.to_string(), rect: [rect.min.x, rect.min.y, rect.width(), rect.height()] }); + self.elements.push(Element { + id: id.to_string(), + label: label.to_string(), + rect: [rect.min.x, rect.min.y, rect.width(), rect.height()], + widget_id: None, + enabled: None, + focusable: None, + response_id: None, + }); + } + /// Observe the widget without changing its accessibility role or input behavior. + pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + self.add(id, response.rect, label); + if let Some(element) = self.elements.last_mut() { + element.widget_id = Some(format!("{:016x}", response.id.value())); + element.enabled = Some(response.enabled()); + element.focusable = Some(response.sense.is_focusable()); + element.response_id = Some(response.id); + } } pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 162602581..7b4bdd6d1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,7 +288,9 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) if req.method == "ui.panel.show" { app.show_panel(panel); } else { - app.ui.dock.close(panel); + if let Err(error) = app.apply_dock(craft_ui::docking::Action::Close { panel }) { + return err(&error); + } } ok(Value::Null) } @@ -299,6 +301,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), }; let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { @@ -312,6 +315,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.drag" => { @@ -323,6 +327,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); for i in 1..=steps { @@ -330,6 +335,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); } app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.scroll" => { @@ -339,6 +345,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + app.synthetic.push(egui::Event::ModifiersChanged(modifiers(p))); app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, @@ -352,18 +359,22 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) pressed: false, modifiers: Default::default(), }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.key" => { let Some(name) = s("key") else { return err("missing `key`") }; let Some((m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; let m = as_pressed(m | modifiers(p)); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.type" => { let Some(text) = s("text") else { return err("missing `text`") }; + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); app.synthetic.push(egui::Event::Text(text.to_string())); app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); Outcome::AfterInput @@ -404,9 +415,30 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "ui.focus" => { - // Explicit request: activate the app and take keyboard focus. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - ok(Value::Null) + if p.is_null() || p.as_object().is_some_and(|object| object.is_empty()) { + // Existing explicit request: activate the app and take OS keyboard focus. + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + return ok(Value::Null); + } + let Some(object) = p.as_object().filter(|object| object.len() == 1 && object.contains_key("id")) else { + return err("ui.focus expects {} or {id: string}"); + }; + let Some(id) = object.get("id").and_then(Value::as_str) else { return err("ui.focus needs a string `id`") }; + let Some(element) = app.auto.find(id) else { return err(format!("no element `{id}` (see ui.elements)")) }; + let Some(widget_id) = element.response_id else { return err(format!("element `{id}` has no focusable widget")) }; + let Some(response) = ctx.read_response(widget_id) else { return err(format!("widget `{id}` is not present")) }; + if !response.enabled() + || !response.sense.is_focusable() + || !response.interact_rect.is_finite() + || !response.interact_rect.is_positive() + || !response.interact_rect.intersects(ctx.content_rect()) + { + return err(format!("widget `{id}` is not enabled, visible and focusable")); + } + // A widget target never activates the native window. The next ui.inspect reports + // actual focus ownership separately from this request acknowledgement. + response.request_focus(); + ok(json!({"focusRequested": id, "widgetId": format!("{:016x}", widget_id.value())})) } "app.quit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -435,10 +467,22 @@ fn merge(v: &mut Value, patch: &Value) { pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); + let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { + let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + json!({ + "widgetId": format!("{:016x}", id.value()), + "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), + "label": elements.first().map(|element| element.label.as_str()), + "active": ctx.read_response(id).is_some_and(|response| response.has_focus()), + }) + }); json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, + "keyboardFocus": keyboard_focus, + "input": {"mode": if app.control_input_only { "controlOnly" } else { "interactive" }, + "nativeWindowFocused": app.native_window_focused, "effectiveWindowFocused": ctx.input(|input| input.focused)}, "ui": app.ui, "sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0}, "playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()}, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b7..218bca0c1 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -5,6 +5,8 @@ use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::{Deserialize, Serialize}; +use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect}; + use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -137,6 +139,16 @@ pub enum SplitSize { FixedB(f32), } +impl SplitSize { + fn shared(self) -> SharedSplitSize { + match self { + Self::Ratio(value) => SharedSplitSize::Ratio(value), + Self::FixedA(value) => SharedSplitSize::FixedFirst(value), + Self::FixedB(value) => SharedSplitSize::FixedSecond(value), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -183,11 +195,24 @@ pub struct WorkspacePrefs { pub struct SavedWorkspace { pub name: String, pub layout: serde_json::Value, + /// Kept raw for the same per-workspace failure isolation as the dock tree. + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub floating: serde_json::Value, } impl WorkspacePrefs { pub fn load(path: &std::path::Path) -> Self { - std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + use std::io::Read as _; + // Preferences are untrusted; bound reading before JSON can allocate from it. + const MAX_BYTES: u64 = 4 * 1024 * 1024; + let Some(bytes) = std::fs::File::open(path).ok().and_then(|file| { + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= MAX_BYTES).then_some(bytes) + }) else { + return Self::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() } pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> { if let Some(d) = path.parent() { @@ -228,7 +253,55 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { /// The saved layout of a workspace: the user's saved version, else the built-in default. pub fn saved_layout(prefs: &WorkspacePrefs, name: &str) -> DockNode { - prefs.saved.iter().find(|s| s.name == name).and_then(|s| serde_json::from_value(s.layout.clone()).ok()).unwrap_or_else(|| workspace(name)) + saved_docking(prefs, name).root +} + +pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { + let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { + let mut layout = Layout { + root: serde_json::from_value(saved.layout.clone()).ok()?, + floating: if saved.floating.is_null() { vec![] } else { serde_json::from_value(saved.floating.clone()).ok()? }, + }; + if layout.floating.is_empty() { + migrate_legacy_effects(&mut layout.root); + } + layout.validate().ok().map(|()| layout) + }); + saved.unwrap_or_else(|| Layout { root: workspace(name), floating: vec![] }) +} + +/// Older Effects workspaces stored the same Effect Controls panel twice: selected beside +/// Source, and an inactive duplicate beside Essential Graphics. Remove only that known +/// duplicate shape; unrelated malformed or ambiguous saved layouts still fail validation. +fn migrate_legacy_effects(root: &mut DockNode) { + let Ok(shared) = crate::dock_shared::encode(root, &[]) else { return }; + if shared.panels().iter().filter(|panel| ***panel == PanelKind::EffectControls).count() != 2 { + return; + } + fn selected_source(node: &DockNode) -> bool { + match node { + DockNode::Tabs { panels, active } => { + panels.contains(&PanelKind::Source) && panels.contains(&PanelKind::AudioClipMixer) && panels.get(*active) == Some(&PanelKind::EffectControls) + } + DockNode::Split { a, b, .. } => selected_source(a) || selected_source(b), + } + } + fn remove_inactive(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => { + panels.remove(0); + *active = 0; + } + DockNode::Split { a, b, .. } => { + remove_inactive(a); + remove_inactive(b); + } + DockNode::Tabs { .. } => {} + } + } + if selected_source(root) { + remove_inactive(root); + } } /// The default layout of a named workspace. @@ -262,7 +335,7 @@ pub fn workspace(name: &str) -> DockNode { hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)), hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()), ), - vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EffectControls, EssentialGraphics], 1)), + vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)), ), "Audio" => hsplit( FixedB(320.0), @@ -327,17 +400,7 @@ impl DockNode { } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { - match self { - DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p), - DockNode::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|x| *x == p) { - *active = i; - true - } else { - false - } - } - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p }) } pub fn is_visible(&self, p: PanelKind) -> bool { match self { @@ -347,19 +410,7 @@ impl DockNode { } /// Close a panel (remove its tab; empty groups collapse their split). pub fn close(&mut self, p: PanelKind) { - if let DockNode::Split { a, b, .. } = self { - a.close(p); - b.close(p); - let empty = |n: &DockNode| matches!(n, DockNode::Tabs { panels, .. } if panels.is_empty()); - if empty(a) { - *self = (**b).clone(); - } else if empty(b) { - *self = (**a).clone(); - } - } else if let DockNode::Tabs { panels, active } = self { - panels.retain(|x| *x != p); - *active = (*active).min(panels.len().saturating_sub(1)); - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Close { panel: p }); } /// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after /// it was closed, so opening a sequence always has somewhere to show it. @@ -383,27 +434,40 @@ impl DockNode { } /// Add a panel as a tab next to `near` (or into the first group). pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { - if self.contains(p) { - self.activate(p); + if crate::dock_shared::encode(self, &[]).is_err() { return; } - fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool { - match n { - DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near), - DockNode::Tabs { panels, active } => { - if panels.contains(&near) { - panels.push(p); - *active = panels.len() - 1; - true - } else { - false - } - } - } - } - if !add(self, p, near) && !add(self, p, PanelKind::Project) { - add(self, p, PanelKind::Program); - } + let anchor = [near, PanelKind::Project, PanelKind::Program].into_iter().find(|panel| self.contains(*panel)); + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Open { panel: p, anchor }); + } +} + +/// A floating group retains the same panel identities as docked tabs. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Floating { + pub panels: Vec, + pub active: usize, + /// [x, y, width, height] in logical points. + pub rect: [f32; 4], +} + +/// App persistence shape; all mutations pass through the shared docking model. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Layout { + pub root: DockNode, + #[serde(default)] + pub floating: Vec, +} + +impl Layout { + pub fn apply(&mut self, action: craft_ui::docking::Action) -> Result<(), String> { + crate::dock_shared::apply(self, action) + } + pub fn validate(&self) -> Result<(), String> { + crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string()) + } + pub fn contains(&self, panel: PanelKind) -> bool { + self.root.contains(panel) || self.floating.iter().any(|group| group.panels.contains(&panel)) } } @@ -420,6 +484,7 @@ pub struct Group { #[derive(Debug, Clone, PartialEq)] pub enum DockAction { Activate(PanelKind), + BeginDrag(PanelKind), Focus(PanelKind), Close(PanelKind), PanelMenu(PanelKind, egui::Pos2), @@ -430,22 +495,40 @@ pub enum DockAction { MoveSequence(u64, usize), } -/// Size of a split's first child in `avail` points: the requested size, keeping both children at -/// least 20 points when there is room (and never more than `avail`). Must not panic for any size: -/// a tiny window (or browser canvas) gives splits less than 40 points. -fn split_first(size: SplitSize, avail: f32) -> f32 { - let want = match size { - SplitSize::Ratio(r) => avail * r, - SplitSize::FixedA(px) => px, - SplitSize::FixedB(px) => avail - px, - }; - let lo = 20.0_f32.min(avail * 0.5); - let hi = (avail - 20.0).max(lo); - want.clamp(lo, hi) -} - /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry) { + let mut actions = Vec::new(); + layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions); + for action in actions { + crate::dock_shared::apply_tree(node, action); + } +} + +/// Lay out chrome while returning mutations for the application's UI command route. +pub fn layout_with_actions( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { + layout_inner(ui, node, rect, t, path, &[], out, reg, actions); +} + +fn layout_inner( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + tree_path: &[bool], + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { match node { DockNode::Tabs { panels, active } => { let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h }; @@ -453,45 +536,24 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active }); } DockNode::Split { vertical, size, a, b } => { - let g = t.gap; - let total = if *vertical { rect.height() } else { rect.width() }; - let avail = (total - g).max(0.0); - let first = split_first(*size, avail); - let (ra, gutter, rb) = if *vertical { - ( - Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first), pos2(rect.max.x, rect.min.y + first + g)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first + g), rect.max), - ) - } else { - ( - Rect::from_min_max(rect.min, pos2(rect.min.x + first, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first, rect.min.y), pos2(rect.min.x + first + g, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first + g, rect.min.y), rect.max), - ) - }; - // gutter drag (a slightly larger hit area than the visible gap) - let hit = gutter.expand2(if *vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) }); + let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; let id = egui::Id::new(("dock-gutter", path.to_string())); - let resp = ui.interact(hit, id, Sense::drag()); - reg.add(&format!("dock.gutter.{path}"), hit, "gutter"); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(if *vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); + let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; + let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; + let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); + reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); + if response.delta != 0.0 + && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) + { + actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized }); } - if resp.dragged() { - let d = if *vertical { resp.drag_delta().y } else { resp.drag_delta().x }; - let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0)); - *size = match *size { - SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)), - SplitSize::FixedA(_) => SplitSize::FixedA(nf), - SplitSize::FixedB(_) => SplitSize::FixedB(avail - nf), - }; - } - if resp.dragged() || resp.hovered() { - ui.painter().rect_filled(gutter, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 })); - } - layout(ui, a, ra, t, &format!("{path}a"), out, reg); - layout(ui, b, rb, t, &format!("{path}b"), out, reg); + let mut first_path = tree_path.to_vec(); + first_path.push(false); + let mut second_path = tree_path.to_vec(); + second_path.push(true); + layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions); + layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions); } } } @@ -512,11 +574,39 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, ) -> Vec { + draw_group_chrome_details(ui, g, focused, seqs, t, reg).actions +} + +#[derive(Default)] +pub struct Chrome { + pub actions: Vec, + pub tabs: Vec<(PanelKind, Rect)>, +} + +pub fn draw_group_chrome_details( + ui: &mut egui::Ui, + g: &Group, + focused: PanelKind, + seqs: &SeqTabs, + t: &Tokens, + reg: &mut crate::automation::Registry, +) -> Chrome { let mut actions = Vec::new(); + let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); let active_panel = g.panels.get(g.active).copied(); let compact = g.panels.len() == 1 && g.panels[0].compact(); + // A narrow header grip moves the entire panel, including Timeline panels whose tabs + // separately reorder open sequences. + if let Some(panel) = active_panel { + let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); + let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); + reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); + if response.drag_started() { + actions.push(DockAction::BeginDrag(panel)); + } + } if compact { // grip dots let c = pos2(g.rect.center().x, g.rect.min.y + 4.0); @@ -578,10 +668,12 @@ pub fn draw_group_chrome( if let Some(r) = more { let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - reg.add(&name, r, "more tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + reg.add_response(&name, &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c); + paint_widget_focus(&painter, &resp, t); if resp.clicked() { let open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); ui.ctx().data_mut(|d| d.insert_temp(list_id, !open)); @@ -602,8 +694,22 @@ pub fn draw_group_chrome( // a sequence tab keeps its identity when the tabs are reordered under the pointer let resp = match tab.seq { Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()), + None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), }; + if tab.seq.is_none() && resp.drag_started() { + actions.push(DockAction::BeginDrag(*p)); + } + if let Some((_, rect)) = tab_rects.iter_mut().find(|(panel, _)| panel == p) { + *rect = rect.union(tab_rect); + } else { + tab_rects.push((*p, tab_rect)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_role(egui::accesskit::Role::Tab); + node.set_selected(is_active); + }); + paint_widget_focus(&painter, &resp, t); if let Some(id) = tab.seq { seq_rects.push((id, tab_rect)); if resp.dragged() { @@ -611,10 +717,10 @@ pub fn draw_group_chrome( } } if let Some(id) = tab.seq { - reg.add(&format!("timeline.tab.{id}"), tab_rect, &tab.title); + reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title); } if tab.seq.is_none() || is_active { - reg.add(&format!("panel.tab.{}", p.id()), tab_rect, p.title()); + reg.add_response(&format!("panel.tab.{}", p.id()), &resp, p.title()); } let label_x = tab_rect.min.x + 8.0 + close_w; let label_w = galley.size().x; @@ -625,10 +731,12 @@ pub fn draw_group_chrome( // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - reg.add(&format!("timeline.tab.{id}.close"), cr.expand(4.0), "close sequence"); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc)); + paint_widget_focus(&painter, &cresp, t); if cresp.clicked() { actions.push(DockAction::CloseSequence(id)); closed = true; @@ -637,12 +745,14 @@ pub fn draw_group_chrome( if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - reg.add(&format!("panel.menu.{}", p.id()), mr, "panel menu"); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); + reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); // 1 pt underline spanning label + ≡, 23 pt below the frame top let uy = strip.min.y + 23.0; painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); + paint_widget_focus(&painter, &mresp, t); if mresp.clicked() { actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); } @@ -697,8 +807,8 @@ pub fn draw_group_chrome( let label = if tab.active { format!("✓ {}", tab.title) } else { tab.title.clone() }; let resp = ui.button(label); match tab.seq { - Some(id) => reg.add(&format!("timeline.tabs.list.{id}"), resp.rect, &tab.title), - None => reg.add(&format!("panel.tabs.list.{}", tab.panel.id()), resp.rect, &tab.title), + Some(id) => reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, &tab.title), + None => reg.add_response(&format!("panel.tabs.list.{}", tab.panel.id()), &resp, &tab.title), } if resp.clicked() { picked = Some(tab); @@ -730,7 +840,13 @@ pub fn draw_group_chrome( { actions.push(DockAction::Focus(p)); } - actions + Chrome { actions, tabs: tab_rects } +} + +fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { + if response.has_focus() { + painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside); + } } /// Placeholder body for panels that are not implemented yet. @@ -744,6 +860,9 @@ mod tests { #[test] fn split_first_never_panics_on_tiny_areas() { + let split_first = |size: SplitSize, avail: f32| { + split_rect(Rect::from_min_size(egui::Pos2::ZERO, vec2(avail, 100.0)), SplitAxis::Horizontal, size.shared(), 0.0, 20.0).unwrap().first_size() + }; for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] { for size in [ SplitSize::Ratio(0.0), diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs new file mode 100644 index 000000000..d5bb28c20 --- /dev/null +++ b/crates/ui-egui/src/dock_shared.rs @@ -0,0 +1,113 @@ +//! Convert the persisted workspace schema to shared docking transactions. + +use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node}; +use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; + +use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; + +const MAX_DEPTH: usize = 32; +const MAX_NODES: usize = 4096; +const MAX_PANELS: usize = 128; + +fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result, String> { + *count = count.saturating_add(1); + if depth > MAX_DEPTH || *count > MAX_NODES { + return Err("workspace dock tree exceeds supported limits".into()); + } + Ok(match node { + DockNode::Tabs { panels, active } => { + if panels.len() > MAX_PANELS { + return Err("too many workspace tabs".into()); + } + Node::Tabs { panels: panels.clone(), active: *active } + } + DockNode::Split { vertical, size, a, b } => Node::Split { + axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, + size: match *size { + SplitSize::Ratio(value) => SharedSplitSize::Ratio(value), + SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value), + SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value), + }, + first: Box::new(encode_node(a, depth.saturating_add(1), count)?), + second: Box::new(encode_node(b, depth.saturating_add(1), count)?), + }, + }) +} + +fn decode_node(node: Node) -> Result { + Ok(match node { + Node::Tabs { panels, active } => DockNode::Tabs { panels, active }, + Node::Stack { .. } => return Err("FilmCraft does not use stacked panels".into()), + Node::Split { axis, size, first, second } => DockNode::Split { + vertical: axis == SplitAxis::Vertical, + size: match size { + SharedSplitSize::Ratio(value) => SplitSize::Ratio(value), + SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value), + SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value), + }, + a: Box::new(decode_node(*first)?), + b: Box::new(decode_node(*second)?), + }, + }) +} + +pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result, String> { + if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) { + return Err("too many floating workspace panels".into()); + } + let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) }; + Ok(SharedLayout { + root, + floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn decode(layout: SharedLayout) -> Result { + Ok(Layout { + root: match layout.root { + Some(root) => decode_node(root)?, + None => DockNode::Tabs { panels: vec![], active: 0 }, + }, + floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { + let mut shared = encode(&layout.root, &layout.floating)?; + shared.validate().map_err(|error| error.to_string())?; + check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let proposed = decode(shared)?; + encode(&proposed.root, &proposed.floating)?; + *layout = proposed; + Ok(()) +} + +pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool { + let Ok(mut shared) = encode(root, &[]) else { return false }; + if shared.apply(action).is_err() { + return false; + } + let Ok(layout) = decode(shared) else { return false }; + if encode(&layout.root, &[]).is_err() { + return false; + } + *root = layout.root; + true +} + +/// Keep one docked destination available for dragging floating panels back into the workspace. +pub(crate) fn check_last_docked(layout: &SharedLayout, action: &Action) -> Result<(), String> { + let panel = match action { + Action::Float { panel, .. } => Some(panel), + Action::Move { panel, anchor, .. } if layout.floating.iter().any(|group| group.panels.contains(anchor)) => Some(panel), + _ => None, + }; + if let Some(panel) = panel { + let docked = SharedLayout { root: layout.root.clone(), floating: vec![] }; + if docked.contains(panel) && docked.panels().len() <= 1 { + return Err("the last docked panel must remain in the workspace".into()); + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs new file mode 100644 index 000000000..d90888abf --- /dev/null +++ b/crates/ui-egui/src/dock_ui.rs @@ -0,0 +1,394 @@ +//! FilmCraft's panel chrome and document tabs over the shared docking transactions. + +use craft_ui::docking::{Action, DropTarget, Placement, Zone, resolve_drop}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::dock::{self, DockAction, DockNode, Group, PanelKind}; +use crate::{FilmcraftApp, panels}; + +/// Single source for rendering and hit testing: saved offscreen positions remain +/// reachable, and every panel has enough room for its chrome. +fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { + let rect = Rect::from_min_size(pos2(group.rect[0], group.rect[1]), vec2(group.rect[2].max(160.0), group.rect[3].max(100.0))); + rect.translate(vec2( + (screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0), + (screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0), + )) +} + +fn drag_id() -> egui::Id { + egui::Id::new("dock-panel-drag") +} + +pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { + if id == "window.dockAction" { + return Some((|| { + let value = params.get("action").cloned().ok_or("missing docking action")?; + let action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; + app.apply_dock(action)?; + Ok(Value::Null) + })()); + } + if !matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") { + return None; + } + Some((|| { + let panel = match params.get("panel") { + None => app.ui.focused, + Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?, + }; + let action = match id { + "window.closePanel" => Action::Close { panel }, + "window.floatPanel" => { + let rect = match params.get("rect") { + None => default_float_rect(ctx.content_rect()), + Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?, + }; + Action::Float { panel, rect } + } + _ => { + let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; + let zone_name = match params.get("zone") { + None => "center", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + let zone = match zone_name.to_ascii_lowercase().as_str() { + "left" => Zone::Left, + "right" => Zone::Right, + "top" => Zone::Top, + "bottom" => Zone::Bottom, + "center" | "tab" => Zone::Center, + _ => return Err("unknown docking zone".into()), + }; + let placement = match params.get("before") { + Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) }, + None => Placement::Split(zone), + }; + Action::Move { panel, anchor, placement } + } + }; + app.apply_dock(action)?; + Ok(json!({"panel": panel.id()})) + })()) +} + +pub(crate) fn default_float_rect(screen: Rect) -> [f32; 4] { + let center = screen.center(); + [center.x - 210.0, center.y - 160.0, 420.0, 320.0] +} + +impl FilmcraftApp { + fn dock_command(&mut self, ctx: &egui::Context, action: Action) -> Result<(), String> { + let value = serde_json::to_value(action).map_err(|error| error.to_string())?; + crate::menus::invoke(self, ctx, "window.dockAction", json!({"action": value})).map(|_| ()) + } + + pub fn apply_dock(&mut self, action: Action) -> Result<(), String> { + let relocating = matches!(&action, Action::Move { .. } | Action::Float { .. }); + let reveal = match &action { + Action::Activate { panel } => Some(*panel), + _ => None, + }; + let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; + shared.validate().map_err(|error| error.to_string())?; + crate::dock_shared::check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let layout = crate::dock_shared::decode(shared)?; + crate::dock_shared::encode(&layout.root, &layout.floating)?; + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + if let Some(panel) = reveal { + self.pending_panel_raise = Some(panel); + } + if relocating + || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) + { + self.ui.keys.maximized = None; + } + Ok(()) + } + + fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) { + self.auto.add(&format!("panel.{}", panel.id()), content, panel.title()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id()))); + child.set_clip_rect(content.intersect(ui.clip_rect())); + panels::show(self, &mut child, panel, content); + } + + /// OS drag positions come from the host cursor hook, so egui pointer capture alone + /// cannot decide whether a Timeline is covered. Use the same geometry and layer + /// ordering as our renderer before any panel can dispatch an edit. + pub(crate) fn timeline_drop_target(&self, ctx: &egui::Context, pos: egui::Pos2) -> bool { + if !ctx.content_rect().contains(pos) { + return false; + } + if let Some(panel) = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))) + { + return panel == PanelKind::Timeline; + } + for index in self.floating_order(ctx).into_iter().rev() { + if let Some(group) = self.ui.floating.get(index) + && floating_rect(group, ctx.content_rect()).contains(pos) + { + return group.panels.get(group.active) == Some(&PanelKind::Timeline); + } + } + true + } + + fn floating_order(&self, ctx: &egui::Context) -> Vec { + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + let mut order: Vec<_> = (0..self.ui.floating.len()).collect(); + order.sort_by_key(|index| { + self.ui + .floating + .get(*index) + .and_then(|group| group.panels.first()) + .and_then(|panel| { + let id = egui::Id::new(("floating-panel", panel.id())); + layers.iter().position(|layer| layer.id == id) + }) + .unwrap_or(usize::MAX) + }); + order + } + + pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { + let t = self.tokens; + let ctx = ui.ctx().clone(); + if let Some(panel) = self.pending_panel_raise.take() + && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) + { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); + } + let maximized = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); + let mut dock = match maximized { + Some(panel) => DockNode::Tabs { panels: vec![panel], active: 0 }, + None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }), + }; + let mut groups = vec![]; + let mut layout_actions = Vec::new(); + dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions); + let seqs = dock::SeqTabs { + open: self + .session + .state + .open_sequences + .iter() + .filter_map(|id| { + self.session + .project + .item(*id) + .filter(|item| matches!(item.kind, filmcraft_project::ItemKind::Sequence(_))) + .map(|item| (id.0, item.name.clone())) + }) + .collect(), + active: self.session.state.active_sequence.map(|id| id.0), + }; + let mut actions = vec![]; + let mut targets = vec![]; + for group in &groups { + let chrome = dock::draw_group_chrome_details(ui, group, self.ui.focused, &seqs, &t, &mut self.auto); + if let Some(target) = target(group, chrome.tabs, true) { + targets.push(target); + } + actions.extend(chrome.actions); + } + if maximized.is_none() { + self.ui.dock = dock; + } + for group in &groups { + if let Some(panel) = group.panels.get(group.active).copied() { + self.panel_body(ui, panel, group.content); + } + } + let floating_order = self.floating_order(&ctx); + for index in floating_order.into_iter().filter(|_| maximized.is_none()) { + if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) { + targets.push(target); + } + } + for action in layout_actions { + let _ = self.dock_command(&ctx, action); + } + for action in actions { + match action { + DockAction::Activate(panel) => { + let _ = self.dock_command(&ctx, Action::Activate { panel }); + } + DockAction::Close(panel) => { + let _ = self.dock_command(&ctx, Action::Close { panel }); + } + DockAction::Focus(panel) => self.ui.focused = panel, + DockAction::BeginDrag(panel) => { + ctx.data_mut(|data| data.insert_temp(drag_id(), panel)); + } + DockAction::OpenSequence(id) => { + let _ = self.dock_command(&ctx, Action::Activate { panel: PanelKind::Timeline }); + if self.session.state.active_sequence.map(|item| item.0) != Some(id) + && let Err(error) = self.session.execute("sequence.open", json!({"item": id})) + { + self.ui.status = error.to_string(); + } + } + DockAction::CloseSequence(id) => { + if let Err(error) = self.session.execute("sequence.close", json!({"item": id})) { + self.ui.status = error.to_string(); + } + } + DockAction::MoveSequence(id, index) => { + if let Err(error) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { + self.ui.status = error.to_string(); + } + } + DockAction::PanelMenu(panel, pos) => { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + }); + } + } + } + self.panel_drag(&ctx, &targets); + panels::panel_menu_popup(self, ui); + } + + fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { + let floating = self.ui.floating.get(index)?.clone(); + let panel = *floating.panels.first()?; + let rect = floating_rect(&floating, ctx.content_rect()); + let t = self.tokens; + let id = egui::Id::new(("floating-panel", panel.id())); + let mut output = None; + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); + let compact = floating.panels.len() == 1 && panel.compact(); + let header = if compact { 8.0 } else { t.tab_h }; + let strip = Rect::from_min_size(rect.min, vec2(rect.width(), header)); + let response = ui.interact(strip, id.with("move"), Sense::drag()); + self.auto.add_response(&format!("panel.float.{index}.move"), &response, "Move floating panel"); + let mut next = if response.dragged() { rect.translate(response.drag_delta()) } else { rect }; + let group = Group { + path: format!("f{index}"), + rect, + content: Rect::from_min_max(pos2(rect.min.x, rect.min.y + header), rect.max), + panels: floating.panels.clone(), + active: floating.active, + }; + let chrome = dock::draw_group_chrome_details(ui, &group, self.ui.focused, seqs, &t, &mut self.auto); + actions.extend(chrome.actions); + if let Some(active) = group.panels.get(group.active).copied() { + self.panel_body(ui, active, group.content); + } + ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); + let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); + let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); + self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); + for step in 0..3 { + let offset = 4.0 + step as f32 * 4.0; + ui.painter() + .line_segment([pos2(rect.max.x - offset, rect.max.y - 2.0), pos2(rect.max.x - 2.0, rect.max.y - offset)], Stroke::new(1.0, t.text_faint)); + } + if response.hovered() || response.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + let delta = floating_resize_delta(ui, &response); + if response.has_focus() { + ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside); + } + if delta != egui::Vec2::ZERO { + next = Rect::from_min_size(next.min, (next.size() + delta).max(vec2(160.0, 100.0))); + } + if next != rect { + let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] }); + } + output = target(&group, chrome.tabs, false); + }); + output + } + + fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget]) { + let Some(panel) = ctx.data(|data| data.get_temp::(drag_id())) else { return }; + let canceled = + ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released())); + if canceled || (!self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) { + ctx.data_mut(|data| data.remove::(drag_id())); + return; + } + let Some(pos) = ctx.pointer_latest_pos() else { return }; + let force_float = ctx.input(|input| input.modifiers.command); + let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let t = self.tokens; + if let Some(proposal) = &proposal { + painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); + painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + if let Some(mark) = proposal.insertion { + painter.rect_filled(mark, 0.0, t.focus); + } + } + let title = painter.layout_no_wrap(panel.title().to_owned(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let ghost = Rect::from_min_size(pos + vec2(12.0, 10.0), title.size() + vec2(16.0, 8.0)); + painter.rect_filled(ghost, 4.0, t.focus.gamma_multiply(0.85)); + painter.galley(ghost.min + vec2(8.0, 4.0), title, Color32::WHITE); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ctx.request_repaint(); + if ctx.input(|input| input.pointer.primary_released()) { + ctx.data_mut(|data| data.remove::(drag_id())); + let action = match proposal { + Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }), + None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => { + Some(Action::Float { panel, rect: [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0] }) + } + None => None, + }; + if let Some(action) = action + && self.dock_command(ctx, action).is_ok() + { + self.ui.focused = panel; + } + } + } +} + +fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option> { + let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y)); + // A compact grip still identifies the source group to the shared resolver. + for panel in &group.panels { + if !tabs.iter().any(|(candidate, _)| candidate == panel) { + tabs.push((*panel, Rect::NOTHING)); + } + } + Some(DropTarget { anchor: *group.panels.get(group.active)?, rect: group.rect, tab_strip: Some(strip), tabs, allow_splits, accepts_tabs: true }) +} + +/// A corner is a two-axis resize control; focused arrows belong to it and Tab moves on. +fn floating_resize_delta(ui: &mut egui::Ui, response: &egui::Response) -> egui::Vec2 { + if response.drag_started() { + response.request_focus(); + } + let mut delta = if response.dragged() { response.drag_delta() } else { egui::Vec2::ZERO }; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input_mut(|input| { + for (key, amount) in [ + (egui::Key::ArrowLeft, vec2(-8.0, 0.0)), + (egui::Key::ArrowRight, vec2(8.0, 0.0)), + (egui::Key::ArrowUp, vec2(0.0, -8.0)), + (egui::Key::ArrowDown, vec2(0.0, 8.0)), + ] { + if input.consume_key(egui::Modifiers::NONE, key) { + delta += amount; + } + } + }); + } + if response.enabled() { delta } else { egui::Vec2::ZERO } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ecb..97ef84279 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -670,6 +670,7 @@ Extract (') Extraer (') Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6) Button Editor Editor de botones + More transport controls Más controles de reproducción Multi-Camera Record on Grabación multicámara activada Multi-Camera Record off Grabación multicámara desactivada {first} - Merged {first} - Combinado @@ -3512,3 +3513,8 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + + Dock Panel Acoplar panel + Undock Panel Desacoplar panel + Docking Action Acción de acoplamiento + Resize floating panel Cambiar tamaño del panel flotante diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f98..403baf62f 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3494,3 +3494,6 @@ Edit Narration 编辑旁白 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + Resize floating panel 调整浮动面板大小 + More transport controls 更多播放控制 + Undock Panel 取消面板停靠 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a56..fea18adb4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod control; pub mod crash; pub mod credits; pub mod dock; +mod dock_shared; +mod dock_ui; pub mod frames; pub mod header; pub mod i18n; @@ -250,6 +252,14 @@ pub struct FilmcraftApp { /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, control_rx: Option>, + /// One explicit panel reveal; consumed by the custom renderer, never repeated per frame. + pub(crate) pending_panel_raise: Option, + /// Explicit automation mode: native widget input and native menu commands are excluded. + pub(crate) control_input_only: bool, + /// Backend focus before synthetic input changes egui's effective focus for this frame. + pub(crate) native_window_focused: Option, + /// Modifiers owned by an in-flight control gesture, carried between its input frames. + synthetic_modifiers: egui::Modifiers, /// Requests waiting for the UI to show an element: (request, give-up time). deferred: Vec<(ControlRequest, f64)>, last_ui_time: f64, @@ -458,6 +468,10 @@ impl FilmcraftApp { auto: Default::default(), textures: HashMap::new(), control_rx: None, + pending_panel_raise: None, + control_input_only: false, + native_window_focused: None, + synthetic_modifiers: egui::Modifiers::NONE, deferred: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), @@ -503,6 +517,13 @@ impl FilmcraftApp { self } + /// Isolate an automation-owned app from native keyboard, pointer, drop and menu input. + /// Window close/quit and screenshot delivery remain available. + pub fn with_control_input_only(mut self, rx: Receiver) -> Self { + self.control_input_only = true; + self.with_control(rx) + } + pub fn services(&self) -> Arc { self.session.services.clone() } @@ -694,7 +715,11 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); - self.ui.dock = dock::saved_layout(&self.workspaces, name); + let layout = dock::saved_docking(&self.workspaces, name); + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + self.pending_panel_raise = None; + self.ui.keys.maximized = None; if self.workspaces.current != name { let mut next = self.workspaces.clone(); next.current = name.to_string(); @@ -708,10 +733,10 @@ impl FilmcraftApp { } pub fn show_panel(&mut self, p: PanelKind) { - if p == PanelKind::Timeline { + if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { self.ui.dock.restore_timeline(); } - if !self.ui.dock.contains(p) { + if !self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { let near = match p { // a side panel beside Properties (or Essential Sound), never over the Program monitor PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics] @@ -732,7 +757,7 @@ impl FilmcraftApp { }; self.ui.dock.open_near(p, near); } - self.ui.dock.activate(p); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p }); self.ui.focused = p; } @@ -1451,11 +1476,13 @@ impl FilmcraftApp { filmcraft_engine::Event::OpenSequence(_) => { // show the sequence with its own view (or fitted, the first time) self.timeline_view_of = None; - self.ui.dock.restore_timeline(); - self.ui.dock.activate(PanelKind::Timeline); + if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { + self.ui.dock.restore_timeline(); + } + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); } filmcraft_engine::Event::OpenSource(_) => { - self.ui.dock.activate(PanelKind::Source); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); } filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), @@ -1466,6 +1493,9 @@ impl FilmcraftApp { self.handle_drops(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { + if self.control_input_only { + continue; + } if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { self.ui.status = e; } @@ -1609,79 +1639,6 @@ impl FilmcraftApp { } .to_string() } - - fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { - let t = self.tokens; - // Maximize or Restore Frame (` / Shift+`): the maximized panel fills the dock area. - let maximized = self.ui.keys.maximized.filter(|p| self.ui.dock.contains(*p)); - let mut dock = match maximized { - Some(p) => dock::DockNode::Tabs { panels: vec![p], active: 0 }, - None => std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }), - }; - let mut groups = Vec::new(); - dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); - let mut actions = Vec::new(); - let seqs = dock::SeqTabs { - open: self - .session - .state - .open_sequences - .iter() - .filter_map(|id| { - self.session.project.item(*id).filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).map(|i| (id.0, i.name.clone())) - }) - .collect(), - active: self.session.state.active_sequence.map(|i| i.0), - }; - for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &seqs, &t, &mut self.auto)); - } - if maximized.is_none() { - self.ui.dock = dock; - } - for g in &groups { - let Some(p) = g.panels.get(g.active).copied() else { continue }; - self.auto.add(&format!("panel.{}", p.id()), g.content, p.title()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id()))); - child.set_clip_rect(g.content); - panels::show(self, &mut child, p, g.content); - } - for a in actions { - match a { - dock::DockAction::Activate(p) => { - self.ui.dock.activate(p); - } - dock::DockAction::Focus(p) => self.ui.focused = p, - dock::DockAction::Close(p) => self.ui.dock.close(p), - dock::DockAction::OpenSequence(id) => { - if self.session.state.active_sequence.map(|i| i.0) != Some(id) - && let Err(e) = self.session.execute("sequence.open", json!({"item": id})) - { - self.ui.status = e.to_string(); - } - } - dock::DockAction::CloseSequence(id) => { - if let Err(e) = self.session.execute("sequence.close", json!({"item": id})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::MoveSequence(id, index) => { - if let Err(e) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::PanelMenu(p, pos) => { - // (with the frame it opened in: the click that opens it is not a click elsewhere) - let frame = ui.ctx().cumulative_frame_nr(); - ui.ctx().data_mut(|d| { - d.insert_temp(egui::Id::new("panel-menu"), (p, pos)); - d.insert_temp(egui::Id::new("panel-menu-opened"), frame); - }); - } - } - } - panels::panel_menu_popup(self, ui); - } } impl FilmcraftApp { @@ -1723,6 +1680,21 @@ impl FilmcraftApp { impl eframe::App for FilmcraftApp { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.native_window_focused = Some(raw_input.focused); + if self.control_input_only { + // Preserve screenshot responses and all viewport lifecycle/geometry metadata. + raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + raw_input.hovered_files.clear(); + raw_input.dropped_files.clear(); + } + if self.control_input_only || !self.synthetic.is_empty() { + // Native modifiers cannot replace those requested by a control gesture. A queued + // reset runs in the frame after release, so clicked() still sees Shift/Alt. + raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + // Widget keyboard handling requires effective egui focus. Control input must work + // in a background window without activating it or taking the user's OS keyboard. + raw_input.focused = true; + } if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag // (all in one frame reads as a click); key/text runs go together up to a key release. @@ -1735,7 +1707,12 @@ impl eframe::App for FilmcraftApp { .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) }; - raw_input.events.extend(self.synthetic.drain(..n)); + for event in self.synthetic.drain(..n) { + if let egui::Event::ModifiersChanged(modifiers) = event { + self.synthetic_modifiers = modifiers; + } + raw_input.events.push(event); + } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d38..fbd509f72 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -22,6 +22,10 @@ macro_rules! uic { } pub const UI_COMMANDS: &[UiCommand] = &[ + uic!("window.dockAction", "Docking Action", [], None), + uic!("window.dockPanel", "Dock Panel", [], None), + uic!("window.floatPanel", "Undock Panel", [], None), + uic!("window.closePanel", "Close Panel", [], None), uic!("app.language.english", "English", ["Edit", "Language"], None), uic!("app.language.japanese", "日本語", ["Edit", "Language"], None), uic!("app.language.spanish", "Español", ["Edit", "Language"], None), @@ -225,6 +229,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: // the engine's counters plus playback, frame workers and UI timings return Ok(crate::perf::stats(app)); } + if let Some(result) = crate::dock_ui::route(app, ctx, id, ¶ms) { + return result; + } if let Some(rest) = id.strip_prefix("window.panel.") { let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?; app.show_panel(p); diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6f..664740613 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -235,7 +235,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { app.ui.keys.maximized = if app.ui.keys.maximized.is_some() { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { - app.ui.dock.activate(p); + let _ = app.apply_dock(craft_ui::docking::Action::Activate { panel: p }); app.ui.focused = p; } json!({"maximized": app.ui.keys.maximized.map(|p| p.title())}) @@ -244,7 +244,7 @@ fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { /// The visible panel under the pointer (from last frame's automation rects). fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option { let pos = ctx.input(|i| i.pointer.latest_pos())?; - app.auto.query("panel.").into_iter().find_map(|e| { + app.auto.query("panel.").into_iter().rev().find_map(|e| { let r = egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])); r.contains(pos).then(|| PanelKind::from_name(&e.id["panel.".len()..])).flatten() }) @@ -254,7 +254,9 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option Vec { let mut all = Vec::new(); app.ui.dock.panels(&mut all); - all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect() + let mut visible: Vec<_> = all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect(); + visible.extend(app.ui.floating.iter().filter_map(|group| group.panels.get(group.active).copied()).filter(|panel| !panel.compact())); + visible } fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fbb..d3186d221 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -224,54 +224,64 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - // the Timeline's tabs are its open sequences: Close Panel closes the active one and - // keeps the panel (Premiere's wording and behaviour) - if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { - let r = ui.button(tl!("Close Panel")); - app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); - if r.clicked() { - let _ = app.session.execute("sequence.close", serde_json::json!({})); - close = true; - } - let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); - app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); - if r.clicked() { - let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); - close = true; - } - } else if ui.button(tl!("Close Panel")).clicked() { - app.ui.dock.close(p); - close = true; - } - if ui.button(tl!("Maximize Frame")).clicked() { - app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![p], active: 0 }; - close = true; - } - if ui.button(tl!("Restore Workspace")).clicked() { - let w = app.ui.workspace.clone(); - app.set_workspace(&w); - close = true; - } - if p == PanelKind::Timeline { - ui.separator(); - let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); - app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); - if r.clicked() { - let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); - close = true; - } - ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); - ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); - } - if p == PanelKind::Project { - ui.separator(); - close |= project::panel_menu(app, ui); - } - if p == PanelKind::MediaBrowser { - ui.separator(); - close |= media_browser::panel_menu(app, ui); - } + let scroll = + egui::ScrollArea::vertical().id_salt("panel-menu-scroll").max_height((ui.ctx().content_rect().height() - 16.0).max(0.0)).show(ui, |ui| { + ui.set_min_width(190.0); + // the Timeline's tabs are its open sequences: Close Panel closes the active one and + // keeps the panel (Premiere's wording and behaviour) + if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { + let r = ui.button(tl!("Close Panel")); + app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); + if r.clicked() { + let _ = app.session.execute("sequence.close", serde_json::json!({})); + close = true; + } + let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); + app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); + if r.clicked() { + let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); + close = true; + } + } else if ui.button(tl!("Close Panel")).clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.closePanel", serde_json::json!({"panel": p.id()})); + close = true; + } + let undock = ui.button(tl!("Undock Panel")); + app.auto.add_response(&format!("panel.menu.{}.undock", p.id()), &undock, "Undock Panel"); + if undock.clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.floatPanel", serde_json::json!({"panel": p.id()})); + close = true; + } + if ui.button(tl!("Maximize Frame")).clicked() { + app.ui.keys.maximized = if app.ui.keys.maximized == Some(p) { None } else { Some(p) }; + close = true; + } + if ui.button(tl!("Restore Workspace")).clicked() { + let w = app.ui.workspace.clone(); + app.set_workspace(&w); + close = true; + } + if p == PanelKind::Timeline { + ui.separator(); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); + app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); + if r.clicked() { + let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); + close = true; + } + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); + } + if p == PanelKind::Project { + ui.separator(); + close |= project::panel_menu(app, ui); + } + if p == PanelKind::MediaBrowser { + ui.separator(); + close |= media_browser::panel_menu(app, ui); + } + }); + app.auto.add("panel.menu.scroll", scroll.inner_rect, "Panel menu"); }); }); // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 11f4bc2d8..3acc7aeaf 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -28,7 +28,10 @@ pub enum Which { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) { let t = app.tokens; let ctx = ui.ctx().clone(); - let controls_h = 28.0 + 24.0 + 36.0; + let stacked_controls = rect.width() < 540.0; + let separate_source_drag = which == Which::Source && stacked_controls && rect.width() < 310.0; + let source_drag_h = if separate_source_drag { 26.0 } else { 0.0 }; + let controls_h = 28.0 + 24.0 + 36.0 + if stacked_controls { 26.0 } else { 0.0 } + source_drag_h; let video_area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - controls_h)); // the Source monitor's time ruler starts at `origin` (a subclip restricted to its range) let mut origin = Tick::ZERO; @@ -323,8 +326,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc); let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000); ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim); - // zoom + resolution dropdowns centred-ish - let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0)); + // At compact widths, timecodes and view settings need separate rows. + let settings_y = row1.min.y + if stacked_controls { 26.0 } else { 0.0 }; + let zoom_x = if stacked_controls && which == Which::Source && !separate_source_drag { + row1.min.x + } else if stacked_controls { + row1.center().x - 83.0 + } else { + row1.min.x + 116.0 + }; + let zr = Rect::from_min_size(pos2(zoom_x, settings_y), vec2(70.0, 24.0)); let zoom_label = match mv.zoom { None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), @@ -333,9 +344,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level"); monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; - let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0)); + let resolution_x = if stacked_controls && which == Which::Source && !separate_source_drag { + row1.max.x - 90.0 + } else if stacked_controls { + zr.max.x + 6.0 + } else { + row1.max.x - 196.0 + }; + let rr = Rect::from_min_size(pos2(resolution_x, settings_y), vec2(62.0, 24.0)); if which == Which::Source && rr.min.x > zr.max.x + 12.0 { - crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, row1.min.y), pos2(rr.min.x - 6.0, row1.min.y + 24.0))); + crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, settings_y), pos2(rr.min.x - 6.0, settings_y + 24.0))); + } + if separate_source_drag { + crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0))); } let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); @@ -346,7 +367,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } } }); - let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, row1.min.y + 1.0), vec2(22.0, 22.0)); + let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0)); let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{prefix}.settings"), wr, "Settings"); @@ -402,7 +423,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } }); // ---- mini timeline / scrub bar - let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, row1.max.y + 2.0), vec2(rect.width() - 28.0, 22.0)); + let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, settings_y + 28.0 + source_drag_h), vec2(rect.width() - 28.0, 22.0)); mini_timeline(app, ui, bar, which, origin, time, duration, rate, mark_in, mark_out); // ---- transport buttons @@ -726,51 +747,107 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] }; - let bw = 30.0; - let total = buttons.len() as f32 * bw; - let mut x = row.center().x - total / 2.0; - let ctx = ui.ctx().clone(); let prefix = if src { "source" } else { "program" }; - for (icon, cmd, tip) in buttons { - let r = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(bw - 2.0, 26.0)); - let resp = ui.interact(r, egui::Id::new((prefix, cmd)), Sense::click()).on_hover_text(tip); - app.auto.add(&format!("{prefix}.transport.{cmd}"), r, tip); - let is_play = cmd.ends_with("play") || cmd == "playback.toggle"; - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); + let available = (row.width() - 16.0).max(0.0); + let widths = vec![28.0; buttons.len()]; + let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 2.0, 28.0) else { + ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| { + egui::ScrollArea::horizontal().id_salt((prefix, "transport-scroll")).show(ui, |ui| { + ui.horizontal(|ui| { + for (icon, command, label) in &buttons { + let (rect, _) = ui.allocate_exact_size(vec2(28.0, 26.0), Sense::hover()); + if transport_button(app, ui, rect, prefix, *icon, command, label) { + invoke_transport(app, ui.ctx(), which, command); + } + } + }); + }); + }); + return; + }; + let total = fit.shown.len() as f32 * 30.0 - 2.0 + if fit.overflow.is_empty() { 0.0 } else { 30.0 }; + let mut x = row.center().x - total.max(28.0) / 2.0; + for (index, _) in &fit.shown { + let Some((icon, command, label)) = buttons.get(*index) else { continue }; + let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0)); + if transport_button(app, ui, rect, prefix, *icon, command, label) { + invoke_transport(app, ui.ctx(), which, command); } - let sz = if is_play { 16.0 } else { 14.0 }; - let on = cmd == "media.toggleProxies" && app.session.media.use_proxies(); - let col = if on { - t.accent - } else if resp.hovered() { - t.tab_text_active - } else { - t.icon - }; - icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(sz, sz)), icon, col); - if resp.clicked() { - let r = match cmd { - "src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|e| e.to_string()), - "src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|e| e.to_string()), - "src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => { - source_nav(app, cmd); - Ok(serde_json::Value::Null) + x += 30.0; + } + if !fit.overflow.is_empty() { + let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0)); + let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls"))); + if response.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + for offset in [-5.0, 0.0, 5.0] { + ui.painter().circle_filled(rect.center() + vec2(offset, 0.0), 1.5, t.icon); + } + if response.has_focus() { + ui.painter().rect_stroke(rect.shrink(2.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + } + app.auto.add_response(&format!("{prefix}.transport.more"), &response, "More transport controls"); + egui::Popup::menu(&response).show(|ui| { + for index in &fit.overflow { + let Some((_, command, label)) = buttons.get(*index) else { continue }; + let selected = match *command { + "media.toggleProxies" => Some(app.session.media.use_proxies()), + "playback.toggle" => Some(app.playback.playing), + "source.playback.toggle" => Some(app.source_playback.clock.playing), + _ => None, + }; + let response = if let Some(selected) = selected { ui.selectable_label(selected, *label) } else { ui.button(*label) }; + app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); + if response.clicked() { + invoke_transport(app, ui.ctx(), which, command); + ui.close(); } + } + }); + } else if row.right() - x >= 30.0 { + // Keep the existing button-editor affordance only when it has its own space. + let rect = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); + let response = ui.interact(rect, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); + icons::paint(ui.painter(), rect.shrink(5.0), Icon::Plus, if response.hovered() { t.tab_text_active } else { t.text_dim }); + } +} + +fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool { + let t = app.tokens; + let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label); + app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); + if response.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let size = if matches!(command, "playback.toggle" | "source.playback.toggle") { 16.0 } else { 14.0 }; + let color = if command == "media.toggleProxies" && app.session.media.use_proxies() { + t.accent + } else if response.hovered() { + t.tab_text_active + } else { + t.icon + }; + icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(size, size)), icon, color); + response.clicked() +} - "exportFrame" => crate::menus::invoke(app, &ctx, "file.exportFrame", json!({"monitor": if src { "source" } else { "program" }})), - c => crate::menus::invoke(app, &ctx, c, json!({"monitor": if src { "source" } else { "program" }})), - }; - if let Err(e) = r { - app.ui.status = e; - } +fn invoke_transport(app: &mut FilmcraftApp, ctx: &egui::Context, which: Which, command: &str) { + let source = which == Which::Source; + let result = match command { + "src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|error| error.to_string()), + "src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|error| error.to_string()), + "src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => { + source_nav(app, command); + Ok(serde_json::Value::Null) } - x += bw; + "exportFrame" => crate::menus::invoke(app, ctx, "file.exportFrame", json!({"monitor": if source { "source" } else { "program" }})), + command => crate::menus::invoke(app, ctx, command, json!({"monitor": if source { "source" } else { "program" }})), + }; + if let Err(error) = result { + app.ui.status = error; } - // button editor "+" - let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); - icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); } fn source_nav(app: &mut FilmcraftApp, cmd: &str) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d59978393..60732fc18 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -566,6 +566,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // accept drops from the project panel if let Some(item) = crate::panels::dragged_project_item(ui) && ui.rect_contains_pointer(rect) + && ui.input(|i| i.pointer.hover_pos()).is_some_and(|pos| app.timeline_drop_target(ui.ctx(), pos)) && ui.input(|i| i.pointer.any_released()) { let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); @@ -2286,6 +2287,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(item) = crate::panels::dragged_project_item(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let source = crate::panels::dragged_source(ui); let row = layout.row_at(p.y).cloned().filter(|r| { @@ -2333,6 +2335,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(template) = crate::panels::dragged_template(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]); let row = layout.row_at(p.y).cloned().filter(|r| r.kind == TrackKind::Video); @@ -2354,6 +2357,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(effect) = crate::panels::dragged_effect(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) && let Hit::Clip { clip, .. } = hit(seq, layout, p) && let Some((tid, it)) = seq.find_item(clip) && let Some(row) = layout.rows.iter().find(|r| r.track == tid) @@ -2408,6 +2412,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // ---- OS file drops imported with "drop imports to the timeline": placed at the drop point, one after another if let Some((items, p)) = app.pending_timeline_drop.clone() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { app.pending_timeline_drop = None; let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned()); @@ -2436,6 +2441,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // preview of OS files being dragged over the Timeline: a placeholder where the drop would start if let Some((count, p)) = app.file_drag_hover && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned()); if let Some(row) = row { @@ -2444,6 +2450,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let r = Rect::from_min_max(pos2(x, row.rect.min.y + 1.0), pos2((x + 120.0).min(layout.content.max.x), row.rect.max.y - 1.0)); ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40)); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + app.auto.add("timeline.fileDropPreview", r, "tracks"); let label = if count == 1 { tl!("1 file").to_string() } else { tlf!("{n} files", n = count) }; ui.painter().text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, label, Tokens::ui(10.0), Color32::WHITE); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 35afefc9d..15228a502 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -24,8 +24,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut()) }; let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click()).on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label())); for tl in &group { - app.auto.add(&format!("tools.{tl:?}"), r, tl.label()); + if *tl == current { + app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label()); + } else { + // Hidden group alternatives remain click aliases, not separate focus targets. + app.auto.add(&format!("tools.{tl:?}"), r, tl.label()); + } } let active = group.contains(&app.ui.tool); if active { @@ -34,6 +40,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(r, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(17.0, 17.0)), current.icon(), if active { egui::Color32::WHITE } else { t.icon }); + if resp.has_focus() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.focus), egui::StrokeKind::Inside); + } if group.len() > 1 { let c = r.right_bottom() - vec2(3.0, 3.0); ui.painter().add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.text_dim, egui::Stroke::NONE)); @@ -64,7 +73,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| { for tl in &group { let row = ui.selectable_label(app.ui.tool == *tl, format!("{} {}", app.ui.language.tr(tl.label()), tl.shortcut())); - app.auto.add(&format!("tools.select.{tl:?}"), row.rect, tl.label()); + app.auto.add_response(&format!("tools.select.{tl:?}"), &row, tl.label()); if row.clicked() { app.ui.tool = *tl; ui.close(); diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 69d529441..0923316c2 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -56,12 +56,17 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Result<(), String> { + crate::dock::Layout { root: app.ui.dock.clone(), floating: app.ui.floating.clone() }.validate()?; let layout = serde_json::to_value(&app.ui.dock).map_err(|e| e.to_string())?; + let floating = if app.ui.floating.is_empty() { serde_json::Value::Null } else { serde_json::to_value(&app.ui.floating).map_err(|e| e.to_string())? }; let mut next = app.workspaces.clone(); let saved = &mut next.saved; match saved.iter_mut().find(|s| s.name == name) { - Some(s) => s.layout = layout, - None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout }), + Some(s) => { + s.layout = layout; + s.floating = floating; + } + None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout, floating }), } next.current = name.to_string(); app.set_workspaces(next) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aacea877e..b02fae254 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -376,6 +376,8 @@ pub struct UiState { pub mode: Mode, pub workspace: String, pub dock: DockNode, + #[serde(default)] + pub floating: Vec, /// Focused panel (blue outline, receives shortcuts). pub focused: PanelKind, pub timeline: TimelineView, @@ -886,6 +888,7 @@ impl Default for UiState { tool: Tool::Selection, mode: Mode::Edit, workspace: "Editing".into(), + floating: vec![], dock: crate::dock::workspace("Editing"), focused: PanelKind::Timeline, timeline: TimelineView::default(), diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/monitor_geometry.rs new file mode 100644 index 000000000..bb6711967 --- /dev/null +++ b/crates/ui-egui/tests/monitor_geometry.rs @@ -0,0 +1,185 @@ +//! Geometry and reachability checks against the production monitor renderer. + +use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{self, DockNode, PanelKind, SeqTabs}, + panels::monitor::{self, Which}, + theme::{self, ThemeKind}, +}; +use serde_json::json; + +fn harness(width: f32, kind: ThemeKind, which: Which) -> Harness<'static, FilmcraftApp> { + harness_at_scale(width, kind, which, 1.0) +} + +fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Harness<'static, FilmcraftApp> { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + session.execute("source.open", json!({"item":5})).unwrap(); + let mut h = Harness::builder().with_size(vec2(width, 340.0)).with_pixels_per_point(scale).build_ui_state( + move |ui, app: &mut FilmcraftApp| { + app.set_theme(ui.ctx(), kind); + app.auto.begin_frame(); + let panel = if which == Which::Source { PanelKind::Source } else { PanelKind::Program }; + let mut node = DockNode::Tabs { panels: vec![panel], active: 0 }; + let mut groups = Vec::new(); + dock::layout(ui, &mut node, ui.max_rect(), &app.tokens, "monitor", &mut groups, &mut app.auto); + for group in groups { + // Exercise the actual parent chrome, including its themed panel background. + dock::draw_group_chrome(ui, &group, panel, &SeqTabs::default(), &app.tokens, &mut app.auto); + monitor::show(app, ui, group.content, which); + } + let source = filmcraft_ui_egui::panels::dragged_source(ui); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); + }, + FilmcraftApp::new(session), + ); + theme::install(&h.ctx, &h.state().tokens); + h.run_steps(4); + h +} + +fn element(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect { + let element = h.state().auto.elements.iter().find(|element| element.id == id).unwrap_or_else(|| panic!("missing {id}")); + let [x, y, w, h] = element.rect; + Rect::from_min_size(pos2(x, y), vec2(w, h)) +} + +fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) { + let pos = element(h, id).center(); + h.event(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn overlaps(first: Rect, second: Rect) -> bool { + let intersection = first.intersect(second); + intersection.width() > 0.0 && intersection.height() > 0.0 +} + +#[test] +fn monitor_readouts_and_dropdowns_do_not_overlap() { + let mut failures = Vec::new(); + for (which, prefix) in [(Which::Source, "source"), (Which::Program, "program")] { + for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] { + for width in [720.0, 500.0, 300.0, 250.0] { + for scale in [1.0, 2.0] { + let mut h = harness_at_scale(width, kind, which, scale); + if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/film-{prefix}-{kind:?}-{width}-{scale}x.png")).unwrap(); + } + let controls = ["timecode", "zoom", "resolution", "settings"].map(|name| element(&h, &format!("{prefix}.{name}"))); + let timecodes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text().matches(':').count() == 3 => { + Some(text.visual_bounding_rect().intersect(shape.clip_rect)) + } + _ => None, + }) + .collect(); + assert_eq!(timecodes.len(), 2, "both actual timecode galleys must be painted"); + if overlaps(timecodes[0], timecodes[1]) { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: actual timecode text overlaps: {timecodes:?}")); + } + for (i, first) in controls.iter().enumerate() { + if first.left() < 0.0 || first.right() > width { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: outside monitor: {first:?}")); + } + for second in controls.iter().skip(i + 1) { + if overlaps(*first, *second) { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: {first:?} overlaps {second:?}")); + } + } + } + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn compact_transport_buttons_stay_inside_monitor() { + for width in [720.0, 500.0, 300.0, 250.0] { + let h = harness(width, ThemeKind::Dark, Which::Program); + for e in &h.state().auto.elements { + if e.id.starts_with("program.transport.") { + let r = element(&h, &e.id); + assert!(r.left() >= 0.0 && r.right() <= width, "{width}: {} {r:?}", e.id); + } + } + } +} + +#[test] +fn compact_transport_more_runs_the_existing_proxy_command() { + let mut h = harness(250.0, ThemeKind::Light, Which::Program); + let before = h.state().session.media.use_proxies(); + click(&mut h, "program.transport.more"); + assert_eq!( + h.get_by_label("Toggle Proxies").accesskit_node().toggled(), + Some(if before { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False }) + ); + click(&mut h, "program.transport.media.toggleProxies"); + assert_eq!(h.state().session.media.use_proxies(), !before); + click(&mut h, "program.transport.more"); + assert_eq!( + h.get_by_label("Toggle Proxies").accesskit_node().toggled(), + Some(if before { egui::accesskit::Toggled::False } else { egui::accesskit::Toggled::True }) + ); +} + +#[test] +fn compact_source_overflow_keeps_source_navigation_routing() { + let mut h = harness(250.0, ThemeKind::Dark, Which::Source); + let before = h.state().session.state.source_playhead; + let program = h.state().session.playhead(); + click(&mut h, "source.transport.more"); + click(&mut h, "source.transport.src.goOut"); + assert!(h.state().session.state.source_playhead > before); + assert_eq!(h.state().session.playhead(), program); +} + +fn source_drag_choices(width: f32) { + for (control, video, audio) in [("video", true, false), ("audio", false, true), ("both", true, true)] { + let mut h = harness(width, ThemeKind::Dark, Which::Source); + let rect = element(&h, &format!("source.drag.{control}")); + assert!(rect.left() >= 0.0 && rect.right() <= width); + let point = rect.center(); + h.event(Event::PointerMoved(point)); + h.step(); + h.event(Event::PointerButton { pos: point, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(point + vec2(0.0, 10.0))); + h.run_steps(2); + let source = h + .ctx + .data(|data| data.get_temp::>(egui::Id::new("observed-source-drag"))) + .flatten() + .expect("drag must create the production Source payload"); + assert_eq!((source.video, source.audio), (video, audio)); + assert_eq!(source.item.0, 5); + assert!(source.range.duration.0 > 0); + } +} + +#[test] +fn source_drag_choices_remain_reachable_at_500_pixels() { + source_drag_choices(500.0); +} + +#[test] +fn source_drag_choices_remain_reachable_at_250_pixels() { + source_drag_choices(250.0); +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32f..b8aab49db 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -322,8 +322,21 @@ fn freeform_view_places_and_stacks_cards() { assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge"); // Freeform View Options… from the panel menu d.click("panel.menu.Project"); + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); + let item_rect = d.rect("project.menu.freeformOptions"); + let ctx = d.harness.ctx.clone(); + let at = egui::pos2(item_rect[0] + item_rect[2] / 2.0, item_rect[1] + item_rect[3] / 2.0); + assert!(ctx.content_rect().contains(at), "bottom menu action must be reachable: {item_rect:?}"); + d.snapshot("project-freeform-menu", None); d.click("project.menu.freeformOptions"); - assert!(d.has("projectDialog.snap")); + let app = d.app(); + let diagnostic = format!( + "dialog={:?}, controls={:?}", + app.ui.project_panel.dialog, + app.auto.elements.iter().filter(|element| element.id.starts_with("projectDialog.")).map(|element| (&element.id, element.rect)).collect::>() + ); + assert!(d.has("projectDialog.snap"), "{diagnostic}"); d.click("projectDialog.snap"); d.click("projectDialog.ok"); assert!(d.app().session.prefs.project_panel.freeform.snap); @@ -358,6 +371,8 @@ fn metadata_display_dialog_and_view_presets() { ] { assert!(d.has(id), "{id}"); } + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); d.click("project.menu.metadataDisplay"); d.frames(4); assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. }))); @@ -573,6 +588,8 @@ fn media_browser_navigation_import_and_columns() { assert!(d.label("mediaBrowser.count").starts_with("2 of 2")); d.snapshot("media-browser-list", Some("panel.MediaBrowser")); d.click("panel.menu.MediaBrowser"); + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); d.click("mediaBrowser.menu.import"); assert_eq!(d.app().session.project.items.len(), 2); // Shift+O opens the selection in the Source monitor (already imported: no duplicate) diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs new file mode 100644 index 000000000..fcabbdd04 --- /dev/null +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -0,0 +1,515 @@ +//! Shared docking preserves FilmCraft document state and legacy workspace files. + +use craft_ui::docking::{Action, Placement, Zone}; +use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{self, DockNode, Layout, PanelKind, SplitSize}, + menus, +}; +use serde_json::json; + +fn layout() -> Layout { + Layout { + root: DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::History], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), + }, + floating: vec![], + } +} + +#[test] +fn shared_transactions_keep_selection_and_reject_invalid_moves_atomically() { + let mut layout = layout(); + layout.apply(Action::Close { panel: PanelKind::Project }).unwrap(); + assert!(layout.root.is_visible(PanelKind::Info)); + let before = layout.clone(); + for action in [ + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Tools, placement: Placement::Split(Zone::Left) }, + Action::Float { panel: PanelKind::Info, rect: [0.0, 0.0, f32::NAN, 100.0] }, + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Tab { before: Some(PanelKind::Tools) } }, + ] { + assert!(layout.apply(action).is_err()); + assert_eq!(layout, before); + } + layout.apply(Action::Float { panel: PanelKind::Info, rect: [50.0, 50.0, 400.0, 300.0] }).unwrap(); + layout.apply(Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Split(Zone::Bottom) }).unwrap(); + assert!(layout.floating.is_empty()); + assert!(layout.root.is_visible(PanelKind::Info)); +} + +#[test] +fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { + for name in dock::WORKSPACES { + let root = dock::workspace(name); + let before = serde_json::to_value(&root).unwrap(); + let prefs: dock::WorkspacePrefs = serde_json::from_value(json!({"saved": [{"name": name, "layout": before}], "current": name})).unwrap(); + let loaded = dock::saved_docking(&prefs, name); + loaded.validate().unwrap(); + assert_eq!(loaded.root, root); + assert!(loaded.floating.is_empty()); + assert!(serde_json::to_value(&prefs).unwrap()["saved"][0].get("floating").is_none()); + } + let dir = std::env::temp_dir().join(format!("filmcraft-shared-dock-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::default(); + session.prefs_path = Some(dir.join("preferences.json")); + let mut app = FilmcraftApp::new(session); + let ctx = egui::Context::default(); + app.ui.dock = layout().root; + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); + menus::invoke(&mut app, &ctx, "window.workspace.saveAs", json!({"name": "Floating test"})).unwrap(); + let prefs = dock::WorkspacePrefs::load(&dir.join(dock::WORKSPACES_FILE)); + let restored = dock::saved_docking(&prefs, "Floating test"); + assert_eq!(restored.floating, app.ui.floating); + assert_eq!(restored.root, app.ui.dock); + let mut restarted_session = Session::default(); + restarted_session.prefs_path = Some(dir.join("preferences.json")); + let restarted = FilmcraftApp::new(restarted_session); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| restarted); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(h.state().ui.dock, restored.root); + assert_eq!(h.state().ui.floating, restored.floating); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn legacy_effects_workspace_drops_only_the_known_inactive_duplicate() { + let expected = dock::workspace("Effects"); + let mut legacy = expected.clone(); + fn duplicate(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EssentialGraphics] => { + panels.insert(0, PanelKind::EffectControls); + *active = 1; + } + DockNode::Split { a, b, .. } => { + duplicate(a); + duplicate(b); + } + DockNode::Tabs { .. } => {} + } + } + duplicate(&mut legacy); + let prefs: dock::WorkspacePrefs = + serde_json::from_value(json!({"saved": [{"name": "Historical Effects", "layout": legacy}], "current": "Historical Effects"})).unwrap(); + assert_eq!(dock::saved_docking(&prefs, "Historical Effects").root, expected); +} + +fn step(h: &mut Harness<'_, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut input = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut input); + *h.input_mut() = input; + h.step(); +} +fn rect(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect { + let element = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])) +} +fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) { + let pos = rect(h, id).center(); + h.event(Event::PointerMoved(pos)); + step(h); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + step(h); + } + for _ in 0..3 { + step(h); + } +} +fn drag(h: &mut Harness<'_, FilmcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(from)); + step(h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + step(h); + for fraction in [0.05, 0.25, 0.5, 0.75, 1.0] { + h.event(Event::PointerMoved(from.lerp(to, fraction))); + step(h); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + step(h); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + for _ in 0..3 { + step(h); + } +} + +#[test] +fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let snapshots = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut builder = Harness::builder().with_size(vec2(1200.0, 760.0)); + if snapshots.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + h.state_mut().ui.dock = DockNode::Split { + vertical: true, + size: SplitSize::Ratio(0.62), + a: Box::new(layout().root), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + for _ in 0..3 { + step(&mut h); + } + let project = h.state().session.project.clone(); + let mut action_log = vec![action_state(&h, "start")]; + let playheads = h.state().session.state.playheads.clone(); + let from = rect(&h, "panel.tab.Info").center(); + let to = rect(&h, "panel.Program").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.dock.is_visible(PanelKind::Info)); + assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program)); + action_log.push(action_state(&h, "merged")); + let from = rect(&h, "panel.tab.Info").center(); + let before = rect(&h, "panel.tab.Source"); + drag(&mut h, from, pos2(before.left() + 2.0, before.center().y), Modifiers::NONE); + assert_eq!(tabs_containing(&h.state().ui.dock, PanelKind::Info).first(), Some(&PanelKind::Info)); + action_log.push(action_state(&h, "reordered")); + + let from = rect(&h, "panel.tab.Info").center(); + let left = rect(&h, "panel.History"); + // Activate a visible sibling after the source left its original group. + let to = pos2(left.left() + 8.0, left.center().y); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.dock.is_visible(PanelKind::Info)); + assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::History)); + assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program)); + action_log.push(action_state(&h, "split")); + click(&mut h, "panel.tab.Program"); + assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip."))); + assert!(h.state().auto.find("program.transport.playhead.stepForward").is_some()); + let from = rect(&h, "panel.tab.Info").center(); + drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND); + assert_eq!(h.state().ui.floating.len(), 1); + assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button); + action_log.push(action_state(&h, "floated")); + let original = h.state().ui.floating[0].rect; + let grip = rect(&h, "panel.float.0.resize").center(); + drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE); + assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0); + assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0); + action_log.push(action_state(&h, "resized")); + let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize"); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + let before_keys = h.state().ui.floating[0].rect; + for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + } + } + assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0); + assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0); + h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + for _ in 0..3 { + step(&mut h); + } + assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + action_log.push(action_state(&h, "keyboard-resized")); + let resized = h.state().ui.floating[0].rect; + let header = rect(&h, "panel.float.0.move"); + let from = pos2(header.right() - 24.0, header.center().y); + drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE); + assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0); + assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0); + action_log.push(action_state(&h, "moved")); + let saved_root = h.state().ui.dock.clone(); + let saved_floating = h.state().ui.floating.clone(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert!(rect(&h, "panel.Info").width() > 1000.0); + action_log.push(action_state(&h, "maximized")); + assert_eq!(h.state().ui.dock, saved_root); + assert_eq!(h.state().ui.floating, saved_floating); + menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert_eq!(h.state().ui.floating, saved_floating); + + if let Some(directory) = &snapshots { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("filmcraft-floating.png")).unwrap(); + } + let from = rect(&h, "panel.tab.Info").center(); + let to = rect(&h, "panel.History").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.floating.is_empty()); + action_log.push(action_state(&h, "redocked")); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + assert_eq!(playheads, h.state().session.state.playheads); + let playhead = h.state().session.playhead(); + click(&mut h, "program.transport.playhead.stepForward"); + assert!(h.state().session.playhead() > playhead); + assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip."))); + action_log.push(action_state(&h, "program-step-forward")); + if let Some(directory) = &snapshots { + std::fs::write(directory.join("filmcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap(); + } + if let Some(directory) = &snapshots { + h.render().unwrap().save(directory.join("filmcraft-redocked.png")).unwrap(); + } + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + assert!(h.state().ui_error.is_none()); +} + +fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool { + match root { + DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second), + DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second), + } +} + +fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec { + match root { + DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(), + DockNode::Split { a, b, .. } => { + let first = tabs_containing(a, panel); + if first.is_empty() { tabs_containing(b, panel) } else { first } + } + _ => vec![], + } +} + +#[test] +fn docking_commands_reject_bad_geometry_and_targets_without_partial_changes() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let ctx = egui::Context::default(); + let before = app.ui.dock.clone(); + for (id, params) in [ + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300]})), + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300, 200, 7]})), + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 1e100, 200]})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Tools"})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "zone": 42})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "before": "Tools"})), + ] { + assert!(menus::invoke(&mut app, &ctx, id, params.clone()).is_err(), "{id} accepted {params}"); + assert_eq!(app.ui.dock, before); + assert!(app.ui.floating.is_empty()); + } + app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + assert!(menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": [1, 2, 300, 200]})).is_err()); +} + +fn action_state(h: &Harness<'_, FilmcraftApp>, action: &str) -> serde_json::Value { + json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused}) +} + +#[test] +fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(300.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }), + }; + let ctx = egui::Context::default(); + for action in [ + Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) }, + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Project, placement: Placement::Tab { before: Some(PanelKind::Project) } }, + Action::Float { panel: PanelKind::Info, rect: [20.0, 40.0, 300.0, 200.0] }, + Action::MoveFloating { panel: PanelKind::Info, rect: [45.0, 65.0, 330.0, 240.0] }, + ] { + filmcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap(); + } + assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]); + let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") }; + assert_eq!(*size, SplitSize::FixedA(360.0)); + let before = json!({"dock": app.ui.dock, "floating": app.ui.floating}); + assert!( + filmcraft_ui_egui::menus::invoke( + &mut app, + &ctx, + "window.dockAction", + json!({"action": { + "ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}} + }}) + ) + .is_err() + ); + assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before); +} + +#[test] +fn unreadable_floating_workspace_does_not_erase_valid_workspaces_or_current_name() { + let root = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + let valid = json!({"name": "Valid", "layout": root, "floating": [{ + "panels": ["TextToSpeech", "Timeline"], "active": 0, "rect": [80, 90, 420, 320] + }]}); + for (index, bad) in [ + json!([{"panels": ["FuturePanel"], "active": 0, "rect": [80, 90, 420, 320]}]), + json!([{"panels": ["Info"], "active": "invalid", "rect": [80, 90, 420, 320]}]), + json!({"malformed": true}), + ] + .into_iter() + .enumerate() + { + let path = std::env::temp_dir().join(format!("filmcraft-floating-isolation-{}-{index}.json", std::process::id())); + let prefs = json!({"saved": [valid.clone(), {"name": "Broken", "layout": root, "floating": bad}], "current": "Valid"}); + std::fs::write(&path, serde_json::to_vec(&prefs).unwrap()).unwrap(); + let loaded = dock::WorkspacePrefs::load(&path); + assert_eq!(loaded.current, "Valid"); + assert_eq!(loaded.saved.len(), 2); + let restored = dock::saved_docking(&loaded, "Valid"); + restored.validate().unwrap(); + assert_eq!(restored.root, root); + assert_eq!(restored.floating[0].panels, vec![PanelKind::TextToSpeech, PanelKind::Timeline]); + assert_eq!(dock::saved_docking(&loaded, "Broken").root, dock::workspace("Broken")); + loaded.save(&path).unwrap(); + assert_eq!(dock::WorkspacePrefs::load(&path), loaded, "saving preserves the unreadable entry for a future version"); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn floating_tts_and_timeline_reveal_keeps_one_owner_and_draft_without_project_edits() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let mut app = FilmcraftApp::new(session); + app.ui.tts.text = "Synthetic retained narration".into(); + app.show_panel(PanelKind::TextToSpeech); + let project = app.session.project.clone(); + let state = serde_json::to_value(&app.session.state).unwrap(); + let draft = serde_json::to_value(&app.ui.tts).unwrap(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "TextToSpeech", "rect": [80, 90, 420, 320]})).unwrap(); + menus::invoke( + &mut app, + &ctx, + "window.dockAction", + json!({"action": Action::Move { + panel: PanelKind::Timeline, anchor: PanelKind::TextToSpeech, placement: Placement::Tab { before: None } + }}), + ) + .unwrap(); + app.show_panel(PanelKind::TextToSpeech); + app.show_panel(PanelKind::Timeline); + assert!(!app.ui.dock.contains(PanelKind::TextToSpeech)); + assert!(!app.ui.dock.contains(PanelKind::Timeline)); + assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::Timeline).count(), 1); + assert_eq!(serde_json::to_value(&app.ui.tts).unwrap(), draft); + assert!(std::sync::Arc::ptr_eq(&project, &app.session.project)); + assert_eq!(serde_json::to_value(&app.session.state).unwrap(), state); +} + +#[test] +fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_project() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let clip = session + .active_sequence() + .unwrap() + .audio_tracks + .iter() + .flat_map(|track| &track.items) + .find(|clip| { + session.project.item(clip.item).and_then(|item| item.as_media()).is_some_and(|media| media.info.kind == filmcraft_media::MediaKind::AudioOnly) + }) + .unwrap() + .clone(); + let narration = filmcraft_project::Narration { + text: "Synthetic narration behind an overlapping pane".into(), + language: "en-US".into(), + voice: filmcraft_tts::default_voice_id().into(), + pitch: filmcraft_project::VocalPitch::Default, + pace: 1.2, + speech_duration: filmcraft_time::Tick::ZERO, + }; + std::sync::Arc::make_mut(&mut session.project).narrations.insert(clip.item, narration.clone()); + let project = session.project.clone(); + let mut app = FilmcraftApp::new(session); + app.show_panel(PanelKind::TextToSpeech); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + let ctx = h.ctx.clone(); + for panel in ["TextToSpeech", "Info"] { + menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": [400, 220, 450, 360]})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + } + let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id()))); + let covered = rect(&h, "panel.TextToSpeech").center(); + assert!(rect(&h, "panel.Info").contains(covered)); + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..3 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)), "precondition: Info really covers TTS"); + h.state_mut().session.state.selection = vec![clip.id]; + for _ in 0..4 { + step(&mut h); + } + let draft = h.state().ui.tts.clone(); + assert_eq!(draft.text, narration.text); + assert_eq!(draft.pace, narration.pace); + assert_eq!(draft.loaded_from, Some((clip.id.0, clip.item.0))); + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward"); + assert_eq!(h.state().ui.focused, PanelKind::TextToSpeech); + assert!(!h.state().ui.dock.contains(PanelKind::TextToSpeech)); + assert_eq!(h.state().ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + // A reveal is one-shot: drawing the selected narration must not steal stacking + // from another pane that the user subsequently brings forward. + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(h.state().ui.tts, draft); + menus::invoke(h.state_mut(), &ctx, "window.panel.TextToSpeech", json!({})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech))); + assert_eq!(h.state().ui.tts, draft); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + // Replacing the workspace cancels a queued reveal before the next render, + // even when the destination happens to contain the same floating panel IDs. + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..3 { + step(&mut h); + } + let saved = dock::SavedWorkspace { + name: "Reveal cancellation".into(), + layout: serde_json::to_value(&h.state().ui.dock).unwrap(), + floating: serde_json::to_value(&h.state().ui.floating).unwrap(), + }; + h.state_mut().workspaces.saved.push(saved); + h.state_mut().show_panel(PanelKind::TextToSpeech); + h.state_mut().set_workspace("Reveal cancellation"); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(h.state().ui.tts, draft); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); +} diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/timecode_ui.rs index badc35365..d0b849284 100644 --- a/crates/ui-egui/tests/timecode_ui.rs +++ b/crates/ui-egui/tests/timecode_ui.rs @@ -174,3 +174,37 @@ fn the_program_and_source_timecodes_move_their_own_playheads() { assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate"); assert_eq!(d.harness.state().session.playhead(), program); } + +#[test] +fn compact_floating_monitor_timecodes_keep_drag_typing_escape_and_caret_routing() { + for scale in [1.0, 2.0] { + let mut d = Driver::demo(); + d.harness.ctx.set_pixels_per_point(scale); + d.frames(4); + for (panel, x) in [("Source", 70), ("Program", 360)] { + d.ok("engine.execute", json!({"command": "window.floatPanel", "params": {"panel": panel, "rect": [x, 100, 250, 360]}})); + } + let project = d.harness.state().session.project.clone(); + d.set_frame(30); + d.drag_by("program.timecode", 20.0); + assert_eq!(d.frame(), 50); + let program = d.harness.state().session.playhead(); + let source = d.source_frame(); + d.drag_by("source.timecode", 20.0); + assert_eq!(d.source_frame(), source + 20); + assert_eq!(d.harness.state().session.playhead(), program); + d.type_into("program.timecode", "1000", "Escape"); + assert_eq!(d.harness.state().session.playhead(), program); + d.type_into("source.timecode", "100", "Enter"); + assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap()); + assert_eq!(d.harness.state().session.playhead(), program); + d.ok("ui.click", json!({"id": "program.timecode"})); + d.ok("ui.key", json!({"key": "Left"})); + assert_eq!(d.harness.state().session.playhead(), program, "caret arrows must not seek"); + d.ok("ui.key", json!({"key": "Escape"})); + d.type_into("program.timecode", "200", "Enter"); + let s = &d.harness.state().session; + assert_eq!(d.frame(), parse_timecode("200", s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame, 0).unwrap()); + assert!(std::sync::Arc::ptr_eq(&project, &s.project)); + } +} diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9fa..514b2a03d 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -152,3 +152,117 @@ fn with_drop_import_off_the_file_is_only_imported() { assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin"); assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status); } + +fn float_panel(h: &mut Harness<'static, FilmcraftApp>, panel: &str, rect: [f32; 4]) { + let ctx = h.ctx.clone(); + filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": rect})).unwrap(); + for _ in 0..4 { + step(h); + } +} + +fn drop_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2, path: &Path) { + pointer_to(h, pos); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf()))); + step(h); + h.input_mut().dropped_files.clear(); + step(h); +} + +fn hover_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2) -> bool { + pointer_to(h, pos); + h.input_mut().hovered_files.push(Default::default()); + step(h); + let preview = h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview"); + h.input_mut().hovered_files.clear(); + step(h); + preview +} + +#[test] +fn floating_panel_blocks_os_drop_and_hover_but_uncovered_timeline_still_accepts() { + for scale in [1.0, 2.0] { + let mut h = harness(); + h.ctx.set_pixels_per_point(scale); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + let project = h.state().session.project.clone(); + let covered_preview = hover_at(&mut h, at); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project), "hover does not edit the project"); + let items_before = h.state().session.project.items.len(); + drop_at(&mut h, at, &still(&format!("drop_covered_{scale}.png"))); + assert_eq!(h.state().session.project.items.len(), items_before + 1); + assert_eq!(clip_counts(&h), (0, 0), "a covered Timeline must not be edited"); + assert!(!covered_preview, "a covered Timeline must not preview a placement"); + float_panel(&mut h, "Info", [5.0, 5.0, 320.0, 200.0]); + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still(&format!("drop_uncovered_{scale}.png"))); + assert_eq!(clip_counts(&h), (1, 0)); + } +} + +#[test] +fn floating_timeline_accepts_once_and_another_float_can_occlude_it() { + let mut h = harness(); + float_panel(&mut h, "Timeline", [300.0, 180.0, 950.0, 640.0]); + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_floating_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + assert!(!hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_occluded_floating_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); +} + +#[test] +fn offscreen_float_uses_rendered_clamp_and_maximized_timeline_ignores_hidden_floats() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + // The very wide offscreen float is clamped back to x=0, over this row. + float_panel(&mut h, "Info", [-5000.0, at.y - 60.0, 1500.0, 200.0]); + let visible = control_rect(&h, "panel.Info"); + assert!(visible.contains(at), "rendered={visible:?}, drop={at:?}, floats={:?}", h.state().ui.floating); + assert!(!hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_clamped_occlusion.png")); + assert_eq!(clip_counts(&h), (0, 0)); + h.state_mut().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Timeline); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_maximized_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); +} + +#[test] +fn os_cursor_hook_selects_destination_when_egui_pointer_has_no_motion() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + // Physical pixels at 1x, with the synthetic viewport origin fixed at zero. + h.state_mut().hooks.cursor_screen_position = Some(Box::new(move || Some((at.x.round() as i32, at.y.round() as i32)))); + if let Some(viewport) = h.input_mut().viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 980.0))); + } + h.input_mut().hovered_files.push(Default::default()); + step(&mut h); + assert!(!h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview")); + h.input_mut().hovered_files.clear(); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_covered.png")))); + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (0, 0)); + h.state_mut().ui.floating.clear(); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_visible.png")))); + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (1, 0)); +} diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs new file mode 100644 index 000000000..773b451dd --- /dev/null +++ b/crates/ui-egui/tests/ui_control_input.rs @@ -0,0 +1,277 @@ +//! Control-only validation must not share keyboard ownership with native computer use. + +use std::sync::{ + Arc, + mpsc::{Sender, channel}, +}; + +use egui::{Event, Id, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + control::ControlRequest, + dock::{DockNode, PanelKind, SplitSize}, +}; +use serde_json::{Value, json}; + +struct Driver { + h: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new(isolated: bool) -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session); + let app = if isolated { app.with_control_input_only(rx) } else { app.with_control(rx) }; + let mut driver = Self { h: Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app), tx }; + driver.frames(4, true); + driver + } + + fn frames(&mut self, count: usize, native_focused: bool) { + for _ in 0..count { + let ctx = self.h.ctx.clone(); + let mut raw = std::mem::take(self.h.input_mut()); + raw.focused = native_focused; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.focused = Some(native_focused); + } + eframe::App::raw_input_hook(self.h.state_mut(), &ctx, &mut raw); + *self.h.input_mut() = raw; + self.h.step(); + assert!(self.h.state().ui_error.is_none()); + } + } + + fn ok(&mut self, method: &str, params: Value, native_focused: bool) -> Value { + let (request, reply) = ControlRequest::new(method, params); + self.tx.send(request).unwrap(); + for _ in 0..60 { + self.frames(1, native_focused); + if let Ok(result) = reply.try_recv() { + assert_eq!(result["ok"], true, "{result}"); + self.frames(2, native_focused); + return result["result"].clone(); + } + } + panic!("no control reply to {method}"); + } +} + +fn native_key(h: &mut Harness<'_, FilmcraftApp>, key: Key) { + for pressed in [true, false] { + // The backend supplies native events before raw_input_hook. Harness::event would + // instead append them after that hook and bypass the input boundary under test. + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } +} + +#[test] +fn native_j_cannot_start_reverse_playback_during_control_only_editing() { + let mut d = Driver::new(true); + d.ok("engine.execute", json!({"command":"playhead.set","params":{"seconds":1}}), true); + let before = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true); + let (request, reply) = ControlRequest::new("ui.key", json!({"key":"K","command":true})); + d.tx.send(request).unwrap(); + d.frames(1, true); // queue the requested command; the native key arrives in its input frame + native_key(&mut d.h, Key::J); + d.frames(4, true); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert!(!d.h.state().playback.playing, "native J must not contaminate the automated edit"); + let after = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true); + assert_eq!(after["playhead"], before["playhead"]); + assert_eq!(after["video"][0]["items"].as_array().unwrap().len(), before["video"][0]["items"].as_array().unwrap().len() + 1); +} + +#[test] +fn background_control_input_resizes_the_focused_divider_without_seeking() { + background_divider(true); +} + +#[test] +fn ordinary_control_input_also_resizes_a_background_divider() { + background_divider(false); +} + +fn background_divider(isolated: bool) { + let mut d = Driver::new(isolated); + d.h.state_mut().ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + d.frames(3, true); + let before_project = d.h.state().session.project.clone(); + let before_playheads = d.h.state().session.state.playheads.clone(); + let divider = Id::new(("dock-gutter", String::new())); + d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); + d.frames(2, true); + d.h.input_mut().events.push(Event::WindowFocused(false)); + d.ok("ui.key", json!({"key":"ArrowRight"}), false); + assert!(matches!(d.h.state().ui.dock, DockNode::Split { size: SplitSize::FixedA(408.0), .. }), "control input must work without native activation"); + assert_eq!(d.h.state().session.state.playheads, before_playheads); + assert!(Arc::ptr_eq(&d.h.state().session.project, &before_project)); + assert!(!d.h.state().playback.playing); +} + +#[test] +fn interactive_control_keeps_native_keyboard_and_menu_input() { + let mut d = Driver::new(false); + native_key(&mut d.h, Key::J); + d.frames(2, true); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); + d.ok("ui.playback", json!({"action":"stop"}), true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + tx.send("playback.reverse".into()).unwrap(); + d.frames(2, true); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); +} + +#[test] +fn native_menu_commands_cannot_contaminate_control_only_validation() { + let mut d = Driver::new(true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + tx.send("playback.reverse".into()).unwrap(); + d.frames(2, true); + assert!(!d.h.state().playback.playing); + // Intentional playback remains reachable through the control command path. + d.ok("engine.execute", json!({"command":"playback.reverse","params":{}}), false); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); +} + +#[test] +fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() { + let mut d = Driver::new(true); + let mut raw = egui::RawInput { focused: false, ..Default::default() }; + let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); + viewport.focused = Some(false); + viewport.events.push(egui::ViewportEvent::Close); + raw.events = vec![ + Event::Text("unrelated typing".into()), + Event::WindowFocused(false), + Event::Screenshot { + viewport_id: egui::ViewportId::ROOT, + user_data: Default::default(), + image: Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE])), + }, + ]; + raw.hovered_files.push(Default::default()); + #[derive(Debug)] + struct UnrelatedFile; + impl egui::DroppedFile for UnrelatedFile { + fn path(&self) -> &std::path::Path { + std::path::Path::new("/unrelated-input.txt") + } + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + Err("must not read unrelated native input".into()) + } + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Err("must not read unrelated native input".into()) }) + } + } + raw.dropped_files.push(Arc::new(UnrelatedFile)); + let ctx = d.h.ctx.clone(); + eframe::App::raw_input_hook(d.h.state_mut(), &ctx, &mut raw); + assert!(raw.focused); + assert_eq!(raw.viewports[&egui::ViewportId::ROOT].focused, Some(false)); + assert_eq!(raw.viewports[&egui::ViewportId::ROOT].events, vec![egui::ViewportEvent::Close]); + assert!(matches!(raw.events.as_slice(), [Event::Screenshot { .. }, Event::ModifiersChanged(Modifiers::NONE)])); + assert!(raw.hovered_files.is_empty() && raw.dropped_files.is_empty()); + let state = filmcraft_ui_egui::control::inspect(d.h.state(), &ctx); + assert_eq!(state["input"]["nativeWindowFocused"], false); +} + +#[test] +fn focus_reports_real_widget_ownership_and_rejects_bad_targets_without_changing_it() { + let mut d = Driver::new(true); + let focus = d.ok("ui.focus", json!({"id":"tools.Selection"}), false); + assert_eq!(focus["focusRequested"], "tools.Selection"); + let state = d.ok("ui.inspect", json!({}), false); + assert_eq!(state["keyboardFocus"]["widgetId"], focus["widgetId"]); + assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.Selection"])); + assert_eq!(state["keyboardFocus"]["active"], true); + assert_eq!(state["input"]["nativeWindowFocused"], false); + assert_eq!(state["input"]["effectiveWindowFocused"], true); + let focused = d.h.ctx.memory(|memory| memory.focused()); + for params in [ + json!({"id":7}), + json!({"id":"missing"}), + json!({"id":"panel.Tools"}), + json!({"id":"tools.Rolling"}), + json!({"id":"tools.Selection","activate":true}), + json!([]), + ] { + let (request, _) = ControlRequest::new("ui.focus", params); + let ctx = d.h.ctx.clone(); + let filmcraft_ui_egui::control::Outcome::Done(result) = filmcraft_ui_egui::control::handle(d.h.state_mut(), &ctx, &request) else { + panic!("bad focus target must fail immediately"); + }; + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(d.h.ctx.memory(|memory| memory.focused()), focused); + } + d.ok("ui.key", json!({"key":"Tab"}), false); + let state = d.ok("ui.inspect", json!({}), false); + assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.TrackSelectForward"])); + assert_ne!(state["keyboardFocus"]["widgetId"], focus["widgetId"]); + assert!(!d.h.state().playback.playing); +} + +#[test] +fn modified_control_click_adds_to_timeline_selection() { + for isolated in [true, false] { + let mut d = Driver::new(isolated); + d.ok("ui.set", json!({"timeline":{"fit":true}}), false); + d.frames(30, false); + let seq = d.h.state().session.active_sequence().unwrap(); + let first = seq.video_tracks[0].items[0].id; + let second = seq.video_tracks[0].items[1].id; + let project = d.h.state().session.project.clone(); + d.ok("engine.execute", json!({"command":"timeline.select","params":{"clips":[first.0]}}), false); + let position = d.ok("ui.timeline.locate", json!({"clip":second.0}), false); + d.ok("ui.click", json!({"x":position["x"],"y":position["y"],"shift":true}), false); + assert!(d.h.state().session.state.selection.contains(&first), "Shift-click must retain the first selected clip (isolated={isolated})"); + assert!(d.h.state().session.state.selection.contains(&second)); + assert!(Arc::ptr_eq(&d.h.state().session.project, &project)); + assert!(!d.h.state().playback.playing); + assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); + } +} + +#[test] +fn modified_control_drag_keeps_shift_until_release_and_clears_it_afterward() { + let mut d = Driver::new(true); + let (request, reply) = ControlRequest::new( + "ui.drag", + json!({ + "from":{"id":"dock.gutter."},"to":{"id":"dock.gutter.","fy":3.0},"steps":4,"shift":true, + }), + ); + d.tx.send(request).unwrap(); + let mut held_frames = 0; + for _ in 0..60 { + d.frames(1, false); + if d.h.ctx.input(|input| input.pointer.any_down() || input.pointer.any_released()) { + held_frames += 1; + assert!(d.h.ctx.input(|input| input.modifiers.shift), "the synthetic gesture owns Shift throughout press, movement and release"); + } + if let Ok(result) = reply.try_recv() { + assert_eq!(result["ok"], true); + break; + } + } + assert!(held_frames >= 4); + d.frames(3, false); + assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); +} diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs new file mode 100644 index 000000000..12129c957 --- /dev/null +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -0,0 +1,95 @@ +//! Shared pane resizing in the full application, including application shortcut ownership. + +use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize}; +use filmcraft_ui_egui::theme::ThemeKind; +use serde_json::json; + +fn step(h: &mut Harness<'_, FilmcraftApp>) { + h.step(); + assert!(h.state().ui_error.is_none(), "the app caught a UI panic: {:?}", h.state().ui_error); +} + +fn frames(h: &mut Harness<'_, FilmcraftApp>, count: usize) { + for _ in 0..count { + step(h); + } +} + +fn snapshots(h: &mut Harness<'_, FilmcraftApp>, directory: Option<&std::path::Path>, phase: &str) { + let Some(directory) = directory else { return }; + std::fs::create_dir_all(directory).unwrap(); + for (name, theme) in [("dark", ThemeKind::Dark), ("medium", ThemeKind::Medium), ("light", ThemeKind::Light)] { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + frames(h, 3); + h.render().unwrap().save(directory.join(format!("filmcraft-{phase}-{name}.png"))).unwrap(); + } + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, ThemeKind::Dark); + frames(h, 3); +} + +fn first_size(h: &Harness<'_, FilmcraftApp>) -> f32 { + match h.state().ui.dock { + DockNode::Split { size: SplitSize::FixedA(value), .. } => value, + ref other => panic!("fixed-size mode changed: {other:?}"), + } +} + +#[test] +fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + // Opt in to GPU snapshots while the ordinary test remains headless and renderer-independent. + let directory = std::env::var_os("CRAFT_UI_PANES_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut builder = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(10_000); + if directory.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + frames(&mut h, 4); + snapshots(&mut h, directory.as_deref(), "workspace"); + h.state_mut().ui.focused = PanelKind::Timeline; + h.state_mut().ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + frames(&mut h, 3); + let before_project = h.state().session.project.clone(); + let before_playheads = h.state().session.state.playheads.clone(); + let id = Id::new(("dock-gutter", String::new())); + h.ctx.memory_mut(|memory| memory.request_focus(id)); + frames(&mut h, 2); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); + snapshots(&mut h, directory.as_deref(), "focused-resize"); + h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + assert_eq!(first_size(&h), 408.0); + assert_eq!(h.state().session.state.playheads, before_playheads, "the Timeline shortcut must not seek"); + assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project)); + + frames(&mut h, 2); + let element = h.state().auto.find("dock.gutter.").unwrap(); + let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center(); + h.event(Event::PointerMoved(from)); + step(&mut h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + step(&mut h); + for offset in [5.0, 10.0, 15.0, 20.0] { + h.event(Event::PointerMoved(from + vec2(offset, 0.0))); + step(&mut h); + } + h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + step(&mut h); + assert_eq!(first_size(&h), 428.0); + assert_eq!(h.state().session.state.playheads, before_playheads); +} diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/ui_keyboard_focus.rs new file mode 100644 index 000000000..8685d4933 --- /dev/null +++ b/crates/ui-egui/tests/ui_keyboard_focus.rs @@ -0,0 +1,107 @@ +//! Keyboard traversal reaches actual controls and preserves document/playback state. + +use std::sync::Arc; + +use egui::{Event, Id, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{DockNode, PanelKind}, + state::Tool, +}; +use serde_json::json; + +fn key(h: &mut Harness<'_, FilmcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(2); + assert!(h.state().ui_error.is_none()); +} + +#[test] +fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let project = session.project.clone(); + let playheads = session.state.playheads.clone(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); + h.run_steps(4); + h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 }; + h.run_steps(3); + let first = Id::new(("tool", "Selection".to_string())); + let second = Id::new(("tool", "TrackSelectForward".to_string())); + let third = Id::new(("tool", "Ripple".to_string())); + for _ in 0..128 { + key(&mut h, Key::Tab, Modifiers::NONE); + if h.ctx.memory(|m| m.focused()) == Some(first) { + break; + } + } + assert_eq!(h.ctx.memory(|m| m.focused()), Some(first), "Tab must reach the first tool"); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(third)); + key(&mut h, Key::Tab, Modifiers::SHIFT); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + assert_eq!(h.state().ui.tool, Tool::Selection, "traversal does not activate controls"); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::TrackSelectForward); + assert!(!h.state().playback.playing, "Space belongs to the focused tool"); + assert_eq!(h.state().session.state.playheads, playheads); + assert!(Arc::ptr_eq(&h.state().session.project, &project)); + let response = h.ctx.read_response(second).unwrap(); + assert!( + h.output().shapes.iter().any(|shape| matches!(&shape.shape, + egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) + && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)), + "the focused custom-painted tool needs a visible focus outline" + ); +} + +#[test] +fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let project = session.project.clone(); + let playheads = session.state.playheads.clone(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); + h.run_steps(4); + h.state_mut().ui.show_header = false; + h.state_mut().ui.show_status_bar = false; + h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }; + h.run_steps(3); + let first = Id::new(("tab", String::new(), 0usize)); + let second = Id::new(("tab", String::new(), 1usize)); + for _ in 0..128 { + key(&mut h, Key::Tab, Modifiers::NONE); + if h.ctx.memory(|m| m.focused()) == Some(second) { + break; + } + } + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + let response = h.ctx.read_response(second).unwrap(); + assert!( + h.output().shapes.iter().any(|shape| matches!(&shape.shape, + egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) + && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)), + "the focused dock tab needs a visible focus outline" + ); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 1, .. })); + assert_eq!(h.state().ui.focused, PanelKind::Effects); + key(&mut h, Key::Tab, Modifiers::SHIFT); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(first)); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert!(Arc::ptr_eq(&h.state().session.project, &project)); + assert_eq!(h.state().session.state.playheads, playheads); + assert!(!h.state().playback.playing); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9cf58cf66..16946c0a7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,8 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | -| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | +| `ui.resize`, `app.quit` | `{width,height}` / `{}` | resize or close the native window | +| `ui.focus` | `{}` / `{id}` | empty parameters activate the native window; a widget ID requests egui focus without OS activation | | `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Selection or explicit targets.** A command that acts on the selection is "not available right now" when @@ -50,7 +51,22 @@ still apply. `engine.commands` and the menus always show the selection's enablem **Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens without activating itself. UI requests and screenshots that need a rendered frame bring the window forward without making it key (macOS `orderFrontRegardless`), so the user's typing keeps going to -the app they are in. Only an explicit `ui.focus` activates FilmCraft. +the app they are in. Only an explicit `ui.focus {}` activates FilmCraft. Injected keys work on +focused widgets even when the native window is in the background. + +`ui.focus {id}` targets an enabled, visible widget registered with a real egui response. It rejects +unknown IDs, non-widget regions and hidden tool aliases. The reply acknowledges the request; +`ui.inspect.keyboardFocus` reports the actual widget ID, matching element IDs, label and active +focus. This is separate from `ui.focused`, which identifies the active panel. Tools, dock tabs, +tab menus and divider handles expose widget IDs through `ui.elements`. Tab/Shift-Tab move widget +focus; Space/Enter activate focused buttons and tabs, with visible focus outlines. + +For unattended validation, start with `--control --control-input-only`. This opt-in mode +accepts widget input through control requests and excludes native keyboard, pointer, file-drop +and menu commands. Window close/quit and screenshots remain available. Ordinary launches and +`--control` alone keep native input enabled. `ui.inspect.input` distinguishes `mode`, the observed +`nativeWindowFocused`, and egui's `effectiveWindowFocused`; effective focus does not activate the +OS window or prove physical-keyboard delivery. Element ids are stable, e.g. `timeline.clip.`, `timeline.track.V1.lock`, `tools.Razor`, `project.item.`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`. @@ -147,3 +163,26 @@ Annotations, argument checking, errors and export progress / cancellation: [agents.md § Conventions](agents.md#conventions). Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`. + +## Panel docking commands + +`ui.menu.invoke` and `engine.execute` route these frontend commands: + +| Command | Parameters | Result | +| --- | --- | --- | +| `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; omitted rect uses a centered 420×320 group. | +| `window.dockPanel` | `{panel?, anchor, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. | +| `window.closePanel` | `{panel?}` | Close a docked or floating panel without closing its project documents. | + +Invalid source/target identities and non-finite or out-of-range geometry return an error with +the layout unchanged. `ui.inspect.ui.floating` reports floating groups and their logical-point +rectangles. `panel.drag.` locates each panel's header grip; `panel.float..move` +and `.resize` locate floating geometry handles. Named workspace saves include these groups. + +`window.dockAction` accepts `{action}` using the shared docking action's JSON form. It +covers tab moves and ordering, split and floating geometry, activation, and closing. +For example, `{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}` +resizes the root split; path entries use `false` for the first child and `true` for +the second. `{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}` +repositions and resizes the floating group containing Info. Invalid actions leave the +layout unchanged. Pointer gestures dispatch through this same catalog command. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 631678d84..07623219b 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -99,7 +99,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ]; /// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus). -const UI_ONLY: &[&str] = &["egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; +const UI_ONLY: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; fn short(name: &str) -> &str { name.strip_prefix("filmcraft-").unwrap_or(name) From 3b1fa296633d2eec2d9fd01a32fb3f9c7eb39251 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:59:15 +0200 Subject: [PATCH 009/157] #790: Matroska AAC / MP3 / AC-3 trims the exact CodecDelay Problem: ffmpeg stores the encoder priming as CodecDelay in ns (1024 AAC samples at 48 kHz = 21.333 ms, 256 AC-3 samples at 44.1 kHz = 5.805 ms) and block timestamps in ms. The demuxer subtracts the delay rounded to 1 ms, and fixed-length packets were anchored on that rounded stamp, so the whole track played up to 16 samples late against ffmpeg's decode. Only Opus undid the rounding. Fix: one stamp-to-samples conversion for every Matroska audio codec that undoes the demuxer's rounding and removes the exact CodecDelay (rounded to the nearest sample; the element holds whole ns), with checked math against hostile timebases. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio_timing_tests.rs | 29 ++++++++++++ crates/codecs/src/mkv.rs | 42 +++++++++++------ crates/codecs/tests/matroska_audio_oracle.rs | 47 ++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 2 + 5 files changed, 108 insertions(+), 14 deletions(-) diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs index 40e43a2d8..62f25cb10 100644 --- a/crates/codecs/src/audio_timing_tests.rs +++ b/crates/codecs/src/audio_timing_tests.rs @@ -176,3 +176,32 @@ fn playing_at_another_rate_leaves_no_holes() { let tail: Vec> = got.iter().map(|c| c[2048..].to_vec()).collect(); assert_eq!(holes(&tail), 0); } + +/// #790: the encoder priming stored as `CodecDelay` (1024 samples: 21.333 ms at 48 kHz, 23.220 ms +/// at 44.1 kHz) is trimmed exactly. The demuxer takes it off the millisecond timestamps rounded to +/// whole milliseconds; starting the first packet there played the track 16 (48 kHz) or 10 (44.1 kHz) +/// samples late. +#[test] +fn matroska_codec_delay_is_trimmed_to_the_sample() { + for rate in [48_000u32, 44_100] { + let (asc, units) = aac(rate); + let want = continuous(&asc, rate, &units); + let delay_ns = (1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC"); + spec.codec_private = asc.clone(); + spec.audio = Some((rate as f64, 2, None)); + spec.codec_delay_ns = delay_ns as u64; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).unwrap(); + for (k, u) in units.iter().enumerate() { + let start_ns = (k as i64 * 1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + w.write_frame(0, start_ns - delay_ns, true, u, None).unwrap(); + } + let bytes: Arc<[u8]> = w.finish().unwrap().into_inner().into(); + let src = MkvSource::open("primed.mkv", bytes).unwrap(); + let frames = units.len().saturating_sub(2) * 1024; + let got = played(&src, rate, frames); + let trimmed: Vec> = want.iter().map(|c| c[1024..].to_vec()).collect(); + let (n, first) = mismatches(&got, &trimmed, 0); + assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the decode with the priming trimmed, first (channel, sample) {first:?}"); + } +} diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 97de67431..c3ca0355d 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -71,15 +71,14 @@ pub struct MkvSource { /// length (AAC, MPEG audio, AC-3) therefore run on from one another, resynchronising to the /// timestamp only across gaps of more than half a packet (and more than two ticks); see /// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their -/// timestamps. +/// timestamps. Timestamps are taken with the exact `CodecDelay` off (see [`stamp_to_samples`]). fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec { use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples}; let Some(t) = file.tracks.get(ti) else { return Vec::new() }; if let Some(h) = opus_head(&t.codec) { return opus_starts(file, bytes, ti, &h, rate); } - let (n, d) = tb(t); - let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64; + let at = stamp_to_samples(t, rate, codec_delay_samples(t, rate)); let first = || file.read_sample(bytes, ti, 0).unwrap_or_default(); let out_rate = u32::try_from(rate).unwrap_or(0); let frame = match &t.codec { @@ -97,18 +96,13 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec /// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed). /// -/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated -/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more -/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is -/// absent). +/// Starts are accumulated from each packet's TOC duration, resynchronising to the timestamp only +/// across real gaps (more than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's +/// pre-skip when it is absent). fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec { let Some(t) = file.tracks.get(ti) else { return Vec::new() }; - let (n, d) = tb(t); - let delay_ns = t.codec_delay_ns as i128; - // The demuxer's rounding of CodecDelay to ticks, undone here. - let delay_ticks = codec_delay_ticks(t); - let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 }; - let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip); + let skip = if t.codec_delay_ns > 0 { codec_delay_samples(t, rate) } else { head.pre_skip as i64 }; + let at = stamp_to_samples(t, rate, skip); let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime { stamp: at(s.pts), stamped: own_stamp(s), @@ -117,6 +111,28 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate)) } +/// A block timestamp (with the demuxer's rounded `CodecDelay` taken off) as a position in sample +/// frames at `rate`, `skip` frames of priming removed (#790). The demuxer's rounding is undone first: +/// `CodecDelay` is rarely a whole number of ticks (1024 AAC samples at 48 kHz are 21.333 ms, 256 +/// AC-3 samples at 44.1 kHz 5.805 ms), so starting the first packet at the rounded delay played the +/// whole track up to 16 samples late. +fn stamp_to_samples(t: &filmcraft_matroska::Track, rate: i64, skip: i64) -> impl Fn(i64) -> i64 { + let (n, d) = tb(t); + let delay_ticks = codec_delay_ticks(t); + move |pts: i64| { + let ticks = pts.saturating_add(delay_ticks) as i128; + let at = ticks.checked_mul(n as i128).and_then(|x| x.checked_mul(rate as i128)).map_or(ticks.signum() * i128::MAX, |x| x / d as i128); + i64::try_from(at).unwrap_or(if at < 0 { i64::MIN } else { i64::MAX }).saturating_sub(skip) + } +} + +/// `CodecDelay` in sample frames at `rate`, rounded to the nearest (the element holds whole +/// nanoseconds: 1024 samples at 48 kHz are stored as 21 333 333 ns). +fn codec_delay_samples(t: &filmcraft_matroska::Track, rate: i64) -> i64 { + let ns = (t.codec_delay_ns as i128).saturating_mul(rate.max(0) as i128); + i64::try_from(ns.saturating_add(500_000_000) / 1_000_000_000).unwrap_or(i64::MAX) +} + /// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before /// subtracting it from every timestamp of the track. fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/matroska_audio_oracle.rs index 1298ccd48..ff7822740 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/matroska_audio_oracle.rs @@ -48,3 +48,50 @@ fn matroska_audio_plays_without_holes() { assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})"); } } + +/// The lag in `-max..=max` at which `ours` best matches `theirs` (least mean |difference| over +/// `from..from + len`). +fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, max: i64) -> i64 { + let err = |lag: i64| -> f64 { (from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::() / len as f64 }; + (-max..=max).min_by(|&a, &b| err(a).total_cmp(&err(b))).unwrap() +} + +/// #790: ffmpeg stores the encoder priming as `CodecDelay` (1024 samples = 21.333 ms for AAC at +/// 48 kHz, 256 = 5.805 ms for AC-3 at 44.1 kHz, 1105 = 25.057 ms for MP3), and block timestamps to +/// the millisecond. Taking the rounded delay as the start of the first packet played the whole +/// track up to 16 samples late; the decode must line up with ffmpeg's to the sample. +#[test] +fn matroska_audio_is_sample_aligned_with_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let cases: [(&str, u32, &[&str]); 5] = [ + ("noise_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]), + ("noise_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]), + ("noise_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]), + ("noise_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]), + ("noise_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]), + ]; + for (name, rate, codec) in cases { + let input = format!("anoisesrc=color=pink:seed=7:amplitude=0.5:sample_rate={rate}:duration=2"); + let args: Vec<&str> = ["-f", "lavfi", "-i", input.as_str(), "-ac", "1"].iter().copied().chain(codec.iter().copied()).collect(); + let Some(path) = fixture(&ff, name, &args) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap(); + let frames = rate as usize * 3 / 2; + let mut ours = Vec::with_capacity(frames); + while ours.len() < frames { + let n = 1000.min(frames - ours.len()); + ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]); + } + let theirs = ffmpeg_audio_f32(&ff, &path, &[]); + assert!(theirs.len() >= frames, "{name}: ffmpeg decoded {} samples", theirs.len()); + let lag = best_lag(&ours, &theirs, rate as usize / 4, rate as usize, 40); + assert_eq!(lag, 0, "{name}: our decode is {lag} samples off ffmpeg's"); + // read from the middle, as a seek does + let at = rate as i64 / 2; + let mid = src.audio(at, 2000, rate).unwrap().channels[0].clone(); + let diff = mid.iter().zip(&ours[at as usize..]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + assert!(diff < 1e-3, "{name}: a read from {at} differs from continuous decoding by {diff}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b19262c84..125eec7dc 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -39,7 +39,7 @@ only). "Tested" names the evidence. | QuickTime MOV | read + write | yes | yes (H.264, HEVC, ProRes, DNxHR, APV, MJPEG, PCM) | ffprobe | 90% | | MXF OP1a / OP-Atom | read + write (XDCAM, AVC-Intra, P2, AS-10 / AS-11, JPEG 2000, DNx) | yes (AVC, DNx, ProRes, MPEG-2, PCM / AES3, timecode) | OP1a (DNxHR, ProRes, H.264), OP-Atom | index-table seeking tests | 70% | | MPEG TS / PS (AVCHD `.mts`, `.m2ts`, `.ts`, `.mpg`, `.vob`, `.mod`) | read; write MPEG-2 / Blu-ray / DVD | yes | **no** | fixtures | 65% | -| Matroska / WebM | read (limited) | yes (H.264, HEVC, VP9, AV1, ProRes, MJPEG + AAC, Opus, FLAC, MP3, Vorbis, PCM); WebM black report (#432), MKV multi-audio (#601) | no | fixtures | 75% | +| Matroska / WebM | read (limited) | yes (H.264, HEVC, VP9, AV1, ProRes, MJPEG + AAC, Opus, FLAC, MP3, Vorbis, PCM); WebM black report (#432), MKV multi-audio (#601) | no | fixtures; audio sample-aligned with ffmpeg (#790) | 75% | | **AVI** | read + write | **no** (extension listed, no demuxer, #598) | no | — | 0% | | WMV / ASF | read; write on Windows | no | no | — | 0% | | FLV / F4V | read + write | no | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1b..ba593bcbb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -163,6 +163,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). +- **Have:** Matroska AAC / MP3 / AC-3 sample-aligned with ffmpeg's decode (the exact + `CodecDelay` is trimmed, not the 1 ms-rounded one; #790). ## G11. No plugin hosting From 39afd2f6c3c8929af2b00ad3f6798753da06d6a9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:48:45 +0200 Subject: [PATCH 010/157] #809: own MPEG audio Layer I / II decoder plays MP2 at ffmpeg's default bit rates Problem: symphonia rejects every Layer II frame whose bit rate / mode combination ISO/IEC 11172-3 does not allow (mono above 192 kbit/s, stereo below 64 kbit/s). ffmpeg writes mono MP2 at 384 kbit/s by default, so such tracks played as silence in MPEG-TS / PS, Matroska and raw .mp2 files. Fix: new L0 crate filmcraft-mpa, a clean-room Layer I / II decoder written from ISO/IEC 11172-3 and 13818-3 (all modes incl. joint stereo, MPEG-1/2/2.5 rates, the disallowed combinations decoded with the allocation table their bit rate per channel selects). PacketDecoder::mpeg_audio uses it for layers I and II (packets of several frames too); raw .mp2 files decode with it, resyncing after damage. Oracle tests vs ffmpeg: 73-84 dB SNR on ffmpeg / twolame streams and synthetic Layer I / joint stereo frames; fuzzed damaged frames never panic. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 1 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/audio.rs | 143 +++++++- crates/codecs/tests/mpeg_oracle.rs | 42 +++ crates/mpa/Cargo.toml | 17 + crates/mpa/README.md | 76 ++++ crates/mpa/src/lib.rs | 409 +++++++++++++++++++++ crates/mpa/src/tables.rs | 189 ++++++++++ crates/mpa/src/tests.rs | 151 ++++++++ crates/mpa/tests/oracle.rs | 572 +++++++++++++++++++++++++++++ docs/architecture.md | 3 +- docs/codec-parity.md | 2 +- docs/gaps.md | 9 +- xtask/src/main.rs | 2 + 15 files changed, 1619 insertions(+), 8 deletions(-) create mode 100644 crates/mpa/Cargo.toml create mode 100644 crates/mpa/README.md create mode 100644 crates/mpa/src/lib.rs create mode 100644 crates/mpa/src/tables.rs create mode 100644 crates/mpa/src/tests.rs create mode 100644 crates/mpa/tests/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..f70d5b165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1819,6 +1819,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2075,6 +2076,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.5.0" +dependencies = [ + "filmcraft-bitstream", + "filmcraft-testkit", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 1d306cffa..46a03f1d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ filmcraft-cfb = { path = "crates/cfb" } filmcraft-mpegts = { path = "crates/mpegts" } filmcraft-mpeg2v = { path = "crates/mpeg2v" } filmcraft-ac3 = { path = "crates/ac3" } +filmcraft-mpa = { path = "crates/mpa" } filmcraft-ogg = { path = "crates/ogg" } filmcraft-h264 = { path = "crates/h264" } filmcraft-hevc = { path = "crates/hevc" } diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c4..ffaeabbc7 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -20,6 +20,7 @@ filmcraft-mxf = { workspace = true } filmcraft-mpegts = { workspace = true } filmcraft-mpeg2v = { workspace = true } filmcraft-ac3 = { workspace = true } +filmcraft-mpa = { workspace = true } filmcraft-ogg = { workspace = true } filmcraft-bitstream = { workspace = true } filmcraft-h264 = { workspace = true } diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 63351636e..bc937efaa 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -27,6 +27,8 @@ enum Inner { Symphonia(Box), /// Our AC-3 and E-AC-3 decoder (ATSC A/52, Annex E). Ac3(Box), + /// Our MPEG-1/2 audio Layer I and II decoder (ISO/IEC 11172-3, 13818-3). + Mpa(Box), } /// Opus always decodes at 48 kHz (RFC 7845 §5.1: the input rate in the header is informational). @@ -124,11 +126,12 @@ impl PacketDecoder { let order = if dec.head().mapping_family == 1 { vorbis_to_wav_order(channels) } else { None }; Ok(Self { inner: Inner::Opus { dec: Box::new(dec), order }, channels }) } - /// MPEG-1/2 audio layer I, II or III (symphonia). + /// MPEG-1/2 audio layer I, II (our decoder: symphonia rejects the bit rate / mode + /// combinations ISO/IEC 11172-3 does not allow, such as ffmpeg's default 384 kbit/s mono MP2) + /// or III (symphonia). pub fn mpeg_audio(layer: u8, sample_rate: u32) -> Result { let codec = match layer { - 1 => symphonia::core::codecs::CODEC_TYPE_MP1, - 2 => symphonia::core::codecs::CODEC_TYPE_MP2, + 1 | 2 => return Ok(Self { inner: Inner::Mpa(Box::default()), channels: 0 }), _ => CODEC_TYPE_MP3, }; Self::new(codec, sample_rate, None) @@ -214,6 +217,31 @@ impl PacketDecoder { self.channels = out.len(); return Ok(out); } + Inner::Mpa(d) => { + // a packet holds one frame or several; a damaged first frame is an error, damage + // after a good frame ends the packet + let mut out: Vec> = Vec::new(); + let mut p = 0; + while let Some(rest) = data.get(p..).filter(|r| !r.is_empty()) { + let f = match d.decode(rest) { + Ok(f) => f, + Err(e) if out.is_empty() => return Err(CodecError::Decode(e.to_string())), + Err(_) => break, + }; + p += f.header.frame_bytes; + if out.is_empty() { + out = f.channels; + } else if out.len() == f.channels.len() { + for (o, c) in out.iter_mut().zip(f.channels) { + o.extend(c); + } + } else { + break; + } + } + self.channels = out.len(); + return Ok(out); + } }; let pkt = Packet::new_from_slice(0, ts, 0, data); let buf = dec.decode(&pkt).map_err(|e| CodecError::Decode(e.to_string()))?; @@ -242,6 +270,7 @@ impl PacketDecoder { up.reset(); } } + Inner::Mpa(d) => d.reset(), Inner::Opus { dec, .. } => dec.reset(), Inner::Symphonia(d) => d.reset(), Inner::Ac3(d) => d.reset(), @@ -741,6 +770,22 @@ pub struct AudioFileSource { impl AudioFileSource { pub fn decode(name: &str, bytes: Arc<[u8]>) -> Result { let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + if matches!(ext.as_str(), "mp2" | "mp3") + && let Some((layer, rate, samples)) = decode_mpeg_layer12(&bytes) + { + let frames = samples.first().map_or(0, Vec::len); + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::AudioOnly, + duration: Tick::from_units(frames as i64, rate as i64), + video: None, + audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: format!("MP{layer}"), bits_per_sample: None }], + container: ext.to_uppercase(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + return Ok(Self { info, rate, samples }); + } let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default()); let mut hint = Hint::new(); hint.with_extension(&ext); @@ -796,6 +841,49 @@ impl AudioFileSource { } } +/// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate, +/// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag); +/// damaged data is skipped up to the next frame header of the same layer and rate, and a frame +/// with another channel count keeps its duration as silence. `None` when the stream does not start +/// with a Layer I / II frame followed by another frame (or the end), e.g. MP3. +fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec>)> { + let mut p = match bytes { + // ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged + [b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => { + let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize); + 10 + size + if flags & 0x10 != 0 { 10 } else { 0 } + } + _ => 0, + }; + let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0); + let first = frame_at(p)?; + let next = p.saturating_add(first.frame_bytes); + if first.layer == 3 || (next < bytes.len() && frame_at(next).is_none_or(|h| h.layer != first.layer || h.sample_rate != first.sample_rate)) { + return None; + } + let mut dec = filmcraft_mpa::Decoder::new(); + let mut out: Vec> = vec![Vec::new(); first.channels()]; + while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 4) { + match dec.decode(rest) { + Ok(f) if f.header.layer == first.layer && f.header.sample_rate == first.sample_rate => { + let n = f.header.samples(); + if f.channels.len() == out.len() { + for (o, c) in out.iter_mut().zip(f.channels) { + o.extend(c); + } + } else { + for o in out.iter_mut() { + o.resize(o.len() + n, 0.0); + } + } + p += f.header.frame_bytes; + } + _ => p += 1, + } + } + out.first().is_some_and(|c| !c.is_empty()).then_some((first.layer, first.sample_rate, out)) +} + impl MediaSource for AudioFileSource { fn info(&self) -> &MediaInfo { &self.info @@ -816,6 +904,55 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option Vec { + let h = [0xFF, 0xFD, bitrate_index << 4 | 0x04, mode << 6]; + let mut f = h.to_vec(); + f.resize(filmcraft_mpa::parse_header(&h).unwrap().frame_bytes, 0); + f + } + + /// #809: mono MP2 at 384 kbit/s (ffmpeg's default for mono) is outside ISO/IEC 11172-3's + /// allowed combinations, and symphonia rejected every frame, so the track played as silence. + #[test] + fn mono_mp2_at_384k_decodes() { + let mut d = PacketDecoder::mpeg_audio(2, 48_000).unwrap(); + let out = d.decode(&silent_frame(14, 3), 0).unwrap(); + assert_eq!((out.len(), out[0].len(), d.channels), (1, 1152, 1)); + // a packet of two stereo frames decodes to both + let two = [silent_frame(10, 0), silent_frame(10, 0)].concat(); + let out = d.decode(&two, 1152).unwrap(); + assert_eq!((out.len(), out[0].len()), (2, 2304)); + // damage: an error for the first frame, the good frames before it otherwise + assert!(d.decode(&[0u8; 8], 0).is_err()); + assert!(d.decode(&silent_frame(10, 0)[..100], 0).is_err()); + let partial = [silent_frame(10, 0), vec![0xFF, 0xFD, 0xA4]].concat(); + assert_eq!(d.decode(&partial, 0).unwrap()[0].len(), 1152); + d.reset(); + } + + /// Raw `.mp2` files: after an ID3v2 tag, frame after frame, skipping damage. + #[test] + fn raw_streams_resync_after_damage() { + let mono = silent_frame(14, 3); + let file = [&b"ID3\x04\0\0\0\0\0\x05abcde"[..], &mono, &mono, &[0xFF, 0xFD, 0x13, 0x37, 0x42], &mono].concat(); + let (layer, rate, samples) = decode_mpeg_layer12(&file).unwrap(); + assert_eq!((layer, rate, samples.len(), samples[0].len()), (2, 48_000, 1, 3 * 1152)); + let src = AudioFileSource::decode("x.mp2", file.clone().into()).unwrap(); + assert_eq!(src.info().audio().map(|a| (a.channels, a.codec.as_str())), Some((1, "MP2"))); + // MP3, junk and truncated data are not ours (symphonia gets them) + assert!(decode_mpeg_layer12(&[0xFF, 0xFB, 0x90, 0x44, 0, 0]).is_none()); + for n in 0..40 { + assert!(decode_mpeg_layer12(&file[..n]).is_none(), "{n}"); + } + assert!(decode_mpeg_layer12(&[&mono[..], &[0x12; 50]].concat()).is_none()); + } +} + #[cfg(test)] mod he_aac_tests { use super::*; diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/mpeg_oracle.rs index d6a7042a0..90cc6bd42 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/mpeg_oracle.rs @@ -16,6 +16,9 @@ use filmcraft_media::{FrameRequest, MediaError, MediaSource}; const NOISE: &[&str] = &["-f", "lavfi", "-i", "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000"]; +/// A mono tone. +const MONO: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000"]; + fn spec(name: &str) -> Vec { let cat = |p: &[&[&str]]| p.concat().into_iter().map(String::from).collect::>(); match name { @@ -142,6 +145,12 @@ fn spec(name: &str) -> Vec { cat(&[&["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"], NOISE, &["-t", "0.6", "-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-c:a", "mp2"]]) } "audio.mp2" => cat(&[NOISE, &["-t", "1", "-c:a", "mp2", "-b:a", "224k", "-f", "mp2"]]), + // mono MP2 at ffmpeg's default bit rate, 384 kbit/s, which ISO/IEC 11172-3 does not allow + "audio_mono.mp2" => cat(&[MONO, &["-t", "1", "-c:a", "mp2", "-f", "mp2"]]), + "ts_mpeg2_mono_mp2.ts" => { + cat(&[&["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"], MONO, &["-t", "1", "-c:v", "mpeg2video", "-c:a", "mp2", "-f", "mpegts"]]) + } + "mkv_mono_mp2.mkv" => cat(&[MONO, &["-t", "1", "-c:a", "mp2"]]), "es_mpeg2.m2v" => { cat(&[&["-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25"], &["-t", "1", "-c:v", "mpeg2video", "-bf", "2", "-g", "9", "-f", "mpeg2video"]]) } @@ -164,6 +173,9 @@ pub const ALL: &[&str] = &[ "mp4_mpeg2.mp4", "mkv_mpeg2.mkv", "audio.mp2", + "audio_mono.mp2", + "ts_mpeg2_mono_mp2.ts", + "mkv_mono_mp2.mkv", "es_mpeg2.m2v", ]; @@ -515,6 +527,36 @@ fn standalone_mp2_file() { assert!(worst <= 2e-4, "{worst}"); } +/// #809: mono MP2 at 384 kbit/s, ffmpeg's default, played as silence in every container because +/// symphonia rejects the bit rate / mode combinations ISO/IEC 11172-3 does not allow. +#[test] +fn mono_mp2_at_ffmpegs_default_bit_rate() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let f = make(&ff, "audio_mono.mp2"); + assert_eq!(std::fs::read(&f).unwrap()[2] >> 4, 14, "384 kbit/s"); + let worst = check_audio(&ff, &make(&ff, "ts_mpeg2_mono_mp2.ts"), 2e-4); + println!("ts_mpeg2_mono_mp2.ts: max error {worst}"); + // Matroska: ffmpeg trims the 481-sample encoder delay; we trim `CodecDelay` rounded to the + // millisecond (#790), so allow a lag of a sample or two there + for (name, max_lag) in [("audio_mono.mp2", 0i64), ("mkv_mono_mp2.mkv", 2)] { + let f = make(&ff, name); + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, 1), "{name}"); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let n = want.len() - 4; + let worst = (-max_lag..=max_lag) + .map(|lag| { + let buf = src.audio(2 + lag, n, 48_000).unwrap(); + want[2..].iter().zip(&buf.channels[0]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max) + }) + .fold(f32::INFINITY, f32::min); + let peak = want.iter().fold(0f32, |m, x| m.max(x.abs())); + println!("{name}: max error {worst}, peak {peak}"); + assert!(peak > 0.1 && worst <= 2e-4, "{name}: peak {peak}, max error {worst}"); + } +} + #[test] fn truncated_and_corrupt_files_never_panic() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/mpa/Cargo.toml b/crates/mpa/Cargo.toml new file mode 100644 index 000000000..8abf08c22 --- /dev/null +++ b/crates/mpa/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filmcraft-mpa" +description = "Clean-room MPEG-1/2 Audio Layer I and Layer II (MP1 / MP2) decoder" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/mpa/README.md b/crates/mpa/README.md new file mode 100644 index 000000000..caa1afde6 --- /dev/null +++ b/crates/mpa/README.md @@ -0,0 +1,76 @@ +# filmcraft-mpa + +Clean-room MPEG-1 / MPEG-2 Audio Layer I and Layer II decoder (MP1 / MP2) in pure Rust. Layer L0: +depends only on `filmcraft-bitstream` and `thiserror`. Builds for `wasm32-unknown-unknown`; no +`unsafe`. No GPL/LGPL code (FFmpeg, libmad, mpg123, twolame) was consulted; FFmpeg (and twolame, +when installed) are used only as external fixture generators and test oracles. + +## Why + +symphonia (the bootstrap decoder for MP3 and others) rejects every Layer II frame whose bit rate / +channel mode combination ISO/IEC 11172-3 does not allow: mono above 192 kbit/s and stereo below +64 kbit/s. ffmpeg writes such streams by default (mono MP2 is 384 kbit/s unless asked otherwise), +so those tracks played as silence in every container (#809). + +## Specification + +ISO/IEC 11172-3:1993: §2.4.1 bit stream syntax (header, Layer I and Layer II audio data), +§2.4.2 semantics, §2.4.3.1 frame length, §2.4.3.2 synthesis subband filter (Figure A.2), +§2.4.3.3 Layer I and §2.4.3.4 Layer II decoding; Annex B: Table 3-B.1 scalefactors (computed as +2^(1 - i/3)), Table 3-B.2a-d Layer II allocation tables, Table 3-B.3 synthesis window (the 257 +coefficients `D[0..=256]`, exact multiples of 2⁻¹⁶, the rest from the prototype's symmetry; +a unit test checks the typed table is the smooth prototype), Table 3-B.4 quantization classes. +ISO/IEC 13818-3: the lower sampling frequencies (16 / 22.05 / 24 kHz), their bit rate tables and +the Layer II allocation table B.1 (its rows confirmed against FFmpeg's decode). + +## API + +```rust +let mut dec = filmcraft_mpa::Decoder::new(); +let h = filmcraft_mpa::parse_header(frame)?; // layer, rate, mode, frame size +let out = dec.decode(frame)?; // h.samples() per channel (384 or 1152) +dec.reset(); // before decoding from another position +``` + +## Decoder + +- Layer I and Layer II; mono, stereo, dual channel and joint (intensity) stereo with every bound; + MPEG-1, MPEG-2 lower sampling frequencies and the unofficial MPEG-2.5; with or without CRC. +- Layer II table selection by sampling frequency and bit rate per channel, with the ranges of + Table 3-B.2 taken as open-ended so the disallowed combinations decode the way encoders write + them (mono 384 kbit/s uses table a at 48 kHz, b at 44.1 / 32 kHz; stereo 32 / 48 kbit/s uses + c, or d at 32 kHz). +- Requantization `(2v + 1 - steps) / steps` (the standard's `C · (s''' + D)` for every class), + grouped 3-, 5- and 9-step codewords, scalefactor selection information. +- Output: planar f32 at full scale ±1. +- Errors (forbidden allocation, reserved header fields, data running past the frame) return + `Err`; garbage never panics (unit fuzz test and damaged-frame oracle test). + +## Accuracy (vs FFmpeg as an external oracle) + +`tests/oracle.rs`, worst channel SNR against FFmpeg's float decoder. FFmpeg's own fixed-point +decoder agrees with its float decoder to ~77 dB (the error sits at the 16-bit LSB); the test +requires 70 dB, and a wrong table entry or misread field costs tens of dB. + +| Stream | SNR | +|---|---| +| Layer II mono 48 kHz, FFmpeg's default 384 kbit/s (not allowed by 11172-3) | 77.1 dB | +| Layer II mono 44.1 kHz 320 kbit/s pink noise (not allowed) | 72.9 dB | +| Layer II stereo 48 kHz 32 kbit/s (table c; not allowed) | 83.2 dB | +| Layer II stereo 48 kHz 192 kbit/s, 44.1 kHz 256 / 96 kbit/s, 32 kHz 128 kbit/s | 83.3-83.6 dB | +| Layer II mono 32 kHz 48 kbit/s (table d) | 76.9 dB | +| Layer II MPEG-2: stereo 24 kHz 160 kbit/s, mono 22.05 kHz 64 kbit/s, stereo 16 kHz 32 kbit/s | 76.9-83.5 dB | +| Layer II joint stereo and dual channel from twolame (when installed) | 83.4-83.6 dB | +| Synthetic valid random frames: Layer I mono / stereo / dual / joint stereo at every bound, MPEG-1 and MPEG-2; Layer II joint stereo at every bound and dual channel with every allocation table | 74.1-80.0 dB | + +No installed encoder writes Layer I, so Layer I is checked on synthetic frames (random +allocations, scalefactors and samples) that FFmpeg decodes as it would any stream. Through +`filmcraft-codecs` (`tests/mpeg_oracle.rs`), mono 384 kbit/s MP2 in MPEG-TS, Matroska and a raw +`.mp2` file is within 3.1·10⁻⁵ of FFmpeg's decode. + +## Gaps + +- Layer III is not decoded here (symphonia does it); `parse_header` reads its headers. +- The free format (bit rate index 0) is refused: its frame length is only known from the next + sync word. +- The CRC is read, not checked; ancillary data is skipped. diff --git a/crates/mpa/src/lib.rs b/crates/mpa/src/lib.rs new file mode 100644 index 000000000..4dd32814d --- /dev/null +++ b/crates/mpa/src/lib.rs @@ -0,0 +1,409 @@ +//! Clean-room MPEG-1 / MPEG-2 Audio Layer I and Layer II decoder (MP1 / MP2), written from +//! ISO/IEC 11172-3 (§2.4 bit stream syntax and decoding, Annex B tables) and ISO/IEC 13818-3 +//! (the lower sampling frequencies 16 / 22.05 / 24 kHz). +//! +//! - Layer I and Layer II; mono, stereo, dual channel and joint (intensity) stereo; every bit rate +//! and sampling frequency of MPEG-1, MPEG-2 and the unofficial MPEG-2.5 (8 / 11.025 / 12 kHz). +//! - Lenient where encoders are: Layer II bit rate / mode combinations that ISO/IEC 11172-3 does not +//! allow (mono above 192 kbit/s, which is ffmpeg's default for mono, and stereo below 64 kbit/s) +//! decode with the allocation table their bit rate per channel selects (see [`Header`]). +//! - Output: planar f32 at full scale ±1, 384 (Layer I) or 1152 (Layer II) samples per channel. +//! +//! Not decoded: Layer III (use an MP3 decoder; [`parse_header`] still reads its headers), the free +//! format (bit rate index 0, whose frame length is only known from the next sync word) and the +//! CRC (read, not checked, like most decoders). + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod tables; + +use filmcraft_bitstream::BitReader; +use tables::AllocTable; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("not an MPEG audio frame")] + NoSync, + #[error("unsupported MPEG audio: {0}")] + Unsupported(&'static str), + #[error("invalid MPEG audio frame: {0}")] + Invalid(&'static str), +} + +pub type Result = std::result::Result; + +/// MPEG audio version (`ID` bit, and the MPEG-2.5 extension of the sync word). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Version { + /// ISO/IEC 11172-3: 32 / 44.1 / 48 kHz. + Mpeg1, + /// ISO/IEC 13818-3 lower sampling frequencies: 16 / 22.05 / 24 kHz. + Mpeg2, + /// The unofficial extension to 8 / 11.025 / 12 kHz. + Mpeg25, +} + +/// Channel mode (`mode`, §2.4.2.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Stereo, + /// Intensity stereo above the bound given by `mode_extension` (Layers I and II). + JointStereo, + /// Two independent mono channels. + DualChannel, + Mono, +} + +/// The 32-bit frame header (§2.4.1.3, §2.4.2.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub version: Version, + /// 1, 2 or 3. + pub layer: u8, + /// A 16-bit CRC follows the header. + pub crc: bool, + /// 0 for the free format. + pub bitrate_kbps: u32, + pub sample_rate: u32, + pub padding: bool, + pub mode: Mode, + pub mode_extension: u8, + /// Bytes in the frame, header included (0 for the free format). + pub frame_bytes: usize, +} + +impl Header { + /// Output channels: 1 for mono, else 2. + pub fn channels(&self) -> usize { + if self.mode == Mode::Mono { 1 } else { 2 } + } + /// Samples per channel the frame decodes to. + pub fn samples(&self) -> usize { + match (self.layer, self.version) { + (1, _) => 384, + (3, Version::Mpeg2 | Version::Mpeg25) => 576, + _ => 1152, + } + } + /// The first subband coded as intensity stereo (`bound`, §2.4.2.3): `mode_extension` 0-3 gives + /// 4, 8, 12 or 16 in joint stereo; otherwise every subband is coded per channel. + fn bound(&self) -> usize { + if self.mode == Mode::JointStereo { 4 * (self.mode_extension as usize + 1) } else { 32 } + } +} + +/// Parse the frame header at the start of `b`. +pub fn parse_header(b: &[u8]) -> Result
{ + let [0xFF, b1, b2, b3, ..] = *b else { + return Err(if b.len() < 4 && b.first().is_none_or(|&x| x == 0xFF) { Error::Invalid("truncated header") } else { Error::NoSync }); + }; + if b1 & 0xE0 != 0xE0 { + return Err(Error::NoSync); + } + let version = match (b1 >> 3) & 3 { + 3 => Version::Mpeg1, + 2 => Version::Mpeg2, + 0 => Version::Mpeg25, + _ => return Err(Error::Invalid("reserved version")), + }; + let layer = match (b1 >> 1) & 3 { + 3 => 1, + 2 => 2, + 1 => 3, + _ => return Err(Error::Invalid("reserved layer")), + }; + let crc = b1 & 1 == 0; + let bitrate_index = (b2 >> 4) as usize; + let rate_index = ((b2 >> 2) & 3) as usize; + let padding = (b2 >> 1) & 1 == 1; + let mode = match b3 >> 6 { + 0 => Mode::Stereo, + 1 => Mode::JointStereo, + 2 => Mode::DualChannel, + _ => Mode::Mono, + }; + let mode_extension = (b3 >> 4) & 3; + let base = *tables::SAMPLE_RATES.get(rate_index).ok_or(Error::Invalid("reserved sampling frequency"))?; + let sample_rate = match version { + Version::Mpeg1 => base, + Version::Mpeg2 => base / 2, + Version::Mpeg25 => base / 4, + }; + let rates = match (version, layer) { + (Version::Mpeg1, 1) => &tables::BITRATES_MPEG1_L1, + (Version::Mpeg1, 2) => &tables::BITRATES_MPEG1_L2, + (Version::Mpeg1, _) => &tables::BITRATES_MPEG1_L3, + (_, 1) => &tables::BITRATES_LSF_L1, + _ => &tables::BITRATES_LSF_L23, + }; + let bitrate_kbps = *rates.get(bitrate_index).ok_or(Error::Invalid("forbidden bit rate index"))?; + // §2.4.3.1: Layer I slots are 4 bytes, 12 slots per 384-sample frame per bit/s over the + // sampling frequency; Layers II / III have 1-byte slots, 144 per 1152 samples (72 for the + // 576-sample Layer III frames of MPEG-2). + let bits_per_second = u64::from(bitrate_kbps) * 1000; + let rate = u64::from(sample_rate); + let pad = u64::from(padding); + let frame_bytes = if bitrate_kbps == 0 { + 0 + } else if layer == 1 { + (12 * bits_per_second / rate + pad) * 4 + } else { + let slots = if layer == 3 && version != Version::Mpeg1 { 72 } else { 144 }; + slots * bits_per_second / rate + pad + }; + Ok(Header { version, layer, crc, bitrate_kbps, sample_rate, padding, mode, mode_extension, frame_bytes: frame_bytes as usize }) +} + +/// One decoded frame. +#[derive(Clone, Debug)] +pub struct Frame { + pub header: Header, + /// Planar samples, [`Header::channels`] × [`Header::samples`]. + pub channels: Vec>, +} + +/// Subband samples of one frame: `[channel][time slot][subband]`, up to 36 time slots. +type Subbands = [[[f32; 32]; 36]; 2]; + +/// A Layer I / Layer II decoder. It keeps the synthesis filter state between frames, so frames +/// must be fed in order; call [`Decoder::reset`] before decoding from another position. +pub struct Decoder { + /// The synthesis filter's `V` vector per channel (§2.4.3.2, Figure A.2). + v: [[f32; 1024]; 2], + sb: Box, +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +impl Decoder { + pub fn new() -> Self { + Self { v: [[0.0; 1024]; 2], sb: Box::new([[[0.0; 32]; 36]; 2]) } + } + + /// Forget the filter history. + pub fn reset(&mut self) { + self.v = [[0.0; 1024]; 2]; + } + + /// Decode the frame at the start of `frame` (which may run on into the next frames; only + /// [`Header::frame_bytes`] are read). + pub fn decode(&mut self, frame: &[u8]) -> Result { + let header = parse_header(frame)?; + if header.layer == 3 { + return Err(Error::Unsupported("Layer III")); + } + if header.frame_bytes == 0 { + return Err(Error::Unsupported("free-format bit rate")); + } + let data = frame.get(..header.frame_bytes).ok_or(Error::Invalid("truncated frame"))?; + let mut r = BitReader::new(data); + r.skip(32 + if header.crc { 16 } else { 0 }).map_err(|_| Error::Invalid("truncated frame"))?; + let nch = header.channels(); + for ch in self.sb.iter_mut() { + for slot in ch.iter_mut() { + *slot = [0.0; 32]; + } + } + let slots = if header.layer == 1 { + layer1(&mut r, &header, &mut self.sb)?; + 12 + } else { + layer2(&mut r, &header, &mut self.sb)?; + 36 + }; + let window = tables::window(); + let matrix = tables::matrix(); + let mut channels = vec![vec![0f32; slots * 32]; nch]; + for (ch, out) in channels.iter_mut().enumerate() { + let (Some(v), Some(sb)) = (self.v.get_mut(ch), self.sb.get(ch)) else { continue }; + for (slot, o) in sb.iter().take(slots).zip(out.as_chunks_mut::<32>().0) { + synthesize(v, matrix, window, slot, o); + } + } + Ok(Frame { header, channels }) + } +} + +fn read(r: &mut BitReader, n: u32) -> Result { + r.read_bits(n).map_err(|_| Error::Invalid("frame data runs past the frame")) +} + +/// Requantize an `steps`-level code `v` (§2.4.3.3.4 / §2.4.3.4.4): the fractional value with +/// the MSB inverted, `C · (s''' + D)`, which for every class is `(2v + 1 - steps) / steps`. +fn requantize(v: u32, steps: u32) -> f32 { + (2.0 * v as f32 + 1.0 - steps as f32) / steps as f32 +} + +/// Layer I audio data (§2.4.1.5): allocation, scalefactors and 12 samples per subband. +fn layer1(r: &mut BitReader, h: &Header, out: &mut Subbands) -> Result<()> { + let nch = h.channels(); + let bound = if nch == 1 { 32 } else { h.bound() }; + let mut alloc = [[0u32; 32]; 2]; + for sb in 0..32 { + if sb < bound { + for ch in 0..nch { + alloc[ch][sb] = read(r, 4)?; + } + } else { + let a = read(r, 4)?; + alloc[0][sb] = a; + alloc[1][sb] = a; + } + } + if alloc.iter().flatten().any(|&a| a == 15) { + return Err(Error::Invalid("forbidden Layer I allocation")); + } + let mut scf = [[0f32; 32]; 2]; + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + scf[ch][sb] = tables::scalefactor(read(r, 6)?); + } + } + } + for s in 0..12 { + for sb in 0..32 { + if sb < bound { + for ch in 0..nch { + let a = alloc[ch][sb]; + if a != 0 { + let nb = a + 1; + out[ch][s][sb] = requantize(read(r, nb)?, (1 << nb) - 1) * scf[ch][sb]; + } + } + } else if alloc[0][sb] != 0 { + let nb = alloc[0][sb] + 1; + let q = requantize(read(r, nb)?, (1 << nb) - 1); + for ch in 0..nch { + out[ch][s][sb] = q * scf[ch][sb]; + } + } + } + } + Ok(()) +} + +/// Read the three samples of one granule in a subband with `steps` quantization steps. +fn granule(r: &mut BitReader, steps: u16) -> Result<[f32; 3]> { + let steps = u32::from(steps); + let mut q = [0u32; 3]; + if let Some(bits) = tables::grouping(steps as u16) { + // three samples in one codeword: c = s0 + steps·s1 + steps²·s2 (§2.4.3.3.4); what is left + // of an invalid codeword beyond steps³ - 1 is ignored + let mut c = read(r, bits)?; + for s in q.iter_mut() { + *s = c % steps; + c /= steps; + } + } else { + let nb = (steps + 1).trailing_zeros(); + for s in q.iter_mut() { + *s = read(r, nb)?.min(steps - 1); + } + } + Ok(q.map(|v| requantize(v, steps))) +} + +/// Layer II audio data (§2.4.1.6): allocation, scalefactor selection, scalefactors and 12 +/// granules of three samples per subband. +fn layer2(r: &mut BitReader, h: &Header, out: &mut Subbands) -> Result<()> { + let nch = h.channels(); + let per_channel = h.bitrate_kbps / nch as u32; + let table = AllocTable::select(h.version != Version::Mpeg1, h.sample_rate, per_channel); + let sblimit = table.sblimit(); + let bound = if nch == 1 { sblimit } else { h.bound().min(sblimit) }; + let mut steps = [[0u16; 32]; 2]; + for sb in 0..sblimit { + let row = table.row(sb); + let nbal = row.len().trailing_zeros(); + let pick = |r: &mut BitReader| -> Result { Ok(row.get(read(r, nbal)? as usize).copied().unwrap_or(0)) }; + if sb < bound { + for ch in 0..nch { + steps[ch][sb] = pick(r)?; + } + } else { + let s = pick(r)?; + steps[0][sb] = s; + steps[1][sb] = s; + } + } + let mut scfsi = [[0u32; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + if steps[ch][sb] != 0 { + scfsi[ch][sb] = read(r, 2)?; + } + } + } + // the scalefactor for each third of the frame (4 granules each) + let mut scf = [[[0f32; 3]; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + if steps[ch][sb] == 0 { + continue; + } + let mut s = || -> Result { Ok(tables::scalefactor(read(r, 6)?)) }; + scf[ch][sb] = match scfsi[ch][sb] { + 0 => [s()?, s()?, s()?], + 1 => { + let (a, b) = (s()?, s()?); + [a, a, b] + } + 2 => { + let a = s()?; + [a, a, a] + } + _ => { + let (a, b) = (s()?, s()?); + [a, b, b] + } + }; + } + } + for gr in 0..12 { + let part = gr / 4; + for sb in 0..sblimit { + if sb < bound { + for ch in 0..nch { + if steps[ch][sb] != 0 { + let q = granule(r, steps[ch][sb])?; + for (k, v) in q.iter().enumerate() { + out[ch][3 * gr + k][sb] = v * scf[ch][sb][part]; + } + } + } + } else if steps[0][sb] != 0 { + let q = granule(r, steps[0][sb])?; + for ch in 0..nch { + for (k, v) in q.iter().enumerate() { + out[ch][3 * gr + k][sb] = v * scf[ch][sb][part]; + } + } + } + } + } + Ok(()) +} + +/// The synthesis subband filter (§2.4.3.2, Figure A.2) for one time slot: 32 subband samples in, +/// 32 PCM samples out. +fn synthesize(v: &mut [f32; 1024], n: &[[f32; 32]; 64], d: &[f32; 512], s: &[f32; 32], out: &mut [f32; 32]) { + v.copy_within(0..960, 64); + for (vi, row) in v.iter_mut().zip(n.iter()) { + *vi = row.iter().zip(s.iter()).map(|(a, b)| a * b).sum(); + } + for (j, o) in out.iter_mut().enumerate() { + let mut acc = 0f32; + for i in 0..8 { + acc += v[i * 128 + j] * d[i * 64 + j] + v[i * 128 + 96 + j] * d[i * 64 + 32 + j]; + } + *o = acc; + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mpa/src/tables.rs b/crates/mpa/src/tables.rs new file mode 100644 index 000000000..4ca947566 --- /dev/null +++ b/crates/mpa/src/tables.rs @@ -0,0 +1,189 @@ +//! Tables from ISO/IEC 11172-3 Annex B and ISO/IEC 13818-3 Annex B. + +use std::sync::OnceLock; + +/// Bit rates in kbit/s by `bitrate_index` (ISO/IEC 11172-3 §2.4.2.3; ISO/IEC 13818-3 §2.4.2.3 for +/// the lower sampling frequencies). Index 0 is the free format, index 15 is forbidden. +pub(crate) const BITRATES_MPEG1_L1: [u32; 15] = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448]; +pub(crate) const BITRATES_MPEG1_L2: [u32; 15] = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]; +pub(crate) const BITRATES_MPEG1_L3: [u32; 15] = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; +pub(crate) const BITRATES_LSF_L1: [u32; 15] = [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256]; +pub(crate) const BITRATES_LSF_L23: [u32; 15] = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]; + +/// Sampling frequencies of MPEG-1 by `sampling_frequency` (3 is reserved); MPEG-2 halves them, +/// the unofficial MPEG-2.5 quarters them. +pub(crate) const SAMPLE_RATES: [u32; 3] = [44_100, 48_000, 32_000]; + +/// Coefficients `D[0..=256]` of the synthesis window (Table 3-B.3), in units of 2⁻¹⁶: the table's +/// nine-decimal values are exact multiples of 1/65536. The rest of the window follows from the +/// symmetry of the prototype filter, see [`window`]. +const WINDOW_HALF: [i32; 257] = [ + 0, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -3, -3, -4, -4, -5, // + -5, -6, -7, -7, -8, -9, -10, -11, -13, -14, -16, -17, -19, -21, -24, -26, // + -29, -31, -35, -38, -41, -45, -49, -53, -58, -63, -68, -73, -79, -85, -91, -97, // + -104, -111, -117, -125, -132, -139, -147, -154, -161, -169, -176, -183, -190, -196, -202, -208, // + 213, 218, 222, 225, 227, 228, 228, 227, 224, 221, 215, 208, 200, 189, 177, 163, // + 146, 127, 106, 83, 57, 29, -2, -36, -72, -111, -153, -197, -244, -294, -347, -401, // + -459, -519, -581, -645, -711, -779, -848, -919, -991, -1064, -1137, -1210, -1283, -1356, -1428, -1498, // + -1567, -1634, -1698, -1759, -1817, -1870, -1919, -1962, -2001, -2032, -2057, -2075, -2085, -2087, -2080, -2063, // + 2037, 2000, 1952, 1893, 1822, 1739, 1644, 1535, 1414, 1280, 1131, 970, 794, 605, 402, 185, // + -45, -288, -545, -814, -1095, -1388, -1692, -2006, -2330, -2663, -3004, -3351, -3705, -4063, -4425, -4788, // + -5153, -5517, -5879, -6237, -6589, -6935, -7271, -7597, -7910, -8209, -8491, -8755, -8998, -9219, -9416, -9585, // + -9727, -9838, -9916, -9959, -9966, -9935, -9863, -9750, -9592, -9389, -9139, -8840, -8492, -8092, -7640, -7134, // + 6574, 5959, 5288, 4561, 3776, 2935, 2037, 1082, 70, -998, -2122, -3300, -4533, -5818, -7154, -8540, // + -9975, -11455, -12980, -14548, -16155, -17799, -19478, -21189, -22929, -24694, -26482, -28289, -30112, -31947, -33791, -35640, // + -37489, -39336, -41176, -43006, -44821, -46617, -48390, -50137, -51853, -53534, -55178, -56778, -58333, -59838, -61289, -62684, // + -64019, -65290, -66494, -67629, -68692, -69679, -70590, -71420, -72169, -72835, -73415, -73908, -74313, -74630, -74856, -74992, // + 75038, +]; + +/// `D[i]` for `i` in `0..=256`, in units of 2⁻¹⁶. +pub(crate) fn window_half(i: usize) -> Option { + WINDOW_HALF.get(i).copied() +} + +/// The 512 synthesis window coefficients `D[i]` (Table 3-B.3). The table stores the symmetric +/// prototype `h[i] = h[512 - i]` with every other block of 64 negated, so for `i > 256` +/// `D[i] = -D[512 - i]`, except at multiples of 64 where both halves share the block parity. +pub(crate) fn window() -> &'static [f32; 512] { + static W: OnceLock<[f32; 512]> = OnceLock::new(); + W.get_or_init(|| { + let mut w = [0f32; 512]; + for (i, d) in w.iter_mut().enumerate() { + let (j, sign) = if i <= 256 { (i, 1.0) } else { (512 - i, if i % 64 == 0 { 1.0 } else { -1.0 }) }; + *d = sign * window_half(j).unwrap_or(0) as f32 / 65_536.0; + } + w + }) +} + +/// The matrixing coefficients `N[i][k] = cos((16 + i)(2k + 1)π / 64)` of the synthesis subband +/// filter (§2.4.3.2, Figure A.2), `i` in `0..64`, `k` in `0..32`. +pub(crate) fn matrix() -> &'static [[f32; 32]; 64] { + static N: OnceLock<[[f32; 32]; 64]> = OnceLock::new(); + N.get_or_init(|| { + let mut n = [[0f32; 32]; 64]; + for (i, row) in n.iter_mut().enumerate() { + for (k, c) in row.iter_mut().enumerate() { + *c = (((16 + i) * (2 * k + 1)) as f64 * std::f64::consts::PI / 64.0).cos() as f32; + } + } + n + }) +} + +/// Scalefactors (Table 3-B.1): `2^(1 - index / 3)`. Index 63 is not used by encoders; it continues +/// the progression rather than failing the frame. +pub(crate) fn scalefactor(index: u32) -> f32 { + static S: OnceLock<[f32; 64]> = OnceLock::new(); + let s = S.get_or_init(|| { + let mut s = [0f32; 64]; + for (i, v) in s.iter_mut().enumerate() { + *v = 2f64.powf(1.0 - i as f64 / 3.0) as f32; + } + s + }); + s.get(index as usize).copied().unwrap_or(0.0) +} + +// Layer II possible quantizations per subband (Tables 3-B.2a-d; ISO/IEC 13818-3 Table B.1): the +// number of quantization steps for each value of the subband's allocation field, 0 meaning +// nothing is transmitted. A row has 2^nbal entries. + +/// 3-B.2a/b subbands 0-2. +const A_LOW: [u16; 16] = [0, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767, 65535]; +/// 3-B.2a/b subbands 3-10. +const A_MID: [u16; 16] = [0, 3, 5, 7, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 65535]; +/// 3-B.2a/b subbands 11-22. +const A_HIGH: [u16; 8] = [0, 3, 5, 7, 9, 15, 31, 65535]; +/// 3-B.2a/b subbands 23-29. +const A_TOP: [u16; 4] = [0, 3, 5, 65535]; +/// 3-B.2c/d subbands 0-1. +const C_LOW: [u16; 16] = [0, 3, 5, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767]; +/// 3-B.2c/d subbands 2-11; 13818-3 B.1 subbands 4-10. +const C_HIGH: [u16; 8] = [0, 3, 5, 9, 15, 31, 63, 127]; +/// 13818-3 B.1 subbands 0-3. +const LSF_LOW: [u16; 16] = [0, 3, 5, 7, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383]; +/// 13818-3 B.1 subbands 11-29. +const LSF_TOP: [u16; 4] = [0, 3, 5, 9]; + +/// A Layer II bit allocation table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AllocTable { + /// 3-B.2a: 27 subbands. + A, + /// 3-B.2b: 30 subbands. + B, + /// 3-B.2c: 8 subbands. + C, + /// 3-B.2d: 12 subbands. + D, + /// ISO/IEC 13818-3 Table B.1 (lower sampling frequencies): 30 subbands. + Lsf, +} + +impl AllocTable { + /// The table for a Layer II stream (§2.4.3.3.1 and Table 3-B.2): by sampling frequency and bit + /// rate per channel. ISO/IEC 11172-3 lists only the allowed bit rate / mode combinations; + /// encoders also write the others (ffmpeg's default for mono is 384 kbit/s), so the bit rate + /// ranges are taken as open-ended: at least 56 kbit/s per channel at 48 kHz, or 56-80 kbit/s, + /// is table a; 96 kbit/s and up at 44.1 / 32 kHz is table b; 48 kbit/s and below is table c + /// (d at 32 kHz). The free format (`kbps_per_channel` 0) uses a or b like the high rates. + pub(crate) fn select(lsf: bool, sample_rate: u32, kbps_per_channel: u32) -> Self { + if lsf { + return Self::Lsf; + } + let free = kbps_per_channel == 0; + if (sample_rate == 48_000 && (free || kbps_per_channel >= 56)) || (56..=80).contains(&kbps_per_channel) { + Self::A + } else if free || kbps_per_channel >= 96 { + Self::B + } else if sample_rate == 32_000 { + Self::D + } else { + Self::C + } + } + + /// Subbands with an allocation (`sblimit`). + pub(crate) fn sblimit(self) -> usize { + match self { + Self::A => 27, + Self::B | Self::Lsf => 30, + Self::C => 8, + Self::D => 12, + } + } + + /// The quantization steps per allocation value for subband `sb`; its length is 2^nbal. + pub(crate) fn row(self, sb: usize) -> &'static [u16] { + match self { + Self::A | Self::B => match sb { + 0..=2 => &A_LOW, + 3..=10 => &A_MID, + 11..=22 => &A_HIGH, + _ => &A_TOP, + }, + Self::C | Self::D => match sb { + 0..=1 => &C_LOW, + _ => &C_HIGH, + }, + Self::Lsf => match sb { + 0..=3 => &LSF_LOW, + 4..=10 => &C_HIGH, + _ => &LSF_TOP, + }, + } + } +} + +/// How the samples of a quantization class are coded (Table 3-B.4): `Some(bits)` for the grouped +/// classes (three samples in one codeword), `None` for one `log2(steps + 1)`-bit code per sample. +pub(crate) fn grouping(steps: u16) -> Option { + match steps { + 3 => Some(5), + 5 => Some(7), + 9 => Some(10), + _ => None, + } +} diff --git a/crates/mpa/src/tests.rs b/crates/mpa/src/tests.rs new file mode 100644 index 000000000..1f2e20a66 --- /dev/null +++ b/crates/mpa/src/tests.rs @@ -0,0 +1,151 @@ +use super::*; +use tables::window_half; + +/// A frame header: MPEG-1 unless `lsf`, `layer` 1/2, no CRC, `bitrate_index`, `rate_index` +/// (0: 44.1 kHz, 1: 48 kHz, 2: 32 kHz), `mode` 0-3 and `mode_extension`. +fn header(lsf: bool, layer: u8, bitrate_index: u8, rate_index: u8, mode: u8, ext: u8) -> [u8; 4] { + let version = if lsf { 0b10 } else { 0b11 }; + [0xFF, 0xE0 | version << 3 | (4 - layer) << 1 | 1, bitrate_index << 4 | rate_index << 2, mode << 6 | ext << 4] +} + +/// A frame of `header` with all-zero audio data: no subband has an allocation. +fn silent(h: [u8; 4]) -> Vec { + let mut f = h.to_vec(); + f.resize(parse_header(&h).unwrap().frame_bytes, 0); + f +} + +#[test] +fn headers() { + // Layer II 48 kHz stereo 192 kbit/s: 144 * 192000 / 48000 = 576 bytes + let h = parse_header(&header(false, 2, 10, 1, 0, 0)).unwrap(); + assert_eq!((h.version, h.layer, h.bitrate_kbps, h.sample_rate, h.frame_bytes, h.channels(), h.samples()), (Version::Mpeg1, 2, 192, 48_000, 576, 2, 1152)); + // ffmpeg's default for mono: Layer II 384 kbit/s, which ISO/IEC 11172-3 does not allow + let h = parse_header(&header(false, 2, 14, 1, 3, 0)).unwrap(); + assert_eq!((h.bitrate_kbps, h.frame_bytes, h.mode, h.channels()), (384, 1152, Mode::Mono, 1)); + // Layer I 44.1 kHz 384 kbit/s with padding: (12 * 384000 / 44100 + 1) * 4 = 420 + let mut b = header(false, 1, 12, 0, 1, 2); + b[2] |= 2; + let h = parse_header(&b).unwrap(); + assert_eq!((h.layer, h.frame_bytes, h.samples(), h.mode, h.mode_extension, h.bound()), (1, 420, 384, Mode::JointStereo, 2, 12)); + // MPEG-2 Layer II 24 kHz 160 kbit/s: 144 * 160000 / 24000 = 960; Layer I 22.05 kHz 256 kbit/s + let h = parse_header(&header(true, 2, 14, 1, 0, 0)).unwrap(); + assert_eq!((h.version, h.sample_rate, h.frame_bytes), (Version::Mpeg2, 24_000, 960)); + let h = parse_header(&header(true, 1, 14, 0, 0, 0)).unwrap(); + assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes), (22_050, 256, 4 * (12 * 256_000 / 22_050))); + // MPEG-2.5 and Layer III headers parse too + assert_eq!(parse_header(&[0xFF, 0xE5, 0x10, 0xC0]).unwrap().sample_rate, 11_025); + let h = parse_header(&[0xFF, 0xFB, 0x90, 0x44]).unwrap(); + assert_eq!((h.layer, h.frame_bytes), (3, 417)); +} + +#[test] +fn bad_headers_are_errors() { + assert_eq!(parse_header(&[]), Err(Error::Invalid("truncated header"))); + assert_eq!(parse_header(&[0xFF, 0xFD]), Err(Error::Invalid("truncated header"))); + assert_eq!(parse_header(&[0x00, 0xFD, 0x90, 0x00]), Err(Error::NoSync)); + assert_eq!(parse_header(&[0xFF, 0x1D, 0x90, 0x00]), Err(Error::NoSync)); + assert_eq!(parse_header(&[0xFF, 0xED, 0x90, 0x00]), Err(Error::Invalid("reserved version"))); + assert_eq!(parse_header(&[0xFF, 0xF9, 0x90, 0x00]), Err(Error::Invalid("reserved layer"))); + assert_eq!(parse_header(&[0xFF, 0xFD, 0xF0, 0x00]), Err(Error::Invalid("forbidden bit rate index"))); + assert_eq!(parse_header(&[0xFF, 0xFD, 0x9C, 0x00]), Err(Error::Invalid("reserved sampling frequency"))); + let mut d = Decoder::new(); + assert_eq!(d.decode(&[0xFF, 0xFB, 0x90, 0x44]).unwrap_err(), Error::Unsupported("Layer III")); + assert_eq!(d.decode(&[0xFF, 0xFD, 0x04, 0x00, 0, 0]).unwrap_err(), Error::Unsupported("free-format bit rate")); + assert_eq!(d.decode(&silent(header(false, 2, 10, 1, 0, 0))[..100]).unwrap_err(), Error::Invalid("truncated frame")); +} + +#[test] +fn silent_frames_decode_to_silence() { + let mut d = Decoder::new(); + for (h, ch, n) in [ + (header(false, 2, 14, 1, 3, 0), 1, 1152), + (header(false, 2, 1, 2, 0, 0), 2, 1152), + (header(true, 2, 8, 0, 1, 3), 2, 1152), + (header(false, 1, 6, 0, 2, 0), 2, 384), + (header(false, 1, 4, 1, 3, 0), 1, 384), + ] { + let f = d.decode(&silent(h)).unwrap(); + assert_eq!(f.channels.len(), ch, "{h:?}"); + assert!(f.channels.iter().all(|c| c.len() == n && c.iter().all(|&s| s == 0.0)), "{h:?}"); + } +} + +/// Table 3-B.3 as typed in must be the smooth prototype filter: with every other block of 64 +/// negated back, its third differences stay within the rounding of the 2⁻¹⁶ units (a mistyped +/// coefficient shows up as a spike of hundreds). +#[test] +fn synthesis_window_is_the_smooth_prototype() { + let h: Vec = (0..=256).map(|i| window_half(i).unwrap() as f64 * if (i / 64) % 2 == 1 { -1.0 } else { 1.0 }).collect(); + let d1: Vec = h.windows(2).map(|w| w[1] - w[0]).collect(); + let d2: Vec = d1.windows(2).map(|w| w[1] - w[0]).collect(); + let worst = d2.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0, f64::max); + assert!(worst <= 8.0, "{worst}"); + assert_eq!(window_half(257), None); + let w = tables::window(); + // symmetry of the full window and the table's endpoints + assert_eq!((w[0], w[256], w[511], w[448]), (0.0, 75_038.0 / 65_536.0, 1.0 / 65_536.0, w[64])); + assert_eq!(w[300], -w[212]); +} + +#[test] +fn layer2_allocation_tables() { + use tables::AllocTable as T; + for t in [T::A, T::B, T::C, T::D, T::Lsf] { + for sb in 0..t.sblimit() { + let row = t.row(sb); + assert!(row.len().is_power_of_two() && row[0] == 0, "{t:?} {sb}"); + } + } + // the allowed combinations (Table 3-B.2) and the ones encoders write anyway + assert_eq!(T::select(false, 48_000, 192), T::A); + assert_eq!(T::select(false, 44_100, 64), T::A); + assert_eq!(T::select(false, 44_100, 96), T::B); + assert_eq!(T::select(false, 32_000, 192), T::B); + assert_eq!(T::select(false, 48_000, 32), T::C); + assert_eq!(T::select(false, 44_100, 48), T::C); + assert_eq!(T::select(false, 32_000, 48), T::D); + assert_eq!(T::select(false, 48_000, 384), T::A); + assert_eq!(T::select(false, 44_100, 384), T::B); + assert_eq!(T::select(false, 32_000, 16), T::D); + assert_eq!(T::select(true, 24_000, 160), T::Lsf); +} + +#[test] +fn requantization_is_symmetric() { + assert_eq!([0, 1, 2].map(|v| requantize(v, 3)), [-2.0 / 3.0, 0.0, 2.0 / 3.0]); + assert_eq!(requantize(4, 9), 0.0); + assert_eq!(requantize(0, 65_535), -65_534.0 / 65_535.0); +} + +/// Random audio data behind valid headers, and damaged headers: errors or noise, never a panic. +#[test] +fn random_frames_never_panic() { + let mut x = 0x1234_5678u32; + let mut rnd = move || { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x + }; + let mut d = Decoder::new(); + for k in 0..4000 { + let mut h = header(k % 3 == 0, 1 + (k % 2) as u8, 1 + (rnd() % 14) as u8, (rnd() % 3) as u8, (rnd() % 4) as u8, (rnd() % 4) as u8); + if k % 5 == 0 { + h[1] &= 0xFE; // CRC present + } + if k % 7 == 0 { + h[2] ^= rnd() as u8; + h[3] ^= rnd() as u8; + } + let len = parse_header(&h).map(|p| p.frame_bytes).unwrap_or(64); + let mut f = h.to_vec(); + f.extend((0..len.saturating_sub(4) + (rnd() % 8) as usize).map(|_| rnd() as u8)); + if k % 11 == 0 { + f.truncate(rnd() as usize % f.len()); + } + if let Ok(fr) = d.decode(&f) { + assert!(fr.channels.iter().flatten().all(|s| s.is_finite())); + } + } +} diff --git a/crates/mpa/tests/oracle.rs b/crates/mpa/tests/oracle.rs new file mode 100644 index 000000000..57ad6544f --- /dev/null +++ b/crates/mpa/tests/oracle.rs @@ -0,0 +1,572 @@ +//! Layer I and Layer II against ffmpeg's decoder as the reference. +//! +//! - Encoded streams: ffmpeg's MP2 encoder (mono, stereo, MPEG-1 and MPEG-2 sampling frequencies, +//! every Layer II allocation table, and bit rate / mode combinations ISO/IEC 11172-3 does not +//! allow but ffmpeg writes: its default for mono is 384 kbit/s); twolame, when installed, for +//! joint stereo and dual channel. +//! - Synthetic streams: valid random frames for what no installed encoder writes: Layer I (mono, +//! stereo, dual channel, joint stereo at every bound) and Layer II joint stereo / dual channel +//! with every allocation table. ffmpeg decodes them as it would any stream. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use filmcraft_bitstream::BitWriter; +use filmcraft_mpa::{Decoder, Mode, parse_header}; + +/// A stream from an encoder. +struct Encoded { + name: &'static str, + input: &'static str, + /// Encoder arguments after the input (ffmpeg) or flags (twolame). + enc: &'static [&'static str], + rate: u32, + channels: usize, + /// The bit rate the frames must carry (proves which combination is covered). + kbps: u32, + mode: Mode, + twolame: bool, +} + +const TONES: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t)"; + +const ENCODED: &[Encoded] = &[ + // ffmpeg's default for mono: 384 kbit/s, which ISO/IEC 11172-3 does not allow (table a) + Encoded { + name: "mono_48k_default", + input: "sine=frequency=1000:sample_rate=48000", + enc: &[], + rate: 48_000, + channels: 1, + kbps: 384, + mode: Mode::Mono, + twolame: false, + }, + Encoded { + name: "stereo_48k_192k", + input: TONES, + enc: &["-ar", "48000", "-b:a", "192k"], + rate: 48_000, + channels: 2, + kbps: 192, + mode: Mode::Stereo, + twolame: false, + }, + // table b + Encoded { + name: "stereo_44k_256k", + input: TONES, + enc: &["-ar", "44100", "-b:a", "256k"], + rate: 44_100, + channels: 2, + kbps: 256, + mode: Mode::Stereo, + twolame: false, + }, + // table b, mono above 192 kbit/s (not allowed) + Encoded { + name: "mono_44k_320k", + input: "anoisesrc=color=pink:sample_rate=44100:amplitude=0.3:seed=5", + enc: &["-b:a", "320k"], + rate: 44_100, + channels: 1, + kbps: 320, + mode: Mode::Mono, + twolame: false, + }, + // table c, and stereo below 64 kbit/s (not allowed) + Encoded { + name: "stereo_48k_32k", + input: TONES, + enc: &["-ar", "48000", "-b:a", "32k"], + rate: 48_000, + channels: 2, + kbps: 32, + mode: Mode::Stereo, + twolame: false, + }, + Encoded { + name: "stereo_44k_96k", + input: TONES, + enc: &["-ar", "44100", "-b:a", "96k"], + rate: 44_100, + channels: 2, + kbps: 96, + mode: Mode::Stereo, + twolame: false, + }, + // table d + Encoded { + name: "mono_32k_48k", + input: "sine=frequency=440:sample_rate=32000", + enc: &["-b:a", "48k"], + rate: 32_000, + channels: 1, + kbps: 48, + mode: Mode::Mono, + twolame: false, + }, + // table a at 32 kHz + Encoded { + name: "stereo_32k_128k", + input: TONES, + enc: &["-ar", "32000", "-b:a", "128k"], + rate: 32_000, + channels: 2, + kbps: 128, + mode: Mode::Stereo, + twolame: false, + }, + // MPEG-2 lower sampling frequencies (ISO/IEC 13818-3 table) + Encoded { + name: "lsf_stereo_24k_160k", + input: TONES, + enc: &["-ar", "24000", "-b:a", "160k"], + rate: 24_000, + channels: 2, + kbps: 160, + mode: Mode::Stereo, + twolame: false, + }, + Encoded { + name: "lsf_mono_22k_64k", + input: "sine=frequency=700:sample_rate=22050", + enc: &["-b:a", "64k"], + rate: 22_050, + channels: 1, + kbps: 64, + mode: Mode::Mono, + twolame: false, + }, + Encoded { + name: "lsf_stereo_16k_32k", + input: TONES, + enc: &["-ar", "16000", "-b:a", "32k"], + rate: 16_000, + channels: 2, + kbps: 32, + mode: Mode::Stereo, + twolame: false, + }, + // twolame: joint stereo (intensity coding above the bound) and dual channel + Encoded { + name: "twolame_joint_48k_128k", + input: TONES, + enc: &["-b", "128", "-m", "j"], + rate: 48_000, + channels: 2, + kbps: 128, + mode: Mode::JointStereo, + twolame: true, + }, + Encoded { + name: "twolame_joint_44k_64k", + input: TONES, + enc: &["-b", "64", "-m", "j"], + rate: 44_100, + channels: 2, + kbps: 64, + mode: Mode::JointStereo, + twolame: true, + }, + Encoded { + name: "twolame_dual_44k_192k", + input: TONES, + enc: &["-b", "192", "-m", "d"], + rate: 44_100, + channels: 2, + kbps: 192, + mode: Mode::DualChannel, + twolame: true, + }, +]; + +fn tool(name: &str) -> Option { + filmcraft_testkit::oracle::find_tool(name) +} + +fn make_encoded(ff: &Path, s: &Encoded) -> Option { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{}.mp2", s.name)); + if s.twolame { + let twolame = tool("twolame")?; + return filmcraft_testkit::fixtures::generate(&out, |tmp| { + let wav = tmp.with_extension("wav"); + let made = Command::new(ff) + .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", &format!("{}:s={}", s.input, s.rate), "-t", "1", "-c:a", "pcm_s16le"]) + .arg(&wav) + .stdin(Stdio::null()) + .status() + .is_ok_and(|x| x.success()) + && Command::new(twolame).arg("--quiet").args(s.enc).arg(&wav).arg(tmp).stdin(Stdio::null()).status().is_ok_and(|x| x.success()); + let _ = std::fs::remove_file(&wav); + made + }); + } + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ff) + .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1", "-c:a", "mp2"]) + .args(s.enc) + .args(["-f", "mp2"]) + .arg(tmp) + .stdin(Stdio::null()) + .status() + .is_ok_and(|x| x.success()) + }) +} + +/// The frames of a raw stream (an ID3 tag or garbage before the first frame is not expected). +fn frames(data: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut p = 0; + while let Some(h) = data.get(p..).and_then(|b| parse_header(b).ok()) { + let Some(f) = data.get(p..p + h.frame_bytes).filter(|_| h.frame_bytes > 0) else { break }; + out.push(f); + p += h.frame_bytes; + } + out +} + +/// ffmpeg's decode of `file`, interleaved f32. +fn ffmpeg_decode(ff: &Path, file: &Path) -> Vec { + let o = Command::new(ff).args(["-v", "error", "-f", "mp3", "-i"]).arg(file).args(["-f", "f32le", "-"]).output().unwrap(); + assert!(o.status.success(), "{}: {}", file.display(), String::from_utf8_lossy(&o.stderr)); + o.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Decode `file` with our decoder and return the worst channel's SNR against ffmpeg (dB). +fn snr_vs_ffmpeg(ff: &Path, file: &Path, rate: u32, channels: usize) -> f64 { + let data = std::fs::read(file).unwrap(); + let fr = frames(&data); + assert!(fr.len() > 10, "{}: {} frames", file.display(), fr.len()); + let want = ffmpeg_decode(ff, file); + let n = want.len() / channels; + // ffmpeg drops the first frame of a stream whose second frame changes the channel mode + // (twolame starts in joint stereo and switches to stereo): start there too + let first = if first_frame_mode_changes(&data) { 1 } else { 0 }; + let mut dec = Decoder::new(); + let mut ours: Vec> = vec![Vec::new(); channels]; + for f in &fr[first..] { + let d = dec.decode(f).unwrap_or_else(|e| panic!("{}: {e}", file.display())); + assert_eq!((d.header.sample_rate, d.channels.len()), (rate, channels), "{}", file.display()); + for (o, c) in ours.iter_mut().zip(d.channels) { + o.extend(c); + } + } + assert_eq!(n, ours[0].len(), "{}: sample count", file.display()); + let mut worst = f64::INFINITY; + for (c, ours) in ours.iter().enumerate() { + let (mut sig, mut err) = (0f64, 0f64); + for (i, &a) in ours.iter().enumerate() { + let b = want[i * channels + c] as f64; + sig += b * b; + err += (a as f64 - b) * (a as f64 - b); + } + worst = worst.min(10.0 * (sig / err.max(1e-30)).log10()); + } + worst +} + +fn first_frame_mode_changes(data: &[u8]) -> bool { + let f = frames(data); + f.len() > 1 && parse_header(f[0]).unwrap().mode != parse_header(f[1]).unwrap().mode +} + +/// The minimum SNR against ffmpeg's float decoder. ffmpeg's own fixed-point decoder agrees with +/// it to ~77 dB (the error is at the 16-bit LSB, ~-96 dBFS); we measure 73-84 dB. A wrong table +/// entry or a misread field costs tens of dB. +const MIN_SNR: f64 = 70.0; + +#[test] +fn encoded_streams_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for s in ENCODED { + let Some(f) = make_encoded(&ff, s) else { + assert!(s.twolame, "could not generate {}", s.name); + eprintln!("{}: twolame not installed, skipped", s.name); + continue; + }; + let data = std::fs::read(&f).unwrap(); + let h = parse_header(&data).unwrap(); + assert_eq!((h.layer, h.bitrate_kbps, h.mode), (2, s.kbps, s.mode), "{}", s.name); + let snr = snr_vs_ffmpeg(&ff, &f, s.rate, s.channels); + println!("{}: {} Hz, {} kbit/s, {:?}: SNR vs ffmpeg {snr:.1} dB", s.name, s.rate, s.kbps, s.mode); + assert!(snr >= MIN_SNR, "{}: SNR {snr:.1} dB < {MIN_SNR}", s.name); + } +} + +struct Rng(u32); +impl Rng { + fn next(&mut self) -> u32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 + } + fn below(&mut self, n: u32) -> u32 { + self.next() % n + } +} + +/// Header bytes (no CRC): `lsf` MPEG-2, `layer` 1/2, the indices, `mode` 0-3, `ext`. +fn header(lsf: bool, layer: u8, bitrate_index: u8, rate_index: u8, mode: u8, ext: u8) -> [u8; 4] { + let version = if lsf { 0b10 } else { 0b11 }; + [0xFF, 0xE0 | version << 3 | (4 - layer) << 1 | 1, bitrate_index << 4 | rate_index << 2, mode << 6 | ext << 4] +} + +/// A valid random Layer I frame: random allocations (as many as the frame holds), scalefactors +/// and samples. +fn random_layer1(rng: &mut Rng, h: [u8; 4]) -> Vec { + let hd = parse_header(&h).unwrap(); + let nch = hd.channels(); + let bound = if hd.mode == Mode::JointStereo { 4 * (hd.mode_extension as usize + 1) } else { 32 }; + let mut alloc = [[0u32; 32]; 2]; + for sb in 0..32 { + for ch in 0..nch { + if (ch == 0 || sb < bound) && rng.below(3) != 0 { + alloc[ch][sb] = 1 + rng.below(14); + } + } + if sb >= bound { + alloc[1][sb] = alloc[0][sb]; + } + } + let bits = |alloc: &[[u32; 32]; 2]| { + let mut n = 32 + 4 * (32 + bound.min(32) * (nch - 1)); + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + n += 6; + if ch == 0 || sb < bound { + n += 12 * (alloc[ch][sb] as usize + 1); + } + } + } + } + n + }; + // drop allocations from the top until the frame holds them + let mut sb = 31; + while bits(&alloc) > hd.frame_bytes * 8 { + alloc[0][sb] = 0; + alloc[1][sb] = 0; + sb -= 1; + } + let mut w = BitWriter::new(); + w.write_bytes(&h); + for sb in 0..32 { + for ch in 0..if sb < bound { nch } else { 1 } { + w.write_bits(alloc[ch][sb], 4); + } + } + let mut scf = || 10 + rng.below(40); + let mut scfs = vec![]; + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + scfs.push(scf()); + } + } + } + for s in scfs { + w.write_bits(s, 6); + } + for _ in 0..12 { + for sb in 0..32 { + for ch in 0..if sb < bound { nch } else { 1 } { + let nb = alloc[ch][sb] + 1; + if alloc[ch][sb] != 0 { + // any code but the forbidden all-ones + w.write_bits(rng.below((1 << nb) - 1), nb); + } + } + } + } + let mut f = w.finish(); + f.resize(hd.frame_bytes, 0); + f +} + +/// Allocation field widths (nbal) per subband of the Layer II table the test frames use, by the +/// same selection as the decoder; every table's allocation value 1 is the 3-step class. +fn nbal(lsf: bool, rate: u32, kbps_per_channel: u32) -> Vec { + let rep = |v: &[(u32, usize)]| v.iter().flat_map(|&(n, k)| std::iter::repeat_n(n, k)).collect::>(); + if lsf { + rep(&[(4, 4), (3, 7), (2, 19)]) + } else if (rate == 48_000 && kbps_per_channel >= 56) || (56..=80).contains(&kbps_per_channel) { + rep(&[(4, 11), (3, 12), (2, 4)]) + } else if kbps_per_channel >= 96 { + rep(&[(4, 11), (3, 12), (2, 7)]) + } else if rate == 32_000 { + rep(&[(4, 2), (3, 10)]) + } else { + rep(&[(4, 2), (3, 6)]) + } +} + +/// A valid random Layer II frame using only the 3-step class (allocation value 1, grouped). +fn random_layer2(rng: &mut Rng, h: [u8; 4]) -> Vec { + let hd = parse_header(&h).unwrap(); + let nch = hd.channels(); + let nbal = nbal(h[1] & 0x08 == 0, hd.sample_rate, hd.bitrate_kbps / nch as u32); + let sblimit = nbal.len(); + let bound = if hd.mode == Mode::JointStereo { (4 * (hd.mode_extension as usize + 1)).min(sblimit) } else { sblimit }; + let mut alloc = [[false; 32]; 2]; + let mut scfsi = [[0u32; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + alloc[ch][sb] = if ch == 1 && sb >= bound { alloc[0][sb] } else { rng.below(3) != 0 }; + scfsi[ch][sb] = rng.below(4); + } + } + let scf_bits = |s: u32| { + if s == 0 { + 18 + } else if s == 2 { + 6 + } else { + 12 + } + }; + let bits = |alloc: &[[bool; 32]; 2]| { + let mut n = 32; + for sb in 0..sblimit { + n += nbal[sb] as usize * if sb < bound { nch } else { 1 }; + for ch in 0..nch { + if alloc[ch][sb] { + n += 2 + scf_bits(scfsi[ch][sb]); + if ch == 0 || sb < bound { + n += 12 * 5; + } + } + } + } + n + }; + let mut sb = sblimit - 1; + while bits(&alloc) > hd.frame_bytes * 8 { + alloc[0][sb] = false; + alloc[1][sb] = false; + sb -= 1; + } + let mut w = BitWriter::new(); + w.write_bytes(&h); + for sb in 0..sblimit { + for ch in 0..if sb < bound { nch } else { 1 } { + w.write_bits(alloc[ch][sb] as u32, nbal[sb]); + } + } + for sb in 0..sblimit { + for ch in 0..nch { + if alloc[ch][sb] { + w.write_bits(scfsi[ch][sb], 2); + } + } + } + for sb in 0..sblimit { + for ch in 0..nch { + if alloc[ch][sb] { + for _ in 0..scf_bits(scfsi[ch][sb]) / 6 { + w.write_bits(10 + rng.below(40), 6); + } + } + } + } + for _ in 0..12 { + for sb in 0..sblimit { + for ch in 0..if sb < bound { nch } else { 1 } { + if alloc[ch][sb] { + w.write_bits(rng.below(27), 5); + } + } + } + } + let mut f = w.finish(); + f.resize(hd.frame_bytes, 0); + f +} + +/// (name, layer, MPEG-2, bitrate index, sampling frequency index, mode, mode extension) +const SYNTHETIC: &[(&str, u8, bool, u8, u8, u8, u8)] = &[ + ("l1_mono_44k_192k", 1, false, 6, 0, 3, 0), + ("l1_stereo_48k_384k", 1, false, 12, 1, 0, 0), + ("l1_dual_32k_256k", 1, false, 8, 2, 2, 0), + ("l1_joint4_48k_192k", 1, false, 6, 1, 1, 0), + ("l1_joint8_44k_320k", 1, false, 10, 0, 1, 1), + ("l1_joint12_32k_128k", 1, false, 4, 2, 1, 2), + ("l1_joint16_48k_448k", 1, false, 14, 1, 1, 3), + ("l1_lsf_stereo_24k_128k", 1, true, 8, 1, 0, 0), + ("l1_lsf_joint_16k_64k", 1, true, 4, 2, 1, 1), + ("l2_joint4_48k_192k", 2, false, 10, 1, 1, 0), + ("l2_joint8_44k_256k", 2, false, 12, 0, 1, 1), + ("l2_joint12_48k_64k", 2, false, 4, 1, 1, 2), + ("l2_joint16_32k_96k", 2, false, 6, 2, 1, 3), + ("l2_dual_44k_160k", 2, false, 9, 0, 2, 0), + ("l2_dual_32k_384k", 2, false, 14, 2, 2, 0), + ("l2_lsf_joint_22k_96k", 2, true, 10, 0, 1, 1), + ("l2_lsf_mono_24k_160k", 2, true, 14, 1, 3, 0), +]; + +#[test] +fn synthetic_streams_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (k, &(name, layer, lsf, br, sr, mode, ext)) in SYNTHETIC.iter().enumerate() { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{name}.mp{layer}")); + let f = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let mut rng = Rng(0x9E37_79B9 ^ k as u32); + let h = header(lsf, layer, br, sr, mode, ext); + let data: Vec = (0..40).flat_map(|_| if layer == 1 { random_layer1(&mut rng, h) } else { random_layer2(&mut rng, h) }).collect(); + std::fs::write(tmp, data).is_ok() + }) + .unwrap(); + let h = parse_header(&std::fs::read(&f).unwrap()).unwrap(); + let snr = snr_vs_ffmpeg(&ff, &f, h.sample_rate, h.channels()); + println!("{name}: SNR vs ffmpeg {snr:.1} dB"); + assert!(snr >= MIN_SNR, "{name}: SNR {snr:.1} dB < {MIN_SNR}"); + } +} + +/// Damaged frames (bit flips, truncation, scrambled headers) give errors or noise, never a panic. +#[test] +fn damaged_frames_never_panic() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut rng = Rng(0x00C0_FFEE); + let mut streams: Vec> = ENCODED.iter().filter(|s| !s.twolame).take(5).map(|s| std::fs::read(make_encoded(&ff, s).unwrap()).unwrap()).collect(); + let mut synth = Rng(7); + streams.push((0..8).flat_map(|_| random_layer1(&mut synth, header(false, 1, 12, 1, 1, 1))).collect()); + for data in &streams { + let mut dec = Decoder::new(); + for fr in frames(data).into_iter().take(8) { + for k in 0..300 { + let mut m = fr.to_vec(); + match k % 3 { + 0 => { + for _ in 0..1 + rng.below(8) { + let bit = rng.below(m.len() as u32 * 8) as usize; + m[bit / 8] ^= 0x80 >> (bit % 8); + } + } + 1 => m.truncate(rng.below(m.len() as u32) as usize), + _ => { + // keep the sync word; scramble version, layer, rates and mode + m[1] ^= rng.next() as u8 & 0x1F; + m[2] ^= rng.next() as u8; + m[3] ^= rng.next() as u8; + } + } + if let Ok(d) = dec.decode(&m) { + assert!(d.channels.iter().flatten().all(|s| s.is_finite())); + } + } + } + } +} + +#[test] +#[ignore] +fn generate_fixtures() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpa fixtures") else { return }; + for s in ENCODED { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{}.mp2", s.name)); + filmcraft_testkit::fixtures::generate_and_report(&format!("mpa/{}", s.name), &[out], || make_encoded(&ff, s)); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af9..97fc72094 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ Design principles: L2 edit · codecs · interchange · captions · speech · tts · tts-text L1 frame · media · project · audio-dsp · text L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) - codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · opus + codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · mpa · opus ``` Crates are named `filmcraft-` (`crates/time` is `filmcraft-time`). The apps are `filmcraft` @@ -56,6 +56,7 @@ and `filmcraft-cli`. | `mpeg2v` | L0 | MPEG-2 video (Main / 4:2:2 profile; frame and field pictures, dual prime) and MPEG-1 video decoder ([README](../crates/mpeg2v/README.md)) | | `aac` | L0 | AAC-LC decoder and encoder | | `ac3` | L0 | AC-3 (ATSC A/52) decoder ([README](../crates/ac3/README.md)) | +| `mpa` | L0 | MPEG-1/2 Audio Layer I and II (MP1 / MP2) decoder ([README](../crates/mpa/README.md)) | | `testkit` | L0 | test-only helpers, used only as a dev-dependency: ffmpeg/ffprobe discovery, fixture dirs, golden images ([testing.md](testing.md)) | | `frame` | L1 | `VideoFrame` (planar YUV / RGBA8 / linear RGBA f32, colour metadata), `AudioBuffer` | | `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, image sequences, WAV / Broadcast WAV | diff --git a/docs/codec-parity.md b/docs/codec-parity.md index ba0da95b9..ecbf6d724 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -42,7 +42,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | | AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | | E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h | -| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | +| MP3 / MP2 | decode + MP3 encode | MP3: symphonia (MPL-2.0); MP1 / MP2: own decoder (ISO/IEC 11172-3 / 13818-3, #809), also the bit rate / mode combinations ffmpeg writes by default (mono 384 kbit/s) | **none** | MP1 / MP2: SNR vs ffmpeg 73–84 dB | 65% | 10–20 h (MP3 encoder) | | Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | | FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | | WMA | decode on Windows | none | none | — | 0% | 10–20 h | diff --git a/docs/gaps.md b/docs/gaps.md index ab25b8af5..fca996d6e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -156,9 +156,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G10. Container and codec gaps on import - **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent - substream, 1–6 blocks per syncframe, spectral extension. -- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 - dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no + substream, 1–6 blocks per syncframe, spectral extension. MP1 / MP2 (MPEG audio Layer I / II): + own decoder (#809) in raw files, MPEG-TS / PS and Matroska, including the bit rate / mode + combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is 384 kbit/s). +- **Missing:** free-format MPEG audio, MP1 in Matroska (`A_MPEG/L1`), E-AC-3 adaptive hybrid + transform, enhanced coupling and the extra channels of 7.1 dependent substreams (played as the + 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1ea276afe..ab3955ac7 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -39,6 +39,7 @@ const LAYERS: &[(&str, u8)] = &[ ("mpegts", 0), ("mpeg2v", 0), ("ac3", 0), + ("mpa", 0), ("ogg", 0), ("riff", 0), ("h264", 0), @@ -406,6 +407,7 @@ const FIXTURE_GENERATORS: &[(&str, &str)] = &[ ("codecs", "mpeg_oracle"), ("mpeg2v", "oracle"), ("ac3", "oracle"), + ("mpa", "oracle"), ("codecs", "ogg_oracle"), ]; From 116364cf083bf3e6c7aa95f5d71318ae366ee802 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:45 +0200 Subject: [PATCH 011/157] #820: fall back to DX12, then a software adapter, when the graphics start fails Problem: a graphics driver that crashes inside wgpu instance or device creation (Intel Vulkan igvk64.dll #512 #413 #793, AMD atio6axx.dll #477) killed every launch the same way until the user set WGPU_BACKEND by hand; a start-up error wgpu did report ended in an error dialog. Fix: each start leaves a marker in the data folder (graphics-start.json), cleared once the window has painted two frames. A marker still pending at the next launch moves on along the platform's ladder (Windows: Vulkan+DX12, DX12 only, software/WARP; Linux: default, software/lavapipe or llvmpipe) and keeps the mode that works. A wgpu start-up error moves on within the same process (eframe reuses its event loop). Fallback modes don't register the GPU export renderer, whose own instance would load the failed driver. An explicit WGPU_BACKEND leaves the marker alone. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/filmcraft/Cargo.toml | 2 + apps/filmcraft/src/args.rs | 2 +- apps/filmcraft/src/graphics.rs | 391 +++++++++++++++++++++++++++++++-- apps/filmcraft/src/main.rs | 361 ++++++++++++++++-------------- docs/gaps.md | 12 +- 6 files changed, 582 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..77d378546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "pollster 0.4.0", "rfd", "serde_json", "sys-locale", diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 85175489d..3ae35e6c6 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -41,6 +41,8 @@ open = "5" [dev-dependencies] glob = "0.3" +# graphics.rs: enumerate the real adapters in a test. +pollster = "0.4" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a9684..c923112c6 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -26,7 +26,7 @@ pub enum Cli { } /// Options of a normal start. -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Launch { pub control_port: Option, /// A project and / or media files to open. diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index b2be99cbf..511ec982c 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -1,33 +1,237 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback +//! when a start fails (docs/gaps.md G4, #820). +//! +//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver +//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error +//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. +//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the +//! window has painted. A marker still pending at the next launch means that start died: the launch +//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that +//! wgpu does report (no adapter, device creation failed) moves on within the same process. An +//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave -/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) -/// names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends - // shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and - // leaves machines without DX12 with no backend at all. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{self, Backends}, +}; + +/// The start marker in the data folder: `{"mode": "", "pending": }`. Delete it to +/// start with the default graphics mode again. +pub const STATE_FILE: &str = "graphics-start.json"; + +/// How the window's graphics device is created. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on + /// macOS, Vulkan or OpenGL elsewhere. + Default, + /// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up. + Dx12, + /// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then + /// composites on the CPU (`FilmcraftApp::set_wgpu`). + Software, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Mode::Default => "default", + Mode::Dx12 => "dx12", + Mode::Software => "software", + } } + + fn parse(name: &str) -> Option { + [Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name) + } +} + +/// The modes tried in order on this platform. macOS has only Metal, and no software adapter. +pub const LADDER: &[Mode] = if cfg!(target_os = "windows") { + &[Mode::Default, Mode::Dx12, Mode::Software] +} else if cfg!(target_os = "macos") { + &[Mode::Default] +} else { + &[Mode::Default, Mode::Software] +}; + +/// The mode after `mode` in `ladder`, if any. +fn next_in(ladder: &[Mode], mode: Mode) -> Option { + let i = ladder.iter().position(|m| *m == mode)?; + ladder.get(i.checked_add(1)?).copied() +} + +/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it +/// is unreadable), and whether it moved on because the last start died. +fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) { + let first = ladder.first().copied().unwrap_or(Mode::Default); + match saved { + // A mode this platform does not use (a data folder copied from another OS): start over. + Some((mode, _)) if !ladder.contains(&mode) => (first, false), + Some((mode, true)) => match next_in(ladder, mode) { + Some(next) => (next, true), + // Nothing left to try: the last mode is still the best chance. + None => (mode, false), + }, + Some((mode, false)) => (mode, false), + None => (first, false), + } +} + +/// `(mode, pending)` from the marker's text; `None` for anything else. +fn parse_state(text: &str) -> Option<(Mode, bool)> { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?)) +} + +fn read_state(path: &Path) -> Option<(Mode, bool)> { + // The marker is a few bytes; anything longer is not ours. + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?; + parse_state(&text) +} + +fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + // Written beside and renamed, so a start that dies mid-write leaves the old marker whole. + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path) +} + +/// This launch's graphics mode and its start marker. +#[derive(Clone, Debug)] +pub struct GraphicsStart { + /// The marker; `None` without a data folder or with `WGPU_BACKEND` set. + path: Option, + mode: Mode, +} + +impl GraphicsStart { + /// Pick this launch's mode from the marker in `data_dir` and mark the start pending. + /// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker + /// alone. + pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self { + let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) }; + let saved = path.as_deref().and_then(read_state); + let (mode, moved_on) = launch_mode(saved, LADDER); + let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); + if moved_on { + let failed = saved.map_or("default", |(m, _)| m.name()); + log::warn!( + "the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)", + mode.name() + ); + } else if mode != Mode::Default { + log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name()); + } + let start = Self { path, mode }; + start.save(true); + start + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// Move on to the next mode after a start-up error wgpu reported; `false` when none is left. + pub fn step_down(&mut self) -> bool { + let Some(next) = next_in(LADDER, self.mode) else { + return false; + }; + log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name()); + self.mode = next; + self.save(true); + true + } + + /// The window is up: keep this mode for the next launches. + pub fn confirm(&self) { + self.save(false); + } + + fn save(&self, pending: bool) { + if let Some(path) = &self.path + && let Err(e) = write_state(path, self.mode, pending) + { + log::warn!("can't write {}: {e}", path.display()); + } + } +} + +/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override` +/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`. +pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option) { + apply_for(options, mode, backend_override, cfg!(target_os = "windows")); +} + +fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option, windows: bool) { + let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else { + return; + }; + match mode { + // eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it + // includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter + // selection or any Rust error handling runs, so the window never appears. Choosing an + // adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep + // Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects + // fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no + // backend at all. + Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default => {} + // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. + Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, + Mode::Software => { + create.instance_descriptor.backends = software_backends(windows); + create.native_adapter_selector = Some(Arc::new(pick_software)); + } + } +} + +/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on +/// Windows for the reason in [`apply_for`]. +fn software_backends(windows: bool) -> Backends { + if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL } +} + +/// The first software adapter that can draw to the window's surface. +fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result { + adapters + .iter() + .find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s))) + .cloned() + .ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len())) +} + +/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may +/// avoid (as opposed to a display, event-loop or app error). +pub fn is_graphics_failure(err: &eframe::Error) -> bool { + matches!(err, eframe::Error::Wgpu(_)) } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software]; + const LINUX: &[Mode] = &[Mode::Default, Mode::Software]; + const MACOS: &[Mode] = &[Mode::Default]; + + fn configured(mode: Mode, backend_override: Option, windows: bool) -> (Backends, bool) { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + apply_for(&mut options, mode, backend_override, windows); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; - create.instance_descriptor.backends + (create.instance_descriptor.backends, create.native_adapter_selector.is_some()) } #[test] @@ -37,16 +241,159 @@ mod tests { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - let configured = configured_backends(None); - assert_eq!(configured, Backends::PRIMARY); - assert!(!configured.contains(Backends::GL)); - assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN)); + let (backends, selector) = configured(Mode::Default, None, true); + assert_eq!(backends, Backends::PRIMARY); + assert!(!backends.contains(Backends::GL)); + assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); + assert!(!selector); } #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + assert_eq!(configured(Mode::Default, Some(backends), true).0, backends); } } + + #[test] + fn fallback_modes_leave_out_the_crashing_backends() { + // DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413). + assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false)); + // Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477). + assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true)); + assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true)); + // Off Windows the default is eframe's own. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false)); + } + + #[test] + fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() { + // first launch + assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false)); + // the default start died in the driver: DX12 next, then software + assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true)); + assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true)); + assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true)); + // a confirmed mode is kept + assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false)); + assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false)); + // nothing left to try: stay + assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false)); + assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false)); + // a mode this platform has not: start over + assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false)); + assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false)); + assert_eq!(launch_mode(None, &[]), (Mode::Default, false)); + } + + #[test] + fn the_marker_round_trips_and_rejects_anything_else() { + for mode in WINDOWS { + for pending in [false, true] { + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + assert_eq!(parse_state(&text), Some((*mode, pending))); + } + } + for bad in [ + "", + "{", + "null", + "[]", + r#"{"mode":"vulkan","pending":true}"#, + r#"{"mode":"dx12"}"#, + r#"{"mode":1,"pending":true}"#, + r#"{"mode":"dx12","pending":"yes"}"#, + ] { + assert_eq!(parse_state(bad), None, "{bad}"); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn begin_marks_the_start_pending_and_confirm_clears_it() { + let dir = temp_dir("marker"); + let file = dir.join(STATE_FILE); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(read_state(&file), Some((Mode::Default, true))); + start.confirm(); + assert_eq!(read_state(&file), Some((Mode::Default, false))); + // a confirmed start is kept on the next launch + assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default); + // ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go) + let next = GraphicsStart::begin(Some(&dir), false); + assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_corrupt_or_huge_marker_starts_with_the_default() { + let dir = temp_dir("corrupt"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] { + let _ = std::fs::write(&file, text); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + // and it is replaced with a well-formed pending marker + assert_eq!(read_state(&file), Some((Mode::Default, true))); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_explicit_backend_leaves_the_marker_alone() { + let dir = temp_dir("explicit"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + let _ = write_state(&file, Mode::Software, true); + let mut start = GraphicsStart::begin(Some(&dir), true); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert_eq!(read_state(&file), Some((Mode::Software, true))); + // no data folder: nothing is written, the default still starts + assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn step_down_walks_the_ladder_and_records_each_attempt() { + let dir = temp_dir("step"); + let file = dir.join(STATE_FILE); + let mut start = GraphicsStart::begin(Some(&dir), false); + for expected in LADDER.iter().skip(1) { + assert!(start.step_down()); + assert_eq!(start.mode(), *expected); + assert_eq!(read_state(&file), Some((*expected, true))); + } + assert!(!start.step_down(), "nothing after the last mode"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_software_selector_only_picks_a_software_adapter() { + if wgpu::Instance::enabled_backend_features().is_empty() { + return; + } + // only the backends software mode uses: no driver this test loads is one the app avoids + let backends = software_backends(cfg!(target_os = "windows")); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + match pick_software(&adapters, None) { + Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()), + Err(why) => { + assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}"); + } + } + assert!(pick_software(&[], None).is_err()); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00fb..86bac7825 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -31,7 +31,6 @@ mod control_server; #[cfg(target_os = "linux")] mod dev_icon; mod file_filters; -#[cfg(any(target_os = "windows", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -44,6 +43,8 @@ use filmcraft_engine::Session; use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir}; use filmcraft_ui_egui::FilmcraftApp; use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id. const APP_ID: &str = "ai.storyteller.filmcraft"; @@ -80,7 +81,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let launch = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -95,7 +96,8 @@ fn main() -> eframe::Result { std::process::exit(2); } }; - let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR)); + let data_root = launch.data_dir.clone().or_else(default_data_dir); + let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR)); log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); // The log file lives next to the crash logs; opened after the arguments, so `--help`, // `--version` and usage errors leave no file behind. Records logged until now go in first. @@ -118,165 +120,35 @@ fn main() -> eframe::Result { // 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(); - register_gpu_frame_renderer(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("FilmCraft") - .with_inner_size([1600.0, 980.0]) - .with_min_inner_size([900.0, 560.0]) - .with_drag_and_drop(true) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - // Started for an agent (`--control`): open without taking the user's keyboard focus. - .with_active(control_port.is_none()) - .with_icon(app_icon()) - // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. - .with_app_id(APP_ID), - event_loop_builder: agent_event_loop(control_port.is_some()), - ..Default::default() + // Which graphics backends and adapter the window starts with: the mode after the last one + // if the previous start died before its window painted (a driver crash), see graphics.rs. + let backend_override = eframe::wgpu::Backends::from_env(); + let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some()); + // The GPU export renderer creates its own wgpu instance with every backend; after a start + // fell back, that would load the driver that failed. Exports then composite on the CPU. + if graphics.mode() == graphics::Mode::Default { + register_gpu_frame_renderer(); + } + let started = loop { + let mut options = native_options(launch.control_port.is_some()); + // 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)); + let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created))); + let app_created = app_created.load(Ordering::Relaxed); + match &result { + // wgpu reported the failure (no adapter, device creation failed) before the app + // existed: try the next mode in this process. + Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => { + log::warn!("graphics start failed: {e}"); + continue; + } + // Closed before the second frame: the window still came up. + Ok(()) if app_created => graphics.confirm(), + _ => {} + } + break result; }; - // Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs). - #[cfg(target_os = "windows")] - let options = { - let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options - }; - let started = eframe::run_native( - "FilmCraft", - options, - Box::new(move |cc| { - let mut session = Session::default(); - if let Some(dir) = data_dir.clone().or_else(default_data_dir) { - let mut cfg = AutosaveConfig::new(dir); - cfg.local_offset = local_offset; - if let Err(e) = session.start_autosave(cfg) { - log::warn!("auto-save and crash recovery unavailable: {e}"); - } - } - // voice-over recording reads the microphone through cpal - session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); - if let Some(p) = project { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { - // Settings ▸ General ▸ At Startup ▸ Open Most Recent - let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); - match recent { - Some(p) => { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } - None => { - let _ = session.execute("file.openDemoProject", json!({})); - } - } - } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { - } else if demo { - let _ = session.execute("file.openDemoProject", json!({})); - } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); - if !media.is_empty() { - let _ = session.execute("file.import", json!({"paths": media})); - } - if recover == Some(true) && !session.recovery_candidates().is_empty() { - let id = session.recovery_candidates()[0].id.clone(); - match session.execute("file.recover", json!({"id": id})) { - Ok(r) => log::info!("recovered {r}"), - Err(e) => log::error!("recovery failed: {e}"), - } - } - let mut app = FilmcraftApp::new(session); - if recover == Some(false) { - app.dialog = None; - } - app.integrated_titlebar = cfg!(target_os = "macos"); - if let Some(rs) = cc.wgpu_render_state.clone() - && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() - { - app.set_wgpu(rs); - } - // Keep device selection available even when the default device is unavailable. - // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). - app.audio = Some(Box::new(audio::CpalOut::new())); - app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })); - // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. - app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save = Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); - app.hooks.pick_folder_at = - Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); - app.hooks.open_path = Some(Box::new(open_path)); - // Help menu, About and Discord links (#642): eframe is built without its `links` - // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher - // that waits for the browser never freezes the UI. - app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); - 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)); - 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). - app.hooks.system_theme = appearance::service(); - app.hooks.raise_without_focus = Some(Box::new(|| { - window_raise::raise_without_focus(); - })); - app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_open_project = Some(Box::new(|| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - #[cfg(target_os = "macos")] - { - let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); - app.command_inbox = Some(rx); - app.hooks.shortcuts_changed = Some(update); - app.ui.show_menu_bar = false; - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - Ok(Box::new(app)) - }), - ); // The window could not be created (no usable GPU / graphics driver, no display): say so // instead of exiting silently, and keep the reason in the crash log. if let Err(e) = &started { @@ -288,6 +160,173 @@ fn main() -> eframe::Result { started } +/// The main window's options; built for each start attempt (they hold a one-shot hook). +fn native_options(agent: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("FilmCraft") + .with_inner_size([1600.0, 980.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + // Started for an agent (`--control`): open without taking the user's keyboard focus. + .with_active(!agent) + .with_icon(app_icon()) + // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. + .with_app_id(APP_ID), + event_loop_builder: agent_event_loop(agent), + ..Default::default() + } +} + +/// Creates the app once eframe has its window and graphics device; sets `created` when it runs, +/// and confirms the graphics mode once two frames have painted. +fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { + let Launch { control_port, files, demo, startup_flag, recover, data_dir } = launch; + Box::new(move |cc| { + created.store(true, Ordering::Relaxed); + let confirmed = AtomicBool::new(false); + cc.egui_ctx.on_end_pass( + "graphics-start", + Arc::new(move |ui| { + if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) { + graphics.confirm(); + } + }), + ); + let mut session = Session::default(); + if let Some(dir) = data_dir.clone().or_else(default_data_dir) { + let mut cfg = AutosaveConfig::new(dir); + cfg.local_offset = local_offset; + if let Err(e) = session.start_autosave(cfg) { + log::warn!("auto-save and crash recovery unavailable: {e}"); + } + } + // voice-over recording reads the microphone through cpal + session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); + let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); + if let Some(p) = project { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { + // Settings ▸ General ▸ At Startup ▸ Open Most Recent + let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); + match recent { + Some(p) => { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } + None => { + let _ = session.execute("file.openDemoProject", json!({})); + } + } + } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { + } else if demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); + if !media.is_empty() { + let _ = session.execute("file.import", json!({"paths": media})); + } + if recover == Some(true) && !session.recovery_candidates().is_empty() { + let id = session.recovery_candidates()[0].id.clone(); + match session.execute("file.recover", json!({"id": id})) { + Ok(r) => log::info!("recovered {r}"), + Err(e) => log::error!("recovery failed: {e}"), + } + } + let mut app = FilmcraftApp::new(session); + if recover == Some(false) { + app.dialog = None; + } + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(rs) = cc.wgpu_render_state.clone() + && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() + { + app.set_wgpu(rs); + } + // Keep device selection available even when the default device is unavailable. + // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). + app.audio = Some(Box::new(audio::CpalOut::new())); + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save = Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_folder_at = + Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); + app.hooks.open_path = Some(Box::new(open_path)); + // Help menu, About and Discord links (#642): eframe is built without its `links` + // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher + // that waits for the browser never freezes the UI. + app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); + 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)); + 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). + app.hooks.system_theme = appearance::service(); + app.hooks.raise_without_focus = Some(Box::new(|| { + window_raise::raise_without_focus(); + })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + #[cfg(target_os = "macos")] + { + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); + app.command_inbox = Some(rx); + app.hooks.shortcuts_changed = Some(update); + app.ui.show_menu_bar = false; + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + Ok(Box::new(app)) + }) +} + /// Files copied in the file manager, else an image, on the system clipboard (Paste, #611). fn clipboard_media() -> Option { let mut clipboard = arboard::Clipboard::new().ok()?; diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..7d3bd17b3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,6 +79,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G4. Crashes at start-up, and no test CI on pull requests +- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a + driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next + launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe) + with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in + the same process. An explicit `WGPU_BACKEND` stays in charge. - **Missing:** a launch that never fails, and CI that runs the gates. - **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468, #661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup @@ -87,9 +92,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `cargo test` or `cargo xtask ci` on a pull request**, so 243 PRs merged since 10-05 were gated by contributors' local runs (main needed "green again after the batch merges" on 10-10). - **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed. -- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, a software / safe-mode - start when the GPU adapter fails, a PR workflow running `cargo xtask ci` on macOS, Windows and - Linux, and a crash-report triage pass. **40–80 h.** +- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode + start when the GPU adapter fails~~ (done, #820; a way back to the default mode from Settings is + still missing: today the marker file is deleted by hand), a PR workflow running `cargo xtask ci` + on macOS, Windows and Linux, and a crash-report triage pass. **40–80 h.** ## G5. Hardware acceleration is partial From d3444534a66f98c883528c2504f8554b9cd103b0 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:04:44 +1100 Subject: [PATCH 012/157] Fix Clear on Close Sequence also dropping File Close (Cmd+W). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Close aliases Close Sequence. The shortcut editor targeted sequence.close while Cmd+W lived on file.close (and Timeline), so Clear left the chord bound. Treat the pair as one shortcut family. Fixes storytold/filmcraft#773 --- crates/engine/src/shortcuts.rs | 29 +++++++++++++++++++++++----- crates/engine/src/shortcuts_tests.rs | 20 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 0c9e47718..b24362989 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -737,7 +737,7 @@ impl Shortcuts { let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) }; let before = self.snapshot(); if !add { - self.bindings.retain(|b| !(b.command == command && b.context() == nb.context())); + self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context())); } let mut reassigned = Vec::new(); if !keep_conflicts { @@ -768,9 +768,19 @@ impl Shortcuts { }; let before = self.snapshot(); let n0 = self.bindings.len(); + let twins = shortcut_twins(command); self.bindings.retain(|b| { - let hit = b.command == command && chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)) && panel.as_ref().is_none_or(|p| &b.panel == p); - !hit + let cmd = same_shortcut_command(&b.command, command); + let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)); + // File ▸ Close and Close Sequence share Cmd+W (app + Timeline). The editor's Clear + // always sends panel Application; treating that as every context for this pair is + // what actually frees the chord (#773). + let panel_ok = match &panel { + None => true, + Some(p) if p.is_none() && twins => true, + Some(p) => &b.panel == p, + }; + !(cmd && key_ok && panel_ok) }); let n = n0 - self.bindings.len(); if n > 0 { @@ -909,12 +919,12 @@ impl Shortcuts { // ---------------------------------------------------------------- queries pub fn for_command(&self, id: &str) -> Vec<&Binding> { - self.bindings.iter().filter(|b| b.command == id).collect() + self.bindings.iter().filter(|b| same_shortcut_command(&b.command, id)).collect() } /// The shortcut menus show: the first application-wide one. pub fn primary(&self, id: &str) -> Option { - self.bindings.iter().find(|b| b.command == id && b.panel.is_none()).map(|b| b.keys.clone()) + self.bindings.iter().find(|b| same_shortcut_command(&b.command, id) && b.panel.is_none()).map(|b| b.keys.clone()) } /// The command a chord runs with `panel` focused (panel shortcuts first). @@ -998,6 +1008,15 @@ fn panel_opt(p: &str) -> Option<&str> { if p.is_empty() { None } else { Some(p) } } +/// `file.close` is File ▸ Close: the same action as `sequence.close` (Close Sequence). +fn shortcut_twins(id: &str) -> bool { + id == "file.close" || id == "sequence.close" +} + +fn same_shortcut_command(bound: &str, target: &str) -> bool { + bound == target || (shortcut_twins(bound) && shortcut_twins(target)) +} + fn check_panel(panel: Option<&str>) -> std::result::Result, String> { match panel { None => Ok(None), diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 9ffd35f4d..c589086d8 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -113,6 +113,26 @@ fn assign_reassigns_conflicts_and_undoes() { assert!(s.shortcuts.for_command("edit.undo").is_empty()); } +#[test] +fn clear_close_sequence_also_clears_file_close() { + // #773: File ▸ Close (`file.close`, Cmd+W) aliases Close Sequence (`sequence.close`). + // The editor's Clear sends panel Application against `sequence.close`, which had only a + // Timeline binding — Cmd+W stayed on `file.close`. + let mut s = Session::default(); + assert_eq!(s.shortcuts.primary("file.close").as_deref(), Some("Cmd+W")); + assert_eq!(s.shortcuts.primary("sequence.close").as_deref(), Some("Cmd+W")); + assert!(s.shortcuts.for_command("sequence.close").iter().any(|b| b.keys == "Cmd+W")); + let r = s.execute("shortcuts.clear", json!({"command": "sequence.close", "panel": "Application"})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 1, "{r}"); + assert!(s.shortcuts.for_command("sequence.close").is_empty(), "{:?}", s.shortcuts.for_command("sequence.close")); + assert!(s.shortcuts.primary("file.close").is_none()); + let w = Chord::parse("Cmd+W").unwrap(); + assert!(s.shortcuts.resolve(&w, None, Platform::current()).is_none()); + assert!(s.shortcuts.resolve(&w, Some("Timeline"), Platform::current()).is_none()); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+W", "add": true})).unwrap(); + assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit"); +} + #[test] fn panel_shortcuts_override_application_ones_with_focus() { let s = Session::default(); From 995053922ed8c18ef50e40c046434fff96a626a1 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:09:36 +1100 Subject: [PATCH 013/157] Show Parametric EQ in Effect Controls on linked A/V clips. Audio effects applied to the video side of a pair now land on the linked audio, and Effect Controls lists that clip's parameters and Custom Setup while the picture is selected. --- crates/engine/src/audio_effects_tests.rs | 16 +++++++++ crates/engine/src/commands.rs | 35 ++++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 20 ++++++++--- crates/ui-egui/tests/clip_audio_ui.rs | 15 +++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/audio_effects_tests.rs b/crates/engine/src/audio_effects_tests.rs index b85463e4c..e3992640a 100644 --- a/crates/engine/src/audio_effects_tests.rs +++ b/crates/engine/src/audio_effects_tests.rs @@ -111,3 +111,19 @@ fn shared_names_resolve_by_track_kind() { let it = s.active_sequence().unwrap().find_item(ClipId(v)).unwrap().1.clone(); assert!(it.effects.iter().any(|e| e.effect == "invert")); } + +/// #696: applying an audio effect to the video side of an A/V pair lands on the linked audio. +#[test] +fn audio_effect_on_video_clip_applies_to_linked_audio() { + let mut s = demo(); + let seq = s.active_sequence().unwrap(); + let v = seq.video_tracks[0].items[0].clone(); + let a = seq.audio_tracks[0].items.iter().find(|i| i.link == v.link).expect("linked audio").clone(); + let n0 = a.effects.len(); + s.execute("effects.apply", json!({"clips": [v.id.0], "effect": "parametric_eq"})).unwrap(); + let a = s.active_sequence().unwrap().find_item(a.id).unwrap().1.clone(); + let v = s.active_sequence().unwrap().find_item(v.id).unwrap().1.clone(); + assert_eq!(a.effects.len(), n0 + 1); + assert_eq!(a.effects.last().unwrap().effect, "parametric_eq"); + assert!(v.effects.iter().all(|e| e.effect != "parametric_eq"), "EQ stays off the picture clip"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 72a32cb8d..067ed2c7e 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -411,6 +411,35 @@ pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit: Ok(()) } +/// Clips an audio effect should land on: audio ids as given, plus the linked audio of any video +/// clip in the list (so applying Parametric EQ to an A/V pair hits the sound). +pub fn audio_effect_targets(s: &Session, clips: &[ClipId]) -> Vec { + let Some(seq) = s.active_sequence() else { return clips.to_vec() }; + let mut out = Vec::new(); + for c in clips { + let Some((tid, it)) = seq.find_item(*c) else { continue }; + let kind = seq.track(tid).map(|t| t.kind); + if kind == Some(TrackKind::Audio) { + if !out.contains(c) { + out.push(*c); + } + continue; + } + if kind != Some(TrackKind::Video) { + continue; + } + let Some(link) = it.link else { continue }; + for t in &seq.audio_tracks { + for i in &t.items { + if i.link == Some(link) && !out.contains(&i.id) { + out.push(i.id); + } + } + } + } + out +} + /// Expand a clip selection with linked partners (when linked selection is on). pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { let Some(seq) = s.active_sequence() else { return clips.to_vec() }; @@ -2558,6 +2587,12 @@ fn build() -> Vec { return Err(bad("effects.apply", "select clips first")); } let is_audio = def.kind == filmcraft_project::EffectKind::Audio; + // Audio effects belong on audio clips. A video clip in an A/V pair is retargeted to + // its linked audio so double-click / drop on the picture still applies Parametric EQ. + let clips = if is_audio { audio_effect_targets(s, &clips) } else { clips }; + if clips.is_empty() { + return Err(bad("effects.apply", "audio effects need an audio clip (or a video clip with linked audio)")); + } let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); let label = format!("Apply {}", def.name); s.edit_sequence(&label, |q, _, _| { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6b071c4ca..e83560506 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -62,7 +62,8 @@ fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> { selected_clips(app).into_iter().next() } -/// The selected clip (video first) and, for a video clip, the selected audio linked to it. +/// The selected clip (video first) and the audio linked to it when that audio is selected or +/// linked selection is on — so clip audio FX (Parametric EQ, Custom Setup) stay visible on A/V pairs. fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { let Some(seq) = app.session.active_sequence() else { return Vec::new() }; let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app @@ -77,10 +78,19 @@ fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { .collect(); let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() }; let mut out = vec![(clip, it.clone(), kind)]; - if kind == TrackKind::Video - && let Some(&(ac, ai, ak)) = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link) - { - out.push((ac, ai.clone(), ak)); + if kind == TrackKind::Video { + let linked = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link).copied().or_else(|| { + if !app.session.state.linked_selection { + return None; + } + let link = it.link?; + seq.audio_tracks.iter().find_map(|t| { + t.items.iter().find(|i| i.link == Some(link)).map(|i| (i.id, i, TrackKind::Audio)) + }) + }); + if let Some((ac, ai, ak)) = linked { + out.push((ac, ai.clone(), ak)); + } } out } diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs index 3ca87a33b..f7f401140 100644 --- a/crates/ui-egui/tests/clip_audio_ui.rs +++ b/crates/ui-egui/tests/clip_audio_ui.rs @@ -143,6 +143,21 @@ fn effect_controls_show_the_audio_of_a_video_clip() { d.rect("effectControls.effect.motion"); } +/// #696: Parametric EQ on a linked A/V pair shows in Effect Controls (and Custom Setup) while the +/// video clip is selected — Effect Controls used to keep only the picture clip. +#[test] +fn effect_controls_show_audio_fx_on_a_linked_pair() { + let (mut d, pair) = Driver::demo(&["EffectControls"]); + d.exec("timeline.select", json!({"clips": [pair.video]})); + d.exec("effects.apply", json!({"clips": [pair.video], "effect": "parametric_eq"})); + assert_eq!(d.item(pair.audio).effects.last().map(|e| e.effect.as_str()), Some("parametric_eq")); + for fx in ["motion", "opacity", "time_remap", "volume", "channel_volume", "panner"] { + d.click(&format!("effectControls.effect.{fx}")); + } + d.rect("effectControls.effect.parametric_eq"); + d.rect("effectControls.effect.parametric_eq.edit"); +} + #[test] fn properties_show_the_audio_of_a_video_clip() { let (mut d, pair) = Driver::demo(&["Properties"]); From 03bf8b1f33b099d0638e861bb47397cfb314d262 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:09:44 +1100 Subject: [PATCH 014/157] Fix OS drops on an empty Timeline creating a sequence. Dragging files from the explorer onto the Timeline imported them into the Project bin but never placed them when no sequence was open, because only the clip-area interact path consumed pending_timeline_drop. The empty Timeline now creates a sequence from the first imported item and places any further items after it, matching the existing Project-panel drop. --- crates/ui-egui/src/panels/timeline.rs | 20 +++++++++++++ crates/ui-egui/tests/timeline_drop_ui.rs | 37 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ca4fab0dc..adfed455d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -627,6 +627,26 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { { let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); } + // OS files dropped on an empty Timeline: import already happened; create a sequence from the first item + if let Some((items, p)) = app.pending_timeline_drop.clone() + && rect.contains(p) + && let Some((first, rest)) = items.split_first() + { + app.pending_timeline_drop = None; + if let Err(e) = app.session.execute("file.newSequence", json!({"fromItem": first.0})) { + app.ui.status = e.to_string(); + } else { + let mut t = app.session.project.item(*first).map(|i| i.duration()).unwrap_or(Tick::ZERO); + for item in rest { + let params = json!({"item": item.0, "time": t.0, "insert": false}); + if let Err(e) = app.session.execute("timeline.place", params) { + app.ui.status = e.to_string(); + } else { + t = Tick(t.0.saturating_add(app.session.project.item(*item).map(|i| i.duration().0.max(0)).unwrap_or(0))); + } + } + } + } } /// The through-edit mark: two small triangles pointing at the cut, centred on the track, with a diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9fa..3ad0510cc 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -143,6 +143,43 @@ fn tone_dropped_on_a_video_row_goes_to_the_audio_track_without_error() { assert_eq!(clip_counts(&h), (0, 1), "status: {}", h.state().ui.status); } +/// Empty project: no sequence yet. Dropping on the empty Timeline must import *and* make a sequence. +fn empty_harness() -> Harness<'static, FilmcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(Session::default())); + for _ in 0..4 { + step(&mut h); + } + h +} + +fn drop_on_empty_timeline(h: &mut Harness<'static, FilmcraftApp>, path: &Path) { + let panel = control_rect(h, "panel.Timeline"); + pointer_to(h, panel.center()); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf()))); + step(h); + h.input_mut().dropped_files.clear(); + step(h); +} + +#[test] +fn still_dropped_on_empty_timeline_creates_a_sequence() { + let mut h = empty_harness(); + assert!(h.state().session.active_sequence().is_none()); + drop_on_empty_timeline(&mut h, &still("drop_still_empty_timeline.png")); + assert_eq!(h.state().session.project.items.len(), 2, "still plus the new sequence"); + assert!(h.state().session.active_sequence().is_some(), "status: {}", h.state().ui.status); + assert_eq!(clip_counts(&h), (1, 0), "status: {}", h.state().ui.status); +} + +#[test] +fn with_drop_import_off_empty_timeline_does_not_create_a_sequence() { + let mut h = empty_harness(); + h.state_mut().session.prefs.timeline.drop_import_to_timeline = false; + drop_on_empty_timeline(&mut h, &still("drop_still_empty_pref_off.png")); + assert_eq!(h.state().session.project.items.len(), 1, "imported to the bin only"); + assert!(h.state().session.active_sequence().is_none()); +} + #[test] fn with_drop_import_off_the_file_is_only_imported() { let mut h = harness(); From f8b37e146373ea5e896d69e55b96a22c76efae6d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:15:53 +0200 Subject: [PATCH 015/157] #831: cargo xtask parity measures effects and transitions (--area) Problem: `cargo xtask parity` (#781, #782) measured only the menu bar; the Effects panel's effects and transitions had no measured parity figure (docs/gaps.md G7). Fix: `--area effects|transitions` compares the catalogue the engine's effect definitions list in the Effects panel (new `effect_catalog` example in filmcraft-project, the panel's own top-folder filter) with a reference of folder paths or bare names (default the local, never committed plan/premiere/.json). Same report, caps and `--min` as menus; placement by folder; Presets / Lumetri Presets and the other area's items are out of scope, so one panel snapshot serves both areas. `--area menus` (default) is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/project/examples/effect_catalog.rs | 34 ++ docs/gaps.md | 9 +- docs/target-app-parity.md | 4 +- xtask/src/main.rs | 9 +- xtask/src/parity.rs | 403 +++++++++++++++++----- 5 files changed, 370 insertions(+), 89 deletions(-) create mode 100644 crates/project/examples/effect_catalog.rs diff --git a/crates/project/examples/effect_catalog.rs b/crates/project/examples/effect_catalog.rs new file mode 100644 index 000000000..12f17fe78 --- /dev/null +++ b/crates/project/examples/effect_catalog.rs @@ -0,0 +1,34 @@ +//! Print FilmCraft's effect and transition catalog as the Effects panel lists it, as JSON: one +//! object per item with its `id`, display `name`, `kind` (`Video`, `Audio`, `VideoTransition`, +//! `AudioTransition`) and Effects-panel folder `path` (e.g. `["Video Effects", "Blur & Sharpen"]`). +//! Items with no Effects-panel folder (intrinsic Motion / Opacity / Volume…, graphic layers) and +//! the hidden obsolete transitions are left out, as in the panel (the same top-level folder filter +//! as `panels/effects.rs`). `cargo xtask parity --area effects|transitions` reads this to measure +//! coverage against a reference list. +//! +//! ```sh +//! cargo run -p filmcraft-project --example effect_catalog +//! ``` + +use std::process::ExitCode; + +use filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; +use serde_json::json; + +fn main() -> ExitCode { + let items: Vec = filmcraft_project::effect_defs() + .iter() + .filter(|d| d.category.first().is_some_and(|top| EFFECT_TOP_FOLDERS.contains(top))) + .map(|d| json!({"id": d.id, "name": d.name, "kind": format!("{:?}", d.kind), "path": d.category})) + .collect(); + match serde_json::to_string_pretty(&items) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("effect_catalog: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..015fccae3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -127,8 +127,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `plan/premiere/menus.json`; presence and placement reported separately (same path, elsewhere, missing), app / Help / recent / account / macOS items out of scope, a markdown checklist, and `--min` to fail below a presence threshold. Without a reference it prints our inventory only. -- **Missing:** the other areas (commands, effects, transitions, panels, preferences, formats), - fidelity tiers (exact, approximate, stub), a CI job, and a committed `docs/parity-checklist.md`. + `--area effects` and `--area transitions` (#831) measure the Effects panel the same way: the + effects and transitions the engine's effect definitions list (`examples/effect_catalog.rs` in + `filmcraft-project`) against a reference of folder paths or bare names (default the local + `plan/premiere/effects.json` / `transitions.json`), placement by folder, Presets and the other + area out of scope. +- **Missing:** the other areas (commands, panels, preferences, formats), fidelity tiers (exact, + approximate, stub), a CI job, and a committed `docs/parity-checklist.md`. - **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script; it has not yet been re-measured with the tool. - **Estimate:** 8–17 h left (the menu snapshot is local-only, so CI needs a committed, Adobe-free diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 13bb67a4d..8c37bb0ff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -51,7 +51,9 @@ issues now document. That is new evidence, not drift: see [Calibration](#calibra - **Effects (measured by tests).** `premiere_26_video_effects_catalogue`, `premiere_audio_set_is_complete_and_foldered` and `premiere_26_transition_tree` check the catalogue against Premiere's Effects panel: video effects 93/93 (+Legacy, Obsolete), audio - effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3. + effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3. `cargo xtask + parity --area effects|transitions` measures the shipped catalogue against a reference list too + (presence and folder placement, [G7](gaps.md#g7-no-measured-parity-tool-in-ci)). - **The installed app (file names and listings only, per [AGENTS.md](../AGENTS.md) §2; nothing inside the bundle was opened or read).** `Contents/Resources/*.lproj`: de, en, es, fr, it, ja, ko, pt, ru, zh_CN (10 UI languages). `Contents/MediaIO/systempresets/` folder names are diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1ea276afe..9f61b7c39 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,9 +11,10 @@ //! - `fixtures [crate…]`: pre-generate the ffmpeg fixture matrix of the oracle tests (runs each //! crate's ignored `generate_fixtures` test, i.e. the same generators the tests use) and print //! what was made, reused or skipped. -//! - `parity [--reference FILE] [--out FILE] [--min PERCENT]`: measured menu parity of our shipped -//! menu bar against a reference list of menu paths (default: the local `plan/premiere/menus.json`), -//! presence and placement reported separately (`parity.rs`, docs/gaps.md G7). +//! - `parity [--area menus|effects|transitions] [--reference FILE] [--out FILE] [--min PERCENT]`: +//! measured parity of our shipped menu bar, or of the effects / transitions the Effects panel +//! lists, against a reference list (default: the local `plan/premiere/.json`), presence and +//! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. @@ -500,7 +501,7 @@ fn main() -> ExitCode { ico::run(&rest.iter().map(String::as_str).collect::>()) } _ => Err( - "usage: cargo xtask " + "usage: cargo xtask " .into(), ), }; diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index e11a570fd..774a0a76a 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,20 +1,31 @@ -//! `cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT]`: measured -//! menu parity (docs/gaps.md G7). +//! `cargo xtask parity [--area menus|effects|transitions] [--reference FILE] [--ours FILE] +//! [--out FILE] [--min PERCENT]`: measured parity (docs/gaps.md G7). //! -//! Compares FilmCraft's shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`, or a saved dump -//! of it given with `--ours`) with a reference list of menu paths, and reports presence and -//! placement separately: a reference item is *same place* when we have its label at the same menu -//! path, *elsewhere* when we have the label under another path, and *missing* otherwise. A label -//! match is presence only, not behaviour. +//! Compares one area of what FilmCraft ships with a reference list, and reports presence and +//! placement separately: a reference item is *same place* when we have its label at the same path, +//! *elsewhere* when we have the label under another path, and *missing* otherwise. A label match is +//! presence only, not behaviour. The areas: //! -//! The reference defaults to the maintainers' local menu snapshot `plan/premiere/menus.json` -//! (gitignored, never committed). Without one the tool prints our own menu inventory and measures -//! nothing. The reference may be any JSON of menu paths: an array of paths (`["File", "New", -//! "Sequence…"]`) or of `"File > New > Sequence…"` strings, a tree of nodes (`{"name": …, -//! "children" | "items" | "submenu": […]}`), or nested objects keyed by label. App, Apple and Help -//! menus, recent-file lists, account / cloud items and macOS system items are counted out of scope. +//! - `menus` (default): the shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`); paths are +//! menu paths. App, Apple and Help menus, recent-file lists, account / cloud items and macOS +//! system items are counted out of scope. +//! - `effects` / `transitions`: the effects (video and audio) or transitions (video and audio) the +//! Effects panel lists, from the engine's effect definitions +//! (`crates/project/examples/effect_catalog.rs`); paths are Effects-panel folders +//! (`Video Effects > Blur & Sharpen > Gaussian Blur`). A reference entry may also be a bare name, +//! which has no folder and so is matched by name alone. Presets and Lumetri Presets are out of +//! scope, and so are the other area's items (a folder named `… Transitions`), so one reference +//! of the whole Effects panel serves both areas. //! -//! The report goes to `target/parity/parity-checklist.md` (or `--out`); `--min` fails when presence +//! `--ours` reads a saved dump of our side instead of running the example. The reference defaults +//! to the maintainers' local snapshot `plan/premiere/.json` (gitignored, never committed). +//! Without one the tool prints our own inventory and measures nothing. The reference may be any +//! JSON of paths: an array of paths (`["File", "New", "Sequence…"]`) or of +//! `"File > New > Sequence…"` strings, a tree of nodes (`{"name": …, "children" | "items" | +//! "submenu": […]}`), or nested objects keyed by label. +//! +//! The report goes to `target/parity/parity-checklist.md` for menus and +//! `target/parity/parity-.md` for the other areas (or `--out`); `--min` fails when presence //! is below the given percentage. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -30,10 +41,99 @@ const MAX_ITEMS: usize = 20_000; /// Keys holding a node's label, and its children. const LABEL_KEYS: [&str; 3] = ["name", "title", "label"]; const CHILD_KEYS: [&str; 6] = ["children", "items", "submenu", "menu", "menuItems", "menu_items"]; -/// Keys of a wrapper object around the whole menu bar. -const WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; +/// Keys of a wrapper object around the whole menu bar, or the whole effect list. +const MENU_WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; +const EFFECT_WRAPPER_KEYS: [&str; 4] = ["effects", "transitions", "folders", "items"]; + +/// What a parity run measures. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Area { + Menus, + Effects, + Transitions, +} + +impl Area { + fn parse(s: &str) -> Result { + match s { + "menus" => Ok(Area::Menus), + "effects" => Ok(Area::Effects), + "transitions" => Ok(Area::Transitions), + other => Err(format!("--area {other}: expected menus, effects or transitions")), + } + } + + fn name(self) -> &'static str { + match self { + Area::Menus => "menus", + Area::Effects => "effects", + Area::Transitions => "transitions", + } + } + + /// Our side, as the report names it. + fn ours(self) -> &'static str { + match self { + Area::Menus => "shipped menu bar", + Area::Effects => "Effects panel effects", + Area::Transitions => "Effects panel transitions", + } + } + + /// What a path is made of: "menu path" or "folder". + fn place(self) -> &'static str { + match self { + Area::Menus => "menu path", + Area::Effects | Area::Transitions => "folder", + } + } + + fn out_file(self) -> String { + match self { + Area::Menus => "parity-checklist.md".into(), + other => format!("parity-{}.md", other.name()), + } + } + + /// Fewest path parts a reference item has: a menu item needs its menu, an effect may be a + /// bare name. + fn min_len(self) -> usize { + match self { + Area::Menus => 2, + Area::Effects | Area::Transitions => 1, + } + } + + fn wrapper_keys(self) -> &'static [&'static str] { + match self { + Area::Menus => &MENU_WRAPPER_KEYS, + Area::Effects | Area::Transitions => &EFFECT_WRAPPER_KEYS, + } + } + + /// The effect kinds (`EffectKind` names in the catalog dump) this area counts. + fn kinds(self) -> &'static [&'static str] { + match self { + Area::Menus => &[], + Area::Effects => &["Video", "Audio"], + Area::Transitions => &["VideoTransition", "AudioTransition"], + } + } + + /// The group a path is tallied under in the report: the top-level menu, or the folder path. + fn group(self, path: &[String]) -> String { + match self { + Area::Menus => path.first().cloned().unwrap_or_default(), + Area::Effects | Area::Transitions => match path.split_last() { + Some((_, folders)) if !folders.is_empty() => show(folders), + _ => "(no folder)".into(), + }, + } + } +} struct Args { + area: Area, reference: Option, ours: Option, out: Option, @@ -41,11 +141,12 @@ struct Args { } fn parse_args(args: &[&str]) -> Result { - let mut a = Args { reference: None, ours: None, out: None, min: None }; + let mut a = Args { area: Area::Menus, reference: None, ours: None, out: None, min: None }; let mut it = args.iter(); while let Some(flag) = it.next() { let mut value = || it.next().map(|v| v.to_string()).ok_or_else(|| format!("{flag} needs a value")); match *flag { + "--area" => a.area = Area::parse(&value()?)?, "--reference" => a.reference = Some(value()?.into()), "--ours" => a.ours = Some(value()?.into()), "--out" => a.out = Some(value()?.into()), @@ -59,7 +160,7 @@ fn parse_args(args: &[&str]) -> Result { } other => { return Err(format!( - "parity: unknown argument {other} (usage: cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" + "parity: unknown argument {other} (usage: cargo xtask parity [--area menus|effects|transitions] [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" )); } } @@ -69,30 +170,36 @@ fn parse_args(args: &[&str]) -> Result { pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { let args = parse_args(args)?; + let area = args.area; let ours_json = match &args.ours { Some(p) => std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?, - None => dump_ours()?, + None => dump_ours(area)?, }; - let ours = our_items(&serde_json::from_str(&ours_json).map_err(|e| format!("our menu dump: {e}"))?)?; - let default_ref = root.join("plan/premiere/menus.json"); + let ours_value: Value = serde_json::from_str(&ours_json).map_err(|e| format!("our {} dump: {e}", area.name()))?; + let ours = match area { + Area::Menus => our_items(&ours_value)?, + Area::Effects | Area::Transitions => our_catalog(&ours_value, area)?, + }; + let default_ref = root.join(format!("plan/premiere/{}.json", area.name())); let reference = match &args.reference { Some(p) => Some(p.clone()), None => default_ref.exists().then_some(default_ref), }; let Some(reference) = reference else { - print!("{}", inventory(&ours)); - println!("\nno reference menu list (plan/premiere/menus.json, or --reference FILE): menu parity not measured"); + print!("{}", inventory(area, &ours)); + let a = area.name(); + println!("\nno reference list (plan/premiere/{a}.json, or --reference FILE): {a} parity not measured"); return match args.min { - Some(_) => Err("--min needs a reference menu list".into()), + Some(_) => Err("--min needs a reference list".into()), None => Ok(()), }; }; let text = std::fs::read_to_string(&reference).map_err(|e| format!("{}: {e}", reference.display()))?; let value: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", reference.display()))?; - let paths = reference_paths(&value)?; + let paths = reference_paths(&value, area)?; let name = reference.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let report = compare(&paths, &ours); - let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity/parity-checklist.md")); + let report = compare(area, &paths, &ours); + let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity").join(area.out_file())); if let Some(dir) = out.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } @@ -100,20 +207,25 @@ pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { println!("{}", report.summary()); println!("report: {}", out.display()); match args.min { - Some(min) if report.presence() < min => Err(format!("menu presence {:.1}% is below --min {min}%", report.presence())), + Some(min) if report.presence() < min => Err(format!("{} presence {:.1}% is below --min {min}%", area.name(), report.presence())), _ => Ok(()), } } -/// Our menu bar as JSON, from the `menu_tree` example. -fn dump_ours() -> Result { +/// Our side as JSON: the menu bar from the `menu_tree` example, or the Effects panel catalog from +/// the `effect_catalog` example. +fn dump_ours(area: Area) -> Result { + let (package, example) = match area { + Area::Menus => ("filmcraft-ui-egui", "menu_tree"), + Area::Effects | Area::Transitions => ("filmcraft-project", "effect_catalog"), + }; let mut cmd = Command::new(env!("CARGO")); - cmd.args(["run", "-q", "-p", "filmcraft-ui-egui", "--example", "menu_tree"]).stderr(Stdio::inherit()); - let out = cmd.output().map_err(|e| format!("menu_tree: {e}"))?; + cmd.args(["run", "-q", "-p", package, "--example", example]).stderr(Stdio::inherit()); + let out = cmd.output().map_err(|e| format!("{example}: {e}"))?; if !out.status.success() { - return Err(format!("menu_tree failed: {}", out.status)); + return Err(format!("{example} failed: {}", out.status)); } - String::from_utf8(out.stdout).map_err(|e| format!("menu_tree: {e}")) + String::from_utf8(out.stdout).map_err(|e| format!("{example}: {e}")) } /// Full paths (menu path + label) of our menu items, from the `menu_tree` dump. @@ -130,32 +242,47 @@ fn our_items(v: &Value) -> Result>, String> { .collect()) } -/// Item counts per top-level menu, for runs without a reference. -fn inventory(ours: &[Vec]) -> String { - let mut per: BTreeMap<&str, usize> = BTreeMap::new(); +/// Full paths (Effects-panel folders + name) of the catalog items of `area`, from the +/// `effect_catalog` dump. +fn our_catalog(v: &Value, area: Area) -> Result>, String> { + let items = v.as_array().ok_or("our effect catalog dump is not an array")?; + Ok(items + .iter() + .filter(|it| it.get("kind").and_then(Value::as_str).is_some_and(|k| area.kinds().contains(&k))) + .filter_map(|it| { + let name = it.get("name")?.as_str()?; + let mut path: Vec = it.get("path")?.as_array()?.iter().filter_map(|p| p.as_str().map(str::to_string)).collect(); + path.push(name.to_string()); + Some(path) + }) + .collect()) +} + +/// Item counts per group (top-level menu, or folder), for runs without a reference. +fn inventory(area: Area, ours: &[Vec]) -> String { + let mut per: BTreeMap = BTreeMap::new(); for p in ours { - if let Some(top) = p.first() { - *per.entry(top.as_str()).or_default() += 1; - } + *per.entry(area.group(p)).or_default() += 1; } - let mut s = format!("FilmCraft menu bar: {} items\n", ours.len()); - for (top, n) in per { - s.push_str(&format!(" {top:<22} {n}\n")); + let mut s = format!("FilmCraft {}: {} items\n", area.ours(), ours.len()); + let width = per.keys().map(|g| g.chars().count()).max().unwrap_or(0).max(22); + for (group, n) in per { + s.push_str(&format!(" {group: Result>, String> { +/// Leaf paths (top-level menu or folder first, item label last) of a reference file. +fn reference_paths(v: &Value, area: Area) -> Result>, String> { let mut root = v; if let Value::Object(o) = v && label_of(o).is_none() - && let Some(inner) = WRAPPER_KEYS.iter().find_map(|k| o.get(*k).filter(|c| c.is_array() || c.is_object())) + && let Some(inner) = area.wrapper_keys().iter().find_map(|k| o.get(*k).filter(|c| c.is_array() || c.is_object())) { root = inner; } let mut out = Vec::new(); - walk(root, &[], 0, &mut out)?; + walk(root, &[], 0, area.min_len(), &mut out)?; Ok(out) } @@ -169,16 +296,16 @@ fn is_separator(label: &str) -> bool { l.is_empty() || l.chars().all(|c| matches!(c, '-' | '—' | '–' | '_')) || l.eq_ignore_ascii_case("missing value") || l.eq_ignore_ascii_case("separator") } -fn push_leaf(prefix: &[String], label: &str, out: &mut Vec>) -> Result<(), String> { +fn push_leaf(prefix: &[String], label: &str, min: usize, out: &mut Vec>) -> Result<(), String> { if is_separator(label) || prefix.iter().any(|p| is_separator(p)) { return Ok(()); } if out.len() >= MAX_ITEMS { - return Err(format!("reference has more than {MAX_ITEMS} menu items")); + return Err(format!("reference has more than {MAX_ITEMS} items")); } let mut path = prefix.to_vec(); path.push(label.trim().to_string()); - if path.len() >= 2 { + if path.len() >= min.max(1) { out.push(path); } Ok(()) @@ -189,9 +316,9 @@ fn split_path(s: &str) -> Option> { (parts.len() >= 2 && parts.iter().all(|p| !p.is_empty())).then_some(parts) } -fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) -> Result<(), String> { +fn walk(v: &Value, prefix: &[String], depth: usize, min: usize, out: &mut Vec>) -> Result<(), String> { if depth > MAX_DEPTH { - return Err(format!("reference menus are nested deeper than {MAX_DEPTH} levels")); + return Err(format!("reference is nested deeper than {MAX_DEPTH} levels")); } let with = |label: &str| { let mut p = prefix.to_vec(); @@ -201,10 +328,10 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) match v { Value::String(s) => match split_path(s).filter(|_| prefix.is_empty()) { Some(parts) => match parts.split_last() { - Some((label, menus)) => push_leaf(menus, label, out), + Some((label, menus)) => push_leaf(menus, label, min, out), None => Ok(()), }, - None => push_leaf(prefix, s, out), + None => push_leaf(prefix, s, min, out), }, Value::Array(a) => { for el in a { @@ -212,10 +339,10 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) Value::Array(path) if !path.is_empty() && path.iter().all(Value::is_string) => { let parts: Vec = prefix.iter().cloned().chain(path.iter().filter_map(|p| p.as_str().map(str::to_string))).collect(); if let Some((label, menus)) = parts.split_last() { - push_leaf(menus, label, out)?; + push_leaf(menus, label, min, out)?; } } - _ => walk(el, prefix, depth + 1, out)?, + _ => walk(el, prefix, depth + 1, min, out)?, } } Ok(()) @@ -224,17 +351,17 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) Some(label) => { let children = CHILD_KEYS.iter().find_map(|k| o.get(*k).filter(|c| has_entries(c))); match children { - Some(c) if !is_separator(label) => walk(c, &with(label), depth + 1, out), + Some(c) if !is_separator(label) => walk(c, &with(label), depth + 1, min, out), Some(_) => Ok(()), - None => push_leaf(prefix, label, out), + None => push_leaf(prefix, label, min, out), } } None => { for (k, val) in o { if has_entries(val) { - walk(val, &with(k), depth + 1, out)?; + walk(val, &with(k), depth + 1, min, out)?; } else { - push_leaf(prefix, k, out)?; + push_leaf(prefix, k, min, out)?; } } Ok(()) @@ -259,7 +386,26 @@ fn norm(label: &str) -> String { } /// Why a reference item is not counted, if it isn't. -fn out_of_scope(path: &[String]) -> Option<&'static str> { +fn out_of_scope(area: Area, path: &[String]) -> Option<&'static str> { + match area { + Area::Menus => menu_out_of_scope(path), + Area::Effects | Area::Transitions => { + let top = norm(path.first()?); + if path.len() >= 2 && (top == "presets" || top == "lumetri presets") { + return Some("presets"); + } + let folders = path.get(..path.len().saturating_sub(1)).unwrap_or_default(); + let transition = folders.iter().any(|f| norm(f).ends_with("transitions")); + match area { + Area::Effects if transition => Some("transitions (--area transitions)"), + Area::Transitions if !folders.is_empty() && !transition => Some("effects (--area effects)"), + _ => None, + } + } + } +} + +fn menu_out_of_scope(path: &[String]) -> Option<&'static str> { let (top, label) = (norm(path.first()?), norm(path.last()?)); if top == "apple" || top == "help" || top.contains("premiere") { return Some("app, Apple and Help menus"); @@ -284,13 +430,14 @@ enum Placement { } struct Report { + area: Area, /// (reference path, where it is in FilmCraft). items: Vec<(Vec, Placement)>, out_of_scope: BTreeMap<&'static str, usize>, ours_only: Vec>, } -fn compare(reference: &[Vec], ours: &[Vec]) -> Report { +fn compare(area: Area, reference: &[Vec], ours: &[Vec]) -> Report { let key = |p: &[String]| p.iter().map(|s| norm(s)).collect::>(); let our_paths: HashSet> = ours.iter().map(|p| key(p)).collect(); let mut by_label: HashMap> = HashMap::new(); @@ -299,18 +446,19 @@ fn compare(reference: &[Vec], ours: &[Vec]) -> Report { by_label.entry(norm(l)).or_insert(p); } } - let mut report = Report { items: Vec::new(), out_of_scope: BTreeMap::new(), ours_only: Vec::new() }; + let mut report = Report { area, items: Vec::new(), out_of_scope: BTreeMap::new(), ours_only: Vec::new() }; let mut seen = HashSet::new(); for p in reference { if !seen.insert(key(p)) { continue; } - if let Some(why) = out_of_scope(p) { + if let Some(why) = out_of_scope(area, p) { *report.out_of_scope.entry(why).or_default() += 1; continue; } let label = p.last().map(|l| norm(l)).unwrap_or_default(); - let placement = if our_paths.contains(&key(p)) { + // a bare name (no folder) has no place to compare: found is all it can be + let placement = if our_paths.contains(&key(p)) || (p.len() == 1 && by_label.contains_key(&label)) { Placement::Same } else if let Some(at) = by_label.get(&label) { Placement::Elsewhere((*at).clone()) @@ -349,7 +497,8 @@ impl Report { let (same, elsewhere, missing) = self.counts(); let n = self.items.len(); format!( - "menus: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + "{}: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + self.area.name(), same + elsewhere, self.presence(), pct(same, n), @@ -360,17 +509,30 @@ impl Report { fn markdown(&self, reference: &str, ours: usize) -> String { let (same, elsewhere, missing) = self.counts(); let n = self.items.len(); - let mut s = String::from("# Menu parity checklist\n\n"); + let area = self.area; + let (title, column) = match area { + Area::Menus => ("Menu", "Menu"), + Area::Effects => ("Effect", "Folder"), + Area::Transitions => ("Transition", "Folder"), + }; + let mut s = format!("# {title} parity checklist\n\n"); s.push_str(&format!( - "Generated by `cargo xtask parity` from `{reference}` ({n} items in scope, {} out of scope) against FilmCraft's \ - shipped menu bar ({ours} items). *Same place*: we have the label at the same menu path; *elsewhere*: under \ - another path; *missing*: nowhere. A label match is presence only, not behaviour.\n\n", - self.out_of_scope.values().sum::() + "Generated by `cargo xtask parity --area {}` from `{reference}` ({n} items in scope, {} out of scope) against \ + FilmCraft's {} ({ours} items). *Same place*: we have the label at the same {place}; *elsewhere*: under \ + another {place}; *missing*: nowhere. A label match is presence only, not behaviour.{}\n\n", + area.name(), + self.out_of_scope.values().sum::(), + area.ours(), + match area { + Area::Menus => "", + Area::Effects | Area::Transitions => " A bare name with no folder in the reference is matched by name alone.", + }, + place = area.place(), )); - s.push_str("| Menu | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n"); - let mut per: Vec<(&str, [usize; 3])> = Vec::new(); + s.push_str(&format!("| {column} | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n")); + let mut per: Vec<(String, [usize; 3])> = Vec::new(); for (p, pl) in &self.items { - let top = p.first().map(String::as_str).unwrap_or_default(); + let top = area.group(p); let i = match per.iter().position(|(t, _)| *t == top) { Some(i) => i, None => { @@ -440,7 +602,7 @@ mod tests { json!({"Edit": {"Undo": "Cmd+Z"}, "File": {"New": ["Sequence…"], "-": null, "Save": null}}), ]; for f in forms { - let mut got = reference_paths(&f).unwrap(); + let mut got = reference_paths(&f, Area::Menus).unwrap(); got.sort(); let mut w = want.clone(); w.sort(); @@ -454,11 +616,15 @@ mod tests { for _ in 0..200 { v = json!({ "name": "m", "children": [v] }); } - assert!(reference_paths(&v).unwrap_err().contains("nested deeper")); - let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); - assert!(reference_paths(&json!(many)).unwrap_err().contains("more than")); + for area in [Area::Menus, Area::Effects] { + assert!(reference_paths(&v, area).unwrap_err().contains("nested deeper")); + let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); + assert!(reference_paths(&json!(many), area).unwrap_err().contains("more than")); + let bare: Vec = (0..MAX_ITEMS + 1).map(|i| format!("Effect {i}")).collect(); + assert!(area == Area::Menus || reference_paths(&json!(bare), area).unwrap_err().contains("more than")); + } // odd values are skipped, not trusted - assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]])).unwrap(), Vec::>::new()); + assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]]), Area::Menus).unwrap(), Vec::>::new()); } #[test] @@ -474,7 +640,7 @@ mod tests { &["Edit", "Spelling"], ]); let ours = paths(&[&["File", "New", "Sequence…"], &["File", "Export", "Export Frame"], &["Edit", "Find and Replace…"], &["Clip", "Nest…"]]); - let r = compare(&reference, &ours); + let r = compare(Area::Menus, &reference, &ours); assert_eq!(r.counts(), (2, 1, 1), "duplicates count once; out-of-scope items are not counted"); assert!((r.presence() - 75.0).abs() < 1e-9); assert_eq!(r.out_of_scope.values().sum::(), 3); @@ -488,7 +654,7 @@ mod tests { #[test] fn empty_reference_measures_zero_without_dividing_by_zero() { - let r = compare(&[], &paths(&[&["File", "Save"]])); + let r = compare(Area::Menus, &[], &paths(&[&["File", "Save"]])); assert_eq!(r.presence(), 0.0); assert!(r.summary().contains("presence 0/0 (0.0%)")); } @@ -502,6 +668,79 @@ mod tests { assert!(parse_args(&["--min", "x"]).is_err()); assert!(parse_args(&["--out"]).is_err()); assert!(parse_args(&["--bogus"]).is_err()); - assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some())); + assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some() && a.area == Area::Menus)); + assert!(parse_args(&["--area", "transitions"]).is_ok_and(|a| a.area == Area::Transitions)); + assert!(parse_args(&["--area", "panels"]).is_err()); + assert!(parse_args(&["--area"]).is_err()); + } + + fn catalog() -> Value { + json!([ + {"id": "gaussian_blur", "name": "Gaussian Blur", "kind": "Video", "path": ["Video Effects", "Blur & Sharpen"]}, + {"id": "mosaic", "name": "Mosaic", "kind": "Video", "path": ["Video Effects", "Stylize"]}, + {"id": "volume_x", "name": "Hard Limiter", "kind": "Audio", "path": ["Audio Effects", "Amplitude and Compression"]}, + {"id": "cross_dissolve", "name": "Cross Dissolve", "kind": "VideoTransition", "path": ["Video Transitions", "Dissolve"]}, + {"id": "push_legacy", "name": "Push (Legacy)", "kind": "VideoTransition", "path": ["Legacy", "Video Transitions"]}, + {"id": "constant_power", "name": "Constant Power", "kind": "AudioTransition", "path": ["Audio Transitions", "Crossfade"]}, + {"bad": true}, + {"name": "No kind", "path": []} + ]) + } + + #[test] + fn effect_catalog_splits_into_effects_and_transitions() { + let fx = our_catalog(&catalog(), Area::Effects).unwrap(); + assert_eq!( + fx, + paths(&[ + &["Video Effects", "Blur & Sharpen", "Gaussian Blur"], + &["Video Effects", "Stylize", "Mosaic"], + &["Audio Effects", "Amplitude and Compression", "Hard Limiter"] + ]) + ); + let tr = our_catalog(&catalog(), Area::Transitions).unwrap(); + assert_eq!(tr.len(), 3); + assert!(tr.contains(&paths(&[&["Legacy", "Video Transitions", "Push (Legacy)"]])[0])); + assert!(our_catalog(&json!({"effects": []}), Area::Effects).is_err()); + let inv = inventory(Area::Effects, &fx); + assert!(inv.starts_with("FilmCraft Effects panel effects: 3 items"), "{inv}"); + assert!(inv.contains("Video Effects ▸ Stylize"), "{inv}"); + } + + #[test] + fn effects_reference_of_bare_names_and_folders_is_measured() { + // one reference of the whole Effects panel: folders, bare names, presets, both areas + let reference = json!({"effects": { + "Presets": {"Bevel Edges": ["Bevel Edges Thick"]}, + "Video Effects": {"Blur & Sharpen": ["Gaussian Blur", "Camera Blur"], "Distort": ["Mosaic"]}, + "Video Transitions": {"Dissolve": ["Cross Dissolve", "Film Dissolve"]}, + "Audio Transitions": {"Crossfade": ["Constant Power"]} + }}); + let bare = json!(["Hard Limiter", "Echo"]); + let mut fx_ref = reference_paths(&reference, Area::Effects).unwrap(); + assert_eq!(fx_ref.len(), 7); + fx_ref.extend(reference_paths(&bare, Area::Effects).unwrap()); + // menus need a menu: bare names are not menu items + assert!(reference_paths(&bare, Area::Menus).unwrap().is_empty()); + let ours = our_catalog(&catalog(), Area::Effects).unwrap(); + let r = compare(Area::Effects, &fx_ref, &ours); + // Gaussian Blur same; Mosaic elsewhere (Stylize); Hard Limiter by bare name; Camera Blur, Echo missing + assert_eq!(r.counts(), (2, 1, 2), "{:?}", r.summary()); + assert_eq!(r.out_of_scope.get("presets"), Some(&1)); + assert_eq!(r.out_of_scope.get("transitions (--area transitions)"), Some(&3)); + let md = r.markdown("effects.json", ours.len()); + assert!(md.starts_with("# Effect parity checklist"), "{md}"); + assert!(md.contains("| Folder | Items |"), "{md}"); + assert!(md.contains("| Video Effects ▸ Blur & Sharpen | 2 | 1 | 0 | 1 | 50.0% |"), "{md}"); + assert!(md.contains("| (no folder) | 2 | 1 | 0 | 1 | 50.0% |"), "{md}"); + assert!(md.contains("- Video Effects ▸ Distort ▸ Mosaic → Video Effects ▸ Stylize ▸ Mosaic"), "{md}"); + assert!(md.contains("- [ ] Echo"), "{md}"); + assert!(r.summary().starts_with("effects: presence 3/5 (60.0%)"), "{}", r.summary()); + + let tr_ref = reference_paths(&reference, Area::Transitions).unwrap(); + let tr = compare(Area::Transitions, &tr_ref, &our_catalog(&catalog(), Area::Transitions).unwrap()); + assert_eq!(tr.counts(), (2, 0, 1), "{}", tr.summary()); + assert_eq!(tr.out_of_scope.get("effects (--area effects)"), Some(&3)); + assert!(tr.markdown("effects.json", 3).contains("- [ ] Video Transitions ▸ Dissolve ▸ Film Dissolve")); } } From f79a51dd24b3973d603650fa24bc2abdada28f5e Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Sat, 10 Oct 2026 17:14:34 +0200 Subject: [PATCH 016/157] #480: exports no longer stop for good when a clip's decoding uses rayon A GopCache decodes with its lock held, and that decoding starts rayon work (rotating iPhone frames, cropping, slice-threaded decoders). On the global pool a worker that took part of it could, waiting inside it, pick up another render job asking the same cache for a frame and block on the lock, while the lock holder waited for the work on that worker's stack: exports of rotated iPhone HEVC clips stopped making frames in about half the runs. The locked decoding now runs on a separate decode pool, whose threads never wait for a cache's lock; a caller that is a rayon worker waits on a plain thread so it does no other rayon work while holding the lock. The cancel/catch-up/draft/ background hints travel with it (filmcraft_media::cancel::Context). Intra-only streams crop and rotate before taking the lock again. Second version of #662 (reverted): the regression test's stand-in decoder summed with overflow, which panics in debug builds; it now wraps explicitly. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/gop.rs | 164 +++++++++++++++++++++++++++++++++++-- crates/media/src/cancel.rs | 54 ++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..068d93ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "filmcraft-vp9", "image", "log", + "rayon", "serde_json", "symphonia", "thiserror 2.0.21", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c4..7caf5eb98 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,6 +31,7 @@ filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-opus = { workspace = true } +rayon = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # Decode timing counters (`gop_stats`); std's Instant panics on wasm32. diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index c1395e430..497683e12 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -109,8 +109,9 @@ impl std::ops::Sub for GopStats { } } -/// A container's video sample table, in decode (file) order. -pub trait VideoSamples { +/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own +/// threads ([`isolated`]). +pub trait VideoSamples: Sync { fn count(&self) -> usize; /// Presentation timestamp of sample `i` (track units). fn pts(&self, i: usize) -> i64; @@ -437,6 +438,57 @@ const MIN_FRAMES: usize = 64; /// rather than restarting at the sync sample. const CONTINUE_THROUGH: usize = 48; +/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made. +#[cfg(not(target_arch = "wasm32"))] +fn decode_pool() -> Option<&'static rayon::ThreadPool> { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() + }) + .as_ref() +} + +/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting +/// without doing other rayon work meanwhile (#480). +/// +/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the +/// pool of the render jobs that ask the cache for frames, a worker that took part of it could, +/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then +/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an +/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever +/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a +/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some +/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead. +fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Result { + let ctx = filmcraft_media::cancel::Context::current(); + let job = move || ctx.run(f); + #[cfg(not(target_arch = "wasm32"))] + if let Some(pool) = decode_pool() { + if rayon::current_thread_index().is_none() { + return pool.install(job); + } + let slot = Mutex::new(Some(job)); + let waited = std::thread::scope(|scope| { + let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || { + let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + job.map(|job| pool.install(job)) + }); + spawned.map(|h| h.join()) + }); + return match waited { + Ok(Ok(Some(r))) => r, + Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())), + // no thread to wait on: decode here, as before + Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) { + Some(job) => job(), + None => Err(CodecError::Decode("internal error while decoding".into())), + }, + }; + } + job() +} + impl GopCache { pub fn new(explicit_color: Option) -> Self { // unit tests count decoder restarts: each of their caches gets a pool of its own @@ -494,7 +546,11 @@ impl GopCache { } fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { - let f = self.finish(f); + self.store_finished(st, pts, self.finish(f), draft); + } + + /// Cache a frame [`Self::finish`] has been applied to. + fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { st.last_used = Instant::now(); if draft { st.drafts.insert(pts); @@ -616,6 +672,22 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } + // with the lock held: see `isolated` + let st = &mut *st; + isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft)) + } + + /// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it. + fn decode_until( + &self, + st: &mut State, + s: &dyn VideoSamples, + i: usize, + want_pts: i64, + late_before: Option, + draft: bool, + ) -> crate::Result> { + let n = s.count(); let mut key = decode_start(s, i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet @@ -669,14 +741,14 @@ impl GopCache { let out = timed(|| decoder.decode(&data, s.pts(k)))?; st.next += 1; DECODED.fetch_add(1, Ordering::Relaxed); - self.store_output(&mut st, out); + self.store_output(st, out); if st.cached(want_pts, draft).is_some() { break; } } if st.cached(want_pts, draft).is_none() { let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default(); - self.store_output(&mut st, out); + self.store_output(st, out); st.next = usize::MAX; } // nearest decoded frame at or before the wanted pts (robust to decoder pts quirks) @@ -761,14 +833,17 @@ impl GopCache { DECODED.fetch_add(1, Ordering::Relaxed); timed(|| dec.decode(&data, want_pts)) }); + // crop and rotate before taking the lock: that work runs on rayon (see `isolated`) + let res = res.map(|out| { + count_frames(&out); + out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() + }); let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); if st.spare.len() < 16 { st.spare.push(dec); } - let res = res?; - count_frames(&res); - for d in res { - self.store(&mut st, d.pts, d.frame, d.draft); + for (pts, frame, draft) in res? { + self.store_finished(&mut st, pts, frame, draft); } st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into())) } @@ -900,6 +975,77 @@ mod tests { assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1); } + /// A decoder that, like a slice-threaded one, splits each picture's work over rayon. + struct ParDec(Box); + + impl VideoDecoder for ParDec { + fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { + use rayon::prelude::*; + let rows: u64 = (0..64u32) + .into_par_iter() + .with_max_len(1) + .map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))) + .reduce(|| 0, u64::wrapping_add); + std::hint::black_box(rows); + self.0.decode(sample, pts) + } + fn flush(&mut self) -> Vec { + self.0.flush() + } + fn reset(&mut self) { + self.0.reset() + } + fn name(&self) -> &str { + "par" + } + } + + struct ParSamples(Samples); + + impl VideoSamples for ParSamples { + fn count(&self) -> usize { + self.0.count() + } + fn pts(&self, i: usize) -> i64 { + self.0.pts(i) + } + fn sync_before(&self, i: usize) -> usize { + self.0.sync_before(i) + } + fn sample_at(&self, t: i64) -> Option { + self.0.sample_at(t) + } + fn read(&self, i: usize) -> crate::Result> { + self.0.read(i) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(ParDec(self.0.make_decoder()?))) + } + } + + /// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's + /// slices, rotating the frames) runs on rayon too. A worker that took part of that work and, + /// waiting inside it, picked up another frame request blocked on the cache's lock for good: + /// an export of rotated iPhone clips stopped making frames. + #[test] + fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() { + use rayon::prelude::*; + let s = Arc::new(ParSamples(samples(240, 30, 4, false))); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for round in 0..40usize { + // export-like: many frame workers, frames of several GOPs of a fresh source at once + let c = GopCache::new(None).with_rotation(1); + (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { + let i = (k * 37 + round * 53) % 240; + assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + }); + } + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); + } + #[test] fn running_decoder_continues_through_a_nearby_sync_sample() { let s = samples(200, 30, 0, false); diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index bfb391dd2..5c2b5f28b 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -96,10 +96,64 @@ pub fn cancelled() -> bool { CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed))) } +/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`], +/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work. +#[derive(Clone, Default)] +pub struct Context { + cancel: Option>, + catch_up: Option, + draft: bool, + background: bool, +} + +impl Context { + /// This thread's hints. + pub fn current() -> Self { + Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() } + } + + /// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must + /// not keep another job's hints after a panic). + pub fn run(self, f: impl FnOnce() -> R) -> R { + struct Restore(Context); + impl Drop for Restore { + fn drop(&mut self) { + let prev = std::mem::take(&mut self.0); + CURRENT.with(|c| *c.borrow_mut() = prev.cancel); + CATCH_UP.with(|c| c.set(prev.catch_up)); + DRAFT.with(|c| c.set(prev.draft)); + BACKGROUND.with(|c| c.set(prev.background)); + } + } + let _restore = Restore(Context::current()); + CURRENT.with(|c| *c.borrow_mut() = self.cancel); + CATCH_UP.with(|c| c.set(self.catch_up)); + DRAFT.with(|c| c.set(self.draft)); + BACKGROUND.with(|c| c.set(self.background)); + f() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn context_carries_the_hints_to_another_thread_and_restores_its_own() { + let flag = Arc::new(AtomicBool::new(true)); + let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current)))); + std::thread::scope(|s| { + s.spawn(|| { + let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up()))); + assert_eq!(seen, (true, true, true, Some(Tick(5)))); + // a panic inside leaves this thread's own hints in place + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job")))); + assert!(r.is_err()); + assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None)); + }); + }); + } + #[test] fn flag_is_scoped_to_the_closure() { let flag = Arc::new(AtomicBool::new(false)); From 199223f3b994128c1c03cd7493dba43295002a5f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:16:50 +1100 Subject: [PATCH 017/157] #697: Paste cut clips from the keyboard even when old OS files linger Copy and Cut snapshot files or an image already on the system clipboard. Paste ignores that same media so Cmd+V and the Edit menu paste the clips instead of re-importing stale files the platform left beside the clip names. --- crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/menus.rs | 29 +++++++++++- crates/ui-egui/tests/clipboard_paste_ui.rs | 51 ++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b6208ed6..c1d3cb816 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -322,6 +322,9 @@ pub struct FilmcraftApp { pub command_inbox: Option>, /// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet. v_down: bool, + /// OS files/image that were already on the clipboard when the last Copy/Cut ran. Paste ignores + /// that same media so it pastes the copied clips instead (#697). + pub(crate) stale_clipboard_media: Option, /// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds. pub gpu: Option, /// Preview render job being watched (job id, where to start playing when it completes). @@ -535,6 +538,7 @@ impl FilmcraftApp { tl: Default::default(), command_inbox: None, v_down: false, + stale_clipboard_media: None, gpu: None, watched_render: None, applied_prefs: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1cca0b691..a6b8fea04 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -510,8 +510,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: return Ok(json!({"dialog": "revert"})); } // Files or an image on the system clipboard paste into the Timeline, or import into the - // Project panel (#611). Copying clips puts their names there as text, so media on the - // clipboard was copied after them; without media, Paste pastes the copied clips. + // Project panel (#611). Copying clips puts their names there as text, so media copied + // *after* them still wins; media that was already there when Copy/Cut ran is ignored so + // Paste of cut/copied clips still works (#697). "edit.paste" | "edit.pasteInsert" if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) => { @@ -556,11 +557,32 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.ui.status = e.clone(); } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + remember_stale_clipboard_media(app); put_clip_names_on_system_clipboard(app, ctx); } r } +/// Snapshot OS files/image present at Copy/Cut. Many platforms keep a previous file list after +/// `copy_text`, so Paste would otherwise import those files instead of the clips (#697). +fn remember_stale_clipboard_media(app: &mut FilmcraftApp) { + app.stale_clipboard_media = app.hooks.clipboard_media.as_mut().and_then(|f| f()).as_ref().map(clipboard_media_key); +} + +fn clipboard_media_key(media: &crate::ClipboardMedia) -> String { + match media { + crate::ClipboardMedia::Files(paths) => format!("files:{}", paths.join("\0")), + crate::ClipboardMedia::Image { width, height, rgba } => { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in rgba { + h ^= u64::from(*b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + format!("img:{width}x{height}:{h}") + } + } +} + /// Copied clips live in the session, but on Windows and Linux Ctrl+V only reaches the app when the /// system clipboard holds text (an empty clipboard sends no paste at all, #199). Put the copied /// clip names there, like other editors put their own data on the clipboard, so Ctrl+V pastes the @@ -578,6 +600,9 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { /// imported into the shown bin in the Project panel. None when the clipboard holds neither. fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option> { let media = (app.hooks.clipboard_media.as_mut()?)()?; + if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { + return None; + } let paths = match media { crate::ClipboardMedia::Files(paths) => paths, crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba) diff --git a/crates/ui-egui/tests/clipboard_paste_ui.rs b/crates/ui-egui/tests/clipboard_paste_ui.rs index 86beeb73c..c01d2e165 100644 --- a/crates/ui-egui/tests/clipboard_paste_ui.rs +++ b/crates/ui-egui/tests/clipboard_paste_ui.rs @@ -185,12 +185,14 @@ fn without_media_ctrl_v_pastes_copied_clips() { let asked = d.clipboard(None); let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); - d.ok("engine.execute", json!({"command": "edit.copy", "params": {}})); + d.ok("ui.menu.invoke", json!({"id": "edit.copy"})); let copies = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); let n = copies(&mut d); + let asked_after_copy = asked.get(); + assert!(asked_after_copy >= 1, "Copy looks at lingering OS media"); d.ctrl_v_without_text(egui::Modifiers::COMMAND); assert_eq!(copies(&mut d), n + 1, "the copied clip is pasted"); - assert_eq!(asked.get(), 1); + assert_eq!(asked.get(), asked_after_copy + 1); for pressed in [true, false] { d.harness.input_mut().events.push(egui::Event::Key { key: egui::Key::V, @@ -202,5 +204,48 @@ fn without_media_ctrl_v_pastes_copied_clips() { d.frames(1); } d.frames(2); - assert_eq!((copies(&mut d), asked.get()), (n + 1, 1), "V alone pastes nothing"); + assert_eq!((copies(&mut d), asked.get()), (n + 1, asked_after_copy + 1), "V alone pastes nothing"); +} + +/// Files left on the OS clipboard from before Copy/Cut must not steal Paste: Ctrl+V pastes the +/// cut clip, as the Edit menu does (#697). +#[test] +fn ctrl_v_pastes_a_cut_clip_even_when_old_files_linger_on_the_clipboard() { + let mut d = Driver::demo("cut-linger"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("stale.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 200, 255])).save(&png).unwrap(); + let path = png.to_string_lossy().into_owned(); + d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()])))); + let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); + d.ok("ui.menu.invoke", json!({"id": "edit.cut"})); + assert!(d.clips().iter().all(|c| c.2 != first.2), "the clip left the timeline"); + let named = |d: &mut Driver| d.clips().iter().filter(|c| c.3 == first.3).count(); + assert_eq!(named(&mut d), 0); + d.ctrl_v_with_text(); + assert!(named(&mut d) >= 1, "the cut clip is pasted"); + assert!(!d.clips().iter().any(|c| c.3.contains("stale")), "lingering OS files are not pasted"); +} + +/// Copy from the keyboard (Ctrl+C as `Event::Copy`) also snapshots lingering OS media, so Ctrl+V +/// pastes the clips rather than those files. +#[test] +fn ctrl_c_then_ctrl_v_pastes_clips_not_lingering_files() { + let mut d = Driver::demo("copy-linger"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("stale.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 200, 0, 255])).save(&png).unwrap(); + let path = png.to_string_lossy().into_owned(); + d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()])))); + let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); + d.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::Copy]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + assert!(!d.app().session.state.clipboard.is_empty()); + let n = d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); + d.ctrl_v_with_text(); + assert_eq!(d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(), n + 1); } From 42b990b72be035562473d6637bbf0a9e4e1af7ad Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:26:31 +0200 Subject: [PATCH 018/157] #841: import packed 10/12-bit and Y'CbCr DPX stills Problem: DPX import rejected the 10/12-bit packed layout (packing 0) and the Y'CbCr descriptors (100 CbYCrY, 101 CbYACrYA, 102 CbYCr, 103 CbYCrA) with an "unsupported" error. Fix: unpack packed components back to back, low bits first, each scan line on a 32-bit boundary (as ffmpeg reads 12-bit packed files), and convert Y'CbCr to RGB with the matrix the colorimetric field names (BT.709; BT.601 for the 601 and composite codes; by picture size when unspecified), in video range unless the reference codes span the full code range; 4:2:2 chroma is shared by each pixel pair. Unit tests for every new layout and hostile headers; an ffmpeg oracle test builds packed 12-bit RGB and 8-bit 4:2:2 / 4:4:4 / 4:4:4:4 files from ffmpeg's test picture and matches its decode within one 8-bit step. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 92 ++++++++++++ crates/media/src/dpx.rs | 216 +++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- docs/gaps.md | 6 +- 4 files changed, 280 insertions(+), 36 deletions(-) diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index 4fc6638e7..da95c5eb6 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -171,6 +171,98 @@ fn dpx_stills_every_layout() { } } +/// A big-endian DPX file holding `data` as its one image element (`w` × `h`, the given +/// descriptor, bit depth, packing and colorimetric code; reference codes undefined). +fn dpx_file(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, colorimetric: u8, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let put = |f: &mut Vec, at: usize, v: &[u8]| f[at..at + v.len()].copy_from_slice(v); + put(&mut f, 0, b"SDPX"); + put(&mut f, 4, &2048u32.to_be_bytes()); + put(&mut f, 8, b"V2.0"); + put(&mut f, 16, &(2048 + data.len() as u32).to_be_bytes()); + put(&mut f, 770, &1u16.to_be_bytes()); + put(&mut f, 772, &w.to_be_bytes()); + put(&mut f, 776, &h.to_be_bytes()); + let el = 780; + put(&mut f, el + 4, &u32::MAX.to_be_bytes()); + put(&mut f, el + 12, &u32::MAX.to_be_bytes()); + f[el + 20] = descriptor; + f[el + 22] = colorimetric; + f[el + 23] = bits; + put(&mut f, el + 24, &packing.to_be_bytes()); + put(&mut f, el + 28, &2048u32.to_be_bytes()); + f.extend_from_slice(data); + f +} + +/// `pix_fmt` raw frame of ffmpeg's test picture at `w` × `h`. +fn raw_picture(ff: &Path, w: u32, h: u32, pix_fmt: &str) -> Vec { + let alpha = if pix_fmt.contains('a') { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale={w}:{h}{alpha}"); + ffmpeg_out(ff, &["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) +} + +/// A DPX file we built must import exactly like ffmpeg decodes it, within `tol` of its 16-bit +/// decode rounded to 8 bits. +fn dpx_matches(ff: &Path, name: &str, file: Vec, tol: u8) { + let path = dir().join(name); + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&path, &file).unwrap(); + let src = filmcraft_codecs::open_bytes(name, file.into()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + let wide = ffmpeg_out(ff, &["-i", path.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + let want: Vec = wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect(); + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + eprintln!("{name}: max diff {d}"); +} + +/// Packed 12-bit RGB (packing 0: components back to back, low bits first, lines on 32-bit +/// boundaries) and 8-bit Y'CbCr (4:2:2, 4:4:4, 4:4:4:4 with alpha; BT.709, BT.601 and unspecified +/// colorimetry), layouts ffmpeg reads but does not write, built here from ffmpeg's test picture +/// (#841). +#[test] +fn dpx_packed_and_ycbcr_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (w, h) = (95u32, 63u32); + let rgb: Vec = raw_picture(&ff, w, h, "rgb48le").as_chunks::<2>().0.iter().map(|c| u32::from(u16::from_le_bytes(*c)) >> 4).collect(); + let mut data = Vec::new(); + for line in rgb.chunks(w as usize * 3) { + let (mut acc, mut n) = (0u64, 0u32); + for &v in line { + acc |= u64::from(v) << n; + n += 12; + while n >= 32 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + } + } + dpx_matches(&ff, "dpx_packed12.dpx", dpx_file(w, h, 50, 12, 0, 6, &data), 1); + + // Y'CbCr: even width, scan lines a multiple of 4 bytes + let (w, h) = (96u32, 64u32); + let px = (w * h) as usize; + let uyvy = raw_picture(&ff, w, h, "uyvy422"); + let yuv = raw_picture(&ff, w, h, "yuv444p"); + let yuva = raw_picture(&ff, w, h, "yuva444p"); + let interleave = |planes: &[u8], order: &[usize]| -> Vec { (0..px).flat_map(|i| order.iter().map(move |&p| planes[p * px + i])).collect() }; + // planes are Y, U (Cb), V (Cr), A; DPX stores Cb Y Cr A + let cbycr = interleave(&yuv, &[1, 0, 2]); + let cbycra = interleave(&yuva, &[1, 0, 2, 3]); + for colorimetric in [6u8, 7, 0] { + dpx_matches(&ff, &format!("dpx_cbycry_{colorimetric}.dpx"), dpx_file(w, h, 100, 8, 0, colorimetric, &uyvy), 1); + dpx_matches(&ff, &format!("dpx_cbycr_{colorimetric}.dpx"), dpx_file(w, h, 102, 8, 0, colorimetric, &cbycr), 1); + } + dpx_matches(&ff, "dpx_cbycra.dpx", dpx_file(w, h, 103, 8, 0, 6, &cbycra), 1); +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs index 2d5832faf..474d1236e 100644 --- a/crates/media/src/dpx.rs +++ b/crates/media/src/dpx.rs @@ -1,16 +1,23 @@ //! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). //! -//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per -//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into -//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the -//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit -//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line -//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing -//! density) transfer is shown as stored, without a film-log conversion. +//! The first image element is decoded: luma, RGB, RGBA, ABGR, or Y'CbCr (CbYCrY 4:2:2, CbYACrYA +//! 4:2:2:4, CbYCr 4:4:4, CbYCrA 4:4:4:4), 8, 10, 12 or 16 bits per component, big-endian (`SDPX`) or +//! little-endian (`XPDS`) files. 10-bit components are filled into 32-bit words (packing 1, method +//! A: two padding bits at the bottom; packing 2, method B: at the top), 12-bit components fill +//! 16-bit words the same two ways; packed 10/12-bit components (packing 0) follow each other with no +//! padding, the first in the low bits of a 32-bit word, a component crossing into the next word +//! continuing in its low bits. Scan lines start on a 32-bit boundary and may carry end-of-line +//! padding. Orientations 0–3 (either scan direction, either line order) are honoured. RGB and luma +//! code values map linearly onto 8 bits (full range); a logarithmic (printing density) transfer is +//! shown as stored, without a film-log conversion. Y'CbCr is converted with the matrix the +//! colorimetric field names (BT.709, or BT.601 for the 601 and composite video codes; by picture +//! size when unspecified), in video range unless the reference low / high data codes span the +//! whole code range; 4:2:2 chroma is shared by the two pixels of a pair. //! -//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" -//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed -//! orientations. +//! Not decoded (an "unsupported" error): run-length encoded elements, 1-bit and floating-point +//! components, transposed orientations. + +use filmcraft_color::{Matrix, Range, normalize_c, normalize_y, ycbcr_to_rgb}; use crate::{MediaError, Result, still::Raster}; @@ -63,6 +70,8 @@ enum Packing { Words { shift: u32 }, /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. Ten { pad: u32 }, + /// `bits`-bit components back to back in 32-bit words, low bits first (packing 0). + Packed { bits: u32 }, } impl Packing { @@ -73,6 +82,7 @@ impl Packing { Packing::Bytes => comps, Packing::Words { .. } => comps.checked_mul(2)?, Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + Packing::Packed { bits } => comps.checked_mul(bits as usize)?.div_ceil(32).checked_mul(4)?, }; raw.checked_next_multiple_of(4) } @@ -93,6 +103,69 @@ impl Packing { } } } + Packing::Packed { bits } => { + let mask = (1u64 << bits) - 1; + for i in 0..comps { + let bit = i * bits as usize; + let at = bit / 32 * 4; + let pair = u64::from(order.u32(line, at)) | (u64::from(order.u32(line, at + 4)) << 32); + out.push(((pair >> (bit % 32)) & mask) as u16); + } + } + } + } +} + +/// What the stored components of a pixel are. +#[derive(Clone, Copy)] +enum Components { + /// `n` components per pixel; the indices of R, G, B and A among them (alpha `None`: opaque). + Rgb { n: usize, map: [Option; 4] }, + /// `n` components per pixel: Cb (4:2:2: Cb on even pixels, Cr on odd ones), Y', then Cr (4:4:4 + /// only), then alpha when `alpha`. + YCbCr { n: usize, sub: bool, alpha: bool, matrix: Matrix, range: Range }, +} + +impl Components { + fn per_pixel(self) -> usize { + match self { + Components::Rgb { n, .. } | Components::YCbCr { n, .. } => n, + } + } + + fn has_alpha(self) -> bool { + match self { + Components::Rgb { map, .. } => map[3].is_some(), + Components::YCbCr { alpha, .. } => alpha, + } + } + + /// RGBA8 of pixel `x` from the unpacked component values of its scan line (`bits` per value). + fn pixel(self, line: &[u16], x: usize, bits: u32) -> [u8; 4] { + let max = (1u32 << bits) - 1; + let to8 = |v: u32| ((v.min(max) * 255 + max / 2) / max) as u8; + let get = |i: usize| line.get(i).map(|&v| u32::from(v)); + match self { + Components::Rgb { n, map } => map.map(|slot| match slot { + Some(i) => get(x * n + i).map_or(0, to8), + None => 255, + }), + Components::YCbCr { n, sub, alpha, matrix, range } => { + let base = x * n; + let (cb, cr) = if sub { + let pair = (x & !1) * n; + (get(pair), get(pair + n)) + } else { + (get(base), get(base + 2)) + }; + // a missing Cr (odd width, 4:2:2) is neutral + let mid = 1u32 << (bits - 1); + let y = normalize_y(get(base + 1).unwrap_or(0), bits, range); + let rgb = ycbcr_to_rgb(y, normalize_c(cb.unwrap_or(mid), bits, range), normalize_c(cr.unwrap_or(mid), bits, range), matrix); + let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = if alpha { get(base + n - 1).map_or(0, to8) } else { 255 }; + [c(rgb[0]), c(rgb[1]), c(rgb[2]), a] + } } } } @@ -110,7 +183,10 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { let orientation = order.u16(bytes, 768); let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); let el = ELEMENT; + let low_code = order.u32(bytes, el + 4); + let high_code = order.u32(bytes, el + 12); let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let colorimetric = bytes.get(el + 22).copied().unwrap_or(0); let bits = bytes.get(el + 23).copied().unwrap_or(0); let packing = order.u16(bytes, el + 24); let encoding = order.u16(bytes, el + 26); @@ -120,14 +196,6 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { return Err(err(name, "empty picture")); } let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; - // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components - let (n, map): (usize, [Option; 4]) = match descriptor { - 6 => (1, [Some(0), Some(0), Some(0), None]), - 50 => (3, [Some(0), Some(1), Some(2), None]), - 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), - 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), - d => return Err(unsupported(name, format!("descriptor {d}"))), - }; if encoding != 0 { return Err(unsupported(name, "run-length encoding".into())); } @@ -138,6 +206,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { (10, 2) => Packing::Ten { pad: 0 }, (12, 1) => Packing::Words { shift: 4 }, (12, 2) => Packing::Words { shift: 0 }, + (10 | 12, 0) => Packing::Packed { bits: u32::from(bits) }, (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), }; let (flip_x, flip_y) = match orientation { @@ -147,6 +216,27 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 3 => (true, true), o => return Err(unsupported(name, format!("orientation {o}"))), }; + let max = (1u32 << bits) - 1; + // video range unless the reference codes span every code value + let range = if low_code == 0 && high_code == max { Range::Full } else { Range::Limited }; + let matrix = match colorimetric { + 5 | 6 => Matrix::Bt709, + 7..=10 => Matrix::Bt601, + _ => filmcraft_frame::default_matrix(w as u32, h as u32), + }; + let ycbcr = |n, sub, alpha| Components::YCbCr { n, sub, alpha, matrix, range }; + let components = match descriptor { + 6 => Components::Rgb { n: 1, map: [Some(0), Some(0), Some(0), None] }, + 50 => Components::Rgb { n: 3, map: [Some(0), Some(1), Some(2), None] }, + 51 => Components::Rgb { n: 4, map: [Some(0), Some(1), Some(2), Some(3)] }, + 52 => Components::Rgb { n: 4, map: [Some(3), Some(2), Some(1), Some(0)] }, + 100 => ycbcr(2, true, false), + 101 => ycbcr(3, true, true), + 102 => ycbcr(3, false, false), + 103 => ycbcr(4, false, true), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + let n = components.per_pixel(); let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; @@ -157,8 +247,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { // the last line needs no end-of-line padding let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; - let max = (1u32 << bits) - 1; - let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let bits = u32::from(bits); let mut rgba = vec![0u8; pixels * 4]; let mut line = Vec::with_capacity(comps); for y in 0..h { @@ -168,19 +257,12 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { for x in 0..w { let dx = if flip_x { w - 1 - x } else { x }; let o = (dy * w + dx) * 4; - let Some(px) = rgba.get_mut(o..o + 4) else { continue }; - for (c, slot) in map.iter().enumerate() { - let v = match slot { - Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), - None => 255, - }; - if let Some(d) = px.get_mut(c) { - *d = v; - } + if let Some(px) = rgba.get_mut(o..o + 4) { + px.copy_from_slice(&components.pixel(&line, x, bits)); } } } - let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + let has_alpha = components.has_alpha() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) } @@ -250,14 +332,84 @@ mod tests { assert_eq!(r.rgba, [255, 0, 0, 128]); } + /// Packing 0: components back to back, low bits first, each line on a 32-bit boundary. + fn packed(lines: &[&[u32]], bits: u32, big: bool) -> Vec { + let mut out = Vec::new(); + for line in lines { + let (mut acc, mut n) = (0u64, 0u32); + for &v in *line { + acc |= u64::from(v) << n; + n += bits; + while n >= 32 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + } + } + out + } + + #[test] + fn packed_ten_and_twelve_bit_components_cross_words() { + // 2 × 2 RGB 10-bit: 6 components (60 bits) per line, the fourth straddles two words + let lines: [&[u32]; 2] = [&[1023, 0, 512, 4, 1019, 1023], &[0, 1023, 0, 1023, 1023, 1023]]; + for big in [true, false] { + let r = decode("a.dpx", &file(big, 2, 2, 50, 10, 0, 0, &packed(&lines, 10, big))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 255, 255, 0, 255, 0, 255, 255, 255, 255, 255]); + } + // 3 × 1 RGB 12-bit: 9 components, 108 bits in 4 words + let line: [&[u32]; 1] = [&[4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]]; + let r = decode("a.dpx", &file(true, 3, 1, 50, 12, 0, 0, &packed(&line, 12, true))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn ycbcr_descriptors_convert_to_rgb() { + // 8-bit CbYCrY, video range, BT.709 (colorimetric 6): black, white, then a red pixel pair + // (the 709 code triple of red is rounded, hence green 1) + let mut f = file(true, 4, 1, 100, 8, 0, 0, &[128, 16, 128, 235, 102, 63, 240, 63]); + f[ELEMENT + 22] = 6; + let r = decode("a.dpx", &f).unwrap(); + assert_eq!(r.rgba, [0, 0, 0, 255, 255, 255, 255, 255, 255, 1, 0, 255, 255, 1, 0, 255]); + assert!(!r.has_alpha); + // red in BT.601 (colorimetric 7): the 601 code triple, rounded + let mut f = file(true, 2, 1, 100, 8, 0, 0, &[90, 81, 240, 81]); + f[ELEMENT + 22] = 7; + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [254, 0, 0, 255, 254, 0, 0, 255]); + // 10-bit CbYCr filled (method A), full range from the reference codes: mid grey + let word = |a: u32, b: u32, c: u32| ((a << 22) | (b << 12) | (c << 2)).to_be_bytes(); + let mut f = file(true, 1, 1, 102, 10, 1, 0, &word(512, 512, 512)); + f[ELEMENT + 4..ELEMENT + 8].copy_from_slice(&0u32.to_be_bytes()); + f[ELEMENT + 12..ELEMENT + 16].copy_from_slice(&1023u32.to_be_bytes()); + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [128, 128, 128, 255]); + // 16-bit CbYCrA: white at half opacity + let v = |x: u16| x.to_be_bytes(); + let data: Vec = [v(32768), v(60160), v(32768), v(32768)].concat(); + let r = decode("a.dpx", &file(true, 1, 1, 103, 16, 0, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 128]); + assert!(r.has_alpha); + // 8-bit CbYACrYA, 3 pixels: the last pixel has no Cr and is neutral + let r = decode("a.dpx", &file(true, 3, 1, 101, 8, 0, 0, &[128, 235, 255, 128, 16, 0, 128, 235, 255, 0, 0, 0])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255]); + assert!(r.has_alpha); + } + #[test] fn hostile_headers_fail_cleanly() { // huge dimensions with no data, truncated data, unsupported layouts assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); - assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); - assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 150, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 3, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 16, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 3, 3, 100, 12, 0, 0, &[0; 20])).is_err()); assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ac9b655c..74e17680e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,7 +56,7 @@ only). "Tested" names the evidence. | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | -| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); packed 10/12-bit, Y'CbCr 4:2:2 / 4:4:4 with or without alpha (#841); no RLE | sequences (10-bit RGB, BT.709; #779) | 90% | | Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index f798de682..aa6c4f1c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -166,9 +166,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer - DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import - since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in - `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). + DPX layout, run-length encoding (Targa and DPX stills and sequences import since #821, packed + 10/12-bit and Y'CbCr DPX since #841, deeper than 8 bits kept at 8); transparent video (#402); + Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From fc855e2b0e12ae9f343539f84db976ae4f4d1b2f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:26:42 +0200 Subject: [PATCH 019/157] #835: workflow acceptance tests through the MCP server, checked by ffprobe Problem: no test did a real editing job end to end. Codec and container oracles exist, but nothing imported phone- or camera-like media, edited it through the command layer and checked the exported file (G6). Fix: apps/filmcraft-cli/tests/workflows.rs drives the `filmcraft-cli mcp` binary over stdio on media generated with ffmpeg into target/fixtures: - a VFR H.264 + AAC MP4: sequence from clip, extract, -6 dB audio gain, title, H.264 MP4 export; - a long-GOP H.264 MKV with AAC + AC-3 tracks: ripple trims, colour matte, centred cross dissolve, save; a new CLI process exports ProRes. ffprobe / ffmpeg check streams, frame count, duration, which source frame lands where, A/V sync across the cut, title pixels, the dissolve mix and audio levels. The tests skip without ffmpeg (testkit oracles). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/filmcraft-cli/Cargo.toml | 3 + apps/filmcraft-cli/tests/workflows.rs | 488 ++++++++++++++++++++++++++ docs/gaps.md | 6 + 4 files changed, 498 insertions(+) create mode 100644 apps/filmcraft-cli/tests/workflows.rs diff --git a/Cargo.lock b/Cargo.lock index 5529201a2..e2da215a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1797,6 +1797,7 @@ dependencies = [ "filmcraft-platform", "filmcraft-project", "filmcraft-render", + "filmcraft-testkit", "filmcraft-time", "serde_json", "tokio", diff --git a/apps/filmcraft-cli/Cargo.toml b/apps/filmcraft-cli/Cargo.toml index ae3a6f317..218b7f7d7 100644 --- a/apps/filmcraft-cli/Cargo.toml +++ b/apps/filmcraft-cli/Cargo.toml @@ -33,3 +33,6 @@ filmcraft-ogg = { workspace = true } filmcraft-automation = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/workflows.rs new file mode 100644 index 000000000..b0f7a57a8 --- /dev/null +++ b/apps/filmcraft-cli/tests/workflows.rs @@ -0,0 +1,488 @@ +//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent +//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`, +//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot: +//! +//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames); +//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3). +//! +//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross +//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes) +//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which +//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels. +//! +//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never +//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure). +//! ffmpeg and ffprobe are external test oracles only. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +// --------------------------------------------------------------------------------------------- +// Fixtures + +/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped +/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The +/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0 +/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers. +fn phone_vfr_mp4(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"]) + .args([ + "-filter_complex", + "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\ + select='not(eq(mod(n,7),3))'[v];\ + [1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]", + ]) + .args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"]) + .args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe, +/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3. +fn camera_long_gop_mkv(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"]) + .args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"]) + .args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"]) + .args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A scratch directory under the workspace `target/`, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Scratch(d) + } + fn path(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +// --------------------------------------------------------------------------------------------- +// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio) + +struct Mcp { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Mcp { + /// Start the headless server (an empty project) and initialise the session. `data_dir` keeps + /// user presets away from the real data directory. + fn start(data_dir: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")) + .args(["--data-dir", data_dir, "mcp"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn filmcraft-cli mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut m = Mcp { child, stdin, stdout, next_id: 1 }; + let init = + m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}})); + assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}"); + m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + m + } + + fn send(&mut self, msg: &Value) { + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// Send a request and return its response (notifications in between are skipped). + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let mut line = String::new(); + assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending"); + let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}")); + if v["id"] == id { + return v; + } + } + } + + /// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error. + fn tool(&mut self, name: &str, arguments: Value) -> Value { + let r = self.request("tools/call", json!({"name": name, "arguments": arguments})); + let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string(); + assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}"); + serde_json::from_str(&text).unwrap_or(Value::String(text)) + } + + /// `command_run`: one engine command by id. + fn run(&mut self, id: &str, params: Value) -> Value { + self.tool("command_run", json!({"id": id, "params": params})) + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// --------------------------------------------------------------------------------------------- +// Oracles + +fn probe(ffprobe: &Path, path: &str) -> Value { + let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).unwrap() +} + +fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> { + j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect() +} + +fn num(v: &Value) -> f64 { + v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN) +} + +/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout. +fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec { + let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap(); + assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout +} + +/// Size of the frames [`frames`] returns. +const FW: usize = 80; +const FH: usize = 60; + +/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes +/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it). +fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec> { + let chain = match vf { + Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"), + None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"), + }; + let bpp = if pix_fmt == "gray" { 1 } else { 3 }; + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]); + raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect() +} + +/// Mean absolute difference of two equally sized frames. +fn diff(a: &[u8], b: &[u8]) -> f64 { + a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::() / a.len().max(1) as f64 +} + +/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close. +fn assert_shows(got: &[u8], src: &[Vec], want: usize, what: &str) { + let d: Vec = src.iter().map(|s| diff(got, s)).collect(); + let best = d.iter().copied().fold(f64::INFINITY, f64::min); + let at = d.iter().position(|x| *x == best).unwrap_or(0); + assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]); +} + +/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz. +fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec { + let map = format!("0:a:{stream}"); + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]); + raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart. +fn onsets(x: &[f32]) -> Vec { + let peak = x.iter().fold(0f32, |m, v| m.max(v.abs())); + let mut out = Vec::new(); + let mut last = f64::NEG_INFINITY; + for (i, block) in x.chunks(48).enumerate() { + let t = i as f64 * 48.0 / 48_000.0; + if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 { + out.push(t); + } + if block.iter().any(|v| v.abs() > peak / 4.0) { + last = t; + } + } + out +} + +fn rms(x: &[f32], from: f64, to: f64) -> f64 { + let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default(); + (s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::() / s.len().max(1) as f64).sqrt() +} + +/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel). +fn tone(x: &[f32], at: f64, freq: f64) -> f64 { + let start = (at * 48_000.0) as usize; + let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default(); + let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos(); + let (mut s1, mut s2) = (0.0, 0.0); + for v in s { + let s0 = f64::from(*v) + c * s1 - s2; + s2 = s1; + s1 = s0; + } + (s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64 +} + +fn db(ratio: f64) -> f64 { + 20.0 * ratio.log10() +} + +fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec { + seq[kind][track]["items"].as_array().unwrap() +} + +// --------------------------------------------------------------------------------------------- +// Workflows + +/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the +/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and +/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame +/// holds the one before), the cut lands on the right frames, picture and sound stay in sync +/// across the cut, the title is in the picture only while it is on the timeline. +#[test] +fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = phone_vfr_mp4(&ffmpeg) else { + return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("phone"); + let mut m = Mcp::start(&dir.path("data")); + + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + assert!(made["sequence"].is_u64(), "{made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate"); + assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps"); + + // Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain. + m.run("markers.markIn", json!({"seconds": 1.5})); + m.run("markers.markOut", json!({"seconds": 2.5})); + m.run("sequence.extract", json!({})); + m.run("edit.selectAll", json!({})); + let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0})); + assert_eq!(gain["clips"], 2, "both audio pieces: {gain}"); + m.run("edit.deselectAll", json!({})); + m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5})); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["durationFrames"], 89, "{seq}"); + assert_eq!(clips_on(&seq, "video", 0).len(), 2); + assert_eq!(clips_on(&seq, "audio", 0).len(), 2); + + let out = dir.path("phone_edit.mp4"); + let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true})); + assert_eq!(r["result"]["frames"], 89, "{r}"); + + // container and streams + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "30/1"); + assert_eq!(v[0]["nb_read_frames"], "89"); + assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}"); + assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]); + + // picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source + // as an editor plays it (CFR 30, a dropped frame holding the previous one) + let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray"); + let got = frames(&ffmpeg, &out, None, "gray"); + assert!(src_frames.len() >= 120, "{} source frames", src_frames.len()); + assert_eq!(got.len(), 89); + for k in [20, 32, 40, 44, 45, 50, 70, 88] { + let want = if k < 45 { k } else { k + 31 }; + assert_shows(&got[k], &src_frames, want, &format!("export frame {k}")); + } + // the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31 + let mean = |f: &Vec| f.iter().map(|v| f64::from(*v)).sum::() / f.len() as f64; + let flashes: Vec = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect(); + assert_eq!(flashes, [30, 31, 59], "white frames"); + + // the title: over frames 0..15 only (its pixels differ from the source there, nowhere after) + let blocks_changed = |k: usize| { + let mut n = 0; + for by in (0..FH).step_by(10) { + for bx in (0..FW).step_by(10) { + let mut d = 0.0; + for y in by..by + 10 { + for x in bx..bx + 10 { + d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x])); + } + } + n += usize::from(d / 100.0 > 15.0); + } + } + n + }; + for k in [2, 8, 14] { + assert!(blocks_changed(k) >= 2, "title missing on frame {k}"); + } + for k in [16, 25] { + assert_eq!(blocks_changed(k), 0, "title still on frame {k}"); + } + + // sound: beeps where the flashes are (A/V sync across the extract), 6 dB down + let src_pcm = pcm(&ffmpeg, &src, 0); + let got_pcm = pcm(&ffmpeg, &out, 0); + let beeps = onsets(&got_pcm); + assert_eq!(beeps.len(), 2, "beeps at {beeps:?}"); + for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) { + assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s"); + } + let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09)); + assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB"); + let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09)); + assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB"); +} + +/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make +/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at +/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross +/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes. +/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio +/// tracks are in the mix at their own level and stop where the clip does. +#[test] +fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = camera_long_gop_mkv(&ffmpeg) else { + return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("camera"); + let data = dir.path("data"); + let project = dir.path("camera.fcproj"); + { + let mut m = Mcp::start(&data); + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + let clips: Vec = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1})); + assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks"); + let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap(); + + // 65 frames off the head and 15 off the tail, the linked audio following + m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65})); + m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15})); + let seq = m.tool("sequence_inspect", json!({})); + let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap(); + for t in 0..2 { + let a = &clips_on(&seq, "audio", t)[0]; + assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1); + } + + let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap(); + let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false})); + let matte_clip = placed["clips"][0].as_u64().unwrap(); + m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"})); + let seq = m.tool("sequence_inspect", json!({})); + let tr = &seq["video"][0]["transitions"][0]; + assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}"); + m.run("file.saveAs", json!({"path": project})); + } + assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit"); + + // a fresh process opens the saved project and renders it + let out = dir.path("camera_edit.mov"); + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap(); + assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr)); + let r: Value = serde_json::from_slice(&o.stdout).unwrap(); + let total = 71 + 50; + assert_eq!(r["result"]["frames"], total, "{r}"); + + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "25/1"); + assert_eq!(v[0]["nb_read_frames"], total.to_string()); + assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}"); + + // picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it + let src_frames = frames(&ffmpeg, &src, None, "rgb24"); + let got = frames(&ffmpeg, &out, None, "rgb24"); + assert_eq!((src_frames.len(), got.len()), (150, total as usize)); + for k in [0, 1, 2, 30, 64] { + assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}")); + } + let red = &got[100]; + assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red"); + // the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut + let mix = |k: usize| { + let (cam, e) = (&src_frames[65 + k], &got[k]); + let (mut num, mut den) = (0.0, 0.0); + for ((c, r), e) in cam.iter().zip(red).zip(e) { + let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e)); + num += (e - c) * (r - c); + den += (r - c) * (r - c); + } + num / den + }; + let alphas: Vec = (64..=78).map(mix).collect(); + assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}"); + assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}"); + assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}"); + + // sound: both tracks in the mix at their own level while the clip plays, silence on the matte + let got_pcm = pcm(&ffmpeg, &out, 0); + let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1)); + for at in [0.2, 1.5, 2.6] { + let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0)); + let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0)); + assert!( + db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0, + "at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})" + ); + } + assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte"); +} + +/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through +/// `filmcraft-cli --project … inspect sequence`. +fn end_frame_of(project: &str) -> Option { + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?; + let v: Value = serde_json::from_slice(&o.stdout).ok()?; + v["video"][0]["items"][0]["endFrame"].as_u64() +} diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..bf456e94e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,6 +110,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G6. No real-world media corpus or workflow acceptance tests +- **Done:** first scripted MCP workflows (#835, `apps/filmcraft-cli/tests/workflows.rs`): the + `filmcraft-cli mcp` binary imports ffmpeg-made phone-like VFR H.264 + AAC MP4 and long-GOP + H.264 Matroska with two audio tracks (AAC, AC-3), edits (extract, audio gain, title; ripple + trims, colour matte, cross dissolve), exports H.264 MP4, and saves a project that a new process + exports as ProRes; ffprobe / ffmpeg check streams, frame count, duration, frame placement, A/V + sync, title pixels, dissolve mix and audio levels. Skipped without ffmpeg. - **Missing:** a pinned corpus of phone, camera and screen recordings (variable frame rate, damaged files, long GOP, odd containers, multi-track audio), each with an import → edit → export round trip checked against an ffprobe / ffmpeg oracle; scripted MCP sessions that do real jobs From 4a3b2a20f19e4a066587508fbf8f415fd658a02f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:39:41 +0200 Subject: [PATCH 020/157] #840: restart an audio stream the device drops instead of playing silent Problem: the cpal error callback marked the stream failed on its first error of any kind, so the UI stopped audio and played the rest of the playback without sound. cpal's ALSA backend reports transient errors (POLLERR after an xrun, spurious poll returns, short writes) that way and keeps the stream alive; after POLLERR it no longer calls back. Reports: a second or two of audio, then silence while meters move (#23). Fix: AudioOut::recover, called every playback frame, lets CpalOut restart the stream: at once when the device is gone, or when an error was reported and no samples were consumed for 300 ms. The restarted stream reuses the shared play-ahead mixer and played-frame clock and must keep the sample rate and channel count; at most 3 restarts in 10 s, then playback falls back to the wall clock as before. Errors while samples keep flowing no longer silence playback. The device callback runs the mixer under catch_unwind: a panic plays silence. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/audio.rs | 347 ++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 55 ++++ crates/ui-egui/src/source_playback.rs | 5 + docs/gaps.md | 4 + 4 files changed, 359 insertions(+), 52 deletions(-) diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index f0e9bc489..cb56e059e 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -1,18 +1,33 @@ //! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host //! ("Device Class"), the output device, the I/O buffer size and the sample rate. +//! +//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which +//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by +//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound +//! resumes where it stopped instead of playback going silent (#840). +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError, TryLockError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use filmcraft_engine::settings::AudioHardwarePrefs; use filmcraft_ui_egui::{AudioDevices, AudioOut}; +/// The playback mixer callback handed to [`AudioOut::start`]. +type Fill = Box; + pub struct CpalOut { stream: Option, + /// The running playback's mixer, shared with the stream so a restarted stream continues it. + fill: Option>>, played: Arc, - failed: Arc, + health: Arc, + watchdog: Watchdog, + /// Time base for the watchdog. + epoch: Instant, rate: u32, channels: u16, hw: AudioHardwarePrefs, @@ -128,8 +143,11 @@ impl CpalOut { }; Self { stream: None, + fill: None, played: Arc::new(AtomicU64::new(0)), - failed: Arc::new(AtomicBool::new(false)), + health: Arc::new(Health::default()), + watchdog: Watchdog::default(), + epoch: Instant::now(), rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0), channels: cfg.as_ref().map_or(2, |c| c.channels()), hw: AudioHardwarePrefs::default(), @@ -153,19 +171,14 @@ impl CpalOut { Ok(exact.unwrap_or(default)) } - fn converted_stream( - &self, - dev: &cpal::Device, - config: &cpal::StreamConfig, - mut fill: Box, - ) -> Result + fn converted_stream(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc>) -> Result where T: cpal::SizedSample + cpal::FromSample, { let channels = usize::from(config.channels); let played = self.played.clone(); - let failed = self.failed.clone(); - let error_flag = failed.clone(); + let health = self.health.clone(); + let error_health = health.clone(); let mut scratch = Vec::new(); dev.build_output_stream( config, @@ -173,39 +186,23 @@ impl CpalOut { // Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread. if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() { buf.fill(T::EQUILIBRIUM); - if !failed.swap(true, Ordering::SeqCst) { + if !health.failed.swap(true, Ordering::SeqCst) { eprintln!("filmcraft: unable to allocate the audio conversion buffer"); } return; } scratch.resize(buf.len(), 0.0); - fill(&mut scratch, channels); + render(&fill, &mut scratch, channels, &played, &health); convert_samples(&scratch, buf); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); }, - move |e| stream_error(&error_flag, e), + move |e| stream_error(&error_health, e), None, ) } -} -fn stream_error(failed: &AtomicBool, e: cpal::StreamError) { - if !failed.swap(true, Ordering::SeqCst) { - eprintln!("filmcraft: audio stream error: {e}"); - } -} - -fn convert_samples>(input: &[f32], output: &mut [T]) { - // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. - let upper = f32::from_bits(1.0_f32.to_bits() - 1); - for (sample, converted) in input.iter().zip(output) { - *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); - } -} - -impl AudioOut for CpalOut { - fn start(&mut self, mut fill: Box) -> Result { - self.stop(); + /// Open the configured output device and play `fill` on it. `expect` is the (sample rate, + /// channels) a restarted stream must keep: the mixer was set up for them. + fn open(&mut self, fill: Arc>, expect: Option<(u32, u16)>) -> Result<(), String> { let host = host(&self.hw.device_class); let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?; self.note = fallback; @@ -214,6 +211,11 @@ impl AudioOut for CpalOut { if channels == 0 || cfg.sample_rate().0 == 0 { return Err("the output device returned an invalid channel count or sample rate".into()); } + if let Some((rate, ch)) = expect + && (rate, ch) != (cfg.sample_rate().0, cfg.channels()) + { + return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels())); + } let mut config: cpal::StreamConfig = cfg.clone().into(); if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size() && (*min..=*max).contains(&self.hw.buffer_size) @@ -222,20 +224,18 @@ impl AudioOut for CpalOut { } self.rate = cfg.sample_rate().0; self.channels = cfg.channels(); - self.played.store(0, Ordering::SeqCst); - self.failed.store(false, Ordering::SeqCst); - let played = self.played.clone(); - let failed = self.failed.clone(); let stream = match cfg.sample_format() { - cpal::SampleFormat::F32 => dev.build_output_stream( - &config, - move |buf: &mut [f32], _| { - fill(buf, channels); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); - }, - move |e| stream_error(&failed, e), - None, - ), + cpal::SampleFormat::F32 => { + let played = self.played.clone(); + let health = self.health.clone(); + let error_health = health.clone(); + dev.build_output_stream( + &config, + move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health), + move |e| stream_error(&error_health, e), + None, + ) + } cpal::SampleFormat::I8 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I16 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I24 => self.converted_stream::(&dev, &config, fill), @@ -251,10 +251,172 @@ impl AudioOut for CpalOut { .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; self.stream = Some(stream); + Ok(()) + } + + /// Replace a dropped stream with a new one on the same mixer, clock and format. + fn restart(&mut self) -> Result<(), String> { + let fill = self.fill.clone().ok_or("nothing is playing")?; + let expect = (self.rate, self.channels); + // The old stream (and its audio thread) goes first: only one stream ever runs the mixer. + self.stream = None; + self.health.clear_errors(); + self.open(fill, Some(expect)) + } +} + +/// What the audio thread reports to the UI thread. +#[derive(Default)] +struct Health { + /// The device went away (unplugged): restart the stream at once. + lost: AtomicBool, + /// A backend error since the last check (cpal keeps the stream alive after these): restart + /// only if the stream also stopped consuming samples. + errored: AtomicBool, + /// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out. + /// [`AudioOut::played_frames`] reports `None` and playback goes on without sound. + failed: AtomicBool, + /// The first error of a stream has been printed. + reported: AtomicBool, +} + +impl Health { + fn clear_errors(&self) { + self.lost.store(false, Ordering::SeqCst); + self.errored.store(false, Ordering::SeqCst); + self.reported.store(false, Ordering::SeqCst); + } +} + +/// How long a stream that reported an error may go without consuming samples before it is +/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock. +const STALL_RESTART: Duration = Duration::from_millis(300); +/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up. +const MAX_RESTARTS: usize = 3; +const RESTART_WINDOW: Duration = Duration::from_secs(10); + +/// What [`Watchdog::check`] decides about the running stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Verdict { + Healthy, + Restart, + GiveUp, +} + +/// Decides when a stream that reported errors is restarted (UI thread, every frame). +#[derive(Default)] +struct Watchdog { + /// Played-frame count last seen, and when it last changed. + seen: (u64, Duration), + /// An error was reported and the stream has not consumed samples since. + pending_error: bool, + /// When recent restarts happened (at most [`MAX_RESTARTS`] kept). + restarts: Vec, +} + +impl Watchdog { + fn reset(&mut self, now: Duration) { + *self = Self { seen: (0, now), ..Self::default() }; + } + + fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict { + if played != self.seen.0 { + self.seen = (played, now); + self.pending_error = false; + } + // An error that arrives with the stream still consuming samples is only a hiccup. + self.pending_error |= errored; + let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART; + if !lost && !(self.pending_error && stalled) { + return Verdict::Healthy; + } + self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW); + if self.restarts.len() >= MAX_RESTARTS { + return Verdict::GiveUp; + } + self.restarts.push(now); + self.seen = (played, now); + self.pending_error = false; + Verdict::Restart + } +} + +/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a +/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never +/// expected: a restart drops the old stream first) plays silence without advancing the clock. +fn render(fill: &Mutex, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) { + let mut guard = match fill.try_lock() { + Ok(guard) => guard, + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => { + buf.fill(0.0); + return; + } + }; + if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() { + buf.fill(0.0); + if !health.failed.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: the audio mixer failed; playing without sound"); + } + return; + } + played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst); +} + +fn stream_error(health: &Health, e: cpal::StreamError) { + match e { + cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst), + cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst), + } + if !health.reported.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: audio stream error: {e}"); + } +} + +fn convert_samples>(input: &[f32], output: &mut [T]) { + // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. + let upper = f32::from_bits(1.0_f32.to_bits() - 1); + for (sample, converted) in input.iter().zip(output) { + *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); + } +} + +impl AudioOut for CpalOut { + fn start(&mut self, fill: Box) -> Result { + self.stop(); + self.played.store(0, Ordering::SeqCst); + self.health = Arc::new(Health::default()); + self.watchdog.reset(self.epoch.elapsed()); + let fill = Arc::new(Mutex::new(fill)); + self.open(fill.clone(), None)?; + self.fill = Some(fill); Ok(self.rate) } fn stop(&mut self) { self.stream = None; + self.fill = None; + } + fn recover(&mut self) { + if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) { + return; + } + let lost = self.health.lost.swap(false, Ordering::SeqCst); + let errored = self.health.errored.swap(false, Ordering::SeqCst); + match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) { + Verdict::Healthy => {} + Verdict::Restart => { + log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" }); + if let Err(e) = self.restart() { + log::warn!("the audio output stream could not be restarted: {e}"); + self.stream = None; + self.health.failed.store(true, Ordering::SeqCst); + } + } + Verdict::GiveUp => { + log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs()); + self.health.failed.store(true, Ordering::SeqCst); + } + } } fn sample_rate(&self) -> u32 { self.rate @@ -263,7 +425,7 @@ impl AudioOut for CpalOut { self.channels as usize } fn played_frames(&self) -> Option { - self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) + self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) } fn devices(&self) -> AudioDevices { let h = host(&self.hw.device_class); @@ -327,11 +489,92 @@ mod tests { } #[test] - fn failed_stream_relinquishes_the_playback_clock() { - let failed = AtomicBool::new(false); - stream_error(&failed, cpal::StreamError::DeviceNotAvailable); - assert!(failed.load(Ordering::SeqCst)); + fn stream_errors_ask_for_a_restart_instead_of_failing_playback() { + let health = Health::default(); + stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } }); + assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst)); + stream_error(&health, cpal::StreamError::DeviceNotAvailable); + assert!(health.lost.load(Ordering::SeqCst)); + assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback"); let output = CpalOut::new(); - assert_eq!(output.played_frames(), None); + assert_eq!(output.played_frames(), None, "no stream, no clock"); + } + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn an_error_while_samples_keep_flowing_is_only_a_hiccup() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy); + // the stream keeps consuming samples after the error: never restarted + for (i, t) in (20..2_000).step_by(20).enumerate() { + assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy); + } + } + + #[test] + fn a_stream_that_errors_and_stops_consuming_is_restarted() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy); + // POLLERR: the backend reports errors and no longer calls back + assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart); + // the restarted stream gets a fresh grace period + assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy); + // a stall without any reported error is left to the UI's own watchdog + assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy); + } + + #[test] + fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp); + // sporadic failures far apart keep being recovered + assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart); + } + + #[test] + fn the_device_callback_never_panics_and_counts_played_frames() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25))); + let mut buf = [1.0_f32; 8]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4)); + // a mixer still held elsewhere plays silence without moving the clock + { + let _held = fill.lock().unwrap(); + render(&fill, &mut buf, 2, &played, &health); + } + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4)); + // zero channels from a broken driver must not divide by zero + render(&fill, &mut buf, 0, &played, &health); + assert_eq!(played.load(Ordering::SeqCst), 12); + } + + #[test] + fn a_panicking_mixer_plays_silence_and_fails_the_stream() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| { + buf.fill(0.5); + panic!("mixer bug"); + })); + let mut buf = [1.0_f32; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); + assert_eq!(played.load(Ordering::SeqCst), 0); + assert!(health.failed.load(Ordering::SeqCst)); + // later callbacks stay silent instead of re-entering the broken mixer + buf = [1.0; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c4c526f7..361cc9489 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,10 @@ pub trait AudioOut { } /// Frames played since `start` (the playback master clock), if the device reports it. fn played_frames(&self) -> Option; + /// Called every frame while playing, before `played_frames`: restart a stream the device + /// dropped (a driver error, an unplugged device), keeping the clock and `fill`. A stream that + /// cannot be restarted reports `played_frames() == None`. + fn recover(&mut self) {} /// Hosts and devices that can be chosen in Settings ▸ Audio Hardware. fn devices(&self) -> AudioDevices { AudioDevices::default() @@ -1013,6 +1017,11 @@ impl FilmcraftApp { self.playback.anchor_time = now; } let rate = self.session.sequence_rate(); + if self.playback.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let reading = if self.playback.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|f| (f, a.sample_rate()))) } else { None }; if self.playback.audio_clock && reading.is_none() { // A device error relinquishes its clock immediately. Resume from the displayed @@ -2263,6 +2272,52 @@ mod audio_recovery_tests { assert!(app.ui.status.contains("Audio output failed")); app.stop(); } + + /// A stream the device dropped, which `recover` restarts on the same clock. + struct Restartable { + reading: Option, + } + + impl AudioOut for Restartable { + fn start(&mut self, _: Box) -> Result { + Ok(48000) + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48000 + } + fn played_frames(&self) -> Option { + self.reading + } + fn recover(&mut self) { + self.reading = self.reading.or(Some(48_000)); + } + } + + #[test] + fn a_restarted_stream_keeps_the_device_clock_and_the_sound() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + app.audio = Some(Box::new(Restartable { reading: None })); + app.playback.playing = true; + app.playback.audio_clock = true; + app.playback.anchor_tick = Tick::ZERO; + app.playback.anchor_time = 0.0; + app.playback.audio_seen = (0, 10.0); + let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(app.playback.audio_clock, "the restarted stream is still the master clock"); + assert!(!app.ui.status.contains("Audio output failed")); + // 48 000 frames played at 48 kHz: one second in, on a frame boundary + let (shown, second) = (app.session.playhead(), Tick::from_seconds_f64(1.0)); + assert!( + shown <= second && shown + app.session.sequence_rate().frame_duration() > second, + "the playhead follows the frames the device played: {shown:?}" + ); + app.stop(); + } } /// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points. diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index 7cdb7c21f..2a783325d 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -213,6 +213,11 @@ impl FilmcraftApp { self.source_playback.clock.audio_seen = (0, now); self.start_source_audio(); } + if self.source_playback.clock.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let clock = &mut self.source_playback.clock; if clock.anchor_time < 0.0 { clock.anchor_time = now; diff --git a/docs/gaps.md b/docs/gaps.md index f798de682..e54135eae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -229,6 +229,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Evidence:** Asus Xonar DX output broken (#605), no audio (#602), audio cuts out with overlapping clips (#336), buggy audio (#410), Linux stutter (#617). No ASIO; cpal only. +- **Have:** a stream the device drops while playing (an ALSA xrun cpal reports as `POLLERR`, an + unplugged device) is restarted with the same rate, channels, clock and mixer position, so sound + resumes instead of playback going silent (at most 3 restarts in 10 s); a panicking mixer plays + silence (#840). - **Estimate:** 20–40 h. ## G18. Effects depth: approximations and missing behaviour From 09f627a73a04edb41ef3eec9390cc07dad89434d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:51:47 +0200 Subject: [PATCH 021/157] #844: on-monitor corner-roundness handle for masks Problem: masks.roundCorners (#813) rounded rectangle/polygon mask corners only from the command line, control channel or MCP; the Program monitor had no Premiere 26-style corner-roundness handle. Fix: a selected mask with sharp corners between straight edges shows a roundness handle just inside its top-left corner (automation id program.mask.roundness). Dragging it into the shape moves it along the corner bisector, previews the rounded outline live with the radius, and on release dispatches one masks.roundCorners (one undo step per drag). The radius is capped where no corner can grow further. Ellipses, pen masks and rounded corners show no handle. Tooltip translated (es/fr/ja/zh-cn). New headless UI test mask_roundness_ui; docs/gaps.md G18 updated. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/masks.rs | 123 ++++++++++++++++- crates/ui-egui/tests/mask_roundness_ui.rs | 158 ++++++++++++++++++++++ docs/gaps.md | 6 +- 7 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/mask_roundness_ui.rs diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 76cc3066b..692ac2b9a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3488,6 +3488,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones. Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar + Drag inward to round the corners Arrastrar hacia dentro para redondear las esquinas Include Alpha Channel Incluir canal alfa Sequence Settings Ajustes de secuencia Color Management Gestión del color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f44..170b8011a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3478,6 +3478,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par rectangle. Utilisez Maj, Alt et Ctrl pour d'autres options. Click to zoom in; Alt-click to zoom out. Cliquez pour un zoom avant ; Alt+clic pour un zoom arrière. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Ctrl+clic : supprimer + Drag inward to round the corners Glisser vers l'intérieur pour arrondir les angles Include Alpha Channel Inclure la couche alpha Sequence Settings Paramètres de séquence Color Management Gestion des couleurs diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b6..41d31f6c8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3487,6 +3487,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Alt、Ctrl キーを併用すると、ほかのオプションも使えます。 Click to zoom in; Alt-click to zoom out. クリックでズームイン、Alt+クリックでズームアウトします。 Drag: move · Alt-click: smooth/corner · Ctrl-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Ctrl+クリック: 削除 + Drag inward to round the corners 内側にドラッグして角を丸める Include Alpha Channel アルファチャンネルを含める Sequence Settings シーケンス設定 Color Management カラーマネジメント diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 9e0e78038..d74a619bf 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3471,6 +3471,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. 单击以选择,或在空白处单击并拖动以框选。使用 Shift、Alt 和 Ctrl 进行其他操作。 Click to zoom in; Alt-click to zoom out. 单击以放大;Alt-单击以缩小。 Drag: move · Alt-click: smooth/corner · Ctrl-click: delete 拖动:移动 · Alt-单击:平滑/拐角 · Ctrl-单击:删除 + Drag inward to round the corners 向内拖动以使拐角变圆 Include Alpha Channel 包含 Alpha 通道 Sequence Settings 序列设置 Color Management 色彩管理 diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918ea..eb479c17e 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -1,13 +1,13 @@ //! Effect masks in the UI: the mask rows of Effect Controls (create ellipse / 4-point polygon / //! pen masks, per-mask Path / Feather / Opacity / Expansion / Inverted / mode) and on-monitor -//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather and -//! expansion handles; the pen places a free-draw Bézier mask). +//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather, +//! expansion and corner-roundness handles; the pen places a free-draw Bézier mask). //! //! Automation ids: `effectControls..mask.{ellipse,polygon,pen}`, //! `effectControls..mask` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows //! `effectControls..mask..*`), `program.mask.vertex.`, `program.mask.in.`, //! `program.mask.out.`, `program.mask.body`, `program.mask.feather`, `program.mask.expansion`, -//! `program.maskPen`. +//! `program.mask.roundness`, `program.maskPen`. use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use filmcraft_geom::{Affine, Vec2}; @@ -344,6 +344,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } } } + // ---- corner roundness (masks with sharp corners: rectangles, polygons) + actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target)); // ---- tangent handles and vertices let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); let sel_v: Option = ui.data(|d| d.get_temp(sel_v_id)); @@ -436,6 +438,121 @@ fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { (p - (a + ab * t)).length() } +/// A sharp corner `masks.roundCorners` can fillet: its point, the unit bisector pointing to the +/// fillet's centre, `sin(θ/2)` of its opening angle θ, and the largest radius it takes (the fillet +/// never uses more than half of either edge). +struct RoundableCorner { + p: Vec2, + bisector: Vec2, + sin_half: f64, + max_radius: f64, +} + +/// The corners of `path` that rounding changes (degenerate ones — straight, reversing, zero-length +/// edges — are left out). +fn roundable_corners(path: &MaskPath) -> Vec { + let n = path.vertices.len(); + path.roundable_corners() + .into_iter() + .filter_map(|i| { + let v = path.vertices.get(i)?.p; + let prev = path.vertices.get((i + n - 1) % n)?.p; + let next = path.vertices.get((i + 1) % n)?.p; + let (a, b) = (prev - v, next - v); + let (la, lb) = (a.length(), b.length()); + if !(la > 1e-6 && lb > 1e-6) { + return None; + } + let (u, w) = (a * (1.0 / la), b * (1.0 / lb)); + let theta = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos(); + if !(1e-6..std::f64::consts::PI - 1e-6).contains(&theta) { + return None; + } + let s = u + w; + let sl = s.length(); + if !sl.is_finite() || sl <= 1e-9 { + return None; + } + let half = theta / 2.0; + let max_radius = la.min(lb) / 2.0 * half.tan(); + max_radius.is_finite().then_some(RoundableCorner { p: v, bisector: s * (1.0 / sl), sin_half: half.sin(), max_radius }) + }) + .collect() +} + +/// Radius of a roundness drag that has moved `dist` clip pixels along the corner's bisector: the +/// handle follows the fillet's centre, so it sits `r / sin(θ/2)` from the corner. +fn drag_radius(dist: f64, c: &RoundableCorner, max_radius: f64) -> f64 { + let r = dist * c.sin_half; + if r.is_finite() { r.clamp(0.0, max_radius.max(0.0)) } else { 0.0 } +} + +/// The corner-roundness handle of the selected mask, just inside its top-left sharp corner. +/// Dragging it into the shape previews the rounded corners live; releasing rounds every sharp +/// corner with one `masks.roundCorners` (one undo step per drag). Masks without a sharp corner +/// between straight edges (ellipses, pen masks, corners already rounded) show no handle. +fn roundness_handle( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + path: &MaskPath, + to_screen: &Affine, + target: &Value, +) -> Option<(String, Value)> { + let corners = roundable_corners(path); + let to_clip = to_screen.inverse()?; + let lin_inv = Affine { e: 0.0, f: 0.0, ..to_clip }; + let c = corners.iter().min_by(|a, b| { + let (pa, pb) = (sp(to_screen, a.p), sp(to_screen, b.p)); + (pa.x + pa.y).total_cmp(&(pb.x + pb.y)) + })?; + let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max); + let id = egui::Id::new("mask-roundness"); + let dist_id = id.with("dist"); + let dragging = ui.ctx().is_being_dragged(id); + let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 }; + let radius = drag_radius(dist, c, max_radius); + let anchor = sp(to_screen, c.p); + let dir = sp(to_screen, c.p + c.bisector) - anchor; + let dir = if dir.length() > 1e-6 { dir.normalized() } else { egui::Vec2::new(1.0, 1.0).normalized() }; + let hp = sp(to_screen, c.p + c.bisector * (radius / c.sin_half.max(1e-9))) + dir * 18.0; + let hr = Rect::from_center_size(hp, vec2(9.0, 9.0)); + let r = ui.interact(hr.expand(3.0), id, Sense::drag()).on_hover_text(tl!("Drag inward to round the corners")); + app.auto.add("program.mask.roundness", hr, "Mask corner roundness"); + let col = Color32::from_rgb(0x5a, 0xb4, 0xff); + if dragging && radius > 0.0 { + let preview = path.with_rounded_corners(radius, &path.roundable_corners()).flatten(0.5); + painter.add(egui::Shape::closed_line(preview.iter().map(|v| sp(to_screen, *v)).collect(), Stroke::new(1.5, Color32::WHITE))); + painter.text(hp + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, format!("{radius:.1}"), Tokens::ui(10.5), app.tokens.hot_text); + } + painter.line_segment([anchor, hp], Stroke::new(1.0, col.gamma_multiply(0.6))); + painter.circle_filled(hp, 4.0, Color32::WHITE); + painter.circle_stroke(hp, 4.0, Stroke::new(1.5, col)); + let primary = egui::PointerButton::Primary; + if r.drag_started_by(primary) { + ui.data_mut(|d| d.insert_temp(dist_id, 0.0_f64)); + } + if r.dragged_by(primary) && r.drag_delta() != egui::Vec2::ZERO { + let d = lin_inv.apply(Vec2::new(r.drag_delta().x as f64, r.drag_delta().y as f64)); + let along = d.x * c.bisector.x + d.y * c.bisector.y; + // accumulate the raw distance: past the limit the handle waits for the pointer to come back + let total = ui.data(|m| m.get_temp::(dist_id)).unwrap_or(0.0) + along; + ui.data_mut(|m| m.insert_temp(dist_id, if total.is_finite() { total } else { 0.0 })); + } + if r.drag_stopped_by(primary) { + let dist: f64 = ui.data_mut(|d| d.remove_temp(dist_id)).unwrap_or(0.0); + let radius = (drag_radius(dist, c, max_radius) * 10.0).round() / 10.0; + if radius > 0.0 { + let mut p = target.clone(); + if let Some(o) = p.as_object_mut() { + o.insert("radius".into(), json!(radius)); + } + return Some(("masks.roundCorners".into(), p)); + } + } + None +} + /// Draft vertices → a mask path. pub fn draft_path(points: &[[f64; 4]]) -> MaskPath { MaskPath { diff --git a/crates/ui-egui/tests/mask_roundness_ui.rs b/crates/ui-egui/tests/mask_roundness_ui.rs new file mode 100644 index 000000000..4311afcf7 --- /dev/null +++ b/crates/ui-egui/tests/mask_roundness_ui.rs @@ -0,0 +1,158 @@ +//! The corner-roundness handle of rectangle / polygon masks on the Program monitor, driven +//! headless through the control channel: dragging it rounds the corners in one undo step; masks +//! without sharp corners show no handle. +//! +//! With `FILMCRAFT_UI_SHOTS=` the test also renders the UI with wgpu and writes PNGs there. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + shots: Option, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}: {v}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + fn center(&mut self, id: &str) -> (f64, f64) { + let r = self.rect(id); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) + } + fn drag(&mut self, id: &str, dx: f64, dy: f64) { + let (x, y) = self.center(id); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6})); + self.frames(2); + } + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().is_some_and(|a| a.iter().any(|e| e["id"] == id)) + } + fn masks(&mut self) -> Vec { + self.exec("masks.list", json!({}))["masks"].as_array().unwrap().clone() + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + // let the background frame workers finish the (blurred) frame + for _ in 0..40 { + self.frames(1); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +fn vertex_count(m: &Value) -> usize { + m["path"]["vertices"].as_array().map_or(0, Vec::len) +} + +#[test] +fn roundness_handle_rounds_mask_corners_in_one_undo_step() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + + // an ellipse has no sharp corner: no roundness handle + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse"})); + d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 0})); + d.frames(3); + assert!(d.has("program.mask.vertex.0"), "the mask overlay is on the monitor"); + assert!(!d.has("program.mask.roundness"), "no roundness handle on an ellipse"); + + // a rectangle has one, just inside its top-left corner + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "rectangle", "center": [960, 540], "size": [960, 540]})); + d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 1})); + d.frames(3); + assert!(d.has("program.mask.roundness")); + let corner = d.center("program.mask.vertex.0"); + let handle = d.center("program.mask.roundness"); + assert!(handle.0 > corner.0 && handle.1 > corner.1, "handle {handle:?} inside the top-left corner {corner:?}"); + assert_eq!(vertex_count(&d.masks()[1]), 4); + d.shot("mask-roundness-handle"); + + // a click without a drag changes nothing + let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + d.ok("ui.click", json!({"id": "program.mask.roundness"})); + d.frames(2); + assert_eq!(vertex_count(&d.masks()[1]), 4); + assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0); + + // dragging inward rounds all four corners: eight vertices, one undo step + d.drag("program.mask.roundness", 30.0, 30.0); + let m = d.masks(); + assert_eq!(vertex_count(&m[1]), 8, "{}", m[1]["path"]); + let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + assert_eq!(undo1, undo0 + 1, "one undo step per drag"); + // the first fillet starts below the old top-left corner (960 − 480, 540 − 270) + let p = &m[1]["path"]["vertices"][0]["p"]; + let (x, y) = (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()); + assert!((x - 480.0).abs() < 1e-6 && y > 270.0 + 10.0, "fillet start ({x}, {y})"); + d.shot("mask-rounded-corners"); + // the rounded corners are no longer sharp: the handle is gone + d.frames(2); + assert!(!d.has("program.mask.roundness")); + + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(vertex_count(&d.masks()[1]), 4); + assert!(d.has("program.mask.roundness")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..52ef93ed7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,15 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles, - redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (constrained lines; a stored, re-editable corner radius: rounding + bakes fillets into the path, so a rounded corner cannot be re-rounded later). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; - #813). + #813), from an on-monitor corner-roundness handle with a live preview (one undo step per drag; + #844). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 8e61e610cc71d51f2e81205f0e0c11c02208aa00 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:54:49 +0200 Subject: [PATCH 022/157] #850: launch files open whatever the extension's case, with absolute paths Problem: the desktop app picked the project among its launch files with a case-sensitive ends_with(".fcproj"), so CUT.FCPROJ opened through a Windows file association or the Linux MIME glob was imported as media. Relative launch paths were stored as given and went missing from another folder. std::env::args panicked on a non-Unicode argument. filmcraft-cli import used canonicalize, which stores \\?\ verbatim paths on Windows. Fix: args::project_and_media finds the project by a case-insensitive extension and makes every launch path absolute with std::path::absolute (no verbatim prefix, symlinks kept). Both apps read args_os; the desktop app keeps --data-dir exactly. filmcraft-cli import uses std::path::absolute. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/main.rs | 34 +++++++++++-- apps/filmcraft/src/args.rs | 91 +++++++++++++++++++++++++++++++--- apps/filmcraft/src/main.rs | 5 +- docs/gaps.md | 3 ++ 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 10592444f..22fe121f0 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -244,6 +244,13 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability { filmcraft_platform::register() } +/// `path` made absolute against the working directory, as the project stores it. Unlike +/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\` +/// prefix (#850). A path it cannot resolve is kept as given. +fn absolute(path: &str) -> String { + std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string()) +} + #[tokio::main] async fn main() { cli().await; @@ -253,7 +260,8 @@ async fn main() { } async fn cli() { - let a = Args::parse(std::env::args().skip(1)); + // `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1) + let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned())); // `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference: // the parser takes it as a flag option, so it never reaches the subcommand match. if a.flag("--help") { @@ -373,8 +381,7 @@ async fn cli() { print(&a, &v); } "import" => { - let paths: Vec = - a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect(); + let paths: Vec = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect(); if paths.is_empty() { usage("import ..."); } @@ -590,6 +597,27 @@ mod format_tests { assert!(p.get("range").is_none()); } + /// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…` + /// verbatim paths in the project (#850). + #[test] + fn imported_paths_are_absolute_without_resolving_links() { + let cwd = std::env::current_dir().unwrap(); + assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy()); + assert!(!super::absolute("clip.mov").starts_with(r"\\?\")); + assert_eq!(super::absolute(""), ""); + #[cfg(unix)] + { + let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("real.mov"), b"x").unwrap(); + let link = dir.join("link.mov"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap(); + assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy()); + std::fs::remove_dir_all(&dir).unwrap(); + } + } + #[test] fn export_format_from_extension() { assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a")); diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a9684..7e5479d15 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -1,5 +1,8 @@ //! The desktop app's command line (`filmcraft --help`). +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + /// `filmcraft --help`. pub const USAGE: &str = "\ Usage: filmcraft [options] [project.fcproj | media files...] @@ -45,29 +48,35 @@ pub struct Launch { /// mistyped flag helps nobody, and starting without the control server that was asked for leaves /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. -pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { +/// +/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name +/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily +/// (its import then reports it missing); `--data-dir` keeps the folder exactly. +pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); - while let Some(a) = args.next() { - match a.as_str() { + while let Some(raw) = args.next() { + let a = raw.to_string_lossy(); + match a.as_ref() { "-h" | "--help" => help = true, "--version" => version = true, "--control" => { let port = args.next().ok_or("--control needs a port number")?; + let port = port.to_string_lossy(); l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?); } "--demo" => (l.demo, l.startup_flag) = (true, true), "--empty" => (l.demo, l.startup_flag) = (false, true), "--recover" => l.recover = Some(true), "--no-recover" => l.recover = Some(false), - "--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?), + "--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?), // everything after `--` is a file, whatever it looks like - "--" => l.files.extend(args.by_ref()), + "--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())), // macOS adds a process serial number when it launches an app bundle _ if a.starts_with("-psn_") => {} _ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")), - _ => l.files.push(a), + _ => l.files.push(a.into_owned()), } } if help { @@ -84,12 +93,34 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator bool { + Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj")) +} + +/// `path` made absolute against the working directory, so a project or media file named relative +/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the +/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks +/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given. +pub fn absolute(path: &str) -> String { + std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string()) +} + +/// The launch files as the project to open (the first one, see [`is_project`]) and the media files +/// to import (every file that is not a project), all made [`absolute`]. +pub fn project_and_media(files: &[String]) -> (Option, Vec) { + let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f)); + let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect(); + (project, media) +} + #[cfg(test)] mod tests { use super::*; fn parse_env(env: Option<&str>, args: &[&str]) -> Result { - parse(env.map(str::to_string), args.iter().map(|a| a.to_string())) + parse(env.map(str::to_string), args.iter().map(OsString::from)) } fn parse_args(args: &[&str]) -> Result { @@ -168,4 +199,50 @@ mod tests { assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]); assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]); } + + /// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on + /// Linux) stopped the app before its window opened. + #[cfg(unix)] + #[test] + fn arguments_that_are_not_unicode_still_start_the_app() { + use std::os::unix::ffi::OsStringExt; + let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec()); + let file = OsString::from_vec(b"caf\xe9.mov".to_vec()); + let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else { + panic!("should start the app"); + }; + assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly"); + assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]); + } + + /// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be + /// imported as a media file instead of opened (#850). + #[test] + fn a_project_is_recognised_whatever_the_case_of_its_extension() { + for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] { + assert!(is_project(p), "{p}"); + } + for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] { + assert!(!is_project(p), "{p}"); + } + } + + /// Relative launch paths were opened and imported as given, so the project's media paths and + /// Recent Projects pointed nowhere once the app ran from another folder (#850). + #[test] + fn launch_files_are_made_absolute_and_split_case_insensitively() { + let cwd = std::env::current_dir().unwrap(); + let nested = Path::new("clips").join("a.mp4"); + let already = cwd.join("b.wav").to_string_lossy().into_owned(); + let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()]; + let (project, media) = project_and_media(&files); + assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned())); + assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]); + for p in media.iter().chain(&project) { + assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}"); + } + assert_eq!(project_and_media(&[]), (None, vec![])); + // nothing to resolve against: kept as given rather than dropped + assert_eq!(absolute(""), ""); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00fb..38e2a6b41 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -80,7 +80,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args_os().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -157,7 +157,7 @@ fn main() -> eframe::Result { } // voice-over recording reads the microphone through cpal session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); + let (project, media) = args::project_and_media(&files); if let Some(p) = project { if let Err(e) = session.execute("file.open", json!({"path": p})) { log::error!("{e}"); @@ -179,7 +179,6 @@ fn main() -> eframe::Result { } else if demo { let _ = session.execute("file.openDemoProject", json!({})); } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); if !media.is_empty() { let _ = session.execute("file.import", json!({"paths": media})); } diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..de518a7f6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -148,6 +148,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G9. Windows and Linux at run time +- **Have:** files given at launch (terminal, Open With, file associations) open whatever the case + of `.fcproj` and are made absolute, and arguments that are not Unicode no longer stop the start; + `filmcraft-cli import` stores no Windows `\\?\` paths (#850). - **Missing:** routine runtime testing off macOS. Linux playback stutter (#617), Wayland title bar (#433), case-sensitive extensions (#593), "Report an issue" link (#642), Windows bins cannot be left (#587), Sequence Settings won't open on Windows (#586, #351), old hardware (#491). From 1edcaa4e78f873eadd74df80018a40d271803057 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:09:49 +0200 Subject: [PATCH 023/157] =?UTF-8?q?#853:=20Shift=20constrains=20pen=20mask?= =?UTF-8?q?=20segments=20to=2045=C2=B0=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the pen mask tool ignored Shift, so straight horizontal, vertical or diagonal mask edges could not be drawn precisely (Premiere 26.0 constrains mask lines with Shift). Separately, path_from_json accepted the `{"p","in","out"}` vertex form through serde first, which silently dropped the `in`/`out` tangents, so a pen mask's dragged-out curves were committed as corners. Fix: with Shift held, a new pen point snaps onto the nearest 45° ray from the previous point (in screen space), the rubber-band preview follows, and a Shift-drag constrains the Bézier handle it pulls out. path_from_json now parses the vertex forms before falling back to the serialized MaskPath. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/masks.rs | 9 ++++-- crates/engine/src/masks_tests.rs | 19 ++++++++++++ crates/ui-egui/src/panels/masks.rs | 46 ++++++++++++++++++++++++++++-- crates/ui-egui/tests/masks_ui.rs | 44 ++++++++++++++++++++++++++++ docs/gaps.md | 5 ++-- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index ffa5a5a16..37da8ac09 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec { /// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices /// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points. pub fn path_from_json(v: &Value) -> Option { - if let Ok(p) = serde_json::from_value::(v.clone()) { - return Some(p); - } + // The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` / + // `masks.add` form) but silently drops the unknown `in` / `out` tangents. + vertex_path(v).or_else(|| serde_json::from_value::(v.clone()).ok()) +} + +fn vertex_path(v: &Value) -> Option { let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?; let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true); let mut vertices = Vec::new(); diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 1fc069a84..c25cb4680 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() assert_eq!(v.len(), 6); assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); } + +/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so +/// a pen mask's dragged-out curves were committed as corners; #853). +#[test] +fn path_json_round_trip_keeps_tangents() { + use filmcraft_geom::Vec2; + use filmcraft_project::mask::{MaskPath, MaskVertex}; + let path = MaskPath { + vertices: vec![ + MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) }, + MaskVertex::corner(Vec2::new(80.0, 20.0)), + MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) }, + ], + closed: true, + }; + assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone())); + // the serialized project form still parses + assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path)); +} diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918ea..c3cd161ba 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -464,6 +464,12 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 let painter = ui.painter().with_clip_rect(pic); let to_c = |p: Pos2| to_clip.apply(Vec2::new(p.x as f64, p.y as f64)); let mut d = draft.clone(); + // Shift constrains the segment from the previous point (and a dragged-out handle) to 45° steps + let shift = ui.input(|i| i.modifiers.shift); + let snap = |from: Option<&[f64; 4]>, p: Pos2| match from { + Some(q) if shift => constrain_45(sp(&to_screen, Vec2::new(q[0], q[1])), p), + _ => p, + }; let mut commit = false; let pressed = resp.drag_started() || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::new("mask-pen-placed")).unwrap_or(false))); if pressed && let Some(p) = resp.interact_pointer_pos() { @@ -471,7 +477,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 if d.points.len() >= 3 && near_first { commit = true; } else { - let c = to_c(p); + let c = to_c(snap(d.points.last(), p)); d.points.push([c.x, c.y, 0.0, 0.0]); ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), resp.drag_started())); } @@ -483,7 +489,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 && !commit && let (Some(p), Some(last)) = (resp.interact_pointer_pos(), d.points.last_mut()) { - let c = to_c(p); + let c = to_c(snap(Some(last), p)); let tan = Vec2::new(c.x - last[0], c.y - last[1]); if tan.length() > 2.0 { last[2] = tan.x; @@ -504,7 +510,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 painter.add(egui::Shape::line(pts, Stroke::new(1.5, col))); } if let (Some(h), Some(l)) = (resp.hover_pos(), d.points.last()) { - painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), h], Stroke::new(1.0, col.gamma_multiply(0.5))); + painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), snap(Some(l), h)], Stroke::new(1.0, col.gamma_multiply(0.5))); } for (i, q) in d.points.iter().enumerate() { let p = sp(&to_screen, Vec2::new(q[0], q[1])); @@ -549,6 +555,19 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec { out } +/// `to` moved onto the nearest 45° ray from `from` (horizontal, vertical or diagonal on screen), +/// keeping its distance along that ray; `to` itself when it coincides with `from` or is not finite. +fn constrain_45(from: Pos2, to: Pos2) -> Pos2 { + let v = to - from; + if !(v.x.is_finite() && v.y.is_finite()) || v.length_sq() == 0.0 { + return to; + } + let step = std::f32::consts::FRAC_PI_4; + let a = (v.y.atan2(v.x) / step).round() * step; + let dir = egui::Vec2::new(a.cos(), a.sin()); + from + dir * v.dot(dir) +} + /// The Mask Path row's value area: mask tracking (backward continuously / one frame, forward one /// frame / continuously; clicking while a track runs stops it) and the tracking-method menu. pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effect: usize, mask: Option, actions: &mut Vec<(String, Value)>) { @@ -607,3 +626,24 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec ui.ctx().request_repaint(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constrain_45_snaps_to_the_nearest_eighth_turn() { + let o = pos2(10.0, 20.0); + let near = |a: Pos2, b: Pos2| (a - b).length() < 1e-3; + // nearly horizontal / vertical: the off-axis component goes + assert!(near(constrain_45(o, pos2(110.0, 30.0)), pos2(110.0, 20.0))); + assert!(near(constrain_45(o, pos2(5.0, -80.0)), pos2(10.0, -80.0))); + // nearly diagonal: projected onto the 45° ray + let d = constrain_45(o, pos2(60.0, 66.0)); + assert!(((d.x - o.x) - (d.y - o.y)).abs() < 1e-3, "{d:?}"); + // degenerate input comes back unchanged + assert_eq!(constrain_45(o, o), o); + let nan = pos2(f32::NAN, 1.0); + assert!(constrain_45(o, nan).x.is_nan()); + } +} diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs index b958f9409..08934963f 100644 --- a/crates/ui-egui/tests/masks_ui.rs +++ b/crates/ui-egui/tests/masks_ui.rs @@ -216,6 +216,50 @@ fn create_and_edit_masks_on_the_monitor() { d.shot("mask-tracked"); } +/// Shift held while placing pen points snaps each new segment to 45° steps from the previous +/// point; a Shift-drag also constrains the Bézier handle it pulls out (#853). +#[test] +fn shift_constrains_pen_mask_segments_to_45_degrees() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + d.frames(3); + d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"})); + d.frames(2); + let pic = d.rect("program.picture"); + let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy}); + // hold Shift (as the OS reports a held key) across the clicks + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + d.frames(1); + for (fx, fy) in [(0.3, 0.3), (0.6, 0.34), (0.63, 0.6)] { + d.ok("ui.click", at(fx, fy)); + d.frames(2); + } + // a Shift-drag places the fourth point and pulls out its handle + d.ok("ui.drag", json!({"from": at(0.32, 0.62), "to": at(0.42, 0.64), "steps": 6, "modifiers": {"shift": true}})); + d.frames(2); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(1); + d.ok("ui.click", at(0.3, 0.3)); + d.frames(3); + let m = d.masks(); + let pen = m.iter().find(|x| x["effectId"] == "opacity").unwrap_or_else(|| panic!("opacity mask: {m:?}")); + assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 4, "{pen}"); + let (p0, p1, p2, p3) = (v0(pen, 0), v0(pen, 1), v0(pen, 2), v0(pen, 3)); + assert!((p1.1 - p0.1).abs() < 1e-6, "segment 0→1 horizontal: {p0:?} {p1:?}"); + assert!(p1.0 > p0.0 + 50.0, "{p0:?} {p1:?}"); + assert!((p2.0 - p1.0).abs() < 1e-6, "segment 1→2 vertical: {p1:?} {p2:?}"); + assert!(p2.1 > p1.1 + 50.0, "{p1:?} {p2:?}"); + assert!((p3.1 - p2.1).abs() < 1e-6, "segment 2→3 horizontal: {p2:?} {p3:?}"); + let out = &pen["path"]["vertices"][3]["out"]; + let (ox, oy) = (out[0].as_f64().unwrap(), out[1].as_f64().unwrap()); + assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}"); +} + #[test] fn presets_bin_drag_and_save_dialog() { let mut d = Driver::demo(); diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..866ba40e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,14 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles, - redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; an on-monitor + corner-roundness handle). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; - #813). + #813); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From b964b9970a8286b141ed081f4db904fbcc77c05e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:16:50 +1100 Subject: [PATCH 024/157] #856: Reject negative times on timeline.place and markers.add Clips and markers before sequence start were accepted, then unreachable in the GUI and cut off on export. Time, frame and seconds before 0 now return a parameter error; zero remains valid. --- crates/engine/src/commands.rs | 13 ++++++++++-- crates/engine/src/sequence_extras.rs | 4 ++-- crates/engine/src/sequence_tools.rs | 6 +++--- crates/engine/src/tests.rs | 31 ++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..6f7d25226 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -239,6 +239,15 @@ pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { None } +/// Sequence time from params. Times before 0 are unreachable in the GUI and cut off on export. +pub(crate) fn sequence_time_p(s: &Session, p: &Value, prefix: &str, cmd: &str) -> Result> { + let Some(t) = time_p(s, p, prefix) else { return Ok(None) }; + if t < Tick::ZERO { + return Err(bad(cmd, "time must not be negative")); + } + Ok(Some(t)) +} + pub(crate) fn clip_p(p: &Value, k: &str) -> Option { u64_p(p, k).map(ClipId) } @@ -1999,7 +2008,7 @@ fn build() -> Vec { return Ok(v); } let (_seq, ph) = marker_list_mut(s)?; - let t = time_p(s, p, "").unwrap_or(ph); + let t = sequence_time_p(s, p, "", "markers.add")?.unwrap_or(ph); let name = str_p(p, "name").unwrap_or("").to_string(); let comment = str_p(p, "comment").unwrap_or("").to_string(); let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); @@ -2200,7 +2209,7 @@ fn build() -> Vec { return Err(bad("timeline.place", "enable video, audio, or both")); } let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; - let at = time_p(s, p, "").unwrap_or(s.playhead()); + let at = sequence_time_p(s, p, "", "timeline.place")?.unwrap_or(s.playhead()); let tg = s.targeting(); let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 389590c81..8b5d8e6d8 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, sequence_time_p, str_p, u64_p}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -483,7 +483,7 @@ fn show_active_only(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- fn add_flash_cue(s: &mut Session, p: &Value) -> Result { - let t = s.sequence_rate().snap(time_p(s, p, "").unwrap_or(s.playhead())); + let t = s.sequence_rate().snap(sequence_time_p(s, p, "", "markers.addFlashCue")?.unwrap_or(s.playhead())); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); let comment = str_p(p, "comment").unwrap_or("").to_string(); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c57953..897b91125 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -31,7 +31,7 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links}; +use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, sequence_time_p, str_p, time_p, track_p, with_links}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -206,7 +206,7 @@ fn groups() -> Vec<(&'static str, Vec)> { r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#, has_seq, |s, p| { - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addChapter")?.unwrap_or(s.playhead()); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}")); let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; @@ -977,7 +977,7 @@ fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind /// `duration` say otherwise) at the playhead. fn add_range_marker(s: &mut Session, p: &Value) -> Result { let rate = s.sequence_rate(); - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addRange")?.unwrap_or(s.playhead()); let dur = p .get("duration") .and_then(Value::as_i64) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d7..3b01ca7db 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -958,3 +958,34 @@ fn enable_flips_each_selected_clip() { s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); } + +/// Times before sequence start are unreachable in the GUI and cut off on export. +#[test] +fn negative_place_and_marker_times_are_refused() { + let mut s = demo(); + let item = s.active_sequence().unwrap().video_tracks[0].items[0].item; + let before = s.project.clone(); + let n_markers = s.active_sequence().unwrap().markers.len(); + let n_clips: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + for (cmd, params) in [ + ("timeline.place", json!({"item": item.0, "frame": -12})), + ("timeline.place", json!({"item": item.0, "seconds": -1.0})), + ("timeline.place", json!({"item": item.0, "time": -1})), + ("markers.add", json!({"frame": -10})), + ("markers.add", json!({"seconds": -0.5})), + ("markers.add", json!({"time": i64::MIN})), + ("markers.addChapter", json!({"frame": -1})), + ("markers.addRange", json!({"time": -1, "durationFrames": 10})), + ("markers.addFlashCue", json!({"frame": -3})), + ] { + let e = s.execute(cmd, params.clone()).expect_err(&format!("{cmd} {params}")); + assert!(matches!(&e, EngineError::BadParams { cmd: c, msg } if c == cmd && msg.contains("negative")), "{cmd} {params}: {e:?}"); + assert_eq!(*s.project, *before); + } + assert_eq!(s.active_sequence().unwrap().markers.len(), n_markers); + let n_clips_after: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + assert_eq!(n_clips_after, n_clips); + s.execute("timeline.place", json!({"item": item.0, "frame": 0})).unwrap(); + s.execute("markers.add", json!({"frame": 0, "name": "start"})).unwrap(); + assert!(s.active_sequence().unwrap().markers.iter().any(|m| m.start == Tick::ZERO && m.name == "start")); +} From c0f1383d6695ded3235688627a6a27fdfcc6e4ab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:17:05 +0200 Subject: [PATCH 025/157] #857: reject unknown marker colour names instead of storing Green Problem: program-monitor markers.add (and markers.addRange, addRangeInOut, addChapter, addFlashCue) mapped `color` with Label::from_name and fell back to Green on any unknown name, so `{color:"Red"}` succeeded and stored Green; markers.edit silently ignored an unknown colour. Fix: one marker_color_p helper turns a non-label `color` into a bad-params error that lists the accepted label names; every marker command (and the Source monitor path, which already rejected it) uses it. Absent or null still means the default. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 17 +++++++++++++++-- crates/engine/src/sequence_extras.rs | 4 ++-- crates/engine/src/sequence_tools.rs | 12 ++++++------ crates/engine/src/sequence_tools_tests.rs | 21 +++++++++++++++++++++ crates/engine/src/source_monitor.rs | 2 +- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..6d99827d4 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -185,6 +185,18 @@ pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option< pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A marker's `color` param: absent (or null) is `None`; anything but a label name is an error +/// that lists the accepted names, instead of silently becoming Green (#857). +pub(crate) fn marker_color_p(p: &Value, cmd: &str) -> Result> { + let v = match p.get("color") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + v.as_str().and_then(Label::from_name).map(Some).ok_or_else(|| { + let names: Vec<&str> = Label::ALL.iter().map(|l| l.name()).collect(); + bad(cmd, format!("unknown marker color {v}; use one of: {}", names.join(", "))) + }) +} pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } @@ -2002,7 +2014,7 @@ fn build() -> Vec { let t = time_p(s, p, "").unwrap_or(ph); let name = str_p(p, "name").unwrap_or("").to_string(); let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, "markers.add")?.unwrap_or(Label::Green); let dur = p.get("durationFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)).unwrap_or_default(); let id = s.edit_sequence("Add Marker", |q, ctx, _| { let id = MarkerId(ctx.alloc()); @@ -2078,6 +2090,7 @@ fn build() -> Vec { } let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?; let rate = s.sequence_rate(); + let color = marker_color_p(p, "markers.edit")?; let p = p.clone(); s.edit_sequence("Edit Marker", |q, _, _| { let m = q.markers.iter_mut().find(|m| m.id == id).ok_or_else(|| bad("markers.edit", "no such marker"))?; @@ -2087,7 +2100,7 @@ fn build() -> Vec { if let Some(c) = str_p(&p, "comment") { m.comment = c.into(); } - if let Some(c) = str_p(&p, "color").and_then(Label::from_name) { + if let Some(c) = color { m.color = c; } if let Some(f) = p.get("durationFrames").and_then(Value::as_i64) { diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 389590c81..c67870107 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, marker_color_p, str_p, time_p, u64_p}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -487,7 +487,7 @@ fn add_flash_cue(s: &mut Session, p: &Value) -> Result { let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, "markers.addFlashCue")?.unwrap_or(Label::Green); let dur = u64_p(p, "durationFrames").map(|f| s.sequence_rate().tick_of(f as i64)).unwrap_or(Tick::ZERO); let id = s.edit_sequence("Add Flash Cue Marker", |q, ctx, _| { let id = MarkerId(ctx.alloc()); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c57953..c3dd3db06 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -31,7 +31,7 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links}; +use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, marker_color_p, str_p, time_p, track_p, with_links}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -209,7 +209,7 @@ fn groups() -> Vec<(&'static str, Vec)> { let t = time_p(s, p, "").unwrap_or(s.playhead()); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}")); - let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; + let id = add_marker(s, "markers.addChapter", "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; Ok(json!({"marker": id.0})) }, ), @@ -962,9 +962,9 @@ pub fn ripple_markers(markers: &mut Vec, from: Tick, shift: Tick) { markers.sort_by_key(|m| m.start); } -fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result { +fn add_marker(s: &mut Session, cmd: &str, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result { let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, cmd)?.unwrap_or(Label::Green); s.edit_sequence(label, |q, ctx, _| { let id = MarkerId(ctx.alloc()); q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind, color }); @@ -988,7 +988,7 @@ fn add_range_marker(s: &mut Session, p: &Value) -> Result { return Err(bad("markers.addRange", "the duration must be positive")); } let name = str_p(p, "name").unwrap_or("").to_string(); - let id = add_marker(s, "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?; + let id = add_marker(s, "markers.addRange", "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?; Ok(json!({"marker": id.0})) } @@ -998,7 +998,7 @@ fn add_range_marker_in_out(s: &mut Session, p: &Value) -> Result { let (Some(i), Some(o)) = (q.mark_in, q.mark_out) else { return Err(EngineError::Other("mark an In and an Out point first".into())) }; let dur = o + q.settings.frame_rate.frame_duration() - i; let name = str_p(p, "name").unwrap_or("").to_string(); - let id = add_marker(s, "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?; + let id = add_marker(s, "markers.addRangeInOut", "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?; Ok(json!({"marker": id.0})) } diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index d275f9b3e..817623441 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -476,6 +476,27 @@ fn range_and_chapter_markers() { assert!(s.active_sequence().unwrap().markers.is_empty()); } +#[test] +fn unknown_marker_colours_are_rejected_not_stored_as_green() { + // #857: `markers.add {color:"Red"}` used to succeed and store Green + let mut s = demo(); + let before = s.project.to_json(); + for cmd in ["markers.add", "markers.addRange", "markers.addChapter", "markers.addFlashCue"] { + for color in [json!("Red"), json!("NotAColor"), json!(3)] { + let err = s.execute(cmd, json!({"frame": 10, "color": color})).unwrap_err().to_string(); + assert!(err.contains("Rose"), "{cmd}: the error lists the accepted names: {err}"); + assert_eq!(s.project.to_json(), before, "{cmd} {color} must not add a marker"); + } + } + let id = s.execute("markers.add", json!({"frame": 10, "color": "rose"})).unwrap()["marker"].as_u64().unwrap(); + let colour = |s: &Session| s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().color; + assert_eq!(colour(&s), Label::Rose); + assert!(s.execute("markers.edit", json!({"marker": id, "color": "Red"})).is_err()); + assert_eq!(colour(&s), Label::Rose, "a bad colour leaves the marker unchanged"); + s.execute("markers.edit", json!({"marker": id, "color": "Teal"})).unwrap(); + assert_eq!(colour(&s), Label::Teal); +} + #[test] fn ripple_sequence_markers_follow_ripple_edits() { let mut s = demo(); diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index 2e7ec7455..af97205c5 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -274,7 +274,7 @@ fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result> { p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose() } fn color(p: &Value, id: &str) -> Result> { - text(p, "color", id)?.map(|t| Label::from_name(t).ok_or_else(|| bad(id, "unknown marker color"))).transpose() + crate::commands::marker_color_p(p, id) } fn duration(p: &Value, view: &crate::clip_ops::SourceView, id: &str) -> Result { let Some(v) = p.get("durationFrames") else { return Ok(Tick::ZERO) }; From 111991591b9f6a185c7cd759745243ad6170e9b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:17:47 +1100 Subject: [PATCH 026/157] Roll linked audio with the picture cut (#855). timeline.roll follows linked partners at the same through-edit when linked selection is on, so a +12 frame roll on V1 also moves A1. The command reports linked:false when the cut is not followed as a pair. Reversed clips keep the unmoved-edge media mapping (#526/#558). --- crates/engine/src/commands.rs | 63 ++++++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/roll_linked_tests.rs | 86 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 13 ++-- 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/roll_linked_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afddf..2e76582f9 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -411,6 +411,47 @@ pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit: Ok(()) } +/// Adjacent roll pairs that follow `left`/`right` when linked selection is on: the given cut, plus +/// through-edits between the same pieces' linked partners at that same time (Premiere linked +/// selection). Split edits (audio cut at another time) and unlinked clips are not added. +pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: ClipId, follow_links: bool) -> Vec<(ClipId, ClipId)> { + let mut pairs = vec![(left, right)]; + if !follow_links { + return pairs; + } + let Some((_, l)) = seq.find_item(left) else { + return pairs; + }; + let Some((_, r)) = seq.find_item(right) else { + return pairs; + }; + let cut = l.end(); + let left_link = l.link; + let right_link = r.link; + if left_link.is_none() && right_link.is_none() { + return pairs; + } + for tr in seq.all_tracks() { + if tr.locked { + continue; + } + for w in tr.items.windows(2) { + if w[0].id == left && w[1].id == right { + continue; + } + if w[0].end() != w[1].start || w[0].end() != cut { + continue; + } + let left_ok = left_link.is_some_and(|g| w[0].link == Some(g)); + let right_ok = right_link.is_some_and(|g| w[1].link == Some(g)); + if left_ok && right_ok { + pairs.push((w[0].id, w[1].id)); + } + } + } + pairs +} + /// Expand a clip selection with linked partners (when linked selection is on). pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { let Some(seq) = s.active_sequence() else { return clips.to_vec() }; @@ -2412,8 +2453,26 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let x = s.edit_sequence("Rolling Edit", |q, ctx, _| Ok(edit::roll(q, l, r, d, ctx)?))?; - Ok(json!({"delta": x.0})) + let follow = s.state.linked_selection; + let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| { + let pairs = linked_roll_pairs(q, l, r, follow); + let linked = pairs.len() > 1; + let mut d = d; + for &(pl, pr) in &pairs { + let mut probe = q.clone(); + let x = edit::roll(&mut probe, pl, pr, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for &(pl, pr) in &pairs { + edit::roll(q, pl, pr, d, ctx)?; + } + } + Ok((d, pairs.len(), linked)) + })?; + Ok(json!({"delta": x.0, "pairs": n, "linked": linked})) }), cmd!("timeline.slip", "Slip", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slip", "need `clip`"))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a2..2d9a84261 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1156,6 +1156,8 @@ mod remix_tests; #[cfg(test)] mod ripple_delete_tests; #[cfg(test)] +mod roll_linked_tests; +#[cfg(test)] mod scopes_tests; #[cfg(test)] mod sequence_inspect_tests; diff --git a/crates/engine/src/roll_linked_tests.rs b/crates/engine/src/roll_linked_tests.rs new file mode 100644 index 000000000..dd3f2e4b2 --- /dev/null +++ b/crates/engine/src/roll_linked_tests.rs @@ -0,0 +1,86 @@ +//! #855: a rolling edit on a linked A/V cut must move the sound cut with the picture. + +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +fn source_in(s: &Session, c: ClipId) -> Tick { + s.active_sequence().unwrap().find_item(c).unwrap().1.source_in +} + +/// Two consecutive 48-frame A/V clips (picture on V1, linked sound on A1). +fn two_clips(s: &mut Session) -> [(ClipId, ClipId); 2] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Roll", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for n in 0..2 { + s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + let q = s.active_sequence().unwrap(); + [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)) +} + +/// #855: rolling the V1 cut +12 frames used to leave A1, so export played the second clip's audio +/// under the first clip's picture. +#[test] +fn roll_moves_linked_audio_cut() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + assert!(s.state.linked_selection); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(true), "{r}"); + assert_eq!(r["pairs"], json!(2), "{r}"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(60)), "linked sound must roll with the picture"); + assert_eq!(range(&s, a1), (f(60), f(96))); +} + +/// Linked selection off (or unlinked clips) rolls only the given pair and says so. +#[test] +fn roll_without_linked_selection_leaves_audio() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(false), "{r}: clips were not followed as a linked pair"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(48)), "unlinked A1 cut stays"); + assert_eq!(range(&s, a1), (f(48), f(96))); +} + +/// #526/#558: reversed linked clips still keep the media on the unmoved timeline edges. +#[test] +fn roll_linked_reversed_clips_keeps_media_mapping() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("timeline.select", json!({"clips": [v0.0, a0.0, v1.0, a1.0]})).unwrap(); + s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let left_in = source_in(&s, v0); + let right_in = source_in(&s, v1); + s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, a0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a1), (f(60), f(96))); + // reversed left: source_in moves so the clip's In still shows the same media + assert_eq!(source_in(&s, v0), left_in - f(12)); + assert_eq!(source_in(&s, a0), left_in - f(12)); + // reversed right: source_in stays so the clip's Out still shows the same media + assert_eq!(source_in(&s, v1), right_in); + assert_eq!(source_in(&s, a1), right_in); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 70cde2b69..a2e3dc874 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1680,11 +1680,14 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap { - if let Some((_, l)) = seq.find_item(*left) { - moved(l, filmcraft_edit::Edge::Out, *delta); - } - if let Some((_, r)) = seq.find_item(*right) { - moved(r, filmcraft_edit::Edge::In, *delta); + let pairs = filmcraft_engine::commands::linked_roll_pairs(seq, *left, *right, app.session.state.linked_selection); + for (pl, pr) in pairs { + if let Some((_, l)) = seq.find_item(pl) { + moved(l, filmcraft_edit::Edge::Out, *delta); + } + if let Some((_, r)) = seq.find_item(pr) { + moved(r, filmcraft_edit::Edge::In, *delta); + } } } Drag::Slide { clip, delta } => { From c6d47e20f0ad5ec0737b4ad8cc5492f1d618df7a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:17:58 +0200 Subject: [PATCH 027/157] #859: complete the Brazilian Portuguese (pt-br) interface translation Problem: the Portuguese (Brazil) catalog, one of Premiere's interface languages and one of the 12 key languages in docs/gaps.md G14, covered only the menus (348 of ~3,540 strings); panels, dialogs, Settings pages, effect names, the Keyboard Shortcuts dialog and status messages showed in English. Fix: pt-br.tsv now has every entry Spanish has plus the native macOS Settings item, written from the plain meaning of the English text with ordinary Brazilian video-editing vocabulary (infinitive commands, polite imperative instructions, sentence case); the header records the conventions and a term glossary. Like Japanese and French, tests require only the menus (now every menu label, not just the core set), so later strings show in English until translated; a Portuguese entry without a Spanish one fails, and a new test checks the bundled fonts cover every Portuguese letter. The core-menu set that Ukrainian, German, Russian and French must cover moves from pt-br.tsv (no longer menus-only) to the menus-only Ukrainian catalog, unchanged in content for those tests. Attribution sidecar and ATTRIBUTION.md entry added. Docs: G14, localization-parity, ROADMAP language row, ui-egui README. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- crates/ui-egui/README.md | 6 +- crates/ui-egui/src/i18n/mod.rs | 91 +- crates/ui-egui/src/i18n/pt-br.tsv | 3219 ++++++++++++++++- crates/ui-egui/src/i18n/pt-br.tsv.attribution | 8 + docs/gaps.md | 6 +- docs/localization-parity.md | 13 +- 8 files changed, 3304 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/i18n/pt-br.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0c1793ed1..9285a6b96 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,6 +10,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| | `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 | +| `crates/ui-egui/src/i18n/pt-br.tsv` | FilmCraft contributors | Original work: clean-room Brazilian Portuguese interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/ROADMAP.md b/ROADMAP.md index 9566bc599..4d9726b78 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,7 +79,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Hindi | none (no Devanagari shaping in the interface) | 0% | | Arabic | none (no RTL interface) | 0% | | French | partial (whole interface; native review welcome) | ~100% | -| Portuguese (Brazil) | menus only | 9.9% | +| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% | | Indonesian | none | 0% | | Japanese | partial (whole interface) | 99.7% | | German | none | 0% | diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 69ccdc1e3..a95914ea3 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -32,11 +32,11 @@ fills translated templates, and `i18n::t` translates names from registries. Plac (including user filenames containing braces) are inserted literally. Catalog translations are original work using ordinary language, without proprietary localisation resources. -Spanish, Japanese, Simplified Chinese and French cover menus, panels, dialogs, settings and registry labels. Searches accept both the +Spanish, Japanese, Simplified Chinese, French and Brazilian Portuguese cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain -English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they -fall back to English elsewhere. +English. Ukrainian and Russian currently cover the core menus, German every menu; they fall back +to English elsewhere. Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7225b5c98..8dae92f1f 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -425,19 +425,62 @@ mod tests { assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); } - /// The Brazilian Portuguese catalog covers the menus; every entry must still be a menu label - /// (a renamed command would otherwise leave a dead entry and an untranslated menu item). + /// The core menus every other catalog must cover: the entries of the menus-only Ukrainian + /// catalog, which `ukrainian_entries_cover_the_original_menu_catalog` keeps to menu labels (and + /// the native macOS Settings menu). Brazilian Portuguese played this role while it was + /// menus-only. + fn core_menu_labels() -> Vec { + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + entries.into_iter().map(|(_, en, _)| en).collect() + } + + /// Brazilian Portuguese covers the whole interface (the Spanish catalog's entries and the native + /// macOS Settings menu), but, like Japanese and French, the tests require only the menus, so + /// strings added later show in English until they are translated. Every entry must still have a + /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. #[test] - fn portuguese_entries_are_menu_labels() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - assert!(!entries.is_empty()); - for (_, en, _) in entries { - assert!(known(&en), "not a menu label: {en}"); + fn portuguese_covers_the_menus_and_has_no_dead_entries() { + let (entries, errors) = catalog::parse_entries(include_str!("pt-br.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + for (ctx, en, _) in &entries { + assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Portuguese entry without a Spanish one: {ctx:?} {en:?}"); } - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut missing: Vec = Vec::new(); + let mut need = |text: &str| { + if translatable(text) && Language::Es.has(text) && !Language::PtBr.has(text) && !missing.iter().any(|m| m == text) { + missing.push(text.to_string()); + } + }; + for top in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + need(top); + } + for it in crate::menus::menu_items(&app) { + need(&it.label); + for p in &it.path { + need(p); + } + } + for en in core_menu_labels() { + need(&en); + } + missing.sort(); + assert!(missing.is_empty(), "untranslated Portuguese menu labels: {missing:#?}"); + // beyond the menus: panels, effects and their parameters, settings, status messages + for (en, pt) in [ + ("File", "Arquivo"), + ("Settings", "Preferências"), + ("Gaussian Blur", "Desfoque gaussiano"), + ("Audio Track Mixer", "Misturador de faixas de áudio"), + ("Keyboard Shortcuts", "Atalhos de teclado"), + ("Playhead", "Cursor de reprodução"), + ] { + assert_eq!(Language::PtBr.tr(en), pt); + } + set_current(Language::PtBr); + assert!(matches_query("Gaussian Blur", "desfoque")); + assert!(matches_query("Gaussian Blur", "gaussian")); + set_current(Language::En); } /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. @@ -921,17 +964,13 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { - assert!(Language::Uk.has(&en), "missing Ukrainian menu label: {en}"); - } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); } } - /// German covers at least the core menus (the Brazilian Portuguese entries, the top-level menus - /// and the native macOS Settings menu); every entry must still be a menu label. + /// German covers at least the core menus (`core_menu_labels`, the top-level menus and the native + /// macOS Settings menu); every entry must still be a menu label. #[test] fn german_entries_cover_the_original_menu_catalog() { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); @@ -941,8 +980,7 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::De.has(&en), "missing German menu label: {en}"); } // "Clip" and "Audio" are the same word in German, so check for an entry, not a change @@ -963,8 +1001,7 @@ mod tests { for (ctx, en, _) in &entries { assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::Fr.has(&en), "missing French menu label: {en}"); } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { @@ -991,8 +1028,7 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { @@ -1033,6 +1069,15 @@ mod tests { assert_bundled_fonts_cover(alphabet.chars().chain(Language::Uk.name().chars()).chain(entries.iter().flat_map(|(_, _, uk)| uk.chars()))); } + #[test] + fn portuguese_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + // every Portuguese letter, even ones no entry uses yet, and every letter the entries use + let alphabet = "ÁÂÃÀÇÉÊÍÓÔÕÚÜáâãàçéêíóôõúü"; + let letters = entries.iter().flat_map(|(_, _, pt)| pt.chars()).filter(|ch| ch.is_alphabetic()); + assert_bundled_fonts_cover(alphabet.chars().chain(Language::PtBr.name().chars()).chain(letters)); + } + #[test] fn french_renders_with_bundled_fonts() { let (entries, _) = catalog::parse_entries(include_str!("fr.tsv")); diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fc7b9fd50..283ce9c5c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1,9 +1,22 @@ # Brazilian Portuguese (pt-br) catalog. Same format as `es.tsv`: context source translation. # -# Clean-room: every entry was written from the plain meaning of the English label using ordinary -# Brazilian Portuguese computing vocabulary. No proprietary localisation resource, string table or -# product UI was consulted or copied. This catalog covers the menus; anything else is shown in -# English. +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Brazilian Portuguese video-editing vocabulary. No proprietary localisation resource, string table +# or product UI was consulted or copied; add entries the same way. Commands use the infinitive +# ("Salvar como…"), instructions use the polite imperative ("Selecione um clipe…"), and labels use +# sentence case. Product, format and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, +# EDL, AAF…) keep their spelling; decimals in prose use a comma ("29,97 fps"). This catalog has the +# same entries as `es.tsv` plus the native macOS Settings menu; strings added later show in English +# until they are translated. A native-speaker review is welcome. +# +# Terms: clip = clipe, track = faixa, sequence = sequência, timeline = linha do tempo, bin = pasta, +# playhead = cursor de reprodução, In/Out = entrada / saída, marker = marcador, keyframe = +# quadro-chave, frame = quadro, frame hold = quadro congelado, edit point = ponto de edição, panel = +# painel, workspace = área de trabalho, render = renderizar, preset = predefinição, captions = +# legendas, voice-over = narração, trim = aparar (aparagem), ripple = em cascata, roll = rolante, +# slip / slide = deslizar conteúdo / deslizar clipe, overwrite = sobrescrever, wipe = cortina, +# dissolve = dissolução, scopes = escopos, highlights = realces, offline = offline, legacy = legado, +# preferences (also the native macOS Settings menu) = Preferências, settings = configurações. About FilmCraft Sobre o FilmCraft Add Audio Submix Track Adicionar faixa de submixagem de áudio @@ -86,7 +99,6 @@ Create Multi-Camera Source Sequence… Criar sequência de origem multicâmera… Create Proxies… Criar proxies… Cut Recortar - Dark Escuro Darkest Mais escuro Delete Render Files Excluir arquivos de renderização Delete Render Files In to Out Excluir arquivos de renderização da entrada à saída @@ -184,6 +196,7 @@ Light Claro Link Vincular Link Media… Vincular mídia… + Refresh Changed Media Atualizar mídia alterada Linked Selection Seleção vinculada Lock Guides Bloquear guias Lumetri Color Cor Lumetri @@ -204,6 +217,7 @@ Media Browser Navegador de mídia Media Cache… Cache de mídia… Media… Mídia… + Medium Médio Memory… Memória… Merge Clips… Mesclar clipes… Metadata Metadados @@ -352,7 +366,3200 @@ Zoom In Ampliar Zoom Out Reduzir Zoom to Sequence Zoom para a sequência + Home Início + Import Importar + {name} - Edited {name} - Editado + Full screen Tela cheia + Volume Volume + Master volume: use the Audio Track Mixer Volume principal: use o misturador de faixas de áudio + Search Pesquisar + Quick Export Exportação rápida + Join the ArtCraft Discord (discord.gg/artcraft) Entre no Discord do ArtCraft (discord.gg/artcraft) + Cancel Cancelar + OK OK + Interpret Footage Interpretar clipes + Color Management · {n} item Gerenciamento de cores · {n} item + Color Management · {n} items Gerenciamento de cores · {n} itens + Media colour space (from file metadata): {detected} Espaço de cores da mídia (dos metadados do arquivo): {detected} + Override media colour space: Substituir o espaço de cores da mídia: + Use file metadata Usar os metadados do arquivo + Sequence Color Management Gerenciamento de cores da sequência + Working Color Space Espaço de cores de trabalho + Wide gamut color (composite in BT.2020) Cor de gama ampla (compor em BT.2020) + Auto tone map media (HDR and log into SDR) Mapeamento de tons automático da mídia (HDR e log para SDR) + Audio Gain Ganho de áudio + Set Gain to: Definir ganho como: + Adjust Gain by: Ajustar ganho em: + Normalize Max Peak to: Normalizar pico máximo para: + Normalize All Peaks to: Normalizar todos os picos para: + Peak Amplitude: {peak} Amplitude de pico: {peak} + Standard Padrão + Adaptive Adaptável + Mono Mono + Stereo Estéreo + Add Tracks Adicionar faixas + Amount Quantidade + Placement Posicionamento + Track type Tipo de faixa + Add video tracks Adicionar faixas de vídeo + Add audio tracks Adicionar faixas de áudio + Add audio submix tracks Adicionar faixas de submixagem de áudio + After {name} Depois de {name} + Before First Track Antes da primeira faixa + Delete Tracks Excluir faixas + Video Tracks Faixas de vídeo + Audio Tracks Faixas de áudio + Delete Video Tracks Excluir faixas de vídeo + Delete Audio Tracks Excluir faixas de áudio + All Empty Tracks Todas as faixas vazias + Caption Tracks Faixas de legendas + Delete Caption Tracks Excluir faixas de legendas + Delete Caption Track Excluir faixa de legendas + Delete Empty Caption Tracks Excluir faixas de legendas vazias + Version {version} Versão {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Um editor de vídeo não linear em Rust puro, desenvolvido em sala limpa. Parte da família ArtCraft. + Join the ArtCraft Discord Entre no Discord do ArtCraft + Source code on GitHub Código-fonte no GitHub + Report an issue Relatar um problema + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Fontes: Inter, Noto Serif e JetBrains Mono (SIL OFL 1.1). + just now agora mesmo + {n} s ago há {n} s + {n} min ago há {n} min + {n} h ago há {n} h + All Events Todos os eventos + Warnings and Errors Avisos e erros + Errors Only Somente erros + Clear All Limpar tudo + No events. Nenhum evento. + Show finished jobs Mostrar tarefas concluídas + Cancelled Cancelada + Failed: {e} Falha: {e} + Done Concluída + No background jobs. Nenhuma tarefa em segundo plano. + Recover Unsaved Changes Recuperar alterações não salvas + Untitled Sem título + FilmCraft was closed while “{name}” had unsaved changes. O FilmCraft foi fechado enquanto “{name}” tinha alterações não salvas. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. O FilmCraft fechou inesperadamente enquanto “{name}” tinha alterações não salvas. + Recover unsaved changes from {when}? Recuperar as alterações não salvas de {when}? + The project had not been saved yet. O projeto ainda não tinha sido salvo. + {n} sessions have unsaved changes: {n} sessões têm alterações não salvas: + Discard Descartar + Recover Recuperar + Not Now Agora não + Are you sure you want to discard your changes to '{file}'? Tem certeza de que deseja descartar as alterações em '{file}'? + Yes Sim + No Não + Add media to your project. Drop files anywhere in the window, or choose a source below. Adicione mídia ao projeto. Solte arquivos em qualquer lugar da janela ou escolha uma origem abaixo. + Browse files… Procurar arquivos… + Movies, audio and stills from disk Filmes, áudio e imagens estáticas do disco + Demo footage Clipes de demonstração + Six procedural 1080p clips with sound Seis clipes procedurais 1080p com som + Demo project Projeto de demonstração + A cut sequence with transitions, effects and music Uma sequência editada com transições, efeitos e música + Bars and Tone Barras e tom + SMPTE HD bars with 1 kHz reference tone Barras SMPTE HD com tom de referência de 1 kHz + Community Comunidade + Join us on Discord Junte-se a nós no Discord + OMF Export Settings Configurações de exportação OMF + AAF Export Settings Configurações de exportação AAF + OMF Title: Título OMF: + Mixdown video Mixar vídeo + Audio: Áudio: + Encapsulate Encapsular + Separate Audio Áudio separado + Embed Incorporar + Separate Files Arquivos separados + Link to Media Vincular à mídia + Audio File Format: Formato de arquivo de áudio: + Sample Rate: Taxa de amostragem: + Bits per Sample: Bits por amostra: + Rendering: Renderização: + Trim audio files Aparar arquivos de áudio + Handle Frames: Quadros de margem: + Render audio clip effects Renderizar efeitos de clipes de áudio + Breakout to mono Separar em mono + Exported {path} Exportado: {path} + Select a clip to view its metadata. Selecione um clipe para ver seus metadados. + Clip: {name} Clipe: {name} + Open Abrir + (no sequence) (nenhuma sequência) + Show or hide {color} markers Mostrar ou ocultar os marcadores da cor {color} + Marker Marcador + {tc} Chapter {tc} Capítulo + No markers. Press M to add one. Nenhum marcador. Pressione M para adicionar um. + All markers are hidden by the colour filter. O filtro de cor oculta todos os marcadores. + Type: Tipo: + Video: Vídeo: + Codec: Codec: + Duration: Duração: + Settings: Configurações: + Playhead: Cursor de reprodução: + Video {n}: Vídeo {n}: + Audio {n}: Áudio {n}: + UI: Interface: + {fps} fps · {n} frames queued {fps} fps · {n} quadros na fila + {panel} — coming in a later milestone {panel}: disponível em uma versão futura + Close Panel Fechar painel + Close Other Timeline Panels Fechar os outros painéis de linha do tempo + Close All Fechar tudo + Maximize Frame Maximizar quadro do painel + Restore Frame Restaurar quadro do painel + Restore Workspace Restaurar área de trabalho + Reveal Sequence in Project Mostrar sequência no projeto + Video Thumbnails Miniaturas de vídeo + Audio Waveforms Formas de onda de áudio + Delete Preset Excluir predefinição + Export Preset… Exportar predefinição… + Import Presets… Importar predefinições… + Effect presets Predefinições de efeitos + Save Preset Salvar predefinição + Name: Nome: + Type Tipo + Scale Escala + Anchor to In Point Ancorar no ponto de entrada + Anchor to Out Point Ancorar no ponto de saída + Without keyframes Sem quadros-chave + Description: Descrição: + Saved preset “{name}” Predefinição “{name}” salva + Current duration {seconds} s Duração atual: {seconds} s + Remix Properties Propriedades da remixagem + Target Duration: Duração desejada: + Segments: Segmentos: + Fewer · More Menos · Mais + Variations: Variações: + Remixed to {seconds} s Remixado para {seconds} s + {mode} · no clip {mode} · nenhum clipe + {mode} · no sequence {mode} · nenhuma sequência + Remove Row Remover linha + Add a timecode row Adicionar uma linha de timecode + Panner Panorâmico + Mute Silenciar + Send {n} Level Nível do envio {n} + Show Keyframes Mostrar quadros-chave + Clip Keyframes Quadros-chave do clipe + Track Keyframes Quadros-chave da faixa + Delete Excluir + Untitled Workspace Área de trabalho sem título + New Workspace Nova área de trabalho + Edit Workspaces Editar áreas de trabalho + Rename Renomear + Restore Original Restaurar original + Search effects Pesquisar efeitos + Apply to Selected Clips Aplicar aos clipes selecionados + Metadata Display Exibição de metadados + Save As New View Preset Salvar como nova predefinição de visualização + Manage Saved View Presets Gerenciar predefinições de visualização salvas + Freeform View Options Opções da visualização de forma livre + Save Arrangement Salvar disposição + Saves the view, columns, sort, thumbnail and font size. Salva a visualização, as colunas, a ordenação, a miniatura e o tamanho da fonte. + Grid spacing: Espaçamento da grade: + Default clip size: Tamanho padrão do clipe: + Snap to grid Encaixar na grade + Show clip names Mostrar nomes dos clipes + Show durations Mostrar durações + Show: Mostrar: + Search fields Pesquisar campos + Project Metadata Metadados do projeto + Add Property… Adicionar propriedade… + Column order Ordem das colunas + {n}. (empty) {n}. (vazio) + Restore Restaurar + Overwrite with Current View Sobrescrever com a visualização atual + Graphics text search arrives with M10.1–M10.2 A pesquisa de texto em gráficos chega com M10.1–M10.2 + Open a sequence to work with captions Abra uma sequência para trabalhar com legendas + Add captions Adicionar legendas + Create a caption track or import a caption file. Crie uma faixa de legendas ou importe um arquivo de legendas. + Create new caption track Criar nova faixa de legendas + Import captions file… Importar arquivo de legendas… + New {format} track Nova faixa {format} + Add caption at playhead Adicionar legenda no cursor de reprodução + Split caption at playhead Dividir legenda no cursor de reprodução + Merge selected captions Mesclar legendas selecionadas + Delete selected captions Excluir legendas selecionadas + No captions on this track. Press + to add one at the playhead. Nenhuma legenda nesta faixa. Pressione + para adicionar uma no cursor de reprodução. + No matching captions. Nenhuma legenda correspondente. + Open a sequence to see its transcript Abra uma sequência para ver sua transcrição + Transcribe sequence Transcrever sequência + Speech-to-text turns the dialogue into editable text. A conversão de fala em texto transforma o diálogo em texto editável. + This build has no speech-to-text; import a transcript with transcript.set. Esta compilação não inclui conversão de fala em texto; importe uma transcrição com transcript.set. + Transcribe Transcrever + Extract selected text Extrair texto selecionado + Lift selected text Levantar texto selecionado + Remove filler words Remover muletas + Create captions Criar legendas + Speaker Locutor + Box Caixa + Align Alinhar + Align left Alinhar à esquerda + Align center Centralizar + Align right Alinhar à direita + Position Posição + Position: top Posição: superior + Position: middle Posição: meio + Position: bottom Posição: inferior + (gap) (lacuna) + Out: {name} Saída: {name} + In: {name} Entrada: {name} + Play Around Edit (Space loops, Shift+K once) Reproduzir ao redor da edição (Espaço em loop, Shift+K uma vez) + Trim Backward Many Aparar muitos para trás + Trim Backward Aparar para trás + Apply Default Transitions Aplicar transições padrão + Apply Default Transitions to Selection (Shift+D) Aplicar transições padrão à seleção (Shift+D) + Trim Forward Aparar para a frente + Trim Forward Many Aparar muitos para a frente + Exit Trim Mode Sair do modo de aparagem + Voice-over record (right-click: Voice-Over Record Settings…) Gravar narração (clique direito: Configurações de gravação de narração…) + Voice-Over Record Settings… Configurações de gravação de narração… + Recording voice-over… Gravando narração… + Voice-over recorded: {p} Narração gravada: {p} + Voice-over recording stopped (nothing recorded) Gravação de narração interrompida (nada foi gravado) + Post-roll Pós-roll + ● Recording ● Gravando + Voice-Over Record Settings Configurações de gravação de narração + Source: Origem: + Default Input Entrada padrão + Input: Entrada: + Channel {n} Canal {n} + Countdown Sound Cues Bipes de contagem regressiva + Pre-roll: Pré-roll: + seconds segundos + Post-roll: Pós-roll: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. A reprodução começa com o pré-roll antes do cursor de reprodução (ou do ponto de entrada); com entrada e saída marcadas, a gravação entra e sai nesses pontos. + Effect Efeito + Clip Fx Editor Editor de efeitos do clipe + Track Fx Editor Editor de efeitos da faixa + Master Gain Ganho principal + Reset Redefinir + Band {n} Banda {n} + Solo Solo + Bypass Ignorar + Thr Limiar + Ratio Razão + Att Ataque + Rel Liberação + Gain Ganho + Output Gain Ganho de saída + Limiter Limitador + Link Channels Vincular canais + Limiter Threshold Limiar do limitador + Limiter Release Liberação do limitador + Auto Gate Gate automático + Threshold Limiar + Attack Ataque + Release Liberação + Hold Retenção + Compressor Compressor + Auto Makeup Compensação automática + Makeup Compensação + Expander Expansor + Soft Clip Clipping suave + (no sequences) (nenhuma sequência) + (no clips) (nenhum clipe) + Show Transport Controls Mostrar controles de transporte + Loop Loop + Multi-Camera Audio Follows Video Áudio multicâmera segue o vídeo + Multi-Camera Record Gravação multicâmera + Multi-Camera Selection Top Down Seleção multicâmera de cima para baixo + Show Multi-Camera Preview Monitor Mostrar monitor de pré-visualização multicâmera + Auto-Adjust Multi-Camera Playback Quality Ajustar automaticamente a qualidade de reprodução multicâmera + Transmit Multi-Camera View Transmitir visualização multicâmera + Multi-Camera Layout Layout multicâmera + Automatic Automático + Edit Cameras… Editar câmeras… + Add Marker (M) Adicionar marcador (M) + Mark In (I) Marcar entrada (I) + Mark Out (O) Marcar saída (O) + Go to In (Shift+I) Ir para a entrada (Shift+I) + Step Back 1 Frame (Left) Voltar 1 quadro (Esquerda) + Play-Stop Toggle (Space) Reproduzir/parar (Espaço) + Step Forward 1 Frame (Right) Avançar 1 quadro (Direita) + Go to Out (Shift+O) Ir para a saída (Shift+O) + Insert (,) Inserir (,) + Overwrite (.) Sobrescrever (.) + Export Frame (Shift+E) Exportar quadro (Shift+E) + Lift (;) Levantar (;) + Extract (') Extrair (') + Export Frame: use Export mode (M6) Exportar quadro: use o modo Exportar (M6) + Button Editor Editor de botões + Multi-Camera Record on Gravação multicâmera ativada + Multi-Camera Record off Gravação multicâmera desativada + {first} - Merged {first} - Mesclado + {first} Multicam {first} Multicâmera + In Points Pontos de entrada + Out Points Pontos de saída + Clip Marker Marcador de clipe + Audio Áudio + Merge Clips Mesclar clipes + Create Multi-Camera Source Sequence Criar sequência de origem multicâmera + Synchronize Clips Sincronizar clipes + Clip Name: Nome do clipe: + Sequence Name: Nome da sequência: + {n} clip(s): {names} {n} clipe(s): {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clipe(s) selecionado(s); o clipe da faixa de referência não se move. + Synchronize Point Ponto de sincronização + Ignore Hours Ignorar horas + first marker primeiro marcador + Offset Audio by: Deslocar o áudio em: + Offset: Deslocamento: + frames quadros + Reference track: Faixa de referência: + lowest (V1…) a mais baixa (V1…) + Remove Audio from AV Clip Remover o áudio do clipe AV + Camera 1 Câmera 1 + All Cameras Todas as câmeras + Switch Audio Alternar áudio + Camera Names Nomes das câmeras + Clip Names Nomes dos clipes + Enumerate Cameras Numerar câmeras + Camera Angle Metadata Metadados de ângulo de câmera + Move source clips to Processed Clips bin Mover os clipes de origem para a pasta Clipes processados + Synchronized {n} clip group(s) {n} grupo(s) de clipes sincronizado(s) + Merged clip created Clipe mesclado criado + Multi-camera source sequence with {n} camera(s) Sequência de origem multicâmera com {n} câmera(s) + No multi-camera clip at the playhead Nenhum clipe multicâmera no cursor de reprodução + Camera {n} — {name} Câmera {n} — {name} + Page {page} of {pages} Página {page} de {pages} + Previous page Página anterior + Next page Próxima página + Multi-Camera Record On/Off (0) Gravação multicâmera ativada/desativada (0) + Record Gravar + Edit Cameras Editar câmeras + Cameras shown in the Multi-Camera view (uncheck to hide). Câmeras mostradas na visualização multicâmera (desmarque para ocultar). + Recorded {n} multi-camera cut(s) {n} corte(s) multicâmera gravado(s) + Clamp Signal Limitar sinal + Presets Predefinições + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Forma de onda ({kind}) + Parade Type Tipo de parade + Waveform Type Tipo de forma de onda + Vectorscope Targets Alvos do vetorscópio + Colour Space Espaço de cores + Brightness Brilho + Signal Scale Escala do sinal + Histogram Histograma + Paste Attributes Colar atributos + Remove Attributes Remover atributos + New Offline File Novo arquivo offline + Nested Sequence Name Nome da sequência aninhada + Make Subclip Criar subclipe + Edit Subclip Editar subclipe + Modify Clip: Audio Channels Modificar clipe: canais de áudio + Modify Clip: Timecode Modificar clipe: timecode + Frame Hold Options Opções de quadro congelado + Field Options Opções de campo + Save Project Salvar projeto + Motion Movimento + Opacity Opacidade + Time Remapping Remapeamento de tempo + Channel Volume Volume do canal + In Point Ponto de entrada + Out Point Ponto de saída + Playhead Cursor de reprodução + Source Timecode Timecode de origem + Sequence Time Tempo da sequência + Video Attributes Atributos de vídeo + Audio Attributes Atributos de áudio + No effects Nenhum efeito + Scale Attribute Times Escalar os tempos dos atributos + File Name: Nome do arquivo: + Tape Name: Nome da fita: + Contains: Contém: + Video Vídeo + Width: Largura: + Height: Altura: + Frame Rate: Taxa de quadros: + Media Start: Início da mídia: + Subclip: {name} Subclipe: {name} + Start: Início: + End: Fim: + Restrict Trims To Subclip Boundaries Restringir aparagens aos limites do subclipe + Convert to Master Clip Converter em clipe mestre + Clip Channel Format: Formato de canais do clipe: + Number of Audio Clips: Número de clipes de áudio: + Media Source Channels Canais de origem da mídia + Ch. {n} Can. {n} + Clip {n} Clipe {n} + Source channels the selected audio clips play: Canais de origem que os clipes de áudio selecionados reproduzem: + Set Timecode: Definir timecode: + Use the file's timecode Usar o timecode do arquivo + Hold On Congelar em + Timecode: Timecode: + Hold Filters Congelar filtros + Reverse Field Dominance Inverter dominância de campo + Processing Options Opções de processamento + None Nenhum + Always Deinterlace Sempre desentrelaçar + Flicker Removal Remover cintilação + Save changes to “{project_name}” before closing? Salvar as alterações em “{project_name}” antes de fechar? + Don't Save Não salvar + All media is online. Toda a mídia está online. + {name} (copy) {name} (cópia) + Offline Offline + Missing Ausente + Link Media Vincular mídia + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clipe(s) não encontram sua mídia. Localize-a, pesquise uma pasta ou deixe-os offline. + Clip Name Nome do clipe + File Name Nome do arquivo + File Path Caminho do arquivo + Status Status + Match file properties: Propriedades de arquivo que devem corresponder: + File Extension Extensão do arquivo + Clip ID (fingerprint) ID do clipe (impressão digital) + Duration Duração + Media Start Início da mídia + Frame Size / Rate Tamanho e taxa de quadros + Align Timecode Alinhar timecode + Relink others automatically Revincular os demais automaticamente + Search in: Pesquisar em: + Browse… Procurar… + Exact name matches only Somente correspondências exatas de nome + No candidates yet — Search a folder or Locate the file. Ainda não há candidatos: pesquise uma pasta ou localize o arquivo. + ✔ same file ✔ mesmo arquivo + ✔ matches ✔ corresponde + ✖ different file ✖ arquivo diferente + Preview Pré-visualização + Offline All Tudo offline + Locate… Localizar… + Linked {n} clip(s) {n} clipe(s) vinculado(s) + Nothing found with that name. Nada encontrado com esse nome. + Make Offline Tornar offline + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Tornar {n} clipe(s) selecionado(s) offline. Os clipes mostrarão a claquete offline até que sejam vinculados novamente. + Media files remain on disk Os arquivos de mídia permanecem no disco + Media files are deleted Os arquivos de mídia são excluídos + Create Proxies Criar proxies + {n} clip(s) selected. {n} clipe(s) selecionado(s). + Format and size Formato e tamanho + Destination Destino + Next to the original media, in a Proxies folder Ao lado da mídia original, em uma pasta Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Os proxies são criados em segundo plano e anexados ao terminar. A exportação sempre usa a mídia em resolução total. + Creating {n} proxy file(s)… Criando {n} arquivo(s) proxy… + Project Manager Gerenciador de projetos + Sequences Sequências + Resulting Project Projeto resultante + Collect Files and Copy to New Location Coletar arquivos e copiar para um novo local + Consolidate and Transcode Consolidar e transcodificar + Options Opções + Exclude Unused Clips Omitir clipes não usados + Include Handles: Incluir margens: + Include Proxies Incluir proxies + Include Preview Files Incluir arquivos de pré-visualização + Destination Path Caminho de destino + Disk space: original {original} · resulting {result} ({n} files) Espaço em disco: original {original} · resultante {result} ({n} arquivos) + Disk space: — Espaço em disco: — + Calculate Calcular + Project Manager: writing {path} Gerenciador de projetos: gravando {path} + Timeline: (no sequences) Linha do tempo: (nenhuma sequência) + Browse Procurar + Select audio clips, then click a preset to apply it. Selecione clipes de áudio e clique em uma predefinição para aplicá-la. + {name} (user) {name} (usuário) + Select audio clips to edit them here Selecione clipes de áudio para editá-los aqui + Multiple Clips Selected Vários clipes selecionados + The selected clips have different audio types. Assign one type to edit them together. Os clipes selecionados têm tipos de áudio diferentes. Atribua um tipo para editá-los juntos. + Assign an audio type to the selection: Atribua um tipo de áudio à seleção: + Clear Audio Type Limpar tipo de áudio + Preset: Predefinição: + (Custom) (Personalizado) + Preset name Nome da predefinição + Reduce Noise Reduzir ruído + Reduce Rumble Reduzir ronco + DeHum Remover zumbido + DeEss Remover sibilância + Reduce Reverb Reduzir reverberação + Dynamics Dinâmica + EQ Equalizador + Enhance Speech Aprimorar fala + Low Tone Tom grave + High Tone Tom agudo + Mix Mixagem + Reverb Reverberação + Stereo Width Largura estéreo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Ainda não é possível remixar a música para uma duração desejada. Apare o clipe e adicione uma transição cruzada. + Pan Pan + Auto-Match Correspondência automática + Not matched yet Ainda sem correspondência + Matches each of the {n} clips on its own. Faz a correspondência de cada um dos {n} clipes separadamente. + Duck against: Atenuar em relação a: + Untagged Sem marcação + Sensitivity Sensibilidade + Reduce By Reduzir em + Fades Fades + Generate Keyframes Gerar quadros-chave + Clip Volume Volume do clipe + (no clip selected) (nenhum clipe selecionado) + Master · {name} Mestre · {name} + Add Lumetri Color to clip Adicionar Cor Lumetri ao clipe + Input LUT LUT de entrada + Color Cor + Temperature Temperatura + Tint Tonalidade + Saturation Saturação + Exposure Exposição + Contrast Contraste + Highlights Realces + Shadows Sombras + Whites Brancos + Blacks Pretos + HDR HDR + HDR White Branco HDR + HDR Specular Especular HDR + Look Visual + Look LUT LUT de visual + Intensity Intensidade + Adjustments Ajustes + Faded Film Filme desbotado + Sharpen Nitidez + Vibrance Vivacidade + RGB Curves Curvas RGB + HDR Range Faixa HDR + Hue Saturation Curves Curvas de matiz e saturação + Hue vs Sat Matiz x saturação + Hue vs Hue Matiz x matiz + Hue vs Luma Matiz x luminância + Luma vs Sat Luminância x saturação + Sat vs Sat Saturação x saturação + Midtones Meios-tons + Key Chave + Hue Matiz + Hue Range Intervalo de matiz + Saturation Min Saturação mínima + Luma Min Luminância mínima + Luma Max Luminância máxima + Soften Suavizar + Show Mask Mostrar máscara + Off Desativado + Color/Gray Cor/cinza + Color/Black Cor/preto + White/Black Branco/preto + Refine Refinar + Denoise Reduzir ruído + Blur Desfoque + Correction Correção + Hue Shift Deslocamento de matiz + Midpoint Ponto médio + Roundness Arredondamento + Feather Difusão + LUT LUT + Color Match Correspondência de cores + Reference Referência + Face Detection (protect skin tones) Detecção de rostos (protege os tons de pele) + Apply Match Aplicar correspondência + Create ellipse mask Criar máscara de elipse + Create 4-point polygon mask Criar máscara de polígono de 4 pontos + Free draw bezier Bézier à mão livre + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Clique no monitor do programa para posicionar pontos de máscara (arraste para curvas); clique no primeiro ponto ou pressione Enter para fechar + Delete Mask Excluir máscara + Inverted Invertida + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Arrastar: mover · Alt+clique: suave/canto · Cmd+clique: excluir + Track selected mask backward Rastrear a máscara selecionada para trás + Track selected mask backward 1 frame Rastrear a máscara selecionada 1 quadro para trás + Track selected mask forward 1 frame Rastrear a máscara selecionada 1 quadro para a frente + Track selected mask forward Rastrear a máscara selecionada para a frente + Stop tracking Parar rastreamento + Tracking method: {method} Método de rastreamento: {method} + Composite Composto + Comparison Comparação + Video and Waveform Vídeo e forma de onda + System Default Padrão do sistema + Default location Local padrão + Font family Família de fontes + Label Colors Cores dos rótulos + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Quadros decodificados mantidos para os monitores e miniaturas: {used} de {budget}. + FilmCraft keeps the rest of the RAM free for other applications. O FilmCraft deixa o restante da RAM livre para outros aplicativos. + not available in this session indisponível nesta sessão + Currently: {loc} — {files} file(s), {size} Atualmente: {loc} — {files} arquivo(s), {size} + ({ch} output channels) ({ch} canais de saída) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programa esquerdo → saída {l} do dispositivo, direito → saída {r}{dev}. Saídas além dos canais do dispositivo voltam para 1 e 2. + Auto-saves go to: {dir} Os salvamentos automáticos vão para: {dir} + Recovery copy: {dir} Cópia de recuperação: {dir} + Last auto-save {a} · last recovery copy {j} Último salvamento automático {a} · última cópia de recuperação {j} + Last auto-save {a} Último salvamento automático {a} + Last recovery copy {j} Última cópia de recuperação {j} + Remove Media Cache Files Remover arquivos de cache de mídia + Deleted {n} file(s), {size}. {n} arquivo(s) excluído(s), {size}. + Reset… Redefinir… + Source · {name} Origem · {name} + Reset Effect Redefinir efeito + Reset Parameter Redefinir parâmetro + Save Preset… Salvar predefinição… + Custom Setup Configuração personalizada + Edit… Editar… + Show graphs Mostrar gráficos + Toggle animation Ativar/desativar animação + Eyedropper (pick a color from the Program monitor) Conta-gotas (capturar uma cor do monitor do programa) + Eyedropper: no picture to sample Conta-gotas: nenhuma imagem para amostrar + Add/Remove Keyframe Adicionar/remover quadro-chave + Temporal Interpolation Interpolação temporal + Linear Linear + Bezier Bézier + Auto Bezier Bézier automático + Continuous Bezier Bézier contínuo + Ease In Suavizar entrada + Ease Out Suavizar saída + Select a clip to grade Selecione um clipe para fazer a correção de cor + Select a clip to see its properties Selecione um clipe para ver suas propriedades + Anchor point Ponto de ancoragem + Rotation Rotação + Level Nível + Speed {n}% Velocidade {n}% + Value Valor + Velocity Velocidade + Default Padrão + Custom ({n} points) Personalizada ({n} pontos) + Select a graphic clip, or use the Type tool (T) on the Program monitor Selecione um clipe de gráfico ou use a ferramenta Texto (T) no monitor do programa + Create new graphic Criar novo gráfico + New Text Layer Nova camada de texto + New Text Novo texto + New Rectangle Novo retângulo + New Ellipse Nova elipse + Delete Layer Excluir camada + Select a layer to edit its properties. Selecione uma camada para editar suas propriedades. + Align Left Alinhar à esquerda + Align Center Horizontally Centralizar horizontalmente + Align Right Alinhar à direita + Align Top Alinhar acima + Align Center Vertically Centralizar verticalmente + Align Bottom Alinhar abaixo + Font Fonte + Font Style Estilo da fonte + Font Size Tamanho da fonte + Alignment Alinhamento + Left Align Text Alinhar texto à esquerda + Center Align Text Centralizar texto + Right Align Text Alinhar texto à direita + Justify Justificar + Tracking Espaçamento entre letras + Leading Entrelinha + Baseline Shift Deslocamento da linha de base + Text Box Width Largura da caixa de texto + Style Estilo + Faux Bold Negrito simulado + Faux Italic Itálico simulado + All Caps Tudo em maiúsculas + Small Caps Versaletes + Underline Sublinhado + OpenType OpenType + Kerning Kerning + Ligatures Ligaduras + Shape Forma + Size Tamanho + Corner Radius Raio do canto + Polygon Sides Lados do polígono + Fill Preenchimento + Stroke Traçado + Stroke 2 Traçado 2 + Background Fundo + Opacity Opacidade + Size Tamanho + Corner Radius Raio do canto + Shadow Sombra + Angle Ângulo + Distance Distância + Blur Desfoque + Search templates Pesquisar modelos + All Categories Todas as categorias + Install… Instalar… + Install Motion Graphics Template (.fcgt) Instalar modelo de gráficos de movimento (.fcgt) + No templates match. Nenhum modelo corresponde. + {category} · My Template {category} · Meu modelo + Apply Aplicar + Remove Remover + {n} editable properties {n} propriedades editáveis + Template: {name} Modelo: {name} + Intro Duration Duração da introdução + Outro Duration Duração do encerramento + Roll Rolagem + Start Off Screen Começar fora da tela + End Off Screen Terminar fora da tela + Preroll Pré-roll + Postroll Pós-roll + Video Frame Quadro de vídeo + Pin To Fixar em + Pinned Edges Bordas fixadas + FilmCraft Graphics Template Modelo de gráficos do FilmCraft + {n} Font {n} Fonte + {n} Size {n} Tamanho + {n} Color {n} Cor + {n} Position {n} Posição + Show {n} Mostrar {n} + My Templates Meus modelos + Export As Motion Graphics Template Exportar como modelo de gráficos de movimento + Category: Categoria: + Editable properties: Propriedades editáveis: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Salvo como modelo de gráficos do FilmCraft (.fcgt) na sua pasta de modelos. + Exported graphics template to {path} Modelo de gráficos exportado para {path} + Replace Fonts in Projects Substituir fontes em projetos + Fonts used in the project: Fontes usadas no projeto: + {f} (missing) {f} (ausente) + Replace: Substituir: + Replaced {n} font uses {n} usos de fontes substituídos + Show/Hide Effects and Sends Mostrar/ocultar efeitos e envios + Audio Track Mixer menu Menu do misturador de faixas de áudio + Show/Hide Tracks… Mostrar/ocultar faixas… + Meter Input(s) Only Medir somente entradas + Show/Hide Tracks Mostrar/ocultar faixas + Show All Mostrar tudo + Post-Fader Pós-fader + Remove Effect Remover efeito + Fill the slots above first Preencha primeiro os slots acima + pre pré + Pre-Fader Pré-fader + Mute Send Silenciar envio + Remove Send Remover envio + New Submix Nova submixagem + Pan / balance (drag; double-click: centre) Pan / balanço (arraste; clique duplo: centro) + Solo safe Proteção de solo + Enable track for recording Ativar faixa para gravação + Volume (drag; double-click: 0 dB) Volume (arraste; clique duplo: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panorâmico 5.1 (arraste o ponto; clique duplo: centro frontal) + Center Centro + Go to In Point Ir para o ponto de entrada + Go to Out Point Ir para o ponto de saída + Play In to Out Reproduzir da entrada à saída + Record (voice-over on the record-enabled track) Gravar (narração na faixa ativada para gravação) + Write keyframes Gravar quadros-chave + Something went wrong while drawing the window. Your project is still open; save it to be safe. Ocorreu um erro ao desenhar a janela. Seu projeto continua aberto; salve-o por precaução. + Continue Continuar + Go Back Voltar + Go Forward Avançar + Up One Level Subir um nível + File Types Displayed Tipos de arquivo exibidos + List View Visualização em lista + Thumbnail View Visualização em miniaturas + Ingest Ingestão + Open Ingest Settings Abrir configurações de ingestão + Import as Image Sequence Importar como sequência de imagens + {sel} of {total} items selected {sel} de {total} itens selecionados + {total} items {total} itens + Cannot open {dir}: {e} Não é possível abrir {dir}: {e} + Favorites Favoritos + Local Drives Unidades locais + Network Rede + Recent Directories Pastas recentes + (none) (nenhuma) + Remove from Favorites Remover dos favoritos + Add to Favorites Adicionar aos favoritos + Clear Recent Directories Limpar pastas recentes + Open In Source Monitor Abrir no monitor de origem + Reveal in Finder Mostrar no gerenciador de arquivos + Edit Columns… Editar colunas… + Hover Scrub Reprodução ao passar o mouse + New Media Browser Panel Novo painel de navegador de mídia + Create Folder For Imported Projects Criar pasta para os projetos importados + Allow Duplicate Media During Project Import Permitir mídia duplicada ao importar projetos + Refresh Atualizar + Edit Columns Editar colunas + Guides Guias + (no audio) (sem áudio) + Generating waveform… Gerando forma de onda… + Reference {tc} Referência {tc} + Set Definir + Set the reference to the playhead Definir a referência no cursor de reprodução + Reference: next frame Referência: próximo quadro + Reference: previous frame Referência: quadro anterior + RGBA Channels Canais RGBA + Add Guide Adicionar guia + Save Guides as Template Salvar guias como modelo + Manage Guides Gerenciar guias + Orientation: Orientação: + Vertical Vertical + Horizontal Horizontal + Position: Posição: + No saved guide templates. Nenhum modelo de guias salvo. + Project: {name} Projeto: {name} + Bin: {name} Pasta: {name} + Up one level Subir um nível + Video Usage: {vu} Audio Usage: {au} Uso de vídeo: {vu} Uso de áudio: {au} + Icon View Visualização em ícones + Freeform View Visualização de forma livre + Sort Icons Ordenar ícones + Clear (Delete) Limpar (Delete) + New Item Novo item + New Bin Nova pasta + Find Localizar + Automate to Sequence Automatizar para a sequência + Select items to clear Selecione os itens que deseja limpar + User Order Ordem do usuário + Descending Decrescente + Refresh Project Atualizar projeto + Reveal Project in Finder… Mostrar o projeto no gerenciador de arquivos… + New Bin From Selection Nova pasta a partir da seleção + New Search Bin Nova pasta de pesquisa + New Project Shortcut Novo atalho de projeto + List Lista + Icon Ícone + Freeform Forma livre + Preview Area Área de pré-visualização + Thumbnails Miniaturas + Thumbnails show effects applied As miniaturas mostram os efeitos aplicados + Thumbnail controls for all pointing devices Controles de miniaturas para todos os dispositivos apontadores + Refresh Sort Order Atualizar a ordenação + Metadata Display… Exibição de metadados… + Save Current View Preset Salvar a predefinição de visualização atual + Restore View Preset Restaurar predefinição de visualização + Project View Preset {n} Predefinição de visualização do projeto {n} + Freeform View Options… Opções da visualização de forma livre… + Media missing Mídia ausente + Unreadable Ilegível + Open in Source Monitor Abrir no monitor de origem + Open in Timeline Abrir na linha do tempo + New Sequence From Clip Nova sequência a partir do clipe + Set Poster Frame Definir quadro de pôster + Proxy Proxy + Open in Place Abrir no mesmo lugar + Open in New Tab Abrir em uma nova guia + Open in New Window Abrir em uma nova janela + {n} items {n} itens + Stack Empilhar + Unstack Desempilhar + Clip Size Tamanho do clipe + Larger Maior + Smaller Menor + Align to Grid Alinhar à grade + Reset to Grid Redefinir para a grade + Save Arrangement… Salvar disposição… + Restore Arrangement Restaurar disposição + Delete Arrangement Excluir disposição + Drop media here to create sequence. Solte mídia aqui para criar uma sequência. + Double-click a sequence in the Project panel to open it here. Clique duas vezes em uma sequência no painel Projeto para abri-la aqui. + Open Demo Project Abrir projeto de demonstração + Insert and overwrite sequences as nests or individual clips Inserir e sobrescrever sequências como aninhadas ou como clipes individuais + Snap in Timeline (S) Encaixar na linha do tempo (S) + Caption track options Opções da faixa de legendas + Timeline Display Settings Configurações de exibição da linha do tempo + Show Video Thumbnails Mostrar miniaturas de vídeo + Head Início + Head and Tail Início e fim + Continuous Contínuas + Show Audio Waveform Mostrar forma de onda de áudio + Expand All Tracks Expandir todas as faixas + Minimize All Tracks Minimizar todas as faixas + Unrendered: should play back in real time Não renderizado: deve reproduzir em tempo real + Unrendered: render to play back in real time Não renderizado: renderize para reproduzir em tempo real + Rendered preview Pré-visualização renderizada + ✓ Enable ✓ Ativar + Unlink Desvincular + (Default) (Padrão) + (RGB) Gamma (RGB) Gama + (RGB) Input Black Level (RGB) Nível de preto de entrada + (RGB) Input White Level (RGB) Nível de branco de entrada + (RGB) Output Black Level (RGB) Nível de preto de saída + (RGB) Output White Level (RGB) Nível de branco de saída + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Ponto preto de 10 bits + 10 Bit White Point Ponto branco de 10 bits + 105mm Prime Lente fixa de 105 mm + 29.97 fps Drop-Frame 29,97 fps com drop-frame + 29.97 fps Non Drop-Frame 29,97 fps sem drop-frame + 35mm Prime Lente fixa de 35 mm + 3D Roll Rolagem 3D + 3D Rotate Rotação 3D + 3D Spin Giro 3D + 3D Spinback Giro reverso 3D + 4-Color Gradient Gradiente de 4 cores + 5.1 Media Mídia 5.1 + 5.1 Mixdown Type Tipo de mixagem 5.1 + 50-300mm Zoom Zoom de 50-300 mm + 59.94 fps Drop-Frame 59,94 fps com drop-frame + 59.94 fps Non Drop-Frame 59,94 fps sem drop-frame + ASC CDL ASC CDL + Accessible color contrast Contraste de cores acessível + Action-Safe Margin Margem de segurança de ação + Active Monitor Monitor ativo + Add Adicionar + Add tracks automatically when editing source clips onto the Timeline Adicionar faixas automaticamente ao editar clipes de origem na linha do tempo + Place files dropped onto the Timeline directly on the Timeline Colocar os arquivos soltos na linha do tempo diretamente nela + 1 file 1 arquivo + {n} files {n} arquivos + Additional Scale Escala adicional + Additive Dissolve Dissolução aditiva + Additive Dissolve (Legacy) Dissolução aditiva (legado) + Adjust Ajustar + Advanced Avançado + Aggressive Agressivo + Align and Transform Alinhar e transformar + All lines Todas as linhas + Allow Selection tool to choose Roll and Ripple trims without modifier key Permitir que a ferramenta de seleção escolha aparagens rolantes e em cascata sem tecla modificadora + Allow current tool to change trim type of previously selected edit point Permitir que a ferramenta atual altere o tipo de aparagem do ponto de edição selecionado anteriormente + Allow duplicate media during project import Permitir mídia duplicada ao importar projetos + Alpha Adjust Ajuste alfa + Alpha Blurriness Desfoque alfa + Alpha Channel Canal alfa + Alpha Glow Brilho alfa + Always import embedded CEA-708 closed captions with wide aspect. Sempre importar legendas ocultas CEA-708 incorporadas com proporção larga. + Always override audio tags Sempre substituir as marcações de áudio + Ambience Ambiente + Ambience Intensity Intensidade do ambiente + Ambience Light Color Cor da luz ambiente + Amount of Noise Quantidade de ruído + Amount to Decolor Quantidade de descoloração + Amount to Tint Quantidade de tingimento + Amplifier Amplificador + Amplify Amplificar + Amplitude Amplitude + Amplitude and Compression Amplitude e compressão + Analog Analógico + Analog Delay Delay analógico + Analyze all imported media to search for visuals or audio Analisar toda a mídia importada para pesquisar imagens ou áudio + Anamorphic Anamórfico + Anchor Âncora + Anchor Point Ponto de ancoragem + Angle Ângulo + Animation Animação + Anti-aliasing Quality Qualidade da suavização de serrilhado + Anti-flicker Filter Filtro anticintilação + Antialiasing for Best Quality Suavização de serrilhado para a melhor qualidade + Asymmetry Assimetria + At Startup Na inicialização + At playback end, return to beginning when restarting playback Ao fim da reprodução, voltar ao início ao reiniciá-la + Attempt to force hardware to document sample rate Tentar forçar o hardware para a taxa de amostragem do documento + Audio Codec Codec de áudio + Audio Duration Duração do áudio + Audio Effects Efeitos de áudio + Audio Hardware Hardware de áudio + Audio In Point Ponto de entrada do áudio + Audio Info Informações de áudio + Audio Out Point Ponto de saída do áudio + Audio Samples Amostras de áudio + Audio Time Units Unidades de tempo de áudio + Audio Transition Default Duration Duração padrão das transições de áudio + Audio Transitions Transições de áudio + Audio Usage Uso de áudio + Auto Automático + Auto Align Alinhamento automático + Auto Reframe Reenquadramento automático + Auto Save Salvamento automático + Auto Save also saves the current project(s) O salvamento automático também salva os projetos atuais + Auto-tag audio types in the timeline Marcar automaticamente os tipos de áudio na linha do tempo + Auto-transcribe all imported clips Transcrever automaticamente todos os clipes importados + Auto-transcribe only clips in sequences Transcrever automaticamente somente os clipes de sequências + Automatch Time Tempo de correspondência automática + Automatic Click Remover Removedor automático de cliques + Automatically Hide Dependent Clips Ocultar automaticamente os clipes dependentes + Automatically Save Every Salvar automaticamente a cada + Automatically delete cache files older than Excluir automaticamente os arquivos de cache com mais de + Automatically delete oldest cache files when cache exceeds Excluir automaticamente os arquivos de cache mais antigos quando o cache ultrapassar + Automatically refresh growing files Atualizar automaticamente os arquivos em crescimento + Automatically resume playback for growing files in Source Monitor Retomar automaticamente a reprodução de arquivos em crescimento no monitor de origem + Automatically save projects Salvar projetos automaticamente + Automatically transcribe clips Transcrever clipes automaticamente + Automation Keyframe Optimization Otimização de quadros-chave de automação + Background Color Cor de fundo + Background Corner Radius Raio do canto do fundo + Background Opacity Opacidade do fundo + Background Size Tamanho do fundo + Background fill Preenchimento de fundo + Balance Balanço + Balanced Voice Voz equilibrada + Band 1 Banda 1 + Band 2 Banda 2 + Band 3 Banda 3 + Band 4 Banda 4 + Band 5 Banda 5 + Band Pass Passa-banda + Band Slide Deslizamento de faixas + Band Stop Rejeita-banda + Bandpass Passa-banda + Barn Doors Portas de celeiro + Basic Básico + Basic 3D 3D básico + Basic Correction Correção básica + Bass Graves + Behind Atrás + Bessel Bessel + Bevel Alpha Chanfro alfa + Bevel join Junção chanfrada + Binauralizer - Ambisonics Binauralizador - Ambisonics + Bins Pastas + Black Preto + Black & White Preto e branco + Black Input Level Nível de entrada do preto + Black and white Preto e branco + Black and white with crushed blacks and strong contrast Preto e branco com pretos esmagados e contraste forte + Bleach Bypass Bleach bypass + Bleach-bypass style: desaturated with hard contrast Estilo bleach bypass: dessaturado e com contraste duro + Blend Mesclar + Blend Mode Modo de mesclagem + Blend With Original Mesclar com o original + Blending Mode Modo de mesclagem + Block Dissolve Dissolução em blocos + Block Height Altura do bloco + Block Motion Movimento de blocos + Block Size Tamanho do bloco + Block Width Largura do bloco + Blocks Blocos + Blue Blurriness Desfoque do azul + Blue Curve Curva do azul + Blue Offset Deslocamento do azul + Blue Power Potência do azul + Blue Slope Inclinação do azul + Blue-Blue Azul-azul + Blue-Const Azul-constante + Blue-Green Azul-verde + Blue-Red Azul-vermelho + Blur & Sharpen Desfoque e nitidez + Blur Amount Quantidade de desfoque + Blur Angle Ângulo do desfoque + Blur Dimensions Dimensões do desfoque + Blur Dissolve Dissolução com desfoque + Blur Layer Camada de desfoque + Blur Length Comprimento do desfoque + Blurriness Desfoque + Bokeh Blur Desfoque bokeh + Boost Reforço + Border Borda + Border Color Cor da borda + Border Width Largura da borda + Borders Bordas + Bottom Center Inferior central + Bottom Left Inferior esquerdo + Bottom Right Inferior direito + Bounce Salto + Branching Ramificação + Bright Brilhante + Bright Air Ar brilhante + Bright Studio Estúdio brilhante + Brightness & Contrast Brilho e contraste + British Stack Pilha britânica + Brush Hardness Dureza do pincel + Brush Opacity Opacidade do pincel + Brush Position Posição do pincel + Brush Size Tamanho do pincel + Brush Spacing (secs) Espaçamento do pincel (s) + Brush Strokes Pinceladas + Bubbles Bolhas + Bulge Protuberância + Bulge Smoother Suavização da protuberância + Bump Height Altura do relevo + Burn Alpha Queimar alfa + Burn Chroma Queimar croma + Burn Color Queimar cor + Butt cap Extremidade reta + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Armazenar em cache os resultados da análise para reutilizá-los + Camera Angle Ângulo de câmera + Camera Blur Desfoque de câmera + Camera Shake Tremor de câmera + Capitalisation Uso de maiúsculas + Cathedral Catedral + Cell Pattern Padrão de células + Center Channel Pan Pan do canal central + Center Gradient Gradiente central + Center Input Entrada central + Center Out Saída central + Center Point (Latitude) Ponto central (latitude) + Center Point (Longitude) Ponto central (longitude) + Center Split Divisão central + Center of Sphere Centro da esfera + Cents Cents + Change to Color Alterar para cor + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. As alterações são copiadas em segundo plano logo após cada edição. Se o FilmCraft fechar inesperadamente, elas serão oferecidas na próxima vez que ele for iniciado. + Channel Canal + Channel Blur Desfoque de canal + Channel Mix Mixagem de canais + Channel Mixer Misturador de canais + Chaos Caos + Chebyshev Chebyshev + Checker Wipe Cortina xadrez + Checkerboard Tabuleiro de xadrez + Chinese Chinês + Choke Estreitar + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Croma + Chroma Leak Vazamento de croma + Chromatic Aberration (Blue) Aberração cromática (azul) + Chromatic Aberration (Green) Aberração cromática (verde) + Chromatic Aberration (Red) Aberração cromática (vermelho) + Cineon Converter Conversor Cineon + Circle Círculo + Clarity Clareza + Clean Combo Combo limpo + Clean Up Noisy Dialogue Limpar diálogo com ruído + Clear Muddiness Remover embolamento + Client Cliente + Clip Clipe + Clip Level Nível do clipe + Clipping Clipping + Clock Wipe Cortina de relógio + Clock Wipe (Legacy) Cortina de relógio (legado) + Clockwise Sentido horário + Clone Clonar + Closed Captions Legendas ocultas + Color & Alpha Cor e alfa + Color 1 Cor 1 + Color 2 Cor 2 + Color 3 Cor 3 + Color 4 Cor 4 + Color Balance Equilíbrio de cores + Color Burn Subexposição de cor + Color Channel Canal de cor + Color Correction Correção de cor + Color Distortion Distorção de cor + Color Dodge Superexposição de cor + Color Emboss Relevo colorido + Color Key Chave de cor + Color Pass Passagem de cor + Color Replace Substituição de cor + Color Space Espaço de cores + Color Theme Tema de cores + Color To Leave Cor a manter + Color Tolerance Tolerância de cor + Color Wheels & Match Rodas de cores e correspondência + Colorize Colorir + Columns Colunas + Comment Comentário + Complexity Complexidade + Composite In Back Compor atrás + Composite In Front Compor na frente + Composite Using Compor usando + Composite on Original Compor sobre o original + Compound Blur Desfoque composto + Compression Before Clipping Compressão antes do clipping + Constant Gain Ganho constante + Constant Power Potência constante + Conversion Type Tipo de conversão + Convolution Reverb Reverberação por convolução + Cool Blue Azul frio + Cool, desaturated and punchy Frio, dessaturado e marcante + Core Width Largura do núcleo + Corner Canto + Corner Pin Fixação de cantos + Count Contagem + Counter-clockwise Sentido anti-horário + Crash Recovery Recuperação de falhas + Crawl Left Rastejar para a esquerda + Crawl Right Rastejar para a direita + Create folder for imported projects Criar pasta para os projetos importados + Creative Criativo + Crop Cortar + Crop Less <-> Smooth More Cortar menos <-> Suavizar mais + Cross Displacement Deslocamento cruzado + Cross Dissolve Dissolução cruzada + Cross Dissolve (Legacy) Dissolução cruzada (legado) + Cross Zoom Zoom cruzado + Cross Zoom (Legacy) Zoom cruzado (legado) + Crossfade Transição cruzada + Crossovers Frequências de cruzamento + Crystallize Cristalizar + Crystals Cristais + Cube Spin Giro de cubo + Curl Radius Raio da curvatura + Current Time Tempo atual + Cursor Cursor + Curvature Curvatura + Curve Curva + Curves Curvas + Custom Personalizado + Custom Color Cor personalizada + Cutoff Corte + Cyan-teal shadows and warm highlights with a little more contrast Sombras ciano-esverdeadas e realces quentes com um pouco mais de contraste + Cycle (in Revolutions) Ciclo (em revoluções) + Cycle Evolution Evolução cíclica + Damping Amortecimento + Damping HF Amortecimento de agudos + Damping LF Amortecimento de graves + Dark Escuro + Darken Escurecer + Darker Color Cor mais escura + Date Created Data de criação + Date Modified Data de modificação + Day for night: darker, cooler and less saturated Noite americana: mais escuro, mais frio e menos saturado + DeEsser De-esser + DeHummer Removedor de zumbido + DeNoise Redutor de ruído + DeReverb Redutor de reverberação + Decay Decaimento + Decay Time (RT60) Tempo de decaimento (RT60) + Default Audio Tracks Faixas de áudio padrão + Default Emoji font Fonte de emoji padrão + Default Media Scaling Escala de mídia padrão + Default Output Saída padrão + Default closed caption font Fonte padrão das legendas ocultas + Default language Idioma padrão + Default paragraph direction Direção de parágrafo padrão + Default subtitle and text font Fonte padrão de legendas e texto + Delay Delay + Delay 1 Delay 1 + Delay 2 Delay 2 + Delay 3 Delay 3 + Delay 4 Delay 4 + Delay and Echo Delay e eco + Delete files older than Excluir arquivos com mais de + Delete oldest files when the cache exceeds Excluir os arquivos mais antigos quando o cache ultrapassar + Delete… Excluir… + Depth Profundidade + Desaturate Dessaturar + Description Descrição + Detail Amplitude Amplitude do detalhe + Detail Level Nível de detalhe + Detailed Analysis Análise detalhada + Device Class Classe de dispositivo + Dialogue Diálogo + Diamond Losango + Difference Diferença + Dimensions Dimensões + Dimmed Esmaecido + Dip to Black Fade para preto + Dip to Black (Legacy) Fade para preto (legado) + Dip to Color Fade para cor + Dip to White Fade para branco + Dip to White (Legacy) Fade para branco (legado) + Direction Direção + Directional Direcional + Directional Blur Desfoque direcional + Directional Blur (Legacy) Desfoque direcional (legado) + Disable video output when in the background Desativar a saída de vídeo em segundo plano + Disperse Dispersar + Displacement Deslocamento + Display Color Management Gerenciamento de cores da tela + Display Color Settings Configurações de cor da tela + Display out of sync indicators for unlinked clips Mostrar indicadores de dessincronização em clipes desvinculados + Dissolve Dissolução + Dissolve Blocks Blocos de dissolução + Distance Distância + Distance to Image Distância até a imagem + Distort Distorcer + Distortion Distorção + Distortion Complexity Complexidade da distorção + Distortion Rate Velocidade da distorção + Distortion Type Tipo de distorção + Divide Dividir + Do not delete cache files automatically Não excluir arquivos de cache automaticamente + Don't label speakers Não identificar locutores + Double-click Clique duplo + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Decodificação de rascunho em resolução de reprodução reduzida (H.264: mais rápida, alguns quadros menos filtrados) + Drive Drive + Drop Shadow Sombra projetada + Dry Seco + Dry Out Saída seca + Dry Up a Roomy Voice Secar uma voz com muita sala + Duck Deep Under Dialogue Atenuar bastante sob o diálogo + Duck Under Dialogue Atenuar sob o diálogo + Duck Under Voice and Effects Atenuar sob a voz e os efeitos + Ducking Atenuação automática + Dutch Holandês + Dynamic Link Dynamic Link + Dynamics Processing Processamento de dinâmica + Early Reflections Primeiras reflexões + Earthquake Terremoto + Ease In and Out Suavizar entrada e saída + Easing Suavização + Echo Eco + Echo Glow Brilho de eco + Echo Operator Operador de eco + Echo Time (seconds) Tempo de eco (segundos) + Echoes Ecos + Edge Color Cor da borda + Edge Feather Difusão da borda + Edge Glow Brilho da borda + Edge Sharpness Nitidez da borda + Edge Softness Suavidade da borda + Edge Thickness Espessura da borda + Edge Thin Afinar borda + Edge Threshold Limiar da borda + Edge Type Tipo de borda + Edge Width Largura da borda + Elliptical Elíptico + Emboss Relevo + Enable HSL Secondary Ativar HSL secundário + Enable Mercury Transmit Ativar Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Ativar a codificação ProRes acelerada por hardware, se disponível + Enable clip and XMP metadata linking Ativar a vinculação de metadados de clipe e XMP + Enable developer mode Ativar o modo de desenvolvedor + Enable hardware accelerated decoding (requires restart) Ativar a decodificação acelerada por hardware (requer reinicialização) + Enable language auto-detection Ativar a detecção automática de idioma + Enable proxies Ativar proxies + End Color Cor final + End Point Ponto final + End of Ramp Fim da rampa + Error Erro + Evolution Evolução + Evolution Options Opções de evolução + Exciter Amount Quantidade do excitador + Exciter Mode Modo do excitador + Exclusion Exclusão + Exponential Fade Fade exponencial + Extended Dynamic Range Monitoring Monitoramento de faixa dinâmica estendida + FFT Filter Filtro FFT + Fade Fade + Fade Out Fade de saída + Faded Matte Matte desbotado + Falloff Atenuação + Far Away Distante + Fast Rápido + Faster Motion Movimento mais rápido + Feedback Realimentação + Feedback 1 Realimentação 1 + Feedback 2 Realimentação 2 + Feedback 3 Realimentação 3 + Feedback 4 Realimentação 4 + Feet + Frames 16mm Pés + quadros 16 mm + Feet + Frames 35mm Pés + quadros 35 mm + Female Feminina + File Size Tamanho do arquivo + Fill Background Preencher fundo + Fill Color Cor de preenchimento + Fill Left with Right Preencher esquerda com direita + Fill Point Ponto de preenchimento + Fill Right with Left Preencher direita com esquerda + Fill Selector Seletor de preenchimento + Film Dissolve Dissolução de filme + Film Dissolve (Legacy) Dissolução de filme (legado) + Film Roll Rolo de filme + Filter Filtro + Filter Frequency Frequência do filtro + Filter Resonance Ressonância do filtro + Filter and EQ Filtro e equalizador + Final Delay Delay final + Find Edges Localizar bordas + Flanger Flanger + Flare Reflexo + Flare Brightness Brilho do reflexo + Flare Center Centro do reflexo + Flare Color Cor do reflexo + Flash Flash + Flicker Cintilação + Flickers Cintilações + Flip Motion Movimento de virada + Flip Over Virar + Float Ponto flutuante + Focus Foco + Focus Blur Desfoque de foco + Focus Point Ponto de foco + Focus Shape Forma do foco + Focus Size Tamanho do foco + Fold Motion Movimento de dobra + Foldback Retorno + For color management parameters, go to the Settings tab in the Lumetri Color panel. Para os parâmetros de gerenciamento de cores, vá até a guia Configurações do painel Cor Lumetri. + Format Formato + Four Scopes (RGB, 8 bit) Quatro escopos (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Quatro escopos (YUV, ponto flutuante, sem limitar) + Fractal Influence Influência fractal + Fractal Type Tipo de fractal + Frame Quadro + Frame Color Cor da moldura + Frame Count Contagem de quadros + Frame Layout Layout do quadro + Frame Rate Taxa de quadros + Frame Width Largura da moldura + Frame cache for monitors and thumbnails Cache de quadros para monitores e miniaturas + Frames Quadros + Framing Enquadramento + French Francês + Frequency Frequência + From De + From East Do leste + From North Do norte + From South Do sul + From West Do oeste + From the Left Da esquerda + From the Right Da direita + Front + LFE Frontais + LFE + Front + Rear Frontais + traseiros + Front + Rear + LFE Frontais + traseiros + LFE + Front Only Somente frontais + Fuzz Fuzz + GB GB + Gamma Gama + Gamma Correction Correção de gama + Gamut Warning Aviso de gama de cores + Gamut Warning Color Cor do aviso de gama de cores + Gap Lacuna + Gap Color Cor da lacuna + Gaussian Blur Desfoque gaussiano + Gaussian Blur (Legacy) Desfoque gaussiano (legado) + General Geral + Generate Gerar + Generate Timecode Gerar timecode + Generate waveforms and conform audio using multithreading Gerar formas de onda e conformar o áudio com vários threads + Generate waveforms automatically during import Gerar formas de onda automaticamente durante a importação + German Alemão + Ghosts Fantasmas + Glass Vidro + Glint Cintilar + Glitch Glitch + Glow Brilho difuso + Glow Brightness Intensidade do brilho difuso + Glow Color Cor do brilho difuso + Glow Only Somente brilho difuso + Glow Radius Raio do brilho difuso + Glow Saturation Saturação do brilho difuso + Glow Width Largura do brilho difuso + Golden Hour Hora dourada + Good Boa + Gradient Gradiente + Gradient End Fim do gradiente + Gradient Layer Camada de gradiente + Gradient Placement Posicionamento do gradiente + Gradient Scatter Dispersão do gradiente + Gradient Shape Forma do gradiente + Gradient Start Início do gradiente + Gradient Wipe Cortina com gradiente + Gradient Wipe (Legacy) Cortina com gradiente (legado) + Grain Size Tamanho do grão + Graphic Equalizer (10 Bands) Equalizador gráfico (10 bandas) + Graphic Equalizer (20 Bands) Equalizador gráfico (20 bandas) + Graphic Equalizer (30 Bands) Equalizador gráfico (30 bandas) + Graphics Gráficos + Green Blurriness Desfoque do verde + Green Curve Curva do verde + Green Offset Deslocamento do verde + Green Power Potência do verde + Green Slope Inclinação do verde + Green-Blue Verde-azul + Green-Const Verde-constante + Green-Green Verde-verde + Green-Red Verde-vermelho + Grid Grade + Grow Crescer + Growing Files Arquivos em crescimento + Grunge Grunge + Grunge & Distort Grunge e distorção + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Branco de gráficos HDR (nits) + HLS Vectorscope + Histogram Vetorscópio HLS + histograma + HSL Secondary HSL secundário + Hard Duro + Hard Clip Clipping duro + Hard Light Luz direta + Hard Limiter Limitador rígido + Hard Mix Mistura sólida + Hardware decoding Decodificação por hardware + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Decodificação por hardware: Automático usa o decodificador de vídeo do sistema (VideoToolbox no macOS, Media Foundation no Windows, VA-API ou NVDEC para H.264 e HEVC no Linux) para os fluxos que ele suporta, e o decodificador próprio do FilmCraft para todo o resto ou se o hardware falhar. A mídia que já está aberta mantém seu decodificador até ser reaberta. + Head Size Tamanho da cabeça + Heart Coração + Height Altura + Hexagon Hexágono + Hide Back Face Ocultar face traseira + High Alta + High Cutoff Corte de agudos + High Frequency Cut Corte de altas frequências + High Frequency Damping Amortecimento de altas frequências + High Pass Passa-altas + High Shelf Prateleira de agudos + High Shelf Gain Ganho da prateleira de agudos + High contrast with held highlights and deep shadows Alto contraste com realces contidos e sombras profundas + Highlight Realce + Highlight Blue Balance Balanço de azul nos realces + Highlight Brightness Brilho dos realces + Highlight Color Cor de destaque + Highlight Green Balance Balanço de verde nos realces + Highlight Red Balance Balanço de vermelho nos realces + Highlight Rolloff Atenuação dos realces + Highlight Threshold Limiar dos realces + Highlight Tint Tonalidade dos realces + Highlights Lightness Luminosidade dos realces + Highpass Passa-altas + Hindi Hindi + Hindi digits Dígitos hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alinhamento horizontal + Horizontal Blocks Blocos horizontais + Horizontal Decentering Descentralização horizontal + Horizontal Displacement Deslocamento horizontal + Horizontal Flip Inversão horizontal + Horizontal Margin Margem horizontal + Horizontal and Vertical Horizontal e vertical + Hue Center Centro de matiz + Hue Tolerance Tolerância de matiz + I/O Buffer Size Tamanho do buffer de E/S + Ignore Alpha Ignorar alfa + Image Control Controle de imagem + Immersive Video Vídeo imersivo + Import numbered stills as image sequences Importar imagens estáticas numeradas como sequências de imagens + Impulse Impulso + In a Room Em uma sala + In the Media Cache No cache de mídia + In the project No projeto + In/Out Duration Duração da entrada à saída + Include captions on import Incluir legendas ao importar + Incoming Clipe seguinte + Indeterminate Media Timebase Base de tempo da mídia indeterminada + Initial Delay Delay inicial + Inner Interno + Inner Radius Raio interno + Input Boost Reforço de entrada + Inset Inserção + Inside Dentro + Inside Color Cor interna + Inter Inter + Interior Room Tone Tom de sala interna + Internal Black Point Ponto preto interno + Internal White Point Ponto branco interno + Interpolation Interpolação + Intersect Interseção + Inverse Falloff Atenuação inversa + Invert Inverter + Invert Alpha Inverter alfa + Invert Blur Inverter desfoque + Invert Fill Inverter preenchimento + Invert Gradient Inverter gradiente + Invert Left Inverter esquerda + Invert Right Inverter direita + Iris Íris + Iris Box Íris quadrada + Iris Cross Íris em cruz + Iris Diamond Íris em losango + Iris Radius Raio da íris + Iris Rotation Rotação da íris + Iris Round Íris redonda + Iris Shape Forma da íris + Italian Italiano + Japanese Japonês + JetBrains Mono JetBrains Mono + Join Through Edits Unir edições contíguas + Ripple Trim In Aparagem em cascata da entrada + Ripple Trim Out Aparagem em cascata da saída + Roll Edit Edição rolante + Trim In Aparar entrada + Trim Out Aparar saída + Kaleidoscope Caleidoscópio + Keep a recovery copy of unsaved changes Manter uma cópia de recuperação das alterações não salvas + Key Color Cor-chave + Keying Chaveamento + Korean Coreano + Label Defaults Rótulos padrão + Label speakers Identificar locutores + Labels Rótulos + Large Hall Salão grande + Large Trim Offset Deslocamento de aparagem grande + Large Volume Adjustment Ajuste de volume grande + Latch Latch + Layer Name Nome da camada + Layers Camadas + Leave Color Manter cor + Left - Left Esquerda - esquerda + Left - Right Esquerda - direita + Left channel to device output Canal esquerdo para a saída do dispositivo + Left to Right Da esquerda para a direita + Left to right Da esquerda para a direita + Legacy Legados + Length Comprimento + Lens Blur Desfoque de lente + Lens Distortion Distorção de lente + Lens Flare Reflexo de lente + Lens Type Tipo de lente + Level 1 Nível 1 + Level 2 Nível 2 + Level 3 Nível 3 + Level 4 Nível 4 + Levels Níveis + Lifted blacks, softer contrast, muted colour Pretos elevados, contraste mais suave e cor discreta + Light 1 Luz 1 + Light 2 Luz 2 + Light 3 Luz 3 + Light 4 Luz 4 + Light 5 Luz 5 + Light Angle Ângulo da luz + Light Color Cor da luz + Light Intensity Intensidade da luz + Light Leak Vazamento de luz + Light Leaks Vazamentos de luz + Light Sweep Varredura de luz + Light Type Tipo de luz + Lighten Clarear + Lighter Color Cor mais clara + Lighting Effects Efeitos de iluminação + Lightning Relâmpago + Lights & Blurs Luzes e desfoques + Lights & Glows Luzes e brilhos + Line Thickness Espessura da linha + Linear Burn Subexposição linear + Linear Dodge (Add) Superexposição linear (adicionar) + Linear Light Luz linear + Linear Ramp Rampa linear + Linear Wipe Cortina linear + Linear Wipe (Legacy) Cortina linear (legado) + Linear keyframe thinning Redução linear de quadros-chave + Linear to Log De linear para log + Liquid Distortion Distorção líquida + Local Projects Projetos locais + Location Local + Log Note Nota de registro + Log to Linear De log para linear + Log to Log De log para log + Logo Cutout Recorte de logotipo + Long Shadow Sombra longa + Look-Ahead Time Tempo de antecipação + Loudness Loudness + Loudness Maximizer Maximizador de loudness + Loudness Meter Medidor de loudness + Louver Persiana + Low Baixa + Low Frequency Cut Corte de baixas frequências + Low Pass Passa-baixas + Low Shelf Prateleira de graves + Low Shelf Gain Ganho da prateleira de graves + Lower Left Inferior esquerdo + Lower Right Inferior direito + Lowpass Passa-baixas + Luma Luma + Luma Curve Curva de luma + Luma Fade Fade por luma + Luma Key Chave de luma + Luma Source Origem de luma + Luma and Chroma Luma e croma + Lumetri Presets Predefinições Lumetri + Luminance Luminância + Luminance Threshold Limiar de luminância + Luminosity Luminosidade + MB MB + Magenta Magenta + Magnify Ampliar + Magnify (Legacy) Ampliar (legado) + Maintain pitch while shuttling Manter o tom ao usar o shuttle + Major Radius Raio maior + Male Masculina + Mango Manga + Map Black To Mapear preto para + Map White To Mapear branco para + Maps full-range levels into 16–235 (video range) with a luma curve Converte níveis de faixa completa em 16–235 (faixa de vídeo) com uma curva de luma + Mask Only Somente máscara + Master Amplitude Amplitude principal + Mastering Masterização + Match frame sets in point O quadro correspondente define o ponto de entrada + Matte Matte + Matte Alpha Matte alfa + Matte Cleanup Limpeza do matte + Matte Generation Geração do matte + Matte Luma Matte de luma + Maximum Máximo + Maximum Amplitude Amplitude máxima + Maximum Block Size Tamanho máximo do bloco + Maximum Blur Desfoque máximo + Maximum Project Versions Número máximo de versões do projeto + Maximum Reduction Redução máxima + Maximum Scale Escala máxima + Media Mídia + Media Analysis Análise de mídia + Media Analysis & Transcription Análise e transcrição de mídia + Media Cache Cache de mídia + Media Cache Database Banco de dados do cache de mídia + Media Cache Files Arquivos de cache de mídia + Media Cache Management Gerenciamento do cache de mídia + Media Duration Duração da mídia + Media End Fim da mídia + Media Time Tempo da mídia + Media Timecode Timecode da mídia + Media Type Tipo de mídia + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. As análises e transcrições de mídia serão armazenadas na pasta do cache de mídia. Não será necessário analisar novamente se a mídia for importada em outro projeto neste computador. + Median Mediana + Medium Room Sala média + Memory Memória + Metadata & Timecode Burn-in Gravação de metadados e timecode na imagem + Method Método + Metrics Kerning Kerning métrico + Mid Médios + Mid Point Ponto médio + Midtone Blue Balance Balanço de azul nos meios-tons + Midtone Green Balance Balanço de verde nos meios-tons + Midtone Red Balance Balanço de vermelho nos meios-tons + Midtones Lightness Luminosidade dos meios-tons + Minimum Mínimo + Minimum time Tempo mínimo + Minimum time interval thinning Redução por intervalo de tempo mínimo + Minor Radius Raio menor + Mirror Espelho + Mirror Alternate Espelho alternado + Missing font replacement Substituição de fontes ausentes + Miter join Junção em meia-esquadria + Miter limit Limite de meia-esquadria + Mixed Crystals Cristais mistos + Mode Modo + Modern High Gain Ganho alto moderno + Modulation Modulação + Modulation Rate Velocidade de modulação + Mono Media Mídia mono + Monochrome Monocromático + Monochrome toned cyan-blue Monocromático virado para azul-ciano + Monochrome toned warm brown Monocromático virado para marrom quente + Monoscopic Monoscópico + Monospace only Somente monoespaçadas + More colour in the less saturated areas Mais cor nas áreas menos saturadas + Morph Cut Corte com morphing + Mosaic Mosaico + Mosaic (Legacy) Mosaico (legado) + Motion Blur Desfoque de movimento + Motion Camera Câmera em movimento + Motion Preset Predefinição de movimento + Motion Tween Interpolação de movimento + Move Mover + Movie Filme + Movie (audio and video) Filme (áudio e vídeo) + Multiband Compressor Compressor multibanda + Multichannel Mono Media Mídia mono multicanal + Multiply Multiplicar + Multitap Delay Delay multitap + Music Música + Mute input during timeline recording Silenciar a entrada durante a gravação na linha do tempo + Name Nome + Narrow Estreito + Neon Color Cor neon + Neon Wipe Cortina neon + Next to original media files Ao lado dos arquivos de mídia originais + Night Noite + No Motion Sem movimento + No Scroll Sem rolagem + No plugins are installed. Changes will take effect the next time you start FilmCraft. Nenhum plug-in instalado. As alterações terão efeito na próxima vez que o FilmCraft for iniciado. + Noise Ruído + Noise & Grain Ruído e granulação + Noise (Legacy) Ruído (legado) + Noise Level Nível de ruído + Noise Reduction/Restoration Redução de ruído/restauração + Noise Type Tipo de ruído + Non-Additive Dissolve Dissolução não aditiva + Normal Normal + Notch 1 Notch 1 + Notch 2 Notch 2 + Notch 3 Notch 3 + Notch 4 Notch 4 + Notch 5 Notch 5 + Notch 6 Notch 6 + Notch Filter Filtro notch + Notch Width Largura do notch + Number Of Echoes Número de ecos + Number of Harmonics Número de harmônicos + Obsolete Obsoletos + Octagon Octógono + Offset Deslocamento + Offset (Turbulence) Deslocamento (turbulência) + Offset From Deslocar de + Offset To Deslocar até + Omni Omni + On Ativado + On Original Image Sobre a imagem original + On Transparent Sobre transparente + Open Air Ar livre + Open Most Recent Abrir o mais recente + Open in new window Abrir em uma nova janela + Open in place Abrir no mesmo lugar + Open new tab Abrir uma nova guia + Opens up the shadows Abre as sombras + Order Ordem + Orientation Orientação + Outer Externo + Outgoing Clipe anterior + Output Saída + Output Mapping Mapeamento de saídas + Outside Fora + Outside Color Cor externa + Overflow Transbordamento + Overlay Sobrepor + Overshoot Ultrapassagem + Page Peel Descascar página + Page Scroll Rolar página + Page Turn Virar página + Paint Bucket Balde de tinta + Paint On Black Pintar sobre preto + Paint On Original Image Pintar sobre a imagem original + Paint On Transparent Pintar sobre transparente + Paint On White Pintar sobre branco + Paint Style Estilo de pintura + Paint Surface Superfície de pintura + Pan (Y Axis) Pan (eixo Y) + Panel Wipe Cortina de painéis + Panels Painéis + Panner - Ambisonics Panorâmico - Ambisonics + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Equalizador paramétrico + Passband Ripple Ondulação da banda passante + Path Caminho + Path Points Pontos do caminho + Pause Media Encoder queue during playback Pausar a fila de codificação durante a reprodução + Peak Frequency Frequência de pico + Peak Gain Ganho de pico + Pedestal Pedestal + Pentagon Pentágono + Per line Por linha + Percent Blur Porcentagem de desfoque + Perspective Perspectiva + Phase Difference Diferença de fase + Phaser Phaser + Phone Call Chamada telefônica + Phosphor Fósforo + Phosphor Color Cor do fósforo + Photocopy Fotocópia + Photocopy Color Cor da fotocópia + Pillow Almofada + Pin All Fixar tudo + Pin Horizontal Fixar horizontal + Pin Light Luz pontual + Pin Vertical Fixar vertical + Pinning Fixação + Pitch Shifter Alterador de tom + Plate Placa + Plateau Wipe Cortina em platôs + Plateaus Platôs + Plates Placas + Play after rendering previews Reproduzir após renderizar as pré-visualizações + Play audio while scrubbing in Source and Program Monitors Reproduzir áudio ao percorrer nos monitores de origem e do programa + Playback Reprodução + Playhead position determines trim monitor loop playback A posição do cursor de reprodução determina a reprodução em loop do monitor de aparagem + Plugins Plug-ins + Podcast Voice Voz de podcast + Point 1 Ponto 1 + Point 1 Color Cor do ponto 1 + Point 1 Latitude Latitude do ponto 1 + Point 1 Longitude Longitude do ponto 1 + Point 2 Ponto 2 + Point 2 Color Cor do ponto 2 + Point 2 Latitude Latitude do ponto 2 + Point 2 Longitude Longitude do ponto 2 + Point 3 Ponto 3 + Point 3 Color Cor do ponto 3 + Point 3 Latitude Latitude do ponto 3 + Point 3 Longitude Longitude do ponto 3 + Point 4 Ponto 4 + Point 5 Ponto 5 + Point 6 Ponto 6 + Point 7 Ponto 7 + Point 8 Ponto 8 + Points Pontos + Policy Política + Pop Motion Movimento de surgimento + Portuguese Português + Position & Rotation Posição e rotação + Position Amount Quantidade de posição + Position, Scale & Rotation Posição, escala e rotação + Position, Scale, Rotation Posição, escala, rotação + Posterize Posterizar + Posterize Time Posterizar tempo + Pre-Delay Pré-delay + Prefix Prefixo + Presence Lift Realce de presença + Preserve Luminosity Preservar luminosidade + Preserve Scale Preservar escala + ProcAmp ProcAmp + Projecting cap Extremidade projetada + Projects Projetos + Pull Motion Movimento de puxar + Pulls highlights and whites down Abaixa os realces e os brancos + Push Empurrar + Push (Legacy) Empurrar (legado) + Q Q + RAM reserved for other applications RAM reservada para outros aplicativos + RGB RGB + RGB Split Divisão RGB + RGB-White RGB-branco + Radial Radial + Radial Blur Desfoque radial + Radial Ramp Rampa radial + Radial Wipe Cortina radial + Radial Wipe (Legacy) Cortina radial (legado) + Radio Announcer Locutor de rádio + Radio Voice Voz de rádio + Radius Raio + Ramp Rampa + Ramp Shape Forma da rampa + Random Aleatório + Random Seed Semente aleatória + Range Intervalo + Ray Raio de luz + Ray Color Cor do raio de luz + Ray Length Comprimento do raio de luz + Rays Raios de luz + Rays Only Somente raios de luz + Read Leitura + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Desfoque do vermelho + Red Curve Curva do vermelho + Red Offset Deslocamento do vermelho + Red Power Potência do vermelho + Red Slope Inclinação do vermelho + Red-Blue Vermelho-azul + Red-Const Vermelho-constante + Red-Green Vermelho-verde + Red-Red Vermelho-vermelho + Reduce Axis Reduzir eixo + Reduce Interlace Flicker Reduzir cintilação de entrelaçamento + Reflected Refletido + Reflection Angle Ângulo de reflexão + Reflection Center Centro de reflexão + Refraction Refração + Reframe Offset Deslocamento do reenquadramento + Reframe Zoom Zoom do reenquadramento + Refresh growing Files Every Atualizar os arquivos em crescimento a cada + Relaxed Relaxado + Release Time Tempo de liberação + Relief Relevo + Remaining Restante + Remove Fringe Remover franja + Render Edit in Audition files to: Renderizar os arquivos editados no Audition em: + Render audio when rendering video Renderizar o áudio ao renderizar o vídeo + Repair Reparar + Repeat Edge Pixels Repetir pixels da borda + Replace Color Substituir cor + Replace With Clip From Source Monitor Substituir pelo clipe do monitor de origem + Replicate Replicar + Responsive Design - Position Design responsivo - posição + Responsive Design - Time Design responsivo - tempo + Restore open sequences when opening projects Restaurar as sequências abertas ao abrir projetos + Result Resultado + Retro Retrô + Reveal Nested Sequence Mostrar sequência aninhada + Reveal Original Image Mostrar a imagem original + Reveal in Project Mostrar no projeto + Reverb Amount Quantidade de reverberação + Reverse Inverter + Right - Left Direita - esquerda + Right - Right Direita - direita + Right channel to device output Canal direito para a saída do dispositivo + Right to left Da direita para a esquerda + Ripple trim adds edits to keep both sides of trim in sync A aparagem em cascata adiciona edições para manter os dois lados da aparagem sincronizados + Roll (Z Axis) Rolagem (eixo Z) + Room Size Tamanho da sala + Rotation Amount Quantidade de rotação + Roughen Rugosidade + Roughen Color Cor rugosa + Roughen Edges Bordas ásperas + Round cap Extremidade arredondada + Round join Junção arredondada + Rounded Crop Corte arredondado + Rows Linhas + Russian Russo + Rusty Enferrujado + Rusty Color Cor enferrujada + SFX Efeitos sonoros + Sample Rate Taxa de amostragem + Saturation at 50 % Saturação em 50 % + Save .cfa and .pek media cache files next to original media files when possible Salvar os arquivos de cache .cfa e .pek ao lado da mídia original quando possível + Scale (Degrees) Escala (graus) + Scale Amount Quantidade de escala + Scale From Escalar de + Scale Height Escalar altura + Scale Match Correspondência de escala + Scale To Escalar para + Scale Width Escalar largura + Scale to frame size Escalar para o tamanho do quadro + Scan Lines Linhas de varredura + Scene Cena + Scientific Filter Filtro científico + Scratch disk location for Captured Audio Local do disco de trabalho para o áudio capturado + Screen Tela + Seconds Segundos + Segments Segmentos + Semi-tones Semitons + Sequence Name Nome da sequência + Sequence Timecode Timecode da sequência + Set focus on the Timeline when performing Insert/Overwrite edits Focar a linha do tempo ao fazer edições de inserção/sobrescrita + Set to frame size Definir para o tamanho do quadro + Setting Configuração + Shadow Angle Ângulo da sombra + Shadow Blue Balance Balanço de azul nas sombras + Shadow Blur Desfoque da sombra + Shadow Color Cor da sombra + Shadow Distance Distância da sombra + Shadow Green Balance Balanço de verde nas sombras + Shadow Only Somente sombra + Shadow Opacity Opacidade da sombra + Shadow Red Balance Balanço de vermelho nas sombras + Shadow Size Tamanho da sombra + Shadow Tint Tonalidade das sombras + Shadows Lightness Luminosidade das sombras + Shake Sacudida + Shape Dissolve Dissolução com forma + Shape Flow Fluxo de formas + Shape Size Tamanho da forma + Shapes Formas + Shard Size Tamanho dos fragmentos + Sharp Colors Cores nítidas + Sharpen Amount Quantidade de nitidez + Shift Center To Deslocar o centro para + Shift clips that overlap trim point during ripple trimming Deslocar os clipes que se sobrepõem ao ponto de aparagem durante a aparagem em cascata + Shot Plano + Show "Project:" and "Bin:" in Project panel tab Mostrar "Projeto:" e "Pasta:" na guia do painel Projeto + Show Clip Mismatch Warning dialog Mostrar o aviso de incompatibilidade de clipe + Show Event Indicator Mostrar o indicador de eventos + Show Focus Area Mostrar área de foco + Show Home Mostrar Início + Show Mask Tracker Preview Mostrar a pré-visualização do rastreamento de máscara + Show Only Noise Mostrar somente o ruído + Show Open Dialog Mostrar a caixa de diálogo Abrir + Show Project Load Error Dialog Mostrar a caixa de diálogo de erro ao carregar projetos + Show Rich Tool Tips Mostrar dicas de ferramentas detalhadas + Show Tool Tips Mostrar dicas de ferramentas + Show Workspace Reset Warning dialog on double click Mostrar o aviso de redefinição da área de trabalho ao clicar duas vezes + Show system compatibility issues at startup Mostrar problemas de compatibilidade do sistema na inicialização + Shrink Encolher + Shutter Angle Ângulo do obturador + Similarity Semelhança + Simple Notch Filter Filtro notch simples + Simple Parametric EQ Equalizador paramétrico simples + Simple Text Texto simples + Single-band Compressor Compressor de banda única + Sinusoidal Senoidal + Skew Inclinação + Skew Axis Eixo de inclinação + Slats Ripas + Slice Fatia + Slices Fatias + Slide Deslizar + Slide (Legacy) Deslizar (legado) + Slope Inclinação + Slow Lento + Slower Motion Movimento mais lento + Small Room Sala pequena + Smart Limit Limite inteligente + Smart Tools Ferramentas inteligentes + Smart quotes Aspas tipográficas + Smooth Suave + Smooth Motion Movimento suave + Smooth Scroll Rolagem suave + Smoothness Suavidade + Snap playhead in Timeline when Snap is enabled Encaixar o cursor de reprodução na linha do tempo quando o encaixe estiver ativado + Soft Suave + Soft Clamp Limitação suave + Soft Edges (Best Quality) Bordas suaves (melhor qualidade) + Soft Light Luz indireta + Soft Wipe Cortina suave + Soften Harshness Suavizar a aspereza + Softness Suavidade + Solarize Solarizar + Solid Colors Cores sólidas + Source Point Ponto de origem + Source Text Texto de origem + Spacer Espaçador + Spanish Espanhol + Speaker Angle Ângulo do alto-falante + Speaker Labeling Identificação de locutores + Special Especial + Special Effects Efeitos especiais + Speech model Modelo de fala + Speed Velocidade + Spherize Esferizar + Spiky Pontiagudo + Spill Contaminação + Spill Suppression Supressão de contaminação + Spin Girar + Spin Direction Direção do giro + Spin Motion Movimento de giro + Split Dividir + Split (Legacy) Dividir (legado) + Spotlight Holofote + Spread Espalhamento + Spring Motion Movimento elástico + Square Quadrado + Square (Width Only) Quadrado (somente largura) + Square 1:1 Quadrado 1:1 + Stabilization Estabilização + Stabilize Only Somente estabilizar + Stabilize, Crop Estabilizar, cortar + Stabilize, Crop, Auto-scale Estabilizar, cortar, escalar automaticamente + Stabilize, Synthesize Edges Estabilizar, sintetizar bordas + Stages Estágios + Star Estrela + Star Wipe Cortina em estrela + Start Angle Ângulo inicial + Start Color Cor inicial + Start Point Ponto inicial + Start at 0 Começar em 0 + Start at 1 Começar em 1 + Start of Ramp Início da rampa + Start with an Empty Project Começar com um projeto vazio + Starting Intensity Intensidade inicial + Static Plates Placas estáticas + Step forward/back many Avançar/voltar muitos + Stereo Expand Expansão estéreo + Stereo Expander Expansor estéreo + Stereo Imagery Imagem estéreo + Stereo Media Mídia estéreo + Stereo Phasing Fase estéreo + Stereoscopic - Over/Under Estereoscópico - acima/abaixo + Stereoscopic - Side by Side Estereoscópico - lado a lado + Still Imagem estática + Still Image Default Duration Duração padrão de imagens estáticas + Stone Church Igreja de pedra + Stopband Attenuation Atenuação da banda de rejeição + Straight Color Cor direta + Strength Intensidade + Stretch Esticar + Stretch Gradient to Fit Esticar gradiente para ajustar + Stretch Map to Fit Esticar mapa para ajustar + Stretch Width or Height Esticar largura ou altura + Stretch Wipe Cortina esticada + Stretch to Fit Frame Esticar para ajustar ao quadro + Stripe Listra + Stripes Listras + Strobe Color Cor do estroboscópio + Strobe Duration (secs) Duração do estroboscópio (s) + Strobe Light Luz estroboscópica + Strobe Period (secs) Período do estroboscópio (s) + Stroke 2 Color Cor do traçado 2 + Stroke 2 Type Tipo do traçado 2 + Stroke 2 Width Largura do traçado 2 + Stroke Angle Ângulo do traçado + Stroke Color Cor do traçado + Stroke Density Densidade do traçado + Stroke Length Comprimento do traçado + Stroke Length (secs) Comprimento do traçado (s) + Stroke Only Somente traçado + Stroke Randomness Aleatoriedade do traçado + Stroke Type Tipo de traçado + Stroke Width Largura do traçado + Stroke line cap Extremidade da linha do traçado + Stroke line join Junção da linha do traçado + Studio Reverb Reverberação de estúdio + Stylize Estilizar + Subclip End Fim do subclipe + Subclip Start Início do subclipe + Subspace Warp Distorção de subespaço + Subtitle Legenda + Subtract Subtrair + Sum multichannel outputs to mono in Source Monitor Somar as saídas multicanal em mono no monitor de origem + Super Narrow Superestreito + Surface Gloss Brilho da superfície + Surface Material Material da superfície + Surround Reverb Reverberação surround + Swap Trocar + Swap Channels Trocar canais + Swivel Girar no eixo + Synthetic Sintético + TV Power Ligar TV + Tape Fita + Tape Name Nome da fita + Tape/Tube Fita/válvula + Target Aspect Proporção de destino + Target Color Cor de destino + Target Field of View Campo de visão de destino + Target Loudness Loudness de destino + Teal & Orange Verde-azulado e laranja + Telephone Telefone + Teletext Teletexto + Template Properties Propriedades do modelo + Text Animator Animador de texto + Text Color Cor do texto + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. O cache de mídia guarda pré-visualizações renderizadas de projetos não salvos e outros arquivos que o FilmCraft pode recriar. Excluí-los é sempre seguro; o FilmCraft os recria quando necessário. + Thickness Espessura + Tile Gradient Gradiente em mosaico + Tilt Inclinação + Tilt (X Axis) Inclinação (eixo X) + Time Tempo + Time and Pitch Tempo e tom + Timecode Conversion Conversão de timecode + Timeline Mouse Scrolling Rolagem com o mouse na linha do tempo + Timeline Playback Auto-Scrolling Rolagem automática da linha do tempo durante a reprodução + Tint Color Cor da tonalidade + To Até + Tolerance Tolerância + Tone Tom + Top Center Superior central + Top Left Superior esquerdo + Top Right Superior direito + Touch Touch + Track Matte Key Chave de matte de faixa + Transcription Transcrição + Transcription preferences Preferências de transcrição + Transform Transformar + Transformers Transformadores + Transience Transientes + Transition Completion Conclusão da transição + Transition Softness Suavidade da transição + Transparency Transparência + Trash Lixeira + Travel Motion Movimento de deslocamento + Treble Agudos + Triangle Triângulo + Trim Aparar + Tube Válvula + Tube-modeled Compressor Compressor com modelagem de válvula + Tubular Tubular + Turbulent Turbulento + Turbulent Displace Deslocamento turbulento + Turbulent Sharp Turbulento nítido + Turbulent Smooth Turbulento suave + Turbulent Smoother Turbulento mais suave + Tweed Tweed + Twirl Redemoinho + Twirl (Legacy) Redemoinho (legado) + Twirl Center Centro do redemoinho + Twirl Radius Raio do redemoinho + Twist Torção + Twist Smoother Suavização da torção + Typewriter Máquina de escrever + Ultra Key Ultra Key + Uniform Scale Escala uniforme + Uniform Spacing Espaçamento uniforme + Unsharp Mask Máscara de nitidez + Update it at least every Atualizá-la pelo menos a cada + Update timeline dynamically during ripple edits. Atualizar a linha do tempo dinamicamente durante as edições em cascata. + Upper Frequency Frequência superior + Upper Left Superior esquerdo + Upper Right Superior direito + Use Color Noise Usar ruído colorido + Use End Color Usar cor final + Use File Usar arquivo + Use Glow Color Usar cor do brilho difuso + Use Media Source Usar a origem da mídia + Use Tint Color Usar cor da tonalidade + Utility Utilitário + VHS Damage Dano de VHS + VR Blur Desfoque VR + VR Chroma Leaks Vazamentos de croma VR + VR Chromatic Aberrations Aberrações cromáticas VR + VR Color Gradients Gradientes de cor VR + VR De-Noise Redução de ruído VR + VR Digital Glitch Glitch digital VR + VR Fractal Noise Ruído fractal VR + VR Glow Brilho difuso VR + VR Gradient Wipe Cortina com gradiente VR + VR Iris Wipe Cortina de íris VR + VR Light Leaks Vazamentos de luz VR + VR Light Rays Raios de luz VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plano para esfera VR + VR Projection Projeção VR + VR Random Blocks Blocos aleatórios VR + VR Rotate Sphere Girar esfera VR + VR Sharpen Nitidez VR + VR Spherical Blur Desfoque esférico VR + Validate Content Credentials on import Validar as credenciais de conteúdo ao importar + Vectorscope (YUV) Vetorscópio (YUV) + Vectorscope + Waveform YC Vetorscópio + forma de onda YC + Vectorscope HLS Vetorscópio HLS + Vectorscope YUV Vetorscópio YUV + Venetian Blinds Persianas venezianas + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alinhamento vertical + Vertical Blocks Blocos verticais + Vertical Decentering Descentralização vertical + Vertical Displacement Deslocamento vertical + Vertical Flip Inversão vertical + Vertical Margin Margem vertical + Very Narrow Muito estreito + Video 1 Vídeo 1 + Video 2 Vídeo 2 + Video 3 Vídeo 3 + Video 4 Vídeo 4 + Video 5 Vídeo 5 + Video 6 Vídeo 6 + Video 7 Vídeo 7 + Video 8 Vídeo 8 + Video Codec Codec de vídeo + Video Duration Duração do vídeo + Video Effects Efeitos de vídeo + Video In Point Ponto de entrada do vídeo + Video Info Informações de vídeo + Video Limiter Limitador de vídeo + Video Out Point Ponto de saída do vídeo + Video Transition Default Duration Duração padrão das transições de vídeo + Video Transitions Transições de vídeo + Video Usage Uso de vídeo + Vignette Vinheta + Vivid Light Luz vívida + Vocal Booth Cabine de locução + Vocal Enhancer Realçador vocal + Voice Voz + Voice in a Hall Voz em um salão + Volumetric Rays Raios volumétricos + Warm Film Filme quente + Warm Room Sala quente + Warm Voice Voz quente + Warm print stock with gentle roll-off Cópia de filme quente com atenuação suave + Warm, soft late-afternoon light Luz quente e suave de fim de tarde + Warning Aviso + Warp Stabilizer Estabilizador de distorção + Wave Onda + Wave Height Altura da onda + Wave Speed Velocidade da onda + Wave Warp Distorção de onda + Wave Width Largura da onda + Waveform Forma de onda + Waveform (Luma) Forma de onda (luma) + Waveform (RGB) Forma de onda (RGB) + Wavelength Comprimento de onda + Wet Molhado + Wet Out Saída molhada + When Opening a Project Ao abrir um projeto + Whip Chicote + Whip (Legacy) Chicote (legado) + Whisper base (balanced) Whisper base (equilibrado) + Whisper small (most accurate) Whisper small (mais preciso) + Whisper tiny (fastest) Whisper tiny (mais rápido) + White Branco + White Input Level Nível de entrada do branco + White Is High Branco é alto + Wide Outdoor Externa ampla + Widener Alargador + Width Largura + Wiggle Oscilação + Wipe Cortina + Wipe Angle Ângulo da cortina + Wonder Glow Brilho maravilhoso + Wrap Back Envolver para trás + Write Gravação + Write XMP ID to files on import Gravar o ID XMP nos arquivos ao importar + Write clip markers to XMP Gravar os marcadores de clipe em XMP + Write-on Escrita progressiva + X Rotation Rotação X + Y Rotation Rotação Y + YC YC + YC no Chroma YC sem croma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Realces amarelo-esverdeados e sombras elevadas para o azul com curvas de canais cruzadas + Z Position Posição Z + Z Rotation Rotação Z + Zoom Zoom + Zoom Amount Quantidade de zoom + Zoom Blur Desfoque de zoom + Zoom Strength Intensidade do zoom + days dias + frames quadros + milliseconds milissegundos + minute(s) minuto(s) + second(s) segundo(s) + seconds segundos + AAF… AAF… + Audio… Áudio… + Avid Log Exchange… Avid Log Exchange… + Color… Cor… + EDL… EDL… + Edit Workspaces… Editar áreas de trabalho… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Geral… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Salvar as alterações nesta área de trabalho + Save as New Workspace… Salvar como nova área de trabalho… + Create Search Bin Criar pasta de pesquisa + Edit Offline File Editar arquivo offline + Source Settings Configurações de origem + Update Metadata Atualizar metadados + Automate To Sequence Automatizar para a sequência + Scene Edit Detection Detecção de edição de cena + Analyze Analisar + Normalize Mix Track Normalizar faixa de mixagem + Simplify Sequence Simplificar sequência + Simplify Simplificar + Transcribe Sequence Transcrever sequência + Save as Template Salvar como modelo + Flash Cue Marker Marcador de referência Flash + System Compatibility Report Relatório de compatibilidade do sistema + no GPU adapter: the CPU compositor is used nenhum adaptador de GPU: o compositor da CPU é usado + Avid Log Exchange Avid Log Exchange + FilmCraft Project Projeto do FilmCraft + {name} Selection.fcproj {name} Seleção.fcproj + {name} (Simplified) {name} (simplificada) + Find in: Localizar em: + Find: Localizar: + Search: Pesquisar: + Clip Names and Markers Nomes de clipes e marcadores + Match: Corresponder: + All Todos + Any Qualquer + Case Sensitive Diferenciar maiúsculas e minúsculas + Scratch Disks Discos de trabalho + Ingest Settings Configurações de ingestão + Captured and Generated: Capturado e gerado: + Video Previews: Pré-visualizações de vídeo: + Audio Previews: Pré-visualizações de áudio: + Project Auto Save: Salvamento automático do projeto: + Same as Project Igual ao projeto + Path: {path} Caminho: {path} + Transcode Transcodificar + Copy and Create Proxies Copiar e criar proxies + Action: Ação: + Destination: Destino: + Next to the media Ao lado da mídia + Video Rendering and Playback Renderização e reprodução de vídeo + Renderer: Renderizador: + Display Format: Formato de exibição: + Milliseconds Milissegundos + Capture Captura + Capture Format: Formato de captura: + Action and Title Safe Areas Áreas de segurança de ação e de título + Title Safe Area Área de segurança de título + Action Safe Area Área de segurança de ação + Total Duration Duração total + Average Data Rate Taxa de dados média + Colour Cor + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(is) + Media Name: Nome da mídia: + Scene: Cena: + Shot/Take: Plano/tomada: + Log Note: Nota de registro: + Codec: {codec} Codec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Gravar os metadados dos clipes selecionados ({n}) em arquivos XMP ao lado de sua mídia? + Existing XMP files written by other applications are left unchanged. Os arquivos XMP existentes gravados por outros aplicativos não são alterados. + To {name} Para {name} + Ordering: Ordenação: + Sort Order Ordem de classificação + Selection Order Ordem de seleção + Placement: Posicionamento: + Sequentially Sequencialmente + At Unnumbered Markers Em marcadores não numerados + Method: Método: + Insert Edit Edição de inserção + Overwrite Edit Edição de sobrescrita + Clip Overlap: Sobreposição de clipes: + Frames per Still: Quadros por imagem estática: + Transitions Transições + Apply Default Audio Transition Aplicar a transição de áudio padrão + Apply Default Video Transition Aplicar a transição de vídeo padrão + Ignore Options Opções de omissão + Ignore Audio Ignorar áudio + Ignore Video Ignorar vídeo + Apply a cut at each detected cut point Aplicar um corte em cada ponto de corte detectado + Create a subclip for each detected cut point Criar um subclipe para cada ponto de corte detectado + Generate clip markers at each detected cut point Gerar marcadores de clipe em cada ponto de corte detectado + Sensitivity: Sensibilidade: + Normalize Mix Track to: Normalizar faixa de mixagem para: + Disabled clips Clipes desativados + Empty tracks Faixas vazias + Gaps Lacunas + Video effects Efeitos de vídeo + Audio effects Efeitos de áudio + Text (graphics and captions) Texto (gráficos e legendas) + Clips Clipes + Move clips to the lowest track possible Mover os clipes para a faixa mais baixa possível + Keep: Manter: + Video and Audio Vídeo e áudio + Video Only Somente vídeo + Audio Only Somente áudio + Language: Idioma: + Audio on track {t} Áudio da faixa {t} + Audio analysis: Análise de áudio: + Recognize when different speakers are talking Reconhecer quando pessoas diferentes estão falando + Template Name: Nome do modelo: + Comments: Comentários: + (unavailable) (indisponível) + Decoders Decodificadores + Containers Contêineres + Export formats Formatos de exportação + Match {n} of {total} Correspondência {n} de {total} + Saved template {path} Modelo salvo em {path} + Scene Edit Detection: analysing in the background (see Progress) Detecção de edição de cena: analisando em segundo plano (consulte Progresso) + Delete Search Bin Excluir pasta de pesquisa + (removed from {list}) (removido de {list}) + {key} can't be used as a shortcut {key} não pode ser usado como atalho + Keyboard Shortcuts Atalhos de teclado + {active} (modified) {active} (modificado) + Loaded “{name}” “{name}” carregado + My Shortcuts Meus atalhos + Deleted preset “{active}” Predefinição “{active}” excluída + Export… Exportar… + Exported to {path} Exportado para {path} + Export: no file chosen (agents: shortcuts.export {path}) Exportar: nenhum arquivo escolhido (agentes: shortcuts.export {path}) + Imported “{name}” “{name}” importado + Import: no file chosen (agents: shortcuts.import {path}) Importar: nenhum arquivo escolhido (agentes: shortcuts.import {path}) + Keyboard: US (macOS) Teclado: EUA (macOS) + Keyboard: US Teclado: EUA + Save preset as: Salvar predefinição como: + Commands: Comandos: + {panel} Panel Painel {panel} + Application shortcuts work whatever panel has focus Os atalhos do aplicativo funcionam em qualquer painel que esteja em foco + Panel shortcuts override application shortcuts while that panel has focus Os atalhos de painel substituem os do aplicativo enquanto esse painel estiver em foco + Modifier held / selected Modificador pressionado / selecionado + Search commands or keys Pesquisar comandos ou teclas + Selected: {command} Selecionado: {command} + Command Comando + Shortcut Atalho + Type a shortcut… (Esc cancels) Digite um atalho… (Esc cancela) + Add another shortcut Adicionar outro atalho + No matching commands Nenhum comando correspondente + Key: Tecla: + Modifiers Modificadores + Nothing is assigned to this key Nada está atribuído a esta tecla + Click a key on the keyboard to see its shortcuts Clique em uma tecla do teclado para ver seus atalhos + Assign {key} to {command} Atribuir {key} a {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflito: {key} está atribuída a {commands} ({ctx}) + and e + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Clique na célula Atalho de um comando e pressione as teclas para atribuí-lo; + adiciona outro atalho. + Cleared {n} shortcut(s) of {command} {n} atalho(s) de {command} removido(s) + No export preset named `{name}` Nenhuma predefinição de exportação chamada `{name}` + Open a sequence to export Abra uma sequência para exportar + Destinations Destinos + Media File Arquivo de mídia + Queue ({n}) Fila ({n}) + Send exports here with Send to Queue, then start the queue. Envie exportações para cá com Enviar para a fila e depois inicie a fila. + Ready Pronta + Encoding {n}% Codificando {n}% + Start Queue Iniciar fila + Stop Parar + Clear Finished Limpar concluídas + Not supported by FilmCraft's encoders yet Ainda não suportado pelos codificadores do FilmCraft + Sequence name Nome da sequência + Folder Pasta + Preset Predefinição + More presets… Mais predefinições… + Multiplexer Multiplexador + The sequence has no caption track. A sequência não tem faixa de legendas. + Export Options Opções de exportação + Burn Captions Into Video Gravar as legendas no vídeo + Create Sidecar File Criar arquivo sidecar + File Format Formato de arquivo + SubRip Subtitle (.srt) Legenda SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Título + Creator Autor + Copyright Copyright + Image Imagem + Frame Size Tamanho do quadro + Match Source Igual à origem + Field Order Ordem dos campos + Aspect Proporção + Profile Perfil + Bitrate Encoding Codificação da taxa de bits + Target Bitrate Taxa de bits desejada + Adaptive ({bpp} bits per pixel) Adaptável ({bpp} bits por pixel) + Maximum Bitrate Taxa de bits máxima + Quality (CRF) Qualidade (CRF) + Key Frame Distance Distância entre quadros-chave + Quality Qualidade + Render at Maximum Depth Renderizar na profundidade máxima + Use Maximum Render Quality Usar a qualidade máxima de renderização + Export Audio Exportar áudio + Audio Format Formato de áudio + Uncompressed (PCM) Sem compressão (PCM) + Match Source ({seq_sr} Hz) Igual à origem ({seq_sr} Hz) + Channels Canais + Audio Bitrate Taxa de bits do áudio + Sample Size Tamanho da amostra + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Tamanho / opacidade + Image Overlay Sobreposição de imagem + PNG or JPEG file Arquivo PNG ou JPEG + Name Overlay Sobreposição de nome + Timecode Overlay Sobreposição de timecode + Luma Min / Max Luma mín. / máx. + Loudness Normalization Normalização de loudness + True Peak Limit Limite de pico real + Audio only Somente áudio + Scaling Escala + Scale To Fit Escalar para ajustar + Scale To Fill Escalar para preencher + Stretch To Fill Esticar para preencher + Summary Resumo + Sequence, {w}x{h}, {fps} fps, {rate} Hz Sequência, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Tamanho estimado do arquivo: {size} + Send to Queue Enviar para a fila + Exporting {path} Exportando {path} + Added to the export queue Adicionado à fila de exportação + Preset Manager Gerenciador de predefinições + Search presets Pesquisar predefinições + Favorites only Somente favoritos + Show in the Preset menu Mostrar no menu Predefinição + User Usuário + Save current settings as: Salvar as configurações atuais como: + Export presets Predefinições de exportação + Import: no file chosen (agents: export.presets.import {path}) Importar: nenhum arquivo escolhido (agentes: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exportar: nenhum arquivo escolhido (agentes: export.presets.export {path, names}) + Quick Export: open a sequence first Exportação rápida: abra uma sequência primeiro + File Name & Location Nome e local do arquivo + Quick Export: {path} Exportação rápida: {path} + Graphics problem: switched to software compositing Problema gráfico: alternado para composição por software + GPU acceleration is unavailable; preview renders on the CPU A aceleração por GPU não está disponível; a pré-visualização é renderizada na CPU + Exporting Exportando + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Clique para selecionar, ou clique em um espaço vazio e arraste para selecionar com um retângulo. Use Shift, Opt e Cmd para outras opções. + Click to select all clips to the right in all tracks. Shift-click for a single track. Clique para selecionar todos os clipes à direita em todas as faixas. Shift+clique para uma única faixa. + Click to select all clips to the left in all tracks. Shift-click for a single track. Clique para selecionar todos os clipes à esquerda em todas as faixas. Shift+clique para uma única faixa. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Arraste um ponto de edição para uma aparagem em cascata; os clipes seguintes se movem para manter a lacuna fechada. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Arraste um ponto de edição para rolá-lo: a saída de um clipe e a entrada do seguinte se movem juntas. + Drag an edge to change the clip's speed so it fills the new duration. Arraste uma borda para alterar a velocidade do clipe de modo que ele preencha a nova duração. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Arraste a borda de um clipe de música para remixá-lo para a nova duração em batidas musicalmente correspondentes. + Click to split a clip. Shift-click to split all tracks. Clique para dividir um clipe. Shift+clique para dividir todas as faixas. + Drag a clip to slip its source in/out without moving it. Arraste um clipe para deslizar a entrada/saída de origem sem movê-lo. + Drag a clip to slide it between its neighbours. Arraste um clipe para deslizá-lo entre seus vizinhos. + Drag to scroll the timeline. Arraste para rolar a linha do tempo. + Click to zoom in; Opt-click to zoom out. Clique para ampliar; Opt+clique para reduzir. + FilmCraft hit an error O FilmCraft encontrou um erro + Details: {path} Detalhes: {path} + Opened {url} {url} aberto + (empty) (vazio) + Step Back 1 Frame Voltar 1 quadro + Step Forward 1 Frame Avançar 1 quadro + Match Playhead Corresponder ao cursor de reprodução + Gang to Program Vincular ao programa + AAC Audio Áudio AAC + AAC Audio 48 kHz 320 kbps Áudio AAC 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Áudio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz e 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamórfico 2:1 (2.0) + Animated GIF GIF animado + Animated GIF 640×360 GIF animado 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 com alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Aplicativo + Apply Guide Template Aplicar modelo de guias + Auto Save Now Salvar automaticamente agora + Auto-Match Loudness Correspondência automática de loudness + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom no estilo Avid: arquivo de imagem DNxHR HQ mais um arquivo PCM de 24 bits e 48 kHz por canal (_A1, _A2…) + BMP BMP + BMP Sequence Sequência BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Começar a editar o texto de uma camada de gráfico + Begins With Começa com + Blur In Entrada com desfoque + Blur Out Saída com desfoque + Brightens inside a feathered circular mask Clareia o interior de uma máscara circular difusa + Broadcast Transmissão + CBR CBR + Cancel Dynamic Trim Cancelar aparagem dinâmica + Cancel Queued Export Cancelar exportação na fila + Center Left Centro à esquerda + Center Right Centro à direita + Center align text Centralizar texto + Change Mask Alterar máscara + Clear All Events Limpar todos os eventos + Clear Edit Point Selection Limpar a seleção de pontos de edição + Clear Finished Exports Limpar exportações concluídas + Clear Poster Frame Limpar quadro de pôster + Close Bin Fechar pasta + Close Sequence Fechar sequência + Contains Contém + Cut to Camera 1 Cortar para a câmera 1 + Cut to Camera 2 Cortar para a câmera 2 + Cut to Camera 3 Cortar para a câmera 3 + Cut to Camera 4 Cortar para a câmera 4 + Cut to Camera 5 Cortar para a câmera 5 + Cut to Camera 6 Cortar para a câmera 6 + Cut to Camera 7 Cortar para a câmera 7 + Cut to Camera 8 Cortar para a câmera 8 + Cut to Camera 9 Cortar para a câmera 9 + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask Escurece o exterior de uma máscara oval difusa + Decrease Audio Tracks Height Diminuir a altura das faixas de áudio + Decrease Clip Volume Diminuir o volume do clipe + Decrease Clip Volume Many Diminuir muito o volume do clipe + Decrease Font Size by Five Units Diminuir o tamanho da fonte em cinco unidades + Decrease Font Size by One Unit Diminuir o tamanho da fonte em uma unidade + Decrease Leading by Five Units Diminuir a entrelinha em cinco unidades + Decrease Leading by One Unit Diminuir a entrelinha em uma unidade + Decrease Video Tracks Height Diminuir a altura das faixas de vídeo + Delete Captions Excluir legendas + Delete Graphic Layer Excluir camada de gráfico + Delete Guide Template Excluir modelo de guias + Delete Media Cache Files Excluir arquivos de cache de mídia + Discard Unsaved Changes Descartar alterações não salvas + Does Not Contain Não contém + Download Speech Model Baixar modelo de fala + Dynamic Trim Stop Parar aparagem dinâmica + Edit Caption Text Editar o texto da legenda + Ellipse Tool Ferramenta Elipse + Ends With Termina com + Entire Source Toda a origem + EssentialSound Som essencial + Export Frame Exportar quadro + Extend Next Edit To Playhead Estender a próxima edição até o cursor de reprodução + Extend Previous Edit To Playhead Estender a edição anterior até o cursor de reprodução + Extend Selected Edit to Playhead Estender a edição selecionada até o cursor de reprodução + Extend Selection Down Estender a seleção para baixo + Extend Selection Left Estender a seleção para a esquerda + Extend Selection Right Estender a seleção para a direita + Extend Selection Up Estender a seleção para cima + Fade In Fade de entrada + Gaussian Blur from 0 to 60 over the last second Desfoque gaussiano de 0 a 60 durante o último segundo + Gaussian Blur from 60 to 0 over the first second Desfoque gaussiano de 60 a 0 durante o primeiro segundo + Generate Ducking Keyframes Gerar quadros-chave de atenuação + Go to Next Edit Point Ir para o próximo ponto de edição + Go to Next Edit Point on Any Track Ir para o próximo ponto de edição em qualquer faixa + Go to Previous Edit Point Ir para o ponto de edição anterior + Go to Previous Edit Point on Any Track Ir para o ponto de edição anterior em qualquer faixa + Go to Selected Clip End Ir para o fim do clipe selecionado + Go to Selected Clip Start Ir para o início do clipe selecionado + Go to Sequence End Ir para o fim da sequência + Go to Sequence Start Ir para o início da sequência + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sites de vídeo, VBR 1 passagem, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 passagem, 20 Mbps desejados, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 passagens, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 para sites de vídeo, VBR 1 passagem, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 passagem, 60 Mbps desejados, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 passagens, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 vertical para vídeos curtos em redes sociais, escalado para preencher, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 com o tamanho e a taxa de quadros da sequência; 0,05 bit por pixel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 com o tamanho e a taxa de quadros da sequência; 0,1 bit por pixel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 com o tamanho e a taxa de quadros da sequência; a taxa de bits se adapta ao tamanho do quadro (0,2 bit por pixel) + HD Anamorphic 1080 (1.333) HD anamórfico 1080 (1.333) + Hand Tool Ferramenta Mão + High Quality 1080p HD Alta qualidade 1080p HD + High Quality 2160p 4K Alta qualidade 2160p 4K + Image Sequence Sequência de imagens + Increase Audio Tracks Height Aumentar a altura das faixas de áudio + Increase Clip Volume Aumentar o volume do clipe + Increase Clip Volume Many Aumentar muito o volume do clipe + Increase Font Size by Five Units Aumentar o tamanho da fonte em cinco unidades + Increase Font Size by One Unit Aumentar o tamanho da fonte em uma unidade + Increase Leading by Five Units Aumentar a entrelinha em cinco unidades + Increase Leading by One Unit Aumentar a entrelinha em uma unidade + Increase Video Tracks Height Aumentar a altura das faixas de vídeo + Large blocks resolving to the picture over the first second Blocos grandes que se resolvem na imagem durante o primeiro segundo + Left align text Alinhar texto à esquerda + Looping GIF, 640×360 at 15 fps GIF em loop, 640×360 a 15 fps + Lower First Inferior primeiro + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz, timecode inicial + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP longo (0,3 bit por pixel), PCM de 24 bits e 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM de 24 bits e 48 kHz, timecode inicial + Main Main + Mark In (Hover Scrub) Marcar entrada (reprodução ao passar o mouse) + Mark Out (Hover Scrub) Marcar saída (reprodução ao passar o mouse) + Masks Máscaras + Match Source – Adaptive High Bitrate Igual à origem – taxa de bits adaptável alta + Match Source – Adaptive Low Bitrate Igual à origem – taxa de bits adaptável baixa + Match Source – Adaptive Medium Bitrate Igual à origem – taxa de bits adaptável média + Matches Corresponde a + Maximize or Restore Active Frame Maximizar ou restaurar o quadro ativo do painel + Maximize or Restore Frame Under Cursor Maximizar ou restaurar o quadro do painel sob o cursor + Media Browser Settings Configurações do navegador de mídia + MediaBrowser Navegador de mídia + MediaCache Cache de mídia + Merge Captions Mesclar legendas + Mixer Misturador + Mosaic In Entrada em mosaico + Move All Audio Targets Down Mover todos os destinos de áudio para baixo + Move All Audio Targets Up Mover todos os destinos de áudio para cima + Move All Video Targets Down Mover todos os destinos de vídeo para baixo + Move All Video Targets Up Mover todos os destinos de vídeo para cima + Move Selection Down Mover a seleção para baixo + Move Selection End Mover a seleção para o fim + Move Selection Home Mover a seleção para o início + Move Selection Left Mover a seleção para a esquerda + Move Selection Page Down Mover a seleção uma página para baixo + Move Selection Page Up Mover a seleção uma página para cima + Move Selection Right Mover a seleção para a direita + Move Selection Up Mover a seleção para cima + Multi-Camera Record On/Off Toggle Ativar/desativar gravação multicâmera + Multicam Multicâmera + Navigate to End of Segment Ir para o fim do segmento + Navigate to Next Line Ir para a próxima linha + Navigate to Next Word Ir para a próxima palavra + Navigate to Previous Line Ir para a linha anterior + Navigate to Previous Word Ir para a palavra anterior + Navigate to Start of Segment Ir para o início do segmento + Navigation Navegação + Next Multi-Camera Page Próxima página multicâmera + Nudge Clip Selection Down Empurrar a seleção de clipes para baixo + Nudge Clip Selection Left Five Frames Empurrar a seleção de clipes cinco quadros para a esquerda + Nudge Clip Selection Left One Frame Empurrar a seleção de clipes um quadro para a esquerda + Nudge Clip Selection Right Five Frames Empurrar a seleção de clipes cinco quadros para a direita + Nudge Clip Selection Right One Frame Empurrar a seleção de clipes um quadro para a direita + Nudge Clip Selection Up Empurrar a seleção de clipes para cima + Nudge Selected Object down by five Empurrar o objeto selecionado cinco para baixo + Nudge Selected Object down by one Empurrar o objeto selecionado um para baixo + Nudge Selected Object to left by five Empurrar o objeto selecionado cinco para a esquerda + Nudge Selected Object to left by one Empurrar o objeto selecionado um para a esquerda + Nudge Selected Object to right by five Empurrar o objeto selecionado cinco para a direita + Nudge Selected Object to right by one Empurrar o objeto selecionado um para a direita + Nudge Selected Object up by five Empurrar o objeto selecionado cinco para cima + Nudge Selected Object up by one Empurrar o objeto selecionado um para cima + Nudge Volume +1dB Ajustar volume +1 dB + Nudge Volume +3dB Ajustar volume +3 dB + Nudge Volume -1dB Ajustar volume -1 dB + Nudge Volume -3dB Ajustar volume -3 dB + Numbered BMP stills (24-bit) Imagens estáticas BMP numeradas (24 bits) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Sequência JPEG + Targa Sequence Sequência Targa + DPX Sequence Sequência DPX + Numbered JPEG stills (quality 90) Imagens estáticas JPEG numeradas (qualidade 90) + Numbered Targa stills (24-bit, uncompressed) Imagens estáticas Targa numeradas (24 bits, sem compressão) + Numbered DPX stills (10-bit RGB) Imagens estáticas DPX numeradas (RGB de 10 bits) + OpenEXR OpenEXR + OpenEXR Sequence Sequência OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Imagens estáticas OpenEXR numeradas (meia precisão em ponto flutuante, linear, sem compressão) + Numbered PNG stills at the sequence's frame size Imagens estáticas PNG numeradas no tamanho de quadro da sequência + Numbered TIFF stills (RGB, 8-bit) Imagens estáticas TIFF numeradas (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacidade de 0 a 100 % durante o primeiro segundo + Opacity 100 → 0 % over the last second Opacidade de 100 a 0 % durante o último segundo + Open Bin Abrir pasta + Open Search Abrir pesquisa + PNG PNG + PNG Sequence Sequência PNG + Panel Painel + Pen Tool Ferramenta Caneta + PiP 35% Upper Right PiP 35% superior direito + Picture-in-picture: 35 % scale in the upper right Picture-in-picture: escala de 35 % no canto superior direito + Play Around Reproduzir ao redor + Play In to Out with Preroll/Postroll Reproduzir da entrada à saída com pré-roll/pós-roll + Play from Playhead to Out Point Reproduzir do cursor de reprodução ao ponto de saída + Play/Stop Reproduzir/parar + Previous Multi-Camera Page Página multicâmera anterior + Progressive Progressivo + ProjectPanel Painel Projeto + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) com canal alfa, PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits e 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits e 48 kHz + Quit FilmCraft Sair do FilmCraft + Rate Stretch Tool Ferramenta Alongamento de taxa + Razor Tool Ferramenta Lâmina + Rectangle Tool Ferramenta Retângulo + Remix Tool Ferramenta Remixagem + Right align text Alinhar texto à direita + Ripple Edit Tool Ferramenta Edição em cascata + Ripple Trim Next Edit to Playhead Aparagem em cascata da próxima edição até o cursor de reprodução + Ripple Trim Previous Edit to Playhead Aparagem em cascata da edição anterior até o cursor de reprodução + Rolling Edit Tool Ferramenta Edição rolante + Select Camera 1 Selecionar câmera 1 + Select Camera 2 Selecionar câmera 2 + Select Camera 3 Selecionar câmera 3 + Select Camera 4 Selecionar câmera 4 + Select Camera 5 Selecionar câmera 5 + Select Camera 6 Selecionar câmera 6 + Select Camera 7 Selecionar câmera 7 + Select Camera 8 Selecionar câmera 8 + Select Camera 9 Selecionar câmera 9 + Select Clip at Playhead Selecionar o clipe no cursor de reprodução + Select Find Box Selecionar a caixa de pesquisa + Select Next Clip Selecionar o próximo clipe + Select Next Panel Selecionar o próximo painel + Select Previous Clip Selecionar o clipe anterior + Select Previous Panel Selecionar o painel anterior + Select to Next Line Selecionar até a próxima linha + Select to Next Word Selecionar até a próxima palavra + Select to Previous Line Selecionar até a linha anterior + Select to Previous Word Selecionar até a palavra anterior + Select to Segment End Selecionar até o fim do segmento + Select to Segment Start Selecionar até o início do segmento + Selection Tool Ferramenta Seleção + Set Comparison Reference Definir a referência de comparação + Set Creative Look Definir o visual criativo + Set Input LUT Definir a LUT de entrada + Set Project Panel View Definir a visualização do painel Projeto + Show Next Screen Mostrar a próxima tela + Show Previous Screen Mostrar a tela anterior + Show Program Transcript Mostrar a transcrição do programa + Shuttle Left Shuttle para a esquerda + Shuttle Right Shuttle para a direita + Shuttle Slow Left Shuttle lento para a esquerda + Shuttle Slow Right Shuttle lento para a direita + Shuttle Stop Parar shuttle + Slide Clip Selection Left Five Frames Deslizar a seleção de clipes cinco quadros para a esquerda + Slide Clip Selection Left One Frame Deslizar a seleção de clipes um quadro para a esquerda + Slide Clip Selection Right Five Frames Deslizar a seleção de clipes cinco quadros para a direita + Slide Clip Selection Right One Frame Deslizar a seleção de clipes um quadro para a direita + Slide Tool Ferramenta Deslizar clipe + Slip Clip Selection Left Five Frames Deslizar o conteúdo da seleção de clipes cinco quadros para a esquerda + Slip Clip Selection Left One Frame Deslizar o conteúdo da seleção de clipes um quadro para a esquerda + Slip Clip Selection Right Five Frames Deslizar o conteúdo da seleção de clipes cinco quadros para a direita + Slip Clip Selection Right One Frame Deslizar o conteúdo da seleção de clipes um quadro para a direita + Slip Tool Ferramenta Deslizar conteúdo + Social Vertical 1080×1920 Vertical para redes sociais 1080×1920 + Soft Vignette Vinheta suave + Source In/Out Entrada/saída da origem + Split Caption Dividir legenda + Square Pixels (1.0) Pixels quadrados (1.0) + Stack Clips Empilhar clipes + Start Export Queue Iniciar a fila de exportação + Start Voice-over Recording Iniciar a gravação de narração + Step Back Five Frames Voltar cinco quadros + Step Back One Frame Voltar um quadro + Step Forward Five Frames Avançar cinco quadros + Step Forward One Frame Avançar um quadro + Stop Export Queue Parar a fila de exportação + Stop Multi-Camera Recording Parar a gravação multicâmera + Stop Voice-over Recording Parar a gravação de narração + Sync Voice-over Capture Sincronizar a captura de narração + TIFF TIFF + TIFF Sequence Sequência TIFF + TextPanel Painel Texto + Thumbnail Size Next Próximo tamanho de miniatura + Thumbnail Size Previous Tamanho de miniatura anterior + Toggle All Audio Targets Alternar todos os destinos de áudio + Toggle All Source Audio Alternar todo o áudio de origem + Toggle All Source Video Alternar todo o vídeo de origem + Toggle All Video Targets Alternar todos os destinos de vídeo + Toggle Audio During Scrubbing Alternar áudio ao percorrer + Toggle Full Screen Alternar tela cheia + Toggle Mute for All Targeted Audio Tracks Alternar mudo em todas as faixas de áudio de destino + Toggle Solo for All Targeted Audio Tracks Alternar solo em todas as faixas de áudio de destino + Toggle Source/Program Monitor Focus Alternar o foco entre os monitores de origem e do programa + Toggle Target Audio 1 Alternar destino de áudio 1 + Toggle Target Audio 2 Alternar destino de áudio 2 + Toggle Target Audio 3 Alternar destino de áudio 3 + Toggle Target Audio 4 Alternar destino de áudio 4 + Toggle Target Audio 5 Alternar destino de áudio 5 + Toggle Target Audio 6 Alternar destino de áudio 6 + Toggle Target Audio 7 Alternar destino de áudio 7 + Toggle Target Audio 8 Alternar destino de áudio 8 + Toggle Target Video 1 Alternar destino de vídeo 1 + Toggle Target Video 2 Alternar destino de vídeo 2 + Toggle Target Video 3 Alternar destino de vídeo 3 + Toggle Target Video 4 Alternar destino de vídeo 4 + Toggle Target Video 5 Alternar destino de vídeo 5 + Toggle Target Video 6 Alternar destino de vídeo 6 + Toggle Target Video 7 Alternar destino de vídeo 7 + Toggle Target Video 8 Alternar destino de vídeo 8 + Toggle Track Output for All Targeted Video Tracks Alternar a saída de todas as faixas de vídeo de destino + Toggle Trim Type Alternar tipo de aparagem + Toggle View Alternar visualização + Track Select Backward Tool Ferramenta Selecionar faixa para trás + Track Select Forward Tool Ferramenta Selecionar faixa para a frente + Track Selected Mask Rastrear a máscara selecionada + Trim Next Edit to Playhead Aparar a próxima edição até o cursor de reprodução + Trim Previous Edit to Playhead Aparar a edição anterior até o cursor de reprodução + Trimming Aparagem + Type Tool Ferramenta Texto + Unstack Clips Desempilhar clipes + Upper First Superior primeiro + VBR, 1 pass VBR, 1 passagem + VBR, 2 pass VBR, 2 passagens + CRF (constant quality) CRF (qualidade constante) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Gravação de narração + Voiceover Narração + WAV, stereo 48 kHz 16-bit PCM WAV, estéreo, PCM de 48 kHz e 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, estéreo, PCM de 48 kHz e 24 bits + Warm Tint Tonalidade quente + Warm highlights, brown shadows Realces quentes, sombras marrons + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web e redes sociais + Work Area Região de trabalho + Write Automation Gravar automação + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Ampliar o monitor do programa para 100% + Zoom Program Monitor to Fit Ajustar o monitor do programa à janela + Zoom Source Monitor to 100% Ampliar o monitor de origem para 100% + Zoom Source Monitor to Fit Ajustar o monitor de origem à janela + Zoom Tool Ferramenta Zoom + with: por: + Interface Language Idioma da interface + System Language Idioma do sistema + Cinematic Cinematográfico + Film Emulation Emulação de filme + Technical Técnico + Teal Shadows, Warm Skin Sombras azul-petróleo, pele quente + Night Exterior Externa noturna + Golden Dusk Crepúsculo dourado + Steel Blue Azul-aço + Hard Sun Sol forte + Faded Print Cópia desbotada + Silver Retention Retenção de prata + Cross Processed Processamento cruzado + Warm Negative Negativo quente + Neutral Mono Monocromático neutro + Hard Mono Monocromático intenso + Sepia Tone Tom sépia + Cold Mono Monocromático frio + Lift Shadows Levantar sombras + Protect Highlights Proteger realces + Half Saturation Saturação em 50 % + Boost Vibrance Aumentar vivacidade + Legal Range Squeeze Compressão para a faixa de vídeo + A boxed label with a round pointer; move the label and the pointer follows. Um rótulo em caixa com um ponteiro redondo; ao mover o rótulo, o ponteiro o acompanha. + A rounded tag that fits its text, with a status dot. Uma etiqueta arredondada que se ajusta ao texto, com um ponto de status. + A title on a rounded card with a soft shadow. Um título em um cartão arredondado com uma sombra suave. + Accent Color Cor de ênfase + Bar Color Cor da barra + Box Color Cor da caixa + Box Opacity Opacidade da caixa + Callout – Pointer Chamada – Ponteiro + Callout – Tag Chamada – Etiqueta + Callouts Chamadas + Credits Créditos + Dot Color Cor do ponto + Heading Cabeçalho + Large centred title, accent rule and subtitle. Título grande centralizado, linha de destaque e subtítulo. + Lower Third – Rule Terço inferior – Linha + Lower Third – Slab Terço inferior – Bloco + Lower Thirds Terços inferiores + Name Color Cor do nome + Name Size Tamanho do nome + Name and role on a coloured slab that widens with the name. Nome e função sobre um bloco colorido que se alarga com o nome. + Name over a thin rule that always matches the name's width. Nome sobre uma linha fina que sempre acompanha a largura do nome. + Pointer Color Cor do ponteiro + Role Função + Rule Color Cor da linha + Show Dot Mostrar ponto + Show Role Mostrar função + Slab Color Cor do bloco + Tag Color Cor da etiqueta + Title Color Cor do título + Title Size Tamanho do título + Title – Boxed Título – Em caixa + Title – Centered Título – Centralizado + Titles Títulos + Select an item to preview it Selecione um item para pré-visualizá-lo + End Credits Créditos finais + End Credits – Roll Créditos finais – Rolagem + Ticker – Crawl Letreiro – Rastejamento + Centred credits that roll up from below the frame and leave at the top. Créditos centralizados que sobem de baixo do quadro e saem pelo topo. + A news-style line that crawls in from the right and leaves at the left. Uma linha no estilo de telejornal que entra pela direita e sai pela esquerda. + out saída + in entrada + The language changed but could not be saved: {e} O idioma mudou, mas não pôde ser salvo: {e} + no Chinese font is installed on this system; the interface stays in English nenhuma fonte chinesa está instalada neste sistema; a interface permanece em inglês + no Japanese font is installed on this system; the interface stays in English nenhuma fonte japonesa está instalada neste sistema; a interface permanece em inglês + Show Mostrar + Sort Ordenar + Reverse the order Inverter a ordem + Grab bag Mosaico + Table Tabela + No contributor data was built into this copy. Esta cópia não inclui dados de colaboradores. + No model credits were built into this copy. Esta cópia não inclui créditos de modelos. + Company Empresa + Model Modelo + Version Versão + Commits Commits + % of all commits % de todos os commits + Lines +/− Linhas +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Clip Speed / Duration Velocidade/duração do clipe + Speed: Velocidade: + Reverse Speed Inverter velocidade + Ripple Edit, Shifting Trailing Clips Edição em cascata, deslocando os clipes seguintes + About Sobre + Contributors Colaboradores + Models Modelos + Main (8-bit) Main (8 bits) + Main 10 (10-bit, HDR) Main 10 (10 bits, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para sequências HDR) + Encoder Codificador + Hardware (H.265 has no software encoder) Hardware (o H.265 não tem codificador por software) + Hardware (AV1 has no software encoder) Hardware (o AV1 não tem codificador por software) + Text Properties Propriedades do texto + Text Box Height Altura da caixa de texto + Paragraph Text Texto de parágrafo + Point Text Texto de ponto + Text Layer Type Tipo de camada de texto + Text Styling Estilo do texto + New Color Matte Novo matte de cor + Color Matte Color Cor do matte de cor + Color: Cor: + · {time} left · faltam {time} + Discard take Descartar tomada + Voice-over take discarded Tomada de narração descartada + Binary assets added Recursos binários adicionados + Binary assets removed Recursos binários removidos + Deselect All Project Items Desmarcar todos os itens do projeto + Display name Nome de exibição + First commit Primeiro commit + H.265 (HEVC) H.265 (HEVC) + Last commit Último commit + Line delta Diferença de linhas + Lines added Linhas adicionadas + Lines deleted Linhas excluídas + Merged PRs PRs mesclados + Name (A–Z) Nome (A–Z) + Real name Nome real + Select All Project Items Selecionar todos os itens do projeto + Username Nome de usuário + Vertical Type Tool Ferramenta Texto vertical + Go to previous keyframe Ir para o quadro-chave anterior + Go to next keyframe Ir para o próximo quadro-chave + Add keyframe Adicionar quadro-chave + Remove keyframe Remover quadro-chave + Turn on animation and add keyframe Ativar a animação e adicionar quadro-chave + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Clique para selecionar, ou clique em um espaço vazio e arraste para selecionar com um retângulo. Use Shift, Alt e Ctrl para outras opções. + Click to zoom in; Alt-click to zoom out. Clique para ampliar; Alt+clique para reduzir. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastar: mover · Alt+clique: suave/canto · Ctrl+clique: excluir + Include Alpha Channel Incluir canal alfa + Sequence Settings Configurações da sequência + Color Management Gerenciamento de cores + VR Properties Propriedades de RV + Editing Mode: Modo de edição: + Timebase: Base de tempo: + Frame Size: Tamanho do quadro: + Pixel Aspect Ratio: Proporção de pixels: + Fields: Campos: + Channel Format: Formato de canais: + Number of Channels: Número de canais: + Preview File Format: Formato de arquivo de pré-visualização: + Working Color Space: Espaço de cores de trabalho: + Projection: Projeção: + Layout: Layout: + Captured View: Visualização capturada: + Video Previews Pré-visualizações de vídeo + No Fields (Progressive Scan) Sem campos (varredura progressiva) + Custom ({ratio}) Personalizado ({ratio}) + {rate} frames/second {rate} quadros/segundo + {rate} fps Drop-Frame Timecode Timecode drop-frame a {rate} fps + {rate} fps Non Drop-Frame Timecode Timecode non-drop-frame a {rate} fps + {rate} fps Timecode Timecode a {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Escalar os efeitos de movimento proporcionalmente ao alterar o tamanho do quadro + Maximum Bit Depth Profundidade de bits máxima + Maximum Render Quality Qualidade de renderização máxima + Composite in Linear Color Compor em cor linear + Auto Tone Map Media (HDR and log into SDR) Mapeamento de tons automático da mídia (HDR e log para SDR) + Configure… Configurar… + Not supported in FilmCraft yet Ainda não suportado no FilmCraft + FilmCraft always composites in linear light O FilmCraft sempre compõe em luz linear + Loop marked range Loop do intervalo marcado + Play Source Reproduzir origem + Source Play/Stop Reproduzir/parar origem + Stop Source Parar origem + New Sequence Nova sequência + Tracks Faixas + tracks faixas + Clear Transition Limpar transição + Set Transition Duration Definir duração da transição -# System appearance mode menu command. +# System appearance modes and theme cards. + Active Ativo + Appearance Mode Modo de aparência + Dark Theme Tema escuro + Light Theme Tema claro Next Appearance Mode Próximo modo de aparência + Sync with system Sincronizar com o sistema + UI Scale Escala da interface + Auto (system) Automática (sistema) + In Entrada + Out Saída + Export Markers as CSV… Exportar marcadores como CSV… + Fill Type Tipo de preenchimento + Gradient Angle Ângulo do gradiente + Linear Gradient Gradiente linear + Solid Sólido + GPU Rendering Renderização por GPU + Composite on the GPU when available Compor na GPU quando disponível + Text to Speech Texto para fala + Edit Narration Editar narração Project Notes Notas do projeto + Get Project Notes Obter notas do projeto + Edit Project Notes Editar notas do projeto + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas deste projeto: tarefas, instruções, números de quadro… Salvas com o projeto. + Use Effect Shutter Angle Usar o ângulo do obturador do efeito + +# The native macOS application menu (Settings…). + Settings Preferências diff --git a/crates/ui-egui/src/i18n/pt-br.tsv.attribution b/crates/ui-egui/src/i18n/pt-br.tsv.attribution new file mode 100644 index 000000000..bc354c519 --- /dev/null +++ b/crates/ui-egui/src/i18n/pt-br.tsv.attribution @@ -0,0 +1,8 @@ +asset: pt-br.tsv +title: Brazilian Portuguese interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-11 by vangel76 +notes: Original English-to-Brazilian-Portuguese interface translations using clean-room NLE vocabulary. diff --git a/docs/gaps.md b/docs/gaps.md index 564541067..9b89ee231 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -201,11 +201,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G14. Localization: 8 of the 12 key languages missing -- **Have:** Spanish, Japanese, Simplified Chinese and French (#498) catalogs cover ~99.6–100% of - the ~3,500 cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). +- **Have:** Spanish, Japanese, Simplified Chinese, French (#498) and Portuguese (Brazil) (#859) + catalogs cover ~99.6–100% of the ~3,500 cataloged strings; Ukrainian ~10% (menus). - **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395), Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts), - Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors, + Vietnamese; Premiere also ships Italian and Russian. Engine errors, CLI and MCP stay English. No language has a recorded native-speaker review. - **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6124d1c7a..5a81a3b54 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -17,8 +17,9 @@ are reachable over the control channel. One catalog per language, commands, panels, workspaces, presets) are translated where they are drawn. Searches match the displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu -label; `ja.tsv` and `fr.tsv` have the same entries but are not yet required by tests (French must -cover the core menus and keep no entry without a Spanish one). Engine error messages, the +label; `ja.tsv`, `fr.tsv` and `pt-br.tsv` have the same entries but are not yet required by tests +(French must cover the core menus, Brazilian Portuguese every menu label, and neither may keep an +entry without a Spanish one). Engine error messages, the control channel, the CLI and MCP stay English. Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_FONTS_DIR`, all @@ -43,7 +44,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) | | Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | -| Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | +| Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review | | Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review | | Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review | | German | de | 0 (0%) | — | Latin | — | none (requested #490) | 6–10 h + review | @@ -53,9 +54,9 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. Premiere also ships Italian and Russian, which we don't (6–10 h each). -**Localization dimension:** 5 of the 12 key languages near-complete (English, Spanish, Japanese, -Simplified Chinese, French), 1 menus-only, 6 none; against Premiere's 10 languages we cover 5 well -and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script +**Localization dimension:** 6 of the 12 key languages near-complete (English, Spanish, Japanese, +Simplified Chinese, French, Portuguese (Brazil)), 6 none; against Premiere's 10 languages we cover 6 +well. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script work (RTL and complex shaping in the egui interface) is the largest and least parallel part. Every language needs native-speaker review (human). From 7625ddf4de80c9c2b2fbce3bb5bbc9e37040114d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:22:09 +0200 Subject: [PATCH 028/157] #854: GIF frame delays add up to the sequence duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the GIF exporter gave every frame the same delay of 1000·den/num ms, which the encoder truncates to whole centiseconds. At 24 fps each 41.67 ms frame became 4 cs, so a 4.0 s sequence played in 3.84 s (4 % fast); other non-integer rates drifted the same way. Fix: each frame's delay now runs from its rounded start time to the next frame's (gif_delay_cs), so delays alternate (4, 4, 5, 4, 4, 4 cs at 24 fps) and add up to the exact duration. Checked i128 math also replaces the unchecked `(1000 * den) as u32` cast. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/lib.rs | 17 +++++++++++++++-- crates/export/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e9d5b1285..d6a26863f 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1671,6 +1671,19 @@ pub fn frame_span(rate: FrameRate, range: TimeRange) -> (i64, i64) { (f0, f1.max(f0)) } +/// The delay of the `i`th GIF frame (from the start of the export) in centiseconds, the unit GIF +/// stores. Each frame runs from its rounded start time to the next frame's, so the delays add up +/// to the exact duration (24 fps: 4, 4, 5, 4, 4, 4 cs …) instead of every frame truncating +/// 4.17 cs to 4 cs and the animation playing 4 % fast (#854). +fn gif_delay_cs(rate: FrameRate, i: i64) -> u16 { + let num = i128::from(rate.num.max(1)); + let den = i128::from(rate.den.max(0)); + // start of frame `k` in centiseconds, rounded half up + let at = |k: i64| i128::from(k.max(0)).saturating_mul(200).saturating_mul(den).saturating_add(num) / num.saturating_mul(2); + let cs = at(i.saturating_add(1)).saturating_sub(at(i)); + u16::try_from(cs.clamp(0, i128::from(u16::MAX))).unwrap_or(u16::MAX) +} + /// An in-memory writer that stays reachable after the encoder that owns a clone is dropped. #[derive(Clone, Default)] struct SharedBuf(Arc>>); @@ -1809,7 +1822,6 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } else { None }; - let delay = image::Delay::from_numer_denom_ms((1000 * pipe.rate.den) as u32, pipe.rate.num as u32); let mut f = f0; while f < f1 { if cancelled() { @@ -1818,8 +1830,9 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let end = (f + batch).min(f1); if let Some(enc) = gif.as_mut() { let frames: Vec> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources).0).collect(); - for rgba in frames { + for (fi, rgba) in (f..end).zip(frames) { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame".into()))?; + let delay = image::Delay::from_numer_denom_ms(u32::from(gif_delay_cs(pipe.rate, fi - f0)) * 10, 1); enc.encode_frame(image::Frame::from_parts(img, 0, 0, delay)).map_err(|e| ExportError::Encode(e.to_string()))?; } } else { diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 3b0d6bca5..9417a7fda 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -89,6 +89,31 @@ fn wav_png_gif() { assert!(std::path::Path::new(&tmp("seq005.png")).exists()); } +/// GIF delays are whole centiseconds (#854): at 24 fps they alternate 4/4/5 cs so a second of +/// frames lasts 1000 ms, not 24 × 40 ms = 960 ms. +#[test] +fn gif_frame_delays_add_up_to_the_duration() { + let d: Vec = (0..6).map(|i| gif_delay_cs(FrameRate::FPS_24, i)).collect(); + assert_eq!(d, [4, 4, 5, 4, 4, 4]); + let ntsc: u64 = (0..30).map(|i| u64::from(gif_delay_cs(FrameRate::FPS_29_97, i))).sum(); + assert_eq!(ntsc, 100, "30 frames at 29.97 fps last 1.001 s"); + // hostile rates neither panic nor overflow + assert_eq!(gif_delay_cs(FrameRate { num: 0, den: 0 }, 3), 0); + let _ = gif_delay_cs(FrameRate { num: 1, den: i64::MAX }, i64::MAX); + + let (p, seq, m) = project(); + let path = tmp("timing.gif"); + let mut s = ExportSettings { format: Format::Gif, path: path.clone(), scale: 0.25, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(24))); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + use image::AnimationDecoder; + let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(std::fs::read(&path).unwrap())).unwrap(); + let frames = dec.into_frames().collect_frames().unwrap(); + assert_eq!(frames.len(), 24); + let ms: u32 = frames.iter().map(|f| f.delay().numer_denom_ms()).map(|(n, d)| n / d.max(1)).sum(); + assert_eq!(ms, 1000); +} + #[test] fn prores_export_roundtrip() { let (p, seq, m) = project(); From 50560a8364d5b602ded6e25f42d9edc97d3720c8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:28:15 +0200 Subject: [PATCH 029/157] #786: right-click Label menus show the label colours Problem: #802 gave Edit > Label a colour swatch per label, but the Label submenus of the Project panel item menu and the Timeline clip menu still listed bare built-in names: no colour, no custom names from Preferences > Labels, and no automation ids. Fix: a shared menus::label_choice draws each choice as Edit > Label does (the user's name, a swatch of the user's colour in the left gutter) and registers it (project.itemMenu.label.