video: handle unknown/short durations in thumb + preview gen

`get_duration_seconds` now returns `Option<f64>` and falls back from
`format=duration` to `stream=duration`. Empty stdout no longer
parse-panics with "cannot parse float from empty string", which was
poisoning the preview-clip row with status=failed and re-queueing every
full scan (notably for GoPro LRV files). `generate_preview_clip` handles
the unknown-duration case by transcoding the whole file (capped at 10s).

`generate_video_thumbnail` seeks to ~50% of the probed duration instead
of a hardcoded `-ss 3`, with a first-frame fallback when the probe
returns nothing. Fixes the loop where short Snapchat clips (<3s) got
"missing thumbnail" logged on every scan because ffmpeg exited 0
without writing a frame, and never wrote the .unsupported sentinel
either.

Adds unit tests for `parse_ffprobe_duration` covering the empty-output,
N/A, multi-line, non-positive, and non-finite cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-05-09 23:08:16 -04:00
parent 7350f1916a
commit 5476ed8ac4
2 changed files with 201 additions and 46 deletions

View File

@@ -2,7 +2,7 @@ use crate::database::PreviewDao;
use crate::is_video;
use crate::libraries::Library;
use crate::otel::global_tracer;
use crate::video::ffmpeg::generate_preview_clip;
use crate::video::ffmpeg::{generate_preview_clip, get_duration_seconds_blocking};
use actix::prelude::*;
use log::{debug, error, info, trace, warn};
use opentelemetry::KeyValue;
@@ -108,6 +108,13 @@ pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Ch
}
pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Result<()> {
// Probe duration up front and seek to ~50% — gives a more
// representative frame than a fixed offset (skipping title cards on
// long videos, landing inside the clip on 12s Snapchat MP4s) and
// sidesteps the seek-past-EOF class of bug entirely. When duration
// probing fails (LRV files, fragmented MP4s, ffprobe missing) fall
// back to the first frame: ugly but reliable.
//
// -vf scale + -c:v mjpeg mirrors `generate_image_thumbnail_ffmpeg`. The
// filter chain matters as much as the scale does: without it, ffmpeg
// hands the decoded frame straight to the mjpeg encoder, which rejects
@@ -115,10 +122,14 @@ pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Res
// filter chain lets ffmpeg auto-insert the pix_fmt converter the
// encoder needs, which is how the image-thumbnail path already handles
// the same class of source.
let output = Command::new("ffmpeg")
.arg("-y")
.arg("-ss")
.arg("3")
let seek = get_duration_seconds_blocking(path).map(|d| format!("{:.3}", d / 2.0));
let mut cmd = Command::new("ffmpeg");
cmd.arg("-y");
if let Some(s) = &seek {
cmd.arg("-ss").arg(s);
}
let output = cmd
.arg("-i")
.arg(path)
.arg("-vframes")
@@ -139,6 +150,18 @@ pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Res
String::from_utf8_lossy(&output.stderr).trim()
)));
}
// ffmpeg can exit 0 without writing a frame for malformed files where
// the probe duration lies. Confirm a non-empty file actually landed —
// returning Err makes the caller write the `.unsupported` sentinel so
// we stop re-detecting on every scan.
let wrote = std::fs::metadata(destination)
.map(|m| m.len() > 0)
.unwrap_or(false);
if !wrote {
return Err(std::io::Error::other(
"ffmpeg exited successfully but produced no thumbnail output",
));
}
Ok(())
}