Skip to main content

svg_to_video/
lib.rs

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// ============================================================================
19// Codec Domain Logic
20// ============================================================================
21
22/// Supported output codecs for the video encoding pipeline.
23/// All codecs are configured for mathematically lossless or visually lossless output.
24#[derive(clap::ValueEnum, Clone, Debug, Eq, PartialEq)]
25pub enum Codec {
26    /// Apple `ProRes` 422 HQ (Optimized for NLEs like `DaVinci` Resolve)
27    Prores,
28    /// FFV1 (Mathematically lossless, ultimate archive format)
29    Ffv1,
30    /// H.264 Lossless (Large file size, mathematically perfect)
31    H264,
32    /// HEVC / H.265 Lossless (Better lossless compression than H.264)
33    H265,
34    /// VP9 Lossless (Google's open-source lossless format)
35    Vp9,
36    /// AV1 Lossless (Next-gen open-source, mathematically perfect)
37    Av1,
38}
39
40impl Codec {
41    /// Returns the exact `FFmpeg` flags required to encode this codec in a lossless manner.
42    #[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    /// Warns the user if the chosen file extension does not match the recommended container.
100    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            _ => {} // Valid combination
121        }
122    }
123}
124
125// ============================================================================
126// Internal Helpers
127// ============================================================================
128
129/// Reads the SVG content and wraps it in a perfectly sized HTML document.
130///
131/// Returns the path to the temporary file created on disk.
132pub 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/// Spawns a background thread to render a CLI progress bar dynamically.
157///
158/// Returns the `JoinHandle` of the spawned thread to allow graceful termination.
159#[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
192/// Initializes an instance of Headless Chrome, enforces a consistent color profile,
193/// and navigates a new tab to the provided local HTML wrapper file.
194pub 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
216/// Spawns the underlying `FFmpeg` process configured to accept continuous PNG streams
217/// via `stdin` and writes the output directly to the specified destination.
218pub 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
246/// Executes the core capture loop: pauses animations, advances timing precisely via JS,
247/// triggers a screenshot, and pipes the resulting PNG binary directly into `FFmpeg`.
248pub 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}