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
Binary file added Tests/TestResources/pal-dvbt-fieldcoded-25i.ts
Binary file not shown.
30 changes: 29 additions & 1 deletion app/lib/services/field_order_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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;

Expand Down
36 changes: 25 additions & 11 deletions app/lib/services/preview_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<List<Uint8List>> _extractThumbnails(String videoPath, int count) async {
final thumbnails = <Uint8List>[];
final interval = _duration / count;
Expand Down
55 changes: 55 additions & 0 deletions app/test/scan_type_detection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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()) {
Expand Down
Loading