diff --git a/client/src/lib/changelog.ts b/client/src/lib/changelog.ts index 0295d2c..e556848 100644 --- a/client/src/lib/changelog.ts +++ b/client/src/lib/changelog.ts @@ -8,6 +8,25 @@ export type ChangeLogEntry = { }; export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [ + { + version: "2026.03.17-live-camera-preview-recovery", + releaseDate: "2026-03-17", + repoVersion: "06b9701", + summary: "修复实时分析页标题乱码、同步观看残留状态导致的黑屏,以及切回本机摄像头后预览无法恢复的问题。", + features: [ + "runtime 标题恢复逻辑新增更严格的乱码筛除与二次 UTF-8 解码兜底,`服...`、带替换字符的脏标题现在会优先恢复为正常中文,无法恢复时会安全回退到稳定默认标题", + "同步观看退出时会完整重置 viewer 轮询、连接标记和帧版本,不再把旧 viewer 状态残留到 owner 或空闲态,避免页面继续停留在黑屏或“等待同步画面”", + "本地摄像头预览新增独立重绑流程和多次 watchdog 重试,即使浏览器在首帧时没有及时绑定 `srcObject` 或 `play()` 被短暂打断,也会自动恢复预览", + "视频区域是否显示画面改为按当前 runtime 角色分别判断,避免 viewer 的旧连接状态误导 owner 模式,导致本地没有预览时仍隐藏占位提示", + ], + tests: [ + "pnpm check", + "pnpm vitest run client/src/lib/liveCamera.test.ts", + "pnpm exec playwright test tests/e2e/app.spec.ts --grep \"live camera\"", + "pnpm build", + "线上 smoke: `curl -I https://te.hao.work/`,并检查页面源码中的 `/assets/index-*.js`、`/assets/index-*.css`、`/assets/pose-*.js` 已切换到新构建且返回正确 MIME", + ], + }, { version: "2026.03.16-live-camera-runtime-refresh", releaseDate: "2026-03-16", diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 0eb248c..26a4fe7 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -224,28 +224,52 @@ function normalizeRuntimeTitle(value: string | null | undefined) { const trimmed = value.trim(); if (!trimmed) return ""; - const suspicious = /[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦]/; + const suspicious = /[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦�]/; + const control = /[\u0000-\u001f\u007f]/g; + const score = (text: string) => { + const cjkCount = text.match(/[\u3400-\u9fff]/g)?.length ?? 0; + const latinCount = text.match(/[A-Za-z0-9]/g)?.length ?? 0; + const whitespaceCount = text.match(/\s/g)?.length ?? 0; + const punctuationCount = text.match(/[()\-_:./]/g)?.length ?? 0; + const badCount = text.match(/[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦�]/g)?.length ?? 0; + const controlCount = text.match(control)?.length ?? 0; + return (cjkCount * 3) + latinCount + whitespaceCount + punctuationCount - (badCount * 4) - (controlCount * 6); + }; + const sanitize = (candidate: string) => { + const normalized = candidate.replace(control, "").trim(); + if (!normalized || normalized.includes("�")) { + return ""; + } + return score(normalized) > 0 ? normalized : ""; + }; + if (!suspicious.test(trimmed)) { - return trimmed; + return sanitize(trimmed); } + const candidates = [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; + if (decoded && decoded !== trimmed) { + candidates.push(decoded); + if (suspicious.test(decoded)) { + const decodedBytes = Uint8Array.from(Array.from(decoded).map((char) => char.charCodeAt(0) & 0xff)); + const twiceDecoded = new TextDecoder("utf-8").decode(decodedBytes).trim(); + if (twiceDecoded && twiceDecoded !== decoded) { + candidates.push(twiceDecoded); + } + } } - - 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; + return sanitize(trimmed); } + + return candidates + .map((candidate) => sanitize(candidate)) + .filter(Boolean) + .sort((left, right) => score(right) - score(left))[0] || ""; } function isMobileDevice() { @@ -809,13 +833,44 @@ export default function LiveCamera() { }).catch(() => undefined); }, []); - useEffect(() => { - if (!cameraActive || !streamRef.current || !videoRef.current) return; - if (videoRef.current.srcObject !== streamRef.current) { - videoRef.current.srcObject = streamRef.current; - void videoRef.current.play().catch(() => undefined); + const bindLocalPreview = useCallback(async (providedStream?: MediaStream | null) => { + const stream = providedStream || streamRef.current; + const video = videoRef.current; + if (!stream || !video) { + return false; } - }, [cameraActive, immersivePreview]); + + if (video.srcObject !== stream) { + video.srcObject = stream; + } + video.muted = true; + video.defaultMuted = true; + video.playsInline = true; + await video.play().catch(() => undefined); + return video.srcObject === stream; + }, []); + + useEffect(() => { + if (!cameraActive || !streamRef.current || runtimeRole === "viewer") return; + + let cancelled = false; + const ensurePreview = () => { + if (cancelled) return; + const video = videoRef.current; + const stream = streamRef.current; + if (!video || !stream) return; + if (video.srcObject !== stream || video.videoWidth === 0 || video.paused) { + void bindLocalPreview(stream); + } + }; + + ensurePreview(); + const timers = [300, 900, 1800].map((delay) => window.setTimeout(ensurePreview, delay)); + return () => { + cancelled = true; + timers.forEach((timer) => window.clearTimeout(timer)); + }; + }, [bindLocalPreview, cameraActive, immersivePreview, runtimeRole]); const ensureCompositeCanvas = useCallback(() => { if (typeof document === "undefined") { @@ -984,17 +1039,18 @@ export default function LiveCamera() { frameRelayInFlightRef.current = false; }, []); - const closeViewerPeer = useCallback(() => { + const closeViewerPeer = useCallback((options?: { clearFrameVersion?: boolean }) => { if (viewerRetryTimerRef.current) { window.clearTimeout(viewerRetryTimerRef.current); viewerRetryTimerRef.current = 0; } viewerSessionIdRef.current = null; - if (videoRef.current && !cameraActive) { - videoRef.current.srcObject = null; + if (options?.clearFrameVersion) { + setViewerFrameVersion(0); } setViewerConnected(false); - }, [cameraActive]); + setViewerError(""); + }, []); const releaseRuntime = useCallback(async (phase: RuntimeSnapshot["phase"]) => { if (!runtimeIdRef.current) return; @@ -1125,10 +1181,12 @@ export default function LiveCamera() { useEffect(() => { if (runtimeRole !== "viewer" || !runtimeSession?.mediaSessionId) { - if (!cameraActive) { - closeViewerPeer(); + closeViewerPeer({ + clearFrameVersion: !cameraActive, + }); + if (streamRef.current) { + void bindLocalPreview(); } - setViewerError(""); return; } @@ -1151,6 +1209,7 @@ export default function LiveCamera() { } }; }, [ + bindLocalPreview, cameraActive, closeViewerPeer, runtimeRole, @@ -1226,18 +1285,12 @@ export default function LiveCamera() { preset, }); streamRef.current = stream; + closeViewerPeer(); if (appliedFacingMode !== nextFacing) { setFacing(appliedFacingMode); } + await bindLocalPreview(stream); setCameraActive(true); - if (videoRef.current) { - 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); if (usedFallback) { toast.info("当前设备已自动切换到兼容摄像头模式"); @@ -1246,7 +1299,7 @@ export default function LiveCamera() { } catch (error: any) { toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`); } - }, [facing, mobile, qualityPreset, refreshRuntimeState, syncZoomState]); + }, [bindLocalPreview, closeViewerPeer, facing, mobile, qualityPreset, refreshRuntimeState, syncZoomState]); const switchCamera = useCallback(async () => { const nextFacing: CameraFacing = facing === "user" ? "environment" : "user"; @@ -1739,7 +1792,7 @@ export default function LiveCamera() { const viewerFrameSrc = runtimeRole === "viewer" && runtimeSession?.mediaSessionId ? getMediaAssetUrl(`/assets/sessions/${runtimeSession.mediaSessionId}/live-frame.jpg?ts=${viewerFrameVersion || runtimeSnapshot?.updatedAt || Date.now()}`) : ""; - const hasVideoFeed = cameraActive || viewerConnected; + const hasVideoFeed = runtimeRole === "viewer" ? viewerConnected : cameraActive; const heroAction = ACTION_META[displayAction]; const rawActionMeta = ACTION_META[displayRawAction]; const pendingActionMeta = displayStabilityMeta.pendingAction ? ACTION_META[displayStabilityMeta.pendingAction] : null; @@ -1759,7 +1812,7 @@ export default function LiveCamera() { ? "准备开始实时分析" : "摄像头待启动"; - const viewerModeLabel = normalizedRuntimeTitle || "其他设备正在实时分析"; + const viewerModeLabel = normalizedSnapshotTitle || normalizedRuntimeTitle || "其他设备正在实时分析"; const renderPrimaryActions = (rail = false) => { const buttonClass = rail diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5930b02..705bfbe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,35 @@ # Tennis Training Hub - 变更日志 +## 2026.03.17-live-camera-preview-recovery (2026-03-17) + +### 功能更新 + +- `/live-camera` 的 runtime 标题恢复逻辑新增更严格的乱码筛除与二次 UTF-8 解码兜底,`服...` 这类异常标题会优先恢复为正常中文;无法恢复时会自动回退到稳定默认标题,避免继续显示脏字符串 +- 同步观看退出时会完整重置 viewer 轮询、连接标记和帧版本,不再把旧的 viewer 状态带回 owner 或空闲态,修复退出同步后仍黑屏、仍显示“等待同步画面”的问题 +- 本地摄像头预览增加独立重绑流程和多次 watchdog 重试,即使浏览器首帧没有及时绑定 `srcObject` 或 `play()` 被短暂中断,也会继续自动恢复本地预览 +- 视频区域是否显示画面改为按当前 runtime 角色分别判断,避免 viewer 旧连接状态误导 owner 模式,导致本地没有预览时仍错误隐藏占位提示 + +### 测试 + +- `pnpm check` +- `pnpm vitest run client/src/lib/liveCamera.test.ts` +- `pnpm exec playwright test tests/e2e/app.spec.ts --grep "live camera"` +- `pnpm build` +- 线上 smoke:`curl -I https://te.hao.work/` +- 线上 smoke:`curl -I https://te.hao.work/assets/index-BJ7rV3xe.js` +- 线上 smoke:`curl -I https://te.hao.work/assets/index-tNGuStgv.css` +- 线上 smoke:`curl -I https://te.hao.work/assets/pose-CZKsH31a.js` + +### 线上 smoke + +- `https://te.hao.work/` 已切换到本次新构建 +- 当前公开站点前端资源 revision:`assets/index-BJ7rV3xe.js`、`assets/index-tNGuStgv.css`、`assets/pose-CZKsH31a.js` +- 已确认 `index`、`css` 与 `pose` 模块均返回 `200`,且 MIME 分别为 `application/javascript`、`text/css`、`application/javascript`,不再出现此前的模块脚本和样式被当成 `text/html` 返回的问题 + +### 仓库版本 + +- `06b9701` + ## 2026.03.16-live-camera-runtime-refresh (2026-03-16) ### 功能更新