From 8e9e4915e216dae6c42a18950762a1bf730cbaed Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Mar 2026 23:53:10 +0800 Subject: [PATCH] fix live camera runtime refresh and title recovery --- client/src/pages/LiveCamera.tsx | 91 +++++++++++++++++++++++++++------ tests/e2e/app.spec.ts | 24 +++++++-- tests/e2e/helpers/mockApp.ts | 83 ++++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 22 deletions(-) diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 85fbe93..0eb248c 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -219,6 +219,35 @@ function formatDuration(ms: number) { return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; } +function normalizeRuntimeTitle(value: string | null | undefined) { + if (typeof value !== "string") return ""; + const trimmed = value.trim(); + if (!trimmed) return ""; + + const suspicious = /[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦]/; + if (!suspicious.test(trimmed)) { + return trimmed; + } + + try { + const bytes = Uint8Array.from(Array.from(trimmed).map((char) => char.charCodeAt(0) & 0xff)); + const decoded = new TextDecoder("utf-8").decode(bytes).trim(); + if (!decoded || decoded === trimmed) { + return trimmed; + } + + const score = (text: string) => { + const cjkCount = text.match(/[\u3400-\u9fff]/g)?.length ?? 0; + const badCount = text.match(/[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦]/g)?.length ?? 0; + return (cjkCount * 2) - badCount; + }; + + return score(decoded) > score(trimmed) ? decoded : trimmed; + } catch { + return trimmed; + } +} + function isMobileDevice() { if (typeof window === "undefined") return false; return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || window.matchMedia("(max-width: 768px)").matches; @@ -648,6 +677,8 @@ export default function LiveCamera() { const runtimeRole = (runtimeQuery.data?.role ?? "idle") as RuntimeRole; const runtimeSession = (runtimeQuery.data?.runtimeSession ?? null) as RuntimeSession | null; const runtimeSnapshot = runtimeSession?.snapshot ?? null; + const normalizedRuntimeTitle = normalizeRuntimeTitle(runtimeSession?.title); + const normalizedSnapshotTitle = normalizeRuntimeTitle(runtimeSnapshot?.title); useEffect(() => { avatarRenderRef.current = { @@ -763,6 +794,14 @@ export default function LiveCamera() { [displayVisibleSegments.length, knownRatio, liveScore?.overall, runtimeRole, runtimeSnapshot?.liveScore?.overall], ); + const refreshRuntimeState = useCallback(async () => { + const result = await runtimeQuery.refetch(); + return { + role: (result.data?.role ?? runtimeRole) as RuntimeRole, + runtimeSession: (result.data?.runtimeSession ?? runtimeSession) as RuntimeSession | null, + }; + }, [runtimeQuery, runtimeRole, runtimeSession]); + useEffect(() => { navigator.mediaDevices?.enumerateDevices().then((devices) => { const cameras = devices.filter((device) => device.kind === "videoinput"); @@ -872,7 +911,7 @@ export default function LiveCamera() { phase: phase ?? leaveStatusRef.current, startedAt: sessionStartedAtRef.current || undefined, durationMs: durationMsRef.current, - title: runtimeSession?.title ?? `实时分析 ${ACTION_META[currentActionRef.current].label}`, + title: normalizedRuntimeTitle || `实时分析 ${ACTION_META[currentActionRef.current].label}`, sessionMode: sessionModeRef.current, qualityPreset, facingMode: facing, @@ -890,7 +929,17 @@ export default function LiveCamera() { unknownSegments: segmentsRef.current.filter((segment) => segment.isUnknown).length, archivedVideoCount: archivedVideosRef.current.length, recentSegments: segmentsRef.current.slice(-5), - }), [facing, mobile, qualityPreset, runtimeSession?.title]); + }), [facing, mobile, normalizedRuntimeTitle, qualityPreset]); + + const openSetupGuide = useCallback(async () => { + const latest = await refreshRuntimeState(); + if (latest.role === "viewer") { + setShowSetupGuide(false); + toast.error("当前账号已有其他设备正在实时分析,请先切换到同步观看模式"); + return; + } + setShowSetupGuide(true); + }, [refreshRuntimeState]); const uploadLiveFrame = useCallback(async (sessionId: string) => { const compositeCanvas = ensureCompositeCanvas(); @@ -1162,7 +1211,8 @@ export default function LiveCamera() { preferredZoom = zoomTargetRef.current, preset: CameraQualityPreset = qualityPreset, ) => { - if (runtimeRole === "viewer") { + const latest = await refreshRuntimeState(); + if (latest.role === "viewer") { toast.error("当前账号已有其他设备正在实时分析,请切换到同步观看模式"); return; } @@ -1179,12 +1229,16 @@ export default function LiveCamera() { if (appliedFacingMode !== nextFacing) { setFacing(appliedFacingMode); } + setCameraActive(true); if (videoRef.current) { - videoRef.current.srcObject = stream; - await videoRef.current.play(); + try { + videoRef.current.srcObject = stream; + await videoRef.current.play().catch(() => undefined); + } catch { + // Keep the camera session alive even if preview binding is flaky on the current browser. + } } await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null); - setCameraActive(true); if (usedFallback) { toast.info("当前设备已自动切换到兼容摄像头模式"); } @@ -1192,7 +1246,7 @@ export default function LiveCamera() { } catch (error: any) { toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`); } - }, [facing, mobile, qualityPreset, runtimeRole, syncZoomState]); + }, [facing, mobile, qualityPreset, refreshRuntimeState, syncZoomState]); const switchCamera = useCallback(async () => { const nextFacing: CameraFacing = facing === "user" ? "environment" : "user"; @@ -1417,12 +1471,13 @@ export default function LiveCamera() { }, [flushSegment, liveScore, mobile, saveLiveSessionMutation, sessionMode, stopSessionRecorder]); const startAnalysis = useCallback(async () => { + const latest = await refreshRuntimeState(); if (!cameraActive || !videoRef.current || !streamRef.current) { toast.error("请先启动摄像头"); return; } if (analyzingRef.current || saving) return; - if (runtimeRole === "viewer") { + if (latest.role === "viewer") { toast.error("当前设备处于同步观看模式,不能重复开启分析"); return; } @@ -1570,10 +1625,10 @@ export default function LiveCamera() { appendFrameToSegment, cameraActive, closeBroadcastPeer, + refreshRuntimeState, releaseRuntime, runtimeAcquireMutation, runtimeQuery, - runtimeRole, saving, sessionMode, startBroadcastSession, @@ -1632,9 +1687,15 @@ export default function LiveCamera() { }, [analyzing, saving]); const handleSetupComplete = useCallback(async () => { + const latest = await refreshRuntimeState(); + if (latest.role === "viewer") { + setShowSetupGuide(false); + toast.error("当前账号已有其他设备正在实时分析,请切换到同步观看模式"); + return; + } setShowSetupGuide(false); await startCamera(facing, zoomTargetRef.current, qualityPreset); - }, [facing, qualityPreset, startCamera]); + }, [facing, qualityPreset, refreshRuntimeState, startCamera]); const displayLeaveStatus = runtimeRole === "viewer" ? (runtimeSnapshot?.phase ?? "idle") : leaveStatus; const displayAction = runtimeRole === "viewer" ? (runtimeSnapshot?.currentAction ?? "unknown") : currentAction; @@ -1673,8 +1734,8 @@ export default function LiveCamera() { const runtimeSyncDelayMs = runtimeRole === "viewer" ? getRuntimeSyncDelayMs(runtimeSession?.lastHeartbeatAt) : null; const runtimeSyncLabel = runtimeRole === "viewer" ? formatRuntimeSyncDelay(runtimeSyncDelayMs) : ""; const displayRuntimeTitle = runtimeRole === "viewer" - ? (runtimeSnapshot?.title ?? runtimeSession?.title ?? "其他设备实时分析") - : (runtimeSession?.title ?? `实时分析 ${ACTION_META[currentAction].label}`); + ? (normalizedSnapshotTitle || normalizedRuntimeTitle || "其他设备实时分析") + : (normalizedRuntimeTitle || `实时分析 ${ACTION_META[currentAction].label}`); const viewerFrameSrc = runtimeRole === "viewer" && runtimeSession?.mediaSessionId ? getMediaAssetUrl(`/assets/sessions/${runtimeSession.mediaSessionId}/live-frame.jpg?ts=${viewerFrameVersion || runtimeSnapshot?.updatedAt || Date.now()}`) : ""; @@ -1698,7 +1759,7 @@ export default function LiveCamera() { ? "准备开始实时分析" : "摄像头待启动"; - const viewerModeLabel = runtimeSession?.title || "其他设备正在实时分析"; + const viewerModeLabel = normalizedRuntimeTitle || "其他设备正在实时分析"; const renderPrimaryActions = (rail = false) => { const buttonClass = rail @@ -1738,7 +1799,7 @@ export default function LiveCamera() { ) : ( - diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 34d74d2..63496ff 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -82,7 +82,23 @@ test("live camera switches into viewer mode when another device already owns ana await expect(page.getByTestId("live-camera-score-overall")).toBeVisible(); }); -test("live camera retries viewer stream when owner track is not ready on first attempt", async ({ page }) => { +test("live camera recovers mojibake viewer titles before rendering", async ({ page }) => { + const state = await installAppMocks(page, { authenticated: true, liveViewerMode: true }); + const mojibakeTitle = Buffer.from("服务端同步烟雾测试", "utf8").toString("latin1"); + if (state.liveRuntime.runtimeSession) { + state.liveRuntime.runtimeSession.title = mojibakeTitle; + state.liveRuntime.runtimeSession.snapshot = { + ...state.liveRuntime.runtimeSession.snapshot, + title: mojibakeTitle, + }; + } + + await page.goto("/live-camera"); + await expect(page.getByRole("heading", { name: "服务端同步烟雾测试" })).toBeVisible(); + await expect(page.getByText(mojibakeTitle)).toHaveCount(0); +}); + +test("live camera no longer opens viewer peer retries when server relay is active", async ({ page }) => { const state = await installAppMocks(page, { authenticated: true, liveViewerMode: true, @@ -91,9 +107,9 @@ test("live camera retries viewer stream when owner track is not ready on first a await page.goto("/live-camera"); await expect(page.getByText("同步观看模式")).toBeVisible(); - await expect.poll(() => state.viewerSignalConflictRemaining).toBe(0); - await expect.poll(() => state.mediaSession?.viewerCount ?? 0).toBe(1); - await expect(page.getByText(/同步观看中|重新同步/).first()).toBeVisible(); + await expect.poll(() => state.viewerSignalConflictRemaining).toBe(1); + await expect.poll(() => state.mediaSession?.viewerCount ?? 0).toBe(0); + await expect(page.locator('img[alt="同步中的实时分析画面"]')).toBeVisible(); }); test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => { diff --git a/tests/e2e/helpers/mockApp.ts b/tests/e2e/helpers/mockApp.ts index c8e2380..db5bb25 100644 --- a/tests/e2e/helpers/mockApp.ts +++ b/tests/e2e/helpers/mockApp.ts @@ -866,6 +866,73 @@ export async function installAppMocks( return points; }; + class FakeVideoTrack { + kind = "video"; + enabled = true; + muted = false; + readyState = "live"; + id = "fake-video-track"; + label = "Fake Camera"; + + stop() {} + + getSettings() { + return { + facingMode: "environment", + width: 1280, + height: 720, + frameRate: 30, + }; + } + + getCapabilities() { + return {}; + } + + async applyConstraints() { + return undefined; + } + } + + class FakeAudioTrack { + kind = "audio"; + enabled = true; + muted = false; + readyState = "live"; + id = "fake-audio-track"; + label = "Fake Mic"; + + stop() {} + + getSettings() { + return {}; + } + + getCapabilities() { + return {}; + } + + async applyConstraints() { + return undefined; + } + } + + const createFakeMediaStream = (withAudio = false) => { + const videoTrack = new FakeVideoTrack(); + const audioTrack = withAudio ? new FakeAudioTrack() : null; + const tracks = audioTrack ? [videoTrack, audioTrack] : [videoTrack]; + return { + active: true, + id: `fake-stream-${Math.random().toString(36).slice(2)}`, + getTracks: () => tracks, + getVideoTracks: () => [videoTrack], + getAudioTracks: () => (audioTrack ? [audioTrack] : []), + addTrack: () => undefined, + removeTrack: () => undefined, + clone: () => createFakeMediaStream(withAudio), + } as unknown as MediaStream; + }; + class FakePose { callback = null; @@ -894,9 +961,19 @@ export async function installAppMocks( value: async () => undefined, }); + Object.defineProperty(HTMLMediaElement.prototype, "srcObject", { + configurable: true, + get() { + return (this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject ?? null; + }, + set(value) { + (this as HTMLMediaElement & { __srcObject?: MediaStream }).__srcObject = value as MediaStream; + }, + }); + Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", { configurable: true, - value: () => new MediaStream(), + value: () => createFakeMediaStream(), }); class FakeMediaRecorder extends EventTarget { @@ -961,7 +1038,7 @@ export async function installAppMocks( async setRemoteDescription(description: { type: string; sdp: string }) { this.remoteDescription = description; this.connectionState = "connected"; - this.ontrack?.({ streams: [new MediaStream()] }); + this.ontrack?.({ streams: [createFakeMediaStream()] }); this.onconnectionstatechange?.(); } @@ -984,7 +1061,7 @@ export async function installAppMocks( Object.defineProperty(navigator, "mediaDevices", { configurable: true, value: { - getUserMedia: async () => new MediaStream(), + getUserMedia: async (constraints?: { audio?: unknown }) => createFakeMediaStream(Boolean(constraints?.audio)), enumerateDevices: async () => [ { deviceId: "cam-1", kind: "videoinput", label: "Front Camera", groupId: "g1" }, { deviceId: "cam-2", kind: "videoinput", label: "Back Camera", groupId: "g1" },