use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use gpui_component::notification::Notification;
use gpui_component::WindowExt as _;
use gpui_kit::{div, Context, IntoElement, Render, Window};
use crate::studio::scenario::Shared;
#[derive(Debug, Clone, PartialEq)]
pub enum ExportStatus {
Idle,
Running {
phase: &'static str,
done: u32,
total: u32,
},
Done(PathBuf),
Failed(String),
}
impl ExportStatus {
pub fn is_running(&self) -> bool {
matches!(self, ExportStatus::Running { .. })
}
}
pub type SharedExport = Arc<Mutex<ExportStatus>>;
pub fn export_slot() -> SharedExport {
static SLOT: std::sync::OnceLock<SharedExport> = std::sync::OnceLock::new();
SLOT.get_or_init(|| Arc::new(Mutex::new(ExportStatus::Idle)))
.clone()
}
pub fn start_export(shared: &Shared, status: &SharedExport) {
{
let mut st = status.lock().unwrap_or_else(|e| e.into_inner());
if st.is_running() {
return;
}
*st = ExportStatus::Running {
phase: "Rendering",
done: 0,
total: 0,
};
}
let (path, raw) = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
(m.path.clone(), m.raw.clone())
};
let status = status.clone();
std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_export(path, raw, &status)
}));
let outcome = match result {
Ok(Ok(out)) => ExportStatus::Done(out),
Ok(Err(e)) => ExportStatus::Failed(e),
Err(_) => ExportStatus::Failed("renderer panicked".to_string()),
};
*status.lock().unwrap_or_else(|e| e.into_inner()) = outcome;
});
}
fn run_export(
path: Option<PathBuf>,
raw: serde_json::Value,
status: &SharedExport,
) -> Result<PathBuf, String> {
let scenario = match &path {
Some(p) => rustmotion::loader::load_input(p).map_err(|e| e.to_string())?,
None => {
if raw.is_null() {
return Err("no scenario to export".to_string());
}
let json = serde_json::to_string(&raw).map_err(|e| e.to_string())?;
rustmotion::loader::load_scenario_from_source(None, Some(&json))
.map_err(|e| e.to_string())?
}
};
if !scenario.fonts.is_empty() {
rustmotion::engine::renderer::load_custom_fonts(&scenario.fonts);
}
let output = output_path(&path);
let output_str = output
.to_str()
.ok_or_else(|| "non-UTF-8 output path".to_string())?;
let mut cb = |p: rustmotion::encode::EncodeProgress| {
*status.lock().unwrap_or_else(|e| e.into_inner()) = status_for_progress(&p);
};
if ffmpeg_available() {
rustmotion::encode::encode_with_ffmpeg(
&scenario,
output_str,
true,
"h264",
scenario.video.crf,
false,
Some(&mut cb),
)
.map_err(|e| e.to_string())?;
} else {
rustmotion::encode::encode_video(&scenario, output_str, true, Some(&mut cb))
.map_err(|e| e.to_string())?;
}
Ok(output)
}
fn status_for_progress(p: &rustmotion::encode::EncodeProgress) -> ExportStatus {
use rustmotion::encode::EncodeProgress;
match p {
EncodeProgress::Rendering(c, t) => ExportStatus::Running {
phase: "Rendering",
done: *c,
total: *t,
},
EncodeProgress::Encoding(c, t) => ExportStatus::Running {
phase: "Encoding",
done: *c,
total: *t,
},
EncodeProgress::Muxing => ExportStatus::Running {
phase: "Muxing",
done: 0,
total: 0,
},
}
}
fn output_path(source: &Option<PathBuf>) -> PathBuf {
match source {
Some(p) => p.with_extension("mp4"),
None => std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("export.mp4"),
}
}
pub fn export_label(status: &ExportStatus) -> String {
match status {
ExportStatus::Running { phase, done, total } if *total > 0 => {
let pct = (*done as f64 / *total as f64 * 100.0).round() as u32;
format!("{phase}… {pct}%")
}
ExportStatus::Running { phase, .. } => format!("{phase}…"),
_ => "Export".to_string(),
}
}
fn ffmpeg_available() -> bool {
std::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
const EXPORT_POLL_INTERVAL: Duration = Duration::from_millis(150);
struct ExportNotificationKind;
pub struct ExportWatcher {
status: SharedExport,
last: ExportStatus,
}
impl ExportWatcher {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let this = Self {
status: export_slot(),
last: ExportStatus::Idle,
};
this.start_polling(window, cx);
this
}
fn start_polling(&self, window: &mut Window, cx: &mut Context<Self>) {
cx.spawn_in(window, async move |this, cx| loop {
cx.background_executor().timer(EXPORT_POLL_INTERVAL).await;
let alive = this
.update_in(cx, |this, window, cx| this.tick(window, cx))
.is_ok();
if !alive {
break;
}
})
.detach();
}
fn tick(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let current = self
.status
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
if current == self.last {
return;
}
self.last = current.clone();
match current {
ExportStatus::Idle => {
window.remove_notification::<ExportNotificationKind>(cx);
}
ExportStatus::Running { .. } => {
window.push_notification(
Notification::info(export_label(¤t))
.title("Exporting")
.id::<ExportNotificationKind>()
.autohide(false),
cx,
);
}
ExportStatus::Done(ref path) => {
window.push_notification(
Notification::success(format!("Saved to {}", path.display()))
.title("Export complete")
.id::<ExportNotificationKind>()
.autohide(true),
cx,
);
}
ExportStatus::Failed(ref message) => {
window.push_notification(
Notification::error(message.clone())
.title("Export failed")
.id::<ExportNotificationKind>()
.autohide(false),
cx,
);
}
}
}
}
impl Render for ExportWatcher {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rustmotion::encode::EncodeProgress;
#[test]
fn output_path_sits_next_to_the_source() {
let src = Some(PathBuf::from("/work/demo/scenario.json"));
assert_eq!(output_path(&src), PathBuf::from("/work/demo/scenario.mp4"));
let html = Some(PathBuf::from("/work/demo/hello.html"));
assert_eq!(output_path(&html), PathBuf::from("/work/demo/hello.mp4"));
}
#[test]
fn output_path_defaults_to_export_mp4() {
let out = output_path(&None);
assert_eq!(out.file_name().and_then(|s| s.to_str()), Some("export.mp4"));
}
#[test]
fn progress_maps_to_running_phases() {
assert_eq!(
status_for_progress(&EncodeProgress::Rendering(3, 10)),
ExportStatus::Running {
phase: "Rendering",
done: 3,
total: 10
}
);
assert_eq!(
status_for_progress(&EncodeProgress::Encoding(5, 10)),
ExportStatus::Running {
phase: "Encoding",
done: 5,
total: 10
}
);
assert!(status_for_progress(&EncodeProgress::Muxing).is_running());
}
#[test]
fn labels_show_percentage_when_total_known() {
assert_eq!(
export_label(&ExportStatus::Running {
phase: "Rendering",
done: 42,
total: 100
}),
"Rendering… 42%"
);
assert_eq!(
export_label(&ExportStatus::Running {
phase: "Muxing",
done: 0,
total: 0
}),
"Muxing…"
);
assert_eq!(export_label(&ExportStatus::Idle), "Export");
assert_eq!(
export_label(&ExportStatus::Done(PathBuf::from("/x.mp4"))),
"Export"
);
}
}