Skip to content
Draft
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
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ VapourBox/
| `worker/src/pixel_format.rs` | Source `pix_fmt` → pipe format (see "Source Pixel Formats") |
| `worker/templates/stabilize.py` | Stabilize pass's vertical pitch correction (see "Stabilize") |
| `worker/src/cpu.rs` | The x86 deps CPU tier (`v3`/`v2`) — the single decision; see "CPU tiers" |
| `worker/src/source_decode.rs` | Decoder ffmpeg args for encode + preview: full stored frame, size-guarded (see "Source Frame Size") |
| `worker/src/source_decode.rs` | Decoder ffmpeg args for encode + preview: full stored frame, size-guarded, PTS reset after a seek (see "Source Frame Size") |
| `worker/src/source_timing.rs` | A variable-frame-rate source's stored frames placed on the constant-rate grid (see "Variable Frame Rate Sources") |
| `worker/src/gpu_plugin.rs` | The ncnn-on-Vulkan plugins (RIFE, AI upscaling): file layout, RIFE's model add-on, the `--probe-rife` / `--probe-upscale` probe (on stand-in models, no add-on needed), and the user-facing reason/remedy when they can't run (see "GPU Plugins") |
| `worker/src/animejanai.rs` | AnimeJaNai's add-on models: where they live, which one a job runs, the refusal when they're missing (see "Add-ons") |
Expand Down Expand Up @@ -1016,10 +1016,25 @@ the **single** builder of both decoders' arguments (encode + preview):
`explain_decoder_failure` ahead of the vspipe/encoder status. A job that
omits `inputWidth`/`inputHeight` gets them probed by the worker
(`fill_frame_size`), not the old 720x480 fallback.
- **A decoder that seeks resets PTS first (issue #157).** `rawvideo` gets
constant-rate output timing, and after an `-ss` to `(N - 0.5)/fps` that
timing repeated the first kept frame on many time bases (MP4 1/2997, MOV
1/11988, MKV 1/1000): the preview showed N-1, a trim of 41..52 came out
`41, 41, 42 .. 51`, and the noise score read duplicated pairs as noiseless.
So `RESET_PTS_FILTER` (`setpts=PTS-STARTPTS`) goes **first in the one
`-vf`**, only when the decoder seeks; a decoder that doesn't seek passes no
`-ss` (not even `-ss 0`) and no reset, so untrimmed encodes are untouched and
preview frame 0 is the encode's frame 0. Never `-fps_mode passthrough` here:
it drops the constant-rate slots a soft-telecine source decodes to (only the
VFR decoder uses it). **Test seek alignment against an independent decode**
(`select=eq(n,N)` of the source), never preview against encode, which shift
together — `integration_seek_alignment_test.dart` (heavy). MPEG-PS/TS, MPEG-2
in AVI and leading null frames still seek to the wrong picture for other
reasons (docs/reports/issue-157-seek-alignment).

`integration_clean_aperture_test.dart` (heavy) asserts a clean-aperture MOV and
MKV come through bit-exact at full size. See docs/ENGINEERING_NOTES.md,
"Clean aperture".
"Clean aperture" and "Seek alignment".

### Variable Frame Rate Sources (issue #137)

Expand Down
41 changes: 41 additions & 0 deletions Tests/TestResources/generate_seek_timebase_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/bin/bash
# Generate seek_timebase_2997.mov: a 29.97 fps MOV whose video track time base
# is 1/11988, the timescale a user's ProRes clip had (issue #157).
#
# Why this fixture exists: with that time base (and MP4's 1/2997, and TS's
# 1/90000), an ffmpeg that seeks and writes rawvideo used to emit the first
# kept frame twice, so the preview showed frame N-1 and trimmed encodes were a
# frame late. interlaced_test.avi (1/25) cannot show it, which is how it went
# unnoticed. integration_seek_alignment_test.dart decodes it from several start
# frames and compares against a sequential decode.
#
# Content: testsrc2 (every frame differs), 320x240, 3 s = 90 frames, H.264 with
# B-frames, plus a sine AAC track so the container has audio like a real clip.
#
# Usage: ./generate_seek_timebase_test.sh [output_dir] [ffmpeg]

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
OUTPUT_DIR="${1:-$SCRIPT_DIR}"
FFMPEG="${2:-ffmpeg}"
FFPROBE="$(dirname "$FFMPEG")/ffprobe"
[ -x "$FFPROBE" ] || FFPROBE=ffprobe
OUT="$OUTPUT_DIR/seek_timebase_2997.mov"

"$FFMPEG" -y -v error \
-f lavfi -i "testsrc2=size=320x240:rate=30000/1001:duration=3.003" \
-f lavfi -i "sine=frequency=440:sample_rate=48000:duration=3.003" \
-map 0:v -map 1:a \
-c:v libx264 -preset veryfast -crf 20 -g 30 -bf 2 -pix_fmt yuv420p \
-c:a aac -b:a 64k \
-video_track_timescale 11988 \
"$OUT"

tb="$("$FFPROBE" -v error -select_streams v:0 -show_entries stream=time_base -of csv=p=0 "$OUT")"
if [ "$tb" != "1/11988" ]; then
echo "expected video time base 1/11988, got $tb" >&2
exit 1
fi
frames="$("$FFPROBE" -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 "$OUT")"
echo "Wrote $OUT (time base $tb, $frames frames)"
Binary file added Tests/TestResources/seek_timebase_2997.mov
Binary file not shown.
54 changes: 40 additions & 14 deletions app/lib/services/preview_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,43 @@ class PreviewGenerator {
return thumbnails;
}

/// Get the unprocessed source frame at an exact frame index.
/// The ffmpeg arguments that write source frame [frameIndex] (the "before"
/// picture) to [outputPath].
///
/// 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.
/// before/after comparison always shows the same source frame. An image
/// output is written with passthrough timing, so unlike the worker's
/// `rawvideo` decoders it needs no PTS reset after the seek (issue #157).
///
/// `integration_seek_alignment_test.dart` runs exactly these arguments (with
/// a PNG [outputPath]) against an independent sequential decode, so keep
/// every decode option here rather than at the call site.
static List<String> beforeFrameArgs({
required String videoPath,
required int frameIndex,
required double frameRate,
String? toneMap,
required String outputPath,
}) {
final frame = frameIndex < 0 ? 0 : frameIndex;
final seekTime = (frame - 0.5) / frameRate;
final ss = seekTime < 0 ? 0.0 : seekTime;
return [
'-y',
'-ss', ss.toStringAsFixed(6),
...sourceDecodeOptions,
'-i', videoPath,
'-frames:v', '1',
if (toneMap != null) ...['-vf', toneMap],
'-q:v', '2',
outputPath,
];
}

/// Get the unprocessed source frame at an exact frame index — see
/// [beforeFrameArgs].
Future<Uint8List?> getFrameAtIndex(int frameIndex) async {
if (_currentVideoPath == null || _ffmpegPath == null) return null;

Expand All @@ -229,22 +260,17 @@ class PreviewGenerator {

// Extract frame
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', ss.toStringAsFixed(6),
...sourceDecodeOptions,
'-i', _currentVideoPath!,
'-frames:v', '1',
if (_toneMap case final toneMap?) ...['-vf', toneMap],
'-q:v', '2',
outputPath,
],
beforeFrameArgs(
videoPath: _currentVideoPath!,
frameIndex: frame,
frameRate: _frameRate,
toneMap: _toneMap,
outputPath: outputPath,
),
);

if (result.exitCode == 0 && await File(outputPath).exists()) {
Expand Down
9 changes: 7 additions & 2 deletions app/test/integration_frame_mapping_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -563,9 +563,14 @@ void main() {
await check(job('subframe_sync'), [12, 13, 21]);
}, timeout: const Timeout(Duration(minutes: 10)));

// The in-point is past interlaced_test.avi's leading null frames (stored
// pts 0, then 5). A seek into that gap rebases to the first stored frame
// after it (issue #157's PTS reset), so a trim starting at 1..4 is shifted
// against the untrimmed numbering — the leading-repeat-slots problem of its
// own, not what this test is about. Same offsets as 3 -> 13, 19.
test('from an in-point', () async {
await check(job('subframe_sync_trim', start: 3, end: 50), [13, 19],
origin: 3);
await check(job('subframe_sync_trim', start: 6, end: 53), [16, 22],
origin: 6);
}, timeout: const Timeout(Duration(minutes: 10)));
});

Expand Down
222 changes: 222 additions & 0 deletions app/test/integration_seek_alignment_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Seek alignment (issue #157): a preview of source frame N, and a trimmed
// encode from frame S, must deliver exactly those source pictures.
//
// The worker's decoders write `rawvideo`, which ffmpeg times at a constant
// rate. After a seek, for many time bases (MP4 1/2997, MOV 1/11988, TS
// 1/90000) that timing used to emit the first kept frame TWICE: the preview
// showed frame N-1 next to the app's correct "before" frame N, and a trim of
// 41..52 came out 41, 41, 42 .. 51. Preview and encode shifted together, so
// integration_frame_mapping_test.dart (preview vs encode, on a 1/25 AVI the
// bug cannot touch) passed throughout.
//
// So everything here is checked against an INDEPENDENT reference: a
// sequential decode of the source picked by index (`select=eq(n,N)`), never
// one pipeline path against another. The fixtures are ones whose sequential
// numbering is the app's frame numbering (no leading null frames, no
// pulldown, video starting with the container). Sources where it isn't
// (MPEG-PS/TS, MPEG-2 in AVI, soft telecine, AVI null frames) are separate
// issues; see docs/reports/issue-157-seek-alignment.
@Tags(['heavy'])
library;

import 'dart:io';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';

import 'package:vapourbox/models/encoding_settings.dart';
import 'package:vapourbox/models/processing_pipeline.dart';
import 'package:vapourbox/models/qtgmc_parameters.dart';
import 'package:vapourbox/models/video_job.dart';
import 'package:vapourbox/services/preview_generator.dart';

import 'support/worker_harness.dart';

String get _outDir => '${WorkerHarness.outputDir}/seek_alignment';

String _resource(String name) =>
p.join(WorkerHarness.repoRoot, 'Tests', 'TestResources', name);

/// A fixture and why it is here.
class _Fixture {
final String file;
final String why;
const _Fixture(this.file, this.why);
}

const _fixtures = [
_Fixture('small_clip.mp4', 'MP4, time base 1/2997: affected before #157'),
_Fixture('seek_timebase_2997.mov',
'MOV, time base 1/11988 (a user\'s ProRes timescale): affected before #157'),
_Fixture('face_restore_test.mp4', 'MP4, time base 1/30000: control'),
];

/// Frames checked in each fixture (all fixtures have at least 90).
const _previewFrames = [10, 41, 77];
const _trimStart = 41;
const _trimEnd = 52;

/// What the app learns from ffprobe, as `FieldOrderDetector` would send it.
class _Source {
final int width;
final int height;
final String pixFmt;
final double fps;
_Source(this.width, this.height, this.pixFmt, this.fps);
}

double _rate(String? s) {
final parts = (s ?? '0/0').split('/');
final n = double.tryParse(parts[0]) ?? 0;
final d = parts.length > 1 ? double.tryParse(parts[1]) ?? 0 : 1;
return d == 0 ? 0 : n / d;
}

Future<_Source> _probe(String path) async {
final v = await WorkerHarness.firstStream(path,
selector: 'v:0',
entries: ['width', 'height', 'pix_fmt', 'r_frame_rate', 'avg_frame_rate']);
expect(v, isNotNull, reason: 'no video stream in $path');
final r = _rate(v!['r_frame_rate'] as String?);
final a = _rate(v['avg_frame_rate'] as String?);
return _Source(v['width'] as int, v['height'] as int, v['pix_fmt'] as String,
r > 0 ? r : a);
}

VideoJob _job(String input, _Source s, String name, {int? start, int? end}) {
const off = QTGMCParameters(enabled: false);
return VideoJob(
id: const Uuid().v4(),
inputPath: input,
outputPath: '$_outDir/$name.mkv',
qtgmcParameters: off,
processingPipeline: const ProcessingPipeline(deinterlace: off),
encodingSettings: EncodingSettings(
codec: VideoCodec.ffv1,
container: ContainerFormat.mkv,
audioMode: AudioMode.none,
),
inputWidth: s.width,
inputHeight: s.height,
inputPixelFormat: s.pixFmt,
inputFrameRate: s.fps,
startFrame: start,
endFrame: end,
totalFrames: start != null && end != null ? end - start + 1 : null,
);
}

/// The app's own "before" picture of [frame], through the same argument
/// builder `PreviewGenerator.getFrameAtIndex` uses (PNG instead of JPEG, so
/// the comparison isn't measuring JPEG).
Future<Uint8List> _beforeFrame(String video, int frame, double fps) async {
final out = File(p.join(Directory.systemTemp.path,
'vb_seek_before_${DateTime.now().microsecondsSinceEpoch}.png'));
try {
final r = await Process.run(
WorkerHarness.ffmpegPath,
PreviewGenerator.beforeFrameArgs(
videoPath: video,
frameIndex: frame,
frameRate: fps,
outputPath: out.path,
),
environment: WorkerHarness.ffmpegEnv,
);
expect(r.exitCode, 0, reason: 'before frame $frame: ${r.stderr}');
return await WorkerHarness.imageToRgb24(await out.readAsBytes(), label: 'before');
} finally {
if (await out.exists()) await out.delete().catchError((_) => out);
}
}

/// Asserts [img] is source frame [n] of [refs] (sequential frames keyed by
/// index): the closest of the neighbours, and near-identical to it.
///
/// Relative, as in integration_frame_mapping_test.dart: an absolute bound
/// alone could not tell a one-frame shift from a colour conversion. The
/// absolute bound is there too, because both sides are the same decode and
/// RGB conversion of the same picture, so they agree (measured 0.0 on macOS
/// arm64).
void _expectFrame(String what, Uint8List img, int n, Map<int, Uint8List> refs) {
final diffs = {
for (final e in refs.entries) e.key: WorkerHarness.meanAbsDiff(img, e.value),
};
final best = diffs.entries.reduce((a, b) => a.value <= b.value ? a : b).key;
final table = diffs.entries
.map((e) => '${e.key}: ${e.value.toStringAsFixed(2)}')
.join(', ');
expect(best, n, reason: '$what is source frame $best, not $n ($table)');
expect(diffs[n]!, lessThan(0.5),
reason: '$what is not the same picture as source frame $n ($table)');
}

Future<Map<int, Uint8List>> _neighbours(String video, int n) async => {
for (final k in [n - 1, n, n + 1])
k: await WorkerHarness.frameRgb24(video, k, label: 'src'),
};

void main() {
setUpAll(() async {
await WorkerHarness.ensureReady();
await Directory(_outDir).create(recursive: true);
});

for (final f in _fixtures) {
group('${f.file} (${f.why})', () {
final video = _resource(f.file);
late _Source source;

setUpAll(() async {
expect(await File(video).exists(), isTrue, reason: 'missing $video');
source = await _probe(video);
});

for (final n in _previewFrames) {
test('the preview of frame $n, every pass off, is source frame $n, '
'as is the app\'s before frame', () async {
final pv = await WorkerHarness.runPreview(
_job(video, source, 'preview').toJson(),
frame: n,
label: 'seek_preview_$n');
expect(pv.success, isTrue, reason: '${pv.error}\n${pv.errorTail}');
final preview =
await WorkerHarness.imageToRgb24(pv.png!, label: 'preview');
final before = await _beforeFrame(video, n, source.fps);
final refs = await _neighbours(video, n);

_expectFrame('the preview', preview, n, refs);
_expectFrame('the before frame', before, n, refs);
});
}

test('a trimmed lossless encode of $_trimStart..$_trimEnd starts at '
'$_trimStart and ends at $_trimEnd', () async {
final r = await WorkerHarness.runJob(
_job(video, source, 'trim_${p.basenameWithoutExtension(f.file)}',
start: _trimStart, end: _trimEnd)
.toJson(),
label: 'seek_trim',
);
expect(r.success, isTrue, reason: r.error);
final out = r.outputPath!;
final count = _trimEnd - _trimStart + 1;

final frames = await WorkerHarness.firstStream(out,
selector: 'v:0', entries: ['nb_read_frames'], countFrames: true);
expect(int.parse(frames!['nb_read_frames'].toString()), count);

// The first two frames catch a doubled first frame (41, 41, 42 ..);
// the last catches the frame that doubling pushed off the end.
for (final i in [0, 1, count - 1]) {
final n = _trimStart + i;
_expectFrame('output frame $i of the trim',
await WorkerHarness.frameRgb24(out, i, label: 'trim'), n,
await _neighbours(video, n));
}
});
});
}
}
Loading
Loading