Files
effectcraft/crates/engine/src/media_browser.rs
T

271 lines
10 KiB
Rust

//! Media Browser (Window ▸ Media Browser): browse the local file system for footage, keep
//! favourite folders, read file metadata, and import (`file.import`). Desktop only: the web build
//! has no file system to browse ([`available`] is false there).
//!
//! Also the metadata the Metadata panel shows for project items and files (codec, size, rate,
//! duration, colour profile, dates).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use effectcraft_project::{Footage, FootageKind, Item, ItemKind, Project};
/// Media Browser state (in [`crate::EditorState`], serde for agents).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BrowserState {
/// The folder shown (`None` = the home folder).
#[serde(default)]
pub folder: Option<String>,
/// Favourite folders.
#[serde(default)]
pub favorites: Vec<String>,
/// Show only importable files.
#[serde(default)]
pub importable_only: bool,
}
/// Whether this build can browse files.
pub fn available() -> bool {
!cfg!(target_arch = "wasm32")
}
/// One directory entry.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Entry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
/// Seconds since the Unix epoch.
pub modified: Option<u64>,
/// Footage kind guessed from the extension (`video`, `image`, `audio`, `data`, `model`,
/// `project`), `None` for other files and folders.
pub kind: Option<&'static str>,
}
const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"];
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
const MODEL: &[&str] = &["gltf", "glb", "obj"];
/// Kind of file from its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
let e = ext.as_str();
if VIDEO.contains(&e) {
Some("video")
} else if IMAGE.contains(&e) {
Some("image")
} else if AUDIO.contains(&e) {
Some("audio")
} else if DATA.contains(&e) {
Some("data")
} else if MODEL.contains(&e) {
Some("model")
} else if matches!(e, "ecproj" | "ectemplate" | "json5") {
Some("project")
} else {
None
}
}
/// The user's home folder (or `/`).
pub fn home() -> String {
std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_else(|_| "/".into())
}
fn unix(t: std::io::Result<std::time::SystemTime>) -> Option<u64> {
t.ok()?.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())
}
/// List a folder: folders first, then files, by name (hidden entries skipped).
pub fn list(dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
if !available() {
return Err("the Media Browser needs the desktop app".into());
}
let rd = std::fs::read_dir(dir).map_err(|e| format!("{dir}: {e}"))?;
let mut out: Vec<Entry> = rd
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
return None;
}
let md = e.metadata().ok()?;
let path = e.path().to_string_lossy().to_string();
let kind = if md.is_dir() { None } else { kind_of(&path) };
if importable_only && !md.is_dir() && kind.is_none() {
return None;
}
Some(Entry { name, path, is_dir: md.is_dir(), size: if md.is_dir() { 0 } else { md.len() }, modified: unix(md.modified()), kind })
})
.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 parent folder of `dir`.
pub fn parent(dir: &str) -> Option<String> {
std::path::Path::new(dir).parent().map(|p| p.to_string_lossy().to_string()).filter(|p| !p.is_empty())
}
/// Format seconds since the epoch as an ISO 8601 UTC date-time.
pub fn iso_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}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
}
/// File dates and size: (created, modified, size).
pub fn file_dates(path: &str) -> (Option<u64>, Option<u64>, Option<u64>) {
if !available() || path.is_empty() {
return (None, None, None);
}
match std::fs::metadata(path) {
Ok(md) => (unix(md.created()), unix(md.modified()), Some(md.len())),
Err(_) => (None, None, None),
}
}
fn kind_name(k: FootageKind) -> &'static str {
match k {
FootageKind::Video => "video",
FootageKind::Still => "still",
FootageKind::Sequence => "sequence",
FootageKind::Audio => "audio",
FootageKind::Model => "model",
FootageKind::Data => "data",
}
}
/// Metadata of footage (the fields the Metadata panel lists).
pub fn footage_metadata(f: &Footage) -> Value {
let (created, modified, size) = file_dates(f.sequence.first().map(String::as_str).unwrap_or(&f.path));
json!({
"path": f.path,
"kind": kind_name(f.kind),
"codec": f.codec,
"width": f.width,
"height": f.height,
"pixelAspect": f.pixel_aspect,
"frameRate": f.frame_rate.as_f64(),
"duration": f.duration.seconds(),
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.duration)) } else { None },
"hasVideo": f.has_video,
"hasAudio": f.has_audio,
"alpha": format!("{:?}", f.alpha),
"colorProfile": f.color_profile.map(|c| format!("{c:?}")).unwrap_or_else(|| "sRGB (default)".into()),
"linearLight": f.linear_light,
"fields": format!("{:?}", f.fields),
"sequenceFiles": f.sequence.len(),
"dataRows": f.data.as_ref().map(|d| d.lines().count().saturating_sub(1)),
"created": created.map(iso_date),
"modified": modified.map(iso_date),
"fileSize": size,
"missing": f.missing,
})
}
/// Metadata of a project item.
pub fn item_metadata(p: &Project, it: &Item) -> Value {
let mut v = json!({
"id": it.id.0,
"name": it.name,
"type": it.type_name(),
"comment": it.comment,
"label": format!("{:?}", it.label),
});
let extra = match &it.kind {
ItemKind::Footage(f) => footage_metadata(f),
ItemKind::Comp(c) => json!({
"width": c.width,
"height": c.height,
"pixelAspect": c.pixel_aspect,
"frameRate": c.frame_rate.as_f64(),
"duration": c.duration.seconds(),
"layers": c.layers.len(),
"workArea": [c.work_area.0.seconds(), c.work_area.1.seconds()],
}),
ItemKind::Solid(s) => json!({"width": s.width, "height": s.height, "color": s.color}),
ItemKind::Folder => json!({"items": p.items.values().filter(|i| i.parent == Some(it.id)).count()}),
};
if let (Value::Object(a), Value::Object(b)) = (&mut v, extra) {
a.extend(b);
}
if let Some(px) = &it.proxy {
v["proxy"] = json!(px.footage.path);
}
v
}
/// Project-level metadata.
pub fn project_metadata(p: &Project, path: Option<&str>) -> Value {
let st = &p.settings;
let count = |f: &dyn Fn(&ItemKind) -> bool| p.items.values().filter(|i| f(&i.kind)).count();
json!({
"path": path,
"comment": st.comment,
"bitDepth": st.bit_depth.label(),
"workingSpace": st.working_space.map(|c| format!("{c:?}")),
"linearize": st.linearize,
"timeDisplay": format!("{:?}", st.time_display),
"audioSampleRate": st.audio_sample_rate,
"items": p.items.len(),
"compositions": count(&|k| matches!(k, ItemKind::Comp(_))),
"footage": count(&|k| matches!(k, ItemKind::Footage(_))),
"solids": count(&|k| matches!(k, ItemKind::Solid(_))),
"folders": count(&|k| matches!(k, ItemKind::Folder)),
"renderQueue": p.render_queue.len(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_a_folder() {
let dir = std::env::temp_dir().join(format!("ec-mb-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Shots")).unwrap();
std::fs::write(dir.join("b.png"), b"x").unwrap();
std::fs::write(dir.join("A.mov"), b"xy").unwrap();
std::fs::write(dir.join("notes.txt"), b"xyz").unwrap();
std::fs::write(dir.join(".hidden"), b"").unwrap();
let d = dir.to_string_lossy().to_string();
let all = list(&d, false).unwrap();
let names: Vec<&str> = all.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["Shots", "A.mov", "b.png", "notes.txt"]);
assert!(all[0].is_dir);
assert_eq!(all[1].kind, Some("video"));
assert_eq!(all[2].kind, Some("image"));
assert_eq!(all[1].size, 2);
let media = list(&d, true).unwrap();
assert_eq!(media.len(), 3);
assert_eq!(parent(&dir.join("Shots").to_string_lossy()).as_deref(), Some(d.as_str()));
assert!(list(&dir.join("nope").to_string_lossy(), false).is_err());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn iso_dates() {
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
assert_eq!(iso_date(1_700_000_000), "2023-11-14T22:13:20Z");
assert_eq!(iso_date(951_782_400), "2000-02-29T00:00:00Z");
}
}