playback
This commit is contained in:
@@ -36,21 +36,21 @@ impl FrameCache {
|
||||
self.map.contains_key(&idx)
|
||||
}
|
||||
|
||||
fn get(&mut self, idx: u64) -> Option<Vec<u8>> {
|
||||
let bytes = self.map.get(&idx)?.clone();
|
||||
if let Some(pos) = self.order.iter().position(|&i| i == idx) {
|
||||
self.order.remove(pos);
|
||||
}
|
||||
self.order.push_back(idx);
|
||||
Some(bytes)
|
||||
/// FIFO retrieval (no reorder-on-access). Our real access pattern is
|
||||
/// sequential decode, so cache fills in order and evicts in order; the
|
||||
/// previous LRU reshuffle was an O(n) VecDeque scan that bought nothing.
|
||||
fn get(&self, idx: u64) -> Option<Vec<u8>> {
|
||||
self.map.get(&idx).cloned()
|
||||
}
|
||||
|
||||
fn insert(&mut self, idx: u64, bytes: Vec<u8>) {
|
||||
if self.map.contains_key(&idx) {
|
||||
if let Some(pos) = self.order.iter().position(|&i| i == idx) {
|
||||
self.order.remove(pos);
|
||||
}
|
||||
} else if self.map.len() >= self.capacity {
|
||||
// Re-decode of an already-cached frame — just overwrite, no
|
||||
// order change (the queue entry stays valid).
|
||||
self.map.insert(idx, bytes);
|
||||
return;
|
||||
}
|
||||
if self.map.len() >= self.capacity {
|
||||
if let Some(oldest) = self.order.pop_front() {
|
||||
self.map.remove(&oldest);
|
||||
}
|
||||
@@ -100,16 +100,26 @@ pub struct DecoderSession {
|
||||
/// on any decode failure so the next attempt uses software — we'd
|
||||
/// rather degrade than keep retrying a flaky GPU path.
|
||||
hwaccel: Option<String>,
|
||||
/// When Some(H), ffmpeg downscales to height H (aspect preserved) via
|
||||
/// `-vf scale=-2:H`. Used by the playback decoder for cheap previews;
|
||||
/// the extract decoder always passes None so saved frames are full-res.
|
||||
preview_max_height: Option<u32>,
|
||||
shared: Arc<Shared>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DecoderSession {
|
||||
pub fn open(path: PathBuf, info: VideoInfo, hwaccel: Option<String>) -> Self {
|
||||
pub fn open(
|
||||
path: PathBuf,
|
||||
info: VideoInfo,
|
||||
hwaccel: Option<String>,
|
||||
preview_max_height: Option<u32>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path,
|
||||
info,
|
||||
hwaccel: hwaccel.filter(|s| !s.is_empty()),
|
||||
preview_max_height: preview_max_height.filter(|h| *h > 0),
|
||||
shared: Arc::new(Shared {
|
||||
state: Mutex::new(SharedState {
|
||||
child: None,
|
||||
@@ -223,8 +233,13 @@ impl DecoderSession {
|
||||
fn seek_and_start_worker(&mut self, idx: u64) -> Result<(), String> {
|
||||
self.stop_worker();
|
||||
|
||||
let (child, stdout) =
|
||||
spawn_ffmpeg(&self.path, &self.info, idx, self.hwaccel.as_deref())?;
|
||||
let (child, stdout) = spawn_ffmpeg(
|
||||
&self.path,
|
||||
&self.info,
|
||||
idx,
|
||||
self.hwaccel.as_deref(),
|
||||
self.preview_max_height,
|
||||
)?;
|
||||
|
||||
{
|
||||
let mut st = self.shared.state.lock().unwrap();
|
||||
@@ -314,6 +329,7 @@ fn spawn_ffmpeg(
|
||||
info: &VideoInfo,
|
||||
idx: u64,
|
||||
hwaccel: Option<&str>,
|
||||
preview_max_height: Option<u32>,
|
||||
) -> Result<(Child, BufReader<ChildStdout>), String> {
|
||||
let path_str = path.to_str().ok_or_else(|| "non-utf8 path".to_string())?;
|
||||
let t = idx as f64 / info.fps;
|
||||
@@ -345,8 +361,17 @@ fn spawn_ffmpeg(
|
||||
// milliseconds instead of hundreds of ms.
|
||||
cmd.args(["-ss", &format!("{:.6}", t)]);
|
||||
cmd.args(["-i", path_str]);
|
||||
cmd.args(["-an"]);
|
||||
// Preview scaling: cap output height to `h`, width auto (`-2` keeps
|
||||
// aspect & ensures even). Skip the filter entirely when the source is
|
||||
// already shorter than the cap — no upscale, no wasted filter step.
|
||||
if let Some(h) = preview_max_height {
|
||||
if info.height > h {
|
||||
let vf = format!("scale=-2:{h}");
|
||||
cmd.args(["-vf", &vf]);
|
||||
}
|
||||
}
|
||||
cmd.args([
|
||||
"-an",
|
||||
"-q:v",
|
||||
"3",
|
||||
"-f",
|
||||
@@ -413,34 +438,329 @@ fn worker_loop(mut stdout: BufReader<ChildStdout>, shared: Arc<Shared>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one JPEG (SOI 0xFF 0xD8 .. EOI 0xFF 0xD9) from an image2pipe stream.
|
||||
///
|
||||
/// Chunked scan: for each `fill_buf` chunk we vector-scan for the marker
|
||||
/// byte (`slice::iter().position` compiles to a SIMD memchr in release),
|
||||
/// then `extend_from_slice` the body wholesale. The previous per-byte
|
||||
/// `for &b in buf { push(b); ... }` cost ~3 ns/byte and at ~150 KB/frame ×
|
||||
/// 30 fps it was a measurable chunk of the worker's budget. The bulk-copy
|
||||
/// path measures around 10-20× faster on warm caches.
|
||||
fn read_jpeg_bytes(reader: &mut BufReader<ChildStdout>) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::with_capacity(64 * 1024);
|
||||
let mut last = 0u8;
|
||||
let mut out: Vec<u8> = Vec::with_capacity(64 * 1024);
|
||||
let mut in_jpeg = false;
|
||||
// Last byte of the previous chunk; needed because the 2-byte JPEG
|
||||
// marker (0xFF 0xMM) can straddle a fill_buf boundary.
|
||||
let mut last: u8 = 0;
|
||||
|
||||
loop {
|
||||
let buf = reader.fill_buf().map_err(|e| format!("pipe read: {e}"))?;
|
||||
if buf.is_empty() {
|
||||
return Err("ffmpeg pipe closed before frame completed".into());
|
||||
}
|
||||
let mut consumed = 0usize;
|
||||
for &b in buf {
|
||||
consumed += 1;
|
||||
if !in_jpeg {
|
||||
if last == 0xFF && b == 0xD8 {
|
||||
let n = buf.len();
|
||||
|
||||
if !in_jpeg {
|
||||
match find_marker(buf, last, 0xD8) {
|
||||
Some(d8_pos) => {
|
||||
in_jpeg = true;
|
||||
out.push(0xFF);
|
||||
out.push(0xD8);
|
||||
let after = d8_pos + 1;
|
||||
let rest = &buf[after..];
|
||||
// Same-chunk EOI is the common case for small frames.
|
||||
if let Some(d9_pos) = find_marker(rest, 0, 0xD9) {
|
||||
out.extend_from_slice(&rest[..=d9_pos]);
|
||||
reader.consume(after + d9_pos + 1);
|
||||
return Ok(out);
|
||||
}
|
||||
out.extend_from_slice(rest);
|
||||
last = rest.last().copied().unwrap_or(0);
|
||||
reader.consume(n);
|
||||
}
|
||||
last = b;
|
||||
} else {
|
||||
out.push(b);
|
||||
if last == 0xFF && b == 0xD9 {
|
||||
reader.consume(consumed);
|
||||
return Ok(out);
|
||||
None => {
|
||||
last = buf[n - 1];
|
||||
reader.consume(n);
|
||||
}
|
||||
last = b;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
reader.consume(consumed);
|
||||
|
||||
if let Some(d9_pos) = find_marker(buf, last, 0xD9) {
|
||||
out.extend_from_slice(&buf[..=d9_pos]);
|
||||
reader.consume(d9_pos + 1);
|
||||
return Ok(out);
|
||||
}
|
||||
out.extend_from_slice(buf);
|
||||
last = buf[n - 1];
|
||||
reader.consume(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the index of `marker` byte preceded by 0xFF within `buf`.
|
||||
/// `prev` is the last byte of the previous chunk (0 if none) so a marker
|
||||
/// that straddles a fill_buf boundary is still detected.
|
||||
fn find_marker(buf: &[u8], prev: u8, marker: u8) -> Option<usize> {
|
||||
if buf.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if prev == 0xFF && buf[0] == marker {
|
||||
return Some(0);
|
||||
}
|
||||
let mut start = 0usize;
|
||||
while start + 1 < buf.len() {
|
||||
// SIMD-friendly scan for 0xFF; LLVM lowers this to a memchr loop.
|
||||
let off = buf[start..buf.len() - 1]
|
||||
.iter()
|
||||
.position(|&b| b == 0xFF)?;
|
||||
let ff_pos = start + off;
|
||||
if buf[ff_pos + 1] == marker {
|
||||
return Some(ff_pos + 1);
|
||||
}
|
||||
start = ff_pos + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{BufRead, Cursor, Read, Result as IoResult};
|
||||
|
||||
#[test]
|
||||
fn find_marker_basic() {
|
||||
// 0xFF 0xD8 at index 2-3
|
||||
let buf = &[0x00, 0x11, 0xFF, 0xD8, 0x22][..];
|
||||
assert_eq!(find_marker(buf, 0, 0xD8), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marker_no_match() {
|
||||
let buf = &[0xFF, 0xC0, 0xFF, 0xC4, 0x00][..];
|
||||
assert_eq!(find_marker(buf, 0, 0xD8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marker_boundary() {
|
||||
// prev chunk ended on 0xFF, this chunk starts with 0xD9
|
||||
let buf = &[0xD9, 0x00, 0x00][..];
|
||||
assert_eq!(find_marker(buf, 0xFF, 0xD9), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marker_trailing_ff() {
|
||||
// 0xFF at the last byte — has no successor in this chunk, so no
|
||||
// match here; the caller will carry it as `last` to the next chunk.
|
||||
let buf = &[0x00, 0x11, 0xFF][..];
|
||||
assert_eq!(find_marker(buf, 0, 0xD9), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_marker_skips_false_positive() {
|
||||
// 0xFF followed by 0xC0 (not our marker), then real 0xFF 0xD9 later.
|
||||
let buf = &[0xFF, 0xC0, 0x11, 0xFF, 0xD9, 0x22][..];
|
||||
assert_eq!(find_marker(buf, 0, 0xD9), Some(4));
|
||||
}
|
||||
|
||||
// ChunkedReader: a BufRead that returns the source one configurable
|
||||
// chunk at a time, so we can exercise the boundary logic in
|
||||
// read_jpeg_bytes.
|
||||
struct ChunkedReader {
|
||||
data: Vec<u8>,
|
||||
pos: usize,
|
||||
chunk: usize,
|
||||
held: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ChunkedReader {
|
||||
fn new(data: Vec<u8>, chunk: usize) -> Self {
|
||||
Self {
|
||||
data,
|
||||
pos: 0,
|
||||
chunk,
|
||||
held: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for ChunkedReader {
|
||||
fn read(&mut self, _: &mut [u8]) -> IoResult<usize> {
|
||||
unreachable!("BufRead path only")
|
||||
}
|
||||
}
|
||||
|
||||
impl BufRead for ChunkedReader {
|
||||
fn fill_buf(&mut self) -> IoResult<&[u8]> {
|
||||
if self.held.is_empty() && self.pos < self.data.len() {
|
||||
let end = (self.pos + self.chunk).min(self.data.len());
|
||||
self.held = self.data[self.pos..end].to_vec();
|
||||
self.pos = end;
|
||||
}
|
||||
Ok(&self.held)
|
||||
}
|
||||
fn consume(&mut self, n: usize) {
|
||||
self.held.drain(..n);
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only sibling of read_jpeg_bytes that takes any BufRead. Keeps
|
||||
// the production signature constrained to BufReader<ChildStdout>.
|
||||
fn read_jpeg_generic<R: BufRead>(reader: &mut R) -> Result<Vec<u8>, String> {
|
||||
let mut out: Vec<u8> = Vec::with_capacity(64 * 1024);
|
||||
let mut in_jpeg = false;
|
||||
let mut last: u8 = 0;
|
||||
loop {
|
||||
let buf = reader.fill_buf().map_err(|e| format!("pipe read: {e}"))?;
|
||||
if buf.is_empty() {
|
||||
return Err("ffmpeg pipe closed before frame completed".into());
|
||||
}
|
||||
let n = buf.len();
|
||||
if !in_jpeg {
|
||||
match find_marker(buf, last, 0xD8) {
|
||||
Some(d8_pos) => {
|
||||
in_jpeg = true;
|
||||
out.push(0xFF);
|
||||
out.push(0xD8);
|
||||
let after = d8_pos + 1;
|
||||
let rest_len = n - after;
|
||||
// borrow ends after this if-block; copy data out first
|
||||
let (rest_copy, rest_last) = {
|
||||
let rest = &buf[after..];
|
||||
if let Some(d9_pos) = find_marker(rest, 0, 0xD9) {
|
||||
out.extend_from_slice(&rest[..=d9_pos]);
|
||||
let total = after + d9_pos + 1;
|
||||
reader.consume(total);
|
||||
return Ok(out);
|
||||
}
|
||||
(rest.to_vec(), rest.last().copied().unwrap_or(0))
|
||||
};
|
||||
out.extend_from_slice(&rest_copy);
|
||||
last = rest_last;
|
||||
let _ = rest_len;
|
||||
reader.consume(n);
|
||||
}
|
||||
None => {
|
||||
last = buf[n - 1];
|
||||
reader.consume(n);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(d9_pos) = find_marker(buf, last, 0xD9) {
|
||||
let take = d9_pos + 1;
|
||||
let body: Vec<u8> = buf[..take].to_vec();
|
||||
out.extend_from_slice(&body);
|
||||
reader.consume(take);
|
||||
return Ok(out);
|
||||
}
|
||||
let last_byte = buf[n - 1];
|
||||
let body: Vec<u8> = buf.to_vec();
|
||||
out.extend_from_slice(&body);
|
||||
last = last_byte;
|
||||
reader.consume(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn make_jpeg(prefix: &[u8], body: &[u8], suffix: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
v.extend_from_slice(prefix);
|
||||
v.extend_from_slice(&[0xFF, 0xD8]);
|
||||
v.extend_from_slice(body);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]);
|
||||
v.extend_from_slice(suffix);
|
||||
v
|
||||
}
|
||||
|
||||
fn expected_jpeg(body: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![0xFF, 0xD8];
|
||||
v.extend_from_slice(body);
|
||||
v.extend_from_slice(&[0xFF, 0xD9]);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jpeg_single_chunk() {
|
||||
let body = vec![0x11, 0x22, 0x33, 0x44];
|
||||
let stream = make_jpeg(&[0xAA], &body, &[0xBB]);
|
||||
let mut r = ChunkedReader::new(stream, 4096);
|
||||
let got = read_jpeg_generic(&mut r).unwrap();
|
||||
assert_eq!(got, expected_jpeg(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jpeg_byte_at_a_time() {
|
||||
let body = vec![0x10, 0x20, 0xFF, 0x00, 0x30, 0x40];
|
||||
let stream = make_jpeg(&[0x99, 0xFF, 0xC0], &body, &[]);
|
||||
let mut r = ChunkedReader::new(stream, 1);
|
||||
let got = read_jpeg_generic(&mut r).unwrap();
|
||||
assert_eq!(got, expected_jpeg(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jpeg_soi_straddles_chunks() {
|
||||
// Force a chunk boundary that ends with 0xFF and the next starts
|
||||
// with 0xD8 (the SOI's second byte).
|
||||
let body = vec![0xAA, 0xBB];
|
||||
let mut stream = vec![0xFF]; // chunk1 = [0xFF]
|
||||
stream.extend_from_slice(&[0xD8]); // chunk2 begins with 0xD8
|
||||
stream.extend_from_slice(&body);
|
||||
stream.extend_from_slice(&[0xFF, 0xD9]);
|
||||
let mut r = ChunkedReader::new(stream, 1);
|
||||
let got = read_jpeg_generic(&mut r).unwrap();
|
||||
assert_eq!(got, expected_jpeg(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jpeg_eoi_straddles_chunks() {
|
||||
// chunk1 ends on the 0xFF of EOI, chunk2 starts with 0xD9.
|
||||
let body = vec![0x10, 0x20, 0x30];
|
||||
let mut stream = vec![0xFF, 0xD8];
|
||||
stream.extend_from_slice(&body);
|
||||
stream.push(0xFF); // chunk boundary right after this
|
||||
stream.push(0xD9);
|
||||
let mut r = ChunkedReader::new(stream, 6); // body+SOI = 5, then 0xFF lands at idx 5 → fits in first chunk; need a chunk size that splits between 0xFF and 0xD9. SOI(2)+body(3)+0xFF = 6 bytes; chunk size 6 → first chunk includes through 0xFF. Then chunk2 = [0xD9].
|
||||
let got = read_jpeg_generic(&mut r).unwrap();
|
||||
assert_eq!(got, expected_jpeg(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_jpeg_false_eoi_in_body() {
|
||||
// 0xFF 0xC0 (a JFIF marker) inside body should not be mistaken for EOI.
|
||||
let body = vec![0x11, 0xFF, 0xC0, 0x22, 0xFF, 0xDB, 0x33];
|
||||
let stream = make_jpeg(&[], &body, &[]);
|
||||
let mut r = ChunkedReader::new(stream, 3);
|
||||
let got = read_jpeg_generic(&mut r).unwrap();
|
||||
assert_eq!(got, expected_jpeg(&body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_cache_evicts_oldest_in_order() {
|
||||
let mut c = FrameCache::new(3);
|
||||
c.insert(1, vec![1]);
|
||||
c.insert(2, vec![2]);
|
||||
c.insert(3, vec![3]);
|
||||
c.insert(4, vec![4]); // evicts 1
|
||||
assert!(!c.contains(1));
|
||||
assert_eq!(c.get(2), Some(vec![2]));
|
||||
assert_eq!(c.get(3), Some(vec![3]));
|
||||
assert_eq!(c.get(4), Some(vec![4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fifo_cache_overwrite_keeps_order() {
|
||||
let mut c = FrameCache::new(2);
|
||||
c.insert(1, vec![1]);
|
||||
c.insert(2, vec![2]);
|
||||
c.insert(1, vec![10]); // overwrite, do NOT reorder
|
||||
c.insert(3, vec![3]); // evicts 1 (still the oldest in queue)
|
||||
assert!(!c.contains(1));
|
||||
assert_eq!(c.get(2), Some(vec![2]));
|
||||
assert_eq!(c.get(3), Some(vec![3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_used() {
|
||||
// Suppress unused-import warning under cfg(test).
|
||||
let _ = Cursor::new(vec![1u8]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user