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/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 9ee3337..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(); @@ -746,9 +755,27 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { ); } - Widget _buildCodecList(BuildContext context, MainViewModel viewModel, EncodingSettings settings) { - final detector = HardwareEncoderDetector.instance; + /// 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) { // Group codecs by family final softwareCodecs = [VideoCodec.h264, VideoCodec.h265]; final hardwareCodecs = [ @@ -765,9 +792,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 = []; @@ -783,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)); } } @@ -830,49 +859,70 @@ 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); - final showWarning = !detected && 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()}' + : !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: [ Text( codec.displayName, - style: isSupported - ? null - : TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface - .withValues(alpha: 0.38), - ), + style: isAvailable ? null : disabledStyle, ), - if (showWarning) ...[ - const SizedBox(width: 6), - Tooltip( - message: 'May not be available on this system', - child: Icon(Icons.warning_amber_rounded, - size: 16, color: Colors.orange[700]), - ), + if (indicator != null) ...[ + const SizedBox(width: 8), + indicator, ], ], ), 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..b668f15 100644 --- a/worker/src/pipeline_executor.rs +++ b/worker/src/pipeline_executor.rs @@ -38,8 +38,24 @@ 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::models::{AudioMode, ContainerFormat, DeinterlaceMethod, EncoderFamily, LogLevel, ProgressInfo, SubtitleOutput, VideoCodec, VideoJob}; use crate::progress_reporter::ProgressReporter; use crate::script_generator::{PreviewParams, ScriptGenerator}; @@ -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), @@ -606,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). @@ -685,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()); @@ -720,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()]); @@ -970,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() { @@ -1379,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");