Skip to content

Commit ece0faa

Browse files
authored
feat(studio): export video from the topbar (#36)
Closes #15. CLI-identical encode pipeline on a panic-fenced background thread, real progress in the topbar, output next to the source, single-export guarantee.
1 parent cd76b22 commit ece0faa

3 files changed

Lines changed: 336 additions & 6 deletions

File tree

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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+
}

crates/rustmotion-studio/src/editor/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//! inspector, and the comments/annotations panel.
33
44
mod annotations;
5+
mod export;
56
pub mod frames;
67
mod inspector;
78
mod playback;

crates/rustmotion-studio/src/editor/topbar.rs

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
use dioxus::prelude::*;
2-
use dioxus_icons::lucide::{ChevronLeft, Eye, MessageSquare, Monitor, Moon, Play, Sun};
2+
use dioxus_icons::lucide::{ChevronLeft, Download, Eye, MessageSquare, Monitor, Moon, Play, Sun};
33

44
use crate::components::button::{Button, ButtonSize, ButtonVariant};
5-
use crate::scenario::{Theme, View};
5+
use crate::scenario::{Shared, Theme, View};
6+
7+
use super::export::{export_label, export_slot, start_export, use_export_poll, ExportStatus};
68

79
/// The editor's top bar (open-slide style): a back-to-library control on the
810
/// left, the centered document title, and the action cluster on the right
9-
/// (theme swap, Inspect overlay toggle, Comments panel toggle, and Present).
10-
/// `write_error` is `Some` when the last inspector write failed; shown as a
11-
/// discrete warning indicator using the `--rm-error` token.
11+
/// (theme swap, Inspect overlay toggle, Comments panel toggle, Export, and
12+
/// Present). `write_error` is `Some` when the last inspector write failed;
13+
/// shown as a discrete warning indicator using the `--rm-error` token. The
14+
/// export state (slot + polling) lives here too — the topbar is its only
15+
/// consumer.
1216
#[component]
1317
pub fn TopBar(
1418
view: Signal<View>,
@@ -20,11 +24,20 @@ pub fn TopBar(
2024
comment_count: usize,
2125
write_error: Option<String>,
2226
) -> Element {
27+
let shared = use_context::<Shared>();
2328
let mut theme = use_context::<Signal<Theme>>();
2429
let inspecting = show_hits();
2530
let commenting = show_annotations();
2631
let current_theme = theme();
2732

33+
// Export state: the cross-thread slot the encode thread writes, and the
34+
// polled signal that drives the button label / status text.
35+
let export = use_hook(export_slot);
36+
let mut export_status = use_signal(|| ExportStatus::Idle);
37+
use_export_poll(export.clone(), export_status);
38+
let status = export_status();
39+
let exporting = status.is_running();
40+
2841
rsx! {
2942
div { style: "position:relative; display:flex; align-items:center; justify-content:space-between; height:40px; padding:0 12px; border-bottom:1px solid var(--rm-border); background:var(--rm-surface-2); flex:none;",
3043
div { style: "display:flex; align-items:center; gap:8px; z-index:1;",
@@ -42,7 +55,7 @@ pub fn TopBar(
4255
"{title}"
4356
}
4457

45-
// ── Right: write-error indicator + actions ───────────────
58+
// ── Right: indicators + actions ──────────────────────────
4659
div { style: "display:flex; align-items:center; gap:6px; z-index:1;",
4760
if let Some(ref msg) = write_error {
4861
span {
@@ -51,6 +64,23 @@ pub fn TopBar(
5164
"Changes not saved: {msg}"
5265
}
5366
}
67+
match &status {
68+
ExportStatus::Done(path) => rsx! {
69+
span {
70+
title: "{path.display()}",
71+
style: "color:var(--rm-text-muted); font-size:11px; white-space:nowrap; max-width:240px; overflow:hidden; text-overflow:ellipsis;",
72+
"Exported: {path.display()}"
73+
}
74+
},
75+
ExportStatus::Failed(reason) => rsx! {
76+
span {
77+
title: "{reason}",
78+
style: "color:var(--rm-error); font-size:11px; white-space:nowrap; max-width:240px; overflow:hidden; text-overflow:ellipsis;",
79+
"Export failed: {reason}"
80+
}
81+
},
82+
_ => rsx! {},
83+
}
5484
Button {
5585
variant: ButtonVariant::Outline,
5686
size: ButtonSize::IconSm,
@@ -81,6 +111,28 @@ pub fn TopBar(
81111
}
82112
}
83113
}
114+
Button {
115+
variant: ButtonVariant::Secondary,
116+
size: ButtonSize::Sm,
117+
disabled: exporting,
118+
onclick: {
119+
let shared = shared.clone();
120+
let export = export.clone();
121+
move |_| {
122+
if !exporting {
123+
start_export(&shared, &export);
124+
// Instant feedback; the poll refines it within 150 ms.
125+
export_status.set(ExportStatus::Running {
126+
phase: "Rendering",
127+
done: 0,
128+
total: 0,
129+
});
130+
}
131+
}
132+
},
133+
Download { size: 14 }
134+
"{export_label(&status)}"
135+
}
84136
Button {
85137
variant: ButtonVariant::Primary,
86138
size: ButtonSize::Sm,

0 commit comments

Comments
 (0)