Match segment window by its own action phase

For segmented beats, the repair stage now searches for the source action
window using the segment's own description first; the full beat context is
used only as a fallback or when it scores noticeably higher. The trailer-
offset shift is applied only when the beat context is actually chosen.

Also harden vision-call retries to catch read-side network errors
(TimeoutError, socket.timeout, ConnectionError, OSError) and wrap the
filter/repair loop so a transient vision failure preserves the previously
cached match instead of dropping it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Melbar
2026-05-03 06:22:12 +02:00
parent 2cc05e4737
commit 7b4a98d760
4 changed files with 183 additions and 5 deletions
+111
View File
@@ -0,0 +1,111 @@
# Handover Notes
Stand: 2026-05-03 (Beat-20-Reparatur abgeschlossen).
## Zustand
- `pytest tests/ -q` → 52/52 grün.
- `python cli.py match --beat 20 --vision` läuft erfolgreich durch und schreibt
einen confirmed Match (Score 0.6632, scene 613, in=5284.706s, dur=0.88s).
- Vorheriger Cache wurde nach `.cache/match_results.json.bak` gesichert.
- Kein offener PR; lokale Änderungen sind committed (siehe letzter Commit).
## Was zuletzt geändert wurde und warum
### 1. `cli.py` — `realign_window` wählt das Action-Window pro Segment
In `_filter_semantically_invalid_vision_matches.realign_window`:
- **Vorher:** `find_action_window_in_scene(action_beat or check_beat, …)` — bei
segmentierten Beats wurde immer der ganze Beat als semantischer Kontext
benutzt. Das hat für Beat 20 die Source-Position auf die Kuss-Phase
(5270 s) gelegt, obwohl das *sichtbare* Segment nur "approaching and pulling
apart" zeigt — diese Phase liegt im Source erst um 5284 s.
- **Jetzt:** Es werden zwei Fenster gesucht (Segment-Beschreibung *und* Beat-
Beschreibung). Der Beat-Kontext gewinnt nur bei deutlichem (>0.06) Score-
Vorsprung. Der Trailer-Offset-Shift (`visible_content_offset`) wird nur
angewendet, wenn tatsächlich der Beat-Kontext benutzt wurde — sonst zeigt
das Segment-Fenster bereits auf die richtige Phase.
Effekt für Beat 20: 5270.118 → 5284.706, Score 0.6449 (provisional) → 0.6632
(confirmed).
### 2. `cli.py` — Filter-/Repair-Stufe ist crash-tolerant
`_filter_semantically_invalid_vision_matches` hat den Per-Result-Body in eine
lokale Funktion `_filter_repair_one` herausgezogen und in einen try/except
verpackt. Wenn die Reparatur abbricht (z. B. weil Vision-API mitten in der
Antwort wegfällt), wird der bisher gecachte Treffer behalten statt komplett
verworfen.
### 3. `src/llm/vision_cache.py` — Vision-Retry für Lesefehler
`_call_vision_model` fängt jetzt zusätzlich `TimeoutError`,
`socket.timeout`, `ConnectionError` und `OSError` während des Antwort-Lesens
und retryt mit demselben Backoff wie HTTP-/URL-Fehler. Die Auslöse-Bedingung
war ein 24-h-DSL-Disconnect mitten im Lauf; davor wurde der Match-Lauf hart
abgebrochen und der Cache stand auf "kein Match".
### 4. `README.md`
Zwei kurze Absätze ergänzt, die (1) die Segment-vs-Beat-Window-Auswahl und
(2) das neue Crash-/Netzfehler-Verhalten beschreiben.
## Nicht angefasst, aber relevant für die Übergabe
- Der **vollständige FFmpeg-Vollscan** liefert für Beat 20 weiterhin keinen
bestätigten Treffer (final score 0.419 < provisional 0.430). Den
Confirmed-Match liefert die Action-Window-Reparatur. Das ist erwartet:
das sichtbare Segment ist visuell sehr generisch (Two-Shot Profil mit
unscharfem Hintergrund), die korrekte Phase fällt erst durch die
semantische Aktionsbeschreibung auf.
- Die `candidate_points`-Schleife in `realign_window` (lines ~700765) sucht
nur ±~2 s um `start_s` herum. Solange `start_s` jetzt aus dem Segment-
Fenster kommt, liegt der korrekte Source-Punkt in diesem Bereich. Wenn
künftig Beats mit längeren visiblen Inseln auftauchen, kann diese Range
zu eng werden — dann den Suchradius erweitern statt das Window-Picking
rückgängig machen.
- Es gibt **keine Tests** für `_filter_semantically_invalid_vision_matches`
oder `realign_window`. Wer das anfasst, sollte Beat 20 als Live-Smoke-Test
benutzen (siehe unten).
## Reproduktion / Smoke-Test
```powershell
.\.venv\Scripts\Activate.ps1
python cli.py match --beat 20 --vision
```
Erwartet: `Beat 20: realigned semantically valid long scene by motion/action
windows`, danach `is_confirmed: true` für Beat 20 in
`.cache/match_results.json` mit `in_point_s ≈ 5284.7` und `match_score ≥ 0.65`.
Wenn das fehlschlägt:
1. `python -m pytest tests/ -q` — falls rot, ist die Codebasis selbst kaputt.
2. `.cache/vision_descriptions.json` prüfen — die Schlüssel
`beat:20:73.560:74.680:…` und `action_window:613:5282.390:5285.430:…` müssen
existieren, sonst ruft Vision live ab (kostet Credits; braucht Netz).
3. `match_results.json.bak` zurückspielen, falls der Cache zerschossen ist.
## Offene Risiken / Bekannte Schwächen
- Die Schwelle `0.06` für "Beat-Kontext gewinnt" in `realign_window` ist
kalibriert an Beat 20. Andere Beats sollten auch durchlaufen werden, bevor
weitere Beats angefasst werden — am besten ein voller `python cli.py match`
ohne `--beat` und Diff der `match_results.json` gegen `.bak`.
- Die Filter-/Repair-Stufe kann durch Vision-Calls minutenlang laufen. Das
ist nicht neu, aber bei Netzproblemen sehr sichtbar.
- Die `_filter_repair_one`-Funktion bekommt viele Argumente durchgereicht
(closure-Variablen aus dem Parent). Bei einer nächsten Iteration könnte das
in eine kleine Klasse umgebaut werden.
## Useful greps
- `find_action_window_in_scene` — semantische Action-Window-Suche (Vision).
- `_reference_scoreable_segments` — bestimmt die sichtbaren Inseln eines
Beats.
- `estimate_usable_source_duration` — kürzt Match-Clips, wenn die Source
vor Beat-Ende in eine andere Phase wechselt.
- `_filter_semantically_invalid_vision_matches` — Eintrittspunkt der
Repair-Stufe in `cli.py`.
+22
View File
@@ -185,12 +185,28 @@ Diese In-Scene-Reparatur läuft auch für semantisch gültige Treffer aus langen
Source-Szenen. Dadurch kann ein grob passender Dialogmoment nicht bestehen
bleiben, wenn ein anderes lokales Fenster derselben Szene die gesuchte
Aktionsphase und Bewegung klarer trifft.
Die Kandidatenbewertung dieser Reparatur vergleicht dabei zwei Kontexte:
den ganzen Beat als semantischen Handlungsrahmen und das konkret sichtbare
Beat-Segment als Phasenprüfung. Ein Source-Zeitpunkt muss also nicht nur
"die Szene mit dem Kuss" enthalten, sondern auch zur aktuellen Bewegungsphase
des sichtbaren Trailerabschnitts passen. Pro Kandidat fließt zusätzlich ein
lokaler Content-/Motion-Frame-Score ein, damit cached Vision-Beschreibungen
keinen sichtbar versetzten Bewegungsmoment überstimmen.
Bei blendigen oder segmentierten Beats nutzt die semantische Action-Suche den
ganzen Trailerbeat als Kontext. Die eigentliche Frame-Ausrichtung bleibt auf das
sichtbare Segment begrenzt; der gefundene Source-Inpoint wird dabei um den
Trailer-Offset des Segments verschoben. So geht die globale Aktionsbeschreibung
eines Beats nicht verloren, nur weil der scorebare Teil erst nach einer Blende
beginnt.
Die Suche nach diesem Action-Window prüft pro Segment zwei Beschreibungen:
zuerst die des konkret sichtbaren Segments (so trifft die Phasensuche genau die
gerade gezeigte Bewegung), als Rückfall die des gesamten Beats. Der Beat-
Kontext gewinnt nur, wenn er deutlich (>0.06) besser scort; sonst bleibt das
Segment-Fenster die Wahl, weil die Beat-Beschreibung Aktionen aus Fade-Bildern
mit aufnehmen kann, die im sichtbaren Segment nicht stattfinden.
Der Trailer-Offset-Shift wird nur angewendet, wenn tatsächlich der Beat-Kontext
benutzt wurde; bei segmentbasierter Wahl ist das gefundene Fenster bereits auf
die sichtbare Aktionsphase ausgerichtet.
Der Segment-Offset zählt dabei nur über vorherige scorebare Bildinseln, nicht
über schwarze oder blendige Lücken. Nach dem Retiming wird die nutzbare
Source-Dauer erneut geschätzt; läuft die Source am Ende in eine sichtbar andere
@@ -288,6 +304,12 @@ CV-Scan und darf den besseren oder bestätigten Treffer übernehmen.
OpenRouter-/Vision-Rate-Limits werden mit progressiv längeren Pausen erneut
versucht. Billing-, Credit- oder Token-Guthaben-Fehler werden dagegen sofort als
echter Blocker gemeldet, weil Warten dort nicht hilft.
Auch Netzfehler beim Lesen der Antwort (Timeouts, Verbindungsabbrüche während
einer DSL-Trennung) werden als retrybar behandelt, nicht nur Verbindungsfehler
beim Verbindungsaufbau. Schlägt die Vision-Verifikation während der finalen
Filter-/Repair-Stufe trotzdem dauerhaft fehl, wird der bisherige gecachte
Treffer für diesen Beat behalten statt verworfen — ein Netzproblem darf keinen
schon korrekt gefundenen Match aus dem Cache löschen.
Lange Trailerbeats werden nicht mehr automatisch über ihre gesamte Beat-Länge
gegen einen einzigen Source-Clip validiert. Sobald nach einem sichtbaren
Source-Abschnitt eine anhaltende Schwarzblende oder Titel-/Credit-Insel beginnt,
+36 -5
View File
@@ -662,11 +662,26 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg)
scene = scenes_by_id.get(scene_id)
if scene is None:
return None
found = find_action_window_in_scene(action_beat or check_beat, scene, cfg)
segment_window = find_action_window_in_scene(check_beat, scene, cfg)
if action_beat is not None and action_beat is not check_beat:
beat_window = find_action_window_in_scene(action_beat, scene, cfg)
else:
beat_window = None
use_beat_context = False
if segment_window is None:
found = beat_window
use_beat_context = beat_window is not None
elif beat_window is None:
found = segment_window
elif beat_window[2] > segment_window[2] + 0.06:
found = beat_window
use_beat_context = True
else:
found = segment_window
if found is None:
return None
start_s, end_s, semantic_score, reason = found
if action_beat is not None:
if use_beat_context:
segment_start_offset_s = max(0.0, check_beat.start_s - action_beat.start_s)
content_offset_s = visible_content_offset(action_beat, segment_start_offset_s)
start_s += content_offset_s
@@ -713,6 +728,23 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg)
kept.append(result)
continue
kept_before = len(kept)
try:
_filter_repair_one(result, beat, beats_by_id, scenes_by_id, kept, cfg, realign_window, validate_match_window_with_vision, logger)
except Exception as exc:
logger.warning(
"Beat %d: vision filter/repair failed (%s); keeping previous cached match.",
result.beat_id,
exc,
)
del kept[kept_before:]
kept.append(result)
return kept
def _filter_repair_one(result, beat, beats_by_id, scenes_by_id, kept, cfg, realign_window, validate_match_window_with_vision, logger):
from dataclasses import replace
if True:
windows = []
if getattr(result, "segments", ()):
for segment in result.segments:
@@ -867,7 +899,7 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg)
is_confirmed=repaired_score >= cfg.cv.deep_scan.match_threshold,
segments=tuple(new_segments),
))
continue
return
else:
repair = realign_window(beat, result.scene_id)
if repair is not None:
@@ -886,13 +918,12 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg)
match_score=score,
is_confirmed=score >= cfg.cv.deep_scan.match_threshold,
))
continue
return
logger.warning(
"Beat %d: rejected by vision action-phase verification (%s)",
result.beat_id,
"; ".join(reasons),
)
return kept
def _attach_visual_segments(results: list, beats: list, cfg) -> list:
+14
View File
@@ -13,6 +13,7 @@ import base64
import json
import logging
import re
import socket
import time
import urllib.error
import urllib.request
@@ -193,6 +194,19 @@ def _call_vision_model(label: str, image_urls: list[str], cfg: AppConfig) -> str
len(delays_s),
)
time.sleep(delay_s)
except (TimeoutError, socket.timeout, ConnectionError, OSError) as exc:
if attempt >= len(delays_s):
raise RuntimeError(f"Vision request failed for {url}: {exc}") from exc
delay_s = delays_s[attempt]
logger.warning(
"Vision request network error for %s (%s); waiting %.0fs before retry %d/%d.",
label,
exc,
delay_s,
attempt + 1,
len(delays_s),
)
time.sleep(delay_s)
raise RuntimeError(f"Vision request failed unexpectedly for {url}")