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
170 changes: 170 additions & 0 deletions app/test/integration_frame_rate_combined_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/// Issue #148: Frame Rate combined with Noise Reduction and Sharpen.
///
/// The report was "black shadow-like patches" in the output once Denoise and
/// Sharpen were added to a frame rate conversion that was clean on its own.
/// It did not reproduce on macOS arm64 (docs/reports/issue-148-frame-rate-
/// combined), so this pins the measured behaviour rather than a fix: adding
/// the default Noise Reduction (SMDegrain) and Sharpen (LSFmod) must not make
/// the synthesized frames any more prone to dark patches than frame rate
/// conversion alone.
///
/// The metric is the share of luma pixels in a synthesized frame that are
/// darker than BOTH real frames either side of it by more than 24/255. An
/// interpolator has nothing to build such a pixel from, so it is the signature
/// of the reported defect. On the fixture it is ~0.001-0.005% whatever the
/// passes; a single black 32x32 block in this 320x240 clip would be 1.3% of
/// the frame it appears in. It is checked two ways: the mean over every
/// synthesized frame (within 0.05 percentage points of frame rate alone), and
/// the worst single frame (within 0.25), because a patch confined to one frame
/// adds only ~0.03 points to a mean over ~40 frames and would pass that alone.
///
/// Heavy (full encodes) — nightly, not the push gate.
@Tags(['heavy'])
library;

// ignore_for_file: avoid_print — these tests print diagnostics to the test log.

import 'dart:io';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';

import 'package:vapourbox/models/encoding_settings.dart';
import 'package:vapourbox/models/frame_rate_parameters.dart';
import 'package:vapourbox/models/noise_reduction_parameters.dart';
import 'package:vapourbox/models/processing_pipeline.dart';
import 'package:vapourbox/models/qtgmc_parameters.dart';
import 'package:vapourbox/models/sharpen_parameters.dart';
import 'package:vapourbox/models/video_job.dart';

import 'support/gpu_expectations.dart';
import 'support/worker_harness.dart';

String get _outDir => '${WorkerHarness.outputDir}/frame_rate_combined';

// small_clip.mp4: 320x240 yuv420p, 29.97 fps, real (film) motion.
const _w = 320, _h = 240, _frames = 40;

/// Percentage points the combined pipeline may exceed frame-rate-only by:
/// the mean over synthesized frames, and the worst single one. Measured
/// worst frames are ~0.02-0.05%, so 0.25 is ~5x above that spread and ~5x
/// below one black 32x32 block.
const _meanTolerance = 0.05, _worstTolerance = 0.25;

typedef _DarkShare = ({double mean, double worst});

VideoJob _job(String label, FrameRateMethod method, {required bool filters}) =>
VideoJob(
id: const Uuid().v4(),
inputPath: p.join(
WorkerHarness.repoRoot, 'Tests', 'TestResources', 'small_clip.mp4'),
outputPath: '$_outDir/$label.mkv',
processingPipeline: ProcessingPipeline(
deinterlace: const QTGMCParameters(enabled: false),
noiseReduction: NoiseReductionParameters(enabled: filters),
sharpen: SharpenParameters(enabled: filters),
frameRate: FrameRateParameters(
enabled: true, target: FrameRateTarget.ntsc5994, method: method),
),
// Lossless, so the measurement sees the pipeline, not the encoder.
encodingSettings: const EncodingSettings(
codec: VideoCodec.ffv1,
container: ContainerFormat.mkv,
audioMode: AudioMode.none,
),
inputWidth: _w,
inputHeight: _h,
inputPixelFormat: 'yuv420p',
inputFrameRate: 29.97,
totalFrames: _frames,
endFrame: _frames - 1,
);

/// Every frame of [path] as 8-bit luma.
Future<List<Uint8List>> _lumaFrames(String path) async {
final r = await Process.run(
WorkerHarness.ffmpegPath,
['-v', 'error', '-i', path, '-f', 'rawvideo', '-pix_fmt', 'gray', '-'],
stdoutEncoding: null,
environment: WorkerHarness.ffmpegEnv);
expect(r.exitCode, 0, reason: r.stderr.toString());
final bytes = r.stdout as List<int>;
const size = _w * _h;
return [
for (var o = 0; o + size <= bytes.length; o += size)
Uint8List.fromList(bytes.sublist(o, o + size)),
];
}

/// Over the synthesized frames (odd indices at 2x), the mean and the worst
/// percentage of pixels darker than both neighbouring real frames by more
/// than [t].
_DarkShare _darkPatchPercent(List<Uint8List> f, {int t = 24}) {
var sum = 0.0, worst = 0.0, n = 0;
for (var m = 1; m + 1 < f.length; m += 2) {
final a = f[m - 1], y = f[m], b = f[m + 1];
var dark = 0;
for (var i = 0; i < y.length; i++) {
final lo = a[i] < b[i] ? a[i] : b[i];
if (y[i] < lo - t) dark++;
}
final share = dark / y.length;
sum += share;
if (share > worst) worst = share;
n++;
}
expect(n, greaterThan(10), reason: 'too few synthesized frames to measure');
return (mean: 100 * sum / n, worst: 100 * worst);
}

Future<_DarkShare> _measure(String label, FrameRateMethod method,
{required bool filters}) async {
final r = await WorkerHarness.runJob(
_job(label, method, filters: filters).toJson(),
label: label,
timeout: const Duration(minutes: 20),
extraEnv: const {'VAPOURBOX_STALL_TIMEOUT_SECS': '1200'});
expect(r.success, isTrue, reason: r.error);
final frames = await _lumaFrames(r.outputPath!);
// 29.97 -> 59.94 doubles the count exactly.
expect(frames.length, _frames * 2);
final d = _darkPatchPercent(frames);
print(' $label: dark-patch mean ${d.mean.toStringAsFixed(4)}% '
'worst ${d.worst.toStringAsFixed(4)}%');
return d;
}

void main() {
setUpAll(() async {
await WorkerHarness.ensureReady();
Directory(_outDir).createSync(recursive: true);
});

for (final method in [FrameRateMethod.flowFps, FrameRateMethod.rife]) {
test('${method.name}: Noise Reduction + Sharpen add no dark patches',
() async {
if (method == FrameRateMethod.rife &&
!await requireGpuPlugin('--probe-rife')) {
return;
}
final alone =
await _measure('${method.name}_alone', method, filters: false);
final combined =
await _measure('${method.name}_nr_sharpen', method, filters: true);
expect(combined.mean, lessThan(alone.mean + _meanTolerance),
reason: 'synthesized frames gained dark patches once Noise '
'Reduction and Sharpen were added (issue #148): mean '
'${combined.mean.toStringAsFixed(4)}% vs '
'${alone.mean.toStringAsFixed(4)}% with frame rate conversion '
'alone');
expect(combined.worst, lessThan(alone.worst + _worstTolerance),
reason: 'a synthesized frame gained dark patches once Noise '
'Reduction and Sharpen were added (issue #148): worst frame '
'${combined.worst.toStringAsFixed(4)}% vs '
'${alone.worst.toStringAsFixed(4)}% with frame rate conversion '
'alone');
}, timeout: const Timeout(Duration(minutes: 45)));
}
}
26 changes: 26 additions & 0 deletions docs/ENGINEERING_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3230,6 +3230,32 @@ Untested: a real capture with a visible head-switching band (none in
one), and Windows/Linux (the analysis is plain Rust; only the decode is
platform-specific).

### Frame Rate + Noise Reduction + Sharpen: dark patches not reproduced (issue #148, 2026-11-10)

Full report with scripts:
[docs/reports/issue-148-frame-rate-combined/](reports/issue-148-frame-rate-combined/index.html).

- Reported: "black shadow-like patches" once Denoise and Sharpen were added to
a frame rate conversion that was clean alone. No clip, method or platform.
- Measured on macOS arm64 by exec'ing the template's own `{{#FRAME_RATE}}`
block after the filters: the share of synthesized-frame pixels darker than
**both** real neighbours by >24/255. 5 denoisers x 4 sharpeners x
FlowFPS/RIFE x 8/10-bit sit at 0.001-0.005% with or without the filters (a
single black 32x32 block in the 320x240 fixture is 1.3%). Fast pan and hard
cuts: same with and without.
- Suspects ruled out here: FlowFPS at 10/16-bit matches 8-bit to 0.14/255
mean (MVTools master); RIFE's RGBS input is already out of range from the
*source* (1.2% of samples < -0.05), sharpening barely moves it.
- No pass changed. A clamp before RIFE or a prefiltered FlowFPS search would
change every frame-rate job's output for no measured defect.
- Untested and most likely: MVTools **v24** (Windows, macOS x64) and RIFE on
non-Apple Vulkan drivers. `integration_frame_rate_combined_test.dart`
(heavy, nightly, all four platforms) asserts NR + Sharpen stay within 0.05
percentage points of frame rate alone on the mean over synthesized frames
**and** within 0.25 on the worst single frame, so it covers exactly those.
The mean alone would miss a one-frame patch (~0.03 points over 40 frames).
- `pal-sd-25.mov` is static colour bars: useless for anything motion-related.

### Testing a deps change before publishing

A PR that changes `deps/` has a chicken-and-egg problem: `ci-test.yml` and
Expand Down
1 change: 1 addition & 0 deletions docs/reports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ to it.

| Date | Report | Outcome |
|---|---|---|
| 2026-11-10 | [Issue #148: Frame Rate combined with Noise Reduction and Sharpen](issue-148-frame-rate-combined/index.html) | Not reproduced on macOS arm64 across 5 denoisers x 4 sharpeners x FlowFPS/RIFE x 8/10-bit; no pipeline change; nightly regression test added to cover Windows/MVTools v24 and other GPUs; reporter asked for a clip |
| 2026-11-09 | [Noise and halo scores for the pass list](2026-11-noise-halo-scores/index.html) | Built: `--detect-noise-halo` badges Noise Reduction at a motion-compensated noise sigma ≥ 3.5 and Dehalo at a two-sided edge overshoot ≥ 6% of the step; no false positives on 154 fixtures (clean max 2.06 / 1.48), catches white noise from sigma 4 and tape/grain noise from sigma 6, misses sharpening of already-soft edges. VCG's signalstats TOUT and halo ratio did not separate the fixtures; a spatial (Immerkær) noise score was rejected as blind to tape and grain noise |
| 2026-11-08 | [Crop & Resize: BT.601 → BT.709 on SD → HD, and Detect borders](2026-11-crop-detect-709/index.html) | Built: matrix-only 601 → 709 conversion when the output is HD (default Auto; patches within 0.67 levels of the source; a gamut conversion rejected: it moved 75% green by 55 levels on players without colour management); `--detect-crop` finds blanking, bars and the head-switching band exactly on real-picture fixtures, and matches a by-eye measurement of a DVB-T broadcast |
| 2026-11-08 | [Classical filters for deps 1.16.0: SangNom, vszip NLMeans/WNNM, vs-jetpack ports](2026-11-classical-filters/index.html) | Built: SangNom AA (sharpest; 8/10/16-bit parity; slowest of the three at 1080p), NLMeans (CPU) = KNLMeansCL's output without OpenCL, the IVTC fade fix (field gap 4.12 → 0.06), vine dehalo and mpeg2stinx (both advanced; mpeg2stinx lost PSNR on every fixture). Not built: WNNM (luma ≈ BM3D, chroma 2.6–7.2 dB worse; fast, but NLMeans (CPU) covers that with better chroma); havsfunc's santiag `sangnom` type stays off (calls a double-rate SangNom) |
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading