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:
+111
@@ -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 ~700–765) 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`.
|
||||||
@@ -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
|
Source-Szenen. Dadurch kann ein grob passender Dialogmoment nicht bestehen
|
||||||
bleiben, wenn ein anderes lokales Fenster derselben Szene die gesuchte
|
bleiben, wenn ein anderes lokales Fenster derselben Szene die gesuchte
|
||||||
Aktionsphase und Bewegung klarer trifft.
|
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
|
Bei blendigen oder segmentierten Beats nutzt die semantische Action-Suche den
|
||||||
ganzen Trailerbeat als Kontext. Die eigentliche Frame-Ausrichtung bleibt auf das
|
ganzen Trailerbeat als Kontext. Die eigentliche Frame-Ausrichtung bleibt auf das
|
||||||
sichtbare Segment begrenzt; der gefundene Source-Inpoint wird dabei um den
|
sichtbare Segment begrenzt; der gefundene Source-Inpoint wird dabei um den
|
||||||
Trailer-Offset des Segments verschoben. So geht die globale Aktionsbeschreibung
|
Trailer-Offset des Segments verschoben. So geht die globale Aktionsbeschreibung
|
||||||
eines Beats nicht verloren, nur weil der scorebare Teil erst nach einer Blende
|
eines Beats nicht verloren, nur weil der scorebare Teil erst nach einer Blende
|
||||||
beginnt.
|
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
|
Der Segment-Offset zählt dabei nur über vorherige scorebare Bildinseln, nicht
|
||||||
über schwarze oder blendige Lücken. Nach dem Retiming wird die nutzbare
|
ü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
|
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
|
OpenRouter-/Vision-Rate-Limits werden mit progressiv längeren Pausen erneut
|
||||||
versucht. Billing-, Credit- oder Token-Guthaben-Fehler werden dagegen sofort als
|
versucht. Billing-, Credit- oder Token-Guthaben-Fehler werden dagegen sofort als
|
||||||
echter Blocker gemeldet, weil Warten dort nicht hilft.
|
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
|
Lange Trailerbeats werden nicht mehr automatisch über ihre gesamte Beat-Länge
|
||||||
gegen einen einzigen Source-Clip validiert. Sobald nach einem sichtbaren
|
gegen einen einzigen Source-Clip validiert. Sobald nach einem sichtbaren
|
||||||
Source-Abschnitt eine anhaltende Schwarzblende oder Titel-/Credit-Insel beginnt,
|
Source-Abschnitt eine anhaltende Schwarzblende oder Titel-/Credit-Insel beginnt,
|
||||||
|
|||||||
@@ -662,11 +662,26 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg)
|
|||||||
scene = scenes_by_id.get(scene_id)
|
scene = scenes_by_id.get(scene_id)
|
||||||
if scene is None:
|
if scene is None:
|
||||||
return 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:
|
if found is None:
|
||||||
return None
|
return None
|
||||||
start_s, end_s, semantic_score, reason = found
|
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)
|
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)
|
content_offset_s = visible_content_offset(action_beat, segment_start_offset_s)
|
||||||
start_s += content_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)
|
kept.append(result)
|
||||||
continue
|
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 = []
|
windows = []
|
||||||
if getattr(result, "segments", ()):
|
if getattr(result, "segments", ()):
|
||||||
for segment in 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,
|
is_confirmed=repaired_score >= cfg.cv.deep_scan.match_threshold,
|
||||||
segments=tuple(new_segments),
|
segments=tuple(new_segments),
|
||||||
))
|
))
|
||||||
continue
|
return
|
||||||
else:
|
else:
|
||||||
repair = realign_window(beat, result.scene_id)
|
repair = realign_window(beat, result.scene_id)
|
||||||
if repair is not None:
|
if repair is not None:
|
||||||
@@ -886,13 +918,12 @@ def _filter_semantically_invalid_vision_matches(results: list, beats: list, cfg)
|
|||||||
match_score=score,
|
match_score=score,
|
||||||
is_confirmed=score >= cfg.cv.deep_scan.match_threshold,
|
is_confirmed=score >= cfg.cv.deep_scan.match_threshold,
|
||||||
))
|
))
|
||||||
continue
|
return
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Beat %d: rejected by vision action-phase verification (%s)",
|
"Beat %d: rejected by vision action-phase verification (%s)",
|
||||||
result.beat_id,
|
result.beat_id,
|
||||||
"; ".join(reasons),
|
"; ".join(reasons),
|
||||||
)
|
)
|
||||||
return kept
|
|
||||||
|
|
||||||
|
|
||||||
def _attach_visual_segments(results: list, beats: list, cfg) -> list:
|
def _attach_visual_segments(results: list, beats: list, cfg) -> list:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -193,6 +194,19 @@ def _call_vision_model(label: str, image_urls: list[str], cfg: AppConfig) -> str
|
|||||||
len(delays_s),
|
len(delays_s),
|
||||||
)
|
)
|
||||||
time.sleep(delay_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}")
|
raise RuntimeError(f"Vision request failed unexpectedly for {url}")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user