1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::{
5 fs,
6 io::{Write as _, stdout},
7 path::{Path, PathBuf},
8 process::{Child, Command, Stdio},
9 sync::Arc,
10 thread,
11 time::Duration,
12};
13
14use anyhow::{Context as _, Result};
15use atomic_progress::Progress;
16use headless_chrome::{Browser, LaunchOptions, Tab};
17
18#[derive(clap::ValueEnum, Clone, Debug, Eq, PartialEq)]
25pub enum Codec {
26 Prores,
28 Ffv1,
30 H264,
32 H265,
34 Vp9,
36 Av1,
38}
39
40impl Codec {
41 #[must_use]
43 pub fn ffmpeg_args(&self) -> Vec<&'static str> {
44 match self {
45 Self::Prores => vec![
46 "-c:v",
47 "prores_ks",
48 "-profile:v",
49 "3",
50 "-vendor",
51 "apl0",
52 "-pix_fmt",
53 "yuv422p10le",
54 ],
55 Self::Ffv1 => vec![
56 "-c:v", "ffv1", "-level", "3", "-g", "1", "-pix_fmt", "yuv444p",
57 ],
58 Self::H264 => vec![
59 "-c:v",
60 "libx264",
61 "-preset",
62 "ultrafast",
63 "-crf",
64 "0",
65 "-pix_fmt",
66 "yuv444p",
67 ],
68 Self::H265 => vec![
69 "-c:v",
70 "libx265",
71 "-preset",
72 "ultrafast",
73 "-x265-params",
74 "lossless=1",
75 "-pix_fmt",
76 "yuv444p",
77 ],
78 Self::Vp9 => vec![
79 "-c:v",
80 "libvpx-vp9",
81 "-lossless",
82 "1",
83 "-pix_fmt",
84 "yuv444p",
85 ],
86 Self::Av1 => vec![
87 "-c:v",
88 "libaom-av1",
89 "-crf",
90 "0",
91 "-cpu-used",
92 "8",
93 "-pix_fmt",
94 "yuv444p",
95 ],
96 }
97 }
98
99 pub fn validate_extension(&self, output_path: &Path) {
101 let ext = output_path
102 .extension()
103 .and_then(|e| e.to_str())
104 .unwrap_or("")
105 .to_lowercase();
106
107 match self {
108 Self::Prores if ext != "mov" => {
109 eprintln!("⚠️ Warning: ProRes usually requires a .mov container.");
110 }
111 Self::Ffv1 if ext != "mkv" => {
112 eprintln!("⚠️ Warning: FFV1 requires an .mkv container.");
113 }
114 Self::H264 | Self::H265 if ext != "mp4" && ext != "mkv" => {
115 eprintln!("⚠️ Warning: H.264/H.265 usually expect .mp4 or .mkv.");
116 }
117 Self::Vp9 | Self::Av1 if ext != "webm" && ext != "mkv" => {
118 eprintln!("⚠️ Warning: VP9/AV1 usually expect .webm or .mkv.");
119 }
120 _ => {} }
122 }
123}
124
125pub fn prepare_html_wrapper(input_svg: &Path, width: u32, height: u32) -> Result<PathBuf> {
133 let svg_content = fs::read_to_string(input_svg)
134 .with_context(|| format!("Failed to read input SVG at {}", input_svg.display()))?;
135
136 let html = format!(
137 r"<!DOCTYPE html>
138 <html>
139 <head>
140 <style>
141 body {{ margin: 0; padding: 0; width: {width}px; height: {height}px; background-color: #ffffff; display: flex; justify-content: center; align-items: center; overflow: hidden; }}
142 svg {{ width: 100% !important; height: 100% !important; object-fit: contain !important; }}
143 </style>
144 </head>
145 <body>{svg_content}</body>
146 </html>"
147 );
148
149 let temp_dir = std::env::temp_dir();
150 let temp_html_path = temp_dir.join(format!("svg_wrapper_{}.html", std::process::id()));
151 fs::write(&temp_html_path, &html)?;
152
153 Ok(temp_html_path)
154}
155
156#[must_use]
160pub fn spawn_progress_monitor(progress: Progress) -> thread::JoinHandle<()> {
161 thread::spawn(move || {
162 while !progress.is_finished() {
163 let snap = progress.snapshot();
164 let pct = if snap.total > 0 {
165 #[allow(clippy::cast_precision_loss)]
166 {
167 (snap.position as f64 / snap.total as f64) * 100.0
168 }
169 } else {
170 0.0
171 };
172
173 let filled = (pct / 5.0) as usize;
174 let bar = "█".repeat(filled) + &"░".repeat(20usize.saturating_sub(filled));
175
176 print!(
177 "\r{} |{}| {:>5.1}% | Frame {}/{}",
178 snap.name, bar, pct, snap.position, snap.total
179 );
180 let _ = stdout().flush();
181
182 thread::sleep(Duration::from_millis(100));
183 }
184 println!(
185 "\r{} |{}| 100.0% | Done! ",
186 progress.get_name(),
187 "█".repeat(20)
188 );
189 })
190}
191
192pub fn launch_browser(html_path: &Path, width: u32, height: u32) -> Result<(Browser, Arc<Tab>)> {
195 let browser = Browser::new(LaunchOptions {
196 headless: true,
197 sandbox: false,
198 window_size: Some((width, height)),
199 args: vec![
200 &std::ffi::OsString::from("--disable-dev-shm-usage"),
201 &std::ffi::OsString::from("--disable-gpu"),
202 &std::ffi::OsString::from("--force-color-profile=srgb"),
203 ],
204 ..Default::default()
205 })
206 .context("Failed to launch Headless Chrome")?;
207
208 let tab = browser.new_tab()?;
209 let file_url = format!("file://{}", html_path.display());
210 tab.navigate_to(&file_url)?;
211 tab.wait_until_navigated()?;
212
213 Ok((browser, tab))
214}
215
216pub fn spawn_ffmpeg_pipeline(fps: u64, codec: &Codec, output: &Path) -> Result<Child> {
219 let fps_string = fps.to_string();
220 let mut ffmpeg_args = vec![
221 "-y",
222 "-f",
223 "image2pipe",
224 "-vcodec",
225 "png",
226 "-r",
227 &fps_string,
228 "-i",
229 "-",
230 ];
231
232 ffmpeg_args.extend(codec.ffmpeg_args());
233
234 let output_str = output.to_string_lossy();
235 ffmpeg_args.push(&output_str);
236
237 Command::new("ffmpeg")
238 .args(&ffmpeg_args)
239 .stdin(Stdio::piped())
240 .stdout(Stdio::null())
241 .stderr(Stdio::null())
242 .spawn()
243 .context("Failed to spawn FFmpeg. Ensure it is installed and in your PATH.")
244}
245
246pub fn capture_frames(
249 tab: &Tab,
250 stdin: &mut std::process::ChildStdin,
251 total_frames: u64,
252 frame_duration_ms: f64,
253 progress: &Progress,
254) -> Result<()> {
255 for frame in 0..total_frames {
256 #[allow(clippy::cast_precision_loss)]
257 let current_time_ms = (frame as f64) * frame_duration_ms;
258 let current_time_sec = current_time_ms / 1000.0;
259
260 let eval_script = format!(
261 r"
262 document.getAnimations().forEach(anim => {{
263 anim.pause();
264 anim.currentTime = {current_time_ms};
265 }});
266 document.querySelectorAll('svg').forEach(svg => {{
267 if (typeof svg.pauseAnimations === 'function') {{
268 svg.pauseAnimations();
269 svg.setCurrentTime({current_time_sec});
270 }}
271 }});
272 "
273 );
274 tab.evaluate(&eval_script, false)?;
275
276 let png_data = tab.capture_screenshot(
277 headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption::Png,
278 None,
279 None,
280 true,
281 )?;
282
283 stdin.write_all(&png_data)?;
284 progress.bump();
285 }
286
287 Ok(())
288}