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