mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
Import: item.metadata counts a WebM's last frame when its duration is not a whole millisecond
511 lines
20 KiB
Rust
511 lines
20 KiB
Rust
//! Media Browser (Window ▸ Media Browser): browse for footage, keep favourite folders, read file
|
|
//! metadata, and import (`file.import`).
|
|
//!
|
|
//! What it browses is a [`Browser`]: the local file system on the desktop ([`FsBrowser`]); in the
|
|
//! web app, a virtual tree ([`VirtualTree`]) of the browser's storage and of folders the user
|
|
//! opened through the File System Access API, whose files are read only when imported
|
|
//! ([`Browser::fetch`]).
|
|
//!
|
|
//! Also the metadata the Metadata panel shows for project items and files (codec, size, rate,
|
|
//! duration, colour profile, dates).
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
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 the local file system ([`FsBrowser`]).
|
|
pub fn available() -> bool {
|
|
!cfg!(target_arch = "wasm32")
|
|
}
|
|
|
|
/// Where the Media Browser browses.
|
|
pub trait Browser: Send + Sync {
|
|
/// The folder shown first.
|
|
fn home(&self) -> String;
|
|
/// Places listed above the favourites: (name, folder). The default is Home.
|
|
fn places(&self) -> Vec<(String, String)> {
|
|
vec![("Home".into(), self.home())]
|
|
}
|
|
/// A folder's entries: folders first, then files, by name.
|
|
fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String>;
|
|
/// The folder above `dir`.
|
|
fn parent(&self, dir: &str) -> Option<String> {
|
|
parent(dir)
|
|
}
|
|
/// Make `paths` readable by the importer. `false`: they are being fetched, and the host runs
|
|
/// `mediaBrowser.import` with `params` again once they are (the web app reads opened folders'
|
|
/// files on demand).
|
|
fn fetch(&self, paths: &[String], params: &Value) -> bool {
|
|
let _ = (paths, params);
|
|
true
|
|
}
|
|
/// Extra toolbar actions: (id, label), run by `mediaBrowser.action`.
|
|
fn actions(&self) -> Vec<(String, String)> {
|
|
vec![]
|
|
}
|
|
fn action(&self, id: &str) -> Result<Value, String> {
|
|
Err(format!("unknown Media Browser action `{id}`"))
|
|
}
|
|
}
|
|
|
|
/// The local file system (desktop).
|
|
pub struct FsBrowser;
|
|
|
|
impl Browser for FsBrowser {
|
|
fn home(&self) -> String {
|
|
home()
|
|
}
|
|
fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
|
list(dir, importable_only)
|
|
}
|
|
}
|
|
|
|
impl crate::Session {
|
|
/// What the Media Browser browses: [`crate::Session::browser`], else the local file system
|
|
/// where this build has one.
|
|
pub fn media_browser(&self) -> Option<std::sync::Arc<dyn Browser>> {
|
|
self.browser.clone().or_else(|| available().then(|| std::sync::Arc::new(FsBrowser) as std::sync::Arc<dyn Browser>))
|
|
}
|
|
}
|
|
|
|
/// One entry of a [`VirtualTree`].
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct VNode {
|
|
/// What importing it reads (`None`: a folder).
|
|
import: Option<String>,
|
|
size: u64,
|
|
modified: Option<u64>,
|
|
}
|
|
|
|
/// A browsable tree of virtual paths (`/Folder/sub/file.mov`), each file standing for an
|
|
/// importable path (the web app's file table, or a file of an opened folder).
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct VirtualTree {
|
|
nodes: BTreeMap<String, VNode>,
|
|
}
|
|
|
|
fn vparent(path: &str) -> Option<&str> {
|
|
if path == "/" || path.is_empty() {
|
|
return None;
|
|
}
|
|
let p = path.trim_end_matches('/');
|
|
match p.rfind('/') {
|
|
Some(0) => Some("/"),
|
|
Some(i) => Some(&p[..i]),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
impl VirtualTree {
|
|
/// Add a folder (and the folders above it).
|
|
pub fn add_dir(&mut self, path: &str) {
|
|
let mut cur = Some(path.trim_end_matches('/'));
|
|
while let Some(p) = cur.filter(|p| !p.is_empty() && *p != "/") {
|
|
self.nodes.entry(p.to_string()).or_insert(VNode { import: None, size: 0, modified: None });
|
|
cur = vparent(p);
|
|
}
|
|
}
|
|
|
|
/// Add a file shown at `path` that imports `import`.
|
|
pub fn add_file(&mut self, path: &str, import: &str, size: u64, modified: Option<u64>) {
|
|
if let Some(p) = vparent(path) {
|
|
self.add_dir(p);
|
|
}
|
|
self.nodes.insert(path.to_string(), VNode { import: Some(import.to_string()), size, modified });
|
|
}
|
|
|
|
/// Remove `path` and everything under it.
|
|
pub fn remove(&mut self, path: &str) {
|
|
let pre = format!("{}/", path.trim_end_matches('/'));
|
|
self.nodes.retain(|k, _| k != path && !k.starts_with(&pre));
|
|
}
|
|
|
|
pub fn is_dir(&self, path: &str) -> bool {
|
|
path == "/" || self.nodes.get(path.trim_end_matches('/')).is_some_and(|n| n.import.is_none())
|
|
}
|
|
|
|
/// What a file shown at `path` imports.
|
|
pub fn import_path(&self, path: &str) -> Option<&str> {
|
|
self.nodes.get(path).and_then(|n| n.import.as_deref())
|
|
}
|
|
|
|
pub fn list(&self, dir: &str, importable_only: bool) -> Result<Vec<Entry>, String> {
|
|
let dir = if dir.len() > 1 { dir.trim_end_matches('/') } else { dir };
|
|
if !self.is_dir(dir) {
|
|
return Err(format!("{dir}: no such folder"));
|
|
}
|
|
let mut out: Vec<Entry> = self
|
|
.nodes
|
|
.iter()
|
|
.filter(|(k, _)| vparent(k) == Some(dir))
|
|
.filter_map(|(k, n)| {
|
|
let name = k.rsplit('/').next().unwrap_or(k).to_string();
|
|
let kind = n.import.as_deref().and_then(kind_of);
|
|
if importable_only && n.import.is_some() && kind.is_none() {
|
|
return None;
|
|
}
|
|
Some(Entry {
|
|
name,
|
|
// Folders browse by their virtual path; files import their own path.
|
|
path: n.import.clone().unwrap_or_else(|| k.clone()),
|
|
is_dir: n.import.is_none(),
|
|
size: n.size,
|
|
modified: n.modified,
|
|
kind,
|
|
})
|
|
})
|
|
.collect();
|
|
sort_entries(&mut out);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
fn sort_entries(entries: &mut [Entry]) {
|
|
entries.sort_by_cached_key(|e| (!e.is_dir, e.name.to_lowercase()));
|
|
}
|
|
|
|
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", "cin", "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", "stl", "fbx"];
|
|
|
|
/// 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();
|
|
sort_entries(&mut out);
|
|
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(),
|
|
// Rounded like the media pool's frame count: Matroska stores the duration in whole
|
|
// milliseconds, a hair short of the last frame at 30 fps (#1429).
|
|
"frames": if f.has_video && f.kind != FootageKind::Still { Some(f.frame_rate.frame_at(f.frame_rate.snap_nearest(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,
|
|
"preserveRgb": f.preserve_rgb,
|
|
"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::write(dir.join("Ω.csv"), b"x").unwrap();
|
|
std::fs::write(dir.join("作品.csv"), b"x").unwrap();
|
|
let all = list(&d, false).unwrap();
|
|
assert_eq!(all.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["Shots", "A.mov", "b.png", "notes.txt", "Ω.csv", "作品.csv"]);
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn virtual_tree_lists_like_a_folder() {
|
|
let mut t = VirtualTree::default();
|
|
t.add_file("/Browser Storage/clip.mov", "/clip.mov", 10, Some(5));
|
|
t.add_file("/Folders/Shoot/B/b.png", "/Folders/Shoot/B/b.png", 3, None);
|
|
t.add_file("/Folders/Shoot/a.wav", "/Folders/Shoot/a.wav", 4, None);
|
|
t.add_file("/Folders/Shoot/notes.txt", "/Folders/Shoot/notes.txt", 1, None);
|
|
t.add_dir("/Folders/Empty");
|
|
let names = |t: &VirtualTree, d: &str, only: bool| t.list(d, only).unwrap().into_iter().map(|e| e.name).collect::<Vec<_>>();
|
|
assert_eq!(names(&t, "/", false), ["Browser Storage", "Folders"]);
|
|
assert_eq!(names(&t, "/Folders", false), ["Empty", "Shoot"]);
|
|
assert_eq!(names(&t, "/Folders/Shoot", false), ["B", "a.wav", "notes.txt"]);
|
|
assert_eq!(names(&t, "/Folders/Shoot/", true), ["B", "a.wav"]);
|
|
let e = &t.list("/Browser Storage", false).unwrap()[0];
|
|
assert_eq!((e.path.as_str(), e.size, e.modified, e.kind, e.is_dir), ("/clip.mov", 10, Some(5), Some("video"), false));
|
|
let d = &t.list("/Folders", false).unwrap()[1];
|
|
assert_eq!((d.path.as_str(), d.is_dir), ("/Folders/Shoot", true));
|
|
assert_eq!(t.import_path("/Browser Storage/clip.mov"), Some("/clip.mov"));
|
|
assert!(t.list("/Folders/Shoot/a.wav", false).is_err(), "a file is not a folder");
|
|
assert!(t.list("/Nope", false).is_err());
|
|
t.remove("/Folders/Shoot");
|
|
assert_eq!(names(&t, "/Folders", false), ["Empty"]);
|
|
assert_eq!(vparent("/a"), Some("/"));
|
|
assert_eq!(vparent("/"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn listings_keep_folders_first_and_stable_unicode_order() {
|
|
let mut tree = VirtualTree::default();
|
|
for name in ["作品.csv", "ä.csv", "Ä.csv", "İ.csv", "B.csv", "a.csv", "A.csv"] {
|
|
tree.add_file(&format!("/Shoot/{name}"), &format!("/files/{name}"), 1, None);
|
|
}
|
|
for name in ["zFolder", "Alpha"] {
|
|
tree.add_dir(&format!("/Shoot/{name}"));
|
|
}
|
|
let entries = tree.list("/Shoot", true).unwrap();
|
|
assert_eq!(
|
|
entries.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
|
["Alpha", "zFolder", "A.csv", "a.csv", "B.csv", "İ.csv", "Ä.csv", "ä.csv", "作品.csv"]
|
|
);
|
|
assert_eq!(entries[2].path, "/files/A.csv");
|
|
let mut reversed = entries.iter().rev().cloned().collect::<Vec<_>>();
|
|
sort_entries(&mut reversed);
|
|
assert_eq!(
|
|
reversed.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
|
["Alpha", "zFolder", "a.csv", "A.csv", "B.csv", "İ.csv", "ä.csv", "Ä.csv", "作品.csv"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "Media Browser filename sorting benchmark"]
|
|
fn directory_sort_benchmark() {
|
|
let entries: Vec<_> = (0..1000)
|
|
.map(|i| Entry {
|
|
name: format!("作品 {} {:04}.mov", if i % 2 == 0 { "Clip" } else { "clip" }, i * 7919 % 1000),
|
|
path: format!("/Shoot/{i}.mov"),
|
|
is_dir: i % 7 == 0,
|
|
size: 1,
|
|
modified: None,
|
|
kind: Some("video"),
|
|
})
|
|
.collect();
|
|
let mut before = Vec::new();
|
|
let mut after = Vec::new();
|
|
for _ in 0..5 {
|
|
let mut old = entries.clone();
|
|
let start = std::time::Instant::now();
|
|
old.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
|
before.push(start.elapsed());
|
|
let mut new = entries.clone();
|
|
let start = std::time::Instant::now();
|
|
sort_entries(&mut new);
|
|
after.push(start.elapsed());
|
|
assert_eq!(old, new);
|
|
std::hint::black_box(new);
|
|
}
|
|
before.sort();
|
|
after.sort();
|
|
println!("1,000 filename sort, median: {:?} -> {:?}", before[2], after[2]);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|