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:
@@ -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 1–2s 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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user