Skip to main content

ranim/cmd/preview/
mod.rs

1#[cfg(not(target_family = "wasm"))]
2mod audio;
3mod depth_visual;
4mod playback;
5mod profiler;
6mod timeline;
7
8use std::sync::Arc;
9
10use crate::{
11    Output, Scene, SceneConfig, SceneConstructor,
12    core::{
13        SceneEvaluator,
14        color::{self, LinearSrgb},
15    },
16    render::{Renderer, resource::RenderTextures, utils::WgpuContext, world::RenderFrame},
17};
18#[cfg(all(not(target_family = "wasm"), feature = "render"))]
19use crate::{OutputFormat, cmd::render::file_writer::OutputFormatExt};
20use async_channel::{Receiver, Sender, unbounded};
21use depth_visual::DepthVisualPipeline;
22use eframe::{App, egui};
23use playback::PlaybackEngine;
24use timeline::TimelineState;
25use tracing::{error, info};
26use web_time::Instant;
27
28#[cfg(target_arch = "wasm32")]
29use wasm_bindgen::prelude::*;
30
31pub enum RanimPreviewAppCmd {
32    ReloadScene(Scene, Sender<()>),
33}
34
35/// Default logic grid resolution (Hz), per the time model design.
36pub(crate) const DEFAULT_LOGIC_FPS: f64 = 120.0;
37
38#[cfg(all(not(target_family = "wasm"), feature = "render"))]
39enum ExportProgress {
40    /// (current_frame, total_frames)
41    Progress(u64, u64),
42    Done,
43    Error(String),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ViewMode {
48    Output,
49    Depth,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct Resolution {
54    pub width: u32,
55    pub height: u32,
56}
57
58impl Resolution {
59    pub const fn new(width: u32, height: u32) -> Self {
60        Self { width, height }
61    }
62
63    pub fn ratio(&self) -> f32 {
64        self.width as f32 / self.height as f32
65    }
66
67    /// Calculate and return the simplified aspect ratio (e.g., (16, 9) for 1920x1080)
68    pub fn aspect_ratio(&self) -> (u32, u32) {
69        fn gcd(a: u32, b: u32) -> u32 {
70            if b == 0 { a } else { gcd(b, a % b) }
71        }
72        let g = gcd(self.width, self.height);
73        (self.width / g, self.height / g)
74    }
75
76    pub fn aspect_ratio_str(&self) -> String {
77        let (w, h) = self.aspect_ratio();
78        format!("{w}:{h}")
79    }
80}
81
82// Common resolutions
83impl Resolution {
84    // 16:9
85    pub const HD: Self = Self::new(1280, 720);
86    pub const FHD: Self = Self::new(1920, 1080);
87    pub const QHD: Self = Self::new(2560, 1440);
88    pub const UHD: Self = Self::new(3840, 2160);
89    // 16:10
90    pub const WXGA: Self = Self::new(1280, 800);
91    pub const WUXGA: Self = Self::new(1920, 1200);
92    // 4:3
93    pub const SVGA: Self = Self::new(800, 600);
94    pub const XGA: Self = Self::new(1024, 768);
95    pub const SXGA: Self = Self::new(1280, 960);
96    // 1:1
97    pub const _1K_SQUARE: Self = Self::new(1080, 1080);
98    pub const _2K_SQUARE: Self = Self::new(2160, 2160);
99    // 21:9
100    pub const UW_QHD: Self = Self::new(3440, 1440);
101}
102
103pub struct RanimPreviewApp {
104    cmd_rx: Receiver<RanimPreviewAppCmd>,
105    pub cmd_tx: Sender<RanimPreviewAppCmd>,
106    #[allow(unused)]
107    title: String,
108    clear_color: wgpu::Color,
109    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
110    scene_constructor: Arc<dyn SceneConstructor>,
111    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
112    scene_config: SceneConfig,
113    resolution: Resolution,
114    evaluator: SceneEvaluator,
115    need_eval: bool,
116    last_sec: f64,
117    store: RenderFrame,
118    timeline_state: TimelineState,
119    playback_engine: PlaybackEngine,
120
121    // Rendering
122    renderer: Option<Renderer>,
123    render_textures: Option<RenderTextures>,
124    texture_id: Option<egui::TextureId>,
125    depth_texture_id: Option<egui::TextureId>,
126    view_mode: ViewMode,
127    wgpu_ctx: Option<WgpuContext>,
128    last_render_time: Option<std::time::Duration>,
129    last_eval_time: Option<std::time::Duration>,
130
131    // Depth Visual
132    depth_visual_pipeline: Option<DepthVisualPipeline>,
133    depth_visual_texture: Option<wgpu::Texture>,
134    depth_visual_view: Option<wgpu::TextureView>,
135
136    // Resolution changed flag
137    resolution_dirty: bool,
138
139    // Export
140    #[cfg(all(not(target_family = "wasm"), feature = "render"))]
141    export_dialog_open: bool,
142    export_config: Output,
143    #[cfg(all(not(target_family = "wasm"), feature = "render"))]
144    export_progress_rx: Option<Receiver<ExportProgress>>,
145    #[cfg(all(not(target_family = "wasm"), feature = "render"))]
146    export_current_frame: u64,
147    #[cfg(all(not(target_family = "wasm"), feature = "render"))]
148    export_total_frames: u64,
149
150    // Playback
151    playback_speed: f64,
152    looping: bool,
153
154    // GPU profiler panel
155    profiler_open: bool,
156    /// Last rendered frame's GPU pass times in μs, flattened from the
157    /// wgpu-profiler scope tree. Empty when GPU timers are unavailable.
158    gpu_pass_times: Vec<(String, f64)>,
159    /// CPU spans of the last rendered frame (ms), drained from
160    /// `cpu_probe`.
161    cpu_spans: Vec<(String, f64)>,
162    /// Last rendered frame's per-buffer upload stats (drained each frame).
163    upload_stats:
164        std::collections::BTreeMap<&'static str, crate::render::upload_probe::UploadStats>,
165    /// Profiling samples indexed by timeline position (one bucket per logic
166    /// frame); playing or seeking updates the bucket at the rendered
167    /// position, so the chart shows performance across scene progress.
168    progress_samples: Vec<Option<profiler::ProgressSample>>,
169    progress_total_sec: f64,
170    profiler_metric: profiler::ProfilerMetric,
171    /// Whether stackable chart metrics render stacked (per-pass colors)
172    /// instead of a plain total.
173    profiler_stacked: bool,
174}
175
176impl RanimPreviewApp {
177    pub fn new(
178        scene_constructor: impl SceneConstructor + 'static,
179        title: String,
180        scene_config: SceneConfig,
181    ) -> Self {
182        let t = Instant::now();
183        let scene_constructor = Arc::new(scene_constructor);
184
185        info!("building scene...");
186        let timeline = scene_constructor.build_scene();
187        info!("Scene built, cost: {:?}", t.elapsed());
188
189        info!("Getting timelines info...");
190        let animation_infos = timeline.get_animation_infos();
191        let total_secs = timeline.total_secs();
192        let evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
193        info!("Total {} root animations", animation_infos.len());
194
195        let (cmd_tx, cmd_rx) = unbounded();
196
197        let playback_engine = PlaybackEngine::new(&evaluator);
198
199        Self {
200            cmd_rx,
201            cmd_tx,
202            title,
203            clear_color: wgpu::Color::TRANSPARENT,
204            scene_constructor,
205            scene_config,
206            resolution: Resolution::QHD,
207            timeline_state: TimelineState::new(total_secs, animation_infos),
208            evaluator,
209            need_eval: false,
210            last_sec: -1.0,
211            store: RenderFrame::default(),
212            playback_engine,
213            renderer: None,
214            render_textures: None,
215            texture_id: None,
216            depth_texture_id: None,
217            view_mode: ViewMode::Output,
218            wgpu_ctx: None,
219            last_render_time: None,
220            last_eval_time: None,
221            depth_visual_pipeline: None,
222            depth_visual_texture: None,
223            depth_visual_view: None,
224            resolution_dirty: false,
225            #[cfg(all(not(target_family = "wasm"), feature = "render"))]
226            export_dialog_open: false,
227            export_config: Output::default(),
228            #[cfg(all(not(target_family = "wasm"), feature = "render"))]
229            export_progress_rx: None,
230            #[cfg(all(not(target_family = "wasm"), feature = "render"))]
231            export_current_frame: 0,
232            #[cfg(all(not(target_family = "wasm"), feature = "render"))]
233            export_total_frames: 0,
234            playback_speed: 1.0,
235            looping: false,
236            profiler_open: false,
237            gpu_pass_times: Vec::new(),
238            cpu_spans: Vec::new(),
239            upload_stats: std::collections::BTreeMap::new(),
240            progress_samples: Vec::new(),
241            progress_total_sec: -1.0,
242            profiler_metric: profiler::ProfilerMetric::default(),
243            profiler_stacked: true,
244        }
245    }
246
247    /// Set clear color str
248    pub fn set_clear_color_str(&mut self, color: &str) {
249        let bg = color::try_color(color)
250            .unwrap_or(color::color("#333333ff"))
251            .convert::<LinearSrgb>();
252        let [r, g, b, a] = bg.components.map(|x| x as f64);
253        let clear_color = wgpu::Color { r, g, b, a };
254        self.set_clear_color(clear_color);
255    }
256
257    /// Set clear color
258    pub fn set_clear_color(&mut self, color: wgpu::Color) {
259        self.clear_color = color;
260    }
261
262    /// Set preview resolution
263    pub fn set_resolution(&mut self, resolution: Resolution) {
264        if self.resolution != resolution {
265            self.resolution = resolution;
266            self.resolution_dirty = true;
267        }
268    }
269
270    /// Calculate OIT layers based on resolution to stay within GPU buffer limits
271    fn calculate_oit_layers(&self, ctx: &WgpuContext, width: u32, height: u32) -> usize {
272        const BYTES_PER_PIXEL_PER_LAYER: usize = 8; // 4 bytes color + 4 bytes depth
273        const MAX_OIT_LAYERS: usize = 8;
274
275        let limits = ctx.device.limits();
276        let max_buffer_size = limits.max_storage_buffer_binding_size as usize;
277        let pixel_count = (width * height) as usize;
278        let max_layers_by_buffer = max_buffer_size / (pixel_count * BYTES_PER_PIXEL_PER_LAYER);
279        let oit_layers = max_layers_by_buffer.clamp(1, MAX_OIT_LAYERS);
280
281        if oit_layers < MAX_OIT_LAYERS {
282            tracing::warn!(
283                "OIT layers reduced from {} to {} due to GPU buffer size limit ({}MB @ {}x{})",
284                MAX_OIT_LAYERS,
285                oit_layers,
286                max_buffer_size / 1024 / 1024,
287                width,
288                height
289            );
290        }
291
292        oit_layers
293    }
294
295    fn handle_events(&mut self) {
296        if let Ok(cmd) = self.cmd_rx.try_recv() {
297            match cmd {
298                RanimPreviewAppCmd::ReloadScene(scene, tx) => {
299                    let timeline = scene.constructor.build_scene();
300                    let animation_infos = timeline.get_animation_infos();
301                    let old_cur_second = self.timeline_state.current_sec;
302                    self.timeline_state =
303                        TimelineState::new(timeline.total_secs(), animation_infos);
304                    self.timeline_state.current_sec =
305                        old_cur_second.clamp(0.0, self.timeline_state.total_sec);
306                    self.evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
307                    self.playback_engine.reload_scene(&self.evaluator);
308                    self.store.update(std::iter::empty());
309                    self.need_eval = true;
310                    self.progress_samples.clear();
311                    self.progress_total_sec = -1.0;
312
313                    self.set_clear_color_str(&scene.config.clear_color);
314
315                    if let Err(err) = tx.try_send(()) {
316                        error!("Failed to send reloaded signal: {err:?}");
317                    }
318                }
319            }
320        }
321    }
322
323    fn prepare_renderer(&mut self, frame: &eframe::Frame) {
324        // Check if we need to recreate renderer
325        let needs_init = self.renderer.is_none();
326        let needs_resize = self.resolution_dirty && self.renderer.is_some();
327
328        if !needs_init && !needs_resize {
329            return;
330        }
331
332        let Some(render_state) = frame.wgpu_render_state() else {
333            tracing::info!("frame.wgpu_render_state() is none");
334            tracing::info!("{:?}", frame.info());
335            return;
336        };
337
338        if needs_init {
339            tracing::info!("preparing renderer...");
340        } else if needs_resize {
341            tracing::info!("recreating renderer for resolution change...");
342        }
343
344        // Construct WgpuContext using eframe's resources.
345        // NOTE: We assume ranim-render doesn't strictly depend on the instance for the operations we do here.
346        let ctx = WgpuContext {
347            instance: wgpu::Instance::default(), // Dummy instance
348            adapter: wgpu::Adapter::clone(&render_state.adapter),
349            device: wgpu::Device::clone(&render_state.device),
350            queue: wgpu::Queue::clone(&render_state.queue),
351        };
352
353        let (width, height) = (self.resolution.width, self.resolution.height);
354        let oit_layers = self.calculate_oit_layers(&ctx, width, height);
355        let renderer = Renderer::new(&ctx, width, height, oit_layers);
356        let render_textures = renderer.new_render_textures(&ctx);
357
358        // Init Depth Visual Pipeline
359        if self.depth_visual_pipeline.is_none() {
360            self.depth_visual_pipeline = Some(DepthVisualPipeline::new(&ctx));
361        }
362
363        // Create Depth Visual Texture
364        let depth_visual_texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
365            label: Some("Depth Visual Texture"),
366            size: wgpu::Extent3d {
367                width: render_textures.width(),
368                height: render_textures.height(),
369                depth_or_array_layers: 1,
370            },
371            mip_level_count: 1,
372            sample_count: 1,
373            dimension: wgpu::TextureDimension::D2,
374            format: wgpu::TextureFormat::Rgba8Unorm,
375            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
376            view_formats: &[],
377        });
378        let depth_visual_view =
379            depth_visual_texture.create_view(&wgpu::TextureViewDescriptor::default());
380
381        // Register texture with egui
382        let texture_view = &render_textures.linear_render_view;
383        let texture_id = render_state.renderer.write().register_native_texture(
384            &render_state.device,
385            texture_view,
386            wgpu::FilterMode::Linear,
387        );
388        let depth_id = render_state.renderer.write().register_native_texture(
389            &render_state.device,
390            &depth_visual_view,
391            wgpu::FilterMode::Nearest,
392        );
393
394        self.texture_id = Some(texture_id);
395        self.depth_texture_id = Some(depth_id);
396        self.depth_visual_texture = Some(depth_visual_texture);
397        self.depth_visual_view = Some(depth_visual_view);
398        self.render_textures = Some(render_textures);
399        self.renderer = Some(renderer);
400        self.wgpu_ctx = Some(ctx);
401        self.resolution_dirty = false;
402        self.need_eval = true; // Force re-render with new resolution
403    }
404
405    // MARK: Playback
406
407    fn is_playing(&self) -> bool {
408        self.playback_engine.is_playing()
409    }
410
411    /// Start playback from the playhead (wrapping to 0.0 at the end).
412    fn play(&mut self) {
413        let total = self.timeline_state.total_sec;
414        self.timeline_state.current_sec =
415            self.playback_engine
416                .play(self.timeline_state.current_sec, total, self.playback_speed);
417    }
418
419    /// Stop playback, freezing the playhead at the clock's current reading.
420    fn pause(&mut self) {
421        if self.playback_engine.is_playing() {
422            self.timeline_state.current_sec = self
423                .playback_engine
424                .pause()
425                .clamp(0.0, self.timeline_state.total_sec);
426        }
427    }
428
429    /// Move the playhead, keeping the active clock consistent with it.
430    fn scrub_to(&mut self, sec: f64) {
431        let sec = sec.clamp(0.0, self.timeline_state.total_sec);
432        self.timeline_state.current_sec = sec;
433        self.playback_engine.scrub_to(sec);
434    }
435
436    /// Read the playing clock into the playhead and handle end-of-scene
437    /// (loop or stop). Returns whether the UI should keep repainting.
438    fn tick_playback(&mut self) -> bool {
439        let scrub_audible = self.playback_engine.tick_scrub();
440        let Some(pos) = self.playback_engine.pos_secs() else {
441            return scrub_audible;
442        };
443        let total = self.timeline_state.total_sec;
444        self.timeline_state.current_sec = pos.min(total);
445        if pos >= total {
446            if self.looping {
447                self.play();
448            } else {
449                self.pause();
450            }
451        }
452        self.playback_engine.is_playing() || scrub_audible
453    }
454
455    fn render_animation(&mut self) {
456        if let (Some(ctx), Some(renderer), Some(render_textures)) = (
457            self.wgpu_ctx.as_ref(),
458            self.renderer.as_mut(),
459            self.render_textures.as_mut(),
460        ) {
461            if self.last_sec == self.timeline_state.current_sec && !self.need_eval {
462                return;
463            }
464            self.need_eval = false;
465            self.last_sec = self.timeline_state.current_sec;
466
467            let start_eval = Instant::now();
468            {
469                let _span = crate::render::cpu_probe::span("eval");
470                // Forward/backward direction management is internal to sample_at.
471                let target = self.timeline_state.current_sec;
472                let mut frame_items = Vec::new();
473                self.evaluator.sample_at(target, &mut frame_items);
474                self.store.update(frame_items.into_iter());
475            }
476            self.last_eval_time = Some(start_eval.elapsed());
477
478            let start = Instant::now();
479            renderer.render_frame(render_textures, self.clear_color, &self.store);
480
481            if let (Some(pipeline), Some(view)) = (
482                self.depth_visual_pipeline.as_ref(),
483                self.depth_visual_view.as_ref(),
484            ) {
485                let _span = crate::render::cpu_probe::span("depth_visual");
486                let mut encoder =
487                    ctx.device
488                        .create_command_encoder(&wgpu::CommandEncoderDescriptor {
489                            label: Some("Depth Visual Encoder"),
490                        });
491
492                let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
493                    label: Some("Depth Visual Bind Group"),
494                    layout: &pipeline.bind_group_layout,
495                    entries: &[wgpu::BindGroupEntry {
496                        binding: 0,
497                        resource: wgpu::BindingResource::TextureView(
498                            &render_textures.depth_texture_view,
499                        ),
500                    }],
501                });
502
503                {
504                    let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
505                        label: Some("Depth Visual Pass"),
506                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
507                            view,
508                            resolve_target: None,
509                            depth_slice: None,
510                            ops: wgpu::Operations {
511                                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
512                                store: wgpu::StoreOp::Store,
513                            },
514                        })],
515                        depth_stencil_attachment: None,
516                        timestamp_writes: None,
517                        occlusion_query_set: None,
518                        multiview_mask: None,
519                    });
520                    rpass.set_pipeline(&pipeline.pipeline);
521                    rpass.set_bind_group(0, &bind_group, &[]);
522                    rpass.draw(0..3, 0..1);
523                }
524                ctx.queue.submit(Some(encoder.finish()));
525            }
526
527            self.last_render_time = Some(start.elapsed());
528
529            // GPU profiler panel: drain per-frame data.
530            if let Some(scopes) = renderer.take_last_gpu_scopes() {
531                let mut passes = Vec::new();
532                profiler::flatten_scopes(&scopes, &mut passes);
533                self.gpu_pass_times = passes;
534            }
535            if crate::render::upload_probe::mode().enabled() {
536                self.upload_stats = crate::render::upload_probe::take_stats();
537            } else {
538                self.upload_stats.clear();
539            }
540            self.cpu_spans = crate::render::cpu_probe::take_frame()
541                .into_iter()
542                .map(|(label, ms)| (label.to_string(), ms))
543                .collect();
544            profiler::record_sample(self);
545        }
546    }
547
548    #[cfg(all(not(target_family = "wasm"), feature = "render"))]
549    fn start_export(&mut self, ctx: egui::Context) {
550        let (progress_tx, progress_rx) = unbounded();
551        self.export_progress_rx = Some(progress_rx);
552
553        let constructor = self.scene_constructor.clone();
554        let scene_config = self.scene_config.clone();
555        let output = self.export_config.clone();
556        let name = self.title.clone();
557
558        std::thread::spawn(move || {
559            let progress_tx_cb = progress_tx.clone();
560            let ctx_cb = ctx.clone();
561            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
562                crate::cmd::render::render_scene_output_with_progress(
563                    constructor,
564                    name,
565                    &scene_config,
566                    &output,
567                    2,
568                    Some(Box::new(move |current, total| {
569                        let _ =
570                            progress_tx_cb.send_blocking(ExportProgress::Progress(current, total));
571                        ctx_cb.request_repaint();
572                    })),
573                );
574
575                let _ = progress_tx.send_blocking(ExportProgress::Done);
576                ctx.request_repaint();
577            }));
578
579            if let Err(e) = result {
580                let msg = if let Some(s) = e.downcast_ref::<&str>() {
581                    s.to_string()
582                } else if let Some(s) = e.downcast_ref::<String>() {
583                    s.clone()
584                } else {
585                    "Unknown export error".to_string()
586                };
587                let _ = progress_tx.send_blocking(ExportProgress::Error(msg));
588                ctx.request_repaint();
589            }
590        });
591    }
592}
593
594impl eframe::App for RanimPreviewApp {
595    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
596        let ctx = ui.ctx().clone();
597        self.prepare_renderer(frame);
598        self.handle_events();
599
600        // Space bar toggles play/pause
601        if ctx.input(|i| i.key_pressed(egui::Key::Space)) {
602            if self.is_playing() {
603                self.pause();
604            } else {
605                self.play();
606            }
607        }
608
609        // Arrow keys step forward/back one frame
610        {
611            let frame_dur = 1.0 / self.export_config.fps as f64;
612            if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
613                self.pause();
614                self.scrub_to(self.timeline_state.current_sec - frame_dur);
615            }
616            if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
617                self.pause();
618                self.scrub_to(self.timeline_state.current_sec + frame_dur);
619            }
620        }
621
622        if self.tick_playback() {
623            ctx.request_repaint();
624        }
625
626        self.render_animation();
627
628        egui::Panel::top("top_panel").show(ui, |ui| {
629            ui.horizontal(|ui| {
630                ui.heading(&self.title);
631
632                // Resolution selector
633                {
634                    let resolution = self.resolution;
635                    egui::ComboBox::from_label("Resolution")
636                        .selected_text(format!(
637                            "{}x{} ({})",
638                            resolution.width,
639                            resolution.height,
640                            resolution.aspect_ratio_str()
641                        ))
642                        .show_ui(ui, |ui| {
643                            // 16:9
644                            ui.label(egui::RichText::new("16:9").strong());
645                            ui.selectable_value(
646                                &mut self.resolution,
647                                Resolution::HD,
648                                "1280x720 (HD)",
649                            );
650                            ui.selectable_value(
651                                &mut self.resolution,
652                                Resolution::FHD,
653                                "1920x1080 (FHD)",
654                            );
655                            ui.selectable_value(
656                                &mut self.resolution,
657                                Resolution::QHD,
658                                "2560x1440 (QHD)",
659                            );
660                            ui.selectable_value(
661                                &mut self.resolution,
662                                Resolution::UHD,
663                                "3840x2160 (UHD)",
664                            );
665                            ui.separator();
666                            // 16:10
667                            ui.label(egui::RichText::new("16:10").strong());
668                            ui.selectable_value(
669                                &mut self.resolution,
670                                Resolution::WXGA,
671                                "1280x800 (WXGA)",
672                            );
673                            ui.selectable_value(
674                                &mut self.resolution,
675                                Resolution::WUXGA,
676                                "1920x1200 (WUXGA)",
677                            );
678                            ui.separator();
679                            // 4:3
680                            ui.label(egui::RichText::new("4:3").strong());
681                            ui.selectable_value(
682                                &mut self.resolution,
683                                Resolution::SVGA,
684                                "800x600 (SVGA)",
685                            );
686                            ui.selectable_value(
687                                &mut self.resolution,
688                                Resolution::XGA,
689                                "1024x768 (XGA)",
690                            );
691                            ui.selectable_value(
692                                &mut self.resolution,
693                                Resolution::SXGA,
694                                "1280x960 (SXGA)",
695                            );
696                            ui.separator();
697                            // 1:1
698                            ui.label(egui::RichText::new("1:1").strong());
699                            ui.selectable_value(
700                                &mut self.resolution,
701                                Resolution::_1K_SQUARE,
702                                "1080x1080",
703                            );
704                            ui.selectable_value(
705                                &mut self.resolution,
706                                Resolution::_2K_SQUARE,
707                                "2160x2160",
708                            );
709                            ui.separator();
710                            // 21:9
711                            ui.label(egui::RichText::new("21:9").strong());
712                            ui.selectable_value(
713                                &mut self.resolution,
714                                Resolution::UW_QHD,
715                                "3440x1440 (UW-QHD)",
716                            );
717                        });
718                    if self.resolution != resolution {
719                        self.resolution_dirty = true;
720                    }
721                }
722
723                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
724                    let dark_mode = ui.visuals().dark_mode;
725                    let button_text = if dark_mode {
726                        format!("{} Light", egui_phosphor::regular::SUN)
727                    } else {
728                        format!("{} Dark", egui_phosphor::regular::MOON)
729                    };
730                    if ui.button(button_text).clicked() {
731                        if dark_mode {
732                            ctx.set_visuals(egui::Visuals::light());
733                        } else {
734                            ctx.set_visuals(egui::Visuals::dark());
735                        }
736                    }
737
738                    ui.separator();
739                    #[cfg(all(not(target_family = "wasm"), feature = "render"))]
740                    {
741                        let exporting = self.export_progress_rx.is_some();
742                        if ui
743                            .add_enabled(!exporting, egui::Button::new("Export"))
744                            .clicked()
745                        {
746                            self.export_dialog_open = true;
747                        }
748                        ui.separator();
749                    }
750                    ui.selectable_value(&mut self.view_mode, ViewMode::Output, "Output");
751                    ui.selectable_value(&mut self.view_mode, ViewMode::Depth, "Depth");
752                    ui.separator();
753
754                    {
755                        let mut btn = egui::Button::new(format!(
756                            "{} Profiler",
757                            egui_phosphor::regular::CHART_LINE_UP
758                        ));
759                        if self.profiler_open {
760                            btn = btn.fill(ui.visuals().selection.bg_fill);
761                        }
762                        if ui.add(btn).clicked() {
763                            self.profiler_open = !self.profiler_open;
764                        }
765                    }
766                    ui.separator();
767
768                    if let Some(duration) = self.last_render_time {
769                        ui.label(format!("Render: {:.2}ms", duration.as_secs_f64() * 1000.0));
770                        ui.separator();
771                    }
772                    if let Some(duration) = self.last_eval_time {
773                        ui.label(format!("Eval: {:.2}ms", duration.as_secs_f64() * 1000.0));
774                        ui.separator();
775                    }
776                });
777            });
778        });
779
780        egui::Panel::bottom("bottom_panel")
781            .resizable(true)
782            .default_size(240.0)
783            .min_size(150.0)
784            .max_size(600.0)
785            .show(ui, |ui| {
786                let pre_panel_sec = self.timeline_state.current_sec;
787                ui.label("Timeline");
788
789                ui.horizontal(|ui| {
790                    let fps = self.export_config.fps as f64;
791                    let frame_dur = 1.0 / fps;
792
793                    // |< Jump to start
794                    if ui
795                        .button(egui_phosphor::regular::SKIP_BACK)
796                        .on_hover_text("Jump to start")
797                        .clicked()
798                    {
799                        self.pause();
800                        self.scrub_to(0.0);
801                    }
802
803                    // < Step back one frame
804                    if ui
805                        .button(egui_phosphor::regular::CARET_LEFT)
806                        .on_hover_text("Step back one frame")
807                        .clicked()
808                    {
809                        self.pause();
810                        self.scrub_to(self.timeline_state.current_sec - frame_dur);
811                    }
812
813                    // Play / Pause
814                    let is_playing = self.is_playing();
815                    let play_label = if is_playing {
816                        egui_phosphor::regular::PAUSE
817                    } else {
818                        egui_phosphor::regular::PLAY
819                    };
820                    let play_tooltip = if is_playing { "Pause" } else { "Play" };
821                    if ui.button(play_label).on_hover_text(play_tooltip).clicked() {
822                        if is_playing {
823                            self.pause();
824                        } else {
825                            self.play();
826                        }
827                    }
828
829                    // > Step forward one frame
830                    if ui
831                        .button(egui_phosphor::regular::CARET_RIGHT)
832                        .on_hover_text("Step forward one frame")
833                        .clicked()
834                    {
835                        self.pause();
836                        self.scrub_to(self.timeline_state.current_sec + frame_dur);
837                    }
838
839                    // >| Jump to end
840                    if ui
841                        .button(egui_phosphor::regular::SKIP_FORWARD)
842                        .on_hover_text("Jump to end")
843                        .clicked()
844                    {
845                        self.pause();
846                        self.scrub_to(self.timeline_state.total_sec);
847                    }
848
849                    ui.separator();
850
851                    // Loop toggle
852                    let mut loop_btn = egui::Button::new(egui_phosphor::regular::ARROWS_CLOCKWISE);
853                    if self.looping {
854                        loop_btn = loop_btn.fill(ui.visuals().selection.bg_fill);
855                    }
856                    if ui
857                        .add(loop_btn)
858                        .on_hover_text(if self.looping {
859                            "Looping: ON"
860                        } else {
861                            "Looping: OFF"
862                        })
863                        .clicked()
864                    {
865                        self.looping = !self.looping;
866                    }
867
868                    ui.separator();
869
870                    // Speed control
871                    let drag_speed = (self.playback_speed * 0.02).max(0.01);
872                    let speed_response = ui
873                        .add(
874                            egui::DragValue::new(&mut self.playback_speed)
875                                .speed(drag_speed)
876                                .range(0.1..=10.0)
877                                .suffix("x"),
878                        )
879                        .on_hover_text("Playback speed");
880                    if speed_response.changed() {
881                        self.playback_engine.set_speed(self.playback_speed);
882                    }
883
884                    ui.separator();
885
886                    const TIME_INPUT_WIDTH: f32 = 86.0;
887                    let slider_width =
888                        (ui.available_width() - TIME_INPUT_WIDTH - ui.spacing().item_spacing.x)
889                            .max(40.0);
890                    ui.scope(|ui| {
891                        ui.style_mut().spacing.slider_width = slider_width;
892                        ui.add(
893                            egui::Slider::new(
894                                &mut self.timeline_state.current_sec,
895                                0.0..=self.timeline_state.total_sec,
896                            )
897                            .show_value(false),
898                        )
899                        .on_hover_text("Timeline position");
900                    });
901                    ui.add_sized(
902                        [TIME_INPUT_WIDTH, 18.0],
903                        egui::DragValue::new(&mut self.timeline_state.current_sec)
904                            .range(0.0..=self.timeline_state.total_sec)
905                            .speed(0.001)
906                            .fixed_decimals(3)
907                            .suffix(" s"),
908                    )
909                    .on_hover_text("Drag or enter the current time");
910                });
911
912                self.timeline_state.ui_main_timeline(ui);
913
914                // Any direct playhead edit (slider, time input, timeline drag)
915                // must rebase the active clock so audio follows the picture.
916                if self.timeline_state.current_sec != pre_panel_sec {
917                    self.scrub_to(self.timeline_state.current_sec);
918                }
919            });
920
921        egui::CentralPanel::default().show(ui, |ui| {
922            let texture_id = match self.view_mode {
923                ViewMode::Output => self.texture_id,
924                ViewMode::Depth => self.depth_texture_id,
925            };
926
927            if let Some(tid) = texture_id {
928                // Maintain aspect ratio
929                // TODO: We could update renderer size here if we want dynamic resolution
930                let available_size = ui.available_size();
931                let aspect_ratio = self
932                    .render_textures
933                    .as_ref()
934                    .map(|rt| rt.ratio())
935                    .unwrap_or(1280.0 / 7.0);
936                let mut size = available_size;
937
938                if size.x / size.y > aspect_ratio {
939                    size.x = size.y * aspect_ratio;
940                } else {
941                    size.y = size.x / aspect_ratio;
942                }
943
944                ui.centered_and_justified(|ui| {
945                    ui.image(egui::load::SizedTexture::new(tid, size));
946                });
947            } else {
948                ui.centered_and_justified(|ui| {
949                    ui.spinner();
950                });
951            }
952        });
953
954        // Export (native only)
955        #[cfg(all(not(target_family = "wasm"), feature = "render"))]
956        {
957            // Poll export progress
958            if let Some(rx) = &self.export_progress_rx {
959                let mut done = false;
960                let mut error_msg = None;
961
962                while let Ok(msg) = rx.try_recv() {
963                    match msg {
964                        ExportProgress::Progress(current, total) => {
965                            self.export_current_frame = current;
966                            self.export_total_frames = total;
967                        }
968                        ExportProgress::Done => {
969                            done = true;
970                        }
971                        ExportProgress::Error(err) => {
972                            error_msg = Some(err);
973                            done = true;
974                        }
975                    }
976                }
977
978                if done {
979                    self.export_progress_rx = None;
980                    self.export_current_frame = 0;
981                    self.export_total_frames = 0;
982                    if let Some(err) = error_msg {
983                        error!("Export failed: {err}");
984                    } else {
985                        info!("Export completed");
986                    }
987                } else {
988                    ctx.request_repaint();
989                }
990            }
991
992            // Export configuration dialog
993            let exporting = self.export_progress_rx.is_some();
994            if self.export_dialog_open || exporting {
995                let mut open = self.export_dialog_open;
996                egui::Window::new("Export")
997                    .open(&mut open)
998                    .resizable(false)
999                    .show(&ctx, |ui| {
1000                        ui.add_enabled_ui(!exporting, |ui| {
1001                            egui::Grid::new("export_grid")
1002                                .num_columns(2)
1003                                .show(ui, |ui| {
1004                                    ui.label("Width:");
1005                                    ui.add(
1006                                        egui::DragValue::new(&mut self.export_config.width)
1007                                            .range(1..=7680),
1008                                    );
1009                                    ui.end_row();
1010
1011                                    ui.label("Height:");
1012                                    ui.add(
1013                                        egui::DragValue::new(&mut self.export_config.height)
1014                                            .range(1..=4320),
1015                                    );
1016                                    ui.end_row();
1017
1018                                    ui.label("FPS:");
1019                                    ui.add(
1020                                        egui::DragValue::new(&mut self.export_config.fps)
1021                                            .range(1..=240),
1022                                    );
1023                                    ui.end_row();
1024
1025                                    ui.label("Format:");
1026                                    egui::ComboBox::from_id_salt("export_format")
1027                                        .selected_text(format!("{}", self.export_config.format))
1028                                        .show_ui(ui, |ui| {
1029                                            ui.selectable_value(
1030                                                &mut self.export_config.format,
1031                                                OutputFormat::Mp4,
1032                                                "mp4",
1033                                            );
1034                                            ui.selectable_value(
1035                                                &mut self.export_config.format,
1036                                                OutputFormat::Webm,
1037                                                "webm",
1038                                            );
1039                                            ui.selectable_value(
1040                                                &mut self.export_config.format,
1041                                                OutputFormat::Mov,
1042                                                "mov",
1043                                            );
1044                                            ui.selectable_value(
1045                                                &mut self.export_config.format,
1046                                                OutputFormat::Gif,
1047                                                "gif",
1048                                            );
1049                                        });
1050                                    ui.end_row();
1051
1052                                    ui.label("Output dir:");
1053                                    ui.text_edit_singleline(&mut self.export_config.dir);
1054                                    ui.end_row();
1055
1056                                    // Show resolved output path preview right below the dir input
1057                                    ui.label("");
1058                                    {
1059                                        let mut output_dir =
1060                                            std::path::PathBuf::from(&self.export_config.dir);
1061                                        if !output_dir.is_absolute() {
1062                                            output_dir = std::env::current_dir()
1063                                                .unwrap_or_default()
1064                                                .join(&output_dir);
1065                                        }
1066                                        let (_, _, ext) =
1067                                            self.export_config.format.encoding_params();
1068                                        let name = self
1069                                            .export_config
1070                                            .name
1071                                            .as_deref()
1072                                            .unwrap_or(&self.title);
1073                                        let file_path = output_dir.join(format!(
1074                                            "{}_{}x{}_{}.{ext}",
1075                                            name,
1076                                            self.export_config.width,
1077                                            self.export_config.height,
1078                                            self.export_config.fps,
1079                                        ));
1080                                        ui.label(
1081                                            egui::RichText::new(format!(
1082                                                "-> {}",
1083                                                file_path.display()
1084                                            ))
1085                                            .small()
1086                                            .color(ui.visuals().weak_text_color()),
1087                                        );
1088                                    }
1089                                    ui.end_row();
1090
1091                                    ui.label("Save frames:");
1092                                    ui.checkbox(&mut self.export_config.save_frames, "");
1093                                    ui.end_row();
1094                                });
1095                        }); // end add_enabled_ui
1096
1097                        ui.add_space(8.0);
1098
1099                        // Show progress bar inline when exporting
1100                        if exporting {
1101                            let current = self.export_current_frame;
1102                            let total = self.export_total_frames;
1103                            if total > 0 {
1104                                let progress = current as f32 / total as f32;
1105                                ui.add(egui::ProgressBar::new(progress).text(format!(
1106                                    "{current}/{total} frames ({:.0}%)",
1107                                    progress * 100.0
1108                                )));
1109                            } else {
1110                                ui.horizontal(|ui| {
1111                                    ui.spinner();
1112                                    ui.label("Preparing...");
1113                                });
1114                            }
1115                        } else if ui.button("Start Export").clicked() {
1116                            self.start_export(ctx.clone());
1117                        }
1118                    });
1119                // Don't allow closing the window while exporting
1120                if !exporting {
1121                    self.export_dialog_open = open;
1122                }
1123            }
1124        }
1125
1126        // GPU profiler panel (works without the profiling feature; the GPU
1127        // sections degrade to a hint).
1128        if self.profiler_open {
1129            profiler::ui_profiler_window(self, &ctx);
1130        }
1131    }
1132}
1133
1134pub fn run_app(app: RanimPreviewApp, #[cfg(target_arch = "wasm32")] container_id: String) {
1135    #[cfg(not(target_arch = "wasm32"))]
1136    let title = app.title.clone();
1137    let build_app = |cc: &eframe::CreationContext| {
1138        let mut fonts = egui::FontDefinitions::default();
1139        egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
1140        cc.egui_ctx.set_fonts(fonts);
1141        Ok(Box::new(app) as Box<dyn App>)
1142    };
1143
1144    // NOTE: eframe defaults to wgpu's default limits (8 storage buffers per shader
1145    // stage), but our pipelines exceed that (the VItem pipelines bind 9 storage
1146    // buffers in the fragment stage). Request the adapter's full limits, matching
1147    // the offline `WgpuContext::new()`, or pipeline creation panics in preview.
1148    let wgpu_options = eframe::egui_wgpu::WgpuConfiguration {
1149        wgpu_setup: eframe::egui_wgpu::WgpuSetup::CreateNew(
1150            eframe::egui_wgpu::WgpuSetupCreateNew {
1151                device_descriptor: Arc::new(|adapter| wgpu::DeviceDescriptor {
1152                    label: Some("ranim device"),
1153                    required_limits: adapter.limits(),
1154                    // GPU timer scopes for the profiler panel (no-op where the
1155                    // adapter lacks them; intersected so device creation
1156                    // can't fail on unsupported features).
1157                    required_features: adapter.features() & profiler::gpu_timer_features(),
1158                    ..Default::default()
1159                }),
1160                ..eframe::egui_wgpu::WgpuSetupCreateNew::without_display_handle()
1161            },
1162        ),
1163        ..Default::default()
1164    };
1165
1166    #[cfg(not(target_family = "wasm"))]
1167    {
1168        let native_options = eframe::NativeOptions {
1169            viewport: egui::ViewportBuilder::default()
1170                .with_title(&title)
1171                .with_inner_size([1280.0, 720.0]),
1172            renderer: eframe::Renderer::Wgpu,
1173            wgpu_options,
1174            ..Default::default()
1175        };
1176
1177        // We need to clone title because run_native takes String (or &str) and app is moved into closure
1178
1179        eframe::run_native(&title, native_options, Box::new(build_app)).unwrap();
1180    }
1181
1182    #[cfg(target_arch = "wasm32")]
1183    {
1184        use wasm_bindgen::JsCast;
1185        let web_options = eframe::WebOptions {
1186            wgpu_options,
1187            ..Default::default()
1188        };
1189
1190        // Handling canvas creation if not found to ensure compatibility
1191        let document = web_sys::window().unwrap().document().unwrap();
1192        let canvas = document
1193            .get_element_by_id(&container_id)
1194            .and_then(|c| c.dyn_into::<web_sys::HtmlCanvasElement>().ok());
1195
1196        let canvas = if let Some(canvas) = canvas {
1197            canvas
1198        } else {
1199            let canvas = document.create_element("canvas").unwrap();
1200            canvas.set_id(&container_id);
1201            document.body().unwrap().append_child(&canvas).unwrap();
1202            canvas.dyn_into::<web_sys::HtmlCanvasElement>().unwrap()
1203        };
1204
1205        wasm_bindgen_futures::spawn_local(async {
1206            eframe::WebRunner::new()
1207                .start(canvas, web_options, Box::new(build_app))
1208                .await
1209                .expect("failed to start eframe");
1210        });
1211    }
1212}
1213
1214pub fn preview_constructor_with_name(
1215    scene: impl SceneConstructor + 'static,
1216    name: &str,
1217    scene_config: &SceneConfig,
1218) {
1219    let app = RanimPreviewApp::new(scene, name.to_string(), scene_config.clone());
1220    run_app(
1221        app,
1222        #[cfg(target_arch = "wasm32")]
1223        format!("ranim-app-{name}"),
1224    );
1225}
1226
1227/// Preview a scene
1228pub fn preview_scene(scene: &Scene) {
1229    preview_scene_with_name(scene, &scene.name);
1230}
1231
1232/// Preview a scene with a custom name
1233pub fn preview_scene_with_name(scene: &Scene, name: &str) {
1234    let mut app = RanimPreviewApp::new(scene.constructor, name.to_string(), scene.config.clone());
1235    app.set_clear_color_str(&scene.config.clear_color);
1236    run_app(
1237        app,
1238        #[cfg(target_arch = "wasm32")]
1239        format!("ranim-app-{name}"),
1240    );
1241}
1242
1243// WASM support needs refactoring, mostly keeping it commented or adapting basic entry point.
1244#[cfg(target_arch = "wasm32")]
1245mod wasm {
1246    use super::*;
1247
1248    #[wasm_bindgen(start)]
1249    pub async fn wasm_start() {
1250        console_error_panic_hook::set_once();
1251        wasm_tracing::set_as_global_default();
1252    }
1253
1254    /// WASM wrapper: preview a scene (accepts owned [`Scene`] from `find_scene`)
1255    #[wasm_bindgen]
1256    pub fn preview_scene(scene: &Scene) {
1257        super::preview_scene(scene);
1258    }
1259}