Release: merge main for EffectCraft v0.23.1

This commit is contained in:
FredApps committed 2026-11-10 01:15:05 +01:00
commit f5e5c8a4d8
50 files changed
+4578 -217

No files matched your search

Generated
+39 -39
View File
@@ -1420,7 +1420,7 @@ dependencies = [
[[package]]
name = "effectcraft"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"cpal",
"craft-update",
@@ -1446,7 +1446,7 @@ dependencies = [
[[package]]
name = "effectcraft-aep"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1460,7 +1460,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1474,14 +1474,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"proptest",
"serde",
@@ -1508,7 +1508,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"chrono",
"effectcraft-color",
@@ -1526,7 +1526,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -1565,7 +1565,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1613,7 +1613,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"proptest",
"serde",
@@ -1621,7 +1621,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1644,7 +1644,7 @@ dependencies = [
[[package]]
name = "effectcraft-helper-host"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"bytemuck",
"effectcraft-effects",
@@ -1655,14 +1655,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1689,7 +1689,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1708,7 +1708,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1720,7 +1720,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1738,7 +1738,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1771,7 +1771,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-geom",
"image",
@@ -1784,14 +1784,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1803,7 +1803,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1819,7 +1819,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1831,7 +1831,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"craft-ecproj-format",
"effectcraft-color",
@@ -1845,14 +1845,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"miniz_oxide 0.9.1",
]
[[package]]
name = "effectcraft-raster"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1862,7 +1862,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1885,7 +1885,7 @@ dependencies = [
[[package]]
name = "effectcraft-sam2"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"bytemuck",
"candle-core",
@@ -1900,7 +1900,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1918,7 +1918,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"miniz_oxide 0.9.1",
"ndarray",
@@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1939,7 +1939,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1951,7 +1951,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"proptest",
"serde",
@@ -1959,7 +1959,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1971,7 +1971,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"bytemuck",
"chrono",
@@ -2012,11 +2012,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
version = "0.23.0"
version = "0.23.1"
[[package]]
name = "effectcraft-web"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -7711,7 +7711,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.23.0"
version = "0.23.1"
dependencies = [
"chrono",
"pkg-config",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
version = "0.23.0"
version = "0.23.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+591
View File
@@ -0,0 +1,591 @@
//! A small, separate process reports an unexpected desktop exit (#3357).
//!
//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a
//! helper which never loads a project, font database or GPU. Only an explicit marker after
//! normal teardown suppresses the report. EOF alone identifies an unexpected exit, not its
//! cause. System-wide exhaustion can still prevent the helper or its native dialog working.
use std::ffi::{OsStr, OsString};
use std::fs::{File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const FLAG: &str = "--internal-crash-reporter";
const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n";
const LOG_BYTES: usize = 64 * 1024;
const HEADER_BYTES: usize = 128;
/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes
/// the pipe and reports an unexpected exit; there is intentionally no clean-exit Drop hook.
pub struct Reporter {
pipe: ChildStdin,
child: Child,
}
impl Reporter {
/// Call only after application teardown, including handled startup/device-loss errors.
pub fn finish(mut self) -> io::Result<()> {
let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush());
drop(self.pipe);
// Do not let a suspended/broken helper hang shutdown. Reap it before the GUI
// exits where possible, so Windows updates do not race an old executable.
let cleanup = finish_child(&mut self.child, Duration::from_secs(2));
match (sent, cleanup) {
(Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))),
(Err(error), _) | (_, Err(error)) => Err(error),
(Ok(()), Ok(())) => Ok(()),
}
}
}
fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> {
let start = Instant::now();
while start.elapsed() < grace {
if let Some(status) = child.try_wait()? {
return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) };
}
std::thread::sleep(Duration::from_millis(20));
}
child.kill()?;
// Even after termination, do not introduce an unbounded wait on a stuck OS process.
let start = Instant::now();
while start.elapsed() < Duration::from_secs(1) {
if child.try_wait()?.is_some() {
return Err(io::Error::other("crash reporter did not finish promptly and was terminated"));
}
std::thread::sleep(Duration::from_millis(20));
}
Err(io::Error::other("crash reporter termination could not be confirmed"))
}
/// Shared ownership lets macOS finish in on_exit, while main retains the startup-error path.
#[derive(Clone, Default)]
pub struct Shutdown(std::sync::Arc<std::sync::Mutex<Option<Reporter>>>);
impl Shutdown {
/// Install the one reporter created for this desktop launch.
pub fn install(&self, reporter: Reporter) -> io::Result<()> {
let mut slot = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?;
*slot = Some(reporter);
Ok(())
}
/// Consume the reporter exactly once, after the applicable platform teardown boundary.
pub fn finish(&self) -> io::Result<()> {
let reporter = self.0.lock().map_err(|_| io::Error::other("crash reporter ownership was poisoned"))?.take();
reporter.map_or(Ok(()), Reporter::finish)
}
}
/// A panic during teardown must leave the reporter armed. macOS terminates after this hook
/// without returning to main; other platforms pass None and finish after run_native returns.
pub fn after_teardown(reporter: Option<&Shutdown>, teardown: impl FnOnce()) -> io::Result<()> {
teardown();
reporter.map_or(Ok(()), Shutdown::finish)
}
/// Each process/run has its own report, including simultaneous launches.
pub fn run_id() -> String {
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
format!("{}-{nanos}", std::process::id())
}
pub fn session_marker(id: &str) -> String {
format!("crash-report session {id}")
}
/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app.
pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result<Reporter> {
let exe = std::env::current_exe()?;
let args = [
OsString::from(FLAG),
OsString::from(id),
log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()),
reports.as_os_str().to_owned(),
OsString::from(if interactive { "1" } else { "0" }),
];
spawn(&exe, &args)
}
fn spawn(exe: &Path, args: &[OsString]) -> io::Result<Reporter> {
let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::null()).spawn()?;
match child.stdin.take() {
Some(pipe) => Ok(Reporter { pipe, child }),
None => {
let _ = child.kill();
let _ = child.wait();
Err(io::Error::other("crash reporter has no control pipe"))
}
}
}
/// Handle the internal mode before logging, argument parsing, updates or GUI initialization.
/// The callback is a native dialog on this helper's main thread, not on the failed GUI's.
pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool {
if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) {
return false;
}
let parsed = match args {
[_, id, log, reports, interactive] => id
.to_str()
.filter(|id| valid_id(id))
.zip(match interactive.to_str() {
Some("0") => Some(false),
Some("1") => Some(true),
_ => None,
})
.map(|(id, interactive)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive)),
_ => None,
};
let Some((id, log, reports, interactive)) = parsed else {
eprintln!("EffectCraft crash reporter: invalid internal arguments");
return true;
};
// Keep the exact file open while the GUI is alive: a later rotation must not substitute
// another instance's log. If rotation won the startup race, the session marker rejects it.
let mut log = SessionLog::open(log, id);
match clean_exit(&mut io::stdin().lock()) {
Ok(true) => {}
result => {
let failure = result.err().map(|e| e.to_string());
let saved = write_report(reports, id, &mut log, failure.as_deref());
let description = description(&saved);
eprintln!("{description}");
if interactive {
show(&description);
}
}
}
true
}
fn valid_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-')
}
/// Fixed-size comparison: arbitrary/truncated input never grows a buffer or counts as clean.
fn clean_exit(input: &mut impl Read) -> io::Result<bool> {
let mut marker = [0u8; CLEAN.len()];
let mut filled = 0;
while filled < marker.len() {
let remaining = marker.get_mut(filled..).ok_or_else(|| io::Error::other("invalid control marker offset"))?;
match input.read(remaining) {
Ok(0) => return Ok(false),
Ok(n) => filled += n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
}
Ok(marker == CLEAN)
}
enum SessionLog {
Captured { file: File, marker: String },
Unavailable(String),
}
impl SessionLog {
fn open(path: Option<&Path>, id: &str) -> Self {
let opened = (|| {
let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?;
let mut file = File::open(path)?;
let mut header = [0u8; HEADER_BYTES];
let marker = format!("{}\n", session_marker(id));
let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?;
file.read_exact(prefix)?;
if prefix != marker.as_bytes() {
return Err(io::Error::other("matching session marker unavailable; the log may have rotated"));
}
Ok(file)
})();
match opened {
Ok(file) => Self::Captured { file, marker: format!("{}\n", session_marker(id)) },
Err(e) => Self::Unavailable(e.to_string()),
}
}
fn write_tail(&mut self, report: &mut File) -> io::Result<()> {
match self {
Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"),
Self::Captured { file, marker } => {
// An older concurrent launcher may have truncated the captured file.
// Recheck its identity before copying any content into this report.
file.seek(SeekFrom::Start(0))?;
let mut header = [0u8; HEADER_BYTES];
let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?;
if file.read_exact(prefix).is_err() || prefix != marker.as_bytes() {
return writeln!(report, "Recent log unavailable: session identity changed");
}
let size = file.metadata()?.len();
file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?;
writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?;
io::copy(&mut file.take(LOG_BYTES as u64), report)?;
Ok(())
}
}
}
}
fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Option<&str>) -> io::Result<PathBuf> {
if !valid_id(id) {
return Err(io::Error::other("invalid report identity"));
}
std::fs::create_dir_all(reports)?;
let path = reports.join(format!("crash-{id}.txt"));
let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?;
writeln!(
report,
"EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH
)?;
writeln!(
report,
"The GUI's control pipe ended without its clean-exit marker.\nThe exit status and exact cause are not available to this reporter.\nMemory exhaustion, a native crash or external termination are possible.\nThis report was saved locally; it was not sent anywhere."
)?;
if let Some(error) = pipe_error {
writeln!(report, "Control pipe error: {error}")?;
}
if let Err(error) = log.write_tail(&mut report) {
writeln!(report, "\nCould not read the recent session log: {error}")?;
}
report.flush()?;
Ok(path)
}
fn description(saved: &io::Result<PathBuf>) -> String {
let diagnostic = match saved {
Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()),
Err(error) => format!("A diagnostic report could not be saved: {error}"),
};
format!(
"EffectCraft stopped unexpectedly.\n\n{diagnostic}\n\nThe exact cause is unknown. Reopen EffectCraft to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere."
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE";
fn folder() -> PathBuf {
let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(&p).unwrap();
p
}
fn test_name(name: &str) -> String {
// Also usable as a standalone std-only rustc test while the app's build cache is busy.
let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest);
format!("{prefix}::{name}")
}
fn helper(dir: &Path, id: &str) -> Reporter {
helper_mode(dir, id, false)
}
fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter {
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"])
.env(ROLE, "reporter")
.env("EC_REPORT_DIR", dir)
.env("EC_REPORT_ID", id)
.env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" })
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
Reporter { pipe: child.stdin.take().unwrap(), child }
}
fn wait_file(path: &Path) {
let until = Instant::now() + Duration::from_secs(10);
while !path.is_file() && Instant::now() < until {
thread::sleep(Duration::from_millis(20));
}
assert!(path.is_file(), "report did not arrive: {}", path.display());
}
#[test]
fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() {
let mut clean = CLEAN;
assert!(clean_exit(&mut clean).unwrap());
for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] {
assert!(!clean_exit(&mut input).unwrap());
}
assert!(!clean_exit(&mut &vec![b'x'; 100_000][..]).unwrap());
}
#[test]
fn report_is_bounded_and_keeps_its_session_after_log_rotation() {
let dir = folder();
let original = dir.join("effectcraft.log");
let id = "123-456";
std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap();
let mut log = SessionLog::open(Some(&original), id);
std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap();
std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap();
let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap();
old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap();
old.write_all(b"\nGPU context retired\n").unwrap();
let report = write_report(&dir, id, &mut log, None).unwrap();
let text = std::fs::read_to_string(report).unwrap();
assert!(text.len() < LOG_BYTES + 2048);
assert!(text.contains("GPU context retired"));
assert!(!text.contains("OTHER SESSION"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() {
let dir = folder();
let path = dir.join("log");
std::fs::write(&path, "private other session").unwrap();
for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] {
let report = write_report(&dir, id, &mut SessionLog::open(source, id), None).unwrap();
let text = std::fs::read_to_string(report).unwrap();
assert!(text.contains("Recent log unavailable"));
assert!(!text.contains("private other session"));
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn captured_log_rejects_a_session_overwritten_after_open() {
let dir = folder();
let path = dir.join("log");
std::fs::write(&path, "crash-report session 1-2\noriginal session\n").unwrap();
let mut log = SessionLog::open(Some(&path), "1-2");
std::fs::write(&path, "crash-report session 3-4\nprivate other session\n").unwrap();
let report = write_report(&dir, "1-2", &mut log, None).unwrap();
let text = std::fs::read_to_string(report).unwrap();
assert!(text.contains("Recent log unavailable"));
assert!(!text.contains("private other session"));
assert!(!text.contains("session 3-4"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn session_identity_requires_the_exact_first_line() {
let dir = folder();
let path = dir.join("log");
for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] {
std::fs::write(&path, header).unwrap();
assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_)));
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn report_write_failure_is_described_and_existing_report_is_not_overwritten() {
let dir = folder();
let mut log = SessionLog::Unavailable("no log".into());
let first = write_report(&dir, "2-1", &mut log, None).unwrap();
let before = std::fs::read(&first).unwrap();
let result = write_report(&dir, "2-1", &mut log, None);
assert!(result.is_err());
assert!(description(&result).contains("could not be saved"));
assert_eq!(std::fs::read(first).unwrap(), before);
assert!(write_report(&dir, "../escape", &mut log, None).is_err());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn normal_and_handled_shutdown_send_clean_marker_without_a_report() {
for id in ["3-1", "3-2"] {
let dir = folder();
let r = helper(&dir, id);
r.finish().unwrap();
// A completion file proves the helper consumed the marker, not merely that it
// hadn't yet produced a report.
wait_file(&dir.join(format!("ready-{id}")));
assert!(!dir.join(format!("crash-{id}.txt")).exists());
std::fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn accepted_native_close_finishes_before_process_termination() {
let dir = folder();
let status = Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
.env(ROLE, "native-close")
.env("EC_REPORT_DIR", &dir)
.stdout(Stdio::null())
.status()
.unwrap();
assert!(status.success());
wait_file(&dir.join("ready-8-1"));
assert!(!dir.join("crash-8-1.txt").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_teardown_panic_does_not_mark_the_reporter_clean() {
let dir = folder();
let reporter = Shutdown::default();
reporter.install(helper(&dir, "8-2")).unwrap();
let result = std::panic::catch_unwind(|| {
let _ = after_teardown(Some(&reporter), || panic!("simulated teardown failure"));
});
assert!(result.is_err());
drop(reporter);
wait_file(&dir.join("ready-8-2"));
assert!(dir.join("crash-8-2.txt").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn platforms_returning_to_main_keep_the_reporter_armed_until_then() {
let dir = folder();
let reporter = Shutdown::default();
reporter.install(helper(&dir, "8-3")).unwrap();
after_teardown(None, || {}).unwrap();
assert!(reporter.0.lock().unwrap().is_some());
reporter.clone().finish().unwrap();
reporter.finish().unwrap();
wait_file(&dir.join("ready-8-3"));
assert!(!dir.join("crash-8-3.txt").exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn an_aborting_parent_leaves_one_diagnostic_report() {
let dir = folder();
let status = Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"])
.env(ROLE, "abort")
.env("EC_REPORT_DIR", &dir)
.stdout(Stdio::null())
.status()
.unwrap();
assert!(!status.success());
wait_file(&dir.join("ready-4-1"));
let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap();
assert!(text.contains("exact cause are not available"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn an_unrelated_child_does_not_keep_the_control_pipe_open() {
let dir = folder();
let r = helper(&dir, "5-1");
let mut unrelated = Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"])
.env(ROLE, "linger")
.stdout(Stdio::null())
.spawn()
.unwrap();
drop(r);
wait_file(&dir.join("ready-5-1"));
assert!(unrelated.try_wait().unwrap().is_none());
unrelated.kill().unwrap();
unrelated.wait().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn an_unresponsive_helper_cannot_hang_clean_shutdown() {
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"])
.env(ROLE, "linger")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
let pipe = child.stdin.take().unwrap();
let started = Instant::now();
let result = Reporter { pipe, child }.finish();
assert!(result.unwrap_err().to_string().contains("was terminated"));
assert!(started.elapsed() < Duration::from_secs(5));
}
#[test]
fn simultaneous_sessions_write_separate_reports() {
let dir = folder();
let a = helper(&dir, "6-1");
let b = helper(&dir, "6-2");
drop(a);
drop(b);
wait_file(&dir.join("ready-6-1"));
wait_file(&dir.join("ready-6-2"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn interactive_failure_calls_dialog_once_after_saving_the_report() {
let dir = folder();
drop(helper_mode(&dir, "7-1", true));
wait_file(&dir.join("ready-7-1"));
let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap();
assert!(text.contains("crash-7-1.txt"));
assert!(text.contains("No report has been sent"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn internal_mode_rejects_invalid_arguments_without_launching_the_app() {
assert!(!maybe_run(&[], |_| panic!("no dialog")));
assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog")));
assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog")));
let args = [FLAG, "../escape", "", "", "1"].map(OsString::from);
assert!(maybe_run(&args, |_| panic!("no dialog")));
let args = [FLAG, "1-1", "", "", "invalid"].map(OsString::from);
assert!(maybe_run(&args, |_| panic!("no dialog")));
}
#[test]
fn missing_helper_is_an_error_not_a_panic() {
assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err());
}
// Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise
// real EOF/abort behavior without allocating a huge image or killing the test runner.
#[test]
#[ignore]
fn reporter_process() {
assert_eq!(std::env::var(ROLE).unwrap(), "reporter");
let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
let id = std::env::var("EC_REPORT_ID").unwrap();
let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap();
let args = [OsString::from(FLAG), OsString::from(&id), OsString::new(), dir.as_os_str().to_owned(), OsString::from(&interactive)];
assert!(maybe_run(&args, |description| {
assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog");
assert!(dir.join(format!("crash-{id}.txt")).exists());
let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap();
shown.write_all(description.as_bytes()).unwrap();
}));
std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap();
}
#[test]
#[ignore]
fn aborting_parent() {
let role = std::env::var(ROLE).unwrap();
let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap());
if role == "native-close" {
let reporter = Shutdown::default();
reporter.install(helper(&dir, "8-1")).unwrap();
after_teardown(Some(&reporter.clone()), || {}).unwrap();
std::process::exit(0); // Models macOS not returning from its native event loop.
}
assert_eq!(role, "abort");
let _reporter = helper(&dir, "4-1");
std::process::abort();
}
#[test]
#[ignore]
fn lingering_child() {
assert_eq!(std::env::var(ROLE).unwrap(), "linger");
thread::sleep(Duration::from_secs(30));
}
}
+47 -10
View File
@@ -22,6 +22,7 @@ mod appimage;
mod apple_events;
mod audio_out;
mod control_server;
mod crash_report;
mod launch_guard;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -180,6 +181,23 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) {
/// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then
/// reports startup failure through `startup_failed`. Returns the event loop's result.
fn main() -> eframe::Result {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if crash_report::maybe_run(&args, |description| {
rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft stopped unexpectedly").set_description(description).show();
}) {
return Ok(());
}
let reporter = crash_report::Shutdown::default();
let result = run_desktop(&reporter);
// run_desktop has dropped the app and handled any startup error with its existing dialog.
// An unwinding panic or abort never reaches this marker.
if let Err(error) = reporter.finish() {
log::warn!("could not mark crash reporter clean: {error}");
}
result
}
fn run_desktop(reporter: &crash_report::Shutdown) -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
@@ -205,12 +223,23 @@ fn main() -> eframe::Result {
// The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
// kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
// `--version` leaves no file behind; records logged until now are written to it first.
let run_id = crash_report::run_id();
if let Some(dir) = effectcraft_host::config_dir() {
match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
let header = format!("{}\n", crash_report::session_marker(&run_id));
match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) {
Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => log::warn!("no log file: {e}"),
}
}
let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref());
match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) {
Ok(started) => {
if let Err(error) = reporter.install(started) {
log::warn!("crash reporter could not be retained: {error}");
}
}
Err(error) => log::warn!("crash reporter could not start: {error}"),
}
// The font menus list the installed fonts: read their names while the window opens.
effectcraft_engine::text::fonts::scan_system_in_background();
// Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
@@ -245,6 +274,7 @@ fn main() -> eframe::Result {
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let desktop_reporter = reporter.clone();
let result = eframe::run_native(
"EffectCraft",
options,
@@ -398,6 +428,7 @@ fn main() -> eframe::Result {
window: window::Tracker::new(opened, saved_window, control_port.is_none()),
launch,
drawn: false,
reporter: desktop_reporter,
}))
}),
);
@@ -491,6 +522,7 @@ struct Desktop {
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
reporter: crash_report::Shutdown,
}
impl eframe::App for Desktop {
@@ -525,15 +557,20 @@ impl eframe::App for Desktop {
}
fn on_exit(&mut self) {
// On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
// before `eframe::run_native` returns (#341).
log::info!("the window is closing");
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
effectcraft_host::stop_helpers();
// Scripts in Scripts/Shutdown run with the project closed.
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
// macOS terminates after this hook without returning from run_native (#341).
// Other platforms keep the reporter armed until main sees complete event-loop teardown.
let reporter = cfg!(target_os = "macos").then(|| self.reporter.clone());
if let Err(error) = crash_report::after_teardown(reporter.as_ref(), || {
log::info!("the window is closing");
self.launch.exited();
self.window.save(self.app.session.config.as_deref());
self.app.on_exit();
effectcraft_host::stop_helpers();
// Scripts in Scripts/Shutdown run with the project closed.
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
}) {
log::warn!("could not mark crash reporter clean after teardown: {error}");
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+3 -1
View File
@@ -451,7 +451,9 @@ pub fn adjustment_crop(offset: [f64; 2], canvas_offset: [f64; 2], size: (u32, u3
/// Effects that grow their own buffer by what they need (blur and glow margins, Repeat Edge
/// Pixels through [`Buf::cover`], displacement through [`cover_pad`]): a stack of only these
/// skips the renderer's cover of the whole layer rectangle (#1205).
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace"];
/// Drop Shadow also pads by its distance and softness on both CPU and GPU: covering the
/// layer rectangle first only inflates working images on highly scaled text and shapes (#3357).
pub const SKIPS_COVER: &[&str] = &["ec.blur.gaussian", "ec.blur.fastbox", "ec.stylize.glow", "ec.distort.turbulentdisplace", "ec.perspective.dropshadow"];
/// The grid phase [`Buf::align`] keeps and the step [`cover_pad`] grows in: covers the levels
/// of the resampling pyramid used for downscaled layers.
+4
View File
@@ -863,6 +863,10 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
storage: s.storage_quota.as_deref(),
label: "Save Current Preview".into(),
nested_switches: s.prefs.general.switches_affect_nested_comps,
caches: None,
renderer: None,
gpu_frames: s.prefs.memory.gpu_frames(),
};
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
+4
View File
@@ -145,6 +145,10 @@ fn prerender(
storage: None,
label: format!("Pre-render {}", l.name),
nested_switches: cx.nested_switches,
caches: None,
renderer: None,
gpu_frames: None,
};
let r = exporter.export(&job, progress)?;
Ok(Prerendered { path: r.path, width: comp.width, height: comp.height, frame_rate: comp.frame_rate, duration: end - start })
+46
View File
@@ -34,6 +34,42 @@ pub struct ExportJob<'a> {
pub label: String,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
pub nested_switches: bool,
/// The layer caches of the Render Queue run this job belongs to (#3335): consecutive jobs
/// start warm, and the run trims the accelerator's pooled textures once at its end. `None`:
/// a one-off export (its own caches, trimmed when it ends).
pub caches: Option<&'a RunCaches>,
/// The compositor the queue item's output modules render on (#3335): the first module
/// chooses (measuring a GPU project's first frames) and stores it here, the others follow.
pub renderer: Option<&'a Mutex<Option<effectcraft_render::Backend>>>,
/// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, `None` for Auto.
pub gpu_frames: Option<usize>,
}
/// What the jobs of one Render Queue run keep between them (#3335): a layer cache per
/// compositor (a buffer an effect made on the GPU is not the CPU's), together at the budget
/// each job's single cache had before.
pub struct RunCaches {
pub gpu: effectcraft_render::LayerCache,
pub cpu: effectcraft_render::LayerCache,
}
impl Default for RunCaches {
fn default() -> Self {
let half = effectcraft_render::LayerCache::default().budget() / 2;
RunCaches { gpu: effectcraft_render::LayerCache::new(half), cpu: effectcraft_render::LayerCache::new(half) }
}
}
/// Calls [`effectcraft_render::Accelerator::render_end`] when a Render Queue run ends, however
/// it ends (done, failed, stopped, panicked): its jobs leave the pooled textures to each other.
struct RunEnd<'a>(Option<&'a dyn effectcraft_render::Accelerator>);
impl Drop for RunEnd<'_> {
fn drop(&mut self) {
if let Some(a) = self.0 {
a.render_end();
}
}
}
/// What an export wrote.
@@ -212,6 +248,8 @@ struct Work {
items: Vec<Vec<(RenderQueueItem, String)>>,
/// Settings ▸ General ▸ Switches Affect Nested Comps.
nested_switches: bool,
/// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once.
gpu_frames: Option<usize>,
}
/// Where a relative render output goes when there is no default folder and the project is unsaved:
@@ -307,6 +345,7 @@ pub(crate) async fn run_items(
storage: s.storage_quota.clone(),
items,
nested_switches: s.prefs.general.switches_affect_nested_comps,
gpu_frames: s.prefs.memory.gpu_frames(),
};
run_work(work, shared, notify).await;
}
@@ -317,6 +356,8 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
let mut s = lock(&shared.state);
s.items_total = w.items.len();
}
let caches = RunCaches::default();
let _run_end = RunEnd(w.accel.as_deref());
for (k, modules) in w.items.iter().enumerate() {
let Some((item, _)) = modules.first() else { continue };
if shared.cancel.load(Ordering::Relaxed) {
@@ -337,6 +378,7 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
// Every output module encodes the same frames (Composition ▸ Add Output Module); the
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
let renderer = Mutex::new(None);
for (mi, (mitem, path)) in modules.iter().enumerate() {
let name = w.project.item(mitem.comp).map(|i| i.name.clone()).unwrap_or_default();
let index = w.project.render_queue.iter().position(|i| i.id == mitem.id).map_or(0, |i| i + 1);
@@ -350,6 +392,9 @@ async fn run_work(w: Work, shared: &JobShared, notify: &mut dyn FnMut(&JobShared
storage: w.storage.as_deref(),
label: format!("#{index} {name} (output module {})", mi + 1),
nested_switches: w.nested_switches,
caches: Some(&caches),
renderer: Some(&renderer),
gpu_frames: w.gpu_frames,
};
let rr = w
.exporter
@@ -610,6 +655,7 @@ impl Session {
storage: self.storage_quota.clone(),
items,
nested_switches: self.prefs.general.switches_affect_nested_comps,
gpu_frames: self.prefs.memory.gpu_frames(),
};
let shared = Arc::new(JobShared::default());
if wait {
+89
View File
@@ -248,3 +248,92 @@ fn locked_resize_uses_height_for_height_only_output_module_updates() {
let comp = s.project.comp(item.comp).unwrap();
assert_eq!(item.output.output_size(comp, &item.settings), (32, 32), "explicit width retains precedence");
}
#[test]
fn a_queue_run_keeps_its_jobs_warm() {
// #3335: every job started with a fresh layer cache and trimmed the GPU's texture pool when
// it ended, so each item's first frames were cold. A run's jobs share its caches; the pool is
// trimmed once, when the run ends; an item's output modules share one compositor choice.
use std::sync::atomic::{AtomicUsize, Ordering};
struct Recorder(Mutex<Vec<(Option<usize>, Option<usize>)>>);
impl Exporter for Recorder {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
let caches = job.caches.map(|c| c as *const _ as usize);
let slot = job.renderer.map(|r| {
// The first module chooses; the second sees its choice.
r.lock().unwrap().get_or_insert(effectcraft_render::Backend::Cpu);
r as *const _ as usize
});
self.0.lock().unwrap().push((caches, slot));
Ok(ExportResult { path: job.path.to_string(), ..Default::default() })
}
}
#[derive(Default)]
struct Ends(AtomicUsize);
impl effectcraft_render::Accelerator for Ends {
fn name(&self) -> String {
"ends".into()
}
fn comp_frame(&self, _: &effectcraft_render::Renderer, _: effectcraft_project::ItemId, _: effectcraft_time::Tick) -> Option<effectcraft_raster::Image> {
None
}
fn supports_effect(&self, _: &str) -> bool {
false
}
fn effects(&self, _: &[effectcraft_render::FxStep], _: &effectcraft_effects::Buf, _: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
fn render_end(&self) {
self.0.fetch_add(1, Ordering::SeqCst);
}
}
let mut s = demo();
let ex = Arc::new(Recorder(Mutex::new(vec![])));
s.exporter = Some(ex.clone());
let ends = Arc::new(Ends::default());
s.accel = Some(ends.clone());
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_a.mp4")})).unwrap();
s.execute("render.addOutputModule", json!({"format": "png"})).unwrap();
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out("rq/warm_b.mp4")})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
let jobs = ex.0.lock().unwrap().clone();
assert_eq!(jobs.len(), 3, "{jobs:?}");
assert!(jobs.iter().all(|(c, r)| c.is_some() && r.is_some()), "{jobs:?}");
assert!(jobs.iter().all(|(c, _)| *c == jobs[0].0), "every job of the run shares its caches");
assert_eq!(jobs[0].1, jobs[1].1, "an item's output modules share one choice");
assert_eq!(ends.0.load(Ordering::SeqCst), 1, "the pool is trimmed once, after the run");
}
#[test]
fn the_gpu_frames_setting_reaches_every_render_queue_job() {
// #3335: Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once is Auto (`None`) or 1–3.
struct Seen(Mutex<Vec<Option<usize>>>);
impl Exporter for Seen {
fn formats(&self) -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
fn export(&self, job: &ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String> {
self.0.lock().unwrap().push(job.gpu_frames);
Ok(ExportResult { path: job.path.to_string(), ..Default::default() })
}
}
let mut s = demo();
let ex = Arc::new(Seen(Mutex::new(vec![])));
s.exporter = Some(ex.clone());
assert_eq!(s.prefs.memory.gpu_frames_in_flight, "auto");
let pages = crate::settings::prefs::pages_json();
assert!(pages.to_string().contains("memory.gpuFramesInFlight"), "the Memory & CPU page lists it");
for (value, want) in [("auto", None), ("1", Some(1)), ("2", Some(2)), ("3", Some(3))] {
s.execute("prefs.set", json!({"key": "memory.gpuFramesInFlight", "value": value})).unwrap();
assert_eq!(s.prefs.memory.gpu_frames(), want, "{value}");
s.execute("renderQueue.add", json!({"output": crate::tests::stubs::tmp_out(&format!("rq/frames_{value}.mp4"))})).unwrap();
s.execute("renderQueue.render", json!({})).unwrap();
assert_eq!(ex.0.lock().unwrap().last().copied(), Some(want), "{value}");
}
// A hand-edited settings file's nonsense is Auto.
s.prefs.memory.gpu_frames_in_flight = "9".into();
assert_eq!(s.prefs.memory.gpu_frames(), None);
}
@@ -310,6 +310,7 @@ pub fn pages() -> Vec<Page> {
s("memory.mediaCacheMb", "Footage Frame Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.previewCacheMb", "Preview (RAM) Cache", Kind::Int(64, 1 << 20, "MB"), true),
s("memory.reduceCacheWhenLow", "Reduce Cache Size When System Is Low on Memory", B, true),
s("memory.gpuFramesInFlight", "GPU Frames Rendered at Once", Kind::Choice(&[("Auto", "auto"), ("1", "1"), ("2", "2"), ("3", "3")]), true),
],
},
Page {
@@ -201,8 +201,24 @@ page!(Memory {
/// RAM preview (viewer frame) cache budget (MB).
preview_cache_mb: u32 = 3072,
reduce_cache_when_low: bool = true,
/// GPU Frames Rendered at Once (Render Queue): `auto` (from the GPU memory a frame
/// measured, 1 to 3) or `1`, `2`, `3`. `EFFECTCRAFT_GPU_INFLIGHT` overrides it.
gpu_frames_in_flight: String = "auto".into(),
});
impl Memory {
/// [`Memory::gpu_frames_in_flight`] as a count: 1 to 3, or `None` for Auto (and anything
/// else, as a hand-edited settings file may hold).
pub fn gpu_frames(&self) -> Option<usize> {
match self.gpu_frames_in_flight.trim() {
"1" => Some(1),
"2" => Some(2),
"3" => Some(3),
_ => None,
}
}
}
page!(Video {
enable_output: bool = false,
device: String = String::new(),
+53 -2
View File
@@ -214,16 +214,31 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
let file = create_session_file(&path)?;
Ok((path, file))
}
// Another process may create the current log after our rotation. Never truncate its
// file: its writer and crash reporter may already hold the same inode open.
fn create_session_file(path: &Path) -> std::io::Result<File> {
std::fs::OpenOptions::new().write(true).create_new(true).open(path)
}
/// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to
/// a fresh `effectcraft.log` there, first the ones logged so far; returns its path.
pub fn attach_session_log(dir: &Path) -> Result<PathBuf, String> {
attach_session_log_with_header(dir, "")
}
/// Open a session log with a bounded, unfiltered identity header before buffered records.
/// A crash reporter can then recognize this run even with logging disabled or verbose startup.
pub fn attach_session_log_with_header(dir: &Path, header: &str) -> Result<PathBuf, String> {
if header.len() > 1024 {
return Err("session log header exceeds 1024 bytes".into());
}
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner);
sink.attach(file);
sink.attach_with_header(file, header);
sink.path = Some(path.clone());
Ok(path)
}
@@ -264,8 +279,14 @@ impl Sink {
}
/// Start writing to `file`, first the lines kept so far.
#[cfg(test)]
fn attach(&mut self, file: File) {
self.attach_with_header(file, "");
}
fn attach_with_header(&mut self, file: File, header: &str) {
self.file = Some(file);
self.append(header);
for line in std::mem::take(&mut self.pending) {
self.append(&line);
}
@@ -333,6 +354,22 @@ mod tests {
use super::*;
use std::sync::atomic::AtomicUsize;
#[test]
fn identity_header_precedes_buffered_records_independent_of_log_filter() {
let dir = temp_dir("identity-header");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("session");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write(&"earlier startup record".repeat(1000));
sink.attach_with_header(File::create(&path).unwrap(), "crash-report session 1-2\n");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("crash-report session 1-2\n"));
assert!(text.contains("earlier startup record"));
assert!(attach_session_log_with_header(&dir, &"x".repeat(1025)).is_err());
drop(sink);
std::fs::remove_dir_all(dir).unwrap();
}
/// `RUST_LOG` picks what start-up prints to stderr (#234).
#[test]
fn rust_log_filters_by_level_and_target() {
@@ -397,6 +434,20 @@ mod tests {
assert_eq!(timestamp(u64::MAX), "584556019-04-03T14:25:51.615Z");
}
#[test]
fn concurrent_session_creation_does_not_truncate_the_winner() {
let dir = temp_dir("concurrent-create");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(SESSION_LOG);
let mut winner = create_session_file(&path).unwrap();
winner.write_all(b"crash-report session 1-2\nwinner content\n").unwrap();
assert_eq!(create_session_file(&path).unwrap_err().kind(), std::io::ErrorKind::AlreadyExists);
winner.write_all(b"still the same session\n").unwrap();
assert_eq!(read(&path), "crash-report session 1-2\nwinner content\nstill the same session\n");
drop(winner);
std::fs::remove_dir_all(dir).unwrap();
}
/// Each launch keeps the previous runs' logs, so the log of a run that crashed survives.
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
+37 -1
View File
@@ -53,6 +53,7 @@ pub use effectcraft_project::render_queue;
pub use effectcraft_project::render_queue::StorageQuota;
pub use out::Sink;
pub(crate) use pipeline::Cx;
pub use pipeline::{CACHE_BUDGET, GPU_INFLIGHT_MAX, RENDERER_ENV, auto_gpu_frames, pick_renderer};
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
@@ -107,6 +108,40 @@ pub struct JobOptions<'a> {
pub storage: Option<&'a dyn StorageQuota>,
/// Overflow folders, in order.
pub overflow: Vec<String>,
/// Layer caches shared by the consecutive jobs of a Render Queue run (#3335): the job
/// renders through them instead of fresh ones, and leaves the accelerator's pooled working
/// textures for the next job (whoever holds the caches calls
/// [`effectcraft_render::Accelerator::render_end`] after the last one). `None`: the job
/// starts cold and trims the pool when it ends, as a one-off export does.
pub caches: Option<JobCaches<'a>>,
/// The compositor of the job's frames (`Backend::Gpu` or `Backend::Cpu`; `Auto` counts as unset), shared by the output modules of
/// one queue item: when it holds a choice the job renders on it; otherwise the job stores
/// the compositor it chose there, so the item's other output modules encode the same
/// frames. `None`: the job decides alone.
pub renderer: Option<&'a std::sync::Mutex<Option<effectcraft_render::Backend>>>,
/// Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: 1 to 3, or `None` for Auto (from
/// the device memory a frame measured, #3335). `EFFECTCRAFT_GPU_INFLIGHT` overrides it.
pub gpu_frames: Option<usize>,
}
/// The layer caches of a Render Queue run (see [`JobOptions::caches`]): one per compositor,
/// because a buffer an effect made on the GPU is not the CPU's, and neither may stand in for
/// the other.
#[derive(Clone, Copy)]
pub struct JobCaches<'a> {
pub gpu: &'a effectcraft_render::LayerCache,
pub cpu: &'a effectcraft_render::LayerCache,
}
/// The compositor a job's frames render on: the GPU's (GPU Acceleration, with an
/// accelerator) or the CPU's frame-parallel one (Software Rendering). The whole job renders
/// on one of them, never a mix: the two differ slightly. A GPU project with an accelerator
/// measures both on its first frames and renders the job on the faster (#3335); the choice is
/// in the render log. `EFFECTCRAFT_RQ_RENDERER=gpu|cpu|auto` overrides the measurement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum JobRenderer {
Gpu,
Cpu,
}
/// Progress after a batch of frames.
@@ -176,7 +211,8 @@ pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<
pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
let t0 = Instant::now();
let cx = Cx::new(job);
let render = RenderEnd(job.accel);
// A job of a Render Queue run leaves the pooled working textures warm for the next one.
let render = RenderEnd(job.accel.filter(|_| job.options.caches.is_none()));
let mut r = export_with(&cx, progress, t0).await;
drop(render);
if matches!(r, Err(ExportError::Cancelled)) {
+577 -47
View File
@@ -13,30 +13,107 @@ use web_time::Instant;
use crate::{Job, RenderQuality, out};
/// GPU frames rendered at once by default: one, as the export scheduler's single GPU worker
/// renders image sequences (#898: frames in flight beyond one only with asynchronous YUV
/// readback). Measured on the #879 project at 1080p on an 8 GB card: 6.3 GB peak with one,
/// 7.0 to 7.8 GB with two, and the whole card with three, where main fell back to the CPU.
/// Not sized from free VRAM: the GPU backend reports no free or total device memory on every
/// platform, so a portable estimate is not available here.
/// GPU frames rendered at once until a frame has measured the device memory, and whenever it
/// can't be measured (#898, #879: measured on the #879 project at 1080p on an 8 GB card, 6.3 GB
/// peak with one frame, 7.0 to 7.8 GB with two, the whole card with three).
pub(crate) const GPU_INFLIGHT_DEFAULT: usize = 1;
/// Overrides [`GPU_INFLIGHT_DEFAULT`]: a whole number of frames, at least 1.
/// The most GPU frames Auto renders at once (#3335: four and more measured no faster).
pub const GPU_INFLIGHT_MAX: usize = 3;
/// Overrides Settings ▸ Memory & CPU ▸ GPU Frames Rendered at Once: a whole number of frames,
/// at least 1.
pub(crate) const GPU_INFLIGHT_ENV: &str = "EFFECTCRAFT_GPU_INFLIGHT";
/// GPU frames in flight from the `EFFECTCRAFT_GPU_INFLIGHT` value: a positive whole number, or
/// the default (with a warning when a value was given) when it is 0, negative, not a number or
/// too large.
pub(crate) fn gpu_inflight(var: Option<&str>) -> usize {
let Some(v) = var else { return GPU_INFLIGHT_DEFAULT };
/// `None` (with a warning when a value was given) when it is unset, 0, negative, not a number
/// or too large.
pub(crate) fn gpu_inflight_env(var: Option<&str>) -> Option<usize> {
let v = var?;
match v.trim().parse::<usize>() {
Ok(n) if n >= 1 => n,
Ok(n) if n >= 1 => Some(n),
_ => {
log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: using {GPU_INFLIGHT_DEFAULT}");
GPU_INFLIGHT_DEFAULT
log::warn!(target: "effectcraft_export", "{GPU_INFLIGHT_ENV}={v:?} is not a whole number of frames of at least 1: ignored");
None
}
}
}
/// Auto's GPU frames in flight from the device memory after a frame (#3335): as many more
/// frames as the budget has room for beyond the allocator's peak (which holds one frame's
/// working memory) and what the caches may still grow by, 1 to [`GPU_INFLIGHT_MAX`]. Unknown
/// memory: 1.
pub fn auto_gpu_frames(m: Option<effectcraft_render::FrameMemory>) -> usize {
let Some(m) = m.filter(|m| m.budget > 0 && m.frame > 0 && m.peak > 0) else { return GPU_INFLIGHT_DEFAULT };
let more = m.budget.saturating_sub(m.peak).saturating_sub(m.reserve) / m.frame;
usize::try_from(more.saturating_add(1)).unwrap_or(GPU_INFLIGHT_MAX).clamp(1, GPU_INFLIGHT_MAX)
}
/// The GPU frames a job renders at once and why (for the render log): the environment's
/// override, else the setting (`pref`, `None` = Auto), else Auto from `measured` (`None`: no
/// GPU frame has rendered yet, so nothing is settled).
pub(crate) fn resolve_gpu_frames(env: Option<&str>, pref: Option<usize>, measured: Option<Option<effectcraft_render::FrameMemory>>) -> Option<(usize, String)> {
if let Some(n) = gpu_inflight_env(env) {
return Some((n, format!("set by {GPU_INFLIGHT_ENV}")));
}
if let Some(n) = pref.filter(|n| *n >= 1) {
return Some((n, "Settings".into()));
}
let m = measured?;
let n = auto_gpu_frames(m);
let mib = |b: u64| b >> 20;
Some((
n,
match m {
Some(m) => format!(
"Auto: {} MiB per frame, {} MiB peak and {} MiB cache reserve in a {} MiB budget",
mib(m.frame),
mib(m.peak),
mib(m.reserve),
mib(m.budget)
),
None => "Auto: the GPU's memory is not known".into(),
},
))
}
/// Overrides the Render Queue's measured choice of compositor for GPU projects with an
/// accelerator (#3335): `gpu` (no measurement, the GPU), `cpu` (Software Rendering) or
/// `auto` (measure, the default).
pub const RENDERER_ENV: &str = "EFFECTCRAFT_RQ_RENDERER";
/// Frames the GPU renders, timed, when a job measures which compositor is faster (after one
/// untimed warm-up frame each: layer cache misses, uploads, kernel builds).
const PROBE_FRAMES: usize = 2;
/// Frames the CPU renders at once, timed, in that measurement: its throughput comes from
/// rendering several frames in parallel, which two frames understate.
const PROBE_CPU_FRAMES: usize = 8;
/// Jobs shorter than this render on the project's compositor without measuring: the frames
/// the slower one renders for the measurement would cost more than they could save.
const PROBE_MIN_JOB: u64 = 12;
/// Bytes each of a job's two layer caches (one per compositor) may hold: together what the
/// job's single cache held before (`LayerCache::default()`).
pub const CACHE_BUDGET: usize = 1 << 29;
/// The faster compositor from measured milliseconds per frame (`None`: not measured, or the
/// GPU stopped). As the viewer's Auto ([`effectcraft_render::AutoPick::MARGIN`]), the CPU wins
/// only when clearly faster: ties and noise stay on the GPU.
pub fn pick_renderer(gpu_ms: Option<f64>, cpu_ms: Option<f64>) -> crate::JobRenderer {
match (gpu_ms, cpu_ms) {
(Some(g), Some(c)) if g.is_finite() && c.is_finite() && c < g * effectcraft_render::AutoPick::MARGIN => crate::JobRenderer::Cpu,
(None, Some(_)) => crate::JobRenderer::Cpu,
_ => crate::JobRenderer::Gpu,
}
}
/// How a job's compositor was chosen, for the render log.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Choice {
pub renderer: crate::JobRenderer,
/// Measured ms per frame on each (`None`: not measured).
pub gpu_ms: Option<f64>,
pub cpu_ms: Option<f64>,
pub why: &'static str,
}
/// Caps CPU frames in flight (#1615): a whole number of frames, at least 1. Unset, CPU renders
/// keep one frame per thread in flight as before. A heavy comp can need gigabytes of working
/// memory per frame, so 16 threads at once can get the process OOM-killed; the headless CLI
@@ -168,8 +245,14 @@ pub(crate) struct Cx<'a> {
pub project: Project,
/// Processed layer buffers shared by the job's frames, as the viewer's layer cache: static
/// and transform-only layers render once per job (the GPU compositor then uploads them
/// once). Dropped with the job.
cache: LayerCache,
/// once). One per compositor; the job's own (dropped with it) or a Render Queue run's.
caches: Caches<'a>,
/// The compositor the job renders on, once chosen ([`Cx::backend`]).
choice: Mutex<Option<Choice>>,
/// GPU frames rendered at once and why, once settled ([`Cx::gpu_inflight`]).
gpu_frames: Mutex<Option<(usize, String)>>,
/// A frame has rendered on the GPU compositor: its memory can be measured.
gpu_rendered: std::sync::atomic::AtomicBool,
/// (output frame, seconds) of every rendered frame (Plus Per Frame Info logs).
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
/// Files written to an overflow folder.
@@ -177,6 +260,23 @@ pub(crate) struct Cx<'a> {
written: Mutex<Vec<String>>,
}
/// A job's layer caches: its own, or those of the Render Queue run it belongs to.
enum Caches<'a> {
Own { gpu: Box<LayerCache>, cpu: Box<LayerCache> },
Run(crate::JobCaches<'a>),
}
impl Caches<'_> {
fn of(&self, r: crate::JobRenderer) -> &LayerCache {
match (self, r) {
(Caches::Own { gpu, .. }, crate::JobRenderer::Gpu) => gpu,
(Caches::Own { cpu, .. }, crate::JobRenderer::Cpu) => cpu,
(Caches::Run(c), crate::JobRenderer::Gpu) => c.gpu,
(Caches::Run(c), crate::JobRenderer::Cpu) => c.cpu,
}
}
}
impl<'a> std::ops::Deref for Cx<'a> {
type Target = Job<'a>;
fn deref(&self) -> &Job<'a> {
@@ -222,10 +322,95 @@ impl<'a> Cx<'a> {
// layer and effect quantises at it), not just the finished frame (#2022).
project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
let cache = LayerCache::default();
let caches = match job.options.caches {
Some(c) => Caches::Run(c),
None => Caches::Own { gpu: Box::new(LayerCache::new(CACHE_BUDGET)), cpu: Box::new(LayerCache::new(CACHE_BUDGET)) },
};
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
cache.set_gate(job.accel.and_then(|a| a.miss_gate()));
Cx { job, project, cache, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }
for r in [crate::JobRenderer::Gpu, crate::JobRenderer::Cpu] {
caches.of(r).set_gate(job.accel.and_then(|a| a.miss_gate()));
}
let cx = Cx {
job,
project,
caches,
choice: Mutex::new(None),
gpu_frames: Mutex::new(None),
gpu_rendered: Default::default(),
frame_times,
overflowed: Mutex::new(vec![]),
written: Mutex::new(vec![]),
};
if let Some(c) = cx.fixed_choice(std::env::var(RENDERER_ENV).ok().as_deref()) {
cx.decide(c);
}
cx
}
/// The project's compositor: the GPU's for GPU Acceleration with an accelerator.
fn project_renderer(&self) -> crate::JobRenderer {
if self.project.settings.gpu_acceleration && self.job.accel.is_some() { crate::JobRenderer::Gpu } else { crate::JobRenderer::Cpu }
}
/// The job's compositor when it is decided without measuring: the queue item's first
/// output module's, the project's when there is nothing to choose between (Software Only,
/// no accelerator, deferred readbacks), or `env` ([`RENDERER_ENV`]). `None`: measure.
fn fixed_choice(&self, env: Option<&str>) -> Option<Choice> {
let fixed = |renderer, why| Some(Choice { renderer, gpu_ms: None, cpu_ms: None, why });
if self.project_renderer() == crate::JobRenderer::Cpu {
return fixed(crate::JobRenderer::Cpu, "the project's renderer");
}
if self.deferred() {
return fixed(crate::JobRenderer::Gpu, "the project's renderer");
}
let shared = self.job.options.renderer.and_then(|s| *s.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let shared = match shared {
Some(Backend::Gpu) => Some(crate::JobRenderer::Gpu),
Some(Backend::Cpu) => Some(crate::JobRenderer::Cpu),
Some(Backend::Auto) | None => None,
};
if let Some(r) = shared {
return Some(Choice { renderer: r, gpu_ms: None, cpu_ms: None, why: "as the queue item's first output module" });
}
match env.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("gpu") => fixed(crate::JobRenderer::Gpu, "set by EFFECTCRAFT_RQ_RENDERER"),
Some("cpu") => fixed(crate::JobRenderer::Cpu, "set by EFFECTCRAFT_RQ_RENDERER"),
None | Some("" | "auto") => None,
Some(v) => {
log::warn!(target: "effectcraft_export", "{RENDERER_ENV}={v:?} is not gpu, cpu or auto: measuring");
None
}
}
}
/// Settle the job's compositor (once): remember it for the queue item's other output
/// modules and say so in the log.
fn decide(&self, c: Choice) {
let mut slot = self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if slot.is_some() {
return;
}
*slot = Some(c);
if let Some(shared) = self.job.options.renderer {
let mut shared = shared.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if shared.is_none_or(|b| b == Backend::Auto) {
*shared = Some(match c.renderer {
crate::JobRenderer::Gpu => Backend::Gpu,
crate::JobRenderer::Cpu => Backend::Cpu,
});
}
}
log::info!(target: "effectcraft_export", "{}: {}", self.job.options.label, describe_choice(&c));
}
/// The job's choice, once made.
pub(crate) fn choice(&self) -> Option<Choice> {
*self.choice.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The compositor frames render on: the choice, or the project's until it is made.
fn backend(&self) -> crate::JobRenderer {
self.choice().map_or_else(|| self.project_renderer(), |c| c.renderer)
}
pub fn comp(&self) -> Option<&Comp> {
@@ -234,12 +419,22 @@ impl<'a> Cx<'a> {
/// The renderer of the job's frames: the Render Settings' options, the job's accelerator
/// and layer cache.
#[cfg(test)]
fn renderer(&self) -> Renderer<'_> {
self.renderer_on(self.backend())
}
/// [`Cx::renderer`] on compositor `on`, with that compositor's layer cache.
fn renderer_on(&self, on: crate::JobRenderer) -> Renderer<'_> {
let s = self.job.settings;
// The whole job renders on the project's renderer (Mercury GPU Acceleration or Software
// Only), not on whichever compositor Auto timed faster for each frame: the CPU and GPU
// compositors differ slightly, so a per-frame choice made renders differ run to run.
let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu };
// The whole job renders on one compositor (the project's, or the faster one measured on
// the first frames, #3335), not on whichever compositor Auto timed faster for each
// frame: the CPU and GPU compositors differ slightly, so a per-frame choice made renders
// differ within a render.
let backend = match on {
crate::JobRenderer::Gpu => Backend::Gpu,
crate::JobRenderer::Cpu => Backend::Cpu,
};
let opts = RenderOpts {
scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)),
motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll,
@@ -258,13 +453,13 @@ impl<'a> Cx<'a> {
let mut r = Renderer::new(&self.project, self.job.footage, opts);
r.expr = self.job.expr;
r.accel = self.job.accel;
r.cache = Some(&self.cache);
r.cache = Some(self.caches.of(on));
r
}
async fn render_at(&self, t: Tick) -> Image {
async fn render_at(&self, t: Tick, on: crate::JobRenderer) -> Image {
// With deferred GPU readbacks (a browser worker) the frame renders in passes.
effectcraft_render::passes::comp_frame(&self.renderer(), self.job.comp, t).await
effectcraft_render::passes::comp_frame(&self.renderer_on(on), self.job.comp, t).await
}
/// Whether frames render in passes (an accelerator with deferred readbacks).
@@ -283,10 +478,110 @@ impl<'a> Cx<'a> {
}
return out;
}
self.render_frames(ks, |k, on| self.frame_on(comp, k, on), f)
}
/// `render` then `encode` over the frames `ks` on the job's compositor, results in `ks`
/// order. The first call of a job that still has to choose its compositor measures both on
/// its first frames ([`Cx::probe`]) and keeps the faster one's.
fn render_frames<I: Send, T: Send>(
&self,
ks: Vec<u64>,
render: impl Fn(u64, crate::JobRenderer) -> I + Sync + Send,
encode: impl Fn(u64, I) -> T + Sync + Send,
) -> Vec<T> {
use rayon::prelude::*;
let probed = self.probe(&ks, &render);
// The GPU cap is for the GPU's memory: once it has stopped for good, frames render on the
// CPU, in parallel or under the CPU cap (#1615), which holds throughout.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
map_frames_while(ks, self.inflight(), gpu_live, |k| self.frame(comp, k), f)
let on = self.backend();
if probed.is_empty() {
return map_frames_while(ks, self.inflight(), gpu_live, |k| render(k, on), encode);
}
let rest: Vec<u64> = ks.iter().copied().filter(|k| !probed.iter().any(|(p, _)| p == k)).collect();
let mut done: Vec<(u64, T)> = probed.into_par_iter().map(|(k, i)| (k, encode(k, i))).collect();
done.extend(rest.iter().copied().zip(map_frames_while(rest.clone(), self.inflight(), gpu_live, |k| render(k, on), &encode)));
// Back in `ks` order.
let mut out = Vec::with_capacity(ks.len());
for k in &ks {
if let Some(i) = done.iter().position(|(d, _)| d == k) {
out.push(done.swap_remove(i).1);
}
}
out
}
/// Choose the job's compositor by timing both on the first frames of `ks` (#3335), as the
/// viewer's Auto does per comp: frame 0 warms each up (layer cache misses, uploads, kernel
/// builds) untimed, then the GPU renders the next [`PROBE_FRAMES`] under its in-flight cap
/// and the CPU up to [`PROBE_CPU_FRAMES`] after them in parallel (its throughput). The CPU
/// may render more frames at once in the job than here, so the measurement leans towards
/// the GPU, as the margin of [`pick_renderer`] does: the CPU is chosen only when clearly
/// faster. Returns
/// the frames the faster one rendered (the slower one's are rendered again); empty when
/// the choice was made already or is made without measuring (a short job).
fn probe<I: Send>(&self, ks: &[u64], render: &(impl Fn(u64, crate::JobRenderer) -> I + Sync + Send)) -> Vec<(u64, I)> {
if self.choice().is_some() {
return vec![];
}
let total = self.comp().map_or(0, |c| self.job.settings.frame_count(c));
if total < PROBE_MIN_JOB {
self.decide(Choice { renderer: crate::JobRenderer::Gpu, gpu_ms: None, cpu_ms: None, why: "the project's renderer (too few frames to measure)" });
return vec![];
}
// Nothing will be emitted for a skipped batch, so a later batch can still measure.
if ks.is_empty() {
return vec![];
}
// A resumed sequence can have only one or two missing frames in its first batch.
// Settle before emitting them: measuring a later batch could otherwise switch to the
// CPU after GPU pixels have already been written, including across output modules.
let n = PROBE_FRAMES.min(ks.len().saturating_sub(1) / 2);
if n == 0 {
self.decide(Choice {
renderer: crate::JobRenderer::Gpu,
gpu_ms: None,
cpu_ms: None,
why: "the project's renderer (too few pending frames to measure)",
});
return vec![];
}
let (Some(&first), Some(gpu_ks), Some(cpu_ks)) = (ks.first(), ks.get(1..1 + n), ks.get(1 + n..ks.len().min(1 + n + PROBE_CPU_FRAMES))) else {
return vec![];
};
let logged = || self.frame_times.as_ref().map_or(0, |ft| ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).len());
let cpu_cap = self.inflight().cpu;
let timed = |on: crate::JobRenderer, frames: &[u64]| {
let warm = render(first, on);
// The GPU's frames in flight settle with its warm-up frame (Auto measures it).
let caps = match on {
crate::JobRenderer::Gpu => Inflight { gpu: Some(self.gpu_inflight()), cpu: cpu_cap },
crate::JobRenderer::Cpu => Inflight { gpu: None, cpu: cpu_cap },
};
let t0 = Instant::now();
let gpu_live = || on == crate::JobRenderer::Cpu || self.job.accel.is_some_and(|a| a.available());
let items = map_frames_while(frames.to_vec(), caps, gpu_live, |k| render(k, on), |k, i| (k, i));
let ms = t0.elapsed().as_secs_f64() * 1e3 / frames.len().max(1) as f64;
(std::iter::once((first, warm)).chain(items).collect::<Vec<_>>(), ms)
};
let log0 = logged();
let (gpu, gpu_ms) = timed(crate::JobRenderer::Gpu, gpu_ks);
let log1 = logged();
let (cpu, cpu_ms) = timed(crate::JobRenderer::Cpu, cpu_ks);
// A GPU that stopped meanwhile rendered its frames on the CPU: it is no choice.
let gpu_ms = self.job.accel.is_some_and(|a| a.available()).then_some(gpu_ms);
let renderer = pick_renderer(gpu_ms, Some(cpu_ms));
// The per-frame log keeps the kept frames' times only.
if let Some(ft) = &self.frame_times {
let mut v = ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let (a, b) = if renderer == crate::JobRenderer::Gpu { (log1, v.len()) } else { (log0, log1) };
if a <= b && b <= v.len() {
v.drain(a..b);
}
}
self.decide(Choice { renderer, gpu_ms, cpu_ms: Some(cpu_ms), why: "measured on the first frames" });
if renderer == crate::JobRenderer::Gpu { gpu } else { cpu }
}
/// [`Cx::frames`] for `ks` while `encode` runs beside them on `beside`'s threads, so the
@@ -338,9 +633,29 @@ impl<'a> Cx<'a> {
/// GPU frames rendered at once (#879), decided once per batch: with a GPU accelerator every
/// frame in flight holds its own working textures, so a whole batch rendered at once can
/// exhaust VRAM (or shared memory, which crashes instead of falling back). `None` when the
/// frames render on the CPU: no accelerator, or a Software Only project.
/// frames render on the CPU: no accelerator, a Software Only project, or a job that chose
/// the CPU's compositor (#3335), whose frames render in parallel.
fn gpu_cap(&self) -> Option<usize> {
(self.job.accel.is_some() && self.project.settings.gpu_acceleration).then(|| gpu_inflight(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref()))
(self.job.accel.is_some() && self.backend() == crate::JobRenderer::Gpu).then(|| self.gpu_inflight())
}
/// GPU frames in flight ([`resolve_gpu_frames`]): [`GPU_INFLIGHT_DEFAULT`] until settled,
/// which Auto is after the job's first GPU frame.
fn gpu_inflight(&self) -> usize {
if let Some((n, _)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner) {
return *n;
}
// Measured outside the lock (the allocator report walks every allocation).
let measured = self.gpu_rendered.load(std::sync::atomic::Ordering::Relaxed).then(|| self.job.accel.and_then(|a| a.frame_memory()));
let Some((n, why)) = resolve_gpu_frames(std::env::var(GPU_INFLIGHT_ENV).ok().as_deref(), self.job.options.gpu_frames, measured) else {
return GPU_INFLIGHT_DEFAULT;
};
let mut slot = self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
slot.get_or_insert_with(|| {
log::info!(target: "effectcraft_export", "{}: {n} GPU frame(s) at once ({why})", self.job.options.label);
(n, why)
})
.0
}
/// The GPU and CPU frames-in-flight caps every frame loop shares (#1615).
@@ -349,20 +664,34 @@ impl<'a> Cx<'a> {
}
/// Output frame `i`: sampled (fields, pulldown), at the colour depth, cropped and resized.
#[cfg(test)]
pub fn frame(&self, comp: &Comp, i: u64) -> Image {
effectcraft_render::passes::block_on(self.frame_async(comp, i))
self.frame_on(comp, i, self.backend())
}
/// [`Cx::frame`] on compositor `on`.
fn frame_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image {
effectcraft_render::passes::block_on(self.frame_async_on(comp, i, on))
}
/// [`Cx::frame`] as a future (deferred readbacks: the frame renders in passes).
pub async fn frame_async(&self, comp: &Comp, i: u64) -> Image {
self.frame_async_on(comp, i, self.backend()).await
}
async fn frame_async_on(&self, comp: &Comp, i: u64, on: crate::JobRenderer) -> Image {
let t0 = Instant::now();
let gpu = on == crate::JobRenderer::Gpu && self.job.accel.is_some();
let img = match self.job.settings.sample(comp, i) {
FrameSample::Progressive(t) => self.render_at(t).await,
FrameSample::Progressive(t) => self.render_at(t, on).await,
FrameSample::Fields { first, second, upper_first } => {
let a = self.render_at(first).await;
if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
let a = self.render_at(first, on).await;
if second == first { a } else { interleave(&a, &self.render_at(second, on).await, upper_first) }
}
};
if gpu {
self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
}
let img = self.crop_resize(img);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
@@ -374,7 +703,7 @@ impl<'a> Cx<'a> {
/// by the accelerator ([`effectcraft_render::Accelerator::comp_frame_yuv420`]). Only for
/// frames that need nothing done after the render (progressive, at the project's depth, no
/// crop or resize, rendered at exactly `w`×`h`). `None`: render it with [`Cx::frame`].
pub fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32) -> Option<Vec<u8>> {
fn yuv420_frame(&self, comp: &Comp, i: u64, w: u32, h: u32, on: crate::JobRenderer) -> Option<Vec<u8>> {
if self.deferred() {
return None;
}
@@ -392,7 +721,7 @@ impl<'a> Cx<'a> {
return None;
}
let t0 = Instant::now();
let r = self.renderer();
let r = self.renderer_on(on);
let a = r.active_accel()?;
let planes = a.comp_frame_yuv420(&r, self.job.comp, t, comp.background)?;
let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize);
@@ -401,6 +730,7 @@ impl<'a> Cx<'a> {
}
// A frame rendered off `passes::comp_frame` ends here; on a `None` the fallback renders and ends it.
a.frame_end();
self.gpu_rendered.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(ft) = &self.frame_times {
ft.lock().unwrap_or_else(|e| e.into_inner()).push((i, t0.elapsed().as_secs_f64()));
}
@@ -419,12 +749,9 @@ impl<'a> Cx<'a> {
fallback: impl Fn(u64, Image) -> T + Sync + Send,
) -> Vec<T> {
// As in `frames`: once the GPU has stopped for good, the rest render in parallel.
let gpu_live = || self.job.accel.is_none_or(|a| a.available());
map_frames_while(
self.render_frames(
ks,
self.inflight(),
gpu_live,
|k| self.yuv420_frame(comp, k, w, h).ok_or_else(|| self.frame(comp, k)),
|k, on| self.yuv420_frame(comp, k, w, h, on).ok_or_else(|| self.frame_on(comp, k, on)),
|k, r| match r {
Ok(p) => planes(p),
Err(img) => fallback(k, img),
@@ -544,6 +871,14 @@ impl<'a> Cx<'a> {
Ok(r) => s.push_str(&format!("Result: Done ({} frames, {}×{}, {} bytes) in {seconds:.2} s\n", r.frames, r.width, r.height, r.bytes)),
Err(e) => s.push_str(&format!("Result: Failed\nError: {e}\n")),
}
if let Some(c) = self.choice() {
s.push_str(&format!("Renderer: {}\n", describe_choice(&c)));
}
if self.backend() == crate::JobRenderer::Gpu
&& let Some((n, why)) = &*self.gpu_frames.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
{
s.push_str(&format!("GPU frames rendered at once: {n} ({why})\n"));
}
let over = self.overflowed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !over.is_empty() {
s.push_str(&format!("Storage overflow: {} file(s) written to overflow folders (first: {})\n", over.len(), over[0]));
@@ -575,6 +910,20 @@ impl<'a> Cx<'a> {
}
}
/// The render log's account of a job's compositor: which, why, and what was measured.
pub(crate) fn describe_choice(c: &Choice) -> String {
let name = match c.renderer {
crate::JobRenderer::Gpu => "GPU Acceleration",
crate::JobRenderer::Cpu => "Software Rendering",
};
let ms = |v: Option<f64>| v.map_or_else(|| "n/a".to_string(), |v| format!("{v:.1} ms/frame"));
if c.gpu_ms.is_none() && c.cpu_ms.is_none() {
format!("{name} ({})", c.why)
} else {
format!("{name} ({}: GPU {}, CPU {})", c.why, ms(c.gpu_ms), ms(c.cpu_ms))
}
}
/// Weave two fields: the dominant field's lines (even lines when `upper_first`) from `first`.
pub(crate) fn interleave(first: &Image, second: &Image, upper_first: bool) -> Image {
let mut out = first.clone();
@@ -860,6 +1209,82 @@ mod tests {
assert_eq!(Cx::new(&job).gpu_cap(), None, "no accelerator");
}
/// A resumed sequence can start with one or two missing frames. Once emitted, their
/// compositor must also be used by every later batch and the item's other output modules.
#[test]
fn a_small_first_batch_settles_the_renderer_before_emitting_frames() {
if std::env::var_os(RENDERER_ENV).is_some() {
return;
}
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let accel = NoAccel;
for n in [1, 2] {
let shared = Mutex::new(None);
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: cid,
settings: &settings,
output: &output,
path: "/r/f_[#####].png",
sink: None,
nested_switches: true,
options: crate::JobOptions { renderer: Some(&shared), ..Default::default() },
};
let cx = Cx::new(&job);
let empty = cx.render_frames(Vec::new(), |_, on| on, |_, on| on);
assert!(empty.is_empty());
assert_eq!(cx.choice(), None, "skipped batches do not choose");
let first = cx.render_frames((0..n).collect(), |_, on| on, |_, on| on);
assert_eq!(first, vec![crate::JobRenderer::Gpu; n as usize]);
assert_eq!(*shared.lock().unwrap(), Some(Backend::Gpu), "settled before any pixels are emitted");
let later = cx.render_frames((n..12).collect(), |_, on| on, |_, on| on);
assert!(later.iter().all(|on| *on == crate::JobRenderer::Gpu));
assert_eq!(Cx::new(&job).backend(), crate::JobRenderer::Gpu, "the next output module follows");
}
}
#[test]
fn a_job_that_chose_the_cpu_is_not_capped() {
// #3335: the CPU's frame-parallel compositor is the point of choosing it; a GPU project
// that measured the CPU faster must not keep rendering one frame at a time.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let accel = NoAccel;
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: effectcraft_project::ItemId(0),
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: Default::default(),
};
let cx = Cx::new(&job);
if std::env::var_os(RENDERER_ENV).is_none() {
assert_eq!(cx.choice(), None, "a GPU project measures first");
assert!(cx.gpu_cap().is_some());
}
cx.decide(Choice { renderer: crate::JobRenderer::Cpu, gpu_ms: Some(117.0), cpu_ms: Some(68.0), why: "measured on the first frames" });
assert_eq!(cx.backend(), crate::JobRenderer::Cpu);
assert_eq!(cx.gpu_cap(), None);
assert_eq!(cx.renderer().active_accel().map(|a| a.name()), None, "CPU frames don't use the accelerator");
assert_eq!(describe_choice(&cx.choice().unwrap()), "Software Rendering (measured on the first frames: GPU 117.0 ms/frame, CPU 68.0 ms/frame)");
}
#[test]
fn cpu_inflight_is_off_unless_a_positive_number() {
assert_eq!(cpu_inflight(None), None);
@@ -958,12 +1383,117 @@ mod tests {
}
#[test]
fn gpu_inflight_override_is_a_positive_number_or_the_default() {
assert_eq!(gpu_inflight(None), GPU_INFLIGHT_DEFAULT);
assert_eq!(gpu_inflight(Some("3")), 3);
assert_eq!(gpu_inflight(Some(" 5 ")), 5);
fn gpu_inflight_override_is_a_positive_number_or_ignored() {
assert_eq!(gpu_inflight_env(None), None);
assert_eq!(gpu_inflight_env(Some("3")), Some(3));
assert_eq!(gpu_inflight_env(Some(" 5 ")), Some(5));
for bad in ["0", "", "-2", "three", "2.5", "99999999999999999999999"] {
assert_eq!(gpu_inflight(Some(bad)), GPU_INFLIGHT_DEFAULT, "{bad:?}");
assert_eq!(gpu_inflight_env(Some(bad)), None, "{bad:?}");
}
}
#[test]
fn auto_gpu_frames_follow_the_memory_budget() {
// #3335: Auto renders as many GPU frames at once as the budget has room for, 1 to 3.
use effectcraft_render::FrameMemory;
const GIB: u64 = 1 << 30;
let m = |budget: u64, peak: u64, frame: u64, reserve: u64| Some(FrameMemory { budget, peak, frame, reserve });
// Unknown memory (no allocator report, no budget): one frame.
assert_eq!(auto_gpu_frames(None), 1);
assert_eq!(auto_gpu_frames(m(0, GIB, GIB / 2, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, 0, GIB / 2, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 0, 0)), 1);
// #879 on an 8 GB card: one frame peaked at 6.3 GB, over the 3 GiB budget: one.
assert_eq!(auto_gpu_frames(m(3 * GIB, 63 * GIB / 10, 5 * GIB, 0)), 1);
// Frames that had to wait for memory report the whole budget per frame: one.
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, 3 * GIB, 0)), 1);
// Room for half a frame more, or room the upload cache may still take: one.
assert_eq!(auto_gpu_frames(m(2 * GIB, 3 * GIB / 2, GIB, 0)), 1);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 3 * GIB / 2)), 2);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB, GIB / 2, 2 * GIB)), 1);
// Room for one more frame: two; ample room: three, never more.
assert_eq!(auto_gpu_frames(m(3 * GIB, 2 * GIB, GIB, 0)), 2);
assert_eq!(auto_gpu_frames(m(3 * GIB, GIB / 2, GIB / 4, 0)), 3);
assert_eq!(auto_gpu_frames(m(u64::MAX, 1, 1, 0)), 3);
}
#[test]
fn gpu_frames_come_from_the_environment_the_setting_or_auto() {
use effectcraft_render::FrameMemory;
let ample = Some(Some(FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 1 << 28 }));
let n = |env, pref, measured| resolve_gpu_frames(env, pref, measured).map(|(n, _)| n);
// Auto: unsettled until a GPU frame rendered, then measured.
assert_eq!(n(None, None, None), None);
assert_eq!(n(None, None, Some(None)), Some(1));
assert_eq!(n(None, None, ample), Some(3));
// The setting's 1, 2 or 3, without measuring.
for k in 1..=3 {
assert_eq!(n(None, Some(k), None), Some(k));
assert_eq!(n(None, Some(k), ample), Some(k));
}
// The environment overrides both; a bad value is ignored.
assert_eq!(n(Some("2"), Some(1), None), Some(2));
assert_eq!(n(Some("4"), None, ample), Some(4));
assert_eq!(n(Some("zero"), Some(1), None), Some(1));
let (_, why) = resolve_gpu_frames(None, None, ample).unwrap();
assert_eq!(why, "Auto: 256 MiB per frame, 512 MiB peak and 256 MiB cache reserve in a 3072 MiB budget");
}
/// An accelerator reporting `memory` once asked (renders nothing itself).
struct MemAccel(effectcraft_render::FrameMemory);
impl effectcraft_render::Accelerator for MemAccel {
fn name(&self) -> String {
"mem".into()
}
fn comp_frame(&self, _r: &Renderer, _comp: effectcraft_project::ItemId, _t: Tick) -> Option<Image> {
None
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[effectcraft_render::FxStep], _buf: &effectcraft_effects::Buf, _levels: Option<f32>) -> Option<effectcraft_effects::Buf> {
None
}
fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
Some(self.0)
}
}
#[test]
fn auto_gpu_frames_settle_after_the_first_gpu_frame() {
// #3335: one GPU frame at a time until a frame measured the memory, then Auto's count
// for the rest of the job; the setting skips the measurement.
use effectcraft_project::render_queue::{OutputFormat, OutputModule, RenderSettings};
if std::env::var_os(GPU_INFLIGHT_ENV).is_some() || std::env::var_os(RENDERER_ENV).is_some() {
return;
}
let (settings, output) = (RenderSettings::default(), OutputModule::for_format(OutputFormat::PngSequence));
let mut p = Project::default();
p.settings.gpu_acceleration = true;
let comp = Comp::new(16, 8, effectcraft_time::FrameRate::FPS_25, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", effectcraft_color::Label::Sandstone, None, effectcraft_project::ItemKind::Comp(comp.into()));
let accel = MemAccel(effectcraft_render::FrameMemory { budget: 3 << 30, peak: 1 << 29, frame: 1 << 28, reserve: 0 });
for (pref, want) in [(None, 3), (Some(2), 2), (Some(1), 1)] {
let job = Job {
project: &p,
footage: &effectcraft_render::NoFootage,
expr: None,
accel: Some(&accel),
comp: cid,
settings: &settings,
output: &output,
path: "/c/f_[#####].png",
sink: None,
nested_switches: true,
options: crate::JobOptions { gpu_frames: pref, ..Default::default() },
};
let cx = Cx::new(&job);
if pref.is_none() {
assert_eq!(cx.gpu_cap(), Some(1), "before any GPU frame");
}
let c = cx.comp().unwrap();
cx.frame_on(c, 0, crate::JobRenderer::Gpu);
assert_eq!(cx.gpu_cap(), Some(want), "setting {pref:?}");
}
}
@@ -1002,7 +1532,7 @@ mod tests {
let cx = Cx::new(&job);
let comp = cx.comp().unwrap();
cx.frame(comp, 0);
let renders = cx.cache.stats().misses;
let renders = cx.caches.of(cx.backend()).stats().misses;
assert!(renders > 0);
for i in 1..10 {
let img = cx.frame(comp, i);
@@ -1012,7 +1542,7 @@ mod tests {
uncached.cache = None;
assert_eq!(img.data, uncached.comp_frame(cid, t).data, "frame {i}");
}
let stats = cx.cache.stats();
let stats = cx.caches.of(cx.backend()).stats();
assert_eq!(stats.misses, renders, "the solid rendered on the first frame only");
assert!(stats.hits >= 9, "{stats:?}");
}
+3 -1
View File
@@ -104,6 +104,8 @@ fn project() -> (Project, ItemId) {
fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSettings, path: &Path) -> Vec<u8> {
let om = OutputModule { bitrate_kbps: 4000, ..OutputModule::for_format(OutputFormat::H264) };
let path = path.to_string_lossy().to_string();
// These tests are about the accelerator's own path: no measuring against the CPU (#3335).
let gpu = std::sync::Mutex::new(Some(effectcraft_render::Backend::Gpu));
let job = Job {
project: p,
footage: &NoFootage,
@@ -114,7 +116,7 @@ fn run(p: &Project, cid: ItemId, accel: Option<&dyn Accelerator>, s: &RenderSett
output: &om,
path: &path,
sink: None,
options: Default::default(),
options: effectcraft_export::JobOptions { renderer: Some(&gpu), ..Default::default() },
nested_switches: true,
};
export(&job, &mut |_| true).expect("export");
+147
View File
@@ -501,3 +501,150 @@ fn a_job_renders_on_one_compositor() {
assert_eq!(cpu.frames.load(Ordering::SeqCst), 0);
assert_eq!(c[0].get_pixel(10, 44).0, [255, 0, 0, 255], "the CPU drew the red block");
}
/// A GPU compositor stand-in that renders the frame exactly as the CPU does, after `delay`, and
/// either as it is or solid green (`green`), counting its frames.
struct TimedAccel {
delay: std::time::Duration,
green: bool,
frames: AtomicUsize,
}
impl TimedAccel {
fn new(delay_ms: u64, green: bool) -> TimedAccel {
TimedAccel { delay: std::time::Duration::from_millis(delay_ms), green, frames: AtomicUsize::new(0) }
}
}
impl Accelerator for TimedAccel {
fn name(&self) -> String {
"timed".into()
}
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
self.frames.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(self.delay);
let c = r.project.comp(comp)?;
Some(if self.green { Image::filled(c.width, c.height, [0.0, 1.0, 0.0, 1.0]) } else { r.comp_frame_cpu(comp, t) })
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[FxStep], _buf: &Buf, _levels: Option<f32>) -> Option<Buf> {
None
}
}
/// The environment forces the compositor or the GPU frames in flight: the measured choice
/// isn't what these tests check then.
fn measurement_overridden() -> bool {
std::env::var_os(effectcraft_export::RENDERER_ENV).is_some() || std::env::var_os("EFFECTCRAFT_GPU_INFLIGHT").is_some()
}
/// [`run_on`] on a pool of 8 threads (a batch of 8 frames: room to measure both compositors on
/// the first one, whatever the machine), returning the PNGs and the render log.
fn run_pooled(p: &Project, cid: ItemId, s: &RenderSettings, path: &str, options: JobOptions, accel: &dyn Accelerator) -> (Vec<image::RgbaImage>, String) {
let om = OutputModule::for_format(OutputFormat::PngSequence);
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().unwrap();
let options = JobOptions { log: RenderLog::PlusSettings, ..options };
let (r, files) = pool.install(|| run_on(p, cid, s, &om, path, options, (Some(accel), false)));
let n = r.expect("export").frames as usize;
let log = files.lock().unwrap().iter().find(|(p, _)| p.ends_with("_RenderLog.txt")).map(|(_, d)| String::from_utf8(d.clone()).unwrap()).expect("log");
((0..n).map(|i| png(&files, i)).collect(), log)
}
#[test]
fn a_transfer_bound_gpu_job_renders_on_the_cpu() {
// #3335: a GPU project whose GPU frames are slower than the CPU's renders the whole job on
// the CPU's frame-parallel compositor, measured on its first frames, and says so in the log.
if measurement_overridden() {
return;
}
let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {});
p.settings.gpu_acceleration = true;
let s = custom(0.0, 0.8);
let slow = TimedAccel::new(60, true);
let (frames, log) = run_pooled(&p, cid, &s, "/r/a_[#####].png", JobOptions::default(), &slow);
assert_eq!(frames.len(), 20);
// The GPU rendered its warm-up and timed frames only, and none of them is in the output.
assert_eq!(slow.frames.load(Ordering::SeqCst), 3);
for (i, f) in frames.iter().enumerate() {
assert_eq!(f.get_pixel(10, 44).0, [255, 0, 0, 255], "frame {i} is not the CPU's");
}
assert!(log.contains("Renderer: Software Rendering (measured on the first frames: GPU "), "{log}");
// The queue item's next output module follows the first one's choice without measuring.
let shared = Mutex::new(None);
let opts = || JobOptions { renderer: Some(&shared), ..Default::default() };
let slow = TimedAccel::new(60, true);
run_pooled(&p, cid, &s, "/r/b_[#####].png", opts(), &slow);
assert_eq!(*shared.lock().unwrap(), Some(effectcraft_render::Backend::Cpu));
let again = TimedAccel::new(0, true);
let (frames, log) = run_pooled(&p, cid, &s, "/r/c_[#####].png", opts(), &again);
assert_eq!(again.frames.load(Ordering::SeqCst), 0);
assert!(frames.iter().all(|f| f.get_pixel(10, 44).0 == [255, 0, 0, 255]));
assert!(log.contains("Renderer: Software Rendering (as the queue item's first output module)"), "{log}");
}
#[test]
fn a_gpu_job_that_measures_no_faster_on_the_cpu_stays_on_the_gpu() {
if measurement_overridden() {
return;
}
// A slow CPU (a large comp of eight rotating, translucent full-frame layers, composited
// anew every frame) against a GPU that only fills the frame: every frame is the GPU's.
let (mut p, cid) = project(FrameRate::new(25, 1), |p, comp| {
comp.width = 1920;
comp.height = 1080;
let big = p.add_item("Big", Label::Red, None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 1920, height: 1080, pixel_aspect: 1.0 }));
for k in 0..8 {
let mut l = build::layer(p, comp, "Big", LayerSource::Solid { item: big }, (1920, 1080), None);
l.props.prop_mut("transform/rotation").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(k as f64)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0 + k as f64))];
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(50.0);
comp.layers.insert(0, l);
}
});
p.settings.gpu_acceleration = true;
let s = custom(0.0, 0.6);
let fast = TimedAccel::new(0, true);
let (frames, log) = run_pooled(&p, cid, &s, "/r/d_[#####].png", JobOptions::default(), &fast);
assert_eq!(frames.len(), 15);
assert_eq!(fast.frames.load(Ordering::SeqCst), 15, "every kept frame, warm-up and measured ones included, is the GPU's");
assert!(frames.iter().all(|f| f.pixels().all(|px| px.0 == [0, 255, 0, 255])));
assert!(log.contains("Renderer: GPU Acceleration (measured on the first frames: GPU "), "{log}");
}
#[test]
fn gpu_cpu_and_measured_jobs_write_the_same_frames() {
// #3335: where the two compositors agree, a job is the same whichever renders it: the GPU
// (forced), the CPU (Software Only) or the measured choice.
if measurement_overridden() {
return;
}
let (mut p, cid) = project(FrameRate::new(25, 1), |_, _| {});
let s = custom(0.0, 0.8);
let echo = TimedAccel::new(5, false);
p.settings.gpu_acceleration = false;
let (cpu, log) = run_pooled(&p, cid, &s, "/i/a_[#####].png", JobOptions::default(), &echo);
assert!(log.contains("Renderer: Software Rendering (the project's renderer)"), "{log}");
p.settings.gpu_acceleration = true;
let gpu_slot = Mutex::new(Some(effectcraft_render::Backend::Gpu));
let (gpu, _) = run_pooled(&p, cid, &s, "/i/b_[#####].png", JobOptions { renderer: Some(&gpu_slot), ..Default::default() }, &echo);
let (auto, _) = run_pooled(&p, cid, &s, "/i/c_[#####].png", JobOptions::default(), &echo);
assert_eq!(cpu.len(), 20);
assert_eq!(cpu, gpu);
assert_eq!(cpu, auto);
}
#[test]
fn renderer_choice_follows_the_measurement() {
use effectcraft_export::{JobRenderer, pick_renderer};
assert_eq!(pick_renderer(Some(117.0), Some(68.0)), JobRenderer::Cpu);
assert_eq!(pick_renderer(Some(68.0), Some(117.0)), JobRenderer::Gpu);
// Ties and noise stay on the GPU: the CPU must be clearly faster.
assert_eq!(pick_renderer(Some(100.0), Some(90.0)), JobRenderer::Gpu);
assert_eq!(pick_renderer(Some(100.0), Some(80.0)), JobRenderer::Cpu);
// Unmeasured or nonsense: the GPU, unless the GPU stopped while measuring.
assert_eq!(pick_renderer(None, None), JobRenderer::Gpu);
assert_eq!(pick_renderer(Some(f64::NAN), Some(1.0)), JobRenderer::Gpu);
assert_eq!(pick_renderer(None, Some(50.0)), JobRenderer::Cpu);
}
+1 -1
View File
@@ -78,7 +78,7 @@ fn render(ctx: EvalCtx, comp: ItemId, layer: &Layer, post: bool, t: f64) -> Opti
r.reads = Some(&log);
let ectx = r.eval_ctx(comp, Tick::from_seconds_f64(t))?;
let out = if post {
r.content_buf(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
r.layer_content(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img.clone()), offset: b.offset, scale: b.scale })
} else {
r.layer_source(&ectx, layer).map(|b| Sampled { img: Arc::new(b.img), offset: b.offset, scale: b.scale })
};
+26
View File
@@ -2070,3 +2070,29 @@ fn bench_evaluation() {
println!("BENCH {best:>9} ns/eval {label} (min of 3 x {iters})");
}
}
/// Viewport clipping must never affect layer-space expression reads (#1388).
#[test]
fn sample_image_reads_3d_shape_behind_camera_before_and_after_effects() {
let mut p = Project::default();
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (100, 100), None);
let mut next = p.next_id;
let mut ids = build::Ids(&mut next);
let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let group = build::shape_group(&mut ids, "Box", vec![rect, fill]);
layer.props.sub_mut("contents").unwrap().children.push(group.into());
p.next_id = next;
layer.switches.three_d = true;
layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, -300.0]);
let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.into()));
p.comp_mut(cid).unwrap().layers.push(layer);
let comp = p.comp(cid).unwrap();
let layer = &comp.layers[0];
let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO);
for post in [false, true] {
let pixel = crate::sample::sample_image(ctx, cid, layer, [0.0, 0.0], [0.5, 0.5], post, 0.0).unwrap();
assert!(pixel[0] > 0.99 && pixel[3] > 0.99, "postEffect={post}: {pixel:?}");
}
}
+1 -1
View File
@@ -801,7 +801,7 @@ fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
});
e.record(&[]).copy_buffer_to_buffer(buf, 0, &rb, 0, size);
e.submit();
let out = e.g.read_buffer(&rb, row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
let out = e.g.read_buffer_after(&rb, e.last_submission(), row, row, 1).map_err(|e| log::error!("gpu 3d readback: {e}")).ok()?;
Some(out)
}
+227 -41
View File
@@ -128,6 +128,11 @@ const POOL_BUDGET: usize = 256 << 20;
/// miss (a new allocation), never a wrong pixel.
const STALE_FRAMES: u64 = 2;
/// The pool owner of uploaded textures: never registered by an [`Enc`] (their ids start at 1),
/// so an upload takes only from the shared free list ([`Pool::take`], textures no open encoder
/// can read) and gives its texture back through the guarded [`Pool::release`].
const UPLOAD_OWNER: u64 = 0;
/// Readback staging buffers kept for reuse across sizes (bytes).
const STAGING_BUDGET: u64 = 256 << 20;
@@ -693,6 +698,10 @@ pub struct GpuContext {
dummy_tex: wgpu::TextureView,
dummy_buf: wgpu::Buffer,
uploads: Mutex<Uploads>,
/// Uploaded footage pictures, shared by the layers that show them (see [`crate::upload`]).
pictures: Mutex<crate::upload::Pictures>,
/// Reused staging buffers of large uploads (see [`crate::upload::Staging`]).
upload_staging: crate::upload::Staging,
upload_budget: std::sync::atomic::AtomicUsize,
upload_full_warned: std::sync::atomic::AtomicBool,
pool: Arc<Mutex<Pool>>,
@@ -1017,6 +1026,8 @@ impl GpuContext {
dummy,
dummy_buf,
uploads: Mutex::new(Uploads::default()),
pictures: Default::default(),
upload_staging: Default::default(),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool,
@@ -1124,6 +1135,24 @@ impl GpuContext {
}
}
/// The memory budget (the memory cap: a conservative share of the device, see `memcap`),
/// the allocator's peak, one frame's share of it and what the upload cache may still add.
/// `None` where any is unknown: no allocator report (Metal, WebGPU, GL) or the cap is off.
pub fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
let budget = self.memcap.cap();
let now = self.allocated_bytes();
self.memcap.note(now);
// The allocator's peak (sampled while frames render) holds a frame's working textures
// at their most; what is gone again after the frame (and at least what the pool keeps
// for the next) is one frame's share. Frames that had to wait for memory: no room.
let peak = self.memcap.peak();
let frame = if self.memcap.pressed() { budget } else { peak.saturating_sub(now).max(self.pool_retained_bytes() as u64) };
// The upload cache may still fill up to its budget.
let uploads = self.uploads.lock().map(|u| u.bytes).unwrap_or(0);
let reserve = self.upload_budget().saturating_sub(uploads) as u64;
(budget > 0 && peak > 0 && frame > 0).then_some(effectcraft_render::FrameMemory { budget, peak, frame, reserve })
}
/// Bytes held by wgpu's device allocator (`0` on backends that do not report).
pub(crate) fn allocated_bytes(&self) -> u64 {
self.device.generate_allocator_report().map_or(0, |r| r.total_allocated_bytes)
@@ -1132,11 +1161,11 @@ impl GpuContext {
/// Bytes held in long-lived or still-referenced GPU caches/leases that submitting and waiting
/// on `owner` (`closing_enc`) cannot free.
fn kept_bytes(&self, owner: u64, closing_enc: Option<u64>) -> u64 {
let uploads = self.uploads.lock().map(|u| u.bytes as u64).unwrap_or(0);
// (uploaded layer and footage textures are pooled: the pool's leases count them)
let pool = self.pool.lock().map(|p| p.kept_after_flush(owner, closing_enc) as u64).unwrap_or(0);
let staging: u64 = self.staging.lock().map(|s| s.iter().map(|(k, v)| k * v.len() as u64).sum()).unwrap_or(0);
let zeros: u64 = self.zeros.lock().map(|z| z.keys().map(|&(w, h)| w as u64 * h as u64 * 16).sum()).unwrap_or(0);
uploads + pool + staging + zeros
pool + staging + self.upload_staging.bytes() + zeros
}
#[cfg(test)]
@@ -1198,18 +1227,99 @@ impl GpuContext {
/// never consumes the readback deadline or holds the mutex across the whole timeout.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_buffer(&self, buffer: &wgpu::Buffer, row: usize, stride: usize, height: usize) -> Result<Vec<u8>, String> {
self.read_buffer_after(buffer, None, row, stride, height)
}
/// [`Self::read_buffer`] waiting only for `submission` (the one that wrote `buffer`), not
/// for every submission so far: another frame's GPU work submitted meanwhile doesn't hold
/// this readback up (#3335). `None`: wait for all submitted work.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_buffer_after(
&self,
buffer: &wgpu::Buffer,
submission: Option<wgpu::SubmissionIndex>,
row: usize,
stride: usize,
height: usize,
) -> Result<Vec<u8>, String> {
self.read_mapped(buffer, submission, |view| crate::readback::packed(view, row, stride, height))
}
/// Map `buffer`, wait for the mapping (only for `submission` when given) and hand the
/// mapped bytes to `read`, on this thread, before unmapping: `read` copies the rows
/// straight into their destination, once (#3335). The copy happens outside `poll_lock`, so
/// it doesn't hold up other threads' readbacks the way copying inside the map callback
/// (which runs during a device poll) would.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_mapped<R>(
&self,
buffer: &wgpu::Buffer,
submission: Option<wgpu::SubmissionIndex>,
read: impl FnOnce(&[u8]) -> Result<R, String>,
) -> Result<R, String> {
let (tx, rx) = std::sync::mpsc::channel();
self.map_readback(buffer, row, stride, height, move |r| {
let ticket = self.readbacks.start(move |r| {
let _ = tx.send(r);
})?;
if !ticket.active() {
return Err("GPU readback cancelled".into());
}
let b = buffer.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
// Timed out or cancelled: nobody reads the mapping.
if !ticket.active() {
b.unmap();
return;
}
// Mapped: the waiting thread reads the rows and unmaps (an empty vector = mapped).
ticket.complete(r.map(|_| Vec::new()).map_err(|e| format!("GPU buffer mapping: {e}")));
});
// On any failure, unmap (harmless when not mapped; cancels a pending map) so a mapped
// buffer can never be handed out again.
let fail = |e: String| {
buffer.unmap();
e
};
self.wait_mapped(&rx, submission).map_err(fail)?.map_err(fail)?;
let result = {
let view = buffer.get_mapped_range(..).map_err(|e| format!("GPU mapped range: {e}"));
view.and_then(|view| read(&view))
};
buffer.unmap();
result
}
/// Poll until `rx` reports the mapping (see [`Self::read_mapped`]): polling and receiving
/// share one deadline, started once `poll_lock` is first acquired and polled in
/// [`Self::POLL_SLICE`] slices so lock contention never consumes the readback deadline or
/// holds the mutex across the whole timeout. While another thread holds `poll_lock` it is
/// polling the device, which delivers this mapping too: wait for it instead of queueing.
#[cfg(not(target_arch = "wasm32"))]
fn wait_mapped(
&self,
rx: &std::sync::mpsc::Receiver<Result<Vec<u8>, String>>,
submission: Option<wgpu::SubmissionIndex>,
) -> Result<Result<Vec<u8>, String>, String> {
let mut deadline = None;
loop {
self.readbacks.check()?;
if let Ok(result) = rx.try_recv() {
return result;
return Ok(result);
}
let (remaining, slice, poll_res) = {
let _guard = self.poll_lock.lock().unwrap_or_else(|e| e.into_inner());
let _guard = match self.poll_lock.try_lock() {
Ok(g) => g,
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => {
match rx.recv_timeout(std::time::Duration::from_millis(1)) {
Ok(result) => return Ok(result),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(e) => return Err(format!("GPU readback completion channel: {e}")),
}
// The ticket's own deadline still bounds a wait that never gets the lock.
continue;
}
};
self.readbacks.check()?;
let d = *deadline.get_or_insert_with(|| std::time::Instant::now() + crate::readback::TIMEOUT);
let remaining = d.saturating_duration_since(std::time::Instant::now());
@@ -1217,12 +1327,12 @@ impl GpuContext {
(remaining, std::time::Duration::ZERO, Ok(wgpu::PollStatus::Poll))
} else {
let slice = remaining.min(Self::POLL_SLICE);
let res = self.device.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(slice) });
let res = self.device.poll(wgpu::PollType::Wait { submission_index: submission.clone(), timeout: Some(slice) });
(remaining, slice, res)
}
};
if let Ok(result) = rx.try_recv() {
return result;
return Ok(result);
}
if remaining.is_zero() {
let error = "GPU readback receive timed out; context retired".to_string();
@@ -1241,7 +1351,7 @@ impl GpuContext {
}
// The callback may be running on another thread that polled the same device.
match rx.recv_timeout(remaining.min(std::time::Duration::from_millis(1))) {
Ok(result) => return result,
Ok(result) => return Ok(result),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(e) => return Err(format!("GPU readback completion channel: {e}")),
}
@@ -1253,6 +1363,28 @@ impl GpuContext {
Err("synchronous GPU readback is unavailable in the browser".into())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_buffer_after(
&self,
_buffer: &wgpu::Buffer,
_submission: Option<wgpu::SubmissionIndex>,
_row: usize,
_stride: usize,
_height: usize,
) -> Result<Vec<u8>, String> {
Err("synchronous GPU readback is unavailable in the browser".into())
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn read_mapped<R>(
&self,
_buffer: &wgpu::Buffer,
_submission: Option<wgpu::SubmissionIndex>,
_read: impl FnOnce(&[u8]) -> Result<R, String>,
) -> Result<R, String> {
Err("synchronous GPU readback is unavailable in the browser".into())
}
/// Switch to deferred readbacks (see [`crate::deferred`]): readbacks are never waited for.
pub fn set_deferred(&mut self, on: bool) {
self.readbacks.cancel_pending("GPU readback mode changed".into());
@@ -1446,6 +1578,8 @@ impl GpuContext {
dummy: self.dummy.clone(),
dummy_buf: self.dummy_buf.clone(),
uploads: Mutex::new(Uploads::default()),
pictures: Default::default(),
upload_staging: Default::default(),
upload_budget: std::sync::atomic::AtomicUsize::new(UPLOAD_BUDGET),
upload_full_warned: Default::default(),
pool: self.pool.clone(),
@@ -1625,16 +1759,54 @@ impl GpuContext {
return None;
}
self.transfers.count(true, img.data.len() * 16);
let g = self.image(img.width, img.height);
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
// A texture from the pool's shared free list: no open encoder can still read it, and the
// copy below runs before anything recorded after this call.
let (g, _) = self.pooled(img.width, img.height, UPLOAD_OWNER);
if !self.upload_staging.write(&self.device, &self.queue, img, &g.texture) {
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
}
Some(g)
}
/// Upload a decoded footage picture once while the footage source keeps it: every layer
/// and effect input showing the same frame shares the texture (`Renderer::footage_picture`).
pub(crate) fn upload_picture(&self, img: &Arc<Image>) -> Option<GpuImage> {
if let Some(t) = self.pictures.lock().ok().and_then(|mut p| p.get(img)) {
return Some(t);
}
let tex = self.upload_image(img)?;
if let Ok(mut p) = self.pictures.lock() {
p.insert(img, tex.clone(), crate::upload::PICTURE_BUDGET.min(self.upload_budget() / 4));
}
Some(tex)
}
/// Entries in the footage picture cache.
#[cfg(test)]
pub(crate) fn picture_entries(&self) -> usize {
self.pictures.lock().map(|p| p.len()).unwrap_or_default()
}
/// The upload staging buffers (tests).
#[cfg(test)]
pub(crate) fn upload_staging(&self) -> &crate::upload::Staging {
&self.upload_staging
}
/// A render is done: drop the footage picture textures and the upload staging buffers
/// (the next render's pictures upload again; their textures come from the pool).
pub fn trim_uploads(&self) {
if let Ok(mut p) = self.pictures.lock() {
p.clear();
}
self.upload_staging.clear();
}
/// Entries in the layer upload cache.
#[cfg(test)]
pub(crate) fn upload_entries(&self) -> usize {
@@ -1696,6 +1868,10 @@ impl GpuContext {
u.map.clear();
u.bytes = 0;
}
if let Ok(mut p) = self.pictures.lock() {
p.clear();
}
self.upload_staging.clear();
if let Ok(mut p) = self.pool.lock() {
p.clear();
}
@@ -1952,6 +2128,11 @@ impl<'g> Enc<'g> {
dst
}
/// The last submission of this encoder's work (what a readback after it waits for).
pub(crate) fn last_submission(&self) -> Option<wgpu::SubmissionIndex> {
self.last_submit.clone()
}
/// Submit recorded work.
pub fn submit(&mut self) {
if let Some((enc, id)) = self.enc.take() {
@@ -1970,6 +2151,7 @@ impl<'g> Enc<'g> {
return;
}
let allocated = self.g.allocated_bytes();
self.g.memcap.note(allocated);
if allocated <= self.g.memcap.cap() {
let _ = self.g.memcap.should_wait(allocated, 0);
return;
@@ -1994,13 +2176,13 @@ impl<'g> Enc<'g> {
let layout = crate::readback::Layout::new(img.width, img.height, 16, self.g.device.limits().max_buffer_size)
.map_err(|e| log::error!("gpu image readback: {e}"))
.ok()?;
let mut data = Vec::new();
data.try_reserve_exact(layout.len / 16).map_err(|e| log::error!("gpu image allocation: {e}")).ok()?;
data.resize(layout.len / 16, [0.0; 4]);
let mut out = Image { width: img.width, height: img.height, data };
let dst: &mut [u8] = bytemuck::cast_slice_mut(&mut out.data);
self.read_texture_into(&img.texture, [0, 0, img.width, img.height], 16, dst)?;
Some(out)
// The mapped rows go straight into the image's pixels: one copy (#3335).
let data: Vec<[f32; 4]> = self.read_texture_with(&img.texture, [0, 0, img.width, img.height], 16, crate::readback::rows::<[f32; 4]>)?;
if data.len() != layout.len / 16 {
log::error!("gpu image readback size mismatch");
return None;
}
Some(Image { width: img.width, height: img.height, data })
}
/// Tightly packed texel bytes of a texture (`bpp` bytes per pixel).
@@ -2013,27 +2195,32 @@ impl<'g> Enc<'g> {
pub fn read_rect(&mut self, texture: &wgpu::Texture, x: u32, y: u32, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
let layout =
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
let mut out = Vec::new();
out.try_reserve_exact(layout.len).map_err(|e| log::error!("gpu readback allocation: {e}")).ok()?;
out.resize(layout.len, 0);
self.read_texture_into(texture, [x, y, w, h], bpp, &mut out)?;
let out: Vec<u8> = self.read_texture_with(texture, [x, y, w, h], bpp, crate::readback::rows::<u8>)?;
if out.len() != layout.len {
log::error!("gpu texture readback size mismatch");
return None;
}
Some(out)
}
/// [`Enc::read_rect`] into `out` (`w × bpp × h` bytes), through a reused staging buffer.
/// `rect` is `[x, y, w, h]`.
fn read_texture_into(&mut self, texture: &wgpu::Texture, rect: [u32; 4], bpp: u32, out: &mut [u8]) -> Option<()> {
/// Read `rect` (`[x, y, w, h]`) of `texture` back through a reused staging buffer: `read`
/// gets the mapped bytes, the row length, the padded row stride and the row count, and
/// copies the rows into their destination itself (one copy, #3335). Waits only for this
/// encoder's own submission, so frames rendering on other threads don't delay it.
fn read_texture_with<R>(
&mut self,
texture: &wgpu::Texture,
rect: [u32; 4],
bpp: u32,
read: impl FnOnce(&[u8], usize, usize, usize) -> Result<R, String>,
) -> Option<R> {
let [x, y, w, h] = rect;
if !self.g.can_wait() {
return None;
}
self.g.transfers.count(false, out.len());
let layout =
crate::readback::Layout::new(w, h, bpp, self.g.device.limits().max_buffer_size).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
if out.len() != layout.len {
log::error!("gpu readback destination size mismatch");
return None;
}
self.g.transfers.count(false, layout.len);
let row = layout.row as usize;
let padded = layout.stride;
let size = layout.size;
@@ -2051,12 +2238,11 @@ impl<'g> Enc<'g> {
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
self.submit();
let bytes = self.g.read_buffer(&buf, row, padded as usize, h as usize).map_err(|e| log::error!("gpu texture readback: {e}")).ok()?;
if bytes.len() != out.len() {
log::error!("gpu texture readback size mismatch");
return None;
}
out.copy_from_slice(&bytes);
let out = self
.g
.read_mapped(&buf, self.last_submit.clone(), |view| read(view, row, padded as usize, h as usize))
.map_err(|e| log::error!("gpu texture readback: {e}"))
.ok()?;
// Keep a few staging buffers per size (frames read back at the same size every time).
// Layers whose bounds change every frame read back at a new size each time: drop the
// other sizes once over budget, or mapped host memory grows for the whole render (#2081).
@@ -2070,7 +2256,7 @@ impl<'g> Enc<'g> {
v.push(buf);
}
}
Some(())
Some(out)
}
/// [`Enc::read_texture`] without waiting for the GPU (the browser's main thread can't): `done`
+29 -3
View File
@@ -84,6 +84,7 @@ mod memcap;
mod ops;
mod particles;
mod readback;
mod upload;
mod walk;
mod yuv;
@@ -300,7 +301,12 @@ impl Gpu {
/// Browser callers use the host/owned-device failure bridge and bounded readbacks instead
/// of blocking their event loop on an error-scope future.
pub fn within_memory<T>(&self, f: impl FnOnce() -> T) -> Result<T, FrameError> {
self.ctx.check_health().map_err(FrameError::Retired)?;
if let Err(error) = self.ctx.check_health() {
// Queued jobs may still own this context after the UI detached it. Release its
// working caches before CPU fallback adds host-memory pressure (#3357).
self.ctx.discard_kept();
return Err(FrameError::Retired(error));
}
#[cfg(not(target_arch = "wasm32"))]
{
let oom = self.ctx.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
@@ -314,7 +320,7 @@ impl Gpu {
// The allocation failure first: a validation error is often its consequence.
let errors: Vec<String> = [oom, internal, validation].into_iter().flatten().map(|e| e.to_string()).collect();
if errors.is_empty() {
return self.ctx.check_health().map(|()| out).map_err(FrameError::Retired);
return self.finish_memory_attempt(out);
}
// Its textures go back to the pool when dropped: drop them before emptying it.
drop(out);
@@ -325,7 +331,21 @@ impl Gpu {
#[cfg(target_arch = "wasm32")]
{
let out = f();
self.ctx.check_health().map(|()| out).map_err(FrameError::Retired)
self.finish_memory_attempt(out)
}
}
/// Retirement without a scoped error (for example, a readback timeout) must release
/// the same retained resources as a rejected allocation before the caller retries.
fn finish_memory_attempt<T>(&self, out: T) -> Result<T, FrameError> {
match self.ctx.check_health() {
Ok(()) => Ok(out),
Err(error) => {
// Dropping the result can return leased textures to the pool.
drop(out);
self.ctx.discard_kept();
Err(FrameError::Retired(error))
}
}
}
@@ -432,6 +452,11 @@ impl Accelerator for Gpu {
Some(&self.auto)
}
fn frame_memory(&self) -> Option<effectcraft_render::FrameMemory> {
self.ctx.check_health().ok()?;
self.ctx.frame_memory()
}
fn render_end(&self) {
let (hits, misses) = self.ctx.pool_stats();
let pct = hits.saturating_mul(100) / hits.saturating_add(misses).max(1);
@@ -439,6 +464,7 @@ impl Accelerator for Gpu {
target: "effectcraft_gpu",
"gpu pool: {hits} working textures reused, {misses} allocated ({pct}% reuse) so far"
);
self.ctx.trim_uploads();
self.ctx.trim_pool();
}
+19
View File
@@ -40,6 +40,8 @@ pub(crate) struct MemCap {
floor: AtomicU64,
/// Set after the first over-cap sample where non-reclaimable memory alone exceeds the cap.
warned: AtomicBool,
/// The most the allocator held in any sample (what frames in flight are sized by, #3335).
peak: AtomicU64,
#[cfg(test)]
timeout_ns: AtomicU64,
}
@@ -59,6 +61,7 @@ impl MemCap {
waits: AtomicU64::new(0),
floor: AtomicU64::new(0),
warned: AtomicBool::new(false),
peak: AtomicU64::new(0),
#[cfg(test)]
timeout_ns: AtomicU64::new(crate::readback::TIMEOUT.as_nanos() as u64),
}
@@ -74,6 +77,7 @@ impl MemCap {
waits: AtomicU64::new(0),
floor: AtomicU64::new(0),
warned: AtomicBool::new(false),
peak: AtomicU64::new(0),
timeout_ns: AtomicU64::new(self.timeout_ns.load(Relaxed)),
}
}
@@ -82,6 +86,21 @@ impl MemCap {
self.cap
}
/// Remember an allocator sample: the peak only grows.
pub(crate) fn note(&self, allocated: u64) {
self.peak.fetch_max(allocated, Relaxed);
}
/// The most the allocator held in any sample so far.
pub(crate) fn peak(&self) -> u64 {
self.peak.load(Relaxed)
}
/// Whether frames have had to wait for memory (the device ran over the cap).
pub(crate) fn pressed(&self) -> bool {
self.waits.load(Relaxed) > 0
}
/// Whether to sample the allocator on this call.
pub(crate) fn due(&self) -> bool {
self.cap != 0 && self.calls.fetch_add(1, Relaxed).is_multiple_of(EVERY)
+41 -5
View File
@@ -367,20 +367,32 @@ thread_local! {
/// Validate every row before publishing tightly packed bytes.
pub(crate) fn packed(view: &[u8], row: usize, stride: usize, height: usize) -> Result<Vec<u8>, Error> {
if row == 0 || height == 0 || stride < row {
rows(view, row, stride, height)
}
/// The `height` rows of `row` bytes each, `stride` bytes apart in the mapped `view`, as tightly
/// packed `T`s: one copy, straight from the mapped rows into a vector reserved once (no
/// zero-filled buffer first, no intermediate byte vector). Every row is validated before any
/// is copied. A row that is not aligned for `T` is decoded value by value (same bytes).
pub(crate) fn rows<T: bytemuck::Pod>(view: &[u8], row: usize, stride: usize, height: usize) -> Result<Vec<T>, Error> {
let unit = std::mem::size_of::<T>().max(1);
if row == 0 || height == 0 || stride < row || !row.is_multiple_of(unit) {
return Err("invalid GPU readback layout".into());
}
let size = row.checked_mul(height).ok_or("GPU readback output size overflow")?;
let size = row.checked_mul(height).ok_or("GPU readback output size overflow")? / unit;
let end = (height - 1).checked_mul(stride).and_then(|s| s.checked_add(row)).ok_or("GPU readback mapped size overflow")?;
if view.len() < end {
return Err(format!("short GPU readback: {} bytes, needs {end}", view.len()));
}
let mut out = Vec::new();
let mut out: Vec<T> = Vec::new();
out.try_reserve_exact(size).map_err(|e| format!("allocating GPU readback output: {e}"))?;
for y in 0..height {
let start = y.checked_mul(stride).ok_or("GPU readback row overflow")?;
let bytes = view.get(start..start + row).ok_or("short GPU readback row")?;
out.extend_from_slice(bytes);
let bytes = start.checked_add(row).and_then(|e| view.get(start..e)).ok_or("short GPU readback row")?;
match bytemuck::try_cast_slice::<u8, T>(bytes) {
Ok(values) => out.extend_from_slice(values),
Err(_) => out.extend(bytes.chunks_exact(unit).map(bytemuck::pod_read_unaligned::<T>)),
}
}
Ok(out)
}
@@ -658,4 +670,28 @@ mod tests {
assert!(packed(&[0; 7], 4, 4, 2).is_err());
assert_eq!(packed(&[1, 2, 0, 0, 3, 4], 2, 4, 2).unwrap(), vec![1, 2, 3, 4]);
}
/// #3335: rows go from the mapped buffer straight into the destination type, skipping the
/// row padding, whether or not the mapped bytes are aligned for it.
#[test]
fn rows_copy_once_into_the_destination_type() {
// Two rows of two RGBA f32 pixels, padded to a 48-byte stride.
let px = |i: f32| [i, i + 0.25, i + 0.5, 1.0];
let want = vec![px(1.0), px(2.0), px(3.0), px(4.0)];
let mut view = Vec::new();
for r in want.chunks(2) {
view.extend_from_slice(bytemuck::cast_slice(r));
view.extend_from_slice(&[0xAB; 16]);
}
assert_eq!(rows::<[f32; 4]>(&view, 32, 48, 2).unwrap(), want);
// The same bytes one byte off any f32 alignment decode the same.
let mut shifted = vec![0u8];
shifted.extend_from_slice(&view);
assert_eq!(rows::<[f32; 4]>(&shifted[1..], 32, 48, 2).unwrap(), want);
// A row that isn't whole pixels, a short last row and a stride under the row fail.
assert!(rows::<[f32; 4]>(&view, 30, 48, 2).is_err());
assert!(rows::<[f32; 4]>(&view[..view.len() - 17], 32, 48, 2).is_err());
assert!(rows::<[f32; 4]>(&view, 32, 16, 2).is_err());
assert_eq!(rows::<u8>(&[1, 2, 9, 3, 4], 2, 3, 2).unwrap(), vec![1, 2, 3, 4]);
}
}
+88
View File
@@ -28,8 +28,10 @@ use crate::Gpu;
mod adjust;
mod adv3d;
mod bench_share;
mod deferred;
mod deferred_jobs;
mod footage_share;
mod fx_color;
mod fx_depth;
mod fx_distort;
@@ -996,6 +998,49 @@ fn within_memory_returns_the_work_when_there_is_room() {
assert_eq!((img.width, img.height), (64, 32));
}
/// A timeout can retire a context without any device error scope reporting a failure.
/// Queued jobs keep that context alive, so CPU fallback must not wait for its last owner
/// to drop before releasing cached uploads and working textures (#3357).
fn retired_attempt_releases_kept_resources(retire_before: bool) {
hold_gpu_lock();
let made = Gpu::try_headless();
let Some(g) = require_gpu("retired memory attempt", made.as_ref()) else { return };
let buf = Arc::new(effectcraft_effects::Buf { img: Image::new(16, 16), offset: [0.0; 2], scale: 1.0 });
drop(g.ctx.upload_buf(&buf).expect("a small cached upload"));
drop(crate::context::Enc::new(&g.ctx).image(32, 16));
assert_eq!(g.ctx.upload_entries(), 1);
assert!(g.ctx.pool_retained_bytes() > 0);
// An earlier frame can return a lease after retirement; it must not refill the pool.
let earlier = crate::context::Enc::new(&g.ctx).image(48, 16);
let retire = g.ctx.device_failure_notifier();
if retire_before {
retire("synthetic readback retirement".into());
}
let called = std::cell::Cell::new(false);
let result = g.within_memory(|| {
called.set(true);
let out = crate::context::Enc::new(&g.ctx).image(64, 16);
retire("synthetic readback retirement".into());
out
});
assert!(matches!(result, Err(crate::FrameError::Retired(_))));
assert_eq!(called.get(), !retire_before);
assert_eq!(g.ctx.upload_entries(), 0, "CPU fallback must not retain uploaded layers");
assert_eq!(g.ctx.pool_retained_bytes(), 0, "CPU fallback must not retain working textures");
drop(earlier);
assert_eq!(g.ctx.pool_retained_bytes(), 0, "late leases cannot refill a retired pool");
}
#[test]
fn within_memory_releases_caches_when_already_retired() {
retired_attempt_releases_kept_resources(true);
}
#[test]
fn within_memory_releases_caches_when_retired_during_work() {
retired_attempt_releases_kept_resources(false);
}
/// A texture the device rejected (deliberately too wide), made outside any frame under a scope
/// of its own: the view a frame then makes of it is the reported validation error.
fn rejected_texture(g: &Gpu) -> wgpu::Texture {
@@ -1247,6 +1292,49 @@ fn readback_staging_stays_bounded_across_sizes() {
assert!(held <= (256 << 20) + 2 * 1024 * 16 * 376, "staging buffers hold {held} bytes");
}
/// #3335: a readback copies the mapped rows straight into the image (no zero-filled buffer,
/// no intermediate byte vector), skipping each row's padding: odd widths, sub-rectangles and
/// readbacks from several threads at once (each waiting for its own submission) return the
/// uploaded pixels exactly.
#[test]
fn single_copy_readback_returns_the_exact_pixels() {
let Some(g) = gpu() else { return };
if !g.ctx.can_wait() {
return;
}
let image = |w: u32, h: u32, seed: f32| {
let mut img = Image::new(w, h);
for (i, p) in img.data.iter_mut().enumerate() {
let v = i as f32 * 0.37 + seed;
*p = [v, -v, v * 1e-3, 1.0 / (1.0 + v)];
}
img
};
// 37 × 16 bytes = 592 per row: padded to 768 in the staging buffer.
let src = image(37, 5, 0.5);
let up = g.ctx.upload_image(&src).expect("upload");
let mut e = crate::context::Enc::new(&g.ctx);
let back = e.download(&up).expect("download");
assert_eq!((back.width, back.height), (37, 5));
assert_eq!(back.data, src.data);
let rect = e.read_rect(&up.texture, 3, 1, 5, 2, 16).expect("read_rect");
let want: Vec<[f32; 4]> = (1..3).flat_map(|y| (3..8).map(move |x| (x, y))).map(|(x, y)| src.data[src.idx(x, y)]).collect();
assert_eq!(rect, bytemuck::cast_slice::<[f32; 4], u8>(&want));
// Frames on several threads, each with its own encoder and submission.
std::thread::scope(|s| {
for t in 0..4 {
s.spawn(move || {
for k in 0..3 {
let src = image(61 + t, 7 + k, (t * 10 + k) as f32);
let up = g.ctx.upload_image(&src).expect("upload");
let back = crate::context::Enc::new(&g.ctx).download(&up).expect("download");
assert_eq!(back.data, src.data, "thread {t} frame {k}");
}
});
}
});
}
/// A static adjustment layer with a time-dependent effect (Noise) keeps its footprint key
/// across frames (the footprint is the source through the masks, not the effects), so the GPU
/// uploads nothing after the first frame; matches the CPU.
+83
View File
@@ -0,0 +1,83 @@
//! Warm-frame benchmark of #3333 (run by hand): three layers of the same 1080p footage frame,
//! 32 bpc, a 30-frame sequence already decoded (as the media pool serves a warm frame), with a
//! layer cache as the Render Queue has. Prints the median and mean ms per frame and a hash of
//! every frame (to compare the pixels of two builds).
//!
//! `cargo test -p effectcraft-gpu --release --lib bench_share -- --ignored --nocapture`
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
struct Decoded {
frames: Mutex<HashMap<i64, Arc<Image>>>,
}
impl FootageSource for Decoded {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t).clamp(0, 29);
let mut m = self.frames.lock().unwrap();
Some(m.entry(i).or_insert_with(|| Arc::new(pattern(item.0 as u32 + i as u32, f.width, f.height))).clone())
}
}
#[test]
#[ignore]
fn bench_share() {
let Some(g) = gpu() else { return };
let (w, h) = (1920, 1080);
let mut p = Project::default();
p.settings.bit_depth = BitDepth::Bpc32;
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let f = Footage {
path: "seq.exr".into(),
kind: FootageKind::Sequence,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
alpha: AlphaMode::Premultiplied,
..Default::default()
};
let fid = p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f));
for i in 0..3 {
let mut l = build::layer(&mut p, &comp, "Sequence", LayerSource::Footage { item: fid }, (w, h), None);
if i > 0 {
l.blend_mode = BlendMode::Add;
set(&mut l, "transform/opacity", Value::Scalar(25.0));
}
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
let src = Decoded { frames: Mutex::new(HashMap::new()) };
let frames = 30;
let mut runs = vec![];
let mut hashes = vec![];
for pass in 0..4 {
let cache = effectcraft_render::cache::LayerCache::new(1 << 30);
let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() });
r.accel = Some(g);
r.cache = Some(&cache);
for k in 0..frames {
let t0 = std::time::Instant::now();
let img = g.render(&r, cid, Tick::from_seconds_f64(k as f64 / 30.0)).expect("the GPU renders the frame");
let ms = t0.elapsed().as_secs_f64() * 1e3;
if pass > 0 {
runs.push(ms);
}
if pass == 1 {
use std::hash::{Hash, Hasher};
let mut hs = std::collections::hash_map::DefaultHasher::new();
img.data.iter().for_each(|px| px.map(f32::to_bits).hash(&mut hs));
hashes.push(hs.finish());
}
}
}
runs.sort_by(f64::total_cmp);
let mean = runs.iter().sum::<f64>() / runs.len() as f64;
eprintln!("bench_share: {} frames, median {:.1} ms, mean {:.1} ms, min {:.1} ms", runs.len(), runs[runs.len() / 2], mean, runs[0]);
eprintln!("bench_share frame hashes: {hashes:016x?}");
}
+233
View File
@@ -0,0 +1,233 @@
//! #3333: a footage frame uploads once per frame and its texture is shared by every layer and
//! effect input that shows it; the frame is bit-identical to drawing each layer from its own copy.
use std::collections::HashMap;
use std::sync::Mutex;
use super::*;
use crate::walk::SHARED_PICTURES;
/// Keeps decoded frames as the media pool does: the same frame (at the same size) of the same
/// footage is the same `Arc` while kept. Counts the frames it decodes.
#[derive(Default)]
struct Kept {
frames: Mutex<HashMap<(ItemId, u32, u32, i64, Option<(u32, u32)>), Arc<Image>>>,
}
impl Kept {
fn get(&self, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
let i = f.frame_rate.frame_at(t);
let mut m = self.frames.lock().unwrap();
let img = m.entry((item, f.width, f.height, i, size)).or_insert_with(|| {
let full = pattern(item.0 as u32 + i as u32 * 7 + f.width, f.width, f.height);
Arc::new(match size {
Some((w, h)) => effectcraft_raster::resample(&full, w, h),
None => full,
})
});
Some(img.clone())
}
}
impl FootageSource for Kept {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.get(item, f, t, None)
}
fn frame_at_size(&self, item: ItemId, f: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.get(item, f, t, Some((width, height)))
}
}
/// One footage item (a sequence: a new picture every frame) shown by `layers` layers, each
/// placed differently (the first fills the comp).
fn shared_scene(depth: BitDepth, layers: usize) -> (Scene, ItemId) {
let mut s = Scene::new(depth);
let f = Footage {
path: "seq.exr".into(),
kind: FootageKind::Sequence,
width: 97,
height: 61,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
duration: Tick::from_seconds_f64(2.0),
has_video: true,
alpha: AlphaMode::Premultiplied,
..Default::default()
};
let fid = s.p.add_item("Sequence", Label::Lavender, None, ItemKind::Footage(f));
for i in 0..layers {
let mut l = build::layer(&mut s.p, &s.comp, "Sequence", LayerSource::Footage { item: fid }, (97, 61), None);
if i > 0 {
set(&mut l, "transform/rotation", Value::Scalar(11.0 * i as f64));
set(&mut l, "transform/scale", Value::Vec3([70.0 + 9.0 * i as f64, 64.0, 100.0]));
set(&mut l, "transform/position", v3(30.0 + 13.0 * i as f64, 25.0 + 4.0 * i as f64));
set(&mut l, "transform/opacity", Value::Scalar(80.0));
l.blend_mode = if i % 2 == 0 { BlendMode::Screen } else { BlendMode::Add };
}
s.push(l);
}
(s, fid)
}
/// The GPU frame with shared pictures on and off, and the number of uploads of each.
fn render_both(g: &crate::Gpu, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick) -> (Image, Image, u64, u64) {
let mut r = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Gpu, ..opts });
r.accel = Some(g);
let before = g.ctx.transfer_stats().uploads;
let on = g.render(&r, s.cid, t).expect("the GPU renders the frame");
let uploads = g.ctx.transfer_stats().uploads - before;
SHARED_PICTURES.store(false, std::sync::atomic::Ordering::Relaxed);
let before = g.ctx.transfer_stats().uploads;
let off = g.render(&r, s.cid, t);
let copied = g.ctx.transfer_stats().uploads - before;
SHARED_PICTURES.store(true, std::sync::atomic::Ordering::Relaxed);
(on, off.expect("the GPU renders the frame from per-layer copies"), uploads, copied)
}
fn check_cpu(label: &str, s: &Scene, src: &Kept, opts: RenderOpts, t: Tick, gpu_img: &Image) {
let cpu = Renderer::new(&s.p, src, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t);
let mut d = diff(&cpu, gpu_img, tolerance(s.p.settings.bit_depth));
d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32;
check(label, Some(d), 0.0);
}
/// Three layers showing the same frame upload it once; a fourth that runs an effect on it
/// (resident on the GPU) reads the same texture as its input. Every frame of the sequence
/// uploads its own picture once.
#[test]
fn three_layers_of_one_footage_frame_upload_once() {
let Some(g) = gpu() else { return };
let src = Kept::default();
let (mut s, fid) = shared_scene(BitDepth::Bpc32, 3);
for k in 0..3 {
let t = Tick::from_seconds_f64(k as f64 / 30.0);
let (on, off, uploads, copied) = render_both(g, &s, &src, opts(), t);
assert_eq!(uploads, 1, "frame {k}: three layers of one footage frame uploaded {uploads} times");
assert_eq!(copied, 3, "frame {k}: per-layer copies upload once per layer");
assert!(on.data == off.data, "frame {k}: the shared picture changed the frame");
check_cpu(&format!("shared picture frame {k}"), &s, &src, opts(), t, &on);
}
// An effect input: the blur reads the shared texture. Alone, the blurred layer uploads
// its picture (and whatever the blur itself uploads); the three plain layers showing the
// same frame add no upload.
let mut fx = build::layer(&mut s.p, &s.comp, "Blurred", LayerSource::Footage { item: fid }, (97, 61), None);
set(&mut fx, "transform/position", v3(20.0, 20.0));
s.effect(&mut fx, "ec.blur.gaussian", &[("blurriness", n(3.0))]);
let (mut alone, _) = shared_scene(BitDepth::Bpc32, 0);
alone.p = s.p.clone();
alone.p.comp_mut(alone.cid).unwrap().layers = vec![fx.clone()].into();
let (_, _, blur_uploads, _) = render_both(g, &alone, &src, opts(), Tick::from_seconds_f64(0.4));
s.push(fx);
let t = Tick::from_seconds_f64(0.5);
let (on, off, uploads, _) = render_both(g, &s, &src, opts(), t);
assert_eq!(uploads, blur_uploads, "three layers and an effect input of one footage frame uploaded {uploads} times, the effect layer alone {blur_uploads}");
assert!(on.data == off.data, "the shared picture changed the frame with an effect input");
check_cpu("shared picture with an effect input", &s, &src, opts(), t, &on);
}
/// The frame is bit-identical with shared pictures on and off wherever they apply or not: every
/// bit depth, colour management and linear blending, half resolution, a track matte, Preserve
/// Transparency, motion blur, masks, layer styles and a proxy.
#[test]
fn shared_pictures_do_not_change_the_frame() {
let Some(g) = gpu() else { return };
let src = Kept::default();
let t = Tick::from_seconds_f64(0.2);
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
let mut cases: Vec<(String, Scene, RenderOpts)> = vec![];
cases.push(("plain".into(), shared_scene(depth, 3).0, opts()));
cases.push(("half resolution".into(), shared_scene(depth, 3).0, RenderOpts { scale: 0.5, ..opts() }));
for (ws, blend_linear) in [(Some(ColorSpace::Rec709), false), (None, true)] {
let (mut s, _) = shared_scene(depth, 3);
s.p.settings.working_space = ws;
s.p.settings.blend_linear = blend_linear;
cases.push((format!("colour {ws:?} linear blending {blend_linear}"), s, opts()));
}
{
let (mut s, _) = shared_scene(depth, 3);
let comp = s.p.comp_mut(s.cid).unwrap();
comp.enable_motion_blur = true;
let matte = comp.layers[0].id;
comp.layers[1].track_matte = Some(TrackMatte { layer: matte, kind: MatteKind::Luma });
comp.layers[2].preserve_transparency = true;
comp.layers[2].switches.motion_blur = true;
let pos = comp.layers[2].props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 20.0)), Keyframe::new(Tick::from_seconds_f64(0.5), v3(90.0, 45.0))];
s.comp.enable_motion_blur = true;
cases.push(("matte, preserve transparency, motion blur".into(), s, opts()));
}
{
let (mut s, _) = shared_scene(depth, 3);
let mut next = s.p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 30.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]);
let comp = s.p.comp_mut(s.cid).unwrap();
comp.layers[0].props.sub_mut("masks").unwrap().children.push(m.into());
effectcraft_project::styles::add_style(&mut comp.layers[1], &mut Ids(&mut next), "dropShadow", &s.comp.global_light, true).unwrap();
s.p.next_id = next;
cases.push(("mask and layer style".into(), s, opts()));
}
{
let (mut s, fid) = shared_scene(depth, 3);
let proxy = Footage { path: "proxy.png".into(), kind: FootageKind::Still, width: 48, height: 30, has_video: true, ..Default::default() };
s.p.item_mut(fid).unwrap().proxy = Some(Box::new(effectcraft_project::Proxy { footage: proxy, enabled: true }));
cases.push(("proxy".into(), s, opts()));
}
for (label, s, o) in cases {
let (on, off, _, _) = render_both(g, &s, &src, o, t);
assert!(on.data == off.data, "{label} {depth:?}: the shared picture changed the frame");
check_cpu(&format!("{label} {depth:?}"), &s, &src, o, t, &on);
}
}
}
/// The picture cache keeps no texture of a picture the footage source dropped.
#[test]
fn picture_cache_drops_textures_of_dropped_pictures() {
let Some(g) = gpu() else { return };
let before = g.ctx.picture_entries();
for i in 0..6 {
let img = Arc::new(pattern(i, 64, 64));
let up = g.ctx.upload_picture(&img).expect("upload");
let again = g.ctx.upload_picture(&img).expect("upload");
assert!(up.texture == again.texture, "the same picture uploaded twice");
drop((up, again, img));
}
let kept = g.ctx.picture_entries();
assert!(kept <= before + 1, "the picture cache kept {kept} textures of dropped pictures (had {before})");
}
/// Large uploads copy through reused staging buffers (rows padded to the copy alignment) with
/// the same pixels as `Queue::write_texture`; the buffers stay within their budget, and a
/// clear while one is in flight forgets it.
#[test]
fn staging_uploads_keep_pixels_and_reuse_buffers() {
let Some(g) = gpu() else { return };
let wait = || {
let _ = g.ctx.poll_readbacks(wgpu::PollType::wait_indefinitely());
};
// 300 px: 4800-byte rows, padded to 4864.
for (w, h) in [(300u32, 300u32), (512, 600)] {
let before = g.ctx.upload_staging().copies();
for i in 0..4 {
let img = pattern(40 + i, w, h);
let up = g.ctx.upload_image(&img).expect("upload");
let mut e = crate::context::Enc::new(&g.ctx);
let back = e.download(&up).expect("readback");
assert!(back.data.iter().flatten().map(|v| v.to_bits()).eq(img.data.iter().flatten().map(|v| v.to_bits())), "{w}×{h} upload {i} changed pixels");
drop((up, e));
wait();
}
assert!(g.ctx.upload_staging().copies() - before >= 2, "{w}×{h}: no upload went through a reused staging buffer");
assert!(g.ctx.upload_staging().bytes() <= 192 << 20);
}
// A clear with a copy in flight: nothing kept, and later uploads still work.
let img = pattern(7, 300, 300);
let up = g.ctx.upload_image(&img).expect("upload");
g.ctx.upload_staging().clear();
wait();
assert_eq!(g.ctx.upload_staging().bytes(), 0, "a cleared staging kept its buffers");
let mut e = crate::context::Enc::new(&g.ctx);
assert!(e.download(&up).expect("readback").data == img.data);
let again = g.ctx.upload_image(&img).expect("upload after a clear");
assert!(e.download(&again).expect("readback").data == img.data);
}
+264
View File
@@ -0,0 +1,264 @@
//! Footage uploads (#3333): each decoded footage picture uploads once and its texture is shared
//! by every layer and effect input that shows it ([`Pictures`]), and large uploads copy through
//! staging buffers that are kept mapped and reused ([`Staging`]) instead of one that
//! `Queue::write_texture` allocates and frees per upload.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError, Weak};
use effectcraft_raster::Image;
use crate::context::GpuImage;
/// Bytes of picture textures kept after their frame (a sequence's next frame takes the oldest
/// one's texture back from the pool), at most a quarter of the context's upload budget. The
/// newest picture is always kept, whatever its size.
pub(crate) const PICTURE_BUDGET: usize = 256 << 20;
/// Uploads from this size go through [`Staging`]; smaller ones through `Queue::write_texture`.
const STAGING_MIN: u64 = 1 << 20;
/// Bytes of staging buffers that may exist at once (mapped and ready, or in flight).
const STAGING_BUDGET: u64 = 192 << 20;
struct Picture {
img: Weak<Image>,
tex: GpuImage,
bytes: usize,
last_use: u64,
}
/// Uploaded footage pictures by the identity of the decoded frame (its `Arc`): the footage
/// source serves one `Arc` per (file, frame, size, colour curve) while it keeps the frame, and
/// a new one when the file changed, so the same `Arc` means the same pixels. The `Weak` keeps
/// the allocation, so a key cannot be reused by another picture while its entry exists.
#[derive(Default)]
pub(crate) struct Pictures {
map: HashMap<usize, Picture>,
bytes: usize,
clock: u64,
}
impl Pictures {
/// The texture of `img` if it was uploaded and is still kept.
pub(crate) fn get(&mut self, img: &Arc<Image>) -> Option<GpuImage> {
self.clock += 1;
let now = self.clock;
let e = self.map.get_mut(&(Arc::as_ptr(img) as usize))?;
if !e.img.upgrade().is_some_and(|i| Arc::ptr_eq(&i, img)) {
return None;
}
e.last_use = now;
Some(e.tex.clone())
}
/// Keep `tex`, the upload of `img`. Pictures the footage source no longer holds go at once
/// (nothing can ask for them again), then the least recently used over [`PICTURE_BUDGET`],
/// never `img` itself. A dropped texture goes back to the pool once no open encoder can
/// still read it.
pub(crate) fn insert(&mut self, img: &Arc<Image>, tex: GpuImage, budget: usize) {
let key = Arc::as_ptr(img) as usize;
let bytes = img.data.len().saturating_mul(16);
self.clock += 1;
let entry = Picture { img: Arc::downgrade(img), tex, bytes, last_use: self.clock };
if let Some(old) = self.map.insert(key, entry) {
self.bytes = self.bytes.saturating_sub(old.bytes);
}
self.bytes = self.bytes.saturating_add(bytes);
let dead: Vec<usize> = self.map.iter().filter(|(_, e)| e.img.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = self.map.remove(&k) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
while self.bytes > budget {
let Some(k) = self.map.iter().filter(|(k, _)| **k != key).min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break };
if let Some(e) = self.map.remove(&k) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.map.len()
}
pub(crate) fn clear(&mut self) {
self.map.clear();
self.bytes = 0;
}
}
#[derive(Default)]
struct StagingState {
/// Buffers mapped for writing and not in use, each mapped whole.
ready: Vec<wgpu::Buffer>,
/// Bytes of every staging buffer that exists (ready, being mapped or in flight).
total: u64,
/// Raised by [`Staging::clear`]: a buffer of an earlier generation is dropped when its
/// mapping completes.
generation: u64,
/// Uploads copied through a staging buffer (diagnostics, tests).
copies: u64,
}
/// Reused upload staging buffers (`MAP_WRITE | COPY_SRC`). A buffer is written while mapped,
/// unmapped, copied into the texture by a command buffer submitted at once, and mapped again
/// when that submission is done (`CommandEncoder::map_buffer_on_submit`; the callback runs when
/// the device is polled, as every frame does). A new buffer is mapped asynchronously too (never
/// `mapped_at_creation`, which fails hard on drivers that report a failed allocation as an
/// internal error, #591), so the upload that made it, and any upload while no ready buffer fits
/// or the budget is spent, goes through `Queue::write_texture` instead.
#[derive(Default)]
pub(crate) struct Staging {
state: Arc<Mutex<StagingState>>,
}
/// `bytes_per_row` of a staging copy: rows padded to `COPY_BYTES_PER_ROW_ALIGNMENT`.
fn padded_row(width: u32) -> Option<u64> {
let row = u64::from(width).checked_mul(16)?;
let a = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
row.div_ceil(a).checked_mul(a)
}
impl Staging {
fn lock(&self) -> std::sync::MutexGuard<'_, StagingState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Bytes of staging buffers that exist (see [`STAGING_BUDGET`]).
pub(crate) fn bytes(&self) -> u64 {
self.lock().total
}
/// Uploads copied through a staging buffer so far.
#[cfg(test)]
pub(crate) fn copies(&self) -> u64 {
self.lock().copies
}
/// Forget every staging buffer (after a failed frame, #1319): those in flight are dropped
/// when their mapping completes.
pub(crate) fn clear(&self) {
let mut s = self.lock();
s.ready.clear();
// (buffers still in flight are no longer counted: they drop when their mapping ends)
s.total = 0;
s.generation = s.generation.wrapping_add(1);
}
/// The callback of a write mapping of `buffer`: once mapped it is ready for the next
/// upload; it is dropped (and its bytes uncounted) when mapping failed, and dropped when
/// the staging was cleared meanwhile (whose [`Staging::clear`] stopped counting it).
fn on_mapped(&self, buffer: &wgpu::Buffer, generation: u64) -> impl FnOnce(Result<(), wgpu::BufferAsyncError>) + wgpu::WasmNotSend + 'static {
let state = Arc::downgrade(&self.state);
let buffer = buffer.clone();
move |res: Result<(), wgpu::BufferAsyncError>| {
let Some(state) = state.upgrade() else { return };
let mut s = state.lock().unwrap_or_else(PoisonError::into_inner);
if s.generation != generation {
return;
}
if res.is_ok() {
s.ready.push(buffer);
} else {
s.total = s.total.saturating_sub(buffer.size());
}
}
}
/// Copy `img` into `texture` (its size) through a ready staging buffer, recorded and
/// submitted at once: before anything an open encoder records later, as
/// `Queue::write_texture` would. `false`: not done (small, no ready buffer fits, or the
/// mapping failed); the caller writes it through the queue. Starts mapping a new buffer for
/// later uploads when none fits and the budget allows.
pub(crate) fn write(&self, device: &wgpu::Device, queue: &wgpu::Queue, img: &Image, texture: &wgpu::Texture) -> bool {
let (w, h) = (img.width, img.height);
let row = w as usize * 16;
let Some(stride) = padded_row(w) else { return false };
let (Some(need), Ok(stride32)) = (stride.checked_mul(u64::from(h)), u32::try_from(stride)) else { return false };
if need < STAGING_MIN || img.data.len() != w as usize * h as usize {
return false;
}
let (buffer, generation) = {
let mut s = self.lock();
let generation = s.generation;
// The smallest ready buffer that holds it.
let best = s.ready.iter().enumerate().filter(|(_, b)| b.size() >= need).min_by_key(|(_, b)| b.size()).map(|(i, _)| i);
match best {
Some(i) => (Some(s.ready.swap_remove(i)), generation),
None => {
// Ready buffers too small for it make room (a size that no longer occurs).
while s.total.saturating_add(need) > STAGING_BUDGET
&& let Some(b) = s.ready.pop()
{
s.total = s.total.saturating_sub(b.size());
}
if s.total.saturating_add(need) <= STAGING_BUDGET {
s.total += need;
drop(s);
let b = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("upload staging"),
size: need,
usage: wgpu::BufferUsages::MAP_WRITE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let done = self.on_mapped(&b, generation);
b.map_async(wgpu::MapMode::Write, .., done);
}
(None, generation)
}
}
};
let Some(buffer) = buffer else { return false };
self.lock().copies += 1;
{
let Ok(mut view) = buffer.get_mapped_range_mut(..need) else {
// Not mapped after all: give it up (its bytes too).
let mut s = self.lock();
if s.generation == generation {
s.total = s.total.saturating_sub(buffer.size());
}
return false;
};
let src: &[u8] = bytemuck::cast_slice(&img.data);
let stride = stride as usize;
if stride == row {
view.slice(..src.len()).copy_from_slice(src);
} else {
for (y, line) in src.chunks_exact(row).enumerate() {
let at = y * stride;
view.slice(at..at + row).copy_from_slice(line);
}
}
}
buffer.unmap();
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("upload") });
enc.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(stride32), rows_per_image: Some(h) },
},
wgpu::TexelCopyTextureInfo { texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
let done = self.on_mapped(&buffer, generation);
enc.map_buffer_on_submit(&buffer, wgpu::MapMode::Write, .., done);
queue.submit([enc.finish()]);
true
}
}
#[cfg(test)]
mod tests {
use super::padded_row;
#[test]
fn staging_rows_are_padded_to_the_copy_alignment() {
assert_eq!(padded_row(1920), Some(30720));
assert_eq!(padded_row(97), Some(1792));
assert_eq!(padded_row(16), Some(256));
assert_eq!(padded_row(1), Some(256));
}
}
+69 -6
View File
@@ -241,6 +241,9 @@ fn draw_layer_on(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas
if let Some(done) = draw_resident_fx(e, r, ctx, layer, canvas, opacity) {
return done;
}
if let Some(done) = draw_picture(e, r, ctx, layer, canvas, opacity) {
return done;
}
let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) };
if st.plain {
return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity);
@@ -286,14 +289,28 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can
{
return None;
}
let input = r.layer_input(ctx, layer, 0)?;
if input.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_buf(&input) else { return Some(None) };
// The decoded footage picture itself when it is the layer's input (uploaded once per frame
// and shared with every layer that shows it), else the cached input buffer.
let (src, offset, scale) = match picture(r, ctx, layer) {
Some(p) => {
if p.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) };
(src, p.effect_offset, p.scale)
}
None => {
let input = r.layer_input(ctx, layer, 0)?;
if input.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_buf(&input) else { return Some(None) };
(src, input.offset, input.scale)
}
};
let pipe = r.pipe();
// `layer_input` is in effect space: run the stack there, then back to layer space.
let mut fx = crate::effects::GpuFx::new(e, src, input.offset, input.scale, pipe.levels);
let mut fx = crate::effects::GpuFx::new(e, src, offset, scale, pipe.levels);
let origin = r.run_layer_effects_on(ctx, layer, &mut fx);
let Some((mut img, mut offset, scale)) = fx.finish() else { return Some(None) };
offset = [offset[0] - origin[0] * scale, offset[1] - origin[1] * scale];
@@ -308,6 +325,52 @@ fn draw_resident_fx(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, can
Some(place_src(e, r, ctx, layer, &shape, img, canvas, layer.blend_mode, opacity, pipe.levels).map(|c| *canvas = c))
}
/// Shared footage pictures ([`picture`]); tests turn it off to compare.
pub(crate) static SHARED_PICTURES: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// The layer's source as the decoded footage picture (`Renderer::footage_picture`), unless
/// sharing is off or per-layer timings are being recorded (those time the layer-cache path).
fn picture(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_render::FootagePicture> {
if !SHARED_PICTURES.load(std::sync::atomic::Ordering::Relaxed) || r.profile.is_some() {
return None;
}
r.footage_picture(ctx, layer)
}
/// A footage layer without effects or layer styles whose source is the decoded picture as it
/// is (`Renderer::footage_picture`) and needs no conversion to the blending space: placed
/// straight from the picture's texture, uploaded once per frame and shared by every layer that
/// shows the same frame, instead of from a copy per layer in the layer cache, uploaded per
/// layer. The pixels are the same: the layer-cache buffer is that picture, copied. `None`: not
/// this kind of layer; `Some(None)`: the GPU failed.
fn draw_picture(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage, opacity: f32) -> Option<Option<()>> {
if (layer.switches.effects && layer.effects().is_some_and(|f| !f.children.is_empty())) || styles::active(ctx, layer) || r.pipe().to_blend().is_some() {
return None;
}
let p = picture(r, ctx, layer)?;
if p.img.is_empty() {
return Some(Some(()));
}
let Some(src) = e.g.upload_picture(&p.img) else { return Some(None) };
// The placement only reads the buffer's size, offset and scale.
let shape = Buf { img: Image { width: p.img.width, height: p.img.height, data: Vec::new() }, offset: [0.0; 2], scale: p.scale };
Some(composite_src(e, r, ctx, layer, &shape, src, canvas, opacity))
}
/// [`composite_layer`] of a GPU image `src` shaped like `shape`.
#[allow(clippy::too_many_arguments)]
fn composite_src(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, shape: &Buf, src: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
if opacity <= 0.0 {
return Some(());
}
if r.track_matte(ctx, layer).is_none() && !layer.preserve_transparency {
*canvas = place_src(e, r, ctx, layer, shape, src, canvas, layer.blend_mode, opacity, r.pipe().levels)?;
return Some(());
}
let iso = place_src(e, r, ctx, layer, shape, src, &e.zeros(canvas.width, canvas.height), BlendMode::Normal, 1.0, None)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
}
/// A 2D precomp layer, its nested comp rendered resident on the GPU (`walk::render` one level
/// down) instead of by the CPU compositor and uploaded every frame (`Renderer::layer_source`
/// renders nested comps on the CPU: depth > 0). Then the layer's effects (resident), its
+1 -1
View File
@@ -147,7 +147,7 @@ pub(crate) fn record(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) ->
pub(crate) fn convert(e: &mut Enc, yp: &YuvPipe, img: &GpuImage, bg: [f32; 3]) -> Option<Vec<u8>> {
let stg = record(e, yp, img, bg)?;
let n = planes_len(img.width, img.height) as usize;
e.g.read_buffer(&stg, n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok()
e.g.read_buffer_after(&stg, e.last_submission(), n, n, 1).map_err(|err| log::error!(target: "effectcraft_gpu", "yuv420 readback: {err}")).ok()
}
#[cfg(test)]
+3
View File
@@ -62,6 +62,9 @@ impl Exporter for FileExporter {
label: job.label.clone(),
storage: job.storage,
overflow: job.project.render_prefs.overflow_folders.clone(),
caches: job.caches.map(|c| effectcraft_export::JobCaches { gpu: &c.gpu, cpu: &c.cpu }),
renderer: job.renderer,
gpu_frames: job.gpu_frames,
},
nested_switches: job.nested_switches,
};
+23 -1
View File
@@ -34,6 +34,22 @@ impl Backend {
}
}
/// An accelerator's device memory, to size how many frames an export renders on it at once
/// (see [`Accelerator::frame_memory`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FrameMemory {
/// Bytes the accelerator may use (a conservative share of the device, not its free memory).
pub budget: u64,
/// The most its allocator has held while frames rendered (one frame's working memory
/// included).
pub peak: u64,
/// Bytes of that peak one frame needed (what it released again), or the whole budget once
/// frames have had to wait for memory.
pub frame: u64,
/// Bytes long-lived caches (uploaded layers) may still grow by.
pub reserve: u64,
}
/// One effect of a GPU effect chain (see [`Accelerator::effects`]).
pub struct FxStep<'x> {
pub spec: &'static effectcraft_effects::EffectSpec,
@@ -94,6 +110,12 @@ pub trait Accelerator: Send + Sync {
fn auto_pick(&self) -> Option<&AutoPick> {
None
}
/// The device memory budget and use, after at least one frame rendered: what an export
/// sizes its frames in flight by. `None` where it isn't known (no allocator report, no
/// budget): the export then renders one frame at a time.
fn frame_memory(&self) -> Option<FrameMemory> {
None
}
/// Whether the accelerator can still render frames. `false` once it has stopped for good
/// (its device was lost or retired): every frame it is asked for falls back to the CPU, so
/// callers that limit frames in flight for it can stop doing so.
@@ -352,7 +374,7 @@ impl<'a> Renderer<'a> {
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
// Cached, so an accelerator sees the same buffer (and uploads it once) every frame.
let key = self.cache.and_then(|_| cache::footprint_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
let key = self.mask_gate_key(ctx, layer, key);
let key = self.mask_gate_key(ctx, layer, self.clipped_key(ctx, layer, key));
if let Some((b, _)) = self.cached(key) {
return Some(b);
}
+5
View File
@@ -230,6 +230,11 @@ impl LayerCache {
/// used. (A render that started before a drop may publish its buffer after the drop's
/// purge, Reload Footage clearing the cache while the buffer was being written: the check
/// here, not the order of the two, keeps it from being served.)
/// Whether a buffer is kept in memory under `key` (a peek: no hit, no miss, no disk read).
pub(crate) fn holds(&self, key: u64) -> bool {
self.kept(key, false).is_some()
}
fn kept(&self, key: u64, hit: bool) -> Option<(Arc<Buf>, Arc<ReadFiles>)> {
let found = {
let mut g = self.inner.lock().ok()?;
+8
View File
@@ -295,7 +295,15 @@ impl<'a> Renderer<'a> {
/// in this render ([`Self::mask_blur`]'s gate: the render options, the precomp layers above).
/// The key has the layer's own motion blur state, but a mask set to On blurs without it.
pub(crate) fn mask_gate_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
// Queue jobs share this cache but may override Proxy Use differently. The item's
// proxy switch alone does not describe the source a renderer actually reads.
let k = key?;
let k = match self.opts.proxy {
effectcraft_project::render_queue::ProxyUse::CurrentSettings => k,
effectcraft_project::render_queue::ProxyUse::UseAll => cache::derive(k, 0x7072_6f78_7901),
effectcraft_project::render_queue::ProxyUse::UseCompOnly => cache::derive(k, 0x7072_6f78_7902),
effectcraft_project::render_queue::ProxyUse::UseNone => cache::derive(k, 0x7072_6f78_7903),
};
let masked = layer.masks().is_some_and(|m| !m.children.is_empty());
Some(if masked && !self.mask_blur(ctx, layer).allowed { cache::derive(k, 0x6d62_6f66) } else { k })
}
+4 -2
View File
@@ -46,7 +46,7 @@ mod layers;
mod opts;
mod pipeline;
pub use accel::{Accelerator, Backend, FxStep, FxTarget};
pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit};
pub use frames::{
adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path,
@@ -61,7 +61,7 @@ pub use layers::{Placement, StyledLayer};
pub(crate) use opts::Collapse3d;
pub use opts::{LayerTiming, RenderOpts};
use opts::{MAX_THROUGH, Through};
pub use pipeline::{Collapsed, MediaOverride, PropertyOverride};
pub use pipeline::{Collapsed, FootagePicture, MediaOverride, PropertyOverride};
pub struct Renderer<'a> {
pub project: &'a Project,
@@ -102,6 +102,8 @@ pub struct Renderer<'a> {
through: [Option<Through<'a>>; MAX_THROUGH],
/// More collapsed levels than [`MAX_THROUGH`]: their motion is not blurred.
through_lost: bool,
/// Layer-space consumers must not use the viewport raster optimization.
pub(crate) whole_raster: bool,
}
#[cfg(test)]
+207 -27
View File
@@ -19,6 +19,24 @@ pub(crate) struct EffectStack {
pub origin: [f64; 2],
}
/// A footage layer's source as the decoded picture itself (see [`Renderer::footage_picture`]):
/// its buffer in layer space is `img` at offset `[0, 0]` and `scale`; in effect space (what
/// [`Renderer::layer_input`] gives) at `effect_offset`.
#[derive(Clone, Debug)]
pub struct FootagePicture {
pub img: Arc<Image>,
pub scale: f64,
pub effect_offset: [f64; 2],
}
/// How a decoded footage frame becomes a layer buffer ([`Renderer::footage_buf`]).
enum FootageLayout {
/// Its own pixels, at this buffer scale.
Native { scale: f64 },
/// Resampled to `width`×`height` at this buffer scale.
Resample { width: u32, height: u32, scale: f64 },
}
/// A property value an Essential Properties override gives a layer of a collapsed precomp.
#[derive(Clone, Debug)]
pub struct PropertyOverride {
@@ -123,6 +141,7 @@ impl<'a> Renderer<'a> {
top: true,
through: [None; MAX_THROUGH],
through_lost: false,
whole_raster: false,
}
}
@@ -258,6 +277,8 @@ impl<'a> Renderer<'a> {
top: false,
through: [None; MAX_THROUGH],
through_lost: false,
// Effects and nested renders need whole layer-space pixels.
whole_raster: true,
..*self
}
}
@@ -398,8 +419,9 @@ impl<'a> Renderer<'a> {
}
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
let prev = ctx.time - ctx.comp.frame_rate.frame_duration();
let now = cache::layer_key(ctx, layer, scale, draft, blur);
now.is_some() && now == cache::layer_key(&ctx.at(prev), layer, scale, draft, blur)
let before = ctx.at(prev);
let now = self.clipped_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
now.is_some() && now == self.clipped_key(&before, layer, cache::layer_key(&before, layer, scale, draft, blur))
}
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
@@ -433,14 +455,73 @@ impl<'a> Renderer<'a> {
// a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390:
// rounded up to 2^¼, unscaled text was drawn larger and resampled, softer).
let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
// Keep the buffer within about 16 million pixels.
// Keep the buffer within about 16 million pixels (of the part the output shows, when
// only that is rasterised: see `raster_clip`).
if let Some(b) = content_bounds(ctx, layer) {
let b = match self.raster_clip(ctx, layer) {
Some(c) => {
let r = kurbo::Rect::new(b[0], b[1], b[2], b[3]).intersect(c);
[r.x0, r.y0, r.x1, r.y1]
}
None => b,
};
let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s;
k = k.min((16.0e6 / area).sqrt().max(1.0));
}
s * k
}
/// The layer-space part of a 3D text or shape layer the output shows, when only that part
/// needs rasterising (#1388: text close to the camera was rasterised whole at up to 16
/// million pixels, a quarter gigabyte per frame worker, though the frame showed a fraction
/// of it; behind the camera it was rasterised and not drawn). `None` rasterises it whole:
/// whenever pixels outside the frame can matter — effects, layer styles, masks, motion blur,
/// depth of field, lights (shadows and transmission), Advanced 3D, per-character 3D, a
/// collapsed 2D transform — or the visible part can't be bounded (see
/// [`three_d::compose::visible_clip`]).
pub(crate) fn raster_clip(&self, ctx: &EvalCtx, layer: &Layer) -> Option<kurbo::Rect> {
if self.whole_raster || !layer.is_3d() || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) || self.outer.is_some() {
return None;
}
if (layer.switches.effects && layer.effects().is_some_and(|fx| !fx.children.is_empty()))
|| layer.masks().is_some_and(|m| !m.children.is_empty())
|| styles::active(ctx, layer)
|| ctx.comp.layers.iter().any(|l| matches!(l.source, LayerSource::Light { .. }))
|| self.is_adv3d(ctx)
|| (matches!(layer.source, LayerSource::Text) && (text::per_char_3d(ctx, layer) || text::animator_blur(ctx, layer)))
{
return None;
}
let v = three_d::compose::visible_clip(self, ctx, layer)?;
if v.area() <= 0.0 {
// Behind the camera.
return Some(v);
}
let b = content_bounds(ctx, layer).map(|b| kurbo::Rect::new(b[0], b[1], b[2], b[3]))?;
if v.x0 <= b.x0 && v.y0 <= b.y0 && v.x1 >= b.x1 && v.y1 >= b.y1 {
return None;
}
// Whole cells of a layer-space grid whose cell is about a quarter of the part that shows,
// rounded down to a power of two: a moving camera keeps one raster (and its cache entry)
// while that part stays within the same cells, and a dolly changes the grid only at each
// doubling. The cells cover at most about 2.25 times the part that shows. Worth it only
// when it leaves out at least half of the content.
let c = 2f64.powf(((v.width().max(v.height()) / 4.0).max(1e-6)).log2().floor());
let snap = |v: f64, up: bool| if up { (v / c).ceil() * c } else { (v / c).floor() * c };
// (Not cut to the content bounds: a stroke or a glyph's padding reaches past them.)
let cells = kurbo::Rect::new(snap(v.x0, false), snap(v.y0, false), snap(v.x1, true), snap(v.y1, true));
(cells.is_finite() && cells.intersect(b).area() <= b.area() * 0.5).then_some(cells)
}
/// A layer content cache key that also names the part rasterised ([`Self::raster_clip`]).
pub(crate) fn clipped_key(&self, ctx: &EvalCtx, layer: &Layer, key: Option<u64>) -> Option<u64> {
let k = key?;
Some(match self.raster_clip(ctx, layer) {
Some(c) => raster_clip_key(k, c),
None => k,
})
}
/// Draw a Wireframe-quality layer: its bounds as a one-pixel outline.
pub(crate) fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) {
@@ -482,6 +563,22 @@ impl<'a> Renderer<'a> {
/// Render a composition at comp time `t` (transparent background, comp size × scale).
/// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them.
pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image {
self.comp_frame_with(comp_id, t, false, |a| Ok::<_, std::convert::Infallible>(a.comp_frame(self, comp_id, t))).unwrap_or_else(|never| match never {})
}
/// Render a frame, validating the accelerated attempt before starting any CPU fallback.
/// The caller's scope must run only the accelerator, not this whole method: a GPU fault
/// must release failed resources before a potentially large CPU render (#3357).
/// A scope error returns immediately; the caller can then retry with a fresh CPU renderer
/// without accelerator-produced cache entries. Healthy declines and Auto timing retain
/// the same behavior as [`Self::comp_frame`]. Its fallback disables acceleration for
/// effects too: GPU work must never escape the supplied error scope.
pub fn comp_frame_scoped<E>(&self, comp_id: ItemId, t: Tick, scope: impl FnOnce(&dyn Accelerator) -> Result<Option<Image>, E>) -> Result<Image, E> {
self.comp_frame_with(comp_id, t, true, scope)
}
fn comp_frame_with<E>(&self, comp_id: ItemId, t: Tick, scoped: bool, scope: impl FnOnce(&dyn Accelerator) -> Result<Option<Image>, E>) -> Result<Image, E> {
let cpu = if scoped { self.with_accel(None) } else { self.with_accel(self.accel) };
// Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well;
// Auto renders each comp on whichever compositor measured faster ([`AutoPick`]).
if self.depth == 0
@@ -490,24 +587,24 @@ impl<'a> Renderer<'a> {
let auto = self.frame_auto(a, comp_id, false);
if auto.is_none_or(|(p, key)| p.choose(key)) {
let t0 = web_time::Instant::now();
if let Some(img) = a.comp_frame(self, comp_id, t) {
if let Some(img) = scope(a)? {
if let Some((p, key)) = auto {
p.record(key, true, t0.elapsed().as_secs_f64() * 1e3);
}
return img;
return Ok(img);
}
if let Some((p, key)) = auto {
p.declined(key);
}
}
let t0 = web_time::Instant::now();
let img = self.comp_frame_cpu(comp_id, t);
let img = cpu.comp_frame_cpu(comp_id, t);
if let Some((p, key)) = auto {
p.record(key, false, t0.elapsed().as_secs_f64() * 1e3);
}
return img;
return Ok(img);
}
self.comp_frame_cpu(comp_id, t)
Ok(cpu.comp_frame_cpu(comp_id, t))
}
/// The Auto timing history and key for a top-level frame of `comp` (`None` unless the
@@ -861,12 +958,7 @@ impl<'a> Renderer<'a> {
return Some(buf);
}
}
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
let (img, pf) = self.footage_picture_of(ctx, layer, item, f)?;
Some(self.footage_buf(&img, pf, f.width, f.height))
}
// (the font files go to this render's log, which `ctx` may not carry: see
@@ -881,9 +973,12 @@ impl<'a> Renderer<'a> {
{
log.mark_unkeyed();
}
Some(self.authored(text::render(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer))))
Some(self.authored(text::render_in(&self.with_reads(ctx), layer, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer))))
}
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
LayerSource::Shape => layer
.props
.sub("contents")
.map(|c| self.authored(shapes::render_in(ctx, layer, c, self.raster_scale(ctx, layer), self.raster_clip(ctx, layer)))),
_ => None,
}
}
@@ -898,14 +993,9 @@ impl<'a> Renderer<'a> {
/// A decoded frame of `f` as a layer buffer covering `w`×`h` layer pixels (the footage's
/// nominal size; proxies are scaled up to it), converted from its colour profile.
fn footage_buf(&self, img: &Image, f: &Footage, w: u32, h: u32) -> Buf {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
let mut buf = match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => Buf { img: img.clone(), offset: [0.0; 2], scale: s },
Some((rw, rh)) if k <= 1.0 + 1e-9 => Buf { img: effectcraft_raster::resample(img, rw, rh), offset: [0.0; 2], scale: s },
_ => Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k },
let mut buf = match self.footage_layout(img, w, h) {
FootageLayout::Native { scale } => Buf { img: img.clone(), offset: [0.0; 2], scale },
FootageLayout::Resample { width, height, scale } => Buf { img: effectcraft_raster::resample(img, width, height), offset: [0.0; 2], scale },
};
if let Some(c) = self.pipe.media_in(f) {
color::convert(&mut buf.img, &c);
@@ -913,6 +1003,79 @@ impl<'a> Renderer<'a> {
buf
}
/// How [`Renderer::footage_buf`] lays out a decoded frame `img` of footage `w`×`h` layer
/// pixels: its own pixels, or resampled.
fn footage_layout(&self, img: &Image, w: u32, h: u32) -> FootageLayout {
let s = self.opts.scale;
let k = if img.width > 0 { w.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
match self.reduced_size(w, h) {
// The source resampled it already (`FootageSource::frame_at_size`).
Some(size) if (img.width, img.height) == size => FootageLayout::Native { scale: s },
Some((width, height)) if k <= 1.0 + 1e-9 => FootageLayout::Resample { width, height, scale: s },
_ => FootageLayout::Native { scale: 1.0 / k },
}
}
/// A footage layer's decoded frame at the context time (the proxy's when one is used) and
/// the footage it was decoded from: what [`Renderer::source`] makes the layer's buffer of.
fn footage_picture_of(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &'a Footage) -> Option<(Arc<Image>, &'a Footage)> {
let lt = ctx.source_time(layer);
// The proxy is decoded instead, at its own size, and fills the footage's frame.
let proxy = self.proxy_for(item);
let pf = proxy.unwrap_or(f);
let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none());
let img = self.footage_frame(ctx, layer, item, pf, lt, size)?;
Some((img, pf))
}
/// The decoded footage picture a layer's masked source is, bit for bit, with no copy: a
/// footage layer (not continuously rasterised vector footage) without masks whose frame
/// needs no colour conversion, no resampling and no quantisation (32 bpc). The buffer
/// [`Renderer::layer_input`] gives for no effects is this picture at `effect_offset` and
/// `scale`.
/// Layers showing the same frame get the same `Arc` (the footage source's cached frame), so
/// an accelerator uploads it once and shares the texture. `None`: the layer's buffer is
/// made the usual way (any of the above applies, or the frame is missing), and also when the
/// frame is made per call rather than served by the footage source (frame blending,
/// separate fields, Invert Alpha, Interpret As Linear Light, Cineon conversion: a new
/// picture each time, nothing to share) or when the layer cache already holds the layer's
/// buffer (served from memory as before, without asking the footage source).
pub fn footage_picture(&self, ctx: &EvalCtx, layer: &Layer) -> Option<FootagePicture> {
let LayerSource::Footage { item } = &layer.source else { return None };
if self.pipe.levels.is_some() || layer.masks().is_some_and(|m| !m.children.is_empty()) || frame_blend_mode(ctx, layer) != FrameBlend::Off {
return None;
}
let item = ctx.media_item(layer, *item);
let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None };
if !f.has_video || (layer.switches.collapse && is_vector_footage(f)) {
return None;
}
let pf = self.proxy_for(item).unwrap_or(f);
if self.pipe.media_in(pf).is_some()
|| pf.fields != effectcraft_project::FieldOrder::Off
|| pf.invert_alpha
|| pf.linear_light_applies()
|| pf.cineon_applies()
{
return None;
}
if let Some(cache) = self.cache {
let (scale, draft, blur) = (self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer));
let content = self.content_key(ctx, layer, cache::layer_key(ctx, layer, scale, draft, blur));
let input = self.content_key(ctx, layer, cache::input_key(ctx, layer, scale, draft, blur, 0));
if [content, input].into_iter().flatten().any(|k| cache.holds(k)) {
return None;
}
}
let (img, _) = self.footage_picture_of(ctx, layer, item, f)?;
let FootageLayout::Native { scale } = self.footage_layout(&img, f.width, f.height) else { return None };
// `layer_input`'s rebase into effect space (`Buf::rebase` of a buffer at the origin).
let mut at = Buf { img: Image { width: 0, height: 0, data: Vec::new() }, offset: [0.0; 2], scale };
at.rebase(ctx.effect_bounds(layer).1);
Some(FootagePicture { img, scale, effect_offset: at.offset })
}
/// The size of `w` × `h` layer pixels in this render when it is reduced enough that footage
/// is resampled to it (below 3/4 scale).
fn reduced_size(&self, w: u32, h: u32) -> Option<(u32, u32)> {
@@ -1052,7 +1215,13 @@ impl<'a> Renderer<'a> {
/// (what the motion tracker analyses, like After Effects' Layer panel). `None` for layers
/// without pixels.
pub fn layer_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
self.source(ctx, layer)
Renderer { whole_raster: true, ..*self }.source(ctx, layer)
}
/// Layer-space pixels after masks and effects, including content outside the viewport.
/// Expression sampleImage reads this rather than the frame-oriented clipped buffer.
pub fn layer_content(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
Renderer { whole_raster: true, ..*self }.content_buf(ctx, layer)
}
/// A 3D model or primitive layer rendered on its own, through the comp's camera: what an
@@ -1092,7 +1261,7 @@ impl<'a> Renderer<'a> {
mut timing: Option<&mut LayerTiming>,
) -> Option<(Arc<Buf>, Option<u64>, Arc<ReadFiles>)> {
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer)));
let key = self.content_key(ctx, layer, key);
let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key));
if let Some((b, files)) = self.cached(key) {
if let Some(t) = timing.as_deref_mut() {
t.cached = true;
@@ -1114,8 +1283,13 @@ impl<'a> Renderer<'a> {
/// what effect `effects` sees. Served from / stored in the layer cache under its own key,
/// so scrubbing reuses frames rendered for earlier output frames.
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
// The Layer panel, analysis tools and effect inputs read the whole layer, not the viewport.
Renderer { whole_raster: true, ..*self }.whole_layer_input(ctx, layer, effects)
}
fn whole_layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, self.mb_on(ctx, layer), effects));
let key = self.content_key(ctx, layer, key);
let key = self.content_key(ctx, layer, self.clipped_key(ctx, layer, key));
if let Some((b, _)) = self.cached(key) {
return Some(b);
}
@@ -1132,3 +1306,9 @@ impl<'a> Renderer<'a> {
Some(self.keep(key, Arc::new(buf?), &files).0)
}
}
/// Snap cells can be finer than 1/16 unit: preserve every coordinate bit so visible
/// clips cannot collide with another cell or the empty behind-camera raster.
pub(crate) fn raster_clip_key(key: u64, clip: kurbo::Rect) -> u64 {
[clip.x0, clip.y0, clip.x1, clip.y1].iter().fold(cache::derive(key, 0xc11b), |key, coordinate| cache::derive(key, coordinate.to_bits()))
}
+7 -3
View File
@@ -686,6 +686,12 @@ const BATCH_PIXELS: usize = 8 << 20;
/// Each draw rasterises and composites only its own pixel rectangle; coverage masks of
/// consecutive draws are rasterised in parallel, then composited in stacking order.
pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf {
render_in(ctx, layer, contents, s, None)
}
/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows,
/// see `Renderer::raster_clip`).
pub fn render_in(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64, clip: Option<kurbo::Rect>) -> Buf {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
// Bottom-most draw first.
@@ -702,9 +708,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, s: f64) -> Buf
let Some(b) = resolved.iter().map(|r| r.2).reduce(|a, b| a.union(b)) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
// Keep buffers sane for runaway geometry.
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let Some((w, h, offset)) = crate::text::raster_window(b, s, clip) else { return Buf { img: Image::new(1, 1), offset: [0.0, 0.0], scale: s } };
let m = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
let px_rect = |r: kurbo::Rect| -> Option<PxRect> {
let x0 = (r.x0 * s + offset[0]).floor().max(0.0);
+3
View File
@@ -11,6 +11,7 @@ mod auto;
mod aux_channels;
mod collapse;
mod color;
mod drop_shadow_cover;
mod footage_size;
mod frame_blend;
mod guides;
@@ -20,6 +21,8 @@ mod mask_feather;
mod motion_tile;
mod nested;
mod paint;
mod proxy_cache;
mod raster_clip;
mod remap;
mod roi;
mod stretch_limits;
+87 -1
View File
@@ -14,12 +14,13 @@ struct MockAccel {
handle: bool,
calls: AtomicUsize,
pick: AutoPick,
effect_calls: AtomicUsize,
}
impl MockAccel {
/// Construct a renderer stand-in without making assumptions about scheduling or speed.
fn new(handle: bool) -> MockAccel {
MockAccel { handle, calls: Default::default(), pick: Default::default() }
MockAccel { handle, calls: Default::default(), pick: Default::default(), effect_calls: Default::default() }
}
/// Number of full-frame requests dispatched to this accelerator.
fn calls(&self) -> usize {
@@ -42,6 +43,7 @@ impl Accelerator for MockAccel {
})
}
fn supports_effect(&self, _id: &str) -> bool {
self.effect_calls.fetch_add(1, Ordering::SeqCst);
false
}
fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option<f32>) -> Option<crate::Buf> {
@@ -160,3 +162,87 @@ fn auto_pick_follows_changing_costs() {
assert!(pick.stats(AutoKey::new(ItemId(7), 1.0, true)).is_none());
assert!(pick.stats(AutoKey::new(ItemId(7), 0.5, false)).is_none());
}
/// Counts actual CPU source reads and rejects any CPU fallback inside a GPU error scope.
struct ScopedFootage {
in_scope: std::sync::atomic::AtomicBool,
reads: AtomicUsize,
}
impl crate::FootageSource for ScopedFootage {
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<crate::Image>> {
assert!(!self.in_scope.load(Ordering::SeqCst), "CPU fallback ran before GPU validation/cleanup");
self.reads.fetch_add(1, Ordering::SeqCst);
Some(std::sync::Arc::new(crate::Image::filled(f.width, f.height, [1.0, 0.0, 0.0, 1.0])))
}
}
fn scoped_project() -> (Project, ItemId) {
let (mut p, cid) = project();
let comp = p.comp(cid).unwrap().clone();
let footage = effectcraft_project::Footage {
path: "synthetic.png".into(),
kind: effectcraft_project::FootageKind::Still,
width: 50,
height: 50,
has_video: true,
duration: Tick::from_seconds_f64(2.0),
..Default::default()
};
let item = p.add_item("Source", Label::Red, None, ItemKind::Footage(footage));
let mut layer = build::layer(&mut p, &comp, "Source", LayerSource::Footage { item }, (50, 50), None);
super::add_effect_200(&mut p, &mut layer, "ec.blur.gaussian", &[]);
p.comp_mut(cid).unwrap().layers = vec![layer].into();
(p, cid)
}
/// #3357: a declined GPU frame must not allocate a CPU frame only to discard it when the
/// enclosing GPU scope reports a fault. The caller retries on a fresh CPU renderer once.
#[test]
fn failed_gpu_scope_returns_before_cpu_fallback() {
let (p, cid) = scoped_project();
for handled in [false, true] {
let a = MockAccel::new(handled);
let src = ScopedFootage { in_scope: false.into(), reads: 0.into() };
let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Gpu, ..Default::default() });
r.accel = Some(&a);
let result = r.comp_frame_scoped(cid, Tick::ZERO, |a| {
src.in_scope.store(true, Ordering::SeqCst);
let frame = a.comp_frame(&r, cid, Tick::ZERO);
drop(frame);
src.in_scope.store(false, Ordering::SeqCst);
Err::<Option<crate::Image>, _>("device retired")
});
assert_eq!(result.unwrap_err(), "device retired");
assert_eq!(src.reads.load(Ordering::SeqCst), 0);
let frame = r.with_accel(None).comp_frame(cid, Tick::ZERO);
assert!(frame.get(100, 50)[3] > 0.99);
assert_eq!(src.reads.load(Ordering::SeqCst), 1);
assert_eq!(a.calls(), 1);
}
}
/// A healthy decline closes its scope before CPU work, and still records Auto timings.
#[test]
fn healthy_gpu_decline_renders_cpu_once_outside_scope() {
let (p, cid) = scoped_project();
let a = MockAccel::new(false);
let src = ScopedFootage { in_scope: false.into(), reads: 0.into() };
let mut r = Renderer::new(&p, &src, RenderOpts { backend: Backend::Auto, ..Default::default() });
r.accel = Some(&a);
let frame = r
.comp_frame_scoped(cid, Tick::ZERO, |a| {
src.in_scope.store(true, Ordering::SeqCst);
let frame = a.comp_frame(&r, cid, Tick::ZERO);
src.in_scope.store(false, Ordering::SeqCst);
Ok::<_, ()>(frame)
})
.unwrap();
assert!(frame.get(100, 50)[3] > 0.99);
assert_eq!(src.reads.load(Ordering::SeqCst), 1);
assert_eq!(a.calls(), 1);
assert_eq!(a.pick.stats(AutoKey::new(cid, 1.0, false)).unwrap().cpu_frames, 1);
// Auto's next frame stays on CPU, without entering a GPU scope at all.
r.comp_frame_scoped(cid, Tick::ZERO, |_| -> Result<Option<crate::Image>, ()> { panic!("Auto CPU choice must not enter GPU error scopes") }).unwrap();
assert_eq!(src.reads.load(Ordering::SeqCst), 2);
assert_eq!(a.effect_calls.load(Ordering::SeqCst), 0, "CPU effects must not use the GPU outside its scope");
}
@@ -0,0 +1,142 @@
use super::*;
use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer};
use effectcraft_effects::Buf;
// A synthetic painted source avoids installed-font dependencies and the multi-gigabyte
// allocation in #3357 while exercising the same text/shape effect-stack bounds.
fn source(scale: f64) -> Buf {
let mut img = Image::new(20, 12);
for y in 2..10 {
for x in 2..18 {
img.set(x, y, [0.8, 0.2, 0.1, 1.0]);
}
}
Buf { img, offset: [7.0, -3.0], scale }
}
#[test]
fn drop_shadow_does_not_cover_the_zoomed_layer_rectangle() {
let (mut p, cid, comp) = setup();
let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None);
add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO);
let out = r.apply_effects(&ctx, &layer, source(4.0), false);
// The old cover makes this at least 800 x 400. Alignment and the shadow's
// own padding need less than 128 pixels per side for this source.
assert!(out.img.width < 128 && out.img.height < 128, "{} x {}", out.img.width, out.img.height);
}
#[test]
fn drop_shadow_matches_the_covered_reference() {
for scale in [0.5, 1.0, 4.0] {
for only in [false, true] {
for direction in [0.0, 135.0, 270.0] {
let (mut p, cid, comp) = setup();
let mut layer = build::layer(&mut p, &comp, "Text", LayerSource::Text, (0, 0), None);
add_effect_200(
&mut p,
&mut layer,
"ec.perspective.dropshadow",
&[
("distance", Value::Scalar(3.5)),
("softness", Value::Scalar(2.0)),
("direction", Value::Scalar(direction)),
("shadowOnly", Value::Bool(only)),
],
);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO);
let input = source(scale);
let mut covered = input.clone();
let (size, origin) = ctx.effect_bounds(&layer);
covered.rebase(origin);
covered.cover(size);
covered.rebase([-origin[0], -origin[1]]);
let actual = r.apply_effects(&ctx, &layer, input, false);
let reference = r.apply_effects(&ctx, &layer, covered, false);
// Compare the union in layer-pixel coordinates, including the shadow outside
// the original content. Buffer origins differ by integral pixels.
let x0 = (-actual.offset[0]).min(-reference.offset[0]) as i32;
let y0 = (-actual.offset[1]).min(-reference.offset[1]) as i32;
let x1 = (f64::from(actual.img.width) - actual.offset[0]).max(f64::from(reference.img.width) - reference.offset[0]) as i32;
let y1 = (f64::from(actual.img.height) - actual.offset[1]).max(f64::from(reference.img.height) - reference.offset[1]) as i32;
for y in y0..y1 {
for x in x0..x1 {
let sample = |b: &Buf| b.img.sample_bilinear(f64::from(x) + b.offset[0] + 0.5, f64::from(y) + b.offset[1] + 0.5);
let (a, b) = (sample(&actual), sample(&reference));
for c in 0..4 {
assert!(
(a[c] - b[c]).abs() < 1e-5,
"scale {scale}, only {only}, direction {direction}, ({x}, {y}), channel {c}: {} != {}",
a[c],
b[c]
);
}
}
}
}
}
}
}
#[test]
fn drop_shadow_with_glow_stays_local_but_other_effects_keep_cover() {
for other in ["ec.stylize.glow", "ec.color.tint"] {
let (mut p, cid, comp) = setup();
let mut layer = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (0, 0), None);
add_effect_200(&mut p, &mut layer, "ec.perspective.dropshadow", &[("distance", Value::Scalar(2.0)), ("softness", Value::Scalar(1.0))]);
let values = if other == "ec.stylize.glow" { vec![("radius", Value::Scalar(1.0))] } else { vec![] };
add_effect_200(&mut p, &mut layer, other, &values);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx::new(&p, cid, &comp, Tick::ZERO);
let out = r.apply_effects(&ctx, &layer, source(4.0), false);
if other == "ec.stylize.glow" {
assert!(out.img.width < 192 && out.img.height < 192, "{} x {}", out.img.width, out.img.height);
} else {
assert!(out.img.width >= 800 && out.img.height >= 400);
}
}
}
#[test]
fn parented_text_and_shape_keep_small_working_images_at_2200_percent() {
for text in [false, true] {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(80, 40, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut parent = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None);
parent.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]);
parent.props.prop_mut("transform/position").unwrap().value = Value::Vec3([40.0, 20.0, 0.0]);
parent.props.prop_mut("transform/scale").unwrap().value = Value::Vec3([2200.0, 2200.0, 100.0]);
let source = if text { LayerSource::Text } else { LayerSource::Shape };
let mut layer = build::layer(&mut p, &comp, "Painted", source, (80, 40), None);
layer.parent = Some(parent.id);
layer.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0; 3]);
layer.props.prop_mut("transform/position").unwrap().value = Value::Vec3([0.0; 3]);
if text {
layer.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "I".into(), size: 8.0, ..Default::default() }));
} else {
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [4.0, 4.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0; 4]);
let group = build::shape_group(&mut ids, "Rectangle", vec![rect, fill]);
p.next_id = next;
layer.props.sub_mut("contents").unwrap().children.push(group.into());
}
add_effect(&mut p, &mut layer, "ec.stylize.glow", [80.0, 40.0], &[("radius", Value::Scalar(0.1))]);
add_effect(&mut p, &mut layer, "ec.perspective.dropshadow", [80.0, 40.0], &[("distance", Value::Scalar(0.1)), ("softness", Value::Scalar(0.1))]);
p.comp_mut(cid).unwrap().layers = vec![layer, parent].into();
let comp = p.comp(cid).unwrap();
let layer = &comp.layers[0];
for scale in [1.0, 0.5] {
let r = Renderer::new(&p, &NoFootage, RenderOpts { scale, ..Default::default() });
let ctx = EvalCtx::new(&p, cid, comp, Tick::ZERO);
let input = r.layer_input(&ctx, layer, usize::MAX).unwrap();
assert!(input.img.width < 512 && input.img.height < 512, "text={text}, output scale={scale}: {} x {}", input.img.width, input.img.height);
assert!(input.img.data.iter().any(|pixel| pixel[3] > 0.5), "the painted source is not empty");
let frame = r.comp_frame(cid, Tick::ZERO);
assert!(frame.data.iter().any(|pixel| pixel[3] > 0.5), "the layer still draws in the composition");
}
}
}
+45
View File
@@ -0,0 +1,45 @@
use super::setup;
use crate::eval::EvalCtx;
use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer};
use effectcraft_color::Label;
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Proxy, build, render_queue::ProxyUse};
use effectcraft_time::Tick;
use std::sync::Arc;
/// A source and its proxy deliberately have different pixels.
struct Pictures;
impl FootageSource for Pictures {
fn frame(&self, _: ItemId, footage: &Footage, _: Tick) -> Option<Arc<Image>> {
let color = if footage.path == "proxy" { [0.0, 1.0, 0.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
Some(Arc::new(Image::filled(16, 16, color)))
}
}
/// A queue run reuses its layer cache across jobs with different Proxy Use settings.
/// Time effects read layer_input, which must never serve another job's source pixels.
#[test]
fn shared_layer_cache_separates_proxy_use() {
let (mut p, cid, comp) = setup();
let footage = Footage { path: "original".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() };
let item = p.add_item("Footage", Label::Red, None, ItemKind::Footage(footage.clone()));
p.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage: Footage { path: "proxy".into(), ..footage }, enabled: false }));
let layer = build::layer(&mut p, &comp, "Footage", LayerSource::Footage { item }, (16, 16), None);
p.comp_mut(cid).unwrap().layers.push(layer);
for enabled in [false, true] {
p.item_mut(item).unwrap().proxy.as_mut().unwrap().enabled = enabled;
let cache = LayerCache::default();
let ctx = EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
let layer = &ctx.comp.layers[0];
for proxy in [ProxyUse::UseAll, ProxyUse::UseNone, ProxyUse::CurrentSettings, ProxyUse::UseCompOnly, ProxyUse::UseAll] {
let mut r = Renderer::new(&p, &Pictures, RenderOpts { proxy, ..Default::default() });
let input = r.layer_input(&ctx, layer, 0).unwrap();
let content = r.content_buf(&ctx, layer).unwrap();
r.cache = Some(&cache);
assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img.data, input.img.data, "{proxy:?}, enabled={enabled}");
assert_eq!(r.content_buf(&ctx, layer).unwrap().img.data, content.img.data, "{proxy:?}, enabled={enabled}");
let hits = cache.stats().hits;
r.layer_input(&ctx, layer, 0).unwrap();
assert!(cache.stats().hits > hits, "unchanged proxy settings still reuse inputs");
}
}
}
+360
View File
@@ -0,0 +1,360 @@
//! #1388: 3D text and shape layers rasterise only the part of the layer the output shows. Where
//! the whole raster fits its pixel budget the frame is identical to rasterising the whole layer;
//! close to the camera the raster stays small instead of growing to ~16 million pixels.
use effectcraft_color::Label;
use effectcraft_keyframe::{TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project};
use effectcraft_time::{FrameRate, Tick};
use crate::{NoFootage, RenderOpts, Renderer};
const W: u32 = 400;
const H: u32 = 300;
/// The default camera's distance to the z = 0 plane.
fn zoom() -> f64 {
W as f64 * 50.0 / 36.0
}
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
/// A 3D text layer at `pos`, turned `rot_y` degrees about Y.
fn text3(p: &mut Project, comp: &Comp, pos: [f64; 3], rot_y: f64) -> Layer {
let mut l = build::layer(p, comp, "Text", LayerSource::Text, (W, H), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA TEXT".into(), size: 60.0, ..Default::default() }));
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
set(&mut l, "transform/rotationY", Value::Scalar(rot_y));
l
}
/// A 3D shape layer (a 300×120 rectangle with a stroke) at `pos`.
fn shape3(p: &mut Project, comp: &Comp, pos: [f64; 3]) -> Layer {
let mut l = build::layer(p, comp, "Shape", LayerSource::Shape, (W, H), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let r = build::shape_rect(&mut ids, [300.0, 120.0], [0.0, 0.0], 0.0);
let f = build::shape_fill(&mut ids, [0.9, 0.4, 0.1, 1.0]);
let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 6.0);
let g = build::shape_group(&mut ids, "Box", vec![r, f, s]);
l.props.sub_mut("contents").unwrap().children.push(g.into());
}
p.next_id = next;
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
l
}
fn scene(l: impl FnOnce(&mut Project, &Comp) -> Layer) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let layer = l(&mut p, &comp);
p.comp_mut(cid).unwrap().layers.push(layer);
(p, cid)
}
fn frame(p: &Project, cid: ItemId, whole: bool) -> effectcraft_raster::Image {
let mut r = Renderer::new(p, &NoFootage, RenderOpts::default());
r.whole_raster = whole;
r.comp_frame(cid, Tick::ZERO)
}
/// Pixels of the layer's processed buffer.
fn raster_px(p: &Project, cid: ItemId, whole: bool) -> u64 {
let mut r = Renderer::new(p, &NoFootage, RenderOpts::default());
r.whole_raster = whole;
let c = p.comp(cid).unwrap();
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let b = r.layer_buf(&ctx, &c.layers[0]).unwrap();
b.img.width as u64 * b.img.height as u64
}
#[test]
fn the_visible_part_renders_the_same_frame_as_the_whole_layer() {
let cx = W as f64 / 2.0;
let cy = H as f64 / 2.0;
let cases: Vec<(&str, (Project, ItemId))> = vec![
("far", scene(|p, c| text3(p, c, [cx, cy, 300.0], 0.0))),
("in the frame plane", scene(|p, c| text3(p, c, [cx, cy, 0.0], 0.0))),
("past the frame edge", scene(|p, c| text3(p, c, [60.0, cy, -200.0], 0.0))),
("turned", scene(|p, c| text3(p, c, [cx, cy, -150.0], 55.0))),
("close to the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 40.0], 0.0))),
("partly behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() + 30.0], 80.0))),
("behind the camera", scene(|p, c| text3(p, c, [cx, cy, -zoom() - 200.0], 0.0))),
("shape", scene(|p, c| shape3(p, c, [cx + 150.0, cy, -250.0]))),
];
for (label, (p, cid)) in &cases {
let clipped = frame(p, *cid, false);
let whole = frame(p, *cid, true);
assert_eq!(clipped.data, whole.data, "{label}");
}
}
/// Close to the camera only part of the text shows: its raster is a fraction of the whole one
/// (millions of pixels), and the frame still shows text.
#[test]
fn text_close_to_the_camera_rasterises_what_shows() {
let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let clipped = raster_px(&p, cid, false);
let whole = raster_px(&p, cid, true);
assert!(whole > 5_000_000, "the whole raster is huge ({whole} px)");
eprintln!("near camera: {clipped} px rasterised, {whole} px whole");
assert!(clipped * 10 < whole, "{clipped} px of {whole}");
let img = frame(&p, cid, false);
let covered = img.data.iter().filter(|px| px[3] > 0.5).count();
assert!(covered > (W * H / 20) as usize, "text shows ({covered} px)");
// Behind the camera there is nothing to rasterise.
let (p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0], 0.0));
assert!(raster_px(&p, cid, false) <= 1);
}
/// The #1388 case at full size: 200 pt text in a 1920 × 1080 comp, close to the camera. Rasterised
/// whole it fills the ~16 million pixel cap; clipped, it stays within a few frames' worth of pixels
/// (and the text still shows).
#[test]
fn hd_text_close_to_the_camera_stays_within_a_few_frames() {
let mut p = Project::default();
let comp = Comp::new(1920, 1080, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (1920, 1080), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "NEAR CAMERA".into(), size: 200.0, ..Default::default() }));
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0 + 60.0]));
p.comp_mut(cid).unwrap().layers.push(l);
let (clipped, whole) = (raster_px(&p, cid, false), raster_px(&p, cid, true));
eprintln!("HD near camera: {clipped} px rasterised, {whole} px whole");
assert!(whole > 12_000_000, "{whole}");
assert!(clipped <= 4 * 1920 * 1080, "{clipped} px");
assert!(frame(&p, cid, false).data.iter().any(|px| px[3] > 0.5), "the text shows");
}
/// A slow move of the layer against the camera (a camera move does the same) keeps the part
/// rasterised within the same grid cells for several frames: they reuse the cached raster instead
/// of drawing the text again each frame.
#[test]
fn a_slow_move_reuses_the_clipped_raster() {
let cache = crate::LayerCache::default();
let at = |x: f64| {
let (p, cid) = scene(|p, c| text3(p, c, [x, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, Tick::ZERO);
};
at(W as f64 / 2.0);
let misses = cache.stats().misses;
// 16 frames, a quarter of a layer pixel apart (here about three output pixels).
for i in 1..=16 {
at(W as f64 / 2.0 + i as f64 * 0.25);
}
let rasters = cache.stats().misses - misses;
assert!(rasters <= 2, "{rasters} new rasters over 16 frames");
}
/// One keyframed motion: (seconds, value) pairs.
type Keys<'a> = &'a [(f64, [f64; 3])];
fn keyed(l: &mut Layer, path: &str, keys: Keys) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).keys =
keys.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Vec3(*v))).collect();
}
/// A camera keyed through `pos` / `poi`, and the text keyed through `text_pos` (and turning about
/// Y through `text_rot`): every frame of a second, rendered with the clipped raster and a layer
/// cache shared across the frames, matches rasterising the whole layer without a cache (or differs
/// by an anti-aliasing sub-sample or two on a few edge pixels) — except where the whole raster is held at
/// its pixel cap (softer than the clipped one, which isn't): there the text shows and the clipped
/// raster stays small.
fn moving(label: &str, pos: Keys, poi: Keys, text_pos: Keys, text_rot: &[(f64, f64)]) {
let (mut p, cid, comp) = setup();
let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, 0.0], 0.0);
if !text_pos.is_empty() {
keyed(&mut text, "transform/position", text_pos);
}
if !text_rot.is_empty() {
text.props.prop_mut("transform/rotationY").unwrap().keys =
text_rot.iter().map(|(t, v)| effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(*t), Value::Scalar(*v))).collect();
}
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (W, H), None);
set(&mut cam, "cameraOptions/zoom", Value::Scalar(zoom()));
keyed(&mut cam, "transform/position", pos);
keyed(&mut cam, "transform/poi", poi);
p.comp_mut(cid).unwrap().layers = vec![cam, text].into();
let cache = crate::LayerCache::default();
let (mut exact, mut near, mut capped) = (0, 0, 0);
for f in 0..30 {
let t = Tick::from_seconds_f64(f as f64 / 30.0);
let mut clipped = Renderer::new(&p, &NoFootage, RenderOpts::default());
clipped.cache = Some(&cache);
let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default());
whole.whole_raster = true;
let ctx = whole.eval_ctx(cid, t).unwrap();
let layer = &p.comp(cid).unwrap().layers[1];
let a = clipped.comp_frame(cid, t);
if clipped.raster_scale(&ctx, layer) == whole.raster_scale(&ctx, layer) {
let b = whole.comp_frame(cid, t);
if a.data == b.data {
exact += 1;
} else {
// The clipped raster places the glyphs at other coordinates (smaller ones: the whole
// raster can be thousands of pixels wide), and tiny-skia transforms them in f32: its
// 4 × 4 anti-aliasing may land a sub-sample or two differently on a few edge pixels.
let (n, worst) = a.data.iter().zip(&b.data).fold((0usize, 0.0f32), |(n, w), (p, q)| {
let d = (0..4).map(|i| (p[i] - q[i]).abs()).fold(0.0f32, f32::max);
(n + usize::from(d > 0.0), w.max(d))
});
assert!(worst <= 2.0 / 16.0 + 1e-4 && n * 1000 <= (W * H) as usize, "{label}: frame {f}: {n} px differ, by up to {worst}");
near += 1;
}
} else {
// Held at the cap whole: the clipped raster is sharper, and small.
let buf = clipped.layer_buf(&ctx, layer).unwrap();
assert!(buf.img.width as u64 * buf.img.height as u64 <= 16_500_000, "{label}: frame {f}");
let shows = |img: &effectcraft_raster::Image| img.data.iter().filter(|px| px[3] > 0.5).count();
let (sa, sb) = (shows(&a), shows(&whole.comp_frame(cid, t)));
// The same text shows (or none, the camera past it); coverage within a few percent.
assert!(sa.abs_diff(sb) * 50 <= sb.max(1) + 50, "{label}: frame {f}: {sa} px of text, {sb} rasterised whole");
capped += 1;
}
}
eprintln!("{label}: {exact} frames identical, {near} within anti-aliasing noise, {capped} sharper than the capped whole raster");
assert!(exact + near >= 10, "{label}: {exact} identical, {near} close, {capped} at the cap");
}
#[test]
fn a_camera_moving_any_way_renders_every_frame_as_the_whole_layer() {
let (cx, cy, z) = (W as f64 / 2.0, H as f64 / 2.0, -zoom());
let still = [(0.0, [cx, cy, 0.0])];
// In towards the text, through it and past it; and back out.
moving("dolly in through the text", &[(0.0, [cx, cy, 2.0 * z]), (1.0, [cx, cy, 150.0])], &[(0.0, [cx, cy, 400.0]), (1.0, [cx, cy, 700.0])], &[], &[]);
moving("dolly out", &[(0.0, [cx, cy, -60.0]), (1.0, [cx, cy, 3.0 * z])], &still, &[], &[]);
// Sideways and up and down close to the text, so it leaves the frame on both sides.
moving(
"pan x",
&[(0.0, [cx - 500.0, cy, -120.0]), (1.0, [cx + 500.0, cy, -120.0])],
&[(0.0, [cx - 500.0, cy, 0.0]), (1.0, [cx + 500.0, cy, 0.0])],
&[],
&[],
);
moving("pan y", &[(0.0, [cx, cy - 300.0, -90.0]), (1.0, [cx, cy + 300.0, -90.0])], &[(0.0, [cx, cy - 300.0, 0.0]), (1.0, [cx, cy + 300.0, 0.0])], &[], &[]);
// Around the text, close, looking at it.
let orbit: Vec<(f64, [f64; 3])> = (0..=8)
.map(|i| {
let a = i as f64 / 8.0 * std::f64::consts::TAU * 0.4 - 0.8;
(i as f64 / 8.0, [cx + 150.0 * a.sin(), cy, -150.0 * a.cos()])
})
.collect();
moving("orbit", &orbit, &still, &[], &[]);
// The text moves instead: towards the camera, past it, and across.
moving(
"text towards and past the camera",
&[(0.0, [cx, cy, z])],
&still,
&[(0.0, [cx + 200.0, cy - 80.0, 400.0]), (1.0, [cx - 150.0, cy + 60.0, z - 100.0])],
&[],
);
// Turning close to the camera.
moving("text turning near the camera", &[(0.0, [cx, cy, z])], &still, &[(0.0, [cx, cy, z + 70.0])], &[(0.0, -70.0), (1.0, 70.0)]);
}
/// Reads of a layer in its own space get the whole layer, not the part the frame shows: an effect
/// taking another layer as a parameter (Set Matte, stretched to fit, from hidden text close to the
/// camera), and the source the motion tracker analyses.
#[test]
fn layer_space_reads_get_the_whole_layer() {
let (mut p, cid, comp) = setup();
let mut text = text3(&mut p, &comp, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0);
text.switches.video = false;
let text_id = text.id;
let sid =
p.add_item("Solid", Label::Red, None, ItemKind::Solid(effectcraft_project::Solid { color: [0.2, 0.6, 1.0], width: W, height: H, pixel_aspect: 1.0 }));
let mut solid = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (W, H), None);
let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
g.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(text_id.0));
solid.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers = vec![text, solid].into();
assert_eq!(frame(&p, cid, false).data, frame(&p, cid, true).data, "Set Matte from the text");
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let layer = &p.comp(cid).unwrap().layers[0];
let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default());
whole.whole_raster = true;
assert_eq!(r.layer_source(&ctx, layer).unwrap().img, whole.layer_source(&ctx, layer).unwrap().img, "the tracker's source");
}
/// A layer whose pixels outside the frame can matter (an effect here) is rasterised whole.
#[test]
fn layers_with_effects_rasterise_whole() {
let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(g.into());
assert_eq!(raster_px(&p, cid, false), raster_px(&p, cid, true));
}
/// The Layer panel and analysis tools consume layer-space inputs, even with no effects.
#[test]
fn layer_panel_input_reads_the_whole_layer_behind_the_camera() {
let (p, cid) = scene(|p, c| shape3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() - 200.0]));
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let layer = &p.comp(cid).unwrap().layers[0];
let mut whole = Renderer::new(&p, &NoFootage, RenderOpts::default());
whole.whole_raster = true;
let expected = whole.layer_input(&ctx, layer, 0).unwrap();
assert!(expected.img.data.iter().any(|pixel| pixel[3] > 0.5));
assert_eq!(r.layer_input(&ctx, layer, 0).unwrap().img, expected.img);
}
#[test]
fn fine_clip_cells_have_distinct_cache_keys() {
let a = kurbo::Rect::new(0.0, 0.0, 1.0 / 128.0, 1.0 / 128.0);
let b = kurbo::Rect::new(1.0 / 128.0, 0.0, 2.0 / 128.0, 1.0 / 128.0);
let key = |clip| crate::pipeline::raster_clip_key(42, clip);
// All three clips rounded to identical coordinates in the old 1/16-unit key.
assert_ne!(key(a), key(b));
assert_ne!(key(a), key(kurbo::Rect::ZERO));
assert_ne!(key(b), key(kurbo::Rect::ZERO));
}
/// Text animator Blur reads beyond the fixed anti-aliasing padding of a clipped window.
#[test]
fn animated_text_blur_keeps_offscreen_glyph_pixels_while_panning() {
let (mut p, cid) = scene(|p, c| text3(p, c, [W as f64 / 2.0, H as f64 / 2.0, -zoom() + 40.0], 0.0));
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let props = build::text_anim_props(&mut ids, "blur", false);
let mut animator = build::text_animator(&mut ids, "Blur", props);
animator.prop_mut("properties/blur").unwrap().value = Value::Vec2([40.0, 40.0]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[0].props.group_mut("text/animators").unwrap().children.push(animator.into());
let opts = RenderOpts { scale: 0.125, ..Default::default() };
let cache = crate::LayerCache::default();
for dx in [-10.0, 0.0, 10.0] {
set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3([W as f64 / 2.0 + dx, H as f64 / 2.0, -zoom() + 40.0]));
let mut r = Renderer::new(&p, &NoFootage, opts);
r.cache = Some(&cache);
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
let layer = &p.comp(cid).unwrap().layers[0];
assert!(r.raster_clip(&ctx, layer).is_none(), "animator blur needs off-window pixels");
let mut whole = Renderer::new(&p, &NoFootage, opts);
whole.whole_raster = true;
assert_eq!(r.comp_frame(cid, Tick::ZERO).data, whole.comp_frame(cid, Tick::ZERO).data);
}
}
+49 -3
View File
@@ -509,6 +509,16 @@ pub fn per_char_3d(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.is_3d() && layer.props.sub("text").is_some_and(|t| ctx.b(layer, t, "perChar3d"))
}
/// Animator blur can read glyph pixels beyond a viewport raster window. Conservatively
/// keep the whole raster even when the current selector happens to select no characters.
pub(crate) fn animator_blur(ctx: &EvalCtx, layer: &Layer) -> bool {
layer.props.group("text/animators").is_some_and(|anims| {
anims.groups().filter(|anim| anim.enabled).any(|anim| {
anim.sub("properties").and_then(|props| props.get("blur")).is_some_and(|blur| ctx.value(layer, blur).as_vec2().iter().any(|value| *value != 0.0))
})
})
}
/// One drawn character.
#[derive(Clone, Debug)]
pub struct PlacedGlyph {
@@ -1122,8 +1132,46 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
}
}
/// Buffer pixels kept around a clip (see [`raster_window`]).
const CLIP_PAD: f64 = 24.0;
/// The buffer for content bounds `b` (layer space) at scale `s`: (width, height, layer → buffer
/// offset), two pixels of padding around it. With `clip`, only the pixels of that grid inside
/// `clip`, padded by [`CLIP_PAD`]: a whole-pixel window of the whole buffer's grid. Its pixels are
/// the whole buffer's, except that a glyph cut by the window is rasterised from the window's edge:
/// tiny-skia's 4 × 4 anti-aliasing then lands a sub-sample differently on a few edge pixels, up to
/// a dozen rows in from that edge — inside the padding, out of the frame. `None` when `clip`
/// misses the content.
pub(crate) fn raster_window(b: kurbo::Rect, s: f64, clip: Option<kurbo::Rect>) -> Option<(u32, u32, [f64; 2])> {
let (full_w, full_h) = ((b.width() * s).ceil() + 4.0, (b.height() * s).ceil() + 4.0);
let full = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let (x0, y0, x1, y1) = match clip {
None => (0.0, 0.0, full_w, full_h),
// Nothing shows (behind the camera).
Some(c) if c.area() <= 0.0 => return None,
Some(c) => (
(c.x0 * s + full[0] - CLIP_PAD).floor().max(0.0),
(c.y0 * s + full[1] - CLIP_PAD).floor().max(0.0),
(c.x1 * s + full[0] + CLIP_PAD).ceil().min(full_w),
(c.y1 * s + full[1] + CLIP_PAD).ceil().min(full_h),
),
};
if !(x1 > x0 && y1 > y0) {
return None;
}
let w = ((x1 - x0) as u32).clamp(1, 16384);
let h = ((y1 - y0) as u32).clamp(1, 16384);
Some((w, h, [full[0] - x0, full[1] - y0]))
}
/// Render a text layer into a layer-space buffer at scale `s`.
pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
render_in(ctx, layer, s, None)
}
/// [`render`], only the part of the layer inside `clip` (layer space; the part the output shows,
/// see `Renderer::raster_clip`).
pub fn render_in(ctx: &EvalCtx, layer: &Layer, s: f64, clip: Option<kurbo::Rect>) -> Buf {
let empty = || Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: s };
let Some(geom) = text_geom(ctx, layer) else { return empty() };
let mut bounds: Option<kurbo::Rect> = None;
@@ -1137,9 +1185,7 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
}
let Some(b) = bounds else { return empty() };
let b = b.intersect(kurbo::Rect::new(-20000.0, -20000.0, 20000.0, 20000.0));
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 16384);
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 16384);
let offset = [-b.x0 * s + 2.0, -b.y0 * s + 2.0];
let Some((w, h, offset)) = raster_window(b, s, clip) else { return empty() };
let mut img = Image::new(w, h);
let base = Mat3::translate(vec2(offset[0], offset[1])) * Mat3::scale(vec2(s, s));
draw_glyphs(&geom, &mut img, &base, s, None);
+46
View File
@@ -185,6 +185,52 @@ pub(crate) fn view_size(r: &Renderer, ctx: &EvalCtx) -> (f64, f64) {
(c.width as f64, c.height as f64)
}
/// The layer-space rectangle of a 3D layer that this render's output can show: the output frame
/// (padded by two pixels) projected back onto the layer's plane, the way [`geo`] draws it. `None`
/// when that can't be bounded: an orthographic camera, depth of field (its blur reads past the
/// frame), motion blur (each sub-sample sees another part), or a frame corner whose ray misses the
/// plane in front of the camera (a plane seen towards its horizon). An empty rectangle when the
/// whole content is behind the camera.
pub(crate) fn visible_clip(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> Option<kurbo::Rect> {
let cam = camera_for(r, ctx);
if cam.ortho || cam.dof.is_some() || mb_samples(r, ctx, layer) > 1 {
return None;
}
let comp = view_size(r, ctx);
let s = r.opts.scale;
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
let world = outer * ctx.world_matrix(layer);
let (off, w, h) = match r.roi_offset() {
Some((x, y, w, h)) => ((x, y), w as f64, h as f64),
None => ((0.0, 0.0), (comp.0 * s).round(), (comp.1 * s).round()),
};
let proj = Mat4::translate(vec3(-off.0, -off.1, 0.0)) * Mat4::scale(vec3(s, s, 1.0)) * cam.projection(comp.0, comp.1);
let hinv = (proj * world).plane_to_mat3().inverse()?;
let cv = cam.view * world;
let depth = |u: f64, v: f64| cv.0[2][0] * u + cv.0[2][1] * v + cv.0[2][3];
if let Some(b) = crate::content_bounds(ctx, layer)
&& [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].iter().all(|&(u, v)| depth(u, v) < NEAR)
{
return Some(kurbo::Rect::ZERO);
}
const PAD: f64 = 2.0;
let mut rect: Option<kurbo::Rect> = None;
for (sx, sy) in [(-PAD, -PAD), (w + PAD, -PAD), (w + PAD, h + PAD), (-PAD, h + PAD)] {
let m = &hinv.0;
let qz = m[2][0] * sx + m[2][1] * sy + m[2][2];
if qz <= 1e-300 {
return None;
}
let (u, v) = ((m[0][0] * sx + m[0][1] * sy + m[0][2]) / qz, (m[1][0] * sx + m[1][1] * sy + m[1][2]) / qz);
if !u.is_finite() || !v.is_finite() || depth(u, v) < NEAR {
return None;
}
let p = kurbo::Rect::new(u, v, u, v);
rect = Some(rect.map_or(p, |r| r.union(p)));
}
rect
}
/// Number of motion-blur sub-samples for a layer.
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
if r.opts.motion_blur && ctx.comp.enable_motion_blur && r.layer_motion_blur(layer) {
+149
View File
@@ -0,0 +1,149 @@
//! Render Queue benchmark (#3335): a 1080p 32 bpc job on the GPU, the CPU or the measured choice.
//!
//! `cargo run --release -p effectcraft-ui-egui --example rq_bench -- [options]`
//!
//! It builds a comp like a compositing shot: a 1920 × 1080 OpenEXR sequence (float RGBA with a
//! Cryptomatte ID channel and manifest, generated once into `target/rq-bench/media`, no
//! committed media) under Gaussian Blur, Curves and the Cryptomatte effect and a moving
//! translucent solid, at 32 bpc. It queues the comp twice (the second item shows a warm start) and
//! renders the queue as the app does, printing each item's milliseconds per frame and a hash of
//! the written pixels (equal hashes: identical output).
//!
//! Options: `--renderer gpu|cpu` (the project's renderer, default gpu; with gpu the Render
//! Queue measures both unless `EFFECTCRAFT_RQ_RENDERER` says otherwise), `--frames N` (default
//! 60), `--format png|exr|tiff` (default png), `--out DIR` (default `target/rq-bench/out`).
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::raster::channels3d::{crypto_float, crypto_hash};
use serde_json::{Value, json};
const W: usize = 1920;
const H: usize = 1080;
fn arg(name: &str) -> Option<String> {
let a: Vec<String> = std::env::args().collect();
a.iter().position(|x| x == name).and_then(|i| a.get(i + 1).cloned())
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// The plate's object at pixel (x, y) in frame `f`: 24 bands that drift sideways.
fn object(x: usize, y: usize, f: usize) -> String {
format!("obj{}", ((x + f * 7) / 80 + y / 270 * 24) % 96)
}
/// Frame `f` of the plate: float colour over 1.0 (32 bpc), alpha, and Cryptomatte IDs.
fn plate(path: &std::path::Path, f: usize) {
use exr::prelude::*;
let px = |c: usize| -> Vec<f32> {
(0..W * H)
.map(|i| {
let (x, y) = (i % W, i / W);
let v = ((x as f32 * 0.003 + f as f32 * 0.05 + c as f32).sin() * 0.5 + 0.5) * (1.0 + y as f32 / H as f32);
if c == 3 { 1.0 } else { v }
})
.collect()
};
let ids: Vec<f32> = (0..W * H).map(|i| crypto_float(crypto_hash(&object(i % W, i / W, f)))).collect();
let ch = |n: &str, v: Vec<f32>| AnyChannel::new(n, FlatSamples::F32(v));
let channels = AnyChannels::sort(
vec![ch("R", px(0)), ch("G", px(1)), ch("B", px(2)), ch("A", px(3)), ch("CryptoObject00.R", ids), ch("CryptoObject00.G", vec![1.0; W * H])].into(),
);
let mut layer = Layer::new((W, H), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels);
let names: serde_json::Map<String, Value> = (0..96).map(|k| format!("obj{k}")).map(|n| (n.clone(), json!(format!("{:08x}", crypto_hash(&n))))).collect();
for (key, v) in [("name", "CryptoObject".to_string()), ("manifest", Value::Object(names).to_string())] {
layer.attributes.other.insert(Text::from(format!("cryptomatte/f834d0a/{key}").as_str()), AttributeValue::Text(Text::from(v.as_str())));
}
Image::from_layer(layer).write().to_file(path).expect("write plate");
}
/// FNV-1a over every written frame file, in name order (equal: identical output).
fn hash_dir(dir: &std::path::Path) -> (usize, u64) {
let mut names: Vec<_> = std::fs::read_dir(dir).map(|d| d.filter_map(|e| e.ok()).map(|e| e.path()).collect()).unwrap_or_default();
names.retain(|p| !p.to_string_lossy().ends_with(".txt"));
names.sort();
let mut h = 0xcbf2_9ce4_8422_2325u64;
for p in &names {
for b in std::fs::read(p).unwrap_or_default() {
h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
}
(names.len(), h)
}
fn main() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/rq-bench");
let frames: usize = arg("--frames").and_then(|n| n.parse().ok()).unwrap_or(60);
let format = arg("--format").unwrap_or_else(|| "png".into());
let gpu = arg("--renderer").as_deref() != Some("cpu");
let out = arg("--out").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("out"));
let media = root.join("media");
std::fs::create_dir_all(&media).expect("media folder");
let first = media.join("plate_0000.exr");
for f in 0..frames {
let p = media.join(format!("plate_{f:04}.exr"));
if !p.exists() {
if f == 0 {
eprintln!("writing the {frames}-frame EXR plate (once)…");
}
plate(&p, f);
}
}
let mut s = effectcraft_host::session();
let accel = effectcraft_gpu::Gpu::try_headless().unwrap_or_else(|e| panic!("no GPU: {e}"));
eprintln!("GPU: {}", effectcraft_engine::render::Accelerator::name(&accel));
s.accel = Some(Arc::new(accel));
let r = run(&mut s, "file.import", json!({"paths": [first.to_string_lossy()], "sequence": true}));
let footage = r["items"][0].as_u64().expect("the plate");
let secs = frames as f64 / 30.0;
run(&mut s, "comp.new", json!({"name": "Shot", "width": W, "height": H, "frameRate": 30, "duration": secs}));
let plate = run(&mut s, "layer.addItem", json!({"item": footage}))["layer"].clone();
run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Gaussian Blur"}));
run(&mut s, "prop.set", json!({"layer": plate, "path": "effects/#1/blurriness", "value": 12.0}));
run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Curves"}));
run(&mut s, "effect.apply", json!({"layers": [plate], "effect": "Cryptomatte"}));
let solid = run(&mut s, "layer.newSolid", json!({"color": "#d04020", "width": 400, "height": 300}))["layer"].clone();
run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": 0.0, "value": [200.0, 540.0]}));
run(&mut s, "prop.addKey", json!({"layer": solid, "path": "transform/position", "time": secs, "value": [1720.0, 540.0]}));
run(&mut s, "prop.set", json!({"layer": solid, "path": "transform/opacity", "value": 70.0}));
let mut p = (*s.project).clone();
p.settings.bit_depth = effectcraft_engine::project::BitDepth::Bpc32;
p.settings.gpu_acceleration = gpu;
s.project = Arc::new(p);
let _ = std::fs::remove_dir_all(&out);
let dirs = [out.join("a"), out.join("b")];
for d in &dirs {
std::fs::create_dir_all(d).expect("output folder");
let path = d.join(format!("f_[####].{format}"));
run(&mut s, "renderQueue.add", json!({"comp": "Shot", "format": format, "output": path.to_string_lossy(), "log": "plusSettings"}));
}
let t0 = std::time::Instant::now();
run(&mut s, "renderQueue.render", json!({"wait": true}));
let total = t0.elapsed().as_secs_f64();
println!(
"renderer {} (EFFECTCRAFT_RQ_RENDERER={:?}, EFFECTCRAFT_GPU_INFLIGHT={:?}), {frames} frames {W}x{H} 32 bpc, {format}",
if gpu { "gpu" } else { "cpu" },
std::env::var("EFFECTCRAFT_RQ_RENDERER").ok(),
std::env::var("EFFECTCRAFT_GPU_INFLIGHT").ok()
);
for (i, (item, d)) in s.project.render_queue.iter().zip(&dirs).enumerate() {
let secs = item.render_time.unwrap_or(f64::NAN);
let (n, h) = hash_dir(d);
println!(" item {}: {:?}, {:.1} ms/frame, {n} files, hash {h:016x}", i + 1, item.status, secs * 1000.0 / frames as f64);
// The render log's account of the compositor and the GPU frames in flight.
let logs =
std::fs::read_dir(d).map(|r| r.filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.to_string_lossy().ends_with(".txt")).collect::<Vec<_>>());
for log in logs.unwrap_or_default() {
for line in std::fs::read_to_string(log).unwrap_or_default().lines().filter(|l| l.starts_with("Renderer:") || l.starts_with("GPU frames")) {
println!(" {line}");
}
}
}
println!(" queue: {total:.2} s");
}
+248 -19
View File
@@ -33,7 +33,7 @@
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Condvar, Mutex, PoisonError};
use effectcraft_engine::project::{Comp, Heard, ItemId, ItemKind, Node, Project, PropGroup};
use effectcraft_engine::render::disk_cache::{self, DiskCache};
@@ -54,6 +54,92 @@ const GPU_BUDGET: usize = 1 << 30;
const MIN_GPU_BUDGET: usize = 64 << 20;
const GPU_PRESSURE_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(5);
const GPU_RECOVERY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
/// GPU frames that render at once, however many frame threads there are. Each one holds its own
/// working textures, so 16 at once ran an RTX 3090 out of video memory (#3028).
const MAX_GPU_RENDERS: usize = 2;
/// Counts the GPU renders in flight; the rest of the frame threads wait their turn.
struct GpuRenderSlots {
#[cfg(test)]
entered: std::sync::atomic::AtomicUsize,
busy: Mutex<usize>,
freed: Condvar,
}
/// One GPU render slot, given back on drop.
struct GpuRenderSlot<'a>(&'a GpuRenderSlots);
static GPU_RENDER_SLOTS: GpuRenderSlots = GpuRenderSlots::new();
impl GpuRenderSlots {
const fn new() -> Self {
Self {
#[cfg(test)]
entered: std::sync::atomic::AtomicUsize::new(0),
busy: Mutex::new(0),
freed: Condvar::new(),
}
}
/// The UI-thread display path must hand a frame to the remote worker instead of waiting
/// behind preview lanes. Never block the window to enforce the native memory cap.
fn try_acquire(&self, max: usize) -> Option<GpuRenderSlot<'_>> {
let mut busy = match self.busy.try_lock() {
Ok(busy) => busy,
Err(std::sync::TryLockError::Poisoned(error)) => error.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => return None,
};
if !cfg!(target_arch = "wasm32") && *busy >= max {
return None;
}
*busy += 1;
#[cfg(test)]
self.entered.fetch_add(1, Ordering::SeqCst);
Some(GpuRenderSlot(self))
}
/// A Rayon helper may be executing a nested frame while an admitted render waits for
/// its work. Blocking that helper on a permit would deadlock the render (#3028).
fn for_render(&self, max: usize) -> Option<GpuRenderSlot<'_>> {
// Dedicated preview pools have one thread, protected from nested drains. They cannot
// be helpers of another admitted frame, so waiting preserves GPU preview throughput.
#[cfg(not(target_arch = "wasm32"))]
if DRAINING_LANE.get() {
return Some(self.acquire(max));
}
if rayon::current_thread_index().is_some() { self.try_acquire(max) } else { Some(self.acquire(max)) }
}
/// Wait until fewer than `max` renders are in flight, then take a slot. The browser has no
/// threads to wait on: it never blocks.
fn acquire(&self, max: usize) -> GpuRenderSlot<'_> {
let mut busy = self.busy.lock().unwrap_or_else(PoisonError::into_inner);
while !cfg!(target_arch = "wasm32") && *busy >= max {
busy = self.freed.wait(busy).unwrap_or_else(PoisonError::into_inner);
}
*busy += 1;
#[cfg(test)]
self.entered.fetch_add(1, Ordering::SeqCst);
GpuRenderSlot(self)
}
}
impl Drop for GpuRenderSlot<'_> {
fn drop(&mut self) {
let mut busy = self.0.busy.lock().unwrap_or_else(PoisonError::into_inner);
*busy = busy.saturating_sub(1);
self.0.freed.notify_one();
}
}
/// The window's frame gaps that adapt [`Queue::gpu_limit`] while a preview plays: a longer one
/// is a stall, shorter ones for a while are smooth.
const UI_STALL: std::time::Duration = std::time::Duration::from_millis(100);
const UI_SMOOTH: std::time::Duration = std::time::Duration::from_millis(50);
/// How long the window must stay smooth before one more lane composites on the GPU, and before it
/// tries again a limit that stalled.
const GPU_LANE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
const GPU_LANE_RETRY: std::time::Duration = std::time::Duration::from_secs(10);
mod f16;
pub use f16::{F16_PIXEL_BYTES, F16Frame};
@@ -639,7 +725,7 @@ impl Default for Frames {
})
.collect();
#[cfg(not(target_arch = "wasm32"))]
let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), ..Default::default() };
let queue = Queue { idle_lanes: (0..lanes.len()).collect(), lanes: lanes.len(), gpu_limit: 1, ..Default::default() };
#[cfg(target_arch = "wasm32")]
let queue = Queue::default();
Frames {
@@ -1282,6 +1368,8 @@ impl Frames {
#[cfg(not(target_arch = "wasm32"))]
let moved = q.playhead.is_some() != playhead.is_some();
q.playhead = playhead;
// (called once per frame of the window while a preview plays)
let more_gpu = q.ui_frame(playhead.map(|_| web_time::Instant::now()));
if let Some(p) = playhead
&& let Ok(mut inf) = self.inflight.lock()
{
@@ -1293,6 +1381,13 @@ impl Frames {
!gone
});
}
// A lane more may composite: one that stopped at the limit starts again.
#[cfg(not(target_arch = "wasm32"))]
if more_gpu && !moved {
self.start_lanes(&mut q, 1);
}
#[cfg(target_arch = "wasm32")]
let _ = more_gpu;
#[cfg(not(target_arch = "wasm32"))]
if moved {
self.start_lanes(&mut q, usize::MAX);
@@ -1597,19 +1692,44 @@ struct Worker {
thread_local! {
/// This pool thread is inside [`Worker::drain`] (rendering a frame).
static DRAINING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Only the single thread of a dedicated preview lane may wait for GPU admission.
static DRAINING_LANE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Clears [`DRAINING`] when the drain ends, even by a panic.
#[cfg(not(target_arch = "wasm32"))]
struct Draining;
#[cfg(not(target_arch = "wasm32"))]
impl Draining {
fn enter(lane: Option<usize>) -> Option<Self> {
if DRAINING.replace(true) {
return None;
}
DRAINING_LANE.set(lane.is_some());
Some(Self)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for Draining {
fn drop(&mut self) {
DRAINING_LANE.set(false);
DRAINING.set(false);
}
}
/// A lane's hold on one of the [`Queue::gpu_limit`] GPU slots (see [`Queue::gpu_lanes`]), given back when dropped,
/// even by a panic.
struct GpuSlot<'a>(&'a Mutex<Queue>);
impl Drop for GpuSlot<'_> {
fn drop(&mut self) {
let mut q = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
q.gpu_lanes = q.gpu_lanes.saturating_sub(1);
}
}
impl Worker {
/// Render queued jobs, most important first, until the queue is empty.
///
@@ -1623,11 +1743,7 @@ impl Worker {
/// `lane`: the lane this runs on (see [`Queue::idle_lanes`]), `None` on the pool.
#[cfg(not(target_arch = "wasm32"))]
fn drain(&self, lane: Option<usize>) {
if DRAINING.get() {
return;
}
DRAINING.set(true);
let _reset = Draining;
let Some(_reset) = Draining::enter(lane) else { return };
while self.run_next(lane) {}
}
@@ -1636,19 +1752,25 @@ impl Worker {
/// idle again.
fn run_next(&self, lane: Option<usize>) -> bool {
let Worker { queue, content_keys, .. } = self;
let job = {
// (asked before the queue's lock: it takes the cache's)
let gpu = lane.is_some() && self.gpu_ready();
let (job, mut slot) = {
let Ok(mut q) = queue.lock() else { return false };
let Some(i) = q.next(lane.is_some()) else {
let Some(i) = q.next(lane.is_some(), gpu) else {
q.idle_lanes.extend(lane);
return false;
};
let job = q.jobs.swap_remove(i);
let slot = (gpu && q.gpu_job(&job)).then(|| {
q.gpu_lanes += 1;
GpuSlot(queue)
});
q.running.insert(job.key);
if job.urgent {
q.urgent_running += 1;
q.urgent_cancels.push((job.key, job.cancel.clone()));
}
job
(job, slot)
};
let t0 = web_time::Instant::now();
// (the drop generation before the frame reads anything: File ▸ Reload Footage meanwhile
@@ -1657,6 +1779,10 @@ impl Worker {
job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts)), dc.drop_generation()));
let from_disk = disk.as_ref().and_then(|(dc, k, _)| self.disk_frame(dc, *k, &job));
let disk_hit = from_disk.is_some();
if disk_hit {
// Nothing for the GPU to do: another lane may composite meanwhile.
slot = None;
}
// `files`: the source files the frame was made of (see `render::reads`).
let (ci, files) = match from_disk {
Some(hit) => hit,
@@ -1683,6 +1809,8 @@ impl Worker {
// and compression. Persistence still runs on this worker, using the same snapshot.
let since = disk.as_ref().map(|(dc, _, since)| DiskSince::of(dc, *since));
self.finish(&job, ci.clone(), Some(files.clone()), since, t0);
// The GPU's part is done; storing it on disk is the CPU's.
drop(slot);
if !disk_hit
&& let Some((dc, k, since)) = &disk
&& put_disk_frame(dc, *k, &ci, &files, self.virtual_id.as_deref(), *since)
@@ -1812,7 +1940,10 @@ impl Worker {
r.reads = Some(&files);
r.accel = Some(g as &dyn effectcraft_engine::render::Accelerator);
r.active_accel()?;
let f = g.within_memory(|| g.render_display(&r, job.comp, job.t));
let f = {
let _slot = GPU_RENDER_SLOTS.try_acquire(MAX_GPU_RENDERS)?;
g.within_memory(|| g.render_display(&r, job.comp, job.t))
};
effectcraft_engine::render::Accelerator::frame_end(g);
match f {
Ok(f) => {
@@ -1853,7 +1984,7 @@ impl Worker {
let auto = if cfg!(target_arch = "wasm32") { None } else { r.frame_auto(a, job.comp, true) };
if auto.is_none_or(|(p, key)| p.choose(key)) {
let t0 = web_time::Instant::now();
match g.within_memory(|| g.render_display(&r, job.comp, job.t)) {
match gpu_attempt(g, || g.render_display(&r, job.comp, job.t)) {
Ok(Some(f)) => {
self.gpu_succeeded();
if let Some((p, key)) = auto {
@@ -1872,13 +2003,21 @@ impl Worker {
}
}
Err(e) => {
self.gpu_attempt_failed(g, &e);
self.gpu_attempt_rejected(g, &e);
return self.render_job(job, false, files);
}
}
}
let t0 = web_time::Instant::now();
let img = cpu_frame(&r.comp_frame_cpu(job.comp, job.t), &job.src.project, &job.opts);
// CPU compositing in Auto can still run GPU layer effects. Keep those inside the
// same admission and error scope as a GPU-composited frame (#3028).
let img = match gpu_attempt(g, || r.comp_frame_cpu(job.comp, job.t)) {
Ok(img) => cpu_frame(&img, &job.src.project, &job.opts),
Err(e) => {
self.gpu_attempt_rejected(g, &e);
return self.render_job(job, false, files);
}
};
if let Some((p, key)) = auto {
p.record(key, false, t0.elapsed().as_secs_f64() * 1000.0);
}
@@ -1886,19 +2025,37 @@ impl Worker {
}
if let Some(g) = gpu {
// The GPU composites and the frame is read back.
return match g.within_memory(|| r.comp_frame(job.comp, job.t)) {
return match r.comp_frame_scoped(job.comp, job.t, |a| gpu_attempt(g, || a.comp_frame(&r, job.comp, job.t))) {
Ok(img) => {
self.gpu_succeeded();
cpu_frame(&img, &job.src.project, &job.opts)
}
Err(e) => {
self.gpu_attempt_failed(g, &e);
self.gpu_attempt_rejected(g, &e);
self.render_job(job, false, files)
}
};
}
cpu_frame(&r.comp_frame(job.comp, job.t), &job.src.project, &job.opts)
}
/// Busy shared-pool workers retry on the CPU without treating contention as a GPU fault.
fn gpu_attempt_rejected(&self, gpu: &Gpu, error: &GpuAttemptError) {
if let GpuAttemptError::Failed(error) = error {
self.gpu_attempt_failed(gpu, error);
}
}
}
enum GpuAttemptError {
Busy,
Failed(FrameError),
}
/// Bound all preview GPU attempts, including effects in an Auto CPU composite. The permit
/// ends before the caller retries on the CPU, so failed frames cannot block GPU admissions.
fn gpu_attempt<T>(gpu: &Gpu, render: impl FnOnce() -> T) -> Result<T, GpuAttemptError> {
let _slot = GPU_RENDER_SLOTS.for_render(MAX_GPU_RENDERS).ok_or(GpuAttemptError::Busy)?;
gpu.within_memory(render).map_err(GpuAttemptError::Failed)
}
/// Who asked for a frame (see [`Frames::request_with`]).
@@ -1951,6 +2108,24 @@ struct Queue {
/// caching left the same gaps, and held up the viewer's frame when it shared a piece.
lanes: usize,
idle_lanes: Vec<usize>,
/// Lanes compositing a frame on the GPU, at most [`Queue::gpu_limit`]. The GPU is the
/// viewer's device: each lane records, submits and reads back a whole frame on it, and the
/// window can't draw until that work is done. With a lane per core on a GPU that can't keep
/// up, the window froze for hundreds of milliseconds at a time while a preview played, and
/// the preview jumped ahead when it came back (#3345). The other lanes go on with the frames
/// that don't need the GPU (the disk cache's, or all of them while the GPU is out of memory
/// or retired); the pool and the viewer's frame are not counted.
gpu_lanes: usize,
/// How many lanes may composite on the GPU at a time (at least 1, see [`Queue::ui_frame`]).
gpu_limit: usize,
/// The limit that last stalled the window: tried again only after [`GPU_LANE_RETRY`].
gpu_stalled_at: usize,
/// While a preview plays: the window's last frame, and since when it has been smooth.
ui_last: Option<web_time::Instant>,
ui_smooth_since: Option<web_time::Instant>,
/// Tests: as if there were a GPU compositor (no adapter is there).
#[cfg(test)]
fake_gpu: bool,
}
impl Queue {
@@ -1959,9 +2134,63 @@ impl Queue {
self.lanes > 0 && !j.urgent && (j.background || self.playhead.is_some())
}
/// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own.
fn next(&self, lane: bool) -> Option<usize> {
self.best_of(|j| self.on_lane(j) == lane)
/// The job a lane (`lane`) or the pool renders next: the [`Queue::best`] of its own. `gpu`:
/// the GPU composites (a lane's frames), so a lane takes no GPU frame while
/// [`Queue::gpu_limit`] lanes are compositing.
fn next(&self, lane: bool, gpu: bool) -> Option<usize> {
let gpu_full = gpu && self.gpu_lanes >= self.gpu_limit.max(1);
self.best_of(|j| self.on_lane(j) == lane && !(gpu_full && self.gpu_job(j)))
}
/// The window drew a frame at `now` (`None`: no preview plays). A window that stalls with
/// lanes compositing gets fewer of them: the GPU can't keep up with them and the window. One
/// that stays smooth while every allowed lane composites gets one more, up to all of them: a
/// GPU that keeps up renders the preview on every lane. A limit that stalled is tried again
/// only after a longer smooth spell, so the window doesn't stall over and over. `true` when
/// one more lane may composite.
fn ui_frame(&mut self, now: Option<web_time::Instant>) -> bool {
let Some(now) = now else {
self.ui_last = None;
return false;
};
let limit = self.gpu_limit.max(1);
let gap = self.ui_last.map(|last| now.saturating_duration_since(last));
self.ui_last = Some(now);
let Some(gap) = gap else {
self.ui_smooth_since = Some(now);
return false;
};
if gap > UI_STALL && self.gpu_lanes > 0 {
self.gpu_stalled_at = limit;
self.gpu_limit = (limit / 2).max(1);
self.ui_smooth_since = Some(now);
} else if gap > UI_SMOOTH {
self.ui_smooth_since = Some(now);
} else {
let since = *self.ui_smooth_since.get_or_insert(now);
let wait = if limit + 1 >= self.gpu_stalled_at && self.gpu_stalled_at > 0 { GPU_LANE_RETRY } else { GPU_LANE_STEP };
if self.gpu_lanes >= limit && limit < self.lanes && now.saturating_duration_since(since) >= wait {
self.gpu_limit = limit + 1;
self.ui_smooth_since = Some(now);
return true;
}
}
false
}
/// Whether `j` is composited on the GPU when the GPU is ready: there is a GPU compositor and
/// the frame's renderer uses it (as `Renderer::active_accel` decides: Software Only keeps
/// every frame on the CPU).
fn gpu_job(&self, j: &Job) -> bool {
let adapter = j.src.gpu.is_some();
#[cfg(test)]
let adapter = adapter || self.fake_gpu;
adapter
&& match j.opts.backend {
effectcraft_engine::render::Backend::Cpu => false,
effectcraft_engine::render::Backend::Gpu => true,
effectcraft_engine::render::Backend::Auto => j.src.project.settings.gpu_acceleration,
}
}
/// The job that matters most: urgent first (newest), then, while a preview plays, the
+409
View File
@@ -610,15 +610,21 @@ struct ParallelClip {
started: std::sync::atomic::AtomicUsize,
/// Frames started by the time frame 1 was done.
started_at_first: std::sync::atomic::AtomicUsize,
/// Frames decoding now, and the most at a time.
active: std::sync::atomic::AtomicUsize,
most_active: std::sync::atomic::AtomicUsize,
}
impl FootageSource for ParallelClip {
fn frame(&self, _: ItemId, _: &effectcraft_engine::project::Footage, t: Tick) -> Option<Arc<effectcraft_engine::render::Image>> {
self.started.fetch_add(1, Ordering::SeqCst);
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.most_active.fetch_max(active, Ordering::SeqCst);
(0..16).into_par_iter().for_each(|_| (0..4).into_par_iter().for_each(|_| std::thread::sleep(std::time::Duration::from_millis(1))));
if effectcraft_engine::time::FrameRate::FPS_30.frame_at(t) == 1 {
self.started_at_first.store(self.started.load(Ordering::SeqCst), Ordering::SeqCst);
}
self.active.fetch_sub(1, Ordering::SeqCst);
Some(Arc::new(effectcraft_engine::render::Image::new(4, 4)))
}
}
@@ -729,6 +735,166 @@ fn queued_frames_render_when_a_preview_starts_or_stops() {
assert_eq!(f.cached_frames(&series).len(), 119);
}
/// Waits for the lanes to be idle (after their last frame they give back their GPU slot) and
/// checks every GPU slot was given back.
fn wait_for_idle_lanes(f: &Frames) {
for _ in 0..2000 {
let q = f.queue.lock().unwrap();
if q.idle_lanes.len() == f.lanes.len() {
break;
}
drop(q);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let q = f.queue.lock().unwrap();
assert_eq!(q.idle_lanes.len(), f.lanes.len(), "every lane is idle again");
assert_eq!(q.gpu_lanes, 0, "every lane gave its GPU slot back");
}
/// Plays a preview of `frames` frames of a [`parallel_clip`] as the viewer renders them (the
/// project's renderer: `backend` Auto) or with `backend`, as if there were a GPU compositor
/// (`gpu`), `retired` or with the project on Mercury Software Only (`!gpu_acceleration`), and
/// waits for them: the frame cache and the most frames that rendered at a time.
fn play_preview(gpu: bool, retired: bool, backend: effectcraft_engine::render::Backend, gpu_acceleration: bool) -> (Frames, usize) {
use effectcraft_engine::time::FrameRate;
let (f, footage, mut src, cid, series) = parallel_clip();
Arc::make_mut(&mut src.project).settings.gpu_acceleration = gpu_acceleration;
f.queue.lock().unwrap().fake_gpu = gpu;
if retired {
f.retire_gpu();
}
let opts = RenderOpts { backend, ..RenderOpts::default() };
f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false }));
let frames = 24;
for frame in 1..=frames {
f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts);
}
wait_for_renders(&f);
assert_eq!(f.cached_frames(&series).len(), frames as usize);
wait_for_idle_lanes(&f);
let most = footage.most_active.load(Ordering::SeqCst);
(f, most)
}
/// Every lane composited a playing preview's frame on the GPU at the same time, on the device
/// the window draws with: the window froze for hundreds of milliseconds and the preview jumped
/// ahead (#3345). A preview starts on one GPU lane (see [`Queue::ui_frame`]).
#[test]
fn a_preview_composites_on_a_few_lanes_at_a_time() {
use effectcraft_engine::render::Backend;
for backend in [Backend::Auto, Backend::Gpu] {
let (_, most) = play_preview(true, false, backend, true);
assert!(most <= 1, "{most} lanes composited on the GPU at once ({backend:?})");
}
}
/// Frames that render on the CPU use every lane: with no GPU compositor, with the GPU retired,
/// and with Mercury Software Only (the project's renderer, or the CPU backend).
#[test]
fn frames_on_the_cpu_render_on_every_lane() {
use effectcraft_engine::render::Backend;
for (gpu, retired, backend, gpu_acceleration) in
[(false, false, Backend::Auto, true), (true, true, Backend::Auto, true), (true, false, Backend::Auto, false), (true, false, Backend::Cpu, true)]
{
let (f, most) = play_preview(gpu, retired, backend, gpu_acceleration);
if f.lanes.len() > 2 {
assert!(most > 2, "frames on the CPU rendered on {most} lanes at a time ({gpu} {retired} {backend:?} {gpu_acceleration})");
}
}
}
/// A queue of `lanes` lanes, `busy` of them compositing on the GPU.
fn gpu_queue(lanes: usize, busy: usize) -> Queue {
Queue { lanes, gpu_lanes: busy, gpu_limit: 1, ..Default::default() }
}
/// Window frames every `gap` ms from `t` for `ms` ms; the time after the last.
fn ui_frames(q: &mut Queue, t: web_time::Instant, gap: u64, ms: u64) -> web_time::Instant {
let mut t = t;
for _ in 0..ms / gap {
t += std::time::Duration::from_millis(gap);
q.ui_frame(Some(t));
}
t
}
/// A GPU that keeps up with the window gets every lane, one more each half second the window
/// stays smooth with every allowed lane compositing.
#[test]
fn a_smooth_window_gives_the_gpu_every_lane() {
let mut q = gpu_queue(8, 0);
let t = web_time::Instant::now();
q.ui_frame(Some(t));
// Nothing compositing: the limit isn't what holds the preview back.
let t = ui_frames(&mut q, t, 16, 2000);
assert_eq!(q.gpu_limit, 1);
let mut t = t;
for _ in 0..20 {
q.gpu_lanes = q.gpu_limit;
t = ui_frames(&mut q, t, 16, 520);
}
assert_eq!(q.gpu_limit, 8, "no more than the lanes");
}
/// A window that stalls while lanes composite halves the limit, and the limit that stalled is
/// tried again only after a long smooth spell; a window that is only slow keeps the limit.
#[test]
fn a_stalling_window_takes_lanes_from_the_gpu() {
let mut q = gpu_queue(8, 4);
q.gpu_limit = 4;
let t = web_time::Instant::now();
q.ui_frame(Some(t));
let t = ui_frames(&mut q, t, 300, 300);
assert_eq!((q.gpu_limit, q.gpu_stalled_at), (2, 4));
let t = ui_frames(&mut q, t, 80, 5000);
assert_eq!(q.gpu_limit, 2, "a slow (not stalling) window keeps the limit");
q.gpu_lanes = 2;
let t = ui_frames(&mut q, t, 16, 600);
assert_eq!(q.gpu_limit, 3);
q.gpu_lanes = 3;
let t = ui_frames(&mut q, t, 16, 2000);
assert_eq!(q.gpu_limit, 3, "4 stalled: it waits longer");
ui_frames(&mut q, t, 16, 10_000);
assert_eq!(q.gpu_limit, 4);
// A stall with nothing compositing isn't the GPU's.
let mut q = gpu_queue(8, 0);
q.gpu_limit = 4;
let t = web_time::Instant::now();
q.ui_frame(Some(t));
ui_frames(&mut q, t, 500, 500);
assert_eq!(q.gpu_limit, 4);
// Never below one lane; a stopped preview's pause is no stall.
let mut q = gpu_queue(8, 1);
let t = web_time::Instant::now();
q.ui_frame(Some(t));
ui_frames(&mut q, t, 400, 400);
assert_eq!(q.gpu_limit, 1);
q.gpu_limit = 2;
q.ui_frame(None);
q.ui_frame(Some(t + std::time::Duration::from_secs(5)));
assert_eq!(q.gpu_limit, 2);
}
/// A preview that stops while lanes composite: their frames finish, the queued ones move to the
/// pool and render, the viewer's frame is not held up, and every GPU slot is given back.
#[test]
fn a_preview_stopping_mid_render_gives_the_gpu_slots_back() {
use effectcraft_engine::time::FrameRate;
let (f, _, src, cid, series) = parallel_clip();
f.queue.lock().unwrap().fake_gpu = true;
let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Auto, ..RenderOpts::default() };
f.set_playhead(Some(Playhead { series, cur: 0, start: 0, end: 299, step: 1, looping: false }));
for frame in 1..=24 {
f.request(&src, FrameKey { frame, ..series }, cid, FrameRate::FPS_30.tick_of(frame), opts);
}
std::thread::sleep(std::time::Duration::from_millis(20));
f.set_playhead(None);
f.request_urgent(&src, FrameKey { frame: 100, ..series }, cid, FrameRate::FPS_30.tick_of(100), opts);
wait_for_renders(&f);
assert_eq!(f.cached_frames(&series).len(), 25);
wait_for_idle_lanes(&f);
}
/// The cache is full. A zoom (or preview-resolution) change left the frames of the old
/// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back
/// at went before the ones of the abandoned zoom. The other size's frames go first, then the
@@ -2957,3 +3123,246 @@ mod frame_files {
assert_eq!(healed.hits(), (1, 0), "the rewritten entry is used");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gpu_renders_in_flight_stay_within_the_cap() {
use std::sync::atomic::AtomicUsize;
let slots = GpuRenderSlots::new();
let now = AtomicUsize::new(0);
let peak = AtomicUsize::new(0);
std::thread::scope(|s| {
for _ in 0..16 {
s.spawn(|| {
let _slot = slots.acquire(2);
let n = now.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(n, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(5));
now.fetch_sub(1, Ordering::SeqCst);
});
}
});
assert!(peak.load(Ordering::SeqCst) <= 2);
assert_eq!(*slots.busy.lock().unwrap_or_else(PoisonError::into_inner), 0);
}
/// #3028: exercise the actual worker routes, not only the admission counter. The Auto CPU
/// compositor still has a live accelerator for its layer effects.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gpu_worker_routes_admit_display_readback_and_auto_effects() {
use effectcraft_engine::render::{Accelerator, Backend};
let Some(gpu) = Gpu::headless() else {
eprintln!("GPU worker route test skipped: no adapter");
return;
};
let (frames, mut src, key, _, _, _) = disk_fixture(&[]);
let mut session = effectcraft_engine::Session::default();
session.execute("comp.new", serde_json::json!({"width": 16, "height": 16, "duration": 1})).unwrap();
let comp = session.active_comp_id().unwrap();
let solid = session.execute("layer.newSolid", serde_json::json!({"color": "#ff0000"})).unwrap()["layer"].clone();
session.execute("effect.apply", serde_json::json!({"layers": [solid], "effect": "Gaussian Blur"})).unwrap();
src.project = session.project;
src.gpu = Some(gpu.clone());
src.gpu_display = true;
let mut job = Job {
key,
src,
comp,
t: Tick::ZERO,
opts: RenderOpts { backend: Backend::Gpu, ..Default::default() },
urgent: true,
background: false,
seq: 0,
cancel: Arc::default(),
};
let worker = frames.worker();
let admissions = || GPU_RENDER_SLOTS.entered.load(Ordering::SeqCst);
// This path is called on the UI thread by dispatch_remote: a full GPU must decline
// immediately so the frame can go to the remote worker and the window keeps painting.
let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let (tx, rx) = std::sync::mpsc::channel();
let declined = std::thread::scope(|scope| {
scope.spawn(|| tx.send(worker.render_gpu(&job).is_none()).unwrap());
let result = rx.recv_timeout(std::time::Duration::from_secs(2));
drop((first, second));
result
});
assert_eq!(declined, Ok(true), "UI display must not wait for GPU admission");
let before = admissions();
assert!(worker.render_gpu(&job).is_some(), "direct display path renders");
assert!(admissions() > before, "direct display bypassed GPU admission");
let before = admissions();
assert!(worker.render_job(&job, true, &ReadLog::new()).is_gpu());
assert!(admissions() > before, "worker display bypassed GPU admission");
job.src.gpu_display = false;
job.src.layer_cache.clear();
let before = admissions();
assert!(!worker.render_job(&job, true, &ReadLog::new()).is_gpu());
assert!(admissions() > before, "readback bypassed GPU admission");
job.src.gpu_display = true;
job.src.layer_cache.clear();
job.opts.backend = Backend::Auto;
let renderer = Renderer::new(&job.src.project, job.src.footage.as_ref(), job.opts);
let (pick, key) = renderer.frame_auto(&gpu, comp, true).unwrap();
pick.declined(key); // Select CPU compositing while retaining GPU-capable layer effects.
let before = admissions();
assert!(!worker.render_job(&job, true, &ReadLog::new()).is_gpu());
assert!(admissions() > before, "Auto CPU effects bypassed GPU admission");
let before = admissions();
assert!(!worker.render_job(&job, false, &ReadLog::new()).is_gpu());
assert_eq!(admissions(), before, "CPU retry must not hold a GPU slot");
assert_eq!(*GPU_RENDER_SLOTS.busy.lock().unwrap(), 0);
// Saturation in the shared Rayon pool is contention, not a GPU fault. It must finish
// through the fresh CPU route without admitting GPU effects or retiring the device.
let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let before = admissions();
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let cpu = std::thread::scope(|scope| {
scope.spawn(|| pool.install(|| tx.send(!worker.render_job(&job, true, &ReadLog::new()).is_gpu()).unwrap()));
let result = rx.recv_timeout(std::time::Duration::from_secs(2));
drop((first, second));
result
});
assert_eq!(cpu, Ok(true), "busy pool worker must complete on the CPU");
assert_eq!(admissions(), before);
assert!(worker.gpu_ready());
assert!(gpu.context().check_health().is_ok());
// The real dedicated-lane drain waits, then caches a GPU frame instead of silently
// switching a healthy preview to CPU rendering while the other lanes are busy.
frames.queue.lock().unwrap().jobs.push(Job {
key: job.key,
src: job.src.clone(),
comp,
t: job.t,
opts: RenderOpts { backend: Backend::Gpu, ..job.opts },
urgent: false,
background: true,
seq: 1,
cancel: Arc::default(),
});
let first = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let second = GPU_RENDER_SLOTS.acquire(MAX_GPU_RENDERS);
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let (started, ready) = std::sync::mpsc::channel();
let (tx, rx) = std::sync::mpsc::channel();
let (early, finished) = std::thread::scope(|scope| {
scope.spawn(|| {
pool.install(|| {
started.send(()).unwrap();
worker.drain(Some(0));
tx.send(()).unwrap();
})
});
ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
let early = rx.recv_timeout(std::time::Duration::from_millis(100));
drop((first, second));
let finished = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2)));
(early, finished)
});
assert!(early.is_err(), "preview lane must wait for an available GPU permit");
assert_eq!(finished, Ok(()));
assert!(frames.cache.lock().unwrap().map.get(&job.key).unwrap().img.is_gpu());
// A retirement found inside the scope releases the permit before the fresh CPU retry.
gpu.context().device_failure_notifier()("synthetic worker retirement".into());
let before = admissions();
assert!(!worker.render_job(&job, true, &ReadLog::new()).is_gpu());
assert_eq!(admissions(), before + 1, "failed GPU attempt must retry without a second admission");
assert_eq!(*GPU_RENDER_SLOTS.busy.lock().unwrap(), 0);
gpu.frame_end();
}
/// Early returns and unwinding release admission even before another render starts.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gpu_render_slots_return_after_failure_and_unwind() {
let slots = GpuRenderSlots::new();
let failed = || -> Result<(), ()> {
let _slot = slots.acquire(2);
Err(())
};
assert!(failed().is_err());
assert_eq!(*slots.busy.lock().unwrap(), 0);
let result = std::panic::catch_unwind(|| {
let _slot = slots.acquire(2);
panic!("synthetic render panic");
});
assert!(result.is_err());
assert_eq!(*slots.busy.lock().unwrap(), 0);
}
/// A Rayon helper must decline a full gate rather than wait for a render that can itself be
/// waiting for that helper. Release the permits on timeout so a regression fails, not hangs.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gpu_render_slots_never_block_rayon_helpers() {
let slots = GpuRenderSlots::new();
let first = slots.acquire(2);
let second = slots.acquire(2);
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let declined = std::thread::scope(|scope| {
scope.spawn(|| {
pool.install(|| {
rayon::join(|| {}, || tx.send(slots.for_render(2).is_none()).unwrap());
});
});
let result = rx.recv_timeout(std::time::Duration::from_secs(2));
drop((first, second));
result
});
assert_eq!(declined, Ok(true), "Rayon helpers must not wait on GPU permits");
assert_eq!(*slots.busy.lock().unwrap(), 0);
}
/// Dedicated preview lanes have one protected thread and can wait without creating the
/// shared-pool helper dependency cycle. They must keep rendering on a healthy GPU.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn gpu_preview_lane_waits_for_admission_and_resets_its_marker() {
let slots = GpuRenderSlots::new();
let first = slots.acquire(2);
let second = slots.acquire(2);
let pool = rayon::ThreadPoolBuilder::new().num_threads(1).build().unwrap();
let (started, ready) = std::sync::mpsc::channel();
let (tx, rx) = std::sync::mpsc::channel();
let (early, admitted) = std::thread::scope(|scope| {
scope.spawn(|| {
pool.install(|| {
let _drain = Draining::enter(Some(0)).unwrap();
assert!(Draining::enter(None).is_none(), "a lane cannot start a nested drain");
assert!(DRAINING_LANE.get(), "nested rejection must not clear the lane marker");
started.send(()).unwrap();
tx.send(slots.for_render(2).is_some()).unwrap();
});
});
ready.recv_timeout(std::time::Duration::from_secs(2)).unwrap();
let early = rx.recv_timeout(std::time::Duration::from_millis(100));
drop((first, second));
let admitted = early.or_else(|_| rx.recv_timeout(std::time::Duration::from_secs(2)));
(early, admitted)
});
assert!(early.is_err(), "busy dedicated lane must wait instead of falling back to CPU");
assert_eq!(admitted, Ok(true));
pool.install(|| {
assert!(!DRAINING.get());
assert!(!DRAINING_LANE.get());
let panic = std::panic::catch_unwind(|| {
let _drain = Draining::enter(Some(0)).unwrap();
panic!("synthetic lane failure");
});
assert!(panic.is_err());
assert!(!DRAINING.get());
assert!(!DRAINING_LANE.get());
});
}
+5
View File
@@ -891,6 +891,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Footage Frame Cache", "フッテージフレームキャッシュ"),
("Preview (RAM) Cache", "プレビュー(RAM)キャッシュ"),
("Reduce Cache Size When System Is Low on Memory", "システムのメモリが不足しているときにキャッシュサイズを減らす"),
("GPU Frames Rendered at Once", "一度にレンダリングするGPUフレーム数"),
("Video", "ビデオ"),
("Enable Video Preview Output", "ビデオプレビュー出力を有効にする"),
("Video Device", "ビデオデバイス"),
@@ -2023,6 +2024,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Footage Frame Cache", "素材帧缓存"),
("Preview (RAM) Cache", "预览(内存)缓存"),
("Reduce Cache Size When System Is Low on Memory", "系统内存不足时减少缓存大小"),
("GPU Frames Rendered at Once", "GPU 同时渲染的帧数"),
("Video", "视频"),
("Enable Video Preview Output", "启用视频预览输出"),
("Video Device", "视频设备"),
@@ -3135,6 +3137,7 @@ const SWEDISH: &[(&str, &str)] = &[
("Footage Frame Cache", "Bildrutecache för material"),
("Preview (RAM) Cache", "Förhandsgranskningscache (RAM)"),
("Reduce Cache Size When System Is Low on Memory", "Minska cachestorleken när systemet har ont om minne"),
("GPU Frames Rendered at Once", "GPU-bildrutor som renderas samtidigt"),
("Video", "Video"),
("Enable Video Preview Output", "Aktivera videoutgång för förhandsgranskning"),
("Video Device", "Videoenhet"),
@@ -4352,6 +4355,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Footage Frame Cache", "素材影格快取"),
("Preview (RAM) Cache", "預覽(記憶體)快取"),
("Reduce Cache Size When System Is Low on Memory", "系統記憶體不足時減少快取大小"),
("GPU Frames Rendered at Once", "GPU 同時算圖的影格數"),
("Video", "視訊"),
("Enable Video Preview Output", "啟用視訊預覽輸出"),
("Video Device", "視訊裝置"),
@@ -5917,6 +5921,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Recent Projects Shown", "Projetos recentes exibidos"),
("Red", "Vermelho"),
("Reduce Cache Size When System Is Low on Memory", "Reduzir o tamanho do cache quando o sistema estiver com pouca memória"),
("GPU Frames Rendered at Once", "Quadros da GPU renderizados de uma vez"),
("Remove from favorites", "Remover dos favoritos"),
("Repair", "Reparo"),
("Report Missing Frames", "Relatar quadros ausentes"),
+6 -1
View File
@@ -412,7 +412,12 @@ as NaNs (a kernel that skips pixels fails the oracle) and validation errors pani
(`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. Outside
tests, device errors are logged rather than fatal. A viewer frame that runs out of video memory
(`Gpu::within_memory`) renders on the CPU, the uploaded layers and pooled textures are freed, and
the RAM preview keeps half as many GPU frames from then on. The same guard catches a frame's
the RAM preview keeps half as many GPU frames from then on. However many frame threads the viewer
runs, at most two of them start GPU render attempts at once. Shared-pool workers use the CPU
when both slots are busy rather than blocking Rayon helpers; dedicated single-thread preview
lanes wait for admission and remain on the GPU, so
working-texture pressure is bounded by concurrent attempts rather than the thread count (#3028).
A single large frame or retained frames can still exceed the available video memory. The same guard catches a frame's
validation errors (an invalid texture, often left by a failed allocation): that frame renders on
the CPU and the kept textures are freed, but the compositor stays on and, with no allocation
failure before it, the RAM preview keeps its GPU frames (#1319). A frame whose render panics is
+8
View File
@@ -82,6 +82,14 @@ Commands (CLI, MCP, control channel):
- `memory.layerCacheMb`: Layer Cache (in the browser at most 512 MB, see [web.md](web.md))
- `memory.mediaCacheMb`: Footage Frame Cache (in the browser at most 256 MB)
- `memory.previewCacheMb`: Preview (RAM) Cache
- `memory.gpuFramesInFlight`: GPU Frames Rendered at Once (`auto`, `1`, `2`, `3`): how many
frames the Render Queue renders on the GPU at once. Auto measures the GPU memory after the
job's first GPU frame and fits up to 3 frames into the GPU memory cap (by default a
conservative 3 GiB on a discrete card, less when its memory is known, 2 GiB on an integrated
one; `EFFECTCRAFT_GPU_MEMCAP_MB` sets it), keeping room for the upload cache to fill. It
renders one frame at a time where the memory can't be measured (Metal, WebGPU, GL, or the
cap turned off) and once frames have had to wait for memory. The render log names the count.
`EFFECTCRAFT_GPU_INFLIGHT` overrides it.
- `threeD.defaultRenderer`: Default 3D Renderer
- `labels.N.name` / `labels.N.color`: the 16 label names and colours, used by the Label menu,
the timeline, project panel, render queue and viewer handles
+24
View File
@@ -112,3 +112,27 @@ and line instead of being discarded. `--fail-over PCT` requires the same run key
successful latest runs, and at least one comparable resource metric per run. Failed latest runs,
missing coverage, empty results and wall-time-only measurements cannot pass the gate. Omit
`--fail-over` for an exploratory partial comparison; failed latest runs remain visible.
## Unexpected desktop exit diagnostics
The desktop starts a separate, lightweight reporter before loading fonts, projects or the GPU.
A normal exit (including a handled startup error) sends a clean marker only after desktop
teardown. macOS finishes at the end of its successful exit hook because its native event loop
terminates without returning to main; other platforms finish after the event loop returns.
A teardown panic leaves reporting armed. An abort or other unexpected process termination
closes the pipe without that marker.
The helper saves a local report under the session log folder's `Crash Reports` directory and
shows a native error dialog. Control-server launches save the report without a modal dialog.
Reports contain version/platform and at most the last 64 KiB of the matching session's log.
Logging filters and the session file size cap can omit later events from that tail.
They are never uploaded automatically. Review logs for private project paths before sharing.
Run `cargo test -p effectcraft crash_report::tests` for bounded protocol, normal shutdown,
actual subprocess abort, unrelated child handles, concurrent sessions, missing log/helper,
log rotation and report-write failure coverage. The three ignored tests are subprocess entry
points launched by these tests, not manual tests.
This is diagnosis, not memory recovery: a pipe closing does not prove an out-of-memory failure
or provide the process's exit code. System-wide exhaustion, killing the whole process tree,
or an unavailable native dialog service can also prevent the helper from reporting. Existing
auto-save recovery remains responsible for restoring previously saved work.