fix live camera runtime refresh and title recovery
这个提交包含在:
@@ -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() {
|
||||
<Button
|
||||
data-testid={rail ? undefined : "live-camera-toolbar-start-button"}
|
||||
className={buttonClass}
|
||||
onClick={() => setShowSetupGuide(true)}
|
||||
onClick={() => void openSetupGuide()}
|
||||
>
|
||||
<Camera className={rail ? "h-5 w-5" : "mr-2 h-4 w-4"} />
|
||||
{!rail && "启动摄像头"}
|
||||
@@ -2120,7 +2181,7 @@ export default function LiveCamera() {
|
||||
{viewerConnected ? "刷新同步" : "获取同步画面"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button data-testid="live-camera-start-button" onClick={() => setShowSetupGuide(true)} className="rounded-2xl">
|
||||
<Button data-testid="live-camera-start-button" onClick={() => void openSetupGuide()} className="rounded-2xl">
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
启动摄像头
|
||||
</Button>
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
在新工单中引用
屏蔽一个用户