diff --git a/client/src/lib/changelog.ts b/client/src/lib/changelog.ts index 25fd828..75ae0db 100644 --- a/client/src/lib/changelog.ts +++ b/client/src/lib/changelog.ts @@ -8,6 +8,29 @@ export type ChangeLogEntry = { }; export const CHANGE_LOG_ENTRIES: ChangeLogEntry[] = [ + { + version: "2026.03.17-live-camera-pose-buffer-window", + releaseDate: "2026-03-17", + repoVersion: "f3f7e19+pose-buffer-window", + summary: + "修复实时分析启动时的 MediaPipe Pose 模块加载崩溃,并把多端同步缓存改为默认 2 分钟、可选 10 秒到 5 分钟。", + features: [ + "live-camera 开始分析时不再直接解构 `import(\"@mediapipe/pose\")` 的返回值,而是兼容 `Pose`、`default.Pose` 和默认导出三种形态;模块缺失时会抛出明确错误,避免再次出现 `Cannot destructure property 'Pose' ... as it is undefined`", + "同步观看的 relay 缓存时长改为按会话配置,范围 10 秒到 5 分钟,默认 2 分钟;viewer 文案、徽标和设置面板都会实时显示当前缓存窗口", + "owner 端合成画布录制改为每 10 秒上传一次 relay 分片,同时继续维持每 60 秒一段的自动归档录像,因此观看端切到短缓存时不需要再等满 60 秒才出现平滑视频", + "media 服务会按各自 relay 会话的缓存窗口裁剪预览分段,并在从磁盘恢复旧会话时自动归一化缓存秒数,避免旧数据继续按固定 60 秒窗口工作", + "线上 smoke 已确认 `https://te.hao.work/` 已经提供本次新构建,而不是旧资源版本;首页、主样式和 `pose` 模块都已切到本次发布的最新资源 revision", + ], + tests: [ + "cd media && go test ./...", + "pnpm vitest run client/src/lib/liveCamera.test.ts", + "pnpm check", + "pnpm build", + "pnpm exec playwright test tests/e2e/app.spec.ts", + "playwright-skill 线上 smoke: 登录 `H1` 后访问 `https://te.hao.work/live-camera`,完成校准、启用假摄像头并点击“开始分析”,确认页面进入分析中状态、默认显示“缓存 2 分钟”、且无控制台与页面级错误", + "curl -I https://te.hao.work/,并确认首页、主样式与 `pose` 模块资源均返回 `200` 和正确 MIME", + ], + }, { version: "2026.03.17-live-camera-relay-buffer", releaseDate: "2026-03-17", diff --git a/client/src/lib/media.ts b/client/src/lib/media.ts index 1107cb4..764c3ec 100644 --- a/client/src/lib/media.ts +++ b/client/src/lib/media.ts @@ -43,6 +43,7 @@ export type MediaSession = { uploadedBytes: number; previewSegments: number; durationMs: number; + relayBufferSeconds?: number; lastError?: string; previewUpdatedAt?: string; streamConnected: boolean; @@ -115,6 +116,7 @@ export async function createMediaSession(payload: { facingMode: string; deviceKind: string; purpose?: "recording" | "relay"; + relayBufferSeconds?: number; }) { return request<{ session: MediaSession }>("/sessions", { method: "POST", diff --git a/client/src/pages/LiveCamera.tsx b/client/src/pages/LiveCamera.tsx index 9e0f66f..6ae645c 100644 --- a/client/src/pages/LiveCamera.tsx +++ b/client/src/pages/LiveCamera.tsx @@ -151,6 +151,7 @@ type RuntimeSnapshot = { title?: string; sessionMode?: SessionMode; qualityPreset?: CameraQualityPreset; + relayBufferSeconds?: number; facingMode?: CameraFacing; deviceKind?: "mobile" | "desktop"; avatarEnabled?: boolean; @@ -253,6 +254,15 @@ const SEGMENT_MAX_MS = 10_000; const MERGE_GAP_MS = 900; const MIN_SEGMENT_MS = 1_200; const ANALYSIS_RECORDING_SEGMENT_MS = 60_000; +const RELAY_UPLOAD_SEGMENT_MS = 10_000; +const RELAY_BUFFER_OPTIONS = [ + { value: 10, label: "10 秒缓存" }, + { value: 30, label: "30 秒缓存" }, + { value: 60, label: "1 分钟缓存" }, + { value: 120, label: "2 分钟缓存" }, + { value: 180, label: "3 分钟缓存" }, + { value: 300, label: "5 分钟缓存" }, +] as const; const CAMERA_QUALITY_PRESETS: Record< CameraQualityPreset, { label: string; subtitle: string; description: string } @@ -798,6 +808,19 @@ function formatRuntimeSyncDelay(delayMs: number | null) { return "同步较慢"; } +function formatRelayBufferLabel(seconds: number | null | undefined) { + const normalized = Math.max(10, Math.min(300, seconds ?? 120)); + if (normalized < 60) { + return `${normalized} 秒`; + } + if (normalized % 60 === 0) { + return `${normalized / 60} 分钟`; + } + const minutes = Math.floor(normalized / 60); + const remainSeconds = normalized % 60; + return `${minutes} 分 ${remainSeconds} 秒`; +} + export default function LiveCamera() { const { user } = useAuth(); const utils = trpc.useUtils(); @@ -819,6 +842,8 @@ export default function LiveCamera() { const recorderStopPromiseRef = useRef | null>(null); const recorderSegmentStartedAtRef = useRef(0); const recorderSequenceRef = useRef(0); + const relaySequenceRef = useRef(0); + const relayChunkStartedAtRef = useRef(0); const recorderRotateTimerRef = useRef(0); const recorderUploadQueueRef = useRef(Promise.resolve()); const relayUploadQueueRef = useRef(Promise.resolve()); @@ -872,6 +897,7 @@ export default function LiveCamera() { const [segmentFilter, setSegmentFilter] = useState("all"); const [qualityPreset, setQualityPreset] = useState("economy"); + const [relayBufferSeconds, setRelayBufferSeconds] = useState(120); const [zoomState, setZoomState] = useState(() => readTrackZoomState(null)); const [stabilityMeta, setStabilityMeta] = useState(() => createEmptyStabilizedActionMeta() @@ -936,6 +962,11 @@ export default function LiveCamera() { rawActionRef.current = rawAction; }, [rawAction]); + const relayBufferLabel = useMemo( + () => formatRelayBufferLabel(relayBufferSeconds), + [relayBufferSeconds] + ); + useEffect(() => { liveScoreRef.current = liveScore; }, [liveScore]); @@ -1253,6 +1284,7 @@ export default function LiveCamera() { } if (!recorder) { await recorderUploadQueueRef.current; + await relayUploadQueueRef.current; return; } const stopPromise = recorderStopPromiseRef.current; @@ -1261,6 +1293,7 @@ export default function LiveCamera() { } await (stopPromise ?? Promise.resolve()); await recorderUploadQueueRef.current; + await relayUploadQueueRef.current; }, []); const buildRuntimeSnapshot = useCallback( @@ -1273,6 +1306,7 @@ export default function LiveCamera() { `实时分析 ${ACTION_META[currentActionRef.current].label}`, sessionMode: sessionModeRef.current, qualityPreset, + relayBufferSeconds, facingMode: facing, deviceKind: mobile ? "mobile" : "desktop", avatarEnabled: avatarRenderRef.current.enabled, @@ -1292,7 +1326,7 @@ export default function LiveCamera() { archivedVideoCount: archivedVideosRef.current.length, recentSegments: segmentsRef.current.slice(-5), }), - [facing, mobile, normalizedRuntimeTitle, qualityPreset] + [facing, mobile, normalizedRuntimeTitle, qualityPreset, relayBufferSeconds] ); const openSetupGuide = useCallback(async () => { @@ -1416,12 +1450,13 @@ export default function LiveCamera() { facingMode: facing, deviceKind: mobile ? "mobile" : "desktop", purpose: "relay", + relayBufferSeconds, }); const sessionId = sessionResponse.session.id; broadcastSessionIdRef.current = sessionId; return sessionId; - }, [facing, mobile, qualityPreset, user?.id]); + }, [facing, mobile, qualityPreset, relayBufferSeconds, user?.id]); const startViewerStream = useCallback(async (mediaSessionId: string) => { const response = await getMediaSession(mediaSessionId); @@ -1466,6 +1501,7 @@ export default function LiveCamera() { setZoomState(readTrackZoomState(null)); archivedVideosRef.current = []; recorderSequenceRef.current = 0; + relaySequenceRef.current = 0; setArchivedVideoCount(0); setCameraActive(false); }, [stopSessionRecorder]); @@ -1736,10 +1772,24 @@ export default function LiveCamera() { const sequence = recorderSequenceRef.current + 1; recorderSequenceRef.current = sequence; recorderSegmentStartedAtRef.current = Date.now(); + relayChunkStartedAtRef.current = recorderSegmentStartedAtRef.current; recorder.ondataavailable = event => { if (event.data && event.data.size > 0) { recorderChunksRef.current.push(event.data); + const nextRelaySequence = relaySequenceRef.current + 1; + relaySequenceRef.current = nextRelaySequence; + const now = Date.now(); + const relayDurationMs = Math.max( + 1, + now - relayChunkStartedAtRef.current + ); + relayChunkStartedAtRef.current = now; + void queueRelaySegmentUpload( + event.data, + nextRelaySequence, + relayDurationMs + ); } }; @@ -1760,7 +1810,6 @@ export default function LiveCamera() { recorderRef.current = null; recorderStopPromiseRef.current = null; if (blob && blob.size > 0 && durationMs > 0) { - void queueRelaySegmentUpload(blob, sequence, durationMs); void queueArchivedVideoUpload(blob, sequence, durationMs); } if (analyzingRef.current) { @@ -1775,7 +1824,7 @@ export default function LiveCamera() { }; }); - recorder.start(); + recorder.start(RELAY_UPLOAD_SEGMENT_MS); recorderRotateTimerRef.current = window.setTimeout(() => { if (recorder.state === "recording") { recorder.stop(); @@ -1983,6 +2032,7 @@ export default function LiveCamera() { volatilitySamplesRef.current = []; archivedVideosRef.current = []; recorderSequenceRef.current = 0; + relaySequenceRef.current = 0; setArchivedVideoCount(0); sessionStartedAtRef.current = Date.now(); setCurrentAction("unknown"); @@ -1998,14 +2048,19 @@ export default function LiveCamera() { const testFactory = ( window as typeof window & { - __TEST_MEDIAPIPE_FACTORY__?: () => Promise<{ Pose: any }>; + __TEST_MEDIAPIPE_FACTORY__?: () => Promise; } ).__TEST_MEDIAPIPE_FACTORY__; - const { Pose } = testFactory + const poseModule = testFactory ? await testFactory() : await import("@mediapipe/pose"); - const pose = new Pose({ + const PoseConstructor = + poseModule?.Pose ?? poseModule?.default?.Pose ?? poseModule?.default; + if (typeof PoseConstructor !== "function") { + throw new Error("MediaPipe Pose 模块加载失败"); + } + const pose = new PoseConstructor({ locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`, }); @@ -2225,6 +2280,15 @@ export default function LiveCamera() { runtimeRole === "viewer" ? (runtimeSnapshot?.qualityPreset ?? qualityPreset) : qualityPreset; + const displayRelayBufferSeconds = + runtimeRole === "viewer" + ? (runtimeSnapshot?.relayBufferSeconds ?? + viewerMediaSession?.relayBufferSeconds ?? + relayBufferSeconds) + : relayBufferSeconds; + const displayRelayBufferLabel = formatRelayBufferLabel( + displayRelayBufferSeconds + ); const displayFacing = runtimeRole === "viewer" ? (runtimeSnapshot?.facingMode ?? facing) : facing; const displayDeviceKind = @@ -2287,8 +2351,8 @@ export default function LiveCamera() { ? viewerConnected ? `${runtimeSyncLabel} · 服务端缓存同步中` : viewerBufferReady - ? "正在加载最近 60 秒缓存" - : "正在缓冲最近 60 秒视频" + ? `正在加载最近 ${displayRelayBufferLabel} 缓存` + : `正在缓冲最近 ${displayRelayBufferLabel} 视频` : analyzing ? displayStabilityMeta.pending && pendingActionMeta ? `${pendingActionMeta.label} 切换确认中` @@ -2639,8 +2703,8 @@ export default function LiveCamera() { {viewerModeLabel} 。当前设备不会占用本地摄像头,也不能再次开启分析;同步画面会通过 - media 服务中转,并以最近 60 - 秒缓存视频方式平滑回放,动作、评分与会话信息会按心跳自动同步。 + media 服务中转,并以最近 {displayRelayBufferLabel} + 缓存视频方式平滑回放,动作、评分与会话信息会按心跳自动同步。 ) : null} @@ -2683,6 +2747,10 @@ export default function LiveCamera() {