|
| 1 | +//! Background video export: encodes the current scenario to an MP4 next to the |
| 2 | +//! source file, reusing the exact CLI encode pipeline (ffmpeg when available, |
| 3 | +//! embedded openh264 otherwise). The encode runs on a plain `std::thread` with |
| 4 | +//! progress reported through a shared status slot the UI polls. |
| 5 | +
|
| 6 | +use std::path::PathBuf; |
| 7 | +use std::sync::{Arc, Mutex}; |
| 8 | +use std::time::Duration; |
| 9 | + |
| 10 | +use dioxus::prelude::*; |
| 11 | + |
| 12 | +use crate::scenario::Shared; |
| 13 | + |
| 14 | +/// Lifecycle of one export, shared between the encode thread and the UI. |
| 15 | +#[derive(Debug, Clone, PartialEq)] |
| 16 | +pub enum ExportStatus { |
| 17 | + Idle, |
| 18 | + Running { |
| 19 | + phase: &'static str, |
| 20 | + done: u32, |
| 21 | + total: u32, |
| 22 | + }, |
| 23 | + Done(PathBuf), |
| 24 | + Failed(String), |
| 25 | +} |
| 26 | + |
| 27 | +impl ExportStatus { |
| 28 | + pub fn is_running(&self) -> bool { |
| 29 | + matches!(self, ExportStatus::Running { .. }) |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +/// The cross-thread status slot. The encode thread writes; the UI polls. |
| 34 | +pub type SharedExport = Arc<Mutex<ExportStatus>>; |
| 35 | + |
| 36 | +/// The app-global status slot. Global (not per-component) so a running export |
| 37 | +/// survives a switch to the library and back: the remounted topbar re-attaches |
| 38 | +/// to the same slot, and `start_export`'s running-check keeps exports serial |
| 39 | +/// app-wide. |
| 40 | +pub fn export_slot() -> SharedExport { |
| 41 | + static SLOT: std::sync::OnceLock<SharedExport> = std::sync::OnceLock::new(); |
| 42 | + SLOT.get_or_init(|| Arc::new(Mutex::new(ExportStatus::Idle))) |
| 43 | + .clone() |
| 44 | +} |
| 45 | + |
| 46 | +/// Poll the shared export status into a Dioxus signal (~150 ms), following the |
| 47 | +/// same polling pattern as `use_hot_reload`. Signal writes only fire on change |
| 48 | +/// so idle polling doesn't re-render the topbar. |
| 49 | +pub fn use_export_poll(status: SharedExport, mut sig: Signal<ExportStatus>) { |
| 50 | + use_future(move || { |
| 51 | + let status = status.clone(); |
| 52 | + async move { |
| 53 | + loop { |
| 54 | + tokio::time::sleep(Duration::from_millis(150)).await; |
| 55 | + let s = status.lock().unwrap_or_else(|e| e.into_inner()).clone(); |
| 56 | + if s != sig() { |
| 57 | + sig.set(s); |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | + }); |
| 62 | +} |
| 63 | + |
| 64 | +/// Kick off a background export of the scenario currently loaded in `shared`. |
| 65 | +/// No-op if an export is already running. The scenario is re-loaded from its |
| 66 | +/// source on the worker thread (`ResolvedScenario` isn't `Clone`, and the |
| 67 | +/// watcher keeps the on-disk file authoritative), so the model lock is held |
| 68 | +/// only long enough to snapshot the path and raw JSON. |
| 69 | +pub fn start_export(shared: &Shared, status: &SharedExport) { |
| 70 | + { |
| 71 | + let mut st = status.lock().unwrap_or_else(|e| e.into_inner()); |
| 72 | + if st.is_running() { |
| 73 | + return; |
| 74 | + } |
| 75 | + *st = ExportStatus::Running { |
| 76 | + phase: "Rendering", |
| 77 | + done: 0, |
| 78 | + total: 0, |
| 79 | + }; |
| 80 | + } |
| 81 | + |
| 82 | + let (path, raw) = { |
| 83 | + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); |
| 84 | + (m.path.clone(), m.raw.clone()) |
| 85 | + }; |
| 86 | + |
| 87 | + let status = status.clone(); |
| 88 | + std::thread::spawn(move || { |
| 89 | + // A Skia/encoder panic must not leave the status stuck on Running |
| 90 | + // (and a poisoned status Mutex would break every later export), so |
| 91 | + // the whole encode is fenced with catch_unwind — same rationale as |
| 92 | + // the frame asset handler. |
| 93 | + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { |
| 94 | + run_export(path, raw, &status) |
| 95 | + })); |
| 96 | + let outcome = match result { |
| 97 | + Ok(Ok(out)) => ExportStatus::Done(out), |
| 98 | + Ok(Err(e)) => ExportStatus::Failed(e), |
| 99 | + Err(_) => ExportStatus::Failed("renderer panicked".to_string()), |
| 100 | + }; |
| 101 | + *status.lock().unwrap_or_else(|e| e.into_inner()) = outcome; |
| 102 | + }); |
| 103 | +} |
| 104 | + |
| 105 | +/// Load, render and encode; returns the output path or a user-facing reason. |
| 106 | +fn run_export( |
| 107 | + path: Option<PathBuf>, |
| 108 | + raw: serde_json::Value, |
| 109 | + status: &SharedExport, |
| 110 | +) -> Result<PathBuf, String> { |
| 111 | + let scenario = match &path { |
| 112 | + Some(p) => rustmotion::loader::load_input(p).map_err(|e| e.to_string())?, |
| 113 | + None => { |
| 114 | + if raw.is_null() { |
| 115 | + return Err("no scenario to export".to_string()); |
| 116 | + } |
| 117 | + let json = serde_json::to_string(&raw).map_err(|e| e.to_string())?; |
| 118 | + rustmotion::loader::load_scenario_from_source(None, Some(&json)) |
| 119 | + .map_err(|e| e.to_string())? |
| 120 | + } |
| 121 | + }; |
| 122 | + |
| 123 | + if !scenario.fonts.is_empty() { |
| 124 | + rustmotion::engine::renderer::load_custom_fonts(&scenario.fonts); |
| 125 | + } |
| 126 | + |
| 127 | + let output = output_path(&path); |
| 128 | + let output_str = output |
| 129 | + .to_str() |
| 130 | + .ok_or_else(|| "non-UTF-8 output path".to_string())?; |
| 131 | + |
| 132 | + let mut cb = |p: rustmotion::encode::EncodeProgress| { |
| 133 | + *status.lock().unwrap_or_else(|e| e.into_inner()) = status_for_progress(&p); |
| 134 | + }; |
| 135 | + |
| 136 | + // Same pipeline selection as the CLI: ffmpeg when available (10-bit |
| 137 | + // H.264), embedded openh264 otherwise. Output is fixed to .mp4, so the |
| 138 | + // codec is h264; the scenario's optional `crf` is honored. |
| 139 | + if ffmpeg_available() { |
| 140 | + rustmotion::encode::encode_with_ffmpeg( |
| 141 | + &scenario, |
| 142 | + output_str, |
| 143 | + true, |
| 144 | + "h264", |
| 145 | + scenario.video.crf, |
| 146 | + false, |
| 147 | + Some(&mut cb), |
| 148 | + ) |
| 149 | + .map_err(|e| e.to_string())?; |
| 150 | + } else { |
| 151 | + rustmotion::encode::encode_video(&scenario, output_str, true, Some(&mut cb)) |
| 152 | + .map_err(|e| e.to_string())?; |
| 153 | + } |
| 154 | + Ok(output) |
| 155 | +} |
| 156 | + |
| 157 | +/// Map an encoder progress event to the UI status. |
| 158 | +fn status_for_progress(p: &rustmotion::encode::EncodeProgress) -> ExportStatus { |
| 159 | + use rustmotion::encode::EncodeProgress; |
| 160 | + match p { |
| 161 | + EncodeProgress::Rendering(c, t) => ExportStatus::Running { |
| 162 | + phase: "Rendering", |
| 163 | + done: *c, |
| 164 | + total: *t, |
| 165 | + }, |
| 166 | + EncodeProgress::Encoding(c, t) => ExportStatus::Running { |
| 167 | + phase: "Encoding", |
| 168 | + done: *c, |
| 169 | + total: *t, |
| 170 | + }, |
| 171 | + EncodeProgress::Muxing => ExportStatus::Running { |
| 172 | + phase: "Muxing", |
| 173 | + done: 0, |
| 174 | + total: 0, |
| 175 | + }, |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +/// v1 output location: `<scenario dir>/<stem>.mp4`, or `./export.mp4` when the |
| 180 | +/// scenario has no path. |
| 181 | +fn output_path(source: &Option<PathBuf>) -> PathBuf { |
| 182 | + match source { |
| 183 | + Some(p) => p.with_extension("mp4"), |
| 184 | + None => std::env::current_dir() |
| 185 | + .unwrap_or_else(|_| PathBuf::from(".")) |
| 186 | + .join("export.mp4"), |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +/// Label for the export button, with a live percentage while running. |
| 191 | +pub fn export_label(status: &ExportStatus) -> String { |
| 192 | + match status { |
| 193 | + ExportStatus::Running { phase, done, total } if *total > 0 => { |
| 194 | + let pct = (*done as f64 / *total as f64 * 100.0).round() as u32; |
| 195 | + format!("{phase}… {pct}%") |
| 196 | + } |
| 197 | + ExportStatus::Running { phase, .. } => format!("{phase}…"), |
| 198 | + _ => "Export".to_string(), |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +/// Same detection as the CLI: probe `ffmpeg -version`. |
| 203 | +fn ffmpeg_available() -> bool { |
| 204 | + std::process::Command::new("ffmpeg") |
| 205 | + .arg("-version") |
| 206 | + .stdout(std::process::Stdio::null()) |
| 207 | + .stderr(std::process::Stdio::null()) |
| 208 | + .status() |
| 209 | + .map(|s| s.success()) |
| 210 | + .unwrap_or(false) |
| 211 | +} |
| 212 | + |
| 213 | +#[cfg(test)] |
| 214 | +mod tests { |
| 215 | + use super::*; |
| 216 | + use rustmotion::encode::EncodeProgress; |
| 217 | + |
| 218 | + #[test] |
| 219 | + fn output_path_sits_next_to_the_source() { |
| 220 | + let src = Some(PathBuf::from("/work/demo/scenario.json")); |
| 221 | + assert_eq!(output_path(&src), PathBuf::from("/work/demo/scenario.mp4")); |
| 222 | + let html = Some(PathBuf::from("/work/demo/hello.html")); |
| 223 | + assert_eq!(output_path(&html), PathBuf::from("/work/demo/hello.mp4")); |
| 224 | + } |
| 225 | + |
| 226 | + #[test] |
| 227 | + fn output_path_defaults_to_export_mp4() { |
| 228 | + let out = output_path(&None); |
| 229 | + assert_eq!(out.file_name().and_then(|s| s.to_str()), Some("export.mp4")); |
| 230 | + } |
| 231 | + |
| 232 | + #[test] |
| 233 | + fn progress_maps_to_running_phases() { |
| 234 | + assert_eq!( |
| 235 | + status_for_progress(&EncodeProgress::Rendering(3, 10)), |
| 236 | + ExportStatus::Running { |
| 237 | + phase: "Rendering", |
| 238 | + done: 3, |
| 239 | + total: 10 |
| 240 | + } |
| 241 | + ); |
| 242 | + assert_eq!( |
| 243 | + status_for_progress(&EncodeProgress::Encoding(5, 10)), |
| 244 | + ExportStatus::Running { |
| 245 | + phase: "Encoding", |
| 246 | + done: 5, |
| 247 | + total: 10 |
| 248 | + } |
| 249 | + ); |
| 250 | + assert!(status_for_progress(&EncodeProgress::Muxing).is_running()); |
| 251 | + } |
| 252 | + |
| 253 | + #[test] |
| 254 | + fn labels_show_percentage_when_total_known() { |
| 255 | + assert_eq!( |
| 256 | + export_label(&ExportStatus::Running { |
| 257 | + phase: "Rendering", |
| 258 | + done: 42, |
| 259 | + total: 100 |
| 260 | + }), |
| 261 | + "Rendering… 42%" |
| 262 | + ); |
| 263 | + assert_eq!( |
| 264 | + export_label(&ExportStatus::Running { |
| 265 | + phase: "Muxing", |
| 266 | + done: 0, |
| 267 | + total: 0 |
| 268 | + }), |
| 269 | + "Muxing…" |
| 270 | + ); |
| 271 | + assert_eq!(export_label(&ExportStatus::Idle), "Export"); |
| 272 | + assert_eq!( |
| 273 | + export_label(&ExportStatus::Done(PathBuf::from("/x.mp4"))), |
| 274 | + "Export" |
| 275 | + ); |
| 276 | + } |
| 277 | +} |
0 commit comments