filmcraft/crates/engine/src/media_browser.rs

776 lines
31 KiB
Rust

//! 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`; a folder becomes a bin, its sub-folders bins inside it; 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"];
/// Project and interchange files the browser can import. Avid Log Exchange (`.ale`) is left out:
/// FilmCraft writes it (File ▸ Export) but, like Premiere, does not read it (#1292).
const PROJECT_EXTENSIONS: [&str; 5] = ["fcproj", "edl", "xml", "fcpxml", "otio"];
const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
const RECENT_MAX: usize = 10;
/// Preferences of the Media Browser (`Preferences::media_browser`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct MediaBrowserPrefs {
pub favorites: Vec<String>,
/// Recent directories, newest first.
pub recent: Vec<String>,
/// Directory shown when the browser opens ("" = the home directory).
pub last_dir: String,
/// One of [`FILE_TYPES`], or a file extension (`mov`).
pub file_types: String,
/// `list` | `thumbnails`.
pub view: String,
pub columns: Vec<String>,
pub import_as_image_sequence: bool,
pub hover_scrub: bool,
pub thumbnail_size: f32,
}
impl Default for MediaBrowserPrefs {
fn default() -> Self {
MediaBrowserPrefs {
favorites: Vec::new(),
recent: Vec::new(),
last_dir: String::new(),
file_types: "all".into(),
view: "list".into(),
columns: DEFAULT_COLUMNS.iter().map(|c| c.to_string()).collect(),
import_as_image_sequence: false,
hover_scrub: true,
thumbnail_size: 120.0,
}
}
}
/// Session state of the Media Browser.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserState {
/// The directory shown ("" = not chosen yet: the last or home directory).
pub dir: String,
pub back: Vec<String>,
pub forward: Vec<String>,
pub selection: Vec<String>,
/// Probed media properties by path (None = not a readable media file).
#[serde(skip)]
pub probes: BTreeMap<String, Option<MediaInfo>>,
}
/// A listed entry with what the browser shows about it.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub name: String,
pub path: String,
pub is_dir: bool,
/// `folder` | `video` | `audio` | `image` | `project` | `caption`.
pub kind: &'static str,
pub size: Option<u64>,
pub modified: Option<u64>,
/// A numbered still (Import as Image Sequence applies).
pub numbered: bool,
}
fn ext_of(name: &str) -> String {
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
}
/// The kind of file a name is, or None when the browser doesn't show it.
pub fn kind_of(name: &str) -> Option<&'static str> {
let e = ext_of(name);
if filmcraft_media::VIDEO_EXTENSIONS.contains(&e.as_str()) {
Some("video")
} else if filmcraft_media::AUDIO_EXTENSIONS.contains(&e.as_str()) {
Some("audio")
} else if filmcraft_media::STILL_EXTENSIONS.contains(&e.as_str()) {
Some("image")
} else if PROJECT_EXTENSIONS.contains(&e.as_str()) {
Some("project")
} else if CAPTION_EXTENSIONS.contains(&e.as_str()) {
Some("caption")
} else {
None
}
}
/// Does a file of `kind` and extension pass the file-type filter?
pub fn passes(filter: &str, kind: &str, name: &str) -> bool {
match filter {
"" | "all" => true,
"video" | "audio" | "image" | "project" | "caption" => kind == filter,
ext => ext_of(name) == ext.trim_start_matches('.').to_ascii_lowercase(),
}
}
/// Every extension the browser lists (for the file-type menu).
pub fn extensions() -> Vec<&'static str> {
filmcraft_media::VIDEO_EXTENSIONS
.iter()
.chain(filmcraft_media::AUDIO_EXTENSIONS)
.chain(filmcraft_media::STILL_EXTENSIONS)
.chain(&PROJECT_EXTENSIONS)
.chain(&CAPTION_EXTENSIONS)
.copied()
.collect()
}
fn windows_style(p: &str) -> bool {
p.contains('\\') && !p.contains('/')
}
/// `dir` + `name` with the directory's separator.
pub fn join(dir: &str, name: &str) -> String {
let sep = if windows_style(dir) { '\\' } else { '/' };
if dir.is_empty() {
name.to_string()
} else if dir.ends_with(sep) {
format!("{dir}{name}")
} else {
format!("{dir}{sep}{name}")
}
}
/// The parent directory (None at a root).
pub fn parent(dir: &str) -> Option<String> {
let sep = if windows_style(dir) { '\\' } else { '/' };
let t = dir.trim_end_matches(sep);
if t.is_empty() {
return None;
}
let i = t.rfind(sep)?;
let p = &t[..i];
if p.is_empty() {
Some(sep.to_string())
} else if windows_style(dir) && p.ends_with(':') {
Some(format!("{p}\\"))
} else {
Some(p.to_string())
}
}
/// The last path component (a directory's name in the tree).
pub fn base_name(path: &str) -> String {
let sep = if windows_style(path) { '\\' } else { '/' };
let t = path.trim_end_matches(sep);
t.rsplit(sep).next().filter(|n| !n.is_empty()).unwrap_or(path).to_string()
}
/// List a directory: sub-directories first, then files the browser shows (hidden files left out),
/// each group by name (case-insensitive).
pub fn list(services: &dyn Services, dir: &str, filter: &str) -> std::io::Result<Vec<Entry>> {
let raw = services.list_entries(dir).unwrap_or_else(|| Err(std::io::Error::other("this host cannot list directories")))?;
let mut out: Vec<Entry> = raw
.into_iter()
.filter(|e| !e.name.starts_with('.'))
.filter_map(|e| {
let path = join(dir, &e.name);
if e.is_dir {
return Some(Entry { numbered: false, name: e.name, path, is_dir: true, kind: "folder", size: None, modified: e.modified });
}
let kind = kind_of(&e.name)?;
if !passes(filter, kind, &e.name) {
return None;
}
let numbered = kind == "image" && filmcraft_media::sequence::Numbered::parse(&path).is_some();
Some(Entry { numbered, name: e.name, path, is_dir: false, kind, size: e.size, modified: e.modified })
})
.collect();
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
/// The directory shown: the current one, else the last one, else home, else the first volume.
pub fn current_dir(s: &Session) -> String {
if !s.browser.dir.is_empty() {
return s.browser.dir.clone();
}
let last = &s.prefs.media_browser.last_dir;
if !last.is_empty() {
return last.clone();
}
s.services.home_dir().or_else(|| s.services.volumes().first().map(|v| v.path.clone())).unwrap_or_else(|| "/".into())
}
fn save_prefs(s: &mut Session, prefs: crate::autosave::Preferences) -> Result<()> {
if prefs != s.prefs {
s.set_prefs(prefs).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
}
Ok(())
}
fn state_json(s: &Session) -> Value {
json!({
"dir": current_dir(s),
"canBack": !s.browser.back.is_empty(),
"canForward": !s.browser.forward.is_empty(),
"canUp": parent(&current_dir(s)).is_some(),
"selection": s.browser.selection,
"favorite": s.prefs.media_browser.favorites.contains(&current_dir(s)),
})
}
fn roots(s: &mut Session, _: &Value) -> Result<Value> {
let vols = s.services.volumes();
let (local, network): (Vec<Volume>, Vec<Volume>) = vols.into_iter().partition(|v| v.kind == VolumeKind::Local);
let mb = &s.prefs.media_browser;
let named = |v: &Vec<String>| v.iter().map(|p| json!({"name": base_name(p), "path": p})).collect::<Vec<_>>();
Ok(json!({
"favorites": named(&mb.favorites),
"localDrives": local,
"network": network,
"recent": named(&mb.recent),
"home": s.services.home_dir(),
}))
}
fn list_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let dir = str_p(p, "path").map(str::to_string).unwrap_or_else(|| current_dir(s));
let filter = str_p(p, "fileTypes").map(str::to_string).unwrap_or_else(|| s.prefs.media_browser.file_types.clone());
let entries = list(&*s.services, &dir, &filter).map_err(|e| bad("mediaBrowser.list", format!("{dir}: {e}")))?;
let mut out = state_json(s);
out["dir"] = json!(dir);
out["fileTypes"] = json!(filter);
out["entries"] = serde_json::to_value(&entries).unwrap_or_default();
Ok(out)
}
fn go(s: &mut Session, dir: String, history: bool) -> Result<()> {
// the directory must be listable
list(&*s.services, &dir, "all").map_err(|e| bad("mediaBrowser.navigate", format!("{dir}: {e}")))?;
let cur = current_dir(s);
if history && cur != dir {
s.browser.back.push(cur);
s.browser.forward.clear();
}
s.browser.dir = dir.clone();
s.browser.selection.clear();
let mut prefs = s.prefs.clone();
let mb = &mut prefs.media_browser;
mb.recent.retain(|r| *r != dir);
mb.recent.insert(0, dir.clone());
mb.recent.truncate(RECENT_MAX);
mb.last_dir = dir;
save_prefs(s, prefs)
}
fn navigate(s: &mut Session, p: &Value) -> Result<Value> {
if bool_p(p, "back") == Some(true) {
let to = s.browser.back.pop().ok_or_else(|| bad("mediaBrowser.navigate", "nothing to go back to"))?;
let cur = current_dir(s);
if let Err(e) = go(s, to.clone(), false) {
s.browser.back.push(to);
return Err(e);
}
s.browser.forward.push(cur);
} else if bool_p(p, "forward") == Some(true) {
let to = s.browser.forward.pop().ok_or_else(|| bad("mediaBrowser.navigate", "nothing to go forward to"))?;
let cur = current_dir(s);
if let Err(e) = go(s, to.clone(), false) {
s.browser.forward.push(to);
return Err(e);
}
s.browser.back.push(cur);
} else if bool_p(p, "up") == Some(true) {
let to = parent(&current_dir(s)).ok_or_else(|| bad("mediaBrowser.navigate", "already at the top"))?;
go(s, to, true)?;
} else {
let path = str_p(p, "path").ok_or_else(|| bad("mediaBrowser.navigate", "need `path`, or `back` / `forward` / `up`"))?;
go(s, path.to_string(), true)?;
}
list_cmd(s, &json!({}))
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
let paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();
s.browser.selection = paths;
Ok(state_json(s))
}
fn favorite(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").map(str::to_string).unwrap_or_else(|| current_dir(s));
let remove = bool_p(p, "remove").unwrap_or(false);
let mut prefs = s.prefs.clone();
let fav = &mut prefs.media_browser.favorites;
if remove {
if !fav.contains(&path) {
return Err(bad("mediaBrowser.favorite", format!("{path} is not a favorite")));
}
fav.retain(|f| *f != path);
} else if !fav.contains(&path) {
fav.push(path);
}
save_prefs(s, prefs)?;
Ok(json!({"favorites": s.prefs.media_browser.favorites}))
}
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let mut prefs = s.prefs.clone();
let mb = &mut prefs.media_browser;
if let Some(v) = str_p(p, "fileTypes") {
let v = v.trim().trim_start_matches('.').to_ascii_lowercase();
if !FILE_TYPES.iter().any(|f| f.0 == v) && !extensions().contains(&v.as_str()) {
return Err(bad("mediaBrowser.settings", format!("unknown file type `{v}`")));
}
mb.file_types = v;
}
if let Some(v) = str_p(p, "view") {
mb.view = match v.to_ascii_lowercase().as_str() {
"list" => "list".into(),
"thumbnails" | "thumbnail" | "icon" => "thumbnails".into(),
_ => return Err(bad("mediaBrowser.settings", "view is `list` or `thumbnails`")),
};
}
if let Some(a) = p.get("columns").and_then(Value::as_array) {
let mut cols = vec!["Name".to_string()];
for c in a.iter().filter_map(Value::as_str) {
let c = ALL_COLUMNS.iter().find(|x| x.eq_ignore_ascii_case(c)).ok_or_else(|| bad("mediaBrowser.settings", format!("unknown column `{c}`")))?;
if !cols.iter().any(|x| x == c) {
cols.push(c.to_string());
}
}
mb.columns = cols;
}
if let Some(v) = bool_p(p, "importAsImageSequence") {
mb.import_as_image_sequence = v;
}
if let Some(v) = bool_p(p, "hoverScrub") {
mb.hover_scrub = v;
}
if let Some(v) = f64_p(p, "thumbnailSize") {
mb.thumbnail_size = (v as f32).clamp(60.0, 320.0);
}
save_prefs(s, prefs)?;
Ok(serde_json::to_value(&s.prefs.media_browser).unwrap_or_default())
}
fn paths_p(s: &Session, p: &Value) -> Vec<String> {
match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
None => str_p(p, "path").map(|x| vec![x.to_string()]).unwrap_or_else(|| s.browser.selection.clone()),
}
}
/// Most folders one import walks, and how deep it goes: a folder import mirrors the tree as bins,
/// and symlinked folders can loop.
const FOLDER_IMPORT_MAX_FOLDERS: usize = 4096;
const FOLDER_IMPORT_MAX_DEPTH: usize = 32;
/// A folder to import as a bin: its name, its files and its sub-folders that hold files.
struct Folder {
name: String,
files: Vec<String>,
subs: Vec<Folder>,
}
/// The files the browser shows in `dir` and its sub-folders, as a tree; `None` when the tree
/// holds none (or `budget` folders were walked already).
fn walk_folder(services: &dyn Services, dir: &str, depth: usize, budget: &mut usize) -> Option<Folder> {
*budget = budget.checked_sub(1)?;
let entries = list(services, dir, "all").ok()?;
let mut folder = Folder { name: base_name(dir), files: Vec::new(), subs: Vec::new() };
for e in entries {
if !e.is_dir {
folder.files.push(e.path);
} else if depth < FOLDER_IMPORT_MAX_DEPTH
&& let Some(sub) = walk_folder(services, &e.path, depth + 1, budget)
{
folder.subs.push(sub);
}
}
(!folder.files.is_empty() || !folder.subs.is_empty()).then_some(folder)
}
/// Add a `file.import` result to the merged one: arrays add up, other values (the Ingest result)
/// are listed.
fn merge_import(out: &mut serde_json::Map<String, Value>, r: Value) {
let Value::Object(r) = r else { return };
for (k, v) in r {
let slot = out.entry(k).or_insert_with(|| json!([]));
let Value::Array(all) = slot else { continue };
match v {
Value::Null => {}
Value::Array(a) => all.extend(a),
other => all.push(other),
}
}
}
/// Run `file.import` and merge its result; a failure becomes an error of the merged result.
fn import_into(s: &mut Session, files: &[String], bin: Option<u64>, out: &mut serde_json::Map<String, Value>) {
let mut q = json!({"paths": files, "imageSequence": false});
if let Some(b) = bin {
q["bin"] = json!(b);
}
match s.execute("file.import", q) {
Ok(r) => merge_import(out, r),
Err(e) => merge_import(out, json!({"errors": [e.to_string()]})),
}
}
/// Import a folder as a bin named after it, in `parent`: its files go into the bin, each
/// sub-folder becomes a bin inside it (Premiere's Media Browser keeps the folder structure).
fn import_folder(s: &mut Session, folder: &Folder, parent: Option<u64>, out: &mut serde_json::Map<String, Value>) -> Result<()> {
let name = folder.name.clone();
let bin = s.edit("New Bin", |pr, _| Ok(pr.add_bin(&name, parent.map(filmcraft_project::BinId))))?;
merge_import(out, json!({"bins": [bin.0]}));
if !folder.files.is_empty() {
import_into(s, &folder.files, Some(bin.0), out);
}
// recursion as deep as the walk went, which is bounded
for sub in &folder.subs {
import_folder(s, sub, Some(bin.0), out)?;
}
Ok(())
}
/// `mediaBrowser.import {paths?, bin?, imageSequence?}`: imports files. A folder becomes a bin
/// named after it (in `bin`) holding its files, and each sub-folder a bin inside that, as in
/// Premiere; the result then also lists the new `bins`. Import as Image Sequence takes the first
/// file instead.
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 bin = u64_p(p, "bin");
let mut files = Vec::new();
let mut folders = Vec::new();
let mut empty = Vec::new();
let mut budget = FOLDER_IMPORT_MAX_FOLDERS;
for path in paths {
let is_dir = matches!(s.services.list_entries(&path), Some(Ok(_))) && kind_of(&path).is_none();
if !is_dir {
files.push(path);
} else if seq {
// an image sequence: the folder's frames
match list(&*s.services, &path, "all") {
Ok(entries) => files.extend(entries.into_iter().filter(|e| !e.is_dir).map(|e| e.path)),
Err(_) => files.push(path),
}
} else {
match walk_folder(&*s.services, &path, 0, &mut budget) {
Some(f) => folders.push(f),
None => empty.push(path),
}
}
}
// an image sequence imports from its first selected frame only
if seq {
files.truncate(1);
}
if folders.is_empty() {
if files.is_empty() && !empty.is_empty() {
return Err(bad("mediaBrowser.import", format!("no media files in {}", empty.join(", "))));
}
let mut q = json!({"paths": files, "imageSequence": seq});
if let Some(b) = bin {
q["bin"] = json!(b);
}
return s.execute("file.import", q);
}
let mut out = serde_json::Map::new();
merge_import(&mut out, json!({"items": [], "errors": empty.iter().map(|e| format!("{e}: no media files")).collect::<Vec<_>>(), "bins": []}));
if !files.is_empty() {
import_into(s, &files, bin, &mut out);
}
for f in &folders {
import_folder(s, f, bin, &mut out)?;
}
Ok(Value::Object(out))
}
/// 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). Only the container's index is read; a format
/// without a streaming reader is read whole only when small (see [`MediaPool::probe_file`]), so
/// browsing a folder of large AVI or WAV files doesn't read all of them (#157).
///
/// [`MediaPool::probe_file`]: crate::media_pool::MediaPool::probe_file
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.probe_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()).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()).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. Windows prefers `USERPROFILE`: shells like Git Bash set
/// `HOME` to a Unix-style path (`/c/Users/…`) that native file APIs cannot open.
pub fn std_home_dir() -> Option<String> {
let vars = if cfg!(windows) { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
vars.iter().find_map(|v| std::env::var(v).ok().filter(|h| !h.is_empty()))
}