1use 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
22fn 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
37pub 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
50pub 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
60pub struct RenderJob {
66 pub output: Output,
68 pub capture_marks: bool,
73}
74
75impl RenderJob {
76 pub fn full(output: Output, capture_marks: bool) -> Self {
78 Self {
79 output,
80 capture_marks,
81 }
82 }
83}
84
85pub 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
104pub 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
119pub 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 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 if evaluator.has_audio() {
157 match audio::audio_codec(output.format) {
158 Some(_) => {
159 let pcm = evaluator.audio();
160 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
178const DEFAULT_LOGIC_FPS: f64 = 120.0;
180
181pub 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 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 pub fn get_store(&self) -> EvaluatedFrame {
200 self.back_rx.recv_blocking().unwrap()
201 }
202
203 pub fn retrive(&self) -> RenderWorker {
205 self.submit_frame_tx.close(); self.worker_rx.recv_blocking().unwrap()
207 }
208}
209
210pub struct RenderWorker {
212 ctx: WgpuContext,
213 render_app: SubApp,
214 render_textures: Vec<RenderTextures>,
215 clear_color: wgpu::Color,
216 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 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 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 pub fn video_path(&self) -> &Path {
329 &self.video_path
330 }
331
332 pub fn finish_video(&mut self) {
337 self.video_writer = None;
338 }
339
340 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 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 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 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 back_tx.send_blocking(store).unwrap();
382
383 while let Some(&(prev, _)) = pending.front() {
386 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 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 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 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 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 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 let buffer = self.render_textures[target_idx].get_rendered_texture_img_buffer(&self.ctx);
449 buffer.save(path).unwrap();
450 }
451
452 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
474pub struct RanimRenderApp {
477 render_worker: Option<RenderWorker>,
478 fps: u32,
479 store: EvaluatedFrame,
480}
481
482impl RanimRenderApp {
483 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 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 pub fn finish_video(&mut self) {
503 if let Some(worker) = self.render_worker.as_mut() {
504 worker.finish_video();
505 }
506 }
507
508 #[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 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 #[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 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
618const FFMPEG_RELEASE_URL: &str = "https://github.com/eugeneware/ffmpeg-static/releases/latest";
620
621#[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
631pub 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}