fix live camera runtime refresh and title recovery

这个提交包含在:
cryptocommuniums-afk
2026-03-16 23:53:10 +08:00
父节点 634a4704c7
当前提交 8e9e4915e2
修改 3 个文件,包含 176 行新增22 行删除

查看文件

@@ -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>