Merge branch 'main' into integrate2

This commit is contained in:
Brandon Thomas 2026-10-04 00:26:25 -04:00
commit 905ab40480
21 changed files with 613 additions and 47 deletions

View File

@ -135,7 +135,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
import::install_drop_handlers()?;
let demo = !query_flag("empty");
let web_options = eframe::WebOptions::default();
let mut web_options = eframe::WebOptions::default();
// `?webgl`: WebGL2 only. The page reloads with it when starting on WebGPU fails (an adapter
// whose device can't be created, a driver the browser rejects…); see `index.html`.
if query_flag("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut web_options.wgpu_options.wgpu_setup
{
c.instance_descriptor.backends = eframe::wgpu::Backends::GL;
}
let runner = eframe::WebRunner::new();
runner
.start(

View File

@ -193,7 +193,7 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
Err(_) => return Some(Ok(inner)),
};
let Some(track) = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty()) else { return Some(Ok(inner)) };
let entry = &file.tracks[track].entries[0];
let Some(entry) = file.tracks[track].entries.first() else { return Some(Ok(inner)) };
let fourcc = entry.format.to_string();
let Some((family, codec, description)) = codec_string(&entry.codec, &fourcc) else { return Some(Ok(inner)) };
if !SUPPORTED.with(|s| s.borrow().contains(&family)) {

View File

@ -6,8 +6,9 @@
//
// Steps: load (timing), demo project, play 2 s (frames shown/dropped), import the media through
// `filmcraft.importUrl`, put it on a new sequence, export H.264 (download), screenshots of each
// step. `--media` must be reachable from the page (copy it next to index.html). Prints a JSON
// report; exits non-zero on failure.
// step; then every mode and a tiny window. `--media` must be reachable from the page (copy it next
// to index.html). Prints a JSON report; exits non-zero on failure, including any Rust panic or
// uncaught exception in the console.
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync, readdirSync, statSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
@ -148,6 +149,24 @@ try {
await sleep(1000);
report.steps.export.downloads = readdirSync(downloads).filter((f) => !f.endsWith(".crdownload")).map((f) => ({ f, bytes: statSync(join(downloads, f)).size }));
report.steps.export.screenshot = await shot("04-exported");
// every top-level mode and a tiny window: these used to panic (temp_dir in Export mode, a dock
// clamp below 40 points) and freeze the canvas
for (const mode of ["import", "export", "edit"]) {
await js(`filmcraft.request('ui.set', {mode: ${JSON.stringify(mode)}})`);
await sleep(500);
}
await send("Emulation.setDeviceMetricsOverride", { width: 120, height: 80, deviceScaleFactor: 1, mobile: false });
await sleep(500);
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 2, mobile: false });
await sleep(500);
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
report.steps.modes = { screenshot: await shot("05-modes") };
// still alive? (a panicked app never answers)
await Promise.race([js("filmcraft.inspect().then(() => true)"), sleep(5000).then(() => { throw new Error("app stopped answering"); })]);
const fatal = logs.filter((l) => /panicked at|RuntimeError|EXCEPTION/.test(l));
if (fatal.length) throw new Error("panic / uncaught exception:\n" + fatal.join("\n").slice(0, 4000));
if (await js("window.filmcraftLoad.fatal || null")) throw new Error("fatal overlay shown");
report.ok = true;
} catch (e) {
report.ok = false;

View File

@ -16,24 +16,78 @@
}
#loading img { width: 56px; height: 56px; }
#loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; }
#fatal {
position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
background: rgba(20, 20, 22, 0.88); color: #e6e6e8; font: 14px/1.45 system-ui, sans-serif; z-index: 10;
}
#fatal.on { display: flex; }
#fatal .box { max-width: min(78ch, calc(100vw - 32px)); padding: 20px 24px; background: #26272b; border-radius: 8px; }
#fatal h1 { margin: 0 0 8px; font-size: 17px; color: #ff8a7a; }
#fatal pre { max-height: 40vh; overflow: auto; white-space: pre-wrap; font-size: 12px; color: #c9c9cc; }
#fatal button { font: inherit; padding: 6px 14px; border: 0; border-radius: 6px; background: #3b6fe0; color: #fff; cursor: pointer; }
</style>
</head>
<body>
<canvas id="filmcraft_canvas"></canvas>
<div id="loading"><img src="favicon.png" alt=""><div id="status">Loading FilmCraft…</div></div>
<script type="module">
import init, { start } from "./filmcraft_web.js";
const t0 = performance.now();
<div id="fatal" role="alertdialog" aria-labelledby="fatal-title">
<div class="box">
<h1 id="fatal-title">FilmCraft stopped working</h1>
<div>Unsaved changes are kept in the browser every few seconds and come back when you reload.</div>
<pre id="fatal-msg"></pre>
<button id="fatal-reload" type="button">Reload</button>
</div>
</div>
<script>
// A Rust panic (or an out-of-memory abort) inside a frame stops the app for good: eframe logs
// "panicked at …" with console.error, then the wasm traps ("RuntimeError: unreachable") and
// the canvas freezes or goes blank. Show what happened instead of a dead page.
window.filmcraftLoad = {};
let lastPanic = null;
const consoleError = console.error.bind(console);
console.error = (...a) => {
const m = a.map(String).join(" ");
if (/panicked at|memory allocation of/.test(m)) lastPanic = m;
consoleError(...a);
};
window.filmcraftFatal = (what) => {
if (window.filmcraftLoad.fatal) return;
const msg = lastPanic || String(what && (what.stack || what.message) || what);
window.filmcraftLoad.fatal = msg;
const el = document.getElementById("fatal");
document.getElementById("fatal-msg").textContent = msg.split("\n\nStack:")[0];
el.classList.add("on");
};
document.getElementById("fatal-reload").onclick = () => location.reload();
const isFatal = (e) => e instanceof WebAssembly.RuntimeError || /panicked at|unreachable|memory access out of bounds/.test(String(e && e.message || e));
// only after start-up: a failed start is shown in #loading below
addEventListener("error", (ev) => { if (window.filmcraftLoad.readyMs && isFatal(ev.error || ev.message)) window.filmcraftFatal(ev.error || ev.message); });
addEventListener("unhandledrejection", (ev) => { if (window.filmcraftLoad.readyMs && isFatal(ev.reason)) window.filmcraftFatal(ev.reason); });
</script>
<script type="module">
// `__FC_BUILD__` is replaced by `cargo xtask web` with a hash of the build: the glue and the
// wasm are cached as immutable, so their URLs must change with every build.
const build = "__FC_BUILD__";
const v = build.startsWith("__") ? "" : "?v=" + build;
const t0 = performance.now();
const params = new URLSearchParams(location.search);
try {
await init();
const { default: init, start } = await import("./filmcraft_web.js" + v);
await init({ module_or_path: new URL("filmcraft_web_bg.wasm" + v, location.href) });
window.filmcraftLoad.wasmMs = performance.now() - t0;
await start("filmcraft_canvas");
window.filmcraftLoad.readyMs = performance.now() - t0;
} catch (e) {
const s = document.getElementById("status");
if (s) { s.className = "err"; s.textContent = "FilmCraft failed to start:\n" + (e && e.stack || e); }
const text = String(e && e.stack || e);
window.filmcraftLoad.error = String(e);
// WebGPU was found but could not be started: try again on WebGL2 (once)
if (!params.has("webgl") && !params.has("cpu") && /wgpu|webgpu|requestDevice|requestAdapter|GPUDevice|surface/i.test(text)) {
params.set("webgl", "");
location.replace(location.pathname + "?" + params.toString().replace(/=(&|$)/g, "$1") + location.hash);
} else {
const s = document.getElementById("status");
if (s) { s.className = "err"; s.textContent = "FilmCraft failed to start:\n" + text; }
}
throw e;
}
</script>

View File

@ -68,6 +68,9 @@ fn main() -> eframe::Result {
_ => files.push(a),
}
}
// Panics anywhere go to <data dir>/Logs/crash-<day>.log with a backtrace; the UI pass and
// frame workers catch them and keep running (see filmcraft_ui_egui::crash).
filmcraft_ui_egui::crash::install(data_dir.clone().or_else(default_data_dir).map(|d| d.join("Logs")));
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("FilmCraft")
@ -85,7 +88,7 @@ fn main() -> eframe::Result {
event_loop_builder: agent_event_loop(control_port.is_some()),
..Default::default()
};
eframe::run_native(
let started = eframe::run_native(
"FilmCraft",
options,
Box::new(move |cc| {
@ -178,7 +181,16 @@ fn main() -> eframe::Result {
}
Ok(Box::new(app))
}),
)
);
// The window could not be created (no usable GPU / graphics driver, no display): say so
// instead of exiting silently, and keep the reason in the crash log.
if let Err(e) = &started {
let msg = format!("FilmCraft could not start its window: {e}\n\nUpdating the graphics driver usually fixes this.");
eprintln!("filmcraft: {msg}");
filmcraft_ui_egui::crash::record(&msg);
let _ = rfd::MessageDialog::new().set_title("FilmCraft").set_description(&msg).set_level(rfd::MessageLevel::Error).show();
}
started
}
/// Open a file in its default application, or reveal it in the file manager (Edit Original,

View File

@ -427,3 +427,18 @@ fn ranges_resolve_and_sidecar_captions_are_written() {
// a bad preset name is a clean error
assert!(s.execute("file.exportMedia", json!({"preset": "Nope", "path": out})).is_err());
}
/// Export mode asks for a default folder as soon as it opens. With no saved project and no
/// HOME (the web build) this used to reach `std::env::temp_dir()`, which panics on wasm32 and
/// killed the web app whenever the Export tab was clicked.
#[test]
fn default_export_dir_never_needs_a_real_filesystem() {
let s = Session::default();
let d = crate::export_tools::default_export_dir(&s);
assert!(!d.as_os_str().is_empty());
// the shared temp-dir helper is what every former `std::env::temp_dir()` call goes through
assert!(!crate::temp_dir().as_os_str().is_empty());
// a saved project exports next to itself
let s = Session { path: Some("/projects/show/edit.fcproj".into()), ..Default::default() };
assert_eq!(crate::export_tools::default_export_dir(&s), std::path::PathBuf::from("/projects/show"));
}

View File

@ -281,11 +281,15 @@ pub fn default_export_dir(s: &Session) -> PathBuf {
if let Some(d) = s.path.as_deref().and_then(|p| Path::new(p).parent()).filter(|d| !d.as_os_str().is_empty()) {
return d.to_path_buf();
}
if cfg!(target_arch = "wasm32") {
// the web build's virtual file table: written files are offered as downloads
return PathBuf::from("/exports");
}
if let Ok(h) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
let movies = Path::new(&h).join("Movies");
return if movies.is_dir() { movies } else { PathBuf::from(h) };
}
std::env::temp_dir()
crate::temp_dir()
}
fn file_safe(n: &str) -> String {
@ -672,9 +676,9 @@ fn queue_cancel(s: &mut Session, p: &Value) -> Result<Value> {
cancel_item(s, i);
}
}
if bool_p(p, "wait").unwrap_or(false) {
// wait for a cancelled encode to stop
let t0 = std::time::Instant::now();
// wait for a cancelled encode to stop (there is no encode thread, clock or sleep on wasm32)
if bool_p(p, "wait").unwrap_or(false) && !cfg!(target_arch = "wasm32") {
let t0 = web_time::Instant::now();
while s.export_queue.items.iter().any(|i| i.status == QueueStatus::Encoding) && t0.elapsed() < std::time::Duration::from_secs(60) {
let running = s.export_queue.running;
s.export_queue.running = false;

View File

@ -63,6 +63,12 @@ pub use filmcraft_render as render;
pub use filmcraft_time as time;
pub use media_pool::MediaPool;
/// The OS temp folder; `/tmp` on wasm32, where `std::env::temp_dir()` panics ("no filesystem on
/// this platform"). Use this instead of `std::env::temp_dir()` in any code the web build runs.
pub fn temp_dir() -> std::path::PathBuf {
if cfg!(target_arch = "wasm32") { std::path::PathBuf::from("/tmp") } else { std::env::temp_dir() }
}
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]

View File

@ -153,7 +153,7 @@ impl PreviewStore {
return;
}
if let Some(old) = &old
&& (old.starts_with(std::env::temp_dir()) || self.temp_root().is_some_and(|r| old.starts_with(r)))
&& (old.starts_with(crate::temp_dir()) || self.temp_root().is_some_and(|r| old.starts_with(r)))
{
let _ = std::fs::create_dir_all(&dir);
for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() {
@ -410,7 +410,7 @@ fn default_temp_dir() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") {
return None;
}
Some(untitled_dir(&std::env::temp_dir().join("FilmCraft Previews")))
Some(untitled_dir(&crate::temp_dir().join("FilmCraft Previews")))
}
/// A fresh per-process folder for an unsaved project's previews under `root`.

View File

@ -376,7 +376,7 @@ fn record_dir(s: &Session, p: &Value) -> String {
if let Some(d) = s.path.as_deref().and_then(|x| std::path::Path::new(x).parent()).filter(|d| !d.as_os_str().is_empty()) {
return d.to_string_lossy().into_owned();
}
let base = s.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.to_path_buf()).unwrap_or_else(|| std::env::temp_dir().join("FilmCraft"));
let base = s.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.to_path_buf()).unwrap_or_else(|| crate::temp_dir().join("FilmCraft"));
base.join("Voice-over Recordings").to_string_lossy().into_owned()
}

View File

@ -448,7 +448,7 @@ pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Opti
Err(e) => json!({"ok": false, "error": e}),
};
}
let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("filmcraft-screenshot.png").to_string_lossy().to_string());
let path = path.map(str::to_string).unwrap_or_else(|| filmcraft_engine::temp_dir().join("filmcraft-screenshot.png").to_string_lossy().to_string());
match encode_png(&rgba, cw as u32, ch as u32) {
Ok(png) => match std::fs::write(&path, png) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": cw, "height": ch}}),

View File

@ -0,0 +1,86 @@
//! Panic reporting: a panic anywhere (UI pass, frame workers, background jobs) is written to a
//! crash log with its thread, location and backtrace, and the last one is kept for the in-app
//! error window. Without this a panic on the UI thread closes the app silently and one in a
//! worker leaves a monitor blank with no trace.
use std::sync::Mutex;
/// The most recent panic, as shown to the user: message, `file:line`, thread.
static LAST: Mutex<Option<String>> = Mutex::new(None);
/// Where crash logs go (desktop); `None` = stderr only (web, tests).
static LOG_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None);
/// Install the panic hook (idempotent). Panics are also passed to the previous hook, so they
/// still reach stderr / the browser console.
pub fn install(log_dir: Option<std::path::PathBuf>) {
*LOG_DIR.lock().unwrap_or_else(|e| e.into_inner()) = log_dir;
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let msg = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".into());
let at = info.location().map(|l| format!("{}:{}", l.file(), l.line())).unwrap_or_default();
let thread = std::thread::current().name().unwrap_or("unnamed").to_string();
let summary = format!("{msg} (at {at}, thread {thread})");
*LAST.lock().unwrap_or_else(|e| e.into_inner()) = Some(summary.clone());
write_log(&summary);
prev(info);
}));
});
}
/// Write a non-panic fatal error (e.g. the window could not be created) to the crash log.
pub fn record(msg: &str) {
write_log(msg);
}
/// Take the last panic summary (the error window shows it once).
pub fn take_last() -> Option<String> {
LAST.lock().unwrap_or_else(|e| e.into_inner()).take()
}
/// The crash log file for today, if logging to disk.
pub fn log_path() -> Option<std::path::PathBuf> {
let dir = LOG_DIR.lock().unwrap_or_else(|e| e.into_inner()).clone()?;
let secs = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
Some(dir.join(format!("crash-{}.log", secs / 86_400)))
}
#[cfg(not(target_arch = "wasm32"))]
fn write_log(summary: &str) {
use std::io::Write;
let Some(path) = log_path() else { return };
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
let bt = std::backtrace::Backtrace::force_capture();
let secs = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "=== {} FilmCraft {} (unix {secs})\n{summary}\n{bt}\n", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
}
}
#[cfg(target_arch = "wasm32")]
fn write_log(_summary: &str) {}
#[cfg(test)]
mod tests {
#[test]
fn hook_records_and_logs_worker_panics() {
let dir = std::env::temp_dir().join(format!("fc-crash-{}", std::process::id()));
super::install(Some(dir.clone()));
let _ = super::take_last();
let r = std::thread::Builder::new().name("filmcraft-test-worker".into()).spawn(|| panic!("boom in a worker")).unwrap().join();
assert!(r.is_err());
let last = super::take_last().expect("recorded");
assert!(last.contains("boom in a worker") && last.contains("filmcraft-test-worker"), "{last}");
let log = std::fs::read_to_string(super::log_path().unwrap()).unwrap();
assert!(log.contains("boom in a worker"));
let _ = std::fs::remove_dir_all(dir);
}
}

View File

@ -335,6 +335,20 @@ pub enum DockAction {
PanelMenu(PanelKind, egui::Pos2),
}
/// Size of a split's first child in `avail` points: the requested size, keeping both children at
/// least 20 points when there is room (and never more than `avail`). Must not panic for any size:
/// a tiny window (or browser canvas) gives splits less than 40 points.
fn split_first(size: SplitSize, avail: f32) -> f32 {
let want = match size {
SplitSize::Ratio(r) => avail * r,
SplitSize::FixedA(px) => px,
SplitSize::FixedB(px) => avail - px,
};
let lo = 20.0_f32.min(avail * 0.5);
let hi = (avail - 20.0).max(lo);
want.clamp(lo, hi)
}
/// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging.
pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec<Group>, reg: &mut crate::automation::Registry) {
match node {
@ -347,12 +361,7 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
let g = t.gap;
let total = if *vertical { rect.height() } else { rect.width() };
let avail = (total - g).max(0.0);
let first = match *size {
SplitSize::Ratio(r) => avail * r,
SplitSize::FixedA(px) => px.min(avail - 20.0),
SplitSize::FixedB(px) => avail - px.min(avail - 20.0),
}
.clamp(20.0_f32.min(avail), (avail - 20.0).max(0.0));
let first = split_first(*size, avail);
let (ra, gutter, rb) = if *vertical {
(
Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)),
@ -480,6 +489,26 @@ pub fn placeholder(ui: &mut egui::Ui, rect: Rect, t: &Tokens, text: &str) {
mod tests {
use super::*;
#[test]
fn split_first_never_panics_on_tiny_areas() {
for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] {
for size in [
SplitSize::Ratio(0.0),
SplitSize::Ratio(0.5),
SplitSize::Ratio(1.0),
SplitSize::FixedA(300.0),
SplitSize::FixedB(300.0),
SplitSize::FixedA(0.0),
] {
let f = split_first(size, avail);
assert!((0.0..=avail).contains(&f), "{size:?} in {avail}: {f}");
}
}
assert_eq!(split_first(SplitSize::FixedA(300.0), 1000.0), 300.0);
assert_eq!(split_first(SplitSize::FixedB(300.0), 1000.0), 700.0);
assert_eq!(split_first(SplitSize::Ratio(0.0), 1000.0), 20.0);
}
#[test]
fn workspaces_contain_core_panels() {
for w in WORKSPACES {

View File

@ -103,6 +103,8 @@ struct Shared {
pub struct FrameStats {
/// Jobs finished (including cancelled ones).
pub jobs: u64,
/// Jobs whose decode/render panicked (the worker survives; the frame is not cached).
pub failed: u64,
pub cancelled: u64,
/// Jobs served from a render preview.
pub preview: u64,
@ -136,7 +138,7 @@ impl FrameStats {
let n = self.jobs.max(1) as f64;
let req = self.request_hits + self.request_misses;
serde_json::json!({
"jobs": self.jobs, "cancelled": self.cancelled, "previewJobs": self.preview, "prefetchJobs": self.prefetch,
"jobs": self.jobs, "cancelled": self.cancelled, "failedJobs": self.failed, "previewJobs": self.preview, "prefetchJobs": self.prefetch,
"requestHitRate": if req == 0 { 0.0 } else { self.request_hits as f64 / req as f64 },
"decodeMs": {"mean": self.source_ms / n, "p50": pct(&|r| r.0, 0.5), "p95": pct(&|r| r.0, 0.95)},
"renderMs": {"mean": self.render_ms / n, "p50": pct(&|r| r.1, 0.5), "p95": pct(&|r| r.1, 0.95)},
@ -747,7 +749,15 @@ fn worker(
q = sh.cv.wait(q).unwrap_or_else(|e| e.into_inner());
}
};
run_job(&sh, &job, &pool, &services, &previews);
// A panic while decoding or rendering one frame (bad media, a decoder bug) must not take
// the worker down with it: a dead worker leaves its frame "in flight" forever and the
// monitors blank. Log it, forget the job and keep serving.
let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_job(&sh, &job, &pool, &services, &previews)));
if ran.is_err() {
sh.in_flight.lock().unwrap_or_else(|e| e.into_inner()).retain(|(k, c, _)| !(*k == job.key && Arc::ptr_eq(c, &job.cancel)));
sh.stats.lock().unwrap_or_else(|e| e.into_inner()).failed += 1;
log::error!("frame job {:?} panicked; the worker continues", job.key);
}
if let Some(f) = repaint.lock().unwrap_or_else(|e| e.into_inner()).as_ref() {
f();
}
@ -756,8 +766,19 @@ fn worker(
/// Run one job and cache its result. Returns false when the job's media was still loading (an
/// asynchronous web read): nothing was cached and the job should be retried.
/// Fault injection for robustness tests (and agents checking recovery): the next `n` frame jobs
/// panic as if a decoder had hit a bug.
#[doc(hidden)]
pub fn inject_job_panics(n: u32) {
INJECT_PANICS.store(n, Ordering::Relaxed);
}
static INJECT_PANICS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
fn run_job(sh: &Shared, job: &Job, pool: &Arc<MediaPool>, services: &Arc<dyn Services>, previews: &PreviewStore) -> bool {
sh.in_flight.lock().unwrap_or_else(|e| e.into_inner()).push((job.key, job.cancel.clone(), job.prefetch));
if INJECT_PANICS.load(Ordering::Relaxed) > 0 && INJECT_PANICS.fetch_sub(1, Ordering::Relaxed) > 0 {
panic!("injected frame-job fault");
}
let profiling = sh.profiling.load(Ordering::Relaxed);
let (started, cpu0) = (Instant::now(), if profiling { thread_cpu_time().unwrap_or_default() } else { Duration::ZERO });
SOURCE_TIME.with(|s| s.set((Duration::ZERO, Duration::ZERO)));

View File

@ -7,6 +7,7 @@
pub mod automation;
pub mod brand;
pub mod control;
pub mod crash;
pub mod dock;
pub mod frames;
pub mod header;
@ -157,6 +158,11 @@ pub struct FilmcraftApp {
input_waiters: Vec<Sender<Value>>,
next_token: u64,
styled: bool,
/// A panic in the UI pass, shown in an error window until dismissed (see [`crash`]).
pub ui_error: Option<String>,
/// Fault injection for robustness tests: the next UI pass panics.
#[doc(hidden)]
pub panic_next_frame: bool,
fonts_ready: bool,
pub integrated_titlebar: bool,
pub last_timeline_width: f32,
@ -187,26 +193,107 @@ pub struct GpuState {
pub size: (u32, u32),
/// Composite time of the last frame (ms).
pub last_ms: f32,
/// Set by the device's uncaptured-error handler: the GPU path failed (validation, lost
/// device, out of memory). The app then drops it and composites on the CPU.
pub broken: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// Largest texture side the device accepts.
pub max_texture: u32,
}
/// Largest texture side a plan needs on the GPU (output and every layer).
fn plan_side(plan: &filmcraft_render::plan::FramePlan) -> usize {
use filmcraft_render::plan::FramePlan;
match plan {
FramePlan::Layers { width, height, layers } => {
layers.iter().map(|l| l.frame.width.max(l.frame.height) as usize).fold((*width).max(*height), usize::max)
}
FramePlan::Image(img) => img.w.max(img.h),
}
}
/// Whether the GPU compositor can run on this adapter: it renders and blends Rgba16Float
/// targets and uploads whole frames as textures. Older or OpenGL-backed GPUs (some Intel Macs,
/// VMs, Linux without Vulkan, WebGL) can't; those use the CPU compositor, which renders the same
/// frames. Returns the reason when unsupported.
pub fn gpu_compositor_unsupported(adapter: &eframe::wgpu::Adapter) -> Option<String> {
use eframe::wgpu::{TextureFormat, TextureFormatFeatureFlags as F, TextureUsages as U};
let f = adapter.get_texture_format_features(TextureFormat::Rgba16Float);
if !f.allowed_usages.contains(U::RENDER_ATTACHMENT | U::TEXTURE_BINDING) {
return Some("Rgba16Float is not renderable".into());
}
if !f.flags.contains(F::BLENDABLE | F::FILTERABLE) {
return Some("Rgba16Float is not blendable/filterable".into());
}
let max = adapter.limits().max_texture_dimension_2d;
if max < 4096 {
return Some(format!("max texture size {max} < 4096"));
}
None
}
impl FilmcraftApp {
/// Enable the GPU compositor on the eframe wgpu device.
pub fn set_wgpu(&mut self, rs: eframe::egui_wgpu::RenderState) {
let compositor = filmcraft_gpu::GpuCompositor::new(&rs.device, &rs.queue);
self.gpu = Some(GpuState { render_state: rs, compositor, texture: None, last_key: None, size: (0, 0), last_ms: 0.0 });
if let Some(why) = gpu_compositor_unsupported(&rs.adapter) {
log::warn!("GPU compositor disabled ({why}); compositing on the CPU");
return;
}
// wgpu's default reaction to a validation error or lost device is to panic, which closed
// the app (or left the monitors blank) on GPUs the compositor doesn't suit. Log instead and
// fall back to the CPU compositor.
let broken = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = broken.clone();
rs.device.on_uncaptured_error(std::sync::Arc::new(move |e: eframe::wgpu::Error| {
log::error!("GPU error, switching to the CPU compositor: {e}");
flag.store(true, std::sync::atomic::Ordering::Relaxed);
}));
let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| filmcraft_gpu::GpuCompositor::new(&rs.device, &rs.queue)));
match made {
Ok(compositor) if !broken.load(std::sync::atomic::Ordering::Relaxed) => {
let max_texture = rs.device.limits().max_texture_dimension_2d;
self.gpu = Some(GpuState { render_state: rs, compositor, texture: None, last_key: None, size: (0, 0), last_ms: 0.0, broken, max_texture });
}
_ => log::error!("GPU compositor failed to initialise; compositing on the CPU"),
}
}
/// Drop the GPU compositor after a GPU error; the monitors composite on the CPU from then on.
fn disable_gpu(&mut self, why: &str) {
log::error!("GPU compositor disabled: {why}");
if let Some(g) = self.gpu.take()
&& let Some(id) = g.texture
{
g.render_state.renderer.write().free_texture(&id);
}
self.ui.status = "Graphics problem: switched to software compositing".into();
}
/// Composite a plan on the GPU and return the egui texture showing it.
pub fn gpu_present(&mut self, key: FrameKey, plan: &frames::GpuPlan) -> Option<(egui::TextureId, (u32, u32))> {
if self.gpu.as_ref()?.broken.load(std::sync::atomic::Ordering::Relaxed) {
self.disable_gpu("device error");
return None;
}
let g = self.gpu.as_mut()?;
if g.last_key == Some(key)
&& let Some(t) = g.texture
{
return Some((t, g.size));
}
if plan_side(&plan.plan) > g.max_texture as usize {
self.disable_gpu("frame larger than the GPU's maximum texture size");
return None;
}
let t0 = web_time::Instant::now();
let (view, size) = g.compositor.composite_prepared(&plan.plan, Some(&plan.prepared));
let view = view.clone();
let ran = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let (view, size) = g.compositor.composite_prepared(&plan.plan, Some(&plan.prepared));
(view.clone(), size)
}));
let Ok((view, size)) = ran else {
self.disable_gpu("compositor panicked");
return None;
};
let g = self.gpu.as_mut()?;
g.last_ms = t0.elapsed().as_secs_f32() * 1000.0;
let mut renderer = g.render_state.renderer.write();
let id = match g.texture {
@ -252,6 +339,8 @@ impl FilmcraftApp {
input_waiters: Vec::new(),
next_token: 1,
styled: false,
ui_error: None,
panic_next_frame: false,
fonts_ready: false,
integrated_titlebar: false,
last_timeline_width: 1000.0,
@ -899,6 +988,9 @@ impl FilmcraftApp {
// ---------------------------------------------------------------- frame
fn frame(&mut self, ui: &mut egui::Ui) {
if std::mem::take(&mut self.panic_next_frame) {
panic!("injected UI fault");
}
let ctx = ui.ctx().clone();
self.auto.begin_frame();
self.frames.set_context(&ctx);
@ -1074,6 +1166,38 @@ impl FilmcraftApp {
}
}
impl FilmcraftApp {
/// The "FilmCraft hit an error" window after a caught UI panic. Automation ids:
/// `error.dismiss`, `error.save`.
fn error_window(&mut self, ctx: &egui::Context) {
let Some(msg) = self.ui_error.clone() else { return };
let mut close = false;
egui::Window::new("FilmCraft hit an error").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
ui.set_max_width(460.0);
ui.label("Something went wrong while drawing the window. Your project is still open; save it to be safe.");
ui.add_space(6.0);
ui.label(egui::RichText::new(&msg).monospace().small());
if let Some(p) = crash::log_path() {
ui.label(egui::RichText::new(format!("Details: {}", p.display())).small());
}
ui.add_space(8.0);
ui.horizontal(|ui| {
let s = ui.button("Save Project");
self.auto.add("error.save", s.rect, "Save Project");
if s.clicked() {
let _ = crate::menus::invoke(self, ctx, "file.save", serde_json::json!({}));
}
let d = ui.button("Continue");
self.auto.add("error.dismiss", d.rect, "Continue");
close |= d.clicked();
});
});
if close {
self.ui_error = None;
}
}
}
impl eframe::App for FilmcraftApp {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
if !self.synthetic.is_empty() {
@ -1142,7 +1266,13 @@ impl eframe::App for FilmcraftApp {
// No frame worker threads on the web: render queued frames here, within a time budget
// that leaves room for the UI pass (a no-op where workers run).
self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 }));
self.frame(ui);
// A panic in one panel must not close the app (losing unsaved work) or leave a blank
// window: catch it, keep the session, and say what happened.
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.frame(ui))).is_err() {
self.ui_error = Some(crash::take_last().unwrap_or_else(|| "unknown error".into()));
self.playback.playing = false;
}
self.error_window(ui.ctx());
let ctx = ui.ctx().clone();
self.last_ui_time = ctx.input(|i| i.time);
if !self.synthetic.is_empty() {
@ -1158,3 +1288,21 @@ impl eframe::App for FilmcraftApp {
}
}
}
#[cfg(test)]
mod gpu_fallback_tests {
use filmcraft_render::plan::{FramePlan, PlanLayer};
#[test]
fn plan_side_covers_output_and_every_layer() {
let frame = |w: u32, h: u32| std::sync::Arc::new(filmcraft_frame::VideoFrame::rgba_f32(w, h, vec![0.0; (w * h * 4) as usize]));
let layer = |w, h| PlanLayer { frame: frame(w, h), matrix: filmcraft_geom::Affine::IDENTITY, opacity: 1.0 };
// a 4K source in an HD sequence still needs a 3840-wide texture on the GPU
let p = FramePlan::Layers { width: 1920, height: 1080, layers: vec![layer(64, 64), layer(3840, 2160)] };
assert_eq!(super::plan_side(&p), 3840);
let p = FramePlan::Layers { width: 1920, height: 1080, layers: vec![] };
assert_eq!(super::plan_side(&p), 1920);
let p = FramePlan::Image(filmcraft_render::Image::new(800, 4000));
assert_eq!(super::plan_side(&p), 4000);
}
}

View File

@ -205,7 +205,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
let split = Rect::from_min_max(pos2(tree_r.max.x, body.min.y), pos2(tree_r.max.x + 4.0, body.max.y));
let sresp = ui.interact(split, egui::Id::new("mb-split"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
if sresp.dragged() {
app.ui.media_browser.tree_width = (tw + sresp.drag_delta().x).clamp(100.0, rect.width() * 0.7);
app.ui.media_browser.tree_width = (tw + sresp.drag_delta().x).clamp(100.0, (rect.width() * 0.7).max(100.0));
}
ui.painter().line_segment([split.center_top(), split.center_bottom()], Stroke::new(1.0, t.separator));
tree(app, ui, tree_r, &dir);

View File

@ -127,7 +127,7 @@ pub fn open_path(app: &mut FilmcraftApp, ctx: &egui::Context, path: &str, reveal
/// Where Reveal Log Files points: `<data dir>/Logs` (the temp folder without a data directory).
pub fn logs_dir(app: &FilmcraftApp) -> std::path::PathBuf {
app.session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Logs")).unwrap_or_else(|| std::env::temp_dir().join("FilmCraft Logs"))
app.session.prefs_path.as_ref().and_then(|p| p.parent()).map(|d| d.join("Logs")).unwrap_or_else(|| filmcraft_engine::temp_dir().join("FilmCraft Logs"))
}
/// Write this session's log (system report and the command journal) into [`logs_dir`].

View File

@ -0,0 +1,142 @@
//! Robustness: a panic while decoding/rendering a frame must not kill the frame worker (the
//! monitors would stay blank), and a panic in the UI pass must not close the app (losing unsaved
//! work) — it shows an error window and the session carries on.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
/// The tests run one at a time: fault injection (`inject_job_panics`) is process-wide, so frame
/// workers of a parallel test would consume the injected panics.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn serial() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
}
impl Driver {
fn demo() -> Self {
filmcraft_ui_egui::crash::install(None);
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app);
let mut d = Driver { harness, tx };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
// synthetic input (ui.click) enters through the app's raw input hook, as in eframe
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
assert_eq!(v["ok"], json!(true), "{method} {params}: {v}");
return v["result"].clone();
}
}
panic!("no reply to {method}");
}
fn frame_stats(&mut self) -> Value {
self.ok("engine.execute", json!({"command": "perf.stats"}))["frames"].clone()
}
/// Step until the frame workers are idle.
fn settle(&mut self) {
for _ in 0..400 {
self.frames(1);
if self.harness.state().frames.queue_len() == 0 {
std::thread::sleep(std::time::Duration::from_millis(20));
self.frames(1);
if self.harness.state().frames.queue_len() == 0 {
return;
}
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
#[test]
fn frame_worker_survives_a_panicking_job_and_keeps_rendering() {
let _serial = serial();
let mut d = Driver::demo();
d.settle();
let jobs_before = d.frame_stats()["jobs"].as_u64().unwrap_or(0);
let workers = d.harness.state().frames.workers() as u32;
// more faults than there are workers: before the fix every worker died and nothing rendered
filmcraft_ui_egui::frames::inject_job_panics(workers + 2);
for t in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0] {
d.ok("engine.execute", json!({"command": "playhead.set", "params": {"seconds": t}}));
d.settle();
}
let st = d.frame_stats();
assert_eq!(st["failedJobs"].as_u64(), Some(workers as u64 + 2), "{st}");
let done_after_faults = st["jobs"].as_u64().unwrap_or(0);
assert!(done_after_faults > jobs_before, "workers still finish jobs after the panics: {st}");
assert_eq!(d.harness.state().frames.queue_len(), 0, "no job stuck in the queue");
}
#[test]
fn a_ui_panic_shows_an_error_window_and_the_app_keeps_running() {
let _serial = serial();
let mut d = Driver::demo();
d.harness.state_mut().panic_next_frame = true;
d.frames(2);
let msg = d.harness.state().ui_error.clone().expect("error recorded");
assert!(msg.contains("injected UI fault"), "{msg}");
// the session survived: commands still run and the error window is on screen
d.ok("engine.execute", json!({"command": "playhead.set", "params": {"seconds": 2.0}}));
let els = d.ok("ui.elements", json!({"prefix": "error."}));
assert!(els.to_string().contains("error.dismiss"), "{els}");
d.ok("ui.click", json!({"id": "error.dismiss"}));
d.frames(3);
assert!(d.harness.state().ui_error.is_none());
// and the UI keeps drawing normally afterwards
let els = d.ok("ui.elements", json!({"prefix": "timeline."}));
assert!(els.as_array().is_some_and(|a| !a.is_empty()) || els.is_object(), "{els}");
}
/// Tiny windows and squeezed panels: dock splits under 40 points used to panic in
/// `dock::layout` (`clamp` with min > max), killing the app (web) or the UI pass.
#[test]
fn tiny_windows_and_squeezed_panels_do_not_panic() {
let _serial = serial();
filmcraft_ui_egui::crash::install(None);
for (w, h) in [(120.0, 80.0), (300.0, 200.0), (60.0, 40.0), (900.0, 560.0)] {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let app = FilmcraftApp::new(session);
let mut harness = Harness::builder().with_size(egui::vec2(w, h)).with_max_steps(10_000).build_eframe(move |_cc| app);
for ws in ["Editing", "Color", "Audio", "Effects", "Captions and Graphics", "Assembly"] {
harness.state_mut().set_workspace(ws);
for _ in 0..3 {
harness.step();
}
}
assert!(harness.state().ui_error.is_none(), "{w}x{h}: {:?}", harness.state().ui_error);
}
}

View File

@ -29,8 +29,15 @@ unaffected; `cargo xtask wasm` (part of `cargo xtask ci`) checks it, with `filmc
`wasm32-unknown-unknown`.
URL flags: `?empty` (no demo project), `?norecover` (don't reopen the auto-saved project),
`?fresh` (also don't restore media kept in OPFS), `?cpu` (CPU compositor), `?nowebcodecs`
(decode with FilmCraft's own decoders only).
`?fresh` (also don't restore media kept in OPFS), `?cpu` (CPU compositor), `?webgl` (WebGL2 only;
the page reloads with it when WebGPU is present but fails to start), `?nowebcodecs` (decode with
FilmCraft's own decoders only).
If the app panics after start-up (or runs out of memory), `index.html` shows a "FilmCraft stopped
working" overlay with the panic message and a Reload button instead of a frozen canvas, and sets
`window.filmcraftLoad.fatal`. Code the web build runs must not call `std::env::temp_dir`,
`std::time::{Instant, SystemTime}::now`, `std::thread::sleep`/`spawn` or `std::process::id`: they
panic on `wasm32-unknown-unknown` (use `filmcraft_engine::temp_dir`, `web_time`, or a `cfg`).
## How the desktop pieces map to the browser

View File

@ -6,18 +6,19 @@ static site in `filmcraft-web-<version>/`:
| File | What it is |
|---|---|
| `index.html` | The page. It loads everything through relative URLs. |
| `filmcraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
| `filmcraft-web-<hash>_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
| `filmcraft_web.js` | wasm-bindgen glue (generated, ES module) |
| `filmcraft_web_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
There is no server-side code. Upload the folder's contents anywhere that serves static files.
## Any path works
All URLs in `index.html` are relative (`public_url = "./"` in `apps/filmcraft-web/Trunk.toml`),
so the site works at a domain root (`https://example.com/`), under a prefix
(`https://example.com/tools/filmcraft/`) and from a CDN bucket. The asset names carry a content
hash, so they can be cached forever. Only `index.html` needs revalidation.
All URLs in `index.html` are relative, so the site works at a domain root
(`https://example.com/`), under a prefix (`https://example.com/tools/filmcraft/`) and from a CDN
bucket. The assets carry a content
hash in their URL (`index.html` loads them with `?v=<build hash>`), so they can be cached
forever. Only `index.html` needs revalidation.
## Required server settings
@ -27,8 +28,9 @@ hash, so they can be cached forever. Only `index.html` needs revalidation.
- **Compression:** turn on gzip or Brotli for `.wasm`, `.js` and `.html`. That takes the
download from about 13 MB to about 5 MB. You can also precompress (`brotli -k *.wasm`) and let
the server send `Content-Encoding: br`.
- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the hashed `.wasm` and
`.js` files, and `no-cache` on `index.html`.
- **Caching:** `Cache-Control: public, max-age=31536000, immutable` on the `.wasm` and `.js`
files (their URLs carry the build hash), and `no-cache` on `index.html`. `index.html` must not
be cached, or a browser can pair an old page with new files.
- **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://`
or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2.
- **No special isolation headers:** FilmCraft doesn't use `SharedArrayBuffer`, so it doesn't
@ -81,7 +83,7 @@ FilmCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall
| Flag | Effect |
|---|---|
| *(none)* | WebGPU if available, otherwise WebGL2 |
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves). The page reloads with it by itself when WebGPU is present but fails to start |
| `?cpu` | Force the CPU canvas path (slowest, most compatible) |
For example: `<iframe src="https://example.com/filmcraft/?webgl" ...>`.

View File

@ -304,10 +304,24 @@ fn web(args: &[String]) -> Result<(), String> {
.arg(&opt))?;
std::fs::rename(&opt, &bg).map_err(|e| e.to_string())?;
}
// index.html loads the glue and the wasm with `?v=<hash of this build>`: hosts cache them as
// immutable (packaging/web/_headers), so a new build must change their URLs.
let build = {
let mut h: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a
for f in ["filmcraft_web.js", "filmcraft_web_bg.wasm"] {
for b in std::fs::read(dist.join(f)).map_err(|e| format!("{f}: {e}"))? {
h = (h ^ b as u64).wrapping_mul(0x100_0000_01b3);
}
}
format!("{h:016x}")
};
let web = std::path::Path::new("apps/filmcraft-web/web");
for e in std::fs::read_dir(web).map_err(|e| e.to_string())?.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !name.ends_with(".attribution") {
if name == "index.html" {
let html = std::fs::read_to_string(e.path()).map_err(|e| format!("{name}: {e}"))?;
std::fs::write(dist.join(&name), html.replace("__FC_BUILD__", &build)).map_err(|e| format!("{name}: {e}"))?;
} else if !name.ends_with(".attribution") {
std::fs::copy(e.path(), dist.join(&name)).map_err(|e| format!("{name}: {e}"))?;
}
}