performance improvement
This commit is contained in:
@@ -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, ChildStdout, Command, Stdio};
|
||||
use std::process::{Child, ChildStderr, ChildStdout, Command, Stdio};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -14,6 +14,10 @@ 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;
|
||||
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>>,
|
||||
@@ -89,20 +93,32 @@ 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 {
|
||||
path: PathBuf,
|
||||
info: VideoInfo,
|
||||
/// ffmpeg `-hwaccel` argument. None / empty = software decode. Cleared
|
||||
/// on any decode failure so the next attempt uses software — we'd
|
||||
/// rather degrade than keep retrying a flaky GPU path.
|
||||
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 {
|
||||
pub fn open(path: PathBuf, info: VideoInfo) -> Self {
|
||||
pub fn open(path: PathBuf, info: VideoInfo, hwaccel: Option<String>) -> Self {
|
||||
Self {
|
||||
path,
|
||||
info,
|
||||
hwaccel: hwaccel.filter(|s| !s.is_empty()),
|
||||
shared: Arc::new(Shared {
|
||||
state: Mutex::new(SharedState {
|
||||
child: None,
|
||||
@@ -115,8 +131,10 @@ impl DecoderSession {
|
||||
shutdown: false,
|
||||
}),
|
||||
cond: Condvar::new(),
|
||||
stderr_tail: Mutex::new(VecDeque::new()),
|
||||
}),
|
||||
worker: None,
|
||||
stderr_drain: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +150,28 @@ impl DecoderSession {
|
||||
}
|
||||
|
||||
pub fn decode_at(&mut self, idx: u64) -> Result<Vec<u8>, String> {
|
||||
match self.decode_at_inner(idx) {
|
||||
Ok(bytes) => Ok(bytes),
|
||||
Err(e) => {
|
||||
// Auto-fallback: any decode failure while hwaccel is on
|
||||
// retries once with software. Covers both first-frame init
|
||||
// failures and later seek/codec-edge failures (e.g. a GPU
|
||||
// path that worked on h264 but chokes on an HEVC segment).
|
||||
// Once cleared, subsequent failures propagate.
|
||||
if let Some(prev) = self.hwaccel.take() {
|
||||
eprintln!(
|
||||
"[decoder] hwaccel '{prev}' failed ({e}); falling back to software"
|
||||
);
|
||||
self.stop_worker();
|
||||
self.decode_at_inner(idx)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_at_inner(&mut self, idx: u64) -> Result<Vec<u8>, String> {
|
||||
if idx >= self.info.total_frames {
|
||||
return Err(format!(
|
||||
"frame {} out of range (total {})",
|
||||
@@ -194,7 +234,8 @@ 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)?;
|
||||
let (child, stdout, stderr) =
|
||||
spawn_ffmpeg(&self.path, &self.info, idx, self.hwaccel.as_deref())?;
|
||||
|
||||
{
|
||||
let mut st = self.shared.state.lock().unwrap();
|
||||
@@ -205,8 +246,24 @@ 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);
|
||||
@@ -232,6 +289,12 @@ 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;
|
||||
@@ -246,15 +309,55 @@ impl Drop for DecoderSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a VAAPI render node. `SAM_TOOL_VAAPI_DEVICE` overrides; otherwise we
|
||||
/// probe `/dev/dri/renderD128..199` for the first that exists. Multi-GPU
|
||||
/// systems can end up with the integrated card on D128 and a discrete card
|
||||
/// on D129+ — the env var is the escape hatch when the auto pick is wrong.
|
||||
fn vaapi_device() -> String {
|
||||
if let Ok(p) = std::env::var("SAM_TOOL_VAAPI_DEVICE") {
|
||||
if !p.is_empty() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
for n in 128..200u32 {
|
||||
let p = format!("/dev/dri/renderD{n}");
|
||||
if std::path::Path::new(&p).exists() {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
"/dev/dri/renderD128".to_string()
|
||||
}
|
||||
|
||||
fn spawn_ffmpeg(
|
||||
path: &PathBuf,
|
||||
info: &VideoInfo,
|
||||
idx: u64,
|
||||
) -> Result<(Child, BufReader<ChildStdout>), String> {
|
||||
hwaccel: Option<&str>,
|
||||
) -> Result<(Child, BufReader<ChildStdout>, ChildStderr), 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");
|
||||
cmd.args(["-v", "error", "-threads", "0"]);
|
||||
// -hwaccel must come BEFORE -i. Output goes to CPU memory by default
|
||||
// (no -hwaccel_output_format), so the downstream mjpeg encoder works
|
||||
// unchanged. If the GPU init fails, ffmpeg will exit nonzero and the
|
||||
// worker thread surfaces the error via shared.error.
|
||||
match hwaccel {
|
||||
Some("auto") => {
|
||||
cmd.args(["-hwaccel", "auto"]);
|
||||
}
|
||||
Some("vaapi") => {
|
||||
let dev = vaapi_device();
|
||||
cmd.args(["-hwaccel", "vaapi", "-vaapi_device", &dev]);
|
||||
}
|
||||
Some("cuda") => {
|
||||
cmd.args(["-hwaccel", "cuda"]);
|
||||
}
|
||||
Some("vdpau") => {
|
||||
cmd.args(["-hwaccel", "vdpau"]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if t > 1.0 {
|
||||
cmd.args(["-ss", &format!("{:.3}", t - 1.0)]);
|
||||
cmd.args(["-i", path_str]);
|
||||
@@ -273,13 +376,21 @@ fn spawn_ffmpeg(
|
||||
"mjpeg",
|
||||
"pipe:1",
|
||||
]);
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
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())?;
|
||||
Ok((child, BufReader::with_capacity(READ_BUF_BYTES, stdout)))
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| "ffmpeg stderr unavailable".to_string())?;
|
||||
Ok((
|
||||
child,
|
||||
BufReader::with_capacity(READ_BUF_BYTES, stdout),
|
||||
stderr,
|
||||
))
|
||||
}
|
||||
|
||||
fn worker_loop(mut stdout: BufReader<ChildStdout>, shared: Arc<Shared>) {
|
||||
@@ -315,8 +426,19 @@ 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 e.contains("pipe closed") || e.contains("EOF") {
|
||||
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") {
|
||||
st.eof = true;
|
||||
} else {
|
||||
st.error = Some(e);
|
||||
@@ -328,6 +450,50 @@ 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;
|
||||
|
||||
@@ -33,9 +33,14 @@ fn open_video(
|
||||
return Err(format!("file not found: {}", p.display()));
|
||||
}
|
||||
let info = video::probe(&p)?;
|
||||
let decoder = DecoderSession::open(p, info.clone());
|
||||
if let Ok(settings) = settings::load(&app) {
|
||||
decoder.set_cache_capacity(settings.cache_capacity);
|
||||
let settings = settings::load(&app).ok();
|
||||
let hwaccel = settings
|
||||
.as_ref()
|
||||
.map(|s| s.hwaccel.clone())
|
||||
.filter(|s| !s.is_empty());
|
||||
let decoder = DecoderSession::open(p, info.clone(), hwaccel);
|
||||
if let Some(s) = settings.as_ref() {
|
||||
decoder.set_cache_capacity(s.cache_capacity);
|
||||
}
|
||||
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
|
||||
*guard = Some(decoder);
|
||||
@@ -117,23 +122,27 @@ fn bulk_extract(
|
||||
out_dir: String,
|
||||
shapes_by_frame: HashMap<String, Vec<coco::Shape>>,
|
||||
author: String,
|
||||
frame_skip: Option<u64>,
|
||||
) -> Result<u64, String> {
|
||||
if end < start {
|
||||
return Err("end must be >= start".into());
|
||||
}
|
||||
let step = frame_skip.unwrap_or(1).max(1);
|
||||
let mut guard = state.decoder.lock().map_err(|e| format!("lock: {e}"))?;
|
||||
let decoder = guard.as_mut().ok_or("no video loaded")?;
|
||||
let dir = PathBuf::from(&out_dir);
|
||||
let total = end - start + 1;
|
||||
let total = ((end - start) / step) + 1;
|
||||
let empty: Vec<coco::Shape> = Vec::new();
|
||||
let mut done: u64 = 0;
|
||||
for idx in start..=end {
|
||||
let mut idx = start;
|
||||
while idx <= end {
|
||||
let shapes = shapes_by_frame.get(&idx.to_string()).unwrap_or(&empty);
|
||||
extract::extract_one(decoder, idx, &dir, shapes, &author)?;
|
||||
done += 1;
|
||||
if done == 1 || done % 5 == 0 || done == total {
|
||||
let _ = app.emit("bulk-extract-progress", BulkProgress { done, total });
|
||||
}
|
||||
idx += step;
|
||||
}
|
||||
Ok(done)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ pub struct Settings {
|
||||
pub sam_model: Option<String>,
|
||||
#[serde(default = "default_cache_capacity")]
|
||||
pub cache_capacity: usize,
|
||||
/// ffmpeg `-hwaccel` argument. Empty string = software decode (default).
|
||||
/// Recognized: "auto", "vaapi", "cuda", "vdpau". Applied on next
|
||||
/// `open_video` or worker respawn.
|
||||
#[serde(default)]
|
||||
pub hwaccel: String,
|
||||
/// Decimation factor used by playback (advance by N frames per tick) and
|
||||
/// bulk extract (take every Nth frame in range). 1 = no decimation.
|
||||
#[serde(default = "default_frame_skip")]
|
||||
pub frame_skip: u64,
|
||||
}
|
||||
|
||||
fn default_sam_url() -> String {
|
||||
@@ -25,11 +34,18 @@ fn default_cache_capacity() -> usize {
|
||||
crate::decoder::DEFAULT_CACHE_CAPACITY
|
||||
}
|
||||
|
||||
fn default_frame_skip() -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
fn normalize(mut s: Settings) -> Settings {
|
||||
s.cache_capacity = s.cache_capacity.clamp(
|
||||
crate::decoder::MIN_CACHE_CAPACITY,
|
||||
crate::decoder::MAX_CACHE_CAPACITY,
|
||||
);
|
||||
if s.frame_skip < 1 {
|
||||
s.frame_skip = 1;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
@@ -40,6 +56,8 @@ impl Default for Settings {
|
||||
sam_enabled: false,
|
||||
sam_model: None,
|
||||
cache_capacity: default_cache_capacity(),
|
||||
hwaccel: String::new(),
|
||||
frame_skip: default_frame_skip(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user