1mod depth_visual;
2mod timeline;
3
4use std::sync::Arc;
5
6use crate::{
7 Output, Scene, SceneConfig, SceneConstructor,
8 core::{
9 SceneEvaluator,
10 color::{self, LinearSrgb},
11 },
12 render::{Renderer, resource::RenderTextures, utils::WgpuContext, world::RenderFrame},
13};
14#[cfg(all(not(target_family = "wasm"), feature = "render"))]
15use crate::{OutputFormat, cmd::render::file_writer::OutputFormatExt};
16use async_channel::{Receiver, Sender, unbounded};
17use depth_visual::DepthVisualPipeline;
18use eframe::{App, egui};
19use timeline::TimelineState;
20use tracing::{error, info};
21use web_time::Instant;
22
23#[cfg(target_arch = "wasm32")]
24use wasm_bindgen::prelude::*;
25
26pub enum RanimPreviewAppCmd {
27 ReloadScene(Scene, Sender<()>),
28}
29
30const DEFAULT_LOGIC_FPS: f64 = 120.0;
32
33#[cfg(all(not(target_family = "wasm"), feature = "render"))]
34enum ExportProgress {
35 Progress(u64, u64),
37 Done,
38 Error(String),
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ViewMode {
43 Output,
44 Depth,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct Resolution {
49 pub width: u32,
50 pub height: u32,
51}
52
53impl Resolution {
54 pub const fn new(width: u32, height: u32) -> Self {
55 Self { width, height }
56 }
57
58 pub fn ratio(&self) -> f32 {
59 self.width as f32 / self.height as f32
60 }
61
62 pub fn aspect_ratio(&self) -> (u32, u32) {
64 fn gcd(a: u32, b: u32) -> u32 {
65 if b == 0 { a } else { gcd(b, a % b) }
66 }
67 let g = gcd(self.width, self.height);
68 (self.width / g, self.height / g)
69 }
70
71 pub fn aspect_ratio_str(&self) -> String {
72 let (w, h) = self.aspect_ratio();
73 format!("{w}:{h}")
74 }
75}
76
77impl Resolution {
79 pub const HD: Self = Self::new(1280, 720);
81 pub const FHD: Self = Self::new(1920, 1080);
82 pub const QHD: Self = Self::new(2560, 1440);
83 pub const UHD: Self = Self::new(3840, 2160);
84 pub const WXGA: Self = Self::new(1280, 800);
86 pub const WUXGA: Self = Self::new(1920, 1200);
87 pub const SVGA: Self = Self::new(800, 600);
89 pub const XGA: Self = Self::new(1024, 768);
90 pub const SXGA: Self = Self::new(1280, 960);
91 pub const _1K_SQUARE: Self = Self::new(1080, 1080);
93 pub const _2K_SQUARE: Self = Self::new(2160, 2160);
94 pub const UW_QHD: Self = Self::new(3440, 1440);
96}
97
98pub struct RanimPreviewApp {
99 cmd_rx: Receiver<RanimPreviewAppCmd>,
100 pub cmd_tx: Sender<RanimPreviewAppCmd>,
101 #[allow(unused)]
102 title: String,
103 clear_color: wgpu::Color,
104 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
105 scene_constructor: Arc<dyn SceneConstructor>,
106 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
107 scene_config: SceneConfig,
108 resolution: Resolution,
109 evaluator: SceneEvaluator,
110 need_eval: bool,
111 last_sec: f64,
112 store: RenderFrame,
113 timeline_state: TimelineState,
114 play_prev_t: Option<Instant>,
115
116 renderer: Option<Renderer>,
118 render_textures: Option<RenderTextures>,
119 texture_id: Option<egui::TextureId>,
120 depth_texture_id: Option<egui::TextureId>,
121 view_mode: ViewMode,
122 wgpu_ctx: Option<WgpuContext>,
123 last_render_time: Option<std::time::Duration>,
124 last_eval_time: Option<std::time::Duration>,
125
126 depth_visual_pipeline: Option<DepthVisualPipeline>,
128 depth_visual_texture: Option<wgpu::Texture>,
129 depth_visual_view: Option<wgpu::TextureView>,
130
131 resolution_dirty: bool,
133
134 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
136 export_dialog_open: bool,
137 export_config: Output,
138 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
139 export_progress_rx: Option<Receiver<ExportProgress>>,
140 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
141 export_current_frame: u64,
142 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
143 export_total_frames: u64,
144
145 playback_speed: f64,
147 looping: bool,
148}
149
150impl RanimPreviewApp {
151 pub fn new(
152 scene_constructor: impl SceneConstructor + 'static,
153 title: String,
154 scene_config: SceneConfig,
155 ) -> Self {
156 let t = Instant::now();
157 let scene_constructor = Arc::new(scene_constructor);
158
159 info!("building scene...");
160 let timeline = scene_constructor.build_scene();
161 info!("Scene built, cost: {:?}", t.elapsed());
162
163 info!("Getting timelines info...");
164 let animation_infos = timeline.get_animation_infos();
165 let total_secs = timeline.total_secs();
166 let evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
167 info!("Total {} root animations", animation_infos.len());
168
169 let (cmd_tx, cmd_rx) = unbounded();
170
171 Self {
172 cmd_rx,
173 cmd_tx,
174 title,
175 clear_color: wgpu::Color::TRANSPARENT,
176 scene_constructor,
177 scene_config,
178 resolution: Resolution::QHD,
179 timeline_state: TimelineState::new(total_secs, animation_infos),
180 evaluator,
181 need_eval: false,
182 last_sec: -1.0,
183 store: RenderFrame::default(),
184 play_prev_t: None,
185 renderer: None,
186 render_textures: None,
187 texture_id: None,
188 depth_texture_id: None,
189 view_mode: ViewMode::Output,
190 wgpu_ctx: None,
191 last_render_time: None,
192 last_eval_time: None,
193 depth_visual_pipeline: None,
194 depth_visual_texture: None,
195 depth_visual_view: None,
196 resolution_dirty: false,
197 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
198 export_dialog_open: false,
199 export_config: Output::default(),
200 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
201 export_progress_rx: None,
202 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
203 export_current_frame: 0,
204 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
205 export_total_frames: 0,
206 playback_speed: 1.0,
207 looping: false,
208 }
209 }
210
211 pub fn set_clear_color_str(&mut self, color: &str) {
213 let bg = color::try_color(color)
214 .unwrap_or(color::color("#333333ff"))
215 .convert::<LinearSrgb>();
216 let [r, g, b, a] = bg.components.map(|x| x as f64);
217 let clear_color = wgpu::Color { r, g, b, a };
218 self.set_clear_color(clear_color);
219 }
220
221 pub fn set_clear_color(&mut self, color: wgpu::Color) {
223 self.clear_color = color;
224 }
225
226 pub fn set_resolution(&mut self, resolution: Resolution) {
228 if self.resolution != resolution {
229 self.resolution = resolution;
230 self.resolution_dirty = true;
231 }
232 }
233
234 fn calculate_oit_layers(&self, ctx: &WgpuContext, width: u32, height: u32) -> usize {
236 const BYTES_PER_PIXEL_PER_LAYER: usize = 8; const MAX_OIT_LAYERS: usize = 8;
238
239 let limits = ctx.device.limits();
240 let max_buffer_size = limits.max_storage_buffer_binding_size as usize;
241 let pixel_count = (width * height) as usize;
242 let max_layers_by_buffer = max_buffer_size / (pixel_count * BYTES_PER_PIXEL_PER_LAYER);
243 let oit_layers = max_layers_by_buffer.clamp(1, MAX_OIT_LAYERS);
244
245 if oit_layers < MAX_OIT_LAYERS {
246 tracing::warn!(
247 "OIT layers reduced from {} to {} due to GPU buffer size limit ({}MB @ {}x{})",
248 MAX_OIT_LAYERS,
249 oit_layers,
250 max_buffer_size / 1024 / 1024,
251 width,
252 height
253 );
254 }
255
256 oit_layers
257 }
258
259 fn handle_events(&mut self) {
260 if let Ok(cmd) = self.cmd_rx.try_recv() {
261 match cmd {
262 RanimPreviewAppCmd::ReloadScene(scene, tx) => {
263 let timeline = scene.constructor.build_scene();
264 let animation_infos = timeline.get_animation_infos();
265 let old_cur_second = self.timeline_state.current_sec;
266 self.timeline_state =
267 TimelineState::new(timeline.total_secs(), animation_infos);
268 self.timeline_state.current_sec =
269 old_cur_second.clamp(0.0, self.timeline_state.total_sec);
270 self.evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
271 self.store.update(std::iter::empty());
272 self.need_eval = true;
273
274 self.set_clear_color_str(&scene.config.clear_color);
275
276 if let Err(err) = tx.try_send(()) {
277 error!("Failed to send reloaded signal: {err:?}");
278 }
279 }
280 }
281 }
282 }
283
284 fn prepare_renderer(&mut self, frame: &eframe::Frame) {
285 let needs_init = self.renderer.is_none();
287 let needs_resize = self.resolution_dirty && self.renderer.is_some();
288
289 if !needs_init && !needs_resize {
290 return;
291 }
292
293 let Some(render_state) = frame.wgpu_render_state() else {
294 tracing::info!("frame.wgpu_render_state() is none");
295 tracing::info!("{:?}", frame.info());
296 return;
297 };
298
299 if needs_init {
300 tracing::info!("preparing renderer...");
301 } else if needs_resize {
302 tracing::info!("recreating renderer for resolution change...");
303 }
304
305 let ctx = WgpuContext {
308 instance: wgpu::Instance::default(), adapter: wgpu::Adapter::clone(&render_state.adapter),
310 device: wgpu::Device::clone(&render_state.device),
311 queue: wgpu::Queue::clone(&render_state.queue),
312 };
313
314 let (width, height) = (self.resolution.width, self.resolution.height);
315 let oit_layers = self.calculate_oit_layers(&ctx, width, height);
316 let renderer = Renderer::new(&ctx, width, height, oit_layers);
317 let render_textures = renderer.new_render_textures(&ctx);
318
319 if self.depth_visual_pipeline.is_none() {
321 self.depth_visual_pipeline = Some(DepthVisualPipeline::new(&ctx));
322 }
323
324 let depth_visual_texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
326 label: Some("Depth Visual Texture"),
327 size: wgpu::Extent3d {
328 width: render_textures.width(),
329 height: render_textures.height(),
330 depth_or_array_layers: 1,
331 },
332 mip_level_count: 1,
333 sample_count: 1,
334 dimension: wgpu::TextureDimension::D2,
335 format: wgpu::TextureFormat::Rgba8Unorm,
336 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
337 view_formats: &[],
338 });
339 let depth_visual_view =
340 depth_visual_texture.create_view(&wgpu::TextureViewDescriptor::default());
341
342 let texture_view = &render_textures.linear_render_view;
344 let texture_id = render_state.renderer.write().register_native_texture(
345 &render_state.device,
346 texture_view,
347 wgpu::FilterMode::Linear,
348 );
349 let depth_id = render_state.renderer.write().register_native_texture(
350 &render_state.device,
351 &depth_visual_view,
352 wgpu::FilterMode::Nearest,
353 );
354
355 self.texture_id = Some(texture_id);
356 self.depth_texture_id = Some(depth_id);
357 self.depth_visual_texture = Some(depth_visual_texture);
358 self.depth_visual_view = Some(depth_visual_view);
359 self.render_textures = Some(render_textures);
360 self.renderer = Some(renderer);
361 self.wgpu_ctx = Some(ctx);
362 self.resolution_dirty = false;
363 self.need_eval = true; }
365
366 fn render_animation(&mut self) {
367 if let (Some(ctx), Some(renderer), Some(render_textures)) = (
368 self.wgpu_ctx.as_ref(),
369 self.renderer.as_mut(),
370 self.render_textures.as_mut(),
371 ) {
372 if self.last_sec == self.timeline_state.current_sec && !self.need_eval {
373 return;
374 }
375 self.need_eval = false;
376 self.last_sec = self.timeline_state.current_sec;
377
378 let start_eval = Instant::now();
379 let target = self.timeline_state.current_sec;
381 if target < self.evaluator.clock() {
382 self.evaluator.seek(target);
383 } else {
384 self.evaluator.advance_to(target);
385 }
386 let mut frame_items = Vec::new();
387 self.evaluator.sample_into(&mut frame_items);
388 self.store.update(frame_items.into_iter());
389 self.last_eval_time = Some(start_eval.elapsed());
390
391 let start = Instant::now();
392 renderer.render_frame(render_textures, self.clear_color, &self.store);
393
394 if let (Some(pipeline), Some(view)) = (
395 self.depth_visual_pipeline.as_ref(),
396 self.depth_visual_view.as_ref(),
397 ) {
398 let mut encoder =
399 ctx.device
400 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
401 label: Some("Depth Visual Encoder"),
402 });
403
404 let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
405 label: Some("Depth Visual Bind Group"),
406 layout: &pipeline.bind_group_layout,
407 entries: &[wgpu::BindGroupEntry {
408 binding: 0,
409 resource: wgpu::BindingResource::TextureView(
410 &render_textures.depth_texture_view,
411 ),
412 }],
413 });
414
415 {
416 let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
417 label: Some("Depth Visual Pass"),
418 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
419 view,
420 resolve_target: None,
421 depth_slice: None,
422 ops: wgpu::Operations {
423 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
424 store: wgpu::StoreOp::Store,
425 },
426 })],
427 depth_stencil_attachment: None,
428 timestamp_writes: None,
429 occlusion_query_set: None,
430 multiview_mask: None,
431 });
432 rpass.set_pipeline(&pipeline.pipeline);
433 rpass.set_bind_group(0, &bind_group, &[]);
434 rpass.draw(0..3, 0..1);
435 }
436 ctx.queue.submit(Some(encoder.finish()));
437 }
438
439 self.last_render_time = Some(start.elapsed());
440 }
441 }
442
443 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
444 fn start_export(&mut self, ctx: egui::Context) {
445 let (progress_tx, progress_rx) = unbounded();
446 self.export_progress_rx = Some(progress_rx);
447
448 let constructor = self.scene_constructor.clone();
449 let scene_config = self.scene_config.clone();
450 let output = self.export_config.clone();
451 let name = self.title.clone();
452
453 std::thread::spawn(move || {
454 let progress_tx_cb = progress_tx.clone();
455 let ctx_cb = ctx.clone();
456 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
457 crate::cmd::render::render_scene_output_with_progress(
458 constructor,
459 name,
460 &scene_config,
461 &output,
462 2,
463 Some(Box::new(move |current, total| {
464 let _ =
465 progress_tx_cb.send_blocking(ExportProgress::Progress(current, total));
466 ctx_cb.request_repaint();
467 })),
468 );
469
470 let _ = progress_tx.send_blocking(ExportProgress::Done);
471 ctx.request_repaint();
472 }));
473
474 if let Err(e) = result {
475 let msg = if let Some(s) = e.downcast_ref::<&str>() {
476 s.to_string()
477 } else if let Some(s) = e.downcast_ref::<String>() {
478 s.clone()
479 } else {
480 "Unknown export error".to_string()
481 };
482 let _ = progress_tx.send_blocking(ExportProgress::Error(msg));
483 ctx.request_repaint();
484 }
485 });
486 }
487}
488
489impl eframe::App for RanimPreviewApp {
490 fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
491 let ctx = ui.ctx().clone();
492 self.prepare_renderer(frame);
493 self.handle_events();
494
495 if ctx.input(|i| i.key_pressed(egui::Key::Space)) {
497 if self.play_prev_t.is_some() {
498 self.play_prev_t = None;
499 } else {
500 if self.timeline_state.current_sec >= self.timeline_state.total_sec {
501 self.timeline_state.current_sec = 0.0;
502 }
503 self.play_prev_t = Some(Instant::now());
504 }
505 }
506
507 {
509 let frame_dur = 1.0 / self.export_config.fps as f64;
510 if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
511 self.play_prev_t = None;
512 self.timeline_state.current_sec =
513 (self.timeline_state.current_sec - frame_dur).max(0.0);
514 }
515 if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
516 self.play_prev_t = None;
517 self.timeline_state.current_sec = (self.timeline_state.current_sec + frame_dur)
518 .min(self.timeline_state.total_sec);
519 }
520 }
521
522 if let Some(play_prev_t) = self.play_prev_t {
523 let elapsed = play_prev_t.elapsed().as_secs_f64() * self.playback_speed;
524 self.timeline_state.current_sec =
525 (self.timeline_state.current_sec + elapsed).min(self.timeline_state.total_sec);
526 if self.timeline_state.current_sec >= self.timeline_state.total_sec {
527 if self.looping {
528 self.timeline_state.current_sec = 0.0;
529 self.play_prev_t = Some(Instant::now());
530 ctx.request_repaint();
531 } else {
532 self.play_prev_t = None;
533 }
534 } else {
535 self.play_prev_t = Some(Instant::now());
536 ctx.request_repaint();
537 }
538 }
539
540 self.render_animation();
541
542 egui::Panel::top("top_panel").show(ui, |ui| {
543 ui.horizontal(|ui| {
544 ui.heading(&self.title);
545
546 {
548 let resolution = self.resolution;
549 egui::ComboBox::from_label("Resolution")
550 .selected_text(format!(
551 "{}x{} ({})",
552 resolution.width,
553 resolution.height,
554 resolution.aspect_ratio_str()
555 ))
556 .show_ui(ui, |ui| {
557 ui.label(egui::RichText::new("16:9").strong());
559 ui.selectable_value(
560 &mut self.resolution,
561 Resolution::HD,
562 "1280x720 (HD)",
563 );
564 ui.selectable_value(
565 &mut self.resolution,
566 Resolution::FHD,
567 "1920x1080 (FHD)",
568 );
569 ui.selectable_value(
570 &mut self.resolution,
571 Resolution::QHD,
572 "2560x1440 (QHD)",
573 );
574 ui.selectable_value(
575 &mut self.resolution,
576 Resolution::UHD,
577 "3840x2160 (UHD)",
578 );
579 ui.separator();
580 ui.label(egui::RichText::new("16:10").strong());
582 ui.selectable_value(
583 &mut self.resolution,
584 Resolution::WXGA,
585 "1280x800 (WXGA)",
586 );
587 ui.selectable_value(
588 &mut self.resolution,
589 Resolution::WUXGA,
590 "1920x1200 (WUXGA)",
591 );
592 ui.separator();
593 ui.label(egui::RichText::new("4:3").strong());
595 ui.selectable_value(
596 &mut self.resolution,
597 Resolution::SVGA,
598 "800x600 (SVGA)",
599 );
600 ui.selectable_value(
601 &mut self.resolution,
602 Resolution::XGA,
603 "1024x768 (XGA)",
604 );
605 ui.selectable_value(
606 &mut self.resolution,
607 Resolution::SXGA,
608 "1280x960 (SXGA)",
609 );
610 ui.separator();
611 ui.label(egui::RichText::new("1:1").strong());
613 ui.selectable_value(
614 &mut self.resolution,
615 Resolution::_1K_SQUARE,
616 "1080x1080",
617 );
618 ui.selectable_value(
619 &mut self.resolution,
620 Resolution::_2K_SQUARE,
621 "2160x2160",
622 );
623 ui.separator();
624 ui.label(egui::RichText::new("21:9").strong());
626 ui.selectable_value(
627 &mut self.resolution,
628 Resolution::UW_QHD,
629 "3440x1440 (UW-QHD)",
630 );
631 });
632 if self.resolution != resolution {
633 self.resolution_dirty = true;
634 }
635 }
636
637 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
638 let dark_mode = ui.visuals().dark_mode;
639 let button_text = if dark_mode {
640 format!("{} Light", egui_phosphor::regular::SUN)
641 } else {
642 format!("{} Dark", egui_phosphor::regular::MOON)
643 };
644 if ui.button(button_text).clicked() {
645 if dark_mode {
646 ctx.set_visuals(egui::Visuals::light());
647 } else {
648 ctx.set_visuals(egui::Visuals::dark());
649 }
650 }
651
652 ui.separator();
653 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
654 {
655 let exporting = self.export_progress_rx.is_some();
656 if ui
657 .add_enabled(!exporting, egui::Button::new("Export"))
658 .clicked()
659 {
660 self.export_dialog_open = true;
661 }
662 ui.separator();
663 }
664 ui.selectable_value(&mut self.view_mode, ViewMode::Output, "Output");
665 ui.selectable_value(&mut self.view_mode, ViewMode::Depth, "Depth");
666 ui.separator();
667
668 if let Some(duration) = self.last_render_time {
669 ui.label(format!("Render: {:.2}ms", duration.as_secs_f64() * 1000.0));
670 ui.separator();
671 }
672 if let Some(duration) = self.last_eval_time {
673 ui.label(format!("Eval: {:.2}ms", duration.as_secs_f64() * 1000.0));
674 ui.separator();
675 }
676 });
677 });
678 });
679
680 egui::Panel::bottom("bottom_panel")
681 .resizable(true)
682 .default_size(240.0)
683 .min_size(150.0)
684 .max_size(600.0)
685 .show(ui, |ui| {
686 ui.label("Timeline");
687
688 ui.horizontal(|ui| {
689 let fps = self.export_config.fps as f64;
690 let frame_dur = 1.0 / fps;
691
692 if ui
694 .button(egui_phosphor::regular::SKIP_BACK)
695 .on_hover_text("Jump to start")
696 .clicked()
697 {
698 self.timeline_state.current_sec = 0.0;
699 self.play_prev_t = None;
700 }
701
702 if ui
704 .button(egui_phosphor::regular::CARET_LEFT)
705 .on_hover_text("Step back one frame")
706 .clicked()
707 {
708 self.play_prev_t = None;
709 self.timeline_state.current_sec =
710 (self.timeline_state.current_sec - frame_dur).max(0.0);
711 }
712
713 let is_playing = self.play_prev_t.is_some();
715 let play_label = if is_playing {
716 egui_phosphor::regular::PAUSE
717 } else {
718 egui_phosphor::regular::PLAY
719 };
720 let play_tooltip = if is_playing { "Pause" } else { "Play" };
721 if ui.button(play_label).on_hover_text(play_tooltip).clicked() {
722 if is_playing {
723 self.play_prev_t = None;
724 } else {
725 if self.timeline_state.current_sec >= self.timeline_state.total_sec {
726 self.timeline_state.current_sec = 0.0;
727 }
728 self.play_prev_t = Some(Instant::now());
729 }
730 }
731
732 if ui
734 .button(egui_phosphor::regular::CARET_RIGHT)
735 .on_hover_text("Step forward one frame")
736 .clicked()
737 {
738 self.play_prev_t = None;
739 self.timeline_state.current_sec = (self.timeline_state.current_sec
740 + frame_dur)
741 .min(self.timeline_state.total_sec);
742 }
743
744 if ui
746 .button(egui_phosphor::regular::SKIP_FORWARD)
747 .on_hover_text("Jump to end")
748 .clicked()
749 {
750 self.timeline_state.current_sec = self.timeline_state.total_sec;
751 self.play_prev_t = None;
752 }
753
754 ui.separator();
755
756 let mut loop_btn = egui::Button::new(egui_phosphor::regular::ARROWS_CLOCKWISE);
758 if self.looping {
759 loop_btn = loop_btn.fill(ui.visuals().selection.bg_fill);
760 }
761 if ui
762 .add(loop_btn)
763 .on_hover_text(if self.looping {
764 "Looping: ON"
765 } else {
766 "Looping: OFF"
767 })
768 .clicked()
769 {
770 self.looping = !self.looping;
771 }
772
773 ui.separator();
774
775 let drag_speed = (self.playback_speed * 0.02).max(0.01);
777 ui.add(
778 egui::DragValue::new(&mut self.playback_speed)
779 .speed(drag_speed)
780 .range(0.1..=10.0)
781 .suffix("x"),
782 )
783 .on_hover_text("Playback speed");
784
785 ui.separator();
786
787 const TIME_INPUT_WIDTH: f32 = 86.0;
788 let slider_width =
789 (ui.available_width() - TIME_INPUT_WIDTH - ui.spacing().item_spacing.x)
790 .max(40.0);
791 ui.scope(|ui| {
792 ui.style_mut().spacing.slider_width = slider_width;
793 ui.add(
794 egui::Slider::new(
795 &mut self.timeline_state.current_sec,
796 0.0..=self.timeline_state.total_sec,
797 )
798 .show_value(false),
799 )
800 .on_hover_text("Timeline position");
801 });
802 ui.add_sized(
803 [TIME_INPUT_WIDTH, 18.0],
804 egui::DragValue::new(&mut self.timeline_state.current_sec)
805 .range(0.0..=self.timeline_state.total_sec)
806 .speed(0.001)
807 .fixed_decimals(3)
808 .suffix(" s"),
809 )
810 .on_hover_text("Drag or enter the current time");
811 });
812
813 self.timeline_state.ui_main_timeline(ui);
814 });
815
816 egui::CentralPanel::default().show(ui, |ui| {
817 let texture_id = match self.view_mode {
818 ViewMode::Output => self.texture_id,
819 ViewMode::Depth => self.depth_texture_id,
820 };
821
822 if let Some(tid) = texture_id {
823 let available_size = ui.available_size();
826 let aspect_ratio = self
827 .render_textures
828 .as_ref()
829 .map(|rt| rt.ratio())
830 .unwrap_or(1280.0 / 7.0);
831 let mut size = available_size;
832
833 if size.x / size.y > aspect_ratio {
834 size.x = size.y * aspect_ratio;
835 } else {
836 size.y = size.x / aspect_ratio;
837 }
838
839 ui.centered_and_justified(|ui| {
840 ui.image(egui::load::SizedTexture::new(tid, size));
841 });
842 } else {
843 ui.centered_and_justified(|ui| {
844 ui.spinner();
845 });
846 }
847 });
848
849 #[cfg(all(not(target_family = "wasm"), feature = "render"))]
851 {
852 if let Some(rx) = &self.export_progress_rx {
854 let mut done = false;
855 let mut error_msg = None;
856
857 while let Ok(msg) = rx.try_recv() {
858 match msg {
859 ExportProgress::Progress(current, total) => {
860 self.export_current_frame = current;
861 self.export_total_frames = total;
862 }
863 ExportProgress::Done => {
864 done = true;
865 }
866 ExportProgress::Error(err) => {
867 error_msg = Some(err);
868 done = true;
869 }
870 }
871 }
872
873 if done {
874 self.export_progress_rx = None;
875 self.export_current_frame = 0;
876 self.export_total_frames = 0;
877 if let Some(err) = error_msg {
878 error!("Export failed: {err}");
879 } else {
880 info!("Export completed");
881 }
882 } else {
883 ctx.request_repaint();
884 }
885 }
886
887 let exporting = self.export_progress_rx.is_some();
889 if self.export_dialog_open || exporting {
890 let mut open = self.export_dialog_open;
891 egui::Window::new("Export")
892 .open(&mut open)
893 .resizable(false)
894 .show(&ctx, |ui| {
895 ui.add_enabled_ui(!exporting, |ui| {
896 egui::Grid::new("export_grid")
897 .num_columns(2)
898 .show(ui, |ui| {
899 ui.label("Width:");
900 ui.add(
901 egui::DragValue::new(&mut self.export_config.width)
902 .range(1..=7680),
903 );
904 ui.end_row();
905
906 ui.label("Height:");
907 ui.add(
908 egui::DragValue::new(&mut self.export_config.height)
909 .range(1..=4320),
910 );
911 ui.end_row();
912
913 ui.label("FPS:");
914 ui.add(
915 egui::DragValue::new(&mut self.export_config.fps)
916 .range(1..=240),
917 );
918 ui.end_row();
919
920 ui.label("Format:");
921 egui::ComboBox::from_id_salt("export_format")
922 .selected_text(format!("{}", self.export_config.format))
923 .show_ui(ui, |ui| {
924 ui.selectable_value(
925 &mut self.export_config.format,
926 OutputFormat::Mp4,
927 "mp4",
928 );
929 ui.selectable_value(
930 &mut self.export_config.format,
931 OutputFormat::Webm,
932 "webm",
933 );
934 ui.selectable_value(
935 &mut self.export_config.format,
936 OutputFormat::Mov,
937 "mov",
938 );
939 ui.selectable_value(
940 &mut self.export_config.format,
941 OutputFormat::Gif,
942 "gif",
943 );
944 });
945 ui.end_row();
946
947 ui.label("Output dir:");
948 ui.text_edit_singleline(&mut self.export_config.dir);
949 ui.end_row();
950
951 ui.label("");
953 {
954 let mut output_dir =
955 std::path::PathBuf::from(&self.export_config.dir);
956 if !output_dir.is_absolute() {
957 output_dir = std::env::current_dir()
958 .unwrap_or_default()
959 .join(&output_dir);
960 }
961 let (_, _, ext) =
962 self.export_config.format.encoding_params();
963 let name = self
964 .export_config
965 .name
966 .as_deref()
967 .unwrap_or(&self.title);
968 let file_path = output_dir.join(format!(
969 "{}_{}x{}_{}.{ext}",
970 name,
971 self.export_config.width,
972 self.export_config.height,
973 self.export_config.fps,
974 ));
975 ui.label(
976 egui::RichText::new(format!(
977 "-> {}",
978 file_path.display()
979 ))
980 .small()
981 .color(ui.visuals().weak_text_color()),
982 );
983 }
984 ui.end_row();
985
986 ui.label("Save frames:");
987 ui.checkbox(&mut self.export_config.save_frames, "");
988 ui.end_row();
989 });
990 }); ui.add_space(8.0);
993
994 if exporting {
996 let current = self.export_current_frame;
997 let total = self.export_total_frames;
998 if total > 0 {
999 let progress = current as f32 / total as f32;
1000 ui.add(egui::ProgressBar::new(progress).text(format!(
1001 "{current}/{total} frames ({:.0}%)",
1002 progress * 100.0
1003 )));
1004 } else {
1005 ui.horizontal(|ui| {
1006 ui.spinner();
1007 ui.label("Preparing...");
1008 });
1009 }
1010 } else if ui.button("Start Export").clicked() {
1011 self.start_export(ctx.clone());
1012 }
1013 });
1014 if !exporting {
1016 self.export_dialog_open = open;
1017 }
1018 }
1019 }
1020 }
1021}
1022
1023pub fn run_app(app: RanimPreviewApp, #[cfg(target_arch = "wasm32")] container_id: String) {
1024 #[cfg(not(target_arch = "wasm32"))]
1025 let title = app.title.clone();
1026 let build_app = |cc: &eframe::CreationContext| {
1027 let mut fonts = egui::FontDefinitions::default();
1028 egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
1029 cc.egui_ctx.set_fonts(fonts);
1030 Ok(Box::new(app) as Box<dyn App>)
1031 };
1032
1033 #[cfg(not(target_family = "wasm"))]
1034 {
1035 let native_options = eframe::NativeOptions {
1036 viewport: egui::ViewportBuilder::default()
1037 .with_title(&title)
1038 .with_inner_size([1280.0, 720.0]),
1039 renderer: eframe::Renderer::Wgpu,
1040 ..Default::default()
1041 };
1042
1043 eframe::run_native(&title, native_options, Box::new(build_app)).unwrap();
1046 }
1047
1048 #[cfg(target_arch = "wasm32")]
1049 {
1050 use wasm_bindgen::JsCast;
1051 let web_options = eframe::WebOptions {
1052 ..Default::default()
1053 };
1054
1055 let document = web_sys::window().unwrap().document().unwrap();
1057 let canvas = document
1058 .get_element_by_id(&container_id)
1059 .and_then(|c| c.dyn_into::<web_sys::HtmlCanvasElement>().ok());
1060
1061 let canvas = if let Some(canvas) = canvas {
1062 canvas
1063 } else {
1064 let canvas = document.create_element("canvas").unwrap();
1065 canvas.set_id(&container_id);
1066 document.body().unwrap().append_child(&canvas).unwrap();
1067 canvas.dyn_into::<web_sys::HtmlCanvasElement>().unwrap()
1068 };
1069
1070 wasm_bindgen_futures::spawn_local(async {
1071 eframe::WebRunner::new()
1072 .start(canvas, web_options, Box::new(build_app))
1073 .await
1074 .expect("failed to start eframe");
1075 });
1076 }
1077}
1078
1079pub fn preview_constructor_with_name(
1080 scene: impl SceneConstructor + 'static,
1081 name: &str,
1082 scene_config: &SceneConfig,
1083) {
1084 let app = RanimPreviewApp::new(scene, name.to_string(), scene_config.clone());
1085 run_app(
1086 app,
1087 #[cfg(target_arch = "wasm32")]
1088 format!("ranim-app-{name}"),
1089 );
1090}
1091
1092pub fn preview_scene(scene: &Scene) {
1094 preview_scene_with_name(scene, &scene.name);
1095}
1096
1097pub fn preview_scene_with_name(scene: &Scene, name: &str) {
1099 let mut app = RanimPreviewApp::new(scene.constructor, name.to_string(), scene.config.clone());
1100 app.set_clear_color_str(&scene.config.clear_color);
1101 run_app(
1102 app,
1103 #[cfg(target_arch = "wasm32")]
1104 format!("ranim-app-{name}"),
1105 );
1106}
1107
1108#[cfg(target_arch = "wasm32")]
1110mod wasm {
1111 use super::*;
1112
1113 #[wasm_bindgen(start)]
1114 pub async fn wasm_start() {
1115 console_error_panic_hook::set_once();
1116 wasm_tracing::set_as_global_default();
1117 }
1118
1119 #[wasm_bindgen]
1121 pub fn preview_scene(scene: &Scene) {
1122 super::preview_scene(scene);
1123 }
1124}