fix live analysis multi-device lock

这个提交包含在:
cryptocommuniums-afk
2026-03-16 18:05:58 +08:00
父节点 13e59b8e8a
当前提交 f9db6ef590
修改 7 个文件,包含 221 行新增28 行删除

查看文件

@@ -549,6 +549,7 @@ export default function LiveCamera() {
const broadcastSessionIdRef = useRef<string | null>(null); const broadcastSessionIdRef = useRef<string | null>(null);
const viewerPeerRef = useRef<RTCPeerConnection | null>(null); const viewerPeerRef = useRef<RTCPeerConnection | null>(null);
const viewerSessionIdRef = useRef<string | null>(null); const viewerSessionIdRef = useRef<string | null>(null);
const viewerRetryTimerRef = useRef<number>(0);
const runtimeIdRef = useRef<number | null>(null); const runtimeIdRef = useRef<number | null>(null);
const heartbeatTimerRef = useRef<number>(0); const heartbeatTimerRef = useRef<number>(0);
const recorderRef = useRef<MediaRecorder | null>(null); const recorderRef = useRef<MediaRecorder | null>(null);
@@ -883,6 +884,10 @@ export default function LiveCamera() {
}, []); }, []);
const closeViewerPeer = useCallback(() => { const closeViewerPeer = useCallback(() => {
if (viewerRetryTimerRef.current) {
window.clearTimeout(viewerRetryTimerRef.current);
viewerRetryTimerRef.current = 0;
}
viewerSessionIdRef.current = null; viewerSessionIdRef.current = null;
if (viewerPeerRef.current) { if (viewerPeerRef.current) {
viewerPeerRef.current.ontrack = null; viewerPeerRef.current.ontrack = null;
@@ -1026,6 +1031,7 @@ export default function LiveCamera() {
await peer.setLocalDescription(offer); await peer.setLocalDescription(offer);
await waitForIceGathering(peer); await waitForIceGathering(peer);
try {
const answer = await signalMediaViewerSession(mediaSessionId, { const answer = await signalMediaViewerSession(mediaSessionId, {
sdp: peer.localDescription?.sdp || "", sdp: peer.localDescription?.sdp || "",
type: peer.localDescription?.type || "offer", type: peer.localDescription?.type || "offer",
@@ -1035,6 +1041,12 @@ export default function LiveCamera() {
type: answer.type as RTCSdpType, type: answer.type as RTCSdpType,
sdp: answer.sdp, sdp: answer.sdp,
}); });
} catch (error) {
if (viewerPeerRef.current === peer) {
closeViewerPeer();
}
throw error;
}
}, [closeViewerPeer]); }, [closeViewerPeer]);
const stopCamera = useCallback(() => { const stopCamera = useCallback(() => {
@@ -1087,11 +1099,27 @@ export default function LiveCamera() {
void startViewerStream(runtimeSession.mediaSessionId).catch((error: any) => { void startViewerStream(runtimeSession.mediaSessionId).catch((error: any) => {
const message = error?.message || "同步画面连接失败"; const message = error?.message || "同步画面连接失败";
if (!/409/.test(message)) { if (/409|viewer stream not ready/i.test(message)) {
setViewerError(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(() => { useEffect(() => {
return () => { return () => {

查看文件

@@ -189,6 +189,10 @@ function summarizeActions(actionSummary: Record<ActionType, number>) {
export default function Recorder() { export default function Recorder() {
const { user } = useAuth(); const { user } = useAuth();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const runtimeQuery = trpc.analysis.runtimeGet.useQuery(undefined, {
refetchInterval: 1000,
refetchIntervalInBackground: true,
});
const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({ const finalizeTaskMutation = trpc.task.createMediaFinalize.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setArchiveTaskId(data.taskId); setArchiveTaskId(data.taskId);
@@ -262,6 +266,9 @@ export default function Recorder() {
const mobile = useMemo(() => isMobileDevice(), []); const mobile = useMemo(() => isMobileDevice(), []);
const mimeType = useMemo(() => pickRecorderMimeType(), []); 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 currentPlaybackUrl = mediaSession?.playback.mp4Url || mediaSession?.playback.webmUrl || "";
const archiveTaskQuery = useBackgroundTask(archiveTaskId); const archiveTaskQuery = useBackgroundTask(archiveTaskId);
const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession); const archiveProgress = archiveTaskQuery.data?.progress ?? getArchiveProgress(mediaSession);
@@ -402,6 +409,11 @@ export default function Recorder() {
preferredZoom = zoomTargetRef.current, preferredZoom = zoomTargetRef.current,
preset: keyof typeof QUALITY_PRESETS = qualityPreset, preset: keyof typeof QUALITY_PRESETS = qualityPreset,
) => { ) => {
if (liveAnalysisOccupied) {
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
toast.error(`${title},当前设备不能再开启录制摄像头`);
throw new Error("当前账号已有其他设备正在实时分析");
}
try { try {
if (streamRef.current) { if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current.getTracks().forEach((track) => track.stop());
@@ -440,7 +452,7 @@ export default function Recorder() {
toast.error(`摄像头启动失败: ${message}`); toast.error(`摄像头启动失败: ${message}`);
throw error; throw error;
} }
}), [facingMode, mobile, qualityPreset, syncZoomState]); }), [facingMode, liveAnalysisOccupied, liveAnalysisRuntime?.title, mobile, qualityPreset, syncZoomState]);
const ensurePreviewStream = useCallback(async () => { const ensurePreviewStream = useCallback(async () => {
if (streamRef.current) { if (streamRef.current) {
@@ -849,6 +861,11 @@ export default function Recorder() {
toast.error("请先登录后再开始录制"); toast.error("请先登录后再开始录制");
return; return;
} }
if (liveAnalysisOccupied) {
const title = liveAnalysisRuntime?.title || "其他设备正在实时分析";
toast.error(`${title},当前设备不能同时开始录制`);
return;
}
try { try {
setMode("preparing"); setMode("preparing");
@@ -898,7 +915,21 @@ export default function Recorder() {
setMode("idle"); setMode("idle");
toast.error(`启动录制失败: ${error?.message || "未知错误"}`); 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 finishRecording = useCallback(async () => {
const session = currentSessionRef.current; const session = currentSessionRef.current;
@@ -1140,9 +1171,10 @@ export default function Recorder() {
data-testid="recorder-start-camera-button" data-testid="recorder-start-camera-button"
onClick={() => void startCamera()} onClick={() => void startCamera()}
className={buttonClass()} className={buttonClass()}
disabled={liveAnalysisOccupied}
> >
<Camera className={iconClass} /> <Camera className={iconClass} />
{labelFor("启动摄像头", "启动")} {labelFor(liveAnalysisOccupied ? "实时分析占用中" : "启动摄像头", liveAnalysisOccupied ? "占用" : "启动")}
</Button> </Button>
) : ( ) : (
<> <>
@@ -1150,9 +1182,10 @@ export default function Recorder() {
data-testid="recorder-start-recording-button" data-testid="recorder-start-recording-button"
onClick={() => void beginRecording()} onClick={() => void beginRecording()}
className={buttonClass("record")} className={buttonClass("record")}
disabled={liveAnalysisOccupied}
> >
<Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} /> <Circle className={`${iconClass} ${rail ? "fill-current" : "fill-current"}`} />
{labelFor("开始录制", "录制")} {labelFor(liveAnalysisOccupied ? "实时分析占用中" : "开始录制", liveAnalysisOccupied ? "占用" : "录制")}
</Button> </Button>
<Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}> <Button variant="outline" onClick={stopCamera} className={buttonClass("outline")}>
<VideoOff className={iconClass} /> <VideoOff className={iconClass} />
@@ -1362,6 +1395,23 @@ export default function Recorder() {
</Alert> </Alert>
) : null} ) : null}
{liveAnalysisOccupied ? (
<Alert className="border-amber-300/70 bg-amber-50 text-amber-950">
<ShieldAlert className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
{liveAnalysisRuntime?.title || "其他设备正在实时分析"}
{" "}
<a href="/live-camera" className="font-medium underline underline-offset-4">
</a>
{" "}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]"> <div className="grid gap-4 xl:grid-cols-[minmax(0,1.7fr)_minmax(340px,0.9fr)]">
<section className="space-y-4"> <section className="space-y-4">
<Card className="overflow-hidden border-0 shadow-lg"> <Card className="overflow-hidden border-0 shadow-lg">

查看文件

@@ -13,6 +13,26 @@ import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, se
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { syncTutorialImages } from "../tutorialImages"; import { syncTutorialImages } from "../tutorialImages";
async function warmupApplicationData() {
const tasks: Array<{ label: string; run: () => Promise<unknown> }> = [
{ label: "seedTutorials", run: () => seedTutorials() },
{ label: "syncTutorialImages", run: () => syncTutorialImages() },
{ label: "seedVisionReferenceImages", run: () => seedVisionReferenceImages() },
{ label: "seedAchievementDefinitions", run: () => seedAchievementDefinitions() },
{ label: "seedAppSettings", run: () => seedAppSettings() },
];
for (const task of tasks) {
const startedAt = Date.now();
try {
await task.run();
console.log(`[startup] ${task.label} finished in ${Date.now() - startedAt}ms`);
} catch (error) {
console.error(`[startup] ${task.label} failed`, error);
}
}
}
async function scheduleDailyNtrpRefresh() { async function scheduleDailyNtrpRefresh() {
const now = new Date(); const now = new Date();
if (now.getHours() !== 0 || now.getMinutes() > 5) { if (now.getHours() !== 0 || now.getMinutes() > 5) {
@@ -64,12 +84,6 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
} }
async function startServer() { async function startServer() {
await seedTutorials();
await syncTutorialImages();
await seedVisionReferenceImages();
await seedAchievementDefinitions();
await seedAppSettings();
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
registerMediaProxy(app); registerMediaProxy(app);
@@ -108,6 +122,7 @@ async function startServer() {
server.listen(port, () => { server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`); console.log(`Server running on http://localhost:${port}/`);
void warmupApplicationData();
}); });
setInterval(() => { setInterval(() => {

57
server/_core/sdk.test.ts 普通文件
查看文件

@@ -0,0 +1,57 @@
import { SignJWT } from "jose";
import { describe, expect, it, vi } from "vitest";
async function loadSdkForTest() {
process.env.JWT_SECRET = "test-cookie-secret";
process.env.VITE_APP_ID = "test-app";
vi.resetModules();
const [{ sdk }, { ENV }] = await Promise.all([
import("./sdk"),
import("./env"),
]);
return { sdk, ENV };
}
async function signLegacyToken(openId: string, appId: string, name: string) {
const secret = new TextEncoder().encode(process.env.JWT_SECRET || "");
return new SignJWT({
openId,
appId,
name,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(Math.floor((Date.now() + 60_000) / 1000))
.sign(secret);
}
describe("sdk.verifySession", () => {
it("derives a stable legacy sid when the token payload does not include sid", async () => {
const { sdk, ENV } = await loadSdkForTest();
const legacyToken = await signLegacyToken("username_H1_legacy", ENV.appId, "H1");
const session = await sdk.verifySession(legacyToken);
expect(session).not.toBeNull();
expect(session?.sid).toMatch(/^legacy-token:/);
expect(session?.sid).toHaveLength("legacy-token:".length + 32);
});
it("derives different legacy sid values for different legacy login tokens", async () => {
const firstLoad = await loadSdkForTest();
const tokenA = await signLegacyToken("username_H1_legacy", firstLoad.ENV.appId, "H1");
await new Promise((resolve) => setTimeout(resolve, 5));
const secondLoad = await loadSdkForTest();
const tokenB = await signLegacyToken("username_H1_legacy", secondLoad.ENV.appId, "H1-second");
const sessionA = await firstLoad.sdk.verifySession(tokenA);
const sessionB = await secondLoad.sdk.verifySession(tokenB);
expect(sessionA?.sid).toMatch(/^legacy-token:/);
expect(sessionB?.sid).toMatch(/^legacy-token:/);
expect(sessionA?.sid).not.toBe(sessionB?.sid);
});
});

查看文件

@@ -4,6 +4,7 @@ import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie"; import { parse as parseCookieHeader } from "cookie";
import type { Request } from "express"; import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { createHash } from "node:crypto";
import type { User } from "../../drizzle/schema"; import type { User } from "../../drizzle/schema";
import * as db from "../db"; import * as db from "../db";
import { ENV } from "./env"; import { ENV } from "./env";
@@ -223,11 +224,15 @@ class SDKServer {
return null; return null;
} }
const derivedSid = typeof sid === "string" && sid.length > 0
? sid
: `legacy-token:${createHash("sha256").update(cookieValue).digest("hex").slice(0, 32)}`;
return { return {
openId, openId,
appId, appId,
name: typeof name === "string" ? name : undefined, name: typeof name === "string" ? name : undefined,
sid: typeof sid === "string" ? sid : undefined, sid: derivedSid,
}; };
} catch (error) { } catch (error) {
console.warn("[Auth] Session verification failed", String(error)); console.warn("[Auth] Session verification failed", String(error));

查看文件

@@ -78,6 +78,20 @@ test("live camera switches into viewer mode when another device already owns ana
await expect(page.getByTestId("live-camera-score-overall")).toBeVisible(); 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 }) => {
const state = await installAppMocks(page, {
authenticated: true,
liveViewerMode: true,
viewerSignalConflictOnce: true,
});
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();
});
test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => { test("live camera archives overlay videos into the library after analysis stops", async ({ page }) => {
await installAppMocks(page, { authenticated: true, videos: [] }); await installAppMocks(page, { authenticated: true, videos: [] });
@@ -126,3 +140,11 @@ test("recorder flow archives a session and exposes it in videos", async ({ page
await expect(page.getByTestId("video-card")).toHaveCount(1); await expect(page.getByTestId("video-card")).toHaveCount(1);
await expect(page.getByText("E2E 录制")).toBeVisible(); await expect(page.getByText("E2E 录制")).toBeVisible();
}); });
test("recorder blocks local camera when another device owns live analysis", async ({ page }) => {
await installAppMocks(page, { authenticated: true, liveViewerMode: true });
await page.goto("/recorder");
await expect(page.getByText("当前账号已有其他设备正在实时分析")).toBeVisible();
await expect(page.getByTestId("recorder-start-camera-button")).toBeDisabled();
});

查看文件

@@ -100,6 +100,7 @@ type MockAppState = {
nextVideoId: number; nextVideoId: number;
nextTaskId: number; nextTaskId: number;
authMeNullResponsesAfterLogin: number; authMeNullResponsesAfterLogin: number;
viewerSignalConflictRemaining: number;
}; };
function trpcResult(json: unknown) { function trpcResult(json: unknown) {
@@ -637,15 +638,24 @@ async function handleMedia(route: Route, state: MockAppState) {
return; return;
} }
if (path.endsWith("/signal")) { if (path.endsWith("/viewer-signal")) {
state.mediaSession.status = "recording"; if (state.viewerSignalConflictRemaining > 0) {
await fulfillJson(route, { type: "answer", sdp: "mock-answer" }); state.viewerSignalConflictRemaining -= 1;
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ error: "viewer stream not ready" }),
});
return;
}
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
return; return;
} }
if (path.endsWith("/viewer-signal")) { if (path.endsWith("/signal")) {
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1; state.mediaSession.status = "recording";
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" }); await fulfillJson(route, { type: "answer", sdp: "mock-answer" });
return; return;
} }
@@ -714,6 +724,7 @@ export async function installAppMocks(
userName?: string; userName?: string;
authMeNullResponsesAfterLogin?: number; authMeNullResponsesAfterLogin?: number;
liveViewerMode?: boolean; liveViewerMode?: boolean;
viewerSignalConflictOnce?: boolean;
} }
) { ) {
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null; const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
@@ -817,6 +828,7 @@ export async function installAppMocks(
nextVideoId: 100, nextVideoId: 100,
nextTaskId: 1, nextTaskId: 1,
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0, authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
viewerSignalConflictRemaining: options?.viewerSignalConflictOnce ? 1 : 0,
}; };
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -921,9 +933,12 @@ export async function installAppMocks(
localDescription: { type: string; sdp: string } | null = null; localDescription: { type: string; sdp: string } | null = null;
remoteDescription: { type: string; sdp: string } | null = null; remoteDescription: { type: string; sdp: string } | null = null;
onconnectionstatechange: (() => void) | null = null; onconnectionstatechange: (() => void) | null = null;
ontrack: ((event: { streams: MediaStream[] }) => void) | null = null;
addTrack() {} addTrack() {}
addTransceiver() {}
async createOffer() { async createOffer() {
return { type: "offer", sdp: "mock-offer" }; return { type: "offer", sdp: "mock-offer" };
} }
@@ -937,6 +952,7 @@ export async function installAppMocks(
async setRemoteDescription(description: { type: string; sdp: string }) { async setRemoteDescription(description: { type: string; sdp: string }) {
this.remoteDescription = description; this.remoteDescription = description;
this.connectionState = "connected"; this.connectionState = "connected";
this.ontrack?.({ streams: [new MediaStream()] });
this.onconnectionstatechange?.(); this.onconnectionstatechange?.();
} }