Skip to main content

ranim/cmd/preview/
mod.rs

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