Skip to main content

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, SceneConstructor};
6use bevy_app::SubApp;
7use file_writer::{FileWriter, FileWriterBuilder};
8use indicatif::{ProgressState, ProgressStyle};
9use ranim_core::color::{self, LinearSrgb};
10use ranim_core::{SceneEvaluator, TimeMark, frame::EvaluatedFrame};
11use ranim_render::resource::RenderTextures;
12use ranim_render::{RenderAppExt, RenderSetup, build_render_app, 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 audio;
20pub(crate) mod file_writer;
21
22/// Render the output basename template by replacing placeholders.
23///
24/// Supported placeholders:
25/// - `{name}`: the scene/output name
26/// - `{width}`: output width in pixels
27/// - `{height}`: output height in pixels
28/// - `{fps}`: output frame rate
29fn render_output_basename(template: &str, name: &str, width: u32, height: u32, fps: u32) -> String {
30    template
31        .replace("{name}", name)
32        .replace("{width}", &width.to_string())
33        .replace("{height}", &height.to_string())
34        .replace("{fps}", &fps.to_string())
35}
36
37/// Render a scene with all its outputs
38pub fn render_scene(scene: &Scene, setup: &RenderSetup, buffer_count: usize) {
39    for (i, output) in scene.outputs.iter().enumerate() {
40        info!(
41            "Rendering output {}/{} ({})",
42            i + 1,
43            scene.outputs.len(),
44            output.format
45        );
46        render_scene_output(scene, setup, output, buffer_count);
47    }
48}
49
50/// Render a scene output
51pub fn render_scene_output(
52    scene: &Scene,
53    setup: &RenderSetup,
54    output: &Output,
55    buffer_count: usize,
56) {
57    render_scene_output_with_progress(scene, setup, output, buffer_count, None);
58}
59
60/// A single rendering job: one [`Output`] plus whether capture marks should
61/// be processed after the main pass.
62///
63/// The CLI currently only creates full-scene jobs. Time-range and still-frame
64/// rendering will extend this type together with the debug rendering commands.
65pub struct RenderJob {
66    /// Output settings used for this job.
67    pub output: Output,
68    /// Render and save every [`TimeMark::Capture`] after the main pass.
69    ///
70    /// This is enabled by `ranim output` (declared outputs) and disabled by
71    /// the ad-hoc `ranim render` command.
72    pub capture_marks: bool,
73}
74
75impl RenderJob {
76    /// Creates a job that renders the whole scene.
77    pub fn full(output: Output, capture_marks: bool) -> Self {
78        Self {
79            output,
80            capture_marks,
81        }
82    }
83}
84
85/// Render one output with optional progress callback.
86///
87/// The callback receives `(current_frame, total_frames)` each frame.
88pub fn render_scene_output_with_progress(
89    scene: &Scene,
90    setup: &RenderSetup,
91    output: &Output,
92    buffer_count: usize,
93    on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
94) {
95    render_scene_job(
96        scene,
97        setup,
98        RenderJob::full(output.clone(), true),
99        buffer_count,
100        on_progress,
101    );
102}
103
104/// Render a single ad-hoc output for a scene.
105///
106/// This is the low-level rendering entry point used by `ranim render`: it
107/// renders the whole scene once with the given [`Output`], but unlike
108/// [`render_scene_output_with_progress`] it does not process capture marks.
109pub fn render_scene_once(scene: &Scene, setup: &RenderSetup, output: &Output, buffer_count: usize) {
110    render_scene_job(
111        scene,
112        setup,
113        RenderJob::full(output.clone(), false),
114        buffer_count,
115        None,
116    );
117}
118
119/// Build the scene and run a [`RenderJob`].
120pub fn render_scene_job(
121    scene: &Scene,
122    setup: &RenderSetup,
123    job: RenderJob,
124    buffer_count: usize,
125    on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
126) {
127    use std::time::Instant;
128
129    let RenderJob {
130        output,
131        capture_marks,
132    } = job;
133
134    info!(
135        "Output: {}x{} {}fps {} dir={:?} save_frames={}",
136        output.width, output.height, output.fps, output.format, output.dir, output.save_frames
137    );
138
139    let t = Instant::now();
140    let timeline = scene.build_scene();
141    trace!("Build timeline cost: {:?}", t.elapsed());
142
143    // Default logic grid 120 Hz (per the time model design); render fps only
144    // decides which logic states are sampled.
145    let mut evaluator = timeline.into_evaluator(DEFAULT_LOGIC_FPS);
146
147    let mut app = RanimRenderApp::new(scene, setup, &output, buffer_count);
148    app.render_scene_with_progress(&mut evaluator, on_progress);
149
150    if capture_marks && !evaluator.time_marks().is_empty() {
151        app.render_capture_marks(&mut evaluator);
152    }
153
154    // The audio plane was baked in one offline pass at seal: mux the baked
155    // buffer into the finished file.
156    if evaluator.has_audio() {
157        match audio::audio_codec(output.format) {
158            Some(_) => {
159                let pcm = evaluator.audio();
160                // Finalize the encoder before reading the file back for muxing.
161                app.finish_video();
162                let video_path = app.video_path();
163                if !video_path.exists() {
164                    tracing::warn!("no video file was written, skipping audio muxing");
165                } else if let Err(err) = audio::mux_audio_into_video(video_path, pcm, output.format)
166                {
167                    tracing::error!("failed to mux audio into {}: {err:#}", video_path.display());
168                }
169            }
170            None => tracing::warn!(
171                "the scene has audio, but the {:?} format cannot store it; rendering silently",
172                output.format
173            ),
174        }
175    }
176}
177
178/// Default logic grid resolution (Hz), per the time model design.
179const DEFAULT_LOGIC_FPS: f64 = 120.0;
180
181/// Handle to the background render thread, used to submit frame data from the main thread.
182///
183/// Dropping this handle closes the submission channel and terminates the worker thread loop.
184pub struct RenderThreadHandle {
185    submit_frame_tx: async_channel::Sender<EvaluatedFrame>,
186    back_rx: async_channel::Receiver<EvaluatedFrame>,
187    worker_rx: async_channel::Receiver<RenderWorker>,
188}
189
190impl RenderThreadHandle {
191    /// Reuses an [`EvaluatedFrame`], applies `f` to it, and submits it to the background render thread.
192    pub fn sync_and_submit(&self, f: impl FnOnce(&mut EvaluatedFrame)) {
193        let mut store = self.get_store();
194        f(&mut store);
195        self.submit_frame_tx.send_blocking(store).unwrap();
196    }
197
198    /// Retrieves a previously rendered [`EvaluatedFrame`] from the background thread for reuse.
199    pub fn get_store(&self) -> EvaluatedFrame {
200        self.back_rx.recv_blocking().unwrap()
201    }
202
203    /// Closes the submission channel, waits for the background thread to finish, and returns the [`RenderWorker`].
204    pub fn retrive(&self) -> RenderWorker {
205        self.submit_frame_tx.close(); // This terminates the worker thread loop
206        self.worker_rx.recv_blocking().unwrap()
207    }
208}
209
210/// Background rendering worker that owns the wgpu context, render sub-app, output textures, and video/image writers.
211pub struct RenderWorker {
212    ctx: WgpuContext,
213    render_app: SubApp,
214    render_textures: Vec<RenderTextures>,
215    clear_color: wgpu::Color,
216    // video writer
217    video_writer: Option<FileWriter>,
218    video_writer_builder: Option<FileWriterBuilder>,
219    video_path: PathBuf,
220    save_frames: bool,
221    output_dir: PathBuf,
222    scene_name: String,
223    width: u32,
224    height: u32,
225    fps: u32,
226}
227
228impl RenderWorker {
229    /// Creates a new [`RenderWorker`].
230    ///
231    /// This checks for or downloads `ffmpeg`, initializes the wgpu context and the
232    /// render sub-app (configured by `setup`), and configures the video writer from [`Output`].
233    ///
234    /// # Panics
235    ///
236    /// Panics if `buffer_count < 1`.
237    pub fn new(scene: &Scene, setup: &RenderSetup, output: &Output, buffer_count: usize) -> Self {
238        assert!(buffer_count >= 1, "buffer_count must be at least 1");
239        let scene_name = scene.name.clone();
240        info!("Checking ffmpeg...");
241        let t = Instant::now();
242        if let Ok(ffmpeg_path) = which::which("ffmpeg") {
243            info!("ffmpeg found at {ffmpeg_path:?}");
244        } else {
245            use std::path::Path;
246
247            info!(
248                "ffmpeg not found from path env, searching in {:?}...",
249                Path::new("./").canonicalize().unwrap()
250            );
251            if Path::new("./ffmpeg").exists() {
252                info!("ffmpeg found at current working directory")
253            } else {
254                info!("ffmpeg not found at current working directory, downloading...");
255                download_ffmpeg("./").expect("failed to download ffmpeg");
256            }
257        }
258        trace!("Check ffmmpeg cost: {:?}", t.elapsed());
259
260        let t = Instant::now();
261        info!("Creating wgpu context...");
262        let ctx = pollster::block_on(WgpuContext::new());
263        trace!("Create wgpu context cost: {:?}", t.elapsed());
264
265        let mut output_dir = PathBuf::from(&output.dir);
266        if !output_dir.is_absolute() {
267            output_dir = std::env::current_dir().unwrap().join(output_dir);
268        }
269        let mut render_app = build_render_app(&ctx, output.width, output.height, 8);
270        render_app.add_plugins(setup.clone());
271        render_app.finish();
272        render_app.cleanup();
273        let render_textures: Vec<RenderTextures> = (0..buffer_count)
274            .map(|_| RenderTextures::new(&ctx, output.width, output.height))
275            .collect();
276        let clear_color = color::try_color(&scene.config.clear_color)
277            .unwrap_or(color::color("#333333ff"))
278            .convert::<LinearSrgb>();
279        let [r, g, b, a] = clear_color.components.map(|x| x as f64);
280        let clear_color = wgpu::Color { r, g, b, a };
281        let (_, _, ext) = output.format.encoding_params();
282        let video_path = output_dir.join({
283            let template = output
284                .name_template
285                .as_deref()
286                .unwrap_or("{name}_{width}x{height}_{fps}");
287            let base_name = render_output_basename(
288                template,
289                output.name.as_deref().unwrap_or(&scene_name),
290                output.width,
291                output.height,
292                output.fps,
293            );
294            format!("{base_name}.{ext}")
295        });
296        Self {
297            ctx,
298            render_app,
299            render_textures,
300            clear_color,
301            video_writer: None,
302            video_writer_builder: Some(
303                FileWriterBuilder::default()
304                    .with_fps(output.fps)
305                    .with_size(output.width, output.height)
306                    .with_file_path(video_path.clone())
307                    .with_output_format(output.format),
308            ),
309            video_path,
310            save_frames: output.save_frames,
311            output_dir,
312            scene_name,
313            width: output.width,
314            height: output.height,
315            fps: output.fps,
316        }
317    }
318
319    /// Returns the directory path used to save individual frame images.
320    pub fn save_frame_dir(&self) -> PathBuf {
321        self.output_dir.join(format!(
322            "{}_{}x{}_{}-frames",
323            self.scene_name, self.width, self.height, self.fps
324        ))
325    }
326
327    /// Returns the path of the video file being written.
328    pub fn video_path(&self) -> &Path {
329        &self.video_path
330    }
331
332    /// Finalize the video file: flush the encoder and wait for it to exit.
333    ///
334    /// After this call the file on disk is complete (valid container index)
335    /// and no further frames can be written.
336    pub fn finish_video(&mut self) {
337        self.video_writer = None;
338    }
339
340    /// Moves this [`RenderWorker`] into a newly spawned background thread and returns a [`RenderThreadHandle`].
341    ///
342    /// The background thread loops over incoming [`EvaluatedFrame`]s, rendering and outputting frames using multi-buffered async readback.
343    pub fn yeet(self) -> RenderThreadHandle {
344        let (submit_frame_tx, submit_frame_rx) = async_channel::bounded(1);
345        let (back_tx, back_rx) = async_channel::bounded(2);
346        let (worker_tx, worker_rx) = async_channel::bounded(1);
347
348        // Evaluate one frame while the worker renders the other, reusing both.
349        for _ in 0..2 {
350            back_tx.send_blocking(EvaluatedFrame::default()).unwrap();
351        }
352        std::thread::spawn(move || {
353            let mut worker = self;
354            let n = worker.render_textures.len();
355            let mut frame_count = 0u64;
356            let mut cur = 0usize;
357            let mut pending: VecDeque<(usize, u64)> = VecDeque::new();
358
359            while let Ok(mut store) = submit_frame_rx.recv_blocking() {
360                // Drain oldest pending readback if all targets are occupied
361                if pending.len() >= n {
362                    let (prev, prev_fc) = pending.pop_front().unwrap();
363                    worker.render_textures[prev].finish_readback(&worker.ctx);
364                    worker.output_frame_from(prev, prev_fc);
365                }
366
367                // Render current frame and start async readback
368                worker.render_app.render_frame(
369                    &mut worker.render_textures[cur],
370                    worker.clear_color,
371                    &mut store,
372                );
373                worker.render_textures[cur].start_readback(&worker.ctx);
374
375                pending.push_back((cur, frame_count));
376                frame_count += 1;
377                cur = (cur + 1) % n;
378
379                // Return store early so main thread can eval next frame
380                // while GPU processes the readback
381                back_tx.send_blocking(store).unwrap();
382
383                // Now try to drain any completed readbacks while we wait
384                // for the next frame from the main thread
385                while let Some(&(prev, _)) = pending.front() {
386                    // Non-blocking: check if the oldest readback is ready
387                    if !worker.render_textures[prev].try_finish_readback(&worker.ctx) {
388                        break;
389                    }
390                    let (prev, prev_fc) = pending.pop_front().unwrap();
391                    worker.output_frame_from(prev, prev_fc);
392                }
393            }
394
395            // Flush all remaining pending frames
396            while let Some((prev, prev_fc)) = pending.pop_front() {
397                worker.render_textures[prev].finish_readback(&worker.ctx);
398                worker.output_frame_from(prev, prev_fc);
399            }
400
401            worker_tx.send_blocking(worker).unwrap();
402        });
403        RenderThreadHandle {
404            submit_frame_tx,
405            back_rx,
406            worker_rx,
407        }
408    }
409
410    /// Renders a single frame synchronously on this worker (typically used for captures).
411    ///
412    /// The frame's data and capacity remain available for reuse after rendering.
413    pub fn render_store(&mut self, store: &mut EvaluatedFrame) {
414        self.render_app
415            .render_frame(&mut self.render_textures[0], self.clear_color, store);
416    }
417
418    /// Write and save (if [`Self::save_frames`] is true)
419    pub fn output_frame_from(&mut self, target_idx: usize, frame_number: u64) {
420        self.write_frame_from(target_idx);
421        if self.save_frames {
422            self.save_frame_from(target_idx, frame_number);
423        }
424    }
425
426    /// Write frame data from the given target to the video file.
427    pub fn write_frame_from(&mut self, target_idx: usize) {
428        let data = self.render_textures[target_idx]
429            .render_texture
430            .texture_data();
431        if let Some(video_writer) = self.video_writer.as_mut() {
432            video_writer.write_frame(data);
433        } else if let Some(builder) = self.video_writer_builder.as_ref() {
434            self.video_writer
435                .get_or_insert(builder.clone().build())
436                .write_frame(data);
437        }
438    }
439
440    /// Save frame from the given target as a PNG image.
441    pub fn save_frame_from(&mut self, target_idx: usize, frame_number: u64) {
442        let path = self.save_frame_dir().join(format!("{frame_number:04}.png"));
443        let dir = path.parent().unwrap();
444        if !dir.exists() || !dir.is_dir() {
445            std::fs::create_dir_all(dir).unwrap();
446        }
447        // Data is already in cpu buffer after finish_readback, this won't trigger GPU work
448        let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx);
449        buffer.save(path).unwrap();
450    }
451
452    /// Capture frame to image file (sync path, uses target 0).
453    pub fn capture_frame(&mut self, path: impl AsRef<Path>) {
454        let path = path.as_ref();
455        let path = if !path.is_absolute() {
456            self.output_dir
457                .join(format!(
458                    "{}_{}x{}_{}",
459                    self.scene_name, self.width, self.height, self.fps
460                ))
461                .join(path)
462        } else {
463            path.to_path_buf()
464        };
465        let dir = path.parent().unwrap();
466        if !dir.exists() || !dir.is_dir() {
467            std::fs::create_dir_all(dir).unwrap();
468        }
469        let buffer = self.render_textures[0].get_rendered_texture_img_buffer(&self.ctx);
470        buffer.save(path).unwrap();
471    }
472}
473
474/// MARK: RanimRenderApp
475/// Application wrapper that drives frame-by-frame scene rendering, video output, and capture screenshots.
476pub struct RanimRenderApp {
477    render_worker: Option<RenderWorker>,
478    fps: u32,
479    store: EvaluatedFrame,
480}
481
482impl RanimRenderApp {
483    /// Creates the application and initializes the internal [`RenderWorker`].
484    pub fn new(scene: &Scene, setup: &RenderSetup, output: &Output, buffer_count: usize) -> Self {
485        let render_worker = RenderWorker::new(scene, setup, output, buffer_count);
486        Self {
487            render_worker: Some(render_worker),
488            fps: output.fps,
489            store: EvaluatedFrame::default(),
490        }
491    }
492
493    /// Returns the path of the video file written by this app's worker.
494    pub fn video_path(&self) -> &Path {
495        self.render_worker
496            .as_ref()
497            .expect("render worker must exist outside rendering")
498            .video_path()
499    }
500
501    /// Finalizes the video file (flushes the encoder and waits for it).
502    pub fn finish_video(&mut self) {
503        if let Some(worker) = self.render_worker.as_mut() {
504            worker.finish_video();
505        }
506    }
507
508    /// Renders the entire scene and reports progress via a tracing progress bar and an optional `on_progress` callback.
509    ///
510    /// The frame count is `evaluator.total_secs() * fps` rounded up, with an extra frame appended when necessary to sample the final state.
511    #[instrument(skip_all)]
512    pub fn render_scene_with_progress(
513        &mut self,
514        evaluator: &mut SceneEvaluator,
515        on_progress: Option<Box<dyn Fn(u64, u64) + Send>>,
516    ) {
517        let start = Instant::now();
518        let worker_thread = self.render_worker.take().unwrap().yeet();
519
520        let total_secs = evaluator.total_secs();
521        let fps = self.fps as f64;
522        let raw_frames = total_secs * fps;
523        // Add an extra frame to sample the final state exactly,
524        // unless total_secs * fps is already an integer (last frame lands on total_secs).
525        let n = raw_frames.ceil() as u64;
526        let num_frames = if (raw_frames - raw_frames.round()).abs() < 1e-9 {
527            n
528        } else {
529            n + 1
530        };
531        let style =             ProgressStyle::with_template(
532                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
533            )
534            .unwrap()
535            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
536                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
537            })
538            .progress_chars("#>-");
539
540        let span = Span::current();
541        span.pb_set_style(&style);
542        span.pb_set_length(num_frames);
543
544        (0..num_frames)
545            .map(|f| (f as f64 / fps).min(total_secs))
546            .enumerate()
547            .for_each(|(i, sec)| {
548                worker_thread.sync_and_submit(|store| {
549                    evaluator.sample_at(sec, store);
550                });
551
552                span.pb_inc(1);
553                if let Some(cb) = &on_progress {
554                    cb(i as u64 + 1, num_frames);
555                }
556                span.pb_set_message(
557                    format!(
558                        "rendering {:.1?}/{:.1?}",
559                        Duration::from_secs_f64(sec),
560                        Duration::from_secs_f64(total_secs)
561                    )
562                    .as_str(),
563                );
564            });
565        self.render_worker.replace(worker_thread.retrive());
566
567        info!(
568            "rendered {} frames({:?}) in {:?}",
569            num_frames,
570            Duration::from_secs_f64(evaluator.total_secs()),
571            start.elapsed(),
572        );
573        trace!("render timeline cost: {:?}", start.elapsed());
574    }
575
576    /// Renders and saves screenshots for every [`TimeMark::Capture`] mark on the timeline.
577    #[instrument(skip_all)]
578    pub fn render_capture_marks(&mut self, evaluator: &mut SceneEvaluator) {
579        let start = Instant::now();
580        let timemarks = evaluator
581            .time_marks()
582            .iter()
583            .filter(|mark| matches!(mark.1, TimeMark::Capture(_)))
584            .map(|(sec, mark)| (*sec, mark.clone()))
585            .collect::<Vec<_>>();
586
587        let style =             ProgressStyle::with_template(
588                "[{elapsed_precise}] [{wide_bar:.cyan/blue}] frame {human_pos}/{human_len} (eta {eta}) {msg}",
589            )
590            .unwrap()
591            .with_key("eta", |state: &ProgressState, w: &mut dyn std::fmt::Write| {
592                write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap()
593            })
594            .progress_chars("#>-");
595
596        let span = Span::current();
597        span.pb_set_style(&style);
598        span.pb_set_length(timemarks.len() as u64);
599        let _enter = span.enter();
600
601        let mut captured = 0usize;
602        for (sec, TimeMark::Capture(filename)) in timemarks {
603            // The render has advanced to the end; captures seek back and replay
604            // (deterministic contract, direction handled inside sample_at).
605            evaluator.sample_at(sec, &mut self.store);
606            let worker = self.render_worker.as_mut().unwrap();
607            worker.render_store(&mut self.store);
608            worker.capture_frame(filename);
609            span.pb_inc(1);
610            captured += 1;
611        }
612        info!("saved {} capture frames from time marks", captured);
613
614        trace!("save capture frames cost: {:?}", start.elapsed());
615    }
616}
617
618// MARK: Download ffmpeg
619const FFMPEG_RELEASE_URL: &str = "https://github.com/eugeneware/ffmpeg-static/releases/latest";
620
621/// Returns the directory of the current executable.
622#[allow(unused)]
623pub(crate) fn exe_dir() -> PathBuf {
624    std::env::current_exe()
625        .unwrap()
626        .parent()
627        .unwrap()
628        .to_path_buf()
629}
630
631/// Download latest release of ffmpeg from <https://github.com/eugeneware/ffmpeg-static/releases/latest> to <target_dir>/ffmpeg
632pub fn download_ffmpeg(target_dir: impl AsRef<Path>) -> Result<PathBuf, anyhow::Error> {
633    use anyhow::Context;
634    use itertools::Itertools;
635    use std::io::Read;
636    use tracing::info;
637
638    let target_dir = target_dir.as_ref();
639
640    let res = reqwest::blocking::get(FFMPEG_RELEASE_URL).context("failed to get release url")?;
641    let url = res.url().to_string();
642    let url = url.split("tag").collect_array::<2>().unwrap();
643    let url = format!("{}/download/{}", url[0], url[1]);
644    info!("ffmpeg release url: {url:?}");
645
646    #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
647    let url = format!("{url}/ffmpeg-win32-x64.gz");
648    #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
649    let url = format!("{url}/ffmpeg-linux-x64.gz");
650    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
651    let url = format!("{url}/ffmpeg-linux-arm64.gz");
652    #[cfg(all(target_os = "macos", target_arch = "x86_64"))]
653    let url = format!("{url}/ffmpeg-darwin-x64.gz");
654    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
655    let url = format!("{url}/ffmpeg-darwin-arm64.gz");
656
657    info!("downloading ffmpeg from {url:?}...");
658
659    let res = reqwest::blocking::get(&url).context("get err")?;
660    let mut decoder = flate2::bufread::GzDecoder::new(std::io::BufReader::new(
661        std::io::Cursor::new(res.bytes().unwrap()),
662    ));
663    let mut bytes = Vec::new();
664    decoder
665        .read_to_end(&mut bytes)
666        .context("GzDecoder decode err")?;
667    let ffmpeg_path = target_dir.join("ffmpeg");
668    std::fs::write(&ffmpeg_path, bytes).unwrap();
669
670    #[cfg(target_family = "unix")]
671    {
672        use std::os::unix::fs::PermissionsExt;
673
674        std::fs::set_permissions(&ffmpeg_path, std::fs::Permissions::from_mode(0o755))?;
675    }
676    info!("ffmpeg downloaded to {target_dir:?}");
677    Ok(ffmpeg_path)
678}
679
680#[cfg(test)]
681mod tests {
682    use super::render_output_basename;
683
684    #[test]
685    fn render_output_basename_substitutes_template_placeholders() {
686        let cases = [
687            ("{name}_{width}x{height}_{fps}", "my_scene_1920x1080_60"),
688            (
689                "{name}_{fps}fps_{width}x{height}",
690                "my_scene_60fps_1920x1080",
691            ),
692            ("{name}", "my_scene"),
693        ];
694        for (template, expected) in cases {
695            assert_eq!(
696                render_output_basename(template, "my_scene", 1920, 1080, 60),
697                expected,
698                "template {template}"
699            );
700        }
701    }
702}