playback similar to videoannotator

This commit is contained in:
2026-06-04 18:20:13 +05:30
parent 85b090a8b9
commit f7029e8748
4 changed files with 265 additions and 318 deletions

View File

@@ -2,7 +2,7 @@ use crate::video::VideoInfo;
use std::collections::{HashMap, VecDeque};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, ChildStderr, ChildStdout, Command, Stdio};
use std::process::{Child, ChildStdout, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
@@ -12,12 +12,10 @@ pub const MIN_CACHE_CAPACITY: usize = 50;
pub const MAX_CACHE_CAPACITY: usize = 800;
const READ_BUF_BYTES: usize = 256 * 1024;
/// If the requested frame is this far ahead of the worker, kill + reseek.
const SEEK_AHEAD_THRESHOLD: u64 = 60;
/// Higher = fewer ffmpeg restarts during forward scan; the cap is bounded
/// by lookahead × cache capacity anyway.
const SEEK_AHEAD_THRESHOLD: u64 = 120;
const DECODE_TIMEOUT: Duration = Duration::from_secs(15);
/// Keep the last N stderr lines from ffmpeg so error messages can include
/// the real reason (e.g. "vaapi: failed to load driver") instead of just
/// "pipe closed before frame completed".
const STDERR_TAIL_LINES: usize = 32;
struct FrameCache {
map: HashMap<u64, Vec<u8>>,
@@ -93,9 +91,6 @@ struct SharedState {
struct Shared {
state: Mutex<SharedState>,
cond: Condvar,
/// Ring buffer of recent ffmpeg stderr lines, filled by a sibling drain
/// thread. Cleared at each new seek.
stderr_tail: Mutex<VecDeque<String>>,
}
pub struct DecoderSession {
@@ -107,10 +102,6 @@ pub struct DecoderSession {
hwaccel: Option<String>,
shared: Arc<Shared>,
worker: Option<JoinHandle<()>>,
/// Sibling thread draining ffmpeg's stderr into `shared.stderr_tail`.
/// Joined in `stop_worker` so the tail doesn't get clobbered by a
/// straggling drain across seeks.
stderr_drain: Option<JoinHandle<()>>,
}
impl DecoderSession {
@@ -131,10 +122,8 @@ impl DecoderSession {
shutdown: false,
}),
cond: Condvar::new(),
stderr_tail: Mutex::new(VecDeque::new()),
}),
worker: None,
stderr_drain: None,
}
}
@@ -234,7 +223,7 @@ impl DecoderSession {
fn seek_and_start_worker(&mut self, idx: u64) -> Result<(), String> {
self.stop_worker();
let (child, stdout, stderr) =
let (child, stdout) =
spawn_ffmpeg(&self.path, &self.info, idx, self.hwaccel.as_deref())?;
{
@@ -246,24 +235,8 @@ impl DecoderSession {
st.error = None;
st.shutdown = false;
}
self.shared.stderr_tail.lock().unwrap().clear();
self.shared.cond.notify_all();
// Drain ffmpeg's stderr into a ring buffer so error messages can
// include the real reason. We must drain even on success — leaving
// stderr unread would let the pipe buffer fill and stall ffmpeg.
let drain_shared = self.shared.clone();
self.stderr_drain = Some(thread::spawn(move || {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
let mut tail = drain_shared.stderr_tail.lock().unwrap();
if tail.len() >= STDERR_TAIL_LINES {
tail.pop_front();
}
tail.push_back(line);
}
}));
let shared = self.shared.clone();
let handle = thread::spawn(move || {
worker_loop(stdout, shared);
@@ -289,12 +262,6 @@ impl DecoderSession {
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
// Killing the child closed stderr, so the drain thread sees EOF and
// exits — joining it here prevents a straggling write into the new
// session's stderr_tail after the next seek clears it.
if let Some(handle) = self.stderr_drain.take() {
let _ = handle.join();
}
let mut st = self.shared.state.lock().unwrap();
st.shutdown = false;
@@ -347,7 +314,7 @@ fn spawn_ffmpeg(
info: &VideoInfo,
idx: u64,
hwaccel: Option<&str>,
) -> Result<(Child, BufReader<ChildStdout>, ChildStderr), String> {
) -> 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;
let mut cmd = Command::new("ffmpeg");
@@ -372,14 +339,12 @@ fn spawn_ffmpeg(
}
_ => {}
}
if t > 1.0 {
cmd.args(["-ss", &format!("{:.3}", t - 1.0)]);
cmd.args(["-i", path_str]);
cmd.args(["-ss", "1.0"]);
} else {
cmd.args(["-i", path_str]);
cmd.args(["-ss", &format!("{:.3}", t)]);
}
// Single fast-seek `-ss` before `-i`. The old `(-ss t-1) -i (-ss 1.0)`
// double-seek pattern decoded ~30 hidden warmup frames per seek; on
// typical content the fast-seek is accurate enough and seeks take
// milliseconds instead of hundreds of ms.
cmd.args(["-ss", &format!("{:.6}", t)]);
cmd.args(["-i", path_str]);
cmd.args([
"-an",
"-q:v",
@@ -390,21 +355,16 @@ fn spawn_ffmpeg(
"mjpeg",
"pipe:1",
]);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
// stderr null: capturing it cost a spawn+join drain thread per seek
// plus up to 150 ms of polling on EOF. hwaccel-failure messages still
// surface via the exit code path through `decode_at`'s fallback.
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
let mut child = cmd.spawn().map_err(|e| format!("ffmpeg spawn: {e}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "ffmpeg stdout unavailable".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "ffmpeg stderr unavailable".to_string())?;
Ok((
child,
BufReader::with_capacity(READ_BUF_BYTES, stdout),
stderr,
))
Ok((child, BufReader::with_capacity(READ_BUF_BYTES, stdout)))
}
fn worker_loop(mut stdout: BufReader<ChildStdout>, shared: Arc<Shared>) {
@@ -440,19 +400,8 @@ fn worker_loop(mut stdout: BufReader<ChildStdout>, shared: Arc<Shared>) {
shared.cond.notify_all();
}
Err(e) => {
// ffmpeg closes stderr right alongside stdout; on real
// failures the drain thread may not have flushed yet, so
// we wait briefly. On a clean exit there's nothing to
// flush — collect_stderr_tail short-circuits via the
// child's exit code so end-of-stream EOF isn't penalised.
let tail = collect_stderr_tail(&shared, Duration::from_millis(150));
let mut st = shared.state.lock().unwrap();
if !tail.is_empty() {
// With `-v error`, anything on stderr means ffmpeg
// actually failed (vs a clean exit) — surface it as a
// real error so decode_at can fall back / report.
st.error = Some(format!("{e}: ffmpeg: {tail}"));
} else if e.contains("pipe closed") || e.contains("EOF") {
if e.contains("pipe closed") || e.contains("EOF") {
st.eof = true;
} else {
st.error = Some(e);
@@ -464,50 +413,6 @@ fn worker_loop(mut stdout: BufReader<ChildStdout>, shared: Arc<Shared>) {
}
}
fn read_tail_now(shared: &Arc<Shared>) -> Option<String> {
let tail = shared.stderr_tail.lock().unwrap();
if tail.is_empty() {
None
} else {
Some(tail.iter().cloned().collect::<Vec<_>>().join(" | "))
}
}
fn collect_stderr_tail(shared: &Arc<Shared>, max_wait: Duration) -> String {
// Fast path: drain already flushed — common when ffmpeg writes its
// error before exiting and stdout EOF arrives after the drain.
if let Some(tail) = read_tail_now(shared) {
return tail;
}
// Skip the poll on clean ffmpeg exit (exit code 0): natural end-of-
// stream has nothing on stderr (we run with `-v error`), and 150ms ×
// every EOF seek near the final frames adds up.
let exited_cleanly = {
let mut st = shared.state.lock().unwrap();
st.child
.as_mut()
.and_then(|c| c.try_wait().ok().flatten())
.map(|status| status.success())
.unwrap_or(false)
};
if exited_cleanly {
return String::new();
}
// Slow path: ffmpeg exited non-zero (or hasn't exited yet) and the
// drain thread hasn't flushed. Poll briefly so the error message
// carries the real reason.
let deadline = Instant::now() + max_wait;
loop {
if let Some(tail) = read_tail_now(shared) {
return tail;
}
if Instant::now() >= deadline {
return String::new();
}
thread::sleep(Duration::from_millis(10));
}
}
fn read_jpeg_bytes(reader: &mut BufReader<ChildStdout>) -> Result<Vec<u8>, String> {
let mut out = Vec::with_capacity(64 * 1024);
let mut last = 0u8;