From f1e35bdc4983d5b34f14dea5b535da729955b29a Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 9 Jun 2026 11:15:44 +1000 Subject: [PATCH 1/4] Surface real ffmpeg errors; disable unavailable hw encoders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an encoder ffmpeg fails (e.g. a hardware encoder not present in the bundled ffmpeg), vspipe dies with SIGPIPE writing to the closed pipe, and the worker reported that symptom ("vspipe exited with signal 13") instead of the cause. Now: - pipeline_executor checks ffmpeg's status first and includes the tail of its stderr in the error, so the user sees e.g. "Unknown encoder 'h264_qsv'". A vspipe SIGPIPE is no longer reported when it's just the downstream pipe closing (ffmpeg's result is authoritative). - The settings UI now disables (not just warns on) hardware encoders that ffmpeg didn't report as compiled into this build, with a clear "Not available in this build" reason — preventing a guaranteed-to-fail encode. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/lib/views/settings/settings_dialog.dart | 41 +++++------ worker/src/pipeline_executor.rs | 78 +++++++++++++++------ 2 files changed, 75 insertions(+), 44 deletions(-) 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), From 1e2a026a1d79170e78bfb3378987c1c584a830f7 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 9 Jun 2026 12:27:28 +1000 Subject: [PATCH 2/4] Linux: hardware-enabled ffmpeg, per-platform deps version, platform-filtered codecs - download-deps-linux.sh: switch FFmpeg from the John Van Sickle static build (software-only) to BtbN's static GPL build, which ships hardware encoders (x64: QSV/NVENC/AMF/VAAPI; arm64: NVENC/AMF/V4L2M2M) while still running software-only where no GPU/driver is present. - deps-version.json + dependency_manager: add optional per-platform version/releaseTag so Linux deps can bump independently. Bump linux-x64 and linux-arm64 to 1.4.0 / deps-v1.4.0 (sha256/size null pending the rebuild); macOS/Windows stay on the global 1.3.0 and won't re-download. - settings: show only the hardware encoders relevant to each platform (VideoToolbox on macOS; QSV/NVENC/AMF on Windows + Linux), instead of listing all of them everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) --- Scripts/download-deps-linux.sh | 20 ++++++++---- app/assets/deps-version.json | 16 ++++++---- app/lib/services/dependency_manager.dart | 34 +++++++++++++++++---- app/lib/views/settings/settings_dialog.dart | 27 ++++++++++++++-- 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/Scripts/download-deps-linux.sh b/Scripts/download-deps-linux.sh index 0519c8f..30c9da5 100755 --- a/Scripts/download-deps-linux.sh +++ b/Scripts/download-deps-linux.sh @@ -314,22 +314,30 @@ echo "" echo "=== Downloading FFmpeg ===" if [ "$FORCE" = true ] || [ ! -f "$DEPS_DIR/ffmpeg/ffmpeg" ]; then + # BtbN static GPL builds include hardware encoders (x64: QSV/NVENC/AMF/VAAPI; + # arm64: NVENC/AMF/V4L2M2M) yet run software-only on machines without a GPU or + # driver — the vendor runtimes (libvpl/libnvidia-encode/libva) are dlopen'd + # lazily, so libx264/libx265 always work and absent hw encoders fail + # gracefully at init. (The previous John Van Sickle build had no hw accel.) + BTBN_BASE="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest" if [ "$ARCH" = "x86_64" ]; then - FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" + FFMPEG_URL="$BTBN_BASE/ffmpeg-n7.1-latest-linux64-gpl-7.1.tar.xz" else - FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" + FFMPEG_URL="$BTBN_BASE/ffmpeg-n7.1-latest-linuxarm64-gpl-7.1.tar.xz" fi - echo " Downloading static FFmpeg..." + echo " Downloading static FFmpeg (BtbN, hardware-enabled)..." curl -L -o "$BUILD_DIR/ffmpeg.tar.xz" "$FFMPEG_URL" echo " Extracting..." tar -xJf "$BUILD_DIR/ffmpeg.tar.xz" -C "$BUILD_DIR" - FFMPEG_DIR=$(find "$BUILD_DIR" -maxdepth 1 -name "ffmpeg-*-static" -type d | head -1) - if [ -z "$FFMPEG_DIR" ]; then - echo " ERROR: Could not find extracted FFmpeg directory" + # BtbN tarballs lay the binaries out under /bin/ + FFMPEG_BIN=$(find "$BUILD_DIR" -maxdepth 3 -path "*/bin/ffmpeg" -type f | head -1) + if [ -z "$FFMPEG_BIN" ]; then + echo " ERROR: Could not find extracted FFmpeg binary" exit 1 fi + FFMPEG_DIR=$(dirname "$FFMPEG_BIN") cp "$FFMPEG_DIR/ffmpeg" "$DEPS_DIR/ffmpeg/" cp "$FFMPEG_DIR/ffprobe" "$DEPS_DIR/ffmpeg/" diff --git a/app/assets/deps-version.json b/app/assets/deps-version.json index 2d1ef25..6627470 100644 --- a/app/assets/deps-version.json +++ b/app/assets/deps-version.json @@ -19,14 +19,18 @@ "size": 106078101 }, "linux-x64": { - "filename": "VapourBox-deps-1.3.0-linux-x64.zip", - "sha256": "431b9728b237f86b6160ef4e5a1b04bd789e3d59f91961132e2163fe7cc01797", - "size": 143784868 + "version": "1.4.0", + "releaseTag": "deps-v1.4.0", + "filename": "VapourBox-deps-1.4.0-linux-x64.zip", + "sha256": null, + "size": null }, "linux-arm64": { - "filename": "VapourBox-deps-1.3.0-linux-arm64.zip", - "sha256": "c21eb5e135c4fa6e1aa60befb577322b8cdf5ff63bc13681946e41f060b1cd36", - "size": 123911771 + "version": "1.4.0", + "releaseTag": "deps-v1.4.0", + "filename": "VapourBox-deps-1.4.0-linux-arm64.zip", + "sha256": null, + "size": null } }, "githubRepo": "StuartCameronCode/VapourBox" diff --git a/app/lib/services/dependency_manager.dart b/app/lib/services/dependency_manager.dart index 7986c34..d2959db 100644 --- a/app/lib/services/dependency_manager.dart +++ b/app/lib/services/dependency_manager.dart @@ -79,13 +79,23 @@ class DepsVersionInfo { ); } + /// The deps version expected for a platform. Platforms may override the + /// global version (e.g. when only one platform's deps change), so the app + /// can re-fetch just that platform without disturbing the others. + String versionFor(String platform) => platforms[platform]?.version ?? version; + + /// The release tag a platform's zip lives under (per-platform override, else + /// the global tag). + String releaseTagFor(String platform) => + platforms[platform]?.releaseTag ?? releaseTag; + /// Get the download URL for a specific platform's deps zip. String getDownloadUrl(String platform) { final platformInfo = platforms[platform]; if (platformInfo == null) { throw StateError('No dependency info for platform: $platform'); } - return 'https://github.com/$githubRepo/releases/download/$releaseTag/${platformInfo.filename}'; + return 'https://github.com/$githubRepo/releases/download/${releaseTagFor(platform)}/${platformInfo.filename}'; } } @@ -95,10 +105,19 @@ class PlatformDepsInfo { final String? sha256; final int? size; + /// Optional per-platform version override. When set, this platform's deps are + /// versioned independently of the global `version` (see [DepsVersionInfo.versionFor]). + final String? version; + + /// Optional per-platform release tag override (the release the zip lives in). + final String? releaseTag; + PlatformDepsInfo({ required this.filename, this.sha256, this.size, + this.version, + this.releaseTag, }); factory PlatformDepsInfo.fromJson(Map json) { @@ -106,6 +125,8 @@ class PlatformDepsInfo { filename: json['filename'] as String, sha256: json['sha256'] as String?, size: json['size'] as int?, + version: json['version'] as String?, + releaseTag: json['releaseTag'] as String?, ); } } @@ -320,10 +341,11 @@ class DependencyManager { return DependencyStatus.missing; } - // Check version match - if (installed.version != expected.version) { + // Check version match (per-platform: a platform may pin its own version) + final expectedVersion = expected.versionFor(platformId); + if (installed.version != expectedVersion) { print( - 'DependencyManager: Version mismatch - installed: ${installed.version}, expected: ${expected.version}'); + 'DependencyManager: Version mismatch - installed: ${installed.version}, expected: $expectedVersion'); return DependencyStatus.outdated; } @@ -409,8 +431,8 @@ class DependencyManager { await _extractZip(tempFile); - // Write version file - await _writeInstalledVersion(expected.version); + // Write version file (per-platform version, so the next check matches) + await _writeInstalledVersion(expected.versionFor(platformId)); _progressController.add(DownloadProgress( bytesReceived: platformInfo.size ?? 0, diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index 4c86927..5ed89eb 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -746,6 +746,26 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { ); } + /// Whether a hardware encoder is relevant to the current platform's GPU APIs: + /// VideoToolbox is macOS-only; QSV/NVENC/AMF apply to Windows and Linux. + /// (Software/ProRes/lossless codecs are platform-agnostic.) + bool _isHwCodecForPlatform(VideoCodec codec) { + const videotoolbox = { + VideoCodec.h264Videotoolbox, + VideoCodec.h265Videotoolbox, + }; + const qsvNvencAmf = { + VideoCodec.h264Qsv, VideoCodec.h265Qsv, + VideoCodec.h264Nvenc, VideoCodec.h265Nvenc, + VideoCodec.h264Amf, VideoCodec.h265Amf, + }; + if (videotoolbox.contains(codec)) return Platform.isMacOS; + if (qsvNvencAmf.contains(codec)) { + return Platform.isWindows || Platform.isLinux; + } + return true; + } + Widget _buildCodecList(BuildContext context, MainViewModel viewModel, EncodingSettings settings) { final detector = HardwareEncoderDetector.instance; @@ -765,9 +785,12 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { VideoCodec.ffv1, VideoCodec.huffyuv, VideoCodec.ffvhuff, ]; - // Filter hardware codecs by container support only (show all, warn if undetected) + // Show only hardware encoders relevant to this platform (and supported by + // the container). Whether each is actually usable on the machine is then + // reflected by the per-encoder detection in _buildCodecRadio. final supportedHardware = hardwareCodecs - .where((c) => c.supportsContainer(settings.container)) + .where((c) => + c.supportsContainer(settings.container) && _isHwCodecForPlatform(c)) .toList(); final children = []; From 1c85c495bbda082d659fff44414e2e84459f9521 Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 9 Jun 2026 12:44:03 +1000 Subject: [PATCH 3/4] Probe hardware encoders functionally, with a live busy indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HardwareEncoderDetector is now a ChangeNotifier with two stages: the cheap `ffmpeg -encoders` compiled-in list, then a concurrent functional probe (a throwaway 1-frame encode) per compiled-in hardware encoder to see if it actually initializes on this machine (driver + device present). Probes run concurrently and update state as each resolves. The settings codec list listens to the detector and shows a spinner next to each hardware encoder while its probe is in flight, then resolves to selectable (available) or disabled with a reason — "Not available in this build" (not compiled in) vs "Not available on this system" (probe failed, e.g. no GPU/driver). This keeps the UI honest once the hardware-enabled ffmpeg ships, where every encoder is compiled in regardless of hardware. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../services/hardware_encoder_detector.dart | 135 ++++++++++++++---- app/lib/views/settings/settings_dialog.dart | 70 ++++++--- 2 files changed, 160 insertions(+), 45 deletions(-) diff --git a/app/lib/services/hardware_encoder_detector.dart b/app/lib/services/hardware_encoder_detector.dart index 15f9f38..e7077e5 100644 --- a/app/lib/services/hardware_encoder_detector.dart +++ b/app/lib/services/hardware_encoder_detector.dart @@ -1,27 +1,84 @@ +import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; + import '../models/video_job.dart'; import 'tool_locator.dart'; -/// Detects available hardware video encoders by probing the bundled FFmpeg. -class HardwareEncoderDetector { +/// Per-codec availability state. +enum EncoderProbeState { + /// A functional probe is still running for this encoder. + probing, + + /// The encoder initialized successfully on this machine. + available, + + /// The encoder is not compiled into this build, or the probe failed + /// (e.g. no GPU/driver present). + unavailable, +} + +/// Detects which video encoders are actually usable on this machine. +/// +/// Two stages: +/// 1. `ffmpeg -encoders` — cheap; tells us which encoders are *compiled into* +/// the bundled ffmpeg. This says nothing about the GPU/driver at runtime. +/// 2. A concurrent *functional probe* per compiled-in hardware encoder — a +/// throwaway one-frame encode. Exit 0 means the encoder really initialized +/// (driver + device present); non-zero means it can't run here. ffmpeg does +/// the driver detection; we just read pass/fail. +/// +/// Probes run concurrently and update state as each finishes; the detector is a +/// [ChangeNotifier] so the UI can show a busy indicator while a codec is still +/// being queried and resolve it live. +class HardwareEncoderDetector extends ChangeNotifier { HardwareEncoderDetector._(); static final HardwareEncoderDetector instance = HardwareEncoderDetector._(); - final Set _availableEncoders = {}; + final Set _compiledIn = {}; + final Map _state = {}; bool _initialized = false; - /// Initialize by probing FFmpeg for available encoders. + /// Whether the encoder is compiled into the bundled ffmpeg. Always true for + /// non-hardware codecs (software, ProRes, lossless). + bool isCompiledIn(VideoCodec codec) => + !codec.isHardwareEncoder || _compiledIn.contains(codec.value); + + /// Current probe state for a codec. Non-hardware codecs are always available. + /// Hardware codecs default to [EncoderProbeState.probing] until resolved. + EncoderProbeState probeState(VideoCodec codec) { + if (!codec.isHardwareEncoder) return EncoderProbeState.available; + return _state[codec] ?? EncoderProbeState.probing; + } + + bool isProbing(VideoCodec codec) => + probeState(codec) == EncoderProbeState.probing; + + /// Whether the codec is usable on this machine right now (non-hardware codecs, + /// or hardware codecs whose functional probe succeeded). + bool isAvailable(VideoCodec codec) => + probeState(codec) == EncoderProbeState.available; + + /// Probe the bundled ffmpeg for available encoders. Idempotent. Future initialize() async { if (_initialized) return; + _initialized = true; + + final hwCodecs = + VideoCodec.values.where((c) => c.isHardwareEncoder).toList(); final ffmpegPath = ToolLocator.instance.ffmpegPath; if (ffmpegPath == null) { - _initialized = true; + for (final c in hwCodecs) { + _state[c] = EncoderProbeState.unavailable; + } + notifyListeners(); return; } + // Stage 1: which encoders are compiled into the binary. try { final result = await Process.run( ffmpegPath, @@ -29,39 +86,63 @@ class HardwareEncoderDetector { stdoutEncoding: const SystemEncoding(), stderrEncoding: const SystemEncoding(), ); - if (result.exitCode == 0) { - final output = result.stdout as String; - for (final line in output.split('\n')) { + for (final line in (result.stdout as String).split('\n')) { final trimmed = line.trim(); - // Encoder lines start with a flags field like "V....D" followed by the encoder name + // Encoder lines start with a flags field like "V....D" then the name. if (trimmed.startsWith('V')) { final parts = trimmed.split(RegExp(r'\s+')); - if (parts.length >= 2) { - _availableEncoders.add(parts[1]); - } + if (parts.length >= 2) _compiledIn.add(parts[1]); } } } - } catch (e) { - // FFmpeg not available or failed - hardware encoders won't be detected + } catch (_) { + // ffmpeg missing/failed — _compiledIn stays empty, everything hw is out. } - final detected = VideoCodec.values.where((c) => c.isHardwareEncoder && isDetected(c)).map((c) => c.value); - print('HardwareEncoderDetector: detected encoders: ${detected.isEmpty ? "none" : detected.join(", ")}'); + // Stage 2: a compiled-in hardware encoder still needs a working device. + final toProbe = []; + for (final codec in hwCodecs) { + if (_compiledIn.contains(codec.value)) { + _state[codec] = EncoderProbeState.probing; + toProbe.add(codec); + } else { + _state[codec] = EncoderProbeState.unavailable; + } + } + notifyListeners(); - _initialized = true; + // Run the functional probes concurrently; each resolves independently. + for (final codec in toProbe) { + unawaited(_probe(ffmpegPath, codec)); + } } - /// Whether a hardware codec was reported by ffmpeg's `-encoders` list. - /// - /// Note: ffmpeg reports all encoders *compiled into* the binary, not just - /// those supported by the current hardware. A codec reported here may still - /// fail at runtime if the required GPU/driver is not present. - /// Returns true for non-hardware codecs (software, ProRes, FFV1). - bool isDetected(VideoCodec codec) { - if (!codec.isHardwareEncoder) return true; - return _availableEncoders.contains(codec.value); + Future _probe(String ffmpegPath, VideoCodec codec) async { + var ok = false; + try { + final result = await Process.run( + ffmpegPath, + [ + '-hide_banner', '-loglevel', 'error', + '-f', 'lavfi', '-i', 'color=c=black:s=64x64:r=5:d=1', + '-frames:v', '1', + '-c:v', codec.value, + '-f', 'null', '-', + ], + stdoutEncoding: const SystemEncoding(), + stderrEncoding: const SystemEncoding(), + ); + ok = result.exitCode == 0; + } catch (_) { + ok = false; + } + _state[codec] = + ok ? EncoderProbeState.available : EncoderProbeState.unavailable; + if (kDebugMode) { + print('HardwareEncoderDetector: ${codec.value} -> ' + '${ok ? "available" : "unavailable"}'); + } + notifyListeners(); } - } diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index 5ed89eb..c180018 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -218,10 +218,19 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { super.initState(); _filenamePatternController = TextEditingController(); _customFfmpegArgsController = TextEditingController(); + // Kick off (idempotent) encoder detection and rebuild as probes resolve so + // the codec list can show/clear a busy indicator per encoder live. + HardwareEncoderDetector.instance.addListener(_onEncoderDetectionChanged); + HardwareEncoderDetector.instance.initialize(); + } + + void _onEncoderDetectionChanged() { + if (mounted) setState(() {}); } @override void dispose() { + HardwareEncoderDetector.instance.removeListener(_onEncoderDetectionChanged); _filenamePatternController.dispose(); _customFfmpegArgsController.dispose(); super.dispose(); @@ -767,8 +776,6 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { } Widget _buildCodecList(BuildContext context, MainViewModel viewModel, EncodingSettings settings) { - final detector = HardwareEncoderDetector.instance; - // Group codecs by family final softwareCodecs = [VideoCodec.h264, VideoCodec.h265]; final hardwareCodecs = [ @@ -806,8 +813,7 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { children.add(const SizedBox(height: 8)); children.add(_buildCodecGroupLabel(context, 'Hardware Accelerated')); for (final codec in supportedHardware) { - children.add(_buildCodecRadio(context, viewModel, settings, codec, - detected: detector.isDetected(codec))); + children.add(_buildCodecRadio(context, viewModel, settings, codec)); } } @@ -853,18 +859,50 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { } Widget _buildCodecRadio(BuildContext context, MainViewModel viewModel, - EncodingSettings settings, VideoCodec codec, {bool detected = true}) { + EncodingSettings settings, VideoCodec codec) { + final detector = HardwareEncoderDetector.instance; final isSupported = codec.supportsContainer(settings.container); - // 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 + final isHw = codec.isHardwareEncoder; + final compiledIn = detector.isCompiledIn(codec); + // Still querying whether this (compiled-in) hardware encoder works here. + final probing = isSupported && isHw && compiledIn && detector.isProbing(codec); + // Usable = container-supported AND (non-hardware, or its probe succeeded). + final isAvailable = isSupported && detector.isAvailable(codec); + + final String? unavailableReason = !isSupported ? 'Not supported in ${settings.container.name.toUpperCase()}' - : (notInBuild ? 'Not available in this build' : null); + : !compiledIn + ? 'Not available in this build' + : probing + ? 'Checking availability…' + : !isAvailable + ? 'Not available on this system' + : null; + final disabledStyle = TextStyle( color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.38), ); + + // Trailing affordance: spinner while probing, block icon when unavailable. + Widget? indicator; + if (probing) { + indicator = const Tooltip( + message: 'Checking availability…', + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } else if (isSupported && isHw && !isAvailable) { + indicator = Tooltip( + message: compiledIn + ? 'Not available on this system' + : 'Not available in this build', + child: Icon(Icons.block, size: 16, color: Colors.orange[700]), + ); + } + return RadioListTile( title: Row( children: [ @@ -872,13 +910,9 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { codec.displayName, style: isAvailable ? null : disabledStyle, ), - if (notInBuild) ...[ - const SizedBox(width: 6), - Tooltip( - message: 'Not available in this build', - child: Icon(Icons.block, - size: 16, color: Colors.orange[700]), - ), + if (indicator != null) ...[ + const SizedBox(width: 8), + indicator, ], ], ), From 985eb45dfedeca39483cbeef5999789efed1830c Mon Sep 17 00:00:00 2001 From: Stuart Cameron Date: Tue, 9 Jun 2026 14:10:34 +1000 Subject: [PATCH 4/4] Fix VideoToolbox on Intel Macs: use -b:v, not -q:v h264_videotoolbox/hevc_videotoolbox support constant-quality (-q:v) only on Apple Silicon. On Intel Macs ffmpeg rejects it ("qscale not available for encoder. Use -b:v bitrate instead") and the encode fails. (A user's custom -b:v workaround was ineffective because -q:v still made the encoder fail to open.) Select the quality control by arch (per-slice in the universal binary): -q:v on aarch64 (Apple Silicon), an average bitrate on x86_64 (Intel), derived best-effort from output resolution/fps and the quality setting (HEVC ~60% of H.264). The VideoToolbox test now asserts the right flag per arch, so CI verifies both the Apple-Silicon and Intel jobs. Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/src/pipeline_executor.rs | 74 +++++++++++++++++++++++++++------ 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/worker/src/pipeline_executor.rs b/worker/src/pipeline_executor.rs index 628c5db..b668f15 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -55,7 +55,7 @@ fn is_sigpipe(status: &std::process::ExitStatus) -> bool { } use crate::dependency_locator::DependencyLocator; -use crate::models::{AudioMode, ContainerFormat, DeinterlaceMethod, EncoderFamily, EncodingSettings, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; +use crate::models::{AudioMode, ContainerFormat, DeinterlaceMethod, EncoderFamily, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; use crate::progress_reporter::ProgressReporter; use crate::script_generator::{PreviewParams, ScriptGenerator}; @@ -644,7 +644,7 @@ impl PipelineExecutor { args.extend(["-c:v".to_string(), settings.codec.ffmpeg_codec().to_string()]); // Encoder-family-specific quality and preset args - Self::build_encoder_quality_args(&mut args, settings); + Self::build_encoder_quality_args(&mut args, job); // Force a compatible output pixel format for codecs that can't accept the // pipeline's native format (e.g. classic HuffYUV requires yuv422p). @@ -723,8 +723,28 @@ impl PipelineExecutor { args } + /// Best-effort average bitrate (kbps) for Intel-Mac VideoToolbox, which has + /// no constant-quality (-q:v) mode. Maps the CRF-like quality (0-51, lower = + /// better) to bits-per-pixel, then bitrate = width*height*fps*bpp. HEVC + /// targets ~60% of H.264's bitrate for comparable quality. Floored at 500 kbps. + #[cfg(not(target_arch = "aarch64"))] + fn videotoolbox_bitrate_kbps(job: &VideoJob) -> u32 { + let settings = &job.encoding_settings; + let w = job.input_width.unwrap_or(720).max(1) as f64; + let h = job.input_height.unwrap_or(480).max(1) as f64; + let fps = job.input_frame_rate.unwrap_or(29.97).max(1.0); + let q = settings.quality.clamp(0, 51) as f64; + let mut bpp = 0.20 - (0.18 * q / 51.0); // q=0 -> 0.20, q=51 -> 0.02 + if matches!(settings.codec, VideoCodec::H265Videotoolbox) { + bpp *= 0.6; + } + let bps = w * h * fps * bpp; + ((bps / 1000.0).round() as u32).max(500) + } + /// Build encoder-family-specific quality and preset arguments. - fn build_encoder_quality_args(args: &mut Vec, settings: &EncodingSettings) { + fn build_encoder_quality_args(args: &mut Vec, job: &VideoJob) { + let settings = &job.encoding_settings; if let Some(profile) = settings.codec.prores_profile() { args.push("-profile:v".to_string()); args.push(profile.to_string()); @@ -758,9 +778,24 @@ impl PipelineExecutor { args.extend(["-preset".to_string(), settings.encoder_preset.clone()]); } EncoderFamily::Videotoolbox => { - // VideoToolbox uses q:v with inverted scale (CRF 0-51 -> q:v 100-1) - let vt_quality = ((51 - settings.quality) as f64 * 100.0 / 51.0).round().max(1.0) as i32; - args.extend(["-q:v".to_string(), vt_quality.to_string()]); + // VideoToolbox's constant-quality mode (-q:v) is only + // supported on Apple Silicon. On Intel Macs the encoder has + // no qscale and fails to open with -q:v ("qscale not + // available for encoder. Use -b:v bitrate instead"), so use + // an average bitrate there. target_arch is per-slice in the + // universal binary (x86_64 == Intel, aarch64 == Apple Silicon). + #[cfg(target_arch = "aarch64")] + { + // CRF 0-51 -> q:v 100-1 (inverted scale) + let vt_quality = + ((51 - settings.quality) as f64 * 100.0 / 51.0).round().max(1.0) as i32; + args.extend(["-q:v".to_string(), vt_quality.to_string()]); + } + #[cfg(not(target_arch = "aarch64"))] + { + let kbps = Self::videotoolbox_bitrate_kbps(job); + args.extend(["-b:v".to_string(), format!("{}k", kbps)]); + } } EncoderFamily::Amf => { args.extend(["-rc".to_string(), "cqp".to_string()]); @@ -1008,7 +1043,7 @@ mod tests { args.extend(["-c:v".to_string(), settings.codec.ffmpeg_codec().to_string()]); // Encoder-family-specific quality and preset args - PipelineExecutor::build_encoder_quality_args(&mut args, settings); + PipelineExecutor::build_encoder_quality_args(&mut args, job); // Force a compatible output pixel format (e.g. HuffYUV requires yuv422p) if let Some(pix_fmt) = settings.codec.forced_pix_fmt() { @@ -1417,12 +1452,25 @@ mod tests { let video_codec_idx = args.iter().position(|a| a == "-c:v"); assert_eq!(args[video_codec_idx.unwrap() + 1], "h264_videotoolbox"); - let qv_idx = args.iter().position(|a| a == "-q:v"); - assert!(qv_idx.is_some(), "VideoToolbox should use -q:v"); - - // CRF 18 -> (51-18)*100/51 = 64.7 -> 65 - let qv_value: i32 = args[qv_idx.unwrap() + 1].parse().unwrap(); - assert!(qv_value > 0 && qv_value <= 100, "VideoToolbox q:v should be 1-100, got {}", qv_value); + // VideoToolbox quality control is arch-specific: constant-quality (-q:v) + // on Apple Silicon, average bitrate (-b:v) on Intel (which has no qscale). + #[cfg(target_arch = "aarch64")] + { + let qv_idx = args.iter().position(|a| a == "-q:v"); + assert!(qv_idx.is_some(), "Apple Silicon VideoToolbox should use -q:v"); + // CRF 18 -> (51-18)*100/51 = 64.7 -> 65 + let qv_value: i32 = args[qv_idx.unwrap() + 1].parse().unwrap(); + assert!(qv_value > 0 && qv_value <= 100, "VideoToolbox q:v should be 1-100, got {}", qv_value); + } + #[cfg(not(target_arch = "aarch64"))] + { + assert!(!args.contains(&"-q:v".to_string()), + "Intel VideoToolbox must not use -q:v (qscale unsupported)"); + let bv_idx = args.iter().position(|a| a == "-b:v"); + assert!(bv_idx.is_some(), "Intel VideoToolbox should use -b:v"); + assert!(args[bv_idx.unwrap() + 1].ends_with('k'), + "Intel VideoToolbox -b:v should be a kbps value, got {}", args[bv_idx.unwrap() + 1]); + } assert!(!args.contains(&"-crf".to_string()), "VideoToolbox should not use -crf"); assert!(!args.contains(&"-preset".to_string()), "VideoToolbox should not use -preset");