返回 slidev
recording.ts
根目录 / packages / client / logic / recording.ts
1 import type RecorderType from 'recordrtc'
2 import type { Options as RecorderOptions } from 'recordrtc'
3 import type { Ref } from 'vue'
4 import { isTruthy } from '@antfu/utils'
5 import { fixWebmDuration } from '@fix-webm-duration/fix'
6 import { useDevicesList, useEventListener, useLocalStorage } from '@vueuse/core'
7 import { nextTick, ref, shallowRef, watch } from 'vue'
8 import { currentCamera, currentMic } from '../state'
9
10 type Defined<T> = T extends undefined ? never : T
11 type MimeType = Defined<RecorderOptions['mimeType']>
12
13 export const recordingName = ref('')
14 export const recordCamera = ref(true)
15 export const mimeType = useLocalStorage<MimeType>('slidev-record-mimetype', 'video/webm')
16 export const frameRate = useLocalStorage<number>('slidev-record-framerate', 30)
17 export const bitRate = useLocalStorage<number>('slidev-record-bitrate', 8192)
18 export const resolution = useLocalStorage<string>('slidev-record-resolution', '1920x1080')
19
20 export const mimeExtMap: Record<string, string> = {
21 'video/webm': 'webm',
22 'video/webm;codecs=h264': 'mp4',
23 'video/x-matroska;codecs=avc1': 'mkv',
24 }
25
26 export function getFilename(media?: string, mimeType?: string) {
27 const d = new Date()
28
29 const pad = (v: number) => `${v}`.padStart(2, '0')
30
31 const date = `${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
32
33 const ext = mimeType ? mimeExtMap[mimeType] : 'webm'
34
35 return `${[media, recordingName.value, date].filter(isTruthy).join('-')}.${ext}`
36 }
37
38 function getSupportedMimeTypes() {
39 if (MediaRecorder && typeof MediaRecorder.isTypeSupported === 'function')
40 return Object.keys(mimeExtMap).filter(mime => MediaRecorder.isTypeSupported(mime))
41 return []
42 }
43
44 export const supportedMimeTypes = getSupportedMimeTypes()
45
46 export const {
47 devices,
48 videoInputs: cameras,
49 audioInputs: microphones,
50 ensurePermissions: ensureDevicesListPermissions,
51 } = useDevicesList({
52 onUpdated() {
53 if (currentCamera.value !== 'none') {
54 if (!cameras.value.some(i => i.deviceId === currentCamera.value))
55 currentCamera.value = cameras.value[0]?.deviceId || 'default'
56 }
57 if (currentMic.value !== 'none') {
58 if (!microphones.value.some(i => i.deviceId === currentMic.value))
59 currentMic.value = microphones.value[0]?.deviceId || 'default'
60 }
61 },
62 })
63
64 export function download(name: string, url: string) {
65 const a = document.createElement('a')
66 a.setAttribute('href', url)
67 a.setAttribute('download', name)
68 document.body.appendChild(a)
69 a.click()
70 document.body.removeChild(a)
71 }
72
73 export function useRecording() {
74 const recording = ref(false)
75 const showAvatar = ref(false)
76
77 const recorderCamera: Ref<RecorderType | undefined> = shallowRef()
78 const recorderSlides: Ref<RecorderType | undefined> = shallowRef()
79 const streamCamera: Ref<MediaStream | undefined> = shallowRef()
80 const streamCapture: Ref<MediaStream | undefined> = shallowRef()
81 const streamSlides: Ref<MediaStream | undefined> = shallowRef()
82 let recordingStartTime = 0
83
84 const config: RecorderOptions = {
85 type: 'video',
86 // Extending recording limit as default is only 1h (see https://github.com/muaz-khan/RecordRTC/issues/144)
87 timeSlice: 24 * 60 * 60 * 1000,
88 }
89
90 async function toggleAvatar() {
91 if (currentCamera.value === 'none')
92 return
93
94 if (showAvatar.value) {
95 showAvatar.value = false
96 if (!recording.value)
97 closeStream(streamCamera)
98 }
99 else {
100 await startCameraStream()
101 if (streamCamera.value)
102 showAvatar.value = !!streamCamera.value
103 }
104 }
105
106 async function startCameraStream() {
107 await ensureDevicesListPermissions()
108 await nextTick()
109
110 // Stopped tracks can never be resumed, the whole stream has to be requested again
111 if (streamCamera.value?.getTracks().some(track => track.readyState === 'ended'))
112 closeStream(streamCamera)
113
114 if (!streamCamera.value) {
115 if (currentCamera.value === 'none' && currentMic.value === 'none')
116 return
117
118 streamCamera.value = await navigator.mediaDevices.getUserMedia({
119 video: (currentCamera.value === 'none' || recordCamera.value !== true)
120 ? false
121 : {
122 deviceId: currentCamera.value,
123 },
124 audio: currentMic.value === 'none'
125 ? false
126 : {
127 deviceId: currentMic.value,
128 },
129 })
130 }
131 }
132
133 watch(currentCamera, async (v) => {
134 if (v === 'none') {
135 closeStream(streamCamera)
136 }
137 else {
138 if (recording.value)
139 return
140 // restart camera stream
141 if (streamCamera.value) {
142 closeStream(streamCamera)
143 await startCameraStream()
144 }
145 }
146 })
147
148 async function startRecording(customConfig?: RecorderOptions) {
149 await ensureDevicesListPermissions()
150 const { default: Recorder } = await import('recordrtc')
151 await startCameraStream()
152
153 const [width, height] = resolution.value.split('x').map(Number)
154 streamCapture.value = await navigator.mediaDevices.getDisplayMedia({
155 video: {
156 // aspectRatio: 1.6,
157 frameRate: frameRate.value,
158 width,
159 height,
160 // @ts-expect-error missing types
161 cursor: 'motion',
162 resizeMode: 'crop-and-scale',
163 },
164 selfBrowserSurface: 'include',
165 })
166 streamCapture.value.addEventListener('inactive', stopRecording)
167
168 // We need to create a new Stream to merge video and audio to have the inactive event working on streamCapture
169 streamSlides.value = new MediaStream()
170 streamCapture.value!.getVideoTracks().forEach(videoTrack => streamSlides.value!.addTrack(videoTrack))
171
172 // merge config
173 Object.assign(config, customConfig)
174
175 if (streamCamera.value) {
176 const audioTrack = streamCamera.value!.getAudioTracks()?.[0]
177 if (audioTrack)
178 streamSlides.value!.addTrack(audioTrack)
179
180 recorderCamera.value = new Recorder(
181 streamCamera.value!,
182 config,
183 )
184 recorderCamera.value.startRecording()
185 }
186
187 recorderSlides.value = new Recorder(
188 streamSlides.value!,
189 config,
190 )
191
192 recorderSlides.value.startRecording()
193 recordingStartTime = Date.now()
194 recording.value = true
195 }
196
197 async function stopRecording() {
198 recording.value = false
199 const duration = Date.now() - recordingStartTime
200
201 recorderCamera.value?.stopRecording(() => {
202 if (recordCamera.value) {
203 const blob = recorderCamera.value!.getBlob()
204 downloadBlob(blob, duration, getFilename('camera', config.mimeType))
205 }
206 recorderCamera.value = undefined
207 if (!showAvatar.value)
208 closeStream(streamCamera)
209 })
210 recorderSlides.value?.stopRecording(() => {
211 const blob = recorderSlides.value!.getBlob()
212 downloadBlob(blob, duration, getFilename('screen', config.mimeType))
213 closeStream(streamCapture)
214 // `streamSlides` only borrows its tracks from `streamCapture` and `streamCamera`,
215 // stopping them here would also end the mic still used by the camera stream
216 streamSlides.value = undefined
217 recorderSlides.value = undefined
218 })
219 }
220
221 async function downloadBlob(blob: Blob, duration: number, filename: string) {
222 const fixedBlob = await fixWebmDuration(blob, duration, { logger: false })
223 const url = URL.createObjectURL(fixedBlob)
224 download(filename, url)
225 window.URL.revokeObjectURL(url)
226 }
227
228 function closeStream(stream: Ref<MediaStream | undefined>) {
229 const s = stream.value
230 if (!s)
231 return
232 s.getTracks().forEach((i) => {
233 i.stop()
234 s.removeTrack(i)
235 })
236 stream.value = undefined
237 }
238
239 function toggleRecording() {
240 if (recording.value)
241 stopRecording()
242 else
243 startRecording()
244 }
245
246 useEventListener('beforeunload', (event) => {
247 if (!recording.value)
248 return
249 // eslint-disable-next-line no-alert
250 if (confirm('Recording is not saved yet, do you want to leave?'))
251 return
252 event.preventDefault()
253 event.returnValue = ''
254 })
255
256 return {
257 recording,
258 showAvatar,
259 toggleRecording,
260 startRecording,
261 stopRecording,
262 toggleAvatar,
263 recorderCamera,
264 recorderSlides,
265 streamCamera,
266 streamCapture,
267 streamSlides,
268 }
269 }
270
271 export const recorder = useRecording()
272
272 lines TYPESCRIPT