Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/filters/core/descratch.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"category": "cleanup",
"icon": "healing",
"order": 2,
"maxBitDepth": 8,

"dependencies": {
"vs_plugins": ["libdescratch.dll"]
Expand Down
7 changes: 7 additions & 0 deletions app/lib/models/filter_schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -391,6 +397,7 @@ class FilterSchema {
this.category,
this.icon,
this.order = 0,
this.maxBitDepth,
this.dependencies,
required this.methods,
required this.parameters,
Expand Down
67 changes: 67 additions & 0 deletions app/lib/utils/pixel_format.dart
Original file line number Diff line number Diff line change
@@ -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.';
}
53 changes: 25 additions & 28 deletions app/lib/views/pass_settings/pass_settings_container.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions app/lib/views/settings/settings_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -713,6 +715,7 @@ class _OutputSettingsTabState extends State<_OutputSettingsTab> {
.withValues(alpha: 0.6),
),
),
..._buildChromaBitDepthWarning(viewModel, settings),
],
),
),
Expand Down Expand Up @@ -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<Widget> _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.)
Expand Down
42 changes: 42 additions & 0 deletions app/lib/widgets/warning_banner.dart
Original file line number Diff line number Diff line change
@@ -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),
),
),
],
),
),
);
}
}
130 changes: 130 additions & 0 deletions app/test/pixel_format_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading