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        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
31// Copied from original lib.rs
32pub 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    /// (current_frame, total_frames)
61    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    /// Calculate and return the simplified aspect ratio (e.g., (16, 9) for 1920x1080)
88    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
102// Common resolutions
103impl Resolution {
104    // 16:9
105    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    // 16:10
110    pub const WXGA: Self = Self::new(1280, 800);
111    pub const WUXGA: Self = Self::new(1920, 1200);
112    // 4:3
113    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    // 1:1
117    pub const _1K_SQUARE: Self = Self::new(1080, 1080);
118    pub const _2K_SQUARE: Self = Self::new(2160, 2160);
119    // 21:9
120    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    // Rendering
141    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
151    depth_visual_pipeline: Option<DepthVisualPipeline>,
152    depth_visual_texture: Option<wgpu::Texture>,
153    depth_visual_view: Option<wgpu::TextureView>,
154
155    // Resolution changed flag
156    resolution_dirty: bool,
157
158    // Export
159    #[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
170    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    /// Set clear color str
235    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    /// Set clear color
245    pub fn set_clear_color(&mut self, color: wgpu::Color) {
246        self.clear_color = color;
247    }
248
249    /// Set preview resolution
250    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    /// Calculate OIT layers based on resolution to stay within GPU buffer limits
258    fn calculate_oit_layers(&self, ctx: &WgpuContext, width: u32, height: u32) -> usize {
259        const BYTES_PER_PIXEL_PER_LAYER: usize = 8; // 4 bytes color + 4 bytes depth
260        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        // Check if we need to recreate renderer
309        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        // Construct WgpuContext using eframe's resources.
329        // NOTE: We assume ranim-render doesn't strictly depend on the instance for the operations we do here.
330        let ctx = WgpuContext {
331            instance: wgpu::Instance::default(), // Dummy instance
332            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        // Init Depth Visual Pipeline
343        if self.depth_visual_pipeline.is_none() {
344            self.depth_visual_pipeline = Some(DepthVisualPipeline::new(&ctx));
345        }
346
347        // Create Depth Visual Texture
348        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        // Register texture with egui
366        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; // Force re-render with new resolution
387    }
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        // Space bar toggles play/pause
518        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        // Arrow keys step forward/back one frame
530        {
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                // Resolution selector
569                {
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                            // 16:9
580                            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                            // 16:10
603                            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                            // 4:3
616                            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                            // 1:1
634                            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                            // 21:9
647                            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                    // |< Jump to start
713                    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                    // < Step back one frame
723                    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                    // Play / Pause
734                    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                    // > Step forward one frame
753                    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                    // >| Jump to end
765                    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                    // Loop toggle
777                    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                    // Speed control
796                    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                // Maintain aspect ratio
828                // TODO: We could update renderer size here if we want dynamic resolution
829                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        // Export (native only)
854        #[cfg(all(not(target_family = "wasm"), feature = "render"))]
855        {
856            // Poll export progress
857            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            // Export configuration dialog
892            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                                    // Show resolved output path preview right below the dir input
956                                    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                        }); // end add_enabled_ui
995
996                        ui.add_space(8.0);
997
998                        // Show progress bar inline when exporting
999                        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                // Don't allow closing the window while exporting
1019                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        // We need to clone title because run_native takes String (or &str) and app is moved into closure
1047
1048        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        // Handling canvas creation if not found to ensure compatibility
1059        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
1095/// Preview a scene
1096pub fn preview_scene(scene: &Scene) {
1097    preview_scene_with_name(scene, &scene.name);
1098}
1099
1100/// Preview a scene with a custom name
1101pub 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// WASM support needs refactoring, mostly keeping it commented or adapting basic entry point.
1112#[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 wrapper: preview a scene (accepts owned [`Scene`] from `find_scene`)
1123    #[wasm_bindgen]
1124    pub fn preview_scene(scene: &Scene) {
1125        super::preview_scene(scene);
1126    }
1127}