mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
Merge main into M13.12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
] {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¶ms_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(¶ms, 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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"]));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user