Added automatic offset detection using the difference between the audio and video stream ends.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""Verifies the stream-ends offset estimate: it lands on the known offset of
|
||||
each synthetic fixture, it feeds Preview/Export exactly like a
|
||||
marker-derived offset does (including with no markers set at all), and
|
||||
Use Marker Offset brings the marker-derived value back afterwards without
|
||||
re-marking -- while the manual marker workflow itself behaves as before.
|
||||
|
||||
Also covers the real-world shape this feature exists for, which none of
|
||||
the fixtures in test_media have: both tracks start at t=0 but the audio
|
||||
ends early (it started capturing late, and the recorder stopped both
|
||||
together). Generated on the fly as a 60s clip, long enough that the probe
|
||||
only reads the tail window instead of the whole file.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
import main as app_main
|
||||
from stream_edges import probe_stream_edges, TAIL_WINDOW_SEC
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MEDIA = ROOT / "test_media"
|
||||
|
||||
|
||||
def wait_for(predicate, qapp, timeout=20):
|
||||
deadline = time.time() + timeout
|
||||
while not predicate() and time.time() < deadline:
|
||||
qapp.processEvents()
|
||||
time.sleep(0.05)
|
||||
return predicate()
|
||||
|
||||
|
||||
def load(win, qapp, path):
|
||||
win._load(str(path))
|
||||
qapp.processEvents()
|
||||
time.sleep(0.2)
|
||||
qapp.processEvents()
|
||||
|
||||
|
||||
def estimate(win, qapp):
|
||||
win.estimate_button.click()
|
||||
qapp.processEvents()
|
||||
assert wait_for(lambda: win.estimate_button.isEnabled(), qapp), "estimate did not finish in time"
|
||||
|
||||
|
||||
def make_audio_ends_early_clip(path: Path):
|
||||
subprocess.run([
|
||||
"ffmpeg", "-y", "-v", "error",
|
||||
"-f", "lavfi", "-i", "testsrc=size=320x240:rate=30:duration=60",
|
||||
"-f", "lavfi", "-i", "sine=frequency=1000:duration=59",
|
||||
"-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac",
|
||||
str(path),
|
||||
], check=True)
|
||||
|
||||
|
||||
def main():
|
||||
# -- Probe alone, against every fixture's known offset.
|
||||
expected = {
|
||||
"sync_test_INSYNC.mp4": 0,
|
||||
"sync_test_OUTOFSYNC_audio+350ms.mp4": -350,
|
||||
"sync_test_OUTOFSYNC_video+350ms.mp4": 350,
|
||||
}
|
||||
for name, want in expected.items():
|
||||
edges = probe_stream_edges(str(MEDIA / name))
|
||||
print(f"{name}: end-based {edges.end_delay_ms:+.1f} ms, start-based {edges.start_delay_ms:+.1f} ms")
|
||||
assert abs(edges.end_delay_ms - want) < 5, f"{name}: expected ~{want} ms"
|
||||
print("PASS: end-based estimate matches every fixture's known offset")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
clip = Path(tmp) / "audio_ends_early.mp4"
|
||||
make_audio_ends_early_clip(clip)
|
||||
assert 60 > TAIL_WINDOW_SEC, "clip should be long enough to exercise the tail-window read"
|
||||
edges = probe_stream_edges(str(clip))
|
||||
print(f"audio-ends-early clip: {edges}, end-based {edges.end_delay_ms:+.1f} ms")
|
||||
assert abs(edges.end_delay_ms - 1000) < 30, "expected ~+1000 ms (delay audio)"
|
||||
assert edges.estimates_disagree, "starts both at 0 but ends 1s apart -- should flag disagreement"
|
||||
print("PASS: audio-ends-early clip estimates ~+1000 ms and flags the start/end disagreement")
|
||||
|
||||
qapp = QApplication(sys.argv)
|
||||
win = app_main.MainWindow()
|
||||
win.show()
|
||||
qapp.processEvents()
|
||||
|
||||
assert not win.estimate_button.isEnabled(), "estimate needs a loaded file"
|
||||
assert not win.use_marker_offset_button.isEnabled()
|
||||
|
||||
# -- Estimate with no markers: enables Preview/Export on its own.
|
||||
load(win, qapp, MEDIA / "sync_test_OUTOFSYNC_video+350ms.mp4")
|
||||
assert win.estimate_button.isEnabled() and win.estimate_action.isEnabled()
|
||||
assert not win.export_button.isEnabled() and not win.preview_button.isEnabled()
|
||||
estimate(win, qapp)
|
||||
print(f"offset label after estimate: {win.offset_label.text()!r}")
|
||||
assert win.offset.delay_ms == 350
|
||||
assert win.override_spinbox.value() == 350 and win.override_spinbox.isEnabled()
|
||||
assert win.export_button.isEnabled() and win.preview_button.isEnabled()
|
||||
assert "estimated from stream ends" in win.offset_label.text()
|
||||
assert not win.use_marker_offset_button.isEnabled(), "no markers yet, nothing to go back to"
|
||||
assert win.video_mark is None and win.audio_mark is None, "estimate must not touch the markers"
|
||||
print("PASS: estimate with no markers sets the offset and enables Preview/Export")
|
||||
|
||||
# -- Preview works from an estimate with no markers (centered on the playhead).
|
||||
win.source_panel.player.seek(5.0)
|
||||
qapp.processEvents()
|
||||
win.preview_button.click()
|
||||
qapp.processEvents()
|
||||
assert wait_for(lambda: win.video_tabs.currentIndex() == 1, qapp), "preview did not build"
|
||||
print("PASS: Preview builds from an estimated offset with no markers set")
|
||||
win.video_tabs.setCurrentIndex(0)
|
||||
|
||||
# -- Manual markers behave as before and override the estimate.
|
||||
load(win, qapp, MEDIA / "sync_test_OUTOFSYNC_audio+350ms.mp4")
|
||||
assert win.offset_label.text() == "Offset: — (mark both events)"
|
||||
win.source_panel.player.seek(5.000)
|
||||
qapp.processEvents()
|
||||
win._mark_video()
|
||||
win.source_panel.player.seek(5.350)
|
||||
qapp.processEvents()
|
||||
win._mark_audio()
|
||||
marker_label = win.offset_label.text()
|
||||
print(f"offset label from markers: {marker_label!r}")
|
||||
assert abs(win.offset.delay_ms - (-350)) < 40
|
||||
assert marker_label == win.offset.describe()
|
||||
assert win.use_marker_offset_button.isEnabled()
|
||||
marker_delay = win.offset.delay_ms
|
||||
print("PASS: manual markers set the offset exactly as before")
|
||||
|
||||
# -- Estimate replaces it; Use Marker Offset restores it without re-marking.
|
||||
estimate(win, qapp)
|
||||
assert win.offset.delay_ms == -349, f"got {win.offset.delay_ms}"
|
||||
assert win.override_spinbox.value() == -349
|
||||
assert win.video_mark is not None and win.audio_mark is not None, "estimate must not clear markers"
|
||||
win.use_marker_offset_button.click()
|
||||
qapp.processEvents()
|
||||
assert win.offset.delay_ms == marker_delay
|
||||
assert win.override_spinbox.value() == round(marker_delay)
|
||||
assert win.offset_label.text() == marker_label
|
||||
print("PASS: Use Marker Offset restores the marker-derived offset after an estimate")
|
||||
|
||||
# -- Loading a new file resets everything, including Use Marker Offset.
|
||||
load(win, qapp, MEDIA / "sync_test_INSYNC.mp4")
|
||||
assert not win.use_marker_offset_button.isEnabled()
|
||||
assert not win.export_button.isEnabled()
|
||||
estimate(win, qapp)
|
||||
assert win.offset.delay_ms == 0
|
||||
print("PASS: new file resets marker state; in-sync file estimates 0 ms")
|
||||
|
||||
win.close()
|
||||
qapp.processEvents()
|
||||
print("\nSTREAM ENDS ESTIMATE SMOKE TEST PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user