HLS/previews: rerun on the CPU when the GPU can't be used right now

A busy GPU (llama-swap holding the 3090) made ffmpeg's CUDA setup fail
with CUDA_ERROR_OUT_OF_MEMORY, exit 187. The playlist generator took
that for a bad file and wrote playlist.unsupported, so the video never
played; preview clips were marked failed the same way.

is_gpu_init_failure() recognises CUDA/NVENC setup errors in ffmpeg's
stderr. When an NVENC run fails that way, the playlist and the preview
clip rerun once on libx264. A file that also fails on the CPU still gets
the sentinel. The HLS command and the timed run are pulled out of the
handler (hls_command, run_ffmpeg) so both attempts share them.

Fixes #215

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-11-02 15:25:19 -05:00
1 parent 4bb5936ec0
commit 8e6de37345
2 files changed
+245 -121

No files matched your search

+84 -21
View File
@@ -36,6 +36,24 @@ pub async fn is_nvenc_available() -> bool {
available
}
/// Whether ffmpeg's stderr says the GPU couldn't be used *right now*, as
/// opposed to the input being bad (#215). `is_nvenc_available` only checks
/// that ffmpeg was built with NVENC; on a GPU shared with llama-swap a resident
/// model can leave no memory for a CUDA context, and ffmpeg then exits 187
/// with `cuCtxCreate(...) failed -> CUDA_ERROR_OUT_OF_MEMORY`. Callers rerun
/// such a failure on the CPU encoder instead of recording the file as broken.
pub fn is_gpu_init_failure(stderr: &str) -> bool {
const MARKERS: &[&str] = &[
"CUDA_ERROR_",
"cuCtxCreate",
"OpenEncodeSessionEx failed",
"No capable devices found",
"Cannot load libcuda",
"Device creation failed",
];
MARKERS.iter().any(|m| stderr.contains(m))
}
pub struct Ffmpeg;
pub enum GifType {
@@ -271,25 +289,13 @@ fn parse_ffprobe_duration(stdout: &str) -> Option<f64> {
None
}
/// Generate a preview clip from a video file.
///
/// Creates a ~10 second MP4 by extracting up to 10 equally-spaced 1-second segments
/// at 480p with H.264 video and AAC audio. For short videos (<10s), uses fewer segments.
/// For very short videos (<1s), transcodes the entire video.
///
/// Returns (duration_seconds, file_size_bytes) on success.
pub async fn generate_preview_clip(input_file: &str, output_file: &str) -> Result<(f64, u64)> {
info!("Generating preview clip for: '{}'", input_file);
let start = Instant::now();
let duration = get_duration_seconds(input_file).await?;
let use_nvenc = is_nvenc_available().await;
// Create parent directories for output
if let Some(parent) = std::path::Path::new(output_file).parent() {
std::fs::create_dir_all(parent)?;
}
/// The ffmpeg command for a preview clip, and the clip duration it produces.
fn preview_clip_command(
input_file: &str,
output_file: &str,
duration: Option<f64>,
use_nvenc: bool,
) -> (Command, f64) {
let mut cmd = Command::new("ffmpeg");
cmd.arg("-y");
@@ -350,7 +356,43 @@ pub async fn generate_preview_clip(input_file: &str, output_file: &str) -> Resul
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::piped());
let output = cmd.output().await?;
(cmd, clip_duration)
}
/// Generate a preview clip from a video file.
///
/// Creates a ~10 second MP4 by extracting up to 10 equally-spaced 1-second segments
/// at 480p with H.264 video and AAC audio. For short videos (<10s), uses fewer segments.
/// For very short videos (<1s), transcodes the entire video.
///
/// Returns (duration_seconds, file_size_bytes) on success.
pub async fn generate_preview_clip(input_file: &str, output_file: &str) -> Result<(f64, u64)> {
info!("Generating preview clip for: '{}'", input_file);
let start = Instant::now();
let duration = get_duration_seconds(input_file).await?;
let use_nvenc = is_nvenc_available().await;
// Create parent directories for output
if let Some(parent) = std::path::Path::new(output_file).parent() {
std::fs::create_dir_all(parent)?;
}
let (mut cmd, clip_duration) =
preview_clip_command(input_file, output_file, duration, use_nvenc);
let mut output = cmd.output().await?;
if use_nvenc
&& !output.status.success()
&& is_gpu_init_failure(&String::from_utf8_lossy(&output.stderr))
{
warn!(
"GPU unavailable for preview of '{}', retrying on the CPU encoder",
input_file
);
let (mut cmd, _) = preview_clip_command(input_file, output_file, duration, false);
output = cmd.output().await?;
}
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -376,7 +418,28 @@ pub async fn generate_preview_clip(input_file: &str, output_file: &str) -> Resul
#[cfg(test)]
mod tests {
use super::parse_ffprobe_duration;
use super::{is_gpu_init_failure, parse_ffprobe_duration};
#[test]
fn gpu_busy_is_a_gpu_init_failure() {
// Verbatim from the e2e server with a llama-swap model resident (#215).
let oom = "[CUDA @ 0x5d1c2c0] cu->cuCtxCreate(&hwctx->cuda_ctx, desired_flags, hwctx->internal->cuda_device) failed -> CUDA_ERROR_OUT_OF_MEMORY: out of memory\nDevice creation failed: -542398533.";
assert!(is_gpu_init_failure(oom));
assert!(is_gpu_init_failure(
"[h264_nvenc @ 0x1] OpenEncodeSessionEx failed: out of memory (10): (no details)"
));
assert!(is_gpu_init_failure(
"[h264_nvenc @ 0x1] No capable devices found"
));
}
#[test]
fn bad_input_is_not_a_gpu_init_failure() {
assert!(!is_gpu_init_failure(
"[mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found\nclip.mp4: Invalid data found when processing input"
));
assert!(!is_gpu_init_failure("Conversion failed!"));
}
#[test]
fn empty_output_returns_none() {