M12.7: Project panel and Media Browser parity

List / Icon / Freeform views, Metadata Display, view presets, font size,
Preview Area, bin tabs and windows, hover-scrub In/Out; Media Browser tree,
Favorites, history, file types, thumbnails, Edit Columns, import and drag.
Engine: project_panel and media_browser modules, Services::list_entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 02:14:31 -04:00
1 parent 999af3a1a5
commit c1c846aaad
31 files changed
+6305 -633

No files matched your search

+1 -1
View File
@@ -16,7 +16,7 @@ matched against `filmcraft-cli commands` and the UI command table. Effects: the
| Menus / commands | (cross-check) | ~325 of 344 in-scope menu items | ~95% |
| Effects and transitions | 12% | video effects 93/93 (+Legacy, Obsolete), audio effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3; some approximations (Warp Stabilizer 2-D, Morph Cut, Auto Reframe) | ~92% |
| Media I/O and codecs | 12% | H.264, HEVC, VP9, AV1, ProRes, DNx, MJPEG, Opus, AAC; MP4/MOV/MKV/WebM, MXF (OP1a/OP-Atom), Ogg Opus, image sequences, BWF timecode; no MPEG-2, camera raw, HW decode | ~80% |
| Panels and UI fidelity | 12% | all main panels incl. Metadata, Timecode, Events, Progress, Reference Monitor, full Lumetri Scopes, audio effect editors; Media Browser and Libraries-style panels thin | ~75% |
| Panels and UI fidelity | 12% | all main panels incl. Metadata, Timecode, Events, Progress, Reference Monitor, full Lumetri Scopes, audio effect editors; Project panel (List / Icon / Freeform, Metadata Display, view presets, bin tabs and windows) and Media Browser (tree, Favorites, history, thumbnails, Edit Columns) at parity; Libraries-style panels thin | ~75% |
| Audio | 10% | mixer, automation (track + clip), Essential Sound, meters, all 53 effects, 5.1 tracks/buses/panner/export, voice-over record, Remix | ~88% |
| Colour | 8% | Lumetri complete, LUTs, colour management, HDR, all Lumetri Scopes (+ `scopes.read` for agents) | ~88% |
| Graphics and captions | 8% | text engine, shapes, captions, transcripts; no MOGRT, rolls/crawls, responsive design | ~60% |
+37
View File
@@ -369,6 +369,43 @@ impl Services for WebServices {
fn export_in_memory(&self) -> bool {
true
}
/// The Media Browser lists the virtual file table: picked and dropped files under `/files`.
fn list_entries(&self, dir: &str) -> Option<io::Result<Vec<filmcraft_engine::media_browser::DirEntry>>> {
use filmcraft_engine::media_browser::DirEntry;
let prefix = if dir.ends_with('/') { dir.to_string() } else { format!("{dir}/") };
let out = FS.with(|fs| {
let fs = fs.borrow();
let mut dirs: Vec<String> = Vec::new();
let mut files = Vec::new();
for (path, e) in &fs.entries {
let Some(rest) = path.strip_prefix(&prefix) else { continue };
match rest.split_once('/') {
Some((d, _)) if !d.is_empty() => {
if !dirs.iter().any(|x| x == d) {
dirs.push(d.to_string());
}
}
Some(_) => {}
None => {
let size = match e {
Entry::Blob { size, .. } => *size,
Entry::Mem(b) => b.len() as u64,
};
files.push(DirEntry { name: rest.to_string(), is_dir: false, size: Some(size), modified: None });
}
}
}
let mut v: Vec<DirEntry> = dirs.into_iter().map(|name| DirEntry { name, is_dir: true, ..Default::default() }).collect();
v.extend(files);
v
});
Some(Ok(out))
}
fn home_dir(&self) -> Option<String> {
Some("/files".into())
}
}
/// Offer bytes as a browser download named `name`.
+6
View File
@@ -153,6 +153,10 @@ pub struct Preferences {
pub guides: GuidePrefs,
/// Voice-Over Record Settings (timeline track header ▸ right-click the microphone).
pub voice_over: crate::voiceover::VoiceOverPrefs,
/// Project panel: view, List view columns and sort, view presets, Freeform options.
pub project_panel: crate::project_panel::ProjectPanelPrefs,
/// Media Browser: Favorites, recent directories, file types, view, columns.
pub media_browser: crate::media_browser::MediaBrowserPrefs,
}
impl Default for Preferences {
@@ -178,6 +182,8 @@ impl Default for Preferences {
essential_sound: Default::default(),
guides: Default::default(),
voice_over: Default::default(),
project_panel: Default::default(),
media_browser: Default::default(),
}
}
}
+5 -2
View File
@@ -552,9 +552,10 @@ fn build() -> Vec<CommandSpec> {
Ok(json!({"sequence": id.0}))
}
),
cmd!("file.newBin", "Bin", ["File", "New"], Some("Cmd+B"), r#"{"name":str}"#, always, |s, p| {
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();
let id = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&n, None)))?;
let parent = u64_p(p, "parent").map(filmcraft_project::BinId);
let id = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&n, parent)))?;
Ok(json!({"bin": id.0}))
}),
cmd!("file.newAdjustmentLayer", "Adjustment Layer…", ["File", "New"], None, r#"{"seconds":f64=5}"#, always, |s, p| {
@@ -2159,6 +2160,8 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::remix::commands());
v.extend(crate::voiceover::commands());
v.extend(crate::keyboard::commands());
v.extend(crate::project_panel::commands());
v.extend(crate::media_browser::commands());
// Edit ▸ Label ▸ <colour>, Paste Attributes, subclips, Video / Audio Options, Replace With Clip…
// and their menu order
crate::clip_ops::apply_layout(&mut v);
+31
View File
@@ -20,6 +20,7 @@ pub mod graphics;
pub mod interchange;
pub mod keyboard;
pub mod masks;
pub mod media_browser;
pub mod media_pool;
pub mod mixer;
pub mod multicam;
@@ -28,6 +29,7 @@ pub mod perf;
pub mod presets;
pub mod previews;
pub mod project_manager;
pub mod project_panel;
pub mod project_tools;
pub mod proxies;
pub mod relink;
@@ -115,6 +117,19 @@ pub trait Services: Send + Sync {
fn export_in_memory(&self) -> bool {
false
}
/// Entries of a directory with their kind, size and date (the Media Browser). The default
/// derives them from [`Services::list_dir`] (every name a file).
fn list_entries(&self, dir: &str) -> Option<std::io::Result<Vec<media_browser::DirEntry>>> {
self.list_dir(dir).map(|r| r.map(|names| names.into_iter().map(|name| media_browser::DirEntry { name, ..Default::default() }).collect()))
}
/// Drives and network locations (Media Browser ▸ Local Drives / Network).
fn volumes(&self) -> Vec<media_browser::Volume> {
Vec::new()
}
/// The user's home directory (where the Media Browser starts).
fn home_dir(&self) -> Option<String> {
None
}
}
/// Native filesystem services.
@@ -138,6 +153,15 @@ impl Services for FsServices {
fn file_loader(&self) -> Option<filmcraft_media::sequence::FrameLoader> {
Some(std::sync::Arc::new(|p: &str| std::fs::read(p)))
}
fn list_entries(&self, dir: &str) -> Option<std::io::Result<Vec<media_browser::DirEntry>>> {
Some(media_browser::std_list_entries(dir))
}
fn volumes(&self) -> Vec<media_browser::Volume> {
media_browser::std_volumes()
}
fn home_dir(&self) -> Option<String> {
media_browser::std_home_dir()
}
fn read_range(&self, path: &str, offset: u64, len: usize) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path)?;
@@ -313,6 +337,8 @@ pub struct Session {
pub transcriber: Option<Arc<dyn filmcraft_speech::Transcriber>>,
/// The Events panel log: failed commands, job results, auto-save errors, messages.
pub log: panels::EventLog,
/// Media Browser navigation (directory, back / forward history, selected files).
pub browser: media_browser::BrowserState,
/// Nesting depth of [`Session::execute`] (commands that run other commands).
exec_depth: u32,
}
@@ -398,6 +424,7 @@ impl Session {
stepped: Vec::new(),
transcriber: None,
log: Default::default(),
browser: Default::default(),
exec_depth: 0,
}
}
@@ -826,6 +853,8 @@ mod keyboard_tests;
#[cfg(test)]
mod masks_tests;
#[cfg(test)]
mod media_browser_tests;
#[cfg(test)]
mod media_test_util;
#[cfg(test)]
mod mixer_tests;
@@ -840,6 +869,8 @@ mod previews_tests;
#[cfg(test)]
mod project_manager_tests;
#[cfg(test)]
mod project_panel_tests;
#[cfg(test)]
mod proxies_tests;
#[cfg(test)]
mod relink_tests;
+665
View File
@@ -0,0 +1,665 @@
//! Media Browser (M12.7): directory listing, navigation history, Favorites, recent directories,
//! file-type filter, import and Open in Source Monitor. All file access goes through the host's
//! [`Services`] (`list_entries`, `volumes`, `home_dir`), so tests run against a fake filesystem
//! and the web build lists the files the user picked or dropped.
//!
//! | Id | What |
//! |---|---|
//! | `mediaBrowser.roots` | Favorites, Local Drives, Network, recent directories, home |
//! | `mediaBrowser.list` | entries of a directory (default: the current one), filtered by file type |
//! | `mediaBrowser.navigate` | go to a directory, or `back` / `forward` / `up` |
//! | `mediaBrowser.select` | the selected files (File ▸ Import from Media Browser uses them) |
//! | `mediaBrowser.favorite` | Add to / Remove from Favorites |
//! | `mediaBrowser.clearRecent` | Clear Recent Directories |
//! | `mediaBrowser.settings` | file types, view (list / thumbnails), columns (Edit Columns…), Import as Image Sequence, Hover Scrub, thumbnail size |
//! | `mediaBrowser.import` | Import (the selection or `paths`; Project ▸ Ingest settings apply) |
//! | `mediaBrowser.openInSource` | Open In Source Monitor (Shift+O) |
//! | `mediaBrowser.probe` | a file's media properties (frame rate, duration, video / audio info), cached |
//!
//! Navigation history and the selection are session state ([`BrowserState`]); Favorites, recent
//! directories and the view settings are preferences ([`MediaBrowserPrefs`]).
use std::collections::BTreeMap;
use filmcraft_media::MediaInfo;
use filmcraft_project::ItemId;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, str_p, u64_p};
use crate::{EngineError, Result, Services, Session};
/// One directory entry as the host reports it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: Option<u64>,
/// Modification time (seconds since the Unix epoch).
pub modified: Option<u64>,
}
/// Where a volume shows in the directory tree.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VolumeKind {
#[default]
Local,
Network,
}
/// A drive or network location (Local Drives / Network).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Volume {
pub name: String,
pub path: String,
pub kind: VolumeKind,
}
/// File types shown (the "File Types Displayed" menu).
pub const FILE_TYPES: [(&str, &str); 6] = [
("all", "All Supported Files"),
("video", "Video Files"),
("audio", "Audio Files"),
("image", "Still Images"),
("project", "Projects and Interchange"),
("caption", "Captions"),
];
/// Columns the list view can show (Edit Columns…), in default order.
pub const ALL_COLUMNS: [&str; 10] =
["Name", "Media Type", "Size", "Date Modified", "Frame Rate", "Media Duration", "Video Info", "Audio Info", "Video Codec", "Audio Codec"];
pub const DEFAULT_COLUMNS: [&str; 5] = ["Name", "Frame Rate", "Media Duration", "Video Info", "Size"];
const PROJECT_EXTENSIONS: [&str; 6] = ["fcproj", "edl", "xml", "fcpxml", "otio", "ale"];
const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
const RECENT_MAX: usize = 10;
/// Preferences of the Media Browser (`Preferences::media_browser`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct MediaBrowserPrefs {
pub favorites: Vec<String>,
/// Recent directories, newest first.
pub recent: Vec<String>,
/// Directory shown when the browser opens ("" = the home directory).
pub last_dir: String,
/// One of [`FILE_TYPES`], or a file extension (`mov`).
pub file_types: String,
/// `list` | `thumbnails`.
pub view: String,
pub columns: Vec<String>,
pub import_as_image_sequence: bool,
pub hover_scrub: bool,
pub thumbnail_size: f32,
}
impl Default for MediaBrowserPrefs {
fn default() -> Self {
MediaBrowserPrefs {
favorites: Vec::new(),
recent: Vec::new(),
last_dir: String::new(),
file_types: "all".into(),
view: "list".into(),
columns: DEFAULT_COLUMNS.iter().map(|c| c.to_string()).collect(),
import_as_image_sequence: false,
hover_scrub: true,
thumbnail_size: 120.0,
}
}
}
/// Session state of the Media Browser.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserState {
/// The directory shown ("" = not chosen yet: the last or home directory).
pub dir: String,
pub back: Vec<String>,
pub forward: Vec<String>,
pub selection: Vec<String>,
/// Probed media properties by path (None = not a readable media file).
#[serde(skip)]
pub probes: BTreeMap<String, Option<MediaInfo>>,
}
/// A listed entry with what the browser shows about it.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub name: String,
pub path: String,
pub is_dir: bool,
/// `folder` | `video` | `audio` | `image` | `project` | `caption`.
pub kind: &'static str,
pub size: Option<u64>,
pub modified: Option<u64>,
/// A numbered still (Import as Image Sequence applies).
pub numbered: bool,
}
fn ext_of(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// The kind of file a name is, or None when the browser doesn't show it.
pub fn kind_of(name: &str) -> Option<&'static str> {
let e = ext_of(name);
if filmcraft_media::VIDEO_EXTENSIONS.contains(&e.as_str()) {
Some("video")
} else if filmcraft_media::AUDIO_EXTENSIONS.contains(&e.as_str()) {
Some("audio")
} else if filmcraft_media::STILL_EXTENSIONS.contains(&e.as_str()) {
Some("image")
} else if PROJECT_EXTENSIONS.contains(&e.as_str()) {
Some("project")
} else if CAPTION_EXTENSIONS.contains(&e.as_str()) {
Some("caption")
} else {
None
}
}
/// Does a file of `kind` and extension pass the file-type filter?
pub fn passes(filter: &str, kind: &str, name: &str) -> bool {
match filter {
"" | "all" => true,
"video" | "audio" | "image" | "project" | "caption" => kind == filter,
ext => ext_of(name) == ext.trim_start_matches('.').to_ascii_lowercase(),
}
}
/// Every extension the browser lists (for the file-type menu).
pub fn extensions() -> Vec<&'static str> {
filmcraft_media::VIDEO_EXTENSIONS
.iter()
.chain(filmcraft_media::AUDIO_EXTENSIONS)
.chain(filmcraft_media::STILL_EXTENSIONS)
.chain(&PROJECT_EXTENSIONS)
.chain(&CAPTION_EXTENSIONS)
.copied()
.collect()
}
fn windows_style(p: &str) -> bool {
p.contains('\\') && !p.contains('/')
}
/// `dir` + `name` with the directory's separator.
pub fn join(dir: &str, name: &str) -> String {
let sep = if windows_style(dir) { '\\' } else { '/' };
if dir.is_empty() {
name.to_string()
} else if dir.ends_with(sep) {
format!("{dir}{name}")
} else {
format!("{dir}{sep}{name}")
}
}
/// The parent directory (None at a root).
pub fn parent(dir: &str) -> Option<String> {
let sep = if windows_style(dir) { '\\' } else { '/' };
let t = dir.trim_end_matches(sep);
if t.is_empty() {
return None;
}
let i = t.rfind(sep)?;
let p = &t[..i];
if p.is_empty() {
Some(sep.to_string())
} else if windows_style(dir) && p.ends_with(':') {
Some(format!("{p}\\"))
} else {
Some(p.to_string())
}
}
/// The last path component (a directory's name in the tree).
pub fn base_name(path: &str) -> String {
let sep = if windows_style(path) { '\\' } else { '/' };
let t = path.trim_end_matches(sep);
t.rsplit(sep).next().filter(|n| !n.is_empty()).unwrap_or(path).to_string()
}
/// List a directory: sub-directories first, then files the browser shows (hidden files left out),
/// each group by name (case-insensitive).
pub fn list(services: &dyn Services, dir: &str, filter: &str) -> std::io::Result<Vec<Entry>> {
let raw = services.list_entries(dir).unwrap_or_else(|| Err(std::io::Error::other("this host cannot list directories")))?;
let mut out: Vec<Entry> = raw
.into_iter()
.filter(|e| !e.name.starts_with('.'))
.filter_map(|e| {
let path = join(dir, &e.name);
if e.is_dir {
return Some(Entry { numbered: false, name: e.name, path, is_dir: true, kind: "folder", size: None, modified: e.modified });
}
let kind = kind_of(&e.name)?;
if !passes(filter, kind, &e.name) {
return None;
}
let numbered = kind == "image" && filmcraft_media::sequence::Numbered::parse(&path).is_some();
Some(Entry { numbered, name: e.name, path, is_dir: false, kind, size: e.size, modified: e.modified })
})
.collect();
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
/// The directory shown: the current one, else the last one, else home, else the first volume.
pub fn current_dir(s: &Session) -> String {
if !s.browser.dir.is_empty() {
return s.browser.dir.clone();
}
let last = &s.prefs.media_browser.last_dir;
if !last.is_empty() {
return last.clone();
}
s.services.home_dir().or_else(|| s.services.volumes().first().map(|v| v.path.clone())).unwrap_or_else(|| "/".into())
}
fn save_prefs(s: &mut Session, prefs: crate::autosave::Preferences) -> Result<()> {
if prefs != s.prefs {
s.set_prefs(prefs).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
}
Ok(())
}
fn state_json(s: &Session) -> Value {
json!({
"dir": current_dir(s),
"canBack": !s.browser.back.is_empty(),
"canForward": !s.browser.forward.is_empty(),
"canUp": parent(&current_dir(s)).is_some(),
"selection": s.browser.selection,
"favorite": s.prefs.media_browser.favorites.contains(&current_dir(s)),
})
}
fn roots(s: &mut Session, _: &Value) -> Result<Value> {
let vols = s.services.volumes();
let (local, network): (Vec<Volume>, Vec<Volume>) = vols.into_iter().partition(|v| v.kind == VolumeKind::Local);
let mb = &s.prefs.media_browser;
let named = |v: &Vec<String>| v.iter().map(|p| json!({"name": base_name(p), "path": p})).collect::<Vec<_>>();
Ok(json!({
"favorites": named(&mb.favorites),
"localDrives": local,
"network": network,
"recent": named(&mb.recent),
"home": s.services.home_dir(),
}))
}
fn list_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let dir = str_p(p, "path").map(str::to_string).unwrap_or_else(|| current_dir(s));
let filter = str_p(p, "fileTypes").map(str::to_string).unwrap_or_else(|| s.prefs.media_browser.file_types.clone());
let entries = list(&*s.services, &dir, &filter).map_err(|e| bad("mediaBrowser.list", format!("{dir}: {e}")))?;
let mut out = state_json(s);
out["dir"] = json!(dir);
out["fileTypes"] = json!(filter);
out["entries"] = serde_json::to_value(&entries).unwrap_or_default();
Ok(out)
}
fn go(s: &mut Session, dir: String, history: bool) -> Result<()> {
// the directory must be listable
list(&*s.services, &dir, "all").map_err(|e| bad("mediaBrowser.navigate", format!("{dir}: {e}")))?;
let cur = current_dir(s);
if history && cur != dir {
s.browser.back.push(cur);
s.browser.forward.clear();
}
s.browser.dir = dir.clone();
s.browser.selection.clear();
let mut prefs = s.prefs.clone();
let mb = &mut prefs.media_browser;
mb.recent.retain(|r| *r != dir);
mb.recent.insert(0, dir.clone());
mb.recent.truncate(RECENT_MAX);
mb.last_dir = dir;
save_prefs(s, prefs)
}
fn navigate(s: &mut Session, p: &Value) -> Result<Value> {
if bool_p(p, "back") == Some(true) {
let to = s.browser.back.pop().ok_or_else(|| bad("mediaBrowser.navigate", "nothing to go back to"))?;
let cur = current_dir(s);
if let Err(e) = go(s, to.clone(), false) {
s.browser.back.push(to);
return Err(e);
}
s.browser.forward.push(cur);
} else if bool_p(p, "forward") == Some(true) {
let to = s.browser.forward.pop().ok_or_else(|| bad("mediaBrowser.navigate", "nothing to go forward to"))?;
let cur = current_dir(s);
if let Err(e) = go(s, to.clone(), false) {
s.browser.forward.push(to);
return Err(e);
}
s.browser.back.push(cur);
} else if bool_p(p, "up") == Some(true) {
let to = parent(&current_dir(s)).ok_or_else(|| bad("mediaBrowser.navigate", "already at the top"))?;
go(s, to, true)?;
} else {
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.navigate", "need `path`, or `back` / `forward` / `up`"))?;
go(s, path.to_string(), true)?;
}
list_cmd(s, &json!({}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
s.browser.selection = paths;
Ok(state_json(s))
}
fn favorite(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").map(str::to_string).unwrap_or_else(|| current_dir(s));
let remove = bool_p(p, "remove").unwrap_or(false);
let mut prefs = s.prefs.clone();
let fav = &mut prefs.media_browser.favorites;
if remove {
if !fav.contains(&path) {
return Err(bad("mediaBrowser.favorite", format!("{path} is not a favorite")));
}
fav.retain(|f| *f != path);
} else if !fav.contains(&path) {
fav.push(path);
}
save_prefs(s, prefs)?;
Ok(json!({"favorites": s.prefs.media_browser.favorites}))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let mut prefs = s.prefs.clone();
let mb = &mut prefs.media_browser;
if let Some(v) = str_p(p, "fileTypes") {
let v = v.trim().trim_start_matches('.').to_ascii_lowercase();
if !FILE_TYPES.iter().any(|f| f.0 == v) && !extensions().contains(&v.as_str()) {
return Err(bad("mediaBrowser.settings", format!("unknown file type `{v}`")));
}
mb.file_types = v;
}
if let Some(v) = str_p(p, "view") {
mb.view = match v.to_ascii_lowercase().as_str() {
"list" => "list".into(),
"thumbnails" | "thumbnail" | "icon" => "thumbnails".into(),
_ => return Err(bad("mediaBrowser.settings", "view is `list` or `thumbnails`")),
};
}
if let Some(a) = p.get("columns").and_then(Value::as_array) {
let mut cols = vec!["Name".to_string()];
for c in a.iter().filter_map(Value::as_str) {
let c = ALL_COLUMNS.iter().find(|x| x.eq_ignore_ascii_case(c)).ok_or_else(|| bad("mediaBrowser.settings", format!("unknown column `{c}`")))?;
if !cols.iter().any(|x| x == c) {
cols.push(c.to_string());
}
}
mb.columns = cols;
}
if let Some(v) = bool_p(p, "importAsImageSequence") {
mb.import_as_image_sequence = v;
}
if let Some(v) = bool_p(p, "hoverScrub") {
mb.hover_scrub = v;
}
if let Some(v) = f64_p(p, "thumbnailSize") {
mb.thumbnail_size = (v as f32).clamp(60.0, 320.0);
}
save_prefs(s, prefs)?;
Ok(serde_json::to_value(&s.prefs.media_browser).unwrap_or_default())
}
fn paths_p(s: &Session, p: &Value) -> Vec<String> {
match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
None => str_p(p, "path").map(|x| vec![x.to_string()]).unwrap_or_else(|| s.browser.selection.clone()),
}
}
/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files (directories import their
/// supported files, like dragging a folder in Premiere).
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = paths_p(s, p);
if paths.is_empty() {
return Err(bad("mediaBrowser.import", "select files in the Media Browser"));
}
let seq = bool_p(p, "imageSequence").unwrap_or(s.prefs.media_browser.import_as_image_sequence);
let mut files = Vec::new();
for path in paths {
// a directory imports its media files (not recursive)
let is_dir = matches!(s.services.list_entries(&path), Some(Ok(_))) && kind_of(&path).is_none();
match list(&*s.services, &path, "all") {
Ok(entries) if is_dir => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)),
_ => files.push(path),
}
}
// an image sequence imports from its first selected frame only
if seq {
files.truncate(1);
}
let mut q = json!({"paths": files, "imageSequence": seq});
if let Some(b) = u64_p(p, "bin") {
q["bin"] = json!(b);
}
s.execute("file.import", q)
}
/// The project item that already references a file.
pub fn item_for_path(s: &Session, path: &str) -> Option<ItemId> {
s.project
.items
.values()
.find(|i| i.as_media().is_some_and(|m| matches!(&m.media, filmcraft_project::MediaRef::File { path: p } if p == path)))
.map(|i| i.id)
}
fn open_in_source(s: &mut Session, p: &Value) -> Result<Value> {
let path = paths_p(s, p).into_iter().next().ok_or_else(|| bad("mediaBrowser.openInSource", "select a file in the Media Browser"))?;
let item = match item_for_path(s, &path) {
Some(i) => i,
None => {
let r = import(s, &json!({"paths": [path], "imageSequence": false}))?;
r["items"]
.as_array()
.and_then(|a| a.first())
.and_then(Value::as_u64)
.map(ItemId)
.ok_or_else(|| bad("mediaBrowser.openInSource", "the file could not be imported"))?
}
};
s.execute("source.open", json!({"item": item.0}))?;
Ok(json!({"item": item.0}))
}
/// Media properties of a file (cached per path).
pub fn probe(s: &mut Session, path: &str) -> Option<MediaInfo> {
if let Some(v) = s.browser.probes.get(path) {
return v.clone();
}
let info = if kind_of(path).is_some_and(|k| matches!(k, "video" | "audio" | "image")) {
s.media.open_file(path, &*s.services).ok().map(|src| src.info().clone())
} else {
None
};
s.browser.probes.insert(path.to_string(), info.clone());
info
}
/// The text of a list column for an entry (`probe`: its media properties, when known).
pub fn column_text(e: &Entry, column: &str, probe: Option<&MediaInfo>) -> String {
match column {
"Name" => e.name.clone(),
"Media Type" => {
if e.is_dir {
"Folder".into()
} else {
format!("{}{}", e.kind[..1].to_uppercase(), &e.kind[1..])
}
}
"Size" => e.size.map(format_size).unwrap_or_default(),
"Date Modified" => e.modified.map(format_date).unwrap_or_default(),
"Frame Rate" => probe.and_then(|i| i.video.as_ref()).filter(|_| e.kind == "video").map(|v| format!("{} fps", v.frame_rate.label())).unwrap_or_default(),
"Media Duration" => probe
.filter(|_| e.kind != "image")
.map(|i| {
let rate = i.video.as_ref().map(|v| v.frame_rate).unwrap_or_default();
filmcraft_time::format_time(i.duration, rate, false, filmcraft_time::TimeDisplay::Timecode, 48000)
})
.unwrap_or_default(),
"Video Info" => probe.and_then(|i| i.video.as_ref()).map(|v| format!("{} x {}", v.width, v.height)).unwrap_or_default(),
"Audio Info" => probe.and_then(|i| i.audio.as_ref()).map(|a| format!("{} Hz - {} ch", a.sample_rate, a.channels)).unwrap_or_default(),
"Video Codec" => probe.and_then(|i| i.video.as_ref()).map(|v| v.codec.clone()).unwrap_or_default(),
"Audio Codec" => probe.and_then(|i| i.audio.as_ref()).map(|a| a.codec.clone()).unwrap_or_default(),
_ => String::new(),
}
}
fn format_size(b: u64) -> String {
match b {
b if b >= 1 << 30 => format!("{:.2} GB", b as f64 / (1u64 << 30) as f64),
b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
b if b >= 1 << 10 => format!("{:.0} KB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds.
pub fn format_date(secs: u64) -> String {
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
// civil-from-days (Howard Hinnant's algorithm)
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem / 60) % 60)
}
fn probe_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.probe", "need `path`"))?.to_string();
let info = probe(s, &path);
Ok(json!({"path": path, "media": info.is_some(), "info": info}))
}
pub(crate) fn commands() -> Vec<CommandSpec> {
let c = |id, label, params, run| CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: true };
let q = |id, label, params, run| CommandSpec { id, label, menu: &[], shortcut: None, params, enabled: always, run, journal: false };
vec![
q("mediaBrowser.roots", "Media Browser Locations", "{}", roots),
q("mediaBrowser.list", "List Directory", r#"{"path":str?,"fileTypes":str?}"#, list_cmd),
c("mediaBrowser.navigate", "Go to Directory", r#"{"path":str} | {"back":true} | {"forward":true} | {"up":true}"#, navigate),
c("mediaBrowser.select", "Select Files", r#"{"paths":[str]}"#, select),
c("mediaBrowser.favorite", "Add to Favorites", r#"{"path":str?,"remove":bool?}"#, favorite),
c("mediaBrowser.clearRecent", "Clear Recent Directories", "{}", |s, _| {
let mut prefs = s.prefs.clone();
prefs.media_browser.recent.clear();
save_prefs(s, prefs)?;
Ok(Value::Null)
}),
c(
"mediaBrowser.settings",
"Media Browser Settings",
r#"{"fileTypes":"all|video|audio|image|project|caption|<ext>"?,"view":"list|thumbnails"?,"columns":[str]?,"importAsImageSequence":bool?,"hoverScrub":bool?,"thumbnailSize":f32?}"#,
settings,
),
c("mediaBrowser.import", "Import", r#"{"paths":[str]?,"bin":binId?,"imageSequence":bool?}"#, import),
CommandSpec {
id: "mediaBrowser.openInSource",
label: "Open In Source Monitor",
menu: &[],
shortcut: None,
params: r#"{"path":str?}"#,
enabled: always,
run: open_in_source,
journal: true,
},
q("mediaBrowser.probe", "Media File Properties", r#"{"path":str}"#, probe_cmd),
]
}
// ------------------------------------------------------------------------------------ native fs
/// Directory entries through `std::fs` (the desktop host).
pub fn std_list_entries(dir: &str) -> std::io::Result<Vec<DirEntry>> {
let dir = if dir.is_empty() { "." } else { dir };
let rd = std::fs::read_dir(dir)?;
Ok(rd
.flatten()
.map(|e| {
// follow symlinks (aliases to folders show as folders)
let md = std::fs::metadata(e.path()).ok();
let is_dir = md.as_ref().is_some_and(|m| m.is_dir());
DirEntry {
name: e.file_name().to_string_lossy().to_string(),
is_dir,
size: md.as_ref().filter(|m| m.is_file()).map(|m| m.len()),
modified: md.and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()),
}
})
.collect())
}
/// Drives and network locations of the desktop host.
pub fn std_volumes() -> Vec<Volume> {
let mut v = Vec::new();
let dir_names = |d: &str| -> Vec<String> {
std::fs::read_dir(d)
.map(|rd| rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect())
.unwrap_or_default()
};
if cfg!(windows) {
for l in 'A'..='Z' {
let p = format!("{l}:\\");
if std::path::Path::new(&p).exists() {
v.push(Volume { name: format!("({l}:)"), path: p, kind: VolumeKind::Local });
}
}
return v;
}
if cfg!(target_os = "macos") {
let mut names = dir_names("/Volumes");
names.sort();
for n in names {
v.push(Volume { path: format!("/Volumes/{n}"), name: n, kind: VolumeKind::Local });
}
if v.is_empty() {
v.push(Volume { name: "/".into(), path: "/".into(), kind: VolumeKind::Local });
}
if std::path::Path::new("/Network").is_dir() {
v.push(Volume { name: "Network".into(), path: "/Network".into(), kind: VolumeKind::Network });
}
return v;
}
v.push(Volume { name: "/".into(), path: "/".into(), kind: VolumeKind::Local });
for base in ["/media", "/mnt", "/run/media"] {
for n in dir_names(base) {
let p = format!("{base}/{n}");
// /media/<user>/<volume>
let inner = dir_names(&p);
if base != "/mnt" && !inner.is_empty() {
for m in inner {
v.push(Volume { path: format!("{p}/{m}"), name: m, kind: VolumeKind::Local });
}
} else {
v.push(Volume { name: n, path: p, kind: VolumeKind::Local });
}
}
}
v
}
/// The desktop user's home directory.
pub fn std_home_dir() -> Option<String> {
std::env::var("HOME").ok().or_else(|| std::env::var("USERPROFILE").ok()).filter(|h| !h.is_empty())
}
+250
View File
@@ -0,0 +1,250 @@
//! Tests of the Media Browser (`media_browser`) against a fake filesystem: listing, file-type
//! filter, navigation history, Favorites, recent directories, import and Open in Source Monitor.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use crate::media_browser::{self, DirEntry, Volume, VolumeKind};
use crate::{Services, Session};
/// An in-memory filesystem: directories and files (with bytes).
#[derive(Default)]
struct FakeFs {
dirs: Vec<String>,
files: Mutex<BTreeMap<String, Vec<u8>>>,
}
impl FakeFs {
fn new() -> Self {
let wav = filmcraft_media::wav::write_wav16(&[0.0; 4800], 2, 48_000);
let mut files = BTreeMap::new();
for f in ["/home/me/Movies/b-roll.wav", "/home/me/Movies/A-interview.wav", "/home/me/Movies/notes.txt", "/home/me/Movies/.hidden.wav"] {
files.insert(f.to_string(), wav.clone());
}
for f in [
"/home/me/Movies/shot_0001.png",
"/home/me/Movies/shot_0002.png",
"/home/me/Movies/edit.edl",
"/home/me/Movies/subs.srt",
"/home/me/Movies/clip.mov",
] {
files.insert(f.to_string(), vec![0; 16]);
}
files.insert("/Volumes/Card/DCIM/take1.wav".into(), wav);
FakeFs {
dirs: ["/", "/home", "/home/me", "/home/me/Movies", "/home/me/Movies/Selects", "/Volumes", "/Volumes/Card", "/Volumes/Card/DCIM"]
.iter()
.map(|d| d.to_string())
.collect(),
files: Mutex::new(files),
}
}
}
impl Services for FakeFs {
fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
self.files.lock().unwrap().get(path).cloned().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
}
fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
self.files.lock().unwrap().insert(path.into(), data.to_vec());
Ok(())
}
fn file_size(&self, path: &str) -> std::io::Result<u64> {
self.read_file(path).map(|b| b.len() as u64)
}
fn list_entries(&self, dir: &str) -> Option<std::io::Result<Vec<DirEntry>>> {
if !self.dirs.iter().any(|d| d == dir) {
return Some(Err(std::io::Error::new(std::io::ErrorKind::NotFound, dir.to_string())));
}
let prefix = if dir == "/" { "/".to_string() } else { format!("{dir}/") };
let direct = |p: &str| p.strip_prefix(&prefix).filter(|r| !r.is_empty() && !r.contains('/')).map(str::to_string);
let mut v: Vec<DirEntry> =
self.dirs.iter().filter_map(|d| direct(d)).map(|name| DirEntry { name, is_dir: true, size: None, modified: Some(0) }).collect();
for (p, b) in self.files.lock().unwrap().iter() {
if let Some(name) = direct(p) {
v.push(DirEntry { name, is_dir: false, size: Some(b.len() as u64), modified: Some(1_700_000_000) });
}
}
Some(Ok(v))
}
fn list_dir(&self, dir: &str) -> Option<std::io::Result<Vec<String>>> {
self.list_entries(dir).map(|r| r.map(|v| v.into_iter().map(|e| e.name).collect()))
}
fn volumes(&self) -> Vec<Volume> {
vec![
Volume { name: "Macintosh HD".into(), path: "/".into(), kind: VolumeKind::Local },
Volume { name: "Card".into(), path: "/Volumes/Card".into(), kind: VolumeKind::Local },
Volume { name: "Studio".into(), path: "/Network/Studio".into(), kind: VolumeKind::Network },
]
}
fn home_dir(&self) -> Option<String> {
Some("/home/me".into())
}
}
fn session() -> Session {
Session::new(Arc::new(FakeFs::new()))
}
fn names(v: &Value) -> Vec<String> {
v["entries"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn listing_puts_folders_first_and_hides_unsupported_files() {
let mut s = session();
// starts in the home directory
let l = s.execute("mediaBrowser.list", json!({})).unwrap();
assert_eq!(l["dir"], "/home/me");
assert_eq!(names(&l), ["Movies"]);
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap();
assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]);
let e = &l["entries"][0];
assert_eq!((e["isDir"].as_bool(), e["kind"].as_str()), (Some(true), Some("folder")));
let shot = l["entries"].as_array().unwrap().iter().find(|e| e["name"] == "shot_0001.png").unwrap();
assert_eq!(shot["numbered"], true);
assert_eq!(shot["path"], "/home/me/Movies/shot_0001.png");
assert!(s.execute("mediaBrowser.list", json!({"path": "/nope"})).is_err());
}
#[test]
fn file_type_filter() {
let mut s = session();
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "audio"})).unwrap();
assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav"], "folders always show");
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "image"})).unwrap();
assert_eq!(names(&l), ["Selects", "shot_0001.png", "shot_0002.png"]);
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "mov"})).unwrap();
assert_eq!(names(&l), ["Selects", "clip.mov"]);
// the setting persists and applies by default
s.execute("mediaBrowser.settings", json!({"fileTypes": "project"})).unwrap();
let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap();
assert_eq!(names(&l), ["Selects", "edit.edl"]);
assert!(s.execute("mediaBrowser.settings", json!({"fileTypes": "docx"})).is_err());
assert!(s.execute("mediaBrowser.settings", json!({"view": "cards"})).is_err());
s.execute("mediaBrowser.settings", json!({"view": "thumbnails", "columns": ["size", "Frame Rate"]})).unwrap();
assert_eq!(s.prefs.media_browser.view, "thumbnails");
assert_eq!(s.prefs.media_browser.columns, ["Name", "Size", "Frame Rate"]);
}
#[test]
fn navigation_back_forward_up_and_recent() {
let mut s = session();
s.execute("mediaBrowser.navigate", json!({"path": "/home/me/Movies"})).unwrap();
s.execute("mediaBrowser.navigate", json!({"path": "/home/me/Movies/Selects"})).unwrap();
let r = s.execute("mediaBrowser.navigate", json!({"up": true})).unwrap();
assert_eq!(r["dir"], "/home/me/Movies");
let r = s.execute("mediaBrowser.navigate", json!({"back": true})).unwrap();
assert_eq!(r["dir"], "/home/me/Movies/Selects");
assert_eq!(r["canForward"], true);
let r = s.execute("mediaBrowser.navigate", json!({"back": true})).unwrap();
assert_eq!(r["dir"], "/home/me/Movies");
let r = s.execute("mediaBrowser.navigate", json!({"forward": true})).unwrap();
assert_eq!(r["dir"], "/home/me/Movies/Selects");
// a new location clears forward history
s.execute("mediaBrowser.navigate", json!({"path": "/Volumes/Card"})).unwrap();
assert!(s.execute("mediaBrowser.navigate", json!({"forward": true})).is_err());
// a directory that can't be listed leaves the browser where it was
assert!(s.execute("mediaBrowser.navigate", json!({"path": "/missing"})).is_err());
assert_eq!(media_browser::current_dir(&s), "/Volumes/Card");
// recent: newest first, no duplicates; the last directory is remembered
assert_eq!(s.prefs.media_browser.recent[..3], ["/Volumes/Card", "/home/me/Movies/Selects", "/home/me/Movies"]);
assert_eq!(s.prefs.media_browser.last_dir, "/Volumes/Card");
let roots = s.execute("mediaBrowser.roots", json!({})).unwrap();
assert_eq!(roots["localDrives"].as_array().unwrap().len(), 2);
assert_eq!(roots["network"][0]["name"], "Studio");
assert_eq!(roots["recent"][0]["name"], "Card");
s.execute("mediaBrowser.clearRecent", json!({})).unwrap();
assert!(s.prefs.media_browser.recent.is_empty());
// up at the root fails
s.execute("mediaBrowser.navigate", json!({"path": "/"})).unwrap();
assert!(s.execute("mediaBrowser.navigate", json!({"up": true})).is_err());
}
#[test]
fn favorites_add_and_remove() {
let mut s = session();
s.execute("mediaBrowser.navigate", json!({"path": "/home/me/Movies"})).unwrap();
s.execute("mediaBrowser.favorite", json!({})).unwrap();
s.execute("mediaBrowser.favorite", json!({"path": "/Volumes/Card/DCIM"})).unwrap();
s.execute("mediaBrowser.favorite", json!({})).unwrap();
assert_eq!(s.prefs.media_browser.favorites, ["/home/me/Movies", "/Volumes/Card/DCIM"], "no duplicates");
let r = s.execute("mediaBrowser.list", json!({})).unwrap();
assert_eq!(r["favorite"], true);
let roots = s.execute("mediaBrowser.roots", json!({})).unwrap();
assert_eq!(roots["favorites"][1]["name"], "DCIM");
s.execute("mediaBrowser.favorite", json!({"remove": true})).unwrap();
assert_eq!(s.prefs.media_browser.favorites, ["/Volumes/Card/DCIM"]);
assert!(s.execute("mediaBrowser.favorite", json!({"remove": true})).is_err());
}
#[test]
fn import_the_selection_and_open_in_source() {
let mut s = session();
s.execute("mediaBrowser.navigate", json!({"path": "/home/me/Movies"})).unwrap();
assert!(s.execute("mediaBrowser.import", json!({})).is_err(), "nothing selected");
s.execute("mediaBrowser.select", json!({"paths": ["/home/me/Movies/b-roll.wav", "/home/me/Movies/A-interview.wav"]})).unwrap();
let r = s.execute("mediaBrowser.import", json!({})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2, "{r}");
assert_eq!(s.project.items.len(), 2);
// undoable like any import
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.items.is_empty());
// File ▸ Import from Media Browser uses the selection too
let r = s.execute("file.importFromMediaBrowser", json!({})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 2);
// Open In Source Monitor reuses the imported item
let n = s.project.items.len();
let r = s.execute("mediaBrowser.openInSource", json!({"path": "/home/me/Movies/b-roll.wav"})).unwrap();
assert_eq!(s.project.items.len(), n);
assert_eq!(s.state.source_item.map(|i| i.0), r["item"].as_u64());
// ... and imports a file not in the project yet
s.execute("mediaBrowser.openInSource", json!({"path": "/Volumes/Card/DCIM/take1.wav"})).unwrap();
assert_eq!(s.project.items.len(), n + 1);
// importing a folder imports its media files
let mut s = session();
let r = s.execute("mediaBrowser.import", json!({"paths": ["/Volumes/Card/DCIM"]})).unwrap();
assert_eq!(r["items"].as_array().unwrap().len(), 1);
}
#[test]
fn import_with_ingest_settings() {
let mut s = session();
s.execute("project.ingestSettings", json!({"enabled": true, "action": "copy", "destination": "/home/me/Ingest"})).unwrap();
let r = s.execute("mediaBrowser.import", json!({"paths": ["/home/me/Movies/b-roll.wav"]})).unwrap();
// the copy itself needs a real disk: the fake one makes it fail, which the result reports
let ran = !r["ingest"].is_null() || r["errors"].as_array().is_some_and(|e| e.iter().any(|x| x.as_str().unwrap_or("").starts_with("ingest")));
assert!(ran, "ingest ran: {r}");
assert_eq!(r["items"].as_array().unwrap().len(), 1);
}
#[test]
fn probe_and_columns() {
let mut s = session();
let r = s.execute("mediaBrowser.probe", json!({"path": "/home/me/Movies/b-roll.wav"})).unwrap();
assert_eq!(r["media"], true);
let l = media_browser::list(&*s.services, "/home/me/Movies", "all").unwrap();
let e = l.iter().find(|e| e.name == "b-roll.wav").unwrap();
let info = media_browser::probe(&mut s, &e.path);
assert_eq!(media_browser::column_text(e, "Audio Info", info.as_ref()), "48000 Hz - 2 ch");
assert!(media_browser::column_text(e, "Media Duration", info.as_ref()).starts_with("00:00:00"));
assert_eq!(media_browser::column_text(e, "Media Type", None), "Audio");
assert_eq!(media_browser::column_text(e, "Date Modified", None), "2023-11-14 22:13");
// not media: cached as such
assert!(media_browser::probe(&mut s, "/home/me/Movies/edit.edl").is_none());
}
#[test]
fn path_helpers() {
assert_eq!(media_browser::join("/a", "b"), "/a/b");
assert_eq!(media_browser::join("/", "b"), "/b");
assert_eq!(media_browser::join("C:\\Media", "b.mov"), "C:\\Media\\b.mov");
assert_eq!(media_browser::parent("/a/b"), Some("/a".into()));
assert_eq!(media_browser::parent("/a"), Some("/".into()));
assert_eq!(media_browser::parent("/"), None);
assert_eq!(media_browser::parent("C:\\Media"), Some("C:\\".into()));
assert_eq!(media_browser::base_name("/home/me/Movies/"), "Movies");
}
+1 -1
View File
@@ -237,7 +237,7 @@ pub fn metadata_fields(p: &Project, id: ItemId) -> Vec<Field> {
}
// other stored fields (from imports, Edit Offline, agents…)
for (k, v) in &it.metadata {
if !LOG_FIELDS.contains(&k.as_str()) {
if !LOG_FIELDS.contains(&k.as_str()) && !crate::project_panel::is_freeform_key(k) {
add("Clip", k, v.clone(), true);
}
}
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
//! Tests of the Project panel commands (`project_panel`): view settings, Metadata Display columns,
//! sorting, view presets, Freeform positions / stacks / arrangements (undoable, saved with the
//! project) and bin renames.
use filmcraft_project::{ItemId, ItemKind};
use serde_json::{Value, json};
use crate::Session;
use crate::project_panel::{self, FREEFORM_POS, FREEFORM_STACK, FontSize, ViewMode};
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
fn media(s: &Session) -> Vec<ItemId> {
let mut v: Vec<ItemId> = s.project.items.values().filter(|i| i.as_media().is_some()).map(|i| i.id).collect();
v.sort();
// the items of the first media item's bin (the demo's Footage bin)
let bin = s.project.root.parent_of(v[0]);
v.retain(|i| s.project.root.parent_of(*i) == bin);
v
}
fn bin_of(s: &Session, i: ItemId) -> u64 {
s.project.root.parent_of(i).unwrap().0
}
fn names(rows: &Value) -> Vec<String> {
rows["rows"].as_array().unwrap().iter().filter(|r| r.get("item").is_some()).map(|r| r["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn view_settings_are_preferences() {
let mut s = demo();
assert_eq!(s.prefs.project_panel.view.mode, ViewMode::Icon);
let r = s.execute("project.view.set", json!({"view": "list", "fontSize": "large", "previewArea": true, "iconSize": 1000, "hoverScrub": false})).unwrap();
assert_eq!(r["view"]["mode"], "list");
let pp = &s.prefs.project_panel;
assert_eq!(pp.view.mode, ViewMode::List);
assert_eq!(pp.view.font_size, FontSize::Large);
assert!(pp.preview_area && !pp.hover_scrub);
assert_eq!(pp.view.icon_size, 400.0, "clamped");
assert!(s.execute("project.view.set", json!({"view": "grid"})).is_err());
// round-trips through the preferences file format
let text = serde_json::to_string(&s.prefs).unwrap();
let back: crate::autosave::Preferences = serde_json::from_str(&text).unwrap();
assert_eq!(back.project_panel, s.prefs.project_panel);
// older preference files (no projectPanel key) load with the defaults
let mut v: Value = serde_json::from_str(&text).unwrap();
v.as_object_mut().unwrap().remove("projectPanel");
let old: crate::autosave::Preferences = serde_json::from_value(v).unwrap();
assert_eq!(old.project_panel, project_panel::ProjectPanelPrefs::default());
}
#[test]
fn metadata_display_chooses_and_orders_columns() {
let mut s = demo();
let ids = media(&s);
s.execute("metadata.set", json!({"item": ids[0].0, "field": "Mood", "value": "calm"})).unwrap();
let l = s.execute("project.columns.list", json!({})).unwrap();
let avail: Vec<&str> = l["available"].as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
for c in ["Name", "Label", "Media Duration", "Video Usage", "Mood"] {
assert!(avail.contains(&c), "{c} missing from {avail:?}");
}
// Freeform bookkeeping keys are not offered
s.execute("project.freeform.move", json!({"items": [ids[0].0], "x": 40, "y": 40})).unwrap();
let l = s.execute("project.columns.list", json!({})).unwrap();
assert!(!l["available"].as_array().unwrap().iter().any(|c| c["name"] == FREEFORM_POS));
// Name stays first; unknown-case names are canonicalised; widths are kept
s.execute("project.columns.set", json!({"columns": ["media duration", {"name": "Mood", "width": 90}, "Label"]})).unwrap();
let cols: Vec<String> = s.prefs.project_panel.view.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(cols, ["Name", "Media Duration", "Mood", "Label"]);
assert_eq!(s.prefs.project_panel.view.columns[2].width, 90.0);
s.execute("project.columns.resize", json!({"column": "Label", "width": 140})).unwrap();
assert_eq!(s.prefs.project_panel.view.columns[3].width, 140.0);
assert!(s.execute("project.columns.resize", json!({"column": "Frame Rate", "width": 140})).is_err(), "not shown");
// the rows carry the shown columns' text
let rows = s.execute("project.items", json!({"bin": bin_of(&s, ids[0])})).unwrap();
let row = rows["rows"].as_array().unwrap().iter().find(|r| r["item"] == json!(ids[0].0)).unwrap();
assert_eq!(row["cells"]["Mood"], "calm");
assert!(row["cells"]["Media Duration"].as_str().unwrap().contains(':'));
}
#[test]
fn sorting_by_a_column_and_reversing() {
let mut s = demo();
let bin = bin_of(&s, media(&s)[0]);
let by_name = names(&s.execute("project.items", json!({"bin": bin})).unwrap());
let mut sorted = by_name.clone();
sorted.sort_by_key(|n| n.to_lowercase());
assert!(by_name.len() > 2);
assert_eq!(by_name, sorted, "Name ascending by default");
// the same column again reverses
s.execute("project.sort", json!({"column": "name"})).unwrap();
assert!(s.prefs.project_panel.view.sort.descending);
let rev = names(&s.execute("project.items", json!({"bin": bin})).unwrap());
assert_eq!(rev, sorted.iter().rev().cloned().collect::<Vec<_>>());
// durations sort numerically, not as text
s.execute("project.sort", json!({"column": "Media Duration", "descending": false})).unwrap();
let rows = s.execute("project.items", json!({"bin": bin})).unwrap();
let durs: Vec<i64> =
rows["rows"].as_array().unwrap().iter().filter_map(|r| r["item"].as_u64()).map(|i| s.project.item(ItemId(i)).unwrap().duration().0).collect();
assert!(durs.windows(2).all(|w| w[0] <= w[1]), "{durs:?}");
assert!(s.execute("project.sort", json!({"column": "Nope"})).is_err());
}
#[test]
fn view_presets_save_restore_and_manage() {
let mut s = demo();
assert!(s.execute("project.viewPreset.save", json!({})).is_err(), "no current preset yet");
assert!(s.execute("project.viewPreset.restore", json!({"slot": 1})).is_err(), "none saved");
s.execute("project.view.set", json!({"view": "list"})).unwrap();
s.execute("project.columns.set", json!({"columns": ["Name", "Label"]})).unwrap();
let r = s.execute("project.viewPreset.saveAs", json!({"name": "Logging"})).unwrap();
assert_eq!(r["current"], 1);
assert_eq!(r["presets"][0]["name"], "Logging");
// change the view, then restore preset 1
s.execute("project.view.set", json!({"view": "freeform"})).unwrap();
s.execute("project.columns.set", json!({"columns": ["Name", "Frame Rate", "Video Info"]})).unwrap();
s.execute("project.viewPreset.saveAs", json!({})).unwrap();
assert_eq!(s.prefs.project_panel.presets[1].as_ref().unwrap().name, "Project View Preset 2");
s.execute("project.viewPreset.restore", json!({"slot": 1})).unwrap();
assert_eq!(s.prefs.project_panel.view.mode, ViewMode::List);
assert_eq!(s.prefs.project_panel.view.columns.len(), 2);
// Save Current View Preset overwrites the current slot
s.execute("project.view.set", json!({"fontSize": "small"})).unwrap();
s.execute("project.viewPreset.save", json!({})).unwrap();
assert_eq!(s.prefs.project_panel.presets[0].as_ref().unwrap().settings.font_size, FontSize::Small);
assert_eq!(s.prefs.project_panel.presets[0].as_ref().unwrap().name, "Logging");
// Manage: rename and delete
s.execute("project.viewPreset.rename", json!({"slot": 2, "name": "Grid"})).unwrap();
assert_eq!(s.prefs.project_panel.presets[1].as_ref().unwrap().name, "Grid");
s.execute("project.viewPreset.delete", json!({"slot": 1})).unwrap();
assert!(s.prefs.project_panel.presets[0].is_none());
assert_eq!(s.prefs.project_panel.current_preset, None);
assert!(s.execute("project.viewPreset.restore", json!({"slot": 11})).is_err());
// slots fill up
for _ in 0..9 {
s.execute("project.viewPreset.saveAs", json!({})).unwrap();
}
assert!(s.execute("project.viewPreset.saveAs", json!({})).is_err(), "all ten used");
}
#[test]
fn view_presets_persist_in_the_preferences_file() {
let dir = std::env::temp_dir().join(format!("fc-viewpresets-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("preferences.json");
let mut s = demo();
s.prefs_path = Some(path.clone());
s.execute("project.columns.set", json!({"columns": ["Name", "Scene", "Shot"]})).unwrap();
s.execute("project.viewPreset.saveAs", json!({"name": "Scenes"})).unwrap();
let loaded = crate::autosave::Preferences::load(&path);
assert_eq!(loaded.project_panel.presets[0].as_ref().unwrap().name, "Scenes");
assert_eq!(loaded.project_panel.view.columns.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Name", "Scene", "Shot"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn freeform_positions_are_undoable_and_saved_with_the_project() {
let mut s = demo();
let ids = media(&s);
let bin = bin_of(&s, ids[0]);
let lay = s.execute("project.freeform.layout", json!({"bin": bin, "width": 600})).unwrap();
let cards = lay["cards"].as_array().unwrap();
assert!(cards.len() >= ids.len());
assert!(cards.iter().all(|c| c["placed"] == false), "nothing placed yet: auto grid");
// moving two cards keeps their offsets
let a = cards.iter().find(|c| c["item"] == json!(ids[0].0)).unwrap().clone();
let b = cards.iter().find(|c| c["item"] == json!(ids[1].0)).unwrap().clone();
let dx = b["x"].as_f64().unwrap() - a["x"].as_f64().unwrap();
s.execute("project.freeform.move", json!({"items": [ids[0].0, ids[1].0], "x": 300, "y": 200, "width": 600})).unwrap();
let pos = |s: &Session, i: ItemId| project_panel::parse_pos(s.project.item(i).unwrap().metadata.get(FREEFORM_POS).unwrap()).unwrap();
assert_eq!((pos(&s, ids[0]).0, pos(&s, ids[0]).1), (300.0, 200.0));
assert_eq!(pos(&s, ids[1]).0, (300.0 + dx as f32).round());
// placed cards come first; the rest go on the grid below them
let lay = s.execute("project.freeform.layout", json!({"bin": bin, "width": 600})).unwrap();
let unplaced: Vec<&Value> = lay["cards"].as_array().unwrap().iter().filter(|c| c["placed"] == false).collect();
assert!(unplaced.iter().all(|c| c["y"].as_f64().unwrap() > 200.0));
// undo puts the card back on the grid
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.project.item(ids[0]).unwrap().metadata.contains_key(FREEFORM_POS));
s.execute("edit.redo", json!({})).unwrap();
// snapping and Align to Grid
s.execute("project.freeform.options", json!({"grid": 50, "snap": true})).unwrap();
s.execute("project.freeform.move", json!({"items": [ids[2].0], "x": 123, "y": 77})).unwrap();
assert_eq!((pos(&s, ids[2]).0, pos(&s, ids[2]).1), (100.0, 100.0));
s.execute("project.freeform.options", json!({"snap": false})).unwrap();
s.execute("project.freeform.move", json!({"items": [ids[2].0], "x": 133, "y": 77})).unwrap();
s.execute("project.freeform.alignToGrid", json!({"bin": bin})).unwrap();
assert_eq!((pos(&s, ids[2]).0, pos(&s, ids[2]).1), (150.0, 100.0));
assert!(ids.iter().all(|i| s.project.item(*i).unwrap().metadata.contains_key(FREEFORM_POS)), "align pins every card");
// Clip Size
s.execute("project.freeform.resize", json!({"items": [ids[0].0], "size": 200})).unwrap();
assert_eq!(pos(&s, ids[0]).2, Some(200.0));
s.execute("project.freeform.resize", json!({"items": [ids[0].0], "step": 1})).unwrap();
assert_eq!(pos(&s, ids[0]).2, Some(250.0));
// saved with the project (item metadata) and back
let text = serde_json::to_string(&*s.project).unwrap();
let p2: filmcraft_project::Project = serde_json::from_str(&text).unwrap();
assert_eq!(p2.item(ids[0]).unwrap().metadata.get(FREEFORM_POS), s.project.item(ids[0]).unwrap().metadata.get(FREEFORM_POS));
// the Metadata panel doesn't list the bookkeeping keys
let m = s.execute("metadata.get", json!({"item": ids[0].0})).unwrap();
assert!(!m["fields"].as_array().unwrap().iter().any(|f| f["name"] == FREEFORM_POS));
// Reset to Grid
s.execute("project.freeform.reset", json!({"bin": bin})).unwrap();
assert!(s.project.items.values().all(|i| !i.metadata.contains_key(FREEFORM_POS)));
}
#[test]
fn freeform_stacks_and_arrangements() {
let mut s = demo();
let ids = media(&s);
assert!(s.execute("project.freeform.stack", json!({"items": [ids[0].0]})).is_err(), "needs two");
s.execute("project.freeform.move", json!({"items": [ids[0].0], "x": 400, "y": 300})).unwrap();
s.execute("project.freeform.stack", json!({"items": [ids[0].0, ids[1].0, ids[2].0]})).unwrap();
let bin = bin_of(&s, ids[0]);
let lay = s.execute("project.freeform.layout", json!({"bin": bin})).unwrap();
let card = |i: ItemId| lay["cards"].as_array().unwrap().iter().find(|c| c["item"] == json!(i.0)).unwrap().clone();
assert_eq!(card(ids[1])["stack"], json!(ids[0].0));
// stacked cards sit on the first card, offset
assert!((card(ids[1])["x"].as_f64().unwrap() - 406.0).abs() < 0.01);
assert!((card(ids[2])["y"].as_f64().unwrap() - 312.0).abs() < 0.01);
// one undo step
let n = s.history.undo.len();
s.execute("project.freeform.unstack", json!({"items": [ids[0].0]})).unwrap();
assert_eq!(s.history.undo.len(), n + 1);
assert!(ids[..3].iter().all(|i| !s.project.item(*i).unwrap().metadata.contains_key(FREEFORM_STACK)), "unstacking the first card releases all");
let lay = s.execute("project.freeform.layout", json!({"bin": bin})).unwrap();
let xs: Vec<f64> =
ids[..3].iter().map(|i| lay["cards"].as_array().unwrap().iter().find(|c| c["item"] == json!(i.0)).unwrap()["x"].as_f64().unwrap()).collect();
assert!(xs[0] < xs[1] && xs[1] < xs[2], "released cards are spread: {xs:?}");
// arrangements
s.execute("project.freeform.saveArrangement", json!({"name": "Rough", "bin": bin})).unwrap();
s.execute("project.freeform.move", json!({"items": [ids[0].0], "x": 20, "y": 20})).unwrap();
assert_eq!(s.execute("project.freeform.arrangements", json!({"bin": bin})).unwrap()["arrangements"], json!(["Rough"]));
s.execute("project.freeform.restoreArrangement", json!({"name": "Rough", "bin": bin})).unwrap();
let x0 = project_panel::parse_pos(s.project.item(ids[0]).unwrap().metadata.get(FREEFORM_POS).unwrap()).unwrap().0;
assert_eq!(x0, 400.0);
assert!(s.execute("project.freeform.restoreArrangement", json!({"name": "Nope", "bin": bin})).is_err());
s.execute("project.freeform.deleteArrangement", json!({"name": "Rough", "bin": bin})).unwrap();
assert_eq!(s.execute("project.freeform.arrangements", json!({"bin": bin})).unwrap()["arrangements"], json!([]));
}
#[test]
fn bins_rename_nest_and_list() {
let mut s = demo();
let b = s.execute("file.newBin", json!({"name": "Footage"})).unwrap()["bin"].as_u64().unwrap();
let inner = s.execute("file.newBin", json!({"name": "Day 1", "parent": b})).unwrap()["bin"].as_u64().unwrap();
let ids = media(&s);
s.execute("project.moveToBin", json!({"items": [ids[0].0], "bin": inner})).unwrap();
s.execute("project.renameBin", json!({"bin": b, "name": "Camera"})).unwrap();
let rows = s.execute("project.items", json!({"recursive": true})).unwrap();
let r = rows["rows"].as_array().unwrap();
let cam = r.iter().position(|x| x["bin"] == json!(b)).unwrap();
assert_eq!(r[cam]["name"], "Camera");
assert_eq!(r[cam + 1]["bin"], json!(inner));
assert_eq!(r[cam + 2]["item"], json!(ids[0].0));
assert_eq!(r[cam + 2]["depth"], 2);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.root.find_bin(filmcraft_project::BinId(b)).unwrap().name, "Footage");
assert!(s.execute("project.renameBin", json!({"bin": b, "name": " "})).is_err());
// only the bin's rows without `recursive`
let rows = s.execute("project.items", json!({"bin": inner})).unwrap();
assert_eq!(names(&rows).len(), 1);
}
#[test]
fn usage_counts_sequence_uses() {
let s = demo();
let used = s
.project
.sequences()
.flat_map(|q| q.as_sequence().into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(|i| i.item)).collect::<Vec<_>>())
.next()
.expect("the demo sequence uses an item");
let (v, a) = project_panel::usage(&s.project, used);
assert!(v + a > 0);
let it = s.project.item(used).unwrap();
let (text, _) = project_panel::cell(&s.project, it, if v > 0 { "Video Usage" } else { "Audio Usage" });
assert_eq!(text, (if v > 0 { v } else { a }).to_string());
assert!(!matches!(it.kind, ItemKind::Graphic { .. }));
}
+8 -3
View File
@@ -97,14 +97,19 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
"Import from Media Browser",
&["File"],
Some("Cmd+Alt+I"),
r#"{"paths":[str],"imageSequence":bool?}"#,
r#"{"paths":[str]?,"imageSequence":bool?}"#,
always,
|s, p| {
if p.get("paths").and_then(Value::as_array).is_none_or(|a| a.is_empty()) {
// the Media Browser's selection unless `paths` are given
let paths = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.clone(),
None => s.browser.selection.iter().map(|x| json!(x)).collect(),
};
if paths.is_empty() {
return Err(bad("file.importFromMediaBrowser", "select files in the Media Browser"));
}
let seq = p.get("imageSequence").and_then(Value::as_bool).unwrap_or(false);
s.execute("file.import", json!({"paths": p["paths"], "imageSequence": seq}))
s.execute("file.import", json!({"paths": paths, "imageSequence": seq}))
},
),
// File ▸ Import with "Image Sequence" checked (the UI asks for the first frame).
+2
View File
@@ -311,6 +311,8 @@ pub const PREMIERE_PANEL: &[Entry] = &[
("projectPanel.viewIcon", "Cmd+PageDown", "Project"),
("projectPanel.toggleView", "Shift+\\", "Project"),
("projectPanel.hoverScrub", "Shift+H", "Project"),
("projectPanel.markIn", "I", "Project"),
("projectPanel.markOut", "O", "Project"),
("projectPanel.thumbnailLarger", "Shift+]", "Project"),
("projectPanel.thumbnailLarger", "=", "Project"),
("projectPanel.thumbnailSmaller", "Shift+[", "Project"),
+15
View File
@@ -208,6 +208,21 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest)
Err(e) => return err(format!("`panels`: {e}")),
}
}
// Project panel / Media Browser view state (`panels::project`, `panels::media_browser`), merged
for (k, which) in [("projectPanel", 0), ("mediaBrowser", 1)] {
let Some(patch) = p.get(k) else { continue };
let mut cur =
if which == 0 { serde_json::to_value(&app.ui.project_panel) } else { serde_json::to_value(&app.ui.media_browser) }.unwrap_or_default();
merge(&mut cur, patch);
let r = if which == 0 {
serde_json::from_value(cur).map(|v| app.ui.project_panel = v)
} else {
serde_json::from_value(cur).map(|v| app.ui.media_browser = v)
};
if let Err(e) = r {
return err(format!("`{k}`: {e}"));
}
}
// fields of the open Edit / Clip / File dialog (`panels::clip_dialogs`)
if let Some(m) = p.get("clipDialog").and_then(Value::as_object) {
let Some(d) = app.ui.clip_dialog.as_mut() else { return err("no clip dialog is open") };
+56
View File
@@ -100,6 +100,20 @@ pub enum Icon {
TrackMaskBackFrame,
TrackMaskFwdFrame,
TrackMaskFwd,
/// Project panel footer: Sort Icons (lines of decreasing length).
SortIcons,
/// Project panel footer: Automate to Sequence (three clips in a row).
Automate,
/// Media Browser: a favourite (five-pointed star).
Star,
/// Media Browser: back / forward / up one level.
ChevronLeft,
ArrowUp,
/// Media Browser: a local drive and a network location.
Drive,
Network,
/// Media Browser: recent directories (a clock face).
Clock,
}
pub struct Pen16<'a> {
@@ -574,6 +588,48 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
pen.fill(&[(2.0, 3.5), (8.0, 8.0), (2.0, 12.5)]);
pen.fill(&[(8.0, 3.5), (14.0, 8.0), (8.0, 12.5)]);
}
SortIcons => {
for (i, w) in [12.0, 9.5, 7.0, 4.5].iter().enumerate() {
let y = 3.5 + i as f32 * 3.0;
pen.line(&[(2.0, y), (2.0 + w, y)]);
}
}
Automate => {
pen.rect_fill(1.5, 5.0, 4.5, 11.0);
pen.rect_fill(5.5, 5.0, 8.5, 11.0);
pen.rect_fill(9.5, 5.0, 12.0, 11.0);
pen.line(&[(13.5, 5.0), (13.5, 11.0)]);
}
Star => {
let pts: Vec<(f32, f32)> = (0..10)
.map(|i| {
let a = (-90.0 + i as f32 * 36.0f32).to_radians();
let r = if i % 2 == 0 { 6.5 } else { 2.7 };
(8.0 + r * a.cos(), 8.5 + r * a.sin())
})
.collect();
pen.closed(&pts);
}
ChevronLeft => pen.line(&[(10.0, 4.0), (6.0, 8.0), (10.0, 12.0)]),
ArrowUp => {
pen.line(&[(8.0, 13.5), (8.0, 3.0)]);
pen.line(&[(4.0, 7.0), (8.0, 3.0), (12.0, 7.0)]);
}
Drive => {
pen.rect(1.5, 5.0, 14.5, 11.5);
pen.dot(12.0, 8.25, 0.8);
pen.line(&[(3.5, 8.25), (8.0, 8.25)]);
}
Network => {
pen.circle(8.0, 8.0, 6.0);
pen.line(&[(2.0, 8.0), (14.0, 8.0)]);
pen.arc(8.0, 8.0, 6.0, -90.0, 90.0);
pen.line(&[(8.0, 2.0), (8.0, 14.0)]);
}
Clock => {
pen.circle(8.0, 8.0, 6.0);
pen.line(&[(8.0, 4.5), (8.0, 8.0), (10.5, 9.5)]);
}
}
}
+2
View File
@@ -14,6 +14,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
crate::panels::monitor_view::dialogs(app, ctx);
crate::panels::voiceover::show(app, ctx);
crate::panels::remix::show(app, ctx);
crate::panels::project_dialogs::show(app, ctx);
crate::panels::media_browser::dialogs(app, ctx);
let Some(d) = app.dialog else { return };
if let Some(still_open) = crate::panels::file_dialogs::show(app, ctx, d) {
if !still_open && app.dialog == Some(d) {
+48 -65
View File
@@ -28,7 +28,7 @@ use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::dock::PanelKind;
use crate::menus::UiCommand;
use crate::state::ProjectView;
use filmcraft_engine::project_panel::ViewMode;
/// Keyboard-only view state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -36,8 +36,6 @@ use crate::state::ProjectView;
pub struct KeysState {
/// The maximized frame (Maximize or Restore Frame); None = the normal layout.
pub maximized: Option<PanelKind>,
/// Project panel ▸ Hover Scrub (icon view thumbnails follow the pointer).
pub hover_scrub: bool,
/// Track heights before Expand / Minimize All Tracks (pressing again restores them).
pub saved_heights: Option<[f32; 2]>,
/// Icon view columns in the last frame (Move Selection Up / Down step).
@@ -46,7 +44,7 @@ pub struct KeysState {
impl Default for KeysState {
fn default() -> Self {
Self { maximized: None, hover_scrub: true, saved_heights: None, icon_columns: 1 }
Self { maximized: None, saved_heights: None, icon_columns: 1 }
}
}
@@ -101,6 +99,19 @@ pub const COMMANDS: &[UiCommand] = &[
uic!("projectPanel.extendDown", "Extend Selection Down", [], None),
uic!("projectPanel.extendLeft", "Extend Selection Left", [], None),
uic!("projectPanel.extendRight", "Extend Selection Right", [], None),
uic!("projectPanel.markIn", "Mark In (Hover Scrub)", [], None),
uic!("projectPanel.markOut", "Mark Out (Hover Scrub)", [], None),
uic!("projectPanel.rename", "Rename", [], None),
uic!("projectPanel.openBin", "Open Bin", [], None),
uic!("projectPanel.up", "Up One Level", [], None),
uic!("projectPanel.closeBinTab", "Close Bin", [], None),
uic!("projectPanel.metadataDisplay", "Metadata Display…", [], None),
uic!("projectPanel.saveViewPresetAs", "Save As New View Preset", [], None),
uic!("projectPanel.manageViewPresets", "Manage Saved View Presets", [], None),
uic!("projectPanel.freeformOptions", "Freeform View Options…", [], None),
uic!("projectPanel.saveArrangement", "Save Arrangement…", [], None),
uic!("projectPanel.revealProject", "Reveal Project in Finder…", [], None),
uic!("projectPanel.newPanel", "New Project Panel", ["Window"], None),
uic!("textPanel.prevWord", "Navigate to Previous Word", [], None),
uic!("textPanel.nextWord", "Navigate to Next Word", [], None),
uic!("textPanel.selectPrevWord", "Select to Previous Word", [], None),
@@ -116,7 +127,6 @@ pub const COMMANDS: &[UiCommand] = &[
uic!("textPanel.delete", "Delete", [], None),
uic!("textPanel.rippleDelete", "Ripple Delete", [], None),
uic!("textPanel.showProgramTranscript", "Show Program Transcript", [], None),
uic!("mediaBrowser.openInSource", "Open In Source Monitor", [], None),
uic!("graphics.beginTextEditing", "Begin Text Editing for a Graphic Layer", [], Some("Cmd+Alt+'")),
uic!("help.filmcraftHelp", "FilmCraft Help…", ["Help"], Some("F1")),
uic!("app.quit", "Quit FilmCraft", [], Some("Cmd+Q")),
@@ -187,7 +197,6 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val
Ok(json!({"scroll": tv.target_scroll}))
}
"graphics.beginTextEditing" => begin_text_editing(app),
"mediaBrowser.openInSource" => open_browser_file(app),
"help.filmcraftHelp" => {
crate::links::open(ctx, HELP_URL);
Ok(json!({"url": HELP_URL}))
@@ -196,7 +205,10 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
Ok(Value::Null)
}
_ if id.starts_with("projectPanel.") => project_panel(app, &id["projectPanel.".len()..]),
_ if id.starts_with("projectPanel.") => match crate::panels::project::route(app, ctx, &id["projectPanel.".len()..], params) {
Some(r) => r,
None => project_panel(app, &id["projectPanel.".len()..]),
},
_ if id.starts_with("textPanel.") => text_panel(app, &id["textPanel.".len()..]),
_ => return None,
};
@@ -327,77 +339,48 @@ fn begin_text_editing(app: &mut FilmcraftApp) -> Result<Value, String> {
Ok(json!({"clip": clip, "layer": layer}))
}
/// Media Browser ▸ Open In Source Monitor: the selected file (imported first: the Source monitor
/// shows project items).
fn open_browser_file(app: &mut FilmcraftApp) -> Result<Value, String> {
let path = app.ui.extras.media_browser_selection.first().cloned().ok_or("select a file in the Media Browser")?;
let existing = app
.session
.project
.items
.values()
.find(|i| i.as_media().is_some_and(|m| matches!(&m.media, filmcraft_project::MediaRef::File { path: p } if *p == path)))
.map(|i| i.id);
let item = match existing {
Some(i) => i,
None => {
let r = app.session.execute("file.import", json!({"paths": [path]})).map_err(|e| e.to_string())?;
let id =
r["items"].as_array().and_then(|a| a.first()).and_then(Value::as_u64).or_else(|| r["item"].as_u64()).ok_or("the file could not be imported")?;
filmcraft_project::ItemId(id)
}
};
app.session.execute("source.open", json!({"item": item.0})).map_err(|e| e.to_string())?;
app.show_panel(PanelKind::Source);
Ok(json!({"item": item.0}))
}
// ------------------------------------------------------------------ Project panel
/// Items as the Project panel lists them (expanded bins only in list view).
/// Items as the Project panel lists them (its shown tab, in display order).
fn panel_items(app: &FilmcraftApp) -> Vec<filmcraft_project::ItemId> {
fn walk(app: &FilmcraftApp, bin: &filmcraft_project::Bin, list: bool, out: &mut Vec<filmcraft_project::ItemId>) {
for e in &bin.children {
match e {
filmcraft_project::BinEntry::Item(i) => out.push(*i),
filmcraft_project::BinEntry::Bin(b) => {
if !list || app.ui.expanded_bins.contains(&b.id.0) || !app.ui.project_search.is_empty() {
walk(app, b, list, out);
}
}
}
}
}
let mut v = Vec::new();
walk(app, &app.session.project.root, app.ui.project_view == ProjectView::List, &mut v);
let filter = app.ui.project_search.to_ascii_lowercase();
v.retain(|i| {
app.session.project.item(*i).is_some_and(|it| {
!matches!(it.kind, filmcraft_project::ItemKind::Graphic { .. }) && (filter.is_empty() || it.name.to_ascii_lowercase().contains(&filter))
})
});
v
crate::panels::project::visible_items(app, crate::panels::project::shown_inst(app))
}
fn project_panel(app: &mut FilmcraftApp, op: &str) -> Result<Value, String> {
match op {
"viewList" | "viewIcon" | "toggleView" => {
app.ui.project_view = match op {
"viewList" => ProjectView::List,
"viewIcon" => ProjectView::Icon,
_ if app.ui.project_view == ProjectView::List => ProjectView::Icon,
_ => ProjectView::List,
let inst = crate::panels::project::shown_inst(app);
let cur = crate::panels::project::view_of(app, inst).mode;
let mode = match op {
"viewList" => ViewMode::List,
"viewIcon" => ViewMode::Icon,
_ if cur == ViewMode::List => ViewMode::Icon,
_ => ViewMode::List,
};
return Ok(json!({"view": app.ui.project_view}));
let mut acts = Vec::new();
crate::panels::project::set_mode(app, inst, mode, &mut acts);
for (c, p) in acts {
app.session.execute(&c, p).map_err(|e| e.to_string())?;
}
return Ok(json!({"view": mode}));
}
"hoverScrub" => {
app.ui.keys.hover_scrub = !app.ui.keys.hover_scrub;
return Ok(json!({"hoverScrub": app.ui.keys.hover_scrub}));
let on = !app.session.prefs.project_panel.hover_scrub;
app.session.execute("project.view.set", json!({"hoverScrub": on})).map_err(|e| e.to_string())?;
return Ok(json!({"hoverScrub": on}));
}
"thumbnailLarger" | "thumbnailSmaller" => {
let f = if op == "thumbnailLarger" { 1.25 } else { 0.8 };
app.ui.icon_size = (app.ui.icon_size * f).clamp(64.0, 400.0);
return Ok(json!({"iconSize": app.ui.icon_size}));
let inst = crate::panels::project::shown_inst(app);
let v = crate::panels::project::view_of(app, inst);
let size = (v.icon_size * f).clamp(48.0, 400.0);
match inst {
crate::panels::project::Inst::Main => {
app.session.execute("project.view.set", json!({"iconSize": size})).map_err(|e| e.to_string())?;
}
crate::panels::project::Inst::Tab(k) => app.ui.project_panel.tabs[k].icon_size = size,
}
return Ok(json!({"iconSize": size}));
}
_ => {}
}
@@ -407,7 +390,7 @@ fn project_panel(app: &mut FilmcraftApp, op: &str) -> Result<Value, String> {
}
let sel = app.session.state.project_selection.clone();
let last = sel.last().and_then(|s| items.iter().position(|i| i == s));
let icon = app.ui.project_view != ProjectView::List;
let icon = crate::panels::project::view_of(app, crate::panels::project::shown_inst(app)).mode != ViewMode::List;
let cols = if icon { app.ui.keys.icon_columns.max(1) as i64 } else { 1 };
let page = 10i64;
let n = items.len() as i64;
+839
View File
@@ -0,0 +1,839 @@
//! Media Browser panel (M12.7): a directory tree (Favorites, Local Drives, Network, Recent
//! Directories) beside the media list (List or Thumbnails view with hover scrub), back / forward /
//! up navigation, the path field, the file-type filter, Ingest and Import as Image Sequence, Edit
//! Columns…, Import / Open In Source Monitor and dragging files to the Project panel or Timeline.
//!
//! Listing, navigation, Favorites, settings and import are engine commands
//! (`filmcraft_engine::media_browser`, `mediaBrowser.*`); this module draws them and keeps only view
//! state ([`MediaBrowserUi`], `ui.set {"mediaBrowser": …}`).
use std::collections::BTreeMap;
use std::sync::Arc;
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use filmcraft_engine::media_browser::{self as mb, Entry};
use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, Project, ProjectItem};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::frames::{FrameKey, Target};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
/// Frontend state of the Media Browser (`UiState::media_browser`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MediaBrowserUi {
/// Expanded directories in the tree.
pub expanded: Vec<String>,
/// The path field being edited (None = shows the current directory).
pub path_edit: Option<String>,
/// Edit Columns… dialog: the columns being chosen (open when Some).
pub edit_columns: Option<Vec<String>>,
/// Width of the directory tree (points).
pub tree_width: f32,
/// The thumbnail under the pointer and the hovered fraction of its duration.
pub hover: Option<(String, f32)>,
}
/// Cached directory listings (refreshed every few seconds or on Refresh).
#[derive(Clone, Default)]
struct Cache {
lists: BTreeMap<(String, String), (f64, Arc<Vec<Entry>>)>,
/// Preview items for thumbnails of files not in the project: path → id.
preview_ids: BTreeMap<String, u64>,
preview: Arc<Project>,
/// Item imported for a drag in progress (undone when it is dropped nowhere): items, history
/// length after the import, last pointer position.
drag: Option<(Vec<u64>, usize, egui::Pos2)>,
}
const PREVIEW_BASE: u64 = 1 << 44;
const CACHE_SECS: f64 = 3.0;
const ROW_H: f32 = 22.0;
fn cache_id() -> egui::Id {
egui::Id::new("media-browser-cache")
}
fn with_cache<R>(ctx: &egui::Context, f: impl FnOnce(&mut Cache) -> R) -> R {
ctx.data_mut(|d| {
let c = d.get_temp_mut_or_default::<Cache>(cache_id());
f(c)
})
}
/// Drop cached listings (Refresh, after navigation).
pub fn refresh(ctx: &egui::Context) {
with_cache(ctx, |c| c.lists.clear());
}
fn listing(app: &FilmcraftApp, ctx: &egui::Context, dir: &str, filter: &str) -> Result<Arc<Vec<Entry>>, String> {
let now = ctx.input(|i| i.time);
let key = (dir.to_string(), filter.to_string());
if let Some(v) = with_cache(ctx, |c| c.lists.get(&key).filter(|(t, _)| now - *t < CACHE_SECS).map(|(_, v)| v.clone())) {
return Ok(v);
}
let v = Arc::new(mb::list(&*app.session.services, dir, filter).map_err(|e| e.to_string())?);
with_cache(ctx, |c| c.lists.insert(key, (now, v.clone())));
Ok(v)
}
fn exec(app: &mut FilmcraftApp, ctx: &egui::Context, cmd: &str, p: Value) -> Option<Value> {
match crate::menus::invoke(app, ctx, cmd, p) {
Ok(v) => Some(v),
Err(e) => {
app.ui.status = e;
None
}
}
}
pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
finish_drag(app, ui);
let dir = mb::current_dir(&app.session);
let prefs = app.session.prefs.media_browser.clone();
// ---- toolbar: back, forward, up, path, file types, view, Ingest, Import as Image Sequence
let bar = Rect::from_min_size(rect.min + vec2(6.0, 4.0), vec2(rect.width() - 12.0, 24.0));
let mut x = bar.min.x;
let nav = [
(Icon::ChevronLeft, "back", "Go Back", !app.session.browser.back.is_empty()),
(Icon::ChevronRight, "forward", "Go Forward", !app.session.browser.forward.is_empty()),
(Icon::ArrowUp, "up", "Up One Level", mb::parent(&dir).is_some()),
];
for (icon, id, tip, enabled) in nav {
let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0));
let resp = ui.interact(r, egui::Id::new(("mb-nav", id)), Sense::click()).on_hover_text(tip);
app.auto.add(&format!("mediaBrowser.{id}"), r, tip);
if resp.hovered() && enabled {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(5.0), icon, if enabled { t.icon } else { t.text_faint });
if resp.clicked() && enabled {
exec(app, &ctx, "mediaBrowser.navigate", json!({id: true}));
refresh(&ctx);
}
x += 26.0;
}
// path field: type a directory and press Enter
let pw = (bar.max.x - x - 290.0).max(80.0);
let pr = Rect::from_min_size(pos2(x + 4.0, bar.min.y + 1.0), vec2(pw, 22.0));
let mut path = app.ui.media_browser.path_edit.clone().unwrap_or_else(|| dir.clone());
let resp = ui.put(pr, egui::TextEdit::singleline(&mut path).id(egui::Id::new("mb-path")).font(egui::FontId::proportional(11.5)));
app.auto.add("mediaBrowser.path", pr, "Path");
if resp.changed() {
app.ui.media_browser.path_edit = Some(path.clone());
}
if resp.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && path != dir {
exec(app, &ctx, "mediaBrowser.navigate", json!({"path": path}));
refresh(&ctx);
}
app.ui.media_browser.path_edit = None;
}
x = pr.max.x + 6.0;
// file types
let fr = Rect::from_min_size(pos2(x, bar.min.y + 1.0), vec2(150.0, 22.0));
let label = mb::FILE_TYPES.iter().find(|f| f.0 == prefs.file_types).map(|f| f.1.to_string()).unwrap_or_else(|| format!(".{}", prefs.file_types));
let fresp = crate::widgets::dropdown_text(ui, fr, &label, &t, egui::Id::new("mb-types")).on_hover_text("File Types Displayed");
app.auto.add("mediaBrowser.fileTypes", fr, "File Types Displayed");
egui::Popup::menu(&fresp).show(|ui| {
for (k, l) in mb::FILE_TYPES {
let r = ui.selectable_label(prefs.file_types == k, l);
app.auto.add(&format!("mediaBrowser.fileTypes.{k}"), r.rect, l);
if r.clicked() {
exec(app, &ctx, "mediaBrowser.settings", json!({"fileTypes": k}));
ui.close();
}
}
ui.separator();
ui.menu_button("File Extension", |ui| {
for e in mb::extensions() {
if ui.selectable_label(prefs.file_types == e, format!(".{e}")).clicked() {
exec(app, &ctx, "mediaBrowser.settings", json!({"fileTypes": e}));
ui.close();
}
}
});
});
x = fr.max.x + 6.0;
for (icon, v, tip) in [(Icon::ListView, "list", "List View"), (Icon::IconView, "thumbnails", "Thumbnail View")] {
let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(24.0, 24.0));
let resp = ui.interact(r, egui::Id::new(("mb-view", v)), Sense::click()).on_hover_text(tip);
app.auto.add(&format!("mediaBrowser.view.{v}"), r, tip);
if prefs.view == v {
ui.painter().rect_filled(r, 3.0, t.pressed);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(5.0), icon, if prefs.view == v { t.icon_active } else { t.icon });
if resp.clicked() {
exec(app, &ctx, "mediaBrowser.settings", json!({"view": v}));
}
x += 26.0;
}
// second row: Ingest (Project ▸ Ingest Settings) and Import as Image Sequence
let row2 = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 4.0), vec2(bar.width(), 20.0));
let mut ingest = app.session.project.settings.ingest.enabled;
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(row2).id_salt("mb-row2").layout(egui::Layout::left_to_right(egui::Align::Center)));
let r = c.checkbox(&mut ingest, "Ingest");
app.auto.add("mediaBrowser.ingest", r.rect, "Ingest");
if r.changed() {
exec(app, &ctx, "project.ingestSettings", json!({"enabled": ingest}));
}
let w = c.add(egui::Button::new(egui::RichText::new("⚙").size(12.0)).frame(false)).on_hover_text("Open Ingest Settings");
app.auto.add("mediaBrowser.ingestSettings", w.rect, "Open Ingest Settings");
if w.clicked() {
exec(app, &ctx, "project.ingestSettings", json!({}));
}
c.add_space(12.0);
let mut seq = prefs.import_as_image_sequence;
let r = c.checkbox(&mut seq, "Import as Image Sequence");
app.auto.add("mediaBrowser.importAsImageSequence", r.rect, "Import as Image Sequence");
if r.changed() {
exec(app, &ctx, "mediaBrowser.settings", json!({"importAsImageSequence": seq}));
}
// ---- tree | list
let body = Rect::from_min_max(pos2(rect.min.x, row2.max.y + 4.0), pos2(rect.max.x, rect.max.y - 24.0));
let tw = if app.ui.media_browser.tree_width <= 0.0 { (rect.width() * 0.32).clamp(140.0, 260.0) } else { app.ui.media_browser.tree_width };
let tree_r = Rect::from_min_max(body.min, pos2(body.min.x + tw, body.max.y));
let list_r = Rect::from_min_max(pos2(tree_r.max.x + 4.0, body.min.y), body.max);
// splitter
let split = Rect::from_min_max(pos2(tree_r.max.x, body.min.y), pos2(tree_r.max.x + 4.0, body.max.y));
let sresp = ui.interact(split, egui::Id::new("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
if sresp.dragged() {
app.ui.media_browser.tree_width = (tw + sresp.drag_delta().x).clamp(100.0, rect.width() * 0.7);
}
ui.painter().line_segment([split.center_top(), split.center_bottom()], Stroke::new(1.0, t.separator));
tree(app, ui, tree_r, &dir);
let entries = listing(app, &ctx, &dir, &prefs.file_types);
match entries {
Ok(entries) => {
if prefs.view == "thumbnails" {
thumbnails(app, ui, list_r, &entries);
} else {
list(app, ui, list_r, &entries, &prefs.columns);
}
let n = entries.iter().filter(|e| !e.is_dir).count();
let sel = app.session.browser.selection.len();
let status = if sel > 0 { format!("{sel} of {n} items selected") } else { format!("{n} items") };
ui.painter().text(pos2(list_r.min.x + 6.0, rect.max.y - 12.0), Align2::LEFT_CENTER, &status, Tokens::ui(11.0), t.text_dim);
app.auto.add("mediaBrowser.count", Rect::from_min_size(pos2(list_r.min.x, rect.max.y - 22.0), vec2(160.0, 20.0)), &status);
}
Err(e) => {
ui.painter().text(list_r.center(), Align2::CENTER_CENTER, format!("Cannot open {dir}: {e}"), Tokens::ui(11.5), t.text_dim);
}
}
if prefs.view == "thumbnails" {
let sr = Rect::from_min_size(pos2(rect.max.x - 130.0, rect.max.y - 20.0), vec2(120.0, 16.0));
let mut sz = prefs.thumbnail_size;
let resp = ui.put(sr, egui::Slider::new(&mut sz, 60.0..=320.0).show_value(false));
app.auto.add("mediaBrowser.thumbnailSize", sr, "Thumbnail size");
if sz != prefs.thumbnail_size {
app.session.prefs.media_browser.thumbnail_size = sz;
}
if resp.drag_stopped() || (resp.changed() && !resp.dragged()) {
exec(app, &ctx, "mediaBrowser.settings", json!({"thumbnailSize": sz}));
}
}
}
// ------------------------------------------------------------------------------------ tree
fn tree(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, dir: &str) {
let ctx = ui.ctx().clone();
let roots = app.session.execute("mediaBrowser.roots", json!({})).unwrap_or_default();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(4.0, 0.0))).id_salt("mb-tree"));
child.set_clip_rect(r.intersect(ui.clip_rect()));
let mut go: Option<String> = None;
egui::ScrollArea::vertical().id_salt("mb-tree-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
let sections: [(&str, &str, Icon); 4] = [
("favorites", "Favorites", Icon::Star),
("localDrives", "Local Drives", Icon::Drive),
("network", "Network", Icon::Network),
("recent", "Recent Directories", Icon::Clock),
];
for (key, title, icon) in sections {
let list: Vec<(String, String)> = roots[key]
.as_array()
.map(|a| a.iter().filter_map(|v| Some((v["name"].as_str()?.to_string(), v["path"].as_str()?.to_string()))).collect())
.unwrap_or_default();
let hr = ui.label(egui::RichText::new(title).size(11.0).color(app.tokens.text_dim).strong());
app.auto.add(&format!("mediaBrowser.tree.{key}"), hr.rect, title);
if list.is_empty() {
ui.label(egui::RichText::new(" (none)").size(10.5).color(app.tokens.text_faint));
}
for (name, path) in list {
let expandable = matches!(key, "localDrives" | "network" | "favorites");
node(app, ui, &ctx, &name, &path, icon, 0, expandable, dir, key, &mut go);
}
ui.add_space(6.0);
}
});
if let Some(p) = go {
exec(app, &ctx, "mediaBrowser.navigate", json!({"path": p}));
refresh(&ctx);
}
}
#[allow(clippy::too_many_arguments)]
fn node(
app: &mut FilmcraftApp,
ui: &mut egui::Ui,
ctx: &egui::Context,
name: &str,
path: &str,
icon: Icon,
depth: usize,
expandable: bool,
cur: &str,
section: &str,
go: &mut Option<String>,
) {
let t = app.tokens;
let open = app.ui.media_browser.expanded.iter().any(|p| p == path);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click());
if path == cur {
ui.painter().rect_filled(r, 2.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let x = r.min.x + 2.0 + depth as f32 * 12.0;
let tri = Rect::from_center_size(pos2(x + 5.0, r.center().y), vec2(12.0, 12.0));
if expandable {
icons::paint(ui.painter(), tri.shrink(2.0), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim);
}
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(13.0, 13.0)),
icon,
if icon == Icon::Folder { Color32::from_rgb(237, 150, 58) } else { t.icon },
);
ui.painter().with_clip_rect(r).text(pos2(x + 30.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(11.5), t.text);
let id = if depth == 0 { format!("mediaBrowser.tree.{section}.{name}") } else { format!("mediaBrowser.tree.dir.{path}") };
app.auto.add(&id, r, name);
if resp.clicked() {
let on_tri = expandable && resp.interact_pointer_pos().is_some_and(|p| p.x < x + 11.0);
if on_tri {
toggle(app, path, open);
} else {
*go = Some(path.to_string());
}
}
if resp.double_clicked() && expandable {
toggle(app, path, open);
}
resp.context_menu(|ui| {
let fav = app.session.prefs.media_browser.favorites.iter().any(|f| f == path);
let label = if fav { "Remove from Favorites" } else { "Add to Favorites" };
let b = ui.button(label);
app.auto.add("mediaBrowser.treeMenu.favorite", b.rect, label);
if b.clicked() {
exec(app, ctx, "mediaBrowser.favorite", json!({"path": path, "remove": fav}));
ui.close();
}
if ui.button("Import").clicked() {
exec(app, ctx, "mediaBrowser.import", json!({"paths": [path]}));
ui.close();
}
if section == "recent" && ui.button("Clear Recent Directories").clicked() {
exec(app, ctx, "mediaBrowser.clearRecent", json!({}));
ui.close();
}
});
if expandable && open {
let subs: Vec<Entry> = listing(app, ctx, path, "all").map(|v| v.iter().filter(|e| e.is_dir).cloned().collect()).unwrap_or_default();
for s in subs {
node(app, ui, ctx, &s.name, &s.path, Icon::Folder, depth + 1, true, cur, section, go);
}
}
}
fn toggle(app: &mut FilmcraftApp, path: &str, open: bool) {
let e = &mut app.ui.media_browser.expanded;
if open {
e.retain(|p| p != path);
} else {
e.push(path.to_string());
}
}
// ------------------------------------------------------------------------------------ entries
/// Click / double-click / drag / context menu of an entry.
fn entry_interactions(app: &mut FilmcraftApp, ui: &egui::Ui, resp: &egui::Response, e: &Entry, entries: &[Entry]) {
let ctx = ui.ctx().clone();
if resp.clicked() {
let mods = crate::panels::project_views::click_modifiers(ui);
let mut sel = app.session.browser.selection.clone();
if mods.command {
if let Some(p) = sel.iter().position(|x| *x == e.path) {
sel.remove(p);
} else {
sel.push(e.path.clone());
}
} else if mods.shift && !sel.is_empty() {
let a = entries.iter().position(|x| Some(&x.path) == sel.first()).unwrap_or(0);
let b = entries.iter().position(|x| x.path == e.path).unwrap_or(0);
sel = entries[a.min(b)..=a.max(b)].iter().map(|x| x.path.clone()).collect();
} else {
sel = vec![e.path.clone()];
}
exec(app, &ctx, "mediaBrowser.select", json!({"paths": sel}));
}
if resp.double_clicked() {
if e.is_dir {
exec(app, &ctx, "mediaBrowser.navigate", json!({"path": e.path}));
refresh(&ctx);
} else if matches!(e.kind, "project" | "caption") {
exec(app, &ctx, "mediaBrowser.import", json!({"paths": [e.path]}));
} else {
exec(app, &ctx, "mediaBrowser.openInSource", json!({"path": e.path}));
}
}
if resp.drag_started() && !e.is_dir {
start_file_drag(app, ui, e);
}
resp.context_menu(|ui| {
let paths: Vec<String> = if app.session.browser.selection.contains(&e.path) { app.session.browser.selection.clone() } else { vec![e.path.clone()] };
let b = ui.button("Import");
app.auto.add("mediaBrowser.entryMenu.import", b.rect, "Import");
if b.clicked() {
exec(app, &ctx, "mediaBrowser.import", json!({"paths": paths, "imageSequence": false}));
ui.close();
}
if !e.is_dir {
let b = ui.button("Open In Source Monitor");
app.auto.add("mediaBrowser.entryMenu.openInSource", b.rect, "Open In Source Monitor");
if b.clicked() {
exec(app, &ctx, "mediaBrowser.openInSource", json!({"path": e.path}));
ui.close();
}
}
if e.numbered {
let b = ui.button("Import as Image Sequence");
app.auto.add("mediaBrowser.entryMenu.importSequence", b.rect, "Import as Image Sequence");
if b.clicked() {
exec(app, &ctx, "mediaBrowser.import", json!({"paths": [e.path], "imageSequence": true}));
ui.close();
}
}
if e.is_dir {
let fav = app.session.prefs.media_browser.favorites.contains(&e.path);
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
exec(app, &ctx, "mediaBrowser.favorite", json!({"path": e.path, "remove": fav}));
ui.close();
}
}
if ui.button("Reveal in Finder").clicked() {
let _ = crate::panels::menu_dialogs::open_path(app, &ctx, &e.path, true);
ui.close();
}
});
}
/// Dragging a file imports it (the selection when the file is in it) and carries the new item, so
/// the Project panel, Timeline and monitors accept it; a drop elsewhere undoes the import.
fn start_file_drag(app: &mut FilmcraftApp, ui: &egui::Ui, e: &Entry) {
let paths: Vec<String> = if app.session.browser.selection.contains(&e.path) { app.session.browser.selection.clone() } else { vec![e.path.clone()] };
// files already in the project are dragged as they are
if let Some(i) = mb::item_for_path(&app.session, &e.path) {
crate::panels::start_drag_item(ui, i);
return;
}
let before = app.session.history.undo.len();
let Ok(r) = app.session.execute("mediaBrowser.import", json!({"paths": paths})) else { return };
let items: Vec<u64> = r["items"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
if let Some(first) = items.first() {
crate::panels::start_drag_item(ui, ItemId(*first));
let pos = ui.ctx().pointer_latest_pos().unwrap_or_default();
let after = app.session.history.undo.len();
with_cache(ui.ctx(), |c| c.drag = Some((items, after.max(before), pos)));
}
}
/// After a file drag ends: keep the import when it landed on a panel that takes items.
fn finish_drag(app: &mut FilmcraftApp, ui: &egui::Ui) {
let Some((items, hist, _)) = with_cache(ui.ctx(), |c| c.drag.clone()) else { return };
if crate::panels::dragged_project_item(ui).is_some() {
if let Some(p) = ui.ctx().pointer_latest_pos() {
with_cache(ui.ctx(), |c| {
if let Some(d) = c.drag.as_mut() {
d.2 = p;
}
});
}
return;
}
let pos = with_cache(ui.ctx(), |c| c.drag.take().map(|d| d.2)).unwrap_or_default();
let targets = ["panel.Project", "panel.Timeline", "panel.Source", "panel.Program"];
let landed =
targets.iter().any(|t| app.auto.find(t).is_some_and(|e| Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])).contains(pos)));
let used_elsewhere = app.session.history.undo.len() > hist;
if !landed && !used_elsewhere && items.iter().all(|i| app.session.project.item(ItemId(*i)).is_some()) {
// dropped nowhere: take the import back
let _ = app.session.execute("edit.undo", json!({}));
}
}
fn list(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry], columns: &[String]) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let widths: Vec<f32> = columns.iter().map(|c| if c == "Name" { 220.0 } else { 110.0 }).collect();
let total: f32 = widths.iter().sum::<f32>() + 8.0;
let header = Rect::from_min_size(r.min, vec2(r.width(), 20.0));
let body = Rect::from_min_max(pos2(r.min.x, header.max.y), r.max);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("mb-list"));
child.set_clip_rect(body.intersect(ui.clip_rect()));
let needs_probe =
columns.iter().any(|c| matches!(c.as_str(), "Frame Rate" | "Media Duration" | "Video Info" | "Audio Info" | "Video Codec" | "Audio Codec"));
let mut probes_left = 2;
let out = egui::ScrollArea::both().id_salt("mb-list-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
ui.set_min_width(total.max(body.width()));
for (i, e) in entries.iter().enumerate() {
let (rr, resp) = ui.allocate_exact_size(vec2(total.max(body.width()), ROW_H), Sense::click_and_drag());
let sel = app.session.browser.selection.contains(&e.path);
if sel {
ui.painter().rect_filled(rr, 0.0, t.row_selected);
} else if i % 2 == 1 {
ui.painter().rect_filled(rr, 0.0, t.row_alt);
}
let info = if needs_probe && !e.is_dir {
let cached = app.session.browser.probes.contains_key(&e.path);
if cached || probes_left > 0 {
if !cached {
probes_left -= 1;
ctx.request_repaint();
}
mb::probe(&mut app.session, &e.path)
} else {
None
}
} else {
None
};
let mut x = rr.min.x + 4.0;
for (c, w) in columns.iter().zip(&widths) {
let cr = Rect::from_min_size(pos2(x, rr.min.y), vec2(*w, ROW_H));
let p = ui.painter().with_clip_rect(cr.shrink2(vec2(2.0, 0.0)).intersect(ui.clip_rect()));
if c == "Name" {
let icon = match e.kind {
"folder" => Icon::Folder,
"audio" => Icon::Audio,
"image" => Icon::Image,
"video" => Icon::Film,
"caption" => Icon::Captions,
_ => Icon::Sequence,
};
let col = if e.is_dir { Color32::from_rgb(237, 150, 58) } else { t.icon };
icons::paint(&p, Rect::from_center_size(pos2(cr.min.x + 9.0, cr.center().y), vec2(14.0, 14.0)), icon, col);
p.text(pos2(cr.min.x + 22.0, cr.center().y), Align2::LEFT_CENTER, &e.name, Tokens::ui(12.0), t.text);
} else {
p.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, mb::column_text(e, c, info.as_ref()), Tokens::ui(11.5), t.text_dim);
}
x += w;
}
app.auto.add(&format!("mediaBrowser.entry.{}", e.name), rr.intersect(ui.clip_rect()), &e.name);
entry_interactions(app, ui, &resp, e, entries);
}
let rest = ui.available_rect_before_wrap();
let resp = ui.allocate_rect(Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(30.0))), Sense::click());
if resp.clicked() {
exec(app, &ctx, "mediaBrowser.select", json!({"paths": []}));
}
});
let hp = ui.painter().with_clip_rect(header.intersect(ui.clip_rect()));
hp.rect_filled(header, 0.0, t.panel_bg);
hp.line_segment([header.left_bottom(), header.right_bottom()], Stroke::new(1.0, t.separator));
let mut x = header.min.x + 4.0 - out.state.offset.x;
for (c, w) in columns.iter().zip(&widths) {
let cr = Rect::from_min_size(pos2(x, header.min.y), vec2(*w, 20.0));
hp.text(pos2(cr.min.x + 4.0, cr.center().y), Align2::LEFT_CENTER, c, Tokens::ui(11.0), t.text_dim);
if cr.intersects(header) {
let resp = ui.interact(cr.intersect(header), egui::Id::new(("mb-col", c)), Sense::click());
app.auto.add(&format!("mediaBrowser.header.{c}"), cr.intersect(header), c);
resp.context_menu(|ui| {
if ui.button("Edit Columns…").clicked() {
app.ui.media_browser.edit_columns = Some(columns.to_vec());
ui.close();
}
});
}
x += w;
}
}
/// A preview item for a file's thumbnail (files not in the project), or the project item.
fn preview_item(
app: &mut FilmcraftApp,
ctx: &egui::Context,
e: &Entry,
) -> Option<(Arc<Project>, ItemId, filmcraft_time::Tick, filmcraft_time::FrameRate, u32)> {
if let Some(i) = mb::item_for_path(&app.session, &e.path) {
let it = app.session.project.item(i)?;
let w = it.as_media()?.info.video.as_ref()?.width;
return Some((app.session.project.clone(), i, it.duration(), it.frame_rate(), w));
}
let info = app.session.browser.probes.get(&e.path).cloned().flatten()?;
let v = info.video.as_ref()?;
let (rate, w, dur) = (v.frame_rate, v.width, info.duration);
let (proj, id) = with_cache(ctx, |c| {
let n = c.preview_ids.len() as u64;
let id = *c.preview_ids.entry(e.path.clone()).or_insert(PREVIEW_BASE + n);
if c.preview.item(ItemId(id)).is_none() {
let mut p = (*c.preview).clone();
p.items.insert(
ItemId(id),
ProjectItem {
id: ItemId(id),
name: e.name.clone(),
label: Label::Iris,
kind: ItemKind::Media(MediaClip {
media: MediaRef::File { path: e.path.clone() },
info: info.clone(),
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
metadata: Default::default(),
created: 0,
split: Default::default(),
},
);
c.preview = Arc::new(p);
}
(c.preview.clone(), ItemId(id))
});
Some((proj, id, dur, rate, w))
}
fn thumbnails(app: &mut FilmcraftApp, ui: &mut egui::Ui, r: Rect, entries: &[Entry]) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let size = app.session.prefs.media_browser.thumbnail_size;
let hover_scrub = app.session.prefs.media_browser.hover_scrub;
let th_h = size * 9.0 / 16.0;
let cell = vec2(size + 16.0, th_h + 30.0);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r).id_salt("mb-thumbs"));
child.set_clip_rect(r.intersect(ui.clip_rect()));
let per_row = ((r.width() - 8.0) / cell.x).floor().max(1.0) as usize;
let mut probes_left = 2;
app.ui.media_browser.hover = None;
egui::ScrollArea::vertical().id_salt("mb-thumb-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
for chunk in entries.chunks(per_row) {
let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover());
for (k, e) in chunk.iter().enumerate() {
let tr = Rect::from_min_size(pos2(row.min.x + 8.0 + k as f32 * cell.x, row.min.y + 6.0), vec2(size, th_h));
let resp = ui.interact(tr.expand(2.0), egui::Id::new(("mb-thumb", &e.path)), Sense::click_and_drag());
let sel = app.session.browser.selection.contains(&e.path);
ui.painter().rect_filled(tr, 3.0, Color32::from_gray(14));
if sel {
ui.painter().rect_stroke(tr, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Outside);
}
if e.is_dir {
icons::paint(
ui.painter(),
Rect::from_center_size(tr.center(), vec2(th_h * 0.6, th_h * 0.6)),
Icon::Folder,
Color32::from_rgb(237, 150, 58),
);
} else {
let visible = ui.is_rect_visible(tr);
if visible && matches!(e.kind, "video" | "image") && !app.session.browser.probes.contains_key(&e.path) && probes_left > 0 {
probes_left -= 1;
mb::probe(&mut app.session, &e.path);
ctx.request_repaint();
}
let frac = if hover_scrub && resp.hovered() {
ctx.pointer_hover_pos().map(|p| ((p.x - tr.min.x) / tr.width()).clamp(0.0, 1.0)).unwrap_or(0.3)
} else {
0.3
};
if resp.hovered() {
app.ui.media_browser.hover = Some((e.path.clone(), frac));
}
let drawn = visible && draw_preview(app, ui, &ctx, e, tr, frac);
if !drawn {
let icon = match e.kind {
"audio" => Icon::Audio,
"image" => Icon::Image,
"caption" => Icon::Captions,
"project" => Icon::Sequence,
_ => Icon::Film,
};
icons::paint(ui.painter(), Rect::from_center_size(tr.center(), vec2(26.0, 26.0)), icon, t.text_faint);
}
if hover_scrub && resp.hovered() {
let x = tr.min.x + tr.width() * frac;
ui.painter().line_segment([pos2(x, tr.min.y), pos2(x, tr.max.y)], Stroke::new(1.0, Color32::from_rgb(0x5a, 0x9b, 0xf0)));
}
}
ui.painter().with_clip_rect(Rect::from_min_max(pos2(tr.min.x, tr.max.y), pos2(tr.max.x, tr.max.y + 22.0))).text(
pos2(tr.min.x, tr.max.y + 11.0),
Align2::LEFT_CENTER,
&e.name,
Tokens::ui(11.5),
t.text,
);
app.auto.add(&format!("mediaBrowser.entry.{}", e.name), tr, &e.name);
entry_interactions(app, ui, &resp, e, entries);
}
}
});
}
fn draw_preview(app: &mut FilmcraftApp, ui: &egui::Ui, ctx: &egui::Context, e: &Entry, r: Rect, frac: f32) -> bool {
let Some((proj, id, dur, rate, src_w)) = preview_item(app, ctx, e) else { return false };
let t = filmcraft_time::Tick((dur.0 as f64 * frac as f64) as i64);
let frame = rate.frame_at(crate::panels::project::quantize(t));
let width = (r.width() as u32).clamp(96, 320);
let rev = if proj.item(id).is_some() && id.0 < PREVIEW_BASE { app.item_revision(id) } else { 0 };
let key = FrameKey { target: Target::Item(id), frame, size: width, revision: rev };
let name = format!("mbthumb-{}-{}-{}", id.0, frame, width);
let tex = if let Some(img) = app.frames.get(&key) {
let tid = app.texture_for(ctx, &name, key, &img);
Some((tid, vec2(img.w as f32, img.h as f32)))
} else {
app.frames.request(key, rate.tick_of(frame), width as f32 / src_w.max(1) as f32, &proj, 40);
app.texture_existing(&name)
};
match tex {
Some((tid, sz)) => {
ui.painter().image(tid, crate::panels::monitor::fit(r, sz.x, sz.y), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
true
}
None => false,
}
}
// ------------------------------------------------------------------------------------ menu, dialog
/// The Media Browser's ≡ menu. Returns true to close it.
pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool {
let ctx = ui.ctx().clone();
let prefs = app.session.prefs.media_browser.clone();
let dir = mb::current_dir(&app.session);
let has_sel = !app.session.browser.selection.is_empty();
let mut close = false;
let item = |app: &mut FilmcraftApp, ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, checked: Option<bool>, shortcut: &str| -> bool {
let b = match checked {
Some(c) => egui::Button::selectable(c, label),
None => egui::Button::new(label),
};
let r = ui.add_enabled(enabled, b.shortcut_text(shortcut));
app.auto.add(&format!("mediaBrowser.menu.{id}"), r.rect, label);
r.clicked()
};
if item(app, ui, "editColumns", "Edit Columns…", true, None, "") {
app.ui.media_browser.edit_columns = Some(prefs.columns.clone());
close = true;
}
if item(app, ui, "importAsImageSequence", "Import as Image Sequence", true, Some(prefs.import_as_image_sequence), "") {
exec(app, &ctx, "mediaBrowser.settings", json!({"importAsImageSequence": !prefs.import_as_image_sequence}));
close = true;
}
if item(app, ui, "hoverScrub", "Hover Scrub", true, Some(prefs.hover_scrub), "") {
exec(app, &ctx, "mediaBrowser.settings", json!({"hoverScrub": !prefs.hover_scrub}));
close = true;
}
item(app, ui, "newPanel", "New Media Browser Panel", false, None, "");
let mprefs = app.session.prefs.media.clone();
if item(app, ui, "createFolderForImportedProjects", "Create Folder For Imported Projects", true, Some(mprefs.create_folder_for_imported_projects), "") {
let mut p = app.session.prefs.clone();
p.media.create_folder_for_imported_projects = !mprefs.create_folder_for_imported_projects;
let _ = app.session.set_prefs(p);
close = true;
}
if item(app, ui, "allowDuplicateMedia", "Allow Duplicate Media During Project Import", true, Some(mprefs.allow_duplicate_media), "") {
let mut p = app.session.prefs.clone();
p.media.allow_duplicate_media = !mprefs.allow_duplicate_media;
let _ = app.session.set_prefs(p);
close = true;
}
if item(app, ui, "refresh", "Refresh", true, None, "") {
refresh(&ctx);
app.session.browser.probes.clear();
close = true;
}
ui.separator();
let fav = prefs.favorites.contains(&dir);
if item(app, ui, "favorite", if fav { "Remove from Favorites" } else { "Add to Favorites" }, true, None, "") {
exec(app, &ctx, "mediaBrowser.favorite", json!({"remove": fav}));
close = true;
}
if item(app, ui, "clearRecent", "Clear Recent Directories", !prefs.recent.is_empty(), None, "") {
exec(app, &ctx, "mediaBrowser.clearRecent", json!({}));
close = true;
}
if item(app, ui, "import", "Import", has_sel, None, "") {
exec(app, &ctx, "mediaBrowser.import", json!({}));
close = true;
}
if item(app, ui, "openInSource", "Open In Source Monitor", has_sel, None, "Shift+O") {
exec(app, &ctx, "mediaBrowser.openInSource", json!({}));
close = true;
}
close
}
/// Edit Columns… dialog.
pub fn dialogs(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut cols) = app.ui.media_browser.edit_columns.clone() else { return };
let mut close = false;
let mut ok = false;
egui::Window::new("Edit Columns").id(egui::Id::new("mb-edit-columns")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(
ctx,
|ui| {
let mut mv: Option<(usize, isize)> = None;
for c in mb::ALL_COLUMNS {
ui.horizontal(|ui| {
let mut on = cols.iter().any(|x| x == c);
let r = ui.add_enabled(c != "Name", egui::Checkbox::new(&mut on, c));
app.auto.add(&format!("mediaBrowser.columns.{c}"), r.rect, c);
if r.changed() {
if on {
cols.push(c.to_string());
} else {
cols.retain(|x| x != c);
}
}
if let Some(i) = cols.iter().position(|x| x == c).filter(|i| *i > 0) {
if ui.add_enabled(i > 1, egui::Button::new("▲").small()).clicked() {
mv = Some((i, -1));
}
if ui.add_enabled(i + 1 < cols.len(), egui::Button::new("▼").small()).clicked() {
mv = Some((i, 1));
}
}
});
}
if let Some((i, d)) = mv {
cols.swap(i, (i as isize + d) as usize);
}
ui.separator();
ui.horizontal(|ui| {
let r = ui.button("Cancel");
app.auto.add("mediaBrowser.columns.cancel", r.rect, "Cancel");
close |= r.clicked();
let r = ui.button("OK");
app.auto.add("mediaBrowser.columns.ok", r.rect, "OK");
ok = r.clicked();
});
},
);
if ok {
let r = app.session.execute("mediaBrowser.settings", json!({"columns": cols}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
close = true;
}
app.ui.media_browser.edit_columns = if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { None } else { Some(cols) };
}
+2 -12
View File
@@ -33,15 +33,13 @@ pub struct Extras {
pub dialog: Option<ClipDialogDraft>,
/// View ▸ Dynamic Audio Waveforms: logarithmic (dB) waveform display; off = linear amplitude.
pub dynamic_waveforms: bool,
/// Files selected in the Media Browser (File ▸ Import from Media Browser).
pub media_browser_selection: Vec<String>,
/// Paths handed to the operating system's opener (Edit Original, Reveal Log Files), newest last.
pub opened: Vec<String>,
}
impl Default for Extras {
fn default() -> Self {
Extras { dialog: None, dynamic_waveforms: true, media_browser_selection: Vec::new(), opened: Vec::new() }
Extras { dialog: None, dynamic_waveforms: true, opened: Vec::new() }
}
}
@@ -201,14 +199,6 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val
}
return Some(r);
}
"file.importFromMediaBrowser" if params.get("paths").is_none() => {
let paths = app.ui.extras.media_browser_selection.clone();
if paths.is_empty() {
return Some(Err("select files in the Media Browser".into()));
}
let seq = params.get("imageSequence").and_then(Value::as_bool).unwrap_or(false);
return Some(app.session.execute(id, json!({"paths": paths, "imageSequence": seq})).map_err(|e| e.to_string()));
}
"file.exportSelectionProject" | "file.exportAle" if params.get("path").is_none() => {
if let Err(e) = enabled(app, id) {
return Some(Err(e));
@@ -874,7 +864,7 @@ pub fn search_bin_rows(
crate::theme::Tokens::ui(12.0),
t.text,
);
app.auto.add(&format!("project.searchBin.{}", sb.id.0), r, &sb.name);
app.auto.add(&format!("project.searchBin.{}", sb.id.0), r.intersect(ui.clip_rect()), &sb.name);
if resp.clicked() {
if open {
app.ui.expanded_bins.retain(|b| *b != sb.id.0);
+1 -102
View File
@@ -1,4 +1,4 @@
//! Smaller panels: History, Markers, Info, Media Browser.
//! Smaller panels: History, Markers, Info (the Media Browser is `panels::media_browser`).
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use filmcraft_time::{TimeDisplay, format_time};
@@ -179,104 +179,3 @@ pub fn info(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.separator();
line(ui, "UI:", format!("{:.0} fps · {} frames queued", app.fps, app.frames.queue_len()));
}
pub fn media_browser(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink(8.0)));
let ui = &mut child;
let dir_id = egui::Id::new("media-browser-dir");
#[cfg(not(target_arch = "wasm32"))]
{
let mut dir: String =
ui.data(|d| d.get_temp(dir_id)).unwrap_or_else(|| std::env::var("HOME").map(|h| format!("{h}/Movies")).unwrap_or_else(|_| "/".into()));
ui.horizontal(|ui| {
if ui.button("⬆").clicked()
&& let Some(p) = std::path::Path::new(&dir).parent()
{
dir = p.to_string_lossy().to_string();
}
ui.add(egui::TextEdit::singleline(&mut dir).desired_width(ui.available_width()));
});
let mut entries: Vec<(bool, String)> = std::fs::read_dir(&dir)
.map(|rd| {
rd.flatten()
.filter(|e| !e.file_name().to_string_lossy().starts_with('.'))
.map(|e| (e.path().is_dir(), e.file_name().to_string_lossy().to_string()))
.collect()
})
.unwrap_or_default();
entries.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase())));
let mut import = Vec::new();
let mut import_sequence = None;
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
for (is_dir, name) in &entries {
let path = format!("{dir}/{name}");
let ok = *is_dir || filmcraft_media::is_importable(std::path::Path::new(&path));
if !ok {
continue;
}
let sel = app.ui.extras.media_browser_selection.contains(&path);
let resp = ui.selectable_label(sel, format!("{} {name}", if *is_dir { "📁" } else { "🎞" }));
app.auto.add(&format!("mediaBrowser.entry.{name}"), resp.rect, name);
if resp.clicked() && !*is_dir {
let multi = ui.input(|i| i.modifiers.command);
let s = &mut app.ui.extras.media_browser_selection;
if multi {
if sel {
s.retain(|p| *p != path);
} else {
s.push(path.clone());
}
} else {
*s = vec![path.clone()];
}
}
if resp.double_clicked() {
if *is_dir {
dir = path.clone();
} else {
import.push(path.clone());
}
}
if !*is_dir {
resp.context_menu(|ui| {
let b = ui.button("Import");
app.auto.add(&format!("mediaBrowser.import.{name}"), b.rect, "Import");
if b.clicked() {
import.push(path.clone());
ui.close();
}
// numbered stills: the sequence starting at this frame, as one clip
if filmcraft_media::sequence::Numbered::parse(&path).is_some() {
let b = ui.button("Import as Image Sequence");
app.auto.add(&format!("mediaBrowser.importSequence.{name}"), b.rect, "Import as Image Sequence");
if b.clicked() {
import_sequence = Some(path.clone());
ui.close();
}
}
});
}
}
});
ui.data_mut(|d| d.insert_temp(dir_id, dir));
if !import.is_empty() {
let r = app.session.execute("file.import", serde_json::json!({"paths": import}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
if let Some(first) = import_sequence {
let r = app.session.execute("file.importFromMediaBrowser", serde_json::json!({"paths": [first], "imageSequence": true}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = dir_id;
ui.label(egui::RichText::new("Use File ▸ Import, or drop files on the window.").color(t.text_dim));
}
let _ = t;
}
+14 -1
View File
@@ -16,6 +16,7 @@ pub mod import_mode;
pub mod keyboard;
pub mod lumetri;
pub mod masks;
pub mod media_browser;
pub mod media_dialogs;
pub mod menu_dialogs;
pub mod metadata;
@@ -28,6 +29,8 @@ pub mod multicam;
pub mod panel_state;
pub mod presets;
pub mod project;
pub mod project_dialogs;
pub mod project_views;
pub mod reference;
pub mod remix;
pub mod scopes;
@@ -67,7 +70,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect)
PanelKind::History => misc::history(app, ui, rect),
PanelKind::Markers => misc::markers(app, ui, rect),
PanelKind::Info => misc::info(app, ui, rect),
PanelKind::MediaBrowser => misc::media_browser(app, ui, rect),
PanelKind::MediaBrowser => media_browser::show(app, ui, rect),
PanelKind::AudioTrackMixer => mixer::track_mixer(app, ui, rect),
PanelKind::AudioClipMixer => mixer::clip_mixer(app, ui, rect),
PanelKind::LumetriScopes => scopes::show(app, ui, rect),
@@ -159,6 +162,8 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) {
/// The panel "≡" menu.
pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
drag_ghost(app, ui);
// bins opened in new windows (Project panel)
project::floating(app, ui.ctx());
let id = egui::Id::new("panel-menu");
let Some((p, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return };
let mut close = false;
@@ -183,6 +188,14 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) {
ui.checkbox(&mut app.ui.timeline.show_thumbnails, "Video Thumbnails");
ui.checkbox(&mut app.ui.timeline.show_waveforms, "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);
}
});
});
if close || (area.response.clicked_elsewhere() && ui.input(|i| i.pointer.any_pressed())) {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,253 @@
//! Project panel dialogs: Metadata Display… (choose and order the List view columns from every
//! metadata field), Save As New View Preset, Manage Saved View Presets, Freeform View Options…
//! and Save Arrangement…. Drafts live in `UiState::project_panel.dialog` (serde, so agents fill
//! them with `ui.set`); OK runs one engine command.
use egui::Align2;
use serde_json::{Value, json};
use crate::FilmcraftApp;
use crate::panels::project::ProjectDialog;
fn push(app: &mut FilmcraftApp, id: &str, r: &egui::Response, label: &str) {
app.auto.add(id, r.rect, label);
}
pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) {
let Some(mut d) = app.ui.project_panel.dialog.clone() else { return };
let mut close = false;
let mut run: Option<(String, Value)> = None;
let title = match &d {
ProjectDialog::MetadataDisplay { .. } => "Metadata Display",
ProjectDialog::SavePresetAs { .. } => "Save As New View Preset",
ProjectDialog::ManagePresets { .. } => "Manage Saved View Presets",
ProjectDialog::FreeformOptions { .. } => "Freeform View Options",
ProjectDialog::SaveArrangement { .. } => "Save Arrangement",
};
let mut win = egui::Window::new(title).id(egui::Id::new("project-dialog")).collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]);
// a fixed size keeps the two-list dialog from shifting while its lists settle
if matches!(d, ProjectDialog::MetadataDisplay { .. }) {
win = win.fixed_size(egui::vec2(480.0, 470.0));
}
win.show(ctx, |ui| {
match &mut d {
ProjectDialog::MetadataDisplay { columns, filter } => metadata_display(app, ui, columns, filter, &mut run),
ProjectDialog::SavePresetAs { name } => {
ui.horizontal(|ui| {
ui.label("Name:");
let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
push(app, "projectDialog.name", &r, "Name");
});
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
run = Some(("project.viewPreset.saveAs".into(), json!({"name": name})));
}
ui.label(egui::RichText::new("Saves the view, columns, sort, thumbnail and font size.").weak().size(11.0));
if ok_cancel(app, ui, &mut close) {
run = Some(("project.viewPreset.saveAs".into(), json!({"name": name})));
}
}
ProjectDialog::ManagePresets { selected, name } => manage_presets(app, ui, selected, name, &mut run, &mut close),
ProjectDialog::FreeformOptions { options } => {
egui::Grid::new("ff-options").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
ui.label("Grid spacing:");
let r = ui.add(egui::DragValue::new(&mut options.grid).range(4.0..=200.0).suffix(" pt"));
push(app, "projectDialog.grid", &r, "Grid spacing");
ui.end_row();
ui.label("Default clip size:");
let r = ui.add(egui::DragValue::new(&mut options.card_size).range(48.0..=480.0).suffix(" pt"));
push(app, "projectDialog.cardSize", &r, "Default clip size");
ui.end_row();
});
for (id, label, v) in [
("snap", "Snap to grid", &mut options.snap),
("showNames", "Show clip names", &mut options.show_names),
("showDurations", "Show durations", &mut options.show_durations),
] {
let r = ui.checkbox(v, label);
push(app, &format!("projectDialog.{id}"), &r, label);
}
if ok_cancel(app, ui, &mut close) {
run = Some(("project.freeform.options".into(), json!({"grid": options.grid, "snap": options.snap, "showNames": options.show_names, "showDurations": options.show_durations, "cardSize": options.card_size})));
}
}
ProjectDialog::SaveArrangement { name, bin } => {
ui.horizontal(|ui| {
ui.label("Name:");
let r = ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
push(app, "projectDialog.name", &r, "Name");
});
if ok_cancel(app, ui, &mut close) && !name.trim().is_empty() {
run = Some(("project.freeform.saveArrangement".into(), json!({"name": name, "bin": bin})));
}
}
}
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
if let Some((cmd, p)) = run {
match app.session.execute(&cmd, p) {
Ok(_) => close = !matches!(d, ProjectDialog::ManagePresets { .. }),
Err(e) => app.ui.status = e.to_string(),
}
// Manage keeps the dialog open: refresh its name field
if let ProjectDialog::ManagePresets { selected, name } = &mut d {
*name = app.session.prefs.project_panel.presets.get(*selected).cloned().flatten().map(|p| p.name).unwrap_or_default();
}
}
if close {
app.ui.project_panel.dialog = None;
} else if app.ui.project_panel.dialog.is_some() {
app.ui.project_panel.dialog = Some(d);
}
}
/// OK / Cancel row; true when OK was clicked.
fn ok_cancel(app: &mut FilmcraftApp, ui: &mut egui::Ui, close: &mut bool) -> bool {
ui.separator();
let mut ok = false;
ui.horizontal(|ui| {
let r = ui.button("Cancel");
push(app, "projectDialog.cancel", &r, "Cancel");
if r.clicked() {
*close = true;
}
let r = ui.button("OK");
push(app, "projectDialog.ok", &r, "OK");
ok = r.clicked();
});
ok
}
fn metadata_display(app: &mut FilmcraftApp, ui: &mut egui::Ui, columns: &mut Vec<String>, filter: &mut String, run: &mut Option<(String, Value)>) {
let all = filmcraft_engine::project_panel::all_columns(&app.session.project);
ui.horizontal(|ui| {
ui.label("Show:");
let r = ui.add(egui::TextEdit::singleline(filter).hint_text("Search fields").desired_width(220.0));
push(app, "projectDialog.filter", &r, "Search fields");
});
ui.add_space(4.0);
let f = filter.to_ascii_lowercase();
ui.horizontal_top(|ui| {
// every field, checked when shown
ui.vertical(|ui| {
ui.label(egui::RichText::new("Project Metadata").strong());
egui::ScrollArea::vertical().id_salt("md-all").max_height(340.0).min_scrolled_height(340.0).show(ui, |ui| {
ui.set_min_width(220.0);
for c in all.iter().filter(|c| f.is_empty() || c.to_ascii_lowercase().contains(&f)) {
let mut on = columns.contains(c);
let name_col = c == "Name";
let r = ui.add_enabled(!name_col, egui::Checkbox::new(&mut on, c.as_str()));
push(app, &format!("projectDialog.field.{c}"), &r, c);
if r.changed() {
if on {
columns.push(c.clone());
} else {
columns.retain(|x| x != c);
}
}
}
});
ui.horizontal(|ui| {
let r = ui.button("Add Property…");
push(app, "projectDialog.addProperty", &r, "Add Property…");
if r.clicked() && !filter.trim().is_empty() && !columns.iter().any(|c| c.eq_ignore_ascii_case(filter.trim())) {
// a custom metadata field (typed in the search box)
columns.push(filter.trim().to_string());
}
});
});
ui.separator();
// the shown columns, in order
ui.vertical(|ui| {
ui.label(egui::RichText::new("Column order").strong());
egui::ScrollArea::vertical().id_salt("md-order").max_height(340.0).min_scrolled_height(340.0).show(ui, |ui| {
ui.set_min_width(200.0);
let mut mv: Option<(usize, isize)> = None;
for (i, c) in columns.iter().enumerate() {
ui.horizontal(|ui| {
let up = ui.add_enabled(i > 1, egui::Button::new("▲").small());
push(app, &format!("projectDialog.up.{c}"), &up, "Move up");
let down = ui.add_enabled(i > 0 && i + 1 < columns.len(), egui::Button::new("▼").small());
push(app, &format!("projectDialog.down.{c}"), &down, "Move down");
ui.label(c);
if up.clicked() {
mv = Some((i, -1));
}
if down.clicked() {
mv = Some((i, 1));
}
});
}
if let Some((i, d)) = mv {
let j = (i as isize + d) as usize;
columns.swap(i, j);
}
});
});
});
let mut close = false;
if ok_cancel(app, ui, &mut close) {
*run = Some(("project.columns.set".into(), json!({"columns": columns})));
}
if close {
app.ui.project_panel.dialog = None;
}
}
fn manage_presets(app: &mut FilmcraftApp, ui: &mut egui::Ui, selected: &mut usize, name: &mut String, run: &mut Option<(String, Value)>, close: &mut bool) {
let presets = app.session.prefs.project_panel.presets.clone();
egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| {
ui.set_min_width(300.0);
for i in 0..filmcraft_engine::project_panel::PRESET_SLOTS {
let p = presets.get(i).cloned().flatten();
let label = match &p {
Some(p) => format!("{}. {}", i + 1, p.name),
None => format!("{}. (empty)", i + 1),
};
let r = ui.selectable_label(*selected == i, label.clone());
push(app, &format!("projectDialog.preset.{}", i + 1), &r, &label);
if r.clicked() {
*selected = i;
*name = p.map(|p| p.name).unwrap_or_default();
}
}
});
let filled = presets.get(*selected).is_some_and(Option::is_some);
ui.horizontal(|ui| {
ui.label("Name:");
let r = ui.add_enabled(filled, egui::TextEdit::singleline(name).desired_width(200.0));
push(app, "projectDialog.name", &r, "Name");
let b = ui.add_enabled(filled, egui::Button::new("Rename"));
push(app, "projectDialog.rename", &b, "Rename");
if b.clicked() {
*run = Some(("project.viewPreset.rename".into(), json!({"slot": *selected + 1, "name": name})));
}
});
ui.horizontal(|ui| {
let b = ui.add_enabled(filled, egui::Button::new("Restore"));
push(app, "projectDialog.restore", &b, "Restore");
if b.clicked() {
*run = Some(("project.viewPreset.restore".into(), json!({"slot": *selected + 1})));
}
let b = ui.add_enabled(filled, egui::Button::new("Delete"));
push(app, "projectDialog.delete", &b, "Delete");
if b.clicked() {
*run = Some(("project.viewPreset.delete".into(), json!({"slot": *selected + 1})));
}
let b = ui.button("Overwrite with Current View");
push(app, "projectDialog.overwrite", &b, "Overwrite with Current View");
if b.clicked() {
*run = Some((
"project.viewPreset.saveAs".into(),
json!({"slot": *selected + 1, "name": if name.trim().is_empty() { Value::Null } else { json!(name) }}),
));
}
});
ui.separator();
let r = ui.button("Done");
push(app, "projectDialog.ok", &r, "Done");
if r.clicked() {
*close = true;
}
}
File diff suppressed because it is too large. Load diff
+9 -12
View File
@@ -330,14 +330,6 @@ pub enum GuideDialog {
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProjectView {
#[default]
List,
Icon,
Freeform,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UiState {
pub tool: Tool,
@@ -349,10 +341,8 @@ pub struct UiState {
pub timeline: TimelineView,
pub program: MonitorView,
pub source: MonitorView,
pub project_view: ProjectView,
pub project_search: String,
pub effects_search: String,
pub icon_size: f32,
/// Expanded bins in the project list view.
pub expanded_bins: Vec<u64>,
/// Expanded folders in the Effects panel.
@@ -460,6 +450,13 @@ pub struct UiState {
/// `panels::keyboard`.
#[serde(default)]
pub keys: crate::panels::keyboard::KeysState,
/// Project panel: bin shown in place, bin tabs / windows, inline rename, dialogs, hover scrub
/// (view settings and columns are engine preferences). See `panels::project`.
#[serde(default)]
pub project_panel: crate::panels::project::ProjectPanelUi,
/// Media Browser view state (tree, path field, Edit Columns…). See `panels::media_browser`.
#[serde(default)]
pub media_browser: crate::panels::media_browser::MediaBrowserUi,
}
/// An open Edit / Clip / File menu dialog: the engine command it runs on OK and the parameters
@@ -701,10 +698,8 @@ impl Default for UiState {
timeline: TimelineView::default(),
program: MonitorView::default(),
source: MonitorView::default(),
project_view: ProjectView::Icon,
project_search: String::new(),
effects_search: String::new(),
icon_size: 110.0,
expanded_bins: vec![],
expanded_fx: vec!["Video Transitions".into(), "Video Transitions/Dissolve".into()],
collapsed_fx: vec![],
@@ -742,6 +737,8 @@ impl Default for UiState {
extras: Default::default(),
panels: Default::default(),
keys: Default::default(),
project_panel: Default::default(),
media_browser: Default::default(),
}
}
}
+5 -2
View File
@@ -160,15 +160,18 @@ fn new_keys_work_in_the_app() {
// Project panel: Down moves the selection; Shift+\ toggles the view
d.focus("Project");
// the Icon view shows a bin's clips (sub-bins are folder cards): open the first bin in place
let bin = d.app().session.project.root.children.iter().find_map(|e| if let filmcraft_project::BinEntry::Bin(b) = e { Some(b.id.0) } else { None }).unwrap();
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
d.exec("project.select", json!({"items": []}));
d.key("Down");
assert_eq!(d.app().session.state.project_selection.len(), 1);
let first = d.app().session.state.project_selection[0];
d.key("Down");
assert_ne!(d.app().session.state.project_selection[0], first);
let view0 = d.app().ui.project_view;
let view0 = d.app().session.prefs.project_panel.view.mode;
d.key("Shift+\\");
assert_ne!(d.app().ui.project_view, view0);
assert_ne!(d.app().session.prefs.project_panel.view.mode, view0);
// Cmd+Shift+1 zooms the Program monitor to 100%
d.key("Cmd+Shift+1");
+2 -2
View File
@@ -143,7 +143,7 @@ fn import_image_sequence_from_the_file_menu_and_media_browser() {
let kind = d.app().session.project.item(id).unwrap().as_media().unwrap().info.kind;
assert_eq!(kind, filmcraft_media::MediaKind::ImageSequence);
// Media Browser ▸ Import as Image Sequence (the selection, from frame 3)
d.app().ui.extras.media_browser_selection = vec![paths[2].clone()];
d.exec("mediaBrowser.select", json!({"paths": [paths[2]]}));
let r = d.ok("ui.menu.invoke", json!({"id": "file.importFromMediaBrowser", "params": {"imageSequence": true}}));
assert_eq!(r["imageSequences"][0]["frames"], 3);
let _ = std::fs::remove_dir_all(&dir);
@@ -179,7 +179,7 @@ fn find_dialog_and_search_bins() {
d.click("find.cancel");
// Create Search Bin, shown in the Project panel's list view with its live contents
d.app().ui.project_view = filmcraft_ui_egui::state::ProjectView::List;
d.exec("project.view.set", json!({"view": "list"}));
d.ok("ui.panel.show", json!({"panel": "Project"}));
d.menu("file.newSearchBin");
assert!(d.has("searchBin.text") && d.has("searchBin.column"));
+597
View File
@@ -0,0 +1,597 @@
//! Headless UI tests of the M12.7 Project panel and Media Browser: List view (columns, sorting,
//! resizing, inline rename), Icon view (hover scrub sets In / Out, thumbnail size), Freeform view
//! (dragging places a card, stacks), the panel menu and Metadata Display… dialog, view presets,
//! bins opened in place / in a tab / in a window, and the Media Browser (navigation, Favorites,
//! Edit Columns…, import, drag to the Project panel, Open In Source Monitor).
//!
//! Set `FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` to also render offscreen with wgpu and write
//! `project-*.png` / `media-browser-*.png`.
#![allow(dead_code)]
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_engine::project_panel::{FREEFORM_POS, FREEFORM_STACK, ViewMode};
use filmcraft_project::{BinEntry, ItemId};
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
snapshots: Option<std::path::PathBuf>,
}
impl Driver {
fn with(session: Session) -> Self {
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let snapshots = std::env::var_os("FILMCRAFT_UI_SNAPSHOT_DIR").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if snapshots.is_some() {
b = b.wgpu();
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, snapshots };
d.frames(4);
d
}
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
Self::with(session)
}
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 v = self.call(method, params.clone());
assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}");
v["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn click(&mut self, id: &str) {
self.ok("ui.click", json!({"id": id}));
self.frames(2);
}
fn ids(&mut self, prefix: &str) -> Vec<String> {
let v = self.ok("ui.elements", json!({"prefix": prefix}));
v.as_array().unwrap().iter().filter_map(|e| e["id"].as_str().map(str::to_string)).collect()
}
fn has(&mut self, id: &str) -> bool {
self.ids(id).iter().any(|i| i == id)
}
fn rect(&mut self, id: &str) -> [f32; 4] {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).unwrap_or_else(|| panic!("no element {id}")).clone();
let r = &e["rect"];
[r[0].as_f64().unwrap() as f32, r[1].as_f64().unwrap() as f32, r[2].as_f64().unwrap() as f32, r[3].as_f64().unwrap() as f32]
}
fn label(&mut self, id: &str) -> String {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array().unwrap().iter().find(|e| e["id"] == json!(id)).and_then(|e| e["label"].as_str().map(str::to_string)).unwrap_or_default()
}
/// Step frames until `id` is registered.
fn wait_for(&mut self, id: &str) {
for _ in 0..400 {
if self.has(id) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
self.frames(1);
}
panic!("{id} never appeared: {:?}", self.ids(id.split('.').next().unwrap()));
}
fn key(&mut self, key: &str) {
self.ok("ui.key", json!({"key": key}));
self.frames(2);
}
fn app(&mut self) -> &mut FilmcraftApp {
self.harness.state_mut()
}
/// The demo's Footage bin and its media items.
fn footage(&mut self) -> (u64, Vec<u64>) {
let p = &self.app().session.project;
let bin = p
.root
.children
.iter()
.find_map(|e| match e {
BinEntry::Bin(b) if b.name == "Footage" => Some(b.clone()),
_ => None,
})
.expect("Footage bin");
let items = bin.children.iter().filter_map(|e| if let BinEntry::Item(i) = e { Some(i.0) } else { None }).collect();
(bin.id.0, items)
}
fn snapshot(&mut self, name: &str, prefix: Option<&str>) {
let Some(dir) = self.snapshots.clone() else { return };
let crop = prefix.map(|p| self.rect(p));
self.frames(2);
let img = match self.harness.render() {
Ok(i) => i,
Err(e) => {
eprintln!("snapshot {name} skipped: {e}");
return;
}
};
let ppp = img.width() as f32 / 1600.0;
let img = match crop {
Some([x, y, w, h]) => {
let (x0, y0) = ((x * ppp).max(0.0) as u32, (y * ppp).max(0.0) as u32);
let (x1, y1) = ((((x + w) * ppp) as u32).min(img.width()), (((y + h) * ppp) as u32).min(img.height()));
image::imageops::crop_imm(&img, x0, y0, x1 - x0, y1 - y0).to_image()
}
None => img,
};
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.png"));
img.save(&path).unwrap();
eprintln!("snapshot: {}", path.display());
}
}
/// The demo project with the Project panel maximized (room for the List view's columns).
fn project_driver() -> Driver {
let mut d = Driver::demo();
d.ok("ui.panel.show", json!({"panel": "Project"}));
d.ok("ui.set", json!({"focused": "Project"}));
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
d.frames(3);
d
}
/// Double-click a bin (synthetic input runs one event per frame, too slow for a double-click, so
/// this sends what the double-click does: open as Settings ▸ General ▸ Bins says).
fn double_click_bin(d: &mut Driver, bin: u64, alt: bool) {
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "alt": alt}}));
d.frames(3);
}
#[test]
fn list_view_columns_sort_resize_and_rename() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, items) = d.footage();
d.frames(2);
// expand the Footage bin
d.click(&format!("project.bin.{bin}.toggle"));
assert!(d.app().ui.expanded_bins.contains(&bin));
d.wait_for(&format!("project.item.{}", items[0]));
for c in ["Name", "Frame Rate", "Media Start", "Media Duration"] {
assert!(d.has(&format!("project.list.header.{c}")), "{c} header");
}
assert!(d.has(&format!("project.item.{}.label", items[0])), "label chip");
let y = |d: &mut Driver, i: u64| d.rect(&format!("project.item.{i}"))[1];
// Name ascending: rows ordered by name
let mut by_name: Vec<(String, u64)> = items.iter().map(|i| (d.app().session.project.item(ItemId(*i)).unwrap().name.to_lowercase(), *i)).collect();
by_name.sort();
let (first, last) = (by_name[0].1, by_name.last().unwrap().1);
assert!(y(&mut d, first) < y(&mut d, last));
d.snapshot("project-list", Some("panel.Project"));
// clicking the Name header reverses the order
d.click("project.list.header.Name");
assert!(d.app().session.prefs.project_panel.view.sort.descending);
d.frames(2);
assert!(y(&mut d, first) > y(&mut d, last));
// drag the Frame Rate column wider (saved in preferences)
let w0 = d.app().session.prefs.project_panel.view.columns.iter().find(|c| c.name == "Frame Rate").unwrap().width;
let r = d.rect("project.list.resize.Frame Rate");
let (cx, cy) = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 60.0, "y": cy}}));
d.frames(3);
let w1 = d.app().session.prefs.project_panel.view.columns.iter().find(|c| c.name == "Frame Rate").unwrap().width;
assert!(w1 > w0 + 30.0, "{w0} → {w1}");
// inline rename: Rename, type, Enter (one undo step)
d.exec("project.select", json!({"items": [items[0]]}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.rename"}));
d.frames(3);
assert!(d.has("project.rename"));
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "Hero shot"}));
d.frames(2);
d.key("Enter");
d.frames(2);
assert_eq!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot");
assert!(d.app().ui.project_panel.rename.is_none());
d.exec("edit.undo", json!({}));
assert_ne!(d.app().session.project.item(ItemId(items[0])).unwrap().name, "Hero shot");
}
#[test]
fn icon_view_hover_scrub_sets_in_and_out() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "icon"}));
let (bin, items) = d.footage();
// open the Footage bin in place (Settings ▸ Bins ▸ Double-click: Open in place)
d.click(&format!("project.bin.{bin}"));
double_click_bin(&mut d, bin, false);
assert_eq!(d.app().ui.project_panel.bin, Some(bin));
assert!(d.label("project.breadcrumb").ends_with("/ Footage"));
let card = format!("project.item.{}", items[0]);
d.wait_for(&card);
d.ok("ui.set", json!({"focused": "Project"}));
// hover a quarter in, press I; three quarters in, press O
d.ok("ui.move", json!({"id": card, "fx": 0.25, "fy": 0.5}));
d.frames(2);
let h = d.app().ui.project_panel.hover.expect("hover scrub");
assert_eq!(h.item, items[0]);
d.key("I");
d.ok("ui.move", json!({"id": card, "fx": 0.75, "fy": 0.5}));
d.frames(2);
d.key("O");
let it = d.app().session.project.item(ItemId(items[0])).unwrap().clone();
let m = it.as_media().unwrap();
let (i, o) = (m.mark_in.expect("In set"), m.mark_out.expect("Out set"));
let dur = it.duration().0 as f64;
assert!((i.0 as f64 / dur - 0.25).abs() < 0.05, "In at {}", i.0 as f64 / dur);
assert!((o.0 as f64 / dur - 0.75).abs() < 0.05, "Out at {}", o.0 as f64 / dur);
d.frames(2);
assert!(d.has(&format!("project.item.{}.inOut", items[0])), "In/Out bar on the card");
// the sequence's marks are untouched
assert!(d.app().session.active_sequence().unwrap().mark_in.is_none());
d.snapshot("project-icon", Some("panel.Project"));
// Shift+] / Shift+[ change the thumbnail size
let s0 = d.app().session.prefs.project_panel.view.icon_size;
d.key("Shift+]");
assert!(d.app().session.prefs.project_panel.view.icon_size > s0);
d.key("Shift+[");
assert!((d.app().session.prefs.project_panel.view.icon_size - s0).abs() < 1.0);
// Hover Scrub off: no hover, I marks the sequence again
d.key("Shift+H");
assert!(!d.app().session.prefs.project_panel.hover_scrub);
d.ok("ui.move", json!({"id": card, "fx": 0.5, "fy": 0.5}));
d.frames(4);
d.key("I");
assert!(d.app().session.active_sequence().unwrap().mark_in.is_some());
// up one level
d.click("project.up");
assert_eq!(d.app().ui.project_panel.bin, None);
}
#[test]
fn freeform_view_places_and_stacks_cards() {
let mut d = project_driver();
let (bin, items) = d.footage();
d.exec("project.view.set", json!({"view": "freeform"}));
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
let card = format!("project.item.{}", items[0]);
d.wait_for(&card);
assert!(d.has("project.freeform"));
let r = d.rect(&card);
let canvas = d.rect("project.freeform");
let from = (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
let to = (from.0 + 140.0, from.1 + 90.0);
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 16}));
d.frames(4);
let pos = d.app().session.project.item(ItemId(items[0])).unwrap().metadata.get(FREEFORM_POS).cloned().expect("card placed");
let (x, y, _) = filmcraft_engine::project_panel::parse_pos(&pos).unwrap();
let (ex, ey) = (r[0] - canvas[0] + 140.0, r[1] - canvas[1] + 90.0);
assert!((x - ex).abs() < 4.0 && (y - ey).abs() < 4.0, "placed at {x},{y}, expected {ex},{ey}");
d.frames(2);
let r2 = d.rect(&card);
assert!((r2[0] - r[0] - 140.0).abs() < 4.0, "card drawn where it was dropped");
d.snapshot("project-freeform", Some("panel.Project"));
// the move is one undo step
d.exec("edit.undo", json!({}));
assert!(!d.app().session.project.item(ItemId(items[0])).unwrap().metadata.contains_key(FREEFORM_POS));
// stack two cards from the card menu
d.exec("project.select", json!({"items": [items[0], items[1]]}));
d.ok("ui.click", json!({"id": card, "button": "right"}));
d.frames(2);
d.click("project.cardMenu.stack");
assert_eq!(d.app().session.project.item(ItemId(items[1])).unwrap().metadata.get(FREEFORM_STACK).map(String::as_str), Some(items[0].to_string().as_str()));
d.frames(2);
assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge");
// Freeform View Options… from the panel menu
d.click("panel.menu.Project");
d.click("project.menu.freeformOptions");
assert!(d.has("projectDialog.snap"));
d.click("projectDialog.snap");
d.click("projectDialog.ok");
assert!(d.app().session.prefs.project_panel.freeform.snap);
assert!(d.app().ui.project_panel.dialog.is_none());
}
#[test]
fn metadata_display_dialog_and_view_presets() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, _) = d.footage();
d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}}));
d.frames(2);
// the panel menu lists Premiere's items
d.click("panel.menu.Project");
for id in [
"project.menu.closeProject",
"project.menu.newBin",
"project.menu.newSearchBin",
"project.menu.rename",
"project.menu.automateToSequence",
"project.menu.view.List",
"project.menu.view.Freeform",
"project.menu.previewArea",
"project.menu.hoverScrub",
"project.menu.fontSize",
"project.menu.refreshSortOrder",
"project.menu.metadataDisplay",
"project.menu.saveViewPresetAs",
"project.menu.restoreViewPreset",
"project.menu.freeformOptions",
] {
assert!(d.has(id), "{id}");
}
d.click("project.menu.metadataDisplay");
d.frames(4);
assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. })));
d.click("projectDialog.field.Label");
// fields further down: narrow the list with the search box
d.click("projectDialog.filter");
d.ok("ui.type", json!({"text": "usage"}));
d.frames(2);
d.click("projectDialog.field.Video Usage");
d.click("projectDialog.filter");
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "tape"}));
d.frames(2);
d.click("projectDialog.field.Tape Name");
// move Label up next to Name
for _ in 0..12 {
if !d.has("projectDialog.up.Label") {
break;
}
d.click("projectDialog.up.Label");
}
d.snapshot("project-metadata-display", None);
d.click("projectDialog.ok");
let cols: Vec<String> = d.app().session.prefs.project_panel.view.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(cols[0], "Name");
assert_eq!(cols[1], "Label", "{cols:?}");
assert!(cols.contains(&"Video Usage".to_string()) && !cols.contains(&"Tape Name".to_string()));
d.frames(2);
assert!(d.has("project.list.header.Label") && d.has("project.list.header.Video Usage"));
// Save As New View Preset (named), change the view, restore it from Manage
d.click("panel.menu.Project");
d.click("project.menu.saveViewPresetAs");
d.ok("ui.set", json!({"projectPanel": {"dialog": {"savePresetAs": {"name": "Logging"}}}}));
d.frames(2);
d.click("projectDialog.ok");
assert_eq!(d.app().session.prefs.project_panel.presets[0].as_ref().unwrap().name, "Logging");
d.exec("project.view.set", json!({"view": "icon", "fontSize": "large"}));
d.click("panel.menu.Project");
d.click("project.menu.manageViewPresets");
d.click("projectDialog.preset.1");
d.click("projectDialog.restore");
assert_eq!(d.app().session.prefs.project_panel.view.mode, ViewMode::List);
d.click("projectDialog.ok");
assert!(d.app().ui.project_panel.dialog.is_none());
// Font Size and Preview Area
d.exec("project.view.set", json!({"fontSize": "extraLarge", "previewArea": true}));
let first = d.footage().1[0];
d.exec("project.select", json!({"items": [first]}));
d.frames(3);
assert!(d.has("project.previewArea"));
d.snapshot("project-list-preview-xl", Some("panel.Project"));
}
#[test]
fn bins_open_in_place_in_a_tab_or_in_a_window() {
let mut d = project_driver();
d.exec("project.view.set", json!({"view": "list"}));
let (bin, items) = d.footage();
d.app().session.prefs.general.bins_double_click = "openNewTab".into();
d.frames(2);
double_click_bin(&mut d, bin, false);
assert_eq!(d.app().ui.project_panel.tabs.len(), 1);
assert!(d.has("project.tab.0") && d.has("project.tab.project"));
// the tab shows the bin's items under its own ids
d.wait_for(&format!("projectBin.0.item.{}", items[0]));
d.click("project.tab.project");
assert_eq!(d.app().ui.project_panel.active_tab, None);
d.click("project.tab.0.close");
assert!(d.app().ui.project_panel.tabs.is_empty());
// Opt+double-click: Open in New Window (a floating bin panel)
double_click_bin(&mut d, bin, true);
assert!(d.app().ui.project_panel.tabs.first().is_some_and(|t| t.floating), "{:?}", d.app().ui.project_panel.tabs);
d.wait_for("projectBin.0.window");
// a second Project panel on the project
d.ok("ui.menu.invoke", json!({"id": "projectPanel.newPanel"}));
d.frames(3);
assert!(d.has("projectBin.1.window"));
d.snapshot("project-bin-windows", None);
// inline rename of a bin
d.ok("ui.menu.invoke", json!({"id": "projectPanel.rename", "params": {"bin": bin}}));
d.frames(3);
d.key("Cmd+A");
d.ok("ui.type", json!({"text": "Camera A"}));
d.key("Enter");
let name = d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).unwrap().name.clone();
assert_eq!(name, "Camera A");
}
#[test]
fn footer_buttons_are_wired() {
let mut d = project_driver();
let bins0 = d.app().session.project.root.children.len();
d.click("project.button.file.newBin");
assert_eq!(d.app().session.project.root.children.len(), bins0 + 1);
d.click("project.button.find");
assert!(d.app().ui.extras.dialog.is_some(), "Find… dialog");
d.app().ui.extras.dialog = None;
d.click("project.button.new-item");
d.wait_for("project.newItem.file.newColorMatte");
let n0 = d.app().session.project.items.len();
d.click("project.newItem.file.newColorMatte");
assert_eq!(d.app().session.project.items.len(), n0 + 1);
d.click("project.button.project.delete");
assert_eq!(d.app().session.project.items.len(), n0, "Clear removes the selected new matte");
for v in ["List", "Icon", "Freeform"] {
d.click(&format!("project.view.{v}"));
assert_eq!(format!("{:?}", d.app().session.prefs.project_panel.view.mode), v);
}
d.click("project.view.Icon");
assert!(d.has("project.sortIcons"));
d.click("project.sortIcons");
d.click("project.sortIcons.Name");
assert_eq!(d.app().session.prefs.project_panel.view.icon_sort.column, "Name");
}
// ------------------------------------------------------------------------------------ Media Browser
fn media_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("fc-mb-ui-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Selects")).unwrap();
let wav = filmcraft_media::wav::write_wav16(&[0.0; 9600], 2, 48_000);
for n in ["a-interview.wav", "b-roll.wav"] {
std::fs::write(dir.join(n), &wav).unwrap();
}
std::fs::write(dir.join("Selects").join("pick.wav"), &wav).unwrap();
std::fs::write(dir.join("readme.txt"), b"not media").unwrap();
dir
}
#[test]
fn media_browser_navigation_import_and_columns() {
let dir = media_dir("nav");
let d0 = dir.to_string_lossy().to_string();
let mut d = Driver::with(Session::default());
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
d.ok("ui.set", json!({"focused": "MediaBrowser"}));
d.ok("ui.menu.invoke", json!({"id": "window.maximizeFrame"}));
d.exec("mediaBrowser.navigate", json!({"path": d0}));
d.frames(3);
d.wait_for("mediaBrowser.entry.a-interview.wav");
assert!(d.has("mediaBrowser.entry.Selects") && !d.has("mediaBrowser.entry.readme.txt"));
for id in [
"mediaBrowser.back",
"mediaBrowser.forward",
"mediaBrowser.up",
"mediaBrowser.path",
"mediaBrowser.fileTypes",
"mediaBrowser.ingest",
"mediaBrowser.importAsImageSequence",
"mediaBrowser.tree.localDrives",
] {
assert!(d.has(id), "{id}");
}
// double-click a folder, then Back / Forward
// type a directory into the path field
d.click("mediaBrowser.path");
d.key("Cmd+A");
d.ok("ui.type", json!({"text": format!("{d0}/Selects")}));
d.key("Enter");
d.wait_for("mediaBrowser.entry.pick.wav");
d.click("mediaBrowser.back");
d.wait_for("mediaBrowser.entry.b-roll.wav");
d.click("mediaBrowser.forward");
d.wait_for("mediaBrowser.entry.pick.wav");
d.click("mediaBrowser.up");
d.wait_for("mediaBrowser.entry.b-roll.wav");
// Favorites and Recent Directories in the tree
d.click("panel.menu.MediaBrowser");
d.click("mediaBrowser.menu.favorite");
assert_eq!(d.app().session.prefs.media_browser.favorites, std::slice::from_ref(&d0));
d.frames(2);
let fav_name = dir.file_name().unwrap().to_string_lossy().to_string();
assert!(d.has(&format!("mediaBrowser.tree.favorites.{fav_name}")));
assert!(d.has("mediaBrowser.tree.recent"));
// select two files and Import from the panel menu
d.click("mediaBrowser.entry.a-interview.wav");
d.ok("ui.click", json!({"id": "mediaBrowser.entry.b-roll.wav", "command": true}));
d.frames(2);
assert_eq!(d.app().session.browser.selection.len(), 2);
assert!(d.label("mediaBrowser.count").starts_with("2 of 2"));
d.snapshot("media-browser-list", Some("panel.MediaBrowser"));
d.click("panel.menu.MediaBrowser");
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)
d.click("mediaBrowser.entry.b-roll.wav");
d.ok("ui.set", json!({"focused": "MediaBrowser"}));
d.key("Shift+O");
assert!(d.app().session.state.source_item.is_some());
assert_eq!(d.app().session.project.items.len(), 2);
// Edit Columns…
d.click("panel.menu.MediaBrowser");
d.click("mediaBrowser.menu.editColumns");
d.click("mediaBrowser.columns.Audio Info");
d.click("mediaBrowser.columns.ok");
assert!(d.app().session.prefs.media_browser.columns.contains(&"Audio Info".to_string()));
d.frames(6);
assert!(d.has("mediaBrowser.header.Audio Info"));
// thumbnails view and the file-type filter
d.click("mediaBrowser.view.thumbnails");
assert_eq!(d.app().session.prefs.media_browser.view, "thumbnails");
d.wait_for("mediaBrowser.entry.b-roll.wav");
d.snapshot("media-browser-thumbnails", Some("panel.MediaBrowser"));
d.exec("mediaBrowser.settings", json!({"fileTypes": "image", "view": "list"}));
d.frames(3);
assert!(!d.has("mediaBrowser.entry.b-roll.wav") && d.has("mediaBrowser.entry.Selects"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn media_browser_drag_imports_into_the_project_panel() {
let dir = media_dir("drag");
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).unwrap();
let mut d = Driver::with(session);
// the Editing workspace has Project and Media Browser in one group: put the browser beside it
d.ok("ui.set", json!({"workspace": "Editing"}));
d.ok("ui.panel.show", json!({"panel": "MediaBrowser"}));
d.exec("mediaBrowser.navigate", json!({"path": dir.to_string_lossy()}));
d.frames(3);
d.wait_for("mediaBrowser.entry.b-roll.wav");
let n0 = d.app().session.project.items.len();
// dropped on the Media Browser itself: the import is taken back
let e = d.rect("mediaBrowser.entry.b-roll.wav");
let from = (e[0] + 40.0, e[1] + e[3] / 2.0);
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": from.0 + 30.0, "y": from.1 + 60.0}}));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0, "dropped nowhere");
// dropped on the Timeline: imported and placed
let tl = d.rect("panel.Timeline");
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": tl[0] + tl[2] * 0.6, "y": tl[1] + tl[3] * 0.75}, "steps": 20}));
d.frames(6);
assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline");
let _ = std::fs::remove_dir_all(&dir);
}
+13
View File
@@ -574,6 +574,19 @@ All of these dispatch the same command ids.
`ProjectItem::metadata`), Events (`events.list` / `events.clear`), Progress (`jobs.list` /
`jobs.cancel`). Timecode and Reference Monitor are frontend-only views. Their settings are
`UiState::panels` (`ui.set {"panels": {...}}`).
- **Project panel and Media Browser** (M12.7). View settings (List / Icon / Freeform, thumbnail
and font size, Preview Area, Hover Scrub), the List view's columns, widths and sort, and ten view
presets are preferences (`Preferences::project_panel`; `project.view.*`, `project.columns.*`,
`project.sort`, `project.viewPreset.*`). Freeform positions, Clip Size, stacks and saved
arrangements are item metadata (`Freeform Position`, `Freeform Stack`,
`Freeform Arrangement: <name>`; `project.freeform.*`), so they are undoable and saved with the
project without a schema change. The Media Browser lists directories through
`Services::list_entries` / `volumes` / `home_dir` (native: `std::fs`; web: the virtual file
table; tests: a fake filesystem): `engine::media_browser` keeps navigation history and the
selection in `Session::browser`, Favorites / recent directories / file types / columns in
`Preferences::media_browser` (`mediaBrowser.*`). Bins opened in a tab or a window, the inline
rename, dialogs and the hover-scrubbed card are `UiState::project_panel`; the browser's tree
state is `UiState::media_browser` (`ui.set {"projectPanel": …, "mediaBrowser": …}`).
Protocol reference: [control-protocol.md](control-protocol.md). Agent guide: [agents.md](agents.md).
+1 -1
View File
@@ -19,7 +19,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`):
| `engine.commands` / `ui.menu.list` | – | command registry / menu tree |
| `ui.inspect` | – | UI state (tool, workspace, dock, timeline view, playback, fps…) |
| `ui.elements` | `{prefix?}` | every on-screen interactive element: id, label, rect |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`) |
| `ui.set` | `{tool, workspace, mode, theme, focused, playbackRes, timeline:{pps,scroll,fit}, program:{…}, source:{…}, clipDialog:{param: value}, menuDialog:{param: value}, panels:{scopes, timecode, reference, events, progress}, export:{…}}` | `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `panels` merges into the panel settings (`ui.inspect` → `ui.panels`; see `panels::panel_state`): Lumetri Scopes `{shown: ["vectorscopeYuv"|"vectorscopeHls"|"histogram"|"parade"|"waveform"], waveformType, paradeType, colorSpace, brightness, scale, clamp, targets}`, Timecode `{rows: [{source, mode, display}], showName}`, Reference Monitor `{ganged, time, display: "composite"|"scopes", scopes}`, Events `{level, selected}`, Progress `{showFinished}` `export` deep-merges into Export mode state (`preset` — applied first —, `settings` (ExportSettings JSON), `fileName`, `location`, `range`, `customStart`, `customEnd`, `openSections`, `manager` (Preset Manager: `query`, `favoritesOnly`, `selected`, `saveName`), `quickOpen`, `quickPath`, `quickPreset`); `program` / `source` merge fields into the monitor view state (`res`, `paused_res`, `high_quality`, `display`, `zoom`, `pan`, `show_rulers`, `show_guides`, `lock_guides`, `snap`, `guides`, `compare_ref`, …; see [monitors.md](monitors.md)); `clipDialog` sets fields of the open Edit / Clip / File dialog (Paste Attributes, Make Subclip, Frame Hold Options, …); `menuDialog` those of the M3.11 dialogs (Find, Create Search Bin, Project Settings, Scene Edit Detection, Simplify Sequence, Automate to Sequence…; see `panels::menu_dialogs`); `projectPanel` merges into the Project panel's view state (`bin` shown in place, `tabs` (bins in a tab or window: `{bin, floating, view, iconSize, nav}`), `activeTab`, `selectedBin`, `rename: {item|bin, text}`, `dialog` (`{"metadataDisplay": {columns, filter}}`, `{"savePresetAs": {name}}`, `{"managePresets": {selected, name}}`, `{"freeformOptions": {options}}`, `{"saveArrangement": {name, bin}}`)); `mediaBrowser` into the Media Browser's (`expanded`, `pathEdit`, `editColumns`, `treeWidth`). View settings, columns and presets are engine commands (`project.view.set`, `project.columns.set`, `project.viewPreset.*`, `mediaBrowser.settings`) |
| `ui.panel.show` / `ui.panel.close` | `{panel}` | |
| `ui.click` / `ui.move` | `{id}` or `{x,y}`, `button`, `count`, `modifiers` | synthetic pointer input |
| `ui.drag` | `{from, to, steps, modifiers}` | press–move–release |
+2 -2
View File
@@ -30,7 +30,7 @@ Premiere has these as keys only (most are not in a menu). Modules: `filmcraft_en
| Panels | Maximize or Restore Active Frame / Frame Under Cursor, Select Next/Previous Panel, Toggle Source/Program Monitor Focus, Workspaces 7–9 | Shift+`, `, Ctrl+Shift+. / ,, Alt+Shift+7/8/9 |
| Audio | Increase/Decrease Clip Volume (1 dB) and …Many (Settings ▸ Audio ▸ Large Volume Adjustment), Nudge Volume ±1/±3 dB, Toggle Audio During Scrubbing | ] / [, Shift+] / Shift+[, Shift+S |
| Graphics | Increase/Decrease Font Size and Leading by One/Five Units, Left/Center/Right align text, Begin Text Editing, Nudge Selected Object by one/five (graphic layers, else Motion position) | Cmd+Alt+(Shift+)Left/Right, Alt+(Shift+)Up/Down, Cmd+Shift+L/C/R, Cmd+Alt+', Cmd+(Shift+)arrows (Program, Properties) |
| Project panel | List/Icon/Toggle View, Hover Scrub, Thumbnail Size Next/Previous (and = / -), Move/Extend Selection | Cmd+PageUp/PageDown, Shift+\, Shift+H, Shift+] / Shift+[, arrows, Home/End, PageUp/PageDown |
| Project panel | List/Icon/Toggle View, Hover Scrub, Thumbnail Size Next/Previous (and = / -), Move/Extend Selection, Mark In / Out of the hover-scrubbed clip | Cmd+PageUp/PageDown, Shift+\, Shift+H, Shift+] / Shift+[, arrows, Home/End, PageUp/PageDown, I / O |
| Text panel | Navigate/Select to Previous/Next Word and Line, Start/End of Segment, Delete (lift), Ripple Delete (extract), Show Program Transcript, Merge/Split Segments | arrows, Shift+arrows, Home/End, Shift+Home, Cmd+Shift+Down, Alt+Backspace, Backspace, Shift+X, Alt+M / Alt+S |
| Other | Help (F1), Quit (Cmd+Q), Media Browser ▸ Open In Source Monitor (imports the file first), Send to Media Encoder (= Send to Export Queue, Alt+Shift+M), Metadata panel Play/Loop | |
@@ -56,7 +56,7 @@ Frame has no default key; the Premiere preset moves Shift+E to Export Frame and
| Effect Controls: Remove Selected Effect (Delete), Loop During Audio-Only Playback (⌘L) | Effect Controls has no effect selection state and no audio-only playback mode; effects are removed from their context menu or `effects.remove`. |
| Effects panel: New Custom Bin (⌘/), Delete Custom Item (Delete) | The Effects panel has no custom bins (effect presets live in the Presets bin). |
| History panel: Delete (Delete) | History states cannot be selected and deleted individually. |
| Media Browser: Select Directory List / Select Media List (⇧← / ⇧→) | The Media Browser is a single list without separate directory and media panes. |
| Media Browser: Select Directory List / Select Media List (⇧← / ⇧→) | The directory tree and media list take no separate keyboard focus. |
| Project panel: Delete Selection with Options (⌘Delete) | Needs the "delete instances in sequences" dialog; plain Clear (Backspace) deletes. |
| Project panel: Next/Previous Column Field, Next/Previous Row Field (Tab, ⇧Tab, Return, ⇧Return) | The list view has no inline-editable metadata cells. |
| Text panel: Edit Segment (Return) | No inline transcript text editing (corrections go through `transcript.set`). |
+1
View File
@@ -24,6 +24,7 @@ FILMCRAFT_REQUIRE_ORACLES=1 cargo test --workspace # CI: missing ffmpeg fails
| Engine / command | `crates/engine/src/tests.rs` | run commands on the demo project, assert the sequence, undo/redo, disabled cases |
| Render | `crates/render/src/tests.rs` | compositing, opacity, Motion, cross dissolve midpoint, ½-res vs full, GPU plan vs reference, audio mix, audio-effect continuity |
| Multi-camera / sync | `crates/audio-dsp/src/sync.rs`, `crates/engine/src/multicam_tests.rs`, `crates/ui-egui/tests/multicam_ui.rs` | audio offsets within one sample: Rust-generated multi-mic speech (gains −20…+10 dB, SNR 40/25/10/3 dB, zero-phase colouring, fractional delays, a 0 dB-SNR room with a −4 dB echo) and ffmpeg-made camera/recorder files (pink-noise bursts, independent noise; exact lags recovered); timecode (± hours) and marker sync within a frame; Merge Clips; timeline Synchronize keeps frame-aligned starts; multicam structure, angle render (PSNR vs the camera), Switch Audio mix = the angle's track, live switching from a simulated playback clock (cuts, healing, one undo step), Ctrl+N cuts, flatten (frames identical), save/load; `perf_grid_of_four_1080p_angles` (ignored) prints the grid cost |
| Project panel and Media Browser (M12.7) | `crates/engine/src/{project_panel,media_browser}_tests.rs`, `crates/ui-egui/tests/project_panel_ui.rs` | view settings and their preference round trip; Metadata Display columns (custom fields, Freeform keys hidden), numeric vs text sorting, view presets (save / save as / restore / rename / delete, ten slots, persisted in `preferences.json`); Freeform moves keeping offsets, snapping, Align to Grid, Clip Size, stacks, arrangements, Reset (each one undo step, saved in the project); bin rename / nesting; the Media Browser against a fake filesystem (folders first, hidden and unsupported files left out, file-type filter, back / forward / up, recent directories, Favorites, import of a selection or a folder, Open in Source Monitor, ingest, probe columns). Headless: List view (sort by header, drag a column wider, inline rename), Icon view (hover scrub + I / O set the clip's In / Out, Shift+] / [), Freeform (drag a card, stack from the card menu, options dialog), panel menu, Metadata Display dialog, view presets, bins in place / tab / window, footer buttons, Media Browser navigation, Favorites, import, Shift+O, Edit Columns…, thumbnails, drag to the Timeline (`FILMCRAFT_UI_SNAPSHOT_DIR=<dir>` writes `project-*.png`, `media-browser-*.png`) |
| Menu long tail (M3.11) | `crates/render/src/scene.rs`, `crates/engine/src/{scene_detect,sequence_extras,project_tools}_tests.rs`, `crates/interchange/src/ale.rs`, `crates/project/src/find.rs`, `crates/ui-egui/tests/menus_ui.rs` | Scene Edit Detection on synthetic frames (hard cuts vs pans, flashes, sensitivity) and on generated movies with known cut points (our own ProRes render of demo scenes; an ffmpeg-made H.264 concat of testsrc2 / SMPTE bars / Mandelbrot): cuts with linked audio, clip markers, subclips, one undo step, background job and cancel; Normalize Mix Track to a target peak; Simplify Sequence; Find / Find Next and search bins; Automate to Sequence (overlap, transitions, insert, unnumbered markers); ALE round trip; Project Settings and scratch disks; menu order and shortcuts against Premiere's; the dialogs by automation id |
| Mixer | `crates/render/src/mixer_tests.rs`, `crates/engine/src/mixer_tests.rs` | sample-exact fader gain and pan laws, automation at block boundaries (bit-identical however requests are cut), solo/mute/solo-safe, sends and submix routing, latency-compensation alignment, render-vs-playback identity, Touch ramp-back, recorder modes (Latch/Touch/Write), thinning, Audio Gain, transition curves; `perf_24_tracks_3_effects_realtime_factor` prints the realtime factor |
| 5.1 / multichannel | `crates/audio-dsp/src/channels.rs`, `crates/render/src/mixer_tests.rs`, `crates/export/src/surround_tests.rs`, `crates/engine/src/mixer_tests.rs`, `crates/ui-egui/tests/audio51_ui.rs` | BS.775 downmix / upmix coefficients exact, mixdown types; 5.1 panner point gains equal-power (Σg² = 1 over a 21×21 grid × 3 centre settings), speaker positions exact, 5.1 through the default puck bit-exact identity; graph: stereo/mono tracks into a 5.1 Mix (L/R, C, rear right, room centre −6 dB each, LFE = mean), 5.1 track through a 5.1 Mix and folded into stereo, a 5.1 submix, `pan51` automation block-invariant, six-channel meters; export: 6-ch WAV (`WAVE_FORMAT_EXTENSIBLE`, mask 0x3F) and ProRes MOV (6 × 24-bit PCM) decoded by ffmpeg **sample-exact**, ffprobe `channels` 6 / `channel_layout` 5.1; AAC 5.1 in MP4: every decoded channel's own tone ≥ 30 dB above the others' (no swapped channels), stereo export of a 5.1 Mix = BS.775 within 2·10⁻⁶; `sequence.settings {mix}`, `file.newSequence {mix, trackType}`, panner lanes, Write pass records the puck; UI: puck drag (one undo step), Center / LFE, six meter bars |