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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ VapourBox/
|------|---------|
| `worker/src/models/video_job.rs` | Job config, EncodingSettings, processing passes |
| `worker/src/models/qtgmc_parameters.rs` | All 70+ QTGMC parameters (serde) |
| `worker/src/models/processing_pipeline.rs` | Pipeline passes + `FrameMap` (source↔output frame mapping: `output_count` drives the progress total, `invert`/`total_radius` drive frame-accurate preview) |
| `worker/src/script_generator.rs` | Template substitution for .vpy |
| `worker/src/pipeline_executor.rs` | vspipe \| ffmpeg execution |
| `worker/templates/pipeline_template.vpy` | VapourSynth script template |
Expand Down
22 changes: 22 additions & 0 deletions app/lib/services/frame_math.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/// Pure conversions between a normalized scrubber position (0.0-1.0) and an
/// integer source frame index.
///
/// The scrubber gesture is naturally normalized, but the preview and the
/// step/jump seek controls operate on exact frames. Keeping these conversions
/// pure (and shared) means the rounding behaviour is unit-testable and a
/// position → frame → position round-trip is stable (no drift when stepping).
class FrameMath {
/// Frame index for a normalized [position], given the [totalFrames] count.
/// Returns 0 when the video has 0 or 1 frames.
static int frameForPosition(double position, int totalFrames) {
if (totalFrames <= 1) return 0;
return (position.clamp(0.0, 1.0) * (totalFrames - 1)).round();
}

/// Normalized position for a [frame] index, given the [totalFrames] count.
/// Returns 0.0 when the video has 0 or 1 frames.
static double positionForFrame(int frame, int totalFrames) {
if (totalFrames <= 1) return 0.0;
return (frame / (totalFrames - 1)).clamp(0.0, 1.0);
}
}
48 changes: 33 additions & 15 deletions app/lib/services/preview_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -150,36 +150,43 @@ class PreviewGenerator {
return thumbnails;
}

/// Get a single frame at a specific time position.
Future<Uint8List?> getFrameAt(double timeSeconds) async {
/// Get the unprocessed source frame at an exact frame index.
///
/// Frame-accurate: seeks to the midpoint of the interval *before* the target
/// frame ((index - 0.5)/fps) so the first decoded frame (PTS >= seek time) is
/// exactly `frameIndex`. This matches the worker's preview seek, so the
/// before/after comparison always shows the same source frame.
Future<Uint8List?> getFrameAtIndex(int frameIndex) async {
if (_currentVideoPath == null || _ffmpegPath == null) return null;

final frameIndex = (timeSeconds * _frameRate).round();
final frame = frameIndex < 0 ? 0 : frameIndex;

// Check cache
if (_thumbnailCache.containsKey(frameIndex)) {
return _thumbnailCache[frameIndex];
if (_thumbnailCache.containsKey(frame)) {
return _thumbnailCache[frame];
}

// Extract frame
final outputPath = '$_tempDir/frame_${frameIndex}.jpg';
final outputPath = '$_tempDir/frame_$frame.jpg';
final seekTime = ((frame - 0.5) / _frameRate);
final ss = seekTime < 0 ? 0.0 : seekTime;

try {
final result = await Process.run(
_ffmpegPath!,
[
'-y',
'-ss', timeSeconds.toStringAsFixed(3),
'-ss', ss.toStringAsFixed(6),
'-i', _currentVideoPath!,
'-vframes', '1',
'-frames:v', '1',
'-q:v', '2',
outputPath,
],
);

if (result.exitCode == 0 && await File(outputPath).exists()) {
final bytes = await File(outputPath).readAsBytes();
_thumbnailCache[frameIndex] = bytes;
_thumbnailCache[frame] = bytes;
return bytes;
}
} catch (e) {
Expand All @@ -195,7 +202,7 @@ class PreviewGenerator {
/// the processed frame. Uses the same code path as actual video processing
/// to ensure preview matches the final output.
Future<Uint8List?> generateProcessedPreview({
required double timeSeconds,
required int frameNumber,
required ProcessingPipeline pipeline,
required FieldOrder fieldOrder,
required EncodingSettings encodingSettings,
Expand All @@ -205,14 +212,25 @@ class PreviewGenerator {
return null;
}

// Dimensions come from the ffprobe pass in loadVideo(). If they aren't
// populated yet (probe still running — e.g. a slow first-run Gatekeeper
// assessment of freshly-downloaded ffprobe — or the probe failed), skip
// rather than spawning the worker with a 0x0 job, which fails with a
// misleading "Invalid video dimensions" error. The preview is requested
// again once the video is probed, so this self-heals.
if (_videoWidth <= 0 || _videoHeight <= 0) {
return null;
}

// Cancel any existing preview generation
await cancelPreviewGeneration();

if (cancelToken?.isCancelled ?? false) return null;

// Calculate frame number in the SOURCE video (not output)
// We no longer double for FPSDivisor=1 because we're seeking in the source
final frameNumber = (timeSeconds * _frameRate).round();
// `frameNumber` is the SOURCE frame index, passed straight to the worker
// (no time round-trip). The worker decodes a window centred on it and emits
// the exact output frame it maps to — see generate_preview in the worker.
final frame = frameNumber < 0 ? 0 : frameNumber;
final configPath = '$_tempDir/preview_config_${DateTime.now().millisecondsSinceEpoch}.json';
Process? process;

Expand Down Expand Up @@ -247,7 +265,7 @@ class PreviewGenerator {
// Clear log for this preview generation
_previewLog.clear();
_lastError = null;
_previewLog.add('[${DateTime.now().toIso8601String()}] Starting preview generation for frame $frameNumber');
_previewLog.add('[${DateTime.now().toIso8601String()}] Starting preview generation for frame $frame');

// Run worker in preview mode
// Use local variable to avoid race conditions when another preview request cancels this one
Expand All @@ -257,7 +275,7 @@ class PreviewGenerator {
[
'--config', configPath,
'--preview',
'--frame', frameNumber.toString(),
'--frame', frame.toString(),
],
environment: ToolLocator.instance.workerEnvironment,
workingDirectory: path.dirname(_workerPath!),
Expand Down
45 changes: 37 additions & 8 deletions app/lib/viewmodels/main_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '../models/processing_preset.dart';
import '../services/disc_detector.dart';
import '../services/dvd_service.dart';
import '../services/field_order_detector.dart';
import '../services/frame_math.dart';
import '../services/preset_service.dart';
import '../services/preview_generator.dart';
import '../services/worker_manager.dart';
Expand Down Expand Up @@ -268,6 +269,32 @@ class MainViewModel extends ChangeNotifier {
List<String> get previewLog => _previewGenerator.previewLog;
String? get previewError => _previewGenerator.lastError;

/// Total number of source frames in the loaded video (0 if not loaded).
int get totalFrames => _previewGenerator.totalFrames;

/// The integer source frame the scrubber currently points at. This is the
/// authoritative unit for the preview and step/jump controls — the normalized
/// scrubberPosition is just the gesture representation.
int get currentFrameIndex => _frameForPosition(scrubberPosition);

/// Convert a normalized scrubber position (0.0-1.0) to a source frame index.
int _frameForPosition(double position) =>
FrameMath.frameForPosition(position, _previewGenerator.totalFrames);

/// Convert a source frame index to a normalized scrubber position (0.0-1.0).
double _positionForFrame(int frame) =>
FrameMath.positionForFrame(frame, _previewGenerator.totalFrames);

/// Seek to an exact source frame, clamped to the video's range.
Future<void> seekToFrame(int frame) async {
final total = _previewGenerator.totalFrames;
final clamped = total > 0 ? frame.clamp(0, total - 1) : 0;
await setScrubberPosition(_positionForFrame(clamped));
}

/// Step the scrubber by a signed number of frames (e.g. -1 / +1).
Future<void> stepFrame(int delta) => seekToFrame(currentFrameIndex + delta);

// Timeline zoom getters (delegate to selected item)
double get timelineZoom => selectedItem?.timelineZoom ?? 1.0;
double get timelineViewStart => selectedItem?.timelineViewStart ?? 0.0;
Expand Down Expand Up @@ -487,7 +514,7 @@ class MainViewModel extends ChangeNotifier {
if (_selectedItemId == item.id) {
try {
_queue[index].thumbnails = await _previewGenerator.loadVideo(item.inputPath);
_queue[index].currentFrame = await _previewGenerator.getFrameAt(0);
_queue[index].currentFrame = await _previewGenerator.getFrameAtIndex(0);
} catch (e) {
_logMessages.add(LogMessage(
level: LogLevel.warning,
Expand Down Expand Up @@ -562,8 +589,8 @@ class MainViewModel extends ChangeNotifier {
if (item.thumbnails.isEmpty && item.status != QueueItemStatus.analyzing) {
try {
item.thumbnails = await _previewGenerator.loadVideo(item.inputPath);
item.currentFrame = await _previewGenerator.getFrameAt(
item.scrubberPosition * _previewGenerator.duration,
item.currentFrame = await _previewGenerator.getFrameAtIndex(
_frameForPosition(item.scrubberPosition),
);
notifyListeners();
} catch (e) {
Expand Down Expand Up @@ -641,9 +668,11 @@ class MainViewModel extends ChangeNotifier {

item.scrubberPosition = position.clamp(0.0, 1.0);

// Update current frame immediately
final timeSeconds = item.scrubberPosition * _previewGenerator.duration;
item.currentFrame = await _previewGenerator.getFrameAt(timeSeconds);
// Update the unprocessed source frame immediately, seeking to the exact
// frame the scrubber maps to (same index the processed preview uses, so the
// before/after comparison stays aligned).
item.currentFrame =
await _previewGenerator.getFrameAtIndex(_frameForPosition(item.scrubberPosition));
notifyListeners();

// Debounce the processed preview generation
Expand Down Expand Up @@ -854,11 +883,11 @@ class MainViewModel extends ChangeNotifier {

final cancelToken = CancelToken();
_previewCancelToken = cancelToken;
final timeSeconds = item.scrubberPosition * _previewGenerator.duration;
final frameNumber = _frameForPosition(item.scrubberPosition);

try {
final preview = await _previewGenerator.generateProcessedPreview(
timeSeconds: timeSeconds,
frameNumber: frameNumber,
pipeline: _processingPipeline,
fieldOrder: effectiveFieldOrder,
encodingSettings: _encodingSettings,
Expand Down
29 changes: 29 additions & 0 deletions app/lib/views/preview_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,35 @@ class _PreviewPanelState extends State<PreviewPanel> {
_formatTime(viewModel.scrubberPosition * viewModel.videoDuration),
style: Theme.of(context).textTheme.bodySmall,
),
// Frame-accurate seek controls: step back/forward one frame
// and a readout of the exact source frame being previewed.
if (viewModel.totalFrames > 0) ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.chevron_left, size: 18),
tooltip: 'Previous frame',
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: EdgeInsets.zero,
onPressed: viewModel.currentFrameIndex > 0
? () => viewModel.stepFrame(-1)
: null,
),
Text(
'f ${viewModel.currentFrameIndex} / ${viewModel.totalFrames - 1}',
style: Theme.of(context).textTheme.bodySmall,
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 18),
tooltip: 'Next frame',
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
padding: EdgeInsets.zero,
onPressed: viewModel.currentFrameIndex < viewModel.totalFrames - 1
? () => viewModel.stepFrame(1)
: null,
),
],
const SizedBox(width: 8),
// In point button
Tooltip(
Expand Down
38 changes: 38 additions & 0 deletions app/test/frame_math_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:vapourbox/services/frame_math.dart';

void main() {
group('FrameMath', () {
test('maps endpoints to first and last frame', () {
expect(FrameMath.frameForPosition(0.0, 1000), 0);
expect(FrameMath.frameForPosition(1.0, 1000), 999);
});

test('rounds to the nearest frame', () {
// 0.5 of a 1001-frame clip (last index 1000) -> 500
expect(FrameMath.frameForPosition(0.5, 1001), 500);
});

test('clamps out-of-range positions', () {
expect(FrameMath.frameForPosition(-0.2, 500), 0);
expect(FrameMath.frameForPosition(1.5, 500), 499);
});

test('degenerate clips never divide by zero', () {
expect(FrameMath.frameForPosition(0.7, 0), 0);
expect(FrameMath.frameForPosition(0.7, 1), 0);
expect(FrameMath.positionForFrame(5, 0), 0.0);
expect(FrameMath.positionForFrame(5, 1), 0.0);
});

test('position <-> frame round-trips without drift', () {
// Stepping frame-by-frame must be stable: frame -> position -> frame == frame.
const total = 2500;
for (final frame in [0, 1, 2, 1249, 1250, 2498, 2499]) {
final pos = FrameMath.positionForFrame(frame, total);
expect(FrameMath.frameForPosition(pos, total), frame,
reason: 'round-trip should be stable for frame $frame');
}
});
});
}
11 changes: 5 additions & 6 deletions worker/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,10 @@ fn run_preview_mode(args: &Args) -> ExitCode {
}
};

// Calculate time from frame number
let frame_rate = job.input_frame_rate.unwrap_or(29.97);
let time_seconds = frame as f64 / frame_rate;

eprintln!("Preview: frame {} at {:.3}s (fps: {:.2})", frame, time_seconds, frame_rate);
// The frame index is passed straight through to the worker — no
// time-conversion round-trip — so the rendered frame is exactly the one the
// caller asked for (see generate_preview's frame-accurate windowing).
eprintln!("Preview: source frame {} (fps: {:.2})", frame, job.input_frame_rate.unwrap_or(29.97));

// Execute preview (extracts frames with ffmpeg, processes with VapourSynth)
let executor = match PipelineExecutor::new(ProgressReporter::new()) {
Expand All @@ -291,7 +290,7 @@ fn run_preview_mode(args: &Args) -> ExitCode {
}
};

match executor.generate_preview(&job, time_seconds) {
match executor.generate_preview(&job, frame) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error generating preview: {}", e);
Expand Down
Loading
Loading