fix: 前端时钟校准问题
This commit is contained in:
@@ -7,6 +7,7 @@ urlpatterns = [
|
|||||||
path("dashboard/", views.dashboard_snapshot, name="dashboard-snapshot"),
|
path("dashboard/", views.dashboard_snapshot, name="dashboard-snapshot"),
|
||||||
path("gps/latest/", views.gps_latest, name="gps-latest"),
|
path("gps/latest/", views.gps_latest, name="gps-latest"),
|
||||||
path("network/latest/", views.network_latest, name="network-latest"),
|
path("network/latest/", views.network_latest, name="network-latest"),
|
||||||
|
path("clock/calibrate/", views.clock_calibration, name="clock-calibration"),
|
||||||
path("video/status/", views.video_status, name="video-status"),
|
path("video/status/", views.video_status, name="video-status"),
|
||||||
path("video/frame/", views.video_frame, name="video-frame"),
|
path("video/frame/", views.video_frame, name="video-frame"),
|
||||||
path("video/display-probe/", views.video_display_probe, name="video-display-probe"),
|
path("video/display-probe/", views.video_display_probe, name="video-display-probe"),
|
||||||
|
|||||||
@@ -51,7 +51,13 @@ class VideoDisplayProbeStore:
|
|||||||
request_to_paint_ms=None,
|
request_to_paint_ms=None,
|
||||||
response_to_paint_ms=None,
|
response_to_paint_ms=None,
|
||||||
backend_to_request_ms=None,
|
backend_to_request_ms=None,
|
||||||
|
backend_to_request_ms_raw=None,
|
||||||
|
backend_to_paint_ms=None,
|
||||||
backend_to_paint_ms_raw=None,
|
backend_to_paint_ms_raw=None,
|
||||||
|
browser_backend_clock_offset_ms=None,
|
||||||
|
browser_backend_clock_rtt_ms=None,
|
||||||
|
browser_backend_clock_sample_count=0,
|
||||||
|
browser_backend_clock_calibrated_at=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def record_event(self, payload: dict[str, Any]) -> None:
|
def record_event(self, payload: dict[str, Any]) -> None:
|
||||||
@@ -59,6 +65,10 @@ class VideoDisplayProbeStore:
|
|||||||
request_started_unix_ms = payload.get("request_started_unix_ms")
|
request_started_unix_ms = payload.get("request_started_unix_ms")
|
||||||
response_received_unix_ms = payload.get("response_received_unix_ms")
|
response_received_unix_ms = payload.get("response_received_unix_ms")
|
||||||
paint_unix_ms = payload.get("paint_unix_ms")
|
paint_unix_ms = payload.get("paint_unix_ms")
|
||||||
|
browser_backend_clock_offset_ms = self._coerce_float(payload.get("browser_backend_clock_offset_ms"))
|
||||||
|
browser_backend_clock_rtt_ms = self._coerce_float(payload.get("browser_backend_clock_rtt_ms"))
|
||||||
|
browser_backend_clock_sample_count = self._coerce_int(payload.get("browser_backend_clock_sample_count"))
|
||||||
|
browser_backend_clock_calibrated_at = self._coerce_text(payload.get("browser_backend_clock_calibrated_at"))
|
||||||
request_to_paint_ms = self._duration_ms(paint_unix_ms, request_started_unix_ms, clamp_floor_zero=True)
|
request_to_paint_ms = self._duration_ms(paint_unix_ms, request_started_unix_ms, clamp_floor_zero=True)
|
||||||
response_to_paint_ms = self._duration_ms(paint_unix_ms, response_received_unix_ms, clamp_floor_zero=True)
|
response_to_paint_ms = self._duration_ms(paint_unix_ms, response_received_unix_ms, clamp_floor_zero=True)
|
||||||
backend_received_unix_ms = None
|
backend_received_unix_ms = None
|
||||||
@@ -67,11 +77,24 @@ class VideoDisplayProbeStore:
|
|||||||
backend_received_unix_ms = int(backend_received_unix_ns) / 1_000_000.0
|
backend_received_unix_ms = int(backend_received_unix_ns) / 1_000_000.0
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
backend_received_unix_ms = None
|
backend_received_unix_ms = None
|
||||||
backend_to_request_ms = self._duration_ms(request_started_unix_ms, backend_received_unix_ms, clamp_floor_zero=False)
|
backend_received_browser_unix_ms = None
|
||||||
|
if backend_received_unix_ms is not None and browser_backend_clock_offset_ms is not None:
|
||||||
|
backend_received_browser_unix_ms = round(backend_received_unix_ms + browser_backend_clock_offset_ms, 3)
|
||||||
|
backend_to_request_ms_raw = self._duration_ms(request_started_unix_ms, backend_received_unix_ms, clamp_floor_zero=False)
|
||||||
backend_to_paint_ms_raw = self._duration_ms(paint_unix_ms, backend_received_unix_ms, clamp_floor_zero=False)
|
backend_to_paint_ms_raw = self._duration_ms(paint_unix_ms, backend_received_unix_ms, clamp_floor_zero=False)
|
||||||
|
backend_to_request_ms = self._duration_ms(
|
||||||
|
request_started_unix_ms,
|
||||||
|
backend_received_browser_unix_ms,
|
||||||
|
clamp_floor_zero=True,
|
||||||
|
)
|
||||||
|
backend_to_paint_ms = self._duration_ms(
|
||||||
|
paint_unix_ms,
|
||||||
|
backend_received_browser_unix_ms,
|
||||||
|
clamp_floor_zero=True,
|
||||||
|
)
|
||||||
|
|
||||||
status = VideoDisplayProbeStatus(
|
status = VideoDisplayProbeStatus(
|
||||||
updated_at=str(payload.get("updated_at") or ""),
|
updated_at=self._coerce_text(payload.get("updated_at")),
|
||||||
frame_seq=int(payload["frame_seq"]) if payload.get("frame_seq") is not None else None,
|
frame_seq=int(payload["frame_seq"]) if payload.get("frame_seq") is not None else None,
|
||||||
frame_hash=str(payload.get("frame_hash") or ""),
|
frame_hash=str(payload.get("frame_hash") or ""),
|
||||||
input_to_next_fresh_frame_ms=self._coerce_float(payload.get("input_to_next_fresh_frame_ms")),
|
input_to_next_fresh_frame_ms=self._coerce_float(payload.get("input_to_next_fresh_frame_ms")),
|
||||||
@@ -80,11 +103,29 @@ class VideoDisplayProbeStore:
|
|||||||
request_to_paint_ms=request_to_paint_ms,
|
request_to_paint_ms=request_to_paint_ms,
|
||||||
response_to_paint_ms=response_to_paint_ms,
|
response_to_paint_ms=response_to_paint_ms,
|
||||||
backend_to_request_ms=backend_to_request_ms,
|
backend_to_request_ms=backend_to_request_ms,
|
||||||
|
backend_to_request_ms_raw=backend_to_request_ms_raw,
|
||||||
|
backend_to_paint_ms=backend_to_paint_ms,
|
||||||
backend_to_paint_ms_raw=backend_to_paint_ms_raw,
|
backend_to_paint_ms_raw=backend_to_paint_ms_raw,
|
||||||
|
browser_backend_clock_offset_ms=browser_backend_clock_offset_ms,
|
||||||
|
browser_backend_clock_rtt_ms=browser_backend_clock_rtt_ms,
|
||||||
|
browser_backend_clock_sample_count=browser_backend_clock_sample_count,
|
||||||
|
browser_backend_clock_calibrated_at=browser_backend_clock_calibrated_at,
|
||||||
|
)
|
||||||
|
logged_payload = dict(payload)
|
||||||
|
logged_payload.update(
|
||||||
|
{
|
||||||
|
"request_to_paint_ms": request_to_paint_ms,
|
||||||
|
"response_to_paint_ms": response_to_paint_ms,
|
||||||
|
"backend_received_browser_unix_ms": backend_received_browser_unix_ms,
|
||||||
|
"backend_to_request_ms": backend_to_request_ms,
|
||||||
|
"backend_to_request_ms_raw": backend_to_request_ms_raw,
|
||||||
|
"backend_to_paint_ms": backend_to_paint_ms,
|
||||||
|
"backend_to_paint_ms_raw": backend_to_paint_ms_raw,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest = status
|
self._latest = status
|
||||||
self._logger.write(payload)
|
self._logger.write(logged_payload)
|
||||||
|
|
||||||
def get_status(self) -> dict[str, Any]:
|
def get_status(self) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -99,7 +140,13 @@ class VideoDisplayProbeStore:
|
|||||||
"request_to_paint_ms": latest.request_to_paint_ms,
|
"request_to_paint_ms": latest.request_to_paint_ms,
|
||||||
"response_to_paint_ms": latest.response_to_paint_ms,
|
"response_to_paint_ms": latest.response_to_paint_ms,
|
||||||
"backend_to_request_ms": latest.backend_to_request_ms,
|
"backend_to_request_ms": latest.backend_to_request_ms,
|
||||||
|
"backend_to_request_ms_raw": latest.backend_to_request_ms_raw,
|
||||||
|
"backend_to_paint_ms": latest.backend_to_paint_ms,
|
||||||
"backend_to_paint_ms_raw": latest.backend_to_paint_ms_raw,
|
"backend_to_paint_ms_raw": latest.backend_to_paint_ms_raw,
|
||||||
|
"browser_backend_clock_offset_ms": latest.browser_backend_clock_offset_ms,
|
||||||
|
"browser_backend_clock_rtt_ms": latest.browser_backend_clock_rtt_ms,
|
||||||
|
"browser_backend_clock_sample_count": latest.browser_backend_clock_sample_count,
|
||||||
|
"browser_backend_clock_calibrated_at": latest.browser_backend_clock_calibrated_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
@@ -114,6 +161,20 @@ class VideoDisplayProbeStore:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_int(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_text(value: Any) -> str | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _duration_ms(cls, end_ms: Any, start_ms: Any, *, clamp_floor_zero: bool) -> float | None:
|
def _duration_ms(cls, end_ms: Any, start_ms: Any, *, clamp_floor_zero: bool) -> float | None:
|
||||||
end_value = cls._coerce_float(end_ms)
|
end_value = cls._coerce_float(end_ms)
|
||||||
@@ -152,7 +213,13 @@ class VideoDisplayProbeStatus:
|
|||||||
request_to_paint_ms: float | None
|
request_to_paint_ms: float | None
|
||||||
response_to_paint_ms: float | None
|
response_to_paint_ms: float | None
|
||||||
backend_to_request_ms: float | None
|
backend_to_request_ms: float | None
|
||||||
|
backend_to_request_ms_raw: float | None
|
||||||
|
backend_to_paint_ms: float | None
|
||||||
backend_to_paint_ms_raw: float | None
|
backend_to_paint_ms_raw: float | None
|
||||||
|
browser_backend_clock_offset_ms: float | None
|
||||||
|
browser_backend_clock_rtt_ms: float | None
|
||||||
|
browser_backend_clock_sample_count: int
|
||||||
|
browser_backend_clock_calibrated_at: str | None
|
||||||
|
|
||||||
|
|
||||||
class OmniSocketVideoReceiver:
|
class OmniSocketVideoReceiver:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.http import HttpResponse, StreamingHttpResponse
|
from django.http import HttpResponse, StreamingHttpResponse
|
||||||
@@ -31,6 +32,20 @@ def network_latest(request):
|
|||||||
return Response(network_service.get_latest())
|
return Response(network_service.get_latest())
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
def clock_calibration(request):
|
||||||
|
server_received_unix_ns = time.time_ns()
|
||||||
|
server_sent_unix_ns = time.time_ns()
|
||||||
|
response = Response(
|
||||||
|
{
|
||||||
|
"server_received_unix_ms": round(server_received_unix_ns / 1_000_000.0, 3),
|
||||||
|
"server_sent_unix_ms": round(server_sent_unix_ns / 1_000_000.0, 3),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
def video_status(request):
|
def video_status(request):
|
||||||
return Response(video_service.get_status())
|
return Response(video_service.get_status())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { buildVideoFrameUrl, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
|
import { buildVideoFrameUrl, fetchClockCalibrationSample, fetchVideoStatus, postVideoDisplayProbe } from '@/lib/api'
|
||||||
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
|
import { useOperatorInputTelemetry } from '@/composables/useControlInterface'
|
||||||
import type { NetworkTelemetry, VideoStatus } from '@/types'
|
import type { NetworkTelemetry, VideoStatus } from '@/types'
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const STATUS_REFRESH_MS = 300
|
const STATUS_REFRESH_MS = 300
|
||||||
const DISPLAY_PROBE_INTERVAL_MS = 200
|
const DISPLAY_PROBE_INTERVAL_MS = 200
|
||||||
|
const CLOCK_CALIBRATION_INTERVAL_MS = 1000
|
||||||
|
const CLOCK_CALIBRATION_SAMPLE_WINDOW = 9
|
||||||
|
const CLOCK_CALIBRATION_STALE_MS = CLOCK_CALIBRATION_INTERVAL_MS * 3
|
||||||
|
|
||||||
type PendingInputProbe = {
|
type PendingInputProbe = {
|
||||||
token: number
|
token: number
|
||||||
@@ -23,6 +26,13 @@ type PendingInputProbe = {
|
|||||||
paintResolved: boolean
|
paintResolved: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClockCalibrationSnapshot = {
|
||||||
|
offsetMs: number | null
|
||||||
|
rttMs: number | null
|
||||||
|
sampleCount: number
|
||||||
|
updatedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
const liveVideo = ref<VideoStatus | null>(props.video)
|
const liveVideo = ref<VideoStatus | null>(props.video)
|
||||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||||
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
||||||
@@ -42,6 +52,13 @@ const {
|
|||||||
const freshness = computed(() => displayVideo.value?.freshness)
|
const freshness = computed(() => displayVideo.value?.freshness)
|
||||||
const networkEstimate = computed(() => props.network?.latency_estimate ?? null)
|
const networkEstimate = computed(() => props.network?.latency_estimate ?? null)
|
||||||
const senderClockDebug = computed(() => displayVideo.value?.timing ?? null)
|
const senderClockDebug = computed(() => displayVideo.value?.timing ?? null)
|
||||||
|
const EMPTY_CLOCK_CALIBRATION: ClockCalibrationSnapshot = {
|
||||||
|
offsetMs: null,
|
||||||
|
rttMs: null,
|
||||||
|
sampleCount: 0,
|
||||||
|
updatedAt: null,
|
||||||
|
}
|
||||||
|
const clockCalibration = ref<ClockCalibrationSnapshot>({ ...EMPTY_CLOCK_CALIBRATION })
|
||||||
|
|
||||||
const modeLabel = computed(() => {
|
const modeLabel = computed(() => {
|
||||||
if (!displayVideo.value) {
|
if (!displayVideo.value) {
|
||||||
@@ -83,13 +100,81 @@ function wallClockNowMs() {
|
|||||||
let frameTimer: number | null = null
|
let frameTimer: number | null = null
|
||||||
let statusTimer: number | null = null
|
let statusTimer: number | null = null
|
||||||
let probeTimer: number | null = null
|
let probeTimer: number | null = null
|
||||||
|
let calibrationTimer: number | null = null
|
||||||
let frameKey = 0
|
let frameKey = 0
|
||||||
let probeKey = 0
|
let probeKey = 0
|
||||||
let statusRequestPending = false
|
let statusRequestPending = false
|
||||||
let probeRequestPending = false
|
let probeRequestPending = false
|
||||||
|
let calibrationRequestPending = false
|
||||||
let lastObservedFrameSeq: number | null = null
|
let lastObservedFrameSeq: number | null = null
|
||||||
let lastObservedFrameHash = ''
|
let lastObservedFrameHash = ''
|
||||||
let pendingInputProbe: PendingInputProbe | null = null
|
let pendingInputProbe: PendingInputProbe | null = null
|
||||||
|
let clockOffsetSamples: number[] = []
|
||||||
|
let clockRttSamples: number[] = []
|
||||||
|
|
||||||
|
function boundedMedian(samples: number[]) {
|
||||||
|
if (samples.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const sorted = [...samples].sort((left, right) => left - right)
|
||||||
|
const middle = Math.floor(sorted.length / 2)
|
||||||
|
if (sorted.length % 2 === 1) {
|
||||||
|
return sorted[middle] ?? null
|
||||||
|
}
|
||||||
|
const left = sorted[middle - 1]
|
||||||
|
const right = sorted[middle]
|
||||||
|
if (left == null || right == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (left + right) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearClockCalibration() {
|
||||||
|
clockOffsetSamples = []
|
||||||
|
clockRttSamples = []
|
||||||
|
clockCalibration.value = { ...EMPTY_CLOCK_CALIBRATION }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClockCalibrationFresh(snapshot: ClockCalibrationSnapshot, nowMs = wallClockNowMs()) {
|
||||||
|
if (snapshot.offsetMs == null || snapshot.rttMs == null || !snapshot.updatedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const updatedAtMs = Date.parse(snapshot.updatedAt)
|
||||||
|
if (!Number.isFinite(updatedAtMs)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return nowMs - updatedAtMs <= CLOCK_CALIBRATION_STALE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireClockCalibrationIfStale(nowMs = wallClockNowMs()) {
|
||||||
|
if (clockCalibration.value.sampleCount > 0 && !isClockCalibrationFresh(clockCalibration.value, nowMs)) {
|
||||||
|
clearClockCalibration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentClockCalibration(nowMs = wallClockNowMs()) {
|
||||||
|
expireClockCalibrationIfStale(nowMs)
|
||||||
|
return clockCalibration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClockCalibration(offsetMs: number, rttMs: number) {
|
||||||
|
clockOffsetSamples.push(offsetMs)
|
||||||
|
clockRttSamples.push(rttMs)
|
||||||
|
if (clockOffsetSamples.length > CLOCK_CALIBRATION_SAMPLE_WINDOW) {
|
||||||
|
clockOffsetSamples = clockOffsetSamples.slice(-CLOCK_CALIBRATION_SAMPLE_WINDOW)
|
||||||
|
}
|
||||||
|
if (clockRttSamples.length > CLOCK_CALIBRATION_SAMPLE_WINDOW) {
|
||||||
|
clockRttSamples = clockRttSamples.slice(-CLOCK_CALIBRATION_SAMPLE_WINDOW)
|
||||||
|
}
|
||||||
|
const medianOffset = boundedMedian(clockOffsetSamples)
|
||||||
|
const medianRtt = boundedMedian(clockRttSamples)
|
||||||
|
clockCalibration.value = {
|
||||||
|
offsetMs: medianOffset == null ? null : Number(medianOffset.toFixed(3)),
|
||||||
|
rttMs: medianRtt == null ? null : Number(medianRtt.toFixed(3)),
|
||||||
|
sampleCount: clockOffsetSamples.length,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStatus() {
|
async function refreshStatus() {
|
||||||
if (statusRequestPending) {
|
if (statusRequestPending) {
|
||||||
@@ -105,6 +190,30 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runClockCalibration() {
|
||||||
|
if (calibrationRequestPending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
calibrationRequestPending = true
|
||||||
|
const clientSendUnixMs = wallClockNowMs()
|
||||||
|
expireClockCalibrationIfStale(clientSendUnixMs)
|
||||||
|
try {
|
||||||
|
const sample = await fetchClockCalibrationSample()
|
||||||
|
const clientRecvUnixMs = wallClockNowMs()
|
||||||
|
if (!Number.isFinite(sample.server_received_unix_ms) || !Number.isFinite(sample.server_sent_unix_ms)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const offsetMs = ((clientSendUnixMs - sample.server_received_unix_ms) + (clientRecvUnixMs - sample.server_sent_unix_ms)) / 2
|
||||||
|
const rawRttMs = (clientRecvUnixMs - clientSendUnixMs) - (sample.server_sent_unix_ms - sample.server_received_unix_ms)
|
||||||
|
updateClockCalibration(Number(offsetMs.toFixed(3)), Number(Math.max(0, rawRttMs).toFixed(3)))
|
||||||
|
} catch {
|
||||||
|
// Calibration is best-effort only.
|
||||||
|
} finally {
|
||||||
|
expireClockCalibrationIfStale()
|
||||||
|
calibrationRequestPending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function refreshFrame() {
|
function refreshFrame() {
|
||||||
if (!canRequestFrames.value) {
|
if (!canRequestFrames.value) {
|
||||||
return
|
return
|
||||||
@@ -137,6 +246,17 @@ function startStatusLoop() {
|
|||||||
}, STATUS_REFRESH_MS)
|
}, STATUS_REFRESH_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startClockCalibrationLoop() {
|
||||||
|
if (calibrationTimer != null) {
|
||||||
|
window.clearInterval(calibrationTimer)
|
||||||
|
calibrationTimer = null
|
||||||
|
}
|
||||||
|
void runClockCalibration()
|
||||||
|
calibrationTimer = window.setInterval(() => {
|
||||||
|
void runClockCalibration()
|
||||||
|
}, CLOCK_CALIBRATION_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
function maybeTrackOperatorInput() {
|
function maybeTrackOperatorInput() {
|
||||||
pendingInputProbe = {
|
pendingInputProbe = {
|
||||||
token: operatorInputSequence.value,
|
token: operatorInputSequence.value,
|
||||||
@@ -227,6 +347,7 @@ async function runDisplayProbe() {
|
|||||||
|
|
||||||
lastObservedFrameSeq = frameSeq
|
lastObservedFrameSeq = frameSeq
|
||||||
lastObservedFrameHash = frameHashHeader
|
lastObservedFrameHash = frameHashHeader
|
||||||
|
const calibration = currentClockCalibration(paintUnixMs)
|
||||||
|
|
||||||
await postVideoDisplayProbe({
|
await postVideoDisplayProbe({
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
@@ -240,6 +361,10 @@ async function runDisplayProbe() {
|
|||||||
input_to_next_fresh_frame_ms: inputToNextFreshFrameMs,
|
input_to_next_fresh_frame_ms: inputToNextFreshFrameMs,
|
||||||
input_to_next_changed_frame_ms: inputToNextChangedFrameMs,
|
input_to_next_changed_frame_ms: inputToNextChangedFrameMs,
|
||||||
input_to_next_paint_ms: inputToNextPaintMs,
|
input_to_next_paint_ms: inputToNextPaintMs,
|
||||||
|
browser_backend_clock_offset_ms: calibration.offsetMs,
|
||||||
|
browser_backend_clock_rtt_ms: calibration.rttMs,
|
||||||
|
browser_backend_clock_sample_count: calibration.sampleCount,
|
||||||
|
browser_backend_clock_calibrated_at: calibration.updatedAt,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
@@ -269,6 +394,7 @@ onMounted(() => {
|
|||||||
startStatusLoop()
|
startStatusLoop()
|
||||||
startFrameLoop()
|
startFrameLoop()
|
||||||
startProbeLoop()
|
startProbeLoop()
|
||||||
|
startClockCalibrationLoop()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -281,6 +407,9 @@ onUnmounted(() => {
|
|||||||
if (probeTimer != null) {
|
if (probeTimer != null) {
|
||||||
window.clearInterval(probeTimer)
|
window.clearInterval(probeTimer)
|
||||||
}
|
}
|
||||||
|
if (calibrationTimer != null) {
|
||||||
|
window.clearInterval(calibrationTimer)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ export function fetchVideoStatus() {
|
|||||||
return fetchJson<VideoStatus>('/api/video/status/')
|
return fetchJson<VideoStatus>('/api/video/status/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchClockCalibrationSample() {
|
||||||
|
const response = await fetch(`${API_BASE}/api/clock/calibrate/`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`clock calibration failed: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
return response.json() as Promise<{
|
||||||
|
server_received_unix_ms: number
|
||||||
|
server_sent_unix_ms: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
export function buildVideoFrameUrl(frameKey: number) {
|
export function buildVideoFrameUrl(frameKey: number) {
|
||||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,7 +274,13 @@ export interface VideoStatus {
|
|||||||
request_to_paint_ms: number | null
|
request_to_paint_ms: number | null
|
||||||
response_to_paint_ms: number | null
|
response_to_paint_ms: number | null
|
||||||
backend_to_request_ms: number | null
|
backend_to_request_ms: number | null
|
||||||
|
backend_to_request_ms_raw: number | null
|
||||||
|
backend_to_paint_ms: number | null
|
||||||
backend_to_paint_ms_raw: number | null
|
backend_to_paint_ms_raw: number | null
|
||||||
|
browser_backend_clock_offset_ms: number | null
|
||||||
|
browser_backend_clock_rtt_ms: number | null
|
||||||
|
browser_backend_clock_sample_count: number
|
||||||
|
browser_backend_clock_calibrated_at: string | null
|
||||||
}
|
}
|
||||||
receiver?: {
|
receiver?: {
|
||||||
backend_ready: boolean
|
backend_ready: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user