diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index 9ee3337..4c86927 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -832,47 +832,40 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { Widget _buildCodecRadio(BuildContext context, MainViewModel viewModel, EncodingSettings settings, VideoCodec codec, {bool detected = true}) { final isSupported = codec.supportsContainer(settings.container); - final showWarning = !detected && isSupported; + // A hardware encoder that ffmpeg didn't report (not compiled into this + // build) can't work, so treat it as unavailable: disabled, not just warned. + final notInBuild = !detected && isSupported; + final isAvailable = isSupported && detected; + final unavailableReason = !isSupported + ? 'Not supported in ${settings.container.name.toUpperCase()}' + : (notInBuild ? 'Not available in this build' : null); + final disabledStyle = TextStyle( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38), + ); return RadioListTile( title: Row( children: [ Text( codec.displayName, - style: isSupported - ? null - : TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.38), - ), + style: isAvailable ? null : disabledStyle, ), - if (showWarning) ...[ + if (notInBuild) ...[ const SizedBox(width: 6), Tooltip( - message: 'May not be available on this system', - child: Icon(Icons.warning_amber_rounded, + message: 'Not available in this build', + child: Icon(Icons.block, size: 16, color: Colors.orange[700]), ), ], ], ), subtitle: Text( - isSupported - ? codec.description - : 'Not supported in ${settings.container.name.toUpperCase()}', - style: isSupported - ? null - : TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.38), - ), + isAvailable ? codec.description : unavailableReason!, + style: isAvailable ? null : disabledStyle, ), value: codec, groupValue: settings.codec, - onChanged: isSupported + onChanged: isAvailable ? (value) { if (value != null) { // When switching encoder families, reset the preset to the new family's default diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index ce2143b..628c5db 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -38,6 +38,22 @@ fn format_exit_status(status: &std::process::ExitStatus) -> String { "unknown status".to_string() } +/// True if the process was terminated by SIGPIPE (Unix only; always false +/// elsewhere). A vspipe SIGPIPE means the downstream consumer (ffmpeg) closed +/// the pipe — usually because ffmpeg itself failed, so the ffmpeg error is the +/// one worth reporting. +fn is_sigpipe(status: &std::process::ExitStatus) -> bool { + #[cfg(unix)] + { + status.signal() == Some(13) + } + #[cfg(not(unix))] + { + let _ = status; + false + } +} + use crate::dependency_locator::DependencyLocator; use crate::models::{AudioMode, ContainerFormat, DeinterlaceMethod, EncoderFamily, EncodingSettings, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; use crate::progress_reporter::ProgressReporter; @@ -251,12 +267,20 @@ impl PipelineExecutor { let ffmpeg_reporter = self.reporter.clone(); let ffmpeg_stderr_thread = thread::spawn(move || { let reader = BufReader::new(ffmpeg_stderr); - let mut last_line = String::new(); + // Keep the last few non-empty lines so a failed ffmpeg can report a + // useful error (e.g. "Unknown encoder 'h264_qsv'"), not just its + // final — often blank — stderr line. + let mut tail: std::collections::VecDeque = std::collections::VecDeque::new(); for line in reader.lines().map_while(Result::ok) { ffmpeg_reporter.send_log(LogLevel::Debug, &format!("ffmpeg stderr: {}", line)); - last_line = line; + if !line.trim().is_empty() { + tail.push_back(line); + if tail.len() > 12 { + tail.pop_front(); + } + } } - last_line + tail.into_iter().collect::>().join("\n") }); // Poll the progress file for updates instead of reading piped stderr. @@ -385,38 +409,52 @@ impl PipelineExecutor { // Now safe to join threads (pipes are closed, readers will hit EOF) let _ = decoder_stderr_thread.join(); let _ = vspipe_thread.join(); - let _ = ffmpeg_stderr_thread.join(); + let ffmpeg_stderr_tail = ffmpeg_stderr_thread.join().unwrap_or_default(); // Clean up progress file let _ = fs::remove_file(&progress_file); // Check exit codes. - // Decoder may exit with broken pipe (141=SIGPIPE on Linux, 224=EPIPE on macOS) - // when vspipe finishes reading before the decoder sends all frames (e.g. IVTC - // VDecimate reads fewer frames than available). This is expected and harmless. - if let Some(status) = decoder_status { + // + // ffmpeg is checked FIRST on purpose: when the encoder ffmpeg fails + // (e.g. an unavailable hardware encoder), vspipe dies with SIGPIPE as a + // *symptom* of the closed pipe. Reporting ffmpeg's error — with its + // stderr tail — surfaces the real cause instead of the misleading + // "vspipe exited with signal 13 (SIGPIPE)". + let ffmpeg_ok = if let Some(status) = ffmpeg_status { let code = status.code().unwrap_or(-1); - if code != 0 && code != 130 && code != 141 && code != 224 { - bail!("Decoder ffmpeg exited with {}", format_exit_status(&status)); + if code != 0 && code != 130 && code != 141 { + let tail = ffmpeg_stderr_tail.trim(); + if tail.is_empty() { + bail!("ffmpeg exited with {}", format_exit_status(&status)); + } + bail!("ffmpeg exited with {}:\n{}", format_exit_status(&status), tail); } - } + true + } else { + false + }; + // vspipe: a SIGPIPE here means ffmpeg closed the pipe early. If ffmpeg + // failed we've already reported the real cause above; if ffmpeg was fine + // (e.g. IVTC VDecimate finishing early) it's harmless. Only surface a + // genuine vspipe failure — a real exit code, or a non-SIGPIPE signal. if let Some(status) = vspipe_status { - let code = status.code().unwrap_or(-1); - if code != 0 && code != 130 && code != 141 { + let ok = matches!(status.code(), Some(0) | Some(130) | Some(141)); + if !ok && !is_sigpipe(&status) { bail!("vspipe exited with {}", format_exit_status(&status)); } } - let ffmpeg_ok = if let Some(status) = ffmpeg_status { + // Decoder may exit with broken pipe (141=SIGPIPE on Linux, 224=EPIPE on + // macOS) when vspipe finishes reading before the decoder sends all frames + // (e.g. IVTC VDecimate reads fewer frames than available). Harmless. + if let Some(status) = decoder_status { let code = status.code().unwrap_or(-1); - if code != 0 && code != 130 && code != 141 { - bail!("ffmpeg exited with {}", format_exit_status(&status)); + if code != 0 && code != 130 && code != 141 && code != 224 { + bail!("Decoder ffmpeg exited with {}", format_exit_status(&status)); } - true - } else { - false - }; + } // Send final 100% progress when job succeeds. // The source metadata total can be wrong (e.g. AVI containers),