diff --git a/app/assets/filters/core/descratch.json b/app/assets/filters/core/descratch.json index c993fc3..228c86a 100644 --- a/app/assets/filters/core/descratch.json +++ b/app/assets/filters/core/descratch.json @@ -7,6 +7,7 @@ "category": "cleanup", "icon": "healing", "order": 2, + "maxBitDepth": 8, "dependencies": { "vs_plugins": ["libdescratch.dll"] diff --git a/app/lib/models/filter_schema.dart b/app/lib/models/filter_schema.dart index 29bb6c7..6f13c5d 100644 --- a/app/lib/models/filter_schema.dart +++ b/app/lib/models/filter_schema.dart @@ -357,6 +357,12 @@ class FilterSchema { /// Sort order in filter list. final int order; + /// Highest per-component bit depth this filter processes natively. When set + /// and the source exceeds it, the worker down-converts to this depth for the + /// pass (a lossy round-trip), so the UI warns of reduced precision. Null (the + /// default) means the filter handles the source's bit depth without loss. + final int? maxBitDepth; + /// Dependencies required by this filter. final FilterDependencies? dependencies; @@ -391,6 +397,7 @@ class FilterSchema { this.category, this.icon, this.order = 0, + this.maxBitDepth, this.dependencies, required this.methods, required this.parameters, diff --git a/app/lib/utils/pixel_format.dart b/app/lib/utils/pixel_format.dart new file mode 100644 index 0000000..0106c46 --- /dev/null +++ b/app/lib/utils/pixel_format.dart @@ -0,0 +1,67 @@ +// Utilities for interpreting FFmpeg pixel-format strings (e.g. the `pix_fmt` +// reported by ffprobe: "yuv420p", "yuv422p10le", "yuv420p16le"). + +/// Best-effort per-component bit depth for an FFmpeg pixel-format string. +/// +/// Returns 8 for the common 8-bit formats (yuv420p, yuv422p, nv12, rgb24, …), +/// and the explicit depth for higher-precision formats (yuv422p10le → 10, +/// yuv420p16le → 16, p010le → 10, rgb48le → 16, gray12le → 12). +/// +/// Unknown or null formats fall back to 8 — the conservative choice, so an +/// unrecognized format never produces a spurious "quality will be reduced" +/// warning. +int pixelFormatBitDepth(String? pixFmt) { + if (pixFmt == null || pixFmt.trim().isEmpty) return 8; + final f = pixFmt.trim().toLowerCase(); + + // Planar YUV/GBR/gray with an explicit per-component depth suffix: + // yuv422p10le, yuv420p16le, gbrp12le, gray10le, gray16be, … + final planar = RegExp(r'(?:p|gray|gbrp?)(\d{1,2})(?:le|be)?$').firstMatch(f); + if (planar != null) return int.parse(planar.group(1)!); + + // Semi-planar high-depth: p010le, p016le, p210le, p410le, p216le, … + // (the middle digit is the subsampling, the last two are the depth). + final semi = RegExp(r'^p\d(10|12|16)(?:le|be)?$').firstMatch(f); + if (semi != null) return int.parse(semi.group(1)!); + + // Packed high-depth RGB: rgb48/bgr48 and rgba64/bgra64 are 16-bit/component. + if (RegExp(r'(?:48|64)(?:le|be)?$').hasMatch(f) && + RegExp(r'^[argb]{3,4}').hasMatch(f)) { + return 16; + } + + // Everything else (yuv420p, yuv422p, yuv411p, nv12, gray, rgb24, …) is 8-bit. + return 8; +} + +/// Warning message when an enabled filter with a native [maxBitDepth] ceiling +/// will down-convert a higher-bit-depth [pixelFormat] source (a lossy 8-bit +/// round-trip), or null when no warning is warranted (pass off, no ceiling, +/// unknown format, or the source already fits). +String? filterBitDepthWarning({ + required String filterName, + required bool enabled, + int? maxBitDepth, + String? pixelFormat, +}) { + if (!enabled || maxBitDepth == null || pixelFormat == null) return null; + final depth = pixelFormatBitDepth(pixelFormat); + if (depth <= maxBitDepth) return null; + return '$filterName only processes in $maxBitDepth-bit. Your $depth-bit ' + 'source will be converted to $maxBitDepth-bit for this pass and back, ' + 'reducing colour precision.'; +} + +/// Warning message when converting chroma subsampling will also drop a +/// higher-bit-depth [pixelFormat] source to 8-bit on output, or null when not +/// applicable (not [converting], unknown format, or the source is 8-bit). +String? chromaConversionBitDepthWarning({ + required bool converting, + String? pixelFormat, +}) { + if (!converting || pixelFormat == null) return null; + final depth = pixelFormatBitDepth(pixelFormat); + if (depth <= 8) return null; + return 'Your $depth-bit source will be output as 8-bit when converting ' + 'chroma subsampling. Choose "Original" to keep the source\'s bit depth.'; +} diff --git a/app/lib/views/pass_settings/pass_settings_container.dart b/app/lib/views/pass_settings/pass_settings_container.dart index 53791d5..4ff9d9c 100644 --- a/app/lib/views/pass_settings/pass_settings_container.dart +++ b/app/lib/views/pass_settings/pass_settings_container.dart @@ -3,9 +3,12 @@ import 'package:provider/provider.dart'; import '../../models/dynamic_parameters.dart'; import '../../models/filter_registry.dart'; +import '../../models/filter_schema.dart'; import '../../models/processing_pipeline.dart'; import '../../services/whisper_addon_manager.dart'; +import '../../utils/pixel_format.dart'; import '../../viewmodels/main_viewmodel.dart'; +import '../../widgets/warning_banner.dart'; import '../settings/dynamic_filter_panel.dart'; import '../whisper_download_dialog.dart'; @@ -69,6 +72,7 @@ class PassSettingsContainer extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildOpenCLWarning(context, viewModel, filterId, params), + _buildBitDepthWarning(context, viewModel, schema, params), DynamicFilterPanelCompact( schema: schema, params: params, @@ -113,35 +117,28 @@ class PassSettingsContainer extends StatelessWidget { : 'No OpenCL device detected. OpenCL acceleration will fall back to ' 'CPU NNEDI3.'; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.warning_amber_rounded, - size: 20, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - message, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ), + return WarningBanner(message: message); + } + + /// Warning shown when an enabled filter can't process the source's bit depth + /// natively (schema `maxBitDepth`), so the worker down-converts to that depth + /// for the pass and restores it afterward — a lossy round-trip. E.g. DeScratch + /// is 8-bit only, so a 10-bit source loses precision through that pass. + /// Returns an empty widget when the pass is off or the source fits natively. + Widget _buildBitDepthWarning( + BuildContext context, + MainViewModel viewModel, + FilterSchema schema, + DynamicParameters params, + ) { + final message = filterBitDepthWarning( + filterName: schema.name, + enabled: params.enabled, + maxBitDepth: schema.maxBitDepth, + pixelFormat: viewModel.videoInfo?.pixelFormat, ); + if (message == null) return const SizedBox.shrink(); + return WarningBanner(message: message); } /// Handle parameter changes, with special model-download check for subtitles. diff --git a/app/lib/views/settings/settings_dialog.dart b/app/lib/views/settings/settings_dialog.dart index af09408..cc050c8 100644 --- a/app/lib/views/settings/settings_dialog.dart +++ b/app/lib/views/settings/settings_dialog.dart @@ -10,7 +10,9 @@ import '../../models/video_job.dart'; import '../../services/dependency_manager.dart'; import '../../services/hardware_encoder_detector.dart'; import '../../services/update_checker.dart'; +import '../../utils/pixel_format.dart'; import '../../viewmodels/main_viewmodel.dart'; +import '../../widgets/warning_banner.dart'; class SettingsDialog extends StatefulWidget { const SettingsDialog({super.key}); @@ -713,6 +715,7 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { .withValues(alpha: 0.6), ), ), + ..._buildChromaBitDepthWarning(viewModel, settings), ], ), ), @@ -758,6 +761,23 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> { ); } + /// Warning shown when converting chroma subsampling would also drop a + /// higher-bit-depth source to 8-bit on output. A chroma-subsampling + /// conversion forces an 8-bit output format (YUV420P8/YUV422P8), whereas + /// "Original" preserves the source format and its bit depth. Returns an empty + /// list when nothing needs saying (source is 8-bit, unknown, or "Original"). + List _buildChromaBitDepthWarning( + MainViewModel viewModel, + EncodingSettings settings, + ) { + final message = chromaConversionBitDepthWarning( + converting: settings.chromaSubsampling != ChromaSubsampling.original, + pixelFormat: viewModel.videoInfo?.pixelFormat, + ); + if (message == null) return const []; + return [const SizedBox(height: 12), WarningBanner(message: message)]; + } + /// 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.) diff --git a/app/lib/widgets/warning_banner.dart b/app/lib/widgets/warning_banner.dart new file mode 100644 index 0000000..2f42a56 --- /dev/null +++ b/app/lib/widgets/warning_banner.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +/// A compact, non-blocking advisory banner: a warning icon plus a message in +/// the theme's error-container colours. Used for "this will still work, but…" +/// notices such as missing OpenCL devices or bit-depth quality reductions. +class WarningBanner extends StatelessWidget { + const WarningBanner({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, + size: 20, color: scheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: scheme.onErrorContainer), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/test/pixel_format_test.dart b/app/test/pixel_format_test.dart new file mode 100644 index 0000000..3a9cbfd --- /dev/null +++ b/app/test/pixel_format_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:vapourbox/utils/pixel_format.dart'; + +void main() { + group('pixelFormatBitDepth', () { + test('8-bit formats', () { + for (final f in [ + 'yuv420p', 'yuv422p', 'yuv444p', 'yuv411p', 'yuv440p', + 'yuvj420p', 'yuvj422p', 'nv12', 'nv21', 'gray', 'rgb24', 'bgr24', + 'rgba', 'bgra', 'gbrp', + ]) { + expect(pixelFormatBitDepth(f), 8, reason: f); + } + }); + + test('10-bit formats', () { + for (final f in [ + 'yuv420p10le', 'yuv422p10le', 'yuv444p10le', 'yuv420p10be', + 'gbrp10le', 'gray10le', 'p010le', 'p210le', 'p410le', + ]) { + expect(pixelFormatBitDepth(f), 10, reason: f); + } + }); + + test('12-bit formats', () { + for (final f in ['yuv420p12le', 'yuv444p12le', 'gray12le', 'gbrp12le']) { + expect(pixelFormatBitDepth(f), 12, reason: f); + } + }); + + test('16-bit formats', () { + for (final f in [ + 'yuv420p16le', 'yuv444p16le', 'gray16le', 'gbrp16le', + 'p016le', 'p216le', 'rgb48le', 'bgr48le', 'rgba64le', 'bgra64be', + ]) { + expect(pixelFormatBitDepth(f), 16, reason: f); + } + }); + + test('null / empty / unknown fall back to 8 (no spurious warning)', () { + expect(pixelFormatBitDepth(null), 8); + expect(pixelFormatBitDepth(''), 8); + expect(pixelFormatBitDepth(' '), 8); + expect(pixelFormatBitDepth('unknown'), 8); + expect(pixelFormatBitDepth('some_future_format'), 8); + }); + + test('case and whitespace tolerant', () { + expect(pixelFormatBitDepth('YUV422P10LE'), 10); + expect(pixelFormatBitDepth(' yuv420p '), 8); + }); + + test('the ProRes 422 case from the bug report', () { + // The 10-bit source that triggered the IVTC fix. + expect(pixelFormatBitDepth('yuv422p10le'), 10); + }); + }); + + group('filterBitDepthWarning', () { + String? call({ + bool enabled = true, + int? maxBitDepth = 8, + String? pixelFormat = 'yuv422p10le', + }) => + filterBitDepthWarning( + filterName: 'DeScratch', + enabled: enabled, + maxBitDepth: maxBitDepth, + pixelFormat: pixelFormat, + ); + + test('warns for a 10-bit source on an 8-bit-only filter', () { + final msg = call(); + expect(msg, isNotNull); + expect(msg, contains('DeScratch')); + expect(msg, contains('8-bit')); + expect(msg, contains('10-bit')); + }); + + test('silent when the pass is disabled', () { + expect(call(enabled: false), isNull); + }); + + test('silent when the filter has no bit-depth ceiling', () { + expect(call(maxBitDepth: null), isNull); + }); + + test('silent for an 8-bit source', () { + expect(call(pixelFormat: 'yuv420p'), isNull); + }); + + test('silent when the source fits (depth == ceiling)', () { + expect(call(maxBitDepth: 10, pixelFormat: 'yuv422p10le'), isNull); + }); + + test('silent when the pixel format is unknown/null', () { + expect(call(pixelFormat: null), isNull); + }); + }); + + group('chromaConversionBitDepthWarning', () { + test('warns when converting a >8-bit source', () { + final msg = chromaConversionBitDepthWarning( + converting: true, pixelFormat: 'yuv422p10le'); + expect(msg, isNotNull); + expect(msg, contains('10-bit')); + expect(msg, contains('8-bit')); + }); + + test('silent when keeping Original (not converting)', () { + expect( + chromaConversionBitDepthWarning( + converting: false, pixelFormat: 'yuv422p10le'), + isNull); + }); + + test('silent for an 8-bit source even when converting', () { + expect( + chromaConversionBitDepthWarning( + converting: true, pixelFormat: 'yuv420p'), + isNull); + }); + + test('silent when the pixel format is null', () { + expect( + chromaConversionBitDepthWarning(converting: true, pixelFormat: null), + isNull); + }); + }); +}