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