Merge branch 'main' into integrate2
This commit is contained in:
commit
905ab40480
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`")]
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
11
docs/web.md
11
docs/web.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" ...>`.
|
||||
|
|
|
|||
|
|
@ -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}"))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue