fix: 前端时钟校准问题
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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 type { NetworkTelemetry, VideoStatus } from '@/types'
|
||||
|
||||
@@ -12,6 +12,9 @@ const props = defineProps<{
|
||||
|
||||
const STATUS_REFRESH_MS = 300
|
||||
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 = {
|
||||
token: number
|
||||
@@ -23,6 +26,13 @@ type PendingInputProbe = {
|
||||
paintResolved: boolean
|
||||
}
|
||||
|
||||
type ClockCalibrationSnapshot = {
|
||||
offsetMs: number | null
|
||||
rttMs: number | null
|
||||
sampleCount: number
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
const liveVideo = ref<VideoStatus | null>(props.video)
|
||||
const frameUrl = ref(buildVideoFrameUrl(0))
|
||||
const displayVideo = computed(() => liveVideo.value ?? props.video)
|
||||
@@ -42,6 +52,13 @@ const {
|
||||
const freshness = computed(() => displayVideo.value?.freshness)
|
||||
const networkEstimate = computed(() => props.network?.latency_estimate ?? 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(() => {
|
||||
if (!displayVideo.value) {
|
||||
@@ -83,13 +100,81 @@ function wallClockNowMs() {
|
||||
let frameTimer: number | null = null
|
||||
let statusTimer: number | null = null
|
||||
let probeTimer: number | null = null
|
||||
let calibrationTimer: number | null = null
|
||||
let frameKey = 0
|
||||
let probeKey = 0
|
||||
let statusRequestPending = false
|
||||
let probeRequestPending = false
|
||||
let calibrationRequestPending = false
|
||||
let lastObservedFrameSeq: number | null = null
|
||||
let lastObservedFrameHash = ''
|
||||
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() {
|
||||
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() {
|
||||
if (!canRequestFrames.value) {
|
||||
return
|
||||
@@ -137,6 +246,17 @@ function startStatusLoop() {
|
||||
}, 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() {
|
||||
pendingInputProbe = {
|
||||
token: operatorInputSequence.value,
|
||||
@@ -227,6 +347,7 @@ async function runDisplayProbe() {
|
||||
|
||||
lastObservedFrameSeq = frameSeq
|
||||
lastObservedFrameHash = frameHashHeader
|
||||
const calibration = currentClockCalibration(paintUnixMs)
|
||||
|
||||
await postVideoDisplayProbe({
|
||||
updated_at: new Date().toISOString(),
|
||||
@@ -240,6 +361,10 @@ async function runDisplayProbe() {
|
||||
input_to_next_fresh_frame_ms: inputToNextFreshFrameMs,
|
||||
input_to_next_changed_frame_ms: inputToNextChangedFrameMs,
|
||||
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 {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
@@ -269,6 +394,7 @@ onMounted(() => {
|
||||
startStatusLoop()
|
||||
startFrameLoop()
|
||||
startProbeLoop()
|
||||
startClockCalibrationLoop()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -281,6 +407,9 @@ onUnmounted(() => {
|
||||
if (probeTimer != null) {
|
||||
window.clearInterval(probeTimer)
|
||||
}
|
||||
if (calibrationTimer != null) {
|
||||
window.clearInterval(calibrationTimer)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
|
||||
@@ -20,6 +20,19 @@ export function fetchVideoStatus() {
|
||||
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) {
|
||||
return `${API_BASE}/api/video/frame/?frame=${frameKey}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
@@ -274,7 +274,13 @@ export interface VideoStatus {
|
||||
request_to_paint_ms: number | null
|
||||
response_to_paint_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
|
||||
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?: {
|
||||
backend_ready: boolean
|
||||
|
||||
Reference in New Issue
Block a user