From f9db6ef5903daede0966c99313dfff3f743b034a Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 16 Mar 2026 18:05:58 +0800 Subject: [PATCH] fix live analysis multi-device lock --- client/src/pages/LiveCamera.tsx | 50 +++++++++++++++++++++------- client/src/pages/Recorder.tsx | 58 ++++++++++++++++++++++++++++++--- server/_core/index.ts | 27 +++++++++++---- server/_core/sdk.test.ts | 57 ++++++++++++++++++++++++++++++++ server/_core/sdk.ts | 7 +++- tests/e2e/app.spec.ts | 22 +++++++++++++ tests/e2e/helpers/mockApp.ts | 28 ++++++++++++---- 7 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 server/_core/sdk.test.ts diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 1335809..f88daaf 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -549,6 +549,7 @@ export default function LiveCamera() { const broadcastSessionIdRef = useRef(null); const viewerPeerRef = useRef(null); const viewerSessionIdRef = useRef(null); + const viewerRetryTimerRef = useRef(0); const runtimeIdRef = useRef(null); const heartbeatTimerRef = useRef(0); const recorderRef = useRef(null); @@ -883,6 +884,10 @@ export default function LiveCamera() { }, []); const closeViewerPeer = useCallback(() => { + if (viewerRetryTimerRef.current) { + window.clearTimeout(viewerRetryTimerRef.current); + viewerRetryTimerRef.current = 0; + } viewerSessionIdRef.current = null; if (viewerPeerRef.current) { viewerPeerRef.current.ontrack = null; @@ -1026,15 +1031,22 @@ export default function LiveCamera() { await peer.setLocalDescription(offer); await waitForIceGathering(peer); - const answer = await signalMediaViewerSession(mediaSessionId, { - sdp: peer.localDescription?.sdp || "", - type: peer.localDescription?.type || "offer", - }); + try { + const answer = await signalMediaViewerSession(mediaSessionId, { + sdp: peer.localDescription?.sdp || "", + type: peer.localDescription?.type || "offer", + }); - await peer.setRemoteDescription({ - type: answer.type as RTCSdpType, - sdp: answer.sdp, - }); + await peer.setRemoteDescription({ + type: answer.type as RTCSdpType, + sdp: answer.sdp, + }); + } catch (error) { + if (viewerPeerRef.current === peer) { + closeViewerPeer(); + } + throw error; + } }, [closeViewerPeer]); const stopCamera = useCallback(() => { @@ -1087,11 +1099,27 @@ export default function LiveCamera() { void startViewerStream(runtimeSession.mediaSessionId).catch((error: any) => { const message = error?.message || "同步画面连接失败"; - if (!/409/.test(message)) { - setViewerError(message); + if (/409|viewer stream not ready/i.test(message)) { + setViewerError("持有端正在准备同步画面,正在自动重试..."); + if (!viewerRetryTimerRef.current) { + viewerRetryTimerRef.current = window.setTimeout(() => { + viewerRetryTimerRef.current = 0; + void runtimeQuery.refetch(); + }, 1200); + } + return; } + setViewerError(message); }); - }, [cameraActive, closeViewerPeer, runtimeRole, runtimeSession?.mediaSessionId, startViewerStream]); + }, [ + cameraActive, + closeViewerPeer, + runtimeQuery.refetch, + runtimeQuery.dataUpdatedAt, + runtimeRole, + runtimeSession?.mediaSessionId, + startViewerStream, + ]); useEffect(() => { return () => { diff --git a/client/src/pages/Recorder.tsx b/client/src/pages/Recorder.tsx index b91a092..bfb0cd2 100644 --- a/client/src/pages/Recorder.tsx +++ b/client/src/pages/Recorder.tsx @@ -189,6 +189,10 @@ function summarizeActions(actionSummary: Record) { export default function Recorder() { const { user } = useAuth(); const utils = trpc.useUtils(); + const runtimeQuery = trpc.analysis.runtimeGet.useQuery(undefined, { + refetchInterval: 1000, + refetchIntervalInBackground: true, + }); const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({ onSuccess: (data) => { setArchiveTaskId(data.taskId); @@ -262,6 +266,9 @@ export default function Recorder() { const mobile = useMemo(() => isMobileDevice(), []); const mimeType = useMemo(() => pickRecorderMimeType(), []); + const runtimeRole = runtimeQuery.data?.role ?? "idle"; + const liveAnalysisRuntime = runtimeQuery.data?.runtimeSession; + const liveAnalysisOccupied = runtimeRole === "viewer" && liveAnalysisRuntime?.status === "active"; const currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || ""; const archiveTaskQuery = useBackgroundTask(archiveTaskId); const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession); @@ -402,6 +409,11 @@ export default function Recorder() { preferredZoom = zoomTargetRef.current, preset: keyof typeof QUALITY_PRESETS = qualityPreset, ) => { + if (liveAnalysisOccupied) { + const title = liveAnalysisRuntime?.title || "其他设备正在实时分析"; + toast.error(`${title},当前设备不能再开启录制摄像头`); + throw new Error("当前账号已有其他设备正在实时分析"); + } try { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); @@ -440,7 +452,7 @@ export default function Recorder() { toast.error(`摄像头启动失败: ${message}`); throw error; } - }), [facingMode, mobile, qualityPreset, syncZoomState]); + }), [facingMode, liveAnalysisOccupied, liveAnalysisRuntime?.title, mobile, qualityPreset, syncZoomState]); const ensurePreviewStream = useCallback(async () => { if (streamRef.current) { @@ -849,6 +861,11 @@ export default function Recorder() { toast.error("请先登录后再开始录制"); return; } + if (liveAnalysisOccupied) { + const title = liveAnalysisRuntime?.title || "其他设备正在实时分析"; + toast.error(`${title},当前设备不能同时开始录制`); + return; + } try { setMode("preparing"); @@ -898,7 +915,21 @@ export default function Recorder() { setMode("idle"); toast.error(`启动录制失败: ${error?.message || "未知错误"}`); } - }, [ensurePreviewStream, facingMode, mimeType, mobile, qualityPreset, startActionSampling, startRealtimePush, startRecorderLoop, syncSessionState, title, user]); + }, [ + ensurePreviewStream, + facingMode, + liveAnalysisOccupied, + liveAnalysisRuntime?.title, + mimeType, + mobile, + qualityPreset, + startActionSampling, + startRealtimePush, + startRecorderLoop, + syncSessionState, + title, + user, + ]); const finishRecording = useCallback(async () => { const session = currentSessionRef.current; @@ -1140,9 +1171,10 @@ export default function Recorder() { data-testid="recorder-start-camera-button" onClick={() => void startCamera()} className={buttonClass()} + disabled={liveAnalysisOccupied} > - {labelFor("启动摄像头", "启动")} + {labelFor(liveAnalysisOccupied ? "实时分析占用中" : "启动摄像头", liveAnalysisOccupied ? "占用" : "启动")} ) : ( <> @@ -1150,9 +1182,10 @@ export default function Recorder() { data-testid="recorder-start-recording-button" onClick={() => void beginRecording()} className={buttonClass("record")} + disabled={liveAnalysisOccupied} > - {labelFor("开始录制", "录制")} + {labelFor(liveAnalysisOccupied ? "实时分析占用中" : "开始录制", liveAnalysisOccupied ? "占用" : "录制")}