Skip to main content

ranim/cmd/preview/
mod.rs

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
30/// Default logic grid resolution (Hz), per the time model design.
31const DEFAULT_LOGIC_FPS: f64 = 120.0;
32
33#[cfg(all(not(target_family = "wasm"), feature = "render"))]
34enum ExportProgress {
35    /// (current_frame, total_frames)
36    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    /// Calculate and return the simplified aspect ratio (e.g., (16, 9) for 1920x1080)
63    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
77// Common resolutions
78impl Resolution {
79    // 16:9
80    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    // 16:10
85    pub const WXGA: Self = Self::new(1280, 800);
86    pub const WUXGA: Self = Self::new(1920, 1200);
87    // 4:3
88    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    // 1:1
92    pub const _1K_SQUARE: Self = Self::new(1080, 1080);
93    pub const _2K_SQUARE: Self = Self::new(2160, 2160);
94    // 21:9
95    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    // Rendering
117    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
127    depth_visual_pipeline: Option<DepthVisualPipeline>,
128    depth_visual_texture: Option<wgpu::Texture>,
129    depth_visual_view: Option<wgpu::TextureView>,
130
131    // Resolution changed flag
132    resolution_dirty: bool,
133
134    // Export
135    #[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
146    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    /// Set clear color str
212    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    /// Set clear color
222    pub fn set_clear_color(&mut self, color: wgpu::Color) {
223        self.clear_color = color;
224    }
225
226    /// Set preview resolution
227    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    /// Calculate OIT layers based on resolution to stay within GPU buffer limits
235    fn calculate_oit_layers(&self, ctx: &WgpuContext, width: u32, height: u32) -> usize {
236        const BYTES_PER_PIXEL_PER_LAYER: usize = 8; // 4 bytes color + 4 bytes depth
237        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        // Check if we need to recreate renderer
286        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        // Construct WgpuContext using eframe's resources.
306        // NOTE: We assume ranim-render doesn't strictly depend on the instance for the operations we do here.
307        let ctx = WgpuContext {
308            instance: wgpu::Instance::default(), // Dummy instance
309            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        // Init Depth Visual Pipeline
320        if self.depth_visual_pipeline.is_none() {
321            self.depth_visual_pipeline = Some(DepthVisualPipeline::new(&ctx));
322        }
323
324        // Create Depth Visual Texture
325        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        // Register texture with egui
343        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; // Force re-render with new resolution
364    }
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            // Forward: advance the logic grid; backward scrub: seek and replay.
380            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        // Space bar toggles play/pause
496        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        // Arrow keys step forward/back one frame
508        {
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                // Resolution selector
547                {
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                            // 16:9
558                            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                            // 16:10
581                            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                            // 4:3
594                            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                            // 1:1
612                            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                            // 21:9
625                            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                    // |< Jump to start
693                    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                    // < Step back one frame
703                    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                    // Play / Pause
714                    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                    // > Step forward one frame
733                    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                    // >| Jump to end
745                    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                    // Loop toggle
757                    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                    // Speed control
776                    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                // Maintain aspect ratio
824                // TODO: We could update renderer size here if we want dynamic resolution
825                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        // Export (native only)
850        #[cfg(all(not(target_family = "wasm"), feature = "render"))]
851        {
852            // Poll export progress
853            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            // Export configuration dialog
888            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                                    // Show resolved output path preview right below the dir input
952                                    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                        }); // end add_enabled_ui
991
992                        ui.add_space(8.0);
993
994                        // Show progress bar inline when exporting
995                        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                // Don't allow closing the window while exporting
1015                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        // We need to clone title because run_native takes String (or &str) and app is moved into closure
1044
1045        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        // Handling canvas creation if not found to ensure compatibility
1056        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
1092/// Preview a scene
1093pub fn preview_scene(scene: &Scene) {
1094    preview_scene_with_name(scene, &scene.name);
1095}
1096
1097/// Preview a scene with a custom name
1098pub 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// WASM support needs refactoring, mostly keeping it commented or adapting basic entry point.
1109#[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 wrapper: preview a scene (accepts owned [`Scene`] from `find_scene`)
1120    #[wasm_bindgen]
1121    pub fn preview_scene(scene: &Scene) {
1122        super::preview_scene(scene);
1123    }
1124}