Skip to main content

oxide_browser/
video.rs

1//! FFmpeg-backed video decode, playback clock, HLS variant metadata, and subtitle cues.
2
3use std::io::Write;
4use std::path::Path;
5use std::sync::OnceLock;
6use std::time::Instant;
7
8use ffmpeg::format::{self, Pixel};
9use ffmpeg::media::Type;
10use ffmpeg::software::scaling::{context::Context as ScalerContext, flag::Flags as ScaleFlags};
11use ffmpeg::util::frame::video::Video;
12use ffmpeg::util::mathematics::{rescale, Rescale};
13use ffmpeg_next as ffmpeg;
14use tempfile::NamedTempFile;
15use url::Url;
16
17use crate::subtitle::SubtitleCue;
18
19static FFMPEG_INIT: OnceLock<Result<(), ffmpeg::Error>> = OnceLock::new();
20
21fn ensure_ffmpeg() -> Result<(), ffmpeg::Error> {
22    *FFMPEG_INIT.get_or_init(|| {
23        let r = ffmpeg::init();
24        if r.is_ok() {
25            // Avoid spamming stderr with EOF/packet noise during normal decode.
26            ffmpeg::util::log::set_level(ffmpeg::util::log::Level::Quiet);
27        }
28        r
29    })
30}
31
32fn frame_pts_ms_from_tb(time_base: ffmpeg::Rational, frame: &Video) -> Option<u64> {
33    let pts = frame.timestamp().or_else(|| frame.pts())?;
34    if pts < 0 {
35        return None;
36    }
37    let ms = pts.rescale(time_base, (1, 1000));
38    if ms < 0 {
39        None
40    } else {
41        Some(ms as u64)
42    }
43}
44
45fn scale_frame_to_rgba(
46    scaler: &mut ScalerContext,
47    decoded: &Video,
48) -> Result<(Vec<u8>, u32, u32), String> {
49    let mut rgb = Video::empty();
50    scaler.run(decoded, &mut rgb).map_err(|e| e.to_string())?;
51    let w = rgb.width();
52    let h = rgb.height();
53    let stride = rgb.stride(0);
54    let mut packed = Vec::with_capacity((w * h * 4) as usize);
55    for row in 0..h as usize {
56        let start = row * stride;
57        let end = start + (w as usize * 4);
58        packed.extend_from_slice(&rgb.data(0)[start..end]);
59    }
60    Ok((packed, w, h))
61}
62
63/// Decodes one video stream to RGBA via libswscale.
64pub struct VideoPlayer {
65    input: format::context::Input,
66    video_stream_index: usize,
67    decoder: ffmpeg::decoder::Video,
68    scaler: ScalerContext,
69    time_base: ffmpeg::Rational,
70    pub duration_ms: u64,
71    pub width: u32,
72    pub height: u32,
73    /// Last presented frame PTS (ms), for incremental decode.
74    last_pts_ms: Option<u64>,
75    /// Cached RGBA for last decoded frame.
76    last_rgba: Option<(Vec<u8>, u32, u32, u64)>,
77    /// Whether [`ffmpeg::decoder::Opened::send_eof`] was already sent (must not repeat).
78    decoder_eof_sent: bool,
79}
80
81impl VideoPlayer {
82    fn open_input(input: format::context::Input) -> Result<Self, String> {
83        ensure_ffmpeg().map_err(|e| e.to_string())?;
84
85        let stream = input
86            .streams()
87            .best(Type::Video)
88            .ok_or_else(|| "no video stream".to_string())?;
89        let video_stream_index = stream.index();
90        let time_base = stream.time_base();
91
92        let context = ffmpeg::codec::context::Context::from_parameters(stream.parameters())
93            .map_err(|e| e.to_string())?;
94        let decoder = context.decoder().video().map_err(|e| e.to_string())?;
95
96        let duration_ms = Self::probe_duration_ms(&input, &stream);
97
98        let width = decoder.width();
99        let height = decoder.height();
100
101        let scaler = ScalerContext::get(
102            decoder.format(),
103            width,
104            height,
105            Pixel::RGBA,
106            width,
107            height,
108            ScaleFlags::BILINEAR,
109        )
110        .map_err(|e| e.to_string())?;
111
112        Ok(Self {
113            input,
114            video_stream_index,
115            decoder,
116            scaler,
117            time_base,
118            duration_ms,
119            width,
120            height,
121            last_pts_ms: None,
122            last_rgba: None,
123            decoder_eof_sent: false,
124        })
125    }
126
127    pub fn open_path(path: &Path) -> Result<Self, String> {
128        let input = format::input(path).map_err(|e| e.to_string())?;
129        Self::open_input(input)
130    }
131
132    pub fn open_url(url: &str) -> Result<Self, String> {
133        let input = format::input(url).map_err(|e| e.to_string())?;
134        Self::open_input(input)
135    }
136
137    fn probe_duration_ms(
138        input: &format::context::Input,
139        stream: &ffmpeg::format::stream::Stream,
140    ) -> u64 {
141        let d = input.duration();
142        if d > 0 {
143            let ms = d.rescale(rescale::TIME_BASE, (1, 1000));
144            if ms > 0 {
145                return ms as u64;
146            }
147        }
148        let sd = stream.duration();
149        if sd > 0 {
150            let ms = sd.rescale(stream.time_base(), (1, 1000));
151            return ms.max(0) as u64;
152        }
153        0
154    }
155
156    fn seek_to_ms(&mut self, target_ms: u64) -> Result<(), String> {
157        let ts = (target_ms as i64).rescale((1, 1000), rescale::TIME_BASE);
158        self.input.seek(ts, ts..ts).map_err(|e| e.to_string())?;
159        self.decoder.flush();
160        self.last_pts_ms = None;
161        self.last_rgba = None;
162        self.decoder_eof_sent = false;
163        Ok(())
164    }
165
166    /// Decode a frame appropriate for `target_ms` (last frame with PTS ≤ target, or first at/after seek).
167    pub fn decode_frame_at(&mut self, target_ms: u64) -> Result<(Vec<u8>, u32, u32), String> {
168        let target_ms = target_ms.min(self.duration_ms.saturating_add(500));
169
170        let need_seek = match self.last_pts_ms {
171            None => true,
172            Some(last) => target_ms < last || target_ms.saturating_sub(last) > 2_500,
173        };
174
175        if need_seek {
176            self.seek_to_ms(target_ms)?;
177        }
178
179        let mut best_before: Option<(Vec<u8>, u32, u32, u64)> = None;
180        let mut best_after: Option<(Vec<u8>, u32, u32, u64)> = None;
181
182        {
183            let VideoPlayer {
184                ref mut input,
185                video_stream_index,
186                ref mut decoder,
187                ref mut scaler,
188                time_base,
189                ..
190            } = self;
191
192            'outer: for (stream, packet) in input.packets() {
193                if stream.index() != *video_stream_index {
194                    continue;
195                }
196                decoder.send_packet(&packet).map_err(|e| e.to_string())?;
197                let mut decoded = Video::empty();
198                while decoder.receive_frame(&mut decoded).is_ok() {
199                    let pts_ms = frame_pts_ms_from_tb(*time_base, &decoded).unwrap_or(0);
200                    let (buf, w, h) = scale_frame_to_rgba(scaler, &decoded)?;
201                    if pts_ms >= target_ms {
202                        best_after = Some((buf, w, h, pts_ms));
203                        break 'outer;
204                    }
205                    best_before = Some((buf, w, h, pts_ms));
206                }
207            }
208        }
209
210        // Pull frames that are already buffered in the decoder (no new packets yet).
211        if best_after.is_none() {
212            let VideoPlayer {
213                ref mut decoder,
214                ref mut scaler,
215                time_base,
216                ..
217            } = self;
218            let mut decoded = Video::empty();
219            while decoder.receive_frame(&mut decoded).is_ok() {
220                let pts_ms = frame_pts_ms_from_tb(*time_base, &decoded).unwrap_or(0);
221                let (buf, w, h) = scale_frame_to_rgba(scaler, &decoded)?;
222                if pts_ms >= target_ms {
223                    best_after = Some((buf, w, h, pts_ms));
224                    break;
225                }
226                best_before = Some((buf, w, h, pts_ms));
227            }
228        }
229
230        if let Some((buf, w, h, pts)) = best_after {
231            self.last_pts_ms = Some(pts);
232            self.last_rgba = Some((buf.clone(), w, h, pts));
233            return Ok((buf, w, h));
234        }
235
236        if let Some((buf, w, h, pts)) = best_before {
237            self.last_pts_ms = Some(pts);
238            self.last_rgba = Some((buf.clone(), w, h, pts));
239            return Ok((buf, w, h));
240        }
241
242        // Demuxer exhausted: flush the decoder exactly once, then drain remaining frames.
243        if !self.decoder_eof_sent {
244            let _ = self.decoder.send_eof();
245            self.decoder_eof_sent = true;
246            let VideoPlayer {
247                ref mut decoder,
248                ref mut scaler,
249                time_base,
250                ..
251            } = self;
252            let mut decoded = Video::empty();
253            while decoder.receive_frame(&mut decoded).is_ok() {
254                let pts_ms = frame_pts_ms_from_tb(*time_base, &decoded).unwrap_or(0);
255                let (buf, w, h) = scale_frame_to_rgba(scaler, &decoded)?;
256                if pts_ms >= target_ms {
257                    best_after = Some((buf, w, h, pts_ms));
258                    break;
259                }
260                best_before = Some((buf, w, h, pts_ms));
261            }
262        }
263
264        if let Some((buf, w, h, pts)) = best_after {
265            self.last_pts_ms = Some(pts);
266            self.last_rgba = Some((buf.clone(), w, h, pts));
267            return Ok((buf, w, h));
268        }
269
270        if let Some((buf, w, h, pts)) = best_before {
271            self.last_pts_ms = Some(pts);
272            self.last_rgba = Some((buf.clone(), w, h, pts));
273            return Ok((buf, w, h));
274        }
275
276        if let Some((buf, w, h, _pts)) = self.last_rgba.clone() {
277            return Ok((buf, w, h));
278        }
279
280        Err("no video frame decoded".into())
281    }
282}
283
284/// FFmpeg decoder state is synchronized through [`std::sync::Mutex`] on the host; not shared across threads concurrently.
285unsafe impl Send for VideoPlayer {}
286
287/// Global playback + optional [`VideoPlayer`] and subtitle list.
288pub struct VideoPlaybackState {
289    pub player: Option<VideoPlayer>,
290    pub playing: bool,
291    play_start: Option<Instant>,
292    pub base_position_ms: u64,
293    pub volume: f32,
294    pub looping: bool,
295    pub pip: bool,
296    pub subtitles: Vec<SubtitleCue>,
297    pub last_url_content_type: String,
298    pub hls_variants: Vec<String>,
299    pub hls_base_url: String,
300    temp_file: Option<NamedTempFile>,
301}
302
303impl Default for VideoPlaybackState {
304    fn default() -> Self {
305        Self {
306            player: None,
307            playing: false,
308            play_start: None,
309            base_position_ms: 0,
310            volume: 1.0,
311            looping: false,
312            pip: false,
313            subtitles: Vec::new(),
314            last_url_content_type: String::new(),
315            hls_variants: Vec::new(),
316            hls_base_url: String::new(),
317            temp_file: None,
318        }
319    }
320}
321
322impl VideoPlaybackState {
323    pub fn duration_ms(&self) -> u64 {
324        self.player.as_ref().map(|p| p.duration_ms).unwrap_or(0)
325    }
326
327    pub fn current_position_ms(&self) -> u64 {
328        let dur = self.duration_ms();
329        let pos = if self.playing {
330            let start = self.play_start.expect("play_start when playing");
331            self.base_position_ms + start.elapsed().as_millis() as u64
332        } else {
333            self.base_position_ms
334        };
335        if self.looping && dur > 0 {
336            pos % dur
337        } else if dur > 0 {
338            pos.min(dur)
339        } else {
340            pos
341        }
342    }
343
344    pub fn play(&mut self) {
345        self.play_start = Some(Instant::now());
346        self.playing = true;
347    }
348
349    pub fn pause(&mut self) {
350        if self.playing {
351            self.base_position_ms = self.current_position_ms();
352            self.playing = false;
353            self.play_start = None;
354        }
355    }
356
357    pub fn stop(&mut self) {
358        self.playing = false;
359        self.play_start = None;
360        self.base_position_ms = 0;
361        self.player = None;
362        self.temp_file = None;
363        self.hls_variants.clear();
364        self.hls_base_url.clear();
365    }
366
367    /// Reset clock only (after swapping the underlying stream, e.g. HLS variant).
368    pub fn reset_playback_clock(&mut self) {
369        self.base_position_ms = 0;
370        self.playing = false;
371        self.play_start = None;
372    }
373
374    pub fn seek(&mut self, position_ms: u64) {
375        let dur = self.duration_ms();
376        let pos = if dur > 0 {
377            position_ms.min(dur)
378        } else {
379            position_ms
380        };
381        self.base_position_ms = pos;
382        if self.playing {
383            self.play_start = Some(Instant::now());
384        }
385    }
386
387    pub fn open_path(&mut self, path: &Path) -> Result<(), String> {
388        self.stop();
389        let p = VideoPlayer::open_path(path)?;
390        self.player = Some(p);
391        Ok(())
392    }
393
394    pub fn open_bytes(&mut self, data: &[u8], format_hint: u32) -> Result<(), String> {
395        self.stop();
396        let ext = crate::video_format::suffix_for_format(format_hint);
397        let mut tmp = tempfile::Builder::new()
398            .suffix(ext)
399            .tempfile()
400            .map_err(|e| e.to_string())?;
401        tmp.write_all(data).map_err(|e| e.to_string())?;
402        tmp.flush().map_err(|e| e.to_string())?;
403        let path = tmp.path().to_owned();
404        self.temp_file = Some(tmp);
405        self.open_path(&path)
406    }
407}
408
409/// Parse `#EXT-X-STREAM-INF` master playlist variant URIs (best-effort).
410pub fn parse_hls_master_variants(body: &str) -> Vec<String> {
411    let lines: Vec<&str> = body.lines().collect();
412    let mut out = Vec::new();
413    for i in 0..lines.len() {
414        if lines[i].starts_with("#EXT-X-STREAM-INF") && i + 1 < lines.len() {
415            let u = lines[i + 1].trim();
416            if !u.starts_with('#') && !u.is_empty() {
417                out.push(u.to_string());
418            }
419        }
420    }
421    out
422}
423
424pub fn resolve_against_base(base: &str, relative: &str) -> Option<String> {
425    let b = Url::parse(base).ok()?;
426    b.join(relative).ok().map(|u| u.to_string())
427}