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:
1 parent
999af3a1a5
commit
c1c846aaad
31 files changed
+6305
-633
No files matched your search
+1
-1
@@ -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% |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t_dir(s)).is_some(),
|
||||
"selection": s.browser.selection,
|
||||
"favorite": s.prefs.media_browser.favorites.contains(¤t_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(¤t_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())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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 { .. }));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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") };
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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`). |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user