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
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,38 @@ Three things decide the shape of the output, and they live in different places:
else so a typo falls back to the source's own aspect rather than reaching ffmpeg
as a broken filter argument.

**The preview has to show the same shape (issue #149).** The preview PNG carries
no aspect, so the app used to draw the processed frame at the **source's**
display aspect. Anything a pass reshaped (a 720x576 4:3 source resized and
padded to 1920x1080, say) came out squashed. Three invariants now hold:

- **One decision, two consumers.** `ProcessingPipeline::output_aspect(input_sar)`
(rotation-adjusted SAR → `aspect_declaration`) is used by the encode's
`setsar`/`setdar` **and** by the preview. The preview reports it on its
`PREVIEW_INFO:` stderr line as `sar=N/D` or `dar=N/D` (nothing for square
pixels). `PreviewFrameInfo.displayAspectFor` turns that plus the PNG's size into
the shape to draw.
- **Each side has its own aspect.** `BeforeAfterComparisonWidget` takes
`beforeAspectRatio` (source) and `afterAspectRatio` (processed). 1:1 is
measured against the processed one. The processed aspect is stored on the
`QueueItem` next to the image (`processedPreviewAspect`), not read from
`previewFrameInfo`, which goes null as soon as the scrubber moves.
- **The preview job must carry `inputSar`.** `{{SOURCE_SAR}}` falls back to 1
without it, so square-pixel fitting previewed a different frame from the
one encoded (1350 vs 1440 wide in the #149 setup). `PreviewGenerator` reads it
through the same `FieldOrderDetector.normalizeSar` as the encode job.

The panel reads both sides' aspects from the view model
(`sourceDisplayAspect`, `comparisonAfterAspect`), not from its own arithmetic,
so the wiring is testable without a worker (`debugAcceptProcessedPreview`).

Covered by `test_185` (Rust), `preview_frame_info_test`, `preview_aspect_test`
(including the view-model wiring: an unreshaped anamorphic source keeps its
shape, the aspect survives a scrubber move), `before_after_comparison_test`, and
the heavy `preview aspect (issue #149)` group in `integration_aspect_ratio_test.dart`,
which compares the preview with the encode for square, preserve, forced DAR,
denoise-only anamorphic and rotated anamorphic jobs.

### Borders (issue #86)

Two ways to add bars, sharing one step at the **very end of both templates**
Expand Down
61 changes: 54 additions & 7 deletions app/lib/models/preview_frame_info.dart
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
import 'dart:ui' show Size;

/// What the worker says it rendered for a preview: its `PREVIEW_INFO:` line.
///
/// A source frame can have several output frames — two under a ×2 frame rate
/// or double-rate deinterlacing, two or three at 24 → 60. [subFrame] is which
/// of them this is, and [generated] whether it was made by interpolation
/// rather than taken from a source frame (issue #129).
///
/// The line also carries the aspect the encode will declare for the frame —
/// `sar=16/15` or `dar=16/9`, absent for square pixels — because the PNG
/// carries none (issue #149).
class PreviewFrameInfo {
final int frame;
final int subFrame;
final int subFrames;
final bool generated;

/// The sample aspect ratio the encode stamps on the output, or null for
/// square pixels.
final double? outputSar;

/// A forced display aspect the encode stamps instead of a SAR, or null.
final double? outputDar;

const PreviewFrameInfo({
required this.frame,
this.subFrame = 0,
this.subFrames = 1,
this.generated = false,
this.outputSar,
this.outputDar,
});

/// Whether there is more than one output frame to step through.
bool get hasSubFrames => subFrames > 1;

/// The shape the rendered frame has on screen, given its stored size: the
/// display aspect the encode will have. A worker that reports no aspect
/// reads as square pixels — the frame's own shape.
double displayAspectFor(Size pixels) =>
outputDar ?? pixels.width / pixels.height * (outputSar ?? 1.0);

/// The last `PREVIEW_INFO:` line in a worker's stderr, or null if there is
/// none. A worker from before sub-frames reports only `frame=`, which reads
/// as a single real frame.
Expand All @@ -28,20 +49,46 @@ class PreviewFrameInfo {
.split('\n')
.where((l) => l.trimLeft().startsWith('PREVIEW_INFO:'));
if (lines.isEmpty) return null;
final fields = <String, int>{};
final fields = <String, String>{};
for (final pair in lines.last.trim().substring('PREVIEW_INFO:'.length).split(' ')) {
final kv = pair.split('=');
final value = kv.length == 2 ? int.tryParse(kv[1]) : null;
if (value != null) fields[kv[0]] = value;
if (kv.length == 2) fields[kv[0]] = kv[1];
}
final frame = fields['frame'];
int? integer(String key) {
final v = fields[key];
return v == null ? null : int.tryParse(v);
}

final frame = integer('frame');
if (frame == null) return null;
final subFrames = (fields['sub_frames'] ?? 1).clamp(1, 1 << 20);
final subFrames = (integer('sub_frames') ?? 1).clamp(1, 1 << 20);
return PreviewFrameInfo(
frame: frame,
subFrame: (fields['sub_frame'] ?? 0).clamp(0, subFrames - 1),
subFrame: (integer('sub_frame') ?? 0).clamp(0, subFrames - 1),
subFrames: subFrames,
generated: fields['generated'] == 1,
generated: integer('generated') == 1,
outputSar: parseRatio(fields['sar']),
outputDar: parseRatio(fields['dar']),
);
}

/// A ratio written `16/9`, `16:9` or `1.7778`, or null for anything that is
/// not a finite positive number — so a malformed token falls back to the
/// frame's own shape instead of drawing it zero-wide.
static double? parseRatio(String? text) {
if (text == null) return null;
final t = text.trim();
if (t.isEmpty) return null;
final parts = t.split(RegExp('[:/]'));
double? ratio;
if (parts.length == 2) {
final num = double.tryParse(parts[0].trim());
final den = double.tryParse(parts[1].trim());
if (num == null || den == null || den == 0) return null;
ratio = num / den;
} else if (parts.length == 1) {
ratio = double.tryParse(t);
}
return ratio != null && ratio.isFinite && ratio > 0 ? ratio : null;
}
}
10 changes: 10 additions & 0 deletions app/lib/models/queue_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ class QueueItem {
double scrubberPosition;
Uint8List? currentFrame;
Uint8List? processedPreview;

/// The display aspect [processedPreview] is drawn at: the shape the encode
/// will have, from the aspect the worker reported for it. Kept with the
/// image rather than read from the frame info, which is dropped as soon as
/// the scrubber moves while this image is still on screen (issue #149).
double? processedPreviewAspect;
double timelineZoom;
double timelineViewStart;

Expand Down Expand Up @@ -96,6 +102,7 @@ class QueueItem {
this.scrubberPosition = 0.0,
this.currentFrame,
this.processedPreview,
this.processedPreviewAspect,
this.timelineZoom = 1.0,
this.timelineViewStart = 0.0,
this.thumbnailRangeStart = 0.0,
Expand Down Expand Up @@ -182,6 +189,7 @@ class QueueItem {
double? scrubberPosition,
Uint8List? currentFrame,
Uint8List? processedPreview,
double? processedPreviewAspect,
double? timelineZoom,
double? timelineViewStart,
double? thumbnailRangeStart,
Expand All @@ -200,6 +208,8 @@ class QueueItem {
scrubberPosition: scrubberPosition ?? this.scrubberPosition,
currentFrame: currentFrame ?? this.currentFrame,
processedPreview: processedPreview ?? this.processedPreview,
processedPreviewAspect:
processedPreviewAspect ?? this.processedPreviewAspect,
timelineZoom: timelineZoom ?? this.timelineZoom,
timelineViewStart: timelineViewStart ?? this.timelineViewStart,
thumbnailRangeStart: thumbnailRangeStart ?? this.thumbnailRangeStart,
Expand Down
16 changes: 14 additions & 2 deletions app/lib/services/field_order_detector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,7 @@ class FieldOrderDetector {
final pixelFormat = videoStream['pix_fmt'] as String?;

// Extract sample aspect ratio (SAR) — null if square pixels or unavailable
final rawSar = videoStream['sample_aspect_ratio'] as String?;
final sar = (rawSar != null && rawSar != 'N/A' && rawSar != '1:1') ? rawSar : null;
final sar = normalizeSar(videoStream['sample_aspect_ratio'] as String?);

// Get metadata field order for context
final metadataFieldOrder = videoStream['field_order'] as String?;
Expand Down Expand Up @@ -447,6 +446,19 @@ class FieldOrderDetector {
// UTILITIES
// ============================================================================

/// ffprobe's `sample_aspect_ratio` as the job carries it: null for square
/// pixels or when there is none to read (`N/A`, `1:1`, and `0:1`, which is
/// ffprobe's "unknown"). The encode and the preview jobs both go through
/// this, so the preview fits the frame with the same SAR as the encode
/// (issue #149).
static String? normalizeSar(String? raw) {
final sar = raw?.trim();
if (sar == null || sar.isEmpty || sar == 'N/A' || sar == '1:1') return null;
final parts = sar.split(':');
if (parts.length == 2 && (double.tryParse(parts[0]) ?? 0) <= 0) return null;
return sar;
}

/// 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).
Expand Down
9 changes: 9 additions & 0 deletions app/lib/services/preview_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ class PreviewGenerator {
String? _colorTransfer;
String? _colorRange;

/// Source sample aspect ratio ("16:15"), null for square pixels. Sent with
/// the preview job so the script fits the frame by the same display aspect
/// as the encode — without it `{{SOURCE_SAR}}` fell back to 1, and Square
/// pixel aspect previewed a different frame from the one encoded (#149).
String? _sar;

/// The display tone-map for the loaded source, or null for SDR. Applied to
/// every picture of the source the app draws itself.
String? get _toneMap => HdrDisplay.toneMapFilter(
Expand Down Expand Up @@ -317,6 +323,7 @@ class PreviewGenerator {
inputColorPrimaries: _colorPrimaries,
inputColorTransfer: _colorTransfer,
inputColorRange: _colorRange,
inputSar: _sar,
// Not a trim: the worker's preview only uses it as the point the
// encode's retime starts from, so a generated frame is previewed at
// the moment the encode makes it.
Expand Down Expand Up @@ -565,6 +572,8 @@ class PreviewGenerator {
_colorPrimaries = colorTag(videoStream, 'color_primaries');
_colorTransfer = colorTag(videoStream, 'color_transfer');
_colorRange = colorTag(videoStream, 'color_range');
_sar = FieldOrderDetector.normalizeSar(
videoStream['sample_aspect_ratio'] as String?);
}

/// Parses an ffprobe rational frame-rate string (e.g. "25/1") to a double.
Expand Down
63 changes: 60 additions & 3 deletions app/lib/viewmodels/main_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import '../services/disc_detector.dart';
import '../services/dvd_service.dart';
import '../services/field_order_detector.dart';
import '../services/frame_math.dart';
import '../services/image_dimensions.dart';
import '../services/timeline_math.dart';
import '../services/preset_service.dart';
import '../services/preview_generator.dart';
Expand All @@ -34,6 +35,18 @@ import '../services/system_shutdown.dart';
import '../services/worker_manager.dart';
import '../widgets/comparison_zoom_controller.dart';

/// The display aspect a processed preview is drawn at: its stored size with
/// the aspect the worker reported for it applied, i.e. the shape the encode
/// will have. Null — draw it at its own pixel shape — when there is no image,
/// its size can't be read, or the worker reported nothing (issue #149).
@visibleForTesting
double? processedDisplayAspect(Uint8List? image, PreviewFrameInfo? info) {
if (image == null || info == null) return null;
final pixels = encodedImageSize(image);
if (pixels == null || pixels.isEmpty) return null;
return info.displayAspectFor(pixels);
}

/// Main view model managing application state.
class MainViewModel extends ChangeNotifier {
/// Runs jobs. Injectable so the queue state machine can be driven from a
Expand Down Expand Up @@ -245,7 +258,8 @@ class MainViewModel extends ChangeNotifier {

/// Put an analysed video in the queue and select it, for widget tests.
@visibleForTesting
void debugAddQueueItemForTest(String path, {required int width, required int height}) {
void debugAddQueueItemForTest(String path,
{required int width, required int height, String? sar}) {
final item = QueueItem(
inputPath: path,
outputPath: '$path.out.mkv',
Expand All @@ -258,6 +272,7 @@ class MainViewModel extends ChangeNotifier {
codec: 'h264',
pixelFormat: 'yuv420p',
hasAudio: false,
sar: sar,
),
);
_queue.add(item);
Expand Down Expand Up @@ -672,6 +687,27 @@ class MainViewModel extends ChangeNotifier {
List<Uint8List> get thumbnails => selectedItem?.thumbnails ?? [];
Uint8List? get currentFrame => selectedItem?.currentFrame;
Uint8List? get processedPreview => selectedItem?.processedPreview;

/// The display aspect of [processedPreview] — the shape the encode will
/// have — or null when it is drawn at its own pixel shape.
double? get processedPreviewAspect => selectedItem?.processedPreviewAspect;

/// The source's display aspect, `width / height * SAR`, for the "before"
/// frame; null for square pixels or an unreadable SAR.
double? get sourceDisplayAspect {
final info = videoInfo;
if (info == null || info.height <= 0) return null;
final sar = PreviewFrameInfo.parseRatio(info.sar);
if (sar == null) return null;
return info.width / info.height * sar;
}

/// The aspect the "after" side of the comparison is drawn at: the processed
/// frame's own (the encode's shape, which a resize, pad, crop, rotation or
/// squaring makes different from the source's — issue #149), or, until a
/// processed frame exists, the source's, since the source frame stands in.
double? get comparisonAfterAspect =>
processedPreview != null ? processedPreviewAspect : sourceDisplayAspect;
double get scrubberPosition => selectedItem?.scrubberPosition ?? 0.0;
bool get isGeneratingPreview => _isGeneratingPreview;
double get videoDuration => _previewGenerator.duration;
Expand Down Expand Up @@ -1417,8 +1453,8 @@ class MainViewModel extends ChangeNotifier {
);

if (generation == _previewGeneration && !cancelToken.isCancelled) {
item.processedPreview = preview;
_previewFrameInfo = preview == null ? null : _previewGenerator.lastFrameInfo;
_acceptProcessedPreview(
item, preview, preview == null ? null : _previewGenerator.lastFrameInfo);
// The worker clamps to the frames there are, e.g. after the frame
// rate pass is switched off; step on from what it showed.
if (_previewFrameInfo != null) _previewSubFrame = _previewFrameInfo!.subFrame;
Expand All @@ -1435,6 +1471,27 @@ class MainViewModel extends ChangeNotifier {
}
}

/// Store a rendered preview on [item] with the display aspect it is drawn
/// at. The aspect is kept with the image because [previewFrameInfo] goes
/// null as soon as the scrubber moves, while this image stays on screen
/// until the next one replaces it (issue #149).
void _acceptProcessedPreview(
QueueItem item, Uint8List? preview, PreviewFrameInfo? info) {
item.processedPreview = preview;
_previewFrameInfo = info;
item.processedPreviewAspect = processedDisplayAspect(preview, info);
}

/// Deliver a preview as the worker would have, for tests of what the
/// comparison is drawn at.
@visibleForTesting
void debugAcceptProcessedPreview(Uint8List? preview, PreviewFrameInfo? info) {
final item = selectedItem;
if (item == null) return;
_acceptProcessedPreview(item, preview, info);
notifyListeners();
}

/// Cancels any ongoing preview generation.
void _cancelPreviewGeneration() {
_previewDebounceTimer?.cancel();
Expand Down
22 changes: 4 additions & 18 deletions app/lib/views/preview_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,23 +47,6 @@ class _PreviewPanelState extends State<PreviewPanel> {
}

Widget _buildPreviewComparison(BuildContext context, MainViewModel viewModel) {
// Compute display aspect ratio for SAR correction.
// DAR = (width / height) * (sarNum / sarDen)
// Only needed when SAR is non-square (e.g. anamorphic DVD 32:27).
double? displayAspectRatio;
final videoInfo = viewModel.videoInfo;
if (videoInfo != null && videoInfo.sar != null) {
final parts = videoInfo.sar!.split(':');
if (parts.length == 2) {
final sarNum = double.tryParse(parts[0]);
final sarDen = double.tryParse(parts[1]);
if (sarNum != null && sarDen != null && sarDen > 0) {
displayAspectRatio =
(videoInfo.width / videoInfo.height) * (sarNum / sarDen);
}
}
}

// The scope reads the processed preview, falling back to the source frame
// before any pass has rendered — that is what the user is looking at.
final scopeImage = viewModel.processedPreview ?? viewModel.currentFrame;
Expand All @@ -78,7 +61,10 @@ class _PreviewPanelState extends State<PreviewPanel> {
afterImage: scopeImage,
isBeforeLoading: viewModel.isAnalyzing,
isAfterLoading: viewModel.isGeneratingPreview,
displayAspectRatio: displayAspectRatio,
// Each side at its own shape: the processed frame at the
// encode's, not the source's (issue #149).
beforeAspectRatio: viewModel.sourceDisplayAspect,
afterAspectRatio: viewModel.comparisonAfterAspect,
zoomController: viewModel.previewZoom,
),
),
Expand Down
Loading
Loading