ranim/cmd/render/
mod.rs

1// MARK: Render api
2use std::collections::VecDeque;
3
4use crate::cmd::render::file_writer::OutputFormatExt;
5use crate::{Output, Scene, SceneConfig, SceneConstructor};
6use file_writer::{FileWriter, FileWriterBuilder};
7use indicatif::{ProgressState, ProgressStyle};
8use ranim_core::color::{self, LinearSrgb};
9use ranim_core::store::CoreItemStore;
10use ranim_core::{SealedRanimScene, TimeMark};
11use ranim_render::resource::{RenderPool, RenderTextures};
12use ranim_render::{Renderer, utils::WgpuContext};
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15use std::time::Instant;
16use tracing::{Span, info, instrument, trace};
17use tracing_indicatif::span_ext::IndicatifSpanExt;
18
19pub(crate) mod file_writer;
20
21#[cfg(feature = "profiling")]
22use ranim_render::PUFFIN_GPU_PROFILER;
23
24/// Render a scene with all its outputs
25pub fn render_scene(scene: &Scene, buffer_count: usize) {
26    for (i, output) in scene.outputs.iter().enumerate() {
27        info!(
28            "Rendering output {}/{} ({})",
29            i + 1,
30            scene.outputs.len(),
31            output.format
32        );
33        render_scene_output(
34            scene.constructor,
35            scene.name.to_string(),
36            &scene.config,
37            output,
38            buffer_count,
39        );
40    }
41}
42
43/// Render a scene output
44pub fn render_scene_output(
45    constructor: impl SceneConstructor,
46    name: String,
47    scene_config: &SceneConfig,
48    output: &Output,
49    buffer_count: usize,
50) {
51    render_scene_output_with_progress(constructor, name, scene_config, output, buffer_count, None);
52}
53
54/// Render a scene output with optional progress callback.
55///
56/// The callback receives `(current_frame, total_frames)` each frame.
57pub fn render_scene_output_with_progress(
58    constructor: impl SceneConstructor,
59    name: String,
60    scene_config: &SceneConfig,
61    output: &Output,
62    buffer_count: usize,
63    on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
64) {
65    use std::time::Instant;
66
67    info!(
68        "Output: {}x{} {}fps {} dir={:?} save_frames={}",
69        output.width, output.height, output.fps, output.format, output.dir, output.save_frames
70    );
71
72    let t = Instant::now();
73    let scene = constructor.build_scene();
74    trace!("Build timeline cost: {:?}", t.elapsed());
75
76    let mut app = RanimRenderApp::new(name, scene_config, output, buffer_count);
77    app.render_scene_with_progress(&scene, on_progress);
78    if !scene.time_marks().is_empty() {
79        app.render_capture_marks(&scene);
80    }
81}
82
83/// drop it will close the channel and the thread loop will be terminated
84struct RenderThreadHandle {
85    submit_frame_tx: async_channel::Sender<CoreItemStore>,
86    back_rx: async_channel::Receiver<CoreItemStore>,
87    worker_rx: async_channel::Receiver<RenderWorker>,
88}
89
90impl RenderThreadHandle {
91    fn sync_and_submit(&self, f: impl FnOnce(&mut CoreItemStore)) {
92        let mut store = self.get_store();
93        f(&mut store);
94        self.submit_frame_tx.send_blocking(store).unwrap();
95    }
96    fn get_store(&self) -> CoreItemStore {
97        self.back_rx.recv_blocking().unwrap()
98    }
99    fn retrive(&self) -> RenderWorker {
100        self.submit_frame_tx.close(); // This terminates the worker thread loop
101        self.worker_rx.recv_blocking().unwrap()
102    }
103}
104
105struct RenderWorker {
106    ctx: WgpuContext,
107    renderer: Renderer,
108    render_textures: Vec<RenderTextures>,
109    pool: RenderPool,
110    clear_color: wgpu::Color,
111    // video writer
112    video_writer: Option<FileWriter>,
113    video_writer_builder: Option<FileWriterBuilder>,
114    save_frames: bool,
115    output_dir: PathBuf,
116    scene_name: String,
117    width: u32,
118    height: u32,
119    fps: u32,
120}
121
122impl RenderWorker {
123    fn new(
124        scene_name: String,
125        scene_config: &SceneConfig,
126        output: &Output,
127        buffer_count: usize,
128    ) -> Self {
129        assert!(buffer_count >= 1, "buffer_count must be at least 1");
130        info!("Checking ffmpeg...");
131        let t = Instant::now();
132        if let Ok(ffmpeg_path) = which::which("ffmpeg") {
133            info!("ffmpeg found at {ffmpeg_path:?}");
134        } else {
135            use std::path::Path;
136
137            info!(
138                "ffmpeg not found from path env, searching in {:?}...",
139                Path::new("./").canonicalize().unwrap()
140            );
141            if Path::new("./ffmpeg").exists() {
142                info!("ffmpeg found at current working directory")
143            } else {
144                info!("ffmpeg not found at current working directory, downloading...");
145                download_ffmpeg("./").expect("failed to download ffmpeg");
146            }
147        }
148        trace!("Check ffmmpeg cost: {:?}", t.elapsed());
149
150        let t = Instant::now();
151        info!("Creating wgpu context...");
152        let ctx = pollster::block_on(WgpuContext::new());
153        trace!("Create wgpu context cost: {:?}", t.elapsed());
154
155        let mut output_dir = PathBuf::from(&output.dir);
156        if !output_dir.is_absolute() {
157            output_dir = std::env::current_dir().unwrap().join(output_dir);
158        }
159        let renderer = Renderer::new(&ctx, output.width, output.height, 8);
160        let render_textures: Vec<RenderTextures> = (0..buffer_count)
161            .map(|_| renderer.new_render_textures(&ctx))
162            .collect();
163        let clear_color = color::try_color(&scene_config.clear_color)
164            .unwrap_or(color::color("#333333ff"))
165            .convert::<LinearSrgb>();
166        let [r, g, b, a] = clear_color.components.map(|x| x as f64);
167        let clear_color = wgpu::Color { r, g, b, a };
168        let (_, _, ext) = output.format.encoding_params();
169        Self {
170            ctx,
171            renderer,
172            render_textures,
173            pool: RenderPool::new(),
174            clear_color,
175            video_writer: None,
176            video_writer_builder: Some(
177                FileWriterBuilder::default()
178                    .with_fps(output.fps)
179                    .with_size(output.width, output.height)
180                    .with_file_path(output_dir.join(format!(
181                        "{}_{}x{}_{}.{ext}",
182                        output.name.clone().unwrap_or(scene_name.clone()),
183                        output.width,
184                        output.height,
185                        output.fps
186                    )))
187                    .with_output_format(output.format),
188            ),
189            save_frames: output.save_frames,
190            output_dir,
191            scene_name,
192            width: output.width,
193            height: output.height,
194            fps: output.fps,
195        }
196    }
197
198    fn save_frame_dir(&self) -> PathBuf {
199        self.output_dir.join(format!(
200            "{}_{}x{}_{}-frames",
201            self.scene_name, self.width, self.height, self.fps
202        ))
203    }
204
205    fn yeet(self) -> RenderThreadHandle {
206        let (submit_frame_tx, submit_frame_rx) = async_channel::bounded(1);
207        let (back_tx, back_rx) = async_channel::bounded(1);
208        let (worker_tx, worker_rx) = async_channel::bounded(1);
209
210        back_tx.send_blocking(CoreItemStore::default()).unwrap();
211        std::thread::spawn(move || {
212            let mut worker = self;
213            let n = worker.render_textures.len();
214            let mut frame_count = 0u64;
215            let mut cur = 0usize;
216            let mut pending: VecDeque<(usize, u64)> = VecDeque::new();
217
218            while let Ok(store) = submit_frame_rx.recv_blocking() {
219                // Drain oldest pending readback if all targets are occupied
220                if pending.len() >= n {
221                    let (prev, prev_fc) = pending.pop_front().unwrap();
222                    worker.render_textures[prev].finish_readback(&worker.ctx);
223                    worker.output_frame_from(prev, prev_fc);
224                }
225
226                // Render current frame and start async readback
227                worker.renderer.render_store_with_pool(
228                    &worker.ctx,
229                    &mut worker.render_textures[cur],
230                    worker.clear_color,
231                    &store,
232                    &mut worker.pool,
233                );
234                worker.render_textures[cur].start_readback(&worker.ctx);
235                worker.pool.clean();
236
237                pending.push_back((cur, frame_count));
238                frame_count += 1;
239                cur = (cur + 1) % n;
240
241                // Return store early so main thread can eval next frame
242                // while GPU processes the readback
243                back_tx.send_blocking(store).unwrap();
244
245                // Now try to drain any completed readbacks while we wait
246                // for the next frame from the main thread
247                while let Some(&(prev, _)) = pending.front() {
248                    // Non-blocking: check if the oldest readback is ready
249                    if !worker.render_textures[prev].try_finish_readback(&worker.ctx) {
250                        break;
251                    }
252                    let (prev, prev_fc) = pending.pop_front().unwrap();
253                    worker.output_frame_from(prev, prev_fc);
254                }
255            }
256
257            // Flush all remaining pending frames
258            while let Some((prev, prev_fc)) = pending.pop_front() {
259                worker.render_textures[prev].finish_readback(&worker.ctx);
260                worker.output_frame_from(prev, prev_fc);
261            }
262
263            worker_tx.send_blocking(worker).unwrap();
264        });
265        RenderThreadHandle {
266            submit_frame_tx,
267            back_rx,
268            worker_rx,
269        }
270    }
271
272    fn render_store(&mut self, store: &CoreItemStore) {
273        #[cfg(feature = "profiling")]
274        profiling::scope!("frame");
275
276        {
277            #[cfg(feature = "profiling")]
278            profiling::scope!("render");
279
280            self.renderer.render_store_with_pool(
281                &self.ctx,
282                &mut self.render_textures[0],
283                self.clear_color,
284                store,
285                &mut self.pool,
286            );
287        }
288        self.pool.clean();
289
290        #[cfg(feature = "profiling")]
291        profiling::finish_frame!();
292    }
293
294    /// Write and save (if [`Self::save_frames`] is true)
295    fn output_frame_from(&mut self, target_idx: usize, frame_number: u64) {
296        self.write_frame_from(target_idx);
297        if self.save_frames {
298            self.save_frame_from(target_idx, frame_number);
299        }
300    }
301
302    /// Write frame data from the given target to the video file.
303    fn write_frame_from(&mut self, target_idx: usize) {
304        let data = self.render_textures[target_idx]
305            .render_texture
306            .texture_data();
307        if let Some(video_writer) = self.video_writer.as_mut() {
308            video_writer.write_frame(data);
309        } else if let Some(builder) = self.video_writer_builder.as_ref() {
310            self.video_writer
311                .get_or_insert(builder.clone().build())
312                .write_frame(data);
313        }
314    }
315
316    /// Save frame from the given target as a PNG image.
317    fn save_frame_from(&mut self, target_idx: usize, frame_number: u64) {
318        let path = self.save_frame_dir().join(format!("{frame_number:04}.png"));
319        let dir = path.parent().unwrap();
320        if !dir.exists() || !dir.is_dir() {
321            std::fs::create_dir_all(dir).unwrap();
322        }
323        // Data is already in cpu buffer after finish_readback, this won't trigger GPU work
324        let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx);
325        buffer.save(path).unwrap();
326    }
327
328    /// Capture frame to image file (sync path, uses target 0).
329    pub fn capture_frame(&mut self, path: impl AsRef<Path>) {
330        let path = path.as_ref();
331        let path = if !path.is_absolute() {
332            self.output_dir
333                .join(format!(
334                    "{}_{}x{}_{}",
335                    self.scene_name, self.width, self.height, self.fps
336                ))
337                .join(path)
338        } else {
339            path.to_path_buf()
340        };
341        let dir = path.parent().unwrap();
342        if !dir.exists() || !dir.is_dir() {
343            std::fs::create_dir_all(dir).unwrap();
344        }
345        let buffer = self.render_textures[0].get_rendered_texture_img_buffer(&self.ctx);
346        buffer.save(path).unwrap();
347    }
348}
349
350/// MARK: RanimRenderApp
351struct RanimRenderApp {
352    render_worker: Option<RenderWorker>,
353    fps: u32,
354    store: CoreItemStore,
355}
356
357impl RanimRenderApp {
358    fn new(
359        scene_name: String,
360        scene_config: &SceneConfig,
361        output: &Output,
362        buffer_count: usize,
363    ) -> Self {
364        let render_worker = RenderWorker::new(scene_name, scene_config, output, buffer_count);
365        Self {
366            render_worker: Some(render_worker),
367            fps: output.fps,
368            store: CoreItemStore::default(),
369        }
370    }
371
372    #[instrument(skip_all)]
373    pub fn render_scene_with_progress(
374        &mut self,
375        timeline: &SealedRanimScene,
376        on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
377    ) {
378        let start = Instant::now();
379        #[cfg(feature = "profiling")]
380        let (_cpu_server, _gpu_server) = {
381            puffin::set_scopes_on(true);
382            // default global profiler
383            let cpu_server =
384                puffin_http::Server::new(&format!("0.0.0.0:{}", puffin_http::DEFAULT_PORT))
385                    .unwrap();
386            // custom gpu profiler in `PUFFIN_GPU_PROFILER`
387            let gpu_server = puffin_http::Server::new_custom(
388                &format!("0.0.0.0:{}", puffin_http::DEFAULT_PORT + 1),
389                |sink| PUFFIN_GPU_PROFILER.lock().unwrap().add_sink(sink),
390                |id| _ = PUFFIN_GPU_PROFILER.lock().unwrap().remove_sink(id),
391            )
392            .unwrap();
393            (cpu_server, gpu_server)
394        };
395
396        let worker_thread = self.render_worker.take().unwrap().yeet();
397
398        let total_secs = timeline.total_secs();
399        let fps = self.fps as f64;
400        let raw_frames = total_secs * fps;
401        // Add an extra frame to sample the final state exactly,
402        // unless total_secs * fps is already an integer (last frame lands on total_secs).
403        let n = raw_frames.ceil() as u64;
404        let num_frames = if (raw_frames - raw_frames.round()).abs() < 1e-9 {
405            n
406        } else {
407            n + 1
408        };
409        let style =             ProgressStyle::with_template(
410                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
411            )
412            .unwrap()
413            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
414                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
415            })
416            .progress_chars("#>-");
417
418        let span = Span::current();
419        span.pb_set_style(&style);
420        span.pb_set_length(num_frames);
421
422        (0..num_frames)
423            .map(|f| (f as f64 / fps).min(total_secs))
424            .enumerate()
425            .for_each(|(i, sec)| {
426                worker_thread.sync_and_submit(|store| {
427                    store.update(timeline.eval_at_sec(sec));
428                });
429
430                span.pb_inc(1);
431                if let Some(cb) = &on_progress {
432                    cb(i as u64 + 1, num_frames);
433                }
434                span.pb_set_message(
435                    format!(
436                        "rendering {:.1?}/{:.1?}",
437                        Duration::from_secs_f64(sec),
438                        Duration::from_secs_f64(total_secs)
439                    )
440                    .as_str(),
441                );
442            });
443        self.render_worker.replace(worker_thread.retrive());
444
445        info!(
446            "rendered {} frames({:?}) in {:?}",
447            num_frames,
448            Duration::from_secs_f64(timeline.total_secs()),
449            start.elapsed(),
450        );
451        trace!("render timeline cost: {:?}", start.elapsed());
452    }
453
454    #[instrument(skip_all)]
455    fn render_capture_marks(&mut self, timeline: &SealedRanimScene) {
456        let start = Instant::now();
457        let timemarks = timeline
458            .time_marks()
459            .iter()
460            .filter(|mark| matches!(mark.1, TimeMark::Capture(_)))
461            .collect::<Vec<_>>();
462
463        let style =             ProgressStyle::with_template(
464                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
465            )
466            .unwrap()
467            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
468                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
469            })
470            .progress_chars("#>-");
471
472        let span = Span::current();
473        span.pb_set_style(&style);
474        span.pb_set_length(timemarks.len() as u64);
475        let _enter = span.enter();
476
477        for (sec, TimeMark::Capture(filename)) in &timemarks {
478            let alpha = *sec / timeline.total_secs();
479
480            self.store.update(timeline.eval_at_alpha(alpha));
481            let worker = self.render_worker.as_mut().unwrap();
482            worker.render_store(&self.store);
483            worker.capture_frame(filename);
484            span.pb_inc(1);
485        }
486        info!("saved {} capture frames from time marks", timemarks.len());
487        trace!("save capture frames cost: {:?}", start.elapsed());
488    }
489}
490
491// MARK: Download ffmpeg
492const FFMPEG_RELEASE_URL: &str = "https://github.com/eugeneware/ffmpeg-static/releases/latest";
493
494#[allow(unused)]
495pub(crate) fn exe_dir() -> PathBuf {
496    std::env::current_exe()
497        .unwrap()
498        .parent()
499        .unwrap()
500        .to_path_buf()
501}
502
503/// Download latest release of ffmpeg from <https://github.com/eugeneware/ffmpeg-static/releases/latest> to <target_dir>/ffmpeg
504pub fn download_ffmpeg(target_dir: impl AsRef<Path>) -> Result<PathBuf, anyhow::Error> {
505    use anyhow::Context;
506    use itertools::Itertools;
507    use std::io::Read;
508    use tracing::info;
509
510    let target_dir = target_dir.as_ref();
511
512    let res = reqwest::blocking::get(FFMPEG_RELEASE_URL).context("failed to get release url")?;
513    let url = res.url().to_string();
514    let url = url.split("tag").collect_array::<2>().unwrap();
515    let url = format!("{}/download/{}", url[0], url[1]);
516    info!("ffmpeg release url: {url:?}");
517
518    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
519    let url = format!("{url}/ffmpeg-win32-x64.gz");
520    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
521    let url = format!("{url}/ffmpeg-linux-x64.gz");
522    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
523    let url = format!("{url}/ffmpeg-linux-arm64.gz");
524    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
525    let url = format!("{url}/ffmpeg-darwin-x64.gz");
526    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
527    let url = format!("{url}/ffmpeg-darwin-arm64.gz");
528
529    info!("downloading ffmpeg from {url:?}...");
530
531    let res = reqwest::blocking::get(&url).context("get err")?;
532    let mut decoder = flate2::bufread::GzDecoder::new(std::io::BufReader::new(
533        std::io::Cursor::new(res.bytes().unwrap()),
534    ));
535    let mut bytes = Vec::new();
536    decoder
537        .read_to_end(&mut bytes)
538        .context("GzDecoder decode err")?;
539    let ffmpeg_path = target_dir.join("ffmpeg");
540    std::fs::write(&ffmpeg_path, bytes).unwrap();
541
542    #[cfg(target_family = "unix")]
543    {
544        use std::os::unix::fs::PermissionsExt;
545
546        std::fs::set_permissions(&ffmpeg_path, std::fs::Permissions::from_mode(0o755))?;
547    }
548    info!("ffmpeg downloaded to {target_dir:?}");
549    Ok(ffmpeg_path)
550}