HLS/previews: rerun on the CPU when the GPU can't be used right now #216
No files matched your search
+161
-100
@@ -635,110 +635,37 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
|||||||
|
|
||||||
let nvenc = !use_copy && crate::video::ffmpeg::is_nvenc_available().await;
|
let nvenc = !use_copy && crate::video::ffmpeg::is_nvenc_available().await;
|
||||||
|
|
||||||
let mut cmd = tokio::process::Command::new("ffmpeg");
|
// Default timeout is generous — a long 4K transcode on CPU can take hours.
|
||||||
cmd.arg("-y");
|
|
||||||
if nvenc {
|
|
||||||
// Decode on NVDEC too. Without it a 4K source is decoded on
|
|
||||||
// the CPU and only the encode runs on the GPU: ~4.5x the CPU
|
|
||||||
// time. Frames come back to system memory, so the CPU
|
|
||||||
// filters (autorotate, scale) still apply, and ffmpeg falls
|
|
||||||
// back to software decode for codecs NVDEC lacks (ProRes).
|
|
||||||
cmd.arg("-hwaccel").arg("cuda");
|
|
||||||
}
|
|
||||||
cmd.arg("-i").arg(&video_file);
|
|
||||||
|
|
||||||
if use_copy {
|
|
||||||
cmd.arg("-c:v").arg("copy");
|
|
||||||
cmd.arg("-c:a").arg("aac");
|
|
||||||
} else {
|
|
||||||
if nvenc {
|
|
||||||
// NVENC: no CRF, use VBR + target CQ. p1 = fastest
|
|
||||||
// preset — prioritizes encoder throughput over bitrate
|
|
||||||
// efficiency. CQ 23 roughly matches libx264 crf 21
|
|
||||||
// visually; NVENC has slightly lower compression
|
|
||||||
// efficiency per quality.
|
|
||||||
cmd.arg("-c:v").arg("h264_nvenc");
|
|
||||||
cmd.arg("-preset").arg("p1");
|
|
||||||
cmd.arg("-rc").arg("vbr");
|
|
||||||
cmd.arg("-cq").arg("23");
|
|
||||||
cmd.arg("-pix_fmt").arg("yuv420p");
|
|
||||||
// Without this NVENC turns -force_key_frames into plain
|
|
||||||
// I-frames, which the HLS muxer can't split on: segments
|
|
||||||
// fall back to NVENC's 250-frame GOP (8.34 s at 29.97).
|
|
||||||
cmd.arg("-forced-idr").arg("1");
|
|
||||||
} else {
|
|
||||||
cmd.arg("-c:v").arg("h264");
|
|
||||||
cmd.arg("-crf").arg("21");
|
|
||||||
cmd.arg("-preset").arg("veryfast");
|
|
||||||
}
|
|
||||||
// Cap the short side so landscape and portrait both land at
|
|
||||||
// real 1080p. iw/ih are post-autorotation display dims.
|
|
||||||
cmd.arg("-vf").arg(format!(
|
|
||||||
"scale='if(gt(iw,ih),-2,min({m},iw))':'if(gt(iw,ih),min({m},ih),-2)',setsar=1:1",
|
|
||||||
m = HLS_MAX_SHORT_SIDE
|
|
||||||
));
|
|
||||||
cmd.arg("-c:a").arg("aac");
|
|
||||||
// Force an IDR frame every hls_time seconds so each HLS
|
|
||||||
// segment starts on a keyframe — accurate seeking without
|
|
||||||
// players having to decode from a prior segment.
|
|
||||||
cmd.arg("-force_key_frames").arg("expr:gte(t,n_forced*3)");
|
|
||||||
// Keep source frame timestamps. Variable-frame-rate phone
|
|
||||||
// clips and screen recordings report r_frame_rate as their
|
|
||||||
// timebase (90000/1); the mpegts muxer's default CFR mode
|
|
||||||
// then duplicates frames to fill it — a 2.6 s clip came out
|
|
||||||
// as 233k frames / 221 MB. vfr leaves timing untouched.
|
|
||||||
cmd.arg("-fps_mode").arg("vfr");
|
|
||||||
}
|
|
||||||
|
|
||||||
// -f hls is required because the playlist is written to a .tmp
|
|
||||||
// path during encoding — ffmpeg normally infers the muxer from
|
|
||||||
// the output extension and doesn't recognize ".m3u8.tmp".
|
|
||||||
cmd.arg("-f").arg("hls");
|
|
||||||
cmd.arg("-hls_time").arg("3");
|
|
||||||
cmd.arg("-hls_list_size").arg("0");
|
|
||||||
cmd.arg("-hls_playlist_type").arg("vod");
|
|
||||||
// independent_segments advertises that each segment can be
|
|
||||||
// decoded without reference to any other — the matching guarantee
|
|
||||||
// for the forced keyframes above.
|
|
||||||
cmd.arg("-hls_flags").arg("independent_segments");
|
|
||||||
cmd.arg("-hls_segment_filename").arg(&segment_pattern);
|
|
||||||
cmd.arg(&playlist_tmp);
|
|
||||||
cmd.stdout(Stdio::null());
|
|
||||||
cmd.stderr(Stdio::piped());
|
|
||||||
cmd.kill_on_drop(true);
|
|
||||||
|
|
||||||
// Spawn + wait under a timeout so a hung ffmpeg (corrupt source,
|
|
||||||
// NFS stall, etc.) doesn't permanently hold a semaphore slot.
|
|
||||||
// Default is generous — a long 4K transcode on CPU can take hours.
|
|
||||||
let timeout_secs = std::env::var("HLS_TIMEOUT_SECONDS")
|
let timeout_secs = std::env::var("HLS_TIMEOUT_SECONDS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse::<u64>().ok())
|
.and_then(|v| v.parse::<u64>().ok())
|
||||||
.unwrap_or(7200);
|
.unwrap_or(7200);
|
||||||
|
|
||||||
let ffmpeg_result = match cmd.spawn() {
|
let mut ffmpeg_result = run_ffmpeg(
|
||||||
Ok(child) => {
|
hls_command(&video_file, &playlist_tmp, &segment_pattern, use_copy, nvenc),
|
||||||
match tokio::time::timeout(
|
timeout_secs,
|
||||||
std::time::Duration::from_secs(timeout_secs),
|
)
|
||||||
child.wait_with_output(),
|
.await;
|
||||||
)
|
|
||||||
.await
|
// A GPU that's busy right now (#215: llama-swap holding the 3090)
|
||||||
{
|
// says nothing about the file. Encode on the CPU instead of
|
||||||
Ok(res) => res
|
// writing a sentinel that would skip this video for good.
|
||||||
.inspect_err(|e| {
|
if nvenc
|
||||||
error!("Failed to wait on ffmpeg child process: {}", e)
|
&& matches!(&ffmpeg_result, Ok(out) if !out.status.success()
|
||||||
})
|
&& crate::video::ffmpeg::is_gpu_init_failure(&String::from_utf8_lossy(&out.stderr)))
|
||||||
.map_err(|e| std::io::Error::other(e.to_string())),
|
{
|
||||||
Err(_) => Err(std::io::Error::other(format!(
|
warn!(
|
||||||
"ffmpeg exceeded {}s timeout",
|
"GPU unavailable for {}, retrying the playlist on the CPU encoder",
|
||||||
timeout_secs
|
video_file
|
||||||
))),
|
);
|
||||||
}
|
span.add_event("GPU unavailable, retrying on CPU", vec![]);
|
||||||
}
|
cleanup_partial_hls(&hash_dir).await;
|
||||||
Err(e) => {
|
ffmpeg_result = run_ffmpeg(
|
||||||
error!("Failed to spawn ffmpeg: {}", e);
|
hls_command(&video_file, &playlist_tmp, &segment_pattern, use_copy, false),
|
||||||
Err(std::io::Error::other(e.to_string()))
|
timeout_secs,
|
||||||
}
|
)
|
||||||
};
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
drop(permit);
|
drop(permit);
|
||||||
|
|
||||||
@@ -789,6 +716,118 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The ffmpeg command that writes a video's HLS playlist and segments.
|
||||||
|
fn hls_command(
|
||||||
|
video_file: &str,
|
||||||
|
playlist_tmp: &str,
|
||||||
|
segment_pattern: &str,
|
||||||
|
use_copy: bool,
|
||||||
|
nvenc: bool,
|
||||||
|
) -> tokio::process::Command {
|
||||||
|
let mut cmd = tokio::process::Command::new("ffmpeg");
|
||||||
|
cmd.arg("-y");
|
||||||
|
if nvenc {
|
||||||
|
// Decode on NVDEC too. Without it a 4K source is decoded on
|
||||||
|
// the CPU and only the encode runs on the GPU: ~4.5x the CPU
|
||||||
|
// time. Frames come back to system memory, so the CPU
|
||||||
|
// filters (autorotate, scale) still apply, and ffmpeg falls
|
||||||
|
// back to software decode for codecs NVDEC lacks (ProRes).
|
||||||
|
cmd.arg("-hwaccel").arg("cuda");
|
||||||
|
}
|
||||||
|
cmd.arg("-i").arg(video_file);
|
||||||
|
|
||||||
|
if use_copy {
|
||||||
|
cmd.arg("-c:v").arg("copy");
|
||||||
|
cmd.arg("-c:a").arg("aac");
|
||||||
|
} else {
|
||||||
|
if nvenc {
|
||||||
|
// NVENC: no CRF, use VBR + target CQ. p1 = fastest
|
||||||
|
// preset — prioritizes encoder throughput over bitrate
|
||||||
|
// efficiency. CQ 23 roughly matches libx264 crf 21
|
||||||
|
// visually; NVENC has slightly lower compression
|
||||||
|
// efficiency per quality.
|
||||||
|
cmd.arg("-c:v").arg("h264_nvenc");
|
||||||
|
cmd.arg("-preset").arg("p1");
|
||||||
|
cmd.arg("-rc").arg("vbr");
|
||||||
|
cmd.arg("-cq").arg("23");
|
||||||
|
cmd.arg("-pix_fmt").arg("yuv420p");
|
||||||
|
// Without this NVENC turns -force_key_frames into plain
|
||||||
|
// I-frames, which the HLS muxer can't split on: segments
|
||||||
|
// fall back to NVENC's 250-frame GOP (8.34 s at 29.97).
|
||||||
|
cmd.arg("-forced-idr").arg("1");
|
||||||
|
} else {
|
||||||
|
cmd.arg("-c:v").arg("h264");
|
||||||
|
cmd.arg("-crf").arg("21");
|
||||||
|
cmd.arg("-preset").arg("veryfast");
|
||||||
|
}
|
||||||
|
// Cap the short side so landscape and portrait both land at
|
||||||
|
// real 1080p. iw/ih are post-autorotation display dims.
|
||||||
|
cmd.arg("-vf").arg(format!(
|
||||||
|
"scale='if(gt(iw,ih),-2,min({m},iw))':'if(gt(iw,ih),min({m},ih),-2)',setsar=1:1",
|
||||||
|
m = HLS_MAX_SHORT_SIDE
|
||||||
|
));
|
||||||
|
cmd.arg("-c:a").arg("aac");
|
||||||
|
// Force an IDR frame every hls_time seconds so each HLS
|
||||||
|
// segment starts on a keyframe — accurate seeking without
|
||||||
|
// players having to decode from a prior segment.
|
||||||
|
cmd.arg("-force_key_frames").arg("expr:gte(t,n_forced*3)");
|
||||||
|
// Keep source frame timestamps. Variable-frame-rate phone
|
||||||
|
// clips and screen recordings report r_frame_rate as their
|
||||||
|
// timebase (90000/1); the mpegts muxer's default CFR mode
|
||||||
|
// then duplicates frames to fill it — a 2.6 s clip came out
|
||||||
|
// as 233k frames / 221 MB. vfr leaves timing untouched.
|
||||||
|
cmd.arg("-fps_mode").arg("vfr");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -f hls is required because the playlist is written to a .tmp
|
||||||
|
// path during encoding — ffmpeg normally infers the muxer from
|
||||||
|
// the output extension and doesn't recognize ".m3u8.tmp".
|
||||||
|
cmd.arg("-f").arg("hls");
|
||||||
|
cmd.arg("-hls_time").arg("3");
|
||||||
|
cmd.arg("-hls_list_size").arg("0");
|
||||||
|
cmd.arg("-hls_playlist_type").arg("vod");
|
||||||
|
// independent_segments advertises that each segment can be
|
||||||
|
// decoded without reference to any other — the matching guarantee
|
||||||
|
// for the forced keyframes above.
|
||||||
|
cmd.arg("-hls_flags").arg("independent_segments");
|
||||||
|
cmd.arg("-hls_segment_filename").arg(segment_pattern);
|
||||||
|
cmd.arg(playlist_tmp);
|
||||||
|
cmd.stdout(Stdio::null());
|
||||||
|
cmd.stderr(Stdio::piped());
|
||||||
|
cmd.kill_on_drop(true);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run ffmpeg under a timeout, so a hung run (corrupt source, NFS stall)
|
||||||
|
/// doesn't hold a semaphore slot forever.
|
||||||
|
async fn run_ffmpeg(
|
||||||
|
mut cmd: tokio::process::Command,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<std::process::Output> {
|
||||||
|
match cmd.spawn() {
|
||||||
|
Ok(child) => {
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(timeout_secs),
|
||||||
|
child.wait_with_output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) => res
|
||||||
|
.inspect_err(|e| error!("Failed to wait on ffmpeg child process: {}", e))
|
||||||
|
.map_err(|e| std::io::Error::other(e.to_string())),
|
||||||
|
Err(_) => Err(std::io::Error::other(format!(
|
||||||
|
"ffmpeg exceeded {}s timeout",
|
||||||
|
timeout_secs
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to spawn ffmpeg: {}", e);
|
||||||
|
Err(std::io::Error::other(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete the partial playlist (.tmp) and any segment files left behind by
|
/// Delete the partial playlist (.tmp) and any segment files left behind by
|
||||||
/// a failed ffmpeg run. Wipes every non-sentinel file in the hash dir;
|
/// a failed ffmpeg run. Wipes every non-sentinel file in the hash dir;
|
||||||
/// retains the sentinel if one has already been written by an earlier
|
/// retains the sentinel if one has already been written by an earlier
|
||||||
@@ -958,7 +997,29 @@ impl Handler<GeneratePreviewClipMessage> for PreviewClipGenerator {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{VideoStreamMeta, fits_hls_copy_limits, parse_ffprobe_rational};
|
use super::{VideoStreamMeta, fits_hls_copy_limits, hls_command, parse_ffprobe_rational};
|
||||||
|
|
||||||
|
fn args(nvenc: bool) -> Vec<String> {
|
||||||
|
hls_command("in.mp4", "out.m3u8.tmp", "seg_%03d.ts", false, nvenc)
|
||||||
|
.as_std()
|
||||||
|
.get_args()
|
||||||
|
.map(|a| a.to_string_lossy().into_owned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cpu_fallback_command_never_touches_the_gpu() {
|
||||||
|
// #215: the retry after a GPU init failure must not ask for CUDA again.
|
||||||
|
let cpu = args(false);
|
||||||
|
assert!(!cpu.iter().any(|a| a == "-hwaccel" || a == "h264_nvenc"));
|
||||||
|
assert!(cpu.windows(2).any(|w| w[0] == "-c:v" && w[1] == "h264"));
|
||||||
|
let gpu = args(true);
|
||||||
|
assert!(gpu.windows(2).any(|w| w[0] == "-hwaccel" && w[1] == "cuda"));
|
||||||
|
assert!(
|
||||||
|
gpu.windows(2)
|
||||||
|
.any(|w| w[0] == "-c:v" && w[1] == "h264_nvenc")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const LIMIT: u64 = 20_000_000;
|
const LIMIT: u64 = 20_000_000;
|
||||||
|
|
||||||
|
|||||||
+84
-21
@@ -36,6 +36,24 @@ pub async fn is_nvenc_available() -> bool {
|
|||||||
available
|
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 struct Ffmpeg;
|
||||||
|
|
||||||
pub enum GifType {
|
pub enum GifType {
|
||||||
@@ -271,25 +289,13 @@ fn parse_ffprobe_duration(stdout: &str) -> Option<f64> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a preview clip from a video file.
|
/// The ffmpeg command for a preview clip, and the clip duration it produces.
|
||||||
///
|
fn preview_clip_command(
|
||||||
/// Creates a ~10 second MP4 by extracting up to 10 equally-spaced 1-second segments
|
input_file: &str,
|
||||||
/// at 480p with H.264 video and AAC audio. For short videos (<10s), uses fewer segments.
|
output_file: &str,
|
||||||
/// For very short videos (<1s), transcodes the entire video.
|
duration: Option<f64>,
|
||||||
///
|
use_nvenc: bool,
|
||||||
/// Returns (duration_seconds, file_size_bytes) on success.
|
) -> (Command, f64) {
|
||||||
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 = Command::new("ffmpeg");
|
let mut cmd = Command::new("ffmpeg");
|
||||||
cmd.arg("-y");
|
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.stdout(Stdio::null());
|
||||||
cmd.stderr(Stdio::piped());
|
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() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn empty_output_returns_none() {
|
fn empty_output_returns_none() {
|
||||||
|
|||||||
Reference in new issue
Block a user