Backend (Phase A): - A1: Adaptive silence buffer — natural_gap_ms persisted per cue; renderer computes per-cue silence_before/silence_after instead of fixed 500ms; per-cue silence files - A2: Forward-preferred snap — snap_pause_point prefers boundaries up to 4s ahead over boundaries within 1.5s behind, reducing mid-scene cuts - A3: Min-gap validation — pause points with < 200ms gap trigger forward search to the next acceptable gap - natural_gap_ms added to PausePointData model and api.ts type - New config fields: whisper_snap_forward_window, whisper_snap_backward_window, ad_silence_buffer_default, ad_silence_buffer_min_after, ad_min_acceptable_gap - Tests: test_whisper_snap.py (13 tests), test_video_renderer_buffers.py Frontend (Phase B): - B1: Drag pause-point markers — pointer state machine with 3px move threshold, clamp to min/max bounds, click-without-move still opens PausePointEditor - B2: Drag freeze blocks — orange blocks translate with linked pause point - B3: Time tooltip visible during drag, hidden on release - Tests: TimelinePreview.drag.test.tsx (10 tests) Fixes: - Share link pointed to ai-sandbox.oliver.solutions — added app_url to Settings with correct optical-dev.oliver.solutions default; share_url now configurable via APP_URL env var - Removed all ai-sandbox.oliver.solutions references from docker-compose, apache config, docs, and scripts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
231 lines
9.3 KiB
Python
231 lines
9.3 KiB
Python
"""Tests for the improved snap_pause_point algorithm (A1/A2/A3)."""
|
||
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
# faster_whisper ships only in the Docker image; stub it so pytest can run locally.
|
||
if 'faster_whisper' not in sys.modules:
|
||
sys.modules['faster_whisper'] = MagicMock()
|
||
|
||
import pytest
|
||
from app.services.whisper_service import (
|
||
WhisperService,
|
||
WordTimestamp,
|
||
SpeechGap,
|
||
SentenceBoundary,
|
||
)
|
||
|
||
|
||
# ── fixtures ────────────────────────────────────────────────────────────────
|
||
|
||
@pytest.fixture
|
||
def svc():
|
||
"""WhisperService instance with default settings."""
|
||
return WhisperService()
|
||
|
||
|
||
def _word(start: float, end: float, text: str = "word") -> WordTimestamp:
|
||
return WordTimestamp(word=text, start=start, end=end)
|
||
|
||
|
||
def _gap(start: float, end: float, gap_type: str = "sentence") -> SpeechGap:
|
||
return SpeechGap(start=start, end=end, duration=end - start, gap_type=gap_type)
|
||
|
||
|
||
def _boundary(
|
||
time: float,
|
||
btype: str = "sentence_end",
|
||
has_prev: bool = True,
|
||
has_next: bool = True,
|
||
gap: SpeechGap | None = None,
|
||
) -> SentenceBoundary:
|
||
return SentenceBoundary(
|
||
time=time,
|
||
boundary_type=btype,
|
||
word_index=0,
|
||
has_previous_sentence=has_prev,
|
||
has_next_sentence=has_next,
|
||
gap=gap,
|
||
)
|
||
|
||
|
||
# ── A2: forward-preferred snap ───────────────────────────────────────────────
|
||
|
||
class TestForwardPreferredSnap:
|
||
def test_picks_forward_over_equidistant_backward(self, svc):
|
||
"""Gemini=10.5s; forward boundary@11.2s and backward@9.8s — must pick forward."""
|
||
gap = _gap(11.2, 11.8)
|
||
boundaries = [
|
||
_boundary(9.8, gap=_gap(9.8, 10.0)),
|
||
_boundary(11.2, gap=gap),
|
||
]
|
||
words = [_word(9.0, 9.5), _word(10.0, 10.5), _word(11.0, 11.2)]
|
||
gaps = [_gap(9.8, 10.0), gap]
|
||
|
||
pause, _, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
assert pause == pytest.approx(11.5, abs=0.01) # midpoint of 11.2–11.8
|
||
assert warning is None
|
||
|
||
def test_forward_boundary_within_window_is_preferred(self, svc):
|
||
"""Even a slightly farther forward boundary beats a closer backward one."""
|
||
gap_fwd = _gap(12.0, 12.6)
|
||
gap_bwd = _gap(10.1, 10.4)
|
||
boundaries = [
|
||
_boundary(10.1, gap=gap_bwd),
|
||
_boundary(12.0, gap=gap_fwd),
|
||
]
|
||
words = [_word(9.0, 10.1), _word(10.5, 12.0)]
|
||
gaps = [gap_bwd, gap_fwd]
|
||
|
||
pause, _, _, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
assert pause == pytest.approx(12.3, abs=0.01) # midpoint of 12.0–12.6
|
||
|
||
def test_falls_back_to_backward_when_no_forward_within_window(self, svc):
|
||
"""No forward boundary within snap_forward_window → use backward (within 1.5s)."""
|
||
# Boundary at 9.2s: distance = 10.5 - 9.2 = 1.3s ≤ snap_backward_window (1.5s) ✓
|
||
gap = _gap(9.0, 9.4)
|
||
boundaries = [_boundary(9.0, gap=gap)]
|
||
words = [_word(7.0, 9.0), _word(9.4, 10.5)]
|
||
gaps = [gap]
|
||
|
||
pause, _, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
assert pause == pytest.approx(9.2, abs=0.01) # midpoint of 9.0–9.4
|
||
|
||
def test_no_boundary_in_any_window_returns_gemini_with_warning(self, svc):
|
||
"""Boundary exists but outside both windows → exact Gemini point + warning."""
|
||
# Put boundaries 10s away in both directions (beyond any window)
|
||
boundaries = [
|
||
_boundary(0.1, gap=_gap(0.0, 0.5)),
|
||
_boundary(50.0, gap=_gap(49.0, 50.0)),
|
||
]
|
||
words = [_word(9.0, 12.0)]
|
||
gaps = []
|
||
|
||
pause, resume, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
assert pause == pytest.approx(10.5)
|
||
assert warning is not None
|
||
assert "snap windows" in warning.lower()
|
||
|
||
def test_not_during_speaking_uses_exact_point(self, svc):
|
||
"""Pause point far from all words → no snap, exact point returned."""
|
||
boundaries = [_boundary(5.0, gap=_gap(4.8, 5.3))]
|
||
words = [_word(0.0, 3.0)] # speech ends at 3s; pause at 7s
|
||
# Gap covers 3.0–10.0; pause at 7.0 is inside it
|
||
gaps = [_gap(3.0, 10.0)]
|
||
|
||
pause, _, warning, natural_gap_ms = svc.snap_pause_point(7.0, words, gaps, boundaries)
|
||
|
||
assert pause == pytest.approx(7.0)
|
||
assert warning is None
|
||
# natural_gap covers the pause (7.0 is inside gap 3.0–10.0)
|
||
assert natural_gap_ms > 0
|
||
|
||
|
||
# ── A1: natural_gap_ms returned correctly ───────────────────────────────────
|
||
|
||
class TestNaturalGapMs:
|
||
def test_case_c_returns_gap_duration(self, svc):
|
||
"""Case C (gap midpoint) must return gap.duration * 1000 as natural_gap_ms."""
|
||
gap = _gap(10.0, 11.2)
|
||
boundaries = [_boundary(10.0, gap=gap)]
|
||
words = [_word(9.0, 10.0), _word(11.2, 12.0)]
|
||
gaps = [gap]
|
||
|
||
_, _, _, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
assert natural_gap_ms == pytest.approx(1200.0, abs=1.0) # 1.2s gap
|
||
|
||
def test_no_gap_returns_zero(self, svc):
|
||
"""Fallback case with no gap → natural_gap_ms == 0."""
|
||
b = _boundary(10.0, gap=None) # no gap attached
|
||
words = [_word(9.0, 10.1), _word(10.1, 11.0)]
|
||
gaps = []
|
||
|
||
_, _, _, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, [b])
|
||
|
||
assert natural_gap_ms == 0.0
|
||
|
||
def test_not_during_speaking_reads_gap_from_gaps_list(self, svc):
|
||
"""Not-during-speaking path should read natural gap from the gaps list."""
|
||
gap = _gap(5.0, 6.0) # covers pause at 5.5s
|
||
words = [_word(0.0, 3.0)] # all speech before 3s
|
||
gaps = [gap]
|
||
|
||
_, _, _, natural_gap_ms = svc.snap_pause_point(5.5, words, gaps, [])
|
||
|
||
assert natural_gap_ms == pytest.approx(1000.0, abs=1.0)
|
||
|
||
|
||
# ── A3: minimum gap validation ───────────────────────────────────────────────
|
||
|
||
class TestMinGapValidation:
|
||
def test_short_gap_triggers_forward_search(self, svc):
|
||
"""Case C gap < min_acceptable_gap → searches forward for a better gap."""
|
||
short_gap = _gap(10.0, 10.1) # 0.1s < 0.2s threshold
|
||
good_gap = _gap(11.5, 12.0) # 0.5s — acceptable
|
||
boundaries = [_boundary(10.0, gap=short_gap)]
|
||
words = [_word(9.0, 10.0), _word(10.2, 11.5)]
|
||
gaps = [short_gap, good_gap]
|
||
|
||
pause, _, _, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
# Should snap forward to midpoint of good_gap (11.5+12.0)/2 = 11.75
|
||
assert pause == pytest.approx(11.75, abs=0.01)
|
||
assert natural_gap_ms == pytest.approx(500.0, abs=1.0)
|
||
|
||
def test_short_gap_no_forward_alternative_keeps_original(self, svc):
|
||
"""Short gap, no acceptable gap ahead → stays at original point with warning."""
|
||
short_gap = _gap(10.0, 10.1)
|
||
boundaries = [_boundary(10.0, gap=short_gap)]
|
||
words = [_word(9.0, 10.0), _word(10.2, 14.0)]
|
||
gaps = [short_gap] # no other gap
|
||
|
||
pause, _, warning, _ = svc.snap_pause_point(10.5, words, gaps, boundaries)
|
||
|
||
# Falls back to midpoint of short_gap since no alternative
|
||
assert pause == pytest.approx(10.05, abs=0.01)
|
||
assert warning is None # no warning for "stayed at original"
|
||
|
||
def test_fallback_no_gap_triggers_forward_search(self, svc):
|
||
"""Fallback case (no gap on boundary) with no nearby gap → searches forward."""
|
||
b = _boundary(10.0, gap=None)
|
||
good_gap = _gap(11.0, 11.8)
|
||
words = [_word(9.0, 10.0), _word(10.1, 11.0)]
|
||
gaps = [good_gap]
|
||
|
||
pause, _, warning, natural_gap_ms = svc.snap_pause_point(10.5, words, gaps, [b])
|
||
|
||
assert pause == pytest.approx(11.4, abs=0.01)
|
||
assert natural_gap_ms == pytest.approx(800.0, abs=1.0)
|
||
assert warning is not None # warns that it snapped forward
|
||
|
||
|
||
# ── refine_all_pause_points integration ─────────────────────────────────────
|
||
|
||
class TestRefineAllPausePointsIntegration:
|
||
def test_stores_natural_gap_ms_on_placement(self, svc):
|
||
"""refine_all_pause_points must persist natural_gap_ms onto each placement."""
|
||
gap = _gap(10.0, 11.0)
|
||
words = [_word(8.0, 10.0), _word(11.0, 12.0)]
|
||
gaps = [gap]
|
||
placements = [{"ad_cue_index": 0, "pause_point": 10.5, "ad_duration": 3.0}]
|
||
|
||
refined, _ = svc.refine_all_pause_points(placements, words, gaps)
|
||
|
||
assert "natural_gap_ms" in refined[0]
|
||
assert refined[0]["natural_gap_ms"] == pytest.approx(1000.0, abs=1.0)
|
||
|
||
def test_no_whisper_data_returns_original_with_zero_gap(self, svc):
|
||
"""No words → _is_during_speaking=False → exact point, no warning, natural_gap_ms=0."""
|
||
placements = [{"ad_cue_index": 0, "pause_point": 5.0, "ad_duration": 2.0}]
|
||
|
||
refined, warnings = svc.refine_all_pause_points(placements, [], [])
|
||
|
||
assert refined[0]["pause_point"] == pytest.approx(5.0)
|
||
assert refined[0].get("natural_gap_ms", 0) == 0.0
|
||
# No words → not-during-speaking path → no snap → no warning
|
||
assert len(warnings) == 0
|