1use std::time::{Duration, Instant};
2use tokio::io::AsyncBufReadExt;
3use tokio::process::Command;
4
5use crate::{Codec, RateControlMode, Resolution, ffmpeg_path, probe};
6
7#[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, pub preset: String,
18 pub extra_args: Vec<String>,
19}
20
21#[derive(Debug, Clone)]
23pub struct EncodeResult {
24 pub job: EncodeJob,
25 pub bitrate: f64, pub file_size: u64, pub duration: Duration, }
29
30#[derive(Debug, Clone, Default)]
32pub struct Progress {
33 pub frame: i64,
34 pub fps: f64,
35 pub bitrate: f64, pub speed: f64, pub time: Duration,
38}
39
40pub 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 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 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
88pub 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 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
171fn 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}