Skip to content

Commit a609db6

Browse files
committed
feat(webapp): virtual background
1 parent be0e994 commit a609db6

File tree

8 files changed

+222
-4
lines changed

8 files changed

+222
-4
lines changed

package-lock.json

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"preview": "vite preview -c webapp/vite.config.ts"
1212
},
1313
"dependencies": {
14+
"@mediapipe/tasks-vision": "^0.10.21",
1415
"copy-to-clipboard": "^3.3.3",
1516
"jotai": "^2.10.2",
1617
"jotai-devtools": "^0.10.1",

webapp/components/device.tsx

+26
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import SvgSpeaker from './svg/speaker'
1313
import SvgAudio from './svg/audio'
1414
import SvgVideo from './svg/video'
1515
import { SvgPresentCancel, SvgPresentToAll } from './svg/present'
16+
import { SvgBackgroundCancel, SvgBackground } from './svg/background'
1617

1718
function toDevice(info: MediaDeviceInfo): Device {
1819
const deviceId = info.deviceId
@@ -31,11 +32,13 @@ export default function DeviceBar(props: { streamId: string }) {
3132
const [loadingAudio, setLoadingAudio] = useState(false)
3233
const [loadingVideo, setLoadingVideo] = useState(false)
3334
const [loadingScreen, setLoadingScreen] = useState(false)
35+
const [loadingBackground, setLoadingBackground] = useState(false)
3436

3537
const [currentDeviceSpeaker, setCurrentDeviceSpeaker] = useAtom(deviceSpeakerAtom)
3638
const [speakerStatus, setSpeakerStatus] = useAtom(speakerStatusAtom)
3739

3840
const [settingsEnabledScreen] = useAtom(settingsEnabledScreenAtom)
41+
const [virtualBackgroundEnabled, setVirtualBackgroundEnabled] = useState(false)
3942

4043
const {
4144
userStatus,
@@ -45,6 +48,7 @@ export default function DeviceBar(props: { streamId: string }) {
4548
setCurrentDeviceVideo,
4649
toggleEnableAudio,
4750
toggleEnableVideo,
51+
toggleEnableVirtualBackground,
4852
} = useWhipClient(props.streamId)
4953

5054
const [deviceSpeaker, setDeviceSpeaker] = useState<Device[]>([deviceNone])
@@ -162,6 +166,9 @@ export default function DeviceBar(props: { streamId: string }) {
162166
const onChangedDeviceVideo = async (current: string) => {
163167
setLoadingVideo(true)
164168
await setCurrentDeviceVideo(current)
169+
if (userStatus.screen) {
170+
setVirtualBackgroundEnabled(false)
171+
}
165172
setLoadingVideo(false)
166173
}
167174

@@ -243,6 +250,9 @@ export default function DeviceBar(props: { streamId: string }) {
243250
<button className="text-rose-400 rounded-md w-8 h-8" onClick={async () => {
244251
setLoadingVideo(true)
245252
await toggleEnableVideo()
253+
if (!userStatus.video && virtualBackgroundEnabled) {
254+
setVirtualBackgroundEnabled(false)
255+
}
246256
setLoadingVideo(false)
247257
}}>
248258
<center>{ loadingVideo ? <Loading/> : <SvgVideo/> }</center>
@@ -272,6 +282,22 @@ export default function DeviceBar(props: { streamId: string }) {
272282
)}
273283
</select>
274284
</section>
285+
286+
<section className="m-1 p-1 flex flex-row justify-center rounded-md border-1 border-indigo-500">
287+
<button className="text-rose-400 rounded-md w-8 h-8" disabled={!userStatus.video || userStatus.screen} onClick={async () => {
288+
setLoadingBackground(true)
289+
await toggleEnableVirtualBackground()
290+
setVirtualBackgroundEnabled(s => !s)
291+
setLoadingBackground(false)
292+
}}>
293+
<center>
294+
{ loadingBackground
295+
? <Loading/>
296+
: virtualBackgroundEnabled ? <SvgBackgroundCancel/> : <SvgBackground/>
297+
}
298+
</center>
299+
</button>
300+
</section>
275301
</center>
276302
{!settingsEnabledScreen && (
277303
<center>

webapp/components/svg/background.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function SvgBackground() {
2+
return (
3+
<svg width="24" height="24" viewBox="0 0 24 24">
4+
<rect x="2" y="2" width="20" height="20" stroke="currentColor" fill="none" strokeWidth="2"/>
5+
<path fill="currentColor" d="M12 10c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
6+
</svg>
7+
)
8+
}
9+
export function SvgBackgroundCancel() {
10+
return (
11+
<svg width="24" height="24" viewBox="0 0 24 24">
12+
<path fill="currentColor" d="M12 10c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
13+
</svg>
14+
)
15+
}
+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { ImageSegmenter, FilesetResolver, ImageSegmenterResult } from '@mediapipe/tasks-vision'
2+
3+
let imageSegmenter: ImageSegmenter
4+
let webcamRunning: boolean = false
5+
let streamForVirtualBackground: MediaStream | null = null
6+
7+
const videoWidth = 480
8+
const videoHeight = 360
9+
10+
// 创建背景图片元素
11+
const backgroundImage = new Image()
12+
backgroundImage.src = './background.jpg'
13+
14+
// 初始化视频元素
15+
const video = document.createElement('video')
16+
const canvas = document.createElement('canvas')
17+
const canvasCtx = canvas.getContext('2d')!
18+
19+
// 设置画布尺寸
20+
canvas.width = videoWidth
21+
canvas.height = videoHeight
22+
23+
// 创建临时画布用于处理视频帧
24+
const tempCanvas = document.createElement('canvas')
25+
tempCanvas.width = videoWidth
26+
tempCanvas.height = videoHeight
27+
const tempCtx = tempCanvas.getContext('2d')!
28+
29+
// 创建 ImageSegmenter
30+
async function createImageSegmenter() {
31+
try {
32+
const vision = await FilesetResolver.forVisionTasks(
33+
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
34+
)
35+
36+
imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
37+
baseOptions: {
38+
modelAssetPath: "https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite",
39+
delegate: "GPU"
40+
},
41+
outputCategoryMask: true,
42+
runningMode: "VIDEO"
43+
})
44+
console.log("创建分割器成功")
45+
} catch (error) {
46+
console.error("创建分割器失败:", error)
47+
}
48+
}
49+
50+
function callbackForVideo(segmentationResult: ImageSegmenterResult) {
51+
if (!segmentationResult || !segmentationResult.categoryMask) return
52+
const imageData = tempCtx.getImageData(0, 0, videoWidth, videoHeight).data
53+
// 获取分割结果
54+
// 0 - background
55+
// 1 - hair
56+
// 2 - body-skin
57+
// 3 - face-skin
58+
// 4 - clothes
59+
// 5 - others (accessories)
60+
const maskData = segmentationResult.categoryMask.getAsUint8Array()
61+
62+
for (let i = 0; i < maskData.length; ++i) {
63+
const maskVal = maskData[i]
64+
const j = i * 4
65+
// 将特定类的像素点设置为透明
66+
if (maskVal == 0) {
67+
imageData[j + 3] = 0 // A - 透明
68+
}
69+
}
70+
71+
// 清空主画布
72+
canvasCtx.clearRect(0, 0, videoWidth, videoHeight)
73+
74+
// 绘制背景图片
75+
if (backgroundImage.complete && backgroundImage.naturalHeight !== 0) {
76+
canvasCtx.drawImage(backgroundImage, 0, 0, videoWidth, videoHeight)
77+
}
78+
79+
const uint8Array = new Uint8ClampedArray(imageData.buffer)
80+
const dataNew = new ImageData(
81+
uint8Array,
82+
video.videoWidth,
83+
video.videoHeight
84+
)
85+
86+
// 将处理后的视频帧绘制到主画布上
87+
tempCtx.putImageData(dataNew, 0, 0)
88+
canvasCtx.drawImage(tempCanvas, 0, 0)
89+
90+
// 释放资源
91+
// segmentationResult.close();
92+
93+
window.requestAnimationFrame(predictWebcam)
94+
}
95+
96+
// 处理视频帧
97+
async function predictWebcam() {
98+
if (!imageSegmenter || !webcamRunning) return
99+
try {
100+
// 在临时画布上绘制视频帧
101+
tempCtx.drawImage(video, 0, 0, videoWidth, videoHeight)
102+
imageSegmenter.segmentForVideo(video, performance.now(), callbackForVideo)
103+
} catch (error) {
104+
console.error("处理视频帧时出错:", error)
105+
}
106+
}
107+
108+
async function enableSegmentation(deviceId: string) {
109+
try {
110+
if (!imageSegmenter) {
111+
await createImageSegmenter()
112+
}
113+
// 开始图像分割
114+
const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { width: 480, height: 360, deviceId: deviceId } })
115+
video.srcObject = stream
116+
video.onloadeddata = async () => {
117+
video.play()
118+
webcamRunning = true
119+
await predictWebcam()
120+
streamForVirtualBackground = canvas.captureStream()
121+
}
122+
} catch (error) {
123+
console.error("启动摄像头失败:", error);
124+
}
125+
}
126+
127+
async function disableSegmentation() {
128+
if (streamForVirtualBackground === null) return
129+
webcamRunning = false
130+
const stream = video.srcObject as MediaStream
131+
const tracks = stream.getTracks()
132+
tracks.forEach(track => track.stop())
133+
video.srcObject = null
134+
canvasCtx.clearRect(0, 0, videoWidth, videoHeight)
135+
streamForVirtualBackground = null
136+
}
137+
138+
async function asyncGetStreamForVirtualBackground(deviceId: string): Promise<MediaStream> {
139+
await enableSegmentation(deviceId)
140+
while (streamForVirtualBackground === null) {
141+
await new Promise(resolve => setTimeout(resolve, 100)) // 每100ms检查一次
142+
}
143+
return streamForVirtualBackground
144+
}
145+
146+
export {
147+
asyncGetStreamForVirtualBackground,
148+
disableSegmentation,
149+
}

webapp/components/use/whip.ts

+19-4
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { WHIPClient } from 'whip-whep/whip'
55
import {
66
deviceNone,
77
deviceScreen,
8+
deviceSegmenter,
89
asyncGetAudioStream,
910
asyncGetVideoStream,
1011
} from '../../lib/device'
12+
import { disableSegmentation, asyncGetStreamForVirtualBackground } from './imageSegmentation'
1113

1214
interface WHIPData extends Data {
1315
setUserName: (name: string) => void,
@@ -19,6 +21,8 @@ interface WHIPData extends Data {
1921
setCurrentDeviceVideo: (current: string) => Promise<void>,
2022
toggleEnableAudio: () => Promise<void>,
2123
toggleEnableVideo: () => Promise<void>,
24+
25+
toggleEnableVirtualBackground: () => Promise<void>
2226
}
2327

2428
class WHIPContext extends Context {
@@ -29,6 +33,8 @@ class WHIPContext extends Context {
2933
currentDeviceVideo = deviceNone.deviceId
3034
toggleEnableAudio = async () => this.setCurrentDeviceAudio(this.userStatus.audio ? deviceNone.deviceId : this.currentDeviceAudio)
3135
toggleEnableVideo = async () => this.setCurrentDeviceVideo(this.userStatus.video ? deviceNone.deviceId : this.currentDeviceVideo)
36+
toggleEnableVirtualBackground = async () => this.setCurrentDeviceVideo(this.virtualBackgroundEnabled ? this.currentDeviceVideo : deviceSegmenter.deviceId)
37+
virtualBackgroundEnabled = false
3238

3339
constructor(id: string) {
3440
super(id)
@@ -70,6 +76,8 @@ class WHIPContext extends Context {
7076
setCurrentDeviceVideo: (current: string) => this.setCurrentDeviceVideo(current),
7177
toggleEnableAudio: () => this.toggleEnableAudio(),
7278
toggleEnableVideo: () => this.toggleEnableVideo(),
79+
80+
toggleEnableVirtualBackground: () => this.toggleEnableVirtualBackground(),
7381
}
7482
}
7583

@@ -148,22 +156,29 @@ class WHIPContext extends Context {
148156
async setCurrentDeviceVideo(current: string) {
149157
const { stream, setStream, userStatus, currentDeviceVideo } = this
150158

151-
if (current !== currentDeviceVideo || !userStatus.video) {
159+
if (current !== currentDeviceVideo || !userStatus.video || this.virtualBackgroundEnabled) {
152160
// Closed old tracks
153161
stream.getVideoTracks().map(track => {
154162
track.stop()
155163
stream.removeTrack(track)
156164
})
157-
158-
const mediaStream = await asyncGetVideoStream(current)
165+
let mediaStream: MediaStream
166+
if (current === deviceSegmenter.deviceId) {
167+
this.virtualBackgroundEnabled = true
168+
mediaStream = await asyncGetStreamForVirtualBackground(current)
169+
} else {
170+
this.virtualBackgroundEnabled = false
171+
await disableSegmentation()
172+
mediaStream = await asyncGetVideoStream(current)
173+
}
159174
const audioTracks = stream.getAudioTracks()
160175
const videoTracks = mediaStream.getVideoTracks()
161176

162177
setStream(new MediaStream([...audioTracks, ...videoTracks]))
163178
userStatus.video = current === deviceNone.deviceId ? false : true
164179
// NOTE: screen share
165180
userStatus.screen = current !== deviceScreen.deviceId ? false : true
166-
this.currentDeviceVideo = current === deviceNone.deviceId ? this.currentDeviceVideo : current
181+
this.currentDeviceVideo = (current === deviceNone.deviceId || current === deviceSegmenter.deviceId) ? this.currentDeviceVideo : current
167182

168183
this.sync()
169184
this.syncUserStatus(userStatus)

webapp/lib/device.ts

+6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const deviceScreen = {
1313
label: 'screen',
1414
}
1515

16+
const deviceSegmenter = {
17+
deviceId: 'segmenter',
18+
lable: 'segmenter'
19+
}
20+
1621
async function asyncGetAudioStream(deviceId: string): Promise<MediaStream> {
1722
let stream: MediaStream = new MediaStream()
1823
if (deviceId !== 'none') {
@@ -38,6 +43,7 @@ export {
3843
asyncGetVideoStream,
3944
deviceNone,
4045
deviceScreen,
46+
deviceSegmenter,
4147
}
4248

4349
export type {

webapp/public/background.jpg

168 KB
Loading

0 commit comments

Comments
 (0)