Merge main into M13.12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 09:51:48 -04:00
co-authored by Claude Opus 5.5
335 changed files with 29661 additions and 398 deletions
+55 -13
View File
@@ -29,38 +29,61 @@ fn jobs_wait(s: &mut Session, _: &Value) -> Result<Value> {
// ---------------------------------------------------------------- Media Browser
fn browser_dir(s: &Session, p: &Value) -> String {
str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(mb::home)
fn browser(s: &Session) -> Result<std::sync::Arc<dyn mb::Browser>> {
s.media_browser().ok_or_else(|| EngineError::Other("the Media Browser is not available in this build".into()))
}
fn browser_dir(s: &Session, b: &dyn mb::Browser, p: &Value) -> String {
str_p(p, "path").map(str::to_string).or_else(|| s.state.media_browser.folder.clone()).unwrap_or_else(|| b.home())
}
fn browser_list(s: &mut Session, p: &Value) -> Result<Value> {
let dir = browser_dir(s, p);
let b = browser(s)?;
let dir = browser_dir(s, b.as_ref(), p);
let only = b_p(p, "importableOnly").unwrap_or(s.state.media_browser.importable_only);
let entries = mb::list(&dir, only).map_err(EngineError::Other)?;
Ok(json!({"path": dir, "parent": mb::parent(&dir), "entries": entries, "favorites": s.state.media_browser.favorites}))
let entries = b.list(&dir, only).map_err(EngineError::Other)?;
let places: Vec<Value> = b.places().into_iter().map(|(name, path)| json!({"name": name, "path": path})).collect();
let actions: Vec<Value> = b.actions().into_iter().map(|(id, label)| json!({"id": id, "label": label})).collect();
Ok(json!({
"path": dir,
"parent": b.parent(&dir),
"entries": entries,
"favorites": s.state.media_browser.favorites,
"places": places,
"actions": actions,
}))
}
fn browser_enabled(_: &Session) -> std::result::Result<(), String> {
if mb::available() { Ok(()) } else { Err("the Media Browser needs the desktop app".into()) }
fn browser_enabled(s: &Session) -> std::result::Result<(), String> {
if s.media_browser().is_some() { Ok(()) } else { Err("the Media Browser is not available in this build".into()) }
}
fn browser_go(s: &mut Session, p: &Value) -> Result<Value> {
let b = browser(s)?;
let dir = match str_p(p, "path") {
Some("..") => s.state.media_browser.folder.clone().and_then(|d| mb::parent(&d)).unwrap_or_else(mb::home),
Some("..") => s.state.media_browser.folder.clone().and_then(|d| b.parent(&d)).unwrap_or_else(|| b.home()),
Some(d) => d.to_string(),
None => mb::home(),
None => b.home(),
};
if let Some(b) = b_p(p, "importableOnly") {
s.state.media_browser.importable_only = b;
if let Some(v) = b_p(p, "importableOnly") {
s.state.media_browser.importable_only = v;
}
// Validate before switching.
mb::list(&dir, false).map_err(EngineError::Other)?;
b.list(&dir, false).map_err(EngineError::Other)?;
s.state.media_browser.folder = Some(dir.clone());
browser_list(s, &json!({"path": dir}))
}
/// A browser-specific toolbar action (web: Open Folder…, Add Files…).
fn browser_action(s: &mut Session, p: &Value) -> Result<Value> {
let b = browser(s)?;
let id = str_p(p, "action").ok_or_else(|| bad("mediaBrowser.action", "missing `action`"))?;
b.action(id).map_err(|e| bad("mediaBrowser.action", e))
}
fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result<Value> {
let dir = browser_dir(s, p);
let b = browser(s)?;
let dir = browser_dir(s, b.as_ref(), p);
let favs = &mut s.state.media_browser.favorites;
favs.retain(|f| f != &dir);
if add {
@@ -71,6 +94,16 @@ fn browser_favorite(s: &mut Session, p: &Value, add: bool) -> Result<Value> {
fn browser_import(s: &mut Session, p: &Value) -> Result<Value> {
let paths = p.get("paths").or(p.get("path")).cloned().ok_or_else(|| bad("mediaBrowser.import", "missing `paths`"))?;
// Files the browser still has to read (the web app's opened folders): imported when they
// arrive.
let list: Vec<String> = match &paths {
Value::String(one) => vec![one.clone()],
Value::Array(a) => a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(),
_ => vec![],
};
if !browser(s)?.fetch(&list, p) {
return Ok(json!({"pending": true, "paths": list}));
}
let r = s.execute("file.import", json!({"paths": paths}))?;
// Add to the active comp (drag into the timeline).
if b_p(p, "addToComp").unwrap_or(false)
@@ -201,6 +234,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("mediaBrowser.addFavorite", "Add to Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, true)),
cmd!("mediaBrowser.removeFavorite", "Remove from Favorites", [], None, "{path?}", browser_enabled, |s, p| browser_favorite(s, p, false)),
cmd!("mediaBrowser.import", "Import", [], None, "{paths, addToComp?}", browser_enabled, browser_import),
cmd!(
"mediaBrowser.action",
"Media Browser Action",
[],
None,
"{action}: an action from mediaBrowser.list's `actions` (web: openFolder, addFiles, uploadFolder)",
browser_enabled,
browser_action
),
query!("mediaBrowser.fileInfo", "File Info", "{path}", file_info),
query!("item.metadata", "Metadata", "{item?: id|name}", metadata),
cmd!("project.setProjectComment", "Project Comment", [], None, "{comment}", always, project_comment),
+72 -5
View File
@@ -213,7 +213,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
}
let before = om.clone();
if let Some(f) = str_p(p, "format") {
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, "format: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff"))?;
om.set_format(f);
}
if let Some(c) = str_p(p, "channels") {
@@ -223,7 +223,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
"alpha" | "alphaonly" => Channels::Alpha,
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
};
if om.channels == Channels::Rgba && !om.format.supports_alpha() {
if om.channels == Channels::Rgba && !om.supports_alpha() {
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
}
}
@@ -234,6 +234,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
any |= apply_codec_options(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
@@ -377,6 +378,62 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
Ok(any)
}
/// HEVC / AV1 codec options, the WebM video codec and the Opus audio options.
fn apply_codec_options(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<bool> {
use effectcraft_project::render_queue::{CodecProfile, OpusApplication, RateControlMode, VideoCodecOptions, WebmVideoCodec};
let mut any = false;
if let Some(c) = str_p(p, "webmCodec") {
om.webm_codec = match c.to_ascii_lowercase().as_str() {
"vp9" => WebmVideoCodec::Vp9,
"av1" => WebmVideoCodec::Av1,
_ => return Err(bad(cmd, "webmCodec: vp9|av1")),
};
if !om.supports_alpha() {
om.channels = Channels::Rgb;
}
any = true;
}
if let Some(v) = str_p(p, "profile") {
om.codec.profile = CodecProfile::from_name(v).ok_or_else(|| bad(cmd, "profile: main|main10"))?;
any = true;
}
match p.get("level") {
Some(Value::Null) => {
om.codec.level = None;
any = true;
}
Some(v) => {
let s = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
om.codec.level = VideoCodecOptions::parse_level(&s).ok_or_else(|| bad(cmd, "level: auto|null or a level like 4.1"))?;
any = true;
}
None => {}
}
if let Some(v) = str_p(p, "rateControl") {
om.codec.rate_control = RateControlMode::from_name(v).ok_or_else(|| bad(cmd, "rateControl: bitrate|quality"))?;
any = true;
}
if let Some(q) = f_p(p, "quality") {
om.codec.quality = q.clamp(1.0, 100.0) as u8;
}
if let Some(b) = f_p(p, "audioBitrate") {
om.opus_bitrate_kbps = b.clamp(6.0, 510.0) as u32;
any = true;
}
if let Some(a) = str_p(p, "opusApplication") {
om.opus_application = match a.to_ascii_lowercase().as_str() {
"audio" | "music" => OpusApplication::Audio,
"voip" | "voice" | "speech" => OpusApplication::Voip,
_ => return Err(bad(cmd, "opusApplication: audio|voice")),
};
any = true;
}
Ok(any)
}
/// `module` (1-based; 1 = the first output module).
fn module_p(s: &Session, i: usize, p: &Value, cmd: &str) -> Result<usize> {
let n = p.get("module").and_then(Value::as_u64).unwrap_or(1) as usize;
@@ -618,7 +675,17 @@ fn formats(s: &mut Session, _: &Value) -> Result<Value> {
})
})
.collect();
Ok(json!({"formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>()}))
use effectcraft_project::render_queue::{CodecProfile, VideoCodecOptions};
let level = |l: &u8| format!("{}.{}", l / 10, l % 10);
Ok(json!({
"formats": v,
"proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>(),
"codecProfiles": CodecProfile::ALL.iter().map(|p| p.label()).collect::<Vec<_>>(),
"hevcLevels": VideoCodecOptions::HEVC_LEVELS.iter().map(level).collect::<Vec<_>>(),
"av1Levels": VideoCodecOptions::AV1_LEVELS.iter().map(level).collect::<Vec<_>>(),
"webmCodecs": ["vp9", "av1"],
"opusApplications": ["audio", "voice"],
}))
}
/// `item?|index?`, else the last item (the queue's newest entry).
@@ -902,7 +969,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
Some("Cmd+M"),
"{comp?: id|name, template?: Render Settings template, format?: h264|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -922,7 +989,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), template?: name, format?, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames, proresProfile?: proxy|lt|standard|hq|4444|4444xq, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
+10
View File
@@ -26,6 +26,7 @@ pub mod offload;
pub mod prefs;
pub mod preview;
pub mod psd_import;
pub mod remote;
pub mod render_queue;
pub mod roto;
pub mod scriptui;
@@ -353,6 +354,11 @@ pub struct Session {
pub offload: Option<Arc<dyn offload::Offload>>,
/// Analyses running in the [`Session::offload`] worker.
pub offloaded: Vec<offload::OffloadedJob>,
/// What the Media Browser browses (`None`: the local file system where there is one).
pub browser: Option<Arc<dyn media_browser::Browser>>,
/// Offloaded jobs that ended, newest last (kind, error): how a caller waiting for one
/// (`wait: true` over the control channel in the browser) learns its outcome.
pub offload_log: Vec<(offload::JobKind, Option<String>)>,
/// Generic background tasks (Content-Aware Fill, Scene Edit Detection), see [`jobs`].
pub tasks: Vec<jobs::Task>,
/// Finished tasks, newest last (Progress panel, `jobs.list`).
@@ -425,6 +431,8 @@ impl Default for Session {
applied_nested_switches: None,
offload: None,
offloaded: vec![],
offload_log: vec![],
browser: None,
tasks: vec![],
job_log: vec![],
next_task_id: 1,
@@ -782,6 +790,8 @@ mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
#[cfg(test)]
mod tests_codec_options;
#[cfg(test)]
mod tests_color_view;
#[cfg(test)]
mod tests_disk_cache;
+178 -4
View File
@@ -1,10 +1,16 @@
//! 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).
//! 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};
@@ -25,11 +31,153 @@ pub struct BrowserState {
pub importable_only: bool,
}
/// Whether this build can browse files.
/// 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();
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)
}
}
/// One directory entry.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -261,6 +409,32 @@ mod tests {
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 iso_dates() {
assert_eq!(iso_date(0), "1970-01-01T00:00:00Z");
+121 -9
View File
@@ -16,10 +16,11 @@
//! | Track Motion / Stabilize Motion | the tracker analysis | the tracker group |
//! | Mask tracking | the mask track | the mask group |
//! | Roto Brush Freeze | the freeze | the effect group |
//! | Roto Brush propagation | the propagation | segmentations ([`WorkerReply::Segs`], streamed while it runs) go into the page's segmentation cache |
//! | 3D Camera Tracker analysis | [`Session::start_camera`] | the effect's property group |
//!
//! Roto Brush propagation fills the session's segmentation cache rather than the project, so it
//! keeps running on the UI thread.
//! Viewer frames go to a separate, long-lived frame worker fed with project diffs
//! ([`crate::remote`]).
//!
//! Cancelling terminates the worker: what an analysis tracked so far is dropped and the Render
//! Queue item being rendered becomes "User Stopped".
@@ -76,6 +77,13 @@ pub enum WorkerJob {
layer: LayerId,
effect: Uid,
},
/// Roto Brush propagation: the segmentations come back as [`WorkerReply::Segs`].
RotoPropagate {
comp: ItemId,
layer: LayerId,
effect: Uid,
direction: crate::roto::Direction,
},
Track {
comp: ItemId,
layer: LayerId,
@@ -103,6 +111,7 @@ impl WorkerJob {
WorkerJob::Warp { .. } => JobKind::Warp,
WorkerJob::Camera { .. } => JobKind::Camera,
WorkerJob::RotoFreeze { .. } => JobKind::RotoFreeze,
WorkerJob::RotoPropagate { .. } => JobKind::RotoPropagate,
WorkerJob::Track { .. } => JobKind::Track,
WorkerJob::MaskTrack { .. } => JobKind::MaskTrack,
}
@@ -112,9 +121,10 @@ impl WorkerJob {
pub fn target(&self) -> Option<(ItemId, LayerId, Uid)> {
match *self {
WorkerJob::Render { .. } => None,
WorkerJob::Warp { comp, layer, effect } | WorkerJob::Camera { comp, layer, effect } | WorkerJob::RotoFreeze { comp, layer, effect } => {
Some((comp, layer, effect))
}
WorkerJob::Warp { comp, layer, effect }
| WorkerJob::Camera { comp, layer, effect }
| WorkerJob::RotoFreeze { comp, layer, effect }
| WorkerJob::RotoPropagate { comp, layer, effect, .. } => Some((comp, layer, effect)),
WorkerJob::Track { comp, layer, tracker, .. } => Some((comp, layer, tracker)),
WorkerJob::MaskTrack { comp, layer, mask, .. } => Some((comp, layer, mask)),
}
@@ -128,6 +138,7 @@ pub enum JobKind {
Warp,
Camera,
RotoFreeze,
RotoPropagate,
Track,
MaskTrack,
}
@@ -140,6 +151,7 @@ impl JobKind {
JobKind::Warp => "Warp Stabilizer Analysis",
JobKind::Camera => "3D Camera Tracker Analysis",
JobKind::RotoFreeze => "Freeze",
JobKind::RotoPropagate => "Roto Brush Propagation",
JobKind::Track => "Analyze Track",
JobKind::MaskTrack => "Track Mask",
}
@@ -170,6 +182,10 @@ pub enum WorkerReply {
group: Box<PropGroup>,
message: String,
},
/// Roto Brush segmentations computed so far (propagation), not sent before.
Segs {
segs: Vec<SegData>,
},
Failed {
error: String,
},
@@ -177,6 +193,41 @@ pub enum WorkerReply {
Done,
}
/// One full-resolution Roto Brush segmentation: its chain key and the base64 of
/// [`effectcraft_track::roto::FrameSeg::to_bytes`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SegData {
pub key: u64,
pub data: String,
}
impl SegData {
pub fn new(key: u64, seg: &effectcraft_track::roto::FrameSeg) -> SegData {
SegData { key, data: effectcraft_track::roto::rle::base64_encode(&seg.to_bytes()) }
}
/// Put the segmentation into this instance's cache (what the Roto Brush effect renders from).
pub fn store(&self) -> bool {
let Some(seg) = effectcraft_track::roto::rle::base64_decode(&self.data).and_then(|b| effectcraft_track::roto::FrameSeg::from_bytes(&b)) else {
return false;
};
effectcraft_effects::roto::store(self.key, 1.0, Arc::new(seg));
true
}
}
/// The full-resolution segmentations of `keys` that are cached here and not in `sent` yet.
pub fn segs_to_send(keys: &[u64], sent: &mut std::collections::HashSet<u64>) -> Vec<SegData> {
let new: Vec<(u64, Arc<effectcraft_track::roto::FrameSeg>)> =
keys.iter().filter(|k| !sent.contains(k)).filter_map(|k| effectcraft_effects::roto::cached(*k, 1.0).map(|s| (*k, s))).collect();
new.into_iter()
.map(|(k, s)| {
sent.insert(k);
SegData::new(k, &s)
})
.collect()
}
/// Replies waiting for the UI thread.
#[derive(Default)]
pub struct Inbox(Mutex<Vec<WorkerReply>>);
@@ -292,6 +343,7 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
WorkerJob::RotoFreeze { comp, layer, effect } => analysis(s, kind, target, post, |s| {
s.start_roto(comp, layer, effect, crate::roto::RotoTask::Freeze, crate::roto::Direction::Both, true).map(|_| ())
}),
WorkerJob::RotoPropagate { comp, layer, effect, direction } => propagate(s, comp, layer, effect, direction, post),
WorkerJob::Track { comp, layer, tracker, direction, settings, points, times } => analysis(s, kind, target, post, |s| {
let work = crate::tracking::Work {
project: s.project.clone(),
@@ -323,6 +375,38 @@ fn run_job(s: &mut Session, job: WorkerJob, post: Post) -> Result<(), String> {
}
}
/// Roto Brush propagation in a worker: the segmentations stream back (at most every 250 ms)
/// as they are computed, so stopping the job keeps what was propagated.
fn propagate(s: &mut Session, comp: ItemId, layer: LayerId, effect: Uid, dir: crate::roto::Direction, post: Post) -> Result<(), String> {
let keys: Vec<u64> = s.roto_chain(comp, layer, effect).map(|c| c.keys.values().copied().collect()).ok_or("no Roto Brush strokes")?;
let sent = Rc::new(RefCell::new(std::collections::HashSet::new()));
let (p, k2, s2) = (post.clone(), keys.clone(), sent.clone());
let mut last = None::<web_time::Instant>;
let hook = Box::new(move |done: u64, total: u64| {
if last.is_none_or(|t: web_time::Instant| t.elapsed().as_millis() >= 250) {
last = Some(web_time::Instant::now());
let segs = segs_to_send(&k2, &mut s2.borrow_mut());
if !segs.is_empty() {
p(WorkerReply::Segs { segs });
}
p(WorkerReply::Progress { done, total });
}
});
s.events.clear();
with_hook(hook, || s.start_roto(comp, layer, effect, crate::roto::RotoTask::Propagate, dir, true))?;
let segs = segs_to_send(&keys, &mut sent.borrow_mut());
if !segs.is_empty() {
post(WorkerReply::Segs { segs });
}
if let Some(e) = s.events.iter().find_map(|e| match e {
Event::Toast { message, error: true } => Some(message.clone()),
_ => None,
}) {
return Err(e);
}
Ok(())
}
fn analysis(
s: &mut Session,
kind: JobKind,
@@ -398,6 +482,8 @@ pub struct OffloadedJob {
pub uid: Uid,
pub inbox: Arc<Inbox>,
pub progress: OffloadProgress,
/// Segmentations received (Roto Brush propagation).
pub segs: u64,
started: web_time::Instant,
}
@@ -432,7 +518,17 @@ impl Session {
let inbox = Arc::new(Inbox::default());
let req = WorkerRequest { id, project: self.project.to_json(), files: footage_files(&self.project), job };
off.start(req, inbox.clone())?;
self.offloaded.push(OffloadedJob { id, kind, comp, layer, uid, inbox, progress: OffloadProgress::default(), started: web_time::Instant::now() });
self.offloaded.push(OffloadedJob {
id,
kind,
comp,
layer,
uid,
inbox,
progress: OffloadProgress::default(),
segs: 0,
started: web_time::Instant::now(),
});
self.events.push(Event::ProjectChanged { revision: self.revision });
Ok(())
}
@@ -469,6 +565,12 @@ impl Session {
j.progress.total = total;
}
WorkerReply::Group { comp, layer, group, message } => results.push((j.kind, comp, layer, group, message)),
WorkerReply::Segs { segs } => {
for sd in &segs {
sd.store();
}
j.segs += segs.len() as u64;
}
WorkerReply::Failed { error } => j.progress.error = Some(error),
WorkerReply::Done => j.progress.finished = true,
WorkerReply::Render { .. } | WorkerReply::Item { .. } => {}
@@ -495,17 +597,25 @@ impl Session {
let mut done = vec![];
self.offloaded.retain(|j| {
if j.progress.finished {
done.push((j.kind, j.progress.clone()));
done.push((j.kind, j.progress.clone(), j.segs));
}
!j.progress.finished
});
for (kind, p) in done {
for (kind, p, segs) in done {
changed = true;
self.offload_log.push((kind, p.error.clone().or_else(|| p.cancelled.then(|| "stopped".to_string()))));
if self.offload_log.len() > 32 {
self.offload_log.remove(0);
}
if let Some(e) = p.error {
self.events.push(Event::Toast { message: format!("{}: {e}", kind.label()), error: true });
} else if p.cancelled {
self.events.push(Event::Toast { message: format!("{} stopped", kind.label()), error: false });
let kept = if segs > 0 { format!(" ({segs} frame(s) kept)") } else { String::new() };
self.events.push(Event::Toast { message: format!("{} stopped{kept}", kind.label()), error: false });
} else if kind == JobKind::RotoPropagate {
self.events.push(Event::Toast { message: format!("Roto Brush: propagated {} frame(s) in {:.1} s", segs, p.elapsed), error: false });
}
// Rendered frames depend on the result (an analysis' group, the segmentation cache).
self.bump();
}
changed
@@ -567,6 +677,7 @@ mod tests {
WorkerJob::Warp { comp, layer: LayerId(3), effect: 9 },
WorkerJob::RotoFreeze { comp, layer: LayerId(3), effect: 9 },
WorkerJob::Camera { comp, layer: LayerId(3), effect: 9 },
WorkerJob::RotoPropagate { comp, layer: LayerId(3), effect: 9, direction: crate::roto::Direction::Forward },
WorkerJob::Track {
comp,
layer: LayerId(1),
@@ -606,6 +717,7 @@ mod tests {
WorkerReply::Item { update: ItemUpdate::Finished { id: 3, status: RenderStatus::Failed("x".into()), seconds: 1.5, output: Some("/a.gif".into()) } },
WorkerReply::Progress { done: 1, total: 2 },
WorkerReply::Group { comp, layer: LayerId(1), group: Box::new(g), message: "ok".into() },
WorkerReply::Segs { segs: vec![SegData::new(5, &effectcraft_track::roto::FrameSeg::empty(4, 3))] },
WorkerReply::Failed { error: "boom".into() },
WorkerReply::Done,
] {
+384
View File
@@ -0,0 +1,384 @@
//! Viewer frames rendered by another engine instance (the browser's frame worker).
//!
//! The page keeps no frame threads (a wasm build without shared memory has one thread per
//! instance), so the web app renders viewer frames in a long-lived Web Worker that holds a
//! replica of the project. The page sends the replica **project diffs** rather than the whole
//! project per edit, then render requests; the worker answers each with the frame's pixels.
//!
//! - [`diff`] / [`patch`]: a structural JSON diff of the serialized project (object members
//! added, changed or removed; arrays element-wise when their length is unchanged, else
//! replaced). Lossless (unlike JSON Merge Patch, `null` values survive).
//! - [`Mirror`] (page side): what the worker holds; turns the next project revision into a
//! [`FrameMsg::Sync`] (a full project the first time, a diff afterwards).
//! - [`FrameServer`] (worker side): applies syncs, relayed Roto Brush segmentations and render
//! requests, and renders frames as premultiplied RGBA8 ([`rgba8_premultiplied`]).
//!
//! The transport (structured-clone messages, transferable pixel buffers) is the web crate's;
//! everything here is portable and tested natively.
use std::sync::Arc;
use effectcraft_project::{ItemId, Project};
use effectcraft_render::{ExprHost, FootageSource, Image, LayerCache, RenderOpts, Renderer};
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::offload::SegData;
/// One step of a path into a JSON document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Step {
Key(String),
Index(usize),
}
/// One change: set the value at `path` (creating or replacing the member/element), or remove
/// the object member at `path` (`value` absent).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Op {
pub path: Vec<Step>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<Value>,
}
/// The changes that turn `old` into `new` (empty when equal).
pub fn diff(old: &Value, new: &Value) -> Vec<Op> {
let mut ops = vec![];
diff_into(old, new, &mut vec![], &mut ops);
ops
}
fn diff_into(old: &Value, new: &Value, path: &mut Vec<Step>, ops: &mut Vec<Op>) {
if old == new {
return;
}
match (old, new) {
(Value::Object(a), Value::Object(b)) => {
for (k, va) in a {
path.push(Step::Key(k.clone()));
match b.get(k) {
Some(vb) => diff_into(va, vb, path, ops),
None => ops.push(Op { path: path.clone(), value: None }),
}
path.pop();
}
for (k, vb) in b {
if !a.contains_key(k) {
path.push(Step::Key(k.clone()));
ops.push(Op { path: path.clone(), value: Some(vb.clone()) });
path.pop();
}
}
}
(Value::Array(a), Value::Array(b)) if a.len() == b.len() => {
for (i, (va, vb)) in a.iter().zip(b).enumerate() {
path.push(Step::Index(i));
diff_into(va, vb, path, ops);
path.pop();
}
}
_ => ops.push(Op { path: path.clone(), value: Some(new.clone()) }),
}
}
/// Apply [`diff`]'s changes to `doc`.
pub fn patch(doc: &mut Value, ops: &[Op]) -> Result<(), String> {
for op in ops {
let Some((last, parents)) = op.path.split_last() else {
*doc = op.value.clone().ok_or("cannot remove the document")?;
continue;
};
let mut cur = &mut *doc;
for st in parents {
cur = match (st, cur) {
(Step::Key(k), Value::Object(m)) => m.get_mut(k).ok_or_else(|| format!("no member `{k}`"))?,
(Step::Index(i), Value::Array(a)) => a.get_mut(*i).ok_or_else(|| format!("no element {i}"))?,
_ => return Err("path does not match the document".into()),
};
}
match (last, cur, &op.value) {
(Step::Key(k), Value::Object(m), Some(v)) => {
m.insert(k.clone(), v.clone());
}
(Step::Key(k), Value::Object(m), None) => {
m.remove(k);
}
(Step::Index(i), Value::Array(a), Some(v)) => *a.get_mut(*i).ok_or_else(|| format!("no element {i}"))? = v.clone(),
_ => return Err("path does not match the document".into()),
}
}
Ok(())
}
/// Page → frame worker.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FrameMsg {
/// The whole project (JSON) at `revision`.
Project { revision: u64, project: String },
/// Changes from the worker's revision `from` to `revision`.
Patch { from: u64, revision: u64, ops: Vec<Op> },
/// Render a frame of the current project.
Render { id: u64, revision: u64, comp: ItemId, time: Tick, opts: Box<RenderOpts> },
/// Roto Brush segmentations computed elsewhere (a propagation worker).
Segs { segs: Vec<SegData> },
/// Drop cached layer buffers (Edit ▸ Purge).
Purge,
}
/// Frame worker → page. A [`FrameReply::Frame`]'s pixels travel next to the message (a
/// transferable buffer in the browser).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FrameReply {
/// Premultiplied RGBA8 pixels of request `id`, rendered in `ms` milliseconds.
Frame { id: u64, width: u32, height: u32, ms: f64 },
/// Request `id` was not rendered (`stale`: the worker holds another revision).
Failed { id: u64, error: String, stale: bool },
/// A sync could not be applied: the page must send the whole project again.
Resync { error: String },
}
/// The page's record of a worker's replica.
#[derive(Default)]
pub struct Mirror {
sent: Option<(u64, Value)>,
}
impl Mirror {
/// The revision the worker holds.
pub fn revision(&self) -> Option<u64> {
self.sent.as_ref().map(|s| s.0)
}
/// Forget the replica (the next sync sends the whole project).
pub fn reset(&mut self) {
self.sent = None;
}
/// The message that brings the worker to `revision` of `project` (`None` when it has it).
pub fn sync(&mut self, revision: u64, project: &Project) -> Option<FrameMsg> {
if self.revision() == Some(revision) {
return None;
}
let v = serde_json::to_value(project).unwrap_or(Value::Null);
let msg = match self.sent.take() {
Some((from, old)) => FrameMsg::Patch { from, revision, ops: diff(&old, &v) },
None => FrameMsg::Project { revision, project: v.to_string() },
};
self.sent = Some((revision, v));
Some(msg)
}
}
/// Premultiplied f32 → premultiplied RGBA8 (colour clamped to alpha), as the viewer shows it.
pub fn rgba8_premultiplied(img: &Image) -> Vec<u8> {
let mut out = Vec::with_capacity(img.data.len() * 4);
for p in &img.data {
let a = p[3].clamp(0.0, 1.0);
let c = |v: f32| (v.clamp(0.0, a) * 255.0 + 0.5) as u8;
out.extend_from_slice(&[c(p[0]), c(p[1]), c(p[2]), (a * 255.0 + 0.5) as u8]);
}
out
}
/// The worker's side: a project replica and what rendering needs.
pub struct FrameServer {
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
pub cache: Arc<LayerCache>,
doc: Value,
project: Option<Arc<Project>>,
revision: Option<u64>,
}
impl FrameServer {
pub fn new(footage: Arc<dyn FootageSource>, expr: Option<Arc<dyn ExprHost>>) -> FrameServer {
FrameServer { footage, expr, cache: Arc::new(LayerCache::default()), doc: Value::Null, project: None, revision: None }
}
pub fn revision(&self) -> Option<u64> {
self.revision
}
pub fn project(&self) -> Option<&Arc<Project>> {
self.project.as_ref()
}
fn load(&mut self, revision: u64) -> Result<(), String> {
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
self.project = Some(Arc::new(p));
self.revision = Some(revision);
Ok(())
}
/// Handle one message; returns the replies with their pixels.
pub fn handle(&mut self, msg: FrameMsg) -> Vec<(FrameReply, Option<Vec<u8>>)> {
match msg {
FrameMsg::Project { revision, project } => {
let r = serde_json::from_str(&project).map_err(|e| e.to_string()).and_then(|v| {
self.doc = v;
self.load(revision)
});
match r {
Ok(()) => vec![],
Err(e) => self.lost(e),
}
}
FrameMsg::Patch { from, revision, ops } => {
if self.revision != Some(from) {
return self.lost(format!("the replica is at {:?}, the patch starts at {from}", self.revision));
}
match patch(&mut self.doc, &ops).and_then(|_| self.load(revision)) {
Ok(()) => vec![],
Err(e) => self.lost(e),
}
}
FrameMsg::Segs { segs } => {
for s in &segs {
s.store();
}
vec![]
}
FrameMsg::Purge => {
self.cache.clear();
vec![]
}
FrameMsg::Render { id, revision, comp, time, opts } => {
let fail = |error: String, stale: bool| vec![(FrameReply::Failed { id, error, stale }, None)];
if self.revision != Some(revision) {
return fail(format!("the replica is at {:?}, not {revision}", self.revision), true);
}
let Some(p) = self.project.clone() else { return fail("no project".into(), true) };
if p.comp(comp).is_none() {
return fail(format!("no composition {}", comp.0), false);
}
let t0 = web_time::Instant::now();
let mut r = Renderer::new(&p, self.footage.as_ref(), *opts);
r.expr = self.expr.as_deref();
r.cache = Some(&self.cache);
let img = r.comp_frame(comp, time);
let ms = t0.elapsed().as_secs_f64() * 1000.0;
vec![(FrameReply::Frame { id, width: img.width, height: img.height, ms }, Some(rgba8_premultiplied(&img)))]
}
}
}
fn lost(&mut self, error: String) -> Vec<(FrameReply, Option<Vec<u8>>)> {
self.doc = Value::Null;
self.project = None;
self.revision = None;
vec![(FrameReply::Resync { error }, None)]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
fn round<T: Serialize + for<'de> Deserialize<'de>>(v: &T) -> T {
serde_json::from_str(&serde_json::to_string(v).unwrap()).unwrap()
}
#[test]
fn diff_and_patch_round_trip() {
let a = json!({"a": 1, "b": [1, 2, {"c": null}], "d": {"e": "x", "f": [1]}, "g": null});
let b = json!({"a": 2, "b": [1, 3, {"c": 4}], "d": {"f": [1, 2]}, "g": null, "h": {"i": null}});
let ops = diff(&a, &b);
assert!(!ops.is_empty());
let mut x = a.clone();
patch(&mut x, &round(&ops)).unwrap();
assert_eq!(x, b);
// Removal and the reverse direction.
let mut y = b.clone();
patch(&mut y, &diff(&b, &a)).unwrap();
assert_eq!(y, a);
assert!(diff(&a, &a).is_empty());
// A whole-document change.
let mut z = json!(1);
patch(&mut z, &diff(&json!(1), &json!([1]))).unwrap();
assert_eq!(z, json!([1]));
// A patch for another document fails cleanly.
assert!(patch(&mut json!(3), &diff(&a, &b)).is_err());
}
fn server(s: &Session) -> FrameServer {
FrameServer::new(s.footage.clone(), s.expr.clone())
}
/// Pixels the page itself would render.
fn local(s: &Session, comp: ItemId, t: Tick, opts: RenderOpts) -> Vec<u8> {
let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts);
r.expr = s.expr.as_deref();
rgba8_premultiplied(&r.comp_frame(comp, t))
}
#[test]
fn replica_follows_edits_through_diffs_and_renders_the_same_frames() {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
let comp = s.active_comp_id().unwrap();
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
let mut m = Mirror::default();
let mut w = server(&s);
let send = |m: FrameMsg, w: &mut FrameServer| w.handle(round(&m));
// First sync: the whole project.
let first = m.sync(s.revision, &s.project).unwrap();
assert!(matches!(first, FrameMsg::Project { .. }));
assert!(send(first, &mut w).is_empty());
assert!(m.sync(s.revision, &s.project).is_none(), "nothing new");
let t = Tick::from_seconds_f64(1.0);
let render = |w: &mut FrameServer, rev: u64, id: u64| w.handle(round(&FrameMsg::Render { id, revision: rev, comp, time: t, opts: Box::new(opts) }));
let out = render(&mut w, s.revision, 1);
let (FrameReply::Frame { id, width, height, .. }, Some(px)) = &out[0] else { panic!("{:?}", out[0].0) };
assert_eq!((*id, *width as usize * *height as usize * 4), (1, px.len()));
assert_eq!(*px, local(&s, comp, t, opts));
// An edit travels as a small diff; the replica renders the edited frame.
s.execute("layer.newSolid", json!({"color": "#ff8800", "name": "Orange"})).unwrap();
let msg = m.sync(s.revision, &s.project).unwrap();
let FrameMsg::Patch { ops, .. } = &msg else { panic!("expected a patch") };
assert!(!ops.is_empty() && serde_json::to_string(ops).unwrap().len() < s.project.to_json().len() / 2);
assert!(send(msg, &mut w).is_empty());
assert_eq!(**w.project().unwrap(), *s.project);
let out = render(&mut w, s.revision, 2);
assert_eq!(out[0].1.as_deref(), Some(&local(&s, comp, t, opts)[..]));
// A request for another revision is refused as stale.
let out = render(&mut w, s.revision + 7, 3);
assert!(matches!(out[0].0, FrameReply::Failed { id: 3, stale: true, .. }));
// A patch from the wrong base asks for a resync; the mirror then sends the project.
let bad = FrameMsg::Patch { from: 999, revision: 1000, ops: vec![] };
assert!(matches!(send(bad, &mut w)[0].0, FrameReply::Resync { .. }));
m.reset();
assert!(matches!(m.sync(s.revision, &s.project), Some(FrameMsg::Project { .. })));
}
#[test]
fn frame_messages_round_trip() {
for m in [
FrameMsg::Purge,
FrameMsg::Segs { segs: vec![SegData { key: 7, data: "AAAA".into() }] },
FrameMsg::Render {
id: 3,
revision: 4,
comp: ItemId(2),
time: Tick::from_seconds_f64(0.5),
opts: Box::new(RenderOpts { scale: 0.5, draft: true, roi: Some([1.0, 2.0, 3.0, 4.0]), ..Default::default() }),
},
] {
let a = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::to_string(&round(&m)).unwrap(), a);
assert!(a.starts_with("{\"type\":"), "{a}");
}
for r in [
FrameReply::Frame { id: 1, width: 2, height: 3, ms: 4.5 },
FrameReply::Failed { id: 1, error: "x".into(), stale: true },
FrameReply::Resync { error: "y".into() },
] {
assert_eq!(round(&r), r);
}
}
}
+1 -1
View File
@@ -173,7 +173,7 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
let px = (w as f64 * h as f64) / (1920.0 * 1080.0);
let mbps = |m: f64| m * 1_000_000.0 / 8.0;
match om.format {
OutputFormat::H264 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps,
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 => om.bitrate_kbps as f64 * 1000.0 / 8.0 / fps,
OutputFormat::ProRes => {
let rate = match om.prores_profile {
Proxy => 45.0,
+29 -12
View File
@@ -3,7 +3,8 @@
//!
//! Segmentations are derived data in `effectcraft_effects::roto`'s content-keyed cache (see that
//! module). `roto.propagate` fills it at full resolution in the background (or blocking with
//! `wait`), frame by frame outward from the base frame, rendering the effect's input
//! `wait`; in the browser the background is a Web Worker whose segmentations stream back,
//! [`crate::offload`]), frame by frame outward from the base frame, rendering the effect's input
//! (`Renderer::layer_input`, source → masks → the effects above it). `roto.freeze` runs the same
//! pass and stores the final mattes in the effect (one undo step). [`sync`], run after every
//! edit, keeps each instance's hidden Input Key equal to a signature of what its frames are made
@@ -403,16 +404,21 @@ pub(crate) fn job_frames(d: &effectcraft_track::roto::RotoData, dir: Direction)
}
impl Session {
/// The Roto Brush job running in a worker (Freeze or propagation), with its task.
fn offloaded_roto(&self) -> Option<(&crate::offload::OffloadedJob, RotoTask)> {
self.offloaded(JobKind::RotoFreeze).map(|j| (j, RotoTask::Freeze)).or_else(|| self.offloaded(JobKind::RotoPropagate).map(|j| (j, RotoTask::Propagate)))
}
pub fn is_roto_running(&self) -> bool {
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded(JobKind::RotoFreeze).is_some()
self.roto_job.as_ref().is_some_and(|j| !j.is_finished()) || self.offloaded_roto().is_some()
}
pub fn roto_progress(&self) -> Option<RotoProgress> {
self.roto_job.as_ref().map(|j| j.progress()).or_else(|| {
self.offloaded(JobKind::RotoFreeze).map(|j| {
self.offloaded_roto().map(|(j, task)| {
let p = &j.progress;
RotoProgress {
task: Some(RotoTask::Freeze),
task: Some(task),
done: p.done,
total: p.total,
elapsed: p.elapsed,
@@ -425,14 +431,22 @@ impl Session {
/// (comp, layer, effect uid, task) of the running job.
pub fn roto_target(&self) -> Option<(ItemId, LayerId, Uid, RotoTask)> {
self.roto_job
.as_ref()
.map(|j| (j.comp, j.layer, j.effect, j.task))
.or_else(|| self.offloaded(JobKind::RotoFreeze).map(|j| (j.comp, j.layer, j.uid, RotoTask::Freeze)))
self.roto_job.as_ref().map(|j| (j.comp, j.layer, j.effect, j.task)).or_else(|| self.offloaded_roto().map(|(j, task)| (j.comp, j.layer, j.uid, task)))
}
/// The segmentation chain a propagation of `effect` computes (its frames' cache keys).
pub fn roto_chain(&self, comp: ItemId, layer: LayerId, effect: Uid) -> Option<fx::Chain> {
let c = self.project.comp(comp)?;
let l = c.layer(layer)?;
let g = l.effects()?.groups().find(|g| g.uid == effect && is_roto(g))?;
let d = fx::data(&params_static(g));
let t = comp_time_of(c, l, d.base?);
let params = params_at(&self.project, comp, l, g, t, self.expr.as_deref());
Some(fx::Chain::new(&params, layer_size(&self.project, c, l), c.frame_rate.as_f64(), 1.0))
}
pub fn stop_roto(&mut self) -> bool {
if self.cancel_offloaded(JobKind::RotoFreeze) {
if self.cancel_offloaded(JobKind::RotoFreeze) || self.cancel_offloaded(JobKind::RotoPropagate) {
return true;
}
match &self.roto_job {
@@ -491,9 +505,12 @@ impl Session {
task,
};
let strokes = str_of(g, fx::STROKES);
if !wait && task == RotoTask::Freeze && self.offloads() {
if !wait && self.offloads() {
drop(work);
self.offload_analysis(crate::offload::WorkerJob::RotoFreeze { comp, layer, effect })?;
self.offload_analysis(match task {
RotoTask::Freeze => crate::offload::WorkerJob::RotoFreeze { comp, layer, effect },
RotoTask::Propagate => crate::offload::WorkerJob::RotoPropagate { comp, layer, effect, direction: dir },
})?;
return Ok(n);
}
let wait = wait || cfg!(target_arch = "wasm32");
@@ -567,7 +584,7 @@ impl Session {
/// instances in the background.
pub fn poll_roto(&mut self, auto: bool) -> bool {
let changed = self.poll_roto_job();
if auto && self.roto_job.is_none() && self.state.roto.auto_propagate {
if auto && self.roto_job.is_none() && self.offloaded_roto().is_none() && self.state.roto.auto_propagate {
while let Some((c, l, e)) = self.roto_pending.first().copied() {
self.roto_pending.remove(0);
if self.start_roto(c, l, e, RotoTask::Propagate, Direction::Both, false).is_ok() {
+1 -1
View File
@@ -159,6 +159,6 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 10);
assert_eq!(f["formats"].as_array().unwrap().len(), 12);
assert_eq!(f["formats"][0]["available"], false);
}
+5 -1
View File
@@ -48,8 +48,12 @@ impl Session {
if !s.is_empty() {
return Some(std::path::PathBuf::from(s));
}
// (the browser has no file system: no default folder there)
if self.config.is_none() || cfg!(target_arch = "wasm32") {
return None;
}
let base = effectcraft_render::disk_cache::default_folder();
self.config.is_some().then(|| base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name)))
Some(base.parent().map(|p| p.join(name)).unwrap_or_else(|| base.join(name)))
}
/// Settings ▸ Disk ▸ Database and Cache Folder (media cache: audio waveform summaries).
+53
View File
@@ -0,0 +1,53 @@
//! M13.4: HEVC / AV1 output modules and the WebM codec / Opus options over the command API.
use effectcraft_project::render_queue::{Channels, CodecProfile, OpusApplication, OutputFormat, RateControlMode, VideoCodecOptions, WebmVideoCodec};
use serde_json::json;
use crate::Session;
fn session() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s
}
#[test]
fn hevc_and_av1_output_module_options() {
let mut s = session();
let a = s
.execute(
"renderQueue.add",
json!({"format": "hevc", "profile": "main10", "level": "4.1", "rateControl": "quality", "quality": 85, "keyframeInterval": 30}),
)
.unwrap();
assert!(a["outputPath"].as_str().unwrap().ends_with(".mp4"), "{a}");
let om = &s.project.render_queue[0].output;
assert_eq!(om.format, OutputFormat::Hevc);
assert_eq!(om.codec, VideoCodecOptions { profile: CodecProfile::Main10, level: Some(41), rate_control: RateControlMode::Quality, quality: 85 });
assert_eq!(om.keyframe_interval, 30);
assert_eq!(a["output"]["codec"]["profile"], "Main10", "{a}");
let id = a["item"].clone();
s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "av1", "level": null, "rateControl": "bitrate", "bitrate": 3000})).unwrap();
let om = &s.project.render_queue[0].output;
assert_eq!((om.format, om.codec.level, om.codec.rate_control, om.bitrate_kbps), (OutputFormat::Av1, None, RateControlMode::Bitrate, 3000));
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "channels": "rgba"})).is_err(), "AV1 MP4 has no alpha");
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "profile": "high"})).is_err());
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "level": "0.5"})).is_err());
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "rateControl": "cbr2"})).is_err());
// WebM: VP9 with alpha, then AV1 (alpha dropped) with voice-tuned Opus.
s.execute("renderQueue.setOutputModule", json!({"item": id, "format": "webm", "channels": "rgba"})).unwrap();
assert_eq!(s.project.render_queue[0].output.channels, Channels::Rgba);
s.execute("renderQueue.setOutputModule", json!({"item": id, "webmCodec": "av1", "audioBitrate": 24, "opusApplication": "voice"})).unwrap();
let om = &s.project.render_queue[0].output;
assert_eq!((om.webm_codec, om.channels, om.opus_bitrate_kbps, om.opus_application), (WebmVideoCodec::Av1, Channels::Rgb, 24, OpusApplication::Voip));
assert!(s.execute("renderQueue.setOutputModule", json!({"item": id, "webmCodec": "theora"})).is_err());
// The formats query lists the new formats and option values.
let f = s.execute("renderQueue.formats", json!({})).unwrap();
let ids: Vec<&str> = f["formats"].as_array().unwrap().iter().filter_map(|v| v["id"].as_str()).collect();
assert!(ids.contains(&"Hevc") && ids.contains(&"Av1"), "{ids:?}");
assert_eq!(f["codecProfiles"], json!(["Main", "Main 10"]));
assert!(f["hevcLevels"].as_array().unwrap().contains(&json!("4.1")));
assert_eq!(f["webmCodecs"], json!(["vp9", "av1"]));
}
+61
View File
@@ -516,3 +516,64 @@ fn panels_commands_are_wired() {
assert!(s.is_enabled(id), "{id}");
}
}
/// A browser like the web app's: a virtual tree whose files are read on demand.
#[derive(Default)]
struct VirtualBrowser {
tree: Mutex<crate::media_browser::VirtualTree>,
fetched: Mutex<Vec<String>>,
actions: Mutex<Vec<String>>,
}
impl crate::media_browser::Browser for VirtualBrowser {
fn home(&self) -> String {
"/Browser Storage".into()
}
fn places(&self) -> Vec<(String, String)> {
vec![("Browser Storage".into(), self.home()), ("Shoot".into(), "/Folders/Shoot".into())]
}
fn list(&self, dir: &str, only: bool) -> Result<Vec<crate::media_browser::Entry>, String> {
self.tree.lock().unwrap().list(dir, only)
}
fn fetch(&self, paths: &[String], _: &Value) -> bool {
// Opened folders' files arrive later.
let pending: Vec<String> = paths.iter().filter(|p| p.starts_with("/Folders/")).cloned().collect();
self.fetched.lock().unwrap().extend(pending.iter().cloned());
pending.is_empty()
}
fn actions(&self) -> Vec<(String, String)> {
vec![("openFolder".into(), "Open Folder…".into())]
}
fn action(&self, id: &str) -> Result<Value, String> {
self.actions.lock().unwrap().push(id.into());
Ok(json!({"pending": true}))
}
}
#[test]
fn media_browser_over_a_virtual_tree() {
let b = Arc::new(VirtualBrowser::default());
{
let mut t = b.tree.lock().unwrap();
t.add_dir("/Browser Storage");
t.add_file("/Folders/Shoot/a.csv", "/Folders/Shoot/a.csv", 12, Some(1));
}
let mut s = Session { browser: Some(b.clone()), ..Default::default() };
let r = s.execute("mediaBrowser.list", json!({})).unwrap();
assert_eq!(r["path"], "/Browser Storage");
assert_eq!(r["places"][1]["path"], "/Folders/Shoot");
assert_eq!(r["actions"][0]["id"], "openFolder");
let r = s.execute("mediaBrowser.go", json!({"path": "/Folders/Shoot"})).unwrap();
assert_eq!(r["entries"][0]["name"], "a.csv");
assert_eq!(r["parent"], "/Folders");
assert!(s.execute("mediaBrowser.go", json!({"path": "/Nope"})).is_err());
// Importing a file that is not read yet: pending (the host imports it once it arrives).
let r = s.execute("mediaBrowser.import", json!({"paths": ["/Folders/Shoot/a.csv"]})).unwrap();
assert_eq!(r["pending"], true);
assert_eq!(*b.fetched.lock().unwrap(), ["/Folders/Shoot/a.csv"]);
assert!(s.project.items.values().all(|i| !matches!(i.kind, ItemKind::Footage(_))));
let r = s.execute("mediaBrowser.action", json!({"action": "openFolder"})).unwrap();
assert_eq!(r["pending"], true);
assert_eq!(*b.actions.lock().unwrap(), ["openFolder"]);
assert!(s.execute("mediaBrowser.action", json!({})).is_err());
}
+49
View File
@@ -286,3 +286,52 @@ fn propagation_is_deterministic_and_runs_in_background() {
let r = s.execute("roto.stroke", json!({"layer": clip.0, "points": [[90, 110]]})).unwrap();
assert_eq!(r["radius"], 15.0);
}
/// Runs offloaded jobs at once in a second session sharing the footage, through JSON (what the
/// browser's Web Worker does, minus the thread).
struct InlineWorker(std::sync::Arc<dyn effectcraft_render::FootageSource>);
impl crate::offload::Offload for InlineWorker {
fn start(&self, req: crate::offload::WorkerRequest, inbox: std::sync::Arc<crate::offload::Inbox>) -> Result<(), String> {
let req: crate::offload::WorkerRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
let mut w = Session { footage: self.0.clone(), ..Default::default() };
let post: crate::offload::Post = std::rc::Rc::new(move |r| inbox.push(serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap()));
crate::offload::run_request(&mut w, req, &post);
Ok(())
}
fn cancel(&self, _: u64) {}
}
#[test]
fn offloaded_propagation_streams_segmentations_back() {
let (mut s, clip, uid) = painted(center, "disk-offload.mov");
s.execute("roto.span", json!({"layer": clip.0, "end": 5})).unwrap();
s.offload = Some(std::sync::Arc::new(InlineWorker(s.footage.clone())));
let r = s.execute("roto.propagate", json!({"layer": clip.0})).unwrap();
assert_eq!(r["frames"], 6, "{r}");
assert!(s.roto_job.is_none(), "offloaded, not on a thread");
assert!(s.is_roto_running());
assert_eq!(s.roto_progress().unwrap().task, Some(crate::roto::RotoTask::Propagate));
assert_eq!(s.roto_target().map(|t| (t.1, t.2)), Some((clip, uid)));
assert!(s.jobs().iter().any(|j| j.id == "roto"));
// The worker has its own segmentation cache: here nothing is computed until its replies
// are applied.
forget(&s, clip, uid);
assert_eq!(s.execute("roto.status", json!({"layer": clip.0})).unwrap()["computed"], json!([]));
let rev = s.revision;
assert!(s.poll_offload());
assert!(!s.is_roto_running());
assert!(s.revision > rev, "frames are redrawn");
let st = s.execute("roto.status", json!({"layer": clip.0})).unwrap();
assert_eq!(st["computed"], json!([0, 1, 2, 3, 4, 5]), "{st}");
assert!(s.events.iter().any(|e| matches!(e, crate::Event::Toast { message, error: false } if message.contains("propagated 6 frame"))));
// The matte matches one computed here.
let a = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone();
forget(&s, clip, uid);
let b = s.execute("roto.status", json!({"layer": clip.0, "frame": 5, "matte": true})).unwrap()["matte"].clone();
assert!(a.is_string());
assert_eq!(a, b);
// `wait` still runs here.
s.execute("roto.propagate", json!({"layer": clip.0, "wait": true})).unwrap();
assert!(s.offloaded.is_empty());
}