Skip to main content

viser_ffmpeg/
encode.rs

1use std::time::{Duration, Instant};
2use tokio::io::AsyncBufReadExt;
3use tokio::process::Command;
4
5use crate::{Codec, RateControlMode, Resolution, ffmpeg_path, probe};
6
7/// Parameters for a single encode.
8#[derive(Debug, Clone)]
9pub struct EncodeJob {
10    pub input: String,
11    pub output: String,
12    pub resolution: Option<Resolution>,
13    pub codec: Codec,
14    pub crf: i32,
15    pub rate_control: RateControlMode,
16    pub target_bitrate: f64, // kbps, used for VBR mode
17    pub preset: String,
18    pub extra_args: Vec<String>,
19}
20
21/// Output of a completed encode.
22#[derive(Debug, Clone)]
23pub struct EncodeResult {
24    pub job: EncodeJob,
25    pub bitrate: f64,       // kbps (average)
26    pub file_size: u64,     // bytes
27    pub duration: Duration, // wall-clock encode time
28}
29
30/// Real-time encoding progress info parsed from FFmpeg.
31#[derive(Debug, Clone, Default)]
32pub struct Progress {
33    pub frame: i64,
34    pub fps: f64,
35    pub bitrate: f64, // kbps
36    pub speed: f64,   // e.g. 2.5x
37    pub time: Duration,
38}
39
40/// Runs an FFmpeg encode job. Progress updates are sent on the channel if provided.
41pub async fn encode(
42    job: EncodeJob,
43    progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
44) -> anyhow::Result<EncodeResult> {
45    let args = build_encode_args(&job);
46
47    let mut cmd = Command::new(ffmpeg_path());
48    cmd.args(&args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
49
50    let start = Instant::now();
51    let mut child = cmd.spawn().map_err(|e| anyhow::anyhow!("failed to start ffmpeg: {e}"))?;
52
53    // Parse progress from stdout
54    if let Some(stdout) = child.stdout.take() {
55        let tx = progress_tx.clone();
56        tokio::spawn(async move {
57            let reader = tokio::io::BufReader::new(stdout);
58            let mut lines = reader.lines();
59            let mut p = Progress::default();
60            while let Ok(Some(line)) = lines.next_line().await {
61                if parse_progress_line(&line, &mut p) {
62                    if let Some(ref tx) = tx {
63                        let _ = tx.try_send(p.clone());
64                    }
65                }
66            }
67        });
68    }
69
70    let output = child.wait_with_output().await?;
71    if !output.status.success() {
72        let stderr = String::from_utf8_lossy(&output.stderr);
73        anyhow::bail!("ffmpeg encode failed: {stderr}");
74    }
75
76    let elapsed = start.elapsed();
77
78    // Probe the output to get actual bitrate and file size
79    let meta = std::fs::metadata(&job.output)
80        .map_err(|e| anyhow::anyhow!("failed to stat output: {e}"))?;
81
82    let probe_result = probe(&job.output).await?;
83    let bitrate = probe_result.format.bit_rate as f64 / 1000.0;
84
85    Ok(EncodeResult { job, bitrate, file_size: meta.len(), duration: elapsed })
86}
87
88/// Copies a segment of a video file without re-encoding.
89pub async fn extract(input: &str, output: &str, start: f64, duration: f64) -> anyhow::Result<()> {
90    let args = vec![
91        "-y".to_string(),
92        "-ss".into(),
93        format!("{start:.6}"),
94        "-i".into(),
95        input.into(),
96        "-t".into(),
97        format!("{duration:.6}"),
98        "-c".into(),
99        "copy".into(),
100        "-avoid_negative_ts".into(),
101        "make_zero".into(),
102        output.into(),
103    ];
104
105    let output = Command::new(ffmpeg_path())
106        .args(&args)
107        .stderr(std::process::Stdio::piped())
108        .output()
109        .await?;
110
111    if !output.status.success() {
112        let stderr = String::from_utf8_lossy(&output.stderr);
113        anyhow::bail!("ffmpeg extract failed: {stderr}");
114    }
115    Ok(())
116}
117
118fn build_encode_args(job: &EncodeJob) -> Vec<String> {
119    let mut args = vec![
120        "-y".into(),
121        "-i".into(),
122        job.input.clone(),
123        "-an".into(),
124        "-progress".into(),
125        "pipe:1".into(),
126        "-nostats".into(),
127    ];
128
129    args.extend(["-c:v".into(), job.codec.as_str().into()]);
130
131    // Rate control mode
132    match job.rate_control {
133        RateControlMode::Qp => match job.codec {
134            Codec::SvtAv1 => {
135                args.extend(["-qp".into(), job.crf.to_string()]);
136                args.extend(["-svtav1-params".into(), "enable-adaptive-quantization=0".into()]);
137            }
138            _ => {
139                args.extend(["-qp".into(), job.crf.to_string()]);
140            }
141        },
142        RateControlMode::Vbr => {
143            args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
144            args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
145            args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
146        }
147        RateControlMode::Crf => {
148            args.extend(["-crf".into(), job.crf.to_string()]);
149        }
150    }
151
152    if !job.preset.is_empty() {
153        args.extend(["-preset".into(), job.preset.clone()]);
154    }
155
156    if let Some(ref res) = job.resolution {
157        if res.width > 0 && res.height > 0 {
158            args.extend([
159                "-vf".into(),
160                format!("scale={}:{}:flags=lanczos", res.width, res.height),
161            ]);
162        }
163    }
164
165    args.extend(job.extra_args.iter().cloned());
166    args.push(job.output.clone());
167
168    args
169}
170
171/// Returns true when a complete progress block is ready.
172fn parse_progress_line(line: &str, p: &mut Progress) -> bool {
173    let Some((key, value)) = line.split_once('=') else {
174        return false;
175    };
176
177    match key {
178        "frame" => {
179            p.frame = value.parse().unwrap_or(0);
180        }
181        "fps" => {
182            p.fps = value.parse().unwrap_or(0.0);
183        }
184        "bitrate" => {
185            let v = value.trim_end_matches("kbits/s");
186            p.bitrate = v.parse().unwrap_or(0.0);
187        }
188        "speed" => {
189            let v = value.trim_end_matches('x');
190            p.speed = v.parse().unwrap_or(0.0);
191        }
192        "out_time_us" => {
193            let us: u64 = value.parse().unwrap_or(0);
194            p.time = Duration::from_micros(us);
195        }
196        "progress" => return true,
197        _ => {}
198    }
199    false
200}