From a84e6042bbddf8521ab51262acb0ce74fbc50e69 Mon Sep 17 00:00:00 2001 From: Jacob Bandes-Storch Date: Wed, 15 Nov 2023 18:35:07 -0800 Subject: [PATCH] Add h264 recording support to mcap.dev demo (#1015) ### Public-Facing Changes The recording demo on mcap.dev can now record H.264-encoded CompressedVideo, if supported by the browser. ### Description - Use VideoEncoder to encode h264 - Detection of browser support & workarounds for some Safari issues (related to https://github.com/WebKit/WebKit/pull/15562) Depends on #1014 (for VideoEncoder type definitions) --- website/package.json | 1 + .../McapRecordingDemo.module.css | 25 ++ .../McapRecordingDemo/McapRecordingDemo.tsx | 126 +++++-- .../components/McapRecordingDemo/Recorder.ts | 132 ++++--- .../McapRecordingDemo/addProtobufChannel.ts | 1 - .../McapRecordingDemo/videoCapture.ts | 328 ++++++++++++++++-- yarn.lock | 10 + 7 files changed, 521 insertions(+), 102 deletions(-) diff --git a/website/package.json b/website/package.json index 3dee406cf3..d0c5f68edb 100644 --- a/website/package.json +++ b/website/package.json @@ -46,6 +46,7 @@ "promise-queue": "2.2.5", "protobufjs": "7.2.3", "react": "17.0.2", + "react-async": "10.0.1", "react-dom": "17.0.2", "typescript": "5.2.2", "zustand": "4.3.8" diff --git a/website/src/components/McapRecordingDemo/McapRecordingDemo.module.css b/website/src/components/McapRecordingDemo/McapRecordingDemo.module.css index 6f81a0f1f7..c6d5836325 100644 --- a/website/src/components/McapRecordingDemo/McapRecordingDemo.module.css +++ b/website/src/components/McapRecordingDemo/McapRecordingDemo.module.css @@ -130,6 +130,21 @@ position: absolute; inset: 0; object-fit: cover; + z-index: 0; +} + +.videoContainer .videoErrorContainer { + width: 100%; + height: 100%; + position: absolute; + background-color: #f5f1ffc4; + padding: 0.5rem; + inset: 0; + z-index: 1; +} + +[data-theme="dark"] .videoContainer .videoErrorContainer { + background-color: #17151ec4; } .videoPlaceholderText { @@ -170,6 +185,16 @@ border-color: #585858; } +.h264Warning { + font-weight: 600; + font-size: 0.8rem; + border: 1px solid var(--ifm-color-warning-dark); + background-color: var(--ifm-color-warning-contrast-background); + padding: 6px 6px 6px 12px; + margin-bottom: 16px; + color: var(--ifm-color-warning-contrast-foreground); +} + .downloadInfoCloseButton { float: right; font-size: 1rem; diff --git a/website/src/components/McapRecordingDemo/McapRecordingDemo.tsx b/website/src/components/McapRecordingDemo/McapRecordingDemo.tsx index 26bf5858af..d4bbcc411f 100644 --- a/website/src/components/McapRecordingDemo/McapRecordingDemo.tsx +++ b/website/src/components/McapRecordingDemo/McapRecordingDemo.tsx @@ -5,6 +5,7 @@ import { fromMillis } from "@foxglove/rostime"; import { PoseInFrame } from "@foxglove/schemas"; import cx from "classnames"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useAsync } from "react-async"; import { create } from "zustand"; import styles from "./McapRecordingDemo.module.css"; @@ -14,7 +15,12 @@ import { Recorder, toProtobufTime, } from "./Recorder"; -import { startVideoCapture, startVideoStream } from "./videoCapture"; +import { + H264Frame, + startVideoCapture, + startVideoStream, + supportsH264Encoding, +} from "./videoCapture"; type State = { bytesWritten: bigint; @@ -26,7 +32,8 @@ type State = { addMouseEventMessage: (msg: MouseEventMessage) => void; addPoseMessage: (msg: DeviceOrientationEvent) => void; - addCameraImage: (blob: Blob) => void; + addJpegFrame: (blob: Blob) => void; + addH264Frame: (frame: H264Frame) => void; closeAndRestart: () => Promise; }; @@ -54,8 +61,11 @@ const useStore = create((set) => { void recorder.addPose(deviceOrientationToPose(msg)); set({ latestOrientation: msg }); }, - addCameraImage(blob: Blob) { - void recorder.addCameraImage(blob); + addJpegFrame(blob: Blob) { + void recorder.addJpegFrame(blob); + }, + addH264Frame(frame: H264Frame) { + void recorder.addH264Frame(frame); }, async closeAndRestart() { return await recorder.closeAndRestart(); @@ -113,20 +123,26 @@ export function McapRecordingDemo(): JSX.Element { const [orientationPermissionError, setOrientationPermissionError] = useState(false); - const videoRef = useRef(null); - const [recordVideo, setRecordVideo] = useState(false); + const videoRef = useRef(); + const videoContainerRef = useRef(null); + const [recordJpeg, setRecordJpeg] = useState(false); + const [recordH264, setRecordH264] = useState(false); const [recordMouse, setRecordMouse] = useState(true); const [recordOrientation, setRecordOrientation] = useState(true); const [videoStarted, setVideoStarted] = useState(false); - const [videoPermissionError, setVideoPermissionError] = useState(false); + const [videoError, setVideoError] = useState(); const [showDownloadInfo, setShowDownloadInfo] = useState(false); - const { addCameraImage, addMouseEventMessage, addPoseMessage } = state; + const { addJpegFrame, addH264Frame, addMouseEventMessage, addPoseMessage } = + state; + + const { data: h264Support } = useAsync(supportsH264Encoding); const canStartRecording = recordMouse || (!hasMouse && recordOrientation) || - (recordVideo && !videoPermissionError); + (recordH264 && !videoError) || + (recordJpeg && !videoError); // Automatically pause recording after 30 seconds to avoid unbounded growth useEffect(() => { @@ -172,47 +188,77 @@ export function McapRecordingDemo(): JSX.Element { }; }, [addPoseMessage, recording, recordOrientation]); + const enableCamera = recordH264 || recordJpeg; useEffect(() => { - const video = videoRef.current; - if (!recordVideo || !video) { + const videoContainer = videoContainerRef.current; + if (!videoContainer || !enableCamera) { return; } + if (videoRef.current) { + videoRef.current.remove(); + } + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + videoRef.current = video; + videoContainer.appendChild(video); + const cleanup = startVideoStream({ - video, + video: videoRef.current, onStart: () => { setVideoStarted(true); }, onError: (err) => { + setVideoError(err); console.error(err); - setVideoPermissionError(true); }, }); return () => { cleanup(); + video.remove(); setVideoStarted(false); - setVideoPermissionError(false); + setVideoError(undefined); }; - }, [recordVideo]); + }, [enableCamera]); useEffect(() => { const video = videoRef.current; - if (!recording || !recordVideo || !video || !videoStarted) { + if (!recording || !video || !videoStarted) { + return; + } + if (!recordH264 && !recordJpeg) { return; } const stopCapture = startVideoCapture({ video, + enableH264: recordH264, + enableJpeg: recordJpeg, frameDurationSec: 1 / 30, - onFrame: (blob) => { - addCameraImage(blob); + onJpegFrame: (blob) => { + addJpegFrame(blob); + }, + onH264Frame: (frame) => { + addH264Frame(frame); + }, + onError: (err) => { + setVideoError(err); + console.error(err); }, }); return () => { stopCapture(); }; - }, [addCameraImage, recordVideo, recording, videoStarted]); + }, [ + addJpegFrame, + addH264Frame, + recordH264, + recording, + videoStarted, + recordJpeg, + ]); const onRecordClick = useCallback( (event: React.MouseEvent) => { @@ -284,15 +330,27 @@ export function McapRecordingDemo(): JSX.Element {

+ {h264Support?.supported === true && ( + + )}