diff --git a/CLAUDE.md b/CLAUDE.md
index 17f8fd4..0665bd8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1299,6 +1299,13 @@ asserted in `shutdown_when_done_test.dart`:
exact vertically (stretched horizontally by SAR). The shortcuts reach the
widget's zoom through `MainViewModel.previewZoom`
(`ComparisonZoomController`); `before_after_comparison_test.dart` covers it.
+- **Preview view mode (Original / Split / Processed)** is
+ `MainViewModel.previewViewMode` (shortcuts B / S / P), passed into the
+ controlled `BeforeAfterComparisonWidget`, so it survives scrubber moves and
+ new previews. The divider spans the full 0–1 of the panel (it was 5–95%,
+ which hid a typical left crop); line and handle are drawn inset at the
+ edges. Each mode draws only its side at its own aspect, and 1:1 measures the
+ frame on screen (Original: the source; otherwise the processed frame).
- **Themes** are `AppTheme` in `theme_service.dart`, persisted as `appTheme`
and provided above the MaterialApp. Theme-specific decoration beyond
`ColorScheme` (Pride's stripe) goes through the `AppDecorations` theme
diff --git a/README.md b/README.md
index da876cd..9896443 100644
--- a/README.md
+++ b/README.md
@@ -164,6 +164,7 @@ Every filter explains in place what it does and when to use it. Where two filter
Preview, timeline and trimming
- **Click** a thumbnail to jump there, or **drag** to scrub. The transport buttons step a frame at a time or jump to either end.
+- **Original / Split / Processed** (B / S / P) above the preview show either frame whole or both side by side; in Split, drag the divider all the way to either edge.
- Zoom the preview with the **mouse wheel** or **pinch**; **double-click** resets it. **1:1** shows actual pixels, the way to judge fine detail, and **Fit** shows the whole frame.
- The **mouse wheel** or **pinch** also zooms the timeline around the cursor, and **Shift+wheel** or a **two-finger swipe** pans it.
- **Set In** / **Set Out** process only part of the source, handy for trying settings on a short section.
@@ -185,6 +186,7 @@ Every button with a shortcut shows it in its tooltip, and **?** lists them all.
| Go to in / out point | ⇧I / ⇧O | Shift+I / Shift+O |
| Clear in and out points | ⌥X | Alt+X |
| Preview at actual pixels (1:1) / fit | ⌘1 / ⌘0 | Ctrl+1 / Ctrl+0 |
+| Show original / split / processed | B / S / P | B / S / P |
| Zoom timeline in / out / to fit | = / - / \ | = / - / \ |
| Add videos | ⌘O | Ctrl+O |
| Start processing | ⌘↩ | Ctrl+Enter |
diff --git a/app/lib/models/app_shortcut.dart b/app/lib/models/app_shortcut.dart
index 1f60bfa..e543dd1 100644
--- a/app/lib/models/app_shortcut.dart
+++ b/app/lib/models/app_shortcut.dart
@@ -89,6 +89,21 @@ enum AppShortcut {
alternates: [LogicalKeyboardKey.numpad0],
primary: true,
),
+ previewOriginal(
+ 'Show original (before)',
+ AppShortcutGroup.preview,
+ LogicalKeyboardKey.keyB,
+ ),
+ previewSplit(
+ 'Split original / processed',
+ AppShortcutGroup.preview,
+ LogicalKeyboardKey.keyS,
+ ),
+ previewProcessed(
+ 'Show processed',
+ AppShortcutGroup.preview,
+ LogicalKeyboardKey.keyP,
+ ),
zoomIn(
'Zoom in',
AppShortcutGroup.zoom,
diff --git a/app/lib/viewmodels/main_viewmodel.dart b/app/lib/viewmodels/main_viewmodel.dart
index 0e4a90b..3e36eb9 100644
--- a/app/lib/viewmodels/main_viewmodel.dart
+++ b/app/lib/viewmodels/main_viewmodel.dart
@@ -34,6 +34,7 @@ import '../services/failure_log_writer.dart';
import '../services/job_runner.dart';
import '../services/system_shutdown.dart';
import '../services/worker_manager.dart';
+import '../widgets/comparison_view_mode.dart';
import '../widgets/comparison_zoom_controller.dart';
/// The display aspect a processed preview is drawn at: its stored size with
@@ -151,6 +152,18 @@ class MainViewModel extends ChangeNotifier {
/// view state held by the comparison; this lets the shortcuts reach it.
final ComparisonZoomController previewZoom = ComparisonZoomController();
+ /// Original / Split / Processed on the before/after preview. Held here, not
+ /// in the comparison or on a queue item, so it survives scrubber moves, new
+ /// previews and switching videos.
+ ComparisonViewMode _previewViewMode = ComparisonViewMode.split;
+ ComparisonViewMode get previewViewMode => _previewViewMode;
+
+ void setPreviewViewMode(ComparisonViewMode mode) {
+ if (mode == _previewViewMode) return;
+ _previewViewMode = mode;
+ notifyListeners();
+ }
+
// Shut down when the queue finishes (issue #123). Armed for one run only:
// _stopProcessing disarms it, so a run the user cancelled can't leave it
// armed, invisibly, for the next one.
diff --git a/app/lib/views/main_window.dart b/app/lib/views/main_window.dart
index ae6cd2f..f6a1fa7 100644
--- a/app/lib/views/main_window.dart
+++ b/app/lib/views/main_window.dart
@@ -16,6 +16,7 @@ import '../services/theme_service.dart';
import '../models/app_shortcut.dart';
import '../widgets/app_shortcut_scope.dart';
import '../widgets/dynamic_range_badge.dart';
+import '../widgets/comparison_view_mode.dart';
import 'keyboard_shortcuts_dialog.dart';
import 'video_file_picker.dart';
import '../viewmodels/main_viewmodel.dart';
@@ -112,6 +113,9 @@ class MainWindow extends StatelessWidget {
AppShortcut.previewActualSize:
whenPreviewing(viewModel.previewZoom.showActualSize),
AppShortcut.previewFit: whenPreviewing(viewModel.previewZoom.fit),
+ for (final mode in ComparisonViewMode.values)
+ mode.shortcut:
+ whenPreviewing(() => viewModel.setPreviewViewMode(mode)),
AppShortcut.zoomIn: whenPreviewing(viewModel.zoomIn),
AppShortcut.zoomOut: whenPreviewing(viewModel.zoomOut),
AppShortcut.zoomToFit: whenPreviewing(viewModel.resetTimelineZoom),
diff --git a/app/lib/views/preview_panel.dart b/app/lib/views/preview_panel.dart
index 77d15f3..8be6405 100644
--- a/app/lib/views/preview_panel.dart
+++ b/app/lib/views/preview_panel.dart
@@ -66,6 +66,8 @@ class _PreviewPanelState extends State {
beforeAspectRatio: viewModel.sourceDisplayAspect,
afterAspectRatio: viewModel.comparisonAfterAspect,
zoomController: viewModel.previewZoom,
+ viewMode: viewModel.previewViewMode,
+ onViewModeChanged: viewModel.setPreviewViewMode,
),
),
diff --git a/app/lib/widgets/before_after_comparison.dart b/app/lib/widgets/before_after_comparison.dart
index 496bf53..48004d1 100644
--- a/app/lib/widgets/before_after_comparison.dart
+++ b/app/lib/widgets/before_after_comparison.dart
@@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import '../models/app_shortcut.dart';
import '../services/image_dimensions.dart';
+import 'comparison_view_mode.dart';
import 'comparison_zoom_controller.dart';
/// A widget that displays a before/after comparison with a draggable divider.
@@ -26,7 +27,8 @@ class BeforeAfterComparisonWidget extends StatefulWidget {
/// The "after" image data (shown on the right).
final Uint8List? afterImage;
- /// Initial divider position (0.0 to 1.0, default 0.5).
+ /// Initial divider position (0.0 to 1.0, default 0.5). The divider spans
+ /// the whole panel: at 0 the processed frame fills it, at 1 the original.
final double initialDividerPosition;
/// Whether the before image is loading.
@@ -51,6 +53,14 @@ class BeforeAfterComparisonWidget extends StatefulWidget {
/// Lets keyboard shortcuts drive Fit / 1:1 from outside the widget.
final ComparisonZoomController? zoomController;
+ /// Which frame is shown: the original, the processed one, or both split by
+ /// the divider. Controlled by the parent so it survives a new preview.
+ final ComparisonViewMode viewMode;
+
+ /// Called when the user picks a mode in the Original | Split | Processed
+ /// control. The control is shown only when this is set.
+ final ValueChanged? onViewModeChanged;
+
const BeforeAfterComparisonWidget({
super.key,
this.beforeImage,
@@ -61,6 +71,8 @@ class BeforeAfterComparisonWidget extends StatefulWidget {
this.beforeAspectRatio,
this.afterAspectRatio,
this.zoomController,
+ this.viewMode = ComparisonViewMode.split,
+ this.onViewModeChanged,
});
@override
@@ -176,16 +188,25 @@ class _BeforeAfterComparisonWidgetState
);
}
- /// Display aspect of the frame [_imagePixels] measures: the processed
- /// frame's when there is one, else the source's.
- double? get _measuredAspect => widget.afterImage != null
- ? widget.afterAspectRatio
- : widget.beforeAspectRatio;
+ /// The frame the 1:1 scale is measured against, with its display aspect:
+ /// the frame on screen. In Original mode that is the source; otherwise the
+ /// processed frame, which is what is being inspected, else the source
+ /// standing in for it. One getter, so size and aspect can't come from
+ /// different frames.
+ ({Uint8List? bytes, double? aspect}) get _measuredFrame {
+ if (widget.viewMode == ComparisonViewMode.original) {
+ return (bytes: widget.beforeImage, aspect: widget.beforeAspectRatio);
+ }
+ return widget.afterImage != null
+ ? (bytes: widget.afterImage, aspect: widget.afterAspectRatio)
+ : (bytes: widget.beforeImage, aspect: widget.beforeAspectRatio);
+ }
+
+ double? get _measuredAspect => _measuredFrame.aspect;
- /// Pixel size of the frame the 1:1 scale is measured against: the
- /// processed frame, which is what is being inspected, else the source.
+ /// Pixel size of the frame in [_measuredFrame].
Size? get _imagePixels {
- final bytes = widget.afterImage ?? widget.beforeImage;
+ final bytes = _measuredFrame.bytes;
if (!identical(bytes, _sizedBytes)) {
_sizedBytes = bytes;
_sizedResult = bytes == null ? null : encodedImageSize(bytes);
@@ -315,6 +336,12 @@ class _BeforeAfterComparisonWidgetState
});
}
+ bool get _isSplit => widget.viewMode == ComparisonViewMode.split;
+
+ void _setDivider(double position) {
+ setState(() => _dividerPosition = position.clamp(0.0, 1.0));
+ }
+
void _handlePanZoomStart(PointerPanZoomStartEvent event) {
_panZoomStartScale = _scale;
_isPanZooming = true;
@@ -407,113 +434,125 @@ class _BeforeAfterComparisonWidgetState
fit: StackFit.expand,
children: [
// After image (full width, shown on right side)
- Transform(
- alignment: Alignment.center,
- transform: Matrix4.identity()
- ..translate(drawnOffset.dx, drawnOffset.dy)
- ..scale(_scale, _scale),
- child: _buildImageLayer(
- imageData: widget.afterImage,
- displayAspectRatio: widget.afterAspectRatio,
- isLoading: widget.isAfterLoading,
- colorScheme: colorScheme,
- label: 'Processed',
- ),
- ),
-
- // Before image (clipped to left of divider)
- // Clip stays in screen space so the split aligns with the divider
- ClipRect(
- clipper: _SplitClipper(dividerPosition: _dividerPosition),
- child: Transform(
+ if (widget.viewMode != ComparisonViewMode.original)
+ Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..translate(drawnOffset.dx, drawnOffset.dy)
..scale(_scale, _scale),
child: _buildImageLayer(
- imageData: widget.beforeImage,
- displayAspectRatio: widget.beforeAspectRatio,
- isLoading: widget.isBeforeLoading,
+ imageData: widget.afterImage,
+ displayAspectRatio: widget.afterAspectRatio,
+ isLoading: widget.isAfterLoading,
colorScheme: colorScheme,
- label: 'Original',
+ label: 'Processed',
),
),
- ),
-
- // Labels
- Positioned(
- left: 12,
- top: 12,
- child: _buildLabel('Original', colorScheme),
- ),
- Positioned(
- right: 12,
- top: 12,
- child: _buildLabel('Processed', colorScheme),
- ),
- // Divider line
- Positioned(
- left: constraints.maxWidth * _dividerPosition - 1.5,
- top: 0,
- bottom: 0,
- child: Container(
- width: 3,
- decoration: BoxDecoration(
- color: Colors.white,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.5),
- blurRadius: 4,
- ),
- ],
+ // Before image: clipped to the left of the divider in Split
+ // mode (the clip stays in screen space so the split aligns
+ // with the divider), whole in Original mode.
+ if (widget.viewMode != ComparisonViewMode.processed)
+ ClipRect(
+ clipper: _SplitClipper(
+ dividerPosition: _isSplit ? _dividerPosition : 1.0,
+ ),
+ child: Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.identity()
+ ..translate(drawnOffset.dx, drawnOffset.dy)
+ ..scale(_scale, _scale),
+ child: _buildImageLayer(
+ imageData: widget.beforeImage,
+ displayAspectRatio: widget.beforeAspectRatio,
+ isLoading: widget.isBeforeLoading,
+ colorScheme: colorScheme,
+ label: 'Original',
+ ),
),
),
- ),
- // Divider handle
- Positioned(
- left: constraints.maxWidth * _dividerPosition - 20,
- top: (constraints.maxHeight - 40) / 2,
- child: Container(
- width: 40,
- height: 40,
- decoration: BoxDecoration(
- color: Colors.white,
- shape: BoxShape.circle,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.3),
- blurRadius: 4,
- offset: const Offset(0, 2),
- ),
- ],
+ // Labels: only the side(s) on screen.
+ if (widget.viewMode != ComparisonViewMode.processed)
+ Positioned(
+ left: 12,
+ top: 12,
+ child: _buildLabel('Original', colorScheme),
+ ),
+ if (widget.viewMode != ComparisonViewMode.original)
+ Positioned(
+ right: 12,
+ top: 12,
+ child: _buildLabel('Processed', colorScheme),
+ ),
+
+ // Divider line. It reaches both edges of the panel; drawn
+ // inset there so the whole line stays visible.
+ if (_isSplit)
+ Positioned(
+ key: const Key('comparison-divider'),
+ left: (constraints.maxWidth * _dividerPosition - 1.5)
+ .clamp(0.0, math.max(0.0, constraints.maxWidth - 3)),
+ top: 0,
+ bottom: 0,
+ child: Container(
+ width: 3,
+ decoration: BoxDecoration(
+ color: Colors.white,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.5),
+ blurRadius: 4,
+ ),
+ ],
+ ),
),
- child: const Icon(
- Icons.drag_handle,
- color: Colors.black54,
- size: 20,
+ ),
+
+ // Divider handle, inset at the edges so it stays fully on
+ // screen while the line sits at the edge.
+ if (_isSplit)
+ Positioned(
+ key: const Key('comparison-divider-handle'),
+ left: (constraints.maxWidth * _dividerPosition - 20)
+ .clamp(0.0, math.max(0.0, constraints.maxWidth - 40)),
+ top: (constraints.maxHeight - 40) / 2,
+ child: Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ color: Colors.white,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.3),
+ blurRadius: 4,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: const Icon(
+ Icons.drag_handle,
+ color: Colors.black54,
+ size: 20,
+ ),
),
),
- ),
// Gesture detector for dragging divider and double-tap reset
Positioned.fill(
child: GestureDetector(
onHorizontalDragUpdate: (details) {
- if (_isPanZooming || _isMiddleDragging) return;
- setState(() {
- _dividerPosition +=
- details.delta.dx / constraints.maxWidth;
- _dividerPosition = _dividerPosition.clamp(0.05, 0.95);
- });
+ if (_isPanZooming || _isMiddleDragging || !_isSplit) {
+ return;
+ }
+ _setDivider(_dividerPosition +
+ details.delta.dx / constraints.maxWidth);
},
onTapDown: (details) {
- setState(() {
- _dividerPosition =
- details.localPosition.dx / constraints.maxWidth;
- _dividerPosition = _dividerPosition.clamp(0.05, 0.95);
- });
+ if (!_isSplit) return;
+ _setDivider(
+ details.localPosition.dx / constraints.maxWidth);
},
onDoubleTap: _resetZoom,
child: Container(
@@ -522,6 +561,20 @@ class _BeforeAfterComparisonWidgetState
),
),
+ // Original | Split | Processed (top centre)
+ if (widget.onViewModeChanged != null)
+ Positioned(
+ top: 12,
+ left: 96,
+ right: 96,
+ child: Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: _buildViewModeControl(),
+ ),
+ ),
+ ),
+
// Zoom controls (bottom-left)
if (widget.beforeImage != null || widget.afterImage != null)
Positioned(
@@ -647,14 +700,14 @@ class _BeforeAfterComparisonWidgetState
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
- _zoomButton(
+ _pillButton(
'Fit',
tooltip: AppShortcut.previewFit
.tooltipFor('Fit the whole frame in the panel'),
selected: _isAtFit,
onPressed: _isAtFit ? null : _resetZoom,
),
- _zoomButton(
+ _pillButton(
'1:1',
tooltip: AppShortcut.previewActualSize.tooltipFor(
'Actual pixels — one video pixel per screen pixel'),
@@ -681,7 +734,31 @@ class _BeforeAfterComparisonWidgetState
);
}
- Widget _zoomButton(
+ /// Original | Split | Processed, styled like the zoom pill, in the order
+ /// the panes sit.
+ Widget _buildViewModeControl() {
+ return Container(
+ key: const Key('comparison-view-mode'),
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.6),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (final mode in ComparisonViewMode.values)
+ _pillButton(
+ mode.label,
+ tooltip: mode.tooltip,
+ selected: widget.viewMode == mode,
+ onPressed: () => widget.onViewModeChanged?.call(mode),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _pillButton(
String text, {
required String tooltip,
required bool selected,
diff --git a/app/lib/widgets/comparison_view_mode.dart b/app/lib/widgets/comparison_view_mode.dart
new file mode 100644
index 0000000..8e10d55
--- /dev/null
+++ b/app/lib/widgets/comparison_view_mode.dart
@@ -0,0 +1,24 @@
+import '../models/app_shortcut.dart';
+
+/// What the before/after preview shows: either frame whole, or the two split
+/// by the draggable divider. Lives in `MainViewModel`, so it survives
+/// scrubber moves and new previews.
+enum ComparisonViewMode {
+ original('Original', 'Show only the original frame'),
+ split('Split', 'Compare original and processed with the divider'),
+ processed('Processed', 'Show only the processed frame');
+
+ final String label;
+ final String description;
+ const ComparisonViewMode(this.label, this.description);
+
+ /// The keyboard shortcut that selects this mode.
+ AppShortcut get shortcut => switch (this) {
+ ComparisonViewMode.original => AppShortcut.previewOriginal,
+ ComparisonViewMode.split => AppShortcut.previewSplit,
+ ComparisonViewMode.processed => AppShortcut.previewProcessed,
+ };
+
+ /// The button's tooltip, with the shortcut's keys.
+ String get tooltip => shortcut.tooltipFor(description);
+}
diff --git a/app/test/app_shortcut_test.dart b/app/test/app_shortcut_test.dart
index aedb5fd..f9f69af 100644
--- a/app/test/app_shortcut_test.dart
+++ b/app/test/app_shortcut_test.dart
@@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vapourbox/models/app_shortcut.dart';
import 'package:vapourbox/widgets/app_shortcut_scope.dart';
+import 'package:vapourbox/widgets/comparison_view_mode.dart';
void main() {
group('AppShortcut', () {
@@ -142,6 +143,33 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
+ testWidgets('B / S / P pick the preview view mode, but not while typing',
+ (tester) async {
+ final fired = [];
+ final field = await pump(tester, {
+ for (final mode in ComparisonViewMode.values)
+ mode.shortcut: () => fired.add(mode),
+ });
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyB);
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyP);
+ expect(fired, [
+ ComparisonViewMode.original,
+ ComparisonViewMode.split,
+ ComparisonViewMode.processed,
+ ]);
+
+ fired.clear();
+ field.fieldFocus.requestFocus();
+ await tester.pump();
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyB);
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
+ await tester.sendKeyEvent(LogicalKeyboardKey.keyP);
+ await tester.pump();
+ expect(fired, isEmpty);
+ });
+
testWidgets('a null binding lets the key through', (tester) async {
await pump(tester, {AppShortcut.setInPoint: null});
// Nothing to assert beyond not throwing: there is no handler to call.
diff --git a/app/test/before_after_comparison_test.dart b/app/test/before_after_comparison_test.dart
index ba3eee4..48ded31 100644
--- a/app/test/before_after_comparison_test.dart
+++ b/app/test/before_after_comparison_test.dart
@@ -10,9 +10,11 @@ import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:vapourbox/services/image_dimensions.dart';
import 'package:vapourbox/widgets/before_after_comparison.dart';
+import 'package:vapourbox/widgets/comparison_view_mode.dart';
import 'package:vapourbox/widgets/comparison_zoom_controller.dart';
Future _png(int width, int height) async {
@@ -261,4 +263,265 @@ void main() {
controller.fit();
});
});
+
+ // Forum feedback (videohelp 419911): the divider stopped 5% short of each
+ // edge, hiding a typical left crop; and there was no way to see either frame
+ // whole.
+ group('divider and view modes', () {
+ const panel = Size(400, 300);
+
+ Future pump(
+ WidgetTester tester, {
+ required Uint8List before,
+ required Uint8List after,
+ double? beforeAspectRatio,
+ double? afterAspectRatio,
+ ComparisonViewMode viewMode = ComparisonViewMode.split,
+ ValueChanged? onViewModeChanged,
+ ComparisonZoomController? zoomController,
+ }) async {
+ tester.view.devicePixelRatio = 1;
+ tester.view.physicalSize = const Size(1000, 1000);
+ addTearDown(tester.view.reset);
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: panel.width,
+ height: panel.height,
+ child: BeforeAfterComparisonWidget(
+ beforeImage: before,
+ afterImage: after,
+ beforeAspectRatio: beforeAspectRatio,
+ afterAspectRatio: afterAspectRatio,
+ viewMode: viewMode,
+ onViewModeChanged: onViewModeChanged,
+ zoomController: zoomController,
+ ),
+ ),
+ ),
+ ));
+ await tester.pumpAndSettle();
+ }
+
+ Rect panelRect(WidgetTester tester) =>
+ tester.getRect(find.byType(BeforeAfterComparisonWidget));
+
+ /// The width of the screen-space clip that shows the original.
+ double originalClipWidth(WidgetTester tester) {
+ final clip = tester.renderObject(find
+ .descendant(
+ of: find.byType(BeforeAfterComparisonWidget),
+ matching: find.byType(ClipRect),
+ )
+ .first);
+ return clip.clipper!.getClip(clip.size).width;
+ }
+
+ Finder images() => find.descendant(
+ of: find.byType(BeforeAfterComparisonWidget),
+ matching: find.byType(Image),
+ );
+
+ testWidgets('a drag past either edge takes the divider to 0 and 1',
+ (tester) async {
+ final image = await tester.runAsync(() => _png(200, 100));
+ await pump(tester, before: image!, after: image);
+ final bounds = panelRect(tester);
+ final centre = bounds.center;
+
+ await tester.dragFrom(centre, const Offset(-1000, 0));
+ await tester.pumpAndSettle();
+ expect(originalClipWidth(tester), 0);
+ final lineAtLeft =
+ tester.getRect(find.byKey(const Key('comparison-divider')));
+ final handleAtLeft =
+ tester.getRect(find.byKey(const Key('comparison-divider-handle')));
+ // The 1 px border insets the content; the line touches its left edge.
+ expect(lineAtLeft.left, closeTo(bounds.left + 1, 1e-6));
+ expect(handleAtLeft.left, greaterThanOrEqualTo(bounds.left));
+
+ await tester.dragFrom(centre, const Offset(2000, 0));
+ await tester.pumpAndSettle();
+ final contentWidth = bounds.width - 2;
+ expect(originalClipWidth(tester), closeTo(contentWidth, 1e-6));
+ final lineAtRight =
+ tester.getRect(find.byKey(const Key('comparison-divider')));
+ final handleAtRight =
+ tester.getRect(find.byKey(const Key('comparison-divider-handle')));
+ expect(lineAtRight.right, closeTo(bounds.right - 1, 1e-6));
+ expect(handleAtRight.right, lessThanOrEqualTo(bounds.right));
+ });
+
+ testWidgets('a tap at the edge puts the divider there, not 5% in',
+ (tester) async {
+ final image = await tester.runAsync(() => _png(200, 100));
+ await pump(tester, before: image!, after: image);
+ final bounds = panelRect(tester);
+
+ await tester.tapAt(Offset(bounds.left + 1.5, bounds.center.dy));
+ await tester.pump(const Duration(milliseconds: 500));
+ expect(originalClipWidth(tester), lessThan(1));
+ });
+
+ group('each mode shows only its own side', () {
+ late Uint8List source;
+ late Uint8List processed;
+
+ Future load(WidgetTester tester) async {
+ source = (await tester.runAsync(() => _png(720, 576)))!;
+ processed = (await tester.runAsync(() => _png(1920, 1080)))!;
+ }
+
+ Future pumpMode(WidgetTester tester, ComparisonViewMode mode) =>
+ pump(
+ tester,
+ before: source,
+ after: processed,
+ beforeAspectRatio: 4 / 3,
+ afterAspectRatio: 16 / 9,
+ viewMode: mode,
+ );
+
+ testWidgets('Original', (tester) async {
+ await load(tester);
+ await pumpMode(tester, ComparisonViewMode.original);
+ expect(images(), findsOneWidget);
+ final rect = tester.getRect(images());
+ expect(rect.width / rect.height, closeTo(4 / 3, 1e-6));
+ expect(find.text('Original'), findsOneWidget);
+ expect(find.text('Processed'), findsNothing);
+ expect(find.byKey(const Key('comparison-divider')), findsNothing);
+ expect(
+ find.byKey(const Key('comparison-divider-handle')), findsNothing);
+ });
+
+ testWidgets('Processed', (tester) async {
+ await load(tester);
+ await pumpMode(tester, ComparisonViewMode.processed);
+ expect(images(), findsOneWidget);
+ final rect = tester.getRect(images());
+ expect(rect.width / rect.height, closeTo(16 / 9, 1e-6));
+ expect(find.text('Processed'), findsOneWidget);
+ expect(find.text('Original'), findsNothing);
+ expect(find.byKey(const Key('comparison-divider')), findsNothing);
+ });
+
+ testWidgets('Split', (tester) async {
+ await load(tester);
+ await pumpMode(tester, ComparisonViewMode.split);
+ expect(images(), findsNWidgets(2));
+ expect(find.text('Original'), findsOneWidget);
+ expect(find.text('Processed'), findsOneWidget);
+ expect(find.byKey(const Key('comparison-divider')), findsOneWidget);
+ });
+
+ testWidgets('dragging does nothing outside Split', (tester) async {
+ await load(tester);
+ await pumpMode(tester, ComparisonViewMode.original);
+ await tester.dragFrom(
+ panelRect(tester).center, const Offset(-1000, 0));
+ await tester.pumpAndSettle();
+ // Back in Split, the divider is still in the middle.
+ await pumpMode(tester, ComparisonViewMode.split);
+ expect(originalClipWidth(tester),
+ closeTo((panel.width - 2) / 2, 1e-6));
+ });
+ });
+
+ testWidgets('1:1 is measured against the frame on screen',
+ (tester) async {
+ final controller = ComparisonZoomController();
+ final source = await tester.runAsync(() => _png(720, 576));
+ final processed = await tester.runAsync(() => _png(1920, 1080));
+ Future pumpMode(ComparisonViewMode mode) => pump(
+ tester,
+ before: source!,
+ after: processed!,
+ beforeAspectRatio: 4 / 3,
+ afterAspectRatio: 16 / 9,
+ viewMode: mode,
+ zoomController: controller,
+ );
+
+ await pumpMode(ComparisonViewMode.processed);
+ controller.showActualSize();
+ await tester.pumpAndSettle();
+ double scale() => tester
+ .widget(find
+ .descendant(
+ of: find.byType(BeforeAfterComparisonWidget),
+ matching: find.byType(Transform),
+ )
+ .first)
+ .transform
+ .getMaxScaleOnAxis();
+ // 398 wide at 16:9; 1080 rows on 1080 pixels.
+ expect(scale(), closeTo(1080 / (398 / (16 / 9)), 1e-9));
+ expect(find.text('100%'), findsOneWidget);
+
+ // The 4:3 source fits by height (298); 576 rows on 576 pixels.
+ await pumpMode(ComparisonViewMode.original);
+ expect(scale(), closeTo(576 / 298, 1e-9));
+ expect(find.text('100%'), findsOneWidget);
+ });
+
+ testWidgets('the mode and the divider survive a new processed frame',
+ (tester) async {
+ final source = await tester.runAsync(() => _png(720, 576));
+ final first = await tester.runAsync(() => _png(1920, 1080));
+ final second = await tester.runAsync(() => _png(1280, 720));
+
+ // Move the divider in Split first.
+ await pump(tester, before: source!, after: first!);
+ await tester.dragFrom(panelRect(tester).center, const Offset(-100, 0));
+ await tester.pumpAndSettle();
+ final moved = originalClipWidth(tester);
+ expect(moved, lessThan((panel.width - 2) / 2));
+
+ await pump(tester,
+ before: source,
+ after: first,
+ viewMode: ComparisonViewMode.processed);
+ await pump(tester,
+ before: source,
+ after: second!,
+ viewMode: ComparisonViewMode.processed);
+ expect(images(), findsOneWidget);
+ final shown = tester.widget(images()).image as MemoryImage;
+ expect(identical(shown.bytes, second), isTrue);
+
+ // Back to Split: the divider is where the user left it.
+ await pump(tester, before: source, after: second);
+ expect(originalClipWidth(tester), closeTo(moved, 1e-6));
+ });
+
+ testWidgets('the view-mode control reports a tap, with shortcut tooltips',
+ (tester) async {
+ final image = await tester.runAsync(() => _png(200, 100));
+ final picked = [];
+ await pump(tester,
+ before: image!, after: image, onViewModeChanged: picked.add);
+
+ final control = find.byKey(const Key('comparison-view-mode'));
+ expect(control, findsOneWidget);
+ await tester.tap(
+ find.descendant(of: control, matching: find.text('Processed')));
+ await tester.tap(
+ find.descendant(of: control, matching: find.text('Original')));
+ expect(picked,
+ [ComparisonViewMode.processed, ComparisonViewMode.original]);
+
+ for (final mode in ComparisonViewMode.values) {
+ expect(find.byTooltip(mode.shortcut.tooltipFor(mode.description)),
+ findsOneWidget);
+ }
+ });
+
+ testWidgets('no control without a callback', (tester) async {
+ final image = await tester.runAsync(() => _png(200, 100));
+ await pump(tester, before: image!, after: image);
+ expect(find.byKey(const Key('comparison-view-mode')), findsNothing);
+ });
+ });
}
diff --git a/app/test/preview_aspect_test.dart b/app/test/preview_aspect_test.dart
index 33108a8..124337c 100644
--- a/app/test/preview_aspect_test.dart
+++ b/app/test/preview_aspect_test.dart
@@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:vapourbox/models/preview_frame_info.dart';
import 'package:vapourbox/services/field_order_detector.dart';
import 'package:vapourbox/viewmodels/main_viewmodel.dart';
+import 'package:vapourbox/widgets/comparison_view_mode.dart';
/// Just enough of a PNG for its header to be read: signature, IHDR length and
/// type, width, height.
@@ -138,6 +139,29 @@ void viewModelAspects() {
expect(vm.comparisonAfterAspect, closeTo(16 / 9, 1e-9));
});
+ test('the preview view mode defaults to Split and notifies on change', () {
+ expect(vm.previewViewMode, ComparisonViewMode.split);
+ var notified = 0;
+ vm.addListener(() => notified++);
+ vm.setPreviewViewMode(ComparisonViewMode.processed);
+ expect(vm.previewViewMode, ComparisonViewMode.processed);
+ expect(notified, 1);
+ vm.setPreviewViewMode(ComparisonViewMode.processed);
+ expect(notified, 1, reason: 'no change, no rebuild');
+ });
+
+ test('the view mode survives a scrubber move and a new preview', () async {
+ vm.debugAddQueueItemForTest('/tmp/vb_pal.mkv',
+ width: 720, height: 576, sar: '16:15');
+ vm.setPreviewViewMode(ComparisonViewMode.original);
+ vm.debugAcceptProcessedPreview(_pngHeader(1920, 1080),
+ PreviewFrameInfo.parse('PREVIEW_INFO:frame=0'));
+ await vm.setScrubberPosition(0.5);
+ vm.debugAcceptProcessedPreview(_pngHeader(1920, 1080),
+ PreviewFrameInfo.parse('PREVIEW_INFO:frame=3'));
+ expect(vm.previewViewMode, ComparisonViewMode.original);
+ });
+
test('a failed preview clears the stored aspect with the image', () {
vm.debugAddQueueItemForTest('/tmp/vb_pal.mkv',
width: 720, height: 576, sar: '16:15');