diff --git a/app/contracts/frames.py b/app/contracts/frames.py index 65fe884..b7781ed 100644 --- a/app/contracts/frames.py +++ b/app/contracts/frames.py @@ -17,2 +17,6 @@ class FrameTime: time_source: str | None = None + # Private identity metadata; frame_snapshot does not export these fields to Redis. + invalidation_reason: str | None = None + invalidated_at_wall_ms: int | None = None + invalidated_frames: int = 0 diff --git a/app/event_artifact_contract.py b/app/event_artifact_contract.py index aabdc29..92f510c 100644 --- a/app/event_artifact_contract.py +++ b/app/event_artifact_contract.py @@ -96,3 +96,6 @@ def build_body(event, media, error, base, relative): if type(wall) is not int or wall <= 0: - return None + from app.event_time_fallback import fallback_time + wall = fallback_time(event) + if wall is None: + return None detail = dict(aiRecordedAtWallMs=event['aiRecordedAtWallMs'], aiRecordedStage=STAGE, @@ -103,2 +106,4 @@ def build_body(event, media, error, base, relative): artifact=media, artifactError=error) + if timing.get('detectedAtWallMs') is None and event.get('eventTimeFallback'): + detail['eventTime'] = dict(event['eventTimeFallback'], detectedAtWallMs=wall) detail['roiLabel'] = (event.get('scenarioConfig') or {}).get(event['scenario'], {}).get('roi_name', event['roiId']) diff --git a/app/event_capture.py b/app/event_capture.py index a18ab97..586cbef 100644 --- a/app/event_capture.py +++ b/app/event_capture.py @@ -99,2 +99,3 @@ class EventCapture: queued=self.results.qsize(), native_drop=self.native.lib.contextra_encoded_dropped(), + event_time_fallback=getattr(self, 'time_fallback_diagnostic', None), training=self.training.status() if self.training else None) @@ -135,2 +136,11 @@ class EventCapture: payload['scenarioConfig'] = snapshot.get('scenarioConfig') + from app.event_time_fallback import event_diagnostic, fallback_time + diagnostic = event_diagnostic(event.frame, getattr(self, 'provider_key', None)) + if diagnostic is not None: + payload['eventTimeFallback'] = diagnostic + if fallback_time(payload) is not None: + self.metrics['event_time_fallback_candidates'] += 1 + self.time_fallback_diagnostic = diagnostic + if self.metrics['event_time_fallback_candidates'] == 1: + print('[EVENT_TIME] ' + json.dumps(diagnostic, ensure_ascii=True), flush=True) accepted, reason = self.store.accept(payload) diff --git a/app/frame_time.py b/app/frame_time.py index fa3deca..6b51d76 100644 --- a/app/frame_time.py +++ b/app/frame_time.py @@ -2,2 +2,3 @@ from dataclasses import replace +import time from pydantic import BaseModel, ConfigDict, Field @@ -28,3 +29,3 @@ def packet_values(packet): class RtpFrameClock: - def __init__(self, anchor): + def __init__(self, anchor, *, clock_ns=time.time_ns): self.anchor = anchor @@ -32,2 +33,14 @@ class RtpFrameClock: self.invalidated = False + self.clock_ns = clock_ns + self.invalidation_reason = None + self.invalidated_at_wall_ms = None + self.invalidated_frames = 0 + + def _carry_fault(self, value): + if not self.invalidated or value is None: + return value + self.invalidated_frames += 1 + return replace(value, invalidation_reason=self.invalidation_reason, + invalidated_at_wall_ms=self.invalidated_at_wall_ms, + invalidated_frames=self.invalidated_frames) @@ -35,3 +48,3 @@ class RtpFrameClock: if packet is None: - return self.anchor + return self._carry_fault(self.anchor) raw, rate, ssrc = packet_values(packet) @@ -40,4 +53,10 @@ class RtpFrameClock: # Wrap/restart cannot silently reuse the previous cycle's zero Anchor. - if raw < old or rate != old_rate or ssrc != old_ssrc: + reasons = [reason for changed, reason in ( + (raw < old, 'RTP_TIMESTAMP_BACKWARD'), + (ssrc != old_ssrc, 'SSRC_CHANGED'), + (rate != old_rate, 'RTP_CLOCK_RATE_CHANGED')) if changed] + if reasons and not self.invalidated: self.invalidated = True + self.invalidation_reason = ','.join(reasons) + self.invalidated_at_wall_ms = self.clock_ns() // 1_000_000 self.previous = raw, rate, ssrc @@ -50,6 +69,6 @@ class RtpFrameClock: base = self.anchor or FrameTime() - return replace(base, anchor_rtp_timestamp=0 if self.anchor else None, + return self._carry_fault(replace(base, anchor_rtp_timestamp=0 if self.anchor else None, source_rtp_timestamp=raw, source_rtp_timestamp_ext=None, rtp_clock_rate=rate, source_time_wall_ms=wall, - sync_status='ESTIMATED' if valid else 'RAW_ONLY') + sync_status='ESTIMATED' if valid else 'RAW_ONLY')) @@ -64,4 +83,8 @@ def validate_carried_time(value, anchor): time = FrameTime(**value) + from app.event_time_fallback import validate_clock_fault + validate_clock_fault(time) if time.source_rtp_timestamp is None: - if time != anchor: + original = replace(time, invalidation_reason=None, invalidated_at_wall_ms=None, + invalidated_frames=0) + if original != anchor: raise ValueError('Unexpected unobserved Frame time.')