diff --git a/Tests/TestResources/pal-dvbt-fieldcoded-25i.ts b/Tests/TestResources/pal-dvbt-fieldcoded-25i.ts new file mode 100644 index 0000000..a077ec3 Binary files /dev/null and b/Tests/TestResources/pal-dvbt-fieldcoded-25i.ts differ diff --git a/app/lib/services/field_order_detector.dart b/app/lib/services/field_order_detector.dart index 44b5f30..e00061a 100644 --- a/app/lib/services/field_order_detector.dart +++ b/app/lib/services/field_order_detector.dart @@ -74,7 +74,10 @@ class FieldOrderDetector { final width = videoStream['width'] as int?; final height = videoStream['height'] as int?; - final frameRate = _parseFrameRate(videoStream['r_frame_rate'] as String?); + final frameRate = selectFrameRate( + _parseFrameRate(videoStream['r_frame_rate'] as String?), + _parseFrameRate(videoStream['avg_frame_rate'] as String?), + ); final duration = _parseDuration(format?['duration'] as String?); final frameCount = _parseFrameCount(videoStream['nb_frames'] as String?); final codec = videoStream['codec_name'] as String?; @@ -432,6 +435,31 @@ class FieldOrderDetector { // UTILITIES // ============================================================================ + /// Picks the true picture frame rate from the container's `r_frame_rate` + /// (the stream's base/tick rate) and `avg_frame_rate` (the average displayed + /// rate). + /// + /// Field-coded interlaced H.264 — e.g. a DVB-T PAL rip stored as "separated + /// fields" — reports `r_frame_rate` as the *field* rate (50 for 25fps PAL, + /// 59.94 for 29.97fps NTSC) while `avg_frame_rate` reports the real picture + /// rate (25 / 29.97). Trusting the field rate makes VapourBox think the + /// source is 50p, so QTGMC "Double Rate" deinterlacing targets 100fps and + /// the pipe-source clip is built at the wrong rate (issue #13). + /// + /// When `avg` is roughly half of `r` (the field-coded signature), trust + /// `avg`. Otherwise keep `r` — it's the reliable rate for CFR progressive + /// and frame-coded interlaced content, and `avg` can be 0/0 or skewed by + /// VFR/duration rounding. + static double? selectFrameRate(double? rFrameRate, double? avgFrameRate) { + if (rFrameRate == null || rFrameRate <= 0) return avgFrameRate; + if (avgFrameRate == null || avgFrameRate <= 0) return rFrameRate; + final ratio = rFrameRate / avgFrameRate; + if (ratio > 1.8 && ratio < 2.2) { + return avgFrameRate; + } + return rFrameRate; + } + double? _parseFrameRate(String? rateStr) { if (rateStr == null) return null; diff --git a/app/lib/services/preview_generator.dart b/app/lib/services/preview_generator.dart index 003fb99..d742eae 100644 --- a/app/lib/services/preview_generator.dart +++ b/app/lib/services/preview_generator.dart @@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart'; import '../models/encoding_settings.dart'; import '../models/processing_pipeline.dart'; import '../models/video_job.dart'; +import 'field_order_detector.dart'; import 'tool_locator.dart'; /// Service for generating video thumbnails and processed previews. @@ -393,17 +394,14 @@ class PreviewGenerator { // Parse duration _duration = double.tryParse(format?['duration']?.toString() ?? '') ?? 0; - // Parse frame rate - final rFrameRate = videoStream['r_frame_rate'] as String?; - if (rFrameRate != null) { - final parts = rFrameRate.split('/'); - if (parts.length == 2) { - final num = double.tryParse(parts[0]); - final den = double.tryParse(parts[1]); - if (num != null && den != null && den != 0) { - _frameRate = num / den; - } - } + // Parse frame rate. Prefer the picture rate (avg_frame_rate) over the + // field/base rate (r_frame_rate) for field-coded interlaced sources — see + // FieldOrderDetector.selectFrameRate (issue #13). + final rFrameRate = _parseRational(videoStream['r_frame_rate'] as String?); + final avgFrameRate = _parseRational(videoStream['avg_frame_rate'] as String?); + final selected = FieldOrderDetector.selectFrameRate(rFrameRate, avgFrameRate); + if (selected != null && selected > 0) { + _frameRate = selected; } // Parse frame count @@ -416,6 +414,22 @@ class PreviewGenerator { _pixelFormat = videoStream['pix_fmt'] as String? ?? 'yuv420p'; } + /// Parses an ffprobe rational frame-rate string (e.g. "25/1") to a double. + /// Returns null for null/"0/0"/unparseable input. + double? _parseRational(String? rateStr) { + if (rateStr == null) return null; + final parts = rateStr.split('/'); + if (parts.length == 2) { + final num = double.tryParse(parts[0]); + final den = double.tryParse(parts[1]); + if (num != null && den != null && den != 0) { + return num / den; + } + return null; + } + return double.tryParse(rateStr); + } + Future> _extractThumbnails(String videoPath, int count) async { final thumbnails = []; final interval = _duration / count; diff --git a/app/test/scan_type_detection_test.dart b/app/test/scan_type_detection_test.dart index c0a4c44..a22d925 100644 --- a/app/test/scan_type_detection_test.dart +++ b/app/test/scan_type_detection_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as path; +import 'package:vapourbox/models/video_job.dart'; import 'package:vapourbox/services/field_order_detector.dart'; import 'package:vapourbox/services/tool_locator.dart'; @@ -35,6 +36,35 @@ void main() { detector = FieldOrderDetector(); }); + group('selectFrameRate (issue #13)', () { + test('field-coded interlaced PAL (r=50, avg=25) → picture rate 25', () { + // DVB-T PAL rip stored as separated fields reports r_frame_rate as the + // field rate. Must resolve to 25 so QTGMC double-rate targets 50p, not 100. + expect(FieldOrderDetector.selectFrameRate(50.0, 25.0), 25.0); + }); + + test('field-coded interlaced NTSC (r=59.94, avg=29.97) → 29.97', () { + expect(FieldOrderDetector.selectFrameRate(59.94, 29.97), 29.97); + }); + + test('true 50p progressive (r=50, avg=50) keeps 50', () { + expect(FieldOrderDetector.selectFrameRate(50.0, 50.0), 50.0); + }); + + test('frame-coded 25fps (r=25, avg=25) keeps 25', () { + expect(FieldOrderDetector.selectFrameRate(25.0, 25.0), 25.0); + }); + + test('missing/invalid avg falls back to r', () { + expect(FieldOrderDetector.selectFrameRate(25.0, null), 25.0); + expect(FieldOrderDetector.selectFrameRate(50.0, 0.0), 50.0); + }); + + test('missing r falls back to avg', () { + expect(FieldOrderDetector.selectFrameRate(null, 25.0), 25.0); + }); + }); + group('Scan Type Detection', () { test('soft_telecine_test.mkv is detected as soft telecine', () async { final videoPath = path.join(testResourcesDir, 'soft_telecine_test.mkv'); @@ -65,6 +95,31 @@ void main() { '— hard telecine requiring IVTC'); }); + test( + 'pal-dvbt-fieldcoded-25i.ts reports 25fps picture rate, not 50 (issue #13)', + () async { + // DVB-T2 PAL rip, H.264 interlaced stored as separated fields. ffprobe + // reports r_frame_rate=50/1 (field rate) but avg_frame_rate=25/1 + // (picture rate). VapourBox must use 25 — otherwise QTGMC double-rate + // targets 100fps and the pipe-source clip is built at the wrong rate. + final videoPath = + path.join(testResourcesDir, 'pal-dvbt-fieldcoded-25i.ts'); + if (!await File(videoPath).exists()) { + markTestSkipped('pal-dvbt-fieldcoded-25i.ts not found'); + return; + } + + final info = await detector.getVideoInfo(videoPath); + expect(info, isNotNull); + expect(info!.frameRate, closeTo(25.0, 0.01), + reason: 'field-coded interlaced source must resolve to the 25fps ' + 'picture rate, not the 50fps field rate (issue #13)'); + expect(info.scanType, ScanType.interlaced, + reason: 'idet shows TFF interlaced frames without repeated fields'); + expect(info.fieldOrder, FieldOrder.topFieldFirst, + reason: 'field_order=tt → top field first'); + }); + test('interlaced_test.avi is detected as interlaced', () async { final videoPath = path.join(testResourcesDir, 'interlaced_test.avi'); if (!await File(videoPath).exists()) {