fix live camera preview recovery

这个提交包含在:
cryptocommuniums-afk
2026-03-17 07:39:22 +08:00
父节点 06b9701e03
当前提交 63dbfd2787
修改 3 个文件,包含 139 行新增37 行删除

查看文件

@@ -8,6 +8,25 @@ export type ChangeLogEntry = {
}; };
export const CHANGE_LOG_ENTRIES: 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", version: "2026.03.16-live-camera-runtime-refresh",
releaseDate: "2026-03-16", releaseDate: "2026-03-16",

查看文件

@@ -224,28 +224,52 @@ function normalizeRuntimeTitle(value: string | null | undefined) {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return ""; if (!trimmed) return "";
const suspicious = /[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦]/; const suspicious = /[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦<EFBFBD>]/;
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(/[ÃÂÆÐÑØæåçéèêëïîôöûüœŠŽƒ€¦<E282AC>]/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("<22>")) {
return "";
}
return score(normalized) > 0 ? normalized : "";
};
if (!suspicious.test(trimmed)) { if (!suspicious.test(trimmed)) {
return trimmed; return sanitize(trimmed);
} }
const candidates = [trimmed];
try { try {
const bytes = Uint8Array.from(Array.from(trimmed).map((char) => char.charCodeAt(0) & 0xff)); const bytes = Uint8Array.from(Array.from(trimmed).map((char) => char.charCodeAt(0) & 0xff));
const decoded = new TextDecoder("utf-8").decode(bytes).trim(); const decoded = new TextDecoder("utf-8").decode(bytes).trim();
if (!decoded || decoded === trimmed) { if (decoded && decoded !== trimmed) {
return 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 { } catch {
return trimmed; return sanitize(trimmed);
} }
return candidates
.map((candidate) => sanitize(candidate))
.filter(Boolean)
.sort((left, right) => score(right) - score(left))[0] || "";
} }
function isMobileDevice() { function isMobileDevice() {
@@ -809,13 +833,44 @@ export default function LiveCamera() {
}).catch(() => undefined); }).catch(() => undefined);
}, []); }, []);
useEffect(() => { const bindLocalPreview = useCallback(async (providedStream?: MediaStream | null) => {
if (!cameraActive || !streamRef.current || !videoRef.current) return; const stream = providedStream || streamRef.current;
if (videoRef.current.srcObject !== streamRef.current) { const video = videoRef.current;
videoRef.current.srcObject = streamRef.current; if (!stream || !video) {
void videoRef.current.play().catch(() => undefined); 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(() => { const ensureCompositeCanvas = useCallback(() => {
if (typeof document === "undefined") { if (typeof document === "undefined") {
@@ -984,17 +1039,18 @@ export default function LiveCamera() {
frameRelayInFlightRef.current = false; frameRelayInFlightRef.current = false;
}, []); }, []);
const closeViewerPeer = useCallback(() => { const closeViewerPeer = useCallback((options?: { clearFrameVersion?: boolean }) => {
if (viewerRetryTimerRef.current) { if (viewerRetryTimerRef.current) {
window.clearTimeout(viewerRetryTimerRef.current); window.clearTimeout(viewerRetryTimerRef.current);
viewerRetryTimerRef.current = 0; viewerRetryTimerRef.current = 0;
} }
viewerSessionIdRef.current = null; viewerSessionIdRef.current = null;
if (videoRef.current && !cameraActive) { if (options?.clearFrameVersion) {
videoRef.current.srcObject = null; setViewerFrameVersion(0);
} }
setViewerConnected(false); setViewerConnected(false);
}, [cameraActive]); setViewerError("");
}, []);
const releaseRuntime = useCallback(async (phase: RuntimeSnapshot["phase"]) => { const releaseRuntime = useCallback(async (phase: RuntimeSnapshot["phase"]) => {
if (!runtimeIdRef.current) return; if (!runtimeIdRef.current) return;
@@ -1125,10 +1181,12 @@ export default function LiveCamera() {
useEffect(() => { useEffect(() => {
if (runtimeRole !== "viewer" || !runtimeSession?.mediaSessionId) { if (runtimeRole !== "viewer" || !runtimeSession?.mediaSessionId) {
if (!cameraActive) { closeViewerPeer({
closeViewerPeer(); clearFrameVersion: !cameraActive,
});
if (streamRef.current) {
void bindLocalPreview();
} }
setViewerError("");
return; return;
} }
@@ -1151,6 +1209,7 @@ export default function LiveCamera() {
} }
}; };
}, [ }, [
bindLocalPreview,
cameraActive, cameraActive,
closeViewerPeer, closeViewerPeer,
runtimeRole, runtimeRole,
@@ -1226,18 +1285,12 @@ export default function LiveCamera() {
preset, preset,
}); });
streamRef.current = stream; streamRef.current = stream;
closeViewerPeer();
if (appliedFacingMode !== nextFacing) { if (appliedFacingMode !== nextFacing) {
setFacing(appliedFacingMode); setFacing(appliedFacingMode);
} }
await bindLocalPreview(stream);
setCameraActive(true); 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); await syncZoomState(preferredZoom, stream.getVideoTracks()[0] || null);
if (usedFallback) { if (usedFallback) {
toast.info("当前设备已自动切换到兼容摄像头模式"); toast.info("当前设备已自动切换到兼容摄像头模式");
@@ -1246,7 +1299,7 @@ export default function LiveCamera() {
} catch (error: any) { } catch (error: any) {
toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`); toast.error(`摄像头启动失败: ${error?.message || "未知错误"}`);
} }
}, [facing, mobile, qualityPreset, refreshRuntimeState, syncZoomState]); }, [bindLocalPreview, closeViewerPeer, facing, mobile, qualityPreset, refreshRuntimeState, syncZoomState]);
const switchCamera = useCallback(async () => { const switchCamera = useCallback(async () => {
const nextFacing: CameraFacing = facing === "user" ? "environment" : "user"; const nextFacing: CameraFacing = facing === "user" ? "environment" : "user";
@@ -1739,7 +1792,7 @@ export default function LiveCamera() {
const viewerFrameSrc = runtimeRole === "viewer" && runtimeSession?.mediaSessionId const viewerFrameSrc = runtimeRole === "viewer" && runtimeSession?.mediaSessionId
? getMediaAssetUrl(`/assets/sessions/${runtimeSession.mediaSessionId}/live-frame.jpg?ts=${viewerFrameVersion || runtimeSnapshot?.updatedAt || Date.now()}`) ? 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 heroAction = ACTION_META[displayAction];
const rawActionMeta = ACTION_META[displayRawAction]; const rawActionMeta = ACTION_META[displayRawAction];
const pendingActionMeta = displayStabilityMeta.pendingAction ? ACTION_META[displayStabilityMeta.pendingAction] : null; 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 renderPrimaryActions = (rail = false) => {
const buttonClass = rail const buttonClass = rail

查看文件

@@ -1,5 +1,35 @@
# Tennis Training Hub - 变更日志 # 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) ## 2026.03.16-live-camera-runtime-refresh (2026-03-16)
### 功能更新 ### 功能更新